build: keep the DVD and MQTT helpers, and count the deletion honestly
Building the package showed what reading the tree did not: classroom-dvd still calls classroom-window, classroom-mqtt still calls classroom-notify, and the DVD overlay still imports the icon and stylesheet helpers the dock shared. They go back in, and the write-up now counts what was actually removed rather than what could be. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6e4786f448
commit
4c3f19d599
@@ -1,314 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Keep Chrome's first window tiled and make later windows usable popups.
|
||||
|
||||
Chrome gives the main browser and its secondary top-level windows the same
|
||||
app_id, the same process and often nothing else to tell them apart. The only
|
||||
stable fact is creation order, so this remembers the first Chrome container
|
||||
and treats every later one as secondary. Page titles are deliberately ignored.
|
||||
|
||||
The close affordance for a secondary window lives in the dock. An earlier
|
||||
attempt anchored a layer-shell button to the popup's own corner and moved it
|
||||
whenever the window moved; on real hardware it chased the window and
|
||||
flickered, because every position change meant tearing a surface down and
|
||||
building another one. The dock is already on screen, already owns the
|
||||
classroom's visual language, and does not move. This publishes which window
|
||||
the dock's Close button should act on, and the dock does the rest.
|
||||
|
||||
Driven by Sway's own window events rather than a poll, so a window that is
|
||||
being dragged costs nothing.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
||||
)
|
||||
|
||||
from classroom_chrome import ChromeRoles, chrome_windows # noqa: E402
|
||||
from classroom_sway import command, windows # noqa: E402
|
||||
|
||||
SECONDARY_SIZE_PERCENT = 76
|
||||
|
||||
# Chrome renegotiates the size of a popup that was opened with explicit
|
||||
# dimensions, and does it after the resize returns. Centring before that
|
||||
# settles leaves the window visibly off to one side, so it is placed
|
||||
# once more a moment later. Only ever at creation: after that the window
|
||||
# is the teacher's to move, and nothing here touches it again.
|
||||
SETTLE_SECONDS = 0.35
|
||||
|
||||
# Everything else a window event can report — a title change, an urgency
|
||||
# hint, a mark — cannot change which window is which, and Chrome retitles
|
||||
# constantly.
|
||||
INTERESTING = frozenset(
|
||||
("new", "close", "focus", "move", "floating", "fullscreen_mode")
|
||||
)
|
||||
|
||||
RESUBSCRIBE_SECONDS = 2
|
||||
|
||||
# The system print dialog, matched by its exact title.
|
||||
#
|
||||
# This is the one place a title is used, and only to recognise a single
|
||||
# known dialog. It is not classifying Chrome windows — creation order
|
||||
# still does that. The dialog and the window that rendered the document
|
||||
# for it are otherwise identical to sway, and if the render window ends
|
||||
# up in front the teacher cannot print at all.
|
||||
PRINT_TITLE = "Print"
|
||||
|
||||
STATE_NAME = "chrome-close-target"
|
||||
|
||||
|
||||
def state_directory():
|
||||
runtime = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp")
|
||||
directory = runtime / "ekklesia-classroom"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return directory
|
||||
|
||||
|
||||
def criterion(identifier):
|
||||
return f"[con_id={identifier}]"
|
||||
|
||||
|
||||
def configure_primary(identifier):
|
||||
"""A promoted remaining window becomes the ordinary classroom browser."""
|
||||
command(f"{criterion(identifier)} floating disable")
|
||||
command(f"{criterion(identifier)} border none")
|
||||
command(f"{criterion(identifier)} focus")
|
||||
|
||||
|
||||
def place(target):
|
||||
command(
|
||||
f"{target} resize set width {SECONDARY_SIZE_PERCENT} ppt "
|
||||
f"height {SECONDARY_SIZE_PERCENT} ppt"
|
||||
)
|
||||
command(f"{target} move position center")
|
||||
|
||||
|
||||
def configure_secondary(identifier, arriving=True):
|
||||
"""Float one exact container; never apply a broad Chrome float rule."""
|
||||
target = criterion(identifier)
|
||||
command(f"{target} floating enable")
|
||||
command(f"{target} border none")
|
||||
|
||||
if not arriving:
|
||||
# Only restoring the floating role to a window that already
|
||||
# existed. Never move it: that would undo wherever the teacher
|
||||
# dragged it. Never focus it: that would raise the render window
|
||||
# over the print dialog sitting above it.
|
||||
return
|
||||
|
||||
place(target)
|
||||
command(f"{target} focus")
|
||||
|
||||
# Let Chrome have its say about the size, then place it again.
|
||||
time.sleep(SETTLE_SECONDS)
|
||||
place(target)
|
||||
|
||||
|
||||
def is_print_dialog(window):
|
||||
return (window or {}).get("name") == PRINT_TITLE
|
||||
|
||||
|
||||
class ActiveSecondary:
|
||||
"""The one secondary window the dock offers to close.
|
||||
|
||||
Deliberately sticky. This used to be whichever secondary happened to
|
||||
be focused, which made the button impossible to click: moving the
|
||||
pointer down to the dock moves focus away from the popup, so the
|
||||
button disappeared on the way to it. Nobody could ever reach it.
|
||||
|
||||
It is remembered instead. Losing focus changes nothing. It only
|
||||
changes when the window it names goes away, or when another
|
||||
secondary takes over by being focused.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.target = None
|
||||
|
||||
def update(self, secondary, chrome):
|
||||
# A focused secondary takes over. Focus on the dock, the print
|
||||
# dialog, the main browser or any layer-shell surface is not a
|
||||
# secondary, so none of them disturb this.
|
||||
for identifier in secondary:
|
||||
window = chrome.get(identifier)
|
||||
|
||||
if window and window["focused"] and not window["hidden"]:
|
||||
self.target = identifier
|
||||
break
|
||||
|
||||
# Never anything that has closed, and never the main browser:
|
||||
# `secondary` cannot contain the primary.
|
||||
if self.target is not None and self.target not in secondary:
|
||||
self.target = None
|
||||
|
||||
# If the remembered window closed, fall back to another one that
|
||||
# is still open, so cancelling a print dialog leaves the render
|
||||
# window still closeable from the dock.
|
||||
if self.target is None:
|
||||
live = [
|
||||
identifier
|
||||
for identifier in secondary
|
||||
if identifier in chrome and not chrome[identifier]["hidden"]
|
||||
]
|
||||
self.target = live[-1] if live else None
|
||||
|
||||
return self.target
|
||||
|
||||
|
||||
def publish(target, path):
|
||||
"""Tell the dock which window to offer to close, atomically."""
|
||||
temporary = path.with_suffix(".tmp")
|
||||
|
||||
try:
|
||||
temporary.write_text("" if target is None else f"{target}\n")
|
||||
os.replace(temporary, path)
|
||||
except OSError as error:
|
||||
print(f"[classroom-chrome-windows] could not publish state: {error}",
|
||||
flush=True)
|
||||
|
||||
|
||||
def reconcile(roles, active, path):
|
||||
current = windows()
|
||||
chrome = {window["id"]: window for window in chrome_windows(current)}
|
||||
changes = roles.update(current)
|
||||
|
||||
if changes["promoted"] is not None:
|
||||
configure_primary(changes["promoted"])
|
||||
|
||||
# The print dialog is configured last, because configuring a window
|
||||
# focuses it and whichever is done last ends up in front. Both can
|
||||
# arrive between two events, and the tree does not promise to list
|
||||
# them in the order they were created.
|
||||
arriving = sorted(
|
||||
changes["new_secondary"],
|
||||
key=lambda identifier: is_print_dialog(chrome.get(identifier)),
|
||||
)
|
||||
|
||||
for identifier in arriving:
|
||||
configure_secondary(identifier)
|
||||
|
||||
# Reassert the role if something put a secondary back into the tiling
|
||||
# layout — a restore, or an application-initiated geometry change.
|
||||
# Hidden windows are left parked quietly.
|
||||
for identifier in roles.secondary:
|
||||
window = chrome.get(identifier)
|
||||
|
||||
if window and not window["hidden"] and not window["floating"]:
|
||||
configure_secondary(identifier, arriving=False)
|
||||
|
||||
keep_print_in_front(roles.secondary, chrome)
|
||||
|
||||
publish(active.update(roles.secondary, chrome), path)
|
||||
|
||||
|
||||
def keep_print_in_front(secondary, chrome):
|
||||
"""The print dialog stays the obvious foreground window.
|
||||
|
||||
The window that rendered the document is a secondary like any other
|
||||
and will raise itself over the dialog given the chance, which leaves
|
||||
the teacher looking at a document they cannot print.
|
||||
|
||||
Focus is only ever taken back from another Chrome popup. Clicking
|
||||
the main browser, the dock or anything else is the teacher's
|
||||
business and is left alone.
|
||||
"""
|
||||
dialogs = [
|
||||
identifier
|
||||
for identifier in secondary
|
||||
if identifier in chrome
|
||||
and is_print_dialog(chrome[identifier])
|
||||
and not chrome[identifier]["hidden"]
|
||||
]
|
||||
|
||||
if not dialogs:
|
||||
return
|
||||
|
||||
dialog = dialogs[-1]
|
||||
|
||||
if chrome[dialog]["focused"]:
|
||||
return
|
||||
|
||||
intruder = next(
|
||||
(
|
||||
identifier
|
||||
for identifier in secondary
|
||||
if identifier in chrome
|
||||
and chrome[identifier]["focused"]
|
||||
and identifier != dialog
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if intruder is None:
|
||||
return
|
||||
|
||||
command(f"[con_id={dialog}] focus")
|
||||
|
||||
|
||||
def subscribe():
|
||||
try:
|
||||
return subprocess.Popen(
|
||||
["swaymsg", "-t", "subscribe", "-m", "-r", '["window"]'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def watch():
|
||||
directory = state_directory()
|
||||
path = directory / STATE_NAME
|
||||
|
||||
with (directory / "chrome-windows.lock").open("w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return 0
|
||||
|
||||
roles = ChromeRoles()
|
||||
active = ActiveSecondary()
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Whatever happened while there was no subscription.
|
||||
reconcile(roles, active, path)
|
||||
|
||||
stream = subscribe()
|
||||
|
||||
if stream is None or stream.stdout is None:
|
||||
time.sleep(RESUBSCRIBE_SECONDS)
|
||||
continue
|
||||
|
||||
for line in stream.stdout:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if event.get("change") in INTERESTING:
|
||||
reconcile(roles, active, path)
|
||||
|
||||
# Sway went away or restarted; wait, then pick it back up.
|
||||
stream.wait()
|
||||
time.sleep(RESUBSCRIBE_SECONDS)
|
||||
finally:
|
||||
publish(None, path)
|
||||
|
||||
|
||||
def main():
|
||||
return watch()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,754 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import math
|
||||
import os
|
||||
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 Gio, 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
|
||||
|
||||
# Which secondary Chrome window the Close button acts on, published by
|
||||
# classroom-chrome-windows. A file rather than a socket because the dock
|
||||
# only ever reads it, and Gio can watch it without a poll.
|
||||
CLOSE_TARGET_NAME = "chrome-close-target"
|
||||
|
||||
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 = {}
|
||||
self.close_target = None
|
||||
self.close_monitor = 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")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
# Only ever on screen while a secondary browser window is focused,
|
||||
# so the ordinary dock is exactly what it has always been.
|
||||
close = self.make_button(
|
||||
"close",
|
||||
custom_icon("close.svg", scaled(BROWSER_ICON_SIZE, self.scale))
|
||||
or themed_icon("window-close-symbolic"),
|
||||
self.close_secondary,
|
||||
)
|
||||
close.set_no_show_all(True)
|
||||
close.set_visible(False)
|
||||
|
||||
# show_all() does not recurse into a no-show-all widget, so the
|
||||
# icon has to be shown by hand or the button appears empty when
|
||||
# it is finally revealed.
|
||||
self.buttons["close"]["image"].show()
|
||||
|
||||
dock.pack_start(close, False, False, 0)
|
||||
|
||||
self.watch_close_target()
|
||||
|
||||
return dock
|
||||
|
||||
# -- the close affordance for secondary browser windows ---------
|
||||
#
|
||||
# The button lives here rather than on the window because a surface
|
||||
# anchored to a moving window chases it and flickers. The dock does
|
||||
# not move.
|
||||
|
||||
def watch_close_target(self):
|
||||
directory = Path(
|
||||
os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
|
||||
) / "ekklesia-classroom"
|
||||
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.close_path = directory / CLOSE_TARGET_NAME
|
||||
|
||||
# Watching the directory rather than the file: the state is
|
||||
# published by rename, so the file itself is replaced.
|
||||
self.close_monitor = Gio.File.new_for_path(
|
||||
str(directory)
|
||||
).monitor_directory(Gio.FileMonitorFlags.NONE, None)
|
||||
|
||||
self.close_monitor.connect(
|
||||
"changed",
|
||||
self.on_close_target_changed,
|
||||
)
|
||||
except (OSError, GLib.Error) as error:
|
||||
print(
|
||||
f"classroom-dock: not watching for browser popups: {error}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
self.close_path = None
|
||||
return
|
||||
|
||||
self.refresh_close_target()
|
||||
|
||||
def on_close_target_changed(self, monitor, changed, other, event):
|
||||
if changed is not None and changed.get_basename() != CLOSE_TARGET_NAME:
|
||||
return
|
||||
|
||||
self.refresh_close_target()
|
||||
|
||||
def refresh_close_target(self):
|
||||
target = None
|
||||
|
||||
if self.close_path is not None:
|
||||
try:
|
||||
text = self.close_path.read_text().strip()
|
||||
except OSError:
|
||||
text = ""
|
||||
|
||||
if text.isdigit():
|
||||
target = int(text)
|
||||
|
||||
if target == self.close_target:
|
||||
return
|
||||
|
||||
self.close_target = target
|
||||
|
||||
entry = self.buttons.get("close")
|
||||
|
||||
if entry is not None:
|
||||
entry["button"].set_visible(target is not None)
|
||||
|
||||
def close_secondary(self, *_):
|
||||
if self.close_target is None or sway is None:
|
||||
return
|
||||
|
||||
# Closes exactly this container. Never the main browser, and
|
||||
# never a Chrome process.
|
||||
sway.command(f"[con_id={self.close_target}] kill")
|
||||
|
||||
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()
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Starts mako with the classroom styling at the room's UI scale.
|
||||
|
||||
mako only reads ~/.config, and its measurements have to follow UI_SCALE,
|
||||
so the packaged template is resolved into the session's runtime directory
|
||||
and mako is pointed at the result.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
||||
|
||||
from classroom_config import SHELL_SCALE, scaled # noqa: E402
|
||||
|
||||
MAKO = "/usr/bin/mako"
|
||||
TEMPLATE = Path("/etc/ekklesia-classroom/mako/config.in")
|
||||
|
||||
# The same measurements the template carries, at UI_SCALE=1.0.
|
||||
MEASUREMENTS = {
|
||||
"font-size": 12,
|
||||
"radius": 18,
|
||||
"padding-v": 14,
|
||||
"padding-h": 18,
|
||||
"margin": 18,
|
||||
"width": 380,
|
||||
"height": 160,
|
||||
"icon-size": 48,
|
||||
}
|
||||
|
||||
|
||||
def runtime_directory():
|
||||
base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
|
||||
|
||||
directory = Path(base) / "ekklesia-classroom"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return directory
|
||||
|
||||
|
||||
def render(scale):
|
||||
config = TEMPLATE.read_text()
|
||||
|
||||
for token, value in MEASUREMENTS.items():
|
||||
config = config.replace(
|
||||
f"@{token}@",
|
||||
str(scaled(value, scale)),
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def main():
|
||||
scale = SHELL_SCALE
|
||||
|
||||
try:
|
||||
path = runtime_directory() / "mako.config"
|
||||
path.write_text(render(scale))
|
||||
config_argument = str(path)
|
||||
except OSError as error:
|
||||
# Better plain notifications than none at all.
|
||||
print(
|
||||
f"classroom-notifications: falling back to the unscaled template: {error}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
config_argument = str(TEMPLATE)
|
||||
|
||||
os.execv(MAKO, [MAKO, "--config", config_argument])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+23
-21
@@ -11,12 +11,12 @@ set -euo pipefail
|
||||
# Used by classroom-notify-test to send a sample, and by classroom-mqtt
|
||||
# to put messages from the church broker on the screen.
|
||||
#
|
||||
# The classroom desktop runs as the classroom user, and mako is attached
|
||||
# to that user's D-Bus session. An administrator over SSH sits in a
|
||||
# different session where nothing owns org.freedesktop.Notifications, so
|
||||
# a plain notify-send there asks D-Bus to start a second mako, which has
|
||||
# no display to open and times out with NoReply. Find the running desktop
|
||||
# session and deliver into it instead.
|
||||
# The classroom desktop runs as the classroom user, and the notification
|
||||
# service is part of that user's Plasma session. An administrator over SSH
|
||||
# sits in a different session where nothing owns
|
||||
# org.freedesktop.Notifications, so a plain notify-send there asks D-Bus to
|
||||
# start a service that has no display to open, and times out with NoReply.
|
||||
# Find the running desktop session and deliver into it instead.
|
||||
|
||||
CLASSROOM_USER=classroom
|
||||
|
||||
@@ -80,7 +80,7 @@ environ_value() {
|
||||
printf '%s\n' "$1" | sed -n "s/^$2=//p" | head -n 1
|
||||
}
|
||||
|
||||
# sway holds its own wayland-N.lock open, so the display name can be read
|
||||
# The compositor holds its own wayland-N.lock open, so the display name can be read
|
||||
# back off the process instead of being guessed at.
|
||||
display_from_lock() {
|
||||
local link target
|
||||
@@ -115,17 +115,19 @@ if [[ "$current_uid" == "$classroom_uid" &&
|
||||
-n "${WAYLAND_DISPLAY:-}" &&
|
||||
-n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
|
||||
# Already inside the classroom desktop session.
|
||||
sway_pid="n/a"
|
||||
session_pid="n/a"
|
||||
runtime_dir="${XDG_RUNTIME_DIR:-}"
|
||||
bus="$DBUS_SESSION_BUS_ADDRESS"
|
||||
wayland_display="$WAYLAND_DISPLAY"
|
||||
else
|
||||
session="the $CLASSROOM_USER desktop session"
|
||||
|
||||
sway_pid="$(pgrep -u "$CLASSROOM_USER" -x sway | head -n 1 || true)"
|
||||
# The compositor is the session: it owns the Wayland display and the
|
||||
# environment every other part of the session inherited.
|
||||
session_pid="$(pgrep -u "$CLASSROOM_USER" -x kwin_wayland | head -n 1 || true)"
|
||||
|
||||
[[ -n "$sway_pid" ]] ||
|
||||
fail "no classroom desktop session is running: there is no sway process for user $CLASSROOM_USER."
|
||||
[[ -n "$session_pid" ]] ||
|
||||
fail "no classroom desktop session is running: there is no kwin_wayland process for user $CLASSROOM_USER."
|
||||
|
||||
# Checked before anything that needs to look inside the session, so
|
||||
# an administrator gets told what to do rather than a permission
|
||||
@@ -134,12 +136,12 @@ else
|
||||
fail "run this as root (sudo classroom-notify) or as the $CLASSROOM_USER user."
|
||||
fi
|
||||
|
||||
environment_block="$(read_environ "$sway_pid")"
|
||||
environment_block="$(read_environ "$session_pid")"
|
||||
|
||||
if [[ -n "$environment_block" ]]; then
|
||||
source_note="read from sway (pid $sway_pid)"
|
||||
source_note="read from the compositor (pid $session_pid)"
|
||||
else
|
||||
source_note="sway's environment was unreadable; using session defaults"
|
||||
source_note="the compositor's environment was unreadable; using session defaults"
|
||||
fi
|
||||
|
||||
runtime_dir="$(environ_value "$environment_block" XDG_RUNTIME_DIR)"
|
||||
@@ -164,7 +166,7 @@ else
|
||||
wayland_display="$(environ_value "$environment_block" WAYLAND_DISPLAY)"
|
||||
|
||||
if [[ -z "$wayland_display" ]]; then
|
||||
wayland_display="$(display_from_lock "$sway_pid")"
|
||||
wayland_display="$(display_from_lock "$session_pid")"
|
||||
fi
|
||||
|
||||
environment=(
|
||||
@@ -186,15 +188,15 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deliver to the mako that is already running. Sending into a session
|
||||
# without one would have D-Bus start a second mako, which is exactly the
|
||||
# failure this script exists to avoid.
|
||||
pgrep -u "$CLASSROOM_USER" -x mako >/dev/null 2>&1 ||
|
||||
fail "mako is not running in the classroom session, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a mako"
|
||||
# Deliver to the notification service that is already running. Sending into
|
||||
# a session without one would have D-Bus start something with no display to
|
||||
# draw on, which is exactly the failure this script exists to avoid.
|
||||
pgrep -u "$CLASSROOM_USER" -x plasmashell >/dev/null 2>&1 ||
|
||||
fail "the classroom shell is not running, and this will not start it. Check with: pgrep -u $CLASSROOM_USER -a plasmashell"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
echo "Would notify $session:"
|
||||
echo " sway pid: $sway_pid"
|
||||
echo " compositor pid: $session_pid"
|
||||
echo " session environment: $source_note"
|
||||
echo " XDG_RUNTIME_DIR: $runtime_dir"
|
||||
echo " DBUS_SESSION_BUS_ADDRESS: $bus"
|
||||
|
||||
Reference in New Issue
Block a user