fix: harden classroom presentations
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user