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
54 changes: 54 additions & 0 deletions crates/sublore-edit/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -165,6 +171,7 @@ pub fn plan(document: &SubtitleDocument, edit: &Edit) -> Result<Planned, EditErr
from,
to,
} => 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,
Expand Down Expand Up @@ -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<Planned, EditError> {
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::<String>()
} 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,
Expand Down
3 changes: 3 additions & 0 deletions crates/sublore-edit/src/splice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions crates/sublore-edit/tests/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions e2e/specs/command-registry.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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).
Expand Down
103 changes: 102 additions & 1 deletion e2e/specs/current-line-bands.spec.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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: [] },
]);
});

Expand Down Expand Up @@ -838,6 +841,7 @@ describe("the current line's bands", () => {
parts: ["Layer", "Start", "End", "Duration", "L", "R", "V"],
},
{ band: "actions", parts: [] },
{ band: "bottom", parts: [] },
]);
}

Expand Down Expand Up @@ -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(() => {
Expand Down
52 changes: 52 additions & 0 deletions e2e/specs/source-column.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"), {
Expand Down
2 changes: 1 addition & 1 deletion e2e/wdio.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions src-tauri/src/subtitle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CuePatchDto, SubtitleError> {
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.
Expand Down
Loading