mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
Make the setup window DPI aware so Windows does not stretch it
The window ran in a DPI-unaware process, so Windows drew it at 96 DPI and stretched the result on a display at 125% or more, which is why it was blurry at 200%. The process is now system DPI aware before Tk starts (installer/dpi.py: Tk 9 has no per-monitor support, so system awareness is what it can honour); Tk then sizes the fonts with the display's DPI and dpi.px sizes the paddings, the wrap widths and the progress bar with it. The launcher makes its own process aware the same way for its error box. tests/installer_gui_test.py checks that a fresh process ends up aware and that Tk's scaling follows the display. Co-Authored-By: DeepSeek V4.1 Flash 1017 (DeepSeek Harness) <noreply@deepseek.com>
This commit is contained in:
1 parent
8f1ac244b5
commit
315064b3ec
5 files changed
+232
-54
No files matched your search
+10
-4
@@ -128,9 +128,12 @@ python -m installer --base FILE --update FILE --keys FILE --lang CODE --dest DIR
|
||||
English as the game language, because Chinese game text needs the Switch shared fonts, which the setup extracts only
|
||||
from a firmware folder. A folder with
|
||||
`totk-install.json` needs only the folder. `--selftest [--shots DIR]` builds every screen in every language and
|
||||
closes itself.
|
||||
- `tests/installer_gui_test.py`: event model, form, catalogs, cancel, the self-closing window and one end-to-end run
|
||||
of the CLI in `--verify` mode (needs `build/install-test` and `build/extracted/v121`; see its docstring).
|
||||
closes itself. The window is not stretched on a display whose scale is the system DPI: `installer/dpi.py` makes the
|
||||
process system DPI aware before Tk starts, Tk then draws the fonts at the display's scale and `dpi.px` sizes the
|
||||
paddings and wrap widths with it; the launcher does the same for its error box.
|
||||
- `tests/installer_gui_test.py`: event model, form, catalogs, cancel, the display scale, the self-closing window and
|
||||
one end-to-end run of the CLI in `--verify` mode (needs `build/install-test` and `build/extracted/v121`; see its
|
||||
docstring).
|
||||
- `tests/installer_test.py`: bookkeeping with fake steps. `tests/installer_e2e.py`: a dev-mode run with the files of
|
||||
`config.local.toml`.
|
||||
|
||||
@@ -209,7 +212,8 @@ bin/ totk_xlate, hactool, ninja, libSDL3.so.0
|
||||
|
||||
## Constraints
|
||||
|
||||
- The installer steps contain no Win32 calls: Linux runs the same steps with its own compiler ("Linux release").
|
||||
- The installer steps contain no Win32 calls; only the window's DPI awareness (`installer/dpi.py`) is Windows-only, and
|
||||
it does nothing elsewhere. Linux runs the same steps with its own compiler ("Linux release").
|
||||
- The keys file path is passed to the tools; key values never appear in the UI, logs or files.
|
||||
- The window is a front end over the CLI (`python -m installer --base ... --update ... --keys ... --lang ... --dest
|
||||
...`), which prints one JSON progress event per line. Tests and CI use the CLI.
|
||||
@@ -233,3 +237,5 @@ bin/ totk_xlate, hactool, ninja, libSDL3.so.0
|
||||
## Later
|
||||
|
||||
- Re-warm the driver cache on start after a move, GPU or driver change.
|
||||
- Per-monitor DPI in the setup window: Tk 9 has none, so a display whose scale differs from the primary one still gets
|
||||
the window stretched.
|
||||
@@ -0,0 +1,109 @@
|
||||
"""The display scale of the installer window: process DPI awareness and the pixel sizes that follow it.
|
||||
|
||||
Windows draws a DPI-unaware process at 96 DPI and stretches its windows to the display's scale, which is what made the
|
||||
setup window blurry at 125% and above. gui.py calls enable() before it imports tkinter, so the process is system DPI
|
||||
aware before its first window exists; Tk then reads the display's DPI when it starts (`tk scaling` is 2.667 at 200%)
|
||||
and every font size in points comes out at the right size. The pixel sizes of the layout have no such scaling of their
|
||||
own, so gui.py passes them through px().
|
||||
|
||||
System DPI awareness, not per-monitor: Tk 9.0.4, the one the release bundles, has no per-monitor support (its DLL
|
||||
imports no DPI-awareness function), so it draws everything at the system DPI. Per-monitor awareness would leave the
|
||||
window crisp on a display at another scale but with the primary display's font sizes; system awareness draws the
|
||||
primary display exactly and lets Windows stretch the window only on a display whose scale differs from it, as before.
|
||||
|
||||
On Linux the window stays 1:1 (px returns n as it is): Tk and the desktop scale what they draw.
|
||||
"""
|
||||
import ctypes
|
||||
import os
|
||||
|
||||
DPI_96 = 96 # the scale Windows calls 100%
|
||||
DPI_AWARENESS_CONTEXT_SYSTEM_AWARE = -2
|
||||
PROCESS_SYSTEM_DPI_AWARE = 1 # SetProcessDpiAwareness' value for the same thing (Windows 8.1)
|
||||
LOGPIXELSX = 88 # GetDeviceCaps' horizontal DPI, the only source before Windows 10 1607
|
||||
LOAD_LIBRARY_SEARCH_SYSTEM32 = 0x00000800 # ctypes.WinDLL's winmode: System32 only, never the installer's folder
|
||||
|
||||
_scale = None
|
||||
|
||||
|
||||
def enable():
|
||||
"""Make the process system DPI aware, then return the display scale (1.0 at 96 DPI). Must run before the first
|
||||
window; a later call only returns the scale."""
|
||||
global _scale
|
||||
if _scale is None:
|
||||
dpi = _system_dpi() if os.name == 'nt' else DPI_96
|
||||
_scale = dpi / DPI_96
|
||||
return _scale
|
||||
|
||||
|
||||
def scale():
|
||||
"""The display scale: 1.0 at 100%, 2.0 at 200%."""
|
||||
return enable()
|
||||
|
||||
|
||||
def px(n):
|
||||
"""n pixels at 100% as this display's pixels: 14 stays 14 at 100% and becomes 28 at 200%."""
|
||||
return int(round(n * enable()))
|
||||
|
||||
|
||||
def awareness():
|
||||
"""What Windows says the process is: 0 unaware, 1 system aware, 2 per-monitor aware."""
|
||||
if os.name != 'nt':
|
||||
return 0
|
||||
try: # Windows 10 1607
|
||||
user32 = ctypes.WinDLL('user32')
|
||||
user32.GetThreadDpiAwarenessContext.restype = ctypes.c_void_p
|
||||
user32.GetAwarenessFromDpiAwarenessContext.argtypes = [ctypes.c_void_p]
|
||||
return int(user32.GetAwarenessFromDpiAwarenessContext(user32.GetThreadDpiAwarenessContext()))
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows 8.1
|
||||
value = ctypes.c_int()
|
||||
shcore = ctypes.WinDLL('shcore', winmode=LOAD_LIBRARY_SEARCH_SYSTEM32)
|
||||
shcore.GetProcessDpiAwareness.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)]
|
||||
if shcore.GetProcessDpiAwareness(None, ctypes.byref(value)) == 0:
|
||||
return value.value
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows Vista
|
||||
return 1 if ctypes.WinDLL('user32').IsProcessDPIAware() else 0
|
||||
except (AttributeError, OSError):
|
||||
return 0
|
||||
|
||||
|
||||
def _system_dpi():
|
||||
"""Make the process system DPI aware and return the display DPI. Each call that this Windows version has is tried;
|
||||
one failing means the process already is aware (a manifest, or the call before it) and changes nothing."""
|
||||
try:
|
||||
user32 = ctypes.WinDLL('user32', use_last_error=True)
|
||||
except OSError:
|
||||
return DPI_96
|
||||
try: # Windows 10 1607, and the only way to ask for the system context explicitly
|
||||
user32.SetProcessDpiAwarenessContext.argtypes = [ctypes.c_void_p]
|
||||
user32.SetProcessDpiAwarenessContext.restype = ctypes.c_bool
|
||||
user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(DPI_AWARENESS_CONTEXT_SYSTEM_AWARE))
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows 8.1
|
||||
ctypes.WinDLL('shcore', winmode=LOAD_LIBRARY_SEARCH_SYSTEM32).SetProcessDpiAwareness(PROCESS_SYSTEM_DPI_AWARE)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows Vista
|
||||
user32.SetProcessDPIAware()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # Windows 10 1607
|
||||
return int(user32.GetDpiForSystem()) or DPI_96
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try: # before that
|
||||
gdi32 = ctypes.WinDLL('gdi32')
|
||||
user32.GetDC.restype = ctypes.c_void_p
|
||||
user32.GetDC.argtypes = [ctypes.c_void_p]
|
||||
user32.ReleaseDC.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
||||
gdi32.GetDeviceCaps.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
dc = user32.GetDC(None)
|
||||
dpi = gdi32.GetDeviceCaps(ctypes.c_void_p(dc), LOGPIXELSX)
|
||||
user32.ReleaseDC(None, ctypes.c_void_p(dc))
|
||||
return dpi or DPI_96
|
||||
except (AttributeError, OSError):
|
||||
return DPI_96
|
||||
+67
-48
@@ -3,7 +3,8 @@
|
||||
A front end over the CLI (docs/installer.md): it collects the game files, the keys file path, the optional firmware
|
||||
folder, the game language and the install folder, runs `python -m installer ...` as a subprocess and shows its JSON
|
||||
events. Decisions live in guilogic.py; this file only draws. The window text is in
|
||||
installer/text/<en|ru|pt-BR|zh-Hans>.json under "ui.*" and "launcher.*" ids.
|
||||
installer/text/<en|ru|pt-BR|zh-Hans>.json under "ui.*" and "launcher.*" ids. On Windows the window follows the
|
||||
display's DPI (installer/dpi.py): fonts and paddings grow with the scale, so Windows does not stretch it.
|
||||
|
||||
--selftest [--shots DIR] build every screen in every language and close by itself (PNG per screen with --shots)
|
||||
--lang en|ru|pt-BR|zh-Hans window language (default: the Windows display language)
|
||||
@@ -15,9 +16,16 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from . import dpi
|
||||
|
||||
# Windows fixes the DPI awareness of a process when its first window is created, and Tk reads the display's DPI when it
|
||||
# starts: both happen inside the first tkinter.Tk(), so this call comes before tkinter is imported at all.
|
||||
dpi.enable()
|
||||
|
||||
import tkinter as tk # noqa: E402 (after dpi.enable, see above)
|
||||
from tkinter import filedialog, messagebox, ttk # noqa: E402
|
||||
|
||||
try:
|
||||
from PIL import ImageGrab # only the screenshots of --selftest --shots need it; the release has no PIL
|
||||
@@ -32,7 +40,9 @@ from .guilogic import (DONE, FAILED, PENDING, RUNNING, SKIPPED, UI_LANGUAGE_LABE
|
||||
POLL_MS = 100
|
||||
GLYPH = {PENDING: '\u25cb', RUNNING: '\u25b6', DONE: '\u2714', SKIPPED: '\u2013', FAILED: '\u2716'}
|
||||
COLOR = {PENDING: '#808080', RUNNING: '#1a5fb4', DONE: '#26a269', SKIPPED: '#808080', FAILED: '#c01c28'}
|
||||
PAD = 14
|
||||
# The window's pixels at 100%: Tk scales the fonts by itself (tk scaling), these through installer/dpi.py.
|
||||
PAD = dpi.px(14) # padding of the window and its frames
|
||||
WRAP = dpi.px(620) # wrap width of the long labels
|
||||
|
||||
|
||||
def fix_tcl_paths():
|
||||
@@ -74,7 +84,7 @@ class App:
|
||||
self.mono_font = 'Consolas' if os.name == 'nt' else 'DejaVu Sans Mono'
|
||||
style = ttk.Style(root)
|
||||
style.configure('Title.TLabel', font=(self.ui_font, 15, 'bold'))
|
||||
style.configure('Notice.TLabel', background='#fff3cd', foreground='#664d03', padding=8)
|
||||
style.configure('Notice.TLabel', background='#fff3cd', foreground='#664d03', padding=dpi.px(8))
|
||||
style.configure('Err.TLabel', foreground='#c01c28')
|
||||
style.configure('Version.TLabel', foreground='#808080')
|
||||
|
||||
@@ -84,7 +94,7 @@ class App:
|
||||
self.title.pack(side='left')
|
||||
version = ttk.Label(top, style='Version.TLabel')
|
||||
self.bind_text(version, 'text', 'ui.version', version=layout.release)
|
||||
version.pack(side='left', padx=(10, 0), pady=(6, 0))
|
||||
version.pack(side='left', padx=(dpi.px(10), 0), pady=(dpi.px(6), 0))
|
||||
sw = ttk.Frame(top)
|
||||
sw.pack(side='right')
|
||||
self.ui_var = tk.StringVar(value=self.cat.lang) # the window language code; the box shows its name
|
||||
@@ -185,19 +195,20 @@ class App:
|
||||
self.root.geometry(f'{max(w, self.root.winfo_width())}x{max(h, self.root.winfo_height())}')
|
||||
|
||||
def notice(self, parent):
|
||||
w = ttk.Label(parent, style='Notice.TLabel', wraplength=620, justify='left')
|
||||
w = ttk.Label(parent, style='Notice.TLabel', wraplength=WRAP, justify='left')
|
||||
self.bind_text(w, 'text', 'ui.notice')
|
||||
return w
|
||||
|
||||
def build_form(self):
|
||||
f = ttk.Frame(self.body)
|
||||
gap = dpi.px(4) # between the form's rows
|
||||
bar = ttk.Frame(f) # packed first so pack shrinks the text above it, never the buttons
|
||||
bar.pack(side='bottom', fill='x', pady=(12, 0))
|
||||
bar.pack(side='bottom', fill='x', pady=(dpi.px(12), 0))
|
||||
self.start_button = self.button(bar, 'ui.start', self.start)
|
||||
self.start_button.pack(side='right')
|
||||
self.form_intro = self.label(f, 'ui.intro', wraplength=620, justify='left')
|
||||
self.form_intro.pack(anchor='w', pady=(0, 8))
|
||||
self.notice(f).pack(fill='x', pady=(0, 10))
|
||||
self.form_intro = self.label(f, 'ui.intro', wraplength=WRAP, justify='left')
|
||||
self.form_intro.pack(anchor='w', pady=(0, dpi.px(8)))
|
||||
self.notice(f).pack(fill='x', pady=(0, dpi.px(10)))
|
||||
grid = ttk.Frame(f)
|
||||
grid.pack(fill='x')
|
||||
grid.columnconfigure(1, weight=1)
|
||||
@@ -206,44 +217,44 @@ class App:
|
||||
self.form_rows = {}
|
||||
for i, (name, label_id, pick_id) in enumerate(rows):
|
||||
lab = self.label(grid, label_id)
|
||||
lab.grid(row=i, column=0, sticky='w', pady=4, padx=(0, 10))
|
||||
ttk.Entry(grid, textvariable=self.vars[name]).grid(row=i, column=1, sticky='ew', pady=4)
|
||||
lab.grid(row=i, column=0, sticky='w', pady=gap, padx=(0, dpi.px(10)))
|
||||
ttk.Entry(grid, textvariable=self.vars[name]).grid(row=i, column=1, sticky='ew', pady=gap)
|
||||
b = self.button(grid, 'ui.browse', lambda n=name, p=pick_id: self.pick_file(n, p))
|
||||
b.grid(row=i, column=2, padx=(8, 0))
|
||||
b.grid(row=i, column=2, padx=(dpi.px(8), 0))
|
||||
self.form_rows[name] = lab
|
||||
i = len(rows)
|
||||
self.label(grid, 'ui.firmware').grid(row=i, column=0, sticky='w', pady=4)
|
||||
ttk.Entry(grid, textvariable=self.vars['firmware']).grid(row=i, column=1, sticky='ew', pady=4)
|
||||
self.button(grid, 'ui.browse', self.pick_firmware).grid(row=i, column=2, padx=(8, 0))
|
||||
self.label(grid, 'ui.firmware').grid(row=i, column=0, sticky='w', pady=gap)
|
||||
ttk.Entry(grid, textvariable=self.vars['firmware']).grid(row=i, column=1, sticky='ew', pady=gap)
|
||||
self.button(grid, 'ui.browse', self.pick_firmware).grid(row=i, column=2, padx=(dpi.px(8), 0))
|
||||
i += 1
|
||||
self.label(grid, 'ui.game_language').grid(row=i, column=0, sticky='w', pady=4)
|
||||
self.label(grid, 'ui.game_language').grid(row=i, column=0, sticky='w', pady=gap)
|
||||
self.lang_box = ttk.Combobox(grid, textvariable=self.vars['lang'], state='readonly', width=30)
|
||||
self.lang_box.grid(row=i, column=1, sticky='w', pady=4)
|
||||
self.lang_box.grid(row=i, column=1, sticky='w', pady=gap)
|
||||
self.lang_touched = False
|
||||
self.lang_box.bind('<<ComboboxSelected>>', lambda _e: setattr(self, 'lang_touched', True))
|
||||
self.fill_languages()
|
||||
self.label(grid, 'ui.dest').grid(row=i + 1, column=0, sticky='w', pady=4)
|
||||
ttk.Entry(grid, textvariable=self.vars['dest']).grid(row=i + 1, column=1, sticky='ew', pady=4)
|
||||
self.button(grid, 'ui.browse', self.pick_dest).grid(row=i + 1, column=2, padx=(8, 0))
|
||||
self.label(f, 'ui.compressed_help', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0))
|
||||
self.label(f, 'ui.firmware_help', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0))
|
||||
self.label(grid, 'ui.dest').grid(row=i + 1, column=0, sticky='w', pady=gap)
|
||||
ttk.Entry(grid, textvariable=self.vars['dest']).grid(row=i + 1, column=1, sticky='ew', pady=gap)
|
||||
self.button(grid, 'ui.browse', self.pick_dest).grid(row=i + 1, column=2, padx=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.compressed_help', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.firmware_help', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
# On by default and named with its source and licence: the player sees what is downloaded and can untick it
|
||||
self.dlss_var = tk.BooleanVar(value=True)
|
||||
dlss = ttk.Checkbutton(f, variable=self.dlss_var)
|
||||
self.bind_text(dlss, 'text', 'ui.dlss')
|
||||
dlss.pack(anchor='w', pady=(8, 0))
|
||||
self.label(f, 'ui.dlss_help', wraplength=620, justify='left').pack(anchor='w', padx=(22, 0))
|
||||
self.update_hint = ttk.Label(f, wraplength=620, justify='left')
|
||||
self.update_hint.pack(anchor='w', pady=(8, 0))
|
||||
self.form_error = ttk.Label(f, style='Err.TLabel', wraplength=620, justify='left')
|
||||
self.form_error.pack(anchor='w', pady=(8, 0))
|
||||
self.label(f, 'ui.keys_private', wraplength=620, justify='left').pack(anchor='w', pady=(8, 0))
|
||||
dlss.pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.dlss_help', wraplength=WRAP, justify='left').pack(anchor='w', padx=(dpi.px(22), 0))
|
||||
self.update_hint = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.update_hint.pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.form_error = ttk.Label(f, style='Err.TLabel', wraplength=WRAP, justify='left')
|
||||
self.form_error.pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
self.label(f, 'ui.keys_private', wraplength=WRAP, justify='left').pack(anchor='w', pady=(dpi.px(8), 0))
|
||||
return f
|
||||
|
||||
def build_run(self):
|
||||
f = ttk.Frame(self.body)
|
||||
bar = ttk.Frame(f)
|
||||
bar.pack(side='bottom', fill='x', pady=(8, 0))
|
||||
bar.pack(side='bottom', fill='x', pady=(dpi.px(8), 0))
|
||||
self.cancel_button = self.button(bar, 'ui.cancel', self.cancel)
|
||||
self.cancel_button.pack(side='right')
|
||||
self.log_toggle = self.button(bar, 'ui.show_log', self.toggle_log)
|
||||
@@ -252,14 +263,22 @@ class App:
|
||||
self.run_header.pack(anchor='w')
|
||||
self.step_rows = {}
|
||||
self.steps_box = ttk.Frame(f)
|
||||
self.steps_box.pack(fill='x', pady=(8, 8))
|
||||
self.phase_label = ttk.Label(f, wraplength=620, justify='left')
|
||||
self.steps_box.pack(fill='x', pady=dpi.px(8))
|
||||
self.phase_label = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.phase_label.pack(anchor='w')
|
||||
self.bar = ttk.Progressbar(f, mode='determinate', maximum=1000)
|
||||
self.bar.pack(fill='x', pady=(4, 6))
|
||||
self.space_label = ttk.Label(f, wraplength=620, justify='left')
|
||||
bar_holder = ttk.Frame(f)
|
||||
bar_holder.pack(fill='x', pady=(dpi.px(4), dpi.px(6)))
|
||||
self.bar = ttk.Progressbar(bar_holder, mode='determinate', maximum=1000)
|
||||
self.bar.pack(fill='both', expand=True)
|
||||
if os.name == 'nt' and ttk.Style(f).theme_use() == 'vista':
|
||||
# The vista theme draws the bar 22 px high at every DPI and has no thickness option: the holder gives it the
|
||||
# height it would have at 100%, scaled with the display. Every other theme draws its own height, and the
|
||||
# holder follows it.
|
||||
bar_holder.pack_propagate(False)
|
||||
bar_holder.configure(height=dpi.px(22))
|
||||
self.space_label = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.space_label.pack(anchor='w')
|
||||
self.warn_label = ttk.Label(f, wraplength=620, justify='left', foreground='#9a6700')
|
||||
self.warn_label = ttk.Label(f, wraplength=WRAP, justify='left', foreground='#9a6700')
|
||||
self.warn_label.pack(anchor='w')
|
||||
self.log_frame = ttk.Frame(f)
|
||||
self.log = tk.Text(self.log_frame, height=10, wrap='word', state='disabled', font=(self.mono_font, 9))
|
||||
@@ -273,22 +292,22 @@ class App:
|
||||
def build_finish(self):
|
||||
f = ttk.Frame(self.body)
|
||||
bar = ttk.Frame(f)
|
||||
bar.pack(side='bottom', fill='x', pady=(12, 0))
|
||||
bar.pack(side='bottom', fill='x', pady=(dpi.px(12), 0))
|
||||
self.button(bar, 'ui.close', self.on_close).pack(side='right')
|
||||
self.open_button = self.button(bar, 'ui.open_folder', self.open_folder)
|
||||
self.open_button.pack(side='right', padx=(0, 8))
|
||||
self.open_button.pack(side='right', padx=(0, dpi.px(8)))
|
||||
self.back_button = self.button(bar, 'ui.back', lambda: self.show('form'))
|
||||
self.back_button.pack(side='left')
|
||||
if self.rebuild: # a failed or cancelled rebuild: the game starts with the code it has
|
||||
self.button(bar, 'ui.start_game', self.start_game).pack(side='right', padx=(0, 8))
|
||||
self.finish_title = ttk.Label(f, style='Title.TLabel', wraplength=620, justify='left')
|
||||
self.button(bar, 'ui.start_game', self.start_game).pack(side='right', padx=(0, dpi.px(8)))
|
||||
self.finish_title = ttk.Label(f, style='Title.TLabel', wraplength=WRAP, justify='left')
|
||||
self.finish_title.pack(anchor='w')
|
||||
self.finish_text = ttk.Label(f, wraplength=620, justify='left')
|
||||
self.finish_text.pack(anchor='w', pady=(8, 8))
|
||||
self.finish_text = ttk.Label(f, wraplength=WRAP, justify='left')
|
||||
self.finish_text.pack(anchor='w', pady=dpi.px(8))
|
||||
self.finish_steps = ttk.Frame(f)
|
||||
self.finish_steps.pack(fill='x', pady=(0, 8))
|
||||
self.finish_steps.pack(fill='x', pady=(0, dpi.px(8)))
|
||||
self.finish_notice = self.notice(f)
|
||||
self.finish_notice.pack(fill='x', pady=(8, 0))
|
||||
self.finish_notice.pack(fill='x', pady=(dpi.px(8), 0))
|
||||
return f
|
||||
|
||||
# ---- form -----------------------------------------------------------------------------------------------------
|
||||
@@ -365,7 +384,7 @@ class App:
|
||||
self.step_rows = {}
|
||||
for s in steps:
|
||||
row = ttk.Frame(self.steps_box)
|
||||
row.pack(fill='x', pady=1)
|
||||
row.pack(fill='x', pady=dpi.px(1))
|
||||
glyph = ttk.Label(row, width=3)
|
||||
glyph.pack(side='left')
|
||||
name = ttk.Label(row)
|
||||
@@ -450,7 +469,7 @@ class App:
|
||||
def toggle_log(self):
|
||||
self.log_open = not self.log_open
|
||||
if self.log_open:
|
||||
self.log_frame.pack(fill='both', expand=True, pady=(8, 0))
|
||||
self.log_frame.pack(fill='both', expand=True, pady=(dpi.px(8), 0))
|
||||
else:
|
||||
self.log_frame.pack_forget()
|
||||
self.bound = [b for b in self.bound if b[0] is not self.log_toggle]
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
No console window. The two message strings come from the catalog (installer/text/<lang>.json, ids launcher.title and
|
||||
launcher.python_missing); tools/make_release.py writes them into launcher_text.h before it compiles this file.
|
||||
Build: llvm/bin/llvm-windres -I runtime -O coff launcher.rc -o launcher_rc.o, then
|
||||
llvm/bin/clang -O2 -mwindows launcher.c launcher_rc.o -o TotK-Setup.exe */
|
||||
llvm/bin/clang -O2 -mwindows launcher.c launcher_rc.o -o TotK-Setup.exe
|
||||
|
||||
System DPI aware, like the window's Python process (installer/dpi.py): Windows draws the error box of fail() at the
|
||||
display's scale instead of stretching it, which it would at 125% and above. */
|
||||
#include <windows.h>
|
||||
#include <wchar.h>
|
||||
|
||||
@@ -38,6 +41,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR command_line, i
|
||||
STARTUPINFOW si = { sizeof si };
|
||||
PROCESS_INFORMATION pi;
|
||||
(void)instance; (void)previous; (void)command_line; (void)show;
|
||||
SetProcessDPIAware(); /* before the message box exists; the call fails harmlessly when a manifest set it */
|
||||
if (n == 0 || n >= PATH_CHARS) { fail(); return 1; }
|
||||
while (n > 0 && folder[n - 1] != L'\\') n--;
|
||||
folder[n > 0 ? n - 1 : 0] = 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""The installer window: event model, form, catalogs, cancel, a self-closing Tk run and one end-to-end run.
|
||||
"""The installer window: event model, form, catalogs, cancel, DPI awareness, a self-closing Tk run and one end-to-end
|
||||
run.
|
||||
|
||||
.venv-py314\\Scripts\\python tests\\installer_gui_test.py
|
||||
|
||||
@@ -27,6 +28,7 @@ from types import SimpleNamespace
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from installer import layout as layouts # noqa: E402
|
||||
from installer import dpi # noqa: E402
|
||||
from installer import state as statefile # noqa: E402
|
||||
from installer.events import Reporter # noqa: E402
|
||||
from installer.runner import Context # noqa: E402
|
||||
@@ -341,6 +343,44 @@ class CancelTest(unittest.TestCase):
|
||||
p.kill()
|
||||
|
||||
|
||||
class DpiTest(unittest.TestCase):
|
||||
def test_px_follows_the_display_scale(self):
|
||||
scale = dpi.scale()
|
||||
self.assertGreaterEqual(scale, 1.0)
|
||||
self.assertEqual(dpi.px(0), 0)
|
||||
self.assertEqual(dpi.px(14), round(14 * scale))
|
||||
|
||||
def test_px_at_the_scales_of_other_displays(self):
|
||||
"""A 96 DPI runner cannot show the scaling: give px() the scales Windows reports at 125, 150 and 200%."""
|
||||
saved = dpi._scale
|
||||
try:
|
||||
for scale, pad, wrap in ((1.0, 14, 620), (1.25, 18, 775), (1.5, 21, 930), (2.0, 28, 1240)):
|
||||
dpi._scale = scale
|
||||
self.assertEqual((dpi.px(14), dpi.px(620)), (pad, wrap), scale)
|
||||
finally:
|
||||
dpi._scale = saved
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt', 'the awareness and the DPI Tk reads are Windows settings')
|
||||
def test_the_window_draws_at_the_display_scale(self):
|
||||
"""A fresh process imports the window (which makes itself DPI aware) and starts Tk: the fonts must be sized for
|
||||
the display, not for 96 DPI, which is what Windows then stretched (the blurry window)."""
|
||||
try:
|
||||
import tkinter # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest('no tkinter')
|
||||
code = ('import tkinter\n'
|
||||
'from installer import dpi, gui\n'
|
||||
'root = tkinter.Tk()\n'
|
||||
'print(dpi.awareness(), dpi.scale(), root.tk.call("tk", "scaling"), gui.PAD, gui.WRAP)\n'
|
||||
'root.destroy()\n')
|
||||
r = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, timeout=120)
|
||||
self.assertEqual(r.returncode, 0, r.stderr[-2000:])
|
||||
awareness, scale, scaling, pad, wrap = r.stdout.split()
|
||||
self.assertGreaterEqual(int(awareness), 1) # 1 system aware, 2 per-monitor aware
|
||||
self.assertAlmostEqual(float(scaling), float(scale) * 96 / 72, places=6)
|
||||
self.assertEqual((int(pad), int(wrap)), (round(14 * float(scale)), round(620 * float(scale))))
|
||||
|
||||
|
||||
class SmokeTest(unittest.TestCase):
|
||||
def test_every_screen_in_both_languages(self):
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user