diff --git a/pyproject.toml b/pyproject.toml index ca057ecb..0622a153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "kaleido", # required for exporting PNG to disk "asteval", "muscle3", + "panel-jstree", ] dynamic = ["version"] diff --git a/waveform_editor/gui/main.py b/waveform_editor/gui/main.py index ce6039fa..741e3442 100644 --- a/waveform_editor/gui/main.py +++ b/waveform_editor/gui/main.py @@ -39,6 +39,12 @@ def exception_handler(ex): "tabulator", "terminal", "katex", + "tree", # panel_jstree registers under "tree", not "jstree" + js_files={ + # jQuery must be in the page before Bokeh renders panel_jstree + "jquery": "https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js", + "jstree": "https://cdnjs.cloudflare.com/ajax/libs/jstree/3.3.12/jstree.min.js", + }, notifications=True, exception_handler=exception_handler, ) diff --git a/waveform_editor/gui/selector/options_button_row.py b/waveform_editor/gui/selector/options_button_row.py index 1739e80c..271db46d 100644 --- a/waveform_editor/gui/selector/options_button_row.py +++ b/waveform_editor/gui/selector/options_button_row.py @@ -7,80 +7,66 @@ if TYPE_CHECKING: from waveform_editor.gui.main import WaveformEditorGui - from waveform_editor.gui.selector.selection_group import SelectionGroup + from waveform_editor.gui.selector.selector import WaveformSelector class OptionsButtonRow(Viewer): - """Row of options buttons for the SelectionGroup""" + """Row of options buttons for waveform/group management. - def __init__( - self, main_gui: "WaveformEditorGui", selection_group: "SelectionGroup" - ): + Operates on the currently active group tracked by selector.active_group_path. + """ + + def __init__(self, main_gui: "WaveformEditorGui", selector: "WaveformSelector"): super().__init__() self.main_gui = main_gui self.config = main_gui.config - self.selection_group = selection_group - self.selector = selection_group.selector - self.path = selection_group.path - has_waveforms = selection_group.has_waveforms + self.selector = selector + + not_root = pn.bind(lambda path: bool(path), selector.param.active_group_path) - # 'Select all' Button self.select_all_button = pn.widgets.ButtonIcon( icon="select-all", size="20px", active_icon="check", - description="Select all waveforms in this group", - on_click=selection_group.select_all, - disabled=self.selector.param.multiselect.rx.not_(), - visible=has_waveforms, + description="Select all waveforms in active group", + on_click=self._select_all, + disabled=selector.param.multiselect.rx.not_(), ) - - # 'Deselect all' Button self.deselect_all_button = pn.widgets.ButtonIcon( icon="deselect", size="20px", active_icon="check", - description="Deselect all waveforms in this group", - on_click=selection_group.deselect_all, - visible=has_waveforms, + description="Deselect all waveforms in active group", + on_click=self._deselect_all, ) - - # 'Add new waveform' button self.new_waveform_button = pn.widgets.ButtonIcon( icon="plus", size="20px", active_icon="check", - description="Add new waveform", + description="Add new waveform to active group", on_click=self._on_add_waveform_button_click, - visible=not self.selection_group.is_root, + visible=not_root, ) self.new_waveform_panel = TextInputForm( "Enter name of new waveform", is_visible=False, on_click=self._add_new_waveform, ) - - # 'Remove waveform' button self.remove_waveform_button = pn.widgets.ButtonIcon( icon="minus", size="20px", active_icon="check", - description="Remove selected waveforms in this group", + description="Remove selected waveforms from active group", on_click=self._show_remove_waveform_modal, - visible=has_waveforms, + visible=not_root, ) - - # 'Rename waveform' button self.rename_waveform_button = pn.widgets.ButtonIcon( icon="cursor-text", size="20px", active_icon="check", - description="Rename waveform", + description="Rename selected waveform", on_click=self._on_rename_waveform_button_click, - visible=has_waveforms, ) - - # 'Add new group' button self.new_group_button = pn.widgets.ButtonIcon( icon="library-plus", size="20px", @@ -93,18 +79,15 @@ def __init__( is_visible=False, on_click=self._add_new_group, ) - - # 'Remove group' button self.remove_group_button = pn.widgets.ButtonIcon( icon="trash", size="20px", active_icon="trash-filled", - description="Remove this group", + description="Remove active group", on_click=self._show_remove_group_modal, - visible=not self.selection_group.is_root, + visible=not_root, ) - # Combine all into a button row option_buttons = pn.Row( self.new_waveform_button, self.remove_waveform_button, @@ -120,90 +103,114 @@ def __init__( self.new_group_panel, ) + def _get_active_group(self): + group = self.config + for part in self.selector.active_group_path: + group = group.groups[part] + return group + + def _get_active_group_selection(self): + """Selected waveforms that belong to the active group.""" + path = self.selector.active_group_path + if not path: + return [] + group = self._get_active_group() + return [w for w in self.selector.selection if w in group.waveforms] + + def _select_all(self, event=None): + path = self.selector.active_group_path + if not path: + pn.state.notifications.warning("Click a group in the tree first.") + return + all_wf = list(self._get_active_group().waveforms.keys()) + new_sel = list(dict.fromkeys(self.selector.selection + all_wf)) + self.selector.set_selection(new_sel) + + def _deselect_all(self, event=None): + path = self.selector.active_group_path + if not path: + return + grp_wf = set(self._get_active_group().waveforms.keys()) + new_sel = [w for w in self.selector.selection if w not in grp_wf] + self.selector.set_selection(new_sel) + def _show_remove_waveform_modal(self, event): - if not self.selection_group.get_selection(): + selection = self._get_active_group_selection() + if not selection: pn.state.notifications.error("No waveforms selected for removal.") return + path = self.selector.active_group_path self.main_gui.confirm_modal.show( - "Are you sure you want to delete the selected waveform(s) from the " - f"**{self.path[-1]}** group?", + f"Are you sure you want to delete the selected waveform(s) from the " + f"**{path[-1]}** group?", on_confirm=self._remove_waveforms, ) def _remove_waveforms(self): - """Remove all selected waveforms in this SelectionGroup.""" - for waveform_name in self.selection_group.get_selection(): + for waveform_name in self._get_active_group_selection(): self.config.remove_waveform(waveform_name) - with self.selector.is_removing_waveform: # Signal we're removing waveforms - self.selection_group.sync_waveforms() + with self.selector.is_removing_waveform: + new_sel = [ + v for v in self.selector.selection if v in self.config.waveform_map + ] + self.selector._rebuild_tree() + self.selector.set_selection(new_sel) def _show_remove_group_modal(self, event): + path = self.selector.active_group_path + if not path: + return self.main_gui.confirm_modal.show( - f"Are you sure you want to delete the **{self.path[-1]}** group? \n" + f"Are you sure you want to delete the **{path[-1]}** group? \n" "This will also remove all waveforms and subgroups in this group!", on_confirm=self._remove_group, ) def _remove_group(self): - """Remove the group.""" - # Remove from config - self.config.remove_group(self.path) - # Remove from GUI - self.selector.remove_group(self.path) + path = self.selector.active_group_path + self.config.remove_group(path) + self.selector.remove_group(path) def _on_add_waveform_button_click(self, event): - """Show the text input form to add a new waveform.""" self.new_waveform_panel.is_visible(True) def _add_new_waveform(self, event): - """Add the new waveform to CheckButtonGroup and update the - WaveformConfiguration.""" name = self.new_waveform_panel.input.value_input - - # Add empty waveform to YAML + path = self.selector.active_group_path new_waveform = self.config.parse_waveform(f"{name}:\n- {{}}") - self.config.add_waveform(new_waveform, self.path) - self.selection_group.sync_waveforms() + self.config.add_waveform(new_waveform, path) + self.selector._rebuild_tree() self.new_waveform_panel.cancel() def _on_add_group_button_click(self, event): - """Show the text input form to add a new group.""" self.new_group_panel.is_visible(True) def _on_rename_waveform_button_click(self, event): - """Rename a waveform and update the configuration and GUI.""" - selection = self.selection_group.get_selection() + selection = self.selector.selection if len(selection) != 1: pn.state.notifications.error( "You must select only a single waveform to rename." ) return - old_name = selection[0] - self.main_gui.rename_modal.show( - current_name=old_name, on_accept=self._rename_waveform + current_name=selection[0], on_accept=self._rename_waveform ) def _rename_waveform(self, new_name): - """Rename a waveform and update the GUI.""" - old_name = self.selection_group.get_selection()[0] + old_name = self.selector.selection[0] if new_name == old_name: return self.config.rename_waveform(old_name, new_name) - self.selection_group.sync_waveforms() - self.selection_group.set_selection([new_name]) + self.selector._rebuild_tree() + self.selector.set_selection([new_name]) def _add_new_group(self, event): - """Add the new group as a panel accordion and update the YAML.""" name = self.new_group_panel.input.value_input - - # Create new group in configuration - new_group = self.config.add_group(name, self.path) - - # Update UI - self.selection_group.add_group(new_group) + path = self.selector.active_group_path + new_group = self.config.add_group(name, path) + self.selector.active_group_path = path + [new_group.name] + self.selector._rebuild_tree() self.new_group_panel.cancel() def __panel__(self): - """Returns the panel UI element.""" return self.panel diff --git a/waveform_editor/gui/selector/selection_group.py b/waveform_editor/gui/selector/selection_group.py deleted file mode 100644 index 7332101f..00000000 --- a/waveform_editor/gui/selector/selection_group.py +++ /dev/null @@ -1,139 +0,0 @@ -from typing import TYPE_CHECKING - -import panel as pn -import param -from panel.viewable import Viewer - -from waveform_editor.configuration import WaveformConfiguration -from waveform_editor.group import WaveformGroup -from waveform_editor.gui.selector.options_button_row import OptionsButtonRow - -if TYPE_CHECKING: - from waveform_editor.gui.selector.selector import WaveformSelector - - -class SelectionGroup(Viewer): - """User Interface for selecting waveforms in a single waveform group""" - - visible = param.Boolean(True, allow_refs=True) - - def __init__( - self, - selector: "WaveformSelector", - group: WaveformConfiguration | WaveformGroup, - path: list[str], - ) -> None: - name = getattr(group, "name", "") - super().__init__(name=name) - - self.selector = selector - self.group = group - self.path = path - self.is_root = not path - - # Waveform selector - if self.is_root: # We have no waveforms - self.waveform_selector = None - self.has_waveforms = False - else: - self.waveform_selector = pn.widgets.CheckButtonGroup( - value=[], - options=list(group.waveforms.keys()), - button_type="primary", - button_style="outline", - sizing_mode="stretch_width", - orientation="vertical", - stylesheets=["button {text-align: left!important;}"], - ) - self.waveform_selector.param.watch(self.selector.on_select, "value") - self.selector.param.watch(self.sync_waveforms, "selection") - # Reactive expression which is True if there are waveforms in this group: - self.has_waveforms = self.waveform_selector.param.options.rx.bool() - - self.button_row = OptionsButtonRow(selector.main_gui, self) - # Sub-groups - self.selection_groups = { - group.name: SelectionGroup(self.selector, group, self.path + [group.name]) - for group in self.group.groups.values() - } - self.accordion = pn.Accordion( - *self.selection_groups.values(), - sizing_mode="stretch_width", - ) - - # Create container - if self.is_root: - elems = [self.button_row, self.accordion] - else: - elems = [self.button_row, self.waveform_selector, self.accordion] - self.panel = pn.Column( - *elems, sizing_mode="stretch_both", name=name, visible=self.param.visible - ) - - def sync_waveforms(self, event=None): - """Update waveform selector options and selected values""" - new_waveforms = list(self.group.waveforms.keys()) - self.waveform_selector.options = new_waveforms - self.waveform_selector.value = [ - val for val in new_waveforms if val in self.selector.selection - ] - - def add_group(self, group: WaveformGroup) -> None: - """Add a sub-group to this UI element.""" - group_ui = SelectionGroup(self.selector, group, self.path + [group.name]) - self.selection_groups[group.name] = group_ui - self.accordion.append(group_ui) - # Auto-expand new group. N.B. active list must be replaced to take effect: - new_index = len(self.accordion.objects) - 1 - self.accordion.active = self.accordion.active + [new_index] - - def remove_group(self, group: str) -> None: - """Remove a group from this UI element""" - # Find index of the group UI: - for index, group_ui in enumerate(self.selection_groups.values()): # noqa: B007 - if group_ui.name == group: - break - # Calculate which panes should be active after removing this one - new_active = [ - num if num < index else num - 1 - for num in self.accordion.active - if num != index - ] - # Remove from accordion and update active panes - self.selection_groups.pop(group) - self.accordion.remove(self.accordion[index]) - self.accordion.active = new_active - - def get_selection(self, recursive=False) -> list[str]: - """Get a list of the selected waveforms. - - Args: - recursive: Include selection in sub-groups as well. - """ - selection = [] if self.is_root else self.waveform_selector.value.copy() - if recursive: - for group_ui in self.selection_groups.values(): - selection.extend(group_ui.get_selection(recursive)) - return selection - - def set_selection(self, selection: list[str]) -> None: - """Select a list of waveforms. - - Args: - selection: List of waveform names to select. - """ - if self.waveform_selector is not None: - self.waveform_selector.value = [ - s for s in selection if s in self.waveform_selector.options - ] - - def deselect_all(self, event=None) -> None: - """Deselect all waveforms.""" - self.waveform_selector.value = [] - - def select_all(self, event=None) -> None: - """Select all waveforms.""" - self.waveform_selector.value = self.waveform_selector.options.copy() - - def __panel__(self): - return self.panel diff --git a/waveform_editor/gui/selector/selector.py b/waveform_editor/gui/selector/selector.py index e608ec43..3b16bd21 100644 --- a/waveform_editor/gui/selector/selector.py +++ b/waveform_editor/gui/selector/selector.py @@ -1,8 +1,8 @@ import panel as pn import param from panel.viewable import Viewer +from panel_jstree import Tree -from waveform_editor.gui.selector.selection_group import SelectionGroup from waveform_editor.util import State @@ -18,22 +18,32 @@ class WaveformSelector(Viewer): doc="Allow selecting multiple waveforms", allow_refs=True, ) + active_group_path = param.List( + doc="Path to the currently active group (list of group names)", + ) def __init__(self, main_gui): super().__init__() - self.main_gui = main_gui # options_button_row needs the modals from main_gui + self.main_gui = main_gui self.config = main_gui.config self.is_removing_waveform = State() self._ignore_selection_change = State() - # UI self.filter_input = pn.widgets.TextInput( placeholder="Filter waveforms...", sizing_mode="stretch_width" ) - self.selection_group = SelectionGroup(self, self.config, []) - self.selection_group.visible = self.filter_input.param.value_input.rx.not_() - # Flat selection group for filtered results + self.tree = Tree( + data=self._build_tree_data(), + checkbox=True, + select_multiple=True, + cascade=False, + show_icons=False, + sizing_mode="stretch_width", + ) + self.tree.param.watch(self._on_tree_value_change, "value") + + # Flat view for filter results self.filtered_results = pn.widgets.CheckButtonGroup( button_type="primary", button_style="outline", @@ -51,7 +61,6 @@ def __init__(self, main_gui): visible=self.filter_input.param.value_input.rx.bool(), on_click=lambda event: setattr(self.filter_input, "value_input", ""), ) - filter_empty_text = pn.pane.Markdown( "_No waveforms found_", visible=pn.bind( @@ -63,93 +72,143 @@ def __init__(self, main_gui): self.filtered_results.param.watch(self.on_select, "value") self.filter_input.param.watch(self._update_filter_view, "value_input") + from waveform_editor.gui.selector.options_button_row import OptionsButtonRow + + self.button_row = OptionsButtonRow(main_gui, self) + self.panel = pn.Column( pn.Row(self.filter_input, clear_filter_button), - self.selection_group, + self.button_row, + pn.Column(self.tree, visible=self.filter_input.param.value_input.rx.not_()), self.filtered_results, filter_empty_text, visible=self.param.visible, ) - def _update_filter_view(self, event): - """Update the appropriate view based on whether a filter is active.""" - filter_text = self.filter_input.value_input.lower() - if not filter_text: + def _build_tree_data(self): + """Build jstree data structure from config.""" + + def build_group_node(group, parent_path): + path = parent_path + [group.name] + node_id = "grp:" + "/".join(path) + children = [ + {"id": f"wf:{wf_name}", "text": wf_name} for wf_name in group.waveforms + ] + children += [build_group_node(sg, path) for sg in group.groups.values()] + return { + "id": node_id, + "text": group.name, + "children": children, + "state": {"opened": True}, + } + + return [build_group_node(g, []) for g in self.config.groups.values()] + + def _on_tree_value_change(self, event): + """Handle changes to tree selection.""" + if self._ignore_selection_change: return + new_value = event.new or [] + old_value = event.old or [] + + # Update active group when a group node is newly selected + new_grp_ids = [ + v for v in new_value if v.startswith("grp:") and v not in old_value + ] + if new_grp_ids: + self.active_group_path = new_grp_ids[-1][4:].split("/") # strip "grp:" + + wf_names = [v[3:] for v in new_value if v.startswith("wf:")] + + if not self.multiselect: + old_wf_names = [v[3:] for v in old_value if v.startswith("wf:")] + newly_added = [n for n in wf_names if n not in old_wf_names] + new_sel = [newly_added[-1]] if newly_added else wf_names[:1] + self.set_selection(new_sel) + else: + self.set_selection(wf_names) + + def _rebuild_tree(self): + """Rebuild tree from config, preserving current selection.""" with self._ignore_selection_change: - filtered = [w for w in self.config.waveform_map if filter_text in w.lower()] - self.filtered_results.options = sorted(filtered) - self._sync_filtered_view() + self.tree.data = self._build_tree_data() + self._sync_tree_selection() - def refresh(self): - """Discard the current UI state and re-build from self.config. + def _sync_tree_selection(self): + """Sync tree.value to reflect self.selection. - Should be called after loading a new configuration. + Must be called within _ignore_selection_change. """ + existing_wf_ids = {f"wf:{wf}" for wf in self.config.waveform_map} + current_grp = [v for v in (self.tree.value or []) if v.startswith("grp:")] + valid_wf_ids = [ + f"wf:{n}" for n in self.selection if f"wf:{n}" in existing_wf_ids + ] + self.tree.value = current_grp + valid_wf_ids + + def refresh(self): + """Discard current UI state and re-build from self.config.""" self.filter_input.value = "" - self.selection_group = SelectionGroup(self, self.config, []) - self.selection_group.visible = self.filter_input.param.value_input.rx.not_() - self.panel[1] = self.selection_group self.selection = [] + self.active_group_path = [] + with self._ignore_selection_change: + self.tree.data = self._build_tree_data() + self.tree.value = [] @param.depends("multiselect", watch=True) def _multiselect_changed(self): - """Update selection when multiselect True -> False: keep at most one item.""" if not self.multiselect: self.set_selection(self.selection[:1]) @param.depends("selection", watch=True) def _sync_filtered_view(self): - """Syncs the main selection list to the filtered view's value.""" self.filtered_results.value = [ s for s in self.selection if s in self.filtered_results.options ] + def _update_filter_view(self, event): + filter_text = self.filter_input.value_input.lower() + if not filter_text: + return + with self._ignore_selection_change: + filtered = [w for w in self.config.waveform_map if filter_text in w.lower()] + self.filtered_results.options = sorted(filtered) + self._sync_filtered_view() + def set_selection(self, new_selection: list[str]): - """Update the active selection.""" - with self._ignore_selection_change: # Don't listen to the widget callbacks - if not self.multiselect: - assert len(new_selection) <= 1 + """Update the active selection and sync the tree.""" + if not self.multiselect: + assert len(new_selection) <= 1 + with self._ignore_selection_change: self.selection = new_selection + self._sync_tree_selection() def remove_group(self, path: list[str]): - """Remove the UI element for the group at the specified path.""" - parent_group, group_ui = None, self.selection_group - for part in path: - parent_group, group_ui = group_ui, group_ui.selection_groups[part] - - selection_to_delete = group_ui.get_selection(True) - parent_group.remove_group(path[-1]) - del group_ui - if selection_to_delete: - new_sel = [val for val in self.selection if val not in selection_to_delete] - with self.is_removing_waveform: # Flag that we're removing waveforms + """Remove the UI element for the group at path and update selection.""" + new_sel = [v for v in self.selection if v in self.config.waveform_map] + self._rebuild_tree() + if new_sel != self.selection: + with self.is_removing_waveform: self.set_selection(new_sel) + if self.active_group_path[: len(path)] == path: + self.active_group_path = [] def on_select(self, event): - """Handles the selection and deselection of waveforms in the check button - groups. - - Args: - event: list containing the new selection. - """ + """Handle selection in the filtered results view.""" if self._ignore_selection_change: return - if self.multiselect: if self.filter_input.value_input: - preserved_selection = [ + preserved = [ s for s in self.selection if s not in self.filtered_results.options ] - self.set_selection(preserved_selection + event.new) - else: # Just update all selected - self.selection = self.selection_group.get_selection(True) - else: # Check which one is new and deselect anything else + self.set_selection(preserved + event.new) + else: + self.set_selection(event.new) + else: new_selection = [name for name in event.new if name not in event.old] - assert len(new_selection) <= 1 - self.set_selection(new_selection) + self.set_selection(new_selection[:1]) def __panel__(self): - """Returns the waveform selector UI component.""" return self.panel