From 9e119c01bd1dbe3cb284af7554d6e634090da0f4 Mon Sep 17 00:00:00 2001 From: prekabreki Date: Sun, 19 Jul 2026 00:40:44 +0200 Subject: [PATCH] feat(gui): Library empty/no-results state + clearer preview play affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two GUI audit findings on the Library view: - #121 (H6): the QTableView showed a bare blank grid with no guidance when empty. Add a viewport overlay driven by a Qt-free empty_state_message(total, visible) helper: total==0 -> "No catalog yet - run Scan on the Pipeline tab"; total>0 & visible==0 -> "No lines match - clear filters"; otherwise nothing. ASCII hyphens throughout. - #109 (M10): the tiny "▷" preview glyph read as a tree-expand triangle. Use a filled ▶ for playable rows and a hollow ▷ for unavailable ones (shape now carries availability, not just color), render it enlarged via a FontRole, give the column a roomier fixed width, and show a pointing-hand cursor while hovering a playable cell. Click behavior is unchanged (unavailable = no-op). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/deciwaves/gui/library_model.py | 19 +++++++ src/deciwaves/gui/views/library.py | 87 +++++++++++++++++++++++++++--- tests/gui/test_library_model.py | 27 ++++++++++ tests/gui/test_library_view.py | 76 +++++++++++++++++++++++++- 4 files changed, 201 insertions(+), 8 deletions(-) diff --git a/src/deciwaves/gui/library_model.py b/src/deciwaves/gui/library_model.py index 5d472e1..780db1a 100644 --- a/src/deciwaves/gui/library_model.py +++ b/src/deciwaves/gui/library_model.py @@ -435,3 +435,22 @@ def preview_unavailable_tooltip(game: str, *, bind_done: bool) -> str: if game == "fw": return "No audio for this line" return "Preview unavailable" + + +# --- empty / no-results overlay (#121, audit H6) --------------------------- + +def empty_state_message(total: int, visible: int) -> str | None: + """The message the Library should overlay on an otherwise-blank grid, or ``None`` when + there are rows to show (audit H6, #121). Two distinct states are worth different guidance: + + - ``total == 0`` -- nothing has been loaded at all (no catalog artifact yet). + - ``visible == 0`` (with ``total > 0``) -- there is a catalog, but the current filters + exclude every row. + + ASCII hyphens only in the returned text (no em-dashes), per the repo's user-facing + string convention.""" + if total == 0: + return "No catalog yet - run Scan on the Pipeline tab" + if visible == 0: + return "No lines match - clear filters" + return None diff --git a/src/deciwaves/gui/views/library.py b/src/deciwaves/gui/views/library.py index 0d3e061..5d10dc8 100644 --- a/src/deciwaves/gui/views/library.py +++ b/src/deciwaves/gui/views/library.py @@ -7,7 +7,7 @@ from __future__ import annotations from PySide6.QtCore import QAbstractTableModel, QEvent, QModelIndex, Qt, Signal -from PySide6.QtGui import QColor +from PySide6.QtGui import QColor, QFont from PySide6.QtWidgets import ( QAbstractItemView, QCheckBox, @@ -31,6 +31,7 @@ check_all, check_none, distinct_speakers, + empty_state_message, has_known_lengths, is_bind_done, load_lines, @@ -43,17 +44,24 @@ visible_rows, ) -# Gray foreground for a pending/unavailable ▷ (spec §6.2/§6.5). A value type -- safe to build -# at import time without a running QApplication. +# Gray foreground for a pending/unavailable preview glyph (spec §6.2/§6.5). A value type -- +# safe to build at import time without a running QApplication. _PREVIEW_PENDING_FG = QColor(0x88, 0x88, 0x88) +# Preview affordance glyphs (#109, audit M10): a *filled* triangle reads as a play button, a +# hollow one as a disabled/pending control -- neither reads as a tree-expand arrow the way the +# old bare "▷" did. Rendered enlarged (see LibraryView._preview_font) with a pointing-hand +# cursor over playable rows. +_PREVIEW_GLYPH_PLAYABLE = "▶" +_PREVIEW_GLYPH_UNAVAILABLE = "▷" + class _TableModel(QAbstractTableModel): """Wraps the current filtered+sorted ``LineRow`` slice. Check state is read from the view's unchecked set (checked is the default), so a bulk selection command only needs a ``dataChanged`` over the checkbox column -- never a full model rebuild.""" - COLS = ["▷", "✓", "id / name", "length", "speaker", "subtitle"] + COLS = ["▶", "✓", "id / name", "length", "speaker", "subtitle"] COL_PREVIEW, COL_CHECK, COL_ID, COL_LEN, COL_SPEAKER, COL_SUB = range(6) def __init__(self, view: LibraryView): @@ -102,9 +110,19 @@ def data(self, index, role=Qt.DisplayRole): if role == Qt.CheckStateRole and col == self.COL_CHECK: checked = row.line_id not in self._view._unchecked return Qt.Checked if checked else Qt.Unchecked + # Render the preview glyph enlarged so it reads as a play control, not a tiny + # tree-expand triangle (#109). The font is built once on the view. + if role == Qt.FontRole and col == self.COL_PREVIEW: + return self._view._preview_font + if role == Qt.TextAlignmentRole and col == self.COL_PREVIEW: + return int(Qt.AlignCenter) if role == Qt.DisplayRole: if col == self.COL_PREVIEW: - return "▷" + # Filled ▶ for a playable row, hollow ▷ for one that can't play yet (#109): + # the shape itself now carries availability, not just the (subtler) color. + return (_PREVIEW_GLYPH_PLAYABLE + if self._view._available.get(row.line_id, False) + else _PREVIEW_GLYPH_UNAVAILABLE) if col == self.COL_ID: return row.name or row.line_id if col == self.COL_LEN: @@ -197,6 +215,12 @@ def __init__(self, parent=None): selection.addWidget(self._undo_btn) selection.addStretch(1) + # Enlarged font for the ▶/▷ preview glyph (#109): built here, where a QApplication is + # guaranteed to exist, so it inherits the app default and just bumps the point size. + self._preview_font = QFont() + base_pt = self._preview_font.pointSize() + self._preview_font.setPointSize(base_pt + 5 if base_pt > 0 else 14) + # --- table --- self._model = _TableModel(self) self._table = QTableView() @@ -206,9 +230,25 @@ def __init__(self, parent=None): self._table.setSortingEnabled(False) # we sort the model ourselves (None-last) header = self._table.horizontalHeader() header.setSectionsClickable(True) + header.setSectionResizeMode(_TableModel.COL_PREVIEW, QHeaderView.Fixed) + self._table.setColumnWidth(_TableModel.COL_PREVIEW, 44) # roomy click target (#109) header.setSectionResizeMode(_TableModel.COL_SUB, QHeaderView.Stretch) header.sectionClicked.connect(self._on_header_clicked) + # Empty / no-results overlay (#121): a centered message floated over the viewport when + # the grid would otherwise be blank -- distinguishes "no catalog yet" from "filters hid + # everything". Parented to the viewport so it paints above the (empty) grid. + self._empty_overlay = QLabel("", self._table.viewport()) + self._empty_overlay.setAlignment(Qt.AlignCenter) + self._empty_overlay.setWordWrap(True) + self._empty_overlay.setStyleSheet("color: #888888;") + self._empty_overlay.hide() + + # Pointing-hand cursor over a playable ▶ (#109): needs per-pixel mouse tracking so the + # cursor updates on hover, not only on click. + self._table.viewport().setMouseTracking(True) + self._table.viewport().installEventFilter(self) + self._status = QLabel("") # Export panel (#72, spec §8): operates on the checked rows. The shell connects its @@ -299,6 +339,18 @@ def _apply_filters(self) -> None: self._sort_key, self._sort_desc) self._model.set_rows(self._visible) self._update_status() + self._update_overlay() + + def _update_overlay(self) -> None: + """Show/hide the empty-state message (#121) for the current row counts. The message + choice is the Qt-free ``empty_state_message`` -- here we only place and toggle it.""" + msg = empty_state_message(self.total_count(), self.visible_count()) + if msg is None: + self._empty_overlay.hide() + return + self._empty_overlay.setText(msg) + self._empty_overlay.resize(self._table.viewport().size()) + self._empty_overlay.show() def _on_header_clicked(self, section: int) -> None: key = self._SORT_KEYS.get(section) @@ -361,8 +413,9 @@ def _on_cell_clicked(self, index) -> None: def eventFilter(self, obj, event): """Keyboard on the table (spec §6.5): Enter/Return previews the current row (same - availability gate as clicking ▷); Space toggles the current row's checkbox from any - column, not just the check column.""" + availability gate as clicking ▶); Space toggles the current row's checkbox from any + column, not just the check column. On the viewport we also keep the empty-state overlay + sized to it (#121) and show a pointing-hand cursor while hovering a playable ▶ (#109).""" if obj is self._table and event.type() == QEvent.KeyPress: key = event.key() if key in (Qt.Key_Return, Qt.Key_Enter): @@ -371,8 +424,28 @@ def eventFilter(self, obj, event): if key == Qt.Key_Space: self._toggle_current_row_check() return True + elif obj is self._table.viewport(): + if event.type() == QEvent.Resize: + self._empty_overlay.resize(event.size()) + elif event.type() == QEvent.MouseMove: + self._update_preview_cursor(event.position().toPoint()) return super().eventFilter(obj, event) + def _update_preview_cursor(self, pos) -> None: + """Pointing-hand cursor while hovering a *playable* preview cell, the arrow otherwise + (#109) -- makes the ▶ read as a clickable control. An unavailable ▷ keeps the arrow, + matching its no-op click.""" + idx = self._table.indexAt(pos) + playable = ( + idx.isValid() + and idx.column() == _TableModel.COL_PREVIEW + and self._available.get(self._model.row_at(idx.row()).line_id, False) + ) + if playable: + self._table.viewport().setCursor(Qt.PointingHandCursor) + else: + self._table.viewport().unsetCursor() + def _current_row(self) -> LineRow | None: idx = self._table.currentIndex() return self._model.row_at(idx.row()) if idx.isValid() else None diff --git a/tests/gui/test_library_model.py b/tests/gui/test_library_model.py index ea2d2c6..6554e84 100644 --- a/tests/gui/test_library_model.py +++ b/tests/gui/test_library_model.py @@ -16,6 +16,7 @@ check_all, check_none, distinct_speakers, + empty_state_message, has_known_lengths, load_lines, load_selection, @@ -437,3 +438,29 @@ def test_availability_by_id_lookup_is_cheap_and_per_row(): assert availability_by_id(rows, "hzd", bind_done=True) == {"a": True, "b": True} # DS: always available assert availability_by_id(rows, "ds", bind_done=False) == {"a": True, "b": True} + + +# --- empty / no-results overlay (#121) ------------------------------------- + +def test_empty_state_message_no_catalog(): + """No rows loaded at all -> guidance to run Scan (audit H6, #121).""" + assert empty_state_message(0, 0) == "No catalog yet - run Scan on the Pipeline tab" + + +def test_empty_state_message_filters_hide_everything(): + """A non-empty catalog whose filters exclude every row -> a *different* message.""" + assert empty_state_message(5, 0) == "No lines match - clear filters" + + +def test_empty_state_message_none_when_rows_visible(): + """Rows to show -> no overlay (the grid speaks for itself).""" + assert empty_state_message(5, 3) is None + assert empty_state_message(5, 5) is None + + +def test_empty_state_message_uses_ascii_hyphens(): + """User-facing strings use ASCII hyphens, never em-dashes (repo convention).""" + for msg in (empty_state_message(0, 0), empty_state_message(3, 0)): + assert msg is not None + assert "—" not in msg and "–" not in msg + assert " - " in msg diff --git a/tests/gui/test_library_view.py b/tests/gui/test_library_view.py index 75468bd..8cf29bf 100644 --- a/tests/gui/test_library_view.py +++ b/tests/gui/test_library_view.py @@ -10,7 +10,7 @@ pytest.importorskip("PySide6") from PySide6.QtCore import QEvent, Qt # noqa: E402 -from PySide6.QtGui import QKeyEvent # noqa: E402 +from PySide6.QtGui import QFont, QKeyEvent # noqa: E402 from PySide6.QtWidgets import QApplication # noqa: E402 from deciwaves.gui.library_model import load_selection # noqa: E402 @@ -257,6 +257,80 @@ def test_preview_column_availability_ds_and_fw_available(qtbot, tmp_path): assert v._model.data(idx, Qt.ToolTipRole) is None +def test_empty_overlay_shows_no_catalog_when_total_zero(qtbot, tmp_path): + """No artifact on disk -> the table isn't a blank grid; it carries the 'run Scan' + guidance (#121, audit H6).""" + ws = str(tmp_path) # no catalog written + v = LibraryView() + qtbot.addWidget(v) + v.refresh("ds", ws) + assert v.total_count() == 0 + assert v._empty_overlay.isVisibleTo(v) is True + assert v._empty_overlay.text() == "No catalog yet - run Scan on the Pipeline tab" + + +def test_empty_overlay_shows_no_results_when_filters_hide_everything(qtbot, tmp_path): + """A loaded catalog whose filters exclude every row shows the *no-results* message, which + is distinct from the *no-catalog* message (#121).""" + ws = str(tmp_path) + _write_ds_catalog(ws, [_cat_row(line_id="a", subtitle_en="hello")]) + v = LibraryView() + qtbot.addWidget(v) + v.refresh("ds", ws) + v._search.setText("no-such-line") + assert v.total_count() == 1 and v.visible_count() == 0 + assert v._empty_overlay.isVisibleTo(v) is True + assert v._empty_overlay.text() == "No lines match - clear filters" + + +def test_empty_overlay_hidden_when_rows_visible(qtbot, tmp_path): + """Rows to show -> the overlay stays hidden and the grid speaks for itself (#121).""" + ws = str(tmp_path) + _write_ds_catalog(ws, [_cat_row(line_id="a")]) + v = LibraryView() + qtbot.addWidget(v) + v.refresh("ds", ws) + assert v.visible_count() == 1 + assert v._empty_overlay.isVisibleTo(v) is False + + +def test_preview_glyph_filled_for_playable_hollow_for_unavailable(qtbot, tmp_path): + """The ▶ affordance reads as a play control: a *filled* triangle on a playable row (DS is + always playable) and a *hollow* one on an unavailable row (HZD pre-bind) -- shape, not just + color, now carries availability (#109, audit M10).""" + ws = str(tmp_path) + _write_ds_catalog(ws, [_cat_row(line_id="a")]) + _write_csv(os.path.join(ws, "out", "hzd", "catalog.csv"), DS_CAT, + [_cat_row(line_id="h1", wem_path_en="")]) + + v = LibraryView() + qtbot.addWidget(v) + v.refresh("ds", ws) # DS: available + ds_glyph = v._model.data(v._model.index(0, v._model.COL_PREVIEW), Qt.DisplayRole) + assert ds_glyph == "▶" + + v.refresh("hzd", ws) # HZD pre-bind: unavailable + hzd_glyph = v._model.data(v._model.index(0, v._model.COL_PREVIEW), Qt.DisplayRole) + assert hzd_glyph == "▷" + assert ds_glyph != hzd_glyph + + +def test_preview_glyph_rendered_enlarged(qtbot, tmp_path): + """The preview cell asks for an enlarged font so it doesn't read as a tiny tree triangle + (#109). We assert the model returns a bigger point size for the preview column than the + view's default -- pragmatic proxy for 'rendered larger'.""" + ws = str(tmp_path) + _write_ds_catalog(ws, [_cat_row(line_id="a")]) + v = LibraryView() + qtbot.addWidget(v) + v.refresh("ds", ws) + font = v._model.data(v._model.index(0, v._model.COL_PREVIEW), Qt.FontRole) + assert font is not None + assert font.pointSize() > QFont().pointSize() # bigger than the app-default glyph size + # a non-preview column carries no font override + assert v._model.data(v._model.index(0, v._model.COL_ID), Qt.FontRole) is None + + def test_filter_state_resets_on_game_change_but_persists_same_game(qtbot, tmp_path): """Switching games drops the prior game's stray search/sort/toggles (spec §6 -- the list is per-game); a same-game refresh (job-finished) preserves all filter/sort state."""