feat: scale the shell to the room's television

A 32" panel a few feet from the children and a 65" set across a hall
want the same design at different sizes. UI_SCALE in device.conf now
sets that, and nobody has to edit CSS or source to tune a room.

The stylesheet and the notification config become 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 execs mako on it, so the process is
still just mako and everything that looks for it by name still finds it.
Neither template is rewritten on disk, so an update cannot lose a room's
setting and a room cannot drift from the packaged design.

Dock height and the reserved strip, corner radii, padding, button size,
spacing, every icon, the clock and its inset, and the notification
geometry all follow the scale. Colours, translucency and the hairline
borders do not: 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 any of this existed, which is the property worth
keeping. Values outside 0.8 to 1.5 are clamped and anything that is not
a number falls back to 1.0, so a typo in device.conf cannot produce an
unusable classroom.

Device configuration moves into a shared module, since the dock and the
notification daemon now have to agree about what a setting means.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-04 21:33:09 -05:00
co-authored by Claude Opus 5
parent 02094a6931
commit 428eb27a74
10 changed files with 352 additions and 77 deletions
+78 -49
View File
@@ -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(
+74
View File
@@ -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()