954 lines
33 KiB
Python
Executable File
954 lines
33 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 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 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;
|
|
}}
|
|
.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, height=-1):
|
|
try:
|
|
return GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
|
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.
|
|
|
|
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,
|
|
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_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)
|
|
|
|
black = Gdk.RGBA()
|
|
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.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.
|
|
|
|
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()
|
|
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
|
|
|
|
if not path.exists():
|
|
return
|
|
|
|
pixbuf = self._fitted(path, width, 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())
|
|
|
|
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):
|
|
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
|
|
|
|
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
|
|
|
|
|
|
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 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
|
|
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
|
|
# 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)
|
|
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)
|
|
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 as cover")
|
|
self.choose.connect("clicked", self._choose)
|
|
|
|
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)
|
|
|
|
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 = 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, height)
|
|
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
|
|
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.cover_action.set_visible_child_name(
|
|
"current" if self.page == chosen else "choose")
|
|
|
|
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(680, 420)
|
|
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(False)
|
|
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 _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)
|
|
|
|
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("")
|
|
empty = Gtk.Label(
|
|
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)
|
|
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()
|
|
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_frame = Gtk.EventBox()
|
|
picture_frame.get_style_context().add_class("thumb")
|
|
picture_frame.set_size_request(CARD_WIDTH - 28, 180)
|
|
|
|
if presentation.thumbnail.exists():
|
|
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)
|
|
|
|
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(32)
|
|
title.set_size_request(CARD_WIDTH - 28, -1)
|
|
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, 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…")
|
|
|
|
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,
|
|
display_geometry=monitor_geometry(self))
|
|
self.hide()
|
|
self.presenter.show_all()
|
|
self.presenter.present()
|
|
self.presenter.fullscreen()
|
|
|
|
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="Remove this presentation?",
|
|
)
|
|
dialog.format_secondary_text(
|
|
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)
|
|
|
|
answer = dialog.run()
|
|
dialog.destroy()
|
|
|
|
if answer != Gtk.ResponseType.OK:
|
|
return
|
|
|
|
if not library.remove(presentation):
|
|
self.trouble(
|
|
presentation,
|
|
"Finish presenting before removing this presentation.")
|
|
return
|
|
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())
|