Skip to content
Closed
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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ There was a separate "Uncertain words" switch alongside a Low/Medium/High select
- **The marking must not read as an error.** No red, and no wavy underline (that means spellcheck). It uses `--color-review-*`, a violet reserved for this and used nowhere else.
- The flagged counter comes from `flagged_word_count()` over the whole word list, and reports 0 while the switch is off. Anything that changes the switch or the sensitivity must call `update_flagged_count()`.

### Highlights (SUNET/scribe-ui#131)

The reader's own highlighter: a note to self made while reviewing against the recording, never a change to the text. Stored as `SRTCaption.highlighted_words`, word positions counted exactly as `edited_words` is, and **kept apart from it** -- the two mean different things and move differently when the text does. Anchored to whole words, not characters: a selection that takes any of a word takes all of it (`words_in_selection()`; nothing selected means the word the caret is in or against), so a highlight survives every edit the way an edit mark does, and cannot end up cutting a word.

- **They follow the words.** `carry_highlights(previous, current, marked)` is the highlight's own `retag_edits`: words that stay move with their marks, words replaced one for one keep the mark, a longer or shorter replacement is highlighted when everything it replaced was, words typed *between two highlighted words* join the highlight and words typed anywhere else do **not** earn one (unlike an edit mark: typing a word is not highlighting it), removed words take theirs with them. `carry_marks(caption, new_text)` does both kinds in one call and is what every site that replaces a caption's text goes through (typing, split with unsent text, Find & Replace). Split/merge/`delete_range` reuse `split_edits`/`joined_edits` on the second set, and the one-word moves carry it across (they still do not carry edit marks). Any new place that rewrites `caption.text` has to carry both sets.
- **Toggling.** Ctrl+Shift+H (Ctrl only, by `event.code === "KeyH"`: Cmd+Shift+H is the browser's Home on a Mac; no Alt) reports `togglehighlight` with one `{id, start, end}` per block the selection reaches into (`highlightSpans()`, offsets in each block's own text), ahead of the block-scoped lists because those see only the caret's block. `SRTEditor.toggle_highlight()` takes everything touched: all already highlighted -> remove, otherwise add (a mixed selection becomes fully highlighted, a second press clears it). One undo step. `TranscriptEditor.toggle_highlight()` redraws and **puts the selection back** (`selectRange()`, offsets are unchanged because the text is), so the same key undoes what it did. Nothing selected and no word at the caret says so. **The toolbar's Highlight button** (`pages/srt.py`, where Validate sits in the subtitle editor, so transcriptions only) does the same for whoever does not know the key: it calls `TranscriptEditor.highlight_selection()` -> the component's `highlightSelection()`, which reads the selection as it is and emits the same `togglehighlight` (an empty list when nothing is selected, answered with the notice). It prevents the default on `mousedown`, as the caption row's note icon does, so pressing it does not take focus and the selection with it; `aria-keyshortcuts` names the key, the tooltip too.
- **Saved with the transcription -- and for now only there.** `to_dict()` writes `"highlighted"` (only when non-empty) next to `"edited"` and `parse_txt()` reads it back, ignoring anything that is not a list of ints. A set of subtitles is saved as plain SRT, which has no room for it, so **highlights are a transcription feature for now**: the key is not bound in `subtitleMode`, `SRTEditor.toggle_highlight()` refuses for `data_format == "srt"`, the shortcuts dialog has no "Highlighting" group there, and neither export option is offered or applied (`export_rtf()` ignores both for subtitles). The way to extend it is a place to keep marks beside an SRT result (positions plus a digest of each caption's text, restored only onto a caption with the same number and text) -- a backend endpoint; a working draft exists on the local branch `backup/highlight-words-with-subtitle-marks`, never pushed.
- **Drawing.** `review_runs()` gives a highlighted word `hl: true` (and makes every word a run of its own whenever a block has any highlight, while they are shown (see the switch below)); the space between two highlighted words is a highlighted run too, so a passage reads as one stretch, except across a line break. The component draws `runClass(run)` (the review/edit class **and** `highlight-word`) with `role="mark"`. Yellow, and a 2px line above and below: yellow on white is 1.14:1 so colour cannot carry it (1.4.1/1.4.11) -- the lines are 4.9:1 against the page in light mode and the text on the yellow 13.6:1; dark mode uses a dark gold (8.0:1 text, 12.6:1 frame). No horizontal padding, so highlighting does not move the text. On a flagged or changed word the highlight keeps its frame (line above and below, as on any highlight) and the word's own bar is drawn outside it, 3px below (`box-shadow` with no inset, so no room is taken): the bar once took the frame's lower line, which made a flagged highlight look like a flagged word on a yellow wash. The playing word's tint wins the wash and the lines stay.
- **RTF export** (`export_rtf(..., highlights=False, edits=False)`, the two checkboxes in the export dialog, transcriptions only, **off by default** and **hidden when there is no mark of that kind** to carry, `rtf_marks_available()`, across every transcription of a bulk export): highlights as `\highlight1` (pure yellow, Word's own marker colour), changed words teal and underlined (`\cf2\ul`, underlined because a printout may have no colour). `marked_spans()` splits a caption into whole-word pieces; the same bridging rule as the editor. The colour table is only written when used, so a clean export is byte-for-byte what it was. No other format carries either.
- **A word typed between two highlighted words is drawn highlighted at once, not only after the next render.** `carry_highlights` marks it on the server, but `set_text` does not re-render the block being typed into, so `highlightNewWord()` (called from `splitNewWord()` only, i.e. for a *new* word, and only when the nearest word on each side is highlighted) puts `data-highlighted` and `role=mark` on its span, styled by `.transcript-text span[data-highlighted]`; the next render drops both, like `data-changed`. Anything that changes which words `carry_highlights` marks has to change that rule too.
- **A switch hides them (Highlights, beside My edits, transcriptions only).** **Not saved**: every editor opens with them shown, and hiding them lasts until the page is left or reloaded -- it is a way of looking at the text for a while, not a preference, and a hidden highlight forgotten for days is worse than switching them off again (there is deliberately no storage key and no count on the switch); `set_show_highlights` re-renders. Hidden means **not drawn and not read out**: `review_runs()` is given no highlights at all, so no run carries `hl`, nothing is a `role="mark"`, and `highlightNewWord` has no highlighted neighbour to copy -- the component needs no flag of its own, and hiding is not CSS. The data stays: `highlighted_words` still follows every split, merge, move and edit, silently, and is still saved. **What cannot be seen is never taken away**: with them hidden, the command (`toggle_highlight` -> `reveal_highlights`) *never removes* -- it adds a highlight to any selected word lacking one, turns the display on (moving the switch through `editor.on_highlights_shown`, which the page sets; the page's handler skips the redraw when the command already did it), and says why with a toast that stays until closed (Quasar gives a toast `role="alert"`, so it is announced -- no separate status region, which would read it twice): "Highlights were hidden and are shown again. Press again to remove this highlight." (all already highlighted: nothing changed, so not a change to save and not an undo step), "Highlight added. Highlights were hidden and are shown again.", or "...and the words without one were highlighted." for a mixed selection. The next press, with them showing, removes as before. Nothing is said while they are shown. The RTF checkbox "Include highlights" is independent of the switch, and undo/redo still changes highlights that are hidden.
- Tests: `tests/test_highlights.py`, `tests/test_highlight_visibility.py`.

## Undo history (`utils/undo_redo.py`)

`save_state()` snapshots an `EditorState`: the captions **and** the speaker set, copied so a later edit to the live editor cannot reach back into a state already on a stack. Both halves have to travel together — the captions carry which speaker each block belongs to, the list carries which names exist at all, and the list cannot simply be recomputed from the captions on the way back, since a speaker can legitimately exist with no block using it ("Add new", or every block reassigned away). Restoring only the captions left a block naming a speaker the list no longer held: the menu highlighted nothing as current, `prompt_rename` refused to act on that name (it checks membership), and the name introduced by the undone edit lingered as unused forever.
Expand Down
72 changes: 72 additions & 0 deletions pages/srt.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,33 @@ async def look_up_original() -> None:
"click",
lambda: editor.validate_captions(),
)
else:
# Where Validate is in the subtitle editor: the one thing
# about the text that is not history or finding. The same
# as Ctrl+Shift+H. Transcriptions only, for now.
#
# A mouse press must not take focus, or the selection it
# is meant to act on is the first thing it would lose --
# the same reason the caption row's music-note icon
# prevents the default on mousedown.
# sym_o_ is Quasar's prefix for Material Symbols Outlined, which
# ships with NiceGUI and has the marker pen the plain
# Material Icons "highlight" (a torch) does not.
with ui.button("Highlight", icon="sym_o_format_ink_highlighter").props(
"flat aria-keyshortcuts=Control+Shift+H"
).classes(
"editor-btn editor-toolbar-btn editor-quiet"
) as highlight_button:
highlight_button.tooltip(
"Highlight the selection, or remove the highlight "
"(Ctrl + Shift + H)"
)
highlight_button.on(
"mousedown", js_handler="(event) => event.preventDefault()"
)
highlight_button.on(
"click", lambda: transcript.highlight_selection()
)

with ui.row().classes("editor-toolbar-group"):
editor.show_keyboard_shortcuts()
Expand Down Expand Up @@ -797,6 +824,51 @@ def save_show_edits(event) -> None:
"changed"
)

# Only a transcription can hold highlights;
# a set of subtitles is saved as plain SRT.
if data_format != "srt":

def save_show_highlights(event) -> None:
value = bool(event.sender.value)

# The highlight command turns this on
# itself and moves the switch to
# match, which lands here too.
if value != editor.show_highlights:
transcript.set_show_highlights(value)

# Not saved: hiding them is a way of
# looking at the text for a while, and
# every editor opens with them shown.

# See save_follow above (F-74).
focus_element(event.sender)

highlights_switch = ui.switch(
"Highlights",
value=editor.show_highlights,
).props("dense").classes(
"editor-switch highlights-switch"
)
# Same reason as the switches above:
# "click" is mouse-only on a QToggle,
# and update:model-value fires for a
# keyboard activation too.
highlights_switch.on(
"update:model-value", save_show_highlights
)
with highlights_switch:
ui.tooltip(
"Show the words you have "
"highlighted. Hiding them keeps "
"them."
)

def highlights_shown() -> None:
highlights_switch.value = True

editor.on_highlights_shown = highlights_shown

# One control rather than a switch plus a level:
# "off" is just the lowest setting of the same
# thing, and splitting them meant two places to
Expand Down
Loading
Loading