Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions labeler/custom_widgets/searchable_combobox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# 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 <https://opensource.org/licenses/Apache-2.0> 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: tk.Misc, values: list[str], *args, **kwargs):
"""
Initialize the SearchableComboBox.

Args:
parent: The parent widget.
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.
"""
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("<Button-1>", 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("<KeyRelease>", self._on_keyrelease)
self.entry.bind("<Down>", self._on_arrow_navigation)
self.entry.bind("<Up>", self._on_arrow_navigation)
self.entry.bind("<Return>", self._on_enter_select)
self.entry.bind("<Escape>", lambda e: self.popup.withdraw())
self.listbox.bind("<<ListboxSelect>>", self._on_listbox_click)
self.entry.bind("<FocusOut>", self._on_focus_out)
self.listbox.bind("<FocusOut>", self._on_focus_out)

def _on_keyrelease(self, event: tk.Event) -> None:
"""
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: tk.Event) -> str:
"""
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: int) -> None:
"""
Highlight and scroll to a specific index in the listbox.

Args:
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: 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: tk.Event) -> None:
"""
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: str) -> None:
"""
Update the entry field with the selected value and close the popup.

Args:
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) -> None:
"""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: list[str]) -> None:
"""
Clear and refill the listbox with new data.

Args:
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) -> None:
"""
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: tk.Event) -> None:
"""
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) -> None:
"""
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()
22 changes: 8 additions & 14 deletions labeler/views/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("<KeyRelease>", 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
Expand Down Expand Up @@ -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("<Button-1>", lambda e: "break")

def show_buttons(self):
"""
Expand All @@ -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("<Button-1>", lambda e: self.label_type._toggle_dropdown())

def select_all(self, event: tk.Event | None = None):
"""
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
37 changes: 4 additions & 33 deletions tests/common/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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"
Expand All @@ -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):
Expand Down Expand Up @@ -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",)
Loading