Open a Tkinter app on a display set to 150% and the text is often too big
for the boxes it sits in. This is why, and the fix that made it stop for
me.
The bug
MultiTerm is a terminal emulator
whose interface, terminal grid and chrome alike, is drawn on Tk canvases.
At 100% scaling it looked right. On a display set to 150%, the labels grew
by half and the bars they sat in did not. The shell picker label was cut
off under the next control. The terminal grid was unaffected, because its
cell size is measured from the font. Everything positioned with a literal
number was wrong.
What Tk does on Windows
Tk has one DPI setting: tk scaling, the number of screen pixels in a
typographic point. At 96 dpi that is 96/72, or 1.333. On a 150% display
Windows reports 144 dpi and, if the process has declared itself DPI-aware
(MultiTerm calls SetProcessDpiAwareness(1) at startup so text is not
blurred), Tk sets tk scaling to 2.0.
That value affects exactly one thing: fonts whose size is a positive
number, which Tk reads as points. A size 10 font becomes 13 pixels at 100%
and 20 pixels at 150%. Correct, and automatic.
Nothing else is touched. canvas.create_rectangle(0, 0, 200, 32) is 200 by
32 pixels on every display. pady=6 is six pixels. A HEADER_HEIGHT = 44
constant is 44 pixels. The text scaled by 1.5, the box by 1.0, and the gap
between those two numbers is the bug.
Scaling the layout to match is not enough on its own. If you multiply every
constant by tk scaling / 1.333, layout and fonts each scale once, which
sounds right, until you use a pixel-sized font somewhere, or override the
scaling for a test, or run on a machine where Tk's number and yours were
computed differently. Two mechanisms, two opinions.
The fix: one factor, applied by you
Take the scale away from Tk and apply it yourself, to fonts and layout
alike. This is the whole of MultiTerm's ui.py, minus docstrings:
import os
BASE_SCALING = 1.3333333 # Tk's pixels-per-point at 96 dpi
PT_TO_PX = 1.3333333
SCALE = 1.0
def init(root):
global SCALE
override = os.environ.get("MULTITERM_UI_SCALE")
if override:
SCALE = max(0.75, min(4.0, float(override)))
else:
reported = float(root.tk.call("tk", "scaling"))
SCALE = max(1.0, min(4.0, reported / BASE_SCALING))
# Fonts are sized in pixels from here on, so keep Tk out of it.
root.tk.call("tk", "scaling", BASE_SCALING)
return SCALE
def px(n):
"""A design pixel, scaled for this display."""
return int(round(n * SCALE))
def font_px(points):
"""Font size in pixels (negative), scaled. Tk never touches these."""
return -max(6, int(round(points * PT_TO_PX * SCALE)))
Three moves.
Read the scale Tk already worked out, divide by the 96 dpi baseline, and
keep the result as a single number. At 150% that is 1.5.
Set tk scaling back to the baseline so Tk stops scaling anything itself.
Give fonts negative sizes. In Tk a negative font size means pixels, and Tk
leaves pixel sizes alone. font_px(10) returns -13 at 100% and -20 at
150%, the same pixel sizes Tk would have chosen, except now they come from
the same factor as the layout.
Then every pixel constant goes through px():
HEADER_H = px(44)
canvas.create_rectangle(px(8), px(6), px(208), px(38), ...)
label_font = tkfont.Font(family=family, size=font_px(10))
Fonts and layout grow together and there is nothing left to disagree.
Test it without a high-DPI monitor
The MULTITERM_UI_SCALE override is the piece I would keep if I threw the
rest away. Set it to 1.5 and the app renders as it would on a 150% display,
on any display. MultiTerm's GUI test can be run that way and it walks the
canvas items to check two things:
- no text item extends past the shape it is drawn on
- no header label overlaps a control
The comment above that check reads "a clipped Command Prompt shipped once".
That is the entire reason it exists.
Things that do not work
Scaling only the layout. Fonts are then scaled twice.
Setting tk scaling to 1.0 instead of 1.333. Fonts stop scaling with DPI,
but now one point is one pixel, so a 10pt font renders at 10px instead of
- Pin to the 96 dpi value, not to 1.
Reading the DPI yourself through GetDpiForSystem or similar. It works,
but Tk has already asked Windows and put the answer in tk scaling. Two
sources for the same number is how the original bug happened.
Letting the factor drop below 1.0. The code clamps at 1.0 on purpose so a
low reported DPI never shrinks the interface.
MultiTerm is a free, MIT-licensed multi-pane terminal for Windows with
workspaces, per-folder startup commands and broadcast typing. The module
above is
multiterm/ui.py.
Top comments (0)