From b7f9b0851b121bae0c9613aad0c0278032dfa758 Mon Sep 17 00:00:00 2001 From: Alcahest Date: Sun, 6 Sep 2026 20:27:34 +0200 Subject: [PATCH 1/2] feat: choose any of a line's four colours at the caret --- BACKLOG.md | 2 + crates/sublore-edit/src/plan.rs | 95 +++++++++ crates/sublore-edit/src/splice.rs | 3 + crates/sublore-edit/tests/session.rs | 61 ++++++ crates/sublore-formats/src/override_tags.rs | 37 +++- e2e/specs/current-line-bands.spec.js | 146 +++++++++++++ e2e/wdio.conf.js | 2 +- src-tauri/src/lib.rs | 1 + src-tauri/src/subtitle/mod.rs | 43 ++++ src/App.tsx | 26 ++- src/components/CurrentLine.tsx | 225 +++++++++++++++++++- src/hooks/useSubtitleFile.ts | 15 ++ src/i18n/en.ts | 12 ++ src/styles/tools.css | 123 +++++++++++ 14 files changed, 779 insertions(+), 12 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 2356517..4021f35 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -220,6 +220,8 @@ Two items jump the queue by owner decision. Full reasoning in `decisions.md`; th - [ ] **N38 The current line's box keeps its pixels while its contents scale, so a large interface loses a control off the bottom (filed 2026-09-06).** The top block opens at a pixel count and an interface size change deliberately does not move it: `interface-scale.spec.js`'s "leaves the three panels at the proportions the sashes were left at when the size changes" asserts `bigger.block === left.block`, and its reason is the waveform, whose measurements are in device pixels because a peak bucket is one millisecond. Everything inside the panel is in rem and does scale. So at 150 per cent in the narrowest window the panel is 1.5 times fuller in a box that has not moved, and the text box needs the panel's scroll to be reached. It has been paid for three times by raising the block's opening height, from 13.5rem to 21rem as the panel gained the effect, the drawing order, the three margins, Next line and the style dropdown, and raising it cannot fix the 150 per cent case because the shortfall there grows with the size. The two candidates are scaling the stored heights with the interface size, which contradicts that criterion and needs the owner, and the reference's own answer, which is merging and splitting the panel's rows by width (edit-bar-tasks.md question 1). +- [ ] **N39 The font button of the current line's row three, and the two halves of the colour picker that are not built (filed 2026-09-06).** Row three names nine buttons before Next line: four style flags, a font, and four colours. The flags and the colours are built; the font is not, and it is the one that needs something this repository does not have, a list of the fonts installed on the machine. Beside it two halves of the colour picker are missing. The alpha: a colour is written with its transparency beside it, `\1a` to `\4a`, and only when that transparency changed, which is two tags in one undo step and `Edit::SetOverrideTag` writes one. The spectrum: the picker offers sixteen colours and a field that takes `#RRGGBB`, where a saturation square, a hue slider, the three number notations and a screen eyedropper belong. The alpha and the font want the same thing first, a planner that writes a list of tags as one step. + - [ ] **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/crates/sublore-edit/src/plan.rs b/crates/sublore-edit/src/plan.rs index d607b4e..7fa4cbc 100644 --- a/crates/sublore-edit/src/plan.rs +++ b/crates/sublore-edit/src/plan.rs @@ -60,6 +60,16 @@ pub enum Edit { from: usize, to: usize, }, + /// Write one override tag with a value the caller chose, over the same stretch a style toggle + /// works on. The pickers use this where the four flags use `ToggleStyle`: a colour is picked + /// rather than flipped, so there is no state to read first. See edit-bar-tasks.md B12. + SetOverrideTag { + cue: usize, + tag: String, + value: String, + from: usize, + to: usize, + }, /// Turn an ASS event into a `Comment:` or back into a `Dialogue:`. The descriptor is not one /// of the fields `AssField` can name, and this changes how many cues a player would draw, so it /// is its own edit. See edit-bar-tasks.md B8. @@ -148,6 +158,13 @@ pub fn plan(document: &SubtitleDocument, edit: &Edit) -> Result plan_toggle_style(document, *cue, *flag, *from, *to), + Edit::SetOverrideTag { + cue, + tag, + value, + from, + to, + } => plan_set_override_tag(document, *cue, tag, value, *from, *to), Edit::Insert { before, start_ms, @@ -1105,6 +1122,84 @@ fn validate_field_value(field: AssField, value: &str) -> Result<(), EditError> { /// The flag's state at the caret, then the opposite of it written there, and the state it had put /// back at the far end of the selection shifted by whatever the first write inserted. That is the /// whole of it, and it is why the writer returns a shift. +/// 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. +fn plan_set_override_tag( + document: &SubtitleDocument, + index: usize, + tag: &str, + value: &str, + from: usize, + to: usize, +) -> Result { + let named = tag.strip_prefix('\\').unwrap_or(""); + // One digit may lead, because the numbered colours and alphas are spelt `\\2c` and `\\1a`, and + // after it the name is letters to the end: whatever follows those is the value. + let letters = named + .strip_prefix(|first: char| first.is_ascii_digit()) + .unwrap_or(named); + if letters.is_empty() || !letters.bytes().all(|byte| byte.is_ascii_alphabetic()) { + return Err(EditError::new( + EditErrorKind::NotApplicable, + format!( + "{tag} is not a tag name: a name is a backslash, one digit at most, then letters" + ), + )); + } + if value.contains(['{', '}', '\\']) { + return Err(EditError::new( + EditErrorKind::NotApplicable, + "a tag value may not carry a brace or a backslash", + )); + } + let located = locate(document, index)?; + if !matches!(&located.cue.detail, CueDetail::Ass(_)) { + return Err(EditError::new( + EditErrorKind::NotApplicable, + "only an ASS event carries override tags", + )); + } + let text = document.slice(located.cue.text); + if from > text.len() + || to > text.len() + || !text.is_char_boundary(from) + || !text.is_char_boundary(to) + { + return Err(EditError::new( + EditErrorKind::NotApplicable, + format!("the range {from}..{to} is outside the cue's text or cuts a character"), + )); + } + let (start, _) = if from <= to { (from, to) } else { (to, from) }; + let (written, _) = override_tags::set_tag(text, start, tag, value); + + 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::SetOverrideTag, + 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_toggle_style( document: &SubtitleDocument, index: usize, diff --git a/crates/sublore-edit/src/splice.rs b/crates/sublore-edit/src/splice.rs index d818af6..910b62d 100644 --- a/crates/sublore-edit/src/splice.rs +++ b/crates/sublore-edit/src/splice.rs @@ -69,6 +69,9 @@ pub enum EditKind { /// Which flag is on the label, for the reason `SetField` carries its field: bold and italic on /// one line must never merge into one undo step. ToggleStyle(sublore_formats::override_tags::StyleFlag), + /// 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, Insert, Delete, Split, diff --git a/crates/sublore-edit/tests/session.rs b/crates/sublore-edit/tests/session.rs index 4b21e9d..7c15b9e 100644 --- a/crates/sublore-edit/tests/session.rs +++ b/crates/sublore-edit/tests/session.rs @@ -986,6 +986,67 @@ fn a_field_committed_as_whitespace_writes_nothing_however_often_it_is_committed( ); } +fn set_override_tag(cue: usize, tag: &str, value: &str, from: usize, to: usize) -> Edit { + Edit::SetOverrideTag { + cue, + tag: tag.to_owned(), + value: value.to_owned(), + from, + to, + } +} + +#[test] +fn a_tag_with_a_chosen_value_is_written_where_the_caret_is() { + // B12: a colour is picked rather than flipped, so the value comes from the caller. + let mut session = session("ass/clean/basic.ass"); + let text = raw_text(&session, 0); + session + .apply( + &set_override_tag(0, "\\c", "&H0000FF&", 0, 0), + Run::New, + Instant::now(), + ) + .expect("an ASS event takes an override tag"); + assert_eq!(raw_text(&session, 0), format!("{{\\c&H0000FF&}}{text}")); + + session.undo().expect("a step to undo").expect("a patch"); + assert_eq!(raw_text(&session, 0), text); +} + +#[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. + let mut session = session("ass/clean/basic.ass"); + let text = raw_text(&session, 0); + for value in ["&H0000FF&", "&H00FF00&"] { + session + .apply( + &set_override_tag(0, "\\2c", value, 0, 0), + Run::New, + Instant::now(), + ) + .expect("an ASS event takes a numbered colour"); + } + assert_eq!(raw_text(&session, 0), format!("{{\\2c&H00FF00&}}{text}")); +} + +#[test] +fn a_tag_name_that_is_not_a_name_and_a_value_that_could_close_a_block_are_both_refused() { + let mut session = session("ass/clean/basic.ass"); + let before = session.to_bytes(); + for (tag, value) in [("c", "&H0&"), ("\\1c1", "&H0&"), ("\\c", "&H0&}x{\\b1")] { + session + .apply( + &set_override_tag(0, tag, value, 0, 0), + Run::New, + Instant::now(), + ) + .expect_err("neither a bare name nor a value carrying a brace is written"); + } + assert_eq!(session.to_bytes(), before, "a refusal writes nothing"); +} + fn toggle_style(cue: usize, flag: StyleFlag, from: usize, to: usize) -> Edit { Edit::ToggleStyle { cue, diff --git a/crates/sublore-formats/src/override_tags.rs b/crates/sublore-formats/src/override_tags.rs index cc9ac5e..2f8c3f4 100644 --- a/crates/sublore-formats/src/override_tags.rs +++ b/crates/sublore-formats/src/override_tags.rs @@ -31,14 +31,21 @@ pub struct Block { pub span: Span, } -/// Whether a braced run holds a tag. A tag is a backslash followed by a letter, and a run with none -/// is a comment: `{note}` is a note and `{\b1}` is styling. +/// Whether a braced run holds a tag. A tag is a backslash and a name, and a run with none is a +/// comment: `{note}` is a note and `{\b1}` is styling. fn holds_a_tag(inside: &str) -> bool { let bytes = inside.as_bytes(); bytes .iter() .enumerate() - .any(|(at, byte)| *byte == b'\\' && bytes.get(at + 1).is_some_and(u8::is_ascii_alphabetic)) + .any(|(at, byte)| *byte == b'\\' && names_a_tag(bytes, at)) +} + +/// Whether the backslash at `at` opens a name: one digit at most, then at least one letter. The +/// digit is there because the numbered colours and alphas are spelt `\2c` and `\1a`. +fn names_a_tag(bytes: &[u8], at: usize) -> bool { + let letters = at + 1 + usize::from(bytes.get(at + 1).is_some_and(u8::is_ascii_digit)); + bytes.get(letters).is_some_and(u8::is_ascii_alphabetic) } /// The drawing scale a braced run leaves behind it: the last `\p` in it, or `None` when it @@ -185,13 +192,14 @@ pub fn tags_in(text: &str, block: Block) -> Vec { at += 1; continue; } - let mut after = at + 1; + let letters = at + 1 + usize::from(bytes.get(at + 1).is_some_and(u8::is_ascii_digit)); + let mut after = letters; while after < end && bytes[after].is_ascii_alphabetic() { after += 1; } // A backslash with no letter after it is not a tag: `\\N` is a line break and its letter is // taken by the name, which is right, and a trailing backslash names nothing. - if after == at + 1 { + if after == letters { at += 1; continue; } @@ -550,4 +558,23 @@ mod tests { ] ); } + + #[test] + fn a_numbered_colour_is_one_name_and_not_a_digit_before_a_value() { + let text = "{\\2c&H0000FF&}word"; + let parsed = blocks(text); + assert_eq!(parsed[0].kind, BlockKind::Override); + let found = tags_in(text, parsed[0]); + assert_eq!(found.len(), 1); + assert_eq!(&text[found[0].name.range()], "\\2c"); + assert_eq!(&text[found[0].value.range()], "&H0000FF&"); + } + + #[test] + fn a_backslash_and_a_digit_with_no_letter_after_it_names_nothing() { + let text = "{\\3}word"; + let parsed = blocks(text); + assert_eq!(parsed[0].kind, BlockKind::Comment); + assert!(tags_in(text, parsed[0]).is_empty()); + } } diff --git a/e2e/specs/current-line-bands.spec.js b/e2e/specs/current-line-bands.spec.js index f2fa962..780b26d 100644 --- a/e2e/specs/current-line-bands.spec.js +++ b/e2e/specs/current-line-bands.spec.js @@ -54,6 +54,7 @@ const CONTROLS = [ ".currentline__actor-open", ".currentline__start", ".currentline__end", + ".currentline__colour-primary", ".currentline__subtitle-next-line", ".currentline__text", ]; @@ -93,6 +94,10 @@ const BARE_SHORTFALL = { 90: [], 110: [], 150: [".currentline__text"] }; * block's opening height again would clear it and would take that height from the grid at every * size, for one control in one configuration out of six. See edit-bar-tasks.md question 1, which is * what actually closes this. + * + * The colour beside them at 110 per cent is that same row and not a new shortfall: the entries on + * either side of it are the first and the last control of the button row, so the row was already + * behind the scroll there before the colours were drawn into it. B12. */ const SHORTFALL = { 90: { floor: [".currentline__text"], wide: [] }, @@ -100,6 +105,7 @@ const SHORTFALL = { floor: [ ".currentline__text", ".currentline__edit-style-bold", + ".currentline__colour-primary", ".currentline__subtitle-next-line", ], wide: [], @@ -109,6 +115,7 @@ const SHORTFALL = { ".currentline__end", ".currentline__text", ".currentline__edit-style-bold", + ".currentline__colour-primary", ".currentline__subtitle-next-line", ], wide: [".currentline__text"], @@ -1237,6 +1244,145 @@ describe("the current line's bands", () => { }); }); + it("writes the colour picked at the caret, and takes it off in one undo", async () => { + const lineText = () => + browser.execute(() => document.querySelector(".currentline__text")?.value ?? null); + const caretBefore = (word) => + browser.execute((wanted) => { + const box = document.querySelector(".currentline__text"); + const at = box.value.indexOf(wanted); + box.focus(); + box.setSelectionRange(at, at); + box.dispatchEvent(new Event("select", { bubbles: true })); + return at; + }, word); + const buttons = () => + browser.execute(() => + Array.from(document.querySelectorAll(".currentline__colour")).map((button) => ({ + name: button.getAttribute("aria-label"), + disabled: button.disabled, + })), + ); + + // An override tag is an ASS thing, so on a format that carries none all four are drawn and + // greyed rather than absent, and greyed is what keeps a refusal from being reachable (24 A2). + await openSubtitle(toplevel, workingCopy("srt/clean/basic-lf.srt")); + await goToRow(toplevel, 1); + expect(await buttons()).toEqual([ + { name: "Primary colour", disabled: true }, + { name: "Secondary colour", disabled: true }, + { name: "Outline colour", disabled: true }, + { name: "Shadow colour", disabled: true }, + ]); + + const copy = workingCopy("ass/clean/speakers.ass"); + await openSubtitle(toplevel, copy); + await goToRow(toplevel, 1); + const before = await lineText(); + expect(before).toContain("harbour"); + + await caretBefore("harbour"); + await waitFor(async () => ((await buttons()).every((one) => !one.disabled) ? 1 : null), { + timeout: 15000, + message: "a caret in the box to ungrey the four colours", + }); + + await clickElement(toplevel, ".currentline__colour-outline"); + await waitFor(() => present(".currentline__picker"), { + timeout: 15000, + message: "the picker to open under the button", + }); + await clickElement(toplevel, '.currentline__swatch[aria-label="#FF0000"]'); + // ASS writes a colour blue first, so red is `&H0000FF&`, and the outline is the third one. + await waitFor(async () => ((await lineText())?.includes("{\\3c&H0000FF&}harbour") ? 1 : null), { + timeout: 15000, + message: "the outline colour to be written where the caret was", + }); + expect(await lineText()).toBe(before.replace("harbour", "{\\3c&H0000FF&}harbour")); + expect(await present(".currentline__picker")).toBe(false); + + await clickElement(toplevel, ".toolbar__edit-undo"); + await waitFor(async () => ((await lineText()) === before ? 1 : null), { + timeout: 15000, + message: "one undo to take the colour back off", + }); + }); + + it("takes a colour typed into the picker, and writes nothing while it is half typed", async () => { + const lineText = () => + browser.execute(() => document.querySelector(".currentline__text")?.value ?? null); + const field = () => + browser.execute(() => { + const box = document.querySelector(".currentline__hex"); + return box === null + ? null + : { value: box.value, invalid: box.getAttribute("aria-invalid") }; + }); + const typeHex = async (toplevel, typed) => { + await clickElement(toplevel, ".currentline__hex"); + await waitFor( + () => + browser.execute( + () => document.activeElement?.classList.contains("currentline__hex") === true, + ), + { timeout: 15000, message: "the picker's field to take the keyboard" }, + ); + pressKey("ctrl+a"); + typeText(typed); + await waitFor(async () => ((await field())?.value === typed ? 1 : null), { + timeout: 15000, + message: `the picker's field to hold exactly ${typed}`, + }); + }; + + const copy = workingCopy("ass/clean/speakers.ass"); + await openSubtitle(toplevel, copy); + await goToRow(toplevel, 1); + const before = await lineText(); + await browser.execute(() => { + const box = document.querySelector(".currentline__text"); + box.focus(); + box.setSelectionRange(0, 0); + box.dispatchEvent(new Event("select", { bubbles: true })); + }); + await waitFor( + async () => + (await browser.execute( + () => document.querySelector(".currentline__colour-primary")?.disabled === false, + )) + ? 1 + : null, + { timeout: 15000, message: "a caret at the start of the box" }, + ); + + await clickElement(toplevel, ".currentline__colour-primary"); + await waitFor(() => present(".currentline__hex"), { + timeout: 15000, + message: "the picker's own field", + }); + + // Four digits is not a colour, so Enter writes nothing and the field says which it is. + await typeHex(toplevel, "#12AB"); + expect((await field())?.invalid).toBe("true"); + pressKey("Return"); + expect(await lineText()).toBe(before); + expect(await present(".currentline__hex")).toBe(true); + + await typeHex(toplevel, "#12AB34"); + pressKey("Return"); + // `#12AB34` is red 12, green AB, blue 34, and ASS writes the three the other way round. + await waitFor(async () => ((await lineText()) === `{\\c&H34AB12&}${before}` ? 1 : null), { + timeout: 15000, + message: "the typed colour to be written at the caret", + }); + + await clickElement(toplevel, ".toolbar__edit-undo"); + await waitFor(async () => ((await lineText()) === before ? 1 : null), { + timeout: 15000, + message: "one undo to take the typed colour back off", + }); + }); + it("turns a line into a comment and back, in one undo step each way", async () => { const flag = () => browser.execute(() => { diff --git a/e2e/wdio.conf.js b/e2e/wdio.conf.js index 0306288..74c2668 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 = 276; +const EXPECTED_TESTS = 278; // Keeps a run out of the real data dir. Created once in the launcher; workers inherit the value. process.env.SUBLORE_E2E_DATA_HOME ??= mkdtempSync(path.join(os.tmpdir(), "sublore-e2e-")); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3311890..197e4cf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -211,6 +211,7 @@ pub fn run() -> tauri::Result<()> { subtitle::subtitle_set_field, subtitle::subtitle_set_comment, subtitle::subtitle_toggle_style, + subtitle::subtitle_set_override_tag, 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 4877a1c..1fc7892 100644 --- a/src-tauri/src/subtitle/mod.rs +++ b/src-tauri/src/subtitle/mod.rs @@ -383,6 +383,49 @@ pub async fn subtitle_toggle_style( .await } +/// One override tag as a caller names it: the tag, the value chosen for it, and the stretch of the +/// line it is written over. One value rather than four more arguments, which is the shape H5 +/// settled on when `clippy::too_many_arguments` was right about `module_invoke`. +#[derive(Debug, Deserialize)] +pub struct OverrideTagWrite { + pub tag: String, + pub value: String, + pub from: usize, + pub to: usize, +} + +/// 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. +#[tauri::command] +pub async fn subtitle_set_override_tag( + app: AppHandle, + state: State<'_, SubtitleState>, + revision: u64, + cue: usize, + write: OverrideTagWrite, +) -> Result { + let OverrideTagWrite { + tag, + value, + from, + to, + } = write; + edited( + &app, + state.slot(), + revision, + Edit::SetOverrideTag { + cue, + tag, + value, + from, + to, + }, + ) + .await +} + /// Whether one cue is a line a player draws. Refused on a format that has no descriptor to /// rewrite, and the panel draws that control greyed instead of asking. See edit-bar-tasks.md B8. #[tauri::command] diff --git a/src/App.tsx b/src/App.tsx index c152602..d645119 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -436,6 +436,15 @@ export default function App() { * leaves it unmatched and the split greyed. */ 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. + */ + const writesAtCaret = + subtitle.summary?.format === "ass" && + activeCue !== null && + caret !== null && + caret.index === selection.active; // 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. @@ -1083,8 +1092,7 @@ export default function App() { ...STYLE_FLAGS.map(({ id, flag, label }): Command => ({ id, label, - // A caret in the line's own editor is what it writes at, so it wants one on this row. - enabled: activeCue !== null && caret !== null && caret.index === selection.active, + enabled: writesAtCaret, run: () => { if (caret !== null && selection.active !== null) { void subtitle.toggleStyle(selection.active, flag, caret.offset, caret.to); @@ -1538,6 +1546,20 @@ export default function App() { styles={subtitle.summary?.styles.map((style) => style.name) ?? []} canComment={subtitle.summary?.format === "ass"} onCommitComment={(cue, comment) => subtitle.setComment(cue, comment)} + canWriteTag={writesAtCaret} + onSetOverrideTag={async (tag, value) => { + // The same rule the button greys on, read again here: a greyed command must not + // run, and a picker left open on a row the cursor has left must not write to it. + if (writesAtCaret && caret !== null && selection.active !== null) { + await subtitle.setOverrideTag( + selection.active, + tag, + value, + caret.offset, + caret.to, + ); + } + }} /> diff --git a/src/components/CurrentLine.tsx b/src/components/CurrentLine.tsx index e9d54e9..d2f5175 100644 --- a/src/components/CurrentLine.tsx +++ b/src/components/CurrentLine.tsx @@ -50,8 +50,72 @@ type CurrentLineProps = { /** Whether the format has a descriptor at all: only ASS has one, so only ASS can be commented. */ canComment: boolean; onCommitComment: (cue: number, comment: boolean) => Promise; + /** + * One override tag written where the caret is. The shell binds the row and the caret, so the + * panel names only the tag and the value it chose. See edit-bar-tasks.md B12. + */ + onSetOverrideTag: (tag: string, value: string) => Promise; + /** Whether there is a caret on this row to write at. Without one the colour buttons grey. */ + canWriteTag: boolean; +}; + +/** The four colours a line can override, in the order row three of the reference draws them. */ +type ColourSlot = "primary" | "secondary" | "outline" | "shadow"; + +const COLOUR_SLOTS: ColourSlot[] = ["primary", "secondary", "outline", "shadow"]; + +/** The four style commands and the letter each is drawn as, in row three's order. */ +const STYLE_GLYPHS: { id: CommandId; glyph: string }[] = [ + { id: "edit.style-bold", glyph: "B" }, + { id: "edit.style-italic", glyph: "I" }, + { id: "edit.style-underline", glyph: "U" }, + { id: "edit.style-strikeout", glyph: "S" }, +]; + +/** + * The tag each of them writes. The first is `\\c` and not `\\1c` because that is the spelling the + * reference writes; a renderer reads the two as one colour. + */ +const COLOUR_TAGS: Record = { + primary: "\\c", + secondary: "\\2c", + outline: "\\3c", + shadow: "\\4c", }; +/** What the picker offers without typing: the sixteen a subtitle is actually coloured with. */ +const PALETTE = [ + "#FFFFFF", + "#C0C0C0", + "#808080", + "#000000", + "#FF0000", + "#800000", + "#FFFF00", + "#808000", + "#00FF00", + "#008000", + "#00FFFF", + "#008080", + "#0000FF", + "#000080", + "#FF00FF", + "#800080", +]; + +/** + * ASS writes a colour blue first, so `#RRGGBB` is written `&HBBGGRR&`. Null when the text is not + * six hexadecimal digits, which is what keeps a half-typed value out of the line. + */ +function assColour(hex: string): string | null { + const found = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()); + if (found === null) { + return null; + } + const digits = found[1].toUpperCase(); + return `&H${digits.slice(4, 6)}${digits.slice(2, 4)}${digits.slice(0, 2)}&`; +} + /** The ASS fields the panel holds as a number: the drawing order and the three margins. */ type NumberField = "layer" | "marginL" | "marginR" | "marginV"; @@ -157,6 +221,8 @@ export default function CurrentLine({ cues, onCommitField, commands, + onSetOverrideTag, + canWriteTag, styles, canComment, onCommitComment, @@ -203,6 +269,15 @@ export default function CurrentLine({ width: number; } | null>(null); const [highlight, setHighlight] = useState(0); + /** Which colour the picker is open on and where it is drawn. Null while it is closed. */ + const [colourAt, setColourAt] = useState<{ + slot: ColourSlot; + left: number; + top: number; + } | null>(null); + /** What the picker's own field holds, kept between openings so a colour is typed once. */ + const [hex, setHex] = useState(PALETTE[0]); + const pickerRef = useRef(null); const comboRefs = useRef>>({}); const values = useMemo( () => ({ actor: fieldValues(cues, "actor"), effect: fieldValues(cues, "effect") }), @@ -330,6 +405,40 @@ export default function CurrentLine({ }; }, [listAt]); + // The picker is anchored to a button on one row, so the cursor leaving that row closes it. + useEffect(() => { + setColourAt(null); + }, [index]); + + // Drawn at coordinates taken when it opened, so it closes rather than hanging over the panel it + // no longer belongs to. A press anywhere but inside it, or on the button that opened it, closes + // it too: the button's own click is what reopens it. + useEffect(() => { + if (colourAt === null) { + return; + } + const close = () => setColourAt(null); + const away = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + close(); + return; + } + const onOpener = target instanceof Element && target.closest(".currentline__colour") !== null; + if (pickerRef.current?.contains(target) !== true && !onOpener) { + close(); + } + }; + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + window.addEventListener("pointerdown", away, true); + return () => { + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + window.removeEventListener("pointerdown", away, true); + }; + }, [colourAt]); + const timesEdited = times.start !== timecode(startMs) || times.end !== timecode(endMs) || @@ -691,6 +800,67 @@ export default function CurrentLine({ ); } + /** Write one colour where the caret is, and close. A value short of six digits writes nothing. */ + async function pickColour(slot: ColourSlot, value: string) { + const written = assColour(value); + if (written === null) { + return; + } + const trimmed = value.trim().toUpperCase(); + setHex(trimmed.startsWith("#") ? trimmed : `#${trimmed}`); + setColourAt(null); + await onSetOverrideTag(COLOUR_TAGS[slot], written); + } + + /** One colour, drawn as the button that opens the picker over it. */ + function colourButton(slot: ColourSlot) { + const open = colourAt !== null && colourAt.slot === slot; + return ( + + ); + } + + /** + * One of the four style commands, drawn the way row three of the reference draws it: a letter in + * the style it writes, not the word. The command's own label stays as the button's spoken name. + */ + function styleButton(id: CommandId, glyph: string) { + const command = commands[id]; + if (command === undefined) { + return null; + } + return ( + + ); + } + /** One command from the registry, drawn as a button that greys and runs by the registry's rule. */ function commandButton(id: CommandId) { const command = commands[id]; @@ -807,10 +977,12 @@ export default function CurrentLine({ {/* Band 3, the commands the panel carries. Row three of the reference puts the style buttons first and Next line last, so it goes at the end and the others arrive before it. */}
- {commandButton("edit.style-bold")} - {commandButton("edit.style-italic")} - {commandButton("edit.style-underline")} - {commandButton("edit.style-strikeout")} + {/* Two groups of four, the way the reference draws them: buttons touching inside a group, + with the band's own gap between the groups rather than between the buttons. */} + + {STYLE_GLYPHS.map(({ id, glyph }) => styleButton(id, glyph))} + + {COLOUR_SLOTS.map((slot) => colourButton(slot))} {commandButton("subtitle.next-line")}