From a1a218b29fc6a2ae8e35477fc8bbfb0ccfb226c3 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 6 Sep 2026 14:19:38 -0500 Subject: [PATCH] fix: draw classroom icons at the display's real pixel density Icons went soft under UI_SCALE while the clock text beside them stayed sharp, which is visible on a 24" monitor even if it hides on a TV. new_from_file_at_scale rasterises an SVG at its *logical* size. The artwork is vector, but by the time sway scaled the session it was already a fixed raster, and the compositor enlarged that. Text never had the problem because GTK draws it at the real density. GTK 3 has no fractional scaling, so sway advertises the ceiling integer scale and scales the finished buffer down. Measured under sway: scale factor 1 at output scale 1.0, and 2 at 1.25, 1.5 and 2.0. So each icon is now rasterised at size x scale_factor physical pixels and handed to GTK as a cairo surface carrying that device scale. The icon lands at the real density; its logical size, and the whole dock layout, is unchanged. Icons redraw on notify::scale-factor as well as at realize, since the scale factor is not known until a widget is on a display and changes if UI_SCALE is changed under a running session. Browser, DVD, LIVE and all six DVD control icons go through it, in a shared module so the dock and the DVD session cannot diverge. The throbber is drawn rather than loaded and had the same flaw, so it draws on a device-scaled surface too rather than being the one soft thing left in a sharp dock. The artwork is untouched. Verified by rendering the dock under sway at 1.0, 1.25, 1.5 and 2.0 against the previous build: apparent icon size is identical at every scale (28, 35, 42, 56 px), 1.0 is unchanged pixel-for-pixel, and peak edge contrast on the Chrome icon goes from 60 to 99 at 1.25, 49 to 83 at 1.5, and 37 to 82 at 2.0 out of 255. The DVD control bar was checked the same way. Also records that Wonder Ink session-only cookies are confirmed working on the endpoint, which settles the question 0.1.34 shipped as unverified and tells us the session is in cookies rather than localStorage. Co-Authored-By: Claude Opus 5 --- docs/browser.md | 57 +++++++-------------- docs/testing.md | 4 ++ docs/ui-scale.md | 10 ++++ lib/classroom_icons.py | 95 +++++++++++++++++++++++++++++++++++ scripts/classroom-dock | 62 ++++++++++++++--------- scripts/classroom-dvd-session | 23 +++------ tools/build-release.sh | 4 ++ 7 files changed, 177 insertions(+), 78 deletions(-) create mode 100644 lib/classroom_icons.py diff --git a/docs/browser.md b/docs/browser.md index d8377cb..79d75ff 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -57,50 +57,31 @@ is meant to avoid. Worth a look at the endpoint: open that menu once and confirm it offers nothing actionable. -## Wonder Ink between teachers — NOT YET VERIFIED +## Wonder Ink between teachers — CONFIRMED WORKING -The intent: teacher A signs in, uses the room, Chrome closes, teacher B -opens Chrome and Wonder Ink is signed out — while YouTube stays signed -into the church account. +Teacher A signs in, uses the room, Chrome closes, teacher B opens Chrome +and Wonder Ink is signed out, while YouTube stays signed into the church +account. -`CookiesSessionOnlyForUrls` with `[*.]wonderink.org` is the official -policy for that, and that pattern covers `app.wonderink.org` and every -other subdomain. **Whether it is sufficient has not been established.** +`CookiesSessionOnlyForUrls` with `[*.]wonderink.org` does it. That +pattern covers `app.wonderink.org` and every other subdomain. -It could not be tested here. Confirming it needs a real Wonder Ink -login, which this machine does not have, and three attempts to prove the -mechanism against a local test site were inconclusive — the positive -control never worked, so the negative results mean nothing. Rather than -report a guess as a result: unverified. +**Confirmed on the physical endpoint**, which is the only place it could +be: it needs a real Wonder Ink login. It could not be established here, +and three attempts to prove the mechanism against a local test site were +inconclusive because the positive control never worked — so this was +shipped as unverified and the endpoint settled it. -Two things must be checked at the endpoint before anyone relies on it. +Two consequences worth keeping in mind. -### 1. Is the session in cookies at all? +Wonder Ink's session is therefore **in cookies**, not in `localStorage`. +That matters because there is no per-site Chrome policy that clears +localStorage on exit: `ClearBrowsingDataOnExitList` and +`BrowsingDataLifetime` both exist in Chrome 152 but are global, and +blanket clearing would take YouTube with it. Had it gone the other way, +this would have needed a classroom-owned cleanup instead. -`CookiesSessionOnlyForUrls` clears **cookies**. A single-page app like -Wonder Ink may keep its token in `localStorage` instead, which that -policy does not promise to touch. - -Sign in, then in DevTools (F12) → Application: - -- **Cookies** under `https://app.wonderink.org` — is there a session or - auth cookie? -- **Local Storage** and **IndexedDB** for the same origin — is there a - token there? - -Then the real test: close every Chrome window, confirm the process is -gone (`pgrep -x chrome` returns nothing), reopen, and see whether Wonder -Ink is still signed in. - -If it is still signed in, the session is not in cookies, and **there is -no per-site Chrome policy that clears localStorage on exit**. -`ClearBrowsingDataOnExitList` and `BrowsingDataLifetime` both exist in -Chrome 152 but are global, not per-site — and blanket clearing is -exactly what must not happen here, because it would take YouTube with -it. That case needs a small classroom-owned cleanup instead, which has -deliberately not been built on speculation. - -### 2. If Wonder Ink login is "Sign in with Google" +### If Wonder Ink login is "Sign in with Google" `app.wonderink.org` loads Google Identity Services (`accounts.google.com/gsi/client`), so Google sign-in is at least diff --git a/docs/testing.md b/docs/testing.md index 4a6e7dd..f01eec8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,6 +32,10 @@ Change it, then `sudo systemctl restart getty@tty1`. - [ ] Chrome's buttons are still easy to hit with the mouse - [ ] Dock, clock, notifications and the DVD bar grow by the same amount — once, not twice +- [ ] Dock icons stay crisp at 1.25 and 1.5 on a normal-density monitor, + not just from across the room: compare an icon against the clock + text beside it +- [ ] DVD control bar icons are crisp at the same scales - [ ] `UI_SCALE=0.8` shrinks everything together - [ ] A nonsense value (`UI_SCALE=banana`) still gives a working shell - [ ] An extreme value (`UI_SCALE=9`) is clamped, not obeyed diff --git a/docs/ui-scale.md b/docs/ui-scale.md index a5ca43a..3400a8c 100644 --- a/docs/ui-scale.md +++ b/docs/ui-scale.md @@ -81,6 +81,16 @@ integer scale of 2 to GTK, which renders at 2x, and sway scales the result down to 1.5x. The dock is the right size but very slightly softer than at 1.0. On a TV at classroom distance this is not visible. +Icons have to be drawn for that themselves. `new_from_file_at_scale` +rasterises an SVG at its *logical* size, and the compositor then +enlarges that finished raster — which made icons visibly soft on a 24" +monitor while the clock text beside them stayed sharp. `classroom_icons` +renders each SVG at `size x scale_factor` physical pixels and hands GTK +a cairo surface carrying that device scale, so the artwork lands at the +display's real density and the layout is unchanged. Measured on the +Chrome icon, peak edge contrast at UI_SCALE 1.5 went from 49 to 83 out +of 255, and at 2.0 from 37 to 82; at 1.0 it is identical to before. + **XWayland is softer at fractional scale.** X11 clients have no concept of a fractional output scale, so sway renders them at the logical size and scales the buffer. Measured on terminal text, scale 1.5 produced diff --git a/lib/classroom_icons.py b/lib/classroom_icons.py new file mode 100644 index 0000000..9fee007 --- /dev/null +++ b/lib/classroom_icons.py @@ -0,0 +1,95 @@ +"""Icons that stay sharp when the compositor scales the session. + +new_from_file_at_scale rasterises an SVG at its *logical* size. Under +sway's output scaling the compositor then enlarges that finished raster, +which is why icons went soft while the clock text and the dock surface +stayed crisp: those are drawn by GTK at the display's real pixel +density, and the icon was not. + +GTK 3 has no fractional scaling, so sway advertises the ceiling integer +scale and scales the finished buffer down. Measured under sway: scale +factor 1 at output scale 1.0, and 2 at 1.25, 1.5 and 2.0. + +So the fix is to rasterise at size * scale_factor physical pixels and +hand GTK a cairo surface carrying that device scale. The icon lands at +the real density, while its logical size — and therefore the whole dock +layout — is exactly what it was before. +""" + +from pathlib import Path + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("GdkPixbuf", "2.0") + +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 + +ICON_DIR = Path("/etc/ekklesia-classroom/icons") + + +def render_surface(path, size, scale, window=None): + """An SVG at `size` logical pixels, drawn at `scale` times the density. + + The box semantics match the old custom_icon(): the artwork is fitted + inside a square of the requested size with its aspect preserved, so + a wide logo like the DVD wordmark keeps the proportions it has now. + """ + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), + size * scale, + size * scale, + True, + ) + + return Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale, window) + + +class ScalableIcon(Gtk.Image): + """A custom SVG icon, redrawn whenever the display density changes.""" + + def __init__(self, filename, size, directory=None): + super().__init__() + + self.directory = directory or ICON_DIR + self.size = size + self.drawn_at = None + self.usable = False + + self.set_icon(filename) + + # The scale factor is not known until the widget is on a display, + # and it changes if UI_SCALE is changed under a running session. + self.connect("realize", lambda *_: self.redraw()) + self.connect("notify::scale-factor", lambda *_: self.redraw()) + + def set_icon(self, filename): + self.path = self.directory / filename + self.usable = self.path.exists() + self.drawn_at = None + + self.redraw() + + def redraw(self): + if not self.usable: + return + + scale = self.get_scale_factor() or 1 + + if scale == self.drawn_at: + return + + try: + surface = render_surface( + self.path, + self.size, + scale, + self.get_window(), + ) + except (GLib.Error, MemoryError): + self.usable = False + return + + self.set_from_surface(surface) + self.drawn_at = scale diff --git a/scripts/classroom-dock b/scripts/classroom-dock index b64ad02..f27028e 100755 --- a/scripts/classroom-dock +++ b/scripts/classroom-dock @@ -22,6 +22,7 @@ from classroom_config import ( # noqa: E402 read_device_config, scaled, ) +from classroom_icons import ScalableIcon # noqa: E402 # Optional on purpose. The dock is the one thing the classroom cannot # afford to lose, so a missing DVD helper simply disables disc detection @@ -112,27 +113,18 @@ def themed_icon(*names): def custom_icon(filename, size): - path = ICON_DIR / filename + """A classroom SVG icon, drawn at the display's real pixel density.""" + icon = ScalableIcon(filename, size, ICON_DIR) - if not path.exists(): - return None - - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - str(path), - size, - size, - True, - ) - - return Gtk.Image.new_from_pixbuf(pixbuf) + return icon if icon.usable else None -def throbber_frames(size): +def throbber_frames(size, scale): """A white arc, drawn once per frame of one revolution. - Pre-rendered into pixbufs and swapped into a Gtk.Image rather than - drawn live, so the busy state stays an ordinary image and the button - underneath keeps behaving exactly as it does with an icon. + Drawn at the display's real density like the icons, so the busy + state is not the one soft thing in a sharp dock. The surface carries + the device scale, so everything below is in logical units. """ if cairo is None: return None @@ -142,7 +134,13 @@ def throbber_frames(size): frames = [] for index in range(THROBBER_FRAMES): - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + surface = cairo.ImageSurface( + cairo.FORMAT_ARGB32, + size * scale, + size * scale, + ) + surface.set_device_scale(scale, scale) + context = cairo.Context(surface) context.set_source_rgba(1, 1, 1, 1) @@ -153,9 +151,7 @@ def throbber_frames(size): context.arc(size / 2, size / 2, radius, start, start + math.pi * 1.4) context.stroke() - frames.append( - Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size) - ) + frames.append(surface) return frames @@ -166,12 +162,32 @@ class Throbber(Gtk.Image): def __init__(self, size): super().__init__() - self.frames = throbber_frames(size) + self.size = size + self.frames = throbber_frames(size, 1) + self.drawn_at = 1 self.frame = 0 self.timer = None if self.frames: - self.set_from_pixbuf(self.frames[0]) + self.set_from_surface(self.frames[0]) + + self.connect("realize", lambda *_: self.rebuild()) + self.connect("notify::scale-factor", lambda *_: self.rebuild()) + + def rebuild(self): + scale = self.get_scale_factor() or 1 + + if scale == self.drawn_at: + return + + frames = throbber_frames(self.size, scale) + + if not frames: + return + + self.frames = frames + self.drawn_at = scale + self.set_from_surface(self.frames[self.frame]) def usable(self): return bool(self.frames) @@ -189,7 +205,7 @@ class Throbber(Gtk.Image): def advance(self): self.frame = (self.frame + 1) % len(self.frames) - self.set_from_pixbuf(self.frames[self.frame]) + self.set_from_surface(self.frames[self.frame]) return True diff --git a/scripts/classroom-dvd-session b/scripts/classroom-dvd-session index 8a01d44..ae849f0 100755 --- a/scripts/classroom-dvd-session +++ b/scripts/classroom-dvd-session @@ -33,10 +33,9 @@ import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") -gi.require_version("GdkPixbuf", "2.0") gi.require_version("GtkLayerShell", "0.1") -from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, GtkLayerShell +from gi.repository import Gdk, GLib, Gtk, GtkLayerShell sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") sys.path.insert( @@ -49,6 +48,7 @@ import classroom_style as style # noqa: E402 import classroom_sway as sway # noqa: E402 import classroom_vlc as vlc # noqa: E402 from classroom_config import SHELL_SCALE, read_device_config, scaled # noqa: E402 +from classroom_icons import ScalableIcon # noqa: E402 # Input shaping needs pycairo. Without it the bar still works, it just # takes pointer events across its whole strip instead of only where it @@ -141,12 +141,8 @@ window#classroom-dvd-bar {{ def icon(filename, size): - path = ICON_DIR / filename - - if not path.exists(): - return None - - return GdkPixbuf.Pixbuf.new_from_file_at_scale(str(path), size, size, True) + """A control bar icon, drawn at the display's real pixel density.""" + return ScalableIcon(filename, size, ICON_DIR) def free_port(): @@ -324,8 +320,7 @@ class ControlBar(Gtk.Window): button.set_relief(Gtk.ReliefStyle.NONE) button.get_style_context().add_class("dock-button") - pixbuf = icon(filename, scaled(ICON_SIZE, scale)) - image = Gtk.Image.new_from_pixbuf(pixbuf) if pixbuf else Gtk.Image() + image = icon(filename, scaled(ICON_SIZE, scale)) self.images[name] = image button.add(image) @@ -446,13 +441,7 @@ class ControlBar(Gtk.Window): if image is None: return - pixbuf = icon( - "media-pause.svg" if playing else "media-play.svg", - scaled(ICON_SIZE, self.scale), - ) - - if pixbuf is not None: - image.set_from_pixbuf(pixbuf) + image.set_icon("media-pause.svg" if playing else "media-play.svg") class DvdSession: diff --git a/tools/build-release.sh b/tools/build-release.sh index ad5ed3b..14968c0 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -142,6 +142,10 @@ install -Dm644 \ "$ROOT/lib/classroom_config.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_config.py" +install -Dm644 \ + "$ROOT/lib/classroom_icons.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_icons.py" + install -Dm644 \ "$ROOT/lib/classroom_style.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_style.py"