diff --git a/AGENTS.md b/AGENTS.md
index 3e22e04..29f9189 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/pages/srt.py b/pages/srt.py
index 39ce6ba..529d828 100644
--- a/pages/srt.py
+++ b/pages/srt.py
@@ -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()
@@ -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
diff --git a/tests/test_highlight_visibility.py b/tests/test_highlight_visibility.py
new file mode 100644
index 0000000..f90b1af
--- /dev/null
+++ b/tests/test_highlight_visibility.py
@@ -0,0 +1,455 @@
+# Copyright (c) 2025-2026 Sunet.
+# Contributor: Kristofer Hallin
+#
+# This file is part of Sunet Scribe.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+The switch that hides the reader's highlights.
+
+Hiding draws nothing and reads out nothing, but keeps every highlight in the
+data. What cannot be seen is never taken away: the highlight command turns the
+display back on instead, and says so.
+"""
+
+import pathlib
+
+import pytest
+
+from test_highlights import FakeBody, make_editor
+
+from utils.transcript_editor import TranscriptEditor
+
+
+@pytest.fixture
+def editor(monkeypatch):
+ notices = []
+ monkeypatch.setattr(
+ "utils.srt.ui.notify",
+ lambda message, **kwargs: notices.append((message, kwargs)),
+ )
+ built = make_editor()
+ built.notices = notices
+ built.refreshes = 0
+
+ def refresh(*args, **kwargs):
+ built.refreshes += 1
+
+ built.refresh_display = refresh
+
+ return built
+
+
+def hidden(editor):
+ editor.set_show_highlights(False)
+ editor.refreshes = 0
+ editor.snapshots = 0
+ editor.changed = 0
+ editor.notices.clear()
+
+ return editor
+
+
+class TestThePreference:
+ def test_every_editor_opens_with_them_shown(self, editor):
+ assert editor.show_highlights is True
+
+ editor.restore_review_state(False, "low")
+
+ assert editor.show_highlights is True
+
+ def test_hiding_them_is_not_remembered(self, editor):
+ """
+ A way of looking at the text for a while, not a preference: nothing
+ stored is read back, and the next editor starts with them shown.
+ """
+
+ editor.set_show_highlights(False)
+
+ assert make_editor().show_highlights is True
+
+ with pytest.raises(TypeError):
+ editor.restore_review_state(False, "low", highlights=False)
+
+ def test_there_is_no_stored_choice(self):
+ import utils.srt
+ import utils.srt_review
+
+ assert not hasattr(utils.srt, "HIGHLIGHTS_SHOW_KEY")
+ assert not hasattr(utils.srt_review, "HIGHLIGHTS_SHOW_KEY")
+
+ def test_switching_redraws(self, editor):
+ editor.set_show_highlights(False)
+
+ assert editor.show_highlights is False
+ assert editor.refreshes == 1
+
+
+class TestDrawing:
+ def test_hidden_highlights_are_not_runs(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ hidden(editor)
+
+ runs = editor.review_runs(first)
+
+ assert not any(run.get("hl") for run in runs)
+ assert runs == [{"t": "ett två tre", "flag": False}]
+
+ def test_the_space_between_hidden_words_is_not_drawn_either(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 1, 2}
+ hidden(editor)
+
+ assert not any(run.get("hl") for run in editor.review_runs(first))
+
+ def test_they_come_back_as_they_were(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ hidden(editor)
+ editor.set_show_highlights(True)
+
+ runs = editor.review_runs(first)
+
+ assert [run["t"] for run in runs if run.get("hl")] == ["två"]
+
+ def test_the_data_is_untouched_while_hidden(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 2}
+ hidden(editor)
+
+ assert first.highlighted_words == {0, 2}
+
+ def test_they_are_still_saved_while_hidden(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 2}
+ hidden(editor)
+
+ assert first.to_dict()["highlighted"] == [0, 2]
+
+ def test_other_markings_are_not_affected(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ editor.show_my_edits = True
+ hidden(editor)
+
+ runs = editor.review_runs(first)
+
+ assert not any(run.get("hl") for run in runs)
+ # One run per word is still what the edit marking needs.
+ assert [run["t"] for run in runs if run["t"].strip()] == [
+ "ett",
+ "två",
+ "tre",
+ ]
+
+ def test_hidden_highlights_do_not_force_a_run_per_word(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ hidden(editor)
+
+ assert len(editor.review_runs(first)) == 1
+
+
+class TestTheCommandWhileTheyAreShown:
+ def test_it_adds_and_removes_as_before_with_no_notice(self, editor):
+ first = editor.captions[0]
+
+ assert editor.toggle_highlight([(first, 4, 7)]) == "added"
+ assert editor.toggle_highlight([(first, 4, 7)]) == "removed"
+
+ assert editor.notices == []
+
+
+class TestTheCommandWhileTheyAreHidden:
+ def test_with_nothing_there_it_adds_one_and_shows_them(self, editor):
+ first = editor.captions[0]
+ hidden(editor)
+
+ assert editor.toggle_highlight([(first, 4, 7)]) == "added"
+
+ assert first.highlighted_words == {1}
+ assert editor.show_highlights is True
+ assert editor.changed == 1
+ assert editor.snapshots == 1
+
+ def test_over_a_hidden_highlight_it_only_shows_and_removes_nothing(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ hidden(editor)
+
+ assert editor.toggle_highlight([(first, 4, 7)]) == "shown"
+
+ assert first.highlighted_words == {1}
+ assert editor.show_highlights is True
+ # Nothing changed, so nothing to save and nothing to undo.
+ assert editor.changed == 0
+ assert editor.snapshots == 0
+
+ def test_the_next_press_is_the_one_that_removes(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ hidden(editor)
+
+ editor.toggle_highlight([(first, 4, 7)])
+
+ assert editor.toggle_highlight([(first, 4, 7)]) == "removed"
+ assert first.highlighted_words == set()
+
+ def test_a_mixed_selection_adds_to_the_words_lacking_one_and_removes_none(
+ self, editor
+ ):
+ first = editor.captions[0]
+ first.highlighted_words = {0}
+ hidden(editor)
+
+ assert editor.toggle_highlight([(first, 0, 11)]) == "added"
+
+ assert first.highlighted_words == {0, 1, 2}
+ assert editor.show_highlights is True
+
+ def test_across_captions_it_never_removes(self, editor):
+ first, second = editor.captions
+ first.highlighted_words = {1, 2}
+ second.highlighted_words = {0}
+ hidden(editor)
+
+ assert editor.toggle_highlight([(first, 4, 11), (second, 0, 4)]) == "shown"
+
+ assert first.highlighted_words == {1, 2}
+ assert second.highlighted_words == {0}
+ assert editor.show_highlights is True
+
+ def test_nothing_selected_says_so_and_leaves_them_hidden(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ hidden(editor)
+
+ assert editor.toggle_highlight([(first, 3, 4)]) == ""
+
+ assert editor.show_highlights is False
+ assert first.highlighted_words == {1}
+ assert [n[0] for n in editor.notices] == [
+ "Select the words to highlight first"
+ ]
+
+ def test_subtitles_still_get_none(self, editor):
+ subtitles = make_editor("srt")
+ subtitles.show_highlights = False
+ caption = subtitles.captions[0]
+
+ assert subtitles.toggle_highlight([(caption, 4, 7)]) == ""
+ assert caption.highlighted_words == set()
+
+
+class TestWhatTheReaderIsTold:
+ def notice(self, editor):
+ assert len(editor.notices) == 1
+
+ return editor.notices[0]
+
+ def test_over_a_hidden_highlight(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ hidden(editor)
+
+ editor.toggle_highlight([(first, 4, 7)])
+
+ assert self.notice(editor)[0] == (
+ "Highlights were hidden and are shown again. "
+ "Press again to remove this highlight."
+ )
+
+ def test_when_a_highlight_was_added(self, editor):
+ first = editor.captions[0]
+ hidden(editor)
+
+ editor.toggle_highlight([(first, 4, 7)])
+
+ assert self.notice(editor)[0] == (
+ "Highlight added. Highlights were hidden and are shown again."
+ )
+
+ def test_when_some_words_already_had_one(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0}
+ hidden(editor)
+
+ editor.toggle_highlight([(first, 0, 11)])
+
+ assert self.notice(editor)[0] == (
+ "Highlights were hidden and are shown again, and the words "
+ "without one were highlighted."
+ )
+
+ def test_it_stays_until_closed_like_the_other_notices(self, editor):
+ first = editor.captions[0]
+ hidden(editor)
+
+ editor.toggle_highlight([(first, 4, 7)])
+
+ kwargs = self.notice(editor)[1]
+
+ assert kwargs["timeout"] is None
+ assert kwargs["close_button"] == "Close"
+
+ def test_nothing_is_said_when_they_are_shown(self, editor):
+ first = editor.captions[0]
+
+ editor.toggle_highlight([(first, 4, 7)])
+
+ assert editor.notices == []
+
+ def test_the_switch_is_told_so_it_follows(self, editor):
+ first = editor.captions[0]
+ told = []
+ editor.on_highlights_shown = lambda: told.append(True)
+ hidden(editor)
+
+ editor.toggle_highlight([(first, 4, 7)])
+
+ assert told == [True]
+
+ def test_the_switch_is_not_told_when_the_reader_flips_it(self, editor):
+ told = []
+ editor.on_highlights_shown = lambda: told.append(True)
+
+ editor.set_show_highlights(False)
+ editor.set_show_highlights(True)
+
+ assert told == []
+
+
+class TestEditingWhileHidden:
+ """
+ Hidden is only about drawing: the marks follow the text as they always do,
+ and nothing is said about it.
+ """
+
+ def test_splitting_divides_them_between_the_halves(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 2}
+ hidden(editor)
+
+ editor.split_caption(first, cursor_position=7)
+
+ assert editor.captions[0].highlighted_words == {0}
+ assert editor.captions[1].highlighted_words == {0}
+ assert editor.notices == []
+ assert editor.show_highlights is False
+
+ def test_a_word_moved_takes_its_highlight_along(self, editor):
+ first, second = editor.captions
+ second.highlighted_words = {0}
+ hidden(editor)
+
+ editor.move_first_word_to_previous(second)
+
+ assert first.highlighted_words == {3}
+ assert second.highlighted_words == set()
+ assert editor.notices == []
+
+ def test_merging_joins_them(self, editor):
+ first, second = editor.captions
+ first.highlighted_words = {2}
+ second.highlighted_words = {0}
+ hidden(editor)
+
+ editor.merge_with_next(first)
+
+ assert editor.captions[0].highlighted_words == {2, 3}
+ assert editor.show_highlights is False
+
+ def test_a_word_typed_inside_one_joins_it(self, editor):
+ carried = editor.carry_highlights("ett två tre", "ett nytt två tre", {0, 1, 2})
+
+ assert carried == {0, 1, 2, 3}
+
+
+class TestThroughTheView:
+ @pytest.fixture
+ def view(self, editor):
+ view = TranscriptEditor(editor)
+ view.body = FakeBody()
+ view.refreshed = 0
+ view.saved = 0
+ view.refresh = lambda *a, **k: setattr(view, "refreshed", view.refreshed + 1)
+ view.changed = lambda *a, **k: setattr(view, "saved", view.saved + 1)
+
+ return view
+
+ def test_showing_them_again_is_not_a_change_to_save(self, view, editor):
+ editor.captions[0].highlighted_words = {1}
+ hidden(editor)
+
+ view.toggle_highlight({"spans": [{"id": 1, "start": 4, "end": 7}]})
+
+ assert editor.show_highlights is True
+ assert view.saved == 0
+ assert view.refreshed == 1
+ # The selection is put back so the next press acts on the same words.
+ assert view.body.selected == [(1, 4, 1, 7)]
+
+ def test_adding_while_hidden_is_a_change_to_save(self, view, editor):
+ hidden(editor)
+
+ view.toggle_highlight({"spans": [{"id": 1, "start": 4, "end": 7}]})
+
+ assert editor.captions[0].highlighted_words == {1}
+ assert view.saved == 1
+
+ def test_the_view_passes_the_switch_on(self, view, editor):
+ view.set_show_highlights(False)
+
+ assert editor.show_highlights is False
+
+
+class TestTheSwitch:
+ def page(self):
+ return pathlib.Path("pages/srt.py").read_text()
+
+ def js(self):
+ return pathlib.Path("utils/transcript_editor.js").read_text()
+
+ def test_it_sits_beside_my_edits_and_only_for_transcriptions(self):
+ source = self.page()
+ edits = source.index('"My edits"')
+ highlights = source.index('"Highlights"')
+ guard = source.rindex('if data_format != "srt":', 0, highlights)
+
+ assert edits < guard < highlights
+
+ def test_it_is_not_saved_between_sessions(self):
+ source = self.page()
+
+ assert "HIGHLIGHTS_SHOW_KEY" not in source
+ assert "srt_show_highlights" not in source
+
+ def test_it_listens_for_the_keyboard_too(self):
+ assert '"update:model-value", save_show_highlights' in self.page()
+
+ def test_the_command_can_move_the_switch(self):
+ source = self.page()
+
+ assert "editor.on_highlights_shown = highlights_shown" in source
+ assert "highlights_switch.value = True" in source
+
+ def test_the_page_does_not_redraw_twice_when_the_command_moved_it(self):
+ assert "if value != editor.show_highlights:" in self.page()
+
+ def test_the_component_needs_no_flag_of_its_own(self):
+ # The server leaves hidden highlights out of the runs, so nothing is
+ # drawn and nothing is a role=mark; with none drawn the script has no
+ # neighbour to copy when a word is typed.
+ assert "showHighlights" not in self.js()
diff --git a/tests/test_highlights.py b/tests/test_highlights.py
new file mode 100644
index 0000000..4973fee
--- /dev/null
+++ b/tests/test_highlights.py
@@ -0,0 +1,1100 @@
+# Copyright (c) 2025-2026 Sunet.
+# Contributor: Kristofer Hallin
+#
+# This file is part of Sunet Scribe.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+The reader's own highlights: kept per word, carried through every edit that
+moves words, saved with a transcription, drawn, and optionally exported.
+"""
+
+import json
+import pathlib
+import re
+
+import pytest
+
+from utils.caption import SRTCaption
+from utils.srt import SRTEditor
+from utils.transcript_editor import TranscriptEditor
+
+
+WORDS = {
+ "version": 1,
+ "words": [
+ {"t": "ett", "s": 0.0, "e": 0.5},
+ {"t": "två", "s": 0.6, "e": 1.0},
+ {"t": "tre", "s": 1.1, "e": 1.5},
+ {"t": "fyra", "s": 2.0, "e": 2.4},
+ {"t": "fem", "s": 2.5, "e": 2.9},
+ {"t": "sex", "s": 3.0, "e": 3.5},
+ ],
+}
+
+
+def make_editor(data_format="txt"):
+ editor = SRTEditor("job-uuid", data_format, "file")
+
+ editor.changed = 0
+ editor.snapshots = 0
+
+ def mark():
+ editor.changed += 1
+
+ def snapshot():
+ editor.snapshots += 1
+
+ editor.refresh_display = lambda *a, **k: None
+ editor.update_words_per_minute = lambda *a, **k: None
+ editor.update_flagged_count = lambda *a, **k: None
+ editor.mark_as_changed = mark
+ editor.save_state_for_undo = snapshot
+
+ editor.load_words(WORDS)
+ editor.captions = [
+ SRTCaption(1, "00:00:00,000", "00:00:01,500", "ett två tre", speaker="A"),
+ SRTCaption(2, "00:00:02,000", "00:00:03,500", "fyra fem sex", speaker="A"),
+ ]
+ editor.speakers = {"A"}
+
+ return editor
+
+
+@pytest.fixture
+def editor(monkeypatch):
+ messages = []
+ monkeypatch.setattr(
+ "utils.srt.ui.notify", lambda message, **k: messages.append(message)
+ )
+ built = make_editor()
+ built.messages = messages
+
+ return built
+
+
+class TestWordsInSelection:
+ def test_a_selection_takes_the_words_it_touches(self, editor):
+ assert editor.words_in_selection("ett två tre", 4, 7) == [1]
+
+ def test_a_selection_ending_inside_a_word_takes_all_of_it(self, editor):
+ assert editor.words_in_selection("ett två tre", 1, 5) == [0, 1]
+
+ def test_a_selection_only_brushing_an_edge_does_not_take_that_word(self, editor):
+ # Ends where "två" begins; starts where "ett" ends.
+ assert editor.words_in_selection("ett två tre", 0, 4) == [0]
+ assert editor.words_in_selection("ett två tre", 3, 8) == [1]
+
+ def test_the_whole_text(self, editor):
+ assert editor.words_in_selection("ett två tre", 0, 11) == [0, 1, 2]
+
+ def test_a_backwards_selection_is_the_same(self, editor):
+ assert editor.words_in_selection("ett två tre", 7, 4) == [1]
+
+ def test_a_caret_in_a_word_takes_that_word(self, editor):
+ assert editor.words_in_selection("ett två tre", 5, 5) == [1]
+
+ def test_a_caret_against_either_edge_of_a_word_takes_that_word(self, editor):
+ assert editor.words_in_selection("ett två tre", 3, 3) == [0]
+ assert editor.words_in_selection("ett två tre", 4, 4) == [1]
+
+ def test_a_caret_in_the_space_between_words_takes_none(self, editor):
+ assert editor.words_in_selection("ett två", 4, 4) == []
+
+ def test_line_breaks_separate_words_too(self, editor):
+ assert editor.words_in_selection("ett\ntvå", 4, 6) == [1]
+
+ def test_empty_text(self, editor):
+ assert editor.words_in_selection("", 0, 0) == []
+
+
+class TestCarryHighlights:
+ def carry(self, editor, before, after, marked):
+ return editor.carry_highlights(before, after, set(marked))
+
+ def test_words_that_stay_move_with_their_marks(self, editor):
+ assert self.carry(editor, "b c", "a b c", {0}) == {1}
+
+ def test_words_removed_take_their_marks_with_them(self, editor):
+ assert self.carry(editor, "a b c", "a c", {1}) == set()
+ assert self.carry(editor, "a b c", "a c", {0, 1, 2}) == {0, 1}
+
+ def test_a_word_corrected_inside_a_highlight_stays_highlighted(self, editor):
+ assert self.carry(editor, "teh cat sat", "the cat sat", {0, 1}) == {0, 1}
+
+ def test_a_word_corrected_elsewhere_does_not_gain_one(self, editor):
+ assert self.carry(editor, "teh cat sat", "the cat sat", {1}) == {1}
+
+ def test_a_new_word_does_not_earn_a_highlight(self, editor):
+ assert self.carry(editor, "a b", "a new b", set()) == set()
+ assert self.carry(editor, "a b", "a b new", {0, 1}) == {0, 1}
+
+ def test_a_word_typed_inside_a_highlight_joins_it(self, editor):
+ assert self.carry(editor, "a b", "a new b", {0, 1}) == {0, 1, 2}
+
+ def test_a_word_typed_next_to_a_highlight_does_not(self, editor):
+ assert self.carry(editor, "a b c", "a b new c", {0, 1}) == {0, 1}
+ assert self.carry(editor, "a b c", "new a b c", {0, 1}) == {1, 2}
+
+ def test_a_longer_replacement_of_highlighted_words_is_highlighted(self, editor):
+ assert self.carry(editor, "a x y d", "a p q r d", {1, 2}) == {1, 2, 3}
+
+ def test_a_replacement_of_part_of_a_highlight_is_not(self, editor):
+ assert self.carry(editor, "a x y d", "a p q r d", {1}) == set()
+
+ def test_a_shorter_replacement_of_highlighted_words_is_highlighted(self, editor):
+ assert self.carry(editor, "a x y d", "a p d", {1, 2}) == {1}
+
+ def test_the_input_is_not_changed(self, editor):
+ marked = {0}
+ editor.carry_highlights("a b", "x a b", marked)
+
+ assert marked == {0}
+
+ def test_no_marks_stay_none(self, editor):
+ assert self.carry(editor, "a b c", "a x c d", set()) == set()
+
+
+class TestToggling:
+ def test_it_highlights_the_words_selected(self, editor):
+ first = editor.captions[0]
+
+ assert editor.toggle_highlight([(first, 4, 7)]) == "added"
+
+ assert first.highlighted_words == {1}
+
+ def test_the_same_selection_again_takes_it_off(self, editor):
+ first = editor.captions[0]
+ editor.toggle_highlight([(first, 4, 7)])
+
+ assert editor.toggle_highlight([(first, 4, 7)]) == "removed"
+
+ assert first.highlighted_words == set()
+
+ def test_a_mixed_selection_is_highlighted_throughout(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0}
+
+ assert editor.toggle_highlight([(first, 0, 11)]) == "added"
+
+ assert first.highlighted_words == {0, 1, 2}
+
+ def test_a_selection_inside_a_highlight_takes_off_only_what_it_holds(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 1, 2}
+
+ assert editor.toggle_highlight([(first, 4, 7)]) == "removed"
+
+ assert first.highlighted_words == {0, 2}
+
+ def test_a_selection_across_captions_is_one_gesture(self, editor):
+ first, second = editor.captions
+
+ editor.toggle_highlight([(first, 4, 11), (second, 0, 4)])
+
+ assert first.highlighted_words == {1, 2}
+ assert second.highlighted_words == {0}
+ assert editor.snapshots == 1
+
+ def test_across_captions_it_comes_off_only_when_all_of_it_is_highlighted(
+ self, editor
+ ):
+ first, second = editor.captions
+ first.highlighted_words = {1, 2}
+
+ # Not all highlighted yet: the second caption's word is not.
+ editor.toggle_highlight([(first, 4, 11), (second, 0, 4)])
+ assert second.highlighted_words == {0}
+
+ editor.toggle_highlight([(first, 4, 11), (second, 0, 4)])
+ assert first.highlighted_words == set()
+ assert second.highlighted_words == set()
+
+ def test_nothing_selected_says_so_and_changes_nothing(self, editor):
+ first = editor.captions[0]
+
+ assert editor.toggle_highlight([(first, 3, 4)]) == ""
+ assert editor.toggle_highlight([]) == ""
+
+ assert first.highlighted_words == set()
+ assert editor.snapshots == 0
+ assert editor.changed == 0
+ assert editor.messages == ["Select the words to highlight first"] * 2
+
+ def test_it_is_one_undo_step(self, editor):
+ editor.toggle_highlight([(editor.captions[0], 0, 11)])
+
+ assert editor.snapshots == 1
+
+ def test_the_text_is_untouched(self, editor):
+ editor.toggle_highlight([(editor.captions[0], 0, 11)])
+
+ assert [caption.text for caption in editor.captions] == [
+ "ett två tre",
+ "fyra fem sex",
+ ]
+ assert editor.captions[0].edited_words == set()
+
+ def test_a_transcription_counts_it_as_a_change_to_save(self, editor):
+ editor.toggle_highlight([(editor.captions[0], 0, 11)])
+
+ assert editor.changed == 1
+
+ def test_subtitles_get_none_for_now(self):
+ """
+ Plain SRT has nowhere to keep a highlight, so a set of subtitles is
+ refused outright -- no change, no undo step, no notice.
+ """
+
+ editor = make_editor("srt")
+
+ assert editor.toggle_highlight([(editor.captions[0], 0, 11)]) == ""
+
+ assert editor.captions[0].highlighted_words == set()
+ assert editor.snapshots == 0
+ assert editor.changed == 0
+
+
+class TestSavedWithTheTranscription:
+ def reopen(self, editor):
+ saved = json.dumps(editor.export_json())
+
+ reopened = make_editor()
+ reopened.captions = []
+ reopened.speakers = set()
+ reopened.parse_txt(saved)
+
+ return reopened
+
+ def test_a_copy_does_not_share_the_marks(self):
+ caption = SRTCaption(1, "00:00:00,000", "00:00:01,000", "a b")
+ caption.highlighted_words = {0}
+
+ copy = caption.copy()
+ copy.highlighted_words.add(1)
+
+ assert caption.highlighted_words == {0}
+
+ def test_they_are_in_the_saved_data_only_when_there_are_any(self):
+ caption = SRTCaption(1, "00:00:00,000", "00:00:01,000", "a b")
+
+ assert "highlighted" not in caption.to_dict()
+
+ caption.highlighted_words = {1, 0}
+
+ assert caption.to_dict()["highlighted"] == [0, 1]
+
+ def test_they_come_back_when_the_file_is_opened_again(self, editor):
+ editor.captions[0].highlighted_words = {0, 2}
+ editor.captions[1].highlighted_words = {1}
+
+ reopened = self.reopen(editor)
+
+ assert [caption.highlighted_words for caption in reopened.captions] == [
+ {0, 2},
+ {1},
+ ]
+
+ def test_edit_marks_and_highlights_are_kept_apart(self, editor):
+ editor.captions[0].edited_words = {0}
+ editor.captions[0].highlighted_words = {2}
+
+ reopened = self.reopen(editor)
+
+ assert reopened.captions[0].edited_words == {0}
+ assert reopened.captions[0].highlighted_words == {2}
+
+ def test_a_file_saved_before_this_existed_opens_without_any(self, editor):
+ reopened = self.reopen(editor)
+
+ assert all(not caption.highlighted_words for caption in reopened.captions)
+
+ def test_junk_in_the_file_is_ignored(self, editor):
+ data = editor.export_json()
+ data["segments"][0]["highlighted"] = [0, "x", None, True, 2.5, 1]
+ data["segments"][1]["highlighted"] = "all of it"
+
+ reopened = make_editor()
+ reopened.captions = []
+ reopened.parse_txt(json.dumps(data))
+
+ assert reopened.captions[0].highlighted_words == {0, 1}
+ assert reopened.captions[1].highlighted_words == set()
+
+
+class TestFollowingTheText:
+ """
+ A highlight is on words, so it has to stay on them whatever the reader does
+ to the text around them.
+ """
+
+ def test_typing_before_it_moves_it_along(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+
+ editor.update_caption_text(first, "nytt ett två tre")
+
+ assert first.highlighted_words == {2}
+
+ def test_correcting_a_word_inside_it_keeps_it(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 1, 2}
+
+ editor.update_caption_text(first, "ett tvo tre")
+
+ assert first.highlighted_words == {0, 1, 2}
+
+ def test_typing_a_word_gives_it_no_highlight_of_its_own(self, editor):
+ first = editor.captions[0]
+
+ editor.update_caption_text(first, "ett två tre fyra")
+
+ assert first.highlighted_words == set()
+
+ def test_splitting_divides_them_between_the_halves(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 2}
+
+ editor.split_caption(first, cursor_position=7)
+
+ assert editor.captions[0].text == "ett två"
+ assert editor.captions[0].highlighted_words == {0}
+ assert editor.captions[1].text == "tre"
+ assert editor.captions[1].highlighted_words == {0}
+
+ def test_splitting_with_unsent_text_keeps_them(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {2}
+
+ editor.split_caption(first, cursor_position=8, text="nytt ett två tre")
+
+ assert editor.captions[0].text == "nytt ett"
+ assert editor.captions[0].highlighted_words == set()
+ assert editor.captions[1].text == "två tre"
+ assert editor.captions[1].highlighted_words == {1}
+
+ def test_merging_with_the_next_joins_them(self, editor):
+ first, second = editor.captions
+ first.highlighted_words = {2}
+ second.highlighted_words = {0}
+
+ editor.merge_with_next(first)
+
+ assert editor.captions[0].highlighted_words == {2, 3}
+
+ def test_merging_with_the_previous_joins_them(self, editor):
+ first, second = editor.captions
+ first.highlighted_words = {2}
+ second.highlighted_words = {0}
+
+ editor.merge_with_previous(second)
+
+ assert editor.captions[0].highlighted_words == {2, 3}
+
+ def test_a_word_moved_to_the_previous_caption_takes_its_highlight(self, editor):
+ first, second = editor.captions
+ second.highlighted_words = {0, 2}
+
+ editor.move_first_word_to_previous(second)
+
+ assert first.highlighted_words == {3}
+ assert second.highlighted_words == {1}
+
+ def test_a_word_moved_to_the_next_caption_takes_its_highlight(self, editor):
+ first, second = editor.captions
+ first.highlighted_words = {0, 2}
+ second.highlighted_words = {1}
+
+ editor.move_last_word_to_next(first)
+
+ assert first.highlighted_words == {0}
+ assert second.highlighted_words == {0, 2}
+
+ def test_a_word_moved_without_one_leaves_the_rest_alone(self, editor):
+ first, second = editor.captions
+ second.highlighted_words = {2}
+
+ editor.move_first_word_to_previous(second)
+
+ assert first.highlighted_words == set()
+ assert second.highlighted_words == {1}
+
+ def test_deleting_across_captions_keeps_what_is_left(self, editor):
+ first, second = editor.captions
+ first.highlighted_words = {0, 2}
+ second.highlighted_words = {0, 2}
+
+ # From the end of "två" in the first to just after "fyra" in the
+ # second: "ett två" and " fem sex" are what is left.
+ editor.delete_range(first, second, 7, 4, "")
+
+ assert editor.captions[0].text == "ett två fem sex"
+ # "ett" and "sex" were highlighted; "två" and "fem" were not.
+ assert editor.captions[0].highlighted_words == {0, 3}
+
+ def test_replacing_text_keeps_them_following_the_words(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {2}
+ editor.search_term = "ett"
+ editor.selected_caption = first
+ editor.case_sensitive = True
+
+ editor.replace_in_current_caption("ett nytt")
+
+ assert first.text == "ett nytt två tre"
+ assert first.highlighted_words == {3}
+
+
+class TestDrawing:
+ def runs(self, editor, caption):
+ return editor.review_runs(caption)
+
+ def test_nothing_is_drawn_when_nothing_is_highlighted(self, editor):
+ assert self.runs(editor, editor.captions[0]) == [
+ {"t": "ett två tre", "flag": False}
+ ]
+
+ def test_a_highlight_is_drawn_without_any_switch_on(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+
+ runs = self.runs(editor, first)
+
+ assert [run["t"] for run in runs if run.get("hl")] == ["två"]
+ assert "".join(run["t"] for run in runs) == "ett två tre"
+
+ def test_the_space_between_highlighted_words_is_highlighted_too(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0, 1}
+
+ runs = self.runs(editor, first)
+
+ assert [run["t"] for run in runs if run.get("hl")] == ["ett", " ", "två"]
+
+ def test_the_space_around_a_lone_highlighted_word_is_not(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+
+ runs = self.runs(editor, first)
+
+ assert [run["t"] for run in runs if run.get("hl")] == ["två"]
+
+ def test_a_line_break_between_highlighted_words_is_left_out(self, editor):
+ first = editor.captions[0]
+ first.text = "ett\ntvå"
+ first.highlighted_words = {0, 1}
+
+ runs = self.runs(editor, first)
+
+ assert [run["t"] for run in runs if run.get("hl")] == ["ett", "två"]
+ assert "".join(run["t"] for run in runs) == "ett\ntvå"
+
+ def test_highlights_are_not_runs_with_a_flag(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {0}
+
+ assert all(run["flag"] is False for run in self.runs(editor, first))
+
+ def test_a_changed_word_can_be_highlighted_too(self, editor):
+ editor.show_my_edits = True
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+ first.edited_words = {1}
+
+ run = [run for run in self.runs(editor, first) if run["t"] == "två"][0]
+
+ assert run["hl"] is True
+ assert run["edit"] is True
+
+ def test_it_carries_the_word_timing_when_following_the_audio(self, editor):
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+
+ runs = editor.review_runs(first, per_word=True)
+ run = [run for run in runs if run["t"] == "två"][0]
+
+ assert run["hl"] is True
+ assert (run["s"], run["e"]) == (0.6, 1.0)
+
+ def test_other_words_read_as_they_always_did(self, editor):
+ editor.show_my_edits = True
+ first = editor.captions[0]
+ first.highlighted_words = {1}
+
+ runs = self.runs(editor, first)
+
+ assert "hl" not in [run for run in runs if run["t"] == "ett"][0]
+
+
+class TestRtfExport:
+ def export(self, editor, **kwargs):
+ return editor.export_rtf(False, False, False, **kwargs)
+
+ def test_a_clean_export_has_no_marking_and_no_colour_table(self, editor):
+ editor.captions[0].highlighted_words = {1}
+ editor.captions[0].edited_words = {0}
+
+ clean = self.export(editor)
+
+ assert "highlight" not in clean
+ assert "colortbl" not in clean
+ assert "\\ul" not in clean
+
+ def test_a_clean_export_is_the_same_as_before_highlights_existed(self, editor):
+ before = self.export(editor)
+
+ editor.captions[0].highlighted_words = {0, 1}
+ editor.captions[1].edited_words = {2}
+
+ assert self.export(editor) == before
+
+ def test_highlights_are_a_yellow_marker_when_asked_for(self, editor):
+ editor.captions[0].highlighted_words = {1}
+
+ rtf = self.export(editor, highlights=True)
+
+ assert r"{\colortbl;\red255\green255\blue0;" in rtf
+ assert r"ett {\highlight1 tv\u229?} tre" in rtf
+
+ def test_a_passage_is_one_marked_stretch(self, editor):
+ editor.captions[0].highlighted_words = {0, 1}
+
+ rtf = self.export(editor, highlights=True)
+
+ assert rtf.count(r"\highlight1") == 1
+ assert r"{\highlight1 ett tv\u229?} tre" in rtf
+
+ def test_a_piece_starting_with_a_space_keeps_it(self, editor):
+ # The one space after a control word is its delimiter; any more are
+ # the text. Two highlighted words with two spaces between them are
+ # bridged, so the piece starts at the first word; the piece after a
+ # lone highlighted word starts with the spaces.
+ editor.captions[0].text = "ett två"
+ editor.captions[0].highlighted_words = {0}
+
+ rtf = self.export(editor, highlights=True)
+
+ assert r"{\highlight1 ett} tv\u229?" in rtf
+
+ def test_changed_words_are_underlined_and_teal_when_asked_for(self, editor):
+ editor.captions[0].edited_words = {2}
+
+ rtf = self.export(editor, edits=True)
+
+ assert r"\red18\green121\blue106;" in rtf
+ assert r"{\cf2\ul tre}" in rtf
+ assert "highlight1" not in rtf
+
+ def test_both_on_the_same_word(self, editor):
+ editor.captions[0].edited_words = {2}
+ editor.captions[0].highlighted_words = {2}
+
+ rtf = self.export(editor, highlights=True, edits=True)
+
+ assert r"{\highlight1\cf2\ul tre}" in rtf
+
+ def test_each_is_independent_of_the_other(self, editor):
+ editor.captions[0].edited_words = {2}
+ editor.captions[0].highlighted_words = {0}
+
+ highlights_only = self.export(editor, highlights=True)
+ edits_only = self.export(editor, edits=True)
+
+ assert r"\ul" not in highlights_only
+ assert "highlight1" not in edits_only
+
+ def test_the_text_is_the_same_with_or_without(self, editor):
+ editor.captions[0].highlighted_words = {0, 1, 2}
+ editor.captions[0].edited_words = {1}
+ editor.captions[1].highlighted_words = {1}
+
+ def body(rtf):
+ return rtf.split(r"\fs20 ", 1)[1]
+
+ marked = body(self.export(editor, highlights=True, edits=True))
+ clean = body(self.export(editor))
+
+ # Take the groups back out: what is left must be the clean text.
+ stripped = re.sub(
+ r"\{(?:\\highlight1)?(?:\\cf2\\ul)? ", "", marked
+ )
+ # Every group's closing brace goes, but the document's own stays.
+ stripped = stripped[:-1].replace("}", "") + "}"
+
+ assert stripped == clean
+
+ def test_subtitles_never_carry_them_for_now(self):
+ subtitles = make_editor("srt")
+ subtitles.captions[0].edited_words = {0}
+ subtitles.captions[0].highlighted_words = {1}
+
+ rtf = self.export(subtitles, highlights=True, edits=True)
+
+ assert "highlight" not in rtf
+ assert "colortbl" not in rtf
+ assert "\\ul" not in rtf
+
+ def test_a_line_break_is_still_a_line_break(self, editor):
+ editor.captions[0].text = "ett\ntvå"
+ editor.captions[0].highlighted_words = {0, 1}
+
+ rtf = self.export(editor, highlights=True)
+
+ assert r"{\highlight1 ett}\line {\highlight1 tv\u229?}" in rtf
+
+ def test_special_characters_in_a_marked_word_are_escaped(self, editor):
+ editor.captions[0].text = "a {b} c"
+ editor.captions[0].highlighted_words = {1}
+
+ rtf = self.export(editor, highlights=True)
+
+ assert r"{\highlight1 \{b\}}" in rtf
+
+ def test_the_braces_balance(self, editor):
+ editor.captions[0].highlighted_words = {0, 2}
+ editor.captions[0].edited_words = {1, 2}
+ editor.captions[1].highlighted_words = {0, 1, 2}
+
+ rtf = self.export(editor, highlights=True, edits=True)
+ unescaped = rtf.replace("\\{", "").replace("\\}", "")
+
+ assert unescaped.count("{") == unescaped.count("}")
+
+
+class TestOfferingTheMarks:
+ """
+ The export dialog offers a kind of mark only when there is one to carry,
+ and only from a transcription, for now.
+ """
+
+ def test_nothing_marked_offers_nothing(self, editor):
+ assert editor.rtf_marks_available() == (False, False)
+
+ def test_each_kind_is_offered_on_its_own(self, editor):
+ editor.captions[1].highlighted_words = {0}
+ assert editor.rtf_marks_available() == (True, False)
+
+ editor.captions[1].highlighted_words = set()
+ editor.captions[0].edited_words = {2}
+ assert editor.rtf_marks_available() == (False, True)
+
+ def test_both(self, editor):
+ editor.captions[0].highlighted_words = {0}
+ editor.captions[1].edited_words = {0}
+
+ assert editor.rtf_marks_available() == (True, True)
+
+ def test_a_bulk_export_offers_a_kind_if_any_file_has_it(self, editor):
+ other = make_editor()
+ other.captions[0].highlighted_words = {1}
+
+ assert editor.rtf_marks_available([other]) == (True, False)
+
+ def test_subtitles_offer_nothing_for_now(self):
+ subtitles = make_editor("srt")
+ subtitles.captions[0].edited_words = {0}
+
+ assert subtitles.rtf_marks_available() == (False, False)
+
+ def test_subtitles_in_a_bulk_export_are_not_counted(self, editor):
+ subtitles = make_editor("srt")
+ subtitles.captions[0].edited_words = {0}
+
+ assert editor.rtf_marks_available([subtitles]) == (False, False)
+
+ def test_the_checkboxes_follow_it_and_start_unchecked(self):
+ source = pathlib.Path("utils/srt_export.py").read_text()
+
+ assert "rtf_hl_incl.set_visibility(has_highlights)" in source
+ assert "rtf_edits_incl.set_visibility(has_edits)" in source
+ assert '"Include highlights", value=False' in source
+ assert '"Include my edits (underlined)", value=False' in source
+
+
+class TestMarkedSpans:
+ def test_the_pieces_are_the_text_again(self, editor):
+ caption = editor.captions[0]
+ caption.text = "ett två\ntre"
+ caption.highlighted_words = {0, 2}
+ caption.edited_words = {1}
+
+ spans = editor.marked_spans(caption, True, True)
+
+ assert "".join(text for text, _, _ in spans) == "ett två\ntre"
+
+ def test_neighbouring_words_with_the_same_marks_are_one_piece(self, editor):
+ caption = editor.captions[0]
+ caption.highlighted_words = {0, 1}
+
+ assert editor.marked_spans(caption, True, False) == [
+ ("ett två", True, False),
+ (" tre", False, False),
+ ]
+
+ def test_with_both_off_it_is_one_unmarked_piece(self, editor):
+ caption = editor.captions[0]
+ caption.highlighted_words = {0, 1}
+ caption.edited_words = {2}
+
+ assert editor.marked_spans(caption, False, False) == [
+ ("ett två tre", False, False)
+ ]
+
+ def test_no_text_no_pieces(self, editor):
+ caption = editor.captions[0]
+ caption.text = ""
+
+ assert editor.marked_spans(caption, True, True) == []
+
+
+class FakeBody:
+ def __init__(self):
+ self.selected = []
+ self.asked = 0
+
+ def highlight_selection(self):
+ self.asked += 1
+
+ def select_range(self, *args):
+ self.selected.append(args)
+
+
+@pytest.fixture
+def view(editor, monkeypatch):
+ view = TranscriptEditor(editor)
+ view.body = FakeBody()
+ view.refreshed = 0
+ view.refresh = lambda *a, **k: setattr(view, "refreshed", view.refreshed + 1)
+ view.changed = lambda *a, **k: None
+
+ return view
+
+
+class TestTheShortcutsDialog:
+ def groups(self, data_format):
+ return dict(make_editor(data_format).keyboard_shortcut_groups())
+
+ def test_a_transcription_lists_it(self):
+ assert self.groups("txt")["Highlighting"] == [
+ ("Highlight selection, or remove the highlight", "Ctrl + Shift + H")
+ ]
+
+ def test_subtitles_do_not_for_now(self):
+ assert "Highlighting" not in self.groups("srt")
+
+
+class TestTheHandler:
+ def test_the_event_reaches_the_view(self):
+ source = pathlib.Path("utils/transcript_editor.py").read_text()
+
+ assert (
+ 'self.body.on(\n "togglehighlight", '
+ "lambda event: self.toggle_highlight(event.args)\n )"
+ ) in source
+
+ def test_it_highlights_what_the_component_measured(self, view, editor):
+ view.toggle_highlight({"spans": [{"id": 1, "start": 4, "end": 7}]})
+
+ assert editor.captions[0].highlighted_words == {1}
+ assert view.refreshed == 1
+
+ def test_the_selection_is_put_back_after_the_redraw(self, view, editor):
+ view.toggle_highlight(
+ {
+ "spans": [
+ {"id": 1, "start": 4, "end": 11},
+ {"id": 2, "start": 0, "end": 4},
+ ]
+ }
+ )
+
+ assert view.body.selected == [(1, 4, 2, 4)]
+
+ def test_nothing_selected_changes_nothing_and_redraws_nothing(self, view, editor):
+ view.toggle_highlight({"spans": [{"id": 1, "start": 3, "end": 4}]})
+
+ assert view.refreshed == 0
+ assert view.body.selected == []
+
+ @pytest.mark.parametrize(
+ "args",
+ [
+ None,
+ "x",
+ {},
+ {"spans": None},
+ {"spans": "all"},
+ {"spans": []},
+ {"spans": [None, 3, "x"]},
+ {"spans": [{"id": 99, "start": 0, "end": 3}]},
+ {"spans": [{"id": 1, "start": "0", "end": 3}]},
+ {"spans": [{"id": 1, "start": True, "end": 3}]},
+ {"spans": [{"id": 1, "start": 0}]},
+ ],
+ )
+ def test_bad_input_is_harmless(self, view, editor, args):
+ view.toggle_highlight(args)
+
+ assert all(not caption.highlighted_words for caption in editor.captions)
+ assert view.refreshed == 0
+
+ def test_a_bad_span_does_not_stop_the_good_ones(self, view, editor):
+ view.toggle_highlight(
+ {
+ "spans": [
+ {"id": 99, "start": 0, "end": 3},
+ {"id": 2, "start": 0, "end": 4},
+ ]
+ }
+ )
+
+ assert editor.captions[1].highlighted_words == {0}
+
+
+class TestTheButton:
+ """
+ The toolbar's Highlight button, where Validate is in the subtitle editor:
+ the same as the key, for whoever does not know the key.
+ """
+
+ def page(self):
+ return pathlib.Path("pages/srt.py").read_text()
+
+ def test_it_asks_the_component_for_the_selection(self, view):
+ view.highlight_selection()
+
+ assert view.body.asked == 1
+
+ def test_it_is_harmless_before_the_editor_is_built(self, editor):
+ TranscriptEditor(editor).highlight_selection()
+
+ def test_nothing_selected_says_so_and_changes_nothing(self, view, editor):
+ view.toggle_highlight({"spans": []})
+
+ assert editor.messages == ["Select the words to highlight first"]
+ assert view.refreshed == 0
+ assert all(not caption.highlighted_words for caption in editor.captions)
+
+ def test_the_component_reports_the_selection_as_the_key_does(self):
+ source = pathlib.Path("utils/transcript_editor.js").read_text()
+ body = source[source.index("highlightSelection() {") :]
+ body = body[: body.index("\n },")]
+
+ assert "this.flush();" in body
+ assert 'this.$emit("togglehighlight", { spans: this.highlightSpans() });' in body
+
+ def test_it_is_in_the_toolbar_for_transcriptions_only(self):
+ page = self.page()
+ validate = page.index('with ui.button("Validate", icon="check")')
+ highlight = page.index('with ui.button("Highlight", icon="sym_o_format_ink_highlighter")')
+
+ # After Validate's own branch, in the else of the same test.
+ between = page[validate:highlight]
+
+ assert between.count("\n else:\n") == 1
+
+ def test_it_has_a_marker_pen_and_not_the_torch(self):
+ """
+ The plain Material Icons "highlight" is a torch. The marker pen is in
+ Material Symbols Outlined, which ships with NiceGUI.
+ """
+
+ page = self.page()
+
+ assert 'icon="sym_o_format_ink_highlighter"' in page
+ assert 'icon="highlight"' not in page
+
+ def test_it_is_a_labelled_quiet_toolbar_button_naming_its_key(self):
+ page = self.page()
+ start = page.index('with ui.button("Highlight", icon="sym_o_format_ink_highlighter")')
+ button = page[start : start + 1400]
+
+ assert "aria-keyshortcuts=Control+Shift+H" in button
+ assert "editor-btn editor-toolbar-btn editor-quiet" in button
+ assert "(Ctrl + Shift + H)" in button
+
+ def test_a_mouse_press_does_not_take_the_selection_with_it(self):
+ page = self.page()
+ start = page.index('with ui.button("Highlight", icon="sym_o_format_ink_highlighter")')
+ button = page[start : start + 1400]
+
+ assert '"mousedown", js_handler="(event) => event.preventDefault()"' in button
+
+ def test_a_click_is_the_same_as_the_key(self):
+ page = self.page()
+ start = page.index('with ui.button("Highlight", icon="sym_o_format_ink_highlighter")')
+ button = page[start : start + 1400]
+
+ assert '"click", lambda: transcript.highlight_selection()' in button
+
+
+class TestTheComponent:
+ def source(self):
+ return pathlib.Path("utils/transcript_editor.js").read_text()
+
+ def test_the_key_is_ctrl_shift_h_by_physical_key(self):
+ source = self.source()
+ start = source.index("event.ctrlKey &&\n event.shiftKey")
+ block = source[start : start + 260]
+
+ assert "!event.altKey" in block
+ assert "!event.metaKey" in block
+ assert 'event.code === "KeyH"' in block
+
+ assert '"togglehighlight"' in source
+
+ def test_it_is_for_transcriptions_only_for_now(self):
+ source = self.source()
+ start = source.index("!this.subtitleMode &&\n event.ctrlKey &&")
+
+ assert 'event.code === "KeyH"' in source[start : start + 260]
+
+ def test_it_is_ahead_of_the_block_scoped_lists(self):
+ """
+ A selection can reach across blocks, which the lists -- working from
+ the caret's own block -- cannot see.
+ """
+
+ source = self.source()
+
+ assert source.index('"togglehighlight"') < source.index(
+ "this.subtitleShortcut(event, at)"
+ )
+
+ def test_both_marking_classes_come_from_one_function(self):
+ source = self.source()
+
+ assert source.count(':class="runClass(run)"') == 2
+ assert 'run.hl ? "highlight-word" : null' in source
+
+ def test_a_highlight_is_exposed_as_marked_text(self):
+ assert self.source().count(""":role="run.hl ? 'mark' : null\"""") == 2
+
+ def test_the_selection_is_sent_one_range_per_block(self):
+ source = self.source()
+ body = source[source.index("highlightSpans() {") :]
+ body = body[: body.index("\n },")]
+
+ assert "first === last" in body
+ assert "this.plainText(block.textContent).length" in body
+
+ def test_the_selection_can_be_put_back(self):
+ source = self.source()
+
+ assert "selectRange(startId, startOffset, endId, endOffset) {" in source
+ assert "pointAt(block, offset) {" in source
+
+
+class TestTheStyles:
+ def css(self):
+ return pathlib.Path("utils/styles.py").read_text()
+
+ def rule(self, selector):
+ css = self.css()
+ rule = css[css.index(selector) :]
+
+ return rule[: rule.index("}")]
+
+ def test_it_has_its_own_colours_in_both_themes(self):
+ css = self.css()
+
+ assert css.count("--color-highlight-bg:") == 2
+ assert css.count("--color-highlight-frame:") == 2
+ assert css.count("--color-highlight-text:") == 2
+
+ def test_it_does_not_pad_the_text_sideways(self):
+ assert "padding: 1px 0;" in self.rule(" .highlight-word {")
+
+ def test_it_has_a_line_above_and_below_that_does_not_depend_on_colour(self):
+ rule = self.rule(" .highlight-word {")
+
+ assert "inset 0 2px 0 var(--color-highlight-frame)" in rule
+ assert "inset 0 -2px 0 var(--color-highlight-frame)" in rule
+
+ @pytest.mark.parametrize(
+ "selector, accent",
+ [
+ (" .highlight-word.review-word {", "review"),
+ (" .highlight-word.edit-word {", "edit"),
+ (" .transcript-text .highlight-word.review-word[data-current] {", "review"),
+ (" .transcript-text .highlight-word.edit-word[data-current] {", "edit"),
+ ],
+ )
+ def test_a_flagged_or_changed_word_keeps_the_frame_and_its_own_bar_outside_it(
+ self, selector, accent
+ ):
+ rule = self.rule(selector)
+
+ # The frame is the same line above and below as on any highlighted
+ # word, so a highlight always looks like a highlight ...
+ assert "inset 0 2px 0 var(--color-highlight-frame)" in rule
+ assert "inset 0 -2px 0 var(--color-highlight-frame)" in rule
+ # ... and the word's own bar sits outside it, where it is not taken
+ # for the frame's lower line.
+ assert f"0 3px 0 var(--color-{accent}-accent)" in rule
+ assert f"inset 0 -2px 0 var(--color-{accent}-accent)" not in rule
+
+ def test_the_word_being_played_keeps_the_lines(self):
+ rule = self.rule(" .transcript-text .highlight-word[data-current] {")
+
+ assert "background-color: var(--color-playing-bg);" in rule
+ assert "inset 0 2px 0 var(--color-highlight-frame)" in rule
+
+
+class TestANewWordTypedBetweenTwoHighlights:
+ """
+ The server highlights it (carry_highlights); the editor has to draw it
+ that way at once, since the block being typed into is not re-rendered.
+ """
+
+ def js(self):
+ return pathlib.Path("utils/transcript_editor.js").read_text()
+
+ def css(self):
+ return pathlib.Path("utils/styles.py").read_text()
+
+ def test_only_a_word_split_off_by_typing_can_be_highlighted_this_way(self):
+ # splitNewWord is the one place a new word gets a span of its own, and
+ # a word that already existed must not pick up a highlight by being
+ # edited.
+ assert self.js().count("this.highlightNewWord(") == 1
+ assert "this.highlightNewWord(wrapper);" in self.js()
+
+ def test_it_needs_a_highlighted_word_on_each_side(self):
+ source = self.js()
+ body = source[source.index(" highlightNewWord(span) {") :]
+ body = body[: body.index("\n },")]
+
+ assert 'nearestWord("previousSibling")' in body
+ assert 'nearestWord("nextSibling")' in body
+ assert "&&" in body
+
+ def test_it_is_an_attribute_and_a_mark_that_the_next_render_takes_away(self):
+ source = self.js()
+
+ assert 'span.setAttribute("data-highlighted", "")' in source
+ assert 'span.setAttribute("role", "mark")' in source
+ assert 'el.removeAttribute("data-highlighted")' in source
+ assert 'el.removeAttribute("role")' in source
+
+ def test_it_is_drawn_as_a_highlight_and_outranks_the_edit_marking(self):
+ css = self.css()
+ rule = css[css.index(" .transcript-text span[data-highlighted] {") :]
+ rule = rule[: rule.index("}")]
+
+ assert "background-color: var(--color-highlight-bg);" in rule
+ assert "inset 0 2px 0 var(--color-highlight-frame)" in rule
+ assert "inset 0 -2px 0 var(--color-highlight-frame)" in rule
+ # span[...] is more specific than .transcript-show-edits [data-changed].
+ assert ".transcript-show-edits .transcript-text span[data-highlighted][data-changed]" in css
diff --git a/tests/test_my_edits.py b/tests/test_my_edits.py
index 7505b78..40b9417 100644
--- a/tests/test_my_edits.py
+++ b/tests/test_my_edits.py
@@ -716,7 +716,10 @@ def test_a_space_already_gone_just_clears_the_reference(self):
def test_the_template_renders_the_edit_marking(self):
source = self.source()
- assert "'edit-word'" in source
+ # Chosen in runClass, which the template calls for every run, so the
+ # highlight can be added to the same list without a second binding.
+ assert '"edit-word"' in source
+ assert ':class="runClass(run)"' in source
# Bound on every word, not only the ones the server marked, so the
# stylesheet can reach the message on a word the browser marked too.
assert ':data-edit="editLabel"' in source
diff --git a/tests/test_transcript_editor.py b/tests/test_transcript_editor.py
index 6890104..084ded8 100644
--- a/tests/test_transcript_editor.py
+++ b/tests/test_transcript_editor.py
@@ -2819,6 +2819,15 @@ def test_the_transcription_list_is_exactly_these_and_nothing_else(self):
("Delete paragraph", "Ctrl + D"),
],
),
+ (
+ "Highlighting",
+ [
+ (
+ "Highlight selection, or remove the highlight",
+ "Ctrl + Shift + H",
+ ),
+ ],
+ ),
(
"File operations",
[
diff --git a/utils/caption.py b/utils/caption.py
index 7c621ae..0f610b4 100644
--- a/utils/caption.py
+++ b/utils/caption.py
@@ -52,6 +52,13 @@ def __init__(
# and marked untouched words as edited.
self.edited_words: Set[int] = set()
+ # Word positions the reader has highlighted for themselves, counted the
+ # same way. A note to self made while reviewing against the recording:
+ # it never changes the text, and it is kept apart from edited_words
+ # because the two mean different things and move differently when the
+ # text changes (see carry_highlights).
+ self.highlighted_words: Set[int] = set()
+
def copy(self) -> "SRTCaption":
"""
Create a deep copy of the caption.
@@ -71,6 +78,7 @@ def copy(self) -> "SRTCaption":
# Copied rather than shared: the undo stack holds these snapshots, so
# undoing a change has to take its marks back with it.
new_caption.edited_words = set(self.edited_words)
+ new_caption.highlighted_words = set(self.highlighted_words)
return new_caption
def to_dict(self) -> Dict[str, Any]:
@@ -83,6 +91,11 @@ def to_dict(self) -> Dict[str, Any]:
# Only when there are any, so a transcription nobody has touched
# exports exactly as it did before this was recorded.
**({"edited": sorted(self.edited_words)} if self.edited_words else {}),
+ **(
+ {"highlighted": sorted(self.highlighted_words)}
+ if self.highlighted_words
+ else {}
+ ),
}
def to_srt_format(self) -> str:
diff --git a/utils/srt.py b/utils/srt.py
index 7f2a08f..217475e 100644
--- a/utils/srt.py
+++ b/utils/srt.py
@@ -123,6 +123,14 @@ def __init__(self, uuid: str, srt_format: str, filename: str):
self.has_confidence = False
self.show_uncertain_words = False
self.show_my_edits = False
+ # The reader's own highlights, shown whenever an editor is opened.
+ # Hiding them draws nothing and reads out nothing, but keeps them, and
+ # is not remembered: it is a way of looking at the text for a while,
+ # and a hidden highlight forgotten for days is worse than having to
+ # switch them off again.
+ self.show_highlights = True
+ # Told when a command turns them back on, so the switch can follow.
+ self.on_highlights_shown = None
# The strip under the video. On by default, the same as the overlay
# and for the same reason: it shows what is already there rather
# than adding anything to read.
@@ -683,6 +691,16 @@ def parse_txt(self, data: dict) -> None:
position for position in marks if isinstance(position, int)
}
+ # The same for the reader's highlights.
+ highlights = segment.get("highlighted")
+
+ if isinstance(highlights, list):
+ restored.highlighted_words = {
+ position
+ for position in highlights
+ if isinstance(position, int) and not isinstance(position, bool)
+ }
+
self.captions.append(restored)
self.speakers.add(segment.get("speaker", ""))
@@ -956,9 +974,7 @@ def split_caption(
if text is not None and text != caption.text:
# The text area holds edits that have not been committed yet;
# splitting must not discard them, nor the marks they earned.
- caption.edited_words = self.retag_edits(
- caption.text, text, caption.edited_words
- )
+ self.carry_marks(caption, text)
caption.text = text
self.mark_as_changed()
@@ -983,10 +999,14 @@ def split_caption(
# Marks follow their words across the break.
kept, moved = self.split_edits(caption.edited_words, len(first_part.split()))
+ kept_highlights, moved_highlights = self.split_edits(
+ caption.highlighted_words, len(first_part.split())
+ )
# Update first caption
caption.text = first_part
caption.edited_words = kept
+ caption.highlighted_words = kept_highlights
caption.end_time = self.seconds_to_timestamp(mid_seconds)
# Create second caption
@@ -998,6 +1018,7 @@ def split_caption(
speaker=caption.speaker,
)
new_caption.edited_words = moved
+ new_caption.highlighted_words = moved_highlights
# Insert new caption
caption_index = self.captions.index(caption)
@@ -1079,9 +1100,21 @@ def move_first_word_to_previous(self, caption: SRTCaption) -> None:
self.save_state_for_undo()
previous = self.captions[position - 1]
+
+ # A highlight on the word follows it across. Read before either text
+ # changes, since both positions are counted against the old words.
+ first_was_highlighted = 0 in caption.highlighted_words
+ previous_count = len(previous.text.split())
+
previous.text = f"{previous.text.rstrip()} {moved_word}"
caption.text = remaining
+ caption.highlighted_words = {
+ index - 1 for index in caption.highlighted_words if index > 0
+ }
+ if first_was_highlighted:
+ previous.highlighted_words.add(previous_count)
+
if self.word_is_timed(moved_timing) and self.word_is_timed(next_timing):
previous.end_time = self.seconds_to_timestamp(moved_timing["e"])
caption.start_time = self.seconds_to_timestamp(next_timing["s"])
@@ -1136,9 +1169,21 @@ def move_last_word_to_next(self, caption: SRTCaption) -> None:
self.save_state_for_undo()
following = self.captions[position + 1]
+
+ # See move_first_word_to_previous: the highlight goes with the word.
+ last_index = len(caption.text.split()) - 1
+ last_was_highlighted = last_index in caption.highlighted_words
+
following.text = f"{moved_word} {following.text.lstrip()}"
caption.text = remaining
+ caption.highlighted_words.discard(last_index)
+ following.highlighted_words = {
+ index + 1 for index in following.highlighted_words
+ }
+ if last_was_highlighted:
+ following.highlighted_words.add(0)
+
if self.word_is_timed(moved_timing) and self.word_is_timed(previous_timing):
following.start_time = self.seconds_to_timestamp(moved_timing["s"])
caption.end_time = self.seconds_to_timestamp(previous_timing["e"])
@@ -1161,6 +1206,119 @@ def finish_word_move(self) -> None:
self.update_words_per_minute()
self.refresh_display(force_full_refresh=True)
+ def toggle_highlight(self, selections: list) -> str:
+ """
+ Highlight the words a selection touches, or take the highlight off.
+
+ `selections` is a list of (caption, start, end) with the offsets in the
+ caption's text, one entry per caption the selection reaches into. A
+ selection that reaches across blocks is one gesture and one undo step.
+
+ One key does both, so which it is depends on what is there: when every
+ word taken is already highlighted the highlight comes off, otherwise
+ everything taken gets one. A mixed selection therefore becomes fully
+ highlighted first -- the reader can press again to clear it.
+
+ Returns "added", "removed", "shown" (hidden highlights were only
+ shown again), or "" when nothing was selected (a notice says so).
+
+ Transcriptions only, for now. A set of subtitles is saved as plain
+ SRT, which has nowhere to keep a highlight, so it gets none: nothing
+ in the editor asks for one there, and this refuses if something does.
+ """
+
+ if self.data_format == "srt":
+ return ""
+
+ touched = []
+
+ for caption, start, end in selections:
+ positions = self.words_in_selection(caption.text, start, end)
+
+ if positions:
+ touched.append((caption, positions))
+
+ if not touched:
+ ui.notify(
+ "Select the words to highlight first",
+ type="warning",
+ timeout=None,
+ close_button="Close",
+ )
+ return ""
+
+ already = [
+ position in caption.highlighted_words
+ for caption, positions in touched
+ for position in positions
+ ]
+
+ # Hidden highlights are not something the key may take off: what
+ # cannot be seen should not be deleted. The command turns the display
+ # on instead, adds a highlight to any word taken that lacks one, and
+ # says what happened -- a second press, with them showing, is the one
+ # that removes.
+ if not self.show_highlights:
+ return self.reveal_highlights(touched, already)
+
+ remove = all(already)
+
+ self.save_state_for_undo()
+
+ for caption, positions in touched:
+ if remove:
+ caption.highlighted_words.difference_update(positions)
+ else:
+ caption.highlighted_words.update(positions)
+
+ # Kept with the transcription's own data, so it is a change to save.
+ self.mark_as_changed()
+
+ return "removed" if remove else "added"
+
+ def reveal_highlights(self, touched: list, already: list) -> str:
+ """
+ The highlight command while highlights are hidden.
+
+ Never removes anything. Words taken that have none get one; if they
+ all have one already, nothing changes and the notice says why nothing
+ seems to have happened. Returns "added" or "shown".
+ """
+
+ if all(already):
+ message = (
+ "Highlights were hidden and are shown again. "
+ "Press again to remove this highlight."
+ )
+ result = "shown"
+ else:
+ self.save_state_for_undo()
+
+ for caption, positions in touched:
+ caption.highlighted_words.update(positions)
+
+ self.mark_as_changed()
+
+ message = (
+ "Highlight added. Highlights were hidden and are shown again."
+ if not any(already)
+ else "Highlights were hidden and are shown again, and the "
+ "words without one were highlighted."
+ )
+ result = "added"
+
+ self.set_show_highlights(True)
+
+ # A toast is announced as it appears (Quasar gives it role=alert), so
+ # this is what tells a screen reader user why the key did what it did.
+ # It stays until closed, like the other notices (WCAG 2.2.1).
+ ui.notify(message, type="info", timeout=None, close_button="Close")
+
+ if self.on_highlights_shown is not None:
+ self.on_highlights_shown()
+
+ return result
+
def add_caption_after(self, caption: SRTCaption) -> None:
"""
Add a new caption after the selected one.
@@ -1270,9 +1428,7 @@ def update_caption_text(
self.save_state_for_undo()
# Worked out before the text is replaced, while there is still
# something to compare it against.
- caption.edited_words = self.retag_edits(
- caption.text, new_text, caption.edited_words
- )
+ self.carry_marks(caption, new_text)
caption.text = new_text
# Editing a word can take it off the count, or put one on it.
self.update_flagged_count()
@@ -1327,14 +1483,21 @@ def delete_range(
# retag_edits does with words a change brought in.
old_text = first.text
old_marks = set(first.edited_words)
+ old_highlights = set(first.highlighted_words)
for caption in self.captions[start + 1 : end + 1]:
old_marks = self.joined_edits(
old_marks, caption.edited_words, len(old_text.split())
)
+ old_highlights = self.joined_edits(
+ old_highlights, caption.highlighted_words, len(old_text.split())
+ )
old_text = "\n".join(part for part in (old_text, caption.text) if part)
first.edited_words = self.retag_edits(old_text, merged, old_marks)
+ first.highlighted_words = self.carry_highlights(
+ old_text, merged, old_highlights
+ )
first.text = merged
first.end_time = last.end_time
@@ -1378,6 +1541,11 @@ def merge_with_next(self, caption: SRTCaption) -> None:
next_caption.edited_words,
len(caption.text.split()),
)
+ caption.highlighted_words = self.joined_edits(
+ caption.highlighted_words,
+ next_caption.highlighted_words,
+ len(caption.text.split()),
+ )
# Only actually joined with a newline when both sides have text --
# an empty caption (freshly added, then merged straight back away
# with Backspace) has nothing to separate from the other, and a
@@ -1426,6 +1594,11 @@ def merge_with_previous(self, caption: SRTCaption) -> None:
caption.edited_words,
len(previous_caption.text.split()),
)
+ previous_caption.highlighted_words = self.joined_edits(
+ previous_caption.highlighted_words,
+ caption.highlighted_words,
+ len(previous_caption.text.split()),
+ )
# See the equivalent join in merge_with_next for why this is not
# always a bare "\n" join.
previous_caption.text = "\n".join(
diff --git a/utils/srt_export.py b/utils/srt_export.py
index d995c8b..8bf590d 100644
--- a/utils/srt_export.py
+++ b/utils/srt_export.py
@@ -160,6 +160,29 @@ class ExportMixin:
Export formats and the export dialog.
"""
+ def rtf_marks_available(self, others: list | None = None) -> tuple:
+ """
+ Whether there is anything for the RTF export to carry: (highlights,
+ changed words). Looked at across this editor and any others exported
+ along with it, so a bulk export offers a choice if any file has one.
+
+ Transcriptions only, for now: a set of subtitles does not carry its
+ marks into an export.
+ """
+
+ editors = [self] + list(others or [])
+ captions = [
+ caption
+ for editor in editors
+ if editor.data_format != "srt"
+ for caption in editor.captions
+ ]
+
+ return (
+ any(caption.highlighted_words for caption in captions),
+ any(caption.edited_words for caption in captions),
+ )
+
def export_rtf(
self,
speakers: bool,
@@ -167,11 +190,24 @@ def export_rtf(
block_nr: bool,
ts_which: str = "both",
ts_fmt: str = "srt",
+ highlights: bool = False,
+ edits: bool = False,
) -> str:
"""
Export captions to RTF format with proper Unicode handling.
+
+ `highlights` carries the reader's highlights as a yellow marker and
+ `edits` carries the words they changed, underlined and in the teal of
+ the editor's own marking -- underlined as well because a printout
+ may have no colour. Both are off by default, which is the clean
+ transcript: nothing about the text itself changes either way.
+
+ Transcriptions only, for now: subtitles never carry them.
"""
+ if self.data_format == "srt":
+ highlights = edits = False
+
def fmt_ts(ts: str, f: str) -> str:
"""
Format timestamp according to selected format.
@@ -210,8 +246,20 @@ def to_rtf_unicode(text: str) -> str:
)
return "".join(result)
+ # Colour 1 is the marker (pure yellow: it is what Word's own highlighter
+ # palette holds, so it is shown as drawn and not matched to the nearest
+ # colour), colour 2 the editor's teal for changed words. The table is
+ # only written when it is used, so a clean export is the document it
+ # always was.
+ colours = (
+ r"{\colortbl;\red255\green255\blue0;\red18\green121\blue106;}"
+ if highlights or edits
+ else ""
+ )
rtf_content = (
- r"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}" r"\viewkind4\uc1\pard\f0\fs20 "
+ r"{\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}"
+ + colours
+ + r"\viewkind4\uc1\pard\f0\fs20 "
)
parts = []
@@ -238,9 +286,24 @@ def to_rtf_unicode(text: str) -> str:
if header_parts:
rtf_data = r"\b " + " ".join(header_parts) + r"\b0\line "
- rtf_data += (
- to_rtf_unicode(caption.text).replace("\n", r"\line ") + r"\line\line "
- )
+ for text, highlighted, edited in self.marked_spans(
+ caption, highlights, edits
+ ):
+ body = to_rtf_unicode(text).replace("\n", r"\line ")
+
+ if highlighted or edited:
+ # A group, so the formatting ends with the piece. The one
+ # space after a control word is its delimiter and is not
+ # part of the text, so a piece that starts with a space
+ # keeps it.
+ style = (r"\highlight1" if highlighted else "") + (
+ r"\cf2\ul" if edited else ""
+ )
+ body = "{" + style + " " + body + "}"
+
+ rtf_data += body
+
+ rtf_data += r"\line\line "
parts.append(rtf_data)
@@ -542,6 +605,23 @@ def _close_and_delete() -> None:
rtf_idx_incl = ui.checkbox(
"Include paragraph numbers", value=False
)
+ # Off by default: an export is clean unless
+ # it is asked to carry the marks. And only
+ # offered for a kind of mark that exists in
+ # what is being exported -- a choice about
+ # nothing is just clutter. Transcriptions
+ # only, for now (see rtf_marks_available).
+ has_highlights, has_edits = self.rtf_marks_available(
+ [editor for _, editor in bulk_editors or []]
+ )
+ rtf_hl_incl = ui.checkbox(
+ "Include highlights", value=False
+ )
+ rtf_hl_incl.set_visibility(has_highlights)
+ rtf_edits_incl = ui.checkbox(
+ "Include my edits (underlined)", value=False
+ )
+ rtf_edits_incl.set_visibility(has_edits)
ui.separator()
# CSV options (for csv only)
@@ -1007,6 +1087,14 @@ def export_one(editor):
rtf_idx_incl.value,
ts_which.value,
ts_fmt.value,
+ highlights=bool(
+ rtf_hl_incl.visible
+ and rtf_hl_incl.value
+ ),
+ edits=bool(
+ rtf_edits_incl.visible
+ and rtf_edits_incl.value
+ ),
)
elif fmt.value == "txt":
parts = []
diff --git a/utils/srt_render.py b/utils/srt_render.py
index 34afae6..a91cf4a 100644
--- a/utils/srt_render.py
+++ b/utils/srt_render.py
@@ -712,8 +712,19 @@ def keyboard_shortcut_groups(self) -> list:
]
)
+ # The selection's own highlight. A transcription's alone, for now: a
+ # set of subtitles is plain SRT, which has nowhere to keep it.
+ highlighting = (
+ []
+ if subtitles
+ else [
+ ("Highlight selection, or remove the highlight", "Ctrl + Shift + H"),
+ ]
+ )
+
shortcut_groups = [
("Editing", editing),
+ *([("Highlighting", highlighting)] if highlighting else []),
(
"File operations",
[
diff --git a/utils/srt_review.py b/utils/srt_review.py
index e224be6..f95f2df 100644
--- a/utils/srt_review.py
+++ b/utils/srt_review.py
@@ -347,6 +347,178 @@ def joined_edits(first: set, second: set, offset: int) -> set:
return set(first) | {index + offset for index in second}
+ @staticmethod
+ def carry_highlights(previous: str, current: str, marked: set) -> set:
+ """
+ Carry a caption's highlights across a change to its text.
+
+ Unlike an edit mark, a highlight is the reader's note about a passage
+ and not about who wrote it, so a change does not earn one: typing a
+ new word somewhere does not highlight it. The reader's marks do have
+ to survive the corrections made inside a highlighted passage, though,
+ or fixing a typo would quietly take the highlight away.
+
+ - Words that stay move with their marks, however far they shift.
+ - Words replaced one for one keep the mark of the word they replace.
+ - A longer or shorter replacement is highlighted when everything it
+ replaced was.
+ - Words inserted between two highlighted words are inside the
+ highlight and take it; anywhere else they do not.
+ - Words that are gone take their highlight with them.
+ """
+
+ before = previous.split()
+ after = current.split()
+ carried: set = set()
+
+ matcher = SequenceMatcher(None, before, after, autojunk=False)
+
+ for tag, before_start, before_end, after_start, after_end in (
+ matcher.get_opcodes()
+ ):
+ if tag == "equal":
+ for offset in range(before_end - before_start):
+ if before_start + offset in marked:
+ carried.add(after_start + offset)
+ elif tag == "replace":
+ old = range(before_start, before_end)
+ new = range(after_start, after_end)
+
+ if len(old) == len(new):
+ carried.update(
+ after_start + offset
+ for offset, index in enumerate(old)
+ if index in marked
+ )
+ elif all(index in marked for index in old):
+ carried.update(new)
+ elif tag == "insert":
+ if before_start - 1 in marked and before_start in marked:
+ carried.update(range(after_start, after_end))
+
+ return carried
+
+ def marked_spans(
+ self, caption: SRTCaption, highlights: bool, edits: bool
+ ) -> list:
+ """
+ A caption's text as (text, highlighted, edited) pieces, for a format
+ that draws the marks itself -- the RTF export.
+
+ Not the editor's own runs (review_runs): those follow the switches
+ under the video, while an export is told what to carry. A piece's
+ text is whole words and the space between them, so concatenating the
+ pieces gives the caption's text back exactly.
+
+ The space between two marked words is marked with them, so a passage
+ is one stretch and not a row of words, as it is drawn in the editor.
+ A line break is left outside the marking, for the same reason as
+ there.
+ """
+
+ tokens = [token for token in re.split(r"(\s+)", caption.text) if token]
+ position = -1
+ positions: list = []
+
+ for token in tokens:
+ if token.strip():
+ position += 1
+ positions.append(position)
+ else:
+ positions.append(None)
+
+ def flags(at: int) -> tuple:
+ word = positions[at]
+
+ if word is not None:
+ return (
+ highlights and self.token_is_highlight(caption, word),
+ edits and self.token_is_edit(caption, word),
+ )
+
+ before = positions[at - 1] if at > 0 else None
+ after = positions[at + 1] if at + 1 < len(tokens) else None
+
+ if before is None or after is None or "\n" in tokens[at]:
+ return (False, False)
+
+ return (
+ highlights
+ and self.token_is_highlight(caption, before)
+ and self.token_is_highlight(caption, after),
+ edits
+ and self.token_is_edit(caption, before)
+ and self.token_is_edit(caption, after),
+ )
+
+ pieces: list = []
+
+ for at, token in enumerate(tokens):
+ highlighted, edited = flags(at)
+
+ if pieces and pieces[-1][1:] == (highlighted, edited):
+ pieces[-1] = (pieces[-1][0] + token, highlighted, edited)
+ else:
+ pieces.append((token, highlighted, edited))
+
+ return pieces
+
+ def carry_marks(self, caption: SRTCaption, new_text: str) -> None:
+ """
+ Carry both kinds of mark on a caption across a change to its text.
+
+ Called before the text itself is replaced, while there is still
+ something to compare the new text against.
+ """
+
+ caption.edited_words = self.retag_edits(
+ caption.text, new_text, caption.edited_words
+ )
+ caption.highlighted_words = self.carry_highlights(
+ caption.text, new_text, caption.highlighted_words
+ )
+
+ @staticmethod
+ def words_in_selection(text: str, start: int, end: int) -> list:
+ """
+ The positions of the words a selection touches, counted in
+ whitespace separated words from the start of the text.
+
+ A word is touched when the selection takes any of it, so a selection
+ that ends part way through a word takes the whole word: marking a
+ passage never cuts a word in two. A selection that only brushes a
+ word's edge, ending where it begins or starting where it ends, does
+ not touch it.
+
+ Nothing selected means the word the caret is in or against: either
+ edge counts, since the caret is as often just after a word as in it,
+ and no two words share an edge because whitespace separates them. A
+ caret in the space between words touches neither.
+ """
+
+ if end < start:
+ start, end = end, start
+
+ touched = []
+
+ for position, match in enumerate(re.finditer(r"\S+", text)):
+ if start == end:
+ if match.start() <= start <= match.end():
+ touched.append(position)
+ elif match.start() < end and match.end() > start:
+ touched.append(position)
+
+ return touched
+
+ @staticmethod
+ def token_is_highlight(caption: SRTCaption, index: int) -> bool:
+ """
+ Whether the reader has highlighted the word at this position.
+ """
+
+ return index in caption.highlighted_words
+
+
def speech_runs(self, gap: float = 0.3) -> List[list]:
"""
Where speech actually is, as [start, end] pairs.
@@ -433,7 +605,9 @@ def restore_review_state(
overlay and timeline default to True, unlike the markings: they show
what is already there rather than adding anything to read, so nothing
stored yet means a reader who has never touched either switch gets
- both on.
+ both on. The highlights switch is not here: hiding them is a way of
+ looking at the text for a while, not a preference, so every editor
+ opens with them shown (show_highlights, set in __init__).
"""
self.show_uncertain_words = bool(show)
@@ -467,6 +641,19 @@ def set_show_my_edits(self, show: bool) -> None:
self.refresh_display(force_full_refresh=True)
+ def set_show_highlights(self, show: bool) -> None:
+ """
+ Show or hide the reader's highlights.
+
+ Hiding only stops them being drawn and read out: the words stay
+ marked in the data, so they still follow the text through every
+ split, merge and move, and they are still in a save and in an export.
+ """
+
+ self.show_highlights = bool(show)
+ self.refresh_display(force_full_refresh=True)
+
+
def set_review_sensitivity(self, sensitivity: str) -> None:
"""
Choose how far up the confidence range to flag words.
@@ -668,17 +855,22 @@ def review_runs(
if not source:
return []
+ # Hidden highlights are not drawn: no run carries one, so nothing is
+ # marked up for a screen reader either. They stay in the caption.
+ highlighted = caption.highlighted_words if self.show_highlights else ()
+
if (
not self.show_uncertain_words
and not self.show_my_edits
and not per_word
+ and not highlighted
):
return [{"t": source, "flag": False}]
# A word has to be a run of its own before it can be marked at all, so
# marking the reader's words needs the same split that following the
- # audio does.
- one_run_per_word = per_word or self.show_my_edits
+ # audio does. So does a highlight, while they are shown.
+ one_run_per_word = per_word or self.show_my_edits or bool(highlighted)
words = self.aligned_words(caption, source)
runs: list = []
@@ -690,12 +882,44 @@ def flush() -> None:
runs.append({"t": "".join(plain), "flag": False})
plain.clear()
- for token in re.split(r"(\s+)", source):
- if not token:
- continue
+ tokens = [token for token in re.split(r"(\s+)", source) if token]
+
+ def is_highlighted(position: int) -> bool:
+ return self.show_highlights and self.token_is_highlight(
+ caption, position
+ )
+
+ # Which word each token is, so that the space between two highlighted
+ # words can be drawn highlighted too: a passage reads as one marked
+ # stretch, not a row of separate words.
+ word_position = -1
+ positions: list = []
+ for token in tokens:
+ if token.strip():
+ word_position += 1
+ positions.append(word_position)
+ else:
+ positions.append(None)
+
+ for at, token in enumerate(tokens):
if not token.strip():
- plain.append(token)
+ before = positions[at - 1] if at > 0 else None
+ after = positions[at + 1] if at + 1 < len(tokens) else None
+
+ # A line break is left unmarked: a highlight running out to the
+ # edge of the block would draw a stub at the end of the line.
+ if (
+ "\n" not in token
+ and before is not None
+ and after is not None
+ and is_highlighted(before)
+ and is_highlighted(after)
+ ):
+ flush()
+ runs.append({"t": token, "flag": False, "hl": True})
+ else:
+ plain.append(token)
continue
word = words[index] if index < len(words) else None
@@ -704,14 +928,19 @@ def flush() -> None:
# be honoured here too or words stay marked with them switched off.
flagged = self.show_uncertain_words and self.word_needs_review(word)
edited = self.show_my_edits and self.token_is_edit(caption, index - 1)
+ marked = is_highlighted(index - 1)
- if not one_run_per_word and not flagged and not edited:
+ if not one_run_per_word and not flagged and not edited and not marked:
plain.append(token)
continue
flush()
run = {"t": token, "flag": flagged}
+ # Only carried when true, like "edit".
+ if marked:
+ run["hl"] = True
+
# Only carried when true, so a run reads the same as it always did
# wherever nothing has been edited.
if edited:
@@ -719,7 +948,8 @@ def flush() -> None:
# Only a word still matching what the model transcribed has a
# timing we can attribute to it; an edited word has none, and nor
- # has a word transcribed without one. Neither is ever highlighted.
+ # has a word transcribed without one. Neither is ever lit up as the
+ # word being played.
if per_word and self.word_is_timed(word):
run["s"] = word["s"]
run["e"] = word["e"]
diff --git a/utils/srt_search.py b/utils/srt_search.py
index 95a27b7..f029ba0 100644
--- a/utils/srt_search.py
+++ b/utils/srt_search.py
@@ -127,11 +127,7 @@ def replace_in_current_caption(self, replacement: str) -> None:
# every mark after it along. Worked out here rather than through
# update_caption_text, which would take a second undo snapshot on
# top of the one already taken above.
- self.selected_caption.edited_words = self.retag_edits(
- self.selected_caption.text,
- new_text,
- self.selected_caption.edited_words,
- )
+ self.carry_marks(self.selected_caption, new_text)
self.selected_caption.text = new_text
self.update_flagged_count()
self.refresh_display()
@@ -171,9 +167,7 @@ def replace_all(self, replacement: str) -> None:
# of the reader's, and a replacement of a different length
# moves every mark after it. One undo snapshot covers the whole
# Replace All, so this cannot go through update_caption_text.
- caption.edited_words = self.retag_edits(
- caption.text, new_text, caption.edited_words
- )
+ self.carry_marks(caption, new_text)
caption.text = new_text
count += 1
diff --git a/utils/styles.py b/utils/styles.py
index 04fadfe..4f711d0 100644
--- a/utils/styles.py
+++ b/utils/styles.py
@@ -127,6 +127,15 @@
--color-edit-accent: #12796a;
--color-edit-text: #10453d;
+ /* Words the reader has highlighted for themselves. The one marking
+ that is yellow, as a highlighter is. Yellow on white is barely
+ brighter than the page (1.14:1) so the colour cannot carry it
+ alone: the frame lines carry it, 4.9:1 against the page (1.4.1,
+ 1.4.11), and the text on the yellow measures 13.6:1. */
+ --color-highlight-bg: #fff1a3;
+ --color-highlight-frame: #8a6d00;
+ --color-highlight-text: #2b2400;
+
/* The word currently being spoken. A pale tint of the brand
navy, so it reads as "you are here" rather than as a status. */
--color-playing-bg: #dbe4f0;
@@ -252,6 +261,12 @@
--color-edit-accent: #6fd3c1;
--color-edit-text: #dff5f0;
+ /* Dark mode keeps the yellow as a dark gold. Text on it 8.0:1, the
+ frame 12.6:1 against the surface. */
+ --color-highlight-bg: #5a4a00;
+ --color-highlight-frame: #ffd54f;
+ --color-highlight-text: #fff6cc;
+
--color-playing-bg: #1c3252;
--color-header-bg: #16181d;
@@ -1372,6 +1387,64 @@
box-shadow: inset 0 -2px 0 var(--color-edit-accent);
}
+ /* Words the reader has highlighted. Yellow, like a highlighter, with a
+ line above and below so it does not depend on the colour: it is a
+ different shape from the other two markings, which are a wash with a
+ bar underneath. No horizontal padding, unlike them, so highlighting
+ a passage does not move the text -- and a run of highlighted words
+ with the spaces between them reads as one stretch.
+
+ On a flagged or changed word the highlight takes the wash and the text
+ colour and keeps its own frame, line above and line below, exactly as
+ on any other highlighted word; the word's own bar is drawn outside it,
+ 3px below. Letting the bar take the place of the frame's lower line
+ made a flagged highlight read as a flagged word on a yellow wash, and
+ was hard to tell from a plain highlight.
+ While the word is being played the playing tint wins the wash but the
+ lines stay, so the passage can still be seen under the playback. */
+ .highlight-word {
+ padding: 1px 0;
+ color: var(--color-highlight-text);
+ background-color: var(--color-highlight-bg);
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame);
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+ }
+ .highlight-word.review-word {
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame),
+ 0 3px 0 var(--color-review-accent);
+ }
+ .highlight-word.edit-word {
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame),
+ 0 3px 0 var(--color-edit-accent);
+ }
+ .transcript-text .highlight-word[data-current] {
+ background-color: var(--color-playing-bg);
+ color: inherit;
+ border-radius: 0;
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame);
+ }
+ .transcript-text .highlight-word.review-word[data-current] {
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame),
+ 0 3px 0 var(--color-review-accent);
+ }
+ .transcript-text .highlight-word.edit-word[data-current] {
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame),
+ 0 3px 0 var(--color-edit-accent);
+ }
+
/* Shared hover message. A CSS box rather than title=, which the browser
draws itself and stylesheets cannot reach.
@@ -1451,6 +1524,30 @@
.review-word[data-changed]::after {
content: none;
}
+
+ /* A word typed between two highlighted words, until the next render says
+ so itself (highlightNewWord in transcript_editor.js): drawn as a
+ highlight, with an attribute because Vue owns the class. Written as
+ span[...] so it outranks .transcript-show-edits [data-changed], which
+ comes later and would otherwise paint the word as a plain edit. */
+ .transcript-text span[data-highlighted] {
+ padding: 1px 0;
+ color: var(--color-highlight-text);
+ background-color: var(--color-highlight-bg);
+ border-radius: 0;
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame);
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+ }
+ .transcript-show-edits .transcript-text span[data-highlighted][data-changed] {
+ border-radius: 3px;
+ box-shadow:
+ inset 0 2px 0 var(--color-highlight-frame),
+ inset 0 -2px 0 var(--color-highlight-frame),
+ 0 3px 0 var(--color-edit-accent);
+ }
.transcript-show-edits [data-changed] {
/* Stated here as well as on .edit-word: a word that was not flagged
carries neither class, so it has none of the shared geometry. Not
diff --git a/utils/transcript_editor.js b/utils/transcript_editor.js
index 814e30f..506d745 100644
--- a/utils/transcript_editor.js
+++ b/utils/transcript_editor.js
@@ -205,11 +205,11 @@ export default {
>