mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:07:03 +01:00
DisplayMixin in _display.py takes the methods that reach widgets only through self._widgets: apply, _play, _verdict, on_undo, on_quit, drain, refresh, tick and attach_player. Widget construction stays in ui.py on purpose. test_ui.py fakes the toolkit with patch.object(ui, "tk", fake), so moving _build out makes 22 tests error with "module ui does not have the attribute 'tk'". An earlier attempt did that and was reverted rather than touching the tests. Also dropped the four _key_* adapters; each only discarded Tk's event argument before forwarding, so _bind_keys binds lambdas directly now. One thing worth knowing for the next split: ruff's TC001 is NOT suppressed by __all__ the way F401 is. Moving Callbacks to its own module and re-exporting it left a TYPE_CHECKING-only import that lints clean and raises AttributeError at runtime -- cli.py constructs ui.Callbacks. Reverted; it stays defined in ui.py. ui.py 398 -> 242, _display.py 179. 277 tests pass, both files 100% covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
180 lines
6.3 KiB
Python
180 lines
6.3 KiB
Python
"""Applying a rendered command to the screen.
|
|
|
|
Split out of :mod:`python_pkg.wsg_grabber.ui` to keep it under the 250-line
|
|
cap. Note what is *not* here: creating widgets. ``ui`` is the only module that
|
|
imports ``tkinter``, and ``test_ui.py`` fakes the toolkit with
|
|
``patch.object(ui, "tk", ...)``, so construction has to stay there. These two
|
|
methods only ever reach widgets through ``self._widgets``, so they move freely.
|
|
|
|
A mixin rather than free functions because both read and write the window's
|
|
playback bookkeeping and ``apply`` is part of ReviewWindow's public surface.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.wsg_grabber import logs, review
|
|
from python_pkg.wsg_grabber.constants import POLL_INTERVAL_MS
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
import queue
|
|
|
|
from python_pkg.wsg_grabber._callbacks import Callbacks
|
|
from python_pkg.wsg_grabber.models import (
|
|
DownloadEvent,
|
|
ReviewCommand,
|
|
Verdict,
|
|
)
|
|
from python_pkg.wsg_grabber.player import Player
|
|
from python_pkg.wsg_grabber.review import SessionState
|
|
from python_pkg.wsg_grabber.ui import Widgets
|
|
|
|
|
|
class DisplayMixin(ABC):
|
|
"""The screen-updating half of :class:`~.ui.ReviewWindow`."""
|
|
|
|
_widgets: Widgets
|
|
_callbacks: Callbacks
|
|
_player: Player | None
|
|
_playing: Path | None
|
|
_state: SessionState
|
|
_after_id: str | None
|
|
_events: queue.SimpleQueue[DownloadEvent]
|
|
|
|
@abstractmethod
|
|
def shutdown(self) -> None:
|
|
"""Close the player and tear the window down."""
|
|
|
|
def apply(self, command: ReviewCommand) -> None:
|
|
"""Carry out a rendered command.
|
|
|
|
Args:
|
|
command: What to show and do.
|
|
"""
|
|
if command.play is not None:
|
|
self._play(command.play)
|
|
if command.stop and self._player is not None and self._playing is not None:
|
|
self._playing = None
|
|
self._player.stop()
|
|
self._widgets.status.configure(text=command.status)
|
|
self._widgets.filename.configure(text=command.filename)
|
|
self._widgets.root.title(command.title)
|
|
if command.verdicts_enabled:
|
|
self._widgets.keep.configure(state="normal")
|
|
self._widgets.skip.configure(state="normal")
|
|
else:
|
|
self._widgets.keep.configure(state="disabled")
|
|
self._widgets.skip.configure(state="disabled")
|
|
if command.undo_enabled:
|
|
self._widgets.undo.configure(state="normal")
|
|
else:
|
|
self._widgets.undo.configure(state="disabled")
|
|
if command.quit_app:
|
|
self.shutdown()
|
|
|
|
def _play(self, path: Path) -> None:
|
|
"""Show a video, unless it is already the one playing.
|
|
|
|
The already-playing guard is essential rather than an optimisation:
|
|
``refresh`` runs on every tick, so without it ``loadfile`` would be sent
|
|
several times a second and the video would restart continuously instead
|
|
of ever playing.
|
|
|
|
A file can also vanish between being downloaded and being reviewed.
|
|
Handing mpv a dead path would leave the reviewer stuck on a black
|
|
frame, so the item is dropped and the next one shown instead.
|
|
|
|
Args:
|
|
path: Video to play.
|
|
"""
|
|
if self._player is None:
|
|
logs.debug("review.play_skipped", reason="no player", file=path.name)
|
|
return
|
|
if path == self._playing:
|
|
logs.debug("review.play_skipped", reason="already playing", file=path.name)
|
|
return
|
|
if not path.exists():
|
|
logs.warning("review.file_missing", file=path.name, path=str(path))
|
|
self._state = self._callbacks.missing(self._state)
|
|
self.refresh()
|
|
return
|
|
# Recorded BEFORE the new load, so a frozen-looking session shows what
|
|
# mpv was doing with the file it was supposedly already playing.
|
|
logs.event(
|
|
"review.show",
|
|
file=path.name,
|
|
previous=self._playing.name if self._playing else None,
|
|
player_alive=self._player.is_alive(),
|
|
mpv_before=self._player.probe(),
|
|
)
|
|
self._playing = path
|
|
self._player.play(path)
|
|
|
|
def _verdict(self, choice: Verdict) -> None:
|
|
"""Apply a verdict and redraw.
|
|
|
|
Args:
|
|
choice: What the user decided.
|
|
"""
|
|
before = self._state.current
|
|
self._state = self._callbacks.commit(self._state, choice)
|
|
after = self._state.current
|
|
logs.event(
|
|
"review.verdict",
|
|
choice=choice.value,
|
|
on=before.orig_name if before else None,
|
|
on_file=before.path.name if before else None,
|
|
next_file=after.path.name if after else None,
|
|
queued=len(self._state.pending),
|
|
kept=self._state.kept,
|
|
passed=self._state.passed,
|
|
)
|
|
self.refresh()
|
|
|
|
def on_undo(self) -> None:
|
|
"""Take back the last verdict and show that video again."""
|
|
before = self._state.current
|
|
self._state = self._callbacks.undo(self._state)
|
|
after = self._state.current
|
|
logs.event(
|
|
"review.undo",
|
|
was_showing=before.orig_name if before else None,
|
|
now_showing=after.orig_name if after else None,
|
|
kept=self._state.kept,
|
|
passed=self._state.passed,
|
|
remaining_undos=self._state.undoable,
|
|
)
|
|
self.refresh()
|
|
|
|
def on_quit(self) -> None:
|
|
"""Begin shutting down."""
|
|
self._state = review.on_quit(self._state)
|
|
self.refresh()
|
|
|
|
def tick(self) -> None:
|
|
"""Drain the worker's events, redraw, and schedule the next tick."""
|
|
self.drain()
|
|
self.refresh()
|
|
self._after_id = self._widgets.root.after(POLL_INTERVAL_MS, self.tick)
|
|
|
|
def drain(self) -> None:
|
|
"""Fold every queued worker event into the session state."""
|
|
while not self._events.empty():
|
|
self._state = review.on_event(self._state, self._events.get_nowait())
|
|
|
|
def refresh(self) -> None:
|
|
"""Re-render from the current state."""
|
|
self.apply(review.render(self._state))
|
|
|
|
def attach_player(self, player: Player) -> None:
|
|
"""Adopt a player and show the first video.
|
|
|
|
Args:
|
|
player: The video player to drive.
|
|
"""
|
|
self._player = player
|
|
self.refresh()
|