UI_SCALE only ever reached the surfaces we draw ourselves. Chrome stayed tiny — its tabs, omnibox and menus are Chrome's own pixels, and Ctrl+ only scales page content, which is no use to a volunteer reading a toolbar from across the room. UI_SCALE is a property of the television, not of one program, so it now sets sway's output scale, which every client is measured against. classroom-scale runs first in the session and applies it. The display mode is untouched: the panel stays at 1920x1080 and only the logical coordinate space shrinks, which is what makes everything in it larger. Our own surfaces stop scaling themselves. They are measured in the same logical pixels sway is already scaling, so applying UI_SCALE again would double it. SHELL_SCALE is that decision in one named place, and setting it back to ui_scale(config) would restore per-surface scaling without touching a template. Measured at 1920x1080 with Chrome 152. Chrome's tab strip 40 -> 49 -> 59px against 1.0, 1.25, 1.5; sway's title bar 25 -> 31 -> 38px; the classroom dock 74 -> 92 -> 110px. Everything scales once, together, and 1.0 is identical to no scaling at all. Two tradeoffs, both measured rather than assumed. GTK 3 has no fractional scaling, so at 1.5 it renders at 2x and sway scales down — very slightly softer, invisible at classroom distance. XWayland is softer too: terminal text at 1.5 showed about 50% more intermediate-tone pixels than at 1.0. VLC is the only XWayland application here, and in DVD mode none of its interface is on screen, only video from a 720x480 disc that is being upscaled regardless. Chrome is a native Wayland client and stays sharp, which is what matters for reading. Co-Authored-By: Claude Opus 5 <[email protected]>
400 lines
9.0 KiB
Python
Executable File
400 lines
9.0 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")
|
|
|
|
import classroom_style as style # noqa: E402
|
|
from classroom_config import ( # noqa: E402
|
|
SHELL_SCALE,
|
|
enabled,
|
|
read_device_config,
|
|
scaled,
|
|
)
|
|
|
|
# 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
|
|
|
|
|
|
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 = SHELL_SCALE
|
|
|
|
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 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(
|
|
style.render(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(SHELL_SCALE)
|
|
|
|
shell = ClassroomShell()
|
|
shell.connect(
|
|
"destroy",
|
|
Gtk.main_quit,
|
|
)
|
|
shell.show_all()
|
|
|
|
Gtk.main()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|