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
+204 -7
View File
@@ -175,6 +175,7 @@ from PyQt6.QtCore import ( # noqa: E402
from PyQt6.QtGui import ( # noqa: E402
QAction,
QGuiApplication,
QIcon,
QKeySequence,
QPageLayout,
QPageSize,
@@ -202,6 +203,7 @@ from PyQt6.QtWidgets import ( # noqa: E402
QSizePolicy,
QSpinBox,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -423,18 +425,36 @@ class WonderInk(QMainWindow):
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)
# 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.tabBar().setExpanding(False)
self._tabs.tabBar().setDrawBase(False)
self._tabs.tabCloseRequested.connect(self.close_tab)
self._tabs.currentChanged.connect(self._current_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 ----------------------------------------------------------
@@ -508,7 +528,11 @@ class WonderInk(QMainWindow):
# 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)
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)
@@ -528,11 +552,16 @@ class WonderInk(QMainWindow):
self._tabs.setTabToolTip(index, title)
if index == self._tabs.currentIndex():
self.setWindowTitle(title)
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)
@@ -540,9 +569,19 @@ class WonderInk(QMainWindow):
view = self._tabs.widget(index)
if view is not None:
self.setWindowTitle(view.title() or "Wonder Ink")
self._name_window(self._tabs.tabToolTip(index))
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):
return self._tabs.currentWidget()
@@ -820,6 +859,7 @@ class WonderInk(QMainWindow):
# 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())
view.load(QUrl.fromLocalFile(str(resolved)))
return view
@@ -952,7 +992,7 @@ class WonderInk(QMainWindow):
return
self._fullscreen_view = self.sender()
self._tabs.tabBar().setVisible(False)
self._set_strip(False)
self.showFullScreen()
return
@@ -987,6 +1027,155 @@ class WonderInk(QMainWindow):
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:
"""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):
super().__init__(window)
self.setObjectName("preview")
self.setWindowTitle(f"Print — {title}")
self.setModal(True)
@@ -1090,6 +1280,8 @@ class PrintPreview(QDialog):
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))
@@ -1128,10 +1320,12 @@ class PrintPreview(QDialog):
form.addRow(self._colour_label, self._colour)
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)
@@ -1264,7 +1458,9 @@ class PrintPreview(QDialog):
def _say(self, message, error=False):
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):
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
# StartupWMClass in ekklesia-wonderink.desktop exactly.
app.setDesktopFileName("ekklesia-wonderink")
app.setWindowIcon(QIcon.fromTheme("ekklesia-wonderink"))
profile = build_profile(app)