diff --git a/src/textual_paint/file_dialogs.py b/src/textual_paint/file_dialogs.py new file mode 100644 index 0000000..cc92aa5 --- /dev/null +++ b/src/textual_paint/file_dialogs.py @@ -0,0 +1,161 @@ +import os +from typing import Any, Callable +from textual.containers import Container +from textual.widget import Widget +from textual.widgets import Button, Input, Tree +from textual.widgets._directory_tree import DirEntry +from textual.containers import Container +from localization.i18n import get as _ +from windows import DialogWindow +from enhanced_directory_tree import EnhancedDirectoryTree + +class FileDialogWindow(DialogWindow): + """A dialog window that lets the user select a file.""" + + def __init__( + self, + *children: Widget, + selected_file_path: str | None, + handle_selected_file_path: Callable[[str], None], + **kwargs: Any, + ) -> None: + """Initialize the dialog window.""" + super().__init__(handle_button=self.handle_button, *children, **kwargs) + self._selected_file_path: str | None = selected_file_path + self.handle_selected_file_path = handle_selected_file_path + self._directory_tree_selected_path: str | None = None + """Last highlighted item in the directory tree""" + self._expanding_directory_tree: bool = False + """Flag to prevent setting the filename input when initially expanding the directory tree""" + + def on_mount(self) -> None: + """Called when the window is mounted.""" + self._expand_directory_tree() + # This MIGHT be more reliable even though it's hacky. + # I don't know what the exact preconditions are for the expansion to work. + # self.call_after_refresh(self._expand_directory_tree) + + def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None: + """ + Called when a file/folder is selected in the DirectoryTree. + + This message comes from Tree. + DirectoryTree gives FileSelected, but only for files, not folders. + """ + assert event.node.data + if event.node.data.is_dir: + self._directory_tree_selected_path = event.node.data.path + elif event.node.parent: + assert event.node.parent.data + self._directory_tree_selected_path = event.node.parent.data.path + name = os.path.basename(event.node.data.path) + if not self._expanding_directory_tree: + self.query_one("FileDialogWindow .filename_input", Input).value = name + else: + self._directory_tree_selected_path = None + + def _expand_directory_tree(self) -> None: + """Expand the directory tree to the target directory, either the folder of the open file or the current working directory.""" + tree = self.content.query_one(EnhancedDirectoryTree) + self._expanding_directory_tree = True + target_dir = (self._selected_file_path or os.getcwd()).rstrip(os.path.sep) + tree.expand_to_path(target_dir) + # There are currently some timers in expand_to_path. + # In particular, it waits before selecting the target node, + # and this flag is for avoiding responding to that. + def done_expanding(): + self._expanding_directory_tree = False + self.set_timer(0.1, done_expanding) + +class OpenDialogWindow(FileDialogWindow): + """A dialog window that lets the user select a file to open. + + `handle_selected_file_path` is called when the user clicks the Open button, + and the window is NOT closed in that case. + """ + + def __init__( + self, + *children: Widget, + selected_file_path: str | None, + handle_selected_file_path: Callable[[str], None], + **kwargs: Any, + ) -> None: + """Initialize the dialog window.""" + super().__init__(*children, selected_file_path=selected_file_path, handle_selected_file_path=handle_selected_file_path, **kwargs) + + def on_mount(self) -> None: + """Called when the window is mounted.""" + self.content.mount( + EnhancedDirectoryTree(path="/"), + Input(classes="filename_input", placeholder=_("Filename")), + Container( + Button(_("Open"), classes="open submit", variant="primary"), + Button(_("Cancel"), classes="cancel"), + classes="buttons", + ), + ) + + def handle_button(self, button: Button) -> None: + """Called when a button is clicked or activated with the keyboard.""" + if not button.has_class("open"): + self.close() + return + filename = self.content.query_one(".filename_input", Input).value + if not filename: + return + # TODO: allow entering an absolute or relative path, not just a filename + if self._directory_tree_selected_path: + file_path = os.path.join(self._directory_tree_selected_path, filename) + else: + file_path = filename + self.handle_selected_file_path(file_path) + + +class SaveAsDialogWindow(FileDialogWindow): + """A dialog window that lets the user select a file to save to. + + `handle_selected_file_path` is called when the user clicks the Save button, + and the window is NOT closed in that case. + """ + + def __init__( + self, + *children: Widget, + selected_file_path: str | None, + handle_selected_file_path: Callable[[str], None], + **kwargs: Any, + ) -> None: + """Initialize the dialog window.""" + super().__init__(*children, selected_file_path=selected_file_path, handle_selected_file_path=handle_selected_file_path, **kwargs) + + def on_mount(self) -> None: + """Called when the window is mounted.""" + filename: str = os.path.basename(self._selected_file_path) if self._selected_file_path else "" + self.content.mount( + EnhancedDirectoryTree(path="/"), + Input(classes="filename_input", placeholder=_("Filename"), value=filename), + Container( + Button(_("Save"), classes="save submit", variant="primary"), + Button(_("Cancel"), classes="cancel"), + classes="buttons", + ), + ) + + def handle_button(self, button: Button) -> None: + """Called when a button is clicked or activated with the keyboard.""" + if button.has_class("cancel"): + self.request_close() + elif button.has_class("save"): + if self._selected_file_path is None: + return + + name = self.query_one(".filename_input", Input).value + if not name: + return + # TODO: allow entering an absolute or relative path, not just a filename + if self._directory_tree_selected_path: + file_path = os.path.join(self._directory_tree_selected_path, name) + else: + file_path = name + self.handle_selected_file_path(file_path) diff --git a/src/textual_paint/paint.css b/src/textual_paint/paint.css index 3590f83..363c301 100644 --- a/src/textual_paint/paint.css +++ b/src/textual_paint/paint.css @@ -202,20 +202,20 @@ DialogWindow .window_content { background: $surface; } -.file_dialog_window .window_content { +FileDialogWindow .window_content { padding: 2 4; width: 80; height: 30; } -.file_dialog_window .window_content Input { +FileDialogWindow .window_content Input { width: 100%; margin-bottom: 1; } -.file_dialog_window .window_content DirectoryTree { +FileDialogWindow .window_content DirectoryTree { height: 15; margin-bottom: 1; } -.file_dialog_window .window_content Button { +FileDialogWindow .window_content Button { width: auto; height: auto; } diff --git a/src/textual_paint/paint.py b/src/textual_paint/paint.py index 3667673..8b72916 100755 --- a/src/textual_paint/paint.py +++ b/src/textual_paint/paint.py @@ -29,16 +29,15 @@ from textual.reactive import var, reactive from textual.strip import Strip from textual.dom import DOMNode from textual.widget import Widget -from textual.widgets import Button, Static, Input, Tree, Header -from textual.widgets._directory_tree import DirEntry +from textual.widgets import Button, Static, Input, Header from textual.binding import Binding from textual.color import Color from menus import MenuBar, Menu, MenuItem, Separator from windows import Window, DialogWindow, CharacterSelectorDialogWindow, MessageBox, get_warning_icon +from file_dialogs import SaveAsDialogWindow, OpenDialogWindow from edit_colors import EditColorsDialogWindow from localization.i18n import get as _, load_language, remove_hotkey -from enhanced_directory_tree import EnhancedDirectoryTree from wallpaper import get_config_dir, set_wallpaper from __init__ import __version__ @@ -1494,11 +1493,6 @@ class PaintApp(App[None]): file_path = var(None) """The path to the file being edited.""" - directory_tree_selected_path: str|None = None - """Last highlighted item in Open/Save As dialogs""" - expanding_directory_tree = False - """Flag to prevent setting the filename input when initially expanding the directory tree""" - image = var(AnsiArtDocument.from_text("Not Loaded")) """The document being edited. Contains the selection, if any.""" image_initialized = False @@ -1892,22 +1886,11 @@ class PaintApp(App[None]): # which is more important than here, since the dialog isn't (currently) modal. # You could make a selection while the dialog is open, for example. self.stop_action_in_progress() - self.close_windows("#save_as_dialog, #open_dialog") + self.close_windows("SaveAsDialogWindow, OpenDialogWindow") saved_future: asyncio.Future[None] = asyncio.Future() - def handle_button(button: Button) -> None: - if not button.has_class("save"): - window.close() - return - name = self.query_one("#save_as_dialog .filename_input", Input).value - if not name: - return - # TODO: allow entering an absolute or relative path, not just a filename - if self.directory_tree_selected_path: - file_path = os.path.join(self.directory_tree_selected_path, name) - else: - file_path = name + def handle_selected_file_path(file_path: str) -> None: def on_save_confirmed(): async def async_on_save_confirmed(): self.file_path = file_path @@ -1923,38 +1906,14 @@ class PaintApp(App[None]): else: on_save_confirmed() - window = DialogWindow( - id="save_as_dialog", - classes="file_dialog_window", + window = SaveAsDialogWindow( title=_("Save As"), - handle_button=handle_button, + handle_selected_file_path=handle_selected_file_path, + selected_file_path=self.file_path or _("Untitled") ) - filename: str = os.path.basename(self.file_path) if self.file_path else _("Untitled") - window.content.mount( - EnhancedDirectoryTree(id="save_as_dialog_directory_tree", path="/"), - Input(classes="filename_input", placeholder=_("Filename"), value=filename), - Container( - Button(_("Save"), classes="save submit", variant="primary"), - Button(_("Cancel"), classes="cancel"), - classes="buttons", - ), - ) - self.mount(window) - self.expand_directory_tree(window.content.query_one("#save_as_dialog_directory_tree", EnhancedDirectoryTree)) + await self.mount(window) await saved_future - def expand_directory_tree(self, tree: EnhancedDirectoryTree) -> None: - """Expand the directory tree to the target directory, either the folder of the open file or the current working directory.""" - self.expanding_directory_tree = True - target_dir = (self.file_path or os.getcwd()).rstrip(os.path.sep) - tree.expand_to_path(target_dir) - # There are currently some timers in expand_to_path. - # In particular, it waits before selecting the target node, - # and this flag is for avoiding responding to that. - def done_expanding(): - self.expanding_directory_tree = False - self.set_timer(0.1, done_expanding) - def confirm_overwrite(self, file_path: str, callback: Callable[[], None]) -> None: """Asks the user if they want to overwrite a file.""" message = _("%1 already exists.\nDo you want to replace it?", file_path) @@ -2077,38 +2036,16 @@ class PaintApp(App[None]): def action_open(self) -> None: """Show dialog to open an image from a file.""" - def handle_button(button: Button) -> None: - if not button.has_class("open"): - window.close() - return - filename = window.content.query_one("#open_dialog .filename_input", Input).value - if not filename: - return - # TODO: allow entering an absolute or relative path, not just a filename - if self.directory_tree_selected_path: - file_path = os.path.join(self.directory_tree_selected_path, filename) - else: - file_path = filename + def handle_selected_file_path(file_path: str) -> None: self.open_from_file_path(file_path, window.close) - self.close_windows("#save_as_dialog, #open_dialog") - window = DialogWindow( - id="open_dialog", - classes="file_dialog_window", + self.close_windows("SaveAsDialogWindow, OpenDialogWindow") + window = OpenDialogWindow( title=_("Open"), - handle_button=handle_button, - ) - window.content.mount( - EnhancedDirectoryTree(id="open_dialog_directory_tree", path="/"), - Input(classes="filename_input", placeholder=_("Filename")), - Container( - Button(_("Open"), classes="open submit", variant="primary"), - Button(_("Cancel"), classes="cancel"), - classes="buttons", - ), + handle_selected_file_path=handle_selected_file_path, + selected_file_path=self.file_path or "", ) self.mount(window) - self.expand_directory_tree(window.content.query_one("#open_dialog_directory_tree", EnhancedDirectoryTree)) def action_new(self, *, force: bool = False) -> None: """Create a new image.""" @@ -3349,25 +3286,6 @@ class PaintApp(App[None]): else: self.selected_bg_color = event.color - def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[DirEntry]) -> None: - """ - Called when a file/folder is selected in the DirectoryTree. - - This message comes from Tree. - DirectoryTree gives FileSelected but only for files. - """ - assert event.node.data - if event.node.data.is_dir: - self.directory_tree_selected_path = event.node.data.path - elif event.node.parent: - assert event.node.parent.data - self.directory_tree_selected_path = event.node.parent.data.path - name = os.path.basename(event.node.data.path) - if not self.expanding_directory_tree: - self.query_one(".file_dialog_window .filename_input", Input).value = name - else: - self.directory_tree_selected_path = None - def on_menu_status_info(self, event: Menu.StatusInfo) -> None: """Called when a menu item is hovered.""" text: str = event.description or ""