forked from ArduPilot/MethodicConfigurator
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfrontend_tkinter_show.py
More file actions
591 lines (464 loc) · 22.2 KB
/
Copy pathfrontend_tkinter_show.py
File metadata and controls
591 lines (464 loc) · 22.2 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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
"""
TKinter base classes reused in multiple parts of the code.
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
"""
# https://wiki.tcl-lang.org/page/Changing+Widget+Colors
import tkinter as tk
from platform import system as platform_system
from tkinter import messagebox, ttk
from typing import Any, NamedTuple, Optional, cast
from weakref import WeakKeyDictionary
from ardupilot_methodic_configurator import _
# Tooltip positioning constants
TOOLTIP_MAX_OFFSET = 100 # Maximum horizontal offset from widget edge
TOOLTIP_VERTICAL_OFFSET = 10 # Vertical offset when positioning above widget
class MonitorBounds(NamedTuple):
"""Screen bounds storing top-left and bottom-right coordinates."""
left: int
top: int
right: int
bottom: int
# Cache for monitor bounds per toplevel window to avoid repeated queries.
# Uses WeakKeyDictionary so cache entries are automatically removed when toplevel is garbage collected.
_monitor_bounds_cache: WeakKeyDictionary[tk.Misc, MonitorBounds] = WeakKeyDictionary()
def _is_valid_monitor_bounds(bounds: Optional[MonitorBounds]) -> bool:
"""
Validate monitor bounds have positive dimensions between 100x100 and 65535x65535.
The validation range ensures:
- Minimum 100x100: Reject degenerate/invalid displays (0x0, negative dimensions)
- Maximum 65535x65535: Accommodate multi-monitor setups including:
* Triple 4K monitors: 11520x2160 (3 x 3840x2160)
* Quad HD monitors: 7680x4320 (2 x 2 array)
* Virtual desktop spanning: up to 16 monitors theoretically
While Win32 RECT uses signed 32-bit coordinates (LONG type), practical multi-monitor
setups rarely exceed 65535 pixels in any dimension. This limit balances broad compatibility
with validation strictness to catch API errors or corrupted data
Args:
bounds: MonitorBounds to validate, or None
Returns:
True if bounds are valid and within acceptable range, False otherwise
"""
if bounds is None:
return False
width = bounds.right - bounds.left
height = bounds.bottom - bounds.top
return 100 <= width <= 65535 and 100 <= height <= 65535
def _get_validated_bounds(bounds: Optional[MonitorBounds]) -> Optional[MonitorBounds]:
"""Return bounds if valid, otherwise None."""
return bounds if _is_valid_monitor_bounds(bounds) else None
def _monitor_bounds_tk(widget: tk.Misc) -> MonitorBounds:
"""
Return bounds reported by Tk for the screen hosting the widget.
This is a fallback method when platform-specific APIs fail.
Uses Tk's virtual root coordinates which represent the screen containing
the toplevel window.
Args:
widget: Any Tkinter widget whose screen bounds are needed
Returns:
MonitorBounds with screen coordinates in pixels
Example:
>>> bounds = _monitor_bounds_tk(my_button)
>>> print(f"Screen: {bounds.left},{bounds.top} to {bounds.right},{bounds.bottom}")
"""
toplevel = widget.winfo_toplevel()
toplevel.update_idletasks()
# Get virtual root position (top-left corner of the screen)
vroot_x = toplevel.winfo_vrootx()
vroot_y = toplevel.winfo_vrooty()
# Get virtual root dimensions
width = toplevel.winfo_vrootwidth()
height = toplevel.winfo_vrootheight()
# Fallback if virtual root reports invalid dimensions
if width <= 0 or height <= 0:
width = toplevel.winfo_screenwidth()
height = toplevel.winfo_screenheight()
return MonitorBounds(vroot_x, vroot_y, vroot_x + width, vroot_y + height)
def _monitor_bounds_windows(widget: tk.Misc) -> Optional[MonitorBounds]: # pylint: disable=too-many-return-statements # noqa: PLR0911
"""Use the Win32 API to determine the monitor that hosts the widget."""
try:
import ctypes # pylint: disable=import-outside-toplevel # noqa: PLC0415 # type: ignore[import-outside-toplevel]
from ctypes import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 # type: ignore[import-outside-toplevel]
wintypes,
)
except ImportError: # pragma: no cover - platform specific
return None
class MONITORINFO(ctypes.Structure): # pylint: disable=too-few-public-methods # type: ignore[misc]
"""Win32 MONITORINFO structure for GetMonitorInfoW API."""
_fields_ = [
("cbSize", wintypes.DWORD),
("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD),
]
windll = getattr(ctypes, "windll", None)
if windll is None:
return None
try:
# Get widget's HWND - may fail on destroyed widgets or virtual displays
hwnd = widget.winfo_id()
except tk.TclError:
return None
# MONITOR_DEFAULTTONEAREST: If the window is not on any monitor, return nearest monitor
MONITOR_DEFAULTTONEAREST = 2 # noqa: N806 # pylint: disable=invalid-name
monitor_handle = windll.user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
if not monitor_handle:
return None
monitor_info = MONITORINFO()
monitor_info.cbSize = ctypes.sizeof(MONITORINFO) # pylint: disable=invalid-name,attribute-defined-outside-init
if windll.user32.GetMonitorInfoW(monitor_handle, ctypes.byref(monitor_info)) == 0:
return None
rect = monitor_info.rcMonitor
bounds = MonitorBounds(rect.left, rect.top, rect.right, rect.bottom)
# Validate bounds before returning
if not _is_valid_monitor_bounds(bounds):
return None
return bounds
def _get_appkit_screens() -> Optional[Any]: # noqa: ANN401
"""
Get NSScreen object from AppKit, return None if unavailable.
AppKit is macOS-specific framework for accessing screen information.
This function safely imports NSScreen without failing on other platforms.
Returns:
NSScreen class if on macOS with AppKit available, None otherwise
Note:
The import is done at runtime to avoid import errors on non-macOS platforms.
"""
try:
import importlib # pylint: disable=import-outside-toplevel # noqa: PLC0415 # type: ignore[import-outside-toplevel]
appkit = importlib.import_module("AppKit")
except ImportError: # pragma: no cover - platform specific
return None
return getattr(appkit, "NSScreen", None)
def _convert_cocoa_to_tk_bounds(frame: Any, primary_height: int) -> MonitorBounds: # noqa: ANN401
"""
Convert Cocoa screen frame (bottom-left origin) to Tk bounds (top-left origin).
macOS uses Cocoa coordinate system with origin at bottom-left,
while Tk uses origin at top-left. This function performs the conversion.
Args:
frame: NSScreen.frame() object containing origin and size
primary_height: Height of primary screen in pixels, used for Y-axis conversion
Returns:
MonitorBounds with Tk-compatible top-left origin coordinates
Example:
Cocoa: (0, 0) is bottom-left
Tk: (0, 0) is top-left
For a 1080p screen:
- Cocoa Y=0 → Tk Y=1080
- Cocoa Y=1080 → Tk Y=0
"""
try:
# Extract Cocoa coordinates (bottom-left origin)
cocoa_x = int(frame.origin.x)
cocoa_y = int(frame.origin.y) # Distance from bottom of primary screen
width = int(frame.size.width)
height = int(frame.size.height)
except (AttributeError, ValueError, TypeError) as e:
# AttributeError: frame missing expected origin/size attributes
# ValueError/TypeError: Conversion to int failed
msg = f"Invalid AppKit frame object: {e}"
raise ValueError(msg) from e
# Convert Y coordinate: bottom-left → top-left
# Formula: y_top = primary_height - y_bottom - height
tk_x = cocoa_x
tk_y = primary_height - (cocoa_y + height)
return MonitorBounds(tk_x, tk_y, tk_x + width, tk_y + height)
def _find_screen_containing_point(
screens: Any, # noqa: ANN401
center_x: int,
center_y: int,
primary_height: int,
) -> Optional[MonitorBounds]:
"""
Find which screen contains the given point and return its bounds.
Iterates through all screens to find the one containing the specified point.
Used for multi-monitor setups to determine which screen hosts a widget.
Args:
screens: Array of NSScreen objects from AppKit
center_x: X coordinate of point to test (in Tk coordinates)
center_y: Y coordinate of point to test (in Tk coordinates)
primary_height: Height of primary screen for coordinate conversion
Returns:
MonitorBounds of the screen containing the point, or None if not found
Note:
The function performs bounds validation to ensure screen dimensions are reasonable.
"""
for screen in screens:
frame = screen.frame()
bounds = _convert_cocoa_to_tk_bounds(frame, primary_height)
# Check if point is within this screen's rectangle
point_in_screen = bounds.left <= center_x < bounds.right and bounds.top <= center_y < bounds.bottom
# Return first valid screen containing the point
if point_in_screen and _is_valid_monitor_bounds(bounds):
return bounds
return None
def _monitor_bounds_macos(widget: tk.Misc) -> Optional[MonitorBounds]:
"""Use AppKit to detect monitor bounds, converting from Cocoa (bottom-left) to Tk (top-left) coordinates."""
ns_screen = _get_appkit_screens()
if ns_screen is None:
return None
try:
screens = ns_screen.screens()
if not screens or len(screens) == 0:
return None
toplevel = widget.winfo_toplevel()
center_x = toplevel.winfo_rootx() + (toplevel.winfo_width() // 2)
center_y = toplevel.winfo_rooty() + (toplevel.winfo_height() // 2)
except (tk.TclError, AttributeError, ValueError, TypeError):
# tk.TclError: Widget destroyed or invalid
# AttributeError: AppKit object missing expected method/property
# ValueError: Invalid coordinate conversion
# TypeError: Unexpected AppKit object type
return None
# Primary screen is screens[0], used for coordinate conversion
primary_height = int(screens[0].frame().size.height)
return _find_screen_containing_point(screens, center_x, center_y, primary_height)
def get_monitor_bounds(widget: tk.Misc) -> MonitorBounds:
"""
Return validated cached monitor bounds using platform APIs with Tk fallback.
This is the main entry point for getting screen bounds. It uses a three-tier approach:
1. Check cache for previously computed bounds (fast path)
2. Query platform-specific APIs (Windows: Win32, macOS: AppKit)
3. Fallback to Tk virtual root (cross-platform but less accurate)
Args:
widget: Any Tkinter widget whose monitor bounds are needed
Returns:
MonitorBounds containing the screen's left, top, right, bottom coordinates
Note:
Results are cached per toplevel window using WeakKeyDictionary.
Cache automatically invalidates when toplevel is destroyed.
Example:
>>> button = ttk.Button(root, text="Click me")
>>> bounds = get_monitor_bounds(button)
>>> print(f"Monitor size: {bounds.right - bounds.left}x{bounds.bottom - bounds.top}")
"""
# Try to get toplevel and check cache
try:
toplevel = widget.winfo_toplevel()
if hasattr(toplevel, "winfo_id") and toplevel in _monitor_bounds_cache:
cached_bounds = _monitor_bounds_cache[toplevel]
# Invalidate cache if widget moved outside cached bounds (likely different monitor)
try:
current_x = toplevel.winfo_rootx()
current_y = toplevel.winfo_rooty()
# Check if toplevel is still within cached monitor bounds
if (
cached_bounds.left <= current_x < cached_bounds.right
and cached_bounds.top <= current_y < cached_bounds.bottom
):
return cached_bounds
except tk.TclError:
pass
except tk.TclError:
toplevel = None
# Query platform-specific API for accurate multi-monitor support
bounds = None
if platform_system() == "Windows":
bounds = _get_validated_bounds(_monitor_bounds_windows(widget))
elif platform_system() == "Darwin":
bounds = _get_validated_bounds(_monitor_bounds_macos(widget))
# Fallback to Tk virtual root if platform API failed
if bounds is None:
bounds = _monitor_bounds_tk(widget)
# Cache the result for future calls (until toplevel is destroyed)
if toplevel is not None:
try:
if hasattr(toplevel, "winfo_id"):
_monitor_bounds_cache[toplevel] = bounds
except tk.TclError:
# Widget destroyed during caching, ignore
pass
return bounds
def show_error_message(title: str, message: str, root: Optional[tk.Tk] = None) -> None:
if root is None:
root = tk.Tk(className="ArduPilotMethodicConfigurator")
# Set the theme to 'alt'
style = ttk.Style()
style.theme_use("alt")
root.withdraw() # Hide the main window
messagebox.showerror(title, message)
root.destroy()
else:
messagebox.showerror(title, message)
def show_warning_message(title: str, message: str, root: Optional[tk.Tk] = None) -> None:
if root is None:
root = tk.Tk(className="ArduPilotMethodicConfigurator")
# Set the theme to 'alt'
style = ttk.Style()
style.theme_use("alt")
root.withdraw() # Hide the main window
messagebox.showwarning(title, message)
root.destroy()
else:
messagebox.showwarning(title, message)
def show_no_param_files_error(_dirname: str) -> None:
error_message = _(
"No intermediate parameter files found in the selected '{_dirname}' vehicle directory.\n"
"Please select and step inside a vehicle directory containing valid ArduPilot intermediate parameter files.\n\n"
"Make sure to step inside the directory (double-click) and not just select it."
)
show_error_message(_("No Parameter Files Found"), error_message.format(**locals()))
def show_no_connection_error(_error_string: str) -> None:
error_message = _("{_error_string}\n\nPlease connect a flight controller to the PC,\nwait at least 7 seconds and retry.")
show_error_message(_("No Connection to the Flight Controller"), error_message.format(**locals()))
def calculate_tooltip_position( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
widget_x: int,
widget_y: int,
widget_width: int,
widget_height: int,
tooltip_width: int,
tooltip_height: int,
area_left: int,
area_top: int,
area_width: int,
area_height: int,
position_below: bool,
) -> tuple[int, int]:
"""Calculate the tooltip position constrained to the provided rectangular area."""
area_right = area_left + area_width
area_bottom = area_top + area_height
x = widget_x + min(widget_width // 2, TOOLTIP_MAX_OFFSET)
y = widget_y + (widget_height if position_below else -TOOLTIP_VERTICAL_OFFSET)
# Keep tooltip inside the allowed horizontal span
if x + tooltip_width > area_right:
x = area_right - tooltip_width
x = max(x, area_left)
# Keep tooltip inside the allowed vertical span
if y + tooltip_height > area_bottom:
y = area_bottom - tooltip_height
y = max(y, area_top)
return x, y
class Tooltip:
"""
A tooltip class for displaying tooltips on widgets.
Creates a tooltip that appears when the mouse hovers over a widget and disappears when the mouse leaves the widget.
"""
def __init__( # pylint: disable=too-many-arguments, too-many-positional-arguments
self,
widget: tk.Widget,
text: str,
position_below: bool = True,
tag_name: str = "",
toplevel_class: Optional[type] = None,
) -> None:
self.widget: tk.Widget = widget
self.text: str = text
self.tooltip: Optional[tk.Toplevel] = None
self.position_below: bool = position_below
self.toplevel_class = toplevel_class or tk.Toplevel
self.hide_timer: Optional[str] = None
# Bind the <Enter> and <Leave> events to show and hide the tooltip
if platform_system() == "Darwin":
# On macOS, only create the tooltip when the mouse enters the widget
if tag_name and isinstance(self.widget, tk.Text):
self.widget.tag_bind(tag_name, "<Enter>", self.create_show, "+")
self.widget.tag_bind(tag_name, "<Leave>", self.destroy_hide, "+")
else:
self.widget.bind("<Enter>", self.create_show, "+")
self.widget.bind("<Leave>", self.destroy_hide, "+")
else:
if tag_name and isinstance(self.widget, tk.Text):
self.widget.tag_bind(tag_name, "<Enter>", self.show, "+")
self.widget.tag_bind(tag_name, "<Leave>", self.hide, "+")
else:
self.widget.bind("<Enter>", self.show, "+")
self.widget.bind("<Leave>", self.hide, "+")
# On non-macOS, create the tooltip immediately and show/hide it on events
self.tooltip = cast("tk.Toplevel", self.toplevel_class(widget))
self.tooltip.wm_overrideredirect(boolean=True)
tooltip_label = ttk.Label(
self.tooltip, text=text, background="#ffffe0", relief="solid", borderwidth=1, justify=tk.LEFT
)
tooltip_label.pack()
self.tooltip.withdraw() # Initially hide the tooltip
# Bind to tooltip to prevent hiding when mouse is over it
self.tooltip.bind("<Enter>", self._cancel_hide)
self.tooltip.bind("<Leave>", self.hide)
def show(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
"""On non-macOS, tooltip already exists, show it on events."""
self._cancel_hide()
if self.tooltip:
self.position_tooltip()
self.tooltip.deiconify()
def _cancel_hide(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
"""Cancel the hide timer."""
if self.hide_timer:
self.widget.after_cancel(self.hide_timer)
self.hide_timer = None
def create_show(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
"""On macOS, only create the tooltip when the mouse enters the widget."""
if self.tooltip:
return # Avoid redundant tooltip creation
self.tooltip = cast("tk.Toplevel", self.toplevel_class(self.widget))
try:
self.tooltip.tk.call(
"::tk::unsupported::MacWindowStyle",
"style",
self.tooltip._w, # type: ignore[attr-defined] # noqa: SLF001 # pylint: disable=protected-access
"help",
"noActivates",
)
self.tooltip.configure(bg="#ffffe0")
except AttributeError: # Catches protected member access error
self.tooltip.wm_attributes("-alpha", 1.0) # Ensure opacity
self.tooltip.wm_attributes("-topmost", True) # Keep on top # noqa: FBT003
self.tooltip.configure(bg="#ffffe0")
tooltip_label = ttk.Label(
self.tooltip, text=self.text, background="#ffffe0", relief="solid", borderwidth=1, justify=tk.LEFT
)
tooltip_label.pack()
self.position_tooltip()
# Bind to tooltip to prevent hiding when mouse is over it
self.tooltip.bind("<Enter>", self._cancel_hide)
self.tooltip.bind("<Leave>", self.destroy_hide)
def position_tooltip(self) -> None:
"""Position tooltip within monitor bounds, handling widget destruction gracefully."""
if not self.tooltip:
return
try:
# Ensure tooltip geometry is calculated
self.tooltip.update_idletasks()
tooltip_width = self.tooltip.winfo_reqwidth()
tooltip_height = self.tooltip.winfo_reqheight()
toplevel = self.widget.winfo_toplevel()
monitor_left, monitor_top, monitor_right, monitor_bottom = get_monitor_bounds(toplevel)
monitor_width = monitor_right - monitor_left
monitor_height = monitor_bottom - monitor_top
x, y = calculate_tooltip_position(
self.widget.winfo_rootx(),
self.widget.winfo_rooty(),
self.widget.winfo_width(),
self.widget.winfo_height(),
tooltip_width,
tooltip_height,
monitor_left,
monitor_top,
monitor_width,
monitor_height,
self.position_below,
)
self.tooltip.geometry(f"+{x}+{y}")
except tk.TclError:
# Widget or tooltip was destroyed during positioning
# Silently ignore - tooltip will be recreated on next hover if needed
pass
def hide(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
"""Hide the tooltip after a delay on non-macOS."""
self._cancel_hide()
self.hide_timer = self.widget.after(10, self._do_hide)
def _do_hide(self) -> None:
"""Actually hide or destroy the tooltip depending on platform."""
if self.tooltip:
self.tooltip.withdraw()
self.hide_timer = None
def destroy_hide(self, event: Optional[tk.Event] = None) -> None: # noqa: ARG002 # pylint: disable=unused-argument
"""On macOS, fully destroy the tooltip when the mouse leaves the widget."""
self._cancel_hide()
if self.tooltip:
self.tooltip.destroy()
self.tooltip = None
def show_tooltip(widget: tk.Widget, text: str, position_below: bool = True) -> Tooltip:
return Tooltip(widget, text, position_below=position_below, tag_name="")
def show_tooltip_on_richtext_tag(widget: tk.Text, text: str, tag_name: str, position_below: bool = True) -> Tooltip:
return Tooltip(widget, text, position_below=position_below, tag_name=tag_name)