Teachers found lessons in Wonder Ink too small next to the ordinary browser. Measured by asking each page for devicePixelRatio: Wonder Ink follows the display scale exactly (dpr 1.5 at 150%), and Chrome is scaled TWICE (dpr 2.25) — under Plasma it follows the compositor itself and classroom-browser also forces UI_SCALE, a flag from the sway session. The browser is left as it is. Wonder Ink's pages start at zoom = UI_SCALE, the room's own number (1.0 at 100%), and measured afterwards match the browser: dpr 2.25 and 853 px against 2.25 and 854. The window is not zoomed; Qt scales it once, correctly. Ctrl+Plus/=/keypad, Ctrl+Minus, Ctrl+0, Ctrl+wheel and the right-click menu set one level for every tab, remembered as LEVEL= in its own file, relative to the room. Two actions on one key are ambiguous to Qt and it then does neither — Ctrl+Minus did nothing until each command became one action with all its keys, which also fixes Ctrl+Shift+Tab. The engine profile is built by one function, which the lab's checks now use: a check with its own profile showed every worksheet blank. The lab fences 10.10.0.0/16 as well: the development host reaches the real Elmer over Tailscale, and the lab provisions a queue pointing at it. Four lab checks written for older code are corrected; one had failed on every run since 0.2.3. tools/lab/wonderink-zoom-check: 18 checks. Lab: converged 120, print 39, worksheets 24, all passing. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1567 lines
57 KiB
Python
Executable File
1567 lines
57 KiB
Python
Executable File
#!/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. Cookies live
|
||
only as long as the window, which is what the Chrome policy's
|
||
CookiesSessionOnlyForUrls did for wonderink.org and is the reason
|
||
closing Wonder Ink ends the teacher's session. Nothing here touches the
|
||
ordinary browser's profile or Chrome's policies.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
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"
|
||
|
||
# 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
|
||
from PyQt6.QtCore import ( # noqa: E402
|
||
QEvent,
|
||
QKeyCombination,
|
||
QMarginsF,
|
||
Qt,
|
||
QTimer,
|
||
QUrl,
|
||
pyqtSignal,
|
||
)
|
||
from PyQt6.QtGui import ( # noqa: E402
|
||
QAction,
|
||
QGuiApplication,
|
||
QKeySequence,
|
||
QPageLayout,
|
||
QPageSize,
|
||
QPixmap,
|
||
)
|
||
from PyQt6.QtNetwork import QLocalServer, QLocalSocket # noqa: E402
|
||
from PyQt6.QtWebEngineCore import ( # noqa: E402
|
||
QWebEngineDownloadRequest,
|
||
QWebEnginePage,
|
||
QWebEngineProfile,
|
||
QWebEngineSettings,
|
||
)
|
||
from PyQt6.QtWebEngineWidgets import QWebEngineView # noqa: E402
|
||
from PyQt6.QtWidgets import ( # noqa: E402
|
||
QApplication,
|
||
QComboBox,
|
||
QDialog,
|
||
QFormLayout,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMainWindow,
|
||
QMenu,
|
||
QPushButton,
|
||
QSizePolicy,
|
||
QSpinBox,
|
||
QTabWidget,
|
||
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 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 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)
|
||
|
||
menu.addSeparator()
|
||
printing = QAction("Print…", menu)
|
||
printing.triggered.connect(self._window.print_current)
|
||
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
|
||
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._tabs = QTabWidget(self)
|
||
self._tabs.setDocumentMode(True)
|
||
self._tabs.setTabsClosable(True)
|
||
self._tabs.setMovable(True)
|
||
# No corner widget. This is where a browser puts its new-tab
|
||
# button, and the classroom deliberately has nowhere to press.
|
||
self._tabs.setElideMode(Qt.TextElideMode.ElideRight)
|
||
self._tabs.tabCloseRequested.connect(self.close_tab)
|
||
self._tabs.currentChanged.connect(self._current_changed)
|
||
self.setCentralWidget(self._tabs)
|
||
|
||
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)
|
||
)
|
||
# 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)
|
||
view.page().deleteLater()
|
||
view.deleteLater()
|
||
self._show_tab_bar()
|
||
|
||
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._tabs.tabBar().setVisible(self._tabs.count() > 1)
|
||
|
||
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.setWindowTitle(title)
|
||
|
||
def _reicon(self, view, icon):
|
||
index = self._tabs.indexOf(view)
|
||
|
||
if index >= 0:
|
||
self._tabs.setTabIcon(index, icon)
|
||
|
||
def _current_changed(self, index):
|
||
view = self._tabs.widget(index)
|
||
|
||
if view is not None:
|
||
self.setWindowTitle(view.title() or "Wonder Ink")
|
||
view.setFocus()
|
||
|
||
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):
|
||
"""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()
|
||
# 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)
|
||
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.
|
||
watch = view.destroyed.connect(
|
||
lambda *_: dialog.fail("The page was 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."""
|
||
attempt = dialog.preparing()
|
||
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._tabs.tabBar().setVisible(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 _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 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 colour or black and white
|
||
where the printer's driver can do both. 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.
|
||
"""
|
||
|
||
# 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.setWindowTitle(f"Print — {title}")
|
||
self.setModal(True)
|
||
|
||
self._title = title
|
||
self._pdf = None
|
||
self._scratch = None
|
||
self._pages = 0
|
||
self._page = 1
|
||
self._images = {}
|
||
self._attempt = None
|
||
self._paper = None
|
||
self._options = {}
|
||
|
||
self._timer = QTimer(self)
|
||
self._timer.setSingleShot(True)
|
||
self._timer.timeout.connect(
|
||
lambda: self.fail("The page took too long to prepare. Close this and try again.")
|
||
)
|
||
|
||
# -- the page ------------------------------------------------------
|
||
self._view = PageView(self)
|
||
self._previous = QPushButton("‹", self)
|
||
self._next = QPushButton("›", self)
|
||
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(["Colour", "Black and white"])
|
||
self._colour_label = QLabel("Colour", self)
|
||
|
||
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)
|
||
|
||
self._status = QLabel(self)
|
||
self._status.setWordWrap(True)
|
||
|
||
self._cancel = QPushButton("Cancel", self)
|
||
self._print = QPushButton("Print", self)
|
||
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.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):
|
||
"""A new PDF is on its way; anything older is stale."""
|
||
if self._attempt is not None:
|
||
self._attempt.live = False
|
||
|
||
self._attempt = _Attempt()
|
||
self._clear()
|
||
self._say("Preparing the preview…")
|
||
self._view.show_message("Preparing the preview…")
|
||
self._timer.start(PREPARE_TIMEOUT_MS)
|
||
return self._attempt
|
||
|
||
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
|
||
|
||
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")
|
||
)
|
||
|
||
path = Path(self._scratch) / f"page-{id(data)}.pdf"
|
||
path.write_bytes(data)
|
||
self.show_document(path)
|
||
|
||
def show_document(self, pdf):
|
||
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
|
||
|
||
self._page = 1
|
||
self._images = {}
|
||
self._say("")
|
||
self._print.setEnabled(bool(self._printer.count()))
|
||
self._show_page()
|
||
|
||
def fail(self, message):
|
||
self._timer.stop()
|
||
|
||
if self._attempt is not None:
|
||
self._attempt.live = False
|
||
|
||
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
|
||
|
||
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._images = {}
|
||
self._print.setEnabled(False)
|
||
self._show_page()
|
||
|
||
def _say(self, message, error=False):
|
||
self._status.setText(message)
|
||
self._status.setStyleSheet("color: #d9534f;" if error else "")
|
||
|
||
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["paper"] != self._paper:
|
||
self._paper = found["paper"]
|
||
|
||
if not initial:
|
||
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
|
||
|
||
if self._page 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[self._page] = pixmap
|
||
|
||
self._view.show_pixmap(self._images[self._page])
|
||
|
||
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._colour.isVisible() and self._colour.currentIndex() == 1:
|
||
gray = self._options[printer]["gray"]
|
||
|
||
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 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 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: closing Wonder Ink ends the teacher's session,
|
||
# so the next person to walk into the room 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.
|
||
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.
|
||
settings.setAttribute(
|
||
QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, False
|
||
)
|
||
settings.setAttribute(
|
||
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, False
|
||
)
|
||
|
||
return profile
|
||
|
||
|
||
def main():
|
||
config = read_device_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")
|
||
|
||
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)
|
||
window.open_tab(HOME_PAGE)
|
||
# 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())
|