Files
Classroom_Desktop/scripts/classroom-dock
T
ekklesiaandClaude Opus 5 3e69f3f05c refactor: share the stylesheet renderer
The DVD control bar has to look like the dock, which is best guaranteed
by drawing it from the same template with the same measurements rather
than by keeping two stylesheets in step by hand.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-04 22:33:37 -05:00

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
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
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 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(ui_scale(read_device_config()))
shell = ClassroomShell()
shell.connect(
"destroy",
Gtk.main_quit,
)
shell.show_all()
Gtk.main()
if __name__ == "__main__":
main()