Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/deciwaves/gui/library_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
87 changes: 80 additions & 7 deletions src/deciwaves/gui/views/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +31,7 @@
check_all,
check_none,
distinct_speakers,
empty_state_message,
has_known_lengths,
is_bind_done,
load_lines,
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/gui/test_library_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
check_all,
check_none,
distinct_speakers,
empty_state_message,
has_known_lengths,
load_lines,
load_selection,
Expand Down Expand Up @@ -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
76 changes: 75 additions & 1 deletion tests/gui/test_library_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
Loading