fix: harden classroom presentations

This commit is contained in:
ekklesia
2026-09-23 00:02:25 -05:00
parent ec1e0d1cd7
commit 6f2d169355
16 changed files with 709 additions and 215 deletions
@@ -87,26 +87,9 @@ if (PARENT_CALL) {
parentCall.writeConfig("maxSectionCount", 1);
}
// HELP, beside Parent Call and not in the row with the applications.
//
// Every room gets it: the manual is the one thing that is never optional,
// because a teacher who needs it is by definition a teacher who does not
// know where to look. It shows only the pages for the features THIS
// device has — see classroom-manual — so the button means the same thing
// in every room while what it opens does not.
//
// On the left, with Parent Call, for the same reason Parent Call is
// there: it is not one of the room's applications, and putting it in the
// middle row would move buttons teachers reach for without looking.
var help = panel.addWidget("org.kde.plasma.quicklaunch");
help.currentConfigGroup = ["General"];
help.writeConfig(
"launcherUrls",
"file:///usr/share/applications/ekklesia-manual.desktop"
);
help.writeConfig("showLauncherNames", false);
help.writeConfig("enablePopup", false);
help.writeConfig("maxSectionCount", 1);
// Help is intentionally absent from this release's teacher-facing panel.
// Its launcher, source pages and build pipeline remain installed for the
// documentation correction pass; only the unfinished entry point is hidden.
var leftGap = panel.addWidget("org.kde.plasma.panelspacer");
leftGap.currentConfigGroup = ["General"];
+7 -8
View File
@@ -82,15 +82,14 @@ NoiseStrength=2
[org.kde.kdecoration2][$i]
library=org.kde.breeze
theme=Breeze
# A pin and a close button, and nothing else.
#
# The pin is the Live View story: keeping a window above the others is a
# window-manager state, not a feature the classroom has to invent. There is
# deliberately no minimise button, because a minimised window is the one
# thing a teacher cannot see the state of — measured, not assumed. Double
# clicking the title bar still maximises, so nothing is unreachable.
# A close button, and nothing else. `F` was the keep-above button, rendered
# by Breeze as an unexplained double chevron beside Close. Classroom windows
# that must stay above others request that state themselves; teachers do not
# need a title-bar toggle for it. There is deliberately no minimise button,
# because a minimised window is the one thing a teacher cannot see the state
# of. Double clicking the title bar still maximises ordinary windows.
ButtonsOnLeft=
ButtonsOnRight=FX
ButtonsOnRight=X
ShowToolTips=false
BorderSize=None
BorderSizeAuto=false
+20 -3
View File
@@ -1,6 +1,6 @@
# Window rules.
#
# There are two: one about video, one about the Parent Call card.
# There are three: video, the Parent Call card, and the presentation surface.
#
# Everything else the classroom used to arrange by hand is ordinary window
# manager behaviour and needs no rule: a dialog stays in front of the window
@@ -17,8 +17,8 @@
# 314-line window arbiter was doing.
[General]
count=2
rules=classroom-video,classroom-parent-call
count=3
rules=classroom-video,classroom-parent-call,classroom-presentation
[classroom-video]
Description=Video is an appliance screen, not a window
@@ -48,3 +48,20 @@ skippagerrule=2
# button, rather than behind it.
above=true
aboverule=2
[classroom-presentation]
Description=A presentation is the whole display, not a large window
# The library and Quick Look have the same Wayland app id, so the stable
# presenter title is part of this match. Matching the app alone would make
# every Presentations window fullscreen.
wmclass=ekklesia-presentations
wmclassmatch=1
wmclasscomplete=false
title=Presenting
titlematch=1
fullscreen=true
fullscreenrule=2
noborder=true
noborderrule=2
skiptaskbar=true
skiptaskbarrule=2
+28 -20
View File
@@ -52,6 +52,8 @@ hash.
This is not a new renderer. LibreOffice does exactly the rendering
Impress would have done live — it is the same engine — and poppler does
what every PDF viewer does. The classroom's own code displays a PNG.
Because playback is made from static page images, PowerPoint animations,
video, audio and other interactive content are not supported.
What it buys:
* **A teacher can never reach an editor**, because no editor ever runs
@@ -74,14 +76,15 @@ interactive runs, so no UI integration package is needed.
## Where things are
```
~/Downloads/ the teacher's own download. NEVER touched.
~/Downloads/ completed presentations move from here
~/Downloads/Wonder Ink/ Wonder Ink's worksheets. NEVER touched.
~/.local/share/ekklesia-classroom/presentations/
imported.json which downloads have been dealt with
<id>/
presentation.json title, pages, thumbnail slide, timers
Session 7 - Romans.pptx the managed copy, original name kept
original/
Session 7 - Romans.pptx the one original, name kept
slides.pdf what everything is rendered from
pages/page-001.png ... one per slide, 1920px wide
thumbnail.png the card picture, from the chosen slide
@@ -108,20 +111,22 @@ A file is imported when **all** of these hold:
* its fingerprint — path, size, modification time — is not already in
`imported.json`.
Two downloads of the same name are two presentations. The second gets
`(2)` after its title; neither overwrites the other.
Two downloads of the same name are two presentations in separate id
directories. The second gets `(2)` after its title; neither overwrites the
other.
**Crash safety.** The metadata is written saying `importing` *before* any
work, and the fingerprint is recorded at the same moment so a file that
crashes the converter cannot be retried forever. On startup the importer
sweeps every directory still saying `importing`, deletes it and forgets
its fingerprint, so an import interrupted by a reboot simply happens
again. A presentation becomes visible to a teacher only once every file
**Move and crash safety.** On one filesystem the completed download is
renamed atomically. Across filesystems it is copied to a hidden temporary,
flushed, published atomically, and only then removed from Downloads. The
metadata and transaction fingerprint are written first. On startup, an
interruption before the source was removed discards the partial destination
and retries; an interruption after the move resumes conversion from the
managed original. A presentation becomes ready only once every derived file
it needs is on disk.
A file that cannot be converted gets a card saying so, offering only
**Remove**. The teacher's download is untouched, which is what the card
says.
**Remove**. Its managed original remains recoverable even when PDF or page
generation fails.
## The three things kept apart
@@ -129,7 +134,7 @@ This is the part most worth not breaking.
| | Changed by | Never changed by |
| --- | --- | --- |
| **Library thumbnail** | *Use this slide as the picture* in Quick Look | presenting, importing |
| **Library cover** | *Use as cover* in Quick Look | presenting, importing |
| **Starting slide** | nothing — always slide 1 | choosing a thumbnail |
| **Retention** | a slide actually reaching the screen | Quick Look, opening the library, choosing a thumbnail, preview generation, a viewer that failed to start |
@@ -159,8 +164,9 @@ The cleanup runs hourly in the importer and:
directory so a crash cannot make a presentation permanently
un-removable;
* only ever removes a directory under the library root;
* never touches `~/Downloads`, and therefore never a Wonder Ink
worksheet;
* removes the managed original and all derived assets together;
* never touches unrelated files in `~/Downloads`, and therefore never a
Wonder Ink worksheet;
* tolerates a directory that is already half gone.
## The presenter
@@ -181,11 +187,13 @@ Page Up and Page Down are what the clicker in the cupboard sends.
Going past the last slide finishes, rather than showing a black "click to
exit" slide.
**Fullscreen is re-asserted, not assumed.** Measured in the lab: focusing
another window on the same workspace dropped the slideshow to half the
screen with the library beside it. `window-state-event` puts it back for
as long as the presentation is running, and stops the moment Escape is
pressed so it can never fight the teacher's way out.
**Fullscreen is re-asserted, not assumed.** The presenter requests
fullscreen after its Wayland surface is mapped, and its stable `Presenting`
title lets a presenter-only KWin rule apply fullscreen and remove the border
without affecting the library or Quick Look. `window-state-event` reasserts
the state for as long as the presentation is running and stops the moment
Escape is pressed. The library is hidden during playback and restored on
exit; the Plasma panel itself is never hidden or reconfigured.
## Fonts
Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

+16 -15
View File
@@ -497,21 +497,25 @@ sudo reboot
- [ ] It does **not** appear in the library while it is still downloading
- [ ] It appears by itself within a few seconds of the download finishing
- [ ] The title is readable — not a hash, not a long filename
- [ ] The original download is still in Chrome's downloads, untouched
- [ ] The completed original moved out of Downloads into the managed library;
there is no duplicate
- [ ] Download the **PDF and the PPTX** of the same session: two cards,
the second titled `(2)`, neither overwriting the other
### Quick Look
- [ ] **Quick Look** opens and shows slide 1
- [ ] **Quick Look** opens on the currently selected cover slide
- [ ] **Next** and **Previous** move through every slide
- [ ] The text and layout are right — check a slide with a Bible verse
- [ ] Go to the session-number slide, usually slide 2
- [ ] **Use this slide as the picture** changes the card
- [ ] **Use as cover** changes the card and shows **Current cover**
- [ ] **Back to Presentations** returns without presenting
- [ ] Close the library, open it again: the chosen picture is still there
- [ ] Reboot, open it again: still there
- [ ] `classroom-presentation-import --status` shows `presented never`
- [ ] At 1920×1080 and 1366×768, scale 1.0 and the larger supported scale,
the title, slide navigation, cover action, Back and Present all remain
above the panel without manually resizing
That last line is the one that matters. Quick Look, browsing every slide
and changing the picture must not have counted as a presentation.
@@ -519,6 +523,7 @@ and changing the picture must not have counted as a presentation.
### Present
- [ ] **Present** from the card fills the **external television**
- [ ] No panel, title bar, border, desktop or other application is visible
- [ ] It starts at slide **1**, not at the slide chosen for the picture
- [ ] **Present** from inside Quick Look does the same
- [ ] Space, the arrow keys and Page Up/Page Down all move slides
@@ -546,21 +551,17 @@ Use a **test copy**. Not tomorrow's session.
- [ ] **Remove** asks first
- [ ] The card goes, and its slides and pictures go with it
- [ ] The original download in Chrome is untouched
- [ ] Its managed original goes too; unrelated downloads and another similarly
named presentation remain
- [ ] It does not come back on the next scan or after a reboot
### The manual
### Help is temporarily hidden
- [ ] **Help** is on the bar, bottom left
- [ ] It opens with no address bar and no tabs
- [ ] It shows Browser, Presentations, sound and the common pages
- [ ] It does **not** mention Wonder Ink, Parent Call or the DVD
- [ ] It works with the network unplugged
- [ ] On a children's classroom machine it shows the opposite set
- [ ] Print `/usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf`
and check the conditional wording reads sensibly in a room that
does not have every feature
- [ ] The revision date is on the printed copy
- [ ] No **Help** launcher is visible on the teacher-facing bar
- [ ] The manual sources, generated HTML/PDF, launcher and feature-aware build
pipeline remain installed for the later documentation correction pass
- [ ] Presentations and Wonder Ink title bars show only Close, with no
double-chevron keep-above control
### Wi-Fi — look, do not touch
+26 -12
View File
@@ -5,15 +5,14 @@ underneath it — where the files live, what is known about each one, and
the two things that have to be right whoever is asking: when a download
has really finished, and when a presentation has really been presented.
WHY THERE IS A MANAGED COPY AT ALL.
WHY THE ORIGINAL LIVES IN THE MANAGED LIBRARY.
A teacher downloads a session through ordinary Chrome, into ordinary
~/Downloads. That file stays exactly where Chrome put it and is never
touched again — not renamed, not moved, not deleted, not by the library
and not by the cleanup. The library keeps its OWN copy, and everything
after the import happens to that copy. So a teacher who tidies their
downloads cannot break a presentation, and a cleanup that removes a
presentation cannot take a teacher's file with it.
A teacher downloads a session into ordinary ~/Downloads. Once the download
is complete, it is moved into this managed library. These locked-down
machines have no general file manager, so leaving another copy in Downloads
only wastes space and makes Remove misleading. The moved original is kept
beside the generated PDF and page images, which means conversion failure is
recoverable and cleanup has one contained directory to remove.
WHY EVERY FORMAT BECOMES A PDF.
@@ -459,6 +458,21 @@ def library(root=None):
return found
def remove(presentation):
"""Delete exactly one managed entry, unless it is on screen now."""
try:
showing = presenting_lock().read_text().strip()
except OSError:
showing = ""
if presentation.id == showing:
return False
shutil.rmtree(presentation.directory, ignore_errors=True)
return not presentation.directory.exists()
def titles_in_use(root=None, exclude=None):
"""Every title already on a card, optionally ignoring one presentation.
@@ -601,10 +615,10 @@ def ledger(root=None):
def remember(mark, value, root=None):
"""Record that this download has been dealt with.
Kept even after a teacher removes the presentation, so that Remove
means removed rather than removed-until-the-next-scan. The original
download is still in Downloads — untouched, as always — and without
this the importer would put it straight back.
The normal successful import moves the source out of Downloads, so it
cannot be found by a later scan. The ledger is still the transaction
record that makes an interrupted same- or cross-filesystem move safe:
startup can tell which half-built directory belongs to which source.
"""
data = ledger(root)
data[mark] = value
+198 -67
View File
@@ -12,10 +12,9 @@ import button, no "where did you save it". This is the in between.
WHAT IT WILL NOT DO.
It never touches the teacher's download. The file Chrome wrote stays
where Chrome wrote it, with the name Chrome gave it, for as long as
the teacher leaves it there. Everything the library does is done to
its own copy.
It never leaves a duplicate in Downloads. Once a supported download is
complete, its one original is moved into the managed library. Generated
PDFs and page images live beside it and can always be rebuilt from it.
It never imports a Wonder Ink worksheet. Wonder Ink downloads into a
directory of its own inside Downloads and those are worksheets, not
@@ -31,11 +30,13 @@ WHAT IT WILL NOT DO.
"""
import argparse
import errno
import os
import shutil
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
@@ -71,14 +72,159 @@ def new_directory(root):
return directory
def sync_directory(directory):
"""Best-effort persistence for a rename or unlink transaction."""
try:
descriptor = os.open(directory, os.O_RDONLY)
except OSError:
return
try:
os.fsync(descriptor)
except OSError:
pass
finally:
os.close(descriptor)
def move_download(source, destination):
"""Move one completed download without risking it on another filesystem.
rename is atomic on one filesystem. Across filesystems, copy to a hidden
temporary file, flush it, publish it atomically, and only then unlink the
source. If unlinking fails, the published copy is removed and the source
remains the one original for the next scan.
"""
source = os.fspath(source)
destination = os.fspath(destination)
temporary = destination + ".moving"
os.makedirs(os.path.dirname(destination), exist_ok=True)
if os.path.exists(destination) or os.path.exists(temporary):
raise FileExistsError(destination)
try:
os.replace(source, destination)
sync_directory(os.path.dirname(destination))
sync_directory(os.path.dirname(source))
return
except OSError as error:
if error.errno != errno.EXDEV:
raise
try:
with open(source, "rb") as incoming, open(temporary, "xb") as outgoing:
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
outgoing.flush()
os.fsync(outgoing.fileno())
shutil.copystat(source, temporary, follow_symlinks=True)
os.replace(temporary, destination)
sync_directory(os.path.dirname(destination))
try:
os.unlink(source)
except OSError:
# Roll back to the still-complete source rather than leave two
# originals or make a failed move look successful.
try:
os.unlink(destination)
sync_directory(os.path.dirname(destination))
except OSError:
pass
raise
sync_directory(os.path.dirname(source))
except Exception:
try:
os.unlink(temporary)
except OSError:
pass
raise
def forget_presentation(presentation_id, root):
"""Remove transaction records for one interrupted presentation."""
data = library.ledger(root)
changed = False
for mark, value in list(data.items()):
if value == presentation_id:
data.pop(mark, None)
changed = True
if changed:
library.write_json(library.ledger_path(root), data)
def prepare(presentation):
"""Build the derived PDF, pages and cover from a managed original."""
data = presentation.data
managed = presentation.managed_copy
if managed is None or not managed.is_file():
raise RuntimeError("the managed original is missing")
# A restart may find conversion or rendering output from the interrupted
# attempt. The managed original is the recovery point; everything else is
# derived and is rebuilt cleanly.
if presentation.slides != managed:
try:
presentation.slides.unlink()
except OSError:
pass
shutil.rmtree(presentation.pages_directory, ignore_errors=True)
try:
presentation.thumbnail.unlink()
except OSError:
pass
if data["format"] == "pdf":
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
data.get("original_name", ""),
library.first_text(presentation.slides)),
library.titles_in_use(presentation.directory.parent,
exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
data["error"] = None
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
def import_one(source, root=None):
"""Take one finished download into the library.
Ordered so that an interruption is always recoverable. The metadata
is written first, saying "importing"; the sweep at startup throws
away anything still saying that and lets it be imported again. A
presentation only becomes visible to a teacher once every file it
needs is on disk.
Ordered so that an interruption is always recoverable. Metadata and the
transaction record are written before the move. Startup either retries
from Downloads or resumes from the moved managed original. A presentation
becomes ready only once every derived file it needs is on disk.
"""
root = root if root is not None else library.library_root()
mark = library.fingerprint(source)
@@ -87,7 +233,7 @@ def import_one(source, root=None):
return None
directory = new_directory(root)
managed = directory / source.name
managed = directory / "original" / source.name
data = {
"id": directory.name,
@@ -95,7 +241,7 @@ def import_one(source, root=None):
"title": library.readable_title(source.name),
"original_name": source.name,
"source_path": str(source),
"managed_copy": source.name,
"managed_copy": str(managed.relative_to(directory)),
"format": library.SUPPORTED.get(source.suffix.lower(), ""),
"imported_at": library.stamp(),
"last_presented_at": None,
@@ -116,47 +262,22 @@ def import_one(source, root=None):
say(f"importing {source.name}")
try:
# THE TEACHER'S OWN DOWNLOAD IS COPIED, NEVER MOVED.
shutil.copy2(source, managed)
if data["format"] == "pdf":
# Already the thing everything else is converted into. A
# session exported from Google Slides as a PDF keeps exactly
# the appearance Google gave it, with no conversion at all.
shutil.copy2(managed, presentation.slides)
else:
converted, error = library.convert_to_pdf(
managed, presentation.slides)
if not converted:
raise RuntimeError(error or "the file could not be converted")
pages, error = library.render_pages(
presentation.slides, presentation.pages_directory)
if not pages:
raise RuntimeError(error or "the slides could not be rendered")
data["pages"] = pages
data["title"] = library.unique_title(
library.readable_title(
source.name, library.first_text(presentation.slides)),
library.titles_in_use(root, exclude=presentation.id),
)
library.render_thumbnail(presentation.page_image(1),
presentation.thumbnail)
data["state"] = "ready"
presentation.save()
say(f"imported {data['title']!r} ({pages} slides)")
return presentation
move_download(source, managed)
except Exception as error: # noqa: BLE001
# A card that says it could not be opened, rather than a card
# that opens onto nothing. The teacher's download is untouched
# either way, which is what the message says.
# A failed move leaves the source in Downloads. Remove the transaction
# and retry on the next scan; there is no half-owned file to show.
forget_presentation(presentation.id, root)
shutil.rmtree(directory, ignore_errors=True)
say(f"could not move {source.name}: {error}")
return None
try:
return prepare(presentation)
except Exception as error: # noqa: BLE001
# Conversion and rendering failures keep the managed original. The
# teacher can remove it, and a technician can recover or inspect it;
# no successfully downloaded file is lost because a derived asset
# could not be made.
data["state"] = "failed"
data["error"] = str(error)[:300]
presentation.save()
@@ -167,36 +288,47 @@ def import_one(source, root=None):
def sweep(root=None):
"""Throw away imports that were interrupted, so they can happen again.
"""Recover imports interrupted during a move, conversion or rendering.
A reboot in the middle of a conversion leaves a directory that says
"importing" and a ledger entry claiming the download is dealt with.
Both go, and the next scan imports it properly.
If Downloads still has the source, discard only the incomplete managed
copy and let the next scan retry. If the source has already been moved,
the managed original is the recovery point and its derived assets are
rebuilt in place. This distinction prevents a crash after the move from
deleting the only copy of a successful download.
"""
root = root if root is not None else library.library_root()
if not root.is_dir():
return 0
ledger = library.ledger(root)
stale = 0
for presentation in library.library(root):
if presentation.state != "importing":
continue
say(f"clearing an import that did not finish: {presentation.title!r}")
source_text = presentation.data.get("source_path")
source = Path(source_text) if source_text else None
managed = presentation.managed_copy
for mark, value in list(ledger.items()):
if value == presentation.id:
ledger.pop(mark, None)
if (managed is not None and managed.is_file()
and (source is None or not source.exists())):
say(f"resuming an interrupted import: {presentation.title!r}")
try:
prepare(presentation)
except Exception as error: # noqa: BLE001
presentation.data["state"] = "failed"
presentation.data["error"] = str(error)[:300]
presentation.save()
say(f"could not import {presentation.title!r}: {error}")
stale += 1
continue
say(f"retrying an interrupted move: {presentation.title!r}")
forget_presentation(presentation.id, root)
shutil.rmtree(presentation.directory, ignore_errors=True)
stale += 1
if stale:
library.write_json(library.ledger_path(root), ledger)
return stale
@@ -222,9 +354,8 @@ def scan(root=None, downloads=None):
def cleanup(root=None, moment=None):
"""Remove presentations whose time is up.
Only ever the library's own directory, and never the presentation on
the screen. The teacher's download is not part of this and is not
looked at.
Only ever the selected managed directory, including its one original and
derived assets, and never the presentation on the screen.
"""
root = root if root is not None else library.library_root()
lock = library.presenting_lock()
@@ -254,7 +385,7 @@ def cleanup(root=None, moment=None):
say(f"removing {presentation.title!r} — {why}")
shutil.rmtree(presentation.directory, ignore_errors=True)
if library.remove(presentation):
removed += 1
return removed
+227 -54
View File
@@ -4,7 +4,7 @@
Three buttons on a card — Present, Quick Look, Remove — and nothing else.
No storage locations, no file names, no import controls, no settings, no
retention. A teacher who has downloaded a session in Chrome opens this,
retention. A teacher who has downloaded a session opens this,
recognises tonight's by its picture, and presses Present.
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
@@ -31,7 +31,6 @@ LibreOffice and poppler, and a picture cannot open a file dialog.
"""
import os
import shutil
import sys
import gi
@@ -106,17 +105,73 @@ button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
padding: 6px 12px;
font-size: 16px;
}}
.current-cover {{
color: {on_accent};
background-color: {accent};
border-radius: 10px;
padding: 6px 10px;
font-size: 14px;
font-weight: 700;
}}
"""
def scaled_pixbuf(path, width):
def scaled_pixbuf(path, width, height=-1):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), width, -1, True)
str(path), width, height, True)
except (GLib.Error, OSError):
return None
def monitor_workarea(window=None):
"""The usable logical pixels on the monitor, excluding Plasma's panel."""
display = Gdk.Display.get_default()
if display is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
monitor = None
if window is not None and window.get_window() is not None:
monitor = display.get_monitor_at_window(window.get_window())
if monitor is None:
monitor = display.get_primary_monitor()
if monitor is None and display.get_n_monitors() > 0:
monitor = display.get_monitor(0)
if monitor is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
return monitor.get_workarea()
def monitor_geometry(window=None):
"""The full logical monitor rectangle, including the panel area."""
display = Gdk.Display.get_default()
if display is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
monitor = None
if window is not None and window.get_window() is not None:
monitor = display.get_monitor_at_window(window.get_window())
if monitor is None:
monitor = display.get_primary_monitor()
if monitor is None and display.get_n_monitors() > 0:
monitor = display.get_monitor(0)
if monitor is None:
return Gdk.Rectangle(x=0, y=0, width=1280, height=720)
return monitor.get_geometry()
class Presenter(Gtk.Window):
"""The slideshow. A fullscreen window showing one picture at a time.
@@ -130,19 +185,30 @@ class Presenter(Gtk.Window):
press and nowhere else to end up.
"""
def __init__(self, presentation, start_page=1, on_finish=None):
super().__init__(title=presentation.title)
def __init__(self, presentation, start_page=1, on_finish=None,
display_geometry=None):
# A stable title gives KWin a presenter-only match. The library and
# Quick Look share this application's Wayland app id, so matching the
# app id alone would fullscreen every window.
super().__init__(title="Presenting")
self.presentation = presentation
self.on_finish = on_finish
self.page = min(max(start_page, 1), presentation.pages or 1)
self.counted = False
self.finished = False
self.display_geometry = display_geometry or monitor_geometry()
# How many times fullscreen has been asked for without it
# sticking. See _state.
self.refused = 0
self.set_app_paintable(True)
self.set_decorated(False)
self.set_skip_taskbar_hint(True)
self.set_role("classroom-presenter")
self.set_default_size(
self.display_geometry.width, self.display_geometry.height)
self.set_size_request(
self.display_geometry.width, self.display_geometry.height)
self.fullscreen()
# A slideshow is the only thing on the screen while it is running.
self.set_keep_above(True)
@@ -151,16 +217,48 @@ class Presenter(Gtk.Window):
black.parse("#000000")
self.override_background_color(Gtk.StateFlags.NORMAL, black)
# Paint an explicit full-window layer behind the image. The old
# app-paintable Gtk.Window never drew its own background, so KWin
# correctly made it fullscreen while the panel and desktop remained
# visible through the transparent space around a portrait slide.
self.image = Gtk.Image()
self.add(self.image)
self.image.set_halign(Gtk.Align.CENTER)
self.image.set_valign(Gtk.Align.CENTER)
stage = Gtk.Overlay()
stage.set_size_request(
self.display_geometry.width, self.display_geometry.height)
background = Gtk.DrawingArea()
background.connect("draw", self._paint_black)
stage.add(background)
stage.add_overlay(self.image)
self.add(stage)
self.connect("key-press-event", self._key)
self.connect("delete-event", lambda *_: self.finish())
self.connect("size-allocate", self._resized)
self.connect("window-state-event", self._state)
self.connect("map-event", self._mapped)
self._drawn_at = (0, 0, 0)
def _paint_black(self, _widget, context):
context.set_source_rgb(0, 0, 0)
context.paint()
return False
def _mapped(self, *_):
# GTK can make the first request before the Wayland surface exists.
# Repeat it after mapping; KWin's matching rule is the compositor-side
# guarantee, while this keeps the window correct on other desktops.
self.fullscreen()
GLib.idle_add(self._mapped_fullscreen)
return False
def _mapped_fullscreen(self):
if not self.finished:
self.fullscreen()
return False
def _state(self, _widget, event):
"""Put the slideshow back on the whole screen if it is taken off it.
@@ -204,7 +302,9 @@ class Presenter(Gtk.Window):
def show_page(self):
path = self.presentation.page_image(self.page)
allocation = self.get_allocation()
want = (self.page, allocation.width, allocation.height)
width = max(allocation.width, self.display_geometry.width)
height = max(allocation.height, self.display_geometry.height)
want = (self.page, width, height)
if want == self._drawn_at:
return
@@ -212,7 +312,7 @@ class Presenter(Gtk.Window):
if not path.exists():
return
pixbuf = self._fitted(path, allocation.width, allocation.height)
pixbuf = self._fitted(path, width, height)
if pixbuf is None:
return
@@ -244,12 +344,30 @@ class Presenter(Gtk.Window):
ratio = min(width / original.get_width(),
height / original.get_height())
return original.scale_simple(
max(1, int(original.get_width() * ratio)),
max(1, int(original.get_height() * ratio)),
fitted_width = max(1, int(original.get_width() * ratio))
fitted_height = max(1, int(original.get_height() * ratio))
fitted = original.scale_simple(
fitted_width,
fitted_height,
GdkPixbuf.InterpType.BILINEAR,
)
# Make the entire client buffer opaque. On Plasma Wayland, painting a
# container behind a smaller Gtk.Image still left the surface outside
# the image transparent: KWin reported a correct 1920x1080 fullscreen
# window, but the panel and desktop showed through the letterbox. A
# full-size RGB pixbuf makes black part of the presented content.
canvas = GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, False, 8, width, height)
canvas.fill(0x000000ff)
fitted.copy_area(
0, 0, fitted_width, fitted_height, canvas,
(width - fitted_width) // 2,
(height - fitted_height) // 2,
)
return canvas
# -- moving about ---------------------------------------------
def go(self, delta):
@@ -309,11 +427,15 @@ class Presenter(Gtk.Window):
except OSError:
pass
if self.on_finish is not None:
self.on_finish()
on_finish = self.on_finish
self.destroy()
# Map and raise the library only after the fullscreen surface is gone.
# Showing it underneath a still-live fullscreen window could leave the
# nested KWin session with no newly damaged return surface on Escape.
if on_finish is not None:
on_finish()
return True
@@ -324,7 +446,7 @@ class QuickLook(Gtk.Window):
purpose because conflating any two of them would be wrong:
browse which slide is on this screen right now;
Use this slide which slide the library card shows;
Use as cover which slide the library card shows;
Present start the slideshow, from the beginning.
Browsing is not presenting: coming in here, looking through every
@@ -340,11 +462,21 @@ class QuickLook(Gtk.Window):
self.presentation = presentation
self.on_close = on_close
self.on_present = on_present
self.page = 1
# Open on the familiar card image. Teachers can immediately see what
# "cover" means, while Present still starts independently at slide 1.
self.page = presentation.thumbnail_page
self.set_transient_for(parent)
self.set_modal(True)
self.set_default_size(1000, 760)
workarea = monitor_workarea(parent)
window_width = min(1000, max(560, int(workarea.width * 0.86)))
# Leave enough room for decorations and a panel even on compositors
# that report the full monitor as the work area (the nested Plasma lab
# does). At 1366x768 this remains comfortably usable at larger scale.
window_height = min(760, max(420, int(workarea.height * 0.75)))
self.set_default_size(window_width, window_height)
self.preview_width = max(320, window_width - 32)
self.preview_height = max(180, window_height - 210)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
outer.set_border_width(16)
@@ -365,7 +497,7 @@ class QuickLook(Gtk.Window):
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
browse.set_halign(Gtk.Align.CENTER)
self.back = Gtk.Button(label="◀ Previous")
self.back = Gtk.Button(label="Previous")
self.back.connect("clicked", lambda *_: self.go(-1))
browse.pack_start(self.back, False, False, 0)
@@ -373,7 +505,7 @@ class QuickLook(Gtk.Window):
self.counter.get_style_context().add_class("subtle")
browse.pack_start(self.counter, False, False, 12)
self.forward = Gtk.Button(label="Next ▶")
self.forward = Gtk.Button(label="Next")
self.forward.connect("clicked", lambda *_: self.go(1))
browse.pack_start(self.forward, False, False, 0)
@@ -382,9 +514,15 @@ class QuickLook(Gtk.Window):
# The three things this window is for.
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.choose = Gtk.Button(label="Use this slide as the picture")
self.choose = Gtk.Button(label="Use as cover")
self.choose.connect("clicked", self._choose)
actions.pack_start(self.choose, False, False, 0)
self.cover = Gtk.Label(label="Current cover")
self.cover.get_style_context().add_class("current-cover")
self.cover_action = Gtk.Stack()
self.cover_action.add_named(self.choose, "choose")
self.cover_action.add_named(self.cover, "current")
actions.pack_start(self.cover_action, False, False, 0)
actions.pack_start(Gtk.Box(), True, True, 0)
@@ -408,14 +546,18 @@ class QuickLook(Gtk.Window):
def show_page(self, force=True):
allocation = self.slide.get_allocation()
width = max(320, allocation.width or 900)
want = (self.page, width, 0)
width = min(self.preview_width,
max(1, allocation.width or self.preview_width))
height = min(self.preview_height,
max(1, allocation.height or self.preview_height))
want = (self.page, width, height)
if want == self._drawn_at and not force:
return
path = self.presentation.page_image(self.page)
pixbuf = scaled_pixbuf(path, width) if path.exists() else None
pixbuf = (scaled_pixbuf(path, width, height)
if path.exists() else None)
if pixbuf is not None:
self.slide.set_from_pixbuf(pixbuf)
@@ -423,12 +565,12 @@ class QuickLook(Gtk.Window):
total = self.presentation.pages
chosen = self.presentation.thumbnail_page
note = " · this is the library picture" if self.page == chosen else ""
self.counter.set_text(f"Slide {self.page} of {total}{note}")
self.counter.set_text(f"Slide {self.page} of {total}")
self.back.set_sensitive(self.page > 1)
self.forward.set_sensitive(self.page < total)
self.choose.set_sensitive(self.page != chosen)
self.cover_action.set_visible_child_name(
"current" if self.page == chosen else "choose")
def go(self, delta):
target = self.page + delta
@@ -483,7 +625,7 @@ class Library(Gtk.Window):
def __init__(self):
super().__init__(title="Presentations")
self.set_default_size(1180, 820)
self.set_default_size(680, 420)
self.presenter = None
self._signature = None
@@ -512,7 +654,7 @@ class Library(Gtk.Window):
self.grid.set_selection_mode(Gtk.SelectionMode.NONE)
self.grid.set_row_spacing(16)
self.grid.set_column_spacing(16)
self.grid.set_homogeneous(True)
self.grid.set_homogeneous(False)
scroller.add(self.grid)
self.connect("key-press-event", self._key)
@@ -544,6 +686,29 @@ class Library(Gtk.Window):
for p in presentations
)
def _fit_window(self, count):
"""Size to the cards while staying inside the panel's work area."""
workarea = monitor_workarea(self)
maximum_width = max(420, int(workarea.width * 0.92))
maximum_height = max(360, int(workarea.height * 0.90))
if count == 0:
width = min(maximum_width, 640)
height = min(maximum_height, 300)
else:
# FlowBox includes the card's CSS padding and the three buttons'
# natural widths when deciding whether another child fits. Budget
# that chrome here too, or two cards can wrap into two rows while
# the window is sized for one.
card_with_gap = CARD_WIDTH + 40
available = max(1, (maximum_width - 40) // card_with_gap)
columns = min(count, available, 4)
rows = (count + columns - 1) // columns
width = min(maximum_width, max(420, 40 + columns * card_with_gap))
height = min(maximum_height, 130 + rows * 340)
self.resize(width, height)
def refresh(self):
presentations = library.library()
signature = self.signature(presentations)
@@ -557,12 +722,10 @@ class Library(Gtk.Window):
self.grid.remove(child)
if not presentations:
self.note.set_text(
"Download a presentation in Chrome and it will appear here.")
self.note.set_text("")
empty = Gtk.Label(
label="Nothing here yet.\n\n"
"Download a PowerPoint or PDF in Chrome, and it will "
"appear on this screen by itself.")
label="No presentations yet\n\n"
"Download a PowerPoint or PDF to get started.")
empty.get_style_context().add_class("empty")
empty.set_justify(Gtk.Justification.CENTER)
self.grid.add(empty)
@@ -589,31 +752,36 @@ class Library(Gtk.Window):
self.grid.add(self.card(presentation))
self.grid.show_all()
self._fit_window(len(presentations))
def card(self, presentation):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.get_style_context().add_class("card")
box.set_size_request(CARD_WIDTH, -1)
picture = Gtk.Image()
picture.get_style_context().add_class("thumb")
pixbuf = None
picture_frame = Gtk.EventBox()
picture_frame.get_style_context().add_class("thumb")
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
if presentation.thumbnail.exists():
pixbuf = scaled_pixbuf(presentation.thumbnail, CARD_WIDTH - 28)
picture = Gtk.Image()
pixbuf = scaled_pixbuf(
presentation.thumbnail, CARD_WIDTH - 28, 180)
if pixbuf is not None:
picture.set_from_pixbuf(pixbuf)
else:
picture = Gtk.Image()
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
picture.set_pixel_size(96)
box.pack_start(picture, False, False, 0)
picture_frame.add(picture)
box.pack_start(picture_frame, False, False, 0)
title = Gtk.Label(label=presentation.title, xalign=0)
title.get_style_context().add_class("card-title")
title.set_line_wrap(True)
title.set_max_width_chars(26)
title.set_max_width_chars(32)
title.set_size_request(CARD_WIDTH - 28, -1)
box.pack_start(title, False, False, 0)
note = Gtk.Label(xalign=0)
@@ -637,11 +805,9 @@ class Library(Gtk.Window):
elif presentation.failed:
# SHORT, AND IN WORDS A TEACHER CAN ACT ON.
#
# Never the converter's error text, never a path, never a
# terminal. The one useful fact is that their own download is
# still where they left it.
note.set_text("This file could not be opened.\n"
"Your download in Chrome is still there.")
# Never the converter's error text, a path or a terminal.
# The managed original remains recoverable until Remove.
note.set_text("This file could not be opened.")
note.set_line_wrap(True)
else:
note.set_text("Getting this ready…")
@@ -678,9 +844,12 @@ class Library(Gtk.Window):
pass
self.presenter = Presenter(
presentation, start_page, on_finish=self._presented)
presentation, start_page, on_finish=self._presented,
display_geometry=monitor_geometry(self))
self.hide()
self.presenter.show_all()
self.presenter.present()
self.presenter.fullscreen()
def _presented(self):
self.presenter = None
@@ -715,12 +884,12 @@ class Library(Gtk.Window):
modal=True,
message_type=Gtk.MessageType.QUESTION,
buttons=Gtk.ButtonsType.NONE,
text=f"Remove “{presentation.title}”?",
text="Remove this presentation?",
)
dialog.format_secondary_text(
"It will be taken off this screen.\n"
"Your download in Chrome is not affected.")
dialog.add_button("Keep it", Gtk.ResponseType.CANCEL)
f"“{presentation.title}”\n\n"
"This will delete it from this computer.")
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
dialog.add_button("Remove", Gtk.ResponseType.OK)
dialog.set_default_response(Gtk.ResponseType.CANCEL)
@@ -730,7 +899,11 @@ class Library(Gtk.Window):
if answer != Gtk.ResponseType.OK:
return
shutil.rmtree(presentation.directory, ignore_errors=True)
if not library.remove(presentation):
self.trouble(
presentation,
"Finish presenting before removing this presentation.")
return
self._signature = None
self.refresh()
+6 -7
View File
@@ -191,8 +191,8 @@ else:
check("and Parent Call in the corner",
every and CALL in every["launchers"],
str((every or {}).get("launchers")))
check("and Help beside it",
every and HELP in every["launchers"],
check("and the unfinished Help launcher is hidden",
every and HELP not in every["launchers"],
str((every or {}).get("launchers")))
no_ink, why = bar({"WONDERINK_ENABLED": "false"})
@@ -210,11 +210,10 @@ else:
check("with no Parent Call button on it",
bible and CALL not in bible["launchers"],
str((bible or {}).get("launchers")))
# Help is not optional. A teacher who needs the manual is by
# definition one who does not know where to look, so every room has
# it — what differs is which pages it opens on.
check("but Help is still there, because every room has a manual",
bible and HELP in bible["launchers"],
# The source and launcher remain installed, but the unfinished digital
# Help entry point is deliberately absent from the teacher-facing bar.
check("and unfinished Help stays hidden",
bible and HELP not in bible["launchers"],
str((bible or {}).get("launchers")))
SLIDES = "applications:ekklesia-presentations.desktop"
+169 -2
View File
@@ -14,11 +14,14 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHONPATH="$ROOT/lib" python3 - "$ROOT" <<'PY'
import os
import errno
import importlib.util
import shutil
import sys
import tempfile
import time
from datetime import timedelta
from importlib.machinery import SourceFileLoader
from pathlib import Path
root = Path(sys.argv[1])
@@ -26,6 +29,14 @@ sys.path.insert(0, str(root / "lib"))
import classroom_presentations as library
loader = SourceFileLoader(
"classroom_presentation_import",
str(root / "scripts" / "classroom-presentation-import"),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
importer = importlib.util.module_from_spec(spec)
loader.exec_module(importer)
passed = failed = 0
@@ -242,19 +253,175 @@ check("it lives in the runtime directory",
or str(library.presenting_lock()).startswith("/tmp"),
str(library.presenting_lock()))
print("10. the ledger remembers a download after its presentation is gone")
print("10. the ledger identifies a download transaction")
mark = library.fingerprint(done)
library.remember(mark, "some-id", shelf)
check("a fingerprint is path, size and time",
mark is not None and str(done.resolve()) in mark, str(mark))
check("it is written down", library.ledger(shelf).get(mark) == "some-id")
check("so Remove means removed, not removed until the next scan",
check("so an interrupted move can be matched after a restart",
mark in library.ledger(shelf))
touched = download("Session 7 - Romans.pptx", contents="y" * 900)
check("but re-downloading the same session is a new presentation",
library.fingerprint(touched) != mark,
"a different size or time is a different download")
print("11. a completed download is moved into the managed library")
original_render_pages = library.render_pages
original_render_thumbnail = library.render_thumbnail
original_first_text = library.first_text
original_convert = library.convert_to_pdf
def fake_render_pages(_pdf, directory, width=library.SLIDE_WIDTH,
progress=None):
directory.mkdir(parents=True, exist_ok=True)
(directory / "page-001.png").write_bytes(b"png")
return 1, ""
def fake_render_thumbnail(_source, destination,
width=library.THUMBNAIL_WIDTH):
destination.write_bytes(b"thumbnail")
return True
library.render_pages = fake_render_pages
library.render_thumbnail = fake_render_thumbnail
library.first_text = lambda _pdf, pages=2: ""
moved = download("Move me.pdf", contents="%PDF-1.4 moved")
moved_contents = moved.read_bytes()
managed = importer.import_one(moved, shelf)
check("the source leaves Downloads after import", not moved.exists())
check("the one original is kept in the managed entry",
managed is not None and managed.managed_copy.read_bytes() == moved_contents,
str(managed.managed_copy if managed else None))
check("its PDF and page images are derived assets",
managed is not None and managed.ready and managed.slides.exists()
and managed.page_image(1).exists())
print("12. conversion failure does not lose the downloaded original")
library.convert_to_pdf = lambda _source, _destination: (False, "broken deck")
broken_source = download("Broken deck.pptx", contents="not really a deck")
failed_import = importer.import_one(broken_source, shelf)
check("the failed source was still moved out of Downloads",
not broken_source.exists())
check("a failed card retains the managed original",
failed_import is not None and failed_import.failed
and failed_import.managed_copy.read_text() == "not really a deck")
check("the failure did not create a second original",
failed_import is not None
and list((failed_import.directory / "original").iterdir())
== [failed_import.managed_copy])
print("13. a cross-filesystem move publishes before deleting")
cross_source = download("Across filesystems.pdf", contents="cross-device")
cross_destination = work / "other-device" / cross_source.name
real_replace = importer.os.replace
source_text = str(cross_source)
destination_text = str(cross_destination)
def cross_replace(source, destination):
if os.fspath(source) == source_text and os.fspath(destination) == destination_text:
raise OSError(errno.EXDEV, "different filesystems")
return real_replace(source, destination)
importer.os.replace = cross_replace
try:
importer.move_download(cross_source, cross_destination)
finally:
importer.os.replace = real_replace
check("the cross-filesystem destination is complete",
cross_destination.read_text() == "cross-device")
check("only the destination remains after it is durable",
not cross_source.exists()
and not cross_destination.with_name(cross_destination.name + ".moving").exists())
print("14. restart recovery never deletes the only moved original")
resume_dir = shelf / "resume"
resume_original = resume_dir / "original" / "Resume.pdf"
resume_original.parent.mkdir(parents=True)
resume_original.write_text("%PDF-1.4 resume")
resume_data = {
"id": "resume",
"state": "importing",
"title": "Resume",
"original_name": "Resume.pdf",
"source_path": str(downloads / "Resume.pdf"),
"managed_copy": "original/Resume.pdf",
"format": "pdf",
"imported_at": library.stamp(),
"last_presented_at": None,
"times_presented": 0,
"thumbnail_page": 1,
"pages": 0,
"error": None,
}
library.Presentation(resume_dir, resume_data).save()
importer.sweep(shelf)
resumed = library.load(resume_dir)
check("an import interrupted after the move resumes in place",
resumed is not None and resumed.ready)
check("and its original survives recovery", resume_original.exists())
print("15. removal is isolated and refuses an active presentation")
remove_one = entry(id="remove-one", title="Similar title")
keep_one = entry(id="keep-one", title="Similar title (2)")
(remove_one.directory / "original").mkdir()
(remove_one.directory / "original" / "one.pdf").write_text("one")
(keep_one.directory / "original").mkdir()
(keep_one.directory / "original" / "two.pdf").write_text("two")
check("removing one managed entry succeeds", library.remove(remove_one))
check("a similarly named presentation and its files are untouched",
keep_one.directory.exists()
and (keep_one.directory / "original" / "two.pdf").exists())
runtime = work / "runtime"
runtime.mkdir()
old_runtime = os.environ.get("XDG_RUNTIME_DIR")
os.environ["XDG_RUNTIME_DIR"] = str(runtime)
library.presenting_lock().write_text(keep_one.id)
check("an actively presented entry cannot be removed",
not library.remove(keep_one) and keep_one.directory.exists())
library.presenting_lock().unlink()
if old_runtime is None:
os.environ.pop("XDG_RUNTIME_DIR", None)
else:
os.environ["XDG_RUNTIME_DIR"] = old_runtime
library.render_pages = original_render_pages
library.render_thumbnail = original_render_thumbnail
library.first_text = original_first_text
library.convert_to_pdf = original_convert
print("16. teacher-facing wording and compositor policy")
ui = (root / "scripts" / "classroom-presentations").read_text()
layout = (root / "config" / "plasma" / "lookandfeel" / "contents"
/ "layouts" / "org.kde.plasma.desktop-layout.js").read_text()
kwin = (root / "config" / "plasma" / "xdg" / "kwinrc").read_text()
rules = (root / "config" / "plasma" / "xdg" / "kwinrulesrc").read_text()
check("the empty state has one concise browser-neutral instruction",
'label="No presentations yet\\n\\n"' in ui
and "Download a PowerPoint or PDF to get started." in ui
and "Download a presentation in Chrome" not in ui)
check("cover selection uses the requested plain wording",
'label="Use as cover"' in ui and 'label="Current cover"' in ui
and "this is the library picture" not in ui)
check("Remove no longer promises a copy in Chrome",
"This will delete it from this computer." in ui
and "Your download in Chrome" not in ui)
check("the presenter has a compositor-enforced fullscreen rule",
"title=Presenting" in rules and "fullscreen=true" in rules
and "fullscreenrule=2" in rules)
check("normal title bars contain only Close",
"ButtonsOnRight=X" in kwin and "ButtonsOnRight=FX" not in kwin)
check("the unfinished Help launcher is absent from the panel",
"ekklesia-manual.desktop" not in layout)
shutil.rmtree(work, ignore_errors=True)
print()
+2 -1
View File
@@ -202,7 +202,8 @@ check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restr
print()
print("4d. clicking a launcher never hides an application")
check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout)
check("there is no minimise button", "ButtonsOnRight=FX" in
check("there is no minimise button or keep-above chevron",
"ButtonsOnRight=X" in
(root / "config/plasma/xdg/kwinrc").read_text())
print()
+4 -3
View File
@@ -316,12 +316,13 @@ grep -q 'PRESENTATIONS_ENABLED' /run/ekklesia-classroom/features.json
classroom-manual --features | grep -qE 'PRESENTATIONS_ENABLED +shown'
classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden'
# The panel offers Presentations only where it is enabled, and the three
# applications that were already on the bar keep their order.
# The panel offers Presentations only where it is enabled, keeps the classroom
# applications in order, and does not expose the separately installed manual
# as a teacher-facing Help launcher.
layout=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js
grep -q 'var PRESENTATIONS = true;' "$layout"
grep -q 'ekklesia-presentations.desktop' "$layout"
grep -q 'ekklesia-manual.desktop' "$layout"
! grep -q 'ekklesia-manual.desktop' "$layout"
python3 - "$layout" <<'ORDER_CHECK'
import re
import sys