The slideshow re-asserts fullscreen when a compositor takes it away, which is what keeps a teacher from being left looking at the library beside their slides. A compositor that granted it and revoked it repeatedly would have had this asking forever, in the middle of a Bible study. It gives up after five goes. The slides are still on the screen and Escape still works; only the insisting stops, which is a far better failure than a window fighting a compositor in front of a room. Co-Authored-By: Claude Opus 5 <[email protected]>
781 lines
26 KiB
Python
Executable File
781 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
"""The Presentations library, as a teacher sees it.
|
|
|
|
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,
|
|
recognises tonight's by its picture, and presses Present.
|
|
|
|
WHY THE PRESENTER IS IN THIS WINDOW AND NOT A SEPARATE PROGRAM.
|
|
|
|
The disc player is a separate process, because a disc plays until
|
|
somebody stops it and the panel is how you get back to it. A
|
|
presentation is the opposite: it is a thing a teacher steps into and
|
|
must step out of, back to the library, reliably, with one key they
|
|
already know.
|
|
|
|
Making the presenter a fullscreen window of this same application is the
|
|
whole of that guarantee. Escape is handled here, the fullscreen window is
|
|
taken down here, and the library is already on the screen underneath it.
|
|
There is no second process to fail to start, no window to be left behind,
|
|
and no path by which a teacher ends up looking at something that is not
|
|
the library.
|
|
|
|
That is also why nothing here launches Impress, Okular or any other
|
|
document application. Every one of them that was measured leaves its own
|
|
editor or viewer on the screen when a slideshow ends — see
|
|
docs/presentations.md for what each one actually did. The slides are
|
|
pictures by the time they reach this window, rendered at import by
|
|
LibreOffice and poppler, and a picture cannot open a file dialog.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "3.0")
|
|
gi.require_version("Gdk", "3.0")
|
|
gi.require_version("GdkPixbuf", "2.0")
|
|
|
|
from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402
|
|
|
|
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
|
|
sys.path.insert(
|
|
0,
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
|
|
)
|
|
|
|
import classroom_presentations as library # noqa: E402
|
|
from classroom_config import feature, read_device_config # noqa: E402
|
|
from classroom_style import load_colours # noqa: E402
|
|
|
|
APP_ID = "ekklesia-presentations"
|
|
|
|
# How often the library notices a newly imported presentation.
|
|
#
|
|
# The importer is a separate service, so a session downloaded while this
|
|
# window is open appears without the teacher doing anything. Two seconds
|
|
# is well under the time it takes to walk back to the machine.
|
|
REFRESH_SECONDS = 2
|
|
|
|
CARD_WIDTH = 320
|
|
|
|
|
|
STYLE = """
|
|
window {{ background-color: {window}; }}
|
|
.heading {{ color: {text}; font-size: 30px; font-weight: 700; }}
|
|
.subtle {{ color: {dim}; font-size: 16px; }}
|
|
.card {{
|
|
background-color: {view};
|
|
border: 1px solid {line};
|
|
border-radius: 18px;
|
|
padding: 14px;
|
|
}}
|
|
.card-title {{ color: {text}; font-size: 19px; font-weight: 600; }}
|
|
.card-note {{ color: {dim}; font-size: 14px; }}
|
|
.thumb {{
|
|
background-color: {window};
|
|
border: 1px solid {line};
|
|
border-radius: 10px;
|
|
}}
|
|
button {{
|
|
color: {text};
|
|
background-image: none;
|
|
background-color: rgba(255,255,255,0.06);
|
|
border: 1px solid {line};
|
|
border-radius: 12px;
|
|
padding: 10px 14px;
|
|
font-size: 16px;
|
|
}}
|
|
button:hover {{ background-color: rgba(255,255,255,0.12); }}
|
|
button.primary {{
|
|
color: {on_accent};
|
|
background-color: {accent};
|
|
border-color: {accent};
|
|
font-weight: 700;
|
|
}}
|
|
button.danger:hover {{ background-color: {negative}; color: {on_accent}; }}
|
|
.empty {{ color: {dim}; font-size: 20px; }}
|
|
.slide-counter {{
|
|
color: {text};
|
|
background-color: rgba(0,0,0,0.65);
|
|
border-radius: 10px;
|
|
padding: 6px 12px;
|
|
font-size: 16px;
|
|
}}
|
|
"""
|
|
|
|
|
|
def scaled_pixbuf(path, width):
|
|
try:
|
|
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
|
str(path), width, -1, True)
|
|
except (GLib.Error, OSError):
|
|
return None
|
|
|
|
|
|
class Presenter(Gtk.Window):
|
|
"""The slideshow. A fullscreen window showing one picture at a time.
|
|
|
|
Everything a presentation remote and a keyboard can reasonably send
|
|
is accepted, because a teacher in front of a room does not get to
|
|
look up which key this one wants. The clicker in the cupboard sends
|
|
Page Up and Page Down; the keyboard sends arrows and space.
|
|
|
|
Escape is the way out and it is the only way out, which is what makes
|
|
this safe to put in front of a teacher: there is nothing else to
|
|
press and nowhere else to end up.
|
|
"""
|
|
|
|
def __init__(self, presentation, start_page=1, on_finish=None):
|
|
super().__init__(title=presentation.title)
|
|
|
|
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
|
|
# How many times fullscreen has been asked for without it
|
|
# sticking. See _state.
|
|
self.refused = 0
|
|
|
|
self.set_app_paintable(True)
|
|
self.fullscreen()
|
|
# A slideshow is the only thing on the screen while it is running.
|
|
self.set_keep_above(True)
|
|
|
|
black = Gdk.RGBA()
|
|
black.parse("#000000")
|
|
self.override_background_color(Gtk.StateFlags.NORMAL, black)
|
|
|
|
self.image = Gtk.Image()
|
|
self.add(self.image)
|
|
|
|
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._drawn_at = (0, 0, 0)
|
|
|
|
def _state(self, _widget, event):
|
|
"""Put the slideshow back on the whole screen if it is taken off it.
|
|
|
|
A compositor may unfullscreen a window without being asked:
|
|
measured in the lab, where focusing another window on the same
|
|
workspace dropped the slideshow to half the screen with the
|
|
library beside it, which is precisely the "teacher is left
|
|
looking at something that is not the presentation" this whole
|
|
design is built to prevent. KWin has its own occasions —
|
|
a notification, a second output appearing.
|
|
|
|
So fullscreen is re-asserted rather than assumed, for as long as
|
|
the presentation is running. It stops the moment Escape is
|
|
pressed, so this can never fight the teacher's way out.
|
|
|
|
And it gives up after a few goes. A compositor that keeps
|
|
granting fullscreen and taking it away again would otherwise have
|
|
this asking forever, in the middle of a Bible study, which is a
|
|
worse failure than the window being the wrong size. After that
|
|
the slides are still on the screen and Escape still works — only
|
|
the insisting stops.
|
|
"""
|
|
if self.finished:
|
|
return False
|
|
|
|
if event.new_window_state & Gdk.WindowState.FULLSCREEN:
|
|
self.refused = 0
|
|
return False
|
|
|
|
if self.refused < 5:
|
|
self.refused += 1
|
|
self.fullscreen()
|
|
|
|
return False
|
|
|
|
# -- showing a slide ------------------------------------------
|
|
|
|
def _resized(self, _widget, _allocation):
|
|
self.show_page()
|
|
|
|
def show_page(self):
|
|
path = self.presentation.page_image(self.page)
|
|
allocation = self.get_allocation()
|
|
want = (self.page, allocation.width, allocation.height)
|
|
|
|
if want == self._drawn_at:
|
|
return
|
|
|
|
if not path.exists():
|
|
return
|
|
|
|
pixbuf = self._fitted(path, allocation.width, allocation.height)
|
|
|
|
if pixbuf is None:
|
|
return
|
|
|
|
self.image.set_from_pixbuf(pixbuf)
|
|
self._drawn_at = want
|
|
|
|
# A SLIDE IS ON THE SCREEN, SO THIS IS A PRESENTATION.
|
|
#
|
|
# Counted here and nowhere earlier. Pressing Present is an
|
|
# intention; a viewer that failed to start, a file that could not
|
|
# be read and a window that never drew are all not presentations,
|
|
# and none of them may buy another thirty days of retention.
|
|
if not self.counted:
|
|
self.counted = True
|
|
self.presentation.mark_presented()
|
|
|
|
def _fitted(self, path, width, height):
|
|
if width < 2 or height < 2:
|
|
return None
|
|
|
|
try:
|
|
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
|
except (GLib.Error, OSError):
|
|
return None
|
|
|
|
# Letterboxed, never cropped and never stretched. A slide with a
|
|
# verse on it cannot lose its edges to fit a television.
|
|
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)),
|
|
GdkPixbuf.InterpType.BILINEAR,
|
|
)
|
|
|
|
# -- moving about ---------------------------------------------
|
|
|
|
def go(self, delta):
|
|
target = self.page + delta
|
|
|
|
if target < 1:
|
|
return
|
|
|
|
if target > self.presentation.pages:
|
|
# PAST THE LAST SLIDE IS THE END, AND THE END IS THE LIBRARY.
|
|
#
|
|
# Not a black "click to exit" slide, and certainly not an
|
|
# editor. A teacher who clicks once more than there are
|
|
# slides is finished, and being finished means being back
|
|
# where they started.
|
|
self.finish()
|
|
return
|
|
|
|
self.page = target
|
|
self.show_page()
|
|
|
|
def _key(self, _widget, event):
|
|
key = event.keyval
|
|
forward = (
|
|
Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_space, Gdk.KEY_Page_Down,
|
|
Gdk.KEY_KP_Page_Down, Gdk.KEY_Return, Gdk.KEY_KP_Enter,
|
|
Gdk.KEY_KP_Right, Gdk.KEY_KP_Down, Gdk.KEY_n,
|
|
)
|
|
backward = (
|
|
Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up,
|
|
Gdk.KEY_BackSpace, Gdk.KEY_KP_Left, Gdk.KEY_KP_Up, Gdk.KEY_p,
|
|
)
|
|
|
|
if key in forward:
|
|
self.go(1)
|
|
elif key in backward:
|
|
self.go(-1)
|
|
elif key in (Gdk.KEY_Home, Gdk.KEY_KP_Home):
|
|
self.page = 1
|
|
self.show_page()
|
|
elif key in (Gdk.KEY_End, Gdk.KEY_KP_End):
|
|
self.page = self.presentation.pages
|
|
self.show_page()
|
|
elif key in (Gdk.KEY_Escape, Gdk.KEY_q, Gdk.KEY_F5):
|
|
self.finish()
|
|
|
|
return True
|
|
|
|
def finish(self):
|
|
if self.finished:
|
|
return True
|
|
|
|
self.finished = True
|
|
|
|
try:
|
|
library.presenting_lock().unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
if self.on_finish is not None:
|
|
self.on_finish()
|
|
|
|
self.destroy()
|
|
|
|
return True
|
|
|
|
|
|
class QuickLook(Gtk.Window):
|
|
"""Look through the slides before showing them to a room.
|
|
|
|
Three things a teacher can do here, and they are kept apart on
|
|
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;
|
|
Present start the slideshow, from the beginning.
|
|
|
|
Browsing is not presenting: coming in here, looking through every
|
|
slide and leaving again must not mark the session as presented or
|
|
move its retention timer by a second. Choosing the picture is not
|
|
presenting either, and it does not change where a presentation
|
|
starts.
|
|
"""
|
|
|
|
def __init__(self, presentation, parent, on_close=None, on_present=None):
|
|
super().__init__(title=presentation.title)
|
|
|
|
self.presentation = presentation
|
|
self.on_close = on_close
|
|
self.on_present = on_present
|
|
self.page = 1
|
|
|
|
self.set_transient_for(parent)
|
|
self.set_modal(True)
|
|
self.set_default_size(1000, 760)
|
|
|
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
|
outer.set_border_width(16)
|
|
self.add(outer)
|
|
|
|
heading = Gtk.Label(label=presentation.title, xalign=0)
|
|
heading.get_style_context().add_class("heading")
|
|
heading.set_line_wrap(True)
|
|
outer.pack_start(heading, False, False, 0)
|
|
|
|
self.slide = Gtk.Image()
|
|
frame = Gtk.EventBox()
|
|
frame.get_style_context().add_class("thumb")
|
|
frame.add(self.slide)
|
|
outer.pack_start(frame, True, True, 0)
|
|
|
|
# Browsing.
|
|
browse = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
|
browse.set_halign(Gtk.Align.CENTER)
|
|
|
|
self.back = Gtk.Button(label="◀ Previous")
|
|
self.back.connect("clicked", lambda *_: self.go(-1))
|
|
browse.pack_start(self.back, False, False, 0)
|
|
|
|
self.counter = Gtk.Label()
|
|
self.counter.get_style_context().add_class("subtle")
|
|
browse.pack_start(self.counter, False, False, 12)
|
|
|
|
self.forward = Gtk.Button(label="Next ▶")
|
|
self.forward.connect("clicked", lambda *_: self.go(1))
|
|
browse.pack_start(self.forward, False, False, 0)
|
|
|
|
outer.pack_start(browse, False, False, 0)
|
|
|
|
# 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.connect("clicked", self._choose)
|
|
actions.pack_start(self.choose, False, False, 0)
|
|
|
|
actions.pack_start(Gtk.Box(), True, True, 0)
|
|
|
|
close = Gtk.Button(label="Back to Presentations")
|
|
close.connect("clicked", lambda *_: self.close_quietly())
|
|
actions.pack_start(close, False, False, 0)
|
|
|
|
present = Gtk.Button(label="Present")
|
|
present.get_style_context().add_class("primary")
|
|
present.connect("clicked", self._present)
|
|
actions.pack_start(present, False, False, 0)
|
|
|
|
outer.pack_start(actions, False, False, 0)
|
|
|
|
self.connect("key-press-event", self._key)
|
|
self.connect("delete-event", lambda *_: self.close_quietly())
|
|
self.connect("size-allocate", lambda *_: self.show_page(False))
|
|
|
|
self._drawn_at = (0, 0, 0)
|
|
self.show_page()
|
|
|
|
def show_page(self, force=True):
|
|
allocation = self.slide.get_allocation()
|
|
width = max(320, allocation.width or 900)
|
|
want = (self.page, width, 0)
|
|
|
|
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
|
|
|
|
if pixbuf is not None:
|
|
self.slide.set_from_pixbuf(pixbuf)
|
|
self._drawn_at = want
|
|
|
|
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.back.set_sensitive(self.page > 1)
|
|
self.forward.set_sensitive(self.page < total)
|
|
self.choose.set_sensitive(self.page != chosen)
|
|
|
|
def go(self, delta):
|
|
target = self.page + delta
|
|
|
|
if 1 <= target <= self.presentation.pages:
|
|
self.page = target
|
|
self.show_page()
|
|
|
|
def _choose(self, *_):
|
|
# Writes the chosen slide and nothing else. Not a presentation,
|
|
# and not a change to where the slideshow starts.
|
|
self.presentation.set_thumbnail_page(self.page)
|
|
self.show_page()
|
|
|
|
if self.on_close is not None:
|
|
self.on_close()
|
|
|
|
def _present(self, *_):
|
|
self.destroy()
|
|
|
|
if self.on_present is not None:
|
|
self.on_present(self.presentation)
|
|
|
|
def close_quietly(self):
|
|
"""Leave without having presented anything."""
|
|
self.destroy()
|
|
|
|
if self.on_close is not None:
|
|
self.on_close()
|
|
|
|
return True
|
|
|
|
def _key(self, _widget, event):
|
|
key = event.keyval
|
|
|
|
if key in (Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_Page_Down,
|
|
Gdk.KEY_space):
|
|
self.go(1)
|
|
elif key in (Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Page_Up):
|
|
self.go(-1)
|
|
elif key == Gdk.KEY_Escape:
|
|
self.close_quietly()
|
|
else:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
class Library(Gtk.Window):
|
|
"""The window the panel button opens."""
|
|
|
|
def __init__(self):
|
|
super().__init__(title="Presentations")
|
|
|
|
self.set_default_size(1180, 820)
|
|
self.presenter = None
|
|
self._signature = None
|
|
|
|
self._apply_style()
|
|
|
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
|
outer.set_border_width(20)
|
|
self.add(outer)
|
|
|
|
heading = Gtk.Label(label="Presentations", xalign=0)
|
|
heading.get_style_context().add_class("heading")
|
|
outer.pack_start(heading, False, False, 0)
|
|
|
|
self.note = Gtk.Label(xalign=0)
|
|
self.note.get_style_context().add_class("subtle")
|
|
outer.pack_start(self.note, False, False, 0)
|
|
|
|
scroller = Gtk.ScrolledWindow()
|
|
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
|
outer.pack_start(scroller, True, True, 8)
|
|
|
|
self.grid = Gtk.FlowBox()
|
|
self.grid.set_valign(Gtk.Align.START)
|
|
self.grid.set_max_children_per_line(4)
|
|
self.grid.set_min_children_per_line(1)
|
|
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)
|
|
scroller.add(self.grid)
|
|
|
|
self.connect("key-press-event", self._key)
|
|
self.connect("destroy", lambda *_: Gtk.main_quit())
|
|
|
|
self.refresh()
|
|
GLib.timeout_add_seconds(REFRESH_SECONDS, self._tick)
|
|
|
|
def _apply_style(self):
|
|
css = Gtk.CssProvider()
|
|
css.load_from_data(STYLE.format(**load_colours()).encode("utf-8"))
|
|
Gtk.StyleContext.add_provider_for_screen(
|
|
self.get_screen(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
|
|
|
# -- the wall of cards ----------------------------------------
|
|
|
|
def _tick(self):
|
|
self.refresh()
|
|
return True
|
|
|
|
def signature(self, presentations):
|
|
"""What the library looks like, so it is only rebuilt when it changes.
|
|
|
|
Rebuilding a FlowBox under a teacher's pointer every two seconds
|
|
would make the buttons unclickable.
|
|
"""
|
|
return tuple(
|
|
(p.id, p.state, p.pages, p.thumbnail_page, p.title)
|
|
for p in presentations
|
|
)
|
|
|
|
def refresh(self):
|
|
presentations = library.library()
|
|
signature = self.signature(presentations)
|
|
|
|
if signature == self._signature:
|
|
return
|
|
|
|
self._signature = signature
|
|
|
|
for child in self.grid.get_children():
|
|
self.grid.remove(child)
|
|
|
|
if not presentations:
|
|
self.note.set_text(
|
|
"Download a presentation in Chrome and it will appear here.")
|
|
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.")
|
|
empty.get_style_context().add_class("empty")
|
|
empty.set_justify(Gtk.Justification.CENTER)
|
|
self.grid.add(empty)
|
|
else:
|
|
# Three states, and a file that could not be opened is not
|
|
# the same as one still being got ready. Telling a teacher
|
|
# something is "being prepared" when it never will be is how
|
|
# somebody ends up waiting through a Bible study for it.
|
|
ready = sum(1 for p in presentations if p.ready)
|
|
broken = sum(1 for p in presentations if p.failed)
|
|
working = len(presentations) - ready - broken
|
|
|
|
parts = [f"{ready} ready to present"]
|
|
|
|
if working:
|
|
parts.append(f"{working} still being prepared")
|
|
|
|
if broken:
|
|
parts.append(f"{broken} could not be opened")
|
|
|
|
self.note.set_text(", ".join(parts) + ".")
|
|
|
|
for presentation in presentations:
|
|
self.grid.add(self.card(presentation))
|
|
|
|
self.grid.show_all()
|
|
|
|
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
|
|
|
|
if presentation.thumbnail.exists():
|
|
pixbuf = scaled_pixbuf(presentation.thumbnail, CARD_WIDTH - 28)
|
|
|
|
if pixbuf is not None:
|
|
picture.set_from_pixbuf(pixbuf)
|
|
else:
|
|
picture.set_from_icon_name("view-presentation", Gtk.IconSize.DIALOG)
|
|
picture.set_pixel_size(96)
|
|
|
|
box.pack_start(picture, 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)
|
|
box.pack_start(title, False, False, 0)
|
|
|
|
note = Gtk.Label(xalign=0)
|
|
note.get_style_context().add_class("card-note")
|
|
box.pack_start(note, False, False, 0)
|
|
|
|
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
|
|
|
if presentation.ready:
|
|
note.set_text(f"{presentation.pages} slides")
|
|
|
|
present = Gtk.Button(label="Present")
|
|
present.get_style_context().add_class("primary")
|
|
present.connect("clicked",
|
|
lambda *_: self.start_presenting(presentation))
|
|
buttons.pack_start(present, True, True, 0)
|
|
|
|
look = Gtk.Button(label="Quick Look")
|
|
look.connect("clicked", lambda *_: self.quick_look(presentation))
|
|
buttons.pack_start(look, True, True, 0)
|
|
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.")
|
|
note.set_line_wrap(True)
|
|
else:
|
|
note.set_text("Getting this ready…")
|
|
|
|
remove = Gtk.Button(label="Remove")
|
|
remove.get_style_context().add_class("danger")
|
|
remove.connect("clicked", lambda *_: self.remove(presentation))
|
|
buttons.pack_start(remove, True, True, 0)
|
|
|
|
box.pack_start(buttons, False, False, 0)
|
|
|
|
return box
|
|
|
|
# -- what the buttons do --------------------------------------
|
|
|
|
def start_presenting(self, presentation, start_page=1):
|
|
if not presentation.ready:
|
|
return
|
|
|
|
first = presentation.page_image(start_page)
|
|
|
|
if not first.exists():
|
|
self.trouble(presentation,
|
|
"This presentation is not ready yet.")
|
|
return
|
|
|
|
try:
|
|
lock = library.presenting_lock()
|
|
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
lock.write_text(presentation.id)
|
|
except OSError:
|
|
# A lock that could not be written only costs the cleanup its
|
|
# politeness. It must not stop a teacher presenting.
|
|
pass
|
|
|
|
self.presenter = Presenter(
|
|
presentation, start_page, on_finish=self._presented)
|
|
self.presenter.show_all()
|
|
self.presenter.present()
|
|
|
|
def _presented(self):
|
|
self.presenter = None
|
|
self._signature = None
|
|
self.refresh()
|
|
self.present_window()
|
|
|
|
def present_window(self):
|
|
self.show_all()
|
|
self.present()
|
|
|
|
def quick_look(self, presentation):
|
|
if not presentation.ready:
|
|
return
|
|
|
|
window = QuickLook(
|
|
presentation, self,
|
|
on_close=self._looked,
|
|
on_present=lambda p: self.start_presenting(p),
|
|
)
|
|
window.show_all()
|
|
|
|
def _looked(self):
|
|
# Coming back from Quick Look is not a presentation. The only
|
|
# thing that may have changed is which slide the card shows.
|
|
self._signature = None
|
|
self.refresh()
|
|
|
|
def remove(self, presentation):
|
|
dialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
modal=True,
|
|
message_type=Gtk.MessageType.QUESTION,
|
|
buttons=Gtk.ButtonsType.NONE,
|
|
text=f"Remove “{presentation.title}”?",
|
|
)
|
|
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)
|
|
dialog.add_button("Remove", Gtk.ResponseType.OK)
|
|
dialog.set_default_response(Gtk.ResponseType.CANCEL)
|
|
|
|
answer = dialog.run()
|
|
dialog.destroy()
|
|
|
|
if answer != Gtk.ResponseType.OK:
|
|
return
|
|
|
|
shutil.rmtree(presentation.directory, ignore_errors=True)
|
|
self._signature = None
|
|
self.refresh()
|
|
|
|
def trouble(self, presentation, message):
|
|
dialog = Gtk.MessageDialog(
|
|
transient_for=self,
|
|
modal=True,
|
|
message_type=Gtk.MessageType.INFORMATION,
|
|
buttons=Gtk.ButtonsType.OK,
|
|
text=presentation.title,
|
|
)
|
|
dialog.format_secondary_text(message)
|
|
dialog.run()
|
|
dialog.destroy()
|
|
|
|
def _key(self, _widget, event):
|
|
if event.keyval == Gdk.KEY_Escape:
|
|
# Escape in the library closes the library, which is the same
|
|
# thing it does everywhere else in this room.
|
|
self.destroy()
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def main():
|
|
GLib.set_prgname(APP_ID)
|
|
GLib.set_application_name("Presentations")
|
|
|
|
# A device that does not offer Presentations does not open them. The
|
|
# panel has no button, so nothing should reach here; saying it out
|
|
# loud is for the technician who ran the command by hand.
|
|
if not feature(read_device_config(), "PRESENTATIONS_ENABLED"):
|
|
print("classroom-presentations: PRESENTATIONS_ENABLED is false on "
|
|
"this device; not starting", flush=True)
|
|
return 0
|
|
|
|
window = Library()
|
|
window.show_all()
|
|
|
|
Gtk.main()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|