From af61d0a1aaaaa24aef6994e6e0342488e5b8c193 Mon Sep 17 00:00:00 2001 From: shrey Date: Wed, 12 Mar 2025 21:48:34 +0530 Subject: [PATCH 01/28] add: custom spinner widget --- datashuttle/tui/css/tui_style.tcss | 6 ++++++ datashuttle/tui/css/tui_tab.tcss | 7 +++++++ datashuttle/tui/custom_widgets.py | 22 ++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/datashuttle/tui/css/tui_style.tcss b/datashuttle/tui/css/tui_style.tcss index f7a0bfed1..37d4358ba 100644 --- a/datashuttle/tui/css/tui_style.tcss +++ b/datashuttle/tui/css/tui_style.tcss @@ -164,3 +164,9 @@ DatatypeCheckboxes { width: auto; padding: 0 0 1 0; } + +/* Spinner ---------------------------------------------------------------------------------*/ + +CustomSpinner { + color: #00FF00; +} diff --git a/datashuttle/tui/css/tui_tab.tcss b/datashuttle/tui/css/tui_tab.tcss index 34321a84c..cfb04053d 100644 --- a/datashuttle/tui/css/tui_tab.tcss +++ b/datashuttle/tui/css/tui_tab.tcss @@ -35,6 +35,13 @@ TabScreen > TabbedContent { margin: 0 0 1 0 } +#input_suggestion_spinner { + width: auto; + height: 1; + position: absolute; + dock: right; +} + #template_settings_validation_on_checkbox.-on > .toggle--button{ color: $success; } diff --git a/datashuttle/tui/custom_widgets.py b/datashuttle/tui/custom_widgets.py index 34c8555bc..df32e179c 100644 --- a/datashuttle/tui/custom_widgets.py +++ b/datashuttle/tui/custom_widgets.py @@ -24,6 +24,7 @@ from textual._segment_tools import line_pad from textual.message import Message from textual.strip import Strip +from textual.widget import Widget from textual.widgets import ( DirectoryTree, Input, @@ -65,6 +66,8 @@ def __init__( ) self.mainwindow = mainwindow + self.styles.min_width = "100%" # TODO: REMOVE LATER + # self.styles.border = ("dashed", "yellow") def _on_click(self, event: events.Click) -> None: self.post_message(self.Clicked(self, event.ctrl)) @@ -474,3 +477,22 @@ def on_select_changed(self, event: Select.Changed) -> None: self.interface.save_tui_settings( top_level_folder, "top_level_folder_select", self.settings_key ) + + +class CustomSpinner(Widget): + def __init__(self, id): + super().__init__() + self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + self.current_frame = 0 + self.id = id + + def on_mount(self) -> None: + # Update frame every 0.1 seconds + self.set_interval(0.1, self.next_frame) + + def next_frame(self) -> None: + self.current_frame = (self.current_frame + 1) % len(self.frames) + self.refresh() + + def render(self) -> Text: + return Text(self.frames[self.current_frame], style="bold") From 6d5546162c285eb99452f29d856faeecc696fe8a Mon Sep 17 00:00:00 2001 From: shrey Date: Thu, 13 Mar 2025 02:31:12 +0530 Subject: [PATCH 02/28] add: search remote for suggestions checkbox --- datashuttle/tui/css/tui_menu.tcss | 11 ++- .../tui/screens/create_folder_settings.py | 72 +++++++++++-------- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/datashuttle/tui/css/tui_menu.tcss b/datashuttle/tui/css/tui_menu.tcss index 00cb9458a..529d66cb1 100644 --- a/datashuttle/tui/css/tui_menu.tcss +++ b/datashuttle/tui/css/tui_menu.tcss @@ -313,9 +313,16 @@ CreateFoldersSettingsScreen { width: 80%; background: $primary-background; border: tall $panel-lighten-3; - } +} +#checkbox_container { + height: 65%; + overflow: hidden auto; +} +#toplevel_folder_select_container { + height: 15%; +} #template_top_container { - height: 50%; + height: 70%; background: $primary-background; border: tall $panel-lighten-3; overflow: hidden auto; diff --git a/datashuttle/tui/screens/create_folder_settings.py b/datashuttle/tui/screens/create_folder_settings.py index 9c7937221..63e8774bb 100644 --- a/datashuttle/tui/screens/create_folder_settings.py +++ b/datashuttle/tui/screens/create_folder_settings.py @@ -86,6 +86,9 @@ def compose(self) -> ComposeResult: """ bypass_validation = self.interface.tui_settings["bypass_validation"] + suggest_next_sub_ses_local_only = self.interface.tui_settings[ + "suggest_next_sub_ses_local_only" + ] yield Container( Horizontal( @@ -97,43 +100,52 @@ def compose(self) -> ComposeResult: self.interface, id="create_folders_settings_toplevel_select", ), - ), - Checkbox( - "Bypass validation", - value=bypass_validation, - id="create_folders_settings_bypass_validation_checkbox", + id="toplevel_folder_select_container", ), Container( - Horizontal( - Checkbox( - "Template Validation", - id="template_settings_validation_on_checkbox", - value=self.interface.get_name_templates()["on"], - ), - id="template_inner_horizontal_container", + Checkbox( + "Search Remote For Suggestions", + value=not suggest_next_sub_ses_local_only, + id="suggest_next_sub_ses_remote", + ), + Checkbox( + "Bypass validation", + value=bypass_validation, + id="create_folders_settings_bypass_validation_checkbox", ), Container( - Label(explanation, id="template_message_label"), + Horizontal( + Checkbox( + "Template Validation", + id="template_settings_validation_on_checkbox", + value=self.interface.get_name_templates()["on"], + ), + id="template_inner_horizontal_container", + ), Container( - RadioSet( - RadioButton( - "Subject", - id="template_settings_subject_radiobutton", - value=sub_on, - ), - RadioButton( - "Session", - id="template_settings_session_radiobutton", - value=ses_on, + Label(explanation, id="template_message_label"), + Container( + RadioSet( + RadioButton( + "Subject", + id="template_settings_subject_radiobutton", + value=sub_on, + ), + RadioButton( + "Session", + id="template_settings_session_radiobutton", + value=ses_on, + ), + id="template_settings_radioset", ), - id="template_settings_radioset", + Input(id="template_settings_input"), + id="template_other_widgets_container", ), - Input(id="template_settings_input"), - id="template_other_widgets_container", + id="template_inner_container", ), - id="template_inner_container", + id="template_top_container", ), - id="template_top_container", + id="checkbox_container", ), Container(), Button("Close", id="create_folders_settings_close_button"), @@ -235,6 +247,10 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: self.query_one("#template_inner_container").disabled = ( disable_container ) + elif event.checkbox.id == "suggest_next_sub_ses_remote": + self.interface.save_tui_settings( + not is_on, "suggest_next_sub_ses_local_only" + ) def on_radio_set_changed(self, event: RadioSet.Changed) -> None: """ From aabfe982fc62b2a57b215b73c429ae2115a6a4b4 Mon Sep 17 00:00:00 2001 From: shrey Date: Thu, 13 Mar 2025 02:53:23 +0530 Subject: [PATCH 03/28] remove: minor bug --- datashuttle/tui/custom_widgets.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/datashuttle/tui/custom_widgets.py b/datashuttle/tui/custom_widgets.py index df32e179c..8694da838 100644 --- a/datashuttle/tui/custom_widgets.py +++ b/datashuttle/tui/custom_widgets.py @@ -66,8 +66,6 @@ def __init__( ) self.mainwindow = mainwindow - self.styles.min_width = "100%" # TODO: REMOVE LATER - # self.styles.border = ("dashed", "yellow") def _on_click(self, event: events.Click) -> None: self.post_message(self.Clicked(self, event.ctrl)) From 938a79eed01f3e3e3d5835a3af5dbd9d51d3a1ed Mon Sep 17 00:00:00 2001 From: shrey Date: Thu, 13 Mar 2025 03:01:53 +0530 Subject: [PATCH 04/28] add: tooltips for search remote checkbox --- datashuttle/tui/css/tui_style.tcss | 2 +- datashuttle/tui/screens/create_folder_settings.py | 3 ++- datashuttle/tui/tooltips.py | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/datashuttle/tui/css/tui_style.tcss b/datashuttle/tui/css/tui_style.tcss index 37d4358ba..b123a119f 100644 --- a/datashuttle/tui/css/tui_style.tcss +++ b/datashuttle/tui/css/tui_style.tcss @@ -165,7 +165,7 @@ DatatypeCheckboxes { padding: 0 0 1 0; } -/* Spinner ---------------------------------------------------------------------------------*/ +/* Spinner --------------------------------------------------------------------------------- */ CustomSpinner { color: #00FF00; diff --git a/datashuttle/tui/screens/create_folder_settings.py b/datashuttle/tui/screens/create_folder_settings.py index 63e8774bb..8de3814b9 100644 --- a/datashuttle/tui/screens/create_folder_settings.py +++ b/datashuttle/tui/screens/create_folder_settings.py @@ -100,7 +100,7 @@ def compose(self) -> ComposeResult: self.interface, id="create_folders_settings_toplevel_select", ), - id="toplevel_folder_select_container", + id="toplevel_folder_select_container", ), Container( Checkbox( @@ -157,6 +157,7 @@ def on_mount(self) -> None: "#create_folders_settings_toplevel_select", "#create_folders_settings_bypass_validation_checkbox", "#template_settings_validation_on_checkbox", + "#suggest_next_sub_ses_remote", ]: self.query_one(id).tooltip = get_tooltip(id) diff --git a/datashuttle/tui/tooltips.py b/datashuttle/tui/tooltips.py index e8d0c771b..1018d1198 100644 --- a/datashuttle/tui/tooltips.py +++ b/datashuttle/tui/tooltips.py @@ -127,6 +127,11 @@ def get_tooltip(id: str) -> str: elif id == "#create_folders_settings_toplevel_select": tooltip = "The top-level-folder to create folders in." + elif id == "#suggest_next_sub_ses_remote": + tooltip = ( + "Search the remote project folder for folder suggestions. " + "Slow compared to a local search." + ) # bypass validation checkbox elif id == "#create_folders_settings_bypass_validation_checkbox": tooltip = ( From c289849428a69c06368c9b86c1fd8595dc01df44 Mon Sep 17 00:00:00 2001 From: shrey Date: Thu, 13 Mar 2025 03:13:38 +0530 Subject: [PATCH 05/28] convert fill input to a worker; add a spinner to indicate loading --- datashuttle/tui/tabs/create_folders.py | 53 +++++++++++++++++++------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index bbbc50343..8123c5030 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -1,16 +1,19 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING, List, Optional if TYPE_CHECKING: from pathlib import Path from textual.app import ComposeResult + from textual.worker import Worker from datashuttle.tui.app import TuiApp from datashuttle.tui.interface import Interface from datashuttle.utils.custom_types import Prefix +from textual import work from textual.containers import Container, Horizontal from textual.widgets import ( Button, @@ -20,6 +23,7 @@ from datashuttle.tui.custom_widgets import ( ClickableInput, CustomDirectoryTree, + CustomSpinner, TreeAndInputTab, ) from datashuttle.tui.screens.create_folder_settings import ( @@ -153,10 +157,26 @@ def on_clickable_input_clicked( prefix: Prefix = "sub" if "subject" in input_id else "ses" - if event.ctrl: - self.fill_input_with_template(prefix, input_id) - else: - self.fill_input_with_next_sub_or_ses_template(prefix, input_id) + async def _on_clickable_input_clicked(): + if event.ctrl: + self.fill_input_with_template(prefix, input_id) + else: + input_box = self.query_one(f"#{input_id}") + spinner = CustomSpinner(id="input_suggestion_spinner") + input_box.mount(spinner) + input_box.disabled = True + worker = self.fill_input_with_next_sub_or_ses_template( + prefix, + input_id, + self.interface.get_tui_settings()[ + "suggest_next_sub_ses_local_only" + ], + ) + await worker.wait() + spinner.remove() + input_box.disabled = False + + asyncio.create_task(_on_clickable_input_clicked()) def on_custom_directory_tree_directory_tree_special_key_press( self, event: CustomDirectoryTree.DirectoryTreeSpecialKeyPress @@ -271,10 +291,10 @@ def reload_directorytree(self) -> None: # Filling Inputs # ---------------------------------------------------------------------------------- - + @work(exclusive=False, thread=True) def fill_input_with_next_sub_or_ses_template( - self, prefix: Prefix, input_id: str - ) -> None: + self, prefix: Prefix, input_id: str, local_only: bool + ) -> Worker: """ This fills a sub / ses Input with a suggested name based on the next subject / session in the project (local). @@ -296,10 +316,17 @@ def fill_input_with_next_sub_or_ses_template( "top_level_folder_select" ]["create_tab"] + def show_error_dialog(output: str) -> None: + self.mainwindow.call_from_thread( + self.mainwindow.show_modal_error_dialog, output + ) + if prefix == "sub": - success, output = self.interface.get_next_sub(top_level_folder) + success, output = self.interface.get_next_sub( + top_level_folder, local_only=local_only + ) if not success: - self.mainwindow.show_modal_error_dialog(output) + show_error_dialog(output) return else: next_val = output @@ -309,14 +336,14 @@ def fill_input_with_next_sub_or_ses_template( ).as_names_list() if len(sub_names) > 1: - self.mainwindow.show_modal_error_dialog( + show_error_dialog( "Can only suggest next session number when a " "single subject is provided." ) return if sub_names == [""]: - self.mainwindow.show_modal_error_dialog( + show_error_dialog( "Must input a subject number before suggesting " "next session number." ) @@ -326,10 +353,10 @@ def fill_input_with_next_sub_or_ses_template( sub = sub_names[0] success, output = self.interface.get_next_ses( - top_level_folder, sub + top_level_folder, sub, local_only=local_only ) if not success: - self.mainwindow.show_modal_error_dialog(output) + show_error_dialog(output) return else: next_val = output From 866f06d815435cf7c2b8fc606fd159d36a51959b Mon Sep 17 00:00:00 2001 From: shrey Date: Wed, 19 Mar 2025 20:30:07 +0530 Subject: [PATCH 06/28] fix merge conflicts, slight refactor --- datashuttle/configs/canonical_configs.py | 1 + datashuttle/datashuttle_class.py | 6 +++- datashuttle/tui/app.py | 7 ++++ datashuttle/tui/interface.py | 8 ++--- .../tui/screens/create_folder_settings.py | 8 ++--- datashuttle/tui/tabs/create_folders.py | 36 +++++++++---------- 6 files changed, 39 insertions(+), 27 deletions(-) diff --git a/datashuttle/configs/canonical_configs.py b/datashuttle/configs/canonical_configs.py index b65ad6c66..20d33a4ea 100644 --- a/datashuttle/configs/canonical_configs.py +++ b/datashuttle/configs/canonical_configs.py @@ -242,6 +242,7 @@ def get_tui_config_defaults() -> Dict: "bypass_validation": False, "overwrite_existing_files": "never", "dry_run": False, + "suggest_next_sub_ses_remote": False, } } diff --git a/datashuttle/datashuttle_class.py b/datashuttle/datashuttle_class.py index 9322323fa..2253e09ee 100644 --- a/datashuttle/datashuttle_class.py +++ b/datashuttle/datashuttle_class.py @@ -1547,7 +1547,11 @@ def _update_settings_with_new_canonical_keys(self, settings: Dict): if "tui" not in settings: settings.update(canonical_tui_configs) - for key in ["overwrite_existing_files", "dry_run"]: + for key in [ + "overwrite_existing_files", + "dry_run", + "suggest_next_sub_ses_remote", + ]: if key not in settings["tui"]: settings["tui"][key] = canonical_tui_configs["tui"][key] diff --git a/datashuttle/tui/app.py b/datashuttle/tui/app.py index 411f9ef8c..022d74f9d 100644 --- a/datashuttle/tui/app.py +++ b/datashuttle/tui/app.py @@ -115,6 +115,13 @@ def load_project_page(self, interface: Interface) -> None: def show_modal_error_dialog(self, message: str) -> None: self.push_screen(modal_dialogs.MessageBox(message, border_color="red")) + def show_modal_error_dialog_from_main_thread(self, message: str) -> None: + """ + Used to call `show_modal_error_dialog from main thread when executing + in another thread. Throws error when called from main thread. + """ + self.call_from_thread(self.show_modal_error_dialog, message) + def handle_open_filesystem_browser(self, path_: Path) -> None: """ Open the system file browser to the path with the `showinfm` diff --git a/datashuttle/tui/interface.py b/datashuttle/tui/interface.py index e9520bb03..aedd2815e 100644 --- a/datashuttle/tui/interface.py +++ b/datashuttle/tui/interface.py @@ -432,20 +432,20 @@ def get_textual_compatible_project_configs(self) -> Configs: return cfg_to_load def get_next_sub( - self, top_level_folder: TopLevelFolder + self, top_level_folder: TopLevelFolder, include_central: bool ) -> InterfaceOutput: try: next_sub = self.project.get_next_sub( top_level_folder, return_with_prefix=True, - include_central=False, + include_central=include_central, ) return True, next_sub except BaseException as e: return False, str(e) def get_next_ses( - self, top_level_folder: TopLevelFolder, sub: str + self, top_level_folder: TopLevelFolder, sub: str, include_central: bool ) -> InterfaceOutput: try: @@ -453,7 +453,7 @@ def get_next_ses( top_level_folder, sub, return_with_prefix=True, - include_central=False, + include_central=include_central, ) return True, next_ses except BaseException as e: diff --git a/datashuttle/tui/screens/create_folder_settings.py b/datashuttle/tui/screens/create_folder_settings.py index 8de3814b9..ceb56dec5 100644 --- a/datashuttle/tui/screens/create_folder_settings.py +++ b/datashuttle/tui/screens/create_folder_settings.py @@ -86,8 +86,8 @@ def compose(self) -> ComposeResult: """ bypass_validation = self.interface.tui_settings["bypass_validation"] - suggest_next_sub_ses_local_only = self.interface.tui_settings[ - "suggest_next_sub_ses_local_only" + suggest_next_sub_ses_remote = self.interface.tui_settings[ + "suggest_next_sub_ses_remote" ] yield Container( @@ -105,7 +105,7 @@ def compose(self) -> ComposeResult: Container( Checkbox( "Search Remote For Suggestions", - value=not suggest_next_sub_ses_local_only, + value=suggest_next_sub_ses_remote, id="suggest_next_sub_ses_remote", ), Checkbox( @@ -250,7 +250,7 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: ) elif event.checkbox.id == "suggest_next_sub_ses_remote": self.interface.save_tui_settings( - not is_on, "suggest_next_sub_ses_local_only" + is_on, "suggest_next_sub_ses_remote" ) def on_radio_set_changed(self, event: RadioSet.Changed) -> None: diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 8123c5030..ce3265e6b 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -157,10 +157,11 @@ def on_clickable_input_clicked( prefix: Prefix = "sub" if "subject" in input_id else "ses" - async def _on_clickable_input_clicked(): - if event.ctrl: - self.fill_input_with_template(prefix, input_id) - else: + if event.ctrl: + self.fill_input_with_template(prefix, input_id) + else: + + async def _on_clickable_input_clicked(): input_box = self.query_one(f"#{input_id}") spinner = CustomSpinner(id="input_suggestion_spinner") input_box.mount(spinner) @@ -169,14 +170,14 @@ async def _on_clickable_input_clicked(): prefix, input_id, self.interface.get_tui_settings()[ - "suggest_next_sub_ses_local_only" + "suggest_next_sub_ses_remote" ], ) await worker.wait() spinner.remove() input_box.disabled = False - asyncio.create_task(_on_clickable_input_clicked()) + asyncio.create_task(_on_clickable_input_clicked()) def on_custom_directory_tree_directory_tree_special_key_press( self, event: CustomDirectoryTree.DirectoryTreeSpecialKeyPress @@ -293,7 +294,7 @@ def reload_directorytree(self) -> None: # ---------------------------------------------------------------------------------- @work(exclusive=False, thread=True) def fill_input_with_next_sub_or_ses_template( - self, prefix: Prefix, input_id: str, local_only: bool + self, prefix: Prefix, input_id: str, include_central: bool ) -> Worker: """ This fills a sub / ses Input with a suggested name based on the @@ -316,17 +317,14 @@ def fill_input_with_next_sub_or_ses_template( "top_level_folder_select" ]["create_tab"] - def show_error_dialog(output: str) -> None: - self.mainwindow.call_from_thread( - self.mainwindow.show_modal_error_dialog, output - ) - if prefix == "sub": success, output = self.interface.get_next_sub( - top_level_folder, local_only=local_only + top_level_folder, include_central=include_central ) if not success: - show_error_dialog(output) + self.mainwindow.show_modal_error_dialog_from_main_thread( + output + ) return else: next_val = output @@ -336,14 +334,14 @@ def show_error_dialog(output: str) -> None: ).as_names_list() if len(sub_names) > 1: - show_error_dialog( + self.mainwindow.show_modal_error_dialog_from_main_thread( "Can only suggest next session number when a " "single subject is provided." ) return if sub_names == [""]: - show_error_dialog( + self.mainwindow.show_modal_error_dialog_from_main_thread( "Must input a subject number before suggesting " "next session number." ) @@ -353,10 +351,12 @@ def show_error_dialog(output: str) -> None: sub = sub_names[0] success, output = self.interface.get_next_ses( - top_level_folder, sub, local_only=local_only + top_level_folder, sub, include_central=include_central ) if not success: - show_error_dialog(output) + self.mainwindow.show_modal_error_dialog_from_main_thread( + output + ) return else: next_val = output From 27c32614fc3a92c3c1ff99d310e27244123837c8 Mon Sep 17 00:00:00 2001 From: shrey Date: Wed, 9 Apr 2025 01:14:55 +0530 Subject: [PATCH 07/28] update: display popup for suggesting next sub/ses remote --- datashuttle/tui/css/tui_menu.tcss | 35 ++++++++++++++++ datashuttle/tui/css/tui_style.tcss | 6 --- datashuttle/tui/css/tui_tab.tcss | 7 ---- datashuttle/tui/custom_widgets.py | 20 --------- datashuttle/tui/screens/modal_dialogs.py | 14 +++++++ datashuttle/tui/tabs/create_folders.py | 53 +++++++++++++++--------- 6 files changed, 82 insertions(+), 53 deletions(-) diff --git a/datashuttle/tui/css/tui_menu.tcss b/datashuttle/tui/css/tui_menu.tcss index 529d66cb1..75feeda4e 100644 --- a/datashuttle/tui/css/tui_menu.tcss +++ b/datashuttle/tui/css/tui_menu.tcss @@ -443,6 +443,41 @@ ConfirmAndAwaitTransferPopup { content-align: center middle; } +/* Suggesting Screen --------------------------------------------------------------------- */ + +SearchingRemoteForNextSubSesPopup { + align: center middle; +} + +#searching_top_container { + align: center middle; + height: 15; + width: 65; + border: tall $panel-lighten-1; + background: $primary-background; + } + +#searching_top_container:light { + background: $boost; + border: tall $panel-darken-3; +} + +#searching_message_label { + align: center middle; + text-align: center; + overflow: hidden auto; + width: 100%; + margin: 1 0 0 0; +} + +#searching_animated_indicator { + align: center middle; + padding: 0; + height: 3; + margin: 1 0 0 0; + content-align: center middle; +} + /* Light Mode Error Screen */ MessageBox:light > #confirm_top_container { diff --git a/datashuttle/tui/css/tui_style.tcss b/datashuttle/tui/css/tui_style.tcss index b123a119f..f7a0bfed1 100644 --- a/datashuttle/tui/css/tui_style.tcss +++ b/datashuttle/tui/css/tui_style.tcss @@ -164,9 +164,3 @@ DatatypeCheckboxes { width: auto; padding: 0 0 1 0; } - -/* Spinner --------------------------------------------------------------------------------- */ - -CustomSpinner { - color: #00FF00; -} diff --git a/datashuttle/tui/css/tui_tab.tcss b/datashuttle/tui/css/tui_tab.tcss index cfb04053d..34321a84c 100644 --- a/datashuttle/tui/css/tui_tab.tcss +++ b/datashuttle/tui/css/tui_tab.tcss @@ -35,13 +35,6 @@ TabScreen > TabbedContent { margin: 0 0 1 0 } -#input_suggestion_spinner { - width: auto; - height: 1; - position: absolute; - dock: right; -} - #template_settings_validation_on_checkbox.-on > .toggle--button{ color: $success; } diff --git a/datashuttle/tui/custom_widgets.py b/datashuttle/tui/custom_widgets.py index 8694da838..34c8555bc 100644 --- a/datashuttle/tui/custom_widgets.py +++ b/datashuttle/tui/custom_widgets.py @@ -24,7 +24,6 @@ from textual._segment_tools import line_pad from textual.message import Message from textual.strip import Strip -from textual.widget import Widget from textual.widgets import ( DirectoryTree, Input, @@ -475,22 +474,3 @@ def on_select_changed(self, event: Select.Changed) -> None: self.interface.save_tui_settings( top_level_folder, "top_level_folder_select", self.settings_key ) - - -class CustomSpinner(Widget): - def __init__(self, id): - super().__init__() - self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - self.current_frame = 0 - self.id = id - - def on_mount(self) -> None: - # Update frame every 0.1 seconds - self.set_interval(0.1, self.next_frame) - - def next_frame(self) -> None: - self.current_frame = (self.current_frame + 1) % len(self.frames) - self.refresh() - - def render(self) -> Text: - return Text(self.frames[self.current_frame], style="bold") diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index 5bd555692..fa437ea03 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -137,6 +137,20 @@ async def handle_transfer_and_update_ui_when_complete(self) -> None: self.app.show_modal_error_dialog(output) +class SearchingRemoteForNextSubSesPopup(ModalScreen): + + def __init__(self, sub_or_ses: Prefix) -> None: + super().__init__() + self.message = f"Searching remote for next {sub_or_ses}" + + def compose(self) -> ComposeResult: + yield Container( + Label(self.message, id="searching_message_label"), + LoadingIndicator(id="searching_animated_indicator"), + id="searching_top_container", + ) + + class SelectDirectoryTreeScreen(ModalScreen): """ A modal screen that includes a DirectoryTree to browse diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index ce3265e6b..6289b6435 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -23,7 +23,6 @@ from datashuttle.tui.custom_widgets import ( ClickableInput, CustomDirectoryTree, - CustomSpinner, TreeAndInputTab, ) from datashuttle.tui.screens.create_folder_settings import ( @@ -33,6 +32,9 @@ DatatypeCheckboxes, DisplayedDatatypesScreen, ) +from datashuttle.tui.screens.modal_dialogs import ( + SearchingRemoteForNextSubSesPopup, +) from datashuttle.tui.tooltips import get_tooltip from datashuttle.tui.utils.tui_decorators import require_double_click from datashuttle.tui.utils.tui_validators import NeuroBlueprintValidator @@ -142,7 +144,7 @@ async def refresh_after_datatypes_changed(self, ignore): self.on_mount() @require_double_click - def on_clickable_input_clicked( + async def on_clickable_input_clicked( self, event: ClickableInput.Clicked ) -> None: """ @@ -160,24 +162,13 @@ def on_clickable_input_clicked( if event.ctrl: self.fill_input_with_template(prefix, input_id) else: + include_central = self.interface.get_tui_settings()[ + "suggest_next_sub_ses_remote" + ] - async def _on_clickable_input_clicked(): - input_box = self.query_one(f"#{input_id}") - spinner = CustomSpinner(id="input_suggestion_spinner") - input_box.mount(spinner) - input_box.disabled = True - worker = self.fill_input_with_next_sub_or_ses_template( - prefix, - input_id, - self.interface.get_tui_settings()[ - "suggest_next_sub_ses_remote" - ], - ) - await worker.wait() - spinner.remove() - input_box.disabled = False - - asyncio.create_task(_on_clickable_input_clicked()) + await self.suggest_next_sub_ses_with_popup( + prefix, input_id, include_central + ) def on_custom_directory_tree_directory_tree_special_key_press( self, event: CustomDirectoryTree.DirectoryTreeSpecialKeyPress @@ -292,7 +283,8 @@ def reload_directorytree(self) -> None: # Filling Inputs # ---------------------------------------------------------------------------------- - @work(exclusive=False, thread=True) + + @work(exclusive=True, thread=True) def fill_input_with_next_sub_or_ses_template( self, prefix: Prefix, input_id: str, include_central: bool ) -> Worker: @@ -370,6 +362,27 @@ def fill_input_with_next_sub_or_ses_template( input = self.query_one(f"#{input_id}") input.value = fill_value + async def suggest_next_sub_ses_with_popup( + self, prefix: Prefix, input_id: str, include_central: bool + ): + if include_central: + searching_popup = SearchingRemoteForNextSubSesPopup(prefix) + self.mainwindow.push_screen(searching_popup) + + async def _fill_suggestion_and_dismiss_popup(): + worker = self.fill_input_with_next_sub_or_ses_template( + prefix, input_id, include_central + ) + await worker.wait() + searching_popup.dismiss() + + asyncio.create_task(_fill_suggestion_and_dismiss_popup()) + else: + worker = self.fill_input_with_next_sub_or_ses_template( + prefix, input_id, include_central + ) + await worker.wait() + def run_local_validation(self, prefix: Prefix): """ Run validation of the values stored in the From 36177c03a56f53e74d00112f2f1717299bb3e24d Mon Sep 17 00:00:00 2001 From: shrey Date: Wed, 9 Apr 2025 21:30:21 +0530 Subject: [PATCH 08/28] update: handle popup closing on error --- datashuttle/tui/tabs/create_folders.py | 28 +++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 6289b6435..89e2d37b4 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -51,6 +51,7 @@ def __init__(self, mainwindow: TuiApp, interface: Interface) -> None: ) self.mainwindow = mainwindow self.interface = interface + self.searching_remote_popup: SearchingRemoteForNextSubSesPopup | None self.prev_click_time = 0.0 @@ -314,7 +315,7 @@ def fill_input_with_next_sub_or_ses_template( top_level_folder, include_central=include_central ) if not success: - self.mainwindow.show_modal_error_dialog_from_main_thread( + self.dismiss_popup_and_show_modal_error_dialog_from_thread( output ) return @@ -326,14 +327,14 @@ def fill_input_with_next_sub_or_ses_template( ).as_names_list() if len(sub_names) > 1: - self.mainwindow.show_modal_error_dialog_from_main_thread( + self.dismiss_popup_and_show_modal_error_dialog_from_thread( "Can only suggest next session number when a " "single subject is provided." ) return if sub_names == [""]: - self.mainwindow.show_modal_error_dialog_from_main_thread( + self.dismiss_popup_and_show_modal_error_dialog_from_thread( "Must input a subject number before suggesting " "next session number." ) @@ -346,7 +347,7 @@ def fill_input_with_next_sub_or_ses_template( top_level_folder, sub, include_central=include_central ) if not success: - self.mainwindow.show_modal_error_dialog_from_main_thread( + self.dismiss_popup_and_show_modal_error_dialog_from_thread( output ) return @@ -366,15 +367,17 @@ async def suggest_next_sub_ses_with_popup( self, prefix: Prefix, input_id: str, include_central: bool ): if include_central: - searching_popup = SearchingRemoteForNextSubSesPopup(prefix) - self.mainwindow.push_screen(searching_popup) + searching_remote_popup = SearchingRemoteForNextSubSesPopup(prefix) + self.searching_remote_popup = searching_remote_popup + self.mainwindow.push_screen(searching_remote_popup) async def _fill_suggestion_and_dismiss_popup(): worker = self.fill_input_with_next_sub_or_ses_template( prefix, input_id, include_central ) await worker.wait() - searching_popup.dismiss() + if self.searching_remote_popup: + searching_remote_popup.dismiss() asyncio.create_task(_fill_suggestion_and_dismiss_popup()) else: @@ -439,3 +442,14 @@ def update_directorytree_root(self, new_root_path: Path) -> None: Will automatically refresh the tree through the reactive attribute `path`. """ self.query_one("#create_folders_directorytree").path = new_root_path + + def dismiss_popup_and_show_modal_error_dialog_from_thread( + self, message: str + ) -> None: + if self.searching_remote_popup: + self.mainwindow.call_from_thread( + self.searching_remote_popup.dismiss + ) + self.searching_remote_popup = None + + self.mainwindow.show_modal_error_dialog_from_main_thread(message) From 8863982a213ae61094cc4b5e1fdea3c86f022060 Mon Sep 17 00:00:00 2001 From: shrey Date: Wed, 9 Apr 2025 22:18:13 +0530 Subject: [PATCH 09/28] slight refactor --- datashuttle/tui/tabs/create_folders.py | 36 ++++++++++++++------------ 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 89e2d37b4..55bf7c098 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -51,7 +51,9 @@ def __init__(self, mainwindow: TuiApp, interface: Interface) -> None: ) self.mainwindow = mainwindow self.interface = interface - self.searching_remote_popup: SearchingRemoteForNextSubSesPopup | None + self.searching_remote_popup: ( + SearchingRemoteForNextSubSesPopup | None + ) = None self.prev_click_time = 0.0 @@ -145,7 +147,7 @@ async def refresh_after_datatypes_changed(self, ignore): self.on_mount() @require_double_click - async def on_clickable_input_clicked( + def on_clickable_input_clicked( self, event: ClickableInput.Clicked ) -> None: """ @@ -167,7 +169,7 @@ async def on_clickable_input_clicked( "suggest_next_sub_ses_remote" ] - await self.suggest_next_sub_ses_with_popup( + self.suggest_next_sub_ses_with_popup( prefix, input_id, include_central ) @@ -363,7 +365,18 @@ def fill_input_with_next_sub_or_ses_template( input = self.query_one(f"#{input_id}") input.value = fill_value - async def suggest_next_sub_ses_with_popup( + async def fill_suggestion_and_dismiss_popup( + self, prefix, input_id, include_central + ): + worker = self.fill_input_with_next_sub_or_ses_template( + prefix, input_id, include_central + ) + await worker.wait() + if self.searching_remote_popup: + self.searching_remote_popup.dismiss() + self.searching_remote_popup = None + + def suggest_next_sub_ses_with_popup( self, prefix: Prefix, input_id: str, include_central: bool ): if include_central: @@ -371,20 +384,11 @@ async def suggest_next_sub_ses_with_popup( self.searching_remote_popup = searching_remote_popup self.mainwindow.push_screen(searching_remote_popup) - async def _fill_suggestion_and_dismiss_popup(): - worker = self.fill_input_with_next_sub_or_ses_template( - prefix, input_id, include_central - ) - await worker.wait() - if self.searching_remote_popup: - searching_remote_popup.dismiss() - - asyncio.create_task(_fill_suggestion_and_dismiss_popup()) - else: - worker = self.fill_input_with_next_sub_or_ses_template( + asyncio.create_task( + self.fill_suggestion_and_dismiss_popup( prefix, input_id, include_central ) - await worker.wait() + ) def run_local_validation(self, prefix: Prefix): """ From fe9d00c425de8b0c27ed965acfa51a48b6a9535f Mon Sep 17 00:00:00 2001 From: shrey Date: Fri, 23 May 2025 23:27:20 +0530 Subject: [PATCH 10/28] rename remote to central and change tooltip --- datashuttle/configs/canonical_configs.py | 2 +- datashuttle/datashuttle_class.py | 2 +- datashuttle/tui/css/tui_menu.tcss | 2 +- .../tui/screens/create_folder_settings.py | 16 +++++------ datashuttle/tui/screens/modal_dialogs.py | 6 ++-- datashuttle/tui/tabs/create_folders.py | 28 ++++++++++--------- datashuttle/tui/tooltips.py | 6 ++-- 7 files changed, 32 insertions(+), 30 deletions(-) diff --git a/datashuttle/configs/canonical_configs.py b/datashuttle/configs/canonical_configs.py index 20d33a4ea..ce76abf8d 100644 --- a/datashuttle/configs/canonical_configs.py +++ b/datashuttle/configs/canonical_configs.py @@ -242,7 +242,7 @@ def get_tui_config_defaults() -> Dict: "bypass_validation": False, "overwrite_existing_files": "never", "dry_run": False, - "suggest_next_sub_ses_remote": False, + "suggest_next_sub_ses_central": False, } } diff --git a/datashuttle/datashuttle_class.py b/datashuttle/datashuttle_class.py index 2253e09ee..b9026dd60 100644 --- a/datashuttle/datashuttle_class.py +++ b/datashuttle/datashuttle_class.py @@ -1550,7 +1550,7 @@ def _update_settings_with_new_canonical_keys(self, settings: Dict): for key in [ "overwrite_existing_files", "dry_run", - "suggest_next_sub_ses_remote", + "suggest_next_sub_ses_central", ]: if key not in settings["tui"]: settings["tui"][key] = canonical_tui_configs["tui"][key] diff --git a/datashuttle/tui/css/tui_menu.tcss b/datashuttle/tui/css/tui_menu.tcss index 75feeda4e..44b8053ec 100644 --- a/datashuttle/tui/css/tui_menu.tcss +++ b/datashuttle/tui/css/tui_menu.tcss @@ -445,7 +445,7 @@ ConfirmAndAwaitTransferPopup { /* Suggesting Screen --------------------------------------------------------------------- */ -SearchingRemoteForNextSubSesPopup { +SearchingCentralForNextSubSesPopup { align: center middle; } diff --git a/datashuttle/tui/screens/create_folder_settings.py b/datashuttle/tui/screens/create_folder_settings.py index ceb56dec5..7174481e1 100644 --- a/datashuttle/tui/screens/create_folder_settings.py +++ b/datashuttle/tui/screens/create_folder_settings.py @@ -86,8 +86,8 @@ def compose(self) -> ComposeResult: """ bypass_validation = self.interface.tui_settings["bypass_validation"] - suggest_next_sub_ses_remote = self.interface.tui_settings[ - "suggest_next_sub_ses_remote" + suggest_next_sub_ses_central = self.interface.tui_settings[ + "suggest_next_sub_ses_central" ] yield Container( @@ -104,9 +104,9 @@ def compose(self) -> ComposeResult: ), Container( Checkbox( - "Search Remote For Suggestions", - value=suggest_next_sub_ses_remote, - id="suggest_next_sub_ses_remote", + "Search Central For Suggestions", + value=suggest_next_sub_ses_central, + id="suggest_next_sub_ses_central", ), Checkbox( "Bypass validation", @@ -157,7 +157,7 @@ def on_mount(self) -> None: "#create_folders_settings_toplevel_select", "#create_folders_settings_bypass_validation_checkbox", "#template_settings_validation_on_checkbox", - "#suggest_next_sub_ses_remote", + "#suggest_next_sub_ses_central", ]: self.query_one(id).tooltip = get_tooltip(id) @@ -248,9 +248,9 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: self.query_one("#template_inner_container").disabled = ( disable_container ) - elif event.checkbox.id == "suggest_next_sub_ses_remote": + elif event.checkbox.id == "suggest_next_sub_ses_central": self.interface.save_tui_settings( - is_on, "suggest_next_sub_ses_remote" + is_on, "suggest_next_sub_ses_central" ) def on_radio_set_changed(self, event: RadioSet.Changed) -> None: diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index fa437ea03..f73dff406 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -10,7 +10,7 @@ from textual.worker import Worker from datashuttle.tui.app import TuiApp - from datashuttle.utils.custom_types import InterfaceOutput + from datashuttle.utils.custom_types import InterfaceOutput, Prefix from pathlib import Path @@ -137,11 +137,11 @@ async def handle_transfer_and_update_ui_when_complete(self) -> None: self.app.show_modal_error_dialog(output) -class SearchingRemoteForNextSubSesPopup(ModalScreen): +class SearchingCentralForNextSubSesPopup(ModalScreen): def __init__(self, sub_or_ses: Prefix) -> None: super().__init__() - self.message = f"Searching remote for next {sub_or_ses}" + self.message = f"Searching central for next {sub_or_ses}" def compose(self) -> ComposeResult: yield Container( diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 55bf7c098..583f88f5f 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -33,7 +33,7 @@ DisplayedDatatypesScreen, ) from datashuttle.tui.screens.modal_dialogs import ( - SearchingRemoteForNextSubSesPopup, + SearchingCentralForNextSubSesPopup, ) from datashuttle.tui.tooltips import get_tooltip from datashuttle.tui.utils.tui_decorators import require_double_click @@ -51,8 +51,8 @@ def __init__(self, mainwindow: TuiApp, interface: Interface) -> None: ) self.mainwindow = mainwindow self.interface = interface - self.searching_remote_popup: ( - SearchingRemoteForNextSubSesPopup | None + self.searching_central_popup_widget: ( + SearchingCentralForNextSubSesPopup | None ) = None self.prev_click_time = 0.0 @@ -166,7 +166,7 @@ def on_clickable_input_clicked( self.fill_input_with_template(prefix, input_id) else: include_central = self.interface.get_tui_settings()[ - "suggest_next_sub_ses_remote" + "suggest_next_sub_ses_central" ] self.suggest_next_sub_ses_with_popup( @@ -372,17 +372,19 @@ async def fill_suggestion_and_dismiss_popup( prefix, input_id, include_central ) await worker.wait() - if self.searching_remote_popup: - self.searching_remote_popup.dismiss() - self.searching_remote_popup = None + if self.searching_central_popup_widget: + self.searching_central_popup_widget.dismiss() + self.searching_central_popup_widget = None def suggest_next_sub_ses_with_popup( self, prefix: Prefix, input_id: str, include_central: bool ): if include_central: - searching_remote_popup = SearchingRemoteForNextSubSesPopup(prefix) - self.searching_remote_popup = searching_remote_popup - self.mainwindow.push_screen(searching_remote_popup) + searching_central_popup = SearchingCentralForNextSubSesPopup( + prefix + ) + self.searching_central_popup_widget = searching_central_popup + self.mainwindow.push_screen(searching_central_popup) asyncio.create_task( self.fill_suggestion_and_dismiss_popup( @@ -450,10 +452,10 @@ def update_directorytree_root(self, new_root_path: Path) -> None: def dismiss_popup_and_show_modal_error_dialog_from_thread( self, message: str ) -> None: - if self.searching_remote_popup: + if self.searching_central_popup_widget: self.mainwindow.call_from_thread( - self.searching_remote_popup.dismiss + self.searching_central_popup_widget.dismiss ) - self.searching_remote_popup = None + self.searching_central_popup_widget = None self.mainwindow.show_modal_error_dialog_from_main_thread(message) diff --git a/datashuttle/tui/tooltips.py b/datashuttle/tui/tooltips.py index 1018d1198..8b1f73492 100644 --- a/datashuttle/tui/tooltips.py +++ b/datashuttle/tui/tooltips.py @@ -127,10 +127,10 @@ def get_tooltip(id: str) -> str: elif id == "#create_folders_settings_toplevel_select": tooltip = "The top-level-folder to create folders in." - elif id == "#suggest_next_sub_ses_remote": + elif id == "#suggest_next_sub_ses_central": tooltip = ( - "Search the remote project folder for folder suggestions. " - "Slow compared to a local search." + "Search the central project folder when suggesting the next subject or session." + "May be slower compared than searching local only." ) # bypass validation checkbox elif id == "#create_folders_settings_bypass_validation_checkbox": From fdefeb83a575c28acbaa0e1fcfc0986f598ea5ed Mon Sep 17 00:00:00 2001 From: shrey Date: Sat, 24 May 2025 16:43:37 +0530 Subject: [PATCH 11/28] refactor functions and some minor changes --- datashuttle/tui/css/tui_menu.tcss | 2 +- .../tui/screens/create_folder_settings.py | 2 +- datashuttle/tui/tabs/create_folders.py | 106 ++++++++++++------ 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/datashuttle/tui/css/tui_menu.tcss b/datashuttle/tui/css/tui_menu.tcss index 44b8053ec..de52087b4 100644 --- a/datashuttle/tui/css/tui_menu.tcss +++ b/datashuttle/tui/css/tui_menu.tcss @@ -443,7 +443,7 @@ ConfirmAndAwaitTransferPopup { content-align: center middle; } -/* Suggesting Screen --------------------------------------------------------------------- */ +/* Suggest next subject / session loading pop up --------------------------------------------------- */ SearchingCentralForNextSubSesPopup { align: center middle; diff --git a/datashuttle/tui/screens/create_folder_settings.py b/datashuttle/tui/screens/create_folder_settings.py index 7174481e1..fac9f5e98 100644 --- a/datashuttle/tui/screens/create_folder_settings.py +++ b/datashuttle/tui/screens/create_folder_settings.py @@ -116,7 +116,7 @@ def compose(self) -> ComposeResult: Container( Horizontal( Checkbox( - "Template Validation", + "Template validation", id="template_settings_validation_on_checkbox", value=self.interface.get_name_templates()["on"], ), diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 583f88f5f..2c349af2b 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -169,9 +169,7 @@ def on_clickable_input_clicked( "suggest_next_sub_ses_central" ] - self.suggest_next_sub_ses_with_popup( - prefix, input_id, include_central - ) + self.suggest_next_sub_ses(prefix, input_id, include_central) def on_custom_directory_tree_directory_tree_special_key_press( self, event: CustomDirectoryTree.DirectoryTreeSpecialKeyPress @@ -287,6 +285,57 @@ def reload_directorytree(self) -> None: # Filling Inputs # ---------------------------------------------------------------------------------- + def suggest_next_sub_ses( + self, prefix: Prefix, input_id: str, include_central: bool + ): + """ + This handles suggesting next sub/ses for the project. Shows + a pop up screen in cases when searching for next sub/ses takes + time such as searching central in SSH connection method. + + Creates an asyncio task which handles the suggestion logic and + dismissing the pop up. + """ + assert self.interface.project.cfg["connection_method"] in [ + "local_filesystem", + "ssh", + ] + + if ( + include_central + and self.interface.project.cfg["connection_method"] == "ssh" + ): + searching_central_popup = SearchingCentralForNextSubSesPopup( + prefix + ) + self.searching_central_popup_widget = searching_central_popup + self.mainwindow.push_screen(searching_central_popup) + + asyncio.create_task( + self.fill_suggestion_and_dismiss_popup( + prefix, input_id, include_central + ) + ) + + async def fill_suggestion_and_dismiss_popup( + self, prefix, input_id, include_central + ): + """ + This handles running the running the `fill_input_with_next_sub_or_ses_template` + worker and waiting for it to complete. If an error occurs in + `fill_input_with_next_sub_or_ses_template`, it dismisses the popup itself. + + Else, if the worker successfully exits, this function handles dismissal + of the popup. + """ + worker = self.fill_input_with_next_sub_or_ses_template( + prefix, input_id, include_central + ) + await worker.wait() + if self.searching_central_popup_widget: + self.searching_central_popup_widget.dismiss() + self.searching_central_popup_widget = None + @work(exclusive=True, thread=True) def fill_input_with_next_sub_or_ses_template( self, prefix: Prefix, input_id: str, include_central: bool @@ -300,6 +349,9 @@ def fill_input_with_next_sub_or_ses_template( sub or ses key-value. Otherwise, the sub/ses key-value pair only will be suggested. + It runs in a worker thread so as to allow the TUI to show a loading + animation. + Parameters prefix : Prefix @@ -365,32 +417,25 @@ def fill_input_with_next_sub_or_ses_template( input = self.query_one(f"#{input_id}") input.value = fill_value - async def fill_suggestion_and_dismiss_popup( - self, prefix, input_id, include_central - ): - worker = self.fill_input_with_next_sub_or_ses_template( - prefix, input_id, include_central - ) - await worker.wait() + def dismiss_popup_and_show_modal_error_dialog_from_thread( + self, message: str + ) -> None: + """ + This is a utility function that the `fill_input_with_next_sub_or_ses_template` + worker calls to display error dialog an if an error occurs while suggesting + the next sub/ses. Handles the TUI widget manipulation from the main thread + when called from within a worker thread. + """ if self.searching_central_popup_widget: - self.searching_central_popup_widget.dismiss() + self.mainwindow.call_from_thread( + self.searching_central_popup_widget.dismiss + ) self.searching_central_popup_widget = None - def suggest_next_sub_ses_with_popup( - self, prefix: Prefix, input_id: str, include_central: bool - ): - if include_central: - searching_central_popup = SearchingCentralForNextSubSesPopup( - prefix - ) - self.searching_central_popup_widget = searching_central_popup - self.mainwindow.push_screen(searching_central_popup) + self.mainwindow.show_modal_error_dialog_from_main_thread(message) - asyncio.create_task( - self.fill_suggestion_and_dismiss_popup( - prefix, input_id, include_central - ) - ) + # Validation + # ---------------------------------------------------------------------------------- def run_local_validation(self, prefix: Prefix): """ @@ -448,14 +493,3 @@ def update_directorytree_root(self, new_root_path: Path) -> None: Will automatically refresh the tree through the reactive attribute `path`. """ self.query_one("#create_folders_directorytree").path = new_root_path - - def dismiss_popup_and_show_modal_error_dialog_from_thread( - self, message: str - ) -> None: - if self.searching_central_popup_widget: - self.mainwindow.call_from_thread( - self.searching_central_popup_widget.dismiss - ) - self.searching_central_popup_widget = None - - self.mainwindow.show_modal_error_dialog_from_main_thread(message) From 5ffe35d0843403832b2db683869bb61630007c41 Mon Sep 17 00:00:00 2001 From: shrey Date: Thu, 29 May 2025 03:00:29 +0530 Subject: [PATCH 12/28] add: new tests for searching central for suggestions feature --- .../tui/screens/create_folder_settings.py | 6 +- datashuttle/tui/tabs/create_folders.py | 3 +- datashuttle/tui/tooltips.py | 2 +- tests/tests_tui/test_tui_create_folders.py | 61 +++++++++++++++ .../test_tui_widgets_and_defaults.py | 76 +++++++++++++++++++ 5 files changed, 143 insertions(+), 5 deletions(-) diff --git a/datashuttle/tui/screens/create_folder_settings.py b/datashuttle/tui/screens/create_folder_settings.py index fac9f5e98..237ff7187 100644 --- a/datashuttle/tui/screens/create_folder_settings.py +++ b/datashuttle/tui/screens/create_folder_settings.py @@ -106,7 +106,7 @@ def compose(self) -> ComposeResult: Checkbox( "Search Central For Suggestions", value=suggest_next_sub_ses_central, - id="suggest_next_sub_ses_central", + id="suggest_next_sub_ses_central_checkbox", ), Checkbox( "Bypass validation", @@ -157,7 +157,7 @@ def on_mount(self) -> None: "#create_folders_settings_toplevel_select", "#create_folders_settings_bypass_validation_checkbox", "#template_settings_validation_on_checkbox", - "#suggest_next_sub_ses_central", + "#suggest_next_sub_ses_central_checkbox", ]: self.query_one(id).tooltip = get_tooltip(id) @@ -248,7 +248,7 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: self.query_one("#template_inner_container").disabled = ( disable_container ) - elif event.checkbox.id == "suggest_next_sub_ses_central": + elif event.checkbox.id == "suggest_next_sub_ses_central_checkbox": self.interface.save_tui_settings( is_on, "suggest_next_sub_ses_central" ) diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 2c349af2b..81d11f0a7 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -314,7 +314,8 @@ def suggest_next_sub_ses( asyncio.create_task( self.fill_suggestion_and_dismiss_popup( prefix, input_id, include_central - ) + ), + name=f"suggest_next_{prefix}_async_task", ) async def fill_suggestion_and_dismiss_popup( diff --git a/datashuttle/tui/tooltips.py b/datashuttle/tui/tooltips.py index 8b1f73492..f1e33f910 100644 --- a/datashuttle/tui/tooltips.py +++ b/datashuttle/tui/tooltips.py @@ -127,7 +127,7 @@ def get_tooltip(id: str) -> str: elif id == "#create_folders_settings_toplevel_select": tooltip = "The top-level-folder to create folders in." - elif id == "#suggest_next_sub_ses_central": + elif id == "#suggest_next_sub_ses_central_checkbox": tooltip = ( "Search the central project folder when suggesting the next subject or session." "May be slower compared than searching local only." diff --git a/tests/tests_tui/test_tui_create_folders.py b/tests/tests_tui/test_tui_create_folders.py index 00a408f76..ac552ac7e 100644 --- a/tests/tests_tui/test_tui_create_folders.py +++ b/tests/tests_tui/test_tui_create_folders.py @@ -524,6 +524,67 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): await pilot.pause() + @pytest.mark.asyncio + async def test_get_next_sub_and_ses_central_no_template( + self, setup_project_paths, mocker + ): + tmp_config_path, tmp_path, project_name = setup_project_paths.values() + + app = TuiApp() + async with app.run_test(size=self.tui_size()) as pilot: + await self.setup_existing_project_create_tab_filled_sub_and_ses( + pilot, project_name, create_folders=True + ) + + # turn on the central checkbox + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_button" + ) + await self.scroll_to_click_pause( + pilot, "#suggest_next_sub_ses_central_checkbox" + ) + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_close_button" + ) + + # mocking the datashuttle functions + spy_get_next_sub = mocker.spy( + pilot.app.screen.interface.project, "get_next_sub" + ) + spy_get_next_ses = mocker.spy( + pilot.app.screen.interface.project, "get_next_ses" + ) + + # check subject suggestion + await self.double_click(pilot, "#create_folders_subject_input") + + if suggest_sub_task := test_utils.get_task_by_name( + "suggest_next_sub_async_task" + ): + await suggest_sub_task + + spy_get_next_sub.assert_called_with( + "rawdata", return_with_prefix=True, include_central=True + ) + + # check session suggestion + await self.fill_input( + pilot, "#create_folders_subject_input", "sub-001" + ) + await self.double_click(pilot, "#create_folders_session_input") + + if suggest_ses_task := test_utils.get_task_by_name( + "suggest_next_ses_async_task" + ): + await suggest_ses_task + + spy_get_next_ses.assert_called_with( + "rawdata", + "sub-001", + return_with_prefix=True, + include_central=True, + ) + # ------------------------------------------------------------------------- # Test Top Level Folders # ------------------------------------------------------------------------- diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index a07092962..37f9279eb 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -909,6 +909,82 @@ async def check_top_folder_select( == expected_val ) + @pytest.mark.asyncio + async def test_search_central_for_suggestion_settings( + self, setup_project_paths + ): + tmp_config_path, tmp_path, project_name = setup_project_paths.values() + + app = TuiApp() + async with app.run_test(size=self.tui_size()) as pilot: + + await self.setup_existing_project_create_tab_filled_sub_and_ses( + pilot, project_name, create_folders=False + ) + + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_button" + ) + + # check default + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is False + ) + assert ( + pilot.app.screen.interface.tui_settings[ + "suggest_next_sub_ses_central" + ] + is False + ) + + # click and check + await self.scroll_to_click_pause( + pilot, "#suggest_next_sub_ses_central_checkbox" + ) + + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is True + ) + assert ( + pilot.app.screen.interface.tui_settings[ + "suggest_next_sub_ses_central" + ] + is True + ) + + # some navigations + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_close_button" + ) + await self.exit_to_main_menu_and_reeneter_project_manager( + pilot, project_name + ) + await self.scroll_to_click_pause( + pilot, "#create_folders_settings_button" + ) + + # ensure settings persist + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is True + ) + assert ( + pilot.app.screen.interface.tui_settings[ + "suggest_next_sub_ses_central" + ] + is True + ) + + await pilot.pause() + @pytest.mark.asyncio async def test_all_checkboxes(self, setup_project_paths): """ From 951be880e3ea470bea42195693efc01811f96e54 Mon Sep 17 00:00:00 2001 From: shrey Date: Thu, 29 May 2025 04:26:03 +0530 Subject: [PATCH 13/28] fix: failing existing tests --- tests/tests_integration/test_settings.py | 2 ++ tests/tests_tui/test_tui_widgets_and_defaults.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/tests/tests_integration/test_settings.py b/tests/tests_integration/test_settings.py index b996a1d02..1f19bede4 100644 --- a/tests/tests_integration/test_settings.py +++ b/tests/tests_integration/test_settings.py @@ -186,6 +186,7 @@ def get_settings_default(self): "bypass_validation": False, "overwrite_existing_files": "never", "dry_run": False, + "suggest_next_sub_ses_central": False, } default_settings["create_checkboxes_on"] = { key: {"on": True, "displayed": True} @@ -227,6 +228,7 @@ def get_settings_changed(self): "bypass_validation": True, "overwrite_existing_files": "always", "dry_run": True, + "suggest_next_sub_ses_central": True, } changed_settings["create_checkboxes_on"] = { diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index 37f9279eb..b03b820af 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -399,6 +399,21 @@ async def test_create_folder_settings_widgets(self, setup_project_paths): == "rawdata" ) + # Search central for suggestions checkbox + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).label._text[0] + == "Search Central For Suggestions" + ) + assert ( + pilot.app.screen.query_one( + "#suggest_next_sub_ses_central_checkbox" + ).value + is False + ) + + # Bypass validation checkbox assert ( pilot.app.screen.query_one( "#create_folders_settings_bypass_validation_checkbox" @@ -412,6 +427,7 @@ async def test_create_folder_settings_widgets(self, setup_project_paths): is False ) + # Template validation assert ( pilot.app.screen.query_one( "#template_settings_validation_on_checkbox" From 450cf277e70a48979f851f1963754ae40ab9d0f0 Mon Sep 17 00:00:00 2001 From: shrey Date: Fri, 30 May 2025 21:41:20 +0530 Subject: [PATCH 14/28] first attempt at fixing tests --- tests/tests_tui/test_tui_create_folders.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/tests_tui/test_tui_create_folders.py b/tests/tests_tui/test_tui_create_folders.py index ac552ac7e..a1210b620 100644 --- a/tests/tests_tui/test_tui_create_folders.py +++ b/tests/tests_tui/test_tui_create_folders.py @@ -445,6 +445,10 @@ async def test_name_template_next_sub_or_ses_and_validation( await self.double_click( pilot, "#create_folders_subject_input", control=False ) + if suggest_sub_task := test_utils.get_task_by_name( + "suggest_next_sub_async_task" + ): + await suggest_sub_task assert ( pilot.app.screen.query_one( "#create_folders_subject_input" @@ -458,6 +462,10 @@ async def test_name_template_next_sub_or_ses_and_validation( await self.double_click( pilot, "#create_folders_session_input", control=False ) + if suggest_ses_task := test_utils.get_task_by_name( + "suggest_next_ses_async_task" + ): + await suggest_ses_task assert ( pilot.app.screen.query_one( "#create_folders_session_input" @@ -504,6 +512,10 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): # Double click without CTRL modifier key. await self.double_click(pilot, "#create_folders_subject_input") + if suggest_sub_task := test_utils.get_task_by_name( + "suggest_next_sub_async_task" + ): + await suggest_sub_task assert ( pilot.app.screen.query_one( "#create_folders_subject_input" @@ -515,6 +527,10 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): pilot, "#create_folders_subject_input", "sub-001" ) await self.double_click(pilot, "#create_folders_session_input") + if suggest_ses_task := test_utils.get_task_by_name( + "suggest_next_ses_async_task" + ): + await suggest_ses_task assert ( pilot.app.screen.query_one( "#create_folders_session_input" From 5d2f82c8838ce4f90390a585dae7a008b059b099 Mon Sep 17 00:00:00 2001 From: Shrey Singh <96627769+cs7-shrey@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:56:21 +0530 Subject: [PATCH 15/28] Apply suggestions from code review edit: some comments and docstrings Co-authored-by: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com> --- datashuttle/tui/tabs/create_folders.py | 2 +- tests/tests_tui/test_tui_create_folders.py | 8 ++++---- tests/tests_tui/test_tui_widgets_and_defaults.py | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 81d11f0a7..ca8b98e2f 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -322,7 +322,7 @@ async def fill_suggestion_and_dismiss_popup( self, prefix, input_id, include_central ): """ - This handles running the running the `fill_input_with_next_sub_or_ses_template` + This handles running the `fill_input_with_next_sub_or_ses_template` worker and waiting for it to complete. If an error occurs in `fill_input_with_next_sub_or_ses_template`, it dismisses the popup itself. diff --git a/tests/tests_tui/test_tui_create_folders.py b/tests/tests_tui/test_tui_create_folders.py index a1210b620..6cff1420b 100644 --- a/tests/tests_tui/test_tui_create_folders.py +++ b/tests/tests_tui/test_tui_create_folders.py @@ -552,7 +552,7 @@ async def test_get_next_sub_and_ses_central_no_template( pilot, project_name, create_folders=True ) - # turn on the central checkbox + # Turn on the central checkbox await self.scroll_to_click_pause( pilot, "#create_folders_settings_button" ) @@ -563,7 +563,7 @@ async def test_get_next_sub_and_ses_central_no_template( pilot, "#create_folders_settings_close_button" ) - # mocking the datashuttle functions + # Mock the datashuttle functions spy_get_next_sub = mocker.spy( pilot.app.screen.interface.project, "get_next_sub" ) @@ -571,7 +571,7 @@ async def test_get_next_sub_and_ses_central_no_template( pilot.app.screen.interface.project, "get_next_ses" ) - # check subject suggestion + # # Check subject suggestion called mocked function correctly await self.double_click(pilot, "#create_folders_subject_input") if suggest_sub_task := test_utils.get_task_by_name( @@ -583,7 +583,7 @@ async def test_get_next_sub_and_ses_central_no_template( "rawdata", return_with_prefix=True, include_central=True ) - # check session suggestion + # Check session suggestion called mocked function correctly await self.fill_input( pilot, "#create_folders_subject_input", "sub-001" ) diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index b03b820af..a188cd52a 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -942,7 +942,7 @@ async def test_search_central_for_suggestion_settings( pilot, "#create_folders_settings_button" ) - # check default + # Check default value assert ( pilot.app.screen.query_one( "#suggest_next_sub_ses_central_checkbox" @@ -956,7 +956,7 @@ async def test_search_central_for_suggestion_settings( is False ) - # click and check + # Click and check the value is switched await self.scroll_to_click_pause( pilot, "#suggest_next_sub_ses_central_checkbox" ) @@ -974,7 +974,7 @@ async def test_search_central_for_suggestion_settings( is True ) - # some navigations + # Refresh the session await self.scroll_to_click_pause( pilot, "#create_folders_settings_close_button" ) @@ -985,7 +985,7 @@ async def test_search_central_for_suggestion_settings( pilot, "#create_folders_settings_button" ) - # ensure settings persist + # Ensure settings persist assert ( pilot.app.screen.query_one( "#suggest_next_sub_ses_central_checkbox" From a45e5c0cd18be172902049575817fe315d1246be Mon Sep 17 00:00:00 2001 From: shrey Date: Fri, 6 Jun 2025 21:47:48 +0530 Subject: [PATCH 16/28] refactor: apply code review changes --- datashuttle/tui/screens/modal_dialogs.py | 7 ++++ datashuttle/tui/tabs/create_folders.py | 7 ++-- tests/test_utils.py | 5 +++ tests/tests_tui/test_tui_create_folders.py | 38 +++++++++---------- .../test_tui_widgets_and_defaults.py | 5 +++ 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index f73dff406..8d33c6a7a 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -138,6 +138,13 @@ async def handle_transfer_and_update_ui_when_complete(self) -> None: class SearchingCentralForNextSubSesPopup(ModalScreen): + """ + A popup to show message and a loading indicator when awaiting search next sub/ses across + the folders present in both local and central machines. This search happens in a separate + thread so as to allow TUI to display the loading indicate without freezing. + + Only displayed when the `include_central` flag is checked and the connection method is "ssh". + """ def __init__(self, sub_or_ses: Prefix) -> None: super().__init__() diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index ca8b98e2f..2cad7b534 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -305,11 +305,10 @@ def suggest_next_sub_ses( include_central and self.interface.project.cfg["connection_method"] == "ssh" ): - searching_central_popup = SearchingCentralForNextSubSesPopup( - prefix + self.searching_central_popup_widget = ( + SearchingCentralForNextSubSesPopup(prefix) ) - self.searching_central_popup_widget = searching_central_popup - self.mainwindow.push_screen(searching_central_popup) + self.mainwindow.push_screen(self.searching_central_popup_widget) asyncio.create_task( self.fill_suggestion_and_dismiss_popup( diff --git a/tests/test_utils.py b/tests/test_utils.py index ad908160e..4d53e9f7b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -695,3 +695,8 @@ def get_task_by_name(name): None, ) return target_task + + +async def await_task_by_name_if_present(name: str) -> None: + if task := get_task_by_name(name): + await task diff --git a/tests/tests_tui/test_tui_create_folders.py b/tests/tests_tui/test_tui_create_folders.py index 6cff1420b..6554789d1 100644 --- a/tests/tests_tui/test_tui_create_folders.py +++ b/tests/tests_tui/test_tui_create_folders.py @@ -445,10 +445,9 @@ async def test_name_template_next_sub_or_ses_and_validation( await self.double_click( pilot, "#create_folders_subject_input", control=False ) - if suggest_sub_task := test_utils.get_task_by_name( + await test_utils.await_task_by_name_if_present( "suggest_next_sub_async_task" - ): - await suggest_sub_task + ) assert ( pilot.app.screen.query_one( "#create_folders_subject_input" @@ -462,10 +461,9 @@ async def test_name_template_next_sub_or_ses_and_validation( await self.double_click( pilot, "#create_folders_session_input", control=False ) - if suggest_ses_task := test_utils.get_task_by_name( + await test_utils.await_task_by_name_if_present( "suggest_next_ses_async_task" - ): - await suggest_ses_task + ) assert ( pilot.app.screen.query_one( "#create_folders_session_input" @@ -512,10 +510,9 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): # Double click without CTRL modifier key. await self.double_click(pilot, "#create_folders_subject_input") - if suggest_sub_task := test_utils.get_task_by_name( + await test_utils.await_task_by_name_if_present( "suggest_next_sub_async_task" - ): - await suggest_sub_task + ) assert ( pilot.app.screen.query_one( "#create_folders_subject_input" @@ -527,10 +524,9 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): pilot, "#create_folders_subject_input", "sub-001" ) await self.double_click(pilot, "#create_folders_session_input") - if suggest_ses_task := test_utils.get_task_by_name( + await test_utils.await_task_by_name_if_present( "suggest_next_ses_async_task" - ): - await suggest_ses_task + ) assert ( pilot.app.screen.query_one( "#create_folders_session_input" @@ -544,6 +540,11 @@ async def test_get_next_sub_and_ses_no_template(self, setup_project_paths): async def test_get_next_sub_and_ses_central_no_template( self, setup_project_paths, mocker ): + """ + Test getting the next subject / session with the include_central option. Check the + checkbox widget that turns the setting on. Trigger a get next subject / session and mock + the underlying datashuttle function to ensure include_central is properly called. + """ tmp_config_path, tmp_path, project_name = setup_project_paths.values() app = TuiApp() @@ -571,13 +572,11 @@ async def test_get_next_sub_and_ses_central_no_template( pilot.app.screen.interface.project, "get_next_ses" ) - # # Check subject suggestion called mocked function correctly + # Check subject suggestion called mocked function correctly await self.double_click(pilot, "#create_folders_subject_input") - - if suggest_sub_task := test_utils.get_task_by_name( + await test_utils.await_task_by_name_if_present( "suggest_next_sub_async_task" - ): - await suggest_sub_task + ) spy_get_next_sub.assert_called_with( "rawdata", return_with_prefix=True, include_central=True @@ -589,10 +588,9 @@ async def test_get_next_sub_and_ses_central_no_template( ) await self.double_click(pilot, "#create_folders_session_input") - if suggest_ses_task := test_utils.get_task_by_name( + await test_utils.await_task_by_name_if_present( "suggest_next_ses_async_task" - ): - await suggest_ses_task + ) spy_get_next_ses.assert_called_with( "rawdata", diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index a188cd52a..a432e9479 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -929,6 +929,11 @@ async def check_top_folder_select( async def test_search_central_for_suggestion_settings( self, setup_project_paths ): + """ + Check the settings for the checkbox that selects include_central when + getting the next subject or session in the 'Create' tab and ensure that + the underlying settings are changed. + """ tmp_config_path, tmp_path, project_name = setup_project_paths.values() app = TuiApp() From 25617d4e6f0978f51340dde2afa4e9b1cf00d2a5 Mon Sep 17 00:00:00 2001 From: shrey Date: Mon, 9 Jun 2025 23:07:19 +0530 Subject: [PATCH 17/28] fix minor bug --- datashuttle/tui/tabs/create_folders.py | 1 + 1 file changed, 1 insertion(+) diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 2cad7b534..04617874f 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -297,6 +297,7 @@ def suggest_next_sub_ses( dismissing the pop up. """ assert self.interface.project.cfg["connection_method"] in [ + None, "local_filesystem", "ssh", ] From a0421ff6ea005b3918ef1bf70f6f738c7e2eccee Mon Sep 17 00:00:00 2001 From: shrey Date: Mon, 9 Jun 2025 23:15:49 +0530 Subject: [PATCH 18/28] first attempt at testing error popup --- tests/tests_tui/test_tui_create_folders.py | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/tests_tui/test_tui_create_folders.py b/tests/tests_tui/test_tui_create_folders.py index 6554789d1..f015586cf 100644 --- a/tests/tests_tui/test_tui_create_folders.py +++ b/tests/tests_tui/test_tui_create_folders.py @@ -599,6 +599,40 @@ async def test_get_next_sub_and_ses_central_no_template( include_central=True, ) + @pytest.mark.asyncio + async def test_get_next_sub_and_ses_error_popup(self, setup_project_paths): + """ + Test the modal error dialog display on encountering an error + while suggesting next sub/ses. Since getting the suggestion happens + in a thread, the `dismiss_popup_and_show_modal_error_dialog_from_thread` + function which is used to display the modal error dialog from main thread + is being tested. It is done by trying to get next session suggestion without + inputting a subject. + """ + tmp_config_path, tmp_path, project_name = setup_project_paths.values() + + app = TuiApp() + async with app.run_test(size=self.tui_size()) as pilot: + await self.setup_existing_project_create_tab_filled_sub_and_ses( + pilot, project_name, create_folders=True + ) + + # Clear the inputs + await self.fill_input(pilot, "#create_folders_subject_input", "") + await self.fill_input(pilot, "#create_folders_session_input", "") + + await self.double_click(pilot, "#create_folders_session_input") + await test_utils.await_task_by_name_if_present( + "suggest_next_ses_async_task" + ) + + assert ( + "Must input a subject number before suggesting next session number." + in pilot.app.screen.query_one( + "#messagebox_message_label" + ).renderable + ) + # ------------------------------------------------------------------------- # Test Top Level Folders # ------------------------------------------------------------------------- From f050150df0df09c6b04d3401e15e9480734c54ef Mon Sep 17 00:00:00 2001 From: shrey Date: Thu, 12 Jun 2025 18:27:54 +0530 Subject: [PATCH 19/28] minor changes --- tests/tests_tui/test_tui_create_folders.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/tests_tui/test_tui_create_folders.py b/tests/tests_tui/test_tui_create_folders.py index f015586cf..85a06cd5c 100644 --- a/tests/tests_tui/test_tui_create_folders.py +++ b/tests/tests_tui/test_tui_create_folders.py @@ -617,9 +617,8 @@ async def test_get_next_sub_and_ses_error_popup(self, setup_project_paths): pilot, project_name, create_folders=True ) - # Clear the inputs + # Clear the subject input await self.fill_input(pilot, "#create_folders_subject_input", "") - await self.fill_input(pilot, "#create_folders_session_input", "") await self.double_click(pilot, "#create_folders_session_input") await test_utils.await_task_by_name_if_present( From 5f0d0f2bd6731b4fc51f3ad49ee5a6c45590aa49 Mon Sep 17 00:00:00 2001 From: shrey Date: Tue, 17 Jun 2025 00:05:49 +0530 Subject: [PATCH 20/28] remove: existing textual version bug --- tests/tests_tui/test_tui_widgets_and_defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index a432e9479..6f0cca531 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -403,7 +403,7 @@ async def test_create_folder_settings_widgets(self, setup_project_paths): assert ( pilot.app.screen.query_one( "#suggest_next_sub_ses_central_checkbox" - ).label._text[0] + ).label._text == "Search Central For Suggestions" ) assert ( From 0285538e7df0fad0cf1a5ddb83cb507ee2e9bd1a Mon Sep 17 00:00:00 2001 From: shrey Date: Tue, 17 Jun 2025 00:31:13 +0530 Subject: [PATCH 21/28] fix: minor bug --- tests/tests_tui/test_tui_widgets_and_defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index 6f0cca531..73f06a22a 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -432,7 +432,7 @@ async def test_create_folder_settings_widgets(self, setup_project_paths): pilot.app.screen.query_one( "#template_settings_validation_on_checkbox" ).label._text - == "Template Validation" + == "Template validation" ) assert ( pilot.app.screen.query_one( From 0c9e3f76d0b31bd08913a8c21e8e55015f16fa09 Mon Sep 17 00:00:00 2001 From: shrey Date: Tue, 17 Jun 2025 02:18:50 +0530 Subject: [PATCH 22/28] update: enhanced decorator for input box --- datashuttle/tui/tabs/create_folders.py | 5 ++-- datashuttle/tui/utils/tui_decorators.py | 40 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index 04617874f..a29b02218 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -36,7 +36,7 @@ SearchingCentralForNextSubSesPopup, ) from datashuttle.tui.tooltips import get_tooltip -from datashuttle.tui.utils.tui_decorators import require_double_click +from datashuttle.tui.utils.tui_decorators import require_double_click_input_box from datashuttle.tui.utils.tui_validators import NeuroBlueprintValidator @@ -56,6 +56,7 @@ def __init__(self, mainwindow: TuiApp, interface: Interface) -> None: ) = None self.prev_click_time = 0.0 + self.prev_click_input_id: str | None = None def compose(self) -> ComposeResult: yield CustomDirectoryTree( @@ -146,7 +147,7 @@ async def refresh_after_datatypes_changed(self, ignore): await self.recompose() self.on_mount() - @require_double_click + @require_double_click_input_box def on_clickable_input_clicked( self, event: ClickableInput.Clicked ) -> None: diff --git a/datashuttle/tui/utils/tui_decorators.py b/datashuttle/tui/utils/tui_decorators.py index f2a96d41f..792929703 100644 --- a/datashuttle/tui/utils/tui_decorators.py +++ b/datashuttle/tui/utils/tui_decorators.py @@ -35,3 +35,43 @@ def wrapper(*args, **kwargs): parent_class.prev_click_time = click_time return wrapper + + +def require_double_click_input_box(func): + """ + Similar to the `require_double_click` decorator but adds an + extra check to register a double click. + + Requires the first argument (`self` on the class) to + have the attribute `prev_click_time` and `prev_click_input_id). + """ + + @wraps(func) + def wrapper(*args, **kwargs): + parent_class = args[0] + event = args[1] + + assert hasattr(parent_class, "prev_click_time"), ( + "Decorator must be used on class method where the class as " + "the attribute `prev_click_time`." + ) + assert hasattr(parent_class, "prev_click_input_id"), ( + "Decorator must be used on class method where the class as " + "the attribute `prev_click_time`." + ) + + click_time = monotonic() + input_id = event.input.id + + if ( + click_time - parent_class.prev_click_time < 0.5 + and input_id == parent_class.prev_click_input_id + ): + parent_class.prev_click_time = click_time + parent_class.prev_click_input_id = input_id + return func(*args, **kwargs) + + parent_class.prev_click_time = click_time + parent_class.prev_click_input_id = input_id + + return wrapper From deef98ca89f2d1b03d2cc1f617bddf3b7a0c7f02 Mon Sep 17 00:00:00 2001 From: shrey Date: Wed, 18 Jun 2025 08:21:08 +0530 Subject: [PATCH 23/28] refactor: changes to decorator specifying id of clicked element --- datashuttle/tui/screens/modal_dialogs.py | 13 ++-- datashuttle/tui/tabs/create_folders.py | 10 +-- datashuttle/tui/tabs/logging.py | 13 ++-- datashuttle/tui/utils/tui_decorators.py | 77 +++++++++++------------- tests/tests_tui/test_tui_configs.py | 7 ++- 5 files changed, 62 insertions(+), 58 deletions(-) diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index 8d33c6a7a..dffe3ecc6 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -19,7 +19,10 @@ from textual.widgets import Button, Input, Label, LoadingIndicator, Static from datashuttle.tui.custom_widgets import CustomDirectoryTree -from datashuttle.tui.utils.tui_decorators import require_double_click +from datashuttle.tui.utils.tui_decorators import ( + ClickInfo, + require_double_click, +) class MessageBox(ModalScreen): @@ -186,7 +189,7 @@ def __init__( path_ = Path().home() self.path_ = path_ - self.prev_click_time = 0 + self.click_info = ClickInfo() def compose(self) -> ComposeResult: @@ -207,11 +210,11 @@ def compose(self) -> ComposeResult: ) @require_double_click - def on_directory_tree_directory_selected(self, node) -> None: - if node.path.is_file(): + def on_directory_tree_directory_selected(self, event) -> None: + if event.path.is_file(): return else: - self.dismiss(node.path) + self.dismiss(event.path) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel_button": diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index a29b02218..036a61dec 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -36,7 +36,10 @@ SearchingCentralForNextSubSesPopup, ) from datashuttle.tui.tooltips import get_tooltip -from datashuttle.tui.utils.tui_decorators import require_double_click_input_box +from datashuttle.tui.utils.tui_decorators import ( + ClickInfo, + require_double_click, +) from datashuttle.tui.utils.tui_validators import NeuroBlueprintValidator @@ -55,8 +58,7 @@ def __init__(self, mainwindow: TuiApp, interface: Interface) -> None: SearchingCentralForNextSubSesPopup | None ) = None - self.prev_click_time = 0.0 - self.prev_click_input_id: str | None = None + self.click_info = ClickInfo() def compose(self) -> ComposeResult: yield CustomDirectoryTree( @@ -147,7 +149,7 @@ async def refresh_after_datatypes_changed(self, ignore): await self.recompose() self.on_mount() - @require_double_click_input_box + @require_double_click def on_clickable_input_clicked( self, event: ClickableInput.Clicked ) -> None: diff --git a/datashuttle/tui/tabs/logging.py b/datashuttle/tui/tabs/logging.py index fb45c8269..ab9b7340f 100644 --- a/datashuttle/tui/tabs/logging.py +++ b/datashuttle/tui/tabs/logging.py @@ -9,7 +9,10 @@ from datashuttle.tui.custom_widgets import ( CustomDirectoryTree, ) -from datashuttle.tui.utils.tui_decorators import require_double_click +from datashuttle.tui.utils.tui_decorators import ( + ClickInfo, + require_double_click, +) class RichLogScreen(ModalScreen): @@ -45,7 +48,7 @@ def __init__(self, title, mainwindow, project, id): # display and functionality are always in sync. self.latest_log_path = None self.update_latest_log_path() - self.prev_click_time = 0 + self.click_info = ClickInfo() def update_latest_log_path(self): logs = list(self.project.get_logging_path().glob("*.log")) @@ -94,15 +97,15 @@ def on_button_pressed(self, event): self.push_rich_log_screen(self.latest_log_path) @require_double_click - def on_directory_tree_file_selected(self, node): - if not node.path.is_file(): + def on_directory_tree_file_selected(self, event): + if not event.path.is_file(): self.mainwindow.show_modal_error_dialog( "Log file no longer exists. Refresh the directory tree" "by pressing CTRL and r at the same time." ) return - self.push_rich_log_screen(node.path) + self.push_rich_log_screen(event.path) def push_rich_log_screen(self, log_path): self.mainwindow.push_screen( diff --git a/datashuttle/tui/utils/tui_decorators.py b/datashuttle/tui/utils/tui_decorators.py index 792929703..e056985fb 100644 --- a/datashuttle/tui/utils/tui_decorators.py +++ b/datashuttle/tui/utils/tui_decorators.py @@ -8,70 +8,65 @@ # ----------------------------------------------------------------------------- -def require_double_click(func): +class ClickInfo: """ - A decorator that calls the decorated function - on a double click, otherwise will not do anything. - - Requires the first argument (`self` on the class) to - have the attribute `prev_click_time`). + A class to hold click-info to checking + double clicks are within the time threshold + and match the widget id. """ - @wraps(func) - def wrapper(*args, **kwargs): - parent_class = args[0] - - assert hasattr(parent_class, "prev_click_time"), ( - "Decorator must be used on class method where the class as " - "the attribute `prev_click_time`." - ) - - click_time = monotonic() - - if click_time - parent_class.prev_click_time < 0.5: - parent_class.prev_click_time = click_time - return func(*args, **kwargs) - - parent_class.prev_click_time = click_time + def __init__(self): - return wrapper + self.prev_click_time = 0.0 + self.prev_click_widget_id = "" -def require_double_click_input_box(func): +def require_double_click(func): """ - Similar to the `require_double_click` decorator but adds an - extra check to register a double click. + A decorator that calls the decorated function + on a double click, otherwise will not do anything. Requires the first argument (`self` on the class) to - have the attribute `prev_click_time` and `prev_click_input_id). + have the attribute `click_info`). Any class holding a widget + that supports double-clicking must have the attribute + self.click_info = ClickInfo() + + The first (non-self) argument depends on the decorated function, + which is usually widget-specific. Unfortunately, these must be + supported on a case-by-case bases and extended when required. """ @wraps(func) def wrapper(*args, **kwargs): parent_class = args[0] - event = args[1] - assert hasattr(parent_class, "prev_click_time"), ( - "Decorator must be used on class method where the class as " - "the attribute `prev_click_time`." - ) - assert hasattr(parent_class, "prev_click_input_id"), ( + assert hasattr(parent_class, "click_info"), ( "Decorator must be used on class method where the class as " - "the attribute `prev_click_time`." + "the attribute `self.click_info = ClickInfo()`." ) click_time = monotonic() - input_id = event.input.id + event = args[1] + + if hasattr(event, "input"): + id = event.input.id + elif hasattr(event, "node"): + id = event.node.tree.id + else: + raise RuntimeError( + "The message type for the widget you are trying to" + "register clicks on is not supported. Add it to the decorator." + ) if ( - click_time - parent_class.prev_click_time < 0.5 - and input_id == parent_class.prev_click_input_id + click_time - parent_class.click_info.prev_click_time < 0.5 + and id == parent_class.click_info.prev_click_widget_id ): - parent_class.prev_click_time = click_time - parent_class.prev_click_input_id = input_id + parent_class.click_info.prev_click_time = click_time + parent_class.click_info.prev_click_widget_id = id return func(*args, **kwargs) - parent_class.prev_click_time = click_time - parent_class.prev_click_input_id = input_id + parent_class.click_info.prev_click_time = click_time + parent_class.click_info.prev_click_widget_id = id return wrapper diff --git a/tests/tests_tui/test_tui_configs.py b/tests/tests_tui/test_tui_configs.py index 00b30b781..e8f5d0767 100644 --- a/tests/tests_tui/test_tui_configs.py +++ b/tests/tests_tui/test_tui_configs.py @@ -1,5 +1,6 @@ import copy from pathlib import Path +from time import monotonic import pytest import test_utils @@ -316,11 +317,11 @@ async def test_configs_select_path(self, monkeypatch): ) root_path = tree.root.data.path - import time + pilot.app.screen.click_info.prev_click_widget_id = tree.id + pilot.app.screen.click_info.prev_click_time = monotonic() - pilot.app.screen.prev_click_time = time.time() pilot.app.screen.on_directory_tree_directory_selected( - tree.root.data + tree.DirectorySelected(tree.root, root_path) ) await pilot.pause() From 990a43d83cb757bf536242d750d7dc169428bef6 Mon Sep 17 00:00:00 2001 From: Joe Ziminski <55797454+JoeZiminski@users.noreply.github.com> Date: Wed, 18 Jun 2025 16:05:24 +0100 Subject: [PATCH 24/28] Revert `node` rename --- datashuttle/tui/screens/modal_dialogs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index dffe3ecc6..8c30636a7 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -7,6 +7,7 @@ from pathlib import Path from textual.app import ComposeResult + from textual.widgets._tree import TreeNode from textual.worker import Worker from datashuttle.tui.app import TuiApp @@ -210,11 +211,11 @@ def compose(self) -> ComposeResult: ) @require_double_click - def on_directory_tree_directory_selected(self, event) -> None: - if event.path.is_file(): + def on_directory_tree_directory_selected(self, node: DirectoryTree.TreeNode) -> None: + if node.path.is_file(): return else: - self.dismiss(event.path) + self.dismiss(node.path) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel_button": From 203a8a8e6ee76b30ea4dd6c477fe6ee025d45e9c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:05:46 +0000 Subject: [PATCH 25/28] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- datashuttle/tui/screens/modal_dialogs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index 8c30636a7..1a9350ff2 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -7,7 +7,6 @@ from pathlib import Path from textual.app import ComposeResult - from textual.widgets._tree import TreeNode from textual.worker import Worker from datashuttle.tui.app import TuiApp @@ -211,7 +210,9 @@ def compose(self) -> ComposeResult: ) @require_double_click - def on_directory_tree_directory_selected(self, node: DirectoryTree.TreeNode) -> None: + def on_directory_tree_directory_selected( + self, node: DirectoryTree.TreeNode + ) -> None: if node.path.is_file(): return else: From 049d4ab18d282c9442414abe298dfac5e8e584d5 Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 18 Jun 2025 16:18:57 +0100 Subject: [PATCH 26/28] Fix linting. --- datashuttle/tui/screens/modal_dialogs.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index 1a9350ff2..08037355d 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -7,6 +7,7 @@ from pathlib import Path from textual.app import ComposeResult + from textual.widgets._tree import TreeNode from textual.worker import Worker from datashuttle.tui.app import TuiApp @@ -210,9 +211,7 @@ def compose(self) -> ComposeResult: ) @require_double_click - def on_directory_tree_directory_selected( - self, node: DirectoryTree.TreeNode - ) -> None: + def on_directory_tree_directory_selected(self, node: TreeNode) -> None: if node.path.is_file(): return else: From 824e2f839b21144ad9c0072ac81e9745834cb19b Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Wed, 18 Jun 2025 16:24:55 +0100 Subject: [PATCH 27/28] Revert node->event. --- datashuttle/tui/tabs/logging.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/datashuttle/tui/tabs/logging.py b/datashuttle/tui/tabs/logging.py index ab9b7340f..68da61a2c 100644 --- a/datashuttle/tui/tabs/logging.py +++ b/datashuttle/tui/tabs/logging.py @@ -1,7 +1,13 @@ +from __future__ import annotations + import os from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from textual import events + from textual.widgets._tree import TreeNode -from textual import events from textual.containers import Container, Horizontal from textual.screen import ModalScreen from textual.widgets import Button, Label, RichLog, TabPane @@ -97,15 +103,15 @@ def on_button_pressed(self, event): self.push_rich_log_screen(self.latest_log_path) @require_double_click - def on_directory_tree_file_selected(self, event): - if not event.path.is_file(): + def on_directory_tree_file_selected(self, node: TreeNode): + if not node.path.is_file(): self.mainwindow.show_modal_error_dialog( "Log file no longer exists. Refresh the directory tree" "by pressing CTRL and r at the same time." ) return - self.push_rich_log_screen(event.path) + self.push_rich_log_screen(node.path) def push_rich_log_screen(self, log_path): self.mainwindow.push_screen( From f7b5ef632980907f12fe68162b07a29117457c4c Mon Sep 17 00:00:00 2001 From: JoeZiminski Date: Thu, 19 Jun 2025 12:29:35 +0100 Subject: [PATCH 28/28] Use correct event types, and use these in the double click decorator. --- datashuttle/tui/screens/modal_dialogs.py | 10 ++++++---- datashuttle/tui/tabs/logging.py | 10 ++++++---- datashuttle/tui/utils/tui_decorators.py | 10 ++++++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/datashuttle/tui/screens/modal_dialogs.py b/datashuttle/tui/screens/modal_dialogs.py index 08037355d..d2fc3dac7 100644 --- a/datashuttle/tui/screens/modal_dialogs.py +++ b/datashuttle/tui/screens/modal_dialogs.py @@ -7,7 +7,7 @@ from pathlib import Path from textual.app import ComposeResult - from textual.widgets._tree import TreeNode + from textual.widgets import DirectoryTree from textual.worker import Worker from datashuttle.tui.app import TuiApp @@ -211,11 +211,13 @@ def compose(self) -> ComposeResult: ) @require_double_click - def on_directory_tree_directory_selected(self, node: TreeNode) -> None: - if node.path.is_file(): + def on_directory_tree_directory_selected( + self, event: DirectoryTree.DirectorySelected + ) -> None: + if event.path.is_file(): return else: - self.dismiss(node.path) + self.dismiss(event.path) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "cancel_button": diff --git a/datashuttle/tui/tabs/logging.py b/datashuttle/tui/tabs/logging.py index 68da61a2c..770387f00 100644 --- a/datashuttle/tui/tabs/logging.py +++ b/datashuttle/tui/tabs/logging.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from textual import events - from textual.widgets._tree import TreeNode + from textual.widgets import DirectoryTree from textual.containers import Container, Horizontal from textual.screen import ModalScreen @@ -103,15 +103,17 @@ def on_button_pressed(self, event): self.push_rich_log_screen(self.latest_log_path) @require_double_click - def on_directory_tree_file_selected(self, node: TreeNode): - if not node.path.is_file(): + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ): + if not event.path.is_file(): self.mainwindow.show_modal_error_dialog( "Log file no longer exists. Refresh the directory tree" "by pressing CTRL and r at the same time." ) return - self.push_rich_log_screen(node.path) + self.push_rich_log_screen(event.path) def push_rich_log_screen(self, log_path): self.mainwindow.push_screen( diff --git a/datashuttle/tui/utils/tui_decorators.py b/datashuttle/tui/utils/tui_decorators.py index e056985fb..9b95735be 100644 --- a/datashuttle/tui/utils/tui_decorators.py +++ b/datashuttle/tui/utils/tui_decorators.py @@ -3,6 +3,10 @@ from functools import wraps from time import monotonic +from textual.widgets import DirectoryTree + +from datashuttle.tui.custom_widgets import ClickableInput + # ----------------------------------------------------------------------------- # Double-click decorator # ----------------------------------------------------------------------------- @@ -48,9 +52,11 @@ def wrapper(*args, **kwargs): click_time = monotonic() event = args[1] - if hasattr(event, "input"): + if isinstance(event, ClickableInput.Clicked): id = event.input.id - elif hasattr(event, "node"): + elif isinstance(event, DirectoryTree.FileSelected) or isinstance( + event, DirectoryTree.DirectorySelected + ): id = event.node.tree.id else: raise RuntimeError(