diff --git a/config/dock/style.css b/config/dock/style.css.in similarity index 66% rename from config/dock/style.css rename to config/dock/style.css.in index e4769d5..037389e 100644 --- a/config/dock/style.css +++ b/config/dock/style.css.in @@ -1,3 +1,11 @@ +/* Ekklesia Classroom dock + * + * A template, not plain CSS. classroom-dock substitutes the bracketed + * measurements at startup from UI_SCALE in device.conf, so a room can + * be tuned for its TV without anyone editing this file. At UI_SCALE=1.0 + * every token resolves to the value it had before scaling existed. + */ + window#classroom-dock, window#classroom-clock { background-color: transparent; @@ -12,9 +20,9 @@ window#classroom-clock { background-color: rgba(34, 38, 50, 0.62); border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 24px; + border-radius: @dock-radius@px; - padding: 8px 10px; + padding: @dock-padding-v@px @dock-padding-h@px; /* Important: no shadow here. GTK was clipping it against the rectangular window edge. */ @@ -28,13 +36,13 @@ button.dock-button { background-color: rgba(255, 255, 255, 0.05); border: none; - border-radius: 16px; + border-radius: @button-radius@px; - padding: 6px; + padding: @button-padding@px; margin: 0; - min-width: 44px; - min-height: 44px; + min-width: @button-size@px; + min-height: @button-size@px; box-shadow: none; outline: none; @@ -74,11 +82,11 @@ button.dock-button image { background-color: rgba(34, 38, 50, 0.62); border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 16px; + border-radius: @clock-radius@px; - padding: 7px 12px; + padding: @clock-padding-v@px @clock-padding-h@px; - font-size: 17px; + font-size: @clock-font@px; font-weight: 600; box-shadow: none; diff --git a/config/mako/config b/config/mako/config.in similarity index 62% rename from config/mako/config rename to config/mako/config.in index d718a5e..b8cda50 100644 --- a/config/mako/config +++ b/config/mako/config.in @@ -4,8 +4,13 @@ # Matched to the dock: same dark slate (#222632), same hairline white # border, same rounded corners, white text. Kept slightly more opaque # than the dock so text stays readable over the wallpaper and over video. +# +# A template, not a finished config. classroom-notifications resolves the +# bracketed measurements from UI_SCALE in device.conf and starts mako on +# the result. At UI_SCALE=1.0 they resolve to the values they had before +# scaling existed. -font=DejaVu Sans 12 +font=DejaVu Sans @font-size@ background-color=#222632e6 text-color=#ffffffff @@ -13,13 +18,13 @@ border-color=#ffffff21 progress-color=over #464b5ae6 border-size=1 -border-radius=18 +border-radius=@radius@ -padding=14,18 -margin=18 +padding=@padding-v@,@padding-h@ +margin=@margin@ -width=380 -height=160 +width=@width@ +height=@height@ anchor=top-right layer=overlay @@ -30,7 +35,7 @@ markup=1 format=%s\n%b icons=1 -max-icon-size=48 +max-icon-size=@icon-size@ default-timeout=6000 diff --git a/config/sway/config b/config/sway/config index 7fcbfbc..7c9771b 100644 --- a/config/sway/config +++ b/config/sway/config @@ -7,9 +7,9 @@ set $mod Mod4 output * bg /usr/share/backgrounds/ekklesia/classroom-default.png fill # Notifications, started before the dock so nothing can race D-Bus into -# activating an unstyled mako first. mako only ever searches ~/.config, -# so the packaged config has to be named explicitly. -exec --no-startup-id /usr/bin/mako --config /etc/ekklesia-classroom/mako/config +# activating an unstyled mako first. The wrapper resolves the styling at +# the room's UI scale and execs mako, so the process is still just mako. +exec --no-startup-id /usr/local/bin/classroom-notifications # Ekklesia Classroom dock exec --no-startup-id /usr/local/bin/classroom-dock diff --git a/docs/testing.md b/docs/testing.md index f87058a..1e6225c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,6 +11,17 @@ before promoting to stable with `./update_stable.sh`. - [ ] Dock appears, centred - [ ] Clock appears, bottom right +## UI scale + +- [ ] `UI_SCALE=1.0` looks exactly as it did before scaling existed +- [ ] `UI_SCALE=1.25`, then `sudo systemctl restart getty@tty1`: + dock, icons, clock and notifications all grow together +- [ ] `UI_SCALE=0.8` shrinks them together +- [ ] The reserved bottom strip grows and shrinks with the dock, and a + maximised Chrome window still stops above it +- [ ] A nonsense value (`UI_SCALE=banana`) still gives a working shell +- [ ] An extreme value (`UI_SCALE=9`) is clamped, not obeyed + ## Reserved bottom shell (regression) The bottom strip is one full-width layer-shell surface with a 76px diff --git a/docs/ui-scale.md b/docs/ui-scale.md new file mode 100644 index 0000000..f752c0e --- /dev/null +++ b/docs/ui-scale.md @@ -0,0 +1,60 @@ +# UI scale + +Every classroom gets the same design at a size that suits its TV. A 32" +panel a few feet from the children and a 65" set across a hall want +different sizes of the same dock, not different docks. + +## Changing it + +Edit `/etc/ekklesia-classroom/device.conf` on that endpoint: + +``` +UI_SCALE=1.25 +``` + +Then restart the classroom shell: + +``` +sudo systemctl restart getty@tty1 +``` + +That ends the session and the autologin brings it straight back with the +new size. A reboot does the same thing. Nothing else needs touching, and +no CSS or source file is ever edited to change a room's size. + +## Values + +| UI_SCALE | Roughly | +| --- | --- | +| 0.8 | Small or close-up panel | +| 1.0 | Default. 1080p classroom TV, 32–50" | +| 1.25 | Large room, or a set viewed from further back | +| 1.5 | 65"+ | + +Anything outside 0.8–1.5 is clamped to that range, and anything that is +not a number at all falls back to 1.0, so a typo in device.conf cannot +produce an unusable shell. + +## What it scales + +- Dock height, and the reserved strip at the bottom of the screen +- Dock corner radius, padding and the spacing between buttons +- Button size, padding and corner radius +- Every dock icon +- Clock text size, padding, corner radius and its inset from the edge +- Notification width, height, padding, margin, corner radius, text size + and icon size + +Colours, translucency and the hairline borders do not scale: those are +the design, not the size. At `UI_SCALE=1.0` the rendered stylesheet is +byte-identical to the one the shell used before scaling existed. + +## How it works + +`/etc/ekklesia-classroom/dock/style.css.in` and +`/etc/ekklesia-classroom/mako/config.in` are templates carrying +measurement tokens. `classroom-dock` resolves the stylesheet in memory at +startup; `classroom-notifications` resolves the notification config into +the session's runtime directory and hands it to mako. Neither template is +ever rewritten on disk, so an update cannot lose a room's setting and a +room's setting cannot drift from the packaged design. diff --git a/lib/classroom_config.py b/lib/classroom_config.py new file mode 100644 index 0000000..840d9bd --- /dev/null +++ b/lib/classroom_config.py @@ -0,0 +1,65 @@ +"""Device configuration for the Ekklesia Classroom shell. + +Everything that has to agree about /etc/ekklesia-classroom/device.conf +reads it through here, so the dock, the notification daemon and the DVD +commands cannot drift apart over what a setting means. +""" + +import math +from pathlib import Path + +CONFIG_FILE = Path("/etc/ekklesia-classroom/device.conf") + +TRUTHY = {"1", "true", "yes", "on", "enabled"} + +# The dock is tuned for a 1080p classroom TV at 1.0. Smaller rooms with a +# 32" panel sit near the bottom of this range, a 65" set near the top. +UI_SCALE_DEFAULT = 1.0 +UI_SCALE_MIN = 0.8 +UI_SCALE_MAX = 1.5 + + +def read_device_config(path=None): + config = {} + path = Path(path) if path is not None else CONFIG_FILE + + if not path.exists(): + return config + + for raw_line in path.read_text().splitlines(): + line = raw_line.strip() + + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + config[key.strip()] = value.strip() + + return config + + +def enabled(config, key, default=False): + if key not in config: + return default + + return config[key].lower() in TRUTHY + + +def ui_scale(config): + """UI_SCALE, clamped to something a classroom can actually use.""" + raw = config.get("UI_SCALE", "") + + try: + value = float(raw) + except (TypeError, ValueError): + return UI_SCALE_DEFAULT + + if not math.isfinite(value): + return UI_SCALE_DEFAULT + + return min(max(value, UI_SCALE_MIN), UI_SCALE_MAX) + + +def scaled(value, scale): + """A pixel measurement at the configured scale, never smaller than 1.""" + return max(1, int(round(value * scale))) diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 00ae229..db8e168 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -22,6 +22,7 @@ chmod 0755 /usr/local/bin/classroom-window chmod 0755 /usr/local/bin/classroom-dvd chmod 0755 /usr/local/bin/classroom-dvd-css-setup chmod 0755 /usr/local/bin/classroom-notify-test +chmod 0755 /usr/local/bin/classroom-notifications # Reading the optical drive needs group cdrom. Takes effect at the next # login, which the OTA reboot provides. @@ -38,13 +39,18 @@ CHANNEL=stable # Set to true on classrooms that have a DVD drive. DVD_ENABLED=false + +# Size of the dock, clock and notifications, 0.8 to 1.5. +# Larger rooms and larger TVs want a larger number. +UI_SCALE=1.0 CONFIG chmod 0644 /etc/ekklesia-classroom/device.conf fi -# Make the DVD option discoverable on classrooms installed before it -# existed. false is what those rooms already do, so nothing changes. +# Make the settings discoverable on classrooms installed before they +# existed. Both defaults are what those rooms already do, so nothing +# about their behaviour changes. if ! grep -q '^DVD_ENABLED=' /etc/ekklesia-classroom/device.conf; then cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG' @@ -53,6 +59,15 @@ DVD_ENABLED=false CONFIG fi +if ! grep -q '^UI_SCALE=' /etc/ekklesia-classroom/device.conf; then + cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG' + +# Size of the dock, clock and notifications, 0.8 to 1.5. +# Larger rooms and larger TVs want a larger number. +UI_SCALE=1.0 +CONFIG +fi + systemctl daemon-reload || true systemctl enable ekklesia-classroom-update.timer || true diff --git a/scripts/classroom-dock b/scripts/classroom-dock index c926a0b..ed38524 100755 --- a/scripts/classroom-dock +++ b/scripts/classroom-dock @@ -12,53 +12,38 @@ gi.require_version("GtkLayerShell", "0.1") from gi.repository import Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") + +from classroom_config import ( # noqa: E402 + enabled, + read_device_config, + scaled, + ui_scale, +) + # Optional on purpose. The dock is the one thing the classroom cannot # afford to lose, so a missing DVD helper simply disables disc detection # instead of taking the whole shell down. -sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") - try: import classroom_optical as optical except ImportError: optical = None -CONFIG_FILE = Path("/etc/ekklesia-classroom/device.conf") -CSS_FILE = Path("/etc/ekklesia-classroom/dock/style.css") +CSS_FILE = Path("/etc/ekklesia-classroom/dock/style.css.in") ICON_DIR = Path("/etc/ekklesia-classroom/icons") DVD_POLL_SECONDS = 2 +# Every measurement in the shell, at UI_SCALE=1.0. The CSS template +# carries the same numbers; these are the ones GTK needs in Python. +SHELL_HEIGHT = 76 +DOCK_SPACING = 10 +CLOCK_EDGE_PADDING = 20 -def read_device_config(): - config = {} - - if not CONFIG_FILE.exists(): - return config - - for raw_line in CONFIG_FILE.read_text().splitlines(): - line = raw_line.strip() - - if not line or line.startswith("#") or "=" not in line: - continue - - key, value = line.split("=", 1) - config[key.strip()] = value.strip() - - return config - - -def enabled(config, key, default=False): - if key not in config: - return default - - return config[key].lower() in { - "1", - "true", - "yes", - "on", - "enabled", - } +BROWSER_ICON_SIZE = 28 +DVD_ICON_SIZE = 40 +SANCTUARY_ICON_SIZE = 38 def launch(command): @@ -122,6 +107,7 @@ class ClassroomShell(Gtk.Window): self.set_resizable(False) self.config = read_device_config() + self.scale = ui_scale(self.config) self.dvd_image = None self.disc_ready = None @@ -148,14 +134,16 @@ class ClassroomShell(Gtk.Window): True, ) - GtkLayerShell.set_exclusive_zone(self, 76) + shell_height = scaled(SHELL_HEIGHT, self.scale) + + GtkLayerShell.set_exclusive_zone(self, shell_height) GtkLayerShell.set_keyboard_interactivity( self, False, ) layout = Gtk.Overlay() - layout.set_size_request(-1, 76) + layout.set_size_request(-1, shell_height) dock = self.build_dock() @@ -181,7 +169,7 @@ class ClassroomShell(Gtk.Window): 0, 0, 0, - 20, + scaled(CLOCK_EDGE_PADDING, self.scale), ) clock_align.add(self.clock) @@ -199,7 +187,7 @@ class ClassroomShell(Gtk.Window): def build_dock(self): dock = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, - spacing=10, + spacing=scaled(DOCK_SPACING, self.scale), ) dock.get_style_context().add_class("dock") @@ -262,9 +250,11 @@ class ClassroomShell(Gtk.Window): return button def browser_icon(self): + size = scaled(BROWSER_ICON_SIZE, self.scale) + image = custom_icon( "browser.svg", - 28, + size, ) if image is not None: @@ -274,14 +264,16 @@ class ClassroomShell(Gtk.Window): "web-browser-symbolic", "internet-web-browser-symbolic", ) - image.set_pixel_size(28) + image.set_pixel_size(size) return image def dvd_icon(self): + size = scaled(DVD_ICON_SIZE, self.scale) + image = custom_icon( "dvd.svg", - 40, + size, ) if image is not None: @@ -292,7 +284,7 @@ class ClassroomShell(Gtk.Window): "media-optical-symbolic", "media-optical", ) - image.set_pixel_size(28) + image.set_pixel_size(size) return image @@ -309,9 +301,11 @@ class ClassroomShell(Gtk.Window): else "live-off.svg" ) + size = scaled(SANCTUARY_ICON_SIZE, self.scale) + image = custom_icon( filename, - 38, + size, ) if image is not None: @@ -321,7 +315,7 @@ class ClassroomShell(Gtk.Window): "video-display-symbolic", "video-display", ) - image.set_pixel_size(28) + image.set_pixel_size(size) return image @@ -364,11 +358,46 @@ class ClassroomShell(Gtk.Window): return True -def load_css(): - provider = Gtk.CssProvider() - provider.load_from_path( - str(CSS_FILE) - ) +def render_css(scale): + """Resolve the measurement tokens in the stylesheet template.""" + css = CSS_FILE.read_text() + + measurements = { + "dock-radius": 24, + "dock-padding-v": 8, + "dock-padding-h": 10, + "button-radius": 16, + "button-padding": 6, + "button-size": 44, + "clock-radius": 16, + "clock-padding-v": 7, + "clock-padding-h": 12, + "clock-font": 17, + } + + for token, value in measurements.items(): + css = css.replace( + f"@{token}@", + str(scaled(value, scale)), + ) + + return css + + +def load_css(scale): + # Styling is worth losing before the dock is. A stylesheet that will + # not parse leaves an unstyled but working shell rather than no shell. + try: + provider = Gtk.CssProvider() + provider.load_from_data( + render_css(scale).encode("utf-8") + ) + except (OSError, GLib.Error) as error: + print( + f"classroom-dock: stylesheet not applied: {error}", + file=sys.stderr, + ) + return screen = Gdk.Screen.get_default() @@ -380,7 +409,7 @@ def load_css(): def main(): - load_css() + load_css(ui_scale(read_device_config())) shell = ClassroomShell() shell.connect( diff --git a/scripts/classroom-notifications b/scripts/classroom-notifications new file mode 100755 index 0000000..97ef84c --- /dev/null +++ b/scripts/classroom-notifications @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +"""Starts mako with the classroom styling at the room's UI scale. + +mako only reads ~/.config, and its measurements have to follow UI_SCALE, +so the packaged template is resolved into the session's runtime directory +and mako is pointed at the result. +""" + +import os +import sys +from pathlib import Path + +sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") + +from classroom_config import read_device_config, scaled, ui_scale # noqa: E402 + +MAKO = "/usr/bin/mako" +TEMPLATE = Path("/etc/ekklesia-classroom/mako/config.in") + +# The same measurements the template carries, at UI_SCALE=1.0. +MEASUREMENTS = { + "font-size": 12, + "radius": 18, + "padding-v": 14, + "padding-h": 18, + "margin": 18, + "width": 380, + "height": 160, + "icon-size": 48, +} + + +def runtime_directory(): + base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp" + + directory = Path(base) / "ekklesia-classroom" + directory.mkdir(parents=True, exist_ok=True) + + return directory + + +def render(scale): + config = TEMPLATE.read_text() + + for token, value in MEASUREMENTS.items(): + config = config.replace( + f"@{token}@", + str(scaled(value, scale)), + ) + + return config + + +def main(): + scale = ui_scale(read_device_config()) + + try: + path = runtime_directory() / "mako.config" + path.write_text(render(scale)) + config_argument = str(path) + except OSError as error: + # Better plain notifications than none at all. + print( + f"classroom-notifications: falling back to the unscaled template: {error}", + file=sys.stderr, + ) + config_argument = str(TEMPLATE) + + os.execv(MAKO, [MAKO, "--config", config_argument]) + + +if __name__ == "__main__": + main() diff --git a/tools/build-release.sh b/tools/build-release.sh index 7f39e2a..4c231a1 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -113,7 +113,15 @@ install -Dm755 \ "$ROOT/scripts/classroom-notify-test" \ "$STAGE/usr/local/bin/classroom-notify-test" -# Shared helpers imported by the dock and by classroom-dvd. +install -Dm755 \ + "$ROOT/scripts/classroom-notifications" \ + "$STAGE/usr/local/bin/classroom-notifications" + +# Shared helpers imported by the classroom commands. +install -Dm644 \ + "$ROOT/lib/classroom_config.py" \ + "$STAGE/usr/local/lib/ekklesia-classroom/classroom_config.py" + install -Dm644 \ "$ROOT/lib/classroom_optical.py" \ "$STAGE/usr/local/lib/ekklesia-classroom/classroom_optical.py" @@ -126,15 +134,15 @@ install -Dm755 \ "$ROOT/scripts/classroom-dock" \ "$STAGE/usr/local/bin/classroom-dock" +# Stylesheet and notification templates. Both carry measurement tokens +# that are resolved from UI_SCALE when the session starts. install -Dm644 \ - "$ROOT/config/dock/style.css" \ - "$STAGE/etc/ekklesia-classroom/dock/style.css" + "$ROOT/config/dock/style.css.in" \ + "$STAGE/etc/ekklesia-classroom/dock/style.css.in" -# Notification styling, matched to the dock. mako only searches -# ~/.config, so sway starts it with an explicit --config path. install -Dm644 \ - "$ROOT/config/mako/config" \ - "$STAGE/etc/ekklesia-classroom/mako/config" + "$ROOT/config/mako/config.in" \ + "$STAGE/etc/ekklesia-classroom/mako/config.in" # Chrome enterprise policy: suppress first-run and sign-in nags, and pin # Home to Wonder Ink. Nothing here restricts where teachers can browse.