655 lines
16 KiB
Python
Executable File
655 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import math
|
|
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,
|
|
dock_items,
|
|
read_device_config,
|
|
scaled,
|
|
)
|
|
from classroom_icons import ScalableIcon # noqa: E402
|
|
|
|
# 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
|
|
|
|
# Only used for the busy feedback on the dock buttons, so a missing
|
|
# module costs a nicety rather than the shell.
|
|
try:
|
|
import classroom_sway as sway
|
|
except ImportError:
|
|
sway = None
|
|
|
|
# Used to draw the throbber. Without it the busy state falls back to a
|
|
# stock GtkSpinner, which works but is smaller than the icon language.
|
|
try:
|
|
import cairo
|
|
except ImportError:
|
|
cairo = 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
|
|
|
|
# How Chrome appears in the sway tree, so the dock can tell a launch from
|
|
# a restore and stop spinning once the window is really there.
|
|
BROWSER_WINDOW = (
|
|
r"google-chrome",
|
|
r"chrome",
|
|
r"chrome-.*",
|
|
r"google-chrome-.*",
|
|
)
|
|
|
|
# The throbber is drawn at the same size as the icon it stands in for,
|
|
# so the button does not change shape when an application is starting.
|
|
BUSY_SPINNER_SIZE = 38
|
|
THROBBER_FRAMES = 24
|
|
THROBBER_INTERVAL_MS = 80
|
|
|
|
BUSY_POLL_MS = 500
|
|
|
|
# Long enough for a cold Chrome start on classroom hardware, short enough
|
|
# that a launch which never happens does not spin all morning.
|
|
BUSY_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
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):
|
|
"""A classroom SVG icon, drawn at the display's real pixel density."""
|
|
icon = ScalableIcon(filename, size, ICON_DIR)
|
|
|
|
return icon if icon.usable else None
|
|
|
|
|
|
def throbber_frames(size, scale):
|
|
"""A white arc, drawn once per frame of one revolution.
|
|
|
|
Drawn at the display's real density like the icons, so the busy
|
|
state is not the one soft thing in a sharp dock. The surface carries
|
|
the device scale, so everything below is in logical units.
|
|
"""
|
|
if cairo is None:
|
|
return None
|
|
|
|
stroke = max(2, round(size / 9))
|
|
radius = size / 2 - stroke / 2 - 1
|
|
frames = []
|
|
|
|
for index in range(THROBBER_FRAMES):
|
|
surface = cairo.ImageSurface(
|
|
cairo.FORMAT_ARGB32,
|
|
size * scale,
|
|
size * scale,
|
|
)
|
|
surface.set_device_scale(scale, scale)
|
|
|
|
context = cairo.Context(surface)
|
|
|
|
context.set_source_rgba(1, 1, 1, 1)
|
|
context.set_line_width(stroke)
|
|
context.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
|
|
start = index * 2 * math.pi / THROBBER_FRAMES
|
|
context.arc(size / 2, size / 2, radius, start, start + math.pi * 1.4)
|
|
context.stroke()
|
|
|
|
frames.append(surface)
|
|
|
|
return frames
|
|
|
|
|
|
class Throbber(Gtk.Image):
|
|
"""The busy state of a dock button."""
|
|
|
|
def __init__(self, size):
|
|
super().__init__()
|
|
|
|
self.size = size
|
|
self.frames = throbber_frames(size, 1)
|
|
self.drawn_at = 1
|
|
self.frame = 0
|
|
self.timer = None
|
|
|
|
if self.frames:
|
|
self.set_from_surface(self.frames[0])
|
|
|
|
self.connect("realize", lambda *_: self.rebuild())
|
|
self.connect("notify::scale-factor", lambda *_: self.rebuild())
|
|
|
|
def rebuild(self):
|
|
scale = self.get_scale_factor() or 1
|
|
|
|
if scale == self.drawn_at:
|
|
return
|
|
|
|
frames = throbber_frames(self.size, scale)
|
|
|
|
if not frames:
|
|
return
|
|
|
|
self.frames = frames
|
|
self.drawn_at = scale
|
|
self.set_from_surface(self.frames[self.frame])
|
|
|
|
def usable(self):
|
|
return bool(self.frames)
|
|
|
|
def start(self):
|
|
if not self.frames or self.timer is not None:
|
|
return
|
|
|
|
self.timer = GLib.timeout_add(THROBBER_INTERVAL_MS, self.advance)
|
|
|
|
def stop(self):
|
|
if self.timer is not None:
|
|
GLib.source_remove(self.timer)
|
|
self.timer = None
|
|
|
|
def advance(self):
|
|
self.frame = (self.frame + 1) % len(self.frames)
|
|
self.set_from_surface(self.frames[self.frame])
|
|
|
|
return True
|
|
|
|
|
|
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
|
|
self.buttons = {}
|
|
|
|
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")
|
|
|
|
items = dock_items(self.config)
|
|
|
|
dock.pack_start(
|
|
self.make_button(
|
|
"browser",
|
|
self.browser_icon(),
|
|
self.open_browser,
|
|
),
|
|
False,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
if "dvd" in items:
|
|
dock.pack_start(
|
|
self.make_button(
|
|
"dvd",
|
|
self.dvd_icon(),
|
|
lambda *_: launch(
|
|
["/usr/local/bin/classroom-dvd", "toggle"]
|
|
),
|
|
),
|
|
False,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
self.start_disc_watch()
|
|
|
|
if "live" in items:
|
|
dock.pack_start(
|
|
self.make_button(
|
|
"sanctuary",
|
|
self.sanctuary_icon(),
|
|
lambda *_: notify(
|
|
"Sanctuary",
|
|
"Sanctuary live view is being prepared.",
|
|
),
|
|
),
|
|
False,
|
|
False,
|
|
0,
|
|
)
|
|
|
|
return dock
|
|
|
|
def make_button(self, name, 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)
|
|
|
|
self.buttons[name] = {
|
|
"button": button,
|
|
"image": image,
|
|
"source": None,
|
|
"spinner": None,
|
|
"waited": 0,
|
|
}
|
|
|
|
return button
|
|
|
|
# -- busy feedback ----------------------------------------------
|
|
#
|
|
# A click has to be visibly acknowledged even when the thing it
|
|
# starts takes several seconds to appear. The button the finger
|
|
# landed on becomes a spinner until the window shows up in sway.
|
|
|
|
def window_open(self, patterns):
|
|
if sway is None:
|
|
return True
|
|
|
|
return any(
|
|
sway.matches(window, patterns) for window in sway.windows()
|
|
)
|
|
|
|
def open_browser(self, *_):
|
|
# Only a real launch is worth spinning over. Focusing or
|
|
# restoring an open window is immediate and should feel it.
|
|
starting = not self.window_open(BROWSER_WINDOW)
|
|
|
|
launch(["/usr/local/bin/classroom-browser"])
|
|
|
|
if starting:
|
|
self.start_busy(
|
|
"browser",
|
|
BROWSER_WINDOW,
|
|
"Browser",
|
|
"The browser did not start. Please try again.",
|
|
)
|
|
|
|
def start_busy(self, name, patterns, title, message):
|
|
entry = self.buttons.get(name)
|
|
|
|
if sway is None or entry is None or entry["source"] is not None:
|
|
return
|
|
|
|
spinner = Throbber(scaled(BROWSER_ICON_SIZE, self.scale))
|
|
|
|
if not spinner.usable():
|
|
# No cairo: the stock spinner is smaller than the icon
|
|
# language would like, but it still says "working".
|
|
spinner = Gtk.Spinner()
|
|
size = scaled(BUSY_SPINNER_SIZE, self.scale)
|
|
spinner.set_size_request(size, size)
|
|
|
|
entry["button"].remove(entry["image"])
|
|
entry["button"].add(spinner)
|
|
spinner.show()
|
|
spinner.start()
|
|
|
|
entry["spinner"] = spinner
|
|
entry["waited"] = 0
|
|
entry["source"] = GLib.timeout_add(
|
|
BUSY_POLL_MS,
|
|
self.check_busy,
|
|
name,
|
|
patterns,
|
|
title,
|
|
message,
|
|
)
|
|
|
|
self.set_busy_cursor(True)
|
|
|
|
def check_busy(self, name, patterns, title, message):
|
|
entry = self.buttons.get(name)
|
|
|
|
if entry is None or entry["source"] is None:
|
|
return GLib.SOURCE_REMOVE
|
|
|
|
entry["waited"] += BUSY_POLL_MS / 1000
|
|
|
|
if self.window_open(patterns):
|
|
self.stop_busy(name)
|
|
return GLib.SOURCE_REMOVE
|
|
|
|
if entry["waited"] >= BUSY_TIMEOUT_SECONDS:
|
|
self.stop_busy(name)
|
|
notify(title, message)
|
|
return GLib.SOURCE_REMOVE
|
|
|
|
return GLib.SOURCE_CONTINUE
|
|
|
|
def stop_busy(self, name):
|
|
entry = self.buttons.get(name)
|
|
|
|
if entry is None or entry["source"] is None:
|
|
return
|
|
|
|
entry["source"] = None
|
|
|
|
spinner = entry["spinner"]
|
|
|
|
if spinner is not None:
|
|
spinner.stop()
|
|
entry["button"].remove(spinner)
|
|
entry["spinner"] = None
|
|
|
|
entry["button"].add(entry["image"])
|
|
entry["image"].show()
|
|
|
|
self.set_busy_cursor(
|
|
any(other["source"] is not None for other in self.buttons.values())
|
|
)
|
|
|
|
def set_busy_cursor(self, busy):
|
|
"""A waiting pointer, for as long as it is over the dock.
|
|
|
|
Wayland gives the cursor to whichever client the pointer is over,
|
|
and sway offers no way to set one globally, so this is only
|
|
honoured on our own surface. That is where the pointer is when a
|
|
dock button has just been pressed, which is the moment that
|
|
matters.
|
|
"""
|
|
window = self.get_window()
|
|
|
|
if window is None:
|
|
return
|
|
|
|
cursor = None
|
|
|
|
if busy:
|
|
cursor = Gdk.Cursor.new_from_name(
|
|
window.get_display(),
|
|
"progress",
|
|
)
|
|
|
|
window.set_cursor(cursor)
|
|
|
|
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()
|