feat: a lesson in Wonder Ink is the size the browser shows it, and zooms

Teachers found lessons in Wonder Ink too small next to the ordinary
browser. Measured by asking each page for devicePixelRatio: Wonder Ink
follows the display scale exactly (dpr 1.5 at 150%), and Chrome is
scaled TWICE (dpr 2.25) — under Plasma it follows the compositor itself
and classroom-browser also forces UI_SCALE, a flag from the sway session.

The browser is left as it is. Wonder Ink's pages start at zoom =
UI_SCALE, the room's own number (1.0 at 100%), and measured afterwards
match the browser: dpr 2.25 and 853 px against 2.25 and 854. The window
is not zoomed; Qt scales it once, correctly.

Ctrl+Plus/=/keypad, Ctrl+Minus, Ctrl+0, Ctrl+wheel and the right-click
menu set one level for every tab, remembered as LEVEL= in its own file,
relative to the room. Two actions on one key are ambiguous to Qt and it
then does neither — Ctrl+Minus did nothing until each command became one
action with all its keys, which also fixes Ctrl+Shift+Tab.

The engine profile is built by one function, which the lab's checks now
use: a check with its own profile showed every worksheet blank.

The lab fences 10.10.0.0/16 as well: the development host reaches the
real Elmer over Tailscale, and the lab provisions a queue pointing at it.
Four lab checks written for older code are corrected; one had failed on
every run since 0.2.3.

tools/lab/wonderink-zoom-check: 18 checks. Lab: converged 120, print 39,
worksheets 24, all passing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-23 19:04:58 -05:00
co-authored by Claude Opus 5.5
parent 10433b850d
commit 48b3097c43
7 changed files with 527 additions and 62 deletions
+265 -40
View File
@@ -62,7 +62,7 @@ sys.path.insert(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
from classroom_config import feature, read_device_config # noqa: E402
from classroom_config import feature, read_device_config, ui_scale # noqa: E402
HOME_PAGE = "https://app.wonderink.org/"
@@ -72,6 +72,48 @@ DATA_HOME = Path(
CACHE_HOME = Path(
os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
)
CONFIG_HOME = Path(
os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
)
# The one thing Wonder Ink remembers between sessions: how large the
# teacher likes the lesson. Its own small file, deliberately nowhere near
# the engine's profile, so remembering a zoom level can never become
# remembering a sign-in.
SETTINGS = CONFIG_HOME / "ekklesia-classroom" / "wonderink.conf"
# HOW LARGE A LESSON IS.
#
# Measured in the lab on Plasma 6 / KWin, Debian 13, by asking each page
# for window.devicePixelRatio and its width in CSS pixels:
#
# display scale Wonder Ink Chrome (classroom-browser)
# 100% dpr 1, 1920 wide dpr 1, 1920 wide
# 150% dpr 1.5, 1280 wide dpr 2.25, 854 wide
# 200% dpr 2, 960 wide dpr 4, 500 wide
#
# Wonder Ink follows the display scale exactly, as the rest of the
# desktop does. Chrome is scaled TWICE: on Plasma it follows the
# compositor's scale by itself, and classroom-browser forces
# --force-device-scale-factor=UI_SCALE on top — a flag from the sway
# session, where Chrome was measured not following it. So in any room
# above 100% every page in the browser is UI_SCALE times larger than
# the desktop around it, and the same lesson in Wonder Ink looked small.
#
# Teachers read lessons in the browser at that size, so that is the size
# Wonder Ink starts at: page zoom = UI_SCALE. It is the room's own
# number, not a guess — 1.0 in a room at 100%, where nothing changes —
# and Wonder Ink's window itself is untouched, because Qt already scales
# that once, correctly.
#
# On top of that, the teacher's own level: Chrome's zoom steps, so
# Ctrl+Plus goes where somebody who knows Chrome expects, shown as a
# percentage of the room's normal size.
ZOOM_STEPS = (0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0)
LEVEL_NORMAL = 1.0
# The engine's own limits on a zoom factor.
ENGINE_ZOOM = (0.25, 5.0)
# Not the old Chrome directory. Two engines' state in one directory is a
# thing nobody can reason about later, and the Chrome profile is left
@@ -123,6 +165,7 @@ if WIDEVINE.exists():
import classroom_printing # noqa: E402
from PyQt6.QtCore import ( # noqa: E402
QEvent,
QKeyCombination,
QMarginsF,
Qt,
QTimer,
@@ -185,6 +228,50 @@ def say(message):
print(f"classroom-wonderink: {message}", file=sys.stderr)
def load_level():
"""The teacher's zoom level, relative to the room's normal size.
Relative, so that an administrator changing UI_SCALE later is not
undone by a number saved at the old one. Never raises.
"""
try:
for line in SETTINGS.read_text(encoding="utf-8").splitlines():
key, _, value = line.partition("=")
if key.strip() == "LEVEL":
level = float(value)
if ZOOM_STEPS[0] <= level <= ZOOM_STEPS[-1]:
return level
except (OSError, ValueError):
pass
return LEVEL_NORMAL
def save_level(level):
"""Write it atomically; a failure costs the setting, not the lesson."""
try:
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
temporary = SETTINGS.with_suffix(".tmp")
temporary.write_text(f"LEVEL={level:g}\n", encoding="utf-8")
temporary.replace(SETTINGS)
except OSError as error:
say(f"could not remember the zoom level: {error}")
def level_step(level, direction):
"""The next of ZOOM_STEPS above (1) or below (-1) LEVEL."""
if direction > 0:
return next((z for z in ZOOM_STEPS if z > level + 0.001), ZOOM_STEPS[-1])
return next((z for z in reversed(ZOOM_STEPS) if z < level - 0.001), ZOOM_STEPS[0])
def nearest_level(level):
return min(ZOOM_STEPS, key=lambda step: abs(step - level))
class LessonPage(QWebEnginePage):
"""A page that answers "open this somewhere else" with a new tab.
@@ -288,6 +375,17 @@ class LessonView(QWebEngineView):
menu.addSeparator()
menu.addAction(self.page().action(QWebEnginePage.WebAction.Copy))
menu.addSeparator()
bigger = QAction("Make the lesson bigger", menu)
bigger.triggered.connect(self._window.zoom_in)
smaller = QAction("Make the lesson smaller", menu)
smaller.triggered.connect(self._window.zoom_out)
normal = QAction("Normal size", menu)
normal.triggered.connect(self._window.zoom_reset)
normal.setEnabled(abs(self._window.level() - LEVEL_NORMAL) > 0.001)
for action in (bigger, smaller, normal):
menu.addAction(action)
menu.addSeparator()
printing = QAction("Print…", menu)
printing.triggered.connect(self._window.print_current)
@@ -297,7 +395,9 @@ class LessonView(QWebEngineView):
class WonderInk(QMainWindow):
def __init__(self, profile):
zoom_changed = pyqtSignal(float)
def __init__(self, profile, room_zoom=1.0):
super().__init__()
self._profile = profile
self._fullscreen_view = None
@@ -316,6 +416,11 @@ class WonderInk(QMainWindow):
self._preview = None
self._print_when_downloaded = set()
self._mime = {}
# One zoom level for the whole window: every tab, every new tab,
# and the next time Wonder Ink is opened. The room's normal size
# times the teacher's own level; see ZOOM_STEPS.
self._room_zoom = room_zoom
self._level = load_level()
self.setWindowTitle("Wonder Ink")
@@ -358,6 +463,14 @@ class WonderInk(QMainWindow):
view.page().loadingChanged.connect(
lambda info, v=view: self._loading_changed(v, info)
)
# Set once, here. Measured: the engine keeps a tab's zoom across
# every navigation, same site or not, so it is never re-applied.
view.setZoomFactor(self.zoom_factor())
# Ctrl+wheel is handled inside the engine and changes one tab.
# It is taken as the teacher's choice for all of them.
view.page().zoomFactorChanged.connect(
lambda zoom, v=view: self._zoomed_by_page(v, zoom)
)
index = self._tabs.addTab(view, "…")
@@ -436,24 +549,124 @@ class WonderInk(QMainWindow):
# ---- the keys a teacher might already know --------------------------
def _shortcuts(self):
def bind(sequence, handler):
def bind(sequences, handler):
# ONE action per command, carrying every key for it.
#
# Two actions answering the same key are ambiguous to Qt, and
# Qt's answer to ambiguity is to do neither — measured: with
# Ctrl+- bound once by name and again as KDE's standard
# zoom-out key, Ctrl+Minus did nothing at all.
keys = []
for sequence in sequences:
if isinstance(sequence, QKeySequence.StandardKey):
keys.extend(QKeySequence.keyBindings(sequence))
else:
keys.append(QKeySequence(sequence))
unique = []
for key in keys:
if not key.isEmpty() and key not in unique:
unique.append(key)
action = QAction(self)
action.setShortcut(QKeySequence(sequence))
action.setShortcuts(unique)
action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
action.triggered.connect(handler)
self.addAction(action)
return action
def keypad(key):
return QKeySequence(QKeyCombination(
Qt.KeyboardModifier.ControlModifier
| Qt.KeyboardModifier.KeypadModifier,
key,
))
# Deliberately absent: Ctrl+T, Ctrl+N, Ctrl+L. There is no new
# tab, no new window and no address bar to focus.
bind(QKeySequence.StandardKey.Close, self._close_current)
bind(QKeySequence.StandardKey.Back, self._back)
bind(QKeySequence.StandardKey.Forward, self._forward)
bind(QKeySequence.StandardKey.Refresh, self._reload)
bind(QKeySequence.StandardKey.Print, self.print_current)
bind(QKeySequence.StandardKey.NextChild, self._next_tab)
bind(QKeySequence.StandardKey.PreviousChild, self._previous_tab)
bind("Ctrl+Shift+Tab", self._previous_tab)
bind([QKeySequence.StandardKey.Close], self._close_current)
bind([QKeySequence.StandardKey.Back], self._back)
bind([QKeySequence.StandardKey.Forward], self._forward)
bind([QKeySequence.StandardKey.Refresh], self._reload)
bind([QKeySequence.StandardKey.Print], self.print_current)
# Chrome's zoom keys belong to the browser around the page, so the
# engine never sees them; in this room the browser around the page
# is us. Ctrl+Plus arrives as Ctrl+Shift+Plus on the keyboards the
# rooms have, and Chrome answers Ctrl+= as well; the keypad's keys
# carry a modifier of their own.
bind(
[
"Ctrl+=", "Ctrl++", "Ctrl+Shift+=", "Ctrl+Shift++",
keypad(Qt.Key.Key_Plus),
QKeySequence.StandardKey.ZoomIn,
],
self.zoom_in,
)
bind(
[
"Ctrl+-", "Ctrl+Shift+_",
keypad(Qt.Key.Key_Minus),
QKeySequence.StandardKey.ZoomOut,
],
self.zoom_out,
)
bind(
[
"Ctrl+0",
keypad(Qt.Key.Key_0),
],
self.zoom_reset,
)
bind([QKeySequence.StandardKey.NextChild], self._next_tab)
bind(
[QKeySequence.StandardKey.PreviousChild, "Ctrl+Shift+Tab"],
self._previous_tab,
)
# ---- how large the lesson is -----------------------------------------
def level(self):
"""The teacher's level: 1.0 is the room's normal size."""
return self._level
def zoom_factor(self):
"""What the engine is told: the room's size times the teacher's."""
low, high = ENGINE_ZOOM
return max(low, min(high, self._room_zoom * self._level))
def set_level(self, level):
level = nearest_level(level)
changed = abs(level - self._level) > 0.001
self._level = level
zoom = self.zoom_factor()
for index in range(self._tabs.count()):
view = self._tabs.widget(index)
if abs(view.zoomFactor() - zoom) > 0.001:
view.setZoomFactor(zoom)
if changed:
save_level(level)
self.zoom_changed.emit(level)
def zoom_in(self):
self.set_level(level_step(self._level, 1))
def zoom_out(self):
self.set_level(level_step(self._level, -1))
def zoom_reset(self):
self.set_level(LEVEL_NORMAL)
def _zoomed_by_page(self, view, zoom):
# Ctrl+wheel, inside the engine. Taken as the teacher's level for
# every tab, on the nearest of the same steps the keys use.
if abs(zoom - self.zoom_factor()) > 0.001:
self.set_level(zoom / self._room_zoom)
def _close_current(self):
self.close_tab(self._tabs.currentIndex())
@@ -1201,37 +1414,17 @@ def keep_going(kind, value, trace):
traceback.print_exception(kind, value, trace, file=sys.stderr)
def main():
config = read_device_config()
# A device that has no Wonder Ink has no Wonder Ink. The panel does not
# offer it, so nothing should reach here; saying it out loud is for the
# technician who ran the command by hand and wants to know why.
# feature(), not enabled(..., default=True). The default belongs in
# classroom_config.FEATURES with every other application's, so that
# the panel and the command can never disagree about whether a device
# offers this — which is the whole reason that table exists.
if not feature(config, "WONDERINK_ENABLED"):
say("WONDERINK_ENABLED is false on this device; not starting")
return 0
if raise_running_instance():
return 0
sys.excepthook = keep_going
QGuiApplication.setDesktopFileName("ekklesia-wonderink")
app = QApplication(sys.argv)
app.setApplicationName("Wonder Ink")
# The name the panel matches a window to its launcher by. Wayland has
# no window class, so this is the app id, and it has to agree with
# StartupWMClass in ekklesia-wonderink.desktop exactly.
app.setDesktopFileName("ekklesia-wonderink")
def build_profile(app, name="wonderink"):
"""The engine profile every Wonder Ink window uses, settings and all.
Its own function so that the lab's checks build exactly this rather
than a profile of their own: a check that forgot PluginsEnabled once
showed every worksheet as a blank page, and blamed Wonder Ink.
"""
PROFILE.mkdir(parents=True, exist_ok=True)
CACHE.mkdir(parents=True, exist_ok=True)
profile = QWebEngineProfile("wonderink", app)
profile = QWebEngineProfile(name, app)
profile.setPersistentStoragePath(str(PROFILE))
profile.setCachePath(str(CACHE))
# The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave
@@ -1301,9 +1494,41 @@ def main():
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False
)
return profile
def main():
config = read_device_config()
# A device that has no Wonder Ink has no Wonder Ink. The panel does not
# offer it, so nothing should reach here; saying it out loud is for the
# technician who ran the command by hand and wants to know why.
# feature(), not enabled(..., default=True). The default belongs in
# classroom_config.FEATURES with every other application's, so that
# the panel and the command can never disagree about whether a device
# offers this — which is the whole reason that table exists.
if not feature(config, "WONDERINK_ENABLED"):
say("WONDERINK_ENABLED is false on this device; not starting")
return 0
if raise_running_instance():
return 0
sys.excepthook = keep_going
QGuiApplication.setDesktopFileName("ekklesia-wonderink")
app = QApplication(sys.argv)
app.setApplicationName("Wonder Ink")
# The name the panel matches a window to its launcher by. Wayland has
# no window class, so this is the app id, and it has to agree with
# StartupWMClass in ekklesia-wonderink.desktop exactly.
app.setDesktopFileName("ekklesia-wonderink")
profile = build_profile(app)
DOWNLOADS.mkdir(parents=True, exist_ok=True)
window = WonderInk(profile)
window = WonderInk(profile, room_zoom=ui_scale(config))
# Connected to the window rather than to a loose function: a finished
# worksheet becomes a tab, and only a window can make one.
profile.downloadRequested.connect(window.on_download)