diff --git a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js index 7c33924..b1b0ddb 100644 --- a/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js +++ b/config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js @@ -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"]; diff --git a/config/plasma/xdg/kwinrc b/config/plasma/xdg/kwinrc index 14df027..df71efd 100644 --- a/config/plasma/xdg/kwinrc +++ b/config/plasma/xdg/kwinrc @@ -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 diff --git a/config/plasma/xdg/kwinrulesrc b/config/plasma/xdg/kwinrulesrc index 63b5b09..db2d292 100644 --- a/config/plasma/xdg/kwinrulesrc +++ b/config/plasma/xdg/kwinrulesrc @@ -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 diff --git a/docs/presentations.md b/docs/presentations.md index 719ed40..c2f1e7d 100644 --- a/docs/presentations.md +++ b/docs/presentations.md @@ -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 / 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 diff --git a/docs/screenshots/presentations-hardened-cover-stack-1366x768.png b/docs/screenshots/presentations-hardened-cover-stack-1366x768.png new file mode 100644 index 0000000..954eea4 Binary files /dev/null and b/docs/screenshots/presentations-hardened-cover-stack-1366x768.png differ diff --git a/docs/screenshots/presentations-hardened-fullscreen.png b/docs/screenshots/presentations-hardened-fullscreen.png new file mode 100644 index 0000000..24bca66 Binary files /dev/null and b/docs/screenshots/presentations-hardened-fullscreen.png differ diff --git a/docs/screenshots/presentations-hardened-library-1366x768.png b/docs/screenshots/presentations-hardened-library-1366x768.png new file mode 100644 index 0000000..06ddc57 Binary files /dev/null and b/docs/screenshots/presentations-hardened-library-1366x768.png differ diff --git a/docs/screenshots/presentations-hardened-quicklook-1366x768.png b/docs/screenshots/presentations-hardened-quicklook-1366x768.png new file mode 100644 index 0000000..9b49a6e Binary files /dev/null and b/docs/screenshots/presentations-hardened-quicklook-1366x768.png differ diff --git a/docs/testing.md b/docs/testing.md index b81008c..2d7a043 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/lib/classroom_presentations.py b/lib/classroom_presentations.py index fb101ec..682240e 100644 --- a/lib/classroom_presentations.py +++ b/lib/classroom_presentations.py @@ -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 diff --git a/scripts/classroom-presentation-import b/scripts/classroom-presentation-import index 41aebb1..a0e1c6b 100755 --- a/scripts/classroom-presentation-import +++ b/scripts/classroom-presentation-import @@ -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,8 +385,8 @@ def cleanup(root=None, moment=None): say(f"removing {presentation.title!r} — {why}") - shutil.rmtree(presentation.directory, ignore_errors=True) - removed += 1 + if library.remove(presentation): + removed += 1 return removed diff --git a/scripts/classroom-presentations b/scripts/classroom-presentations index 2d9f9b1..d428682 100755 --- a/scripts/classroom-presentations +++ b/scripts/classroom-presentations @@ -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) - - if pixbuf is not None: - picture.set_from_pixbuf(pixbuf) + 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() diff --git a/tests/test-classroom-desktop-layout.sh b/tests/test-classroom-desktop-layout.sh index 3dc54e7..8093ce4 100755 --- a/tests/test-classroom-desktop-layout.sh +++ b/tests/test-classroom-desktop-layout.sh @@ -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" diff --git a/tests/test-classroom-presentations.sh b/tests/test-classroom-presentations.sh index 4bcda51..eacf887 100755 --- a/tests/test-classroom-presentations.sh +++ b/tests/test-classroom-presentations.sh @@ -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() diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index 8d8c74c..1509947 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -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() diff --git a/tools/validate-trixie-release.sh b/tools/validate-trixie-release.sh index 1a4ccfb..160d095 100755 --- a/tools/validate-trixie-release.sh +++ b/tools/validate-trixie-release.sh @@ -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