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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

# from logging import debug as logging_debug
from tkinter import ttk
from typing import Union

# from logging import critical as logging_critical
from ardupilot_methodic_configurator import _
Expand All @@ -22,83 +23,84 @@
)


def show_about_window(root: ttk.Frame, _version: str) -> None:
# Create a new window for the custom "About" message
about_window = tk.Toplevel(root)
about_window.title(_("About"))
about_window.geometry("650x340")

main_frame = ttk.Frame(about_window)
main_frame.pack(expand=True, fill=tk.BOTH)

# Add the "About" message
about_message = _(
"ArduPilot Methodic Configurator Version: {_version}\n\n"
"A clear configuration sequence for ArduPilot vehicles.\n\n"
"Copyright © 2024-2026 Amilcar do Carmo Lucas and ArduPilot.org\n\n"
"Licensed under the GNU General Public License v3.0"
)
about_label = ttk.Label(main_frame, text=about_message.format(**locals()), wraplength=450)
about_label.grid(column=0, row=0, padx=10, pady=10, columnspan=5) # Span across all columns

usage_popup_frame = ttk.Frame(main_frame)
usage_popup_frame.grid(column=0, row=1, columnspan=5, padx=10, pady=10)

usage_popup_label = ttk.Label(usage_popup_frame, text=_("Display usage popup"))
usage_popup_label.pack(side=tk.TOP, anchor=tk.W)

def _create_usage_popup_checkbox(popup_type: str, text: str) -> None:
"""Create a usage popup checkbox for the given popup type."""
var = tk.BooleanVar(value=ProgramSettings.display_usage_popup(popup_type))
checkbox = ttk.Checkbutton(
usage_popup_frame,
text=text,
variable=var,
command=lambda: ProgramSettings.set_display_usage_popup(popup_type, var.get()),
class AboutWindow(BaseWindow):
"""About popup window for the ArduPilot Methodic Configurator."""

def __init__(self, root_tk: Union[tk.Tk, tk.Toplevel], version: str) -> None:
super().__init__(root_tk)
self.root.title(_("About"))
self.root.geometry(self.calculate_scaled_geometry(650, 340))

# Add the "About" message
about_message = _(
"ArduPilot Methodic Configurator Version: {version}\n\n"
"A clear configuration sequence for ArduPilot vehicles.\n\n"
"Copyright © 2024-2026 Amilcar do Carmo Lucas and ArduPilot.org\n\n"
"Licensed under the GNU General Public License v3.0"
)
about_label = ttk.Label(self.main_frame, text=about_message.format(version=version), wraplength=450)
about_label.grid(column=0, row=0, padx=10, pady=10, columnspan=5) # Span across all columns

usage_popup_frame = ttk.Frame(self.main_frame)
usage_popup_frame.grid(column=0, row=1, columnspan=5, padx=10, pady=10)

usage_popup_label = ttk.Label(usage_popup_frame, text=_("Display usage popup"))
usage_popup_label.pack(side=tk.TOP, anchor=tk.W)

def _create_usage_popup_checkbox(popup_type: str, text: str) -> None:
"""Create a usage popup checkbox for the given popup type."""
var = tk.BooleanVar(value=ProgramSettings.display_usage_popup(popup_type))
checkbox = ttk.Checkbutton(
usage_popup_frame,
text=text,
variable=var,
command=lambda: ProgramSettings.set_display_usage_popup(popup_type, var.get()),
)
checkbox.pack(side=tk.TOP, anchor=tk.W)

for popup_id, popup_data in USAGE_POPUP_WINDOWS.items():
_create_usage_popup_checkbox(popup_id, popup_data.description)

# Create buttons for each action
user_manual_button = ttk.Button(
self.main_frame,
text=_("User Manual"),
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/blob/master/USERMANUAL.md"),
)
support_forum_button = ttk.Button(
self.main_frame,
text=_("Support Forum"),
command=lambda: webbrowser_open_url(
"http://discuss.ardupilot.org/t/new-ardupilot-methodic-configurator-gui/115038/1"
),
)
report_bug_button = ttk.Button(
self.main_frame,
text=_("Report a Bug"),
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/issues/new/choose"),
)
checkbox.pack(side=tk.TOP, anchor=tk.W)

for popup_id, popup_data in USAGE_POPUP_WINDOWS.items():
_create_usage_popup_checkbox(popup_id, popup_data.description)

# Create buttons for each action
user_manual_button = ttk.Button(
main_frame,
text=_("User Manual"),
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/blob/master/USERMANUAL.md"),
)
support_forum_button = ttk.Button(
main_frame,
text=_("Support Forum"),
command=lambda: webbrowser_open_url("http://discuss.ardupilot.org/t/new-ardupilot-methodic-configurator-gui/115038/1"),
)
report_bug_button = ttk.Button(
main_frame,
text=_("Report a Bug"),
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator/issues/new/choose"),
)
licenses_button = ttk.Button(
main_frame,
text=_("Licenses"),
command=lambda: webbrowser_open_url(
"https://github.com/ArduPilot/MethodicConfigurator/blob/master/credits/CREDITS.md"
),
)
source_button = ttk.Button(
main_frame,
text=_("Source Code"),
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator"),
)

# Place buttons using grid for equal spacing and better control over layout
user_manual_button.grid(column=0, row=2, padx=10, pady=10)
support_forum_button.grid(column=1, row=2, padx=10, pady=10)
report_bug_button.grid(column=2, row=2, padx=10, pady=10)
licenses_button.grid(column=3, row=2, padx=10, pady=10)
source_button.grid(column=4, row=2, padx=10, pady=10)

# Configure the grid to ensure equal spacing and expansion
main_frame.columnconfigure([0, 1, 2, 3, 4], weight=1)

# Center the about window on its parent
BaseWindow.center_window(about_window, root.winfo_toplevel())
licenses_button = ttk.Button(
self.main_frame,
text=_("Licenses"),
command=lambda: webbrowser_open_url(
"https://github.com/ArduPilot/MethodicConfigurator/blob/master/credits/CREDITS.md"
),
)
source_button = ttk.Button(
self.main_frame,
text=_("Source Code"),
command=lambda: webbrowser_open_url("https://github.com/ArduPilot/MethodicConfigurator"),
)

# Place buttons using grid for equal spacing and better control over layout
user_manual_button.grid(column=0, row=2, padx=10, pady=10)
support_forum_button.grid(column=1, row=2, padx=10, pady=10)
report_bug_button.grid(column=2, row=2, padx=10, pady=10)
licenses_button.grid(column=3, row=2, padx=10, pady=10)
source_button.grid(column=4, row=2, padx=10, pady=10)

# Configure the grid to ensure equal spacing and expansion
self.main_frame.columnconfigure([0, 1, 2, 3, 4], weight=1)

# Center the about window relative to its parent
BaseWindow.center_window(self.root, root_tk)
99 changes: 73 additions & 26 deletions ardupilot_methodic_configurator/frontend_tkinter_base_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@
import io
import os
import tkinter as tk

# from logging import debug as logging_debug
# from logging import info as logging_info
from logging import debug as logging_debug
from logging import error as logging_error
from platform import system as platform_system
from tkinter import messagebox, ttk
Expand Down Expand Up @@ -86,12 +84,12 @@ class BaseWindow:

"""

def __init__(self, root_tk: Optional[tk.Tk] = None) -> None:
def __init__(self, root_tk: Optional[Union[tk.Tk, tk.Toplevel]] = None) -> None:
"""
Initialize a new BaseWindow instance.

Args:
root_tk (Optional[tk.Tk]): Parent window. If None, creates a new root window.
root_tk (Optional[Union[tk.Tk, tk.Toplevel]]): Parent window. If None, creates a new root window.
If provided, creates a Toplevel window as a child.

Note:
Expand Down Expand Up @@ -162,11 +160,12 @@ def _setup_theme_and_styling(self) -> None:
style = ttk.Style()
style.theme_use("alt")

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

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

@staticmethod
def _get_win32_system_dpi() -> int:
"""
Query the real system DPI on Windows via Win32 API.

This bypasses DPI virtualization that affects ``winfo_fpixels`` and
``tk scaling`` when the process is not declared DPI-aware.

Returns:
int: The system DPI (e.g. 96, 144, 192), or 0 if not on Windows or on failure.

"""
try:
import ctypes # noqa: PLC0415 # pylint: disable=import-outside-toplevel

return int(ctypes.windll.user32.GetDpiForSystem())
except (AttributeError, OSError):
return 0

def _get_dpi_scaling_factor(self) -> float:
"""
Detect the DPI scaling factor for HiDPI displays.

This method uses multiple detection approaches to determine the appropriate
scaling factor for the current display configuration:

1. Calculates scaling based on actual DPI vs standard DPI (96)
2. Checks Tkinter's internal scaling factor
3. Uses the maximum of both methods for robustness
On Windows the Tk window is typically DPI-virtualized, meaning that
``winfo_fpixels("1i")`` and ``tk scaling`` both report 96 DPI regardless
of the actual system DPI setting. To work around this the method first
tries to read the real DPI from the Win32 API (``GetDpiForSystem``),
which is never virtualized. On other platforms it falls back to the
Tkinter-based measurements.

Returns:
float: The scaling factor (1.0 for standard DPI, 2.0 for 200% scaling, etc.)
float: The scaling factor (1.0 for 100% / 96 DPI, 1.5 for 150%, etc.)

Note:
Falls back to 1.0 if DPI detection fails, ensuring the application
remains functional even on systems with unusual configurations.
Falls back to 1.0 if all detection methods fail.

"""
standard_dpi = 96.0

# --- Windows: query Win32 API directly (bypasses DPI virtualization) ---
if platform_system() == "Windows":
dpi = self._get_win32_system_dpi()
if dpi > 0:
return max(1.0, dpi / standard_dpi)

# --- Tk-based fallback (reliable on Linux/macOS) ---
try:
# Get the DPI from Tkinter
dpi = self.root.winfo_fpixels("1i") # pixels per inch
# Standard DPI is typically 96, so calculate scaling factor
standard_dpi = 96.0
scaling_factor = dpi / standard_dpi
tk_dpi = self.root.winfo_fpixels("1i") # pixels per inch
scaling_from_dpi = tk_dpi / standard_dpi

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

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

def calculate_scaled_font_size(self, base_size: int) -> int:
Expand Down Expand Up @@ -268,6 +295,26 @@ def calculate_scaled_padding_tuple(self, padding1: int, padding2: int) -> tuple[
"""
return (self.calculate_scaled_padding(padding1), self.calculate_scaled_padding(padding2))

def calculate_scaled_geometry(self, width: int, height: int) -> str:
"""
Calculate a DPI-aware geometry string for use with window.geometry().

Args:
width (int): The base window width in pixels
height (int): The base window height in pixels

Returns:
str: A geometry string 'WxH' with both dimensions scaled for the current display

"""
logging_debug(
_("Calculated geometry for width %d and height %d with scaling factor %.2f"),
width,
height,
self.dpi_scaling_factor,
)
return f"{round(width * self.dpi_scaling_factor)}x{round(height * self.dpi_scaling_factor)}"

@staticmethod
def center_window(window: Union[tk.Toplevel, tk.Tk], parent: Union[tk.Toplevel, tk.Tk]) -> None:
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ def __init__(self, model: BatteryMonitorDataModel) -> None:
self.root.title(_("AMC Battery Monitor plugin test window"))
width = 480
height = 250
self.root.geometry(str(width) + "x" + str(height))
self.root.geometry(self.calculate_scaled_geometry(width, height))

self.view = BatteryMonitorView(self.main_frame, model, self)
self.view.pack(fill="both", expand=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,18 @@ def _setup_window(self) -> None:
self.root.title(
_("Amilcar Lucas's - ArduPilot methodic configurator ") + self.version + _(" - Vehicle Component Editor")
)
self.root.geometry(f"{WINDOW_WIDTH_PIX}x600")
BaseWindow.center_window_on_screen(self.root)
self.root.geometry(self.calculate_scaled_geometry(WINDOW_WIDTH_PIX, 600))
self.center_window_on_screen(self.root)
self.root.protocol("WM_DELETE_WINDOW", self.on_closing)

def _setup_styles(self) -> None:
"""Configure the styles for UI elements."""
style = ttk.Style()
# Tk renders point-based fonts at the correct DPI size automatically;
# do not apply calculate_scaled_font_size here as it would double-scale on HiDPI.
style.configure(
"bigger.TLabel",
font=("TkDefaultFont", self.calculate_scaled_font_size(self.default_font_size)),
font=("TkDefaultFont", self.default_font_size),
)
style.configure("comb_input_invalid.TCombobox", fieldbackground="red")
style.configure("comb_input_valid.TCombobox", fieldbackground="white")
Expand Down Expand Up @@ -642,6 +644,7 @@ def create_for_testing(
instance.template_manager = MagicMock()
instance.complexity_var = MagicMock()
instance.default_font_size = 9 if platform_system() == "Windows" else -12
instance.dpi_scaling_factor = 1.0

return instance

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,9 @@ def __init__(
default_baudrate: int = 115200,
) -> None:
super().__init__()
self.root.title(_("AMC {version} - Flight controller connection").format(version=__version__)) # Set the window title
self.root.geometry("520x462") # Set the window size
BaseWindow.center_window_on_screen(self.root)
self.root.title(_("AMC {version} - Flight controller connection").format(version=__version__))
self.root.geometry(self.calculate_scaled_geometry(520, 380))
self.center_window_on_screen(self.root)
self.default_baudrate = default_baudrate

# Explain why we are here
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ class FlightControllerInfoWindow(BaseWindow):
def __init__(self, flight_controller: FlightController, vehicle_dir: Path) -> None:
super().__init__()
self.root.title(_("AMC {version} - Flight Controller Info").format(version=__version__)) # Set the window title
self.root.geometry("500x420") # Adjust the window size as needed
BaseWindow.center_window_on_screen(self.root)
self.root.geometry(self.calculate_scaled_geometry(500, 420)) # Adjust the window size as needed
self.center_window_on_screen(self.root)

self.presenter = FlightControllerInfoPresenter(flight_controller, vehicle_dir)

Expand Down
Loading
Loading