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
+199 -68
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,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
+229 -56
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)
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()