diff --git a/crates/sublore-edit/src/plan.rs b/crates/sublore-edit/src/plan.rs index 7fa4cbc..cc947a4 100644 --- a/crates/sublore-edit/src/plan.rs +++ b/crates/sublore-edit/src/plan.rs @@ -37,6 +37,12 @@ pub enum Edit { SetTexts { edits: Vec<(usize, String)>, }, + /// Empty one cue's text. `keep_tags` leaves every braced run where it stands and drops only the + /// words a reader sees, which is the difference between the reference's Clear and Clear Text. + ClearText { + cue: usize, + keep_tags: bool, + }, SetTimes { cue: usize, start_ms: u32, @@ -165,6 +171,7 @@ pub fn plan(document: &SubtitleDocument, edit: &Edit) -> Result plan_set_override_tag(document, *cue, tag, value, *from, *to), + Edit::ClearText { cue, keep_tags } => plan_clear_text(document, *cue, *keep_tags), Edit::Insert { before, start_ms, @@ -1125,6 +1132,53 @@ fn validate_field_value(field: AssField, value: &str) -> Result<(), EditError> { /// The same write a style toggle makes, with the value given rather than worked out. A tag name /// that is not a backslash and letters is refused: everything downstream reads a name that way, and /// a value carrying a brace would close the block it was written into. +/// Empty a cue's text, keeping the braced runs when asked. +/// +/// Keeping them is the reference's Clear Text: every block that is not words stays where it is, in +/// the order it was in, and only the plain runs go. See edit-bar-tasks.md B13. +fn plan_clear_text( + document: &SubtitleDocument, + index: usize, + keep_tags: bool, +) -> Result { + let located = locate(document, index)?; + let text = document.slice(located.cue.text); + let written = if keep_tags { + override_tags::blocks(text) + .into_iter() + .filter(|block| block.kind != override_tags::BlockKind::Plain) + .map(|block| &text[block.span.range()]) + .collect::() + } else { + String::new() + }; + + let write = plan_text_write(document, &located, &written)?; + Ok(Planned { + splice: Splice::new( + write.range.start, + document.slice(write.range).to_owned(), + write.inserted, + ), + label: EditLabel { + kind: EditKind::ClearText, + cue: index, + }, + expect: Expectation { + from: index, + removed: 1, + cues: vec![ExpectedCue { + text_raw: write.written, + start_ms: located.cue.start.millis(), + end_ms: located.cue.end.millis(), + }], + segments_from: located.segment_index, + segments_removed: 1, + segments_inserted: 1, + }, + }) +} + fn plan_set_override_tag( document: &SubtitleDocument, index: usize, diff --git a/crates/sublore-edit/src/splice.rs b/crates/sublore-edit/src/splice.rs index 910b62d..7c133e0 100644 --- a/crates/sublore-edit/src/splice.rs +++ b/crates/sublore-edit/src/splice.rs @@ -72,6 +72,9 @@ pub enum EditKind { /// A tag written with a value the caller chose. One kind rather than one per tag, unlike the /// flag above: the picker commits on the pick, so every pick opens its own step. SetOverrideTag, + /// One cue emptied, with or without its braced runs kept. Its own kind so a Clear and the + /// typing around it are never one undo step. See edit-bar-tasks.md B13. + ClearText, Insert, Delete, Split, diff --git a/crates/sublore-edit/tests/session.rs b/crates/sublore-edit/tests/session.rs index 7c15b9e..f77b097 100644 --- a/crates/sublore-edit/tests/session.rs +++ b/crates/sublore-edit/tests/session.rs @@ -1014,6 +1014,50 @@ fn a_tag_with_a_chosen_value_is_written_where_the_caret_is() { assert_eq!(raw_text(&session, 0), text); } +#[test] +fn clearing_a_line_empties_it_and_clearing_its_text_leaves_the_braced_runs_where_they_were() { + // B13: the reference's two clears differ in exactly this, and nothing else. + let mut session = session("ass/clean/basic.ass"); + session + .apply( + &Edit::SetText { + cue: 0, + text: "{\\b1}bold{\\b0} and {note} plain".to_owned(), + }, + Run::New, + Instant::now(), + ) + .expect("a line to clear"); + + session + .apply( + &Edit::ClearText { + cue: 0, + keep_tags: true, + }, + Run::New, + Instant::now(), + ) + .expect("clear text is applied"); + assert_eq!(raw_text(&session, 0), "{\\b1}{\\b0}{note}"); + + session + .apply( + &Edit::ClearText { + cue: 0, + keep_tags: false, + }, + Run::New, + Instant::now(), + ) + .expect("clear is applied"); + assert_eq!(raw_text(&session, 0), ""); + + // Two clears are two steps, so the words come back one undo at a time. + session.undo().expect("a step").expect("a patch"); + assert_eq!(raw_text(&session, 0), "{\\b1}{\\b0}{note}"); +} + #[test] fn a_numbered_colour_replaces_the_one_already_in_the_block_rather_than_joining_it() { // B12: `\\2c` is one name, so a second pick of the same colour is not a second tag. diff --git a/e2e/specs/command-registry.spec.js b/e2e/specs/command-registry.spec.js index 12a6ec2..f2aba6a 100644 --- a/e2e/specs/command-registry.spec.js +++ b/e2e/specs/command-registry.spec.js @@ -50,6 +50,10 @@ const DECLARED = [ "asr-transcribe", "edit-undo", "edit-redo", + "edit-revert", + "edit-clear", + "edit-clear-text", + "edit-insert-original", "edit-style-bold", "edit-style-italic", "edit-style-underline", @@ -128,6 +132,10 @@ const FILE_ITEMS = [ const EDIT_ITEMS = [ { id: "edit-undo", disabled: true }, { id: "edit-redo", disabled: true }, + { id: "edit-revert", disabled: true }, + { id: "edit-clear", disabled: true }, + { id: "edit-clear-text", disabled: true }, + { id: "edit-insert-original", disabled: true }, { id: "edit-style-bold", disabled: true }, { id: "edit-style-italic", disabled: true }, { id: "edit-style-underline", disabled: true }, @@ -515,6 +523,11 @@ describe("the command registry", () => { // 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 }, + // The two clears need a line with something in it, which the fixture's first row is. Revert + // beside them stays greyed, because nothing has moved it since the cursor arrived, and so + // does Insert original, which wants a caret and a source and has neither (B13). + { route: "menu", id: "edit-clear", disabled: false }, + { route: "menu", id: "edit-clear-text", 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 // has typed one, so it stays greyed through the open (F5). diff --git a/e2e/specs/current-line-bands.spec.js b/e2e/specs/current-line-bands.spec.js index 780b26d..afa633a 100644 --- a/e2e/specs/current-line-bands.spec.js +++ b/e2e/specs/current-line-bands.spec.js @@ -1,4 +1,4 @@ -/* global describe, it, before, after, document, window, Event */ +/* global describe, it, before, after, document, window, Event, setTimeout */ /** * The current line's bands: the character count on the first one, and the row structure both bands * have to survive at every interface size. See sublore-meta docs/edit-bar-first-tasks.md, E1 and E5. @@ -688,6 +688,9 @@ describe("the current line's bands", () => { { band: "identity", parts: ["Comment", "Style", "Actor", "Effect", "Characters", "CPS"] }, { band: "times", parts: ["Layer", "Start", "End", "Duration", "L", "R", "V"] }, { band: "actions", parts: [] }, + // The reference's own row under the box: what the line was, two ways of emptying it, and the + // source's line. Its buttons carry no label of their own, so the band reads as empty here. + { band: "bottom", parts: [] }, ]); }); @@ -838,6 +841,7 @@ describe("the current line's bands", () => { parts: ["Layer", "Start", "End", "Duration", "L", "R", "V"], }, { band: "actions", parts: [] }, + { band: "bottom", parts: [] }, ]); } @@ -1383,6 +1387,103 @@ describe("the current line's bands", () => { }); }); + /** Undo until the document is what it was on disk, so the next test's open is not refused. */ + async function undoEverything(toplevel) { + for (let step = 0; step < 12; step += 1) { + if (!(await present(".statusbar__dirty"))) { + return; + } + await clickElement(toplevel, ".toolbar__edit-undo"); + await new Promise((settle) => setTimeout(settle, 150)); + } + throw new Error("the document was still dirty after twelve undos"); + } + + it("empties a line two ways, one keeping the braced runs and one keeping nothing", async () => { + const lineText = () => + browser.execute(() => document.querySelector(".currentline__text")?.value ?? null); + + const copy = workingCopy("ass/clean/speakers.ass"); + await openSubtitle(toplevel, copy); + await goToRow(toplevel, 1); + + // A line with words and a braced run in it, so the two clears can be told apart at all. + await clickElement(toplevel, ".currentline__text"); + await waitFor( + () => + browser.execute( + () => document.activeElement?.classList.contains("currentline__text") === true, + ), + { timeout: 15000, message: "the box to take the keyboard" }, + ); + pressKey("ctrl+a"); + typeText("{\\b1}bold{\\b0} and plain"); + await waitFor(async () => ((await lineText()) === "{\\b1}bold{\\b0} and plain" ? 1 : null), { + timeout: 15000, + message: "the box to hold the line the clears are about", + }); + await clickElement(toplevel, ".currentline__comment"); + await clickElement(toplevel, ".currentline__comment"); + + await clickElement(toplevel, ".currentline__edit-clear-text"); + await waitFor(async () => ((await lineText()) === "{\\b1}{\\b0}" ? 1 : null), { + timeout: 15000, + message: "the words to go and the braced runs to stay", + }); + + await clickElement(toplevel, ".currentline__edit-clear"); + await waitFor(async () => ((await lineText()) === "" ? 1 : null), { + timeout: 15000, + message: "the whole line to go", + }); + + // Two clears are two steps, so one undo puts back exactly what the first one left. + await clickElement(toplevel, ".toolbar__edit-undo"); + await waitFor(async () => ((await lineText()) === "{\\b1}{\\b0}" ? 1 : null), { + timeout: 15000, + message: "one undo to take back the second clear and not the first", + }); + await undoEverything(toplevel); + }); + + it("puts a line back to what it was when the cursor reached it, and greys until it moved", async () => { + const lineText = () => + browser.execute(() => document.querySelector(".currentline__text")?.value ?? null); + const revert = () => + browser.execute(() => document.querySelector(".currentline__edit-revert")?.disabled ?? null); + + const copy = workingCopy("ass/clean/speakers.ass"); + await openSubtitle(toplevel, copy); + await goToRow(toplevel, 1); + const before = await lineText(); + // Nothing has moved on this row, so there is nothing to put back. + expect(await revert()).toBe(true); + + await clickElement(toplevel, ".currentline__text"); + await waitFor( + () => + browser.execute( + () => document.activeElement?.classList.contains("currentline__text") === true, + ), + { timeout: 15000, message: "the box to take the keyboard" }, + ); + pressKey("ctrl+a"); + typeText("Typed over the line"); + await clickElement(toplevel, ".currentline__comment"); + await clickElement(toplevel, ".currentline__comment"); + await waitFor(async () => ((await revert()) === false ? 1 : null), { + timeout: 15000, + message: "Revert to wake once the line differs from what it was", + }); + + await clickElement(toplevel, ".currentline__edit-revert"); + await waitFor(async () => ((await lineText()) === before ? 1 : null), { + timeout: 15000, + message: "the line to go back to what it was when the cursor reached it", + }); + await undoEverything(toplevel); + }); + it("turns a line into a comment and back, in one undo step each way", async () => { const flag = () => browser.execute(() => { diff --git a/e2e/specs/source-column.spec.js b/e2e/specs/source-column.spec.js index e93eec1..1090646 100644 --- a/e2e/specs/source-column.spec.js +++ b/e2e/specs/source-column.spec.js @@ -251,6 +251,58 @@ describe("the document being read from", () => { expect(readFileSync(source).equals(sourceBytes)).toBe(true); }); + it("puts the source's own line into the box where the caret is", async () => { + const lineText = () => + browser.execute(() => document.querySelector(".currentline__text")?.value ?? null); + const insert = () => + browser.execute( + () => document.querySelector(".currentline__edit-insert-original")?.disabled ?? null, + ); + + await openSource(toplevel, source); + await waitFor(() => present(".cuelist__headcell--source"), { + timeout: 20000, + message: "the source column to come back", + }); + await clickElement(toplevel, ".currentline__text"); + await waitFor( + () => + browser.execute( + () => document.activeElement?.classList.contains("currentline__text") === true, + ), + { timeout: 15000, message: "the box to take the keyboard" }, + ); + pressKey("ctrl+a"); + pressKey("Home"); + await waitFor(async () => ((await insert()) === false ? 1 : null), { + timeout: 15000, + message: "the button to wake with a caret on a row the source reaches", + }); + const before = await lineText(); + + await clickElement(toplevel, ".currentline__edit-insert-original"); + await waitFor(async () => ((await lineText()) === `${SOURCE_LINES[0]}${before}` ? 1 : null), { + timeout: 15000, + message: "the source's first line to be put at the start of the box", + }); + + await clickElement(toplevel, ".toolbar__edit-undo"); + await waitFor(async () => ((await lineText()) === before ? 1 : null), { + timeout: 15000, + message: "one undo to take the inserted line back out", + }); + + // The button is drawn on every row and greys on one the source does not reach: the target has + // three lines and the source two, so the third has nothing to insert. + await clickElement(toplevel, ".currentline__subtitle-next-line"); + await clickElement(toplevel, ".currentline__subtitle-next-line"); + await clickElement(toplevel, ".currentline__text"); + await waitFor(async () => ((await insert()) === true ? 1 : null), { + timeout: 15000, + message: "the button to grey on the row the source does not reach", + }); + }); + it("refuses a source it cannot read, and leaves the column as it found it", async () => { await openSource(toplevel, source); await waitFor(() => present(".cuelist__headcell--source"), { diff --git a/e2e/wdio.conf.js b/e2e/wdio.conf.js index 9c89651..460d284 100644 --- a/e2e/wdio.conf.js +++ b/e2e/wdio.conf.js @@ -13,7 +13,7 @@ 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 = 288; +const EXPECTED_TESTS = 291; // 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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca943b8..09fb128 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -213,6 +213,7 @@ pub fn run() -> tauri::Result<()> { subtitle::subtitle_set_comment, subtitle::subtitle_toggle_style, subtitle::subtitle_set_override_tag, + subtitle::subtitle_clear_text, subtitle::subtitle_open_source, subtitle::subtitle_close_source, subtitle::subtitle_new_translation, diff --git a/src-tauri/src/subtitle/mod.rs b/src-tauri/src/subtitle/mod.rs index 3c00d2a..2a5ed53 100644 --- a/src-tauri/src/subtitle/mod.rs +++ b/src-tauri/src/subtitle/mod.rs @@ -457,6 +457,25 @@ pub struct OverrideTagWrite { pub to: usize, } +/// Empty one cue's text. `keep_tags` is the reference's Clear Text: the braced runs stay where +/// they are and only the words go. See edit-bar-tasks.md B13. +#[tauri::command] +pub async fn subtitle_clear_text( + app: AppHandle, + state: State<'_, SubtitleState>, + revision: u64, + cue: usize, + keep_tags: bool, +) -> Result { + edited( + &app, + state.slot(), + revision, + Edit::ClearText { cue, keep_tags }, + ) + .await +} + /// One override tag with a value the caller chose, over the same stretch a style toggle works on. /// The name and the value are checked by the planner, which refuses anything that could close the /// block it is written into. See edit-bar-tasks.md B12. diff --git a/src/App.tsx b/src/App.tsx index 7ec3692..a219447 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -186,6 +186,18 @@ const LEAD_OUT_MS = 350; * The four inline style flags, in the order row three of the panel draws them. Each writes its own * override tag into the line's text and is its own undo step. See edit-bar-tasks.md B11. */ +/** + * Put `inserted` into `text` at `at`, counting `at` in UTF-8 bytes the way the backend counts a + * caret. A code-unit slice would land in the wrong place the moment a line carries a character + * outside the Latin block, which a translation usually does. See edit-bar-tasks.md B13. + */ +function spliceUtf8(text: string, at: number, inserted: string): string { + const bytes = new TextEncoder().encode(text); + const cut = Math.min(Math.max(at, 0), bytes.length); + const decoder = new TextDecoder(); + return decoder.decode(bytes.slice(0, cut)) + inserted + decoder.decode(bytes.slice(cut)); +} + const STYLE_FLAGS: { id: CommandId; flag: StyleFlagName; label: string }[] = [ { id: "edit.style-bold", flag: "bold", label: en.menu.edit.bold }, { id: "edit.style-italic", flag: "italic", label: en.menu.edit.italic }, @@ -441,14 +453,31 @@ export default function App() { */ const [caret, setCaret] = useState<{ index: number; offset: number; to: number } | null>(null); /** - * Whether an override tag can be written now. Only ASS carries them, and one is written at a - * caret in the line's own editor, so it wants one on the row the cursor is on. + * Whether there is a caret in the line's own editor, on the row the cursor is on. Two commands + * want it: writing an override tag, which additionally wants ASS because no other format carries + * one, and putting the source's line where the caret is, which every format can hold. */ - const writesAtCaret = - subtitle.summary?.format === "ass" && + /** + * The active row's text as it was when the cursor arrived on it, which is what Revert puts back. + * Tracked here rather than in the panel because an undo elsewhere re-seeds the panel's own copy + * and this one must survive that: it changes when the cursor moves and at no other time. B13. + */ + const [arrived, setArrived] = useState<{ open: number; index: number; text: string } | null>( + null, + ); + if ( + selection.active !== null && activeCue !== null && - caret !== null && - caret.index === selection.active; + (arrived?.index !== selection.active || arrived.open !== subtitle.openId) + ) { + setArrived({ open: subtitle.openId, index: selection.active, text: activeCue.text }); + } + if (selection.active === null && arrived !== null) { + setArrived(null); + } + + const hasCaret = activeCue !== null && caret !== null && caret.index === selection.active; + const writesAtCaret = subtitle.summary?.format === "ass" && hasCaret; // The chooser is modal and answers on its own thread, so a second one asked for while it is up // would sit behind the first. Every chooser the chrome raises is raised here, so one flag covers // them all. @@ -1125,6 +1154,55 @@ export default function App() { } }, })), + { + id: "edit.revert", + label: en.menu.edit.revert, + // Nothing to put back until the line has moved from what it was when the cursor reached it. + enabled: activeCue !== null && arrived !== null && arrived.text !== activeCue.text, + run: () => { + if (selection.active !== null && arrived !== null) { + void subtitle.setText(selection.active, arrived.text); + } + }, + }, + { + id: "edit.clear", + label: en.menu.edit.clear, + enabled: activeCue !== null && activeCue.text !== "", + run: () => { + if (selection.active !== null) { + void subtitle.clearText(selection.active, false); + } + }, + }, + { + id: "edit.clear-text", + label: en.menu.edit.clearText, + enabled: activeCue !== null && activeCue.text !== "", + run: () => { + if (selection.active !== null) { + void subtitle.clearText(selection.active, true); + } + }, + }, + { + id: "edit.insert-original", + label: en.menu.edit.insertOriginal, + // The source's line for this row, put where the caret is. Both are needed, and a row the + // source does not reach has nothing to insert. See side-by-side-tasks.md S4 and B13. + enabled: + hasCaret && selection.active !== null && (source.cues[selection.active]?.text ?? "") !== "", + run: () => { + if (caret === null || selection.active === null || activeCue === null) { + return; + } + const original = source.cues[selection.active]?.text ?? ""; + if (original === "") { + return; + } + void subtitle.setText(selection.active, spliceUtf8(activeCue.text, caret.offset, original)); + }, + }, { id: "subtitle.insert", label: en.menu.subtitles.insert, @@ -1303,6 +1381,10 @@ export default function App() { items: [ "edit.undo", "edit.redo", + "edit.revert", + "edit.clear", + "edit.clear-text", + "edit.insert-original", "edit.style-bold", "edit.style-italic", "edit.style-underline", diff --git a/src/components/CurrentLine.tsx b/src/components/CurrentLine.tsx index d2f5175..250e7a8 100644 --- a/src/components/CurrentLine.tsx +++ b/src/components/CurrentLine.tsx @@ -1034,6 +1034,14 @@ export default function CurrentLine({ ))} )} + {/* Under the box, which is where the reference's own row of four sits: what the line was, + two ways of emptying it, and the source's line put where the caret is. See B13. */} +
+ {commandButton("edit.revert")} + {commandButton("edit.clear")} + {commandButton("edit.clear-text")} + {commandButton("edit.insert-original")} +
{/* The same, over the panel: opened under the button it belongs to, closed by Escape, by a press outside it and by the cursor leaving the row it was opened on. */} {colourAt !== null && ( diff --git a/src/hooks/useSubtitleFile.ts b/src/hooks/useSubtitleFile.ts index ce7b380..9e06b8e 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; + /** Empty one line. `keepTags` leaves the braced runs and drops only the words. See B13. */ + clearText: (cue: number, keepTags: boolean) => 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. */ @@ -422,6 +424,11 @@ export function useSubtitleFile(onRowsMoved: RowsMoved, onPanels: PanelSink): Su [command], ); + const clearText = useCallback( + (cue: number, keepTags: boolean) => command("subtitle_clear_text", { cue, keepTags }), + [command], + ); + const insertCue = useCallback( (before: number, startMs: number, endMs: number, text: string) => command("subtitle_insert", { before, startMs, endMs, text }), @@ -519,6 +526,7 @@ export function useSubtitleFile(onRowsMoved: RowsMoved, onPanels: PanelSink): Su toggleStyle, setOverrideTag, newTranslation, + clearText, insertCue, deleteCue, splitCue, diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b0f6f53..32bcfb0 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -29,6 +29,11 @@ export const en = { bold: "Bold", italic: "Italic", underline: "Underline", + /** The four under the text box, in the order the reference's own row puts them. See B13. */ + revert: "Revert", + clear: "Clear", + clearText: "Clear text", + insertOriginal: "Insert original", strikeout: "Strikeout", find: "Find…", findNext: "Find next", diff --git a/src/styles/tools.css b/src/styles/tools.css index 7a064e4..53c93a0 100644 --- a/src/styles/tools.css +++ b/src/styles/tools.css @@ -140,6 +140,11 @@ of the reference puts them, and they grey with no caret to write at. Each is a letter over a bar, the way the reference draws them: the bar names which colour the button sets and is fixed, never the line's own colour, because the button says what it writes and not what is written. */ +/* The row under the text box. Its buttons are words, not icons, because the reference's are. */ +.currentline__bottom { + margin-top: 0.25rem; +} + /* A run of buttons that touch, which is how the reference groups row three's icons. */ .currentline__group { display: flex;