Skip to content

Commit e2fbc97

Browse files
committed
feat(gui): correct HiDPI scaling via Win32 API and centralize geometry calculations
- Add _get_win32_system_dpi() to bypass DPI virtualization on Windows - Add calculate_scaled_geometry() helper to BaseWindow; use it in all windows - Remove double-scaling of point-based fonts (Tk handles them automatically) - Refactor show_about_window() function into AboutWindow(BaseWindow) class - Fix Treeview column-width and heading-padding measurements in TemplateOverviewWindow Implements #544
1 parent 0e70f83 commit e2fbc97

20 files changed

+242
-161
lines changed

ardupilot_methodic_configurator/frontend_tkinter_about_popup_window.py

Lines changed: 81 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
# from logging import debug as logging_debug
1414
from tkinter import ttk
15+
from typing import Union
1516

1617
# from logging import critical as logging_critical
1718
from ardupilot_methodic_configurator import _
@@ -22,83 +23,84 @@
2223
)
2324

2425

25-
def show_about_window(root: ttk.Frame, _version: str) -> None:
26-
# Create a new window for the custom "About" message
27-
about_window = tk.Toplevel(root)
28-
about_window.title(_("About"))
29-
about_window.geometry("650x340")
30-
31-
main_frame = ttk.Frame(about_window)
32-
main_frame.pack(expand=True, fill=tk.BOTH)
33-
34-
# Add the "About" message
35-
about_message = _(
36-
"ArduPilot Methodic Configurator Version: {_version}\n\n"
37-
"A clear configuration sequence for ArduPilot vehicles.\n\n"
38-
"Copyright © 2024-2026 Amilcar do Carmo Lucas and ArduPilot.org\n\n"
39-
"Licensed under the GNU General Public License v3.0"
40-
)
41-
about_label = ttk.Label(main_frame, text=about_message.format(**locals()), wraplength=450)
42-
about_label.grid(column=0, row=0, padx=10, pady=10, columnspan=5) # Span across all columns
43-
44-
usage_popup_frame = ttk.Frame(main_frame)
45-
usage_popup_frame.grid(column=0, row=1, columnspan=5, padx=10, pady=10)
46-
47-
usage_popup_label = ttk.Label(usage_popup_frame, text=_("Display usage popup"))
48-
usage_popup_label.pack(side=tk.TOP, anchor=tk.W)
49-
50-
def _create_usage_popup_checkbox(popup_type: str, text: str) -> None:
51-
"""Create a usage popup checkbox for the given popup type."""
52-
var = tk.BooleanVar(value=ProgramSettings.display_usage_popup(popup_type))
53-
checkbox = ttk.Checkbutton(
54-
usage_popup_frame,
55-
text=text,
56-
variable=var,
57-
command=lambda: ProgramSettings.set_display_usage_popup(popup_type, var.get()),
26+
class AboutWindow(BaseWindow):
27+
"""About popup window for the ArduPilot Methodic Configurator."""
28+
29+
def __init__(self, root_tk: Union[tk.Tk, tk.Toplevel], version: str) -> None:
30+
super().__init__(root_tk)
31+
self.root.title(_("About"))
32+
self.root.geometry(self.calculate_scaled_geometry(650, 340))
33+
34+
# Add the "About" message
35+
about_message = _(
36+
"ArduPilot Methodic Configurator Version: {version}\n\n"
37+
"A clear configuration sequence for ArduPilot vehicles.\n\n"
38+
"Copyright © 2024-2026 Amilcar do Carmo Lucas and ArduPilot.org\n\n"
39+
"Licensed under the GNU General Public License v3.0"
40+
)
41+
about_label = ttk.Label(self.main_frame, text=about_message.format(version=version), wraplength=450)
42+
about_label.grid(column=0, row=0, padx=10, pady=10, columnspan=5) # Span across all columns
43+
44+
usage_popup_frame = ttk.Frame(self.main_frame)
45+
usage_popup_frame.grid(column=0, row=1, columnspan=5, padx=10, pady=10)
46+
47+
usage_popup_label = ttk.Label(usage_popup_frame, text=_("Display usage popup"))
48+
usage_popup_label.pack(side=tk.TOP, anchor=tk.W)
49+
50+
def _create_usage_popup_checkbox(popup_type: str, text: str) -> None:
51+
"""Create a usage popup checkbox for the given popup type."""
52+
var = tk.BooleanVar(value=ProgramSettings.display_usage_popup(popup_type))
53+
checkbox = ttk.Checkbutton(
54+
usage_popup_frame,
55+
text=text,
56+
variable=var,
57+
command=lambda: ProgramSettings.set_display_usage_popup(popup_type, var.get()),
58+
)
59+
checkbox.pack(side=tk.TOP, anchor=tk.W)
60+
61+
for popup_id, popup_data in USAGE_POPUP_WINDOWS.items():
62+
_create_usage_popup_checkbox(popup_id, popup_data.description)
63+
64+
# Create buttons for each action
65+
user_manual_button = ttk.Button(
66+
self.main_frame,
67+
text=_("User Manual"),
68+
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/blob/master/USERMANUAL.md"),
69+
)
70+
support_forum_button = ttk.Button(
71+
self.main_frame,
72+
text=_("Support Forum"),
73+
command=lambda: webbrowser_open_url(
74+
"http://discuss.ardupilot.org/t/new-ardupilot-methodic-configurator-gui/115038/1"
75+
),
76+
)
77+
report_bug_button = ttk.Button(
78+
self.main_frame,
79+
text=_("Report a Bug"),
80+
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/issues/new/choose"),
5881
)
59-
checkbox.pack(side=tk.TOP, anchor=tk.W)
60-
61-
for popup_id, popup_data in USAGE_POPUP_WINDOWS.items():
62-
_create_usage_popup_checkbox(popup_id, popup_data.description)
63-
64-
# Create buttons for each action
65-
user_manual_button = ttk.Button(
66-
main_frame,
67-
text=_("User Manual"),
68-
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/blob/master/USERMANUAL.md"),
69-
)
70-
support_forum_button = ttk.Button(
71-
main_frame,
72-
text=_("Support Forum"),
73-
command=lambda: webbrowser_open_url("http://discuss.ardupilot.org/t/new-ardupilot-methodic-configurator-gui/115038/1"),
74-
)
75-
report_bug_button = ttk.Button(
76-
main_frame,
77-
text=_("Report a Bug"),
78-
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/issues/new/choose"),
79-
)
80-
licenses_button = ttk.Button(
81-
main_frame,
82-
text=_("Licenses"),
83-
command=lambda: webbrowser_open_url(
84-
"https://github.com/ArduPilot/MethodicConfigurator/blob/master/credits/CREDITS.md"
85-
),
86-
)
87-
source_button = ttk.Button(
88-
main_frame,
89-
text=_("Source Code"),
90-
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator"),
91-
)
92-
93-
# Place buttons using grid for equal spacing and better control over layout
94-
user_manual_button.grid(column=0, row=2, padx=10, pady=10)
95-
support_forum_button.grid(column=1, row=2, padx=10, pady=10)
96-
report_bug_button.grid(column=2, row=2, padx=10, pady=10)
97-
licenses_button.grid(column=3, row=2, padx=10, pady=10)
98-
source_button.grid(column=4, row=2, padx=10, pady=10)
99-
100-
# Configure the grid to ensure equal spacing and expansion
101-
main_frame.columnconfigure([0, 1, 2, 3, 4], weight=1)
102-
103-
# Center the about window on its parent
104-
BaseWindow.center_window(about_window, root.winfo_toplevel())
82+
licenses_button = ttk.Button(
83+
self.main_frame,
84+
text=_("Licenses"),
85+
command=lambda: webbrowser_open_url(
86+
"https://github.com/ArduPilot/MethodicConfigurator/blob/master/credits/CREDITS.md"
87+
),
88+
)
89+
source_button = ttk.Button(
90+
self.main_frame,
91+
text=_("Source Code"),
92+
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator"),
93+
)
94+
95+
# Place buttons using grid for equal spacing and better control over layout
96+
user_manual_button.grid(column=0, row=2, padx=10, pady=10)
97+
support_forum_button.grid(column=1, row=2, padx=10, pady=10)
98+
report_bug_button.grid(column=2, row=2, padx=10, pady=10)
99+
licenses_button.grid(column=3, row=2, padx=10, pady=10)
100+
source_button.grid(column=4, row=2, padx=10, pady=10)
101+
102+
# Configure the grid to ensure equal spacing and expansion
103+
self.main_frame.columnconfigure([0, 1, 2, 3, 4], weight=1)
104+
105+
# Center the about window relative to its parent
106+
BaseWindow.center_window(self.root, root_tk)

ardupilot_methodic_configurator/frontend_tkinter_base_window.py

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@
2323
import io
2424
import os
2525
import tkinter as tk
26-
27-
# from logging import debug as logging_debug
28-
# from logging import info as logging_info
26+
from logging import debug as logging_debug
2927
from logging import error as logging_error
3028
from platform import system as platform_system
3129
from tkinter import messagebox, ttk
@@ -86,12 +84,12 @@ class BaseWindow:
8684
8785
"""
8886

89-
def __init__(self, root_tk: Optional[tk.Tk] = None) -> None:
87+
def __init__(self, root_tk: Optional[Union[tk.Tk, tk.Toplevel]] = None) -> None:
9088
"""
9189
Initialize a new BaseWindow instance.
9290
9391
Args:
94-
root_tk (Optional[tk.Tk]): Parent window. If None, creates a new root window.
92+
root_tk (Optional[Union[tk.Tk, tk.Toplevel]]): Parent window. If None, creates a new root window.
9593
If provided, creates a Toplevel window as a child.
9694
9795
Note:
@@ -162,11 +160,12 @@ def _setup_theme_and_styling(self) -> None:
162160
style = ttk.Style()
163161
style.theme_use("alt")
164162

165-
# Create custom styles with DPI-aware font sizes
163+
# Create custom styles - use the system font size directly.
164+
# Tk renders point-based fonts at the correct DPI size automatically;
165+
# applying calculate_scaled_font_size here would double-scale on HiDPI displays.
166166
self.default_font_size = get_safe_font_size()
167167
# Warning: on linux the font size might be negative
168-
bold_font_size = self.calculate_scaled_font_size(self.default_font_size)
169-
style.configure("Bold.TLabel", font=("TkDefaultFont", bold_font_size, "bold"))
168+
style.configure("Bold.TLabel", font=("TkDefaultFont", self.default_font_size, "bold"))
170169

171170
# Configure Entry and Combobox styles with explicit foreground colors
172171
# This prevents white-on-white text issues on Linux with dark themes
@@ -180,39 +179,67 @@ def _setup_theme_and_styling(self) -> None:
180179
style.map("default_v.TCombobox", selectbackground=[("readonly", "light blue")])
181180
style.map("default_v.TCombobox", selectforeground=[("readonly", "black")])
182181

182+
@staticmethod
183+
def _get_win32_system_dpi() -> int:
184+
"""
185+
Query the real system DPI on Windows via Win32 API.
186+
187+
This bypasses DPI virtualization that affects ``winfo_fpixels`` and
188+
``tk scaling`` when the process is not declared DPI-aware.
189+
190+
Returns:
191+
int: The system DPI (e.g. 96, 144, 192), or 0 if not on Windows or on failure.
192+
193+
"""
194+
try:
195+
import ctypes # noqa: PLC0415 # pylint: disable=import-outside-toplevel
196+
197+
return int(ctypes.windll.user32.GetDpiForSystem())
198+
except (AttributeError, OSError):
199+
return 0
200+
183201
def _get_dpi_scaling_factor(self) -> float:
184202
"""
185203
Detect the DPI scaling factor for HiDPI displays.
186204
187-
This method uses multiple detection approaches to determine the appropriate
188-
scaling factor for the current display configuration:
189-
190-
1. Calculates scaling based on actual DPI vs standard DPI (96)
191-
2. Checks Tkinter's internal scaling factor
192-
3. Uses the maximum of both methods for robustness
205+
On Windows the Tk window is typically DPI-virtualized, meaning that
206+
``winfo_fpixels("1i")`` and ``tk scaling`` both report 96 DPI regardless
207+
of the actual system DPI setting. To work around this the method first
208+
tries to read the real DPI from the Win32 API (``GetDpiForSystem``),
209+
which is never virtualized. On other platforms it falls back to the
210+
Tkinter-based measurements.
193211
194212
Returns:
195-
float: The scaling factor (1.0 for standard DPI, 2.0 for 200% scaling, etc.)
213+
float: The scaling factor (1.0 for 100% / 96 DPI, 1.5 for 150%, etc.)
196214
197215
Note:
198-
Falls back to 1.0 if DPI detection fails, ensuring the application
199-
remains functional even on systems with unusual configurations.
216+
Falls back to 1.0 if all detection methods fail.
200217
201218
"""
219+
standard_dpi = 96.0
220+
221+
# --- Windows: query Win32 API directly (bypasses DPI virtualization) ---
222+
if platform_system() == "Windows":
223+
dpi = self._get_win32_system_dpi()
224+
if dpi > 0:
225+
return max(1.0, dpi / standard_dpi)
226+
227+
# --- Tk-based fallback (reliable on Linux/macOS) ---
202228
try:
203-
# Get the DPI from Tkinter
204-
dpi = self.root.winfo_fpixels("1i") # pixels per inch
205-
# Standard DPI is typically 96, so calculate scaling factor
206-
standard_dpi = 96.0
207-
scaling_factor = dpi / standard_dpi
229+
tk_dpi = self.root.winfo_fpixels("1i") # pixels per inch
230+
scaling_from_dpi = tk_dpi / standard_dpi
208231

209-
# Also check the tk scaling factor which might be set by the system
232+
# tk scaling returns pixels/point (72 points/inch).
233+
# Normalize to the 96 DPI baseline: at standard 96 DPI, tk scaling = 96/72 = 1.333.
234+
# Multiply by (72/96) converts it to a Windows-style scale factor (1.0 at 100%).
210235
tk_scaling = float(self.root.tk.call("tk", "scaling"))
236+
scaling_from_tk = tk_scaling * 72.0 / standard_dpi
211237

212-
# Use the maximum of both methods to ensure we catch HiDPI scaling
213-
return max(scaling_factor, tk_scaling)
238+
# Use the maximum of both normalized methods for robustness.
239+
# The 1.0 floor prevents accidentally scaling windows *down* on platforms
240+
# that report sub-96 DPI (e.g. macOS, which uses 72 pt/inch as its baseline).
241+
return max(1.0, scaling_from_dpi, scaling_from_tk)
214242
except (tk.TclError, AttributeError):
215-
# Fallback to 1.0 if detection fails
216243
return 1.0
217244

218245
def calculate_scaled_font_size(self, base_size: int) -> int:
@@ -268,6 +295,26 @@ def calculate_scaled_padding_tuple(self, padding1: int, padding2: int) -> tuple[
268295
"""
269296
return (self.calculate_scaled_padding(padding1), self.calculate_scaled_padding(padding2))
270297

298+
def calculate_scaled_geometry(self, width: int, height: int) -> str:
299+
"""
300+
Calculate a DPI-aware geometry string for use with window.geometry().
301+
302+
Args:
303+
width (int): The base window width in pixels
304+
height (int): The base window height in pixels
305+
306+
Returns:
307+
str: A geometry string 'WxH' with both dimensions scaled for the current display
308+
309+
"""
310+
logging_debug(
311+
_("Calculated geometry for width %d and height %d with scaling factor %.2f"),
312+
width,
313+
height,
314+
self.dpi_scaling_factor,
315+
)
316+
return f"{round(width * self.dpi_scaling_factor)}x{round(height * self.dpi_scaling_factor)}"
317+
271318
@staticmethod
272319
def center_window(window: Union[tk.Toplevel, tk.Tk], parent: Union[tk.Toplevel, tk.Tk]) -> None:
273320
"""

ardupilot_methodic_configurator/frontend_tkinter_battery_monitor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ def __init__(self, model: BatteryMonitorDataModel) -> None:
342342
self.root.title(_("AMC Battery Monitor plugin test window"))
343343
width = 480
344344
height = 250
345-
self.root.geometry(str(width) + "x" + str(height))
345+
self.root.geometry(self.calculate_scaled_geometry(width, height))
346346

347347
self.view = BatteryMonitorView(self.main_frame, model, self)
348348
self.view.pack(fill="both", expand=True)

ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,18 @@ def _setup_window(self) -> None:
152152
self.root.title(
153153
_("Amilcar Lucas's - ArduPilot methodic configurator ") + self.version + _(" - Vehicle Component Editor")
154154
)
155-
self.root.geometry(f"{WINDOW_WIDTH_PIX}x600")
155+
self.root.geometry(self.calculate_scaled_geometry(WINDOW_WIDTH_PIX, 600))
156156
BaseWindow.center_window_on_screen(self.root)
157157
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
158158

159159
def _setup_styles(self) -> None:
160160
"""Configure the styles for UI elements."""
161161
style = ttk.Style()
162+
# Tk renders point-based fonts at the correct DPI size automatically;
163+
# do not apply calculate_scaled_font_size here as it would double-scale on HiDPI.
162164
style.configure(
163165
"bigger.TLabel",
164-
font=("TkDefaultFont", self.calculate_scaled_font_size(self.default_font_size)),
166+
font=("TkDefaultFont", self.default_font_size),
165167
)
166168
style.configure("comb_input_invalid.TCombobox", fieldbackground="red")
167169
style.configure("comb_input_valid.TCombobox", fieldbackground="white")
@@ -642,6 +644,7 @@ def create_for_testing(
642644
instance.template_manager = MagicMock()
643645
instance.complexity_var = MagicMock()
644646
instance.default_font_size = 9 if platform_system() == "Windows" else -12
647+
instance.dpi_scaling_factor = 1.0
645648

646649
return instance
647650

ardupilot_methodic_configurator/frontend_tkinter_connection_selection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def __init__(
316316
) -> None:
317317
super().__init__()
318318
self.root.title(_("AMC {version} - Flight controller connection").format(version=__version__)) # Set the window title
319-
self.root.geometry("520x462") # Set the window size
319+
self.root.geometry(self.calculate_scaled_geometry(520, 462)) # Set the window size
320320
BaseWindow.center_window_on_screen(self.root)
321321
self.default_baudrate = default_baudrate
322322

ardupilot_methodic_configurator/frontend_tkinter_flightcontroller_info.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class FlightControllerInfoWindow(BaseWindow):
7575
def __init__(self, flight_controller: FlightController, vehicle_dir: Path) -> None:
7676
super().__init__()
7777
self.root.title(_("AMC {version} - Flight Controller Info").format(version=__version__)) # Set the window title
78-
self.root.geometry("500x420") # Adjust the window size as needed
78+
self.root.geometry(self.calculate_scaled_geometry(500, 420)) # Adjust the window size as needed
7979
BaseWindow.center_window_on_screen(self.root)
8080

8181
self.presenter = FlightControllerInfoPresenter(flight_controller, vehicle_dir)

ardupilot_methodic_configurator/frontend_tkinter_motor_test.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -752,9 +752,7 @@ def __init__(self, model: MotorTestDataModel) -> None:
752752
super().__init__()
753753
self.model = model # Store model reference for tests
754754
self.root.title(_("ArduPilot Motor Test"))
755-
width = self.calculate_scaled_image_size(400)
756-
height = self.calculate_scaled_image_size(610)
757-
self.root.geometry(str(width) + "x" + str(height))
755+
self.root.geometry(self.calculate_scaled_geometry(400, 610))
758756

759757
self.view = MotorTestView(self.main_frame, model, self)
760758

0 commit comments

Comments
 (0)