forked from ArduPilot/MethodicConfigurator
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfrontend_tkinter_pair_tuple_combobox.py
More file actions
executable file
·421 lines (340 loc) · 16.7 KB
/
frontend_tkinter_pair_tuple_combobox.py
File metadata and controls
executable file
·421 lines (340 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
#!/usr/bin/env python3
"""
A combobox GUI with support for complex lists.
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
SPDX-License-Identifier: GPL-3.0-or-later
"""
import contextlib
import tkinter as tk
import tkinter.font as tkfont
from argparse import ArgumentParser, Namespace
from logging import basicConfig as logging_basicConfig
from logging import critical as logging_critical
from logging import debug as logging_debug
from logging import getLevelName as logging_getLevelName
from logging import warning as logging_warning
from platform import system as platform_system
from sys import exit as sys_exit
from tkinter import Label, Toplevel, ttk
from typing import Optional, Union
from ardupilot_methodic_configurator import _
from ardupilot_methodic_configurator.common_arguments import add_common_arguments
from ardupilot_methodic_configurator.frontend_tkinter_autoresize_combobox import update_combobox_width
def setup_combobox_mousewheel_handling(combobox: ttk.Combobox) -> None:
"""
Set up mouse wheel handling for a combobox to prevent value changes when scrolling.
This function adds event bindings to prevent the combobox value from changing
when the user scrolls the mouse wheel over it while the dropdown is closed.
When the dropdown is closed, the mouse wheel event is propagated to the parent
widget to allow normal scrolling behavior in the containing widget.
Args:
combobox: The ttk.Combobox widget to configure
"""
# Track dropdown state to control mouse wheel behavior
combobox.dropdown_is_open = False # type: ignore[attr-defined]
def handle_mousewheel(event: tk.Event) -> Optional[str]:
"""Handle mouse wheel events - propagate to parent when dropdown is closed."""
if not combobox.dropdown_is_open: # type: ignore[attr-defined]
# Propagate the wheel event to the parent widget
combobox.master.event_generate("<MouseWheel>", delta=event.delta)
return "break" # Prevent default combobox behavior
return None # Allow default behavior when dropdown is open
def dropdown_opened(_event: Optional[tk.Event] = None) -> None:
"""Mark dropdown as open."""
combobox.dropdown_is_open = True # type: ignore[attr-defined]
def dropdown_closed(_event: Optional[tk.Event] = None) -> None:
"""Mark dropdown as closed."""
combobox.dropdown_is_open = False # type: ignore[attr-defined] # Bind mouse wheel events (Windows and Linux)
combobox.bind("<MouseWheel>", handle_mousewheel, add="+")
combobox.bind("<Button-4>", handle_mousewheel, add="+")
combobox.bind("<Button-5>", handle_mousewheel, add="+")
# Track dropdown open/close events
combobox.bind("<<ComboboxDropdown>>", dropdown_opened, add="+")
combobox.bind("<FocusOut>", dropdown_closed, add="+")
# SPDX-SnippetBegin
# SPDX-License-Identifier: MPL-2.0
# SPDX-SnippetCopyrightText: 2022 geraldew
# https://dev.to/geraldew/python-tkinter-an-exercise-in-wrapping-the-combobox-ndb
class PairTupleCombobox(ttk.Combobox): # pylint: disable=too-many-ancestors
"""
A custom Combobox widget that displays a list of key-value tuples.
This widget processes the list of tuples to separate keys and values for display purposes and allows for the selection
of a tuple based on its key.
"""
def __init__(
self,
master, # noqa: ANN001
list_pair_tuple: list[tuple[str, str]],
selected_element: Union[None, str],
cb_name: str,
*args,
**kwargs,
) -> None:
super().__init__(master, *args, **kwargs)
self.cb_name = cb_name
self.list_keys: list[str] = []
self.list_shows: list[str] = []
self.set_entries_tuple(list_pair_tuple, selected_element)
self.bind("<Configure>", self.on_combo_configure, add="+")
# Add keyboard navigation for up/down arrow keys
self.bind("<Up>", self._on_key_up, add="+")
self.bind("<Down>", self._on_key_down, add="+")
# Add focus handling for keyboard events (enable focus reception)
self.bind("<FocusIn>", lambda _: None, add="+")
# Apply mouse wheel handling to this combobox instance
setup_combobox_mousewheel_handling(self)
def set_entries_tuple(self, list_pair_tuple: list[tuple[str, str]], selected_element: Union[None, str]) -> None:
# Clear existing entries before setting new ones
self.list_keys.clear()
self.list_shows.clear()
self.append_entries_tuple(list_pair_tuple, selected_element)
def append_entries_tuple(self, list_pair_tuple: list[tuple[str, str]], selected_element: Union[None, str]) -> None:
if isinstance(list_pair_tuple, list):
for tpl in list_pair_tuple:
self.list_keys.append(tpl[0])
self.list_shows.append(tpl[1])
elif isinstance(list_pair_tuple, dict):
for key, value in list_pair_tuple.items():
self.list_keys.append(key)
self.list_shows.append(value)
else:
logging_critical(_("list_pair_tuple must be a list of tuples or a dictionary, not %s"), type(list_pair_tuple))
sys_exit(1)
self["values"] = tuple(self.list_shows)
if selected_element:
default_key_index = self._find_and_validate_selected_element(selected_element)
if default_key_index is not None:
self.current(default_key_index)
update_combobox_width(self)
else:
logging_debug(_("No %s combobox element selected"), self.cb_name)
def _find_and_validate_selected_element(self, selected_element: str) -> Union[int, None]:
"""
Find the index of the selected element in list_keys.
Args:
selected_element: The element to find (should be a list_key)
Returns:
The index of the element in list_keys, or None if not found
"""
try:
# Find the element in list_keys
return self.list_keys.index(selected_element)
except ValueError:
# Element not found - this should not happen if data model is clean
logging_critical(
_("%s combobox selected string '%s' not found in keys %s"),
self.cb_name,
selected_element,
self.list_keys,
)
return None
def get_selected_key(self) -> Union[str, None]:
try:
i_index = self.current()
# self.current() returns -1 if no item is selected
if i_index < 0:
return None
return self.list_keys[i_index]
except IndexError:
return None
# SPDX-SnippetEnd
def _on_key_up(self, _event: tk.Event) -> str:
"""Handle up arrow key navigation."""
if not self.list_keys:
return "break"
try:
current_index = self.current()
# Move up (decrease index), stop at the beginning
if current_index > 0:
new_index = current_index - 1
self.current(new_index)
# Highlight the text to show selection
self.selection_range(0, tk.END)
# Force the combobox to update visually
self.update_idletasks()
# Generate a virtual ComboboxSelected event to trigger callbacks
self.event_generate("<<ComboboxSelected>>")
except (ValueError, IndexError):
# Current selection not found or invalid, select first item
if self.list_keys:
self.current(0)
self.update_idletasks()
self.event_generate("<<ComboboxSelected>>")
# Prevent the default behavior
return "break"
def _on_key_down(self, _event: tk.Event) -> str:
"""Handle down arrow key navigation."""
if not self.list_keys:
return "break"
try:
current_index = self.current()
# Move down (increase index), stop at the end
if current_index < len(self.list_keys) - 1:
new_index = current_index + 1
self.current(new_index)
# Highlight the text to show selection
self.selection_range(0, tk.END)
# Force the combobox to update visually
self.update_idletasks()
# Generate a virtual ComboboxSelected event to trigger callbacks
self.event_generate("<<ComboboxSelected>>")
except (ValueError, IndexError):
# Current selection not found or invalid, select first item
if self.list_keys:
self.current(0)
# Highlight the text to show selection
self.selection_range(0, tk.END)
self.update_idletasks()
self.event_generate("<<ComboboxSelected>>")
# Prevent the default behavior
return "break"
# https://stackoverflow.com/questions/39915275/change-width-of-dropdown-listbox-of-a-ttk-combobox
def on_combo_configure(self, event: tk.Event) -> None:
combo = event.widget
style = ttk.Style()
# check if the combobox already has the "postoffset" property
current_combo_style = combo.cget("style") or "TCombobox"
if len(style.lookup(current_combo_style, "postoffset")) > 0:
return
combo_values = combo.cget("values")
if len(combo_values) == 0:
return
longest_value = max(combo_values, key=len)
font = tkfont.nametofont("TkDefaultFont")
width = font.measure(longest_value + "0000") - event.width
if width < 0:
# no need to make the popdown smaller
return
# create an unique style name using widget's id
unique_name = f"Combobox{combo.winfo_id()}"
# the new style must inherit from current widget style (unless it's our custom style!)
style_name = current_combo_style if unique_name in current_combo_style else f"{unique_name}.{current_combo_style}"
if isinstance(combo, ttk.Combobox):
style.configure(style_name, postoffset=(0, 0, width, 0))
combo.configure(style=style_name)
class PairTupleComboboxTooltip(PairTupleCombobox): # pylint: disable=too-many-ancestors
"""
A subclass of PairTupleCombobox that enhances the dropdown experience by displaying tooltips for the highlighted entry.
This class extends the functionality of PairTupleCombobox by adding tooltips to the items displayed in the dropdown menu.
When an item is selected or hovered over, a tooltip appears on the right side of the combobox, showing the full value and
descriptive text of the currently highlighted, potentially truncated, combobox entry.
The tooltip updates dynamically in real-time based on:
1. Cursor movement within the dropdown list
2. Mouse hover over the dropdown items
Key features:
- Provides immediate feedback on long entries without scrolling
- Improves usability by offering more information at a glance
- Maintains focus on the selected item while providing additional details
Behavior:
- Tooltip disappears automatically when:
a) An item is selected from the dropdown
b) The dropdown is closed (either by selection or pressing Esc)
"""
def __init__(
self,
master, # noqa: ANN001
list_pair_tuple: list[tuple[str, str]],
selected_element: Union[None, str],
cb_name: str,
*args,
**kwargs,
) -> None:
super().__init__(master, list_pair_tuple, selected_element, cb_name, *args, **kwargs)
self.tooltip: Union[None, Toplevel] = None
# Bind events related to the dropdown
pd = self.tk.call("ttk::combobox::PopdownWindow", self)
lb = pd + ".f.l"
self._bind(("bind", lb), "<KeyRelease>", self.on_key_release, None) # type: ignore[attr-defined]
self._bind(("bind", lb), "<Motion>", self.on_motion, None) # type: ignore[attr-defined]
self._bind(("bind", lb), "<Escape>", self.on_escape_press, None) # type: ignore[attr-defined]
self.bind("<<ComboboxSelected>>", self.on_combobox_selected, None)
def on_key_release(self, _event: Union[None, tk.Event]) -> None:
"""Get the keyboard highlighted index and create a tooltip for it."""
pd = self.tk.call("ttk::combobox::PopdownWindow", self)
lb = pd + ".f.l"
if self.tk.call(lb, "curselection"):
highlighted_index = int(self.tk.call(lb, "curselection")[0])
self.create_tooltip_from_index(highlighted_index)
def on_motion(self, event: tk.Event) -> None:
"""Get the mouse highlighted index and create a tooltip for it."""
pd = self.tk.call("ttk::combobox::PopdownWindow", self)
lb = pd + ".f.l"
index = self.tk.call(lb, "index", f"@{event.x},{event.y}")
self.create_tooltip_from_index(int(index))
def create_tooltip_from_index(self, index: int) -> None:
with contextlib.suppress(IndexError):
self.create_tooltip(f"{self.list_keys[index]}: {self.list_shows[index]}")
def create_tooltip(self, text: str) -> None:
self.destroy_tooltip()
try:
if self.tooltip is None or self.tooltip.winfo_exists():
self.tooltip = Toplevel(self.master)
self.tooltip.wm_overrideredirect(boolean=True)
x = self.winfo_rootx() + 150 # Adjust width as needed
y = self.winfo_rooty()
self.tooltip.wm_geometry(f"+{x}+{y}")
label = Label(self.tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1, justify=tk.LEFT)
label.pack(fill="both", expand=True)
except tk.TclError:
# If there's no active item, we don't need to update the tooltip
pass
def on_combobox_selected(self, _event: Union[None, tk.Event]) -> None:
self.destroy_tooltip()
def on_escape_press(self, _event: Union[None, tk.Event]) -> None:
self.destroy_tooltip()
def destroy_tooltip(self) -> None:
if hasattr(self, "tooltip") and self.tooltip and self.tooltip.winfo_exists():
self.tooltip.destroy()
self.tooltip = None
def argument_parser() -> Namespace: # pragma: no cover
"""
Parses command-line arguments for the script.
This function sets up an argument parser to handle the command-line arguments for the script.
Returns:
argparse.Namespace: An object containing the parsed arguments.
"""
parser = ArgumentParser(
description=_(
"A GUI for testing the PairTupleCombobox. "
"Not to be used directly, but through the main ArduPilot methodic configurator script."
)
)
return add_common_arguments(parser).parse_args()
def main() -> None: # pragma: no cover
argsp = argument_parser()
logging_basicConfig(level=logging_getLevelName(argsp.loglevel), format="%(asctime)s - %(levelname)s - %(message)s")
logging_warning(
_("This main is for testing and development only, usually the PairTupleCombobox is called from another script")
)
root = tk.Tk()
root.title("Random String Selector")
root.geometry("400x100") # Set a size for the window
# Generate 20 random strings between 4 and 70 characters
import random # pylint: disable=import-outside-toplevel # noqa: PLC0415
import string # pylint: disable=import-outside-toplevel # noqa: PLC0415
random_strings = [
"".join(random.choices(string.ascii_letters + string.digits, k=random.randint(4, 70))) # noqa: S311
for _ in range(20)
]
# Create the PairTupleCombobox2 instance
tuple_pairs = [(str(i), random_string) for i, random_string in enumerate(random_strings)]
combobox = PairTupleCombobox(root, tuple_pairs, None, "Random Strings")
style = ttk.Style()
style.theme_use("alt") # Use an alternative theme for better appearance
font_size = tkfont.nametofont("TkDefaultFont").cget("size")
font_size -= 2 if platform_system() == "Windows" else -1
combobox.config(state="readonly", width=9, font=("TkDefaultFont", font_size))
# Pack the combobox into the main window
combobox.pack(pady=10, padx=10)
# Set the initial value
combobox.set(str(1))
tuple_pairs = [("1", "One"), ("2", "Two"), ("3", "Test")]
# Create a PairTupleComboboxTooltip instance
tooltip_combobox = PairTupleComboboxTooltip(root, tuple_pairs, None, "Tooltip Items")
# Place the tooltip below the combobox
tooltip_combobox.place(relx=0.5, rely=0.7, anchor=tk.CENTER)
# Start the Tkinter event loop
root.mainloop()
if __name__ == "__main__": # pragma: no cover
main()