mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 21: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
243 lines
7.4 KiB
Python
243 lines
7.4 KiB
Python
"""The Tk window: video on top, keep/pass bar underneath.
|
|
|
|
The only module in this package that imports ``tkinter``. Almost every callback
|
|
hands off to :mod:`python_pkg.wsg_grabber.review`, gets a
|
|
:class:`ReviewCommand` back and applies it verbatim, so the behaviour is
|
|
specified and covered without a display ever being opened.
|
|
|
|
Two decisions do live here, both because a bug proved they had to: ``_play``
|
|
skips a reload when the file is already playing, and drops an item whose file
|
|
has vanished. Everything else is wiring.
|
|
|
|
The control bar sits *below* the video rather than over it: with ``--wid`` mpv
|
|
reparents its own child window onto the frame and covers it completely, so a
|
|
widget drawn on top would be hidden.
|
|
|
|
Nothing here tracks the window size. mpv resizes itself to fill the frame it was
|
|
given, and telling it otherwise via ``geometry`` leaves the video drawn at the
|
|
wrong size -- which is exactly what happened the first time this ran.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.wsg_grabber import logs
|
|
from python_pkg.wsg_grabber._display import DisplayMixin
|
|
from python_pkg.wsg_grabber.constants import (
|
|
CONTROL_BAR_HEIGHT_PX,
|
|
KEEP_KEYS,
|
|
PASS_KEYS,
|
|
QUIT_KEYS,
|
|
UNDO_KEYS,
|
|
WINDOW_HEIGHT_PX,
|
|
WINDOW_WIDTH_PX,
|
|
)
|
|
from python_pkg.wsg_grabber.models import Verdict
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
import queue
|
|
|
|
from python_pkg.wsg_grabber.models import DownloadEvent
|
|
from python_pkg.wsg_grabber.player import Player
|
|
from python_pkg.wsg_grabber.review import SessionState
|
|
|
|
_BACKGROUND = "#1B1D21"
|
|
_ACCENT = "#B8862E"
|
|
_TEXT = "#E8E6E3"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Callbacks:
|
|
"""What the window asks the rest of the application to do."""
|
|
|
|
commit: Callable[[SessionState, Verdict], SessionState]
|
|
missing: Callable[[SessionState], SessionState]
|
|
undo: Callable[[SessionState], SessionState]
|
|
shutdown: Callable[[], None]
|
|
|
|
|
|
@dataclass
|
|
class Widgets:
|
|
"""The widgets, grouped so the window stays within the attribute limit."""
|
|
|
|
root: tk.Tk
|
|
video: tk.Frame
|
|
status: tk.Label
|
|
filename: tk.Label
|
|
keep: tk.Button
|
|
skip: tk.Button
|
|
undo: tk.Button
|
|
|
|
|
|
class ReviewWindow(DisplayMixin):
|
|
"""Presents downloaded videos one at a time for a keep/pass decision.
|
|
|
|
The screen-updating and state-transition halves live in _display to keep
|
|
this module under the 250-line cap. Widget construction stays here: this
|
|
is the only module that imports tkinter, and the tests fake the toolkit
|
|
by patching ``ui.tk``.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
state: SessionState,
|
|
events: queue.SimpleQueue[DownloadEvent],
|
|
callbacks: Callbacks,
|
|
) -> None:
|
|
"""Build the window.
|
|
|
|
The player is attached afterwards with :meth:`attach_player`, because
|
|
it needs the X11 id of a frame that must exist first.
|
|
|
|
Args:
|
|
state: Starting session state.
|
|
events: Queue the download worker publishes to.
|
|
callbacks: Hooks back into the application.
|
|
"""
|
|
self._state = state
|
|
self._events = events
|
|
self._callbacks = callbacks
|
|
self._player: Player | None = None
|
|
self._after_id: str | None = None
|
|
self._playing: Path | None = None
|
|
self._widgets = self._build()
|
|
self._bind_keys()
|
|
|
|
@property
|
|
def widgets(self) -> Widgets:
|
|
"""Return the constructed widgets.
|
|
|
|
Returns:
|
|
Widgets: The window's parts.
|
|
"""
|
|
return self._widgets
|
|
|
|
@property
|
|
def state(self) -> SessionState:
|
|
"""Return the current session state.
|
|
|
|
Returns:
|
|
SessionState: What the window is showing.
|
|
"""
|
|
return self._state
|
|
|
|
def _build(self) -> Widgets:
|
|
"""Construct the window and its widgets.
|
|
|
|
Returns:
|
|
Widgets: The constructed parts.
|
|
"""
|
|
root = tk.Tk()
|
|
root.title("/wsg/")
|
|
root.geometry(f"{WINDOW_WIDTH_PX}x{WINDOW_HEIGHT_PX}")
|
|
root.configure(bg=_BACKGROUND)
|
|
|
|
video = tk.Frame(root, bg="black")
|
|
video.pack(fill=tk.BOTH, expand=True)
|
|
|
|
controls = tk.Frame(root, bg=_BACKGROUND, height=CONTROL_BAR_HEIGHT_PX)
|
|
controls.pack(fill=tk.X, side=tk.BOTTOM)
|
|
|
|
keep = tk.Button(
|
|
controls,
|
|
text="✔ KEEP (k)",
|
|
bg=_ACCENT,
|
|
fg=_BACKGROUND,
|
|
command=self.on_keep,
|
|
)
|
|
keep.pack(side=tk.LEFT, padx=16, pady=10)
|
|
|
|
skip = tk.Button(
|
|
controls,
|
|
text="✘ PASS (j)",
|
|
bg=_BACKGROUND,
|
|
fg=_TEXT,
|
|
command=self.on_pass,
|
|
)
|
|
skip.pack(side=tk.RIGHT, padx=16, pady=10)
|
|
|
|
undo = tk.Button(
|
|
controls,
|
|
text="↶ UNDO (u)",
|
|
bg=_BACKGROUND,
|
|
fg=_TEXT,
|
|
command=self.on_undo,
|
|
)
|
|
undo.pack(side=tk.LEFT, padx=8, pady=10)
|
|
|
|
status = tk.Label(controls, text="", bg=_BACKGROUND, fg=_ACCENT)
|
|
status.pack()
|
|
filename = tk.Label(controls, text="", bg=_BACKGROUND, fg=_TEXT)
|
|
filename.pack()
|
|
|
|
root.protocol("WM_DELETE_WINDOW", self.on_quit)
|
|
return Widgets(
|
|
root=root,
|
|
video=video,
|
|
status=status,
|
|
filename=filename,
|
|
keep=keep,
|
|
skip=skip,
|
|
undo=undo,
|
|
)
|
|
|
|
def _bind_keys(self) -> None:
|
|
"""Bind the keyboard shortcuts onto the toplevel."""
|
|
for sequence in KEEP_KEYS:
|
|
self._widgets.root.bind(sequence, lambda _event: self.on_keep())
|
|
for sequence in PASS_KEYS:
|
|
self._widgets.root.bind(sequence, lambda _event: self.on_pass())
|
|
for sequence in UNDO_KEYS:
|
|
self._widgets.root.bind(sequence, lambda _event: self.on_undo())
|
|
for sequence in QUIT_KEYS:
|
|
self._widgets.root.bind(sequence, lambda _event: self.on_quit())
|
|
|
|
def video_wid(self) -> int:
|
|
"""Return the X11 id of the video frame.
|
|
|
|
Returns:
|
|
int: Window id to hand to mpv's ``--wid``.
|
|
"""
|
|
self._widgets.root.update()
|
|
return int(self._widgets.video.winfo_id())
|
|
|
|
def on_keep(self) -> None:
|
|
"""Keep the current video."""
|
|
self._verdict(Verdict.KEEP)
|
|
|
|
def on_pass(self) -> None:
|
|
"""Pass on the current video."""
|
|
self._verdict(Verdict.SKIP)
|
|
|
|
def shutdown(self) -> None:
|
|
"""Cancel the tick, stop the worker and destroy the window."""
|
|
logs.event(
|
|
"review.shutdown",
|
|
kept=self._state.kept,
|
|
passed=self._state.passed,
|
|
queued=len(self._state.pending),
|
|
)
|
|
try:
|
|
if self._after_id is not None:
|
|
self._widgets.root.after_cancel(self._after_id)
|
|
self._after_id = None
|
|
self._callbacks.shutdown()
|
|
player, self._player = self._player, None
|
|
if player is not None:
|
|
player.close()
|
|
finally:
|
|
# Whatever failed above -- a Ctrl-C landing in the worker join, an
|
|
# mpv that will not die -- the window must not be left up and
|
|
# frozen with its tick already cancelled.
|
|
self._widgets.root.destroy()
|
|
|
|
def run(self) -> None:
|
|
"""Start ticking and hand control to Tk."""
|
|
self.tick()
|
|
self._widgets.root.mainloop()
|