From a38ea0fb8b86600f810c3d44735bf67d9db22630 Mon Sep 17 00:00:00 2001 From: simont2k Date: Thu, 5 Mar 2026 08:25:03 +0100 Subject: [PATCH 1/3] Add custom widget: searchable combobox --- labeler/custom_widgets/searchable_combobox.py | 203 ++++++++++++++++++ labeler/views/gui.py | 22 +- tests/common/test_gui.py | 37 +--- tests/common/test_searchable_combobox.py | 120 +++++++++++ 4 files changed, 335 insertions(+), 47 deletions(-) create mode 100644 labeler/custom_widgets/searchable_combobox.py create mode 100644 tests/common/test_searchable_combobox.py diff --git a/labeler/custom_widgets/searchable_combobox.py b/labeler/custom_widgets/searchable_combobox.py new file mode 100644 index 0000000..79ebb05 --- /dev/null +++ b/labeler/custom_widgets/searchable_combobox.py @@ -0,0 +1,203 @@ +# Copyright (C) 2024-2026, Felix Dittrich | Ian List | Devarshi Aggarwal. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +import tkinter as tk +from tkinter import ttk + + +class SearchableComboBox(ttk.Frame): + """ + A custom Tkinter widget that combines an Entry field with a searchable dropdown list. + + This widget allows users to type into an entry field to filter a list of values + displayed in a popup Toplevel window. It supports keyboard navigation (arrows, enter) + and mouse interaction. + """ + + def __init__(self, parent, values, *args, **kwargs): + """ + Initialize the SearchableComboBox. + + Args: + parent: The parent widget. + values (list): A list of strings to be displayed in the dropdown. + *args: Variable length argument list passed to the tk.Frame. + **kwargs: Arbitrary keyword arguments passed to the tk.Frame. + """ + super().__init__(parent, *args, **kwargs) + + self.values = values + self.filtered_values = values + self.entry_frame = ttk.Frame(self) + self.entry_frame.pack(fill="x", expand=True) + self.entry = ttk.Entry(self.entry_frame) + self.entry.pack(side="left", fill="x", expand=True) + self.drop_btn = ttk.Label(self.entry_frame, text="\u25bc", cursor="hand2") # unicode arrow down + self.drop_btn.pack(side="right", fill="y", padx=(0, 5)) + self.drop_btn.bind("", lambda e: self._toggle_dropdown()) + + # Dropdown popup setup + self.popup = tk.Toplevel(self) + self.popup.withdraw() + self.popup.overrideredirect(True) + self.listbox = tk.Listbox(self.popup, bd=1, highlightthickness=0, relief="solid") + self.listbox.pack(fill="both", expand=True) + self.scrollbar = ttk.Scrollbar(self.listbox, orient="vertical", command=self.listbox.yview) + self.scrollbar.pack(side="right", fill="y") + self.listbox.config(yscrollcommand=self.scrollbar.set) + + # Event bindings + self.entry.bind("", self._on_keyrelease) + self.entry.bind("", self._on_arrow_navigation) + self.entry.bind("", self._on_arrow_navigation) + self.entry.bind("", self._on_enter_select) + self.entry.bind("", lambda e: self.popup.withdraw()) + self.listbox.bind("<>", self._on_listbox_click) + self.entry.bind("", self._on_focus_out) + self.listbox.bind("", self._on_focus_out) + + def _on_keyrelease(self, event): + """ + Handle key release events in the entry field for filtering. + + Args: + event: The Tkinter event object. + """ + if event.keysym in ("Up", "Down", "Return", "Escape", "Tab"): + return + + search_term = self.entry.get().lower() + self.filtered_values = [item for item in self.values if search_term in item.lower()] + + if self.filtered_values: + self._update_listbox(self.filtered_values) + self._show_popup() + else: + self.popup.withdraw() + + def _on_arrow_navigation(self, event): + """ + Handle arrow key navigation within the dropdown list. + + Args: + event: The Tkinter event object containing the key symbol. + + Returns: + str: "break" to prevent default Tkinter behavior. + """ + if not self.popup.winfo_viewable(): + self._update_listbox(self.values) + self._show_popup() + return "break" + + current_selection = self.listbox.curselection() + + if event.keysym == "Down": + next_index = (current_selection[0] + 1) if current_selection else 0 + if next_index < self.listbox.size(): + self._update_selection(next_index) + + elif event.keysym == "Up": + prev_index = (current_selection[0] - 1) if current_selection else self.listbox.size() - 1 + if prev_index >= 0: + self._update_selection(prev_index) + + return "break" + + def _update_selection(self, index): + """ + Highlight and scroll to a specific index in the listbox. + + Args: + index (int): The index of the item to select. + """ + self.listbox.selection_clear(0, tk.END) + self.listbox.selection_set(index) + self.listbox.activate(index) + self.listbox.see(index) + + def _on_enter_select(self, event): + """ + Select the currently highlighted listbox item when Enter is pressed. + + Args: + event: The Tkinter event object. + """ + if self.popup.winfo_viewable(): + selection = self.listbox.curselection() + if selection: + self._select_item(self.listbox.get(selection[0])) + return "break" + + def _on_listbox_click(self, event): + """ + Handle mouse click selection in the listbox. + + Args: + event: The Tkinter event object. + """ + if self.listbox.curselection(): + self._select_item(self.listbox.get(self.listbox.curselection()[0])) + + def _select_item(self, value): + """ + Update the entry field with the selected value and close the popup. + + Args: + value (str): The text value to set in the entry. + """ + self.entry.delete(0, tk.END) + self.entry.insert(0, value) + self.popup.withdraw() + self.entry.focus_set() + + def _toggle_dropdown(self): + """Toggle the visibility of the dropdown popup.""" + if self.popup.winfo_viewable(): + self.popup.withdraw() + else: + self._update_listbox(self.values) + self._show_popup() + self.entry.focus_set() + + def _update_listbox(self, data): + """ + Clear and refill the listbox with new data. + + Args: + data (list): List of strings to populate the listbox. + """ + self.listbox.delete(0, tk.END) + for item in data: + self.listbox.insert(tk.END, item) + + def _show_popup(self): + """ + Calculate position and display the dropdown popup relative to the entry frame. + """ + self.update_idletasks() + x = self.entry_frame.winfo_rootx() + y = self.entry_frame.winfo_rooty() + self.entry_frame.winfo_height() + width = self.entry_frame.winfo_width() + self.popup.geometry(f"{width}x150+{x}+{y}") + self.popup.deiconify() + self.popup.lift() + + def _on_focus_out(self, event): + """ + Trigger a delayed check to hide the popup when focus is lost. + + Args: + event: The Tkinter event object. + """ + self.after(200, self._check_focus) + + def _check_focus(self): + """ + Hide the popup if the focus has moved outside the widget's components. + """ + focused = self.focus_get() + if focused not in (self.entry, self.listbox, self.drop_btn): + self.popup.withdraw() diff --git a/labeler/views/gui.py b/labeler/views/gui.py index 4e833d0..b91731c 100644 --- a/labeler/views/gui.py +++ b/labeler/views/gui.py @@ -12,6 +12,8 @@ import tkinter as tk from tkinter import filedialog, ttk +from labeler.custom_widgets.searchable_combobox import SearchableComboBox + from ..automation import TightBox from ..components import DrawPoly from ..logger import logger @@ -209,10 +211,8 @@ def __init__(self, *args, **kwargs): self.type_variable = tk.StringVar(self.top_frame, self.type_options[0]) # Listener for label type self.type_variable.trace_add("write", lambda *args: self.save_type()) - self.label_type = ttk.Combobox( - self.top_frame, textvariable=self.type_variable, values=self.type_options, state="normal" - ) - self.label_type.bind("", self._filter_label_type_values) + self.label_type = SearchableComboBox(self.top_frame, values=self.type_options) + self.label_type.entry.configure(textvariable=self.type_variable) self.progress_bar = ttk.Progressbar(self.top_frame, orient="horizontal", length=100, mode="determinate") # Canvas @@ -357,7 +357,8 @@ def hide_buttons(self): self.draw_poly_button.configure(state="disabled") self.make_tight_button.configure(state="disabled") self.label_text.configure(state="disabled") - self.label_type.configure(state="disabled") + self.label_type.entry.configure(state="disabled") + self.label_type.drop_btn.bind("", lambda e: "break") def show_buttons(self): """ @@ -374,15 +375,8 @@ def show_buttons(self): self.draw_poly_button.configure(state="normal") self.make_tight_button.configure(state="normal") self.label_text.configure(state="normal") - self.label_type.configure(state="normal") - - def _filter_label_type_values(self, event: tk.Event | None = None): - current_text = self.type_variable.get().strip().lower() - if not current_text: - self.label_type["values"] = self.type_options - return - filtered = [t for t in self.type_options if current_text in t.lower()] - self.label_type["values"] = filtered or self.type_options + self.label_type.entry.configure(state="normal") + self.label_type.drop_btn.bind("", lambda e: self.label_type._toggle_dropdown()) def select_all(self, event: tk.Event | None = None): """ diff --git a/tests/common/test_gui.py b/tests/common/test_gui.py index e254007..4fe8c48 100644 --- a/tests/common/test_gui.py +++ b/tests/common/test_gui.py @@ -29,7 +29,7 @@ def test_hide_buttons(gui_app): assert str(gui_app.draw_poly_button["state"]) == "disabled" assert str(gui_app.make_tight_button["state"]) == "disabled" assert str(gui_app.label_text["state"]) == "disabled" - assert str(gui_app.label_type["state"]) == "disabled" + assert str(gui_app.label_type.entry["state"]) == "disabled" def test_show_buttons(gui_app): @@ -45,7 +45,7 @@ def test_show_buttons(gui_app): assert str(gui_app.draw_poly_button["state"]) == "normal" assert str(gui_app.make_tight_button["state"]) == "normal" assert str(gui_app.label_text["state"]) == "normal" - assert str(gui_app.label_type["state"]) == "normal" + assert str(gui_app.label_type.entry["state"]) == "normal" def test_toggle_keep_drawing(gui_app): @@ -130,7 +130,7 @@ def test_hide_buttons_disables_buttons(gui_app): gui_app.draw_poly_button, gui_app.make_tight_button, gui_app.label_text, - gui_app.label_type, + gui_app.label_type.entry, ] for button in buttons: assert str(button["state"]) == "disabled" @@ -154,7 +154,7 @@ def test_show_buttons_enables_buttons(gui_app): ] for button in buttons: assert str(button["state"]) == "normal" - assert str(gui_app.label_type["state"]) == "normal" + assert str(gui_app.label_type.entry["state"]) == "normal" def test_select_all(gui_app): @@ -453,32 +453,3 @@ def test_update_color_palette_with_mapping_and_new_types(gui_app): assert gui_app.color_palette[1] == "#123456" assert len(gui_app.color_palette) == 4 assert gui_app.color_palette[3].startswith("#") - - -def test_filter_label_type_values(gui_app): - gui_app.type_options = ["words", "lines", "header", "paragraph", "footer"] - - gui_app.type_variable.set("") - gui_app._filter_label_type_values(None) - assert tuple(gui_app.label_type["values"]) == tuple(gui_app.type_options) - - gui_app.type_variable.set("he") - gui_app._filter_label_type_values(None) - assert tuple(gui_app.label_type["values"]) == ("header",) - - gui_app.type_variable.set("par") - gui_app._filter_label_type_values(None) - assert tuple(gui_app.label_type["values"]) == ("paragraph",) - - gui_app.type_variable.set("xyz") - gui_app._filter_label_type_values(None) - assert tuple(gui_app.label_type["values"]) == tuple(gui_app.type_options) - - gui_app.type_variable.set("HEAD") - gui_app._filter_label_type_values(None) - assert tuple(gui_app.label_type["values"]) == ("header",) - - gui_app.type_options = ["words"] - gui_app.type_variable.set("w") - gui_app._filter_label_type_values(None) - assert tuple(gui_app.label_type["values"]) == ("words",) diff --git a/tests/common/test_searchable_combobox.py b/tests/common/test_searchable_combobox.py new file mode 100644 index 0000000..ae8c7c7 --- /dev/null +++ b/tests/common/test_searchable_combobox.py @@ -0,0 +1,120 @@ +import time +import unittest + +import tkinter as tk + +from labeler.custom_widgets.searchable_combobox import SearchableComboBox + + +class TestSearchableComboBox(unittest.TestCase): + def setUp(self): + self.root = tk.Tk() + self.values = ["words", "header", "footer"] + self.combo = SearchableComboBox(self.root, self.values) + self.combo.pack() + self.root.update() + + def tearDown(self): + self.root.destroy() + + def test_initialization(self): + self.assertEqual(self.combo.values, self.values) + self.assertEqual(self.combo.entry.get(), "") + self.assertFalse(self.combo.popup.winfo_viewable()) + + def test_filter_on_keyrelease(self): + self.combo.entry.insert(0, "wo") + event = tk.Event() + event.keysym = "r" + self.combo._on_keyrelease(event) + + self.assertEqual(self.combo.filtered_values, ["words"]) + self.assertTrue(self.combo.popup.winfo_viewable()) + + def test_on_keyrelease_ignored_keys(self): + self.combo.entry.insert(0, "words") + event = tk.Event() + event.keysym = "Up" + initial = self.combo.filtered_values + self.combo._on_keyrelease(event) + self.assertEqual(self.combo.filtered_values, initial) + + def test_keyrelease_no_results(self): + self.combo.entry.insert(0, "XYZ") + event = tk.Event() + event.keysym = "Z" + self.combo._on_keyrelease(event) + self.assertFalse(self.combo.popup.winfo_viewable()) + + def test_toggle_dropdown(self): + self.combo._toggle_dropdown() + self.root.update() + self.assertTrue(self.combo.popup.winfo_viewable()) + + self.combo._toggle_dropdown() + self.root.update() + self.assertFalse(self.combo.popup.winfo_viewable()) + + def test_select_item(self): + self.combo._select_item("footer") + self.assertEqual(self.combo.entry.get(), "footer") + self.assertFalse(self.combo.popup.winfo_viewable()) + + def test_arrow_navigation(self): + self.combo._toggle_dropdown() + event = tk.Event() + event.keysym = "Down" + self.combo._on_arrow_navigation(event) + + selection = self.combo.listbox.curselection() + self.assertEqual(selection[0], 0) + self.assertEqual(self.combo.listbox.get(0), "words") + + def test_arrow_navigation_up(self): + self.combo._toggle_dropdown() + self.combo._update_selection(1) + event = tk.Event() + event.keysym = "Up" + self.combo._on_arrow_navigation(event) + self.assertEqual(self.combo.listbox.curselection()[0], 0) + + def test_arrow_navigation_opens_popup(self): + self.combo.popup.withdraw() + event = tk.Event() + event.keysym = "Down" + self.assertEqual(self.combo._on_arrow_navigation(event), "break") + self.assertTrue(self.combo.popup.winfo_viewable()) + + def test_enter_select(self): + self.combo._toggle_dropdown() + self.combo._update_selection(1) + self.assertEqual(self.combo._on_enter_select(tk.Event()), "break") + self.assertEqual(self.combo.entry.get(), "header") + + def test_on_listbox_click(self): + self.combo._toggle_dropdown() + self.combo.listbox.selection_set(2) + self.combo._on_listbox_click(None) + self.assertEqual(self.combo.entry.get(), "footer") + + def test_popup_position(self): + self.combo._show_popup() + self.root.update() + x = int(self.combo.popup.winfo_geometry().split("+")[1]) + self.assertEqual(x, self.combo.entry_frame.winfo_rootx()) + + def test_focus_out_logic(self): + self.combo._toggle_dropdown() + self.assertTrue(self.combo.popup.winfo_viewable()) + other = tk.Entry(self.root) + other.pack() + other.focus_set() + self.root.update() + for _ in range(10): + self.root.update() + time.sleep(0.05) + if not self.combo.popup.winfo_viewable(): + break + self.combo._check_focus() + self.root.update() + self.assertFalse(self.combo.popup.winfo_viewable()) From 3d6b5676e5a8e197e802a672108271dde9687a86 Mon Sep 17 00:00:00 2001 From: simont2k Date: Thu, 5 Mar 2026 09:21:27 +0100 Subject: [PATCH 2/3] Add type annotations, update tests --- labeler/custom_widgets/searchable_combobox.py | 37 ++++++++++--------- tests/common/test_searchable_combobox.py | 7 ++++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/labeler/custom_widgets/searchable_combobox.py b/labeler/custom_widgets/searchable_combobox.py index 79ebb05..8d29daa 100644 --- a/labeler/custom_widgets/searchable_combobox.py +++ b/labeler/custom_widgets/searchable_combobox.py @@ -16,13 +16,13 @@ class SearchableComboBox(ttk.Frame): and mouse interaction. """ - def __init__(self, parent, values, *args, **kwargs): + def __init__(self, parent: tk.Misc, values: list[str], *args, **kwargs): """ Initialize the SearchableComboBox. Args: parent: The parent widget. - values (list): A list of strings to be displayed in the dropdown. + values: A list of strings to be displayed in the dropdown. *args: Variable length argument list passed to the tk.Frame. **kwargs: Arbitrary keyword arguments passed to the tk.Frame. """ @@ -34,7 +34,7 @@ def __init__(self, parent, values, *args, **kwargs): self.entry_frame.pack(fill="x", expand=True) self.entry = ttk.Entry(self.entry_frame) self.entry.pack(side="left", fill="x", expand=True) - self.drop_btn = ttk.Label(self.entry_frame, text="\u25bc", cursor="hand2") # unicode arrow down + self.drop_btn = ttk.Label(self.entry_frame, text="\u25bc", cursor="hand2") # unicode arrow down self.drop_btn.pack(side="right", fill="y", padx=(0, 5)) self.drop_btn.bind("", lambda e: self._toggle_dropdown()) @@ -58,7 +58,7 @@ def __init__(self, parent, values, *args, **kwargs): self.entry.bind("", self._on_focus_out) self.listbox.bind("", self._on_focus_out) - def _on_keyrelease(self, event): + def _on_keyrelease(self, event: tk.Event) -> None: """ Handle key release events in the entry field for filtering. @@ -77,7 +77,7 @@ def _on_keyrelease(self, event): else: self.popup.withdraw() - def _on_arrow_navigation(self, event): + def _on_arrow_navigation(self, event: tk.Event) -> str: """ Handle arrow key navigation within the dropdown list. @@ -106,32 +106,35 @@ def _on_arrow_navigation(self, event): return "break" - def _update_selection(self, index): + def _update_selection(self, index: int) -> None: """ Highlight and scroll to a specific index in the listbox. Args: - index (int): The index of the item to select. + index: The index of the item to select. """ self.listbox.selection_clear(0, tk.END) self.listbox.selection_set(index) self.listbox.activate(index) self.listbox.see(index) - def _on_enter_select(self, event): + def _on_enter_select(self, event: tk.Event) -> str | None: """ Select the currently highlighted listbox item when Enter is pressed. Args: event: The Tkinter event object. + Returns: + str: "break" if an item was selected, else None. """ if self.popup.winfo_viewable(): selection = self.listbox.curselection() if selection: self._select_item(self.listbox.get(selection[0])) return "break" + return None - def _on_listbox_click(self, event): + def _on_listbox_click(self, event: tk.Event) -> None: """ Handle mouse click selection in the listbox. @@ -141,19 +144,19 @@ def _on_listbox_click(self, event): if self.listbox.curselection(): self._select_item(self.listbox.get(self.listbox.curselection()[0])) - def _select_item(self, value): + def _select_item(self, value: str) -> None: """ Update the entry field with the selected value and close the popup. Args: - value (str): The text value to set in the entry. + value: The text value to set in the entry. """ self.entry.delete(0, tk.END) self.entry.insert(0, value) self.popup.withdraw() self.entry.focus_set() - def _toggle_dropdown(self): + def _toggle_dropdown(self) -> None: """Toggle the visibility of the dropdown popup.""" if self.popup.winfo_viewable(): self.popup.withdraw() @@ -162,18 +165,18 @@ def _toggle_dropdown(self): self._show_popup() self.entry.focus_set() - def _update_listbox(self, data): + def _update_listbox(self, data: list[str]) -> None: """ Clear and refill the listbox with new data. Args: - data (list): List of strings to populate the listbox. + data: List of strings to populate the listbox. """ self.listbox.delete(0, tk.END) for item in data: self.listbox.insert(tk.END, item) - def _show_popup(self): + def _show_popup(self) -> None: """ Calculate position and display the dropdown popup relative to the entry frame. """ @@ -185,7 +188,7 @@ def _show_popup(self): self.popup.deiconify() self.popup.lift() - def _on_focus_out(self, event): + def _on_focus_out(self, event: tk.Event) -> None: """ Trigger a delayed check to hide the popup when focus is lost. @@ -194,7 +197,7 @@ def _on_focus_out(self, event): """ self.after(200, self._check_focus) - def _check_focus(self): + def _check_focus(self) -> None: """ Hide the popup if the focus has moved outside the widget's components. """ diff --git a/tests/common/test_searchable_combobox.py b/tests/common/test_searchable_combobox.py index ae8c7c7..86d52a8 100644 --- a/tests/common/test_searchable_combobox.py +++ b/tests/common/test_searchable_combobox.py @@ -91,6 +91,13 @@ def test_enter_select(self): self.assertEqual(self.combo._on_enter_select(tk.Event()), "break") self.assertEqual(self.combo.entry.get(), "header") + self.combo.popup.withdraw() + self.assertIsNone(self.combo._on_enter_select(tk.Event())) + + self.combo._toggle_dropdown() + self.combo.listbox.selection_clear(0, tk.END) + self.assertIsNone(self.combo._on_enter_select(tk.Event())) + def test_on_listbox_click(self): self.combo._toggle_dropdown() self.combo.listbox.selection_set(2) From c33b0a3c0b7f26b3a2d26f65eeb7882eaeee97f5 Mon Sep 17 00:00:00 2001 From: simont2k Date: Thu, 5 Mar 2026 13:40:58 +0100 Subject: [PATCH 3/3] Update version --- setup.py | 2 +- tests/common/test_searchable_combobox.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f9abdb2..6b69aae 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup PKG_NAME = "doctr-labeler" -VERSION = os.getenv("BUILD_VERSION", "0.3.0") +VERSION = os.getenv("BUILD_VERSION", "0.3.1") if __name__ == "__main__": diff --git a/tests/common/test_searchable_combobox.py b/tests/common/test_searchable_combobox.py index 86d52a8..c4e22c4 100644 --- a/tests/common/test_searchable_combobox.py +++ b/tests/common/test_searchable_combobox.py @@ -93,7 +93,7 @@ def test_enter_select(self): self.combo.popup.withdraw() self.assertIsNone(self.combo._on_enter_select(tk.Event())) - + self.combo._toggle_dropdown() self.combo.listbox.selection_clear(0, tk.END) self.assertIsNone(self.combo._on_enter_select(tk.Event()))