diff --git a/datashuttle/tui/screens/datatypes.py b/datashuttle/tui/screens/datatypes.py index 7c21f84df..c319a479a 100644 --- a/datashuttle/tui/screens/datatypes.py +++ b/datashuttle/tui/screens/datatypes.py @@ -185,13 +185,6 @@ def on_selection_list_selection_toggled( class DatatypeCheckboxes(Static): """Dynamically-populated checkbox widget for convenient datatype selection. - Parameters - ---------- - settings_key - 'create' if datatype checkboxes for the create tab, - 'transfer' for the transfer tab. Transfer tab includes - additional datatype options (e.g. "all"). - Attributes ---------- datatype_config @@ -213,7 +206,7 @@ class DatatypeCheckboxes(Static): def __init__( self, interface: Interface, - create_or_transfer: Literal["create", "transfer"] = "create", + tab_name: Literal["create", "transfer"] = "create", id: Optional[str] = None, ) -> None: """Initialise the DatatypeCheckboxes. @@ -223,7 +216,7 @@ def __init__( interface Datashuttle Interface object. - create_or_transfer + tab_name Whether we are on the "create" or "transfer" tab. id @@ -233,9 +226,9 @@ def __init__( super(DatatypeCheckboxes, self).__init__(id=id) self.interface = interface - self.create_or_transfer = create_or_transfer + self.tab_name = tab_name - self.settings_key = get_tui_settings_key_name(self.create_or_transfer) + self.settings_key = get_tui_settings_key_name(self.tab_name) # `datatype_config` is basically just a convenience wrapper # around interface.get_tui_settings @@ -249,12 +242,12 @@ def compose(self) -> ComposeResult: if setting["displayed"]: yield Checkbox( datatype.replace("_", " "), - id=get_checkbox_name(self.create_or_transfer, datatype), + id=get_checkbox_name(self.tab_name, datatype), value=setting["on"], ) @on(Checkbox.Changed) - def on_checkbox_changed(self) -> None: + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: """Handle a datatype checkbox change. When a checkbox is changed, update the `self.datatype_config` @@ -264,7 +257,7 @@ def on_checkbox_changed(self) -> None: for datatype in self.datatype_config.keys(): if self.datatype_config[datatype]["displayed"]: self.datatype_config[datatype]["on"] = self.query_one( - f"#{get_checkbox_name(self.create_or_transfer, datatype)}" + f"#{get_checkbox_name(self.tab_name, datatype)}" ).value self.interface.save_tui_settings( @@ -276,7 +269,7 @@ def on_mount(self) -> None: for datatype in self.datatype_config.keys(): if self.datatype_config[datatype]["displayed"]: self.query_one( - f"#{get_checkbox_name(self.create_or_transfer, datatype)}" + f"#{get_checkbox_name(self.tab_name, datatype)}" ).tooltip = tooltips[datatype] def selected_datatypes(self) -> List[str]: @@ -289,22 +282,92 @@ def selected_datatypes(self) -> List[str]: return selected_datatypes +class TransferDatatypeCheckboxes(DatatypeCheckboxes): + """Subclass of the data type checkboxes class for the transfer tab. + + This subclass extends `on_checkbox_changed` by adding logic to + dynamically turn off mutually exclusive checkboxes when one is + selected, before delegating to the base implementation. + + """ + + def __init__(self, interface, id): + """Initialise TransferDatatypeCheckboxes. + + Parameters + ---------- + interface + Datashuttle Interface object. + + id + Textual ID for the DatatypeCheckboxes widget. + + """ + super().__init__(interface, "transfer", id) + + @on(Checkbox.Changed) + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + """Dynamically turn off checkboxes depending on the activated checkbox then save. + + In the transfer tab, we have a few different checkboxes that + are mutually exclusive. For example, `all` and `all_datatypes` + are redundant. This function turns off checkboxes redundant + with the selected checkbox, before calling the super class + which saves the state of the currently selected checkboxes. + """ + checkbox = event.control + checkbox_name = get_datatype_from_checkbox_name(str(checkbox.id)) + + if checkbox.value: + all_datatypes = [ + dtype + for dtype in self.datatype_config.keys() + if dtype not in ["all", "all_datatype", "all_non_datatype"] + ] + + if checkbox_name == "all": + to_turn_off = [ + "all_datatype", + "all_non_datatype", + ] + all_datatypes + elif checkbox_name == "all_datatype": + to_turn_off = ["all"] + all_datatypes + elif checkbox_name == "all_non_datatype": + to_turn_off = ["all"] + else: + to_turn_off = ["all", "all_datatype"] + + for datatype in to_turn_off: + if self.datatype_config[datatype]["displayed"]: + self.query_one( + f"#{get_checkbox_name(self.tab_name, datatype)}" + ).value = False + + super().on_checkbox_changed(event) + + # Helpers # -------------------------------------------------------------------------------------- def get_checkbox_name( - create_or_transfer: Literal["create", "transfer"], datatype + tab_name: Literal["create", "transfer"], datatype ) -> str: """Return a canonical formatted checkbox name.""" - return f"{create_or_transfer}_{datatype}_checkbox" + return f"{tab_name}_{datatype}_checkbox" + + +def get_datatype_from_checkbox_name(checkbox_name: str) -> str: + """Get the datatype from the output of `get_checkbox_name()`.""" + split_datatype = checkbox_name.split("_")[1:-1] + return "_".join(split_datatype) def get_tui_settings_key_name( - create_or_transfer: Literal["create", "transfer"], + tab_name: Literal["create", "transfer"], ) -> str: """Return the canonical tui settings key.""" - if create_or_transfer == "create": + if tab_name == "create": settings_key = "create_checkboxes_on" else: settings_key = "transfer_checkboxes_on" diff --git a/datashuttle/tui/tabs/create_folders.py b/datashuttle/tui/tabs/create_folders.py index aa9422188..09daeb136 100644 --- a/datashuttle/tui/tabs/create_folders.py +++ b/datashuttle/tui/tabs/create_folders.py @@ -546,5 +546,7 @@ def update_directorytree_root(self, new_root_path: Path) -> None: def get_datatype_checkboxes_widget(self): """Create the datatype checkboxes, centralised as used in multiple places.""" return DatatypeCheckboxes( - self.interface, id="create_folders_datatype_checkboxes" + self.interface, + tab_name="create", + id="create_folders_datatype_checkboxes", ) diff --git a/datashuttle/tui/tabs/transfer.py b/datashuttle/tui/tabs/transfer.py index a6362ad85..d2af4e4ee 100644 --- a/datashuttle/tui/tabs/transfer.py +++ b/datashuttle/tui/tabs/transfer.py @@ -32,8 +32,8 @@ TreeAndInputTab, ) from datashuttle.tui.screens.datatypes import ( - DatatypeCheckboxes, DisplayedDatatypesScreen, + TransferDatatypeCheckboxes, ) from datashuttle.tui.screens.modal_dialogs import ( ConfirmAndAwaitTransferPopup, @@ -424,9 +424,8 @@ def transfer_data(self) -> Worker[InterfaceOutput]: def get_datatypes_checkboxes_widget(self): """Create the datatype checkboxes, centralised as used in multiple places.""" - return DatatypeCheckboxes( + return TransferDatatypeCheckboxes( self.interface, - create_or_transfer="transfer", id="transfer_custom_datatype_checkboxes", ) diff --git a/tests/tests_tui/test_tui_widgets_and_defaults.py b/tests/tests_tui/test_tui_widgets_and_defaults.py index c95c7e9d7..a46389d4b 100644 --- a/tests/tests_tui/test_tui_widgets_and_defaults.py +++ b/tests/tests_tui/test_tui_widgets_and_defaults.py @@ -1075,14 +1075,15 @@ async def test_all_checkboxes(self, setup_project_paths): "ephys", "funcimg", "anat", - "all", - "all_datatype", "all_non_datatype", + # "all" and "all_datatype" will turn off transfer datatypes + # and are tested separately in `test_transfer_checkboxes_dynamic_on_off()`. ]: await self.change_checkbox( pilot, f"#transfer_{datatype}_checkbox" ) expected_transfer[datatype]["on"] = True + self.check_datatype_checkboxes( pilot, "transfer", expected_transfer ) @@ -1103,6 +1104,68 @@ async def test_all_checkboxes(self, setup_project_paths): await pilot.pause() + @pytest.mark.asyncio + async def test_transfer_checkboxes_dynamic_on_off( + self, setup_project_paths + ): + """ + This tests that mutually exclusive checkbox options turn + each other off / on when set. This is necessary for transfer + tab custom datatypes in which some checkboxes (e.g. "all") + should not be selected with other (e.g. "behav"). + """ + tmp_config_path, tmp_path, project_name = setup_project_paths.values() + + app = TuiApp() + async with app.run_test(size=self.tui_size()) as pilot: + # Set up the TUI on the 'transfer' tab (custom) and + # open the datatype selection screen + await self.check_and_click_onto_existing_project( + pilot, project_name + ) + + await self.switch_tab(pilot, "transfer") + await self.scroll_to_click_pause( + pilot, "#transfer_custom_radiobutton" + ) + + # Create a function to reload the settings dict, refreshing the contents + def load_dict(): + return pilot.app.screen.interface.project._load_persistent_settings()[ + "tui" + ]["transfer_checkboxes_on"] + + # turn on "behav" checkbox, check "all" is turned off + assert load_dict()["all"]["on"] + await self.change_checkbox(pilot, "#transfer_behav_checkbox") + assert not load_dict()["all"]["on"] + + # Turn on "all_non_datatype" checkbox, check "behav" is kept on + await self.change_checkbox( + pilot, "#transfer_all_non_datatype_checkbox" + ) + assert load_dict()["all_non_datatype"]["on"] + assert load_dict()["behav"]["on"] + + # Turn on "all_datatype" checkbox, check "behav" is turned off + await self.change_checkbox( + pilot, "#transfer_all_datatype_checkbox" + ) + assert load_dict()["all_datatype"]["on"] + assert not load_dict()["behav"]["on"] + + # Turn on "all" checkbox and check all_non_datatype and all_datatype are switched off + await self.change_checkbox(pilot, "#transfer_all_checkbox") + assert load_dict()["all"]["on"] + assert not load_dict()["all_datatype"]["on"] + assert not load_dict()["all_non_datatype"]["on"] + + # Turn on "all_non_datatype" and check "all" is now off + await self.change_checkbox( + pilot, "#transfer_all_non_datatype_checkbox" + ) + assert not load_dict()["all"]["on"] + def check_datatype_checkboxes(self, pilot, tab, expected_on): assert tab in ["create", "transfer"] if tab == "create":