diff --git a/docs/manual/50-wonderink.md b/docs/manual/50-wonderink.md index 408b996..8e930f5 100644 --- a/docs/manual/50-wonderink.md +++ b/docs/manual/50-wonderink.md @@ -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 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 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 computer, so they never get mixed up with anything. diff --git a/docs/screenshots/ui-one-tab-badge.png b/docs/screenshots/ui-one-tab-badge.png new file mode 100644 index 0000000..5a7a17e Binary files /dev/null and b/docs/screenshots/ui-one-tab-badge.png differ diff --git a/docs/screenshots/ui-print-preview-worksheet.png b/docs/screenshots/ui-print-preview-worksheet.png new file mode 100644 index 0000000..7ec7dd7 Binary files /dev/null and b/docs/screenshots/ui-print-preview-worksheet.png differ diff --git a/docs/screenshots/ui-print-preview.png b/docs/screenshots/ui-print-preview.png new file mode 100644 index 0000000..ead252c Binary files /dev/null and b/docs/screenshots/ui-print-preview.png differ diff --git a/docs/screenshots/ui-three-tabs.png b/docs/screenshots/ui-three-tabs.png new file mode 100644 index 0000000..fa775e9 Binary files /dev/null and b/docs/screenshots/ui-three-tabs.png differ diff --git a/lib/classroom_printing.py b/lib/classroom_printing.py index d2c5b7c..11f497e 100644 --- a/lib/classroom_printing.py +++ b/lib/classroom_printing.py @@ -122,24 +122,40 @@ def page_count(pdf): def render_page(pdf, number, width): - """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 + """PNG bytes of one page, WIDTH pixels wide, or None. - if result.returncode != 0 or not result.stdout: - return None + Rendered into a directory of its own and read back. Measured on + 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): diff --git a/scripts/classroom-wonderink b/scripts/classroom-wonderink index f811494..701be5e 100755 --- a/scripts/classroom-wonderink +++ b/scripts/classroom-wonderink @@ -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) diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh index fcaff2c..53463a9 100755 --- a/tests/test-deployment-support.sh +++ b/tests/test-deployment-support.sh @@ -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 # else a text box in this window is an address bar. 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", - "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) print() diff --git a/tools/lab/converged-check b/tools/lab/converged-check index 1ed689d..9ba16de 100755 --- a/tools/lab/converged-check +++ b/tools/lab/converged-check @@ -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 # this window would be an address bar. 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 # exists: a teacher's Wonder Ink sign-in and the room's shared YouTube # account must never be in one profile. diff --git a/tools/lab/wonderink-print-check b/tools/lab/wonderink-print-check index 345734a..d163c88 100755 --- a/tools/lab/wonderink-print-check +++ b/tools/lab/wonderink-print-check @@ -100,6 +100,13 @@ for scenario in "${scenarios[@]}"; do 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 web) judge "the preview showed the lesson, 3 Letter pages, Elmer chosen" \ diff --git a/tools/lab/wonderink-print-rig.py b/tools/lab/wonderink-print-rig.py index 9ba93b3..1e21a68 100644 --- a/tools/lab/wonderink-print-rig.py +++ b/tools/lab/wonderink-print-rig.py @@ -60,7 +60,7 @@ def when(cond, then, label, limit=30): def preview(): return window._preview def ready(): return preview() is not None and preview()._pdf is not None 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): stamp(f"RESULT notes={notes} preview_open={preview() is not None} tabs={window._tabs.count()}")