Files
Classroom_Desktop/scripts/classroom-wonderink
T
ekklesiaandClaude Opus 5.5 b5d9535467 feat: Wonder Ink brings back recent tabs for two hours, never the sign-in
A teacher who closes Wonder Ink by accident, or whose computer crashes or
restarts, gets their recent tabs back if they open it again within two
hours of last using it. Later than that — overnight, next week — Wonder
Ink starts fresh and the file is removed.

It is a temporary workspace restore, not a sign-in. Measured on the real
site: WonderInk's rrf-auth cookie is "Basic " and the base64 of the
teacher's email and password, and its start-up authenticates again on
every load; keeping a sign-in would mean keeping a password. Nothing of
it is kept — no cookie, header or storage — and closing Wonder Ink still
signs the teacher out. What is kept is each tab's kind, address or
worksheet file, and title; the order; the tab in front; the time of last
use. An address with a credential in it is not kept at all.

Every WonderInk page has one address, so its tabs come back as one, at
the sign-in page. Its resource files are public and come back as they
were. A worksheet comes back only from Downloads/Wonder Ink, and only if
it is still there and still a PDF. The tab in front loads at once, the
rest when first shown: measured, a PDF loaded in a hidden tab showed at
10%.

Using it is the teacher's input — click, key, wheel, touch — never a
page's own activity, a print preview left open, or the restore itself,
so reopening cannot keep a workspace alive. A restore that crashes twice
is abandoned. Written atomically when tabs change and at most every 30 s
for the time of use; unreadable, future-format and clock-gone-back files
start fresh. The keeper stops filtering events at aboutToQuit: left in
place it crashed Wonder Ink on exit one run in several; 30 runs, none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 21:12:17 -05:00

2704 lines
101 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Wonder Ink: an application with tabs, and nothing else a browser has.
WHY THIS IS NOT CHROME ANY MORE.
Wonder Ink used to be `google-chrome --app=`, which gave exactly the
window a classroom wants — no tab strip, no address bar, reads as a
program — and teachers then told us the thing app mode takes away is the
thing they need. A lesson is prepared across several tabs: the lesson,
its video, the activity.
Chrome cannot be asked for "tabs but no omnibox". App mode has no tabs;
a normal window has tabs and an address bar, and there is no policy,
flag or extension that removes the address bar from it. Tabbed web apps
come closest and need a manifest on the site, which is not ours, and
they push off-origin links out into a normal browser window, which is
the whole of what we are trying to avoid.
So the tab strip is ours and the engine is Chromium either way. This is
QtWebEngine, which Debian packages, updates with the rest of the system
and — measured on Debian 13, because the usual objection to it is
codecs — builds with H.264 and AAC: a 720p H.264 file decoded and played
to the end in the lab, and MediaSource reports avc1, mp4a and vp9 all
supported. app.wonderink.org itself loads and its sign-in is an ordinary
email and password form, not a Google flow that would mind an embedded
browser.
WHAT A TEACHER GETS.
[ Lesson ] [ Video ] [ Activity ]
Tabs appear when there is more than one, each with a close button, and
nothing else: no address bar, no search, no button that makes a new tab.
A tab can only come from the lesson itself — a link the site opens in a
new window, a middle click, a Ctrl+click. That is the seam this whole
design turns on: Wonder Ink can take a teacher anywhere it needs to,
including a video CDN it has never told us about, and a teacher cannot
take themselves anywhere.
There is no allowlist of domains, on purpose. A list would have to be
right about every service a lesson might ever embed, and the day it is
wrong a teacher is standing in front of a class.
WHAT STAYS THE SAME.
A separate profile from the ordinary browser, so a teacher's Wonder Ink
sign-in and the room's shared YouTube account never meet. The engine
keeps no cookie beyond the window, as Chrome's CookiesSessionOnlyForUrls
did for wonderink.org, so closing Wonder Ink signs the teacher out. What
does survive a close, for two hours after the teacher last used it, is
the list of tabs they had open (WorkspaceKeeper) — never the sign-in.
Nothing here touches the ordinary browser's profile or Chrome's policies.
"""
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)
from classroom_config import feature, read_device_config, ui_scale # noqa: E402
HOME_PAGE = "https://app.wonderink.org/"
DATA_HOME = Path(
os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
)
CACHE_HOME = Path(
os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
)
CONFIG_HOME = Path(
os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
)
# The one thing Wonder Ink remembers between sessions: how large the
# teacher likes the lesson. Its own small file, deliberately nowhere near
# the engine's profile, so remembering a zoom level can never become
# remembering a sign-in.
SETTINGS = CONFIG_HOME / "ekklesia-classroom" / "wonderink.conf"
# HOW LARGE A LESSON IS.
#
# Measured in the lab on Plasma 6 / KWin, Debian 13, by asking each page
# for window.devicePixelRatio and its width in CSS pixels:
#
# display scale Wonder Ink Chrome (classroom-browser)
# 100% dpr 1, 1920 wide dpr 1, 1920 wide
# 150% dpr 1.5, 1280 wide dpr 2.25, 854 wide
# 200% dpr 2, 960 wide dpr 4, 500 wide
#
# Wonder Ink follows the display scale exactly, as the rest of the
# desktop does. Chrome is scaled TWICE: on Plasma it follows the
# compositor's scale by itself, and classroom-browser forces
# --force-device-scale-factor=UI_SCALE on top — a flag from the sway
# session, where Chrome was measured not following it. So in any room
# above 100% every page in the browser is UI_SCALE times larger than
# the desktop around it, and the same lesson in Wonder Ink looked small.
#
# Teachers read lessons in the browser at that size, so that is the size
# Wonder Ink starts at: page zoom = UI_SCALE. It is the room's own
# number, not a guess — 1.0 in a room at 100%, where nothing changes —
# and Wonder Ink's window itself is untouched, because Qt already scales
# that once, correctly.
#
# On top of that, the teacher's own level: Chrome's zoom steps, so
# Ctrl+Plus goes where somebody who knows Chrome expects, shown as a
# percentage of the room's normal size.
ZOOM_STEPS = (0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0)
LEVEL_NORMAL = 1.0
# The engine's own limits on a zoom factor.
ENGINE_ZOOM = (0.25, 5.0)
# Not the old Chrome directory. Two engines' state in one directory is a
# thing nobody can reason about later, and the Chrome profile is left
# where it is so that a rollback to the previous release finds it intact.
PROFILE = DATA_HOME / "ekklesia-classroom" / "wonderink-shell"
CACHE = CACHE_HOME / "ekklesia-classroom" / "wonderink-shell"
# The teacher's recent tabs, for two hours after they last used Wonder
# Ink. Beside the engine's profile, never in it, and nothing of the
# sign-in: see WorkspaceKeeper and lib/classroom_wonderink_workspace.py.
WORKSPACE_FILE = DATA_HOME / "ekklesia-classroom" / "wonderink-workspace.json"
# Where a lesson's handouts land.
#
# A directory of Wonder Ink's own, inside the ordinary Downloads folder
# rather than beside it, and the ordinary browser is not told about it and
# does not use it. Two reasons, and the second is the one that matters:
#
# a teacher's worksheets are not mixed in with whatever the room's
# shared browser has been asked to save; and
#
# it is the whole of the answer to "which local files may Wonder Ink
# open". Nothing outside this directory is ever loaded, and the
# navigation guard in LessonPage enforces that rather than trusting it.
DOWNLOADS = Path(
os.environ.get("XDG_DOWNLOAD_DIR") or Path.home() / "Downloads"
) / "Wonder Ink"
# What a PDF starts with. A server that answers a worksheet link with an
# HTML error page called .pdf is a real thing, and opening it would give a
# teacher a blank tab with no way to tell why.
PDF_MAGIC = b"%PDF-"
# Widevine, if this machine has it.
#
# Chrome is installed on every classroom machine for the ordinary
# browser, and it brings a Widevine module with it. QtWebEngine will use
# that module if it is told where it is, so a lesson whose video service
# happens to want DRM is not stopped by the engine change. Absent — on a
# machine with no Chrome — the flag is simply not passed and everything
# that does not need Widevine is unaffected.
WIDEVINE = Path(
"/opt/google/chrome/WidevineCdm/_platform_specific/linux_x64/libwidevinecdm.so"
)
# Has to be in the environment before QtWebEngine starts, which is why it
# is here rather than further down with the rest of the setup.
if WIDEVINE.exists():
flags = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "")
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = (
f"{flags} --widevine-path={WIDEVINE}".strip()
)
import classroom_printing # noqa: E402
import classroom_wonderink_workspace as workspace # noqa: E402
from PyQt6.QtCore import ( # noqa: E402
QEvent,
QKeyCombination,
QMarginsF,
QMimeData,
QObject,
QProcess,
QRectF,
Qt,
QTimer,
QUrl,
pyqtSignal,
)
from PyQt6.QtGui import ( # noqa: E402
QAction,
QGuiApplication,
QIcon,
QKeySequence,
QImage,
QPageLayout,
QPageSize,
QPainter,
QPdfWriter,
QPixmap,
)
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
from PyQt6.QtWebEngineCore import ( # noqa: E402
QWebEngineContextMenuRequest,
QWebEngineDownloadRequest,
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
from PyQt6.QtWidgets import ( # noqa: E402
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QFormLayout,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
# One running Wonder Ink per session. A second QtWebEngine process on the
# same profile directory would fight the first for its storage lock, and
# the panel gives a teacher a button that is easy to press twice.
SOCKET = "ekklesia-wonderink"
# Chrome's default margins, near enough, so a lesson prints as it would
# have from the ordinary browser.
PRINT_MARGIN_MM = 10
# How long a lesson page has to become a PDF before the preview gives up
# and says so. Measured: a long lesson page takes well under a second.
PREPARE_TIMEOUT_MS = 20_000
# How long black and white may take before the preview gives up on it.
# Not the same as a page: measured under the real Plasma session, one
# ordinary lesson page with eleven full-size pictures on it took 4.4 s to
# turn grey on an idle machine, and a lesson of photographs on a slower
# laptop could pass 20 s while working perfectly well. The preview says
# what it is doing all the while, and Cancel stops it.
GRAY_TIMEOUT_MS = 60_000
# A picture's page is drawn at printer resolution: 300 dpi is what the
# picture itself is capped at, so nothing it has is thrown away.
PICTURE_DPI = 300
# 300 dpi across a sheet of Letter: the most of a picture a printer can use.
PICTURE_LARGEST = 3300
# How long a picture a teacher right-clicked has to arrive. Measured: 0.01
# to 0.05 s in the lab, 0.016 s under the real Plasma session. A broken
# picture never arrives at all, and this is how long the teacher waits to
# be told so.
TAKE_PICTURE_MS = 3000
# Is this tab a picture on its own, and if so, the picture.
#
# A PNG or JPEG a lesson opens by itself is shown by the engine on a
# near-black viewer page, scaled to the window. Printed as a page, that is
# the black bars above and below the artwork that teachers saw. So the
# picture is taken as it is — drawn onto white, which is what transparent
# parts become on paper — and laid out on the paper by the preview.
# Capped at 3300 pixels on its longest side, which is 300 dpi across a
# sheet of Letter; nothing a printer can show is lost. An SVG is a
# document, not a picture on a viewer page, and prints as a page.
PICTURE = """(function(){
var type = document.contentType || '';
if (type.indexOf('image/') !== 0 || type === 'image/svg+xml') return null;
var img = document.images[0];
if (!img || !img.naturalWidth || document.images.length !== 1) return null;
var scale = Math.min(1, %d / Math.max(img.naturalWidth, img.naturalHeight));
var canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
var g = canvas.getContext('2d');
g.fillStyle = '#ffffff';
g.fillRect(0, 0, canvas.width, canvas.height);
g.drawImage(img, 0, 0, canvas.width, canvas.height);
try { return canvas.toDataURL('image/png'); } catch (e) { return null; }
})()""" % PICTURE_LARGEST
# How much of a page title fits on a tab before it stops being a label
# and starts being a paragraph.
TAB_TITLE = 22
def say(message):
print(f"classroom-wonderink: {message}", file=sys.stderr)
def load_level():
"""The teacher's zoom level, relative to the room's normal size.
Relative, so that an administrator changing UI_SCALE later is not
undone by a number saved at the old one. Never raises.
"""
try:
for line in SETTINGS.read_text(encoding="utf-8").splitlines():
key, _, value = line.partition("=")
if key.strip() == "LEVEL":
level = float(value)
if ZOOM_STEPS[0] <= level <= ZOOM_STEPS[-1]:
return level
except (OSError, ValueError):
pass
return LEVEL_NORMAL
def save_level(level):
"""Write it atomically; a failure costs the setting, not the lesson."""
try:
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
temporary = SETTINGS.with_suffix(".tmp")
temporary.write_text(f"LEVEL={level:g}\n", encoding="utf-8")
temporary.replace(SETTINGS)
except OSError as error:
say(f"could not remember the zoom level: {error}")
def level_step(level, direction):
"""The next of ZOOM_STEPS above (1) or below (-1) LEVEL."""
if direction > 0:
return next((z for z in ZOOM_STEPS if z > level + 0.001), ZOOM_STEPS[-1])
return next((z for z in reversed(ZOOM_STEPS) if z < level - 0.001), ZOOM_STEPS[0])
def nearest_level(level):
return min(ZOOM_STEPS, key=lambda step: abs(step - level))
class WorkspaceKeeper(QObject):
"""The teacher's recent tabs, restored after an accidental close.
TEMPORARY WORKSPACE RESTORE, NOT A SIGN-IN.
Wonder Ink writes down which tabs are open — their addresses, their
order, which is in front, a worksheet's file — whenever that changes,
with the time the teacher last used it. Opened again within
workspace.IDLE_LIMIT_SECONDS of that time, after a close, a crash or a
restart, it opens those tabs again. Later than that, it starts fresh
and the file goes: the next teacher never meets the last one's tabs.
Nothing of the sign-in is kept. WonderInk's sign-in is the teacher's
email and password in a cookie (measured), so a restored WonderInk tab
shows the site's own sign-in page, and closing Wonder Ink still signs
the teacher out, as it always has.
WHAT COUNTS AS USING IT.
Input that reaches Wonder Ink's own windows: a mouse button, a key, the
wheel, a touch. That covers everything a teacher does — following a
link, scrolling, switching, opening and closing tabs, a worksheet,
printing, zooming — and nothing a page does by itself: timers,
animation, background requests, analytics, video buffering, a
service worker. Restoring the tabs is not using them either; the time
kept is the teacher's last use, carried over, so reopening Wonder Ink
cannot keep a workspace alive by itself.
WHEN IT IS WRITTEN.
A second after the tabs change, and the time of last use at most every
KEEP_EVERY_MS, written whole and renamed into place: a crash or power
cut loses at most half a minute of use and never leaves half a file.
Nothing is written until the teacher has used Wonder Ink at all.
A RESTORE THAT CRASHES.
Starting from a workspace is counted in the file before any tab
loads, and the count cleared once Wonder Ink has run SETTLED_MS. A
workspace that has been started from twice without getting that far
is abandoned, and Wonder Ink starts fresh without asking anybody.
"""
ACTIVITY = (
QEvent.Type.MouseButtonPress,
QEvent.Type.KeyPress,
QEvent.Type.Wheel,
QEvent.Type.TouchBegin,
)
KEEP_EVERY_MS = 30_000
SETTLED_MS = 20_000
def __init__(self, window, path=None):
super().__init__(window)
self._window = window
self._path = Path(WORKSPACE_FILE if path is None else path)
self._last_wall = None
self._kept_at = None
self._attempts = 0
# Restored tabs, by what was kept, until they have loaded something
# of their own: a tab nobody has looked at yet is still that tab.
self._pending_tabs = {}
self._save_timer = QTimer(self)
self._save_timer.setSingleShot(True)
self._save_timer.timeout.connect(self.save)
QApplication.instance().installEventFilter(self)
QApplication.instance().aboutToQuit.connect(self.shut_down)
def restore(self, kept):
"""Open the tabs of a kept workspace. False if none of them can be.
The attempt is written down before any of them loads.
"""
tabs, active = workspace.plan(kept["tabs"], kept["active"], DOWNLOADS)
if not tabs:
return False
self._last_wall = kept["last_activity"]
self._attempts = kept["attempts"] + 1
workspace.write(self._path, tabs, active, self._last_wall, self._attempts)
self._kept_at = time.monotonic()
# Each tab loads when it is first shown, the one in front at once.
# Measured: a PDF loaded in a tab nobody is looking at is fitted to
# a window of no size, and shows at 10% when it is looked at.
for item in tabs:
if item["kind"] == "worksheet":
view = self._window.open_pdf(Path(item["where"]), focus=False, load=False)
where = QUrl.fromLocalFile(str(Path(item["where"]).resolve()))
else:
view = self._window.open_tab(focus=False)
where = QUrl(item["where"])
self._window._retitle(view, item["title"] or item["where"])
self._window._pending[view] = (lambda v=view, u=where: v.load(u))
self._pending_tabs[view] = item
self._window._tabs.setCurrentIndex(active)
# setCurrentIndex does not announce a tab that is already current.
front = self._window._tabs.widget(active)
load = self._window._pending.pop(front, None)
if load is not None:
load()
QTimer.singleShot(self.SETTLED_MS, self._settled)
return True
def _settled(self):
self._attempts = 0
self.changed()
# -- what changes it --------------------------------------------------
def eventFilter(self, watched, event):
if event.type() in self.ACTIVITY:
self.touched()
return False
def touched(self):
self._last_wall = time.time()
if self._kept_at is None or time.monotonic() - self._kept_at >= self.KEEP_EVERY_MS / 1000:
self._soon(0)
else:
self._soon(int(self.KEEP_EVERY_MS - (time.monotonic() - self._kept_at) * 1000))
def changed(self):
"""The tabs changed: written a moment later, once they settle."""
self._soon(1000)
def _soon(self, ms):
if not self._save_timer.isActive() or self._save_timer.remainingTime() > ms:
self._save_timer.start(max(0, ms))
def snapshot(self):
tabs = []
views = self._window._tabs
for index in range(views.count()):
view = views.widget(index)
title = views.tabToolTip(index) or view.title()
if view in self._window._pending:
tabs.append(self._pending_tabs[view])
elif view in self._window._pdf_views:
tabs.append(workspace.tab("worksheet", self._window._pdf_views[view], title))
else:
tabs.append(workspace.tab("web", view.url().toString(), title))
return tabs, max(0, views.currentIndex())
def save(self):
self._save_timer.stop()
tabs, active = self.snapshot()
try:
workspace.write(self._path, tabs, active, self._last_wall, self._attempts)
self._kept_at = time.monotonic()
except OSError as error:
say(f"could not keep the workspace: {error}")
def shut_down(self):
"""Keep the tabs, then let go of everything Qt tears down after.
Measured: left as an application-wide event filter while Python
and Qt shut down, a keeper crashed Wonder Ink on the way out one
run in several (a segfault in sip, from Py_Exit) — an event reached
the Python eventFilter of an object already half gone. So it stops
here, at aboutToQuit, while everything still exists: 30 runs, none.
"""
self.save()
self._save_timer.stop()
QApplication.instance().removeEventFilter(self)
class LessonPage(QWebEnginePage):
"""A page that answers "open this somewhere else" with a new tab.
Every route a site has to a second window arrives here: target=_blank,
window.open, a middle click, a Ctrl+click, and the context menu's open
in new tab. Chrome's app mode answered all of them with a bare popup
window; answering with a tab is the entire feature.
"""
def __init__(self, profile, window):
super().__init__(profile, window)
self._window = window
def createWindow(self, window_type):
background = (
window_type
== QWebEnginePage.WebWindowType.WebBrowserBackgroundTab
)
view = self._window.open_tab(focus=not background)
return view.page()
def javaScriptConsoleMessage(self, level, message, line, source):
# A lesson's own console noise is not the classroom's business and
# would otherwise fill the journal on every page load.
pass
def acceptNavigationRequest(self, url, navigation_type, is_main_frame):
"""Which URLs this window is willing to go to at all.
Wonder Ink opens a teacher's downloaded worksheet by handing the
tab a file: URL, so from this release the shell loads local files
— and the moment it does, "which local files" stops being
rhetorical. This is where it is answered.
A file: URL is accepted only if the window put that exact path on
its own list, which it does for one thing: a PDF that its own
download handler has just finished writing into DOWNLOADS. A
lesson cannot navigate to one, a link cannot point at one, and a
redirect cannot reach one, because none of those go through the
list.
Chromium already refuses to let a remote page navigate to file:,
so this is the second lock rather than the only one. It is here
because the first one is somebody else's and this one is ours.
"""
if url.isLocalFile():
allowed = self._window.may_open(Path(url.toLocalFile()))
if not allowed:
say(f"refusing a local file this window did not open: {url.toLocalFile()}")
return allowed
# Everything a lesson legitimately does. Anything else — a mail
# client, a chooser, some handler a desktop happens to register —
# is not a thing a classroom appliance follows.
return url.scheme() in ("http", "https", "about", "data", "blob", "chrome-extension")
class LessonView(QWebEngineView):
"""A page, and a right-click menu with nothing dangerous on it."""
def __init__(self, profile, window):
super().__init__(window)
self._window = window
self.setPage(LessonPage(profile, window))
def contextMenuEvent(self, event):
# Built rather than filtered. QtWebEngine's standard menu carries
# view-source, save-page and open-in-new-*window* entries, and a
# menu the classroom assembles itself cannot grow one of those
# back in a future Qt release without somebody noticing here.
menu = QMenu(self)
for action in (
QWebEnginePage.WebAction.Back,
QWebEnginePage.WebAction.Forward,
QWebEnginePage.WebAction.Reload,
):
menu.addAction(self.page().action(action))
request = self.lastContextMenuRequest()
if request is not None and not request.linkUrl().isEmpty():
menu.addSeparator()
open_tab = self.page().action(
QWebEnginePage.WebAction.OpenLinkInNewTab
)
open_tab.setText("Open in a new tab")
menu.addAction(open_tab)
if request is not None and request.isContentEditable():
menu.addSeparator()
for action in (
QWebEnginePage.WebAction.Cut,
QWebEnginePage.WebAction.Copy,
QWebEnginePage.WebAction.Paste,
):
menu.addAction(self.page().action(action))
elif request is not None and request.selectedText():
menu.addSeparator()
menu.addAction(self.page().action(QWebEnginePage.WebAction.Copy))
menu.addSeparator()
bigger = QAction("Make the lesson bigger", menu)
bigger.triggered.connect(self._window.zoom_in)
smaller = QAction("Make the lesson smaller", menu)
smaller.triggered.connect(self._window.zoom_out)
normal = QAction("Normal size", menu)
normal.triggered.connect(self._window.zoom_reset)
normal.setEnabled(abs(self._window.level() - LEVEL_NORMAL) > 0.001)
for action in (bigger, smaller, normal):
menu.addAction(action)
# Print says which: the picture under the pointer, or the page.
# 0.2.6.2 had one Print, and on a picture it printed the whole
# page around it. The engine's own hit test is the judge: a
# picture (<img>, including one inside a link or a frame) or a
# canvas is offered as a picture; words, space and a CSS
# background are the page. Page is always there, so a teacher who
# wanted the page from a picture still has it.
menu.addSeparator()
if request is not None and request.mediaType() in (
QWebEngineContextMenuRequest.MediaType.MediaTypeImage,
QWebEngineContextMenuRequest.MediaType.MediaTypeCanvas,
):
picture = QAction("Print image…", menu)
picture.triggered.connect(lambda: self._window.print_image(self))
menu.addAction(picture)
printing = QAction("Print page…", menu)
printing.triggered.connect(lambda: self._window.print_view(self))
menu.addAction(printing)
menu.popup(event.globalPos())
class WonderInk(QMainWindow):
zoom_changed = pyqtSignal(float)
def __init__(self, profile, room_zoom=1.0):
super().__init__()
self._profile = profile
# The recent tabs, written down as they change; see WorkspaceKeeper.
self._workspace = None
# Restored tabs not yet shown: what loads them, when they are.
self._pending = {}
self._fullscreen_view = None
# Every local file this window has decided to open, resolved. The
# navigation guard in LessonPage consults exactly this, so a path
# that is not here cannot be loaded however it is asked for.
self._openable = set()
# Tabs showing a worksheet rather than a page. Their names come
# from the file, because a PDF has no document title to take one
# from, and their downloads are not re-opened as further tabs.
self._pdf_views = {}
# Printing: see print_view. The preview on screen, if any; pages
# whose inline PDF is being fetched so that it can be printed; and
# what each tab was last answered with, which is how a PDF shown
# inline is told from a page.
self._preview = None
self._print_when_downloaded = set()
self._mime = {}
# One zoom level for the whole window: every tab, every new tab,
# and the next time Wonder Ink is opened. The room's normal size
# times the teacher's own level; see ZOOM_STEPS.
self._room_zoom = room_zoom
self._level = load_level()
self.setWindowTitle("Wonder Ink")
self.setWindowIcon(QIcon.fromTheme("ekklesia-wonderink"))
# On the window rather than the application, so the print preview
# (a child of it) matches, and so the lab's checks, which build a
# window without main(), see what a teacher sees.
self.setStyleSheet(stylesheet())
self._tabs = QTabWidget(self)
self._tabs.setDocumentMode(True)
self._tabs.setTabsClosable(True)
self._tabs.setMovable(True)
self._tabs.setElideMode(Qt.TextElideMode.ElideRight)
self._tabs.tabBar().setExpanding(False)
self._tabs.tabBar().setDrawBase(False)
self._tabs.tabCloseRequested.connect(self.close_tab)
self._tabs.currentChanged.connect(self._current_changed)
self._tabs.currentChanged.connect(lambda *_: self._workspace_changed())
self._tabs.tabBar().tabMoved.connect(lambda *_: self._workspace_changed())
self.setCentralWidget(self._tabs)
# The one corner widget: how large the lesson is, and Print. Not
# a new-tab button — that is what a browser puts here, and the
# classroom deliberately has nowhere to press for one.
self._controls = LessonControls(self)
self._tabs.setCornerWidget(self._controls, Qt.Corner.TopRightCorner)
self.zoom_changed.connect(self._controls.show_level)
self._controls.show_level(self._level)
# What a change of size looks like with the tab strip hidden: a
# moment's badge, so Ctrl+Plus visibly did something.
self._badge = ZoomBadge(self)
self.zoom_changed.connect(self._badge.show_level)
self._shortcuts()
# ---- tabs ----------------------------------------------------------
def open_tab(self, url=None, focus=True):
view = LessonView(self._profile, self)
view.titleChanged.connect(lambda t, v=view: self._retitle(v, t))
view.iconChanged.connect(lambda i, v=view: self._reicon(v, i))
view.page().fullScreenRequested.connect(self._fullscreen)
view.page().windowCloseRequested.connect(
lambda v=view: self.close_tab(self._tabs.indexOf(v))
)
# A lesson that calls window.print() itself. Measured in the lab:
# without this the button on the page does nothing at all, which
# is worse than no print button, because a teacher presses it and
# concludes the printer is broken. Bound to the page that asked
# rather than to whichever tab is in front.
view.page().printRequested.connect(lambda v=view: self.print_view(v))
# A window.print() from inside a frame: Qt 6.8 reports it apart
# from the main frame's, and it is still this tab being printed.
if hasattr(view.page(), "printRequestedByFrame"):
view.page().printRequestedByFrame.connect(
lambda _frame, v=view: self.print_view(v)
)
view.page().loadingChanged.connect(
lambda info, v=view: self._loading_changed(v, info)
)
# What the recent tabs are, as they change. Not activity: a page
# that navigates itself changes a tab, not the time of last use.
view.urlChanged.connect(lambda *_: self._workspace_changed())
view.titleChanged.connect(lambda *_: self._workspace_changed())
# Set once, here. Measured: the engine keeps a tab's zoom across
# every navigation, same site or not, so it is never re-applied.
view.setZoomFactor(self.zoom_factor())
# Ctrl+wheel is handled inside the engine and changes one tab.
# It is taken as the teacher's choice for all of them.
view.page().zoomFactorChanged.connect(
lambda zoom, v=view: self._zoomed_by_page(v, zoom)
)
index = self._tabs.addTab(view, "…")
if url is not None:
view.load(QUrl(url))
if focus:
self._tabs.setCurrentIndex(index)
self._show_tab_bar()
return view
def close_tab(self, index):
if index < 0:
return
view = self._tabs.widget(index)
# The last tab is the application. Anything else and the lesson
# carries on in the tabs that are left, which is the behaviour a
# teacher expects and the reason tabs are worth having at all.
if self._tabs.count() == 1:
self.close()
return
self._tabs.removeTab(index)
self._pdf_views.pop(view, None)
self._mime.pop(view, None)
self._pending.pop(view, None)
view.page().deleteLater()
view.deleteLater()
self._show_tab_bar()
self._workspace_changed()
def _workspace_changed(self):
if self._workspace is not None:
self._workspace.changed()
def _show_tab_bar(self):
# One tab is not a choice, so it is not drawn. A room with a
# single lesson open looks exactly like the application it
# replaced, and the strip appears the moment there is somewhere
# to switch to.
self._set_strip(self._tabs.count() > 1)
def _set_strip(self, visible):
self._tabs.tabBar().setVisible(visible)
self._controls.setVisible(visible)
def _retitle(self, view, title):
index = self._tabs.indexOf(view)
if index < 0:
return
# A worksheet keeps the name it was downloaded as. Chromium's PDF
# viewer leaves document.title empty, so left alone every PDF tab
# would be called "Wonder Ink" and two of them would be
# indistinguishable.
pdf = self._pdf_views.get(view)
title = pdf.stem if pdf is not None else (title or "Wonder Ink")
title = title.strip()
short = title if len(title) <= TAB_TITLE else title[: TAB_TITLE - 1] + "…"
self._tabs.setTabText(index, short)
self._tabs.setTabToolTip(index, title)
if index == self._tabs.currentIndex():
self._name_window(title)
def _reicon(self, view, icon):
index = self._tabs.indexOf(view)
# A worksheet is marked as a worksheet, whatever the engine's
# viewer offers as an icon, so it can be told from a lesson page.
if view in self._pdf_views:
icon = QIcon.fromTheme("application-pdf", QIcon.fromTheme("x-office-document"))
if index >= 0:
self._tabs.setTabIcon(index, icon)
def _current_changed(self, index):
view = self._tabs.widget(index)
if view is not None:
self._name_window(self._tabs.tabToolTip(index))
view.setFocus()
# A restored tab loads when it is first shown; see restore().
pending = self._pending.pop(view, None)
if pending is not None:
pending()
def _name_window(self, title):
# The panel and the window switcher show this. "Wonder Ink" last,
# so the application is always recognisable, and never twice.
title = (title or "").strip()
if not title or title == "Wonder Ink":
self.setWindowTitle("Wonder Ink")
else:
self.setWindowTitle(f"{title} — Wonder Ink")
def current(self):
return self._tabs.currentWidget()
# ---- the keys a teacher might already know --------------------------
def _shortcuts(self):
def bind(sequences, handler):
# ONE action per command, carrying every key for it.
#
# Two actions answering the same key are ambiguous to Qt, and
# Qt's answer to ambiguity is to do neither — measured: with
# Ctrl+- bound once by name and again as KDE's standard
# zoom-out key, Ctrl+Minus did nothing at all.
keys = []
for sequence in sequences:
if isinstance(sequence, QKeySequence.StandardKey):
keys.extend(QKeySequence.keyBindings(sequence))
else:
keys.append(QKeySequence(sequence))
unique = []
for key in keys:
if not key.isEmpty() and key not in unique:
unique.append(key)
action = QAction(self)
action.setShortcuts(unique)
action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
action.triggered.connect(handler)
self.addAction(action)
return action
def keypad(key):
return QKeySequence(QKeyCombination(
Qt.KeyboardModifier.ControlModifier
| Qt.KeyboardModifier.KeypadModifier,
key,
))
# Deliberately absent: Ctrl+T, Ctrl+N, Ctrl+L. There is no new
# tab, no new window and no address bar to focus.
bind([QKeySequence.StandardKey.Close], self._close_current)
bind([QKeySequence.StandardKey.Back], self._back)
bind([QKeySequence.StandardKey.Forward], self._forward)
bind([QKeySequence.StandardKey.Refresh], self._reload)
bind([QKeySequence.StandardKey.Print], self.print_current)
# Chrome's zoom keys belong to the browser around the page, so the
# engine never sees them; in this room the browser around the page
# is us. Ctrl+Plus arrives as Ctrl+Shift+Plus on the keyboards the
# rooms have, and Chrome answers Ctrl+= as well; the keypad's keys
# carry a modifier of their own.
bind(
[
"Ctrl+=", "Ctrl++", "Ctrl+Shift+=", "Ctrl+Shift++",
keypad(Qt.Key.Key_Plus),
QKeySequence.StandardKey.ZoomIn,
],
self.zoom_in,
)
bind(
[
"Ctrl+-", "Ctrl+Shift+_",
keypad(Qt.Key.Key_Minus),
QKeySequence.StandardKey.ZoomOut,
],
self.zoom_out,
)
bind(
[
"Ctrl+0",
keypad(Qt.Key.Key_0),
],
self.zoom_reset,
)
bind([QKeySequence.StandardKey.NextChild], self._next_tab)
bind(
[QKeySequence.StandardKey.PreviousChild, "Ctrl+Shift+Tab"],
self._previous_tab,
)
# ---- how large the lesson is -----------------------------------------
def level(self):
"""The teacher's level: 1.0 is the room's normal size."""
return self._level
def zoom_factor(self):
"""What the engine is told: the room's size times the teacher's."""
low, high = ENGINE_ZOOM
return max(low, min(high, self._room_zoom * self._level))
def set_level(self, level):
level = nearest_level(level)
changed = abs(level - self._level) > 0.001
self._level = level
zoom = self.zoom_factor()
for index in range(self._tabs.count()):
view = self._tabs.widget(index)
if abs(view.zoomFactor() - zoom) > 0.001:
view.setZoomFactor(zoom)
if changed:
save_level(level)
self.zoom_changed.emit(level)
def zoom_in(self):
self.set_level(level_step(self._level, 1))
def zoom_out(self):
self.set_level(level_step(self._level, -1))
def zoom_reset(self):
self.set_level(LEVEL_NORMAL)
def _zoomed_by_page(self, view, zoom):
# Ctrl+wheel, inside the engine. Taken as the teacher's level for
# every tab, on the nearest of the same steps the keys use.
if abs(zoom - self.zoom_factor()) > 0.001:
self.set_level(zoom / self._room_zoom)
def _close_current(self):
self.close_tab(self._tabs.currentIndex())
def _back(self):
if self.current():
self.current().back()
def _forward(self):
if self.current():
self.current().forward()
def _reload(self):
if self.current():
self.current().reload()
def _next_tab(self):
count = self._tabs.count()
if count > 1:
self._tabs.setCurrentIndex((self._tabs.currentIndex() + 1) % count)
def _previous_tab(self):
count = self._tabs.count()
if count > 1:
self._tabs.setCurrentIndex((self._tabs.currentIndex() - 1) % count)
# ---- a lesson's handouts --------------------------------------------
def may_open(self, path):
"""Is this a local file the window itself decided to open?"""
try:
return path.resolve() in self._openable
except OSError:
return False
def on_download(self, download):
"""A lesson handing the teacher a file.
Wonder Ink gives out leader guides and colouring pages, and some
of its worksheet links are a download rather than a page — the
server sends the PDF as an attachment, so there is nothing for a
tab to show and the file simply lands on disk.
Which, in this room, is the same as it not arriving at all. There
is no file manager, no application launcher and no downloads bar,
on purpose, and a teacher is not going to open a maintenance
terminal to print a Christmas tree. So a worksheet that finishes
downloading is opened in a tab, which is where the teacher was
looking anyway.
"""
download.setDownloadDirectory(str(DOWNLOADS))
download.accept()
# Read now rather than in the callback: the request object is
# deleted once it finishes, and reading it then is a crash.
name = download.downloadFileName()
mime = download.mimeType()
# A save from the PDF viewer's own toolbar is a download whose
# page is already a worksheet. Opening the copy would give a
# teacher a second identical tab every time they pressed it.
from_worksheet = download.page() in [
view.page() for view in self._pdf_views
]
# Fetched by print_view, for a PDF the site showed inline.
url = download.url().toString()
print_after = url in self._print_when_downloaded
self._print_when_downloaded.discard(url)
# stateChanged, not isFinishedChanged.
#
# Measured: a download interrupted mid-transfer — the church wi-fi
# dropping, a server hanging up — reaches
# DownloadState.DownloadInterrupted and isFinishedChanged is never
# emitted at all. Connected to that signal, Wonder Ink said
# nothing whatsoever about a worksheet that did not arrive, which
# is the one case a teacher most needs told about. stateChanged
# fires for every terminal state, so it is the one to listen to.
states = QWebEngineDownloadRequest.DownloadState
settled = []
def state_changed(state):
if settled or state == states.DownloadInProgress:
return
settled.append(state)
if state == states.DownloadCancelled:
return
if state == states.DownloadInterrupted:
# interruptReasonString names which of the dozen ways it
# went wrong, which is the difference between a teacher
# retrying and a teacher giving up.
notify(
"The worksheet did not finish downloading",
f"{name} — {download.interruptReasonString()}. Try again.",
)
return
path = DOWNLOADS / name
if not from_worksheet and self.open_worksheet(path, mime):
if print_after and self.current() in self._pdf_views:
QTimer.singleShot(0, self.print_current)
return
notify("Saved", f"{name} is in {DOWNLOADS}")
download.stateChanged.connect(state_changed)
def open_worksheet(self, path, mime=""):
"""Open a finished PDF in a tab. True if it was."""
if mime != "application/pdf" and path.suffix.lower() != ".pdf":
return False
# A completed download can still be a file worth refusing: an
# error page the server called .pdf, or a nought-byte truncation.
# The tab would come up blank and say nothing about why.
try:
with open(path, "rb") as handle:
head = handle.read(len(PDF_MAGIC))
except OSError as error:
say(f"cannot read {path}: {error}")
notify("Could not open the worksheet", f"{path.name} could not be read")
return True
if head != PDF_MAGIC:
say(f"not a PDF: {path}")
notify(
"Could not open the worksheet",
f"{path.name} did not arrive as a PDF. It is saved in {DOWNLOADS}.",
)
return True
self.open_pdf(path)
return True
def open_pdf(self, path, focus=True, load=True):
"""A worksheet, as a tab of its own."""
resolved = path.resolve()
# The one place anything is added to the list the navigation
# guard reads. Note what it is: a single resolved path, not a
# directory and not a pattern.
self._openable.add(resolved)
view = self.open_tab(focus=focus)
# The file itself, not only its name: a worksheet is printed from
# the PDF on disk, because the engine cannot print its own viewer.
self._pdf_views[view] = resolved
self._retitle(view, path.stem)
self._reicon(view, QIcon())
if load:
view.load(QUrl.fromLocalFile(str(resolved)))
return view
# ---- printing -------------------------------------------------------
# WHAT PRINT DOES, AND WHY IT IS NOT QT'S PRINT DIALOG ANY MORE.
#
# Little Waders, 0.2.2: Wonder Ink disappeared every time a teacher
# pressed Print in the dialog, SIGSEGV in QPrinter::resolution().
# QWebEngineView.print() is asynchronous: it keeps the raw QPrinter
# pointer and returns at once, and when Chromium has rendered the page
# Qt's didPrintPage() reads printer->resolution() and hands the
# printer to a worker thread. The printer was a local variable here,
# so PyQt had deleted it by then. Reproduced in the lab with the 0.2.2
# code — the same backtrace, frame for frame — and no job reached CUPS.
#
# Keeping the printer alive fixed the crash, and was measured, and was
# still the wrong answer: that same path sent a worksheet tab as one
# blank page, sent a lesson as 24 MB of pictures, and called a job
# the printer had refused a success. See lib/classroom_printing.py.
#
# So Print now makes a PDF — a worksheet already is one; a lesson page
# is rendered by the engine onto the printer's own paper — shows the
# teacher that PDF, and hands it to CUPS with lp, which is how Chrome
# prints in the same room. Nothing asynchronous holds a printer, so
# closing a tab or Wonder Ink afterwards has nothing left to break.
def print_current(self):
self.print_view(self.current())
def print_view(self, view):
if view is None or self._preview is not None:
return
title = self._tabs.tabToolTip(self._tabs.indexOf(view)) or "Wonder Ink"
pdf = self._pdf_views.get(view)
if pdf is None and self._shows_pdf(view):
# A PDF the site showed inline rather than sending as a
# download. Measured: the engine cannot render the PDF viewer
# for printing at all — printToPdf never answers — so it is
# fetched as the file it is, opened as a worksheet like any
# other, and printed from there.
self._print_when_downloaded.add(view.url().toString())
view.page().download(view.url())
return
dialog = PrintPreview(self, title)
self._preview = dialog
if pdf is not None:
dialog.show_document(pdf)
else:
dialog.paper_wanted.connect(
lambda paper, v=view, d=dialog: self._render(v, d, paper)
)
self._render(view, dialog, dialog.paper())
# A page may close its own tab while the teacher is looking at the
# preview. The preview says so rather than printing nothing.
self._show_preview(view, dialog, lambda: dialog.fail("The page was closed."))
def print_image(self, view):
"""The picture a teacher right-clicked, laid out as a picture.
The same preview a picture in a tab of its own gets (0.2.6.2) —
Size, Position, Paper, Fill the whole page, Color — for a picture
inside a lesson page, which a teacher often cannot open on its own
at all.
"""
if view is None or self._preview is not None:
return
page = self._tabs.tabToolTip(self._tabs.indexOf(view)) or "Wonder Ink"
dialog = PrintPreview(self, f"Picture from {page}")
self._preview = dialog
taken = self._take_picture(view, dialog)
# Once the picture has been taken the page is not needed; before,
# a page that closes itself has nothing left to take it from.
self._show_preview(
view, dialog,
lambda: None if taken() else dialog.fail("The page was closed."),
)
def _take_picture(self, view, dialog):
"""The picture under the teacher's last right-click, into DIALOG.
WHY THROUGH THE CLIPBOARD.
The engine's own "copy image" is the only way it hands over the
picture it is showing. Measured in the lab, QtWebEngine 6.8.2, on a
page with every kind of picture a lesson might have:
download the image fetches it again: a one-time address
is gone (404), and a canvas gives
nothing at all
fetch it from the page refused for another site's picture
(CORS)
draw it on a canvas refused for another site's picture
(a tainted canvas)
copy image every one of them: a signed-in
picture, hotlink-protected, one-time,
blob:, data:, another site's, a
canvas, WebP (which Qt itself cannot
read), SVG, GIF, transparency kept
Copy image fetches nothing — the server saw no request at all — so
a picture behind the teacher's sign-in needs no sign-in again: it
is the picture the page already has, as large as it really is. In
0.01 to 0.05 s; 0.016 s under the real Plasma session.
What was on the clipboard is kept and put back straight after,
measured under KWin with another program owning it: saved in
0.002 s, and that program could paste its own text again. A
picture that never arrives — one that failed to load — is said so
after TAKE_PICTURE_MS, and nothing prints.
Returns a function saying whether the picture has arrived.
"""
attempt = dialog.preparing("Taking the picture from the page…")
clipboard = QGuiApplication.clipboard()
put_back = keep_clipboard(clipboard)
# [True] once the picture is here, [False] once it has been given up on.
ended = []
def stop_listening():
try:
clipboard.dataChanged.disconnect(arrived)
except (RuntimeError, TypeError):
pass
def arrived():
image = clipboard.image()
if image.isNull():
return
# Whenever it comes — even after the teacher has cancelled, or
# been told it did not come — the clipboard is put back.
stop_listening()
put_back()
if ended:
return
ended.append(True)
if attempt.current():
dialog.show_picture(printable_picture(image))
def gave_up():
if ended:
return
ended.append(False)
# Still listening for a while, so a picture that turns up late
# does not stay on the teacher's clipboard.
QTimer.singleShot(10 * TAKE_PICTURE_MS, stop_listening)
if attempt.current():
say(f"a right-clicked picture did not arrive from {view.url().host()}")
dialog.fail(
"This picture could not be taken from the page — it may not "
"have finished loading. Nothing was printed. Close this and "
"try again, or choose Print page to print the whole page."
)
clipboard.dataChanged.connect(arrived)
view.page().triggerAction(QWebEnginePage.WebAction.CopyImageToClipboard)
QTimer.singleShot(TAKE_PICTURE_MS, gave_up)
return lambda: ended == [True]
def _show_preview(self, view, dialog, closed):
"""DIALOG, until the teacher is done with it; CLOSED if VIEW goes."""
watch = view.destroyed.connect(lambda *_: closed())
try:
dialog.exec()
finally:
try:
view.destroyed.disconnect(watch)
except (RuntimeError, TypeError):
pass
dialog.finish()
dialog.deleteLater()
self._preview = None
def _render(self, view, dialog, paper):
"""Ask the engine for this page as a PDF on PAPER, for DIALOG.
Unless the tab is a picture on its own, in which case the picture
itself is handed to the preview to be laid out on the paper.
"""
attempt = dialog.preparing()
def picture(answer):
if not attempt.current():
return
if isinstance(answer, str) and answer.startswith("data:image/png;base64,"):
import base64
image = QImage.fromData(base64.b64decode(answer.split(",", 1)[1]))
if not image.isNull():
dialog.show_picture(image)
return
self._render_page(view, dialog, paper, attempt)
view.page().runJavaScript(PICTURE, picture)
def _render_page(self, view, dialog, paper, attempt):
size = QPageSize(getattr(QPageSize.PageSizeId, paper))
layout = QPageLayout(
size, QPageLayout.Orientation.Portrait,
QMarginsF(*(PRINT_MARGIN_MM,) * 4), QPageLayout.Unit.Millimeter,
)
# The callback may arrive after the dialog has gone, or never:
# attempt is a plain Python object, so asking it is always safe,
# and the dialog's own timer gives up on an answer that does not
# come.
def rendered(data):
if attempt.current():
dialog.show_rendered(bytes(data))
view.page().printToPdf(rendered, layout)
def _shows_pdf(self, view):
"""Is this tab the engine's PDF viewer showing a remote PDF?"""
if self._mime.get(view) == "application/pdf":
return True
return view.url().path().lower().endswith(".pdf")
def _loading_changed(self, view, info):
if info.status() != info.LoadStatus.LoadSucceededStatus:
return
# responseHeaders() is Qt 6.6 and later; Debian 13 has 6.8. PyQt
# gives it as a dict of lists — it is a multi-map in C++.
headers = getattr(info, "responseHeaders", dict)()
mime = ""
for key, values in headers.items():
if key.data().decode("latin-1").lower() != "content-type":
continue
for value in values if isinstance(values, list) else [values]:
mime = value.data().decode("latin-1").split(";")[0].strip().lower()
self._mime[view] = mime
# ---- full screen ----------------------------------------------------
def _fullscreen(self, request):
# A lesson's video filling a classroom television is the point of
# the room, so this is always granted. The tab strip goes with it:
# a video is not a window a teacher needs chrome around.
request.accept()
if request.toggleOn():
if self.isFullScreen():
return
self._fullscreen_view = self.sender()
self._set_strip(False)
self.showFullScreen()
return
self._fullscreen_view = None
# Clearing the one state bit, rather than asking for maximised.
#
# showMaximized() here was measured to leave the window about
# 200 pixels wide in the corner of the screen: it starts
# maximised and so has no ordinary geometry to come back to, and
# under KWin the fullscreen-to-maximised transition lands on
# that empty geometry. Turning off the fullscreen bit and leaving
# the maximised bit alone keeps the window it already had.
self.setWindowState(
self.windowState() & ~Qt.WindowState.WindowFullScreen
)
# Belt and braces for a window that was somehow neither.
if not self.isMaximized():
self.showMaximized()
self._show_tab_bar()
def changeEvent(self, event):
# Leaving fullscreen by any route the page takes — its own button,
# Escape, a script — comes through here, so the tab strip comes
# back even when the request above is not what ended it.
super().changeEvent(event)
if event.type() == QEvent.Type.WindowStateChange:
if not self.isFullScreen() and self._fullscreen_view is None:
self._show_tab_bar()
class LessonControls(QWidget):
"""− 100% + and Print, at the end of the tab strip."""
def __init__(self, window):
super().__init__(window)
self.setObjectName("controls")
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 4, 10, 4)
layout.setSpacing(4)
self._smaller = self._button("−", "Make the lesson smaller (Ctrl+Minus)", window.zoom_out)
self._level = self._button("100%", "Normal size (Ctrl+0)", window.zoom_reset)
self._level.setObjectName("level")
self._bigger = self._button("+", "Make the lesson bigger (Ctrl+Plus)", window.zoom_in)
self._print = self._button("Print", "Print this tab (Ctrl+P)", window.print_current)
self._print.setObjectName("print")
self._print.setIcon(QIcon.fromTheme("document-print"))
self._print.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
for widget in (self._smaller, self._level, self._bigger):
layout.addWidget(widget)
layout.addSpacing(10)
layout.addWidget(self._print)
def _button(self, text, tip, handler):
button = QToolButton(self)
button.setText(text)
button.setToolTip(tip)
button.setAutoRaise(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(handler)
return button
def show_level(self, level):
self._level.setText(f"{round(level * 100)}%")
self._smaller.setEnabled(level > ZOOM_STEPS[0] + 0.001)
self._bigger.setEnabled(level < ZOOM_STEPS[-1] - 0.001)
class ZoomBadge(QLabel):
"""The lesson's size, for a moment, top right, after it changes."""
SHOWN_MS = 1400
def __init__(self, window):
super().__init__(window)
self.setObjectName("badge")
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self.hide()
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self.hide)
def show_level(self, level):
self.setText(f"Lesson size {round(level * 100)}%")
self.adjustSize()
window = self.parentWidget()
top = window.centralWidget().geometry().top() + 16
if window._tabs.tabBar().isVisible():
top += window._tabs.tabBar().height()
self.move(window.width() - self.width() - 24, top)
self.raise_()
self.show()
self._timer.start(self.SHOWN_MS)
def stylesheet():
"""The window's look, from the classroom's own palette.
The same colour scheme the panel, the notifications, Parent Call and
the Presentations library draw from, read by classroom_style, so
Wonder Ink follows light and dark with the rest of the room. Sizes
are logical pixels: Qt scales them by the display scale once.
"""
try:
from classroom_style import load_colours
c = load_colours()
except Exception as error: # a look is never worth not starting
say(f"using the default palette: {error}")
c = {
"window": "#141c20", "view": "#0f171a", "text": "#e6edef",
"dim": "#93a5aa", "accent": "#4fb3a8", "on_accent": "#0e1518",
"negative": "#e36a6a", "line": "#25333a",
}
return f"""
QTabWidget::pane {{ border: none; }}
QTabWidget::tab-bar {{ left: 8px; }}
QTabBar {{ background: {c['window']}; }}
QTabBar::tab {{
background: transparent;
color: {c['dim']};
font-size: 15px;
padding: 9px 12px 9px 14px;
margin: 6px 2px 0 2px;
min-width: 140px;
max-width: 280px;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom: 3px solid transparent;
}}
QTabBar::tab:hover:!selected {{ background: {c['line']}; color: {c['text']}; }}
QTabBar::tab:selected {{
background: {c['view']};
color: {c['text']};
font-weight: 600;
border-bottom: 3px solid {c['accent']};
}}
QTabBar::close-button {{ subcontrol-position: right; margin-left: 6px; }}
QWidget#controls {{ background: {c['window']}; }}
QWidget#controls QToolButton {{
color: {c['text']};
font-size: 17px;
min-width: 34px;
min-height: 30px;
border-radius: 8px;
padding: 2px 8px;
}}
QWidget#controls QToolButton:hover {{ background: {c['line']}; }}
QWidget#controls QToolButton:disabled {{ color: {c['line']}; }}
QWidget#controls QToolButton#level {{ font-size: 14px; color: {c['dim']}; min-width: 56px; }}
QWidget#controls QToolButton#print {{ font-size: 15px; padding: 2px 12px; }}
QLabel#badge {{
background: {c['window']};
color: {c['text']};
border: 1px solid {c['line']};
border-radius: 12px;
padding: 10px 16px;
font-size: 16px;
}}
QDialog#preview {{ background: {c['window']}; }}
QDialog#preview QLabel, QDialog#preview QComboBox, QDialog#preview QSpinBox,
QDialog#preview QLineEdit, QDialog#preview QPushButton {{ font-size: 15px; }}
QDialog#preview QPushButton {{ min-height: 34px; padding: 4px 18px; border-radius: 8px; }}
QDialog#preview QPushButton#print {{
background: {c['accent']};
color: {c['on_accent']};
font-weight: 600;
}}
QDialog#preview QPushButton#print:disabled {{ background: {c['line']}; color: {c['dim']}; }}
QDialog#preview QPushButton#turn {{ min-width: 44px; padding: 4px 8px; font-size: 18px; }}
QDialog#preview QLabel#status[error="true"] {{ color: {c['negative']}; }}
QDialog#preview QLabel#heading {{ font-weight: 600; font-size: 16px; }}
QDialog#preview QToolButton#place {{
border: 1px solid {c['dim']};
border-radius: 4px;
background: {c['view']};
}}
QDialog#preview QToolButton#place:checked {{ background: {c['accent']}; border-color: {c['accent']}; }}
QDialog#preview QToolButton#place:disabled {{ border-color: {c['line']}; background: {c['window']}; }}
"""
class _Attempt:
"""One request for a PDF. Stale once another is made or the dialog ends.
A plain Python object on purpose: the engine's callback can arrive
after the dialog has been deleted, and asking this is always safe
where asking a deleted Qt object is not.
"""
def __init__(self):
self.live = True
def current(self):
return self.live
class PositionGrid(QWidget):
"""Where on the paper: nine squares, one chosen, like the page itself."""
changed = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
grid = QGridLayout(self)
grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(4)
self._group = QButtonGroup(self)
self._names = {}
for name, (column, row) in classroom_printing.PICTURE_POSITIONS.items():
button = QToolButton(self)
button.setCheckable(True)
button.setObjectName("place")
button.setToolTip(name.capitalize())
button.setAccessibleName(name.capitalize())
button.setFixedSize(30, 24)
grid.addWidget(button, row, column)
self._group.addButton(button)
self._names[button] = name
button.setChecked(name == "centre")
self._group.buttonToggled.connect(
lambda button, checked: self.changed.emit() if checked else None
)
def position(self):
checked = self._group.checkedButton()
return self._names.get(checked, "centre")
def choose(self, name):
for button, known in self._names.items():
if known == name:
button.setChecked(True)
class PageView(QWidget):
"""One page of the document, whole, as it will come out of the printer."""
def __init__(self, parent=None):
super().__init__(parent)
self._pixmap = None
self._message = ""
self.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.setMinimumSize(320, 400)
def show_pixmap(self, pixmap):
self._pixmap, self._message = pixmap, ""
self.update()
def show_message(self, message):
self._pixmap, self._message = None, message
self.update()
def paintEvent(self, event):
from PyQt6.QtGui import QColor, QPainter
painter = QPainter(self)
painter.fillRect(self.rect(), self.palette().window().color().darker(115))
if self._pixmap is None:
painter.setPen(self.palette().windowText().color())
painter.drawText(
self.rect(), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap,
self._message,
)
return
# The page's own shape, as large as fits. Never stretched: a
# preview that changes the proportions is not showing the print.
area = self.rect().adjusted(16, 16, -16, -16)
scaled = self._pixmap.size().scaled(
area.size(), Qt.AspectRatioMode.KeepAspectRatio
)
x = area.x() + (area.width() - scaled.width()) // 2
y = area.y() + (area.height() - scaled.height()) // 2
painter.fillRect(x + 3, y + 3, scaled.width(), scaled.height(), QColor(0, 0, 0, 60))
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(x, y, scaled.width(), scaled.height(), self._pixmap)
class PrintPreview(QDialog):
"""The document as it will print, and the few choices a teacher has.
Printer, which pages, how many copies, and color or black and white
where the printer's driver can do both — and, for a picture, how large
and where on the paper. No paper tray, no scaling, no
"print to file" — there is no file chooser anywhere in this room, and
a print dialog is the usual way one gets in.
What is shown is the file that prints. There are two documents: the
source, in colour — a worksheet, a lesson page the engine rendered, a
picture laid out — and, when Black and white is chosen, a grey copy of
it made here. The preview shows whichever of the two is chosen and
Print hands that same file to CUPS.
"""
# A lesson page is laid out for a paper size; choosing a printer with
# different paper asks for it again.
paper_wanted = pyqtSignal(str)
# Wide enough to stay sharp on a television at twice scale.
RENDER_WIDTH = 1200
def __init__(self, window, title):
super().__init__(window)
self.setObjectName("preview")
self.setWindowTitle(f"Print — {title}")
self.setModal(True)
self._title = title
# What is shown and what prints: the source, or its grey copy.
self._pdf = None
# The document in colour, once it has arrived; None while one is
# on its way.
self._source = None
# (source, its grey copy), made once and kept, so turning Color
# and Black and white back and forth does not make it again.
self._gray = None
self._converter = None
self._conversion = None
self._too_long = ""
self._scratch = None
self._pages = 0
self._page = 1
self._images = {}
self._attempt = None
self._paper = None
self._options = {}
self._serial = iter(range(1, 1 << 30))
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._timed_out)
# -- the page ------------------------------------------------------
self._view = PageView(self)
self._previous = QPushButton("‹", self)
self._next = QPushButton("›", self)
self._previous.setObjectName("turn")
self._next.setObjectName("turn")
self._where = QLabel(self)
self._where.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._previous.clicked.connect(lambda: self._turn(-1))
self._next.clicked.connect(lambda: self._turn(1))
pager = QHBoxLayout()
pager.addStretch(1)
pager.addWidget(self._previous)
pager.addWidget(self._where)
pager.addWidget(self._next)
pager.addStretch(1)
left = QVBoxLayout()
left.addWidget(self._view, 1)
left.addLayout(pager)
# -- the choices ---------------------------------------------------
self._printer = QComboBox(self)
self._which = QComboBox(self)
self._which.addItems(["All pages", "Only some pages"])
self._range = QLineEdit(self)
self._range.setPlaceholderText("for example 1-3, 5")
self._range.setVisible(False)
self._which.currentIndexChanged.connect(self._which_changed)
self._copies = QSpinBox(self)
self._copies.setRange(1, classroom_printing.MAX_COPIES)
self._colour = QComboBox(self)
self._colour.addItems(["Color", "Black and white"])
self._colour_label = QLabel("Color", self)
# The preview changes with it: the same page, in the other colour.
self._colour.currentIndexChanged.connect(
lambda *_: self._present(keep_page=True)
)
form = QFormLayout()
form.addRow("Printer", self._printer)
form.addRow("Pages", self._which)
form.addRow("", self._range)
form.addRow("Copies", self._copies)
form.addRow(self._colour_label, self._colour)
# -- a picture: how large, and where ---------------------------------
self._picture = None
self._compose_timer = QTimer(self)
self._compose_timer.setSingleShot(True)
self._compose_timer.timeout.connect(self._compose)
self._size = QComboBox(self)
for percent in classroom_printing.PICTURE_SIZES:
self._size.addItem(
"As large as fits (100%)" if percent == 100 else f"{percent}%", percent
)
self._place = PositionGrid(self)
self._turned = QComboBox(self)
self._turned.addItem("Automatic", "auto")
self._turned.addItem("Upright (portrait)", "portrait")
self._turned.addItem("Sideways (landscape)", "landscape")
self._fill = QCheckBox("Fill the whole page\n(the edges are cut off)", self)
for signal in (self._size.currentIndexChanged, self._turned.currentIndexChanged,
self._place.changed, self._fill.toggled):
signal.connect(self._picture_changed)
self._picture_box = QWidget(self)
picture_form = QFormLayout(self._picture_box)
picture_form.setContentsMargins(0, 12, 0, 0)
heading = QLabel("Picture", self)
heading.setObjectName("heading")
picture_form.addRow(heading)
picture_form.addRow("Size", self._size)
picture_form.addRow("Position", self._place)
picture_form.addRow("Paper", self._turned)
picture_form.addRow("", self._fill)
self._picture_box.setVisible(False)
self._status = QLabel(self)
self._status.setObjectName("status")
self._status.setWordWrap(True)
self._cancel = QPushButton("Cancel", self)
self._print = QPushButton("Print", self)
self._print.setObjectName("print")
self._print.setDefault(True)
self._cancel.clicked.connect(self.reject)
self._print.clicked.connect(self._submit)
buttons = QHBoxLayout()
buttons.addStretch(1)
buttons.addWidget(self._cancel)
buttons.addWidget(self._print)
right = QVBoxLayout()
right.addLayout(form)
right.addWidget(self._picture_box)
right.addStretch(1)
right.addWidget(self._status)
right.addLayout(buttons)
side = QWidget(self)
side.setLayout(right)
side.setMinimumWidth(300)
side.setMaximumWidth(420)
layout = QHBoxLayout(self)
layout.addLayout(left, 1)
layout.addWidget(side)
names, default = classroom_printing.printers()
self._printer.addItems(names)
if default:
self._printer.setCurrentText(default)
self._printer.currentTextChanged.connect(self._printer_changed)
self._printer_changed(self._printer.currentText(), initial=True)
screen = window.screen().availableGeometry() if window.screen() else None
if screen is not None:
self.resize(int(screen.width() * 0.8), int(screen.height() * 0.85))
self._show_page()
if not names:
self.fail("No printer is set up on this computer. Ask for help.")
# -- what the window tells it -----------------------------------------
def paper(self):
return self._paper or "Letter"
def preparing(self, message="Preparing the preview…"):
"""A new document is on its way; anything older is stale."""
if self._attempt is not None:
self._attempt.live = False
self._attempt = _Attempt()
self._stop_converting()
self._source = None
self._forget_gray()
self._images = {}
self._wait(message, "The page took too long to prepare. Close this and try again.")
return self._attempt
def _wait(self, message, too_long, timeout=PREPARE_TIMEOUT_MS):
self._clear()
self._say(message)
self._view.show_message(message)
self._too_long = too_long
self._timer.start(timeout)
def show_rendered(self, data):
"""The engine's PDF of a lesson page, as bytes."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
if not data.startswith(PDF_MAGIC):
self.fail("This page could not be prepared for printing.")
return
path = self._scratch_dir() / f"page-{next(self._serial)}.pdf"
path.write_bytes(data)
self.show_document(path)
def show_picture(self, image):
"""A picture on its own, to be laid out on the paper here."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
self._picture = image
self._picture_box.setVisible(True)
self._compose()
def _picture_changed(self, *_):
filled = self._fill.isChecked()
self._size.setEnabled(not filled)
self._place.setEnabled(not filled)
if self._picture is not None:
# A moment's pause, so turning through the sizes does not lay
# the page out once for every step.
self._compose_timer.start(120)
def _compose(self):
"""Lay the picture out on one page, in colour, as the source."""
if self._picture is None:
return
image = self._picture
landscape = classroom_printing.picture_landscape(
self._turned.currentData(), image.width(), image.height()
)
path = self._scratch_dir() / f"picture-{next(self._serial)}.pdf"
writer = QPdfWriter(str(path))
writer.setTitle(self._title)
writer.setResolution(PICTURE_DPI)
writer.setPageLayout(QPageLayout(
QPageSize(getattr(QPageSize.PageSizeId, self.paper())),
QPageLayout.Orientation.Landscape if landscape else QPageLayout.Orientation.Portrait,
QMarginsF(0, 0, 0, 0), QPageLayout.Unit.Millimeter,
))
page = (writer.width(), writer.height())
margin = PRINT_MARGIN_MM / 25.4 * PICTURE_DPI
x, y, width, height = classroom_printing.picture_rect(
page, margin, (image.width(), image.height()),
self._size.currentData(), self._place.position(), self._fill.isChecked(),
)
painter = QPainter(writer)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
# Filling overhangs the margins by design; nothing is drawn in them.
painter.setClipRect(QRectF(margin, margin, page[0] - 2 * margin, page[1] - 2 * margin))
painter.drawImage(QRectF(x, y, width, height), image)
painter.end()
previous = self._source
self.show_document(path)
if previous is not None and previous != path and previous.parent == path.parent:
previous.unlink(missing_ok=True)
def show_document(self, pdf):
"""The document in colour: a worksheet, a lesson page, a picture laid out."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
self._stop_converting()
self._forget_gray()
self._images = {}
self._source = Path(pdf)
self._present()
# -- color, or black and white ------------------------------------------
def _wants_gray(self):
"""Black and white chosen, on a printer whose driver can do it."""
found = self._options.get(self._printer.currentText())
return (
self._colour.currentIndex() == 1
and found is not None
and found["gray"] is not None
)
def _present(self, keep_page=False):
"""Show the source in the colour chosen, making the grey copy if needed."""
self._stop_converting()
if self._source is None:
# A document is on its way, and is shown in the colour chosen
# by then when it arrives.
return
if not self._wants_gray():
self._display(self._source, keep_page)
elif self._gray is not None and self._gray[0] == self._source:
self._display(self._gray[1], keep_page)
else:
self._convert(keep_page)
def _convert(self, keep_page):
"""The source in black and white, made in the background.
Measured: 0.05 s for a worksheet, 2 s for 42 pages of text, and
4.4 s for one lesson page with eleven full-size pictures — long
enough that the preview says what it is doing, and Print waits,
rather than the window freezing.
"""
source = self._source
target = self._scratch_dir() / f"gray-{next(self._serial)}.pdf"
expected = classroom_printing.page_count(source)
conversion = self._conversion = _Attempt()
self._wait(
"Preparing black and white…",
"Black and white took too long to prepare. Choose Color, or close this and try again.",
GRAY_TIMEOUT_MS,
)
process = QProcess(self)
self._converter = process
def done(*_):
if self._converter is process:
self._converter = None
process.deleteLater()
if not conversion.current():
target.unlink(missing_ok=True)
return
conversion.live = False
self._timer.stop()
# Judged by the exit status and the pages, not by the file.
# Measured: Ghostscript given a PDF it cannot read exits 1 and
# still leaves a file behind.
if (
process.exitStatus() != QProcess.ExitStatus.NormalExit
or process.exitCode() != 0
or classroom_printing.page_count(target) != expected
):
error = bytes(process.readAllStandardError()).decode(errors="replace")
say(f"black and white failed for {source}: {error.strip()[-300:]}")
target.unlink(missing_ok=True)
# Never the colour file in its place: the teacher asked for
# black and white, and would be charged for colour. The
# source is kept, so choosing Color shows it again.
self._complain(
"Black and white could not be prepared. "
"Choose Color, or close this and try again."
)
return
self._gray = (source, target)
self._display(target, keep_page)
def not_started(error):
# A program that cannot start never finishes: errorOccurred is
# all there is.
if error == QProcess.ProcessError.FailedToStart:
done()
process.finished.connect(done)
process.errorOccurred.connect(not_started)
command = classroom_printing.grayscale_command(source, target)
process.start(command[0], command[1:])
def _stop_converting(self):
if self._conversion is not None:
self._conversion.live = False
process, self._converter = self._converter, None
if process is not None and process.state() != QProcess.ProcessState.NotRunning:
process.kill()
process.waitForFinished(2000)
def _forget_gray(self):
if self._gray is not None:
self._gray[1].unlink(missing_ok=True)
self._gray = None
def _scratch_dir(self):
import tempfile
if self._scratch is None:
# The teacher's own runtime directory: private to them, in
# memory, and gone at logout even if this is never cleaned.
self._scratch = tempfile.mkdtemp(
prefix="wonderink-print-", dir=os.environ.get("XDG_RUNTIME_DIR")
)
return Path(self._scratch)
def _display(self, pdf, keep_page=False):
self._timer.stop()
page = self._page
self._pdf = Path(pdf)
self._pages = classroom_printing.page_count(self._pdf)
if self._pages < 1:
self.fail("This document could not be read.")
return
# Turning Color and Black and white over stays on the page the
# teacher was looking at.
self._page = min(page, self._pages) if keep_page else 1
self._say("")
self._print.setEnabled(bool(self._printer.count()))
self._show_page()
def fail(self, message):
"""Nothing here can be printed any more."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
self._stop_converting()
self._source = None
self._complain(message)
def _timed_out(self):
if self._conversion is not None and self._conversion.current():
# Only black and white is late; the document itself is fine,
# and Color still shows it.
self._stop_converting()
self._complain(self._too_long)
else:
self.fail(self._too_long)
def _complain(self, message):
self._timer.stop()
self._clear()
self._view.show_message(message)
self._say(message, error=True)
def finish(self):
"""Everything this preview made, gone."""
self._timer.stop()
if self._attempt is not None:
self._attempt.live = False
self._stop_converting()
if self._scratch is not None:
import shutil
shutil.rmtree(self._scratch, ignore_errors=True)
self._scratch = None
# -- its own behaviour ------------------------------------------------
def _clear(self):
self._pdf = None
self._pages = 0
self._print.setEnabled(False)
self._show_page()
def _say(self, message, error=False):
self._status.setText(message)
self._status.setProperty("error", error)
self._status.style().unpolish(self._status)
self._status.style().polish(self._status)
def _which_changed(self, index):
self._range.setVisible(index == 1)
if index == 1:
self._range.setFocus()
def _printer_changed(self, name, initial=False):
if not name:
return
if name not in self._options:
self._options[name] = classroom_printing.options(name)
found = self._options[name]
self._colour.setVisible(found["gray"] is not None)
self._colour_label.setVisible(found["gray"] is not None)
if found["gray"] is None and self._colour.currentIndex() != 0:
# A printer that cannot print black and white is not shown a
# black-and-white preview.
self._colour.setCurrentIndex(0)
if found["paper"] != self._paper:
self._paper = found["paper"]
if initial:
return
if self._picture is not None:
self._compose()
else:
self.paper_wanted.emit(self._paper)
def _turn(self, step):
self._page = max(1, min(self._pages, self._page + step))
self._show_page()
def _show_page(self):
showing = self._pdf is not None and self._pages > 0
self._previous.setEnabled(showing and self._page > 1)
self._next.setEnabled(showing and self._page < self._pages)
self._where.setText(f"Page {self._page} of {self._pages}" if showing else "")
if not showing:
return
# Kept for each file, colour and grey alike, so turning Color and
# Black and white back and forth shows a page drawn once already.
key = (self._pdf, self._page)
if key not in self._images:
data = classroom_printing.render_page(self._pdf, self._page, self.RENDER_WIDTH)
pixmap = QPixmap()
if data is None or not pixmap.loadFromData(data):
self._view.show_message(f"Page {self._page} could not be shown.")
return
self._images[key] = pixmap
self._view.show_pixmap(self._images[key])
def _submit(self):
if self._pdf is None:
return
try:
pages = classroom_printing.parse_pages(
self._range.text() if self._which.currentIndex() == 1 else "",
self._pages,
)
except ValueError as error:
self._say(str(error), error=True)
self._range.setFocus()
return
printer = self._printer.currentText()
gray = None
if self._wants_gray():
# The grey file, and still the driver's own grey mode. Elmer's
# generic PCL 6 driver is told ColorModel=Grayscale as before,
# so it prints with pxlmono rather than a colour job that
# happens to be grey.
gray = self._options[printer]["gray"]
if (self._gray is not None and self._pdf == self._gray[1]) != (gray is not None):
# Never print something other than what is shown.
return
self._say(f"Sending to {printer}…")
self._print.setEnabled(False)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
ok, answer = classroom_printing.submit(
self._pdf, printer, self._copies.value(), pages, gray, self._title
)
finally:
QApplication.restoreOverrideCursor()
if not ok:
say(f"print to {printer} refused: {answer}")
self._say(f"{printer} did not take the job: {answer}", error=True)
self._print.setEnabled(True)
return
say(f"printed {self._title!r} to {printer} as {answer}")
notify("Sent to the printer", f"{self._title} — {printer}")
self.accept()
def keep_clipboard(clipboard):
"""What is on the clipboard now, and a function that puts it back."""
data = clipboard.mimeData()
kept = QMimeData()
if data is not None:
if data.hasImage():
kept.setImageData(data.imageData())
for name in data.formats():
if name != "application/x-qt-image":
kept.setData(name, data.data(name))
def put_back():
if kept.formats():
clipboard.setMimeData(kept)
else:
clipboard.clear()
return put_back
def printable_picture(image):
"""IMAGE as a picture is printed: on white, at most PICTURE_LARGEST.
What PICTURE does in the page for a picture in a tab of its own, for a
picture taken from inside one: transparent parts become the white of
the paper, not black.
"""
scale = min(1.0, PICTURE_LARGEST / max(image.width(), image.height(), 1))
width = max(1, round(image.width() * scale))
height = max(1, round(image.height() * scale))
picture = QImage(width, height, QImage.Format.Format_RGB32)
picture.fill(Qt.GlobalColor.white)
painter = QPainter(picture)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawImage(QRectF(0, 0, width, height), image)
painter.end()
return picture
def notify(summary, body):
import subprocess
try:
subprocess.Popen(
[
"notify-send", "--app-name=Wonder Ink",
"--icon=ekklesia-wonderink", summary, body,
]
)
except OSError:
say(f"{summary}: {body}")
def plain_user_agent(profile):
"""Chrome's user agent, without the token that says this is not it.
QtWebEngine advertises "QtWebEngine/6.8.2 Chrome/122...", and a site
that gates on it — a sign-in page deciding whether it trusts the
browser, a video service picking a stream — sees something it has
never heard of. The engine really is that Chromium, so the honest
string is the one without the extra token.
"""
return " ".join(
part for part in profile.httpUserAgent().split()
if not part.startswith("QtWebEngine/")
)
def raise_running_instance():
"""Ask an already-running Wonder Ink to come forward. True if it did."""
socket = QLocalSocket()
socket.connectToServer(SOCKET)
if not socket.waitForConnected(500):
return False
socket.write(b"raise\n")
socket.waitForBytesWritten(500)
socket.disconnectFromServer()
return True
def keep_going(kind, value, trace):
"""An error in Wonder Ink's own code is logged, and the lesson stays.
PyQt6's answer to an exception escaping any slot — a download
finishing, a title changing, a button in the print preview — is to
abort the whole process, measured in the lab: one mistake in one
handler and the window a teacher is presenting from vanishes. The
journal is the right place for that mistake, not the classroom.
"""
import traceback
say("unexpected error, carrying on:")
traceback.print_exception(kind, value, trace, file=sys.stderr)
def open_workspace(window, path=None):
"""Restore the teacher's recent tabs, or start fresh. The keeper, either way."""
path = WORKSPACE_FILE if path is None else path
kept, why = workspace.read(path)
keeper = WorkspaceKeeper(window, path)
window._workspace = keeper
if workspace.is_test_limit():
say(f"TEST ONLY: recent tabs are kept for {workspace.idle_limit()} s after the last use "
f"({workspace.TEST_OVERRIDE} is set)")
if kept is not None and keeper.restore(kept):
say(f"restored {window._tabs.count()} recent tabs, last used "
f"{round((time.time() - kept['last_activity']) / 60)} minutes ago")
else:
say(f"starting fresh ({why or 'nothing in it can be opened'})")
window.open_tab(HOME_PAGE)
return keeper
def build_profile(app, name="wonderink"):
"""The engine profile every Wonder Ink window uses, settings and all.
Its own function so that the lab's checks build exactly this rather
than a profile of their own: a check that forgot PluginsEnabled once
showed every worksheet as a blank page, and blamed Wonder Ink.
"""
PROFILE.mkdir(parents=True, exist_ok=True)
CACHE.mkdir(parents=True, exist_ok=True)
profile = QWebEngineProfile(name, app)
profile.setPersistentStoragePath(str(PROFILE))
profile.setCachePath(str(CACHE))
# The behaviour the Chrome policy's CookiesSessionOnlyForUrls gave
# wonderink.org, kept: no cookie is written to disk, for any site, so
# closing Wonder Ink ends the teacher's session and the next person is
# not signed in as the last one. The whole profile rather than one
# domain, because this profile only ever goes where a lesson sends it.
# WonderInk's sign-in is the teacher's password in a cookie (measured,
# 0.2.6.4): this is also what keeps that off the disk.
profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
)
profile.setHttpUserAgent(plain_user_agent(profile))
# A lesson is not allowed to put a desktop notification on a classroom
# television. The ordinary browser is told the same thing by policy.
profile.setNotificationPresenter(lambda notification: None)
settings = profile.settings()
settings.setAttribute(
QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True
)
# A lesson may start its own video.
#
# Chrome refuses to begin audible playback that no click asked for,
# and Wonder Ink's Present view was measured failing on exactly that:
# the same video plays a click earlier in the lesson view and stops
# dead when Present starts it, because the gesture from the Present
# click does not carry through the view transition. See
# docs/wonder-ink.md for the investigation.
#
# The fix was written down there and deliberately not applied, because
# in Chrome the only lever was AutoplayAllowlist on the policy that
# governs the ordinary browser as well. Here there is no such
# problem: this profile is Wonder Ink and nothing else, so allowing a
# lesson to start its own video allows exactly that and reaches no
# other page in the room. The browser is a separate application with
# a separate policy and is not touched.
settings.setAttribute(
QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, False
)
# No view-source, no developer tools, nothing that turns a lesson into
# a way out of the appliance.
settings.setAttribute(
QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True
)
# A worksheet is rendered by the engine's own PDF viewer, which is
# why there is no second rendering library in this room.
settings.setAttribute(
QWebEngineSettings.WebAttribute.PdfViewerEnabled, True
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.PluginsEnabled, True
)
# THE TWO DOORS A LOCAL FILE COULD OPEN.
#
# Qt leaves LocalContentCanAccessFileUrls on by default, which means a
# local document may read other local documents. Nothing in this room
# needs that — a PDF cannot use it anyway — and now that the shell
# loads file: URLs at all, a default is not a good enough answer.
# Both are named here so that the answer is in the file rather than in
# a Qt release note.
# Page backgrounds are not printed, as in Chrome, where "Background
# graphics" is off unless a teacher asks for it. Qt's default is the
# opposite, and a dark page background printed as solid black.
settings.setAttribute(
QWebEngineSettings.WebAttribute.PrintElementBackgrounds, False
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, False
)
settings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False
)
return profile
# What --diagnose asks a page. The renderer name WebGL reports is the
# plainest answer to "is this engine drawing with the GPU": a real driver's
# name, or llvmpipe / SwiftShader / nothing when it is drawing in software.
# chrome://gpu would say more and draws nothing in this engine build.
DIAGNOSE_GPU = """(function(){
var g = document.createElement('canvas').getContext('webgl');
if (!g) return 'none: no WebGL, so the engine is drawing in software';
var e = g.getExtension('WEBGL_debug_renderer_info');
return e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : g.getParameter(g.RENDERER);
})()"""
DIAGNOSE_LOAD = """(function(){
var n = performance.getEntriesByType('navigation')[0] || {};
var r = performance.getEntriesByType('resource'), bytes = n.transferSize || 0;
r.forEach(function(x){ bytes += x.transferSize || 0; });
return JSON.stringify({ready: !!document.querySelector('input[type=email], input[type=password]'),
first_byte: Math.round(n.responseStart || 0), requests: r.length + 1,
kilobytes: Math.round(bytes / 1024)});
})()"""
def diagnose(config):
"""For a technician at a classroom: why is a lesson slow here?
Run from the maintenance terminal, inside the teacher's session, so
that it sees that session's display and graphics. Opens nothing a
teacher could use, in a throwaway profile, so it can run beside a
signed-in Wonder Ink without touching it, and times the same thing
tools/lab measured: the sign-in page from a cold cache.
"""
import json
import time
from PyQt6.QtCore import PYQT_VERSION_STR, QT_VERSION_STR
from PyQt6.QtWebEngineCore import qWebEngineChromiumVersion, qWebEngineVersion
started = time.monotonic()
app = QApplication(sys.argv[:1])
# Off the record, and OWNED: a profile nobody holds is deleted at
# once while its page still uses it — measured, a segfault, the same
# shape of mistake as the 0.2.2 printing crash.
profile = QWebEngineProfile(app)
view = QWebEngineView()
page = QWebEnginePage(profile, view)
view.setPage(page)
view.resize(1280, 800)
view.show()
room = ui_scale(config)
print("Wonder Ink diagnostics")
print(f" Qt {QT_VERSION_STR}, PyQt {PYQT_VERSION_STR}, QtWebEngine "
f"{qWebEngineVersion()} (Chromium {qWebEngineChromiumVersion()})")
print(f" platform {QGuiApplication.platformName()}, window scale "
f"{view.devicePixelRatioF():g}, UI_SCALE {room:g}, lessons open at "
f"{room * load_level():g}x zoom")
result = {}
def finish(code):
result["code"] = code
app.quit()
def gpu(renderer):
print(f" GPU (WebGL renderer): {renderer}")
view.setZoomFactor(1.0)
view.load(QUrl(HOME_PAGE))
poll()
def poll():
if time.monotonic() - started > 60:
print(" sign-in page: NOT READY after 60 s")
return finish(1)
view.page().runJavaScript(DIAGNOSE_LOAD, measured)
def measured(answer):
found = json.loads(answer) if answer else {}
if not found.get("ready"):
return QTimer.singleShot(100, poll)
print(f" sign-in page usable after {time.monotonic() - loading[0]:.2f} s, "
f"first byte {found['first_byte']} ms, {found['requests']} requests, "
f"{found['kilobytes']} KB")
print(" (the lab, on a wired connection: about 2.9 s cold, "
"130-160 requests, 3.2 MB)")
finish(0)
loading = [time.monotonic()]
view.loadStarted.connect(lambda: loading.__setitem__(0, time.monotonic()))
view.setHtml("<p>diagnostics</p>", QUrl("https://diagnose.invalid/"))
view.loadFinished.connect(
lambda ok: view.page().runJavaScript(DIAGNOSE_GPU, gpu)
if view.url().host() == "diagnose.invalid" else None
)
QTimer.singleShot(90_000, lambda: finish(1))
app.exec()
# The page goes before its profile, or Qt warns and may crash on the
# way out.
view.close()
page.deleteLater()
view.deleteLater()
QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
sys.stdout.flush()
return result.get("code", 1)
def main():
config = read_device_config()
if "--diagnose" in sys.argv[1:]:
return diagnose(config)
# A device that has no Wonder Ink has no Wonder Ink. The panel does not
# offer it, so nothing should reach here; saying it out loud is for the
# technician who ran the command by hand and wants to know why.
# feature(), not enabled(..., default=True). The default belongs in
# classroom_config.FEATURES with every other application's, so that
# the panel and the command can never disagree about whether a device
# offers this — which is the whole reason that table exists.
if not feature(config, "WONDERINK_ENABLED"):
say("WONDERINK_ENABLED is false on this device; not starting")
return 0
if raise_running_instance():
return 0
sys.excepthook = keep_going
QGuiApplication.setDesktopFileName("ekklesia-wonderink")
app = QApplication(sys.argv)
app.setApplicationName("Wonder Ink")
# The name the panel matches a window to its launcher by. Wayland has
# no window class, so this is the app id, and it has to agree with
# StartupWMClass in ekklesia-wonderink.desktop exactly.
app.setDesktopFileName("ekklesia-wonderink")
app.setWindowIcon(QIcon.fromTheme("ekklesia-wonderink"))
profile = build_profile(app)
DOWNLOADS.mkdir(parents=True, exist_ok=True)
window = WonderInk(profile, room_zoom=ui_scale(config))
# Connected to the window rather than to a loose function: a finished
# worksheet becomes a tab, and only a window can make one.
profile.downloadRequested.connect(window.on_download)
open_workspace(window)
# An ordinary size before the maximised one, so the window has
# something sensible to be if it is ever asked to stop being
# maximised. A window that has only ever been maximised has no
# geometry of its own and comes back as a stub.
window.resize(1280, 800)
window.showMaximized()
# The other half of the single instance: a second press of the panel
# button connects here and the window comes forward instead of a
# second Wonder Ink starting on the same profile.
QLocalServer.removeServer(SOCKET)
server = QLocalServer(app)
server.listen(SOCKET)
def come_forward():
connection = server.nextPendingConnection()
connection.disconnectFromServer()
if window.isMinimized():
window.showNormal()
window.raise_()
window.activateWindow()
server.newConnection.connect(come_forward)
return app.exec()
if __name__ == "__main__":
sys.exit(main())