From ae0facc51746ab4bb2d9cd602852d03582b31050 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 30 Sep 2026 14:54:19 +0100 Subject: [PATCH 1/6] first pass at searchable list box, fuzzy match --- .../bhom_tkinter/widgets/__init__.py | 2 + .../widgets/searchable_list_box.py | 307 ++++++++++++++++++ .../Python/tests/test_bhom_tkinter_ui.py | 110 +++++++ 3 files changed, 419 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py 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 7327d538..b8432351 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 00000000..cb33b322 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/searchable_list_box.py @@ -0,0 +1,307 @@ +"""Scrollable listbox with a search filter that keeps selections while items are filtered out.""" + +from __future__ import annotations + +import difflib +import re +import tkinter as tk +from tkinter import ttk +from typing import 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.button import Button +from python_toolkit.bhom_tkinter.widgets.list_box import ScrollableListBox + +_WORD_SPLIT = re.compile(r"[\s_\-\.\(\)\[\],/\\]+") +_TYPO_MIN_LENGTH = 4 # query words shorter than this must match exactly or as a subsequence +_TYPO_MIN_RATIO = 0.8 # difflib similarity needed to accept a misspelt word +_TYPO_MAX_LENGTH_DIFF = 2 # a misspelling may be at most this many letters longer/shorter than the word + + +def _subsequence_gaps(token: str, text: str) -> Optional[int]: + """Characters skipped to find token's letters in order in text, or None if they are not all there.""" + position, gaps = 0, 0 + for char in token: + found = text.find(char, position) + if found < 0: + return None + gaps += found - position + position = found + 1 + return gaps + + +def fuzzy_score(query: str, text: str) -> Optional[float]: + """Score how well `query` matches `text` (higher is better), or None if it does not match. + + Every whitespace-separated word of the query must match the text, in one of these ways (best first): + - contained in the text, with a bonus when it starts a word ("lon" in "London_TRY") + - its letters appear in order with only small gaps ("ldn" in "London"), scored down by the gaps + - a close misspelling of one of the text's words ("birmigham" for "Birmingham"), words of 4+ letters + An empty query matches everything with score 0. + """ + text_l = text.lower() + tokens = [t for t in query.lower().split() if t] + if not tokens: + return 0.0 + words = [w for w in _WORD_SPLIT.split(text_l) if w] + score = 0.0 + for token in tokens: + index = text_l.find(token) + if index >= 0: + starts_word = index == 0 or not text_l[index - 1].isalnum() + score += 100 + (20 if starts_word else 0) - min(index, 20) * 0.5 + continue + gaps = _subsequence_gaps(token, text_l) + # Letters in order only count when they are close together, otherwise short words match almost anything. + if gaps is not None and gaps <= max(3, len(token)): + score += max(60 - gaps * 4, 20) + continue + similar = [w for w in words if abs(len(w) - len(token)) <= _TYPO_MAX_LENGTH_DIFF] + if len(token) >= _TYPO_MIN_LENGTH and similar: + ratio = max(difflib.SequenceMatcher(None, token, w).ratio() for w in similar) + if ratio >= _TYPO_MIN_RATIO: + score += ratio * 50 + continue + return None + return score + + +class SearchableListBox(BHoMBaseWidget): + """A listbox with a filter entry above it, for picking from long lists. + + Typing in the filter narrows the list. With match_mode="fuzzy" (default) each typed word may match as + a substring, as letters in order, or as a close misspelling, and the best matches are listed first + (see `fuzzy_score`). With match_mode="contains" the list shows items containing the text, in order. + Matching is case-insensitive. 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 = "Search", + match_mode: Literal["fuzzy", "contains"] = "fuzzy", + **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. + match_mode (str): "fuzzy" (typo-tolerant, best matches first) or "contains" (plain substring). + **kwargs: Additional BHoMBaseWidget / Frame options (id, item_title, helper_text, on_change, ...). + """ + super().__init__(parent, **kwargs) + + if match_mode not in ("fuzzy", "contains"): + raise ValueError(f"match_mode must be 'fuzzy' or 'contains', not {match_mode!r}") + self.match_mode = match_mode + self._all_items: List[str] = [str(item) for item in (items or [])] + 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) + + # Filter entry + filter_frame = ttk.Frame(self.content_frame) + filter_frame.pack(side="top", fill=tk.X) + ttk.Label(filter_frame, text=filter_label, style="Caption.TLabel").pack(side="top", anchor=self._pack_anchor) + self.filter_var = tk.StringVar() + self.filter_entry = ttk.Entry(filter_frame, textvariable=self.filter_var) + self.filter_entry.pack(side="top", fill=tk.X, pady=(2, 6)) + self.filter_var.trace_add("write", lambda *_: self._apply_filter()) + + # 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() + + 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) + select_widget = Button(controls, text="Select All", command=self.select_all) + select_widget.grid(row=0, column=0, sticky="ew", padx=(0, 4)) + deselect_widget = Button(controls, text="Deselect All", command=self.deselect_all) + deselect_widget.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 _visible(self) -> List[str]: + return self.list_box.get_options() + + def _matching(self, query: str) -> List[str]: + query = query.strip().lower() + if not query: + return list(self._all_items) + if self.match_mode == "contains": + return [item for item in self._all_items if query in item.lower()] + scored = [(fuzzy_score(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.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._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() + hidden = [item for item in selected if item not in set(self._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._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 self._visible() + + 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._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._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 932a18fd..c0d5cc0f 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,101 @@ 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(): + """Fuzzy mode: substrings, close letter sequences and typos match, best first; contains mode is plain.""" + from python_toolkit.bhom_tkinter.widgets.searchable_list_box import fuzzy_score + + assert fuzzy_score("", "anything") == 0.0 + assert fuzzy_score("lon", "London_TRY.epw") is not None + assert fuzzy_score("ldn try", "London_TRY.epw") is not None # letters in order, per word + assert fuzzy_score("birmigham", "Birmingham_TRY.epw") is not None # typo + assert fuzzy_score("xyzq", "London_TRY.epw") is None + assert fuzzy_score("try", "NZL_Taranaki_New_Plymouth.epw") is None # letters too far apart + assert fuzzy_score("london", "LOS-ANGELES-DOWNTOWN.epw") is None # not a close misspelling + # Whole-word start beats a match inside a word + assert fuzzy_score("try", "London_TRY.epw") > fuzzy_score("try", "Country.epw") + + 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") + # Word-start matches rank above the match inside "Country" (earlier matches rank slightly higher) + assert set(fuzzy.get_visible()[:3]) == {"London_TRY.epw", "Birmingham_TRY.epw", "Gibraltar_TRY.epw"} + assert fuzzy.get_visible()[-1] == "Country_Club.epw" + + contains = SearchableListBox(root.content_frame, items=items, match_mode="contains") + contains.build() + contains.set_filter("birmigham") + assert contains.get_visible() == [] + contains.set_filter("try") + assert contains.get_visible() == items # "Country" contains "try"; original order kept + + root.destroy_root() From 5418b1e6ad40fb9c011f090c02decf8d6eff5c01 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 21 Oct 2026 16:45:03 +0100 Subject: [PATCH 2/6] bit of a clean up --- .../widgets/searchable_list_box.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) 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 index cb33b322..32c7a5a6 100644 --- 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 @@ -68,8 +68,9 @@ def fuzzy_score(query: str, text: str) -> Optional[float]: class SearchableListBox(BHoMBaseWidget): - """A listbox with a filter entry above it, for picking from long lists. + """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. With match_mode="fuzzy" (default) each typed word may match as a substring, as letters in order, or as a close misspelling, and the best matches are listed first (see `fuzzy_score`). With match_mode="contains" the list shows items containing the text, in order. @@ -87,7 +88,7 @@ def __init__( show_selection_controls: bool = False, keep_hidden_selection: bool = True, show_summary: Optional[bool] = None, - filter_label: str = "Search", + filter_label: str = "Filter", match_mode: Literal["fuzzy", "contains"] = "fuzzy", **kwargs): """ @@ -118,14 +119,7 @@ def __init__( self._selected: set[str] = set() self._show_summary = self._multi if show_summary is None else bool(show_summary) - # Filter entry - filter_frame = ttk.Frame(self.content_frame) - filter_frame.pack(side="top", fill=tk.X) - ttk.Label(filter_frame, text=filter_label, style="Caption.TLabel").pack(side="top", anchor=self._pack_anchor) self.filter_var = tk.StringVar() - self.filter_entry = ttk.Entry(filter_frame, textvariable=self.filter_var) - self.filter_entry.pack(side="top", fill=tk.X, pady=(2, 6)) - self.filter_var.trace_add("write", lambda *_: self._apply_filter()) # List self.list_box = ScrollableListBox( @@ -139,6 +133,14 @@ def __init__( ) 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)) From 5fc48545ed53db4d8bf9c75233de919c5af368f0 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 26 Oct 2026 10:50:54 +0000 Subject: [PATCH 3/6] clear up the get_visable method --- .../widgets/searchable_list_box.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index 32c7a5a6..27c51200 100644 --- 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 @@ -114,6 +114,7 @@ def __init__( raise ValueError(f"match_mode must be 'fuzzy' or 'contains', not {match_mode!r}") self.match_mode = match_mode 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() @@ -161,9 +162,6 @@ def __init__( # ------------------------------------------------------------------ internals - def _visible(self) -> List[str]: - return self.list_box.get_options() - def _matching(self, query: str) -> List[str]: query = query.strip().lower() if not query: @@ -176,6 +174,7 @@ def _matching(self, query: str) -> List[str]: 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) @@ -185,7 +184,7 @@ def _apply_filter(self) -> None: self._fire_on_change(self.get()) def _on_list_change(self) -> None: - visible = set(self._visible()) + visible = set(self.get_visible()) chosen = set(self.list_box.get_selection()) if self._multi: self._selected = (self._selected - visible) | chosen @@ -200,7 +199,8 @@ def _update_summary(self) -> None: if self.summary_label is None: return selected = self.get() - hidden = [item for item in selected if item not in set(self._visible())] + visible = set(self.get_visible()) + hidden = [item for item in selected if item not in visible] if not selected: text = "None selected." else: @@ -231,7 +231,7 @@ def set(self, value) -> None: if not self._multi: wanted = wanted[:1] self._selected = set(wanted) - self.list_box.set_selections([item for item in self._visible() if item in self._selected]) + 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()) @@ -241,7 +241,7 @@ def get_options(self) -> List[str]: def get_visible(self) -> List[str]: """Return the items currently shown by the filter.""" - return self._visible() + return list(self._shown) def set_options(self, items) -> None: """Replace the items. Selected items that still exist stay selected.""" @@ -264,14 +264,14 @@ def select_all(self) -> None: """Select every item currently shown by the filter.""" if not self._multi: return - self._selected |= set(self._visible()) + 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._visible()) + self._selected -= set(self.get_visible()) self.list_box.deselect_all() self._update_summary() self._fire_on_change(self.get()) From 85fc02939ea3e0490fa0399876d282232a70e6b3 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 26 Oct 2026 11:08:53 +0000 Subject: [PATCH 4/6] removed the search algorithm and utilised rapidfuzz. left an arg for custom matching algos --- Python_Engine/Python/pyproject.toml | 1 + .../widgets/searchable_list_box.py | 86 +++++-------------- .../Python/tests/test_bhom_tkinter_ui.py | 47 +++++----- 3 files changed, 48 insertions(+), 86 deletions(-) diff --git a/Python_Engine/Python/pyproject.toml b/Python_Engine/Python/pyproject.toml index 6db0bd6c..c9f525ca 100644 --- a/Python_Engine/Python/pyproject.toml +++ b/Python_Engine/Python/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pytest>=8.3.5", "pytest-cov>=6.0.0", "pytest-order", + "rapidfuzz", "virtualenv", "pythonnet", ] 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 index 27c51200..1c39ffe9 100644 --- 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 @@ -2,80 +2,41 @@ from __future__ import annotations -import difflib -import re import tkinter as tk from tkinter import ttk -from typing import Iterable, List, Literal, Optional +from typing import Callable, Iterable, List, Literal, Optional + +from rapidfuzz import fuzz, utils as fuzz_utils 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.button import Button from python_toolkit.bhom_tkinter.widgets.list_box import ScrollableListBox -_WORD_SPLIT = re.compile(r"[\s_\-\.\(\)\[\],/\\]+") -_TYPO_MIN_LENGTH = 4 # query words shorter than this must match exactly or as a subsequence -_TYPO_MIN_RATIO = 0.8 # difflib similarity needed to accept a misspelt word -_TYPO_MAX_LENGTH_DIFF = 2 # a misspelling may be at most this many letters longer/shorter than the word - - -def _subsequence_gaps(token: str, text: str) -> Optional[int]: - """Characters skipped to find token's letters in order in text, or None if they are not all there.""" - position, gaps = 0, 0 - for char in token: - found = text.find(char, position) - if found < 0: - return None - gaps += found - position - position = found + 1 - return gaps +Scorer = Callable[[str, str], Optional[float]] # (query, item) -> score (higher is better), or None for no match -def fuzzy_score(query: str, text: str) -> Optional[float]: - """Score how well `query` matches `text` (higher is better), or None if it does not match. +def default_scorer(query: str, text: str, min_score: float = 75) -> Optional[float]: + """Score how well `query` matches `text` using rapidfuzz (higher is better), or None if it is a weak match. - Every whitespace-separated word of the query must match the text, in one of these ways (best first): - - contained in the text, with a bonus when it starts a word ("lon" in "London_TRY") - - its letters appear in order with only small gaps ("ldn" in "London"), scored down by the gaps - - a close misspelling of one of the text's words ("birmigham" for "Birmingham"), words of 4+ letters - An empty query matches everything with score 0. + Args: + query: The text typed into the filter. + text: The item being tested. + min_score: Similarity (0-100, rapidfuzz WRatio) 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`. """ - text_l = text.lower() - tokens = [t for t in query.lower().split() if t] - if not tokens: - return 0.0 - words = [w for w in _WORD_SPLIT.split(text_l) if w] - score = 0.0 - for token in tokens: - index = text_l.find(token) - if index >= 0: - starts_word = index == 0 or not text_l[index - 1].isalnum() - score += 100 + (20 if starts_word else 0) - min(index, 20) * 0.5 - continue - gaps = _subsequence_gaps(token, text_l) - # Letters in order only count when they are close together, otherwise short words match almost anything. - if gaps is not None and gaps <= max(3, len(token)): - score += max(60 - gaps * 4, 20) - continue - similar = [w for w in words if abs(len(w) - len(token)) <= _TYPO_MAX_LENGTH_DIFF] - if len(token) >= _TYPO_MIN_LENGTH and similar: - ratio = max(difflib.SequenceMatcher(None, token, w).ratio() for w in similar) - if ratio >= _TYPO_MIN_RATIO: - score += ratio * 50 - continue - return None - return score + score = fuzz.WRatio(query, text, processor=fuzz_utils.default_process, score_cutoff=min_score) + return 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. With match_mode="fuzzy" (default) each typed word may match as - a substring, as letters in order, or as a close misspelling, and the best matches are listed first - (see `fuzzy_score`). With match_mode="contains" the list shows items containing the text, in order. - Matching is case-insensitive. 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. + Typing in the filter narrows the list to fuzzy matches (typo-tolerant, case-insensitive), best first. + Matching uses `default_scorer` (rapidfuzz); 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__( @@ -89,7 +50,7 @@ def __init__( keep_hidden_selection: bool = True, show_summary: Optional[bool] = None, filter_label: str = "Filter", - match_mode: Literal["fuzzy", "contains"] = "fuzzy", + scorer: Scorer = default_scorer, **kwargs): """ Args: @@ -105,14 +66,13 @@ def __init__( 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. - match_mode (str): "fuzzy" (typo-tolerant, best matches first) or "contains" (plain substring). + scorer (Callable): Called as scorer(query, item); returns a score (higher ranks first) or None to + hide the item. Defaults to a rapidfuzz-based scorer. **kwargs: Additional BHoMBaseWidget / Frame options (id, item_title, helper_text, on_change, ...). """ super().__init__(parent, **kwargs) - if match_mode not in ("fuzzy", "contains"): - raise ValueError(f"match_mode must be 'fuzzy' or 'contains', not {match_mode!r}") - self.match_mode = match_mode + 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") @@ -166,9 +126,7 @@ def _matching(self, query: str) -> List[str]: query = query.strip().lower() if not query: return list(self._all_items) - if self.match_mode == "contains": - return [item for item in self._all_items if query in item.lower()] - scored = [(fuzzy_score(query, item), index, item) for index, item in enumerate(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]))] diff --git a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py index c0d5cc0f..9379fe3a 100644 --- a/Python_Engine/Python/tests/test_bhom_tkinter_ui.py +++ b/Python_Engine/Python/tests/test_bhom_tkinter_ui.py @@ -425,18 +425,15 @@ def test_searchable_list_box(): def test_searchable_list_box_fuzzy_matching(): - """Fuzzy mode: substrings, close letter sequences and typos match, best first; contains mode is plain.""" - from python_toolkit.bhom_tkinter.widgets.searchable_list_box import fuzzy_score - - assert fuzzy_score("", "anything") == 0.0 - assert fuzzy_score("lon", "London_TRY.epw") is not None - assert fuzzy_score("ldn try", "London_TRY.epw") is not None # letters in order, per word - assert fuzzy_score("birmigham", "Birmingham_TRY.epw") is not None # typo - assert fuzzy_score("xyzq", "London_TRY.epw") is None - assert fuzzy_score("try", "NZL_Taranaki_New_Plymouth.epw") is None # letters too far apart - assert fuzzy_score("london", "LOS-ANGELES-DOWNTOWN.epw") is None # not a close misspelling - # Whole-word start beats a match inside a word - assert fuzzy_score("try", "London_TRY.epw") > fuzzy_score("try", "Country.epw") + """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"] @@ -446,15 +443,21 @@ def test_searchable_list_box_fuzzy_matching(): fuzzy.set_filter("birmigham") assert fuzzy.get_visible() == ["Birmingham_TRY.epw"] fuzzy.set_filter("try") - # Word-start matches rank above the match inside "Country" (earlier matches rank slightly higher) - assert set(fuzzy.get_visible()[:3]) == {"London_TRY.epw", "Birmingham_TRY.epw", "Gibraltar_TRY.epw"} - assert fuzzy.get_visible()[-1] == "Country_Club.epw" - - contains = SearchableListBox(root.content_frame, items=items, match_mode="contains") - contains.build() - contains.set_filter("birmigham") - assert contains.get_visible() == [] - contains.set_filter("try") - assert contains.get_visible() == items # "Country" contains "try"; original order kept + 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() From 24e0a175a888c5a936807ece0b8c87209b67f2f3 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 26 Oct 2026 11:41:07 +0000 Subject: [PATCH 5/6] switched to the bhom search engine --- Python_Engine/Python/pyproject.toml | 1 - .../widgets/searchable_list_box.py | 43 +++++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Python_Engine/Python/pyproject.toml b/Python_Engine/Python/pyproject.toml index c9f525ca..6db0bd6c 100644 --- a/Python_Engine/Python/pyproject.toml +++ b/Python_Engine/Python/pyproject.toml @@ -15,7 +15,6 @@ dependencies = [ "pytest>=8.3.5", "pytest-cov>=6.0.0", "pytest-order", - "rapidfuzz", "virtualenv", "pythonnet", ] 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 index 1c39ffe9..c45cbb7a 100644 --- 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 @@ -2,12 +2,13 @@ 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 rapidfuzz import fuzz, utils as fuzz_utils - 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.button import Button @@ -15,18 +16,42 @@ 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 rapidfuzz (higher is better), or None if it is a weak match. + """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, rapidfuzz WRatio) 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`. + 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`. """ - score = fuzz.WRatio(query, text, processor=fuzz_utils.default_process, score_cutoff=min_score) - return score if score >= min_score else None + 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): @@ -34,7 +59,7 @@ class SearchableListBox(BHoMBaseWidget): 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` (rapidfuzz); pass `scorer` to use another algorithm. + 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. """ @@ -67,7 +92,7 @@ def __init__( 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 a rapidfuzz-based scorer. + 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) From ef18389c3b3ec80d0447cae718e6ff3bd3ada732 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Mon, 26 Oct 2026 11:53:35 +0000 Subject: [PATCH 6/6] tidied up the buttons --- .../bhom_tkinter/widgets/searchable_list_box.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 index c45cbb7a..cec23f55 100644 --- 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 @@ -11,7 +11,6 @@ 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.button import Button 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 @@ -26,7 +25,7 @@ def _load_bhom_search(): if _BHOM_SEARCH is None: import clr # pylint: disable=import-outside-toplevel,import-error - sys.path.append(os.path.expandvars("%ProgramData%\BHoM\Assemblies")) + 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 @@ -132,10 +131,10 @@ def __init__( controls.pack(side="top", fill=tk.X, pady=(6, 0)) controls.columnconfigure(0, weight=1) controls.columnconfigure(1, weight=1) - select_widget = Button(controls, text="Select All", command=self.select_all) - select_widget.grid(row=0, column=0, sticky="ew", padx=(0, 4)) - deselect_widget = Button(controls, text="Deselect All", command=self.deselect_all) - deselect_widget.grid(row=0, column=1, sticky="ew", padx=(4, 0)) + 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: