diff --git a/bmds_server/desktop/cli.py b/bmds_server/desktop/cli.py index 6da1148e..80cd5b59 100644 --- a/bmds_server/desktop/cli.py +++ b/bmds_server/desktop/cli.py @@ -1,3 +1,4 @@ +import configparser import logging import os import re @@ -5,7 +6,7 @@ from contextlib import redirect_stderr, redirect_stdout from importlib.metadata import version from io import StringIO -from pathlib import Path +from pathlib import Path, PurePath from threading import Thread from time import sleep from typing import ClassVar @@ -25,6 +26,7 @@ Vertical, ) from textual.reactive import reactive +from textual.screen import ModalScreen from textual.validation import Failure, Function, ValidationResult, Validator from textual.widgets import ( Button, @@ -48,13 +50,51 @@ logger = logging.getLogger(__name__) -ROOT = Path(__file__).parent +APP_ROOT = Path(__file__).parent -def data_folder() -> Path: - path = get_app_home() - path.mkdir(parents=True, exist_ok=True) - return path +def load_config(): + config = configparser.ConfigParser() + config.read(Path(APP_ROOT / "config.ini")) + return config + + +def get_data_folder() -> Path: + # Set default directory by OS + config = load_config() + if config["desktop"]["directory"] == "default": + path = get_app_home() + path.mkdir(parents=True, exist_ok=True) + return path + else: + return config["desktop"]["directory"] + + +def get_project_filename() -> str: + # file <-> db <-> project + config = load_config() + if config["desktop"]["file_name"] == "default": + return "bmds.sqlite3" + else: + return config["desktop"]["file_name"] + + +class QuitModal(ModalScreen): + """Screen with a dialog to quit.""" + + def compose(self) -> ComposeResult: + yield Grid( + Label("Are you sure you want to quit?", id="modal-quit-question"), + Button("Quit", variant="error", id="btn-modal-quit"), + Button("Cancel", variant="primary", id="btn-modal-cancel"), + id="quit-modal", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-modal-quit": + self.app.exit() + else: + self.app.pop_screen() class FNValidator(Validator): @@ -65,31 +105,25 @@ def validate(self, value: str) -> ValidationResult: if self.is_valid_fn(value): return self.success() else: - return self.failure("Invalid Character in filename") + return self.failure("Invalid character in filename.") @staticmethod def is_valid_fn(value: str) -> bool: - # Only allow alphanumeric characters and '_' - if re.fullmatch(r"^\w+", value) is not None: + # No . + # \A(?!(?:COM[0-9]|CON|LPT[0-9]|NUL|PRN|AUX|com[0-9]|con|lpt[0-9]|nul|prn|aux)|\s|[\.]{2,})[^\\\/:*"?<>|]{1,254}(? Iterable[Path]: class DirectoryContainer(Container): """Directory""" - # long_path = long/foo/path/to/thing/bar - # n_path = long_path.split("/") # <-- check pathlib? for dir sep - # n_path.pop() # rm last - # "/".join(n_path) # join back into path + # TODO: Directions/Help Text + + # .parent button? - DEFAULT_PATH = reactive(default=str(data_folder()) + "/") - s_d = Static(str(data_folder()), classes="selected-disp") + @on(Button.Pressed, "#btn-save-dir") + def zzz_btn(self, event: Button.Pressed) -> None: + foo = self.query_one("#selected-disp").renderable.__str__() + if Path(foo).is_dir(): + self.save_dir(self.query_one("#selected-disp").renderable) + if Path(foo).is_file(): + # Select different project/db + self.save_file( + PurePath(self.query_one("#selected-disp").renderable.__str__()).name + ) def compose(self) -> ComposeResult: - yield Button("<<", id="path-parent") + yield Button("<<", id="path-parent-btn") yield Label("Selected Folder:") - yield self.s_d - yield ConfigTree(id="config-tree", path=Path(self.DEFAULT_PATH), classes="zzz") + yield Static( + str(get_data_folder()), id="selected-disp", classes="selected-disp" + ) + yield ConfigTree( + id="config-tree", path=Path(get_data_folder()), classes="config-tree" + ) with Horizontal(classes="save-btns"): - yield Button("save", id="save-dir-btn", classes="btn-auto save") + yield Button("Select Directory / DB", id="btn-save-dir", classes="save") def on_directory_tree_directory_selected(self, DirectorySelected): - self.s_d.update(rf"{DirectorySelected.path!s}") + self.query_one("#selected-disp").update(rf"{DirectorySelected.path!s}") + + def on_directory_tree_file_selected(self, FileSelected): + self.query_one("#selected-disp").update(rf"{FileSelected.path!s}") + + def save_dir(self, directory): + config = load_config() + config["desktop"]["directory"] = str(directory) + + try: + with open(Path(APP_ROOT / "config.ini"), "w") as configfile: + config.write(configfile) + self.notify( + "New project directory selected.", + title="Directory Updated", + severity="information", + ) + self.query_one(ConfigTree).reload() + except Exception as e: + self.notify( + f"{e}", + title="ERROR", + severity="error", + ) + + def save_file(self, file_name): + config = load_config() + config["desktop"]["file_name"] = str(file_name) + + try: + with open(Path(APP_ROOT / "config.ini"), "w") as configfile: + config.write(configfile) + self.notify( + f"{file_name} project selected.", + title="Data Source Updated", + severity="information", + ) + self.query_one(ConfigTree).reload() + except Exception as e: + self.notify( + f"{e}", + title="ERROR", + severity="error", + ) class FileNameContainer(Container): """Filename""" - @on(Input.Changed, "#set-filename") + # CURRENT_FILENAME + @on(Button.Pressed, "#btn-save-fn") + def zzz_btn(self, event: Button.Pressed) -> None: + self.create_project() + + @on(Input.Changed, "#input-filename") def show_invalid_reasons(self, event: Input.Changed) -> None: - # Updating the UI to show the reasons why validation failed + # Update UI to show the reasons why validation failed if not event.validation_result.is_valid: self.query_one(Pretty).update(event.validation_result.failure_descriptions) + # do name saving stuff else: self.query_one(Pretty).update([]) def compose(self) -> ComposeResult: + # disable button until valid yield Label("Current Filename:") - yield Static("CURRENT_FILENAME") + yield Static(get_project_filename()) yield Label("Validation Status:") + # TODO: other kind of display that doesnt show an empty list? yield Pretty([]) yield Input( placeholder="Enter filename here...", - id="set-filename", - classes="set-filename", + id="input-filename", + classes="input-filename", validators=[ FNValidator(), ], ) with Horizontal(classes="save-btns"): - yield Button("save", id="save-fn-btn", classes="btn-auto save") + yield Button("save", id="btn-save-fn", classes="btn-auto save") + + def create_project(self): + zzz = self.query_one(Input).value + zzz = zzz + ".sqlite3" + + config = load_config() + config["desktop"]["file_name"] = str(zzz) + + try: + with open(Path(APP_ROOT / "config.ini"), "w") as configfile: + config.write(configfile) + # update current filename + self.notify( + "New project created.", + title="Project Created", + severity="information", + ) + except Exception as e: + self.notify( + f"{e}", + title="ERROR", + severity="error", + ) class ConfigTab(Static): @@ -274,29 +399,21 @@ class ConfigTab(Static): def container_btn_press(self, event: Button.Pressed) -> None: self.query_one(ContentSwitcher).current = event.button.id - # save button - @on(Button.Pressed, "#save-dir-btn,#save-fn-btn") - def zzz_btn(self, event: Button.Pressed) -> None: - self.notify( - f"{event.button.id}", - title="notification title", - severity="information", - ) - def compose(self) -> ComposeResult: with Horizontal(classes="config-tab"): with Vertical(classes="config-btns"): - yield Button("Directory", id="dir-container", classes="btn-config") - yield Button("Change DB/Filename", id="fn-container") + yield Button( + "Change Directory / Project", id="dir-container", classes="btn-auto" + ) + yield Button( + "Create New Project", id="fn-container", classes="btn-auto" + ) yield Rule(orientation="vertical") with ContentSwitcher(initial="dir-container"): yield DirectoryContainer(id="dir-container", classes="dir-container") yield FileNameContainer(id="fn-container", classes="fn-container") - # on_mount(): - # set change dir btn to active? - class BmdsTabs(Static): def __init__(self, _app: "BmdsDesktop", **kw): @@ -305,15 +422,17 @@ def __init__(self, _app: "BmdsDesktop", **kw): def compose(self) -> ComposeResult: with TabbedContent(id="tabs"): - with TabPane("Application", classes="app"): + with TabPane("Application", id="app", classes="app"): yield Container( self._app.runner.widget, ) yield Container( - Label(f"[b]Data folder:[/b]\n {self._app.config.path}"), - Label(f"[b]Port:[/b]\n {self._app.config.port}"), - Label(f"[b]Host:[/b]\n {self._app.config.host}"), + Label("", id="folder"), + Label("", id="project"), + Label(f"[b]Port:[/b]\n {DesktopConfig().port}", id="port"), + Label(f"[b]Host:[/b]\n {DesktopConfig().host}", id="host"), classes="app-box", + id="app-box", ) with TabPane("Logging"): @@ -322,6 +441,13 @@ def compose(self) -> ComposeResult: with TabPane("Config"): yield ConfigTab() + @on(TabbedContent.TabActivated, "#tabs", tab="#app") + def switch_to_app(self) -> None: + self.query_one("#folder").update(f"[b]Data folder:[/b]\n {get_data_folder()}") + self.query_one("#project").update( + f"[b]Data/Project:[/b]\n {get_project_filename()}" + ) + class BmdsDesktop(App): """A Textual app for BMDS.""" @@ -335,7 +461,7 @@ class BmdsDesktop(App): CSS_PATH = "content/app.tcss" def __init__(self, **kw): - self.config = DesktopConfig() + # self.config = DesktopConfig() self.log_app = LogApp(self) self.runner = AppRunner(self) self.tabs = BmdsTabs(self) @@ -344,8 +470,8 @@ def __init__(self, **kw): def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() - with Container(classes="main"): - yield Markdown((ROOT / "content/top.md").read_text()) + with ScrollableContainer(classes="main"): + yield Markdown((APP_ROOT / "content/top.md").read_text()) yield self.tabs yield Footer() @@ -355,7 +481,7 @@ def toggle_runner(self): def action_quit(self): """Exit the application.""" - self.exit() + self.push_screen(QuitModal()) def action_toggle_dark(self): """An action to toggle dark mode.""" diff --git a/bmds_server/desktop/config.ini b/bmds_server/desktop/config.ini new file mode 100644 index 00000000..9065b2df --- /dev/null +++ b/bmds_server/desktop/config.ini @@ -0,0 +1,4 @@ +[desktop] +directory = c:\bit9prog\dev\bmds-server\logs +file_name = zzz.sqlite3 + diff --git a/bmds_server/desktop/content/app.tcss b/bmds_server/desktop/content/app.tcss index 281cdb1c..63232fa7 100644 --- a/bmds_server/desktop/content/app.tcss +++ b/bmds_server/desktop/content/app.tcss @@ -2,7 +2,7 @@ overflow-y: scroll; } -Button { +#runner-button { width: 30; height: 5; margin: 1; @@ -23,11 +23,6 @@ Button.btn-config{ border: none; } -.zzz{ - height: 100% -} - - .save { background: $primary 50%; } @@ -36,6 +31,16 @@ Button.btn-config{ background: $secondary 50%; } +.config-btns { + align: center top; + width: auto; +} + + +.save-btns { + align: center top; +} + #log { height: 15; } @@ -65,31 +70,28 @@ Button.btn-config{ .dir-container { margin: 1; - border: double grey; - width: 70; - height: 30; + /* border: double grey; + width: 70; + height: 30; */ overflow: auto; background: $panel; } - .fn-container { margin: 1; - border: double grey; - width: 70; - height: 30; + /* border: double grey; */ + /* width: 70; */ + /* height: 30; */ + overflow: auto; background: $panel; } -.config-btns { - align: center top; - width: auto; +.config-tree { + margin: 1 1 1 1; + height: 4fr; } -.save-btns { - align: center top; -} .selected-disp { border: ascii gray; @@ -102,4 +104,30 @@ Input.-valid { Input.-valid:focus { border: ascii $success; +} + +QuitModal { + align: center middle; +} + +#quit-modal { + grid-size: 2; + grid-gutter: 1 2; + grid-rows: 1fr 3; + padding: 0 1; + width: 37; + height: 11; + border: thick $background 80%; + background: $surface; +} + +#modal-quit-question { + column-span: 2; + height: 1fr; + width: 1fr; + content-align: center middle; +} + +.input-filename { + width: 1fr; } \ No newline at end of file diff --git a/bmds_server/main/constants.py b/bmds_server/main/constants.py index d5ca050b..8426a06c 100644 --- a/bmds_server/main/constants.py +++ b/bmds_server/main/constants.py @@ -17,15 +17,11 @@ def get_app_home() -> Path: app_home = Path.home() match platform.system(): case "Windows": - # app_home = app_home / "AppData" / "Roaming" / "bmds" - app_home = Path("c:/bit9prog/dev/bmds-server") + app_home = app_home / "AppData" / "Roaming" / "bmds" + # app_home = Path("c:/bit9prog/dev/bmds-server") # for karen's testing, to remove case "Darwin": app_home = app_home / "Library" / "Application Support" / "bmds" case "Linux" | _: app_home = app_home / ".bmds" app_home.mkdir(parents=True, exist_ok=True) return app_home - - -# new config that get_db_location, |path| , -# start root, append config db path, then can nav up/dpown dir tree