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]>
426 lines
9.6 KiB
Python
Executable File
426 lines
9.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "3.0")
|
|
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.
|
|
try:
|
|
import classroom_optical as optical
|
|
except ImportError:
|
|
optical = None
|
|
|
|
|
|
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
|
|
|
|
BROWSER_ICON_SIZE = 28
|
|
DVD_ICON_SIZE = 40
|
|
SANCTUARY_ICON_SIZE = 38
|
|
|
|
|
|
def launch(command):
|
|
subprocess.Popen(command, start_new_session=True)
|
|
|
|
|
|
def notify(title, message):
|
|
launch([
|
|
"notify-send",
|
|
"--app-name=Ekklesia Classroom",
|
|
title,
|
|
message,
|
|
])
|
|
|
|
|
|
def themed_icon(*names):
|
|
theme = Gtk.IconTheme.get_default()
|
|
|
|
for name in names:
|
|
if theme.has_icon(name):
|
|
return Gtk.Image.new_from_icon_name(
|
|
name,
|
|
Gtk.IconSize.DIALOG,
|
|
)
|
|
|
|
return Gtk.Image.new_from_icon_name(
|
|
"application-x-executable",
|
|
Gtk.IconSize.DIALOG,
|
|
)
|
|
|
|
|
|
def custom_icon(filename, size):
|
|
path = ICON_DIR / filename
|
|
|
|
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)
|
|
|
|
|
|
class ClassroomShell(Gtk.Window):
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
screen = self.get_screen()
|
|
visual = screen.get_rgba_visual()
|
|
|
|
if visual is not None:
|
|
self.set_visual(visual)
|
|
|
|
self.set_app_paintable(True)
|
|
self.set_name("classroom-shell")
|
|
self.set_decorated(False)
|
|
self.set_resizable(False)
|
|
|
|
self.config = read_device_config()
|
|
self.scale = ui_scale(self.config)
|
|
|
|
self.dvd_image = None
|
|
self.disc_ready = None
|
|
|
|
GtkLayerShell.init_for_window(self)
|
|
GtkLayerShell.set_layer(
|
|
self,
|
|
GtkLayerShell.Layer.TOP,
|
|
)
|
|
|
|
GtkLayerShell.set_anchor(
|
|
self,
|
|
GtkLayerShell.Edge.BOTTOM,
|
|
True,
|
|
)
|
|
GtkLayerShell.set_anchor(
|
|
self,
|
|
GtkLayerShell.Edge.LEFT,
|
|
True,
|
|
)
|
|
GtkLayerShell.set_anchor(
|
|
self,
|
|
GtkLayerShell.Edge.RIGHT,
|
|
True,
|
|
)
|
|
|
|
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, shell_height)
|
|
|
|
dock = self.build_dock()
|
|
|
|
dock_align = Gtk.Alignment(
|
|
xalign=0.5,
|
|
yalign=0.5,
|
|
xscale=0,
|
|
yscale=0,
|
|
)
|
|
dock_align.add(dock)
|
|
layout.add(dock_align)
|
|
|
|
self.clock = Gtk.Label()
|
|
self.clock.get_style_context().add_class("clock")
|
|
|
|
clock_align = Gtk.Alignment(
|
|
xalign=1.0,
|
|
yalign=0.5,
|
|
xscale=0,
|
|
yscale=0,
|
|
)
|
|
clock_align.set_padding(
|
|
0,
|
|
0,
|
|
0,
|
|
scaled(CLOCK_EDGE_PADDING, self.scale),
|
|
)
|
|
clock_align.add(self.clock)
|
|
|
|
layout.add_overlay(clock_align)
|
|
layout.set_overlay_pass_through(clock_align, True)
|
|
|
|
self.add(layout)
|
|
|
|
self.update_time()
|
|
GLib.timeout_add_seconds(
|
|
15,
|
|
self.update_time,
|
|
)
|
|
|
|
def build_dock(self):
|
|
dock = Gtk.Box(
|
|
orientation=Gtk.Orientation.HORIZONTAL,
|
|
spacing=scaled(DOCK_SPACING, self.scale),
|
|
)
|
|
dock.get_style_context().add_class("dock")
|
|
|
|
dock.pack_start(
|
|
self.make_button(
|
|
self.browser_icon(),
|
|
lambda *_: launch(
|
|
["/usr/local/bin/classroom-browser"]
|
|
),
|
|
),
|
|
False,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
if enabled(
|
|
self.config,
|
|
"DVD_ENABLED",
|
|
False,
|
|
):
|
|
dock.pack_start(
|
|
self.make_button(
|
|
self.dvd_icon(),
|
|
lambda *_: launch(
|
|
["/usr/local/bin/classroom-dvd", "toggle"]
|
|
),
|
|
),
|
|
False,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
self.start_disc_watch()
|
|
|
|
dock.pack_start(
|
|
self.make_button(
|
|
self.sanctuary_icon(),
|
|
lambda *_: notify(
|
|
"Sanctuary",
|
|
"Sanctuary live view is being prepared.",
|
|
),
|
|
),
|
|
False,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
return dock
|
|
|
|
def make_button(self, image, callback):
|
|
button = Gtk.Button()
|
|
button.set_relief(Gtk.ReliefStyle.NONE)
|
|
button.get_style_context().add_class(
|
|
"dock-button"
|
|
)
|
|
|
|
button.add(image)
|
|
button.connect("clicked", callback)
|
|
|
|
return button
|
|
|
|
def browser_icon(self):
|
|
size = scaled(BROWSER_ICON_SIZE, self.scale)
|
|
|
|
image = custom_icon(
|
|
"browser.svg",
|
|
size,
|
|
)
|
|
|
|
if image is not None:
|
|
return image
|
|
|
|
image = themed_icon(
|
|
"web-browser-symbolic",
|
|
"internet-web-browser-symbolic",
|
|
)
|
|
image.set_pixel_size(size)
|
|
|
|
return image
|
|
|
|
def dvd_icon(self):
|
|
size = scaled(DVD_ICON_SIZE, self.scale)
|
|
|
|
image = custom_icon(
|
|
"dvd.svg",
|
|
size,
|
|
)
|
|
|
|
if image is not None:
|
|
self.dvd_image = image
|
|
return image
|
|
|
|
image = themed_icon(
|
|
"media-optical-symbolic",
|
|
"media-optical",
|
|
)
|
|
image.set_pixel_size(size)
|
|
|
|
return image
|
|
|
|
def sanctuary_icon(self):
|
|
is_live = enabled(
|
|
self.config,
|
|
"SANCTUARY_LIVE",
|
|
False,
|
|
)
|
|
|
|
filename = (
|
|
"live.svg"
|
|
if is_live
|
|
else "live-off.svg"
|
|
)
|
|
|
|
size = scaled(SANCTUARY_ICON_SIZE, self.scale)
|
|
|
|
image = custom_icon(
|
|
filename,
|
|
size,
|
|
)
|
|
|
|
if image is not None:
|
|
return image
|
|
|
|
image = themed_icon(
|
|
"video-display-symbolic",
|
|
"video-display",
|
|
)
|
|
image.set_pixel_size(size)
|
|
|
|
return image
|
|
|
|
def start_disc_watch(self):
|
|
if optical is None:
|
|
return
|
|
|
|
self.check_disc()
|
|
|
|
GLib.timeout_add_seconds(
|
|
DVD_POLL_SECONDS,
|
|
self.check_disc,
|
|
)
|
|
|
|
def check_disc(self):
|
|
ready = optical.drive_status() == optical.CDS_DISC_OK
|
|
|
|
# The disc indicator: the DVD logo sits back when the drive is
|
|
# empty or unplugged and comes up to full strength once a disc is
|
|
# ready, the same gray/colour language the LIVE icon uses.
|
|
if self.dvd_image is not None:
|
|
self.dvd_image.set_opacity(1.0 if ready else 0.55)
|
|
|
|
# The first poll only records what is already in the drive, so a
|
|
# disc left in over the week does not announce itself at boot.
|
|
if self.disc_ready is not None and ready and not self.disc_ready:
|
|
notify(
|
|
"DVD Ready",
|
|
"A disc is ready to play.",
|
|
)
|
|
|
|
self.disc_ready = ready
|
|
|
|
return True
|
|
|
|
def update_time(self):
|
|
self.clock.set_text(
|
|
datetime.now().strftime("%-I:%M %p")
|
|
)
|
|
return True
|
|
|
|
|
|
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()
|
|
|
|
Gtk.StyleContext.add_provider_for_screen(
|
|
screen,
|
|
provider,
|
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
)
|
|
|
|
|
|
def main():
|
|
load_css(ui_scale(read_device_config()))
|
|
|
|
shell = ClassroomShell()
|
|
shell.connect(
|
|
"destroy",
|
|
Gtk.main_quit,
|
|
)
|
|
shell.show_all()
|
|
|
|
Gtk.main()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|