diff --git a/config/dock/style.css.in b/config/dock/style.css.in index 037389e..4a6bf87 100644 --- a/config/dock/style.css.in +++ b/config/dock/style.css.in @@ -72,6 +72,15 @@ button.dock-button image { -gtk-icon-effect: none; } +/* Shown in place of the icon while an application is starting. */ +button.dock-button spinner { + color: white; + opacity: 1; + + min-width: @button-spinner@px; + min-height: @button-spinner@px; +} + /* Clock */ diff --git a/lib/classroom_style.py b/lib/classroom_style.py index b80199e..f1f9bc3 100644 --- a/lib/classroom_style.py +++ b/lib/classroom_style.py @@ -18,6 +18,7 @@ MEASUREMENTS = { "button-radius": 16, "button-padding": 6, "button-size": 44, + "button-spinner": 38, "clock-radius": 16, "clock-padding-v": 7, "clock-padding-h": 12, diff --git a/scripts/classroom-dock b/scripts/classroom-dock index 90c6904..b64ad02 100755 --- a/scripts/classroom-dock +++ b/scripts/classroom-dock @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import math import subprocess import sys from datetime import datetime @@ -30,6 +31,20 @@ try: 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") @@ -45,6 +60,27 @@ 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) @@ -91,6 +127,73 @@ def custom_icon(filename, size): return Gtk.Image.new_from_pixbuf(pixbuf) +def throbber_frames(size): + """A white arc, drawn once per frame of one revolution. + + Pre-rendered into pixbufs and swapped into a Gtk.Image rather than + drawn live, so the busy state stays an ordinary image and the button + underneath keeps behaving exactly as it does with an icon. + """ + 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, size) + 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( + Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size) + ) + + return frames + + +class Throbber(Gtk.Image): + """The busy state of a dock button.""" + + def __init__(self, size): + super().__init__() + + self.frames = throbber_frames(size) + self.frame = 0 + self.timer = None + + if self.frames: + self.set_from_pixbuf(self.frames[0]) + + 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_pixbuf(self.frames[self.frame]) + + return True + + class ClassroomShell(Gtk.Window): def __init__(self): super().__init__() @@ -111,6 +214,7 @@ class ClassroomShell(Gtk.Window): self.dvd_image = None self.disc_ready = None + self.buttons = {} GtkLayerShell.init_for_window(self) GtkLayerShell.set_layer( @@ -193,10 +297,9 @@ class ClassroomShell(Gtk.Window): dock.pack_start( self.make_button( + "browser", self.browser_icon(), - lambda *_: launch( - ["/usr/local/bin/classroom-browser"] - ), + self.open_browser, ), False, False, @@ -210,6 +313,7 @@ class ClassroomShell(Gtk.Window): ): dock.pack_start( self.make_button( + "dvd", self.dvd_icon(), lambda *_: launch( ["/usr/local/bin/classroom-dvd", "toggle"] @@ -224,6 +328,7 @@ class ClassroomShell(Gtk.Window): dock.pack_start( self.make_button( + "sanctuary", self.sanctuary_icon(), lambda *_: notify( "Sanctuary", @@ -237,7 +342,7 @@ class ClassroomShell(Gtk.Window): return dock - def make_button(self, image, callback): + def make_button(self, name, image, callback): button = Gtk.Button() button.set_relief(Gtk.ReliefStyle.NONE) button.get_style_context().add_class( @@ -247,8 +352,143 @@ class ClassroomShell(Gtk.Window): 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)