Skip to content
Merged
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
103 changes: 98 additions & 5 deletions src/scripto/gui/viewmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@

from __future__ import annotations

import queue as queue_mod
import threading
import time
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Callable

Expand Down Expand Up @@ -70,6 +71,23 @@ def deleted(self) -> bool:
return not self.existing


@dataclass
class TranslationJob:
"""One queued history translation; mutated by the worker, read by the UI."""
source: str
name: str
srt_path: str
target: str
status: str = "queued" # queued | running | done | failed
done: int = 0 # translated blocks so far
total: int = 0
error: str = ""

@property
def fraction(self) -> float:
return self.done / self.total if self.total else 0.0


@dataclass
class DrainResult:
changed_rows: list[int] = field(default_factory=list)
Expand Down Expand Up @@ -104,6 +122,12 @@ def __init__(
self._durations: list[float] = []
self._active_since: float | None = None

# History-translation queue: one worker, jobs survive any dialog.
self.translation_jobs: list[TranslationJob] = []
self._translation_q: "queue_mod.Queue[TranslationJob]" = queue_mod.Queue()
self._translation_worker: threading.Thread | None = None
self._translation_lock = threading.Lock()

# ------------------------------------------------------------------ #
# Input / scanning (call from a background thread; never the UI thread)
# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -377,6 +401,15 @@ def translate_history(self, group: "HistoryGroup", target: str) -> list[Path]:
the view runs this on a worker thread); records a history entry."""
if not group.translate_from:
return []
return self._run_translation(
srt_path=group.translate_from, source=group.source,
target=target, progress=None,
)

def _run_translation(
self, *, srt_path: str, source: str, target: str,
progress: Callable[[int, int], None] | None,
) -> list[Path]:
config = self.get_config()
client = OllamaClient(config["ollama_url"])
stage = OllamaTranslateStage(
Expand All @@ -389,14 +422,17 @@ def translate_history(self, group: "HistoryGroup", target: str) -> list[Path]:
)
try:
produced = stage.translate(
Path(group.translate_from), Path(group.source),
stop_check=None, progress=None,
Path(srt_path), Path(source),
stop_check=None, progress=progress,
)
finally:
stage.release()
# Keep the model loaded while more jobs wait; keep_alive handles
# eviction once the queue actually drains.
if self._translation_q.empty():
stage.release()
if produced:
self.history.append(HistoryEntry(
source=group.source,
source=source,
outputs=[
{"lang": target, "format": p.suffix.lstrip("."), "path": str(p)}
for p in produced
Expand All @@ -407,6 +443,63 @@ def translate_history(self, group: "HistoryGroup", target: str) -> list[Path]:
))
return produced

# ------------------------------------------------------------------ #
# Translation queue: batch/single history translations with status
# ------------------------------------------------------------------ #

def queue_translation(self, group: "HistoryGroup", target: str) -> bool:
"""Enqueue a history translation; False when duplicate or invalid."""
if not group.translate_from or target in group.existing:
return False
with self._translation_lock:
for job in self.translation_jobs:
if (job.source == group.source and job.target == target
and job.status in ("queued", "running")):
return False
job = TranslationJob(
source=group.source, name=group.name,
srt_path=group.translate_from, target=target,
)
self.translation_jobs.append(job)
self._translation_q.put(job)
self._ensure_translation_worker()
return True

def translation_snapshot(self) -> list[TranslationJob]:
"""Point-in-time copies for the UI (jobs mutate on the worker)."""
with self._translation_lock:
return [replace(job) for job in self.translation_jobs]

def _ensure_translation_worker(self) -> None:
if self._translation_worker is not None and self._translation_worker.is_alive():
return
self._translation_worker = threading.Thread(
target=self._translation_loop, name="scripto-translate-q", daemon=True
)
self._translation_worker.start()

def _translation_loop(self) -> None:
while True:
job = self._translation_q.get()
job.status = "running"

def progress(done: int, total: int, job: TranslationJob = job) -> None:
job.done, job.total = done, total

try:
produced = self._run_translation(
srt_path=job.srt_path, source=job.source,
target=job.target, progress=progress,
)
if produced:
job.status = "done"
else:
job.status = "failed"
job.error = "no output"
except Exception as exc: # surfaced per-job in the UI
job.status = "failed"
job.error = str(exc)

def history_clean_missing(self) -> int:
stale = {e.id for e, exists in self.history_rows() if not exists}
return self.history.remove(stale) if stale else 0
Expand Down
161 changes: 122 additions & 39 deletions src/scripto/gui_qt/history_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@
QDialog,
QHBoxLayout,
QLabel,
QMenu,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QTextBrowser,
QVBoxLayout,
QWidget,
)

from ..core.languages import known_languages

_MS_RE = re.compile(r"[,.]\d{3}")

from .widgets import ElidedLabel, card, clear_layout, reveal_in_file_manager, subtext
Expand All @@ -45,13 +49,42 @@ def _build(self) -> None:
self.delete_selected_btn.clicked.connect(self._delete_selected)
self.delete_selected_btn.hide()

self.translate_selected_btn = QPushButton()
self.translate_selected_btn.setProperty("variant", "primary")
translate_menu = QMenu(self.translate_selected_btn)
for spec in known_languages():
translate_menu.addAction(
self.window_ref.lang_label(spec.code),
lambda code=spec.code: self._translate_selected(code),
)
self.translate_selected_btn.setMenu(translate_menu)
self.translate_selected_btn.hide()

top = QHBoxLayout()
top.setSpacing(8)
top.addWidget(refresh_btn)
top.addWidget(clean_btn)
top.addWidget(self.translate_selected_btn)
top.addWidget(self.delete_selected_btn)
top.addStretch(1)

# Translation-queue strip: visible whenever jobs are queued/running,
# regardless of any dialog being open.
self.tq_label = subtext()
self.tq_bar = QProgressBar()
self.tq_bar.setRange(0, 1000)
self.tq_bar.setTextVisible(False)
self.tq_bar.setFixedWidth(160)
self.tq_strip = QWidget()
strip = QHBoxLayout(self.tq_strip)
strip.setContentsMargins(0, 0, 0, 0)
strip.setSpacing(8)
strip.addWidget(self.tq_bar)
strip.addWidget(self.tq_label, 1)
self.tq_strip.hide()
self._badges: dict[str, QLabel] = {}
self._seen_terminal = 0

self.list_box = QVBoxLayout()
self.list_box.setSpacing(6)
self.list_box.addStretch(1)
Expand All @@ -65,6 +98,7 @@ def _build(self) -> None:
root.setContentsMargins(16, 12, 16, 12)
root.setSpacing(10)
root.addLayout(top)
root.addWidget(self.tq_strip)
root.addWidget(scroll, 1)

# ------------------------------------------------------------------ #
Expand All @@ -73,6 +107,7 @@ def refresh(self) -> None:
t = self.t
clear_layout(self.list_box, keep_tail=1)
self._selected.clear()
self._badges.clear()
self._sync_delete_button()

groups = self.vm.history_groups()
Expand Down Expand Up @@ -108,6 +143,14 @@ def refresh(self) -> None:
text_col.addWidget(sub)
row.addLayout(text_col, 1)

badge = subtext()
badge.setStyleSheet(
f"color: {self.window_ref.palette_tokens.accent}; font-size: 11px;"
)
badge.hide()
row.addWidget(badge)
self._badges[group.source] = badge

if group.deleted:
deleted = subtext(t("gui.history_deleted"))
deleted.setStyleSheet(
Expand Down Expand Up @@ -156,10 +199,83 @@ def _toggle_selected(self, source: str, on: bool) -> None:
def _sync_delete_button(self) -> None:
count = len(self._selected)
self.delete_selected_btn.setVisible(count > 0)
self.translate_selected_btn.setVisible(count > 0)
if count:
self.delete_selected_btn.setText(
self.t("gui.history_delete_selected", n=count)
)
self.translate_selected_btn.setText(
self.t("gui.translate_selected", n=count)
)

def _translate_selected(self, target: str) -> None:
queued = 0
for group in self.vm.history_groups():
if group.source in self._selected:
if self.vm.queue_translation(group, target):
queued += 1
if queued:
self.window_ref.toast(self.t("gui.tq_enqueued"))
else:
self.window_ref.toast(self.t("gui.tq_nothing"), ok=False)

# ------------------------------------------------------------------ #
# Queue status (driven by MainWindow's 4 Hz ticker)
# ------------------------------------------------------------------ #

def tick_translations(self) -> None:
jobs = self.vm.translation_snapshot()
if not jobs and not self.tq_strip.isVisible():
return

running = [j for j in jobs if j.status == "running"]
queued = [j for j in jobs if j.status == "queued"]
if running or queued:
active = running[0] if running else queued[0]
text = self.t(
"gui.tq_running", name=active.name,
lang=self.window_ref.lang_label(active.target),
pct=int(active.fraction * 100),
)
if queued:
text += self.t("gui.tq_queued_n", n=len(queued))
self.tq_label.setText(text)
self.tq_bar.setValue(int(active.fraction * 1000))
self.tq_strip.show()
else:
self.tq_strip.hide()

by_source: dict[str, str] = {}
for job in running + queued:
if job.source not in by_source:
key = ("gui.tq_badge_running" if job.status == "running"
else "gui.tq_badge_queued")
by_source[job.source] = self.t(
key, lang=self.window_ref.lang_label(job.target),
pct=int(job.fraction * 100),
)
for source, badge in self._badges.items():
text = by_source.get(source, "")
badge.setText(text)
badge.setVisible(bool(text))

# Newly finished jobs: toast once each, refresh the list once.
terminal = [j for j in jobs if j.status in ("done", "failed")]
if len(terminal) > self._seen_terminal:
for job in terminal[self._seen_terminal:]:
lang = self.window_ref.lang_label(job.target)
if job.status == "done":
self.window_ref.toast(
self.t("gui.tq_done", name=job.name, lang=lang)
)
else:
self.window_ref.toast(
self.t("gui.tq_failed", name=job.name, lang=lang,
reason=job.error),
ok=False,
)
self._seen_terminal = len(terminal)
self.refresh()

def _delete_selected(self) -> None:
self._delete_sources(set(self._selected))
Expand Down Expand Up @@ -376,42 +492,9 @@ def _render(self, content: str, path: str) -> None:
self.body.setHtml("".join(parts) or escape(content))

def _translate(self, lang: str) -> None:
if self.busy:
return
self.busy = True
self.status_label.setText(
self.t("gui.history_translating",
lang=self.page.window_ref.lang_label(lang))
)
self._rebuild_buttons()
window = self.page.window_ref

def job() -> None:
status_text = ""
try:
produced = self.vm.translate_history(self.group, lang)
if produced:
status_text = self.t("gui.history_translate_done")
self.group.existing[lang] = str(produced[0])
if lang in self.group.missing:
self.group.missing.remove(lang)
self.lang = lang
else:
status_text = self.t("gui.models_failed", reason="no output")
except Exception as exc:
status_text = self.t("gui.models_failed", reason=exc)

def apply() -> None:
self.busy = False
self.status_label.setText(status_text)
if self.lang in self.group.existing:
path = self.group.existing[self.lang]
try:
self._render(self.vm.read_preview(path), path)
except Exception:
pass
self._rebuild_buttons()

window.run_in_main(apply)

window.run_thread(job)
# Enqueued, not run here: the job survives closing this dialog, and
# its progress lives on the history page (strip + card badge).
if self.vm.queue_translation(self.group, lang):
self.status_label.setText(self.t("gui.tq_enqueued"))
else:
self.status_label.setText(self.t("gui.tq_nothing"))
2 changes: 2 additions & 0 deletions src/scripto/gui_qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ def _tick(self) -> None:
self._last_log_len = len(result.log_lines)
self.run_page.refresh_log(result.log_lines)

self.history_page.tick_translations()

# ------------------------------------------------------------------ #
# Lifecycle
# ------------------------------------------------------------------ #
Expand Down
Loading
Loading