feat: Wonder Ink looks like the classroom, and its preview shows the page

The tab strip is drawn from the classroom's own palette, the one the
panel, Parent Call and Presentations read: larger titles, an accent
under the active tab, worksheets marked with a document icon. Its one
corner widget is the lesson's size and Print — not a new-tab button. With
one tab the strip stays hidden, as before; a change of size shows a
moment's badge instead, and the right-click menu and keys still work.
The window is named "Lesson — Wonder Ink" and carries its own icon.

The print preview showed "Page 1 could not be shown" in the real
session while printing correctly: poppler 25.03 does not write to stdout
when asked for "-" — it writes a file called "-.png" into the current
directory, which for Wonder Ink is the teacher's home. Pages are now
rendered into a directory of their own, and the lab print check fails
any preview that does not draw its page.

The teacher's manual has lesson size and printing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-23 19:13:04 -05:00
co-authored by Claude Opus 5.5
parent 48b3097c43
commit 1cb9fd35d2
11 changed files with 274 additions and 27 deletions
+23 -1
View File
@@ -20,10 +20,32 @@ and the **×** on a tab to close it.
There is no address bar, on purpose. Wonder Ink can take you anywhere There is no address bar, on purpose. Wonder Ink can take you anywhere
the lesson needs to go. the lesson needs to go.
## Making the lesson bigger or smaller
Press **Ctrl** and **+** to make the lesson bigger, **Ctrl** and **−** to
make it smaller, and **Ctrl** and **0** to go back to normal. You can also
right-click the lesson and choose **Make the lesson bigger**. When several
tabs are open, the **− 100% +** buttons at the top right do the same.
The size applies to every tab and is remembered next time.
## Printing
Press **Ctrl** and **P**, click **Print** at the top right, or right-click
and choose **Print…**. You will see the pages exactly as they will print.
* Choose **Only some pages** to print, for example, `2` or `1-3`.
* Set **Copies** for the number of children.
* Choose **Black and white** to save colour ink.
Click **Print**. Wonder Ink says when the printer has it and goes back to
the lesson. **Cancel** prints nothing.
## Worksheets and colouring pages ## Worksheets and colouring pages
Click the worksheet link in the lesson. It opens in a new tab, ready to Click the worksheet link in the lesson. It opens in a new tab, ready to
read or print. read or print. A worksheet's own **−** and **+** at the top of the page
make it bigger or smaller.
Worksheets you download are kept separately from everything else on the Worksheets you download are kept separately from everything else on the
computer, so they never get mixed up with anything. computer, so they never get mixed up with anything.
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+32 -16
View File
@@ -122,24 +122,40 @@ def page_count(pdf):
def render_page(pdf, number, width): def render_page(pdf, number, width):
"""PNG bytes of one page, WIDTH pixels wide, or None.""" """PNG bytes of one page, WIDTH pixels wide, or None.
try:
result = subprocess.run(
[
"pdftoppm", "-f", str(number), "-l", str(number),
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
"-png", "-singlefile", str(pdf), "-",
],
capture_output=True,
timeout=QUERY_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return None
if result.returncode != 0 or not result.stdout: Rendered into a directory of its own and read back. Measured on
return None poppler 25.03: asked for "-" as its output, pdftoppm does NOT write to
stdout — it writes a file called "-.png" (or "--1.png") into whatever
directory it was started in, which for Wonder Ink is the teacher's
home, and the preview showed nothing at all.
"""
import tempfile
from pathlib import Path
return result.stdout with tempfile.TemporaryDirectory(prefix="wonderink-page-") as directory:
root = Path(directory) / "page"
try:
result = subprocess.run(
[
"pdftoppm", "-f", str(number), "-l", str(number),
"-scale-to-x", str(int(width)), "-scale-to-y", "-1",
"-png", "-singlefile", "--", str(pdf), str(root),
],
capture_output=True,
timeout=QUERY_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return None
image = root.with_suffix(".png")
if result.returncode != 0 or not image.is_file():
return None
data = image.read_bytes()
return data or None
def parse_pages(text, count): def parse_pages(text, count):
+204 -7
View File
@@ -175,6 +175,7 @@ from PyQt6.QtCore import ( # noqa: E402
from PyQt6.QtGui import ( # noqa: E402 from PyQt6.QtGui import ( # noqa: E402
QAction, QAction,
QGuiApplication, QGuiApplication,
QIcon,
QKeySequence, QKeySequence,
QPageLayout, QPageLayout,
QPageSize, QPageSize,
@@ -202,6 +203,7 @@ from PyQt6.QtWidgets import ( # noqa: E402
QSizePolicy, QSizePolicy,
QSpinBox, QSpinBox,
QTabWidget, QTabWidget,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -423,18 +425,36 @@ class WonderInk(QMainWindow):
self._level = load_level() self._level = load_level()
self.setWindowTitle("Wonder Ink") 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 = QTabWidget(self)
self._tabs.setDocumentMode(True) self._tabs.setDocumentMode(True)
self._tabs.setTabsClosable(True) self._tabs.setTabsClosable(True)
self._tabs.setMovable(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.setElideMode(Qt.TextElideMode.ElideRight)
self._tabs.tabBar().setExpanding(False)
self._tabs.tabBar().setDrawBase(False)
self._tabs.tabCloseRequested.connect(self.close_tab) self._tabs.tabCloseRequested.connect(self.close_tab)
self._tabs.currentChanged.connect(self._current_changed) self._tabs.currentChanged.connect(self._current_changed)
self.setCentralWidget(self._tabs) 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() self._shortcuts()
# ---- tabs ---------------------------------------------------------- # ---- tabs ----------------------------------------------------------
@@ -508,7 +528,11 @@ class WonderInk(QMainWindow):
# single lesson open looks exactly like the application it # single lesson open looks exactly like the application it
# replaced, and the strip appears the moment there is somewhere # replaced, and the strip appears the moment there is somewhere
# to switch to. # to switch to.
self._tabs.tabBar().setVisible(self._tabs.count() > 1) 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): def _retitle(self, view, title):
index = self._tabs.indexOf(view) index = self._tabs.indexOf(view)
@@ -528,11 +552,16 @@ class WonderInk(QMainWindow):
self._tabs.setTabToolTip(index, title) self._tabs.setTabToolTip(index, title)
if index == self._tabs.currentIndex(): if index == self._tabs.currentIndex():
self.setWindowTitle(title) self._name_window(title)
def _reicon(self, view, icon): def _reicon(self, view, icon):
index = self._tabs.indexOf(view) 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: if index >= 0:
self._tabs.setTabIcon(index, icon) self._tabs.setTabIcon(index, icon)
@@ -540,9 +569,19 @@ class WonderInk(QMainWindow):
view = self._tabs.widget(index) view = self._tabs.widget(index)
if view is not None: if view is not None:
self.setWindowTitle(view.title() or "Wonder Ink") self._name_window(self._tabs.tabToolTip(index))
view.setFocus() view.setFocus()
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): def current(self):
return self._tabs.currentWidget() return self._tabs.currentWidget()
@@ -820,6 +859,7 @@ class WonderInk(QMainWindow):
# the PDF on disk, because the engine cannot print its own viewer. # the PDF on disk, because the engine cannot print its own viewer.
self._pdf_views[view] = resolved self._pdf_views[view] = resolved
self._retitle(view, path.stem) self._retitle(view, path.stem)
self._reicon(view, QIcon())
view.load(QUrl.fromLocalFile(str(resolved))) view.load(QUrl.fromLocalFile(str(resolved)))
return view return view
@@ -952,7 +992,7 @@ class WonderInk(QMainWindow):
return return
self._fullscreen_view = self.sender() self._fullscreen_view = self.sender()
self._tabs.tabBar().setVisible(False) self._set_strip(False)
self.showFullScreen() self.showFullScreen()
return return
@@ -987,6 +1027,155 @@ class WonderInk(QMainWindow):
self._show_tab_bar() 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']}; }}
"""
class _Attempt: class _Attempt:
"""One request for a PDF. Stale once another is made or the dialog ends. """One request for a PDF. Stale once another is made or the dialog ends.
@@ -1067,6 +1256,7 @@ class PrintPreview(QDialog):
def __init__(self, window, title): def __init__(self, window, title):
super().__init__(window) super().__init__(window)
self.setObjectName("preview")
self.setWindowTitle(f"Print — {title}") self.setWindowTitle(f"Print — {title}")
self.setModal(True) self.setModal(True)
@@ -1090,6 +1280,8 @@ class PrintPreview(QDialog):
self._view = PageView(self) self._view = PageView(self)
self._previous = QPushButton("‹", self) self._previous = QPushButton("‹", self)
self._next = QPushButton("›", self) self._next = QPushButton("›", self)
self._previous.setObjectName("turn")
self._next.setObjectName("turn")
self._where = QLabel(self) self._where = QLabel(self)
self._where.setAlignment(Qt.AlignmentFlag.AlignCenter) self._where.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._previous.clicked.connect(lambda: self._turn(-1)) self._previous.clicked.connect(lambda: self._turn(-1))
@@ -1128,10 +1320,12 @@ class PrintPreview(QDialog):
form.addRow(self._colour_label, self._colour) form.addRow(self._colour_label, self._colour)
self._status = QLabel(self) self._status = QLabel(self)
self._status.setObjectName("status")
self._status.setWordWrap(True) self._status.setWordWrap(True)
self._cancel = QPushButton("Cancel", self) self._cancel = QPushButton("Cancel", self)
self._print = QPushButton("Print", self) self._print = QPushButton("Print", self)
self._print.setObjectName("print")
self._print.setDefault(True) self._print.setDefault(True)
self._cancel.clicked.connect(self.reject) self._cancel.clicked.connect(self.reject)
self._print.clicked.connect(self._submit) self._print.clicked.connect(self._submit)
@@ -1264,7 +1458,9 @@ class PrintPreview(QDialog):
def _say(self, message, error=False): def _say(self, message, error=False):
self._status.setText(message) self._status.setText(message)
self._status.setStyleSheet("color: #d9534f;" if error else "") self._status.setProperty("error", error)
self._status.style().unpolish(self._status)
self._status.style().polish(self._status)
def _which_changed(self, index): def _which_changed(self, index):
self._range.setVisible(index == 1) self._range.setVisible(index == 1)
@@ -1523,6 +1719,7 @@ def main():
# no window class, so this is the app id, and it has to agree with # no window class, so this is the app id, and it has to agree with
# StartupWMClass in ekklesia-wonderink.desktop exactly. # StartupWMClass in ekklesia-wonderink.desktop exactly.
app.setDesktopFileName("ekklesia-wonderink") app.setDesktopFileName("ekklesia-wonderink")
app.setWindowIcon(QIcon.fromTheme("ekklesia-wonderink"))
profile = build_profile(app) profile = build_profile(app)
+6 -1
View File
@@ -388,8 +388,13 @@ check("fullscreen still returns to a maximised window",
# One text box exists, and it is the print preview's page range. Anywhere # One text box exists, and it is the print preview's page range. Anywhere
# else a text box in this window is an address bar. # else a text box in this window is an address bar.
preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")] preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")]
# The tab strip has one corner widget, and it is the lesson's size and
# Print — not the new-tab button a browser puts there.
controls = wonderink[wonderink.index("class LessonControls"):wonderink.index("class ZoomBadge")]
check("still no new-tab button or address bar", check("still no new-tab button or address bar",
"setCornerWidget" not in wonderink wonderink.count("setCornerWidget(") == 1
and "setCornerWidget(self._controls" in wonderink
and "open_tab" not in controls and "QLineEdit" not in controls
and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview) and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview)
print() print()
+1 -1
View File
@@ -306,7 +306,7 @@ check "the launcher names the shell's app id" ekklesia-wonderink \
# check reads the code outside that class; anywhere else a text box in # check reads the code outside that class; anywhere else a text box in
# this window would be an address bar. # this window would be an address bar.
check "no code offers a new tab button or an address bar" 0 \ check "no code offers a new tab button or an address bar" 0 \
"$(in_lab "sed '/^class PrintPreview/,/^def notify/d' /usr/local/bin/classroom-wonderink | grep -vE '^[[:space:]]*#' | grep -cE 'QLineEdit\\(|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")" "$(in_lab "sed '/^class PrintPreview/,/^def notify/d; /setCornerWidget(self._controls/d' /usr/local/bin/classroom-wonderink | grep -vE '^[[:space:]]*#' | grep -cE 'QLineEdit\\(|setCornerWidget|addressBar|bind\\(\"Ctrl\\+T' || true")"
# Separate from the ordinary browser, which is the whole reason the split # Separate from the ordinary browser, which is the whole reason the split
# exists: a teacher's Wonder Ink sign-in and the room's shared YouTube # exists: a teacher's Wonder Ink sign-in and the room's shared YouTube
# account must never be in one profile. # account must never be in one profile.
+7
View File
@@ -100,6 +100,13 @@ for scenario in "${scenarios[@]}"; do
judge "Wonder Ink did not crash, and exited by itself" exited_cleanly judge "Wonder Ink did not crash, and exited by itself" exited_cleanly
# The preview has to SHOW the page, not merely count it. A preview
# that printed the right thing while showing a blank was what the
# first version of this check let through.
if said 'PREVIEW '; then
judge "the preview drew the page it will print" "! grep 'PREVIEW ' <<< \"\$out\" | grep -q 'shown=False'"
fi
case "$scenario" in case "$scenario" in
web) web)
judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \ judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \
+1 -1
View File
@@ -60,7 +60,7 @@ def when(cond, then, label, limit=30):
def preview(): return window._preview def preview(): return window._preview
def ready(): return preview() is not None and preview()._pdf is not None def ready(): return preview() is not None and preview()._pdf is not None
def describe(d): def describe(d):
return f"pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}" return f"shown={d._page in d._images} pages={d._pages} printers={[d._printer.itemText(i) for i in range(d._printer.count())]} paper={d.paper()} colour_offered={d._colour.isVisible()} status={d._status.text()!r}"
def finish(code=0): def finish(code=0):
stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}") stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")