diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py index 7327d53..b843235 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/__init__.py @@ -8,6 +8,7 @@ from .drop_down_selection import DropDownSelection from .figure_container import FigureContainer from .list_box import ScrollableListBox +from .searchable_list_box import SearchableListBox from .check_box_selection import CheckboxSelection as MultiBoxSelection from .path_selector import PathSelector from .radio_selection import RadioSelection @@ -28,6 +29,7 @@ "DropDownSelection", "FigureContainer", "ScrollableListBox", + "SearchableListBox", "PathSelector", "RadioSelection", "ValidatedEntryBox", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py new file mode 100644 index 0000000..cec23f5 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py @@ -0,0 +1,291 @@ +"""Scrollable listbox with a search filter that keeps selections while items are filtered out.""" + +from __future__ import annotations + +import os +import re +import sys +import tkinter as tk +from tkinter import ttk +from typing import Callable, Iterable, List, Literal, Optional + +from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget +from python_toolkit.bhom_tkinter.widgets.list_box import ScrollableListBox + +Scorer = Callable[[str, str], Optional[float]] # (query, item) -> score (higher is better), or None for no match + +_NON_ALNUM = re.compile(r"[\W_]+") +_BHOM_SEARCH = None # (Compute, Scorer) from the BHoM Search_Engine, loaded on first use + + +def _load_bhom_search(): + """Load the BHoM Search_Engine through pythonnet the first time it is needed.""" + global _BHOM_SEARCH # pylint: disable=global-statement + if _BHOM_SEARCH is None: + import clr # pylint: disable=import-outside-toplevel,import-error + + sys.path.append(os.path.expandvars("%ProgramData%\\BHoM\\Assemblies")) + clr.AddReference("Search_Engine") + clr.AddReference("Search_oM") + from BH.Engine.Search import Compute # pylint: disable=import-outside-toplevel,import-error + from BH.oM.Search import Scorer as BHoMScorer # pylint: disable=import-outside-toplevel,import-error + + _BHOM_SEARCH = (Compute, BHoMScorer) + return _BHOM_SEARCH + + +def default_scorer(query: str, text: str, min_score: float = 75) -> Optional[float]: + """Score how well `query` matches `text` using the BHoM Search_Engine fuzzy match, or None if it is a weak match. + + Uses BH.Engine.Search.Compute.MatchScore with the WeightedRatio scorer. Case and punctuation are ignored. + + Args: + query: The text typed into the filter. + text: The item being tested. + min_score: Similarity (0-100) an item needs to be shown. To use a different value, bind it with + `functools.partial(default_scorer, min_score=...)` and pass that as `scorer`. + """ + compute, bhom_scorer = _load_bhom_search() + query = _NON_ALNUM.sub(" ", query.lower()).strip() + text = _NON_ALNUM.sub(" ", text.lower()).strip() + score = compute.MatchScore(query, text, bhom_scorer.WeightedRatio) + return float(score) if score >= min_score else None + + +class SearchableListBox(BHoMBaseWidget): + """A listbox with a filter entry beneath it, for picking from long lists. + + The filter sits in a row under the list. + Typing in the filter narrows the list to fuzzy matches (typo-tolerant, case-insensitive), best first. + Matching uses `default_scorer` (BHoM Search_Engine); pass `scorer` to use another algorithm. + By default the selection is kept for items hidden by the filter, so several searches can build up one + selection; `get()` always returns the full selection in item order. + """ + + def __init__( + self, + parent: ttk.Frame, + items=None, + selectmode=tk.MULTIPLE, + height: int = 8, + width: Optional[int] = None, + show_selection_controls: bool = False, + keep_hidden_selection: bool = True, + show_summary: Optional[bool] = None, + filter_label: str = "Filter", + scorer: Scorer = default_scorer, + **kwargs): + """ + Args: + parent (ttk.Frame): The parent widget. + items (list, optional): Items to choose from. + selectmode (str): tk.MULTIPLE / tk.EXTENDED for multi-select, tk.SINGLE / tk.BROWSE for one item. + height (int): Visible rows in the list. + width (int, optional): Width of the list in characters. If None, it expands to fill the space. + show_selection_controls (bool): Show Select All / Deselect All buttons. They act on the items + currently shown by the filter. + keep_hidden_selection (bool): Keep items selected while the filter hides them. If False, the + selection is limited to the visible items each time the filter changes. + show_summary (bool, optional): Show a caption listing the selected items. Defaults to True for + multi-select lists. + filter_label (str): Caption shown above the filter entry. + scorer (Callable): Called as scorer(query, item); returns a score (higher ranks first) or None to + hide the item. Defaults to the BHoM Search_Engine fuzzy match. + **kwargs: Additional BHoMBaseWidget / Frame options (id, item_title, helper_text, on_change, ...). + """ + super().__init__(parent, **kwargs) + + self.scorer = scorer + self._all_items: List[str] = [str(item) for item in (items or [])] + self._shown: List[str] = list(self._all_items) + self._multi = selectmode in (tk.MULTIPLE, tk.EXTENDED, "multiple", "extended") + self._keep_hidden = bool(keep_hidden_selection) + self._selected: set[str] = set() + self._show_summary = self._multi if show_summary is None else bool(show_summary) + + self.filter_var = tk.StringVar() + + # List + self.list_box = ScrollableListBox( + self.content_frame, + items=list(self._all_items), + selectmode=selectmode, + height=height, + width=width, + on_change=lambda _value: self._on_list_change(), + build_options=PackingOptions(side="top", fill=tk.BOTH, expand=True), + ) + self.list_box.build() + + # Filter entry + filter_frame = ttk.Frame(self.content_frame) + filter_frame.pack(side="top", fill=tk.X, pady=(6, 0)) + ttk.Label(filter_frame, text=filter_label, style="Caption.TLabel").pack(side="top", anchor=self._pack_anchor) + self.filter_entry = ttk.Entry(filter_frame, textvariable=self.filter_var) + self.filter_entry.pack(side="top", fill=tk.X, pady=(2, 0)) + self.filter_var.trace_add("write", lambda *_: self._apply_filter()) + + if show_selection_controls and self._multi: + controls = ttk.Frame(self.content_frame) + controls.pack(side="top", fill=tk.X, pady=(6, 0)) + controls.columnconfigure(0, weight=1) + controls.columnconfigure(1, weight=1) + ttk.Button(controls, text="Select All", command=self.select_all).grid( + row=0, column=0, sticky="ew", padx=(0, 4)) + ttk.Button(controls, text="Deselect All", command=self.deselect_all).grid( + row=0, column=1, sticky="ew", padx=(4, 0)) + + self.summary_label: Optional[ttk.Label] = None + if self._show_summary: + self.summary_label = ttk.Label(self.content_frame, text="", style="Caption.TLabel", justify="left") + self.summary_label.pack(side="top", anchor=self._pack_anchor, pady=(4, 0)) + self.content_frame.bind( + "", lambda e: self.summary_label.configure(wraplength=max(e.width - 4, 100)), add="+") + self._update_summary() + + # ------------------------------------------------------------------ internals + + def _matching(self, query: str) -> List[str]: + query = query.strip().lower() + if not query: + return list(self._all_items) + scored = [(self.scorer(query, item), index, item) for index, item in enumerate(self._all_items)] + return [item for score, index, item in sorted((s for s in scored if s[0] is not None), + key=lambda s: (-s[0], s[1]))] + + def _apply_filter(self) -> None: + shown = self._matching(self.filter_var.get()) + self._shown = shown + self.list_box.set_options(shown) + if not self._keep_hidden: + self._selected &= set(shown) + self.list_box.set_selections([item for item in shown if item in self._selected]) + self._update_summary() + if not self._keep_hidden: + self._fire_on_change(self.get()) + + def _on_list_change(self) -> None: + visible = set(self.get_visible()) + chosen = set(self.list_box.get_selection()) + if self._multi: + self._selected = (self._selected - visible) | chosen + else: + # Single select: a visible choice replaces the previous one; clicking away keeps it. + if chosen: + self._selected = chosen + self._update_summary() + self._fire_on_change(self.get()) + + def _update_summary(self) -> None: + if self.summary_label is None: + return + selected = self.get() + visible = set(self.get_visible()) + hidden = [item for item in selected if item not in visible] + if not selected: + text = "None selected." + else: + text = f"{len(selected)} selected: {', '.join(selected)}" + if hidden: + text += f" ({len(hidden)} hidden by the filter)" + self.summary_label.configure(text=text) + + # ------------------------------------------------------------------ public API + + def get(self) -> List[str]: + """Return all selected items (including any hidden by the filter), in item order.""" + return [item for item in self._all_items if item in self._selected] + + def get_selection(self) -> List[str]: + """Alias of get() for parity with ScrollableListBox.""" + return self.get() + + def set(self, value) -> None: + """Set the selection. Accepts one item, a list of items, or None to clear.""" + if value is None: + wanted: Iterable[str] = [] + elif isinstance(value, (list, tuple, set)): + wanted = [str(v) for v in value] + else: + wanted = [str(value)] + wanted = [item for item in self._all_items if item in set(wanted)] + if not self._multi: + wanted = wanted[:1] + self._selected = set(wanted) + self.list_box.set_selections([item for item in self.get_visible() if item in self._selected]) + self._update_summary() + self._fire_on_change(self.get()) + + def get_options(self) -> List[str]: + """Return every item, whatever the filter shows.""" + return list(self._all_items) + + def get_visible(self) -> List[str]: + """Return the items currently shown by the filter.""" + return list(self._shown) + + def set_options(self, items) -> None: + """Replace the items. Selected items that still exist stay selected.""" + self._all_items = [str(item) for item in (items or [])] + self._selected &= set(self._all_items) + self._apply_filter() + self._fire_on_change(self.get()) + + def set_filter(self, text: str) -> None: + """Set the filter text (as if typed).""" + self.filter_var.set(text or "") + + def get_filter(self) -> str: + return self.filter_var.get() + + def clear_filter(self) -> None: + self.set_filter("") + + def select_all(self) -> None: + """Select every item currently shown by the filter.""" + if not self._multi: + return + self._selected |= set(self.get_visible()) + self.list_box.select_all() + self._update_summary() + self._fire_on_change(self.get()) + + def deselect_all(self) -> None: + """Deselect every item currently shown by the filter.""" + self._selected -= set(self.get_visible()) + self.list_box.deselect_all() + self._update_summary() + self._fire_on_change(self.get()) + + def clear(self) -> None: + """Deselect everything, including items hidden by the filter.""" + self._selected = set() + self.list_box.deselect_all() + self._update_summary() + self._fire_on_change(self.get()) + + def validate(self) -> tuple[bool, Optional[str], Optional[Literal['info', 'warning', 'error']]]: + """All states are valid unless a custom validation says otherwise.""" + return self.apply_validation((True, None, None)) + + +if __name__ == "__main__": + + from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow + + root = BHoMBaseWindow(title="SearchableListBox demo", theme_mode="light") + widget = SearchableListBox( + root.content_frame, + items=[f"{city}_{kind}.epw" for city in ["London", "Birmingham", "Manchester", "Glasgow", "Belfast", "Cardiff"] + for kind in ["TRY", "DSY1", "DSY2", "DSY3"]], + height=8, + width=40, + show_selection_controls=True, + item_title="Weather files", + helper_text="Type to filter. Selections are kept while filtering.", + on_change=lambda value: print("selected:", value), + build_options=PackingOptions(padx=10, pady=10, fill="both", expand=True), + ) + widget.build() + root.mainloop() diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py index 932a18f..9379fe3 100644 --- a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -21,6 +21,7 @@ PathSelector, RadioSelection, ScrollableListBox, + SearchableListBox, ValidatedEntryBox, ) from python_toolkit.bhom_tkinter.windows import ( @@ -139,6 +140,17 @@ def run_widget_gallery(auto_close_ms: int | None = None) -> None: build_options=PackingOptions(fill="x", pady=6), ).build() + SearchableListBox( + parent, + item_title="SearchableListBox", + helper_text="Type to filter; selections are kept while filtering", + items=[f"{city}_TRY.epw" for city in ["London", "Birmingham", "Manchester", "Glasgow", "Belfast", "Cardiff"]], + height=4, + show_selection_controls=True, + alignment=alignments[2], + build_options=PackingOptions(fill="x", pady=6), + ).build() + PathSelector( parent, item_title="PathSelector", @@ -348,3 +360,104 @@ def test_modal_window_is_themed_toplevel(): modal.close() host.destroy_root() + +def test_searchable_list_box(): + """Selections survive filtering; select/deselect all act on visible items; single mode keeps one item.""" + root = BHoMBaseWindow(title="SearchableListBox test") + changes = [] + items = ["London_TRY.epw", "London_DSY1.epw", "Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + box = SearchableListBox(root.content_frame, items=items, show_selection_controls=True, + on_change=lambda value: changes.append(list(value))) + box.build() + + # Build a selection across two searches + box.set_filter("gib") + assert box.get_visible() == ["Gibraltar_TRY.epw"] + box.list_box.set_selections(["Gibraltar_TRY.epw"]) + box._on_list_change() + box.set_filter("belfast") + box.list_box.set_selections(["Belfast_TRY.epw"]) + box._on_list_change() + assert box.get() == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + assert changes[-1] == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + # Clearing the filter shows the hidden selections as selected + box.clear_filter() + assert box.get_visible() == items + assert box.list_box.get_selection() == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + # Select/deselect all only affect visible items + box.set_filter("london") + box.select_all() + assert box.get() == items + box.deselect_all() + assert box.get() == ["Gibraltar_TRY.epw", "Belfast_TRY.epw"] + + # set / clear / set_options + box.set(["London_TRY.epw"]) + assert box.get() == ["London_TRY.epw"] + box.clear() + assert box.get() == [] + box.set(items) + box.set_options(["London_TRY.epw", "Cardiff_TRY.epw"]) + assert box.get() == ["London_TRY.epw"] + + # keep_hidden_selection=False drops selections the filter hides + strict = SearchableListBox(root.content_frame, items=items, keep_hidden_selection=False) + strict.build() + strict.set(["London_TRY.epw", "Belfast_TRY.epw"]) + strict.set_filter("london") + assert strict.get() == ["London_TRY.epw"] + + # Single select keeps at most one item, and a hidden choice survives filtering + single = SearchableListBox(root.content_frame, items=items, selectmode=tk.SINGLE) + single.build() + single.set(items) + assert single.get() == ["London_TRY.epw"] + single.set_filter("gib") + assert single.get() == ["London_TRY.epw"] + single.list_box.set_selections(["Gibraltar_TRY.epw"]) + single._on_list_change() + assert single.get() == ["Gibraltar_TRY.epw"] + + root.destroy_root() + + +def test_searchable_list_box_fuzzy_matching(): + """Default scorer: substrings and typos match, best first; min_score and a custom scorer can be supplied.""" + from functools import partial + from python_toolkit.bhom_tkinter.widgets.searchable_list_box import default_scorer + + assert default_scorer("lon", "London_TRY.epw") is not None + assert default_scorer("birmigham", "Birmingham_TRY.epw") is not None # typo + assert default_scorer("xyzq", "London_TRY.epw") is None + assert default_scorer("london", "LOS-ANGELES-DOWNTOWN.epw") is None + assert default_scorer("birmigham", "Birmingham_TRY.epw", min_score=95) is None # stricter threshold + + root = BHoMBaseWindow(title="SearchableListBox fuzzy test") + items = ["Country_Club.epw", "London_TRY.epw", "Birmingham_TRY.epw", "Gibraltar_TRY.epw"] + + fuzzy = SearchableListBox(root.content_frame, items=items) + fuzzy.build() + fuzzy.set_filter("birmigham") + assert fuzzy.get_visible() == ["Birmingham_TRY.epw"] + fuzzy.set_filter("try") + assert set(fuzzy.get_visible()) == set(items) # "try" is a substring of every item; ties keep original order + fuzzy.set_filter("xyzq") + assert fuzzy.get_visible() == [] + + # min_score is an argument of the scorer + strict = SearchableListBox(root.content_frame, items=items, scorer=partial(default_scorer, min_score=95)) + strict.build() + strict.set_filter("birmigham") + assert strict.get_visible() == [] + + # A custom scorer replaces the default + only_london = SearchableListBox( + root.content_frame, items=items, scorer=lambda query, item: 1.0 if item.startswith("London") else None) + only_london.build() + only_london.set_filter("anything") + assert only_london.get_visible() == ["London_TRY.epw"] + + root.destroy_root()