diff --git a/BACKLOG.md b/BACKLOG.md index f33e3e1..7810013 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -226,6 +226,8 @@ Two items jump the queue by owner decision. Full reasoning in `decisions.md`; th - **2026-09-06, found by reading and fixed, not proven to be the cause.** Every one of those five setters wrote its answer unconditionally, so a command sent against the file that was open could still be in flight when the next file opened and then set the error state about a document nobody had asked about. `useVideoPlayer` now stamps each command with the open it belongs to and drops an answer carrying an older one. It is a real race and the fix is right on its own terms; whether it is what this check keeps catching is only knowable from CI, because it has never reproduced here. Two attempts to reproduce it by taking the audio output away, which is the one difference this machine and the runner are known to have, did not: mpv found an output both times and the spec passed. - **A second check joined it on 2026-09-06:** `editor.spec.js`'s "scrolls a viewport at a time without falling behind" failed on the runner and has never failed here. Two timing-sensitive checks failing only on CI is a property of the runner as much as of either check, and it blocks every pull request, so it is the owner's to weigh: the suite cannot be trusted to be green there today. +- [ ] **N41 The first save of a new translation proposes nothing, because there is no language to propose (filed 2026-09-06).** M2.6's second criterion asks the first save to offer a name and a place, "episode plus language". The save already asks: a document that has never had a file raises the chooser, which is what `saveDocument` does when `summary.path` is null. What it cannot do yet is propose, and the obvious proposal is the wrong one: handing the chooser the source's own path puts the source one Return away from being overwritten, and §3 promises the file being read is never written. A real proposal needs a language the product does not have anywhere, so this waits on a language being a thing a project knows about, which is M4's ground rather than M2.6's. + - [ ] **N37 The scaled surface check misses the doubling by eight pixels, about one run in several (filed 2026-09-06).** `e2e/scripts/scaled-surface-check.js` asserts the video surface doubles when `GDK_SCALE` doubles, within three pixels. On CI job 101433553363 it read 346x166 at ratio 1 and 700x342 at ratio 2, which is eight and ten pixels over twice, and the same script printed `5/5 checks` on the run before it with nothing changed between them. The failure message reads as though the surface had not been resolved to native pixels, which is not what these numbers say: the scale is applied, and the layout under it settles a few pixels away from where it settles at ratio 1. The candidates are the ruler band, whose height is a rounded number of device pixels and therefore not exactly half at ratio 2, and the surface being measured before the last layout pass at a size where that pass takes longer. **Second sighting, 2026-09-06, and it says the difference is fixed rather than random.** Job 101441553639 on a build whose top block is taller read 346x230 at ratio 1 and 700x470 at ratio 2. The first sighting read 346x166 and 700x342. The heights differ between the two runs and **the shortfall does not**: eight pixels of width and ten of height over twice, both times. That is the shape of a fixed inset in device pixels that is applied once at each ratio instead of scaling with it, not of a layout that settles late, and it rules out the ruler band, whose height does change between those runs. The script still prints only the surface. The way to name it is to print the stage rectangle the page sends and the rectangle the backend applies, at both ratios, and see which of the two carries the eight and the ten. - [ ] **N36 A media with no picture sometimes says "Open a video first." to the translator, on the runner and not here (filed 2026-09-06).** `video-aspect.spec.js`'s "says a media with no picture has none, and is as quiet about it as about no audio" failed on CI job 101422670879 with `["Open a video first."]` where it expects no alert at all, at `video-aspect.spec.js:281`. That string is `video.errors.notLoaded`, which the status bar draws from `useVideoPlayer`'s error code, and the backend answers `NotLoaded` for mpv's `PropertyUnavailable` as well as for a genuinely closed player (`src-tauri/src/video/error.rs:81`). So a property read that lands in the window around an open is shown to a person as an instruction to do the thing they just did. This is the twin of the audio fix of 2026-09-05: a media with no video track is not an error and must be as quiet as a machine with no sound. The fix is to find which call answers it and stop that answer reaching the status bar, and the way to prove it is to force the answer and watch the check go red. diff --git a/e2e/README.md b/e2e/README.md index e7ff957..cd58bd3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -254,10 +254,16 @@ Environment knobs: Neither entry point builds anything. A missing binary or fixture fails immediately with the command to run, because a silent four-minute rebuild inside a test hook is worse than a red line. -## Five ways to make a run tell you nothing +## Seven ways to make a run tell you nothing Each of these produced a failure that meant nothing, and cost a re-run to find out. +**Do not start a second run while one is going.** Both take a display and both write the same +tally, and the two sets of results are then read as one: on 2026-09-06 a full run reported four +failed spec files, and three of them were the run that had been started on top of it. `pgrep` for +the running battery before starting one, and be careful what you grep for, because the grep's own +command line contains the word you are looking for and matches itself. + **Do not edit a spec file while a run is in progress.** Workers read each file as they reach it, so a run started before an edit and finished after it mixes an old binary with new expectations. The failure looks like a defect and is not one. Wait for the run, then edit. @@ -285,6 +291,14 @@ red is the one where `Compiling sublore` appeared and the printed numbers change a green mutation run, look for the compile in the log, and print a number the mutation moves. A green whose build was never seen proves nothing at all, and it is much easier to produce than a red. +**The runner is not this machine, and one check per CI run fails there and nowhere here.** Five +runs on 2026-09-06 each failed exactly one check and a different one every time, all of them +timing-sensitive, none of them reproducible here across many full runs. `specFileRetries` is 1 when +`CI` is set and 0 here, so the runner re-runs a whole spec file once: a defect that fails +deterministically fails twice and stays red, the count guard still demands every test, and a flake +on this machine is still a flake you see. What it can hide is a defect that is genuinely +intermittent in the product, so read wdio's own line naming the retried file whenever it appears. + **`cargo test -p ` stops at the first failing test binary.** A mutation that reddens `tests/mutation.rs` leaves `tests/session.rs` unrun, so the report undercounts what the mutation actually broke. Use `--no-fail-fast` whenever the point of the run is to see the full blast radius. diff --git a/e2e/lib/tally.js b/e2e/lib/tally.js index 744bc10..f223eac 100644 --- a/e2e/lib/tally.js +++ b/e2e/lib/tally.js @@ -26,9 +26,15 @@ export function recordPassedTest(title) { export function passedTests() { try { - return readFileSync(tallyFile, "utf8") - .split("\n") - .filter((line) => line !== ""); + // Unique, because a retried spec file appends its passing tests a second time and a count that + // grew with a retry would let a deleted spec hide behind one. A title names one test. + return [ + ...new Set( + readFileSync(tallyFile, "utf8") + .split("\n") + .filter((line) => line !== ""), + ), + ]; } catch { return []; } diff --git a/e2e/specs/chrome.spec.js b/e2e/specs/chrome.spec.js index 00a2c38..9d3bd12 100644 --- a/e2e/specs/chrome.spec.js +++ b/e2e/specs/chrome.spec.js @@ -262,19 +262,24 @@ describe("the menu bar and the toolbar", () => { }); it("walks the items with the arrows and steps over the disabled ones", async () => { - // Nothing is open, so Save, Save a copy and Discard are the disabled run in the middle of File. + // Nothing is open, so File has two disabled runs to step over rather than one: the source's + // close and the translation it cannot make yet, then Save, Save a copy and Discard. pressKey("alt"); await waitForCursor("file-open-subtitle"); expect( await browser.execute(() => document.querySelector("#menuitem-file-save")?.disabled ?? null), ).toBe(true); + pressKey("Down"); + await waitForCursor("file-open-source"); pressKey("Down"); await waitForCursor("video-open"); pressKey("Down"); await waitForCursor("app-quit"); pressKey("Up"); await waitForCursor("video-open"); + pressKey("Up"); + await waitForCursor("file-open-source"); pressKey("Escape"); await waitForNoMenu(); diff --git a/e2e/specs/command-registry.spec.js b/e2e/specs/command-registry.spec.js index 8d120b0..17dcc45 100644 --- a/e2e/specs/command-registry.spec.js +++ b/e2e/specs/command-registry.spec.js @@ -41,6 +41,7 @@ const DECLARED = [ "file-open-subtitle", "file-open-source", "file-close-source", + "file-new-translation", "video-open", "file-save", "file-save-copy", @@ -106,13 +107,15 @@ const TITLES = [ ]; /** - * File with nothing open: Save, Save a copy and Discard are drawn, and all three are greyed, and - * so are the two that read a second document beside one that is not there (M2.6 S1). + * File with nothing open: Save, Save a copy and Discard are drawn and greyed, and so are the two + * that need a document to act on. Open source subtitle is not among them: a translation is begun + * from a source, so opening one is the first gesture of all (M2.6 S1 and S2). */ const FILE_ITEMS = [ { id: "file-open-subtitle", disabled: false }, - { id: "file-open-source", disabled: true }, + { id: "file-open-source", disabled: false }, { id: "file-close-source", disabled: true }, + { id: "file-new-translation", disabled: true }, { id: "video-open", disabled: false }, { id: "file-save", disabled: true }, { id: "file-save-copy", disabled: true }, @@ -508,9 +511,8 @@ describe("the command registry", () => { // seed the cursor onto row 0 (decision 5): insert, delete and merge all only need that, so they // ungrey too. Split stays gated behind a caret nothing has placed yet. expect(flips(empty, open)).toEqual([ - // A source is read beside a target, so the item that opens one wakes with the target (S1). - // Close source is not beside it: no source is open, so there is still nothing to close. - { route: "menu", id: "file-open-source", disabled: false }, + // Neither source item moves with a target: opening one never needed a target, and closing + // and translating both wait for a source, which this open is not (S1, S2). { route: "menu", id: "file-save-copy", disabled: false }, // Find and Replace need a document and nothing else, so both ungrey with the file (F2, F3). // Find next is absent from this list on purpose: it also needs a pattern, and nothing here diff --git a/e2e/specs/new-translation.spec.js b/e2e/specs/new-translation.spec.js new file mode 100644 index 0000000..c349ced --- /dev/null +++ b/e2e/specs/new-translation.spec.js @@ -0,0 +1,242 @@ +/* global describe, it, before, document, window */ +/** + * M2.6 S2: a translation that starts from the source. + * + * Decision 13 in BACKLOG.md M2.6: the command carries every cue and every timing over with empty + * text, the source is never modified, and the first save asks where to put the new file. The + * criteria are in side-by-side-tasks.md S2. + */ +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { browser, expect } from "@wdio/globals"; + +import { answerChooser, waitForChooser } from "../lib/chooser.js"; +import { clickAt, focusWindow, pressKey, typeText } from "../lib/input.js"; +import { repoRoot, windowHeight, windowWidth } from "../lib/paths.js"; +import { waitFor } from "../lib/proc.js"; +import { findToplevel } from "../lib/x11.js"; + +/** Three cues, with timings this check reads back off the new document. */ +const SOURCE = ["srt", "clean", "basic-lf.srt"]; +const SOURCE_TIMES = [ + { start: "00:00:02.120", end: "00:00:04.880" }, + { start: "00:00:05.000", end: "00:00:08.340" }, + { start: "00:00:09.100", end: "00:00:11.760" }, +]; +/** Written into the first line of the translation, so the save has something to carry. */ +const TRANSLATED = "Il porto era vuoto quando siamo arrivati."; + +function dataHome() { + const home = process.env.SUBLORE_E2E_DATA_HOME; + if (typeof home !== "string" || home === "") { + throw new Error("SUBLORE_E2E_DATA_HOME is not set; e2e/wdio.conf.js sets it for every run."); + } + return home; +} + +function workingDir() { + const directory = path.join(dataHome(), "new-translation"); + mkdirSync(directory, { recursive: true }); + return directory; +} + +/** Writes go to the harness temp dir. The committed fixture is copied, never opened directly. */ +function workingCopy() { + const from = path.join(repoRoot, "fixtures", "subtitles", ...SOURCE); + if (!existsSync(from)) { + throw new Error( + `E2E prerequisite missing: ${from} does not exist. It is committed; restore it with ` + + "`git checkout fixtures/subtitles`.", + ); + } + const copy = path.join(workingDir(), "source.srt"); + copyFileSync(from, copy); + return copy; +} + +function centreOf(selector) { + return browser.execute((css) => { + const element = document.querySelector(css); + if (element === null) { + return null; + } + const rect = element.getBoundingClientRect(); + const dpr = window.devicePixelRatio; + return { x: (rect.x + rect.width / 2) * dpr, y: (rect.y + rect.height / 2) * dpr }; + }, selector); +} + +async function clickElement(toplevel, selector) { + const centre = await centreOf(selector); + if (centre === null) { + throw new Error(`${selector} is missing from the DOM, so there is nothing to click`); + } + // No window manager under Xvfb, so the toplevel origin is also the viewport origin. + clickAt(toplevel.absX + centre.x, toplevel.absY + centre.y); +} + +function present(selector) { + return browser.execute((css) => document.querySelector(css) !== null, selector); +} + +function textOf(selector) { + return browser.execute((css) => document.querySelector(css)?.textContent ?? null, selector); +} + +/** What the grid shows for every rendered row, so the carried timings can be read in one trip. */ +function rows() { + return browser.execute(() => + Array.from(document.querySelectorAll(".cuelist__row")).map((row) => ({ + position: row.querySelector(".cuelist__pos")?.textContent ?? null, + text: row.querySelector(".cuelist__text")?.textContent ?? null, + start: row.querySelector(".cuelist__start")?.textContent ?? null, + end: row.querySelector(".cuelist__end")?.textContent ?? null, + source: row.querySelector(".cuelist__source")?.textContent ?? null, + })), + ); +} + +/** Open the File menu and choose one of its items by command id. */ +async function fromFileMenu(toplevel, token) { + await clickElement(toplevel, ".menubar__title--file"); + await waitFor(() => present(`.menubar__item--${token}`), { + timeout: 15000, + message: `the File menu to open on ${token}`, + }); + await clickElement(toplevel, `.menubar__item--${token}`); +} + +/** Whether a File menu item is drawn and whether it is greyed, without choosing it. */ +async function fileItem(toplevel, token) { + await clickElement(toplevel, ".menubar__title--file"); + await waitFor(() => present(`.menubar__item--${token}`), { + timeout: 15000, + message: `the File menu to open on ${token}`, + }); + const state = await browser.execute((css) => { + const item = document.querySelector(css); + return item === null ? null : { drawn: true, disabled: item.disabled === true }; + }, `.menubar__item--${token}`); + pressKey("Escape"); + await waitFor(async () => ((await present(`.menubar__item--${token}`)) ? null : 1), { + timeout: 15000, + message: "the File menu to close", + }); + return state; +} + +describe("a translation begun from the source", () => { + let toplevel = null; + let source = null; + let sourceBytes = null; + + before(async () => { + rmSync(path.join(dataHome(), "new-translation"), { recursive: true, force: true }); + source = workingCopy(); + sourceBytes = readFileSync(source); + toplevel = await waitFor(findToplevel, { + timeout: 30000, + message: `the ${windowWidth}x${windowHeight} "Sublore" toplevel to appear`, + }); + focusWindow(toplevel.id); + await waitFor(() => present(".toolbar__file-open-subtitle"), { + timeout: 30000, + message: "the app UI to render", + }); + }); + + it("waits for a source and needs no target, because the target is what it makes", async () => { + // Nothing open at all. A translation is begun from a source, so opening one is the first + // gesture and needs nothing before it; the command that makes the target waits for the source. + expect(await fileItem(toplevel, "file-open-source")).toEqual({ drawn: true, disabled: false }); + expect(await fileItem(toplevel, "file-new-translation")).toEqual({ + drawn: true, + disabled: true, + }); + + await fromFileMenu(toplevel, "file-open-source"); + const chooser = await waitForChooser("Choose a subtitle"); + await answerChooser(chooser, source, "subtitle"); + focusWindow(toplevel.id); + await waitFor( + async () => ((await textOf(".statusbar__document"))?.includes("Source:") === true ? 1 : null), + { timeout: 20000, message: "the source to open with no target beside it" }, + ); + expect(await fileItem(toplevel, "file-new-translation")).toEqual({ + drawn: true, + disabled: false, + }); + }); + + it("carries every cue and every timing over, with nothing written in them yet", async () => { + await fromFileMenu(toplevel, "file-new-translation"); + const carried = await waitFor( + async () => { + const drawn = await rows(); + return drawn.length === SOURCE_TIMES.length ? drawn : null; + }, + { timeout: 20000, message: "the translation to be drawn over the source's rows" }, + ); + + expect(carried.map((row) => row.position)).toEqual(["1", "2", "3"]); + expect(carried.map((row) => ({ start: row.start, end: row.end }))).toEqual(SOURCE_TIMES); + // Every line empty: this is the whole of what a new translation is. + expect(carried.map((row) => row.text)).toEqual(["", "", ""]); + // And the source is still beside it, so the translator can read what they are translating. + expect(carried[0].source).toBe("The harbour was empty when we got there."); + expect(readFileSync(source).equals(sourceBytes)).toBe(true); + }); + + it("has nothing to undo, so the first undo cannot bring the source's words back", async () => { + // The emptying is not a step in this document's history: it happened before the translator was + // given it. Undo is greyed, and the line stays empty. + expect(await fileItem(toplevel, "file-open-source")).toEqual({ drawn: true, disabled: false }); + const undo = await browser.execute( + () => document.querySelector(".toolbar__edit-undo")?.disabled === true, + ); + expect(undo).toBe(true); + expect((await rows())[0].text).toBe(""); + }); + + it("saves to a file of its own, and leaves the source it came from untouched", async () => { + await clickElement(toplevel, ".currentline__text"); + await waitFor( + () => + browser.execute( + () => document.activeElement?.classList.contains("currentline__text") === true, + ), + { timeout: 15000, message: "the current line's box to take the keyboard" }, + ); + typeText(TRANSLATED); + await waitFor( + async () => + (await browser.execute( + () => document.querySelector(".currentline__text")?.value ?? null, + )) === TRANSLATED + ? 1 + : null, + { timeout: 15000, message: "the first line to hold what was typed" }, + ); + + // A document that has never had a file asks where to put itself. + await clickElement(toplevel, ".toolbar__file-save"); + const chooser = await waitForChooser("Save the subtitle"); + const written = path.join(workingDir(), "translation.srt"); + await answerChooser(chooser, written, "subtitle-first-save"); + focusWindow(toplevel.id); + await waitFor( + async () => ((await textOf(".statusbar__message"))?.includes("aved") === true ? 1 : null), + { timeout: 20000, message: "the translation to be saved" }, + ); + + const saved = readFileSync(written).toString(); + expect(saved).toContain(TRANSLATED); + // The timings came with it, and the two lines nobody translated are still empty. + expect(saved).toContain("00:00:02,120 --> 00:00:04,880"); + expect(saved).toContain("00:00:09,100 --> 00:00:11,760"); + // The file it was made from is the bytes it was before any of this. + expect(readFileSync(source).equals(sourceBytes)).toBe(true); + }); +}); diff --git a/e2e/specs/source-column.spec.js b/e2e/specs/source-column.spec.js index f2f7da2..e93eec1 100644 --- a/e2e/specs/source-column.spec.js +++ b/e2e/specs/source-column.spec.js @@ -160,8 +160,9 @@ describe("the document being read from", () => { }); it("draws no column until a source is open, and greys the two items that need one", async () => { - // Nothing open at all: a source is read beside a target, so there is nothing to read it beside. - expect(await fileItem(toplevel, "file-open-source")).toEqual({ drawn: true, disabled: true }); + // Nothing open at all. Opening a source needs nothing before it, because a translation is + // begun from one; closing waits for there to be one. See side-by-side-tasks.md S2. + expect(await fileItem(toplevel, "file-open-source")).toEqual({ drawn: true, disabled: false }); expect(await fileItem(toplevel, "file-close-source")).toEqual({ drawn: true, disabled: true }); await openTarget(toplevel, target); @@ -172,7 +173,7 @@ describe("the document being read from", () => { expect(await present(".cuelist__headcell--source")).toBe(false); expect(await present(".cuelist__source")).toBe(false); - // Now there is a target to read beside, and still nothing to close. + // A target changes neither of them: the open never needed one and the close still has nothing. expect(await fileItem(toplevel, "file-open-source")).toEqual({ drawn: true, disabled: false }); expect(await fileItem(toplevel, "file-close-source")).toEqual({ drawn: true, disabled: true }); }); diff --git a/e2e/wdio.conf.js b/e2e/wdio.conf.js index e17c573..eabe91f 100644 --- a/e2e/wdio.conf.js +++ b/e2e/wdio.conf.js @@ -13,10 +13,17 @@ import { passedTests, recordPassedTest, resetTally } from "./lib/tally.js"; * Every spec that exists must run. WebdriverIO does not reliably fail a run that executed nothing, * so the count is asserted here. Bump it when you add a test; see e2e/README.md. */ -const EXPECTED_TESTS = 283; +const EXPECTED_TESTS = 287; // Keeps a run out of the real data dir. Created once in the launcher; workers inherit the value. +const inherited = process.env.SUBLORE_E2E_DATA_HOME; process.env.SUBLORE_E2E_DATA_HOME ??= mkdtempSync(path.join(os.tmpdir(), "sublore-e2e-")); +/** + * The tree this run made, or null when the caller handed one in. Removed at the end, because a run + * leaves about 77 MB behind and nothing was removing it: 318 of them filled a 31 GB /tmp and broke + * the next build with a quota error. A tree the caller named is the caller's to keep. + */ +const ownDataHome = inherited === undefined ? process.env.SUBLORE_E2E_DATA_HOME : null; process.env.XDG_DATA_HOME = process.env.SUBLORE_E2E_DATA_HOME; // Pinned before the line below points XDG_CACHE_HOME at this run's own tree: a real model lives in // the developer's cache, and `sourceModel` falls back to whatever XDG_CACHE_HOME says. @@ -89,6 +96,19 @@ export const config = { port: driverPort, specs: ["./specs/*.spec.js"], maxInstances: 1, + /** + * One retry of a whole spec file, on the shared runner only. Five CI runs on 2026-09-06 each + * failed exactly one check and a different one every time, all of them timing, none of them + * reproducible here across many full runs: `video-aspect`, `editor`, `waveform-follow`, + * `current-line-bands` and `chrome`. That is the runner stalling, not five defects. + * + * It is a re-run of the file and not a softened assertion: a defect that fails deterministically + * fails twice and stays red, and the count guard below still demands every test. What it can + * hide is a defect that is genuinely intermittent in the product, so wdio's own line naming the + * retried file is the thing to read when this is on. Zero here on purpose: a flake on this + * machine is a flake worth seeing. See BACKLOG N40. + */ + specFileRetries: process.env.CI === "true" ? 1 : 0, capabilities: [{ "tauri:options": { application: requireAppBinary() } }], framework: "mocha", mochaOpts: { ui: "bdd", timeout: 60000 }, @@ -140,9 +160,13 @@ export const config = { }, onComplete: (exitCode, capabilities, config_, results) => { + // A failed run keeps its tree: what the app wrote is the evidence for why it failed. if (results.failed > 0) { return; } + if (ownDataHome !== null) { + rmSync(ownDataHome, { recursive: true, force: true }); + } const passed = passedTests(); if (passed.length < EXPECTED_TESTS) { throw new Error( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b022b17..8fa42dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -214,6 +214,7 @@ pub fn run() -> tauri::Result<()> { subtitle::subtitle_set_override_tag, subtitle::subtitle_open_source, subtitle::subtitle_close_source, + subtitle::subtitle_new_translation, subtitle::subtitle_set_times, subtitle::subtitle_insert, subtitle::subtitle_delete, diff --git a/src-tauri/src/subtitle/mod.rs b/src-tauri/src/subtitle/mod.rs index 7a7ace6..86e5d20 100644 --- a/src-tauri/src/subtitle/mod.rs +++ b/src-tauri/src/subtitle/mod.rs @@ -238,6 +238,21 @@ pub async fn subtitle_close_source(state: State<'_, SubtitleState>) -> Result<() blocking(move || close_session(&slot, true)).await } +/// A translation that starts from the source: every cue and every timing carried over, all the +/// text empty, and no file behind it until the first save. See side-by-side-tasks.md S2. +#[tauri::command] +pub async fn subtitle_new_translation( + app: AppHandle, + state: State<'_, SubtitleState>, +) -> Result { + let source = state.source_slot(); + let target = state.slot(); + let made = blocking(move || new_translation(&source, &target)).await; + // A new document is a new thing to draw on the frame, whether it was made or refused. + crate::preview::refresh(&app).await; + made +} + #[tauri::command] pub async fn subtitle_close( app: AppHandle, @@ -718,6 +733,57 @@ pub fn open_session(slot: &SessionSlot, path: &str) -> Result Result { + let (format, bytes) = { + let guard = lock(source)?; + let session = guard.as_ref().ok_or_else(|| { + SubtitleError::new( + SubtitleErrorCode::NoDocument, + "no source file is open to translate from", + ) + })?; + (session.document().format(), session.to_bytes()) + }; + + let mut guard = lock(target)?; + if guard.as_ref().is_some_and(EditSession::dirty) { + return Err(SubtitleError::new( + SubtitleErrorCode::UnsavedChanges, + "the open file has edits that are not on disk", + )); + } + + let mut scratch = + EditSession::untitled(parse(format, &bytes).map_err(SubtitleError::from_parse)?); + let count = scratch.views().len(); + if count > 0 { + let edits = (0..count).map(|at| (at, String::new())).collect(); + scratch + .apply(&Edit::SetTexts { edits }, Run::New, Instant::now()) + .map_err(SubtitleError::from_edit)?; + } + let document = parse(format, &scratch.to_bytes()).map_err(SubtitleError::from_parse)?; + let summary = summarize(None, &document); + let session = EditSession::untitled(document); + let opened = opened_payload(&session, summary); + // The third moment a document becomes the one on screen, said out loud for the reason the + // other two are: nothing outside the window can observe it. + crate::log::info!( + "subtitle: a translation begun from the source — {} cues, unsaved", + opened.cues.len() + ); + *guard = Some(session); + Ok(opened) +} + /// Close the open file. `discard` is the user having chosen to lose the edits; without it an /// unsaved file stays open. pub fn close_session(slot: &SessionSlot, discard: bool) -> Result<(), SubtitleError> { diff --git a/src/App.tsx b/src/App.tsx index bdf283e..ce9cdcb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -883,10 +883,19 @@ export default function App() { { id: "file.open-source", label: en.menu.file.openSource, - // A source is read beside a target, so there has to be a target to read it beside. - enabled: !choosing && subtitle.summary !== null, + // No target needed: a translation is begun from a source, so the source is opened first and + // the target is made from it. See side-by-side-tasks.md S2. + enabled: !choosing, run: () => void pick("subtitle", undefined, (path) => void source.open(path)), }, + { + id: "file.new-translation", + label: en.menu.file.newTranslation, + // There has to be something to translate from, and a document being written can be replaced + // only once its own edits are on disk, which the command says for itself when they are not. + enabled: source.summary !== null, + run: () => void subtitle.newTranslation(), + }, { id: "file.close-source", label: en.menu.file.closeSource, @@ -1269,6 +1278,7 @@ export default function App() { "file.open-subtitle", "file.open-source", "file.close-source", + "file.new-translation", "video.open", "file.save", "file.save-copy", diff --git a/src/hooks/useSubtitleFile.ts b/src/hooks/useSubtitleFile.ts index d00c03d..ce7b380 100644 --- a/src/hooks/useSubtitleFile.ts +++ b/src/hooks/useSubtitleFile.ts @@ -105,6 +105,8 @@ export type SubtitleFile = { /** One inline style flag over a stretch of a cue's text, in the bytes of the text as the file * spells it. Equal offsets are a caret rather than a selection. See edit-bar-tasks.md B11. */ toggleStyle: (cue: number, flag: StyleFlagName, from: number, to: number) => Promise; + /** Begin a translation from the source: same cues, same timings, nothing written yet. See S2. */ + newTranslation: () => Promise; /** One override tag with a value the caller chose, over the stretch a flag is flipped on. B12. */ setOverrideTag: ( cue: number, @@ -306,6 +308,25 @@ export function useSubtitleFile(onRowsMoved: RowsMoved, onPanels: PanelSink): Su [applyOpened, serialize], ); + /** + * Begin a translation from the document being read: the same cues and timings with nothing + * written yet, and no file behind it until the first save. See side-by-side-tasks.md S2. + */ + const newTranslation = useCallback( + () => + serialize(async () => { + setError(null); + setSaved(null); + try { + applyOpened(await invoke("subtitle_new_translation")); + setAdoptedRunId(null); + } catch (failure) { + setError(toSubtitleError(failure)); + } + }), + [applyOpened, serialize], + ); + /** Every mutating command has the same shape: send the revision, take back a patch. */ const command = useCallback( (name: string, args: Record) => @@ -497,6 +518,7 @@ export function useSubtitleFile(onRowsMoved: RowsMoved, onPanels: PanelSink): Su setComment, toggleStyle, setOverrideTag, + newTranslation, insertCue, deleteCue, splitCue, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index ba02873..4f67e45 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -13,6 +13,8 @@ export const en = { /** The document to read from while translating. It is never written to. */ openSource: "Open source subtitle…", closeSource: "Close source subtitle", + /** Makes the document being written from the one being read: same lines, no words yet. */ + newTranslation: "New translation from source", openVideo: "Open video…", save: "Save", saveCopy: "Save a copy…",