diff --git a/AGENTS.md b/AGENTS.md index 3e22e04..18a541e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,34 @@ 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, since it is drawn with no switch on); 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 takes the wash and the word keeps its own bar underneath; 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. +- Tests: `tests/test_highlights.py`. + +### Comments (SUNET/scribe-ui#131, the second half) + +A note on a stretch of words, with a text of its own: `SRTCaption.comments`, a list of `WordComment(words, text)` (`utils/caption.py`), the words counted exactly as the highlight's are. **Stacked on the highlights branch**, and it reuses its rules: `CommentsMixin` (`utils/srt_comments.py`) carries each comment's words with `carry_highlights()` -- so the two kinds of note cannot drift apart -- and `carry_marks()` carries comments too, which covers typing, split with unsent text and Find & Replace. Split divides the list (`split_comments`; a comment across the split is on both halves with the same text, as separate copies), merge and `delete_range` join it (`join_comments`; neighbouring comments stay separate notes), and the one-word moves (`move_first_word_to_previous`, `move_last_word_to_next`) divide and join it with the same two helpers: **a comment stays on its word wherever the word goes**, so one on the moved word alone goes with it, and one on that word and others is on both sides (a copy with the same text). A comment with no words left is gone, with its words. Any new place that rewrites `caption.text` has to go through `carry_marks`. + +- **Rules.** A comment belongs to **one caption**: a selection reaching across blocks is refused with a notice (a selection that merely brushes the edge of the next block is not across). A word has **at most one comment**: asking to comment on words that already have one opens that comment (`comment_target()`), never a second over it, so no drawing, navigation or export has to deal with overlap; `restore_comments()` enforces it on read too. Text is stripped, never empty (Save is disabled, `set_comment()` returns "" for it) and at most `COMMENT_MAX_LENGTH` (1000) characters. Transcriptions only for now: the key is not bound in `subtitleMode`, there is no panel for `data_format == "srt"`, `set_comment`/`remove_comment` refuse, the dialog group is absent and the export option is not offered -- the same reason as highlights (SRT has nowhere to keep it). +- **Saved** as `"comments": [{"words": [...], "text": "..."}]` per segment, only when there are any. `restore_comments(saved, text)` ignores anything unusable (junk shapes, empty text, non-int or bool or negative or out-of-range positions, a word already taken by an earlier comment) and cuts an over-long text. +- **The panel** (`utils/comments_panel.py`, `CommentsPanel`) is where a comment is written, read and changed. It is a card in the right column under the video, built like `ValidationPanel` (same `validation-panel*` classes, `.is-comment` sets `--vp-tone` to the comment colour): a head with Previous / position / Next and Close, then the words (a button that goes to them), where they are (`where_text()`: the speaker and the start time, as the editor shows them beside the text -- **not a paragraph number, which a transcription does not show**), a text area and Remove / Cancel / Save. **One comment at a time**, shut until needed. `pages/srt.py` builds it for transcriptions only (`transcript.comments_panel`), and `TranscriptEditor.refresh()` ends with `comments_panel.sync()` so a comment that an edit or an undo replaced is found again and one that is gone hides the card. **`sync()` draws the card again every time** (`draw(scroll=False)`; the text box is left alone, so what is being typed is kept), because the speaker and the time it shows come from the caption: a renamed speaker has to show. `TranscriptEditor.set_text()` calls it too, since the block being typed into is not rendered again and no `refresh()` follows: without it a comment whose words were all deleted stayed on the card. A comment is found again by `find_again()`: first by the same words (an undo puts back a copy), then, because an edit builds *new* `WordComment`s with moved words, by the same text and the most words in common. +- **The panel's Remove and Cancel are flat buttons and take `--color-brand-primary` for their text in light mode.** Quasar's own primary blue (#5898d4) is 3.06:1 on the card's white, under the 4.5:1 that 14px text needs (WCAG 1.4.3); dark mode needs nothing, the global flat-button rule draws them white. +- **Opening it.** Ctrl+Shift+K (Ctrl only, by `event.code === "KeyK"`, no Alt/Meta) or the toolbar's **Comment** button (`sym_o_add_comment`, beside Highlight, `mousedown` prevented so the selection survives) -> `commentSelection()` -> `opencomment {spans}` -> `TranscriptEditor.open_comment()` -> `CommentsPanel.open_for()`, which shows the existing comment on those words or an empty card for a new one, with focus in the text area. A file that has comments opens with the first one on the card (`open_first()`, no focus, text not moved). +- **The caret drives the card** (the same idea as the validation cards). The component reports `caretcomment {id, cm, click}` **once per comment** when the caret enters one (`reportCommentCaret()`, called from `updateCaretBlock()` and from `onClick` with `click: true`), and `CommentsPanel.follow()` shows it. An open card follows the caret; a **shut** card opens only for a *click*, so an arrow key crossing commented words does not throw a card up under someone typing; the comment already shown is left alone. A selection that is not collapsed reports nothing (a drag to copy words does not open it). +- **Marking where the card is, in the text.** `CommentsPanel.mark()` (called from `draw()` and from `sync()`, cleared by `hide()`) tells the component, through `TranscriptEditor.mark_comment()` and the props `commentId` / `commentIndex`, which caption and which comment the card is on. The caption gets `.transcript-cell-commenting` (a 3px rule and a 2px outline in the comment colour, like the validation panel's `-reviewing`, after the editing/invalid rules so it wins) and the words of the comment get a `data-comment-current` attribute (`isCommentOnShow()`), drawn as an 18% tint of the comment colour in a `background-image`, which paints above `background-color`, so a highlight's, flagged or changed word's own wash shows through. An attribute and not a class so `runClass()` is untouched. A comment being written marks its caption only. Previous/Next also scroll the caption to the middle (`scroll_to_block`) without moving the caret or taking focus, so the buttons can be pressed again. Contrast: text at least 6.7:1 (light) / 5.9:1 (dark) on the tint over every wash, the comment line at least 4.3:1 / 3.4:1; pinned in tests. +- **What is typed is kept when the reader moves on** (`commit()`): to another comment, into the text, with Previous/Next, or shutting the card -- if it is non-empty and different from what was there. Escape (in the text area) and Cancel are the ways to abandon; Ctrl/Cmd+Enter and Save keep it. Previous/Next are disabled while a new comment is pending. **Keys typed in the text area do not reach the page** (a `keydown` handler with `stopPropagation`; NiceGUI's `ui.keyboard(ignore=[])` is global and listens on the document, so otherwise Ctrl+Z would undo the transcript while a sentence is being written and Ctrl+S would save). After Save, Cancel and Remove the selection goes back on the words (`select_words`). A `sr-only` live label (`spoken`) says "Comment 2 of 5" and the like. +- **Drawing.** `review_runs()` gives a commented word `cm` (the comment's index in the caption); the first word also `cf` and `ct` (the text), the last `cl`. The space between two words of the same comment is in it (not across a line break, not between two comments). A word is drawn with `comment-word`: a **dotted 3px underline, 4px below the text**, in `--color-comment-line` (`#a1136a` light, `#ff9fd2` dark: 6.3-9.4:1 on every wash a word can have, except 4.6:1 on the dark highlight, all above 3:1). A pattern and not colour alone (1.4.1), drawn with `text-decoration` so it takes no room, touches no box-shadow (the other markings keep theirs) and needs no `::after` -- which the review/edit words already use for a hover message that the editor turns off to protect the caret, and a marker after the last word would sit where the caret goes. It is thick and offset because a thin line close to the text was lost against the highlight's own frame lines on words that are both highlighted and commented (rendered in Chromium, both themes, wrapped and not). The comment colour is magenta, not blue: blue is the progress/playing tint. The first word has `aria-description="Comment: "`; **not tested with a screen reader**. +- **RTF export** (`export_rtf(..., comments=False)`, an "Include comments" checkbox, **off by default and hidden when there is none**, `rtf_comments_available()`, bulk exports included): each comment is written as a Word comment -- `{\*\atrfstart N}` before its words, `{\*\atrfend N}{\*\atnid SC}{\*\atnauthor Scribe}\chatn{\*\annotation{\*\atnref N}{\*\atndate D}\pard\plain\f0\fs20 text}` after (`rtf_date()` packs the date as RTF wants it). The pieces of `marked_spans()` are cut where a comment begins or ends so a marker never lands inside a piece; the text between the markers is the caption's own text throughout. Author is the constant "Scribe", **on purpose**: the username is available (`utils/token.get_user_info()`), but it would put a personal identifier into every exported file, and nobody else can write or export a user's comments (files are not shared between users), so there is no one to tell apart. Do not add it without deciding that again. Checked by converting an export with LibreOffice and by opening it in Word (comments by "Scribe" with the right ranges). Highlights show in the export only with "Include highlights" ticked, which is separate. A clean export is byte-for-byte unchanged. +- **A switch hides them (Comments, 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 comment forgotten for days is worse than switching them off again (there is deliberately no storage key and no count on the switch); `TranscriptEditor.set_show_comments` re-renders. Hidden means **not drawn and not read out**: `review_runs()` gives no run `cm`/`cf`/`ct`/`cl`, so nothing carries `data-comment` or an `aria-description`, and the caret has no commented word to report (`caretcomment` never comes; `CommentsPanel.follow` and `open_first` also refuse while hidden). The comments stay on their words in the data, follow every split, merge, move and edit silently, and are still saved. **Hiding puts the card away** (`CommentsPanel.put_away()`: `commit()` first, so nothing typed is lost, then `hide()`; focus stays on the switch), so Previous, Next and Remove cannot act on comments the reader cannot see; switching them on again does not open it. **The command while they are hidden** (`open_for` -> `reveal()`): turns the display on (moving the switch through `editor.on_comments_shown`, which the page sets; the page's handler skips the redraw when the command already did it) and then does what it always does -- opens the comment on those words, or a new one to be written (nothing is made until Save). It removes nothing. It says "Comments were hidden and are shown again." in 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). A refused command (no word taken, a selection across paragraphs) turns nothing on. Nothing is said while they are shown. The RTF checkbox "Include comments" is independent of the switch. +- Tests: `tests/test_comments.py`, `tests/test_comment_visibility.py`. The panel's behaviour (type/Save/Remove/Esc/Ctrl+Enter/Cmd+Enter/Close, follow rules, sync) was driven with NiceGUI's `User` simulation, and the component (key, caret reports, attributes) in jsdom against the real component; jsdom has no layout, so nothing about position or focus order is checked there. **Not yet tried by hand in a browser**: focus moving into and out of the card, the caret following in a real Selection, Ctrl+Shift+K being free in the reader's browser, tab order (the card is after the video in the DOM; no skip link was added), and screen readers. + ## 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..93aeea1 100644 --- a/pages/srt.py +++ b/pages/srt.py @@ -37,6 +37,7 @@ TIMELINE_SHOW_KEY, ) from utils.speech_timeline import SpeechTimeline +from utils.comments_panel import CommentsPanel from utils.validation_panel import ValidationPanel from utils.transcript_editor import TranscriptEditor from utils.video import create_video_proxy @@ -256,6 +257,52 @@ 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() + ) + + # Beside it: a note on the selected words. The same as + # Ctrl+Shift+K, and for the same reason it does not take + # focus on a mouse press. + with ui.button("Comment", icon="sym_o_add_comment").props( + "flat aria-keyshortcuts=Control+Shift+K" + ).classes( + "editor-btn editor-toolbar-btn editor-quiet" + ) as comment_button: + comment_button.tooltip( + "Comment on the selection, or open its comment " + "(Ctrl + Shift + K)" + ) + comment_button.on( + "mousedown", js_handler="(event) => event.preventDefault()" + ) + comment_button.on( + "click", lambda: transcript.comment_selection() + ) with ui.row().classes("editor-toolbar-group"): editor.show_keyboard_shortcuts() @@ -797,6 +844,50 @@ def save_show_edits(event) -> None: "changed" ) + # Only a transcription can hold comments; a + # set of subtitles is saved as plain SRT. + if data_format != "srt": + + def save_show_comments(event) -> None: + value = bool(event.sender.value) + + # The comment command turns this on + # itself and moves the switch to + # match, which lands here too. + if value != editor.show_comments: + transcript.set_show_comments(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) + + comments_switch = ui.switch( + "Comments", + value=editor.show_comments, + ).props("dense").classes( + "editor-switch comments-switch" + ) + # Same reason as the switches above: + # "click" is mouse-only on a QToggle, + # and update:model-value fires for a + # keyboard activation too. + comments_switch.on( + "update:model-value", save_show_comments + ) + with comments_switch: + ui.tooltip( + "Show the comments on words. " + "Hiding them keeps them." + ) + + def comments_shown() -> None: + comments_switch.value = True + + editor.on_comments_shown = comments_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 @@ -928,3 +1019,12 @@ def save_sensitivity(event) -> None: transcript.on_caret = ( editor.validation_panel.follow_caret ) + else: + # Its counterpart in a transcription: the card + # where comments are written and read, drawn like + # the validation card and following the caret the + # same way. Shut until needed -- except in a file + # that already has comments, which opens on the + # first. + transcript.comments_panel = CommentsPanel(transcript) + transcript.comments_panel.open_first() diff --git a/tests/test_comment_visibility.py b/tests/test_comment_visibility.py new file mode 100644 index 0000000..26e89e7 --- /dev/null +++ b/tests/test_comment_visibility.py @@ -0,0 +1,468 @@ +# 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 comments. + +Hiding draws nothing and reads out nothing, and puts the comments card away, +but keeps every comment on its words. What cannot be seen is never changed: +the comment command turns the display back on and then does what it always +does. +""" + +import pathlib + +import pytest + +from tests.test_highlights import make_editor +from tests.test_comments import ( # noqa: F401 (fixtures) + FakeBody, + comment, + editor, + panel, + shape, + view, +) + + +@pytest.fixture +def notices(editor, monkeypatch): + """ + Every notice with its options, whoever raises it. + """ + + seen = [] + monkeypatch.setattr( + "utils.comments_panel.ui.notify", + lambda message, **kwargs: seen.append((message, kwargs)), + ) + editor.refreshes = 0 + + def refresh(*args, **kwargs): + editor.refreshes += 1 + + editor.refresh_display = refresh + + return seen + + +def hide(view, editor): + view.set_show_comments(False) + editor.refreshes = 0 + editor.snapshots = 0 + + +class TestThePreference: + def test_every_editor_opens_with_them_shown(self, editor): + assert editor.show_comments is True + + editor.restore_review_state(False, "low") + + assert editor.show_comments 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_comments(False) + + assert make_editor().show_comments is True + + with pytest.raises(TypeError): + editor.restore_review_state(False, "low", comments=False) + + def test_there_is_no_stored_choice(self): + import utils.srt + import utils.srt_review + + assert not hasattr(utils.srt, "COMMENTS_SHOW_KEY") + assert not hasattr(utils.srt_review, "COMMENTS_SHOW_KEY") + + def test_switching_redraws(self, editor, notices): + editor.set_show_comments(False) + + assert editor.show_comments is False + assert editor.refreshes == 1 + + +class TestDrawing: + def test_hidden_comments_are_not_runs(self, view, editor, notices): + first = editor.captions[0] + first.comments = [comment({1, 2}, "there")] + hide(view, editor) + + runs = editor.review_runs(first) + + assert not any("cm" in run for run in runs) + assert not any("ct" in run for run in runs) + assert runs == [{"t": "ett två tre", "flag": False}] + + def test_the_space_inside_a_hidden_comment_is_not_drawn_either( + self, view, editor, notices + ): + first = editor.captions[0] + first.comments = [comment({0, 1, 2}, "there")] + hide(view, editor) + + assert not any("cm" in run for run in editor.review_runs(first)) + + def test_they_come_back_as_they_were(self, view, editor, notices): + first = editor.captions[0] + first.comments = [comment({1, 2}, "there")] + hide(view, editor) + view.set_show_comments(True) + + runs = editor.review_runs(first) + + assert [run["t"] for run in runs if "cm" in run and run["t"].strip()] == [ + "två", + "tre", + ] + assert [run["ct"] for run in runs if run.get("cf")] == ["there"] + + def test_the_data_is_untouched_while_hidden(self, view, editor, notices): + first = editor.captions[0] + first.comments = [comment({1, 2}, "there")] + hide(view, editor) + + assert shape(first) == [([1, 2], "there")] + assert first.to_dict()["comments"] + + def test_highlights_are_not_affected(self, view, editor, notices): + first = editor.captions[0] + first.comments = [comment({1, 2}, "there")] + first.highlighted_words = {0} + hide(view, editor) + + runs = editor.review_runs(first) + + assert [run["t"] for run in runs if run.get("hl")] == ["ett"] + + def test_hidden_comments_do_not_force_a_run_per_word( + self, view, editor, notices + ): + first = editor.captions[0] + first.comments = [comment({1, 2}, "there")] + hide(view, editor) + + assert len(editor.review_runs(first)) == 1 + + +class TestHidingPutsTheCardAway: + def test_an_open_card_is_shut(self, view, panel, editor, notices): + editor.captions[0].comments = [comment({1, 2}, "there")] + panel.open_for(editor.captions[0], 5, 5) + + view.set_show_comments(False) + + assert panel.container.visible is False + assert panel.current is None + assert view.body.comment_mark == (-1, -1) + + def test_what_was_typed_is_kept_like_when_the_card_is_closed( + self, view, panel, editor, notices + ): + editor.captions[0].comments = [comment({1, 2}, "there")] + panel.open_for(editor.captions[0], 5, 5) + panel.text.value = "there, and more" + + view.set_show_comments(False) + + assert shape(editor.captions[0]) == [([1, 2], "there, and more")] + + def test_a_comment_being_written_is_kept_too( + self, view, panel, editor, notices + ): + panel.open_for(editor.captions[0], 5, 9) + panel.text.value = "new" + + view.set_show_comments(False) + + assert shape(editor.captions[0]) == [([1, 2], "new")] + assert panel.pending is None + + def test_an_empty_new_comment_is_simply_dropped( + self, view, panel, editor, notices + ): + panel.open_for(editor.captions[0], 5, 9) + + view.set_show_comments(False) + + assert editor.captions[0].comments == [] + assert panel.container.visible is False + + def test_showing_them_does_not_open_the_card(self, view, panel, editor, notices): + editor.captions[0].comments = [comment({1, 2}, "there")] + hide(view, editor) + + view.set_show_comments(True) + + assert panel.container.visible is False + + def test_a_file_opened_with_comments_shows_no_card_while_hidden( + self, view, panel, editor, notices + ): + editor.captions[0].comments = [comment({1, 2}, "there")] + editor.show_comments = False + + panel.open_first() + + assert panel.container.visible is False + + def test_the_caret_in_a_hidden_comment_opens_nothing( + self, view, panel, editor, notices + ): + editor.captions[0].comments = [comment({1, 2}, "there")] + editor.show_comments = False + + view.caret_comment({"id": 1, "cm": 0, "click": True}) + + assert panel.container.visible is False + + +class TestTheCommandWhileTheyAreShown: + def test_it_opens_the_card_as_before_with_no_notice( + self, view, panel, editor, notices + ): + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert panel.container.visible is True + assert notices == [] + + +class TestTheCommandWhileTheyAreHidden: + def test_over_a_hidden_comment_it_shows_them_and_opens_that_one( + self, view, panel, editor, notices + ): + editor.captions[0].comments = [comment({1, 2}, "there")] + hide(view, editor) + + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert editor.show_comments is True + assert panel.current[1] is editor.captions[0].comments[0] + assert panel.text.value == "there" + assert panel.container.visible is True + + def test_it_changes_nothing_in_the_data(self, view, panel, editor, notices): + editor.captions[0].comments = [comment({1, 2}, "there")] + hide(view, editor) + + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert shape(editor.captions[0]) == [([1, 2], "there")] + assert editor.snapshots == 0 + + def test_over_words_without_one_it_shows_them_and_opens_a_new_one( + self, view, panel, editor, notices + ): + hide(view, editor) + + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert editor.show_comments is True + assert panel.pending == (editor.captions[0], 4, 11) + # Nothing is made until it is saved. + assert editor.captions[0].comments == [] + + def test_saving_it_then_adds_the_comment(self, view, panel, editor, notices): + hide(view, editor) + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + panel.text.value = "new" + + panel.save() + + assert shape(editor.captions[0]) == [([1, 2], "new")] + + def test_nothing_selected_says_so_and_leaves_them_hidden( + self, view, panel, editor, notices + ): + editor.captions[0].comments = [comment({1, 2}, "there")] + hide(view, editor) + + view.open_comment({"spans": []}) + + assert editor.show_comments is False + # Only the usual warning: nothing was turned on, so nothing is said + # about it. + assert [n[0] for n in notices] == ["Select the words to comment on first"] + + def test_a_caret_between_words_leaves_them_hidden( + self, view, panel, editor, notices + ): + editor.captions[0].text = "ett tre" + hide(view, editor) + + view.open_comment({"spans": [{"id": 1, "start": 4, "end": 4}]}) + + assert editor.show_comments is False + assert [n[0] for n in notices] == ["Select the words to comment on first"] + + def test_a_selection_across_paragraphs_leaves_them_hidden( + self, view, panel, editor, notices + ): + hide(view, editor) + + view.open_comment( + { + "spans": [ + {"id": 1, "start": 4, "end": 11}, + {"id": 2, "start": 0, "end": 4}, + ] + } + ) + + assert editor.show_comments is False + assert panel.container.visible is False + + def test_a_second_press_with_them_shown_is_the_normal_command( + self, view, panel, editor, notices + ): + editor.captions[0].comments = [comment({1, 2}, "there")] + hide(view, editor) + args = {"spans": [{"id": 1, "start": 5, "end": 9}]} + + view.open_comment(args) + notices.clear() + view.open_comment(args) + + assert notices == [] + assert panel.current[1] is editor.captions[0].comments[0] + + +class TestWhatTheReaderIsTold: + def test_the_notice(self, view, panel, editor, notices): + hide(view, editor) + + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert [n[0] for n in notices] == ["Comments were hidden and are shown again."] + + def test_it_stays_until_closed_like_the_other_notices( + self, view, panel, editor, notices + ): + hide(view, editor) + + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert notices[0][1]["timeout"] is None + assert notices[0][1]["close_button"] == "Close" + + def test_the_switch_is_told_so_it_follows(self, view, panel, editor, notices): + told = [] + editor.on_comments_shown = lambda: told.append(True) + hide(view, editor) + + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert told == [True] + + def test_the_switch_is_not_told_when_the_reader_flips_it( + self, view, panel, editor, notices + ): + told = [] + editor.on_comments_shown = lambda: told.append(True) + + view.set_show_comments(False) + view.set_show_comments(True) + + assert told == [] + + +class TestEditingWhileHidden: + """ + Hidden is only about drawing: the comments follow the text as they always + do, and nothing is said about it. + """ + + def test_splitting_divides_them_between_the_halves( + self, view, editor, notices + ): + first = editor.captions[0] + first.comments = [comment({0}, "one"), comment({2}, "three")] + hide(view, editor) + + editor.split_caption(first, cursor_position=7) + + assert shape(editor.captions[0]) == [([0], "one")] + assert shape(editor.captions[1]) == [([0], "three")] + assert notices == [] + assert editor.show_comments is False + + def test_a_word_moved_takes_its_comment_along(self, view, editor, notices): + first, second = editor.captions + second.comments = [comment({0}, "moves")] + hide(view, editor) + + editor.move_first_word_to_previous(second) + + assert shape(first) == [([3], "moves")] + assert second.comments == [] + assert notices == [] + + def test_merging_joins_them(self, view, editor, notices): + first, second = editor.captions + first.comments = [comment({2}, "a")] + second.comments = [comment({0}, "b")] + hide(view, editor) + + editor.merge_with_next(first) + + assert shape(editor.captions[0]) == [([2], "a"), ([3], "b")] + assert editor.show_comments 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"') + comments = source.index('"Comments"') + guard = source.rindex('if data_format != "srt":', 0, comments) + + assert edits < guard < comments + + def test_it_is_not_saved_between_sessions(self): + source = self.page() + + assert "COMMENTS_SHOW_KEY" not in source + assert "srt_show_comments" not in source + + def test_it_listens_for_the_keyboard_too(self): + assert '"update:model-value", save_show_comments' in self.page() + + def test_the_command_can_move_the_switch(self): + source = self.page() + + assert "editor.on_comments_shown = comments_shown" in source + assert "comments_switch.value = True" in source + + def test_the_page_does_not_redraw_twice_when_the_command_moved_it(self): + assert "if value != editor.show_comments:" in self.page() + + def test_the_component_needs_no_flag_of_its_own(self): + # The server leaves hidden comments out of the runs, so nothing is + # drawn, nothing carries data-comment or an aria-description, and the + # caret has no commented word to report. + assert "showComments" not in self.js() diff --git a/tests/test_comments.py b/tests/test_comments.py new file mode 100644 index 0000000..c96210b --- /dev/null +++ b/tests/test_comments.py @@ -0,0 +1,2123 @@ +# 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 comments: notes on a stretch of words, kept with their own text, +carried through every edit that moves words, saved with a transcription, +drawn, opened in a box, and optionally exported as Word comments. +""" + +import json +import pathlib +import re +from datetime import datetime + +import pytest + +from tests.test_highlights import make_editor +from utils.caption import SRTCaption, WordComment +from utils.srt_comments import COMMENT_MAX_LENGTH +from utils.transcript_editor import TranscriptEditor + + +def comment(words, text="note"): + return WordComment(words, text) + + +def shape(caption): + """ + A caption's comments as plain data, to compare. + """ + + return [(sorted(c.words), c.text) for c in caption.comments] + + +@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 TestTheModel: + def test_a_copy_does_not_share_the_comments(self): + caption = SRTCaption(1, "00:00:00,000", "00:00:01,000", "a b") + caption.comments = [comment({0}, "one")] + + copy = caption.copy() + copy.comments[0].words.add(1) + copy.comments[0].text = "changed" + copy.comments.append(comment({1})) + + assert shape(caption) == [([0], "one")] + + 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 "comments" not in caption.to_dict() + + caption.comments = [comment({1, 0}, "why")] + + assert caption.to_dict()["comments"] == [{"words": [0, 1], "text": "why"}] + + +class TestSavedWithTheTranscription: + def reopen(self, editor, mutate=None): + data = editor.export_json() + + if mutate: + mutate(data) + + reopened = make_editor() + reopened.captions = [] + reopened.speakers = set() + reopened.parse_txt(json.dumps(data)) + + return reopened + + def test_they_come_back_when_the_file_is_opened_again(self, editor): + editor.captions[0].comments = [comment({0, 1}, "first"), comment({2}, "last")] + editor.captions[1].comments = [comment({1}, "båda åäö")] + + reopened = self.reopen(editor) + + assert [shape(c) for c in reopened.captions] == [ + [([0, 1], "first"), ([2], "last")], + [([1], "båda åäö")], + ] + + def test_a_file_saved_before_this_existed_opens_without_any(self, editor): + reopened = self.reopen(editor) + + assert all(not caption.comments for caption in reopened.captions) + + def test_all_the_kinds_of_mark_are_kept_apart(self, editor): + first = editor.captions[0] + first.edited_words = {0} + first.highlighted_words = {1} + first.comments = [comment({2}, "c")] + + reopened = self.reopen(editor) + + assert reopened.captions[0].edited_words == {0} + assert reopened.captions[0].highlighted_words == {1} + assert shape(reopened.captions[0]) == [([2], "c")] + + @pytest.mark.parametrize( + "saved", + [ + "all of it", + None, + 5, + {}, + [None, 3, "x", []], + [{"words": [0]}], + [{"text": "no words"}], + [{"words": [0], "text": ""}], + [{"words": [0], "text": " "}], + [{"words": [0], "text": 5}], + [{"words": "0", "text": "x"}], + [{"words": [], "text": "x"}], + [{"words": ["a", None, True, -1, 2.5, 99], "text": "x"}], + ], + ) + def test_junk_in_the_file_is_ignored(self, editor, saved): + def mutate(data): + data["segments"][0]["comments"] = saved + + reopened = self.reopen(editor, mutate) + + assert reopened.captions[0].comments == [] + assert [c.text for c in reopened.captions] == ["ett två tre", "fyra fem sex"] + + def test_a_good_comment_survives_bad_company(self, editor): + def mutate(data): + data["segments"][0]["comments"] = [ + "junk", + {"words": [True, 1, "x"], "text": " kept "}, + ] + + reopened = self.reopen(editor, mutate) + + assert shape(reopened.captions[0]) == [([1], "kept")] + + def test_a_word_has_only_one_comment_whatever_the_file_says(self, editor): + def mutate(data): + data["segments"][0]["comments"] = [ + {"words": [0, 1], "text": "first"}, + {"words": [1, 2], "text": "second"}, + ] + + reopened = self.reopen(editor, mutate) + + assert shape(reopened.captions[0]) == [([0, 1], "first"), ([2], "second")] + + def test_a_comment_on_words_that_are_not_there_is_dropped(self, editor): + def mutate(data): + data["segments"][0]["comments"] = [{"words": [7, 8], "text": "stale"}] + + assert self.reopen(editor, mutate).captions[0].comments == [] + + def test_an_over_long_text_is_cut_to_the_limit(self, editor): + def mutate(data): + data["segments"][0]["comments"] = [ + {"words": [0], "text": "x" * (COMMENT_MAX_LENGTH + 50)} + ] + + reopened = self.reopen(editor, mutate) + + assert len(reopened.captions[0].comments[0].text) == COMMENT_MAX_LENGTH + + +class TestTargeting: + def test_a_selection_takes_whole_words(self, editor): + target = editor.comment_target(editor.captions[0], 5, 9) + + assert target == {"index": None, "text": "", "start": 4, "end": 11} + + def test_a_caret_takes_the_word_it_is_in(self, editor): + target = editor.comment_target(editor.captions[0], 5, 5) + + assert (target["start"], target["end"]) == (4, 7) + + def test_no_word_means_no_target(self, editor): + editor.captions[0].text = "ett tre" + + assert editor.comment_target(editor.captions[0], 4, 4) is None + + def test_words_that_have_a_comment_open_that_comment(self, editor): + first = editor.captions[0] + first.comments = [comment({1, 2}, "there")] + + target = editor.comment_target(first, 0, 5) + + assert target == {"index": 0, "text": "there", "start": 4, "end": 11} + + def test_a_selection_over_two_comments_opens_the_first(self, editor): + first = editor.captions[0] + first.comments = [comment({0}, "one"), comment({2}, "two")] + + target = editor.comment_target(first, 0, 11) + + assert target["text"] == "one" + + +class TestAddingChangingRemoving: + def test_a_comment_is_put_on_the_selected_words(self, editor): + first = editor.captions[0] + + assert editor.set_comment(first, 0, 7, "check") == "added" + + assert shape(first) == [([0, 1], "check")] + assert editor.snapshots == 1 + assert editor.changed == 1 + + def test_the_text_of_the_caption_is_never_touched(self, editor): + editor.set_comment(editor.captions[0], 0, 7, "check") + + assert editor.captions[0].text == "ett två tre" + + def test_the_text_is_kept_without_blank_edges(self, editor): + editor.set_comment(editor.captions[0], 0, 3, " hej\n du \n") + + assert editor.captions[0].comments[0].text == "hej\n du" + + def test_commenting_on_commented_words_changes_that_comment(self, editor): + first = editor.captions[0] + first.comments = [comment({0, 1}, "old")] + + assert editor.set_comment(first, 4, 7, "new") == "changed" + + assert shape(first) == [([0, 1], "new")] + + def test_the_same_text_is_no_change(self, editor): + first = editor.captions[0] + first.comments = [comment({0}, "same")] + + assert editor.set_comment(first, 0, 3, " same ") == "" + assert editor.snapshots == 0 + assert editor.changed == 0 + + def test_no_comment_is_laid_over_another(self, editor): + first = editor.captions[0] + first.comments = [comment({1}, "there")] + + editor.set_comment(first, 0, 11, "wider") + + assert shape(first) == [([1], "wider")] + + def test_comments_are_kept_in_the_order_of_the_text(self, editor): + first = editor.captions[0] + + editor.set_comment(first, 8, 11, "later") + editor.set_comment(first, 0, 3, "earlier") + + assert [c.text for c in first.comments] == ["earlier", "later"] + + def test_an_empty_text_is_not_a_comment(self, editor): + first = editor.captions[0] + + assert editor.set_comment(first, 0, 3, " ") == "" + assert editor.set_comment(first, 0, 3, None) == "" + assert first.comments == [] + assert editor.snapshots == 0 + + def test_a_text_over_the_limit_is_refused_and_says_so(self, editor): + first = editor.captions[0] + + assert editor.set_comment(first, 0, 3, "x" * (COMMENT_MAX_LENGTH + 1)) == "" + + assert first.comments == [] + assert editor.messages == [ + f"A comment can be at most {COMMENT_MAX_LENGTH} characters" + ] + + def test_a_text_at_the_limit_is_kept(self, editor): + editor.set_comment(editor.captions[0], 0, 3, "x" * COMMENT_MAX_LENGTH) + + assert len(editor.captions[0].comments[0].text) == COMMENT_MAX_LENGTH + + def test_no_words_selected_says_so(self, editor): + first = editor.captions[0] + first.text = "ett tre" + + assert editor.set_comment(first, 4, 4, "x") == "" + + assert editor.messages == ["Select the words to comment on first"] + assert first.comments == [] + + def test_a_comment_is_removed_with_the_words_it_is_on(self, editor): + first = editor.captions[0] + first.comments = [comment({1}, "a"), comment({2}, "b")] + + assert editor.remove_comment(first, 4, 7) is True + + assert shape(first) == [([2], "b")] + assert editor.snapshots == 1 + + def test_removing_where_there_is_none_does_nothing(self, editor): + first = editor.captions[0] + first.comments = [comment({2}, "b")] + + assert editor.remove_comment(first, 0, 3) is False + + assert shape(first) == [([2], "b")] + assert editor.snapshots == 0 + + def test_subtitles_have_nowhere_to_keep_one(self): + subtitles = make_editor("srt") + + assert subtitles.set_comment(subtitles.captions[0], 0, 3, "x") == "" + assert subtitles.captions[0].comments == [] + assert subtitles.remove_comment(subtitles.captions[0], 0, 3) is False + + +class TestFollowingTheText: + """ + A comment 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.comments = [comment({1}, "c")] + + editor.update_caption_text(first, "nytt ett två tre") + + assert shape(first) == [([2], "c")] + + def test_correcting_a_word_inside_it_keeps_it(self, editor): + first = editor.captions[0] + first.comments = [comment({0, 1, 2}, "c")] + + editor.update_caption_text(first, "ett tvo tre") + + assert shape(first) == [([0, 1, 2], "c")] + + def test_a_word_added_inside_it_is_in_it(self, editor): + first = editor.captions[0] + first.comments = [comment({0, 1}, "c")] + + editor.update_caption_text(first, "ett nytt två tre") + + assert shape(first) == [([0, 1, 2], "c")] + + def test_a_word_typed_at_its_edge_is_not_in_it(self, editor): + first = editor.captions[0] + first.comments = [comment({0, 1}, "c")] + + editor.update_caption_text(first, "ett två nytt tre") + + assert shape(first) == [([0, 1], "c")] + + def test_it_goes_with_its_words_when_they_are_all_deleted(self, editor): + first = editor.captions[0] + first.comments = [comment({1}, "c"), comment({2}, "d")] + + editor.update_caption_text(first, "ett tre") + + assert shape(first) == [([1], "d")] + + def test_it_is_gone_when_the_text_is_replaced_by_something_else_entirely( + self, editor + ): + first = editor.captions[0] + first.comments = [comment({0, 1, 2}, "c")] + + editor.update_caption_text(first, "") + + assert first.comments == [] + + def test_splitting_divides_them_between_the_halves(self, editor): + first = editor.captions[0] + first.comments = [comment({0}, "a"), comment({2}, "b")] + + editor.split_caption(first, cursor_position=7) + + assert editor.captions[0].text == "ett två" + assert shape(editor.captions[0]) == [([0], "a")] + assert editor.captions[1].text == "tre" + assert shape(editor.captions[1]) == [([0], "b")] + + def test_a_comment_across_the_split_is_on_both_halves(self, editor): + first = editor.captions[0] + first.comments = [comment({1, 2}, "both")] + + editor.split_caption(first, cursor_position=7) + + assert shape(editor.captions[0]) == [([1], "both")] + assert shape(editor.captions[1]) == [([0], "both")] + + def test_the_two_halves_do_not_share_one_comment(self, editor): + first = editor.captions[0] + first.comments = [comment({1, 2}, "both")] + + editor.split_caption(first, cursor_position=7) + editor.captions[0].comments[0].text = "changed" + + assert editor.captions[1].comments[0].text == "both" + + def test_splitting_with_unsent_text_keeps_them(self, editor): + first = editor.captions[0] + first.comments = [comment({2}, "c")] + + editor.split_caption(first, cursor_position=8, text="nytt ett två tre") + + assert editor.captions[0].comments == [] + assert shape(editor.captions[1]) == [([1], "c")] + + def test_merging_with_the_next_joins_them_as_separate_notes(self, editor): + first, second = editor.captions + first.comments = [comment({2}, "a")] + second.comments = [comment({0}, "b")] + + editor.merge_with_next(first) + + assert shape(editor.captions[0]) == [([2], "a"), ([3], "b")] + + def test_merging_with_the_previous_joins_them(self, editor): + first, second = editor.captions + first.comments = [comment({2}, "a")] + second.comments = [comment({0, 1}, "b")] + + editor.merge_with_previous(second) + + assert shape(editor.captions[0]) == [([2], "a"), ([3, 4], "b")] + + def test_a_comment_on_a_moved_word_and_others_is_on_both_captions(self, editor): + first, second = editor.captions + second.comments = [comment({0, 1}, "c")] + + editor.move_first_word_to_previous(second) + + assert shape(first) == [([3], "c")] + assert shape(second) == [([0], "c")] + + def test_a_comment_on_only_that_word_goes_with_it(self, editor): + first, second = editor.captions + second.comments = [comment({0}, "c"), comment({2}, "d")] + + editor.move_first_word_to_previous(second) + + assert shape(first) == [([3], "c")] + assert shape(second) == [([1], "d")] + + def test_the_comments_of_the_caption_it_joins_stay_where_they_are(self, editor): + first, second = editor.captions + first.comments = [comment({0, 1}, "old")] + second.comments = [comment({0}, "new")] + + editor.move_first_word_to_previous(second) + + assert shape(first) == [([0, 1], "old"), ([3], "new")] + + def test_a_comment_on_the_last_word_goes_to_the_next_caption(self, editor): + first, second = editor.captions + first.comments = [comment({2}, "c")] + second.comments = [comment({1}, "d")] + + editor.move_last_word_to_next(first) + + assert first.comments == [] + assert shape(second) == [([0], "c"), ([2], "d")] + + def test_a_comment_on_the_last_word_and_others_is_on_both_going_next( + self, editor + ): + first, second = editor.captions + first.comments = [comment({1, 2}, "c")] + second.comments = [comment({1}, "d")] + + editor.move_last_word_to_next(first) + + assert shape(first) == [([1], "c")] + assert shape(second) == [([0], "c"), ([2], "d")] + + def test_a_comment_on_a_word_that_stays_keeps_it_when_another_moves( + self, editor + ): + first, second = editor.captions + first.comments = [comment({0}, "c")] + + editor.move_last_word_to_next(first) + + assert shape(first) == [([0], "c")] + assert second.comments == [] + + def test_deleting_across_captions_keeps_what_is_left(self, editor): + first, second = editor.captions + first.comments = [comment({0}, "a"), comment({1}, "b")] + second.comments = [comment({0}, "c"), comment({2}, "d")] + + # "ett" and " fem sex" are what is left: "två" and "fyra" are gone. + editor.delete_range(first, second, 3, 4, "") + + assert editor.captions[0].text == "ett fem sex" + assert shape(editor.captions[0]) == [([0], "a"), ([2], "d")] + + def test_replacing_text_keeps_them_following_the_words(self, editor): + first = editor.captions[0] + first.comments = [comment({2}, "c")] + 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 shape(first) == [([3], "c")] + + def test_the_marks_do_not_get_mixed_up(self, editor): + first = editor.captions[0] + first.edited_words = {0} + first.highlighted_words = {1} + first.comments = [comment({2}, "c")] + + editor.update_caption_text(first, "nytt ett två tre") + + assert first.highlighted_words == {2} + assert shape(first) == [([3], "c")] + + +class TestUndo: + """ + With the real history, not the stand-in the other tests use: a comment + has to travel with the snapshots. + """ + + @pytest.fixture + def real(self, editor): + del editor.save_state_for_undo + editor._update_undo_redo_buttons = lambda: None + editor.update_beforeunload_state = lambda: None + + return editor + + def state(self, editor): + first = editor.captions[0] + + return first.text, shape(first) + + def test_adding_one_can_be_undone_and_redone(self, real): + real.set_comment(real.captions[0], 0, 7, "note") + + real.undo() + assert self.state(real) == ("ett två tre", []) + + real.redo() + assert self.state(real) == ("ett två tre", [([0, 1], "note")]) + + def test_deleting_the_words_and_undoing_brings_both_back(self, real): + real.set_comment(real.captions[0], 0, 7, "note") + real.update_caption_text(real.captions[0], "tre") + assert self.state(real) == ("tre", []) + + real.undo() + + assert self.state(real) == ("ett två tre", [([0, 1], "note")]) + + def test_changing_the_text_and_removing_can_be_undone(self, real): + first = real.captions[0] + real.set_comment(first, 0, 3, "one") + real.set_comment(first, 0, 3, "two") + real.remove_comment(first, 0, 3) + + real.undo() + assert shape(real.captions[0]) == [([0], "two")] + + real.undo() + assert shape(real.captions[0]) == [([0], "one")] + + +class TestDrawing: + def runs(self, editor, caption): + return editor.review_runs(caption) + + def test_nothing_is_drawn_when_there_is_no_comment(self, editor): + assert self.runs(editor, editor.captions[0]) == [ + {"t": "ett två tre", "flag": False} + ] + + def test_a_comment_is_drawn_without_any_switch_on(self, editor): + first = editor.captions[0] + first.comments = [comment({1}, "c")] + + runs = self.runs(editor, first) + + assert [run["t"] for run in runs if "cm" in run] == ["två"] + assert "".join(run["t"] for run in runs) == "ett två tre" + + def test_each_word_says_which_comment_it_is_in(self, editor): + first = editor.captions[0] + first.comments = [comment({0}, "a"), comment({2}, "b")] + + runs = self.runs(editor, first) + + assert [(r["t"], r["cm"]) for r in runs if "cm" in r] == [("ett", 0), ("tre", 1)] + + def test_the_first_word_carries_the_text_and_the_last_marks_the_end(self, editor): + first = editor.captions[0] + first.comments = [comment({0, 1, 2}, "hello")] + + runs = [r for r in self.runs(editor, first) if "cm" in r and r["t"].strip()] + + assert [r.get("cf") for r in runs] == [True, None, None] + assert [r.get("ct") for r in runs] == ["hello", None, None] + assert [r.get("cl") for r in runs] == [None, None, True] + + def test_a_one_word_comment_is_both_first_and_last(self, editor): + first = editor.captions[0] + first.comments = [comment({1}, "x")] + + run = next(r for r in self.runs(editor, first) if "cm" in r) + + assert run["cf"] is True and run["cl"] is True + + def test_the_space_inside_a_comment_is_in_it(self, editor): + first = editor.captions[0] + first.comments = [comment({0, 1}, "c")] + + runs = self.runs(editor, first) + + assert [(r["t"], "cm" in r) for r in runs] == [ + ("ett", True), + (" ", True), + ("två", True), + (" ", False), + ("tre", False), + ] + + def test_the_space_between_two_comments_is_in_neither(self, editor): + first = editor.captions[0] + first.comments = [comment({0}, "a"), comment({1}, "b")] + + runs = self.runs(editor, first) + + assert [(r["t"], r.get("cm")) for r in runs][:3] == [ + ("ett", 0), + (" ", None), + ("två", 1), + ] + + def test_a_line_break_is_left_out(self, editor): + first = editor.captions[0] + first.text = "ett\ntvå" + first.comments = [comment({0, 1}, "c")] + + runs = self.runs(editor, first) + + assert [(r["t"], "cm" in r) for r in runs] == [ + ("ett", True), + ("\n", False), + ("två", True), + ] + + def test_it_sits_alongside_a_highlight_on_the_same_word(self, editor): + first = editor.captions[0] + first.highlighted_words = {1} + first.comments = [comment({1}, "c")] + + run = next(r for r in self.runs(editor, first) if r["t"] == "två") + + assert run["hl"] is True + assert run["cm"] == 0 + + def test_the_text_is_always_what_the_caption_says(self, editor): + first = editor.captions[0] + first.comments = [comment({0, 2}, "c")] + + assert "".join(r["t"] for r in self.runs(editor, first)) == first.text + + +class TestRtfExport: + def export(self, editor, **kwargs): + return editor.export_rtf(False, False, False, **kwargs) + + def test_a_clean_export_has_no_comment_in_it(self, editor): + editor.captions[0].comments = [comment({1}, "secret")] + + clean = self.export(editor) + + assert "secret" not in clean + assert "annotation" not in clean + assert "atrf" not in clean + + def test_a_clean_export_is_the_same_as_before_comments_existed(self, editor): + before = self.export(editor) + + editor.captions[0].comments = [comment({0, 1}, "x")] + editor.captions[1].comments = [comment({2}, "y")] + + assert self.export(editor) == before + + def test_a_comment_is_a_word_comment_on_its_words(self, editor): + editor.captions[0].comments = [comment({1}, "check this")] + + rtf = self.export(editor, comments=True) + + assert r"ett {\*\atrfstart 0}tv\u229?{\*\atrfend 0}" in rtf + assert r"{\*\atnauthor Scribe}\chatn{\*\annotation{\*\atnref 0}" in rtf + assert r"\pard\plain\f0\fs20 check this}" in rtf + + def test_the_text_between_the_markers_is_the_words(self, editor): + editor.captions[0].comments = [comment({1, 2}, "x")] + + rtf = self.export(editor, comments=True) + between = re.search(r"\\atrfstart 0\}(.*?)\{\\\*\\atrfend 0\}", rtf).group(1) + + assert between == r"tv\u229? tre" + + def test_each_comment_gets_a_number_of_its_own(self, editor): + editor.captions[0].comments = [comment({0}, "a"), comment({2}, "b")] + editor.captions[1].comments = [comment({0}, "c")] + + rtf = self.export(editor, comments=True) + + assert re.findall(r"\\atrfstart (\d+)", rtf) == ["0", "1", "2"] + assert re.findall(r"\\atrfend (\d+)", rtf) == ["0", "1", "2"] + assert re.findall(r"\\atnref (\d+)", rtf) == ["0", "1", "2"] + + def test_the_texts_are_escaped_and_keep_their_line_breaks(self, editor): + editor.captions[0].comments = [comment({0}, 'a {b} \\c\nåäö "q"')] + + rtf = self.export(editor, comments=True) + + assert r'a \{b\} \\c\line \u229?\u228?\u246? "q"}' in rtf + + def test_it_goes_inside_marked_words_without_breaking_them(self, editor): + second = editor.captions[1] + second.highlighted_words = {0, 1} + second.edited_words = {1, 2} + second.comments = [comment({0}, "c")] + + rtf = self.export(editor, comments=True, highlights=True, edits=True) + + assert ( + r"{\*\atrfstart 0}{\highlight1 fyra}{\*\atrfend 0}" in rtf + ) + # The rest of the highlighted passage carries on after the comment. + assert r"{\highlight1 }{\highlight1\cf2\ul fem}" in rtf + + def test_a_comment_in_the_middle_of_one_marked_stretch_cuts_it_in_two(self, editor): + second = editor.captions[1] + second.highlighted_words = {0, 1, 2} + second.comments = [comment({1}, "c")] + + rtf = self.export(editor, comments=True, highlights=True) + + assert ( + r"{\highlight1 fyra }{\*\atrfstart 0}{\highlight1 fem}{\*\atrfend 0}" in rtf + ) + assert r"{\highlight1 sex}" in rtf + + def test_the_rest_of_the_document_is_unchanged_by_the_comments(self, editor): + editor.captions[0].comments = [comment({1}, "c")] + + with_comments = self.export(editor, comments=True) + stripped = re.sub(r"\{\\\*\\atrfstart \d+\}", "", with_comments) + stripped = re.sub( + r"\{\\\*\\atrfend \d+\}\{\\\*\\atnid [^}]*\}" + r"\{\\\*\\atnauthor [^}]*\}\\chatn" + r"\{\\\*\\annotation\{\\\*\\atnref \d+\}" + r"\{\\\*\\atndate \d+\}[^}]*\}", + "", + stripped, + ) + + assert stripped == self.export(editor) + + def test_the_date_is_the_one_rtf_keeps_dates_in(self): + # Sunday 18 October 2026, 08:21. + from utils.srt_export import ExportMixin + + value = ExportMixin.rtf_date(datetime(2026, 10, 18, 8, 21)) + + assert value & 0x3F == 21 + assert (value >> 6) & 0x1F == 8 + assert (value >> 11) & 0x1F == 18 + assert (value >> 16) & 0xF == 10 + assert (value >> 20) & 0x1FF == 126 + assert (value >> 29) & 0x7 == 0 + + def test_subtitles_never_carry_them(self): + subtitles = make_editor("srt") + subtitles.captions[0].comments = [comment({0}, "x")] + + rtf = subtitles.export_rtf(False, False, False, comments=True) + + assert "annotation" not in rtf + + def test_a_comment_on_no_words_is_not_written(self, editor): + editor.captions[0].comments = [comment({9}, "stale")] + + assert "annotation" not in self.export(editor, comments=True) + + +class TestOfferingThem: + def test_nothing_to_offer_without_a_comment(self, editor): + assert editor.rtf_comments_available() is False + + def test_it_is_offered_when_a_caption_has_one(self, editor): + editor.captions[1].comments = [comment({0})] + + assert editor.rtf_comments_available() is True + + def test_a_bulk_export_offers_it_if_any_file_has_one(self, editor): + other = make_editor() + other.captions[0].comments = [comment({0})] + + assert editor.rtf_comments_available([other]) is True + + def test_subtitles_do_not_count(self, editor): + subtitles = make_editor("srt") + subtitles.captions[0].comments = [comment({0})] + + assert editor.rtf_comments_available([subtitles]) is False + assert subtitles.rtf_comments_available() is False + + def test_the_checkbox_is_off_and_only_visible_when_offered(self): + source = pathlib.Path("utils/srt_export.py").read_text() + start = source.index('"Include comments"') + block = source[start - 80 : start + 400] + + assert "value=False" in block + assert "set_visibility(" in block + assert "rtf_comments_available(" in block + + def test_the_export_asks_for_comments_only_when_the_box_is_shown_and_ticked(self): + source = pathlib.Path("utils/srt_export.py").read_text() + + assert "rtf_comments_incl.visible\n" in source + assert "and rtf_comments_incl.value" in source + + +class FakeBody: + def __init__(self): + self.selected = [] + self.scrolled = [] + self.asked = 0 + self.caret_asked = 0 + self.comment_mark = (-1, -1) + + def set_comment_mark(self, block_id, comment_index=-1): + self.comment_mark = (block_id, comment_index) + + def comment_selection(self): + self.asked += 1 + + def select_range(self, *args): + self.selected.append(args) + + def scroll_to_block(self, block_id): + self.scrolled.append(block_id) + + def focus_caret(self): + self.caret_asked += 1 + + +class Element: + """ + Just enough of a NiceGUI element to see what the panel did to it. + """ + + def __init__(self, value=""): + self.value = value + self.text = "" + self.enabled = True + self.visible = True + self.properties = [] + + def set_value(self, value): + self.value = value + + def set_text(self, text): + self.text = text + + def set_enabled(self, enabled): + self.enabled = enabled + + def set_visibility(self, visible): + self.visible = visible + + def props(self, props): + self.properties.append(props) + + +class FakePanel: + pass + + +@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 + + +@pytest.fixture +def panel(view, editor, monkeypatch): + from utils.comments_panel import CommentsPanel + + built = CommentsPanel.__new__(CommentsPanel) + built.transcript = view + built.current = None + built.pending = None + + for name in ( + "container", + "heading", + "position_label", + "spoken", + "previous", + "next", + "words_button", + "detail", + "remove_button", + "cancel_button", + "save_button", + ): + setattr(built, name, Element()) + + built.container.visible = False + built.text = Element("") + built.focused = 0 + built.focus_text = lambda: setattr(built, "focused", built.focused + 1) + monkeypatch.setattr( + CommentsPanel, "focus", staticmethod(lambda element: None) + ) + view.comments_panel = built + + return built + + +class TestThePanel: + """ + The card under the video where a comment is written, read and changed. + """ + + def type(self, panel, text): + panel.text.value = text + + def test_a_new_comment_opens_empty_on_the_words_with_focus(self, panel, editor): + assert panel.open_for(editor.captions[0], 5, 9) is True + + assert panel.container.visible is True + assert panel.pending == (editor.captions[0], 4, 11) + assert panel.text.value == "" + assert panel.heading.text == "New comment" + assert panel.words_button.text == "\u201ctv\u00e5 tre\u201d" + assert panel.detail.text == "A \u00b7 00:00:00.000" + assert panel.focused == 1 + + def test_opening_changes_nothing_by_itself(self, panel, editor): + panel.open_for(editor.captions[0], 5, 9) + + assert editor.captions[0].comments == [] + assert editor.snapshots == 0 + assert panel.transcript.refreshed == 0 + + def test_the_words_of_an_existing_comment_open_it_with_its_text( + self, panel, editor + ): + editor.captions[0].comments = [comment({1, 2}, "there")] + + panel.open_for(editor.captions[0], 5, 5) + + assert panel.pending is None + assert panel.current[1] is editor.captions[0].comments[0] + assert panel.text.value == "there" + assert panel.heading.text == "Comment 1 of 1" + assert panel.focused == 1 + + def test_no_word_means_nothing_opens(self, panel, editor): + editor.captions[0].text = "ett tre" + + assert panel.open_for(editor.captions[0], 4, 4) is False + assert panel.container.visible is False + + def test_the_heading_says_where_it_is_among_all_the_comments(self, panel, editor): + editor.captions[0].comments = [comment({0}, "a"), comment({2}, "b")] + editor.captions[1].comments = [comment({1}, "c")] + + panel.show(editor.captions[1], editor.captions[1].comments[0]) + + assert panel.heading.text == "Comment 3 of 3" + assert panel.position_label.text == "3 of 3" + assert panel.spoken.text == "Comment 3 of 3." + assert panel.previous.enabled is True + assert panel.next.enabled is False + + def test_drawing_it_scrolls_the_text_to_the_words_unless_told_not_to( + self, panel, editor + ): + editor.captions[1].comments = [comment({1}, "c")] + + panel.show(editor.captions[1], editor.captions[1].comments[0]) + panel.show(editor.captions[1], editor.captions[1].comments[0], scroll=False) + + assert panel.transcript.body.scrolled == [2] + + def test_saving_a_new_comment_puts_it_on_the_words_and_goes_back_to_them( + self, panel, editor + ): + panel.open_for(editor.captions[0], 5, 9) + self.type(panel, " check this ") + + panel.save() + + assert shape(editor.captions[0]) == [([1, 2], "check this")] + assert panel.pending is None + assert panel.current[1] is editor.captions[0].comments[0] + assert panel.transcript.refreshed == 1 + assert panel.transcript.body.selected == [(1, 4, 1, 11)] + assert panel.heading.text == "Comment 1 of 1" + + def test_saving_nothing_does_nothing(self, panel, editor): + panel.open_for(editor.captions[0], 5, 9) + self.type(panel, " ") + + panel.save() + + assert editor.captions[0].comments == [] + assert panel.pending is not None + assert panel.transcript.refreshed == 0 + + def test_save_cannot_be_pressed_with_nothing_typed(self, panel, editor): + panel.open_for(editor.captions[0], 5, 9) + + assert panel.save_button.enabled is False + + self.type(panel, "x") + panel.value_changed() + + assert panel.save_button.enabled is True + + def test_saving_a_changed_text_changes_that_comment(self, panel, editor): + editor.captions[0].comments = [comment({1}, "old")] + panel.open_for(editor.captions[0], 5, 5) + self.type(panel, "new") + + panel.save() + + assert shape(editor.captions[0]) == [([1], "new")] + assert panel.transcript.body.selected == [(1, 4, 1, 7)] + + def test_cancelling_a_new_comment_makes_none_and_goes_back_to_the_words( + self, panel, editor + ): + panel.open_for(editor.captions[0], 5, 9) + self.type(panel, "abandoned") + + panel.cancel() + + assert editor.captions[0].comments == [] + assert panel.pending is None + assert panel.container.visible is False + assert panel.transcript.body.selected == [(1, 4, 1, 11)] + + def test_cancelling_a_new_comment_goes_back_to_the_one_that_was_on_show( + self, panel, editor + ): + editor.captions[0].comments = [comment({0}, "there")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + panel.open_for(editor.captions[1], 0, 4) + + panel.cancel() + + assert panel.container.visible is True + assert panel.text.value == "there" + assert panel.heading.text == "Comment 1 of 1" + + def test_cancelling_a_change_puts_the_old_text_back(self, panel, editor): + editor.captions[0].comments = [comment({1}, "old")] + panel.open_for(editor.captions[0], 5, 5) + self.type(panel, "typed over") + + panel.cancel() + + assert panel.text.value == "old" + assert shape(editor.captions[0]) == [([1], "old")] + assert panel.transcript.body.selected == [(1, 4, 1, 7)] + + def test_removing_takes_the_comment_off_and_shows_the_next(self, panel, editor): + editor.captions[0].comments = [comment({0}, "a"), comment({2}, "b")] + panel.open_for(editor.captions[0], 0, 3) + + panel.remove() + + assert shape(editor.captions[0]) == [([2], "b")] + assert panel.current[1].text == "b" + assert panel.heading.text == "Comment 1 of 1" + assert panel.transcript.body.selected == [(1, 0, 1, 3)] + + def test_removing_the_last_one_puts_the_panel_away(self, panel, editor): + editor.captions[0].comments = [comment({0}, "a")] + panel.open_for(editor.captions[0], 0, 3) + + panel.remove() + + assert editor.captions[0].comments == [] + assert panel.container.visible is False + assert panel.current is None + + def test_a_comment_not_yet_made_cannot_be_removed(self, panel, editor): + panel.open_for(editor.captions[0], 5, 9) + + panel.remove() + + assert editor.snapshots == 0 + assert panel.pending is not None + + def test_closing_keeps_what_was_typed_and_goes_back_to_the_words( + self, panel, editor + ): + panel.open_for(editor.captions[0], 5, 9) + self.type(panel, "kept") + + panel.close() + + assert shape(editor.captions[0]) == [([1, 2], "kept")] + assert panel.container.visible is False + assert panel.transcript.body.selected[-1] == (1, 4, 1, 11) + + def test_closing_with_nothing_typed_makes_nothing(self, panel, editor): + panel.open_for(editor.captions[0], 5, 9) + + panel.close() + + assert editor.captions[0].comments == [] + assert panel.container.visible is False + + def test_the_arrows_step_through_every_comment_in_the_order_of_the_text( + self, panel, editor + ): + editor.captions[0].comments = [comment({0}, "a"), comment({2}, "b")] + editor.captions[1].comments = [comment({1}, "c")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + panel.go_next() + assert panel.text.value == "b" + panel.go_next() + assert panel.text.value == "c" + assert panel.heading.text == "Comment 3 of 3" + panel.go_next() + assert panel.text.value == "c" + panel.go_previous() + assert panel.text.value == "b" + + def test_stepping_keeps_what_was_typed(self, panel, editor): + editor.captions[0].comments = [comment({0}, "a"), comment({2}, "b")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + self.type(panel, "a, rewritten") + + panel.go_next() + + assert shape(editor.captions[0]) == [([0], "a, rewritten"), ([2], "b")] + + def test_a_comment_being_written_cannot_be_stepped_away_from(self, panel, editor): + editor.captions[0].comments = [comment({0}, "a")] + panel.open_for(editor.captions[1], 0, 4) + self.type(panel, "half written") + + panel.go_next() + panel.go_previous() + + assert panel.pending is not None + assert panel.text.value == "half written" + assert panel.previous.enabled is False and panel.next.enabled is False + + def test_the_words_button_selects_them_and_scrolls_to_them(self, panel, editor): + editor.captions[1].comments = [comment({1}, "c")] + panel.show(editor.captions[1], editor.captions[1].comments[0], scroll=False) + + panel.go_to_words() + + assert panel.transcript.body.scrolled == [2] + assert panel.transcript.body.selected == [(2, 5, 2, 8)] + + def test_a_file_with_comments_opens_on_the_first_without_taking_focus( + self, panel, editor + ): + editor.captions[1].comments = [comment({1}, "c")] + + panel.open_first() + + assert panel.container.visible is True + assert panel.text.value == "c" + assert panel.focused == 0 + assert panel.transcript.body.scrolled == [] + + def test_a_file_without_comments_leaves_it_shut(self, panel, editor): + panel.open_first() + + assert panel.container.visible is False + + +class TestFollowingTheCaret: + def one(self, editor): + editor.captions[0].comments = [comment({0}, "a")] + editor.captions[1].comments = [comment({1}, "b")] + + return editor.captions[0].comments[0], editor.captions[1].comments[0] + + def test_a_click_on_commented_words_opens_a_shut_panel_on_the_comment( + self, panel, editor + ): + first, _ = self.one(editor) + + panel.follow(editor.captions[0], first, click=True) + + assert panel.container.visible is True + assert panel.text.value == "a" + assert panel.focused == 0 + + def test_a_key_that_crosses_commented_words_does_not_open_a_shut_panel( + self, panel, editor + ): + first, _ = self.one(editor) + + panel.follow(editor.captions[0], first, click=False) + + assert panel.container.visible is False + + def test_an_open_panel_follows_the_caret_from_comment_to_comment( + self, panel, editor + ): + first, second = self.one(editor) + panel.show(editor.captions[0], first) + + panel.follow(editor.captions[1], second, click=False) + + assert panel.text.value == "b" + assert panel.heading.text == "Comment 2 of 2" + + def test_following_does_not_scroll_the_text_under_the_caret(self, panel, editor): + first, second = self.one(editor) + panel.show(editor.captions[0], first, scroll=False) + + panel.follow(editor.captions[1], second, click=True) + + assert panel.transcript.body.scrolled == [] + + def test_the_comment_already_on_show_is_left_alone(self, panel, editor): + first, _ = self.one(editor) + panel.show(editor.captions[0], first) + self.draws = 0 + panel.draw = lambda **k: setattr(self, "draws", self.draws + 1) + panel.text.value = "typing in it" + + panel.follow(editor.captions[0], first, click=True) + + assert self.draws == 0 + assert panel.text.value == "typing in it" + + def test_what_was_typed_is_kept_when_the_caret_moves_on(self, panel, editor): + first, second = self.one(editor) + panel.show(editor.captions[0], first) + panel.text.value = "a, changed" + + panel.follow(editor.captions[1], second, click=True) + + assert first.text == "a, changed" + assert panel.text.value == "b" + + def test_a_comment_being_written_is_kept_when_the_caret_moves_on( + self, panel, editor + ): + _, second = self.one(editor) + panel.open_for(editor.captions[0], 4, 7) + panel.text.value = "new one" + + panel.follow(editor.captions[1], second, click=True) + + assert [c.text for c in editor.captions[0].comments] == ["a", "new one"] + assert panel.pending is None + + def test_an_empty_comment_being_written_is_dropped_when_the_caret_moves_on( + self, panel, editor + ): + _, second = self.one(editor) + panel.open_for(editor.captions[0], 4, 7) + + panel.follow(editor.captions[1], second, click=True) + + assert [c.text for c in editor.captions[0].comments] == ["a"] + assert panel.pending is None + + +class TestKeepingUpWithTheText: + def test_a_card_that_is_still_right_keeps_what_is_being_typed( + self, panel, editor + ): + editor.captions[0].comments = [comment({0}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + panel.text.value = "being typed" + + panel.sync() + + assert panel.text.value == "being typed" + + def test_a_card_that_is_still_right_is_drawn_again_without_scrolling( + self, panel, editor + ): + editor.captions[0].comments = [comment({0}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + calls = [] + panel.draw = lambda **k: calls.append(k) + + panel.sync() + + assert calls == [{"scroll": False}] + + def test_a_renamed_speaker_is_the_one_the_card_names(self, panel, editor): + editor.captions[0].comments = [comment({0}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + assert panel.detail.text.startswith("A \u00b7 ") + + editor.captions[0].speaker = "Kim" + panel.sync() + + assert panel.detail.text.startswith("Kim \u00b7 ") + + def test_a_renamed_speaker_is_the_one_a_new_comment_names(self, panel, editor): + panel.open_for(editor.captions[0], 0, 3) + assert panel.detail.text.startswith("A \u00b7 ") + + editor.captions[0].speaker = "Kim" + panel.sync() + + assert panel.detail.text.startswith("Kim \u00b7 ") + + def test_a_word_typed_into_the_commented_words_keeps_the_card( + self, panel, editor + ): + editor.captions[0].comments = [comment({0, 1, 2}, "all of it")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + before = panel.current[1] + + editor.update_caption_text(editor.captions[0], "ett två ny tre") + assert editor.captions[0].comments[0] is not before + + panel.sync() + + assert panel.container.visible is True + assert panel.current[1] is editor.captions[0].comments[0] + assert panel.current[1].words == {0, 1, 2, 3} + assert panel.text.value == "all of it" + + def test_a_word_deleted_before_the_comment_keeps_the_card(self, panel, editor): + editor.captions[0].comments = [comment({2}, "the last one")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + editor.update_caption_text(editor.captions[0], "ett tre") + panel.sync() + + assert panel.container.visible is True + assert panel.current[1] is editor.captions[0].comments[0] + assert panel.current[1].words == {1} + + def test_the_card_follows_the_comment_with_the_same_text_when_there_are_two( + self, panel, editor + ): + editor.captions[0].comments = [comment({0}, "one"), comment({2}, "two")] + panel.show(editor.captions[0], editor.captions[0].comments[1]) + + editor.update_caption_text(editor.captions[0], "ett två ny tre") + panel.sync() + + assert panel.current[1].text == "two" + assert panel.current[1] is editor.captions[0].comments[1] + + def test_what_is_typed_in_the_card_is_kept_when_the_comment_is_found_again( + self, panel, editor + ): + editor.captions[0].comments = [comment({0, 1, 2}, "saved text")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + panel.text.value = "saved text, and more not saved yet" + + editor.update_caption_text(editor.captions[0], "ett två ny tre") + panel.sync() + + assert panel.text.value == "saved text, and more not saved yet" + + def test_typing_in_a_block_tells_the_panel(self, view, panel, editor): + editor.captions[0].comments = [comment({0}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + calls = [] + panel.sync = lambda: calls.append(1) + + view.set_text({"id": editor.captions[0].index, "text": "ett två tre!"}) + + assert calls == [1] + + def test_deleting_the_commented_words_by_typing_puts_the_card_away( + self, view, panel, editor + ): + editor.captions[0].comments = [comment({1}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + view.set_text({"id": editor.captions[0].index, "text": "ett tre"}) + + assert panel.container.visible is False + assert panel.current is None + assert panel.transcript.body.comment_mark == (-1, -1) + + def test_a_comment_that_an_undo_replaced_is_found_again_by_its_words( + self, panel, editor + ): + editor.captions[0].comments = [comment({0, 1}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + replacement = editor.captions[0].copy() + replacement.comments[0].text = "as it was" + editor.captions[0] = replacement + + panel.sync() + + assert panel.current[0] is replacement + assert panel.current[1] is replacement.comments[0] + assert panel.text.value == "as it was" + assert panel.container.visible is True + + def test_a_comment_that_is_gone_puts_the_panel_away(self, panel, editor): + editor.captions[0].comments = [comment({0}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + editor.captions[0].comments = [] + panel.sync() + + assert panel.container.visible is False + assert panel.current is None + + def test_a_comment_whose_words_were_deleted_goes_with_the_panel( + self, panel, editor + ): + editor.captions[0].comments = [comment({1}, "a")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + editor.update_caption_text(editor.captions[0], "ett tre") + panel.sync() + + assert panel.container.visible is False + + def test_a_new_comment_whose_paragraph_is_gone_is_dropped(self, panel, editor): + panel.open_for(editor.captions[1], 0, 4) + + editor.captions = [editor.captions[0]] + panel.sync() + + assert panel.pending is None + assert panel.container.visible is False + + def test_a_shut_panel_is_left_alone(self, panel, editor): + panel.sync() + + assert panel.container.visible is False + + def test_the_view_tells_the_panel_whenever_it_redraws(self, view, panel): + calls = [] + panel.sync = lambda: calls.append(1) + view.body.set_blocks = lambda blocks: None + view.body.set_speakers = lambda *a: None + view.refresh_overlay = lambda: None + view.refresh_timeline = lambda: None + del view.refresh + view.blocks = lambda: [] + + view.refresh() + + assert calls == [1] + + +class TestAskingForTheComment: + def test_the_events_reach_the_view(self): + source = pathlib.Path("utils/transcript_editor.py").read_text() + + assert 'self.body.on("opencomment", lambda event: self.open_comment(event.args))' in source + assert 'self.body.on("caretcomment", lambda event: self.caret_comment(event.args))' in source + + def test_the_selected_words_open_the_panel_on_them(self, view, panel, editor): + view.open_comment({"spans": [{"id": 1, "start": 5, "end": 9}]}) + + assert panel.pending == (editor.captions[0], 4, 11) + assert panel.container.visible is True + + def test_nothing_selected_says_so(self, view, panel, editor): + view.open_comment({"spans": []}) + + assert editor.messages == ["Select the words to comment on first"] + assert panel.container.visible is False + + def test_a_caret_between_words_says_so(self, view, panel, editor): + editor.captions[0].text = "ett tre" + + view.open_comment({"spans": [{"id": 1, "start": 4, "end": 4}]}) + + assert editor.messages == ["Select the words to comment on first"] + + def test_a_selection_across_paragraphs_is_refused(self, view, panel, editor): + view.open_comment( + { + "spans": [ + {"id": 1, "start": 4, "end": 11}, + {"id": 2, "start": 0, "end": 4}, + ] + } + ) + + assert editor.messages == ["A comment has to stay within one paragraph"] + assert panel.container.visible is False + + def test_a_selection_that_only_brushes_the_next_paragraph_is_within_one( + self, view, panel, editor + ): + view.open_comment( + { + "spans": [ + {"id": 1, "start": 4, "end": 11}, + {"id": 2, "start": 0, "end": 0}, + ] + } + ) + + assert editor.messages == [] + assert panel.pending[0] is editor.captions[0] + + def test_subtitles_open_nothing(self, monkeypatch): + monkeypatch.setattr("utils.srt.ui.notify", lambda *a, **k: None) + subtitles = make_editor("srt") + view = TranscriptEditor(subtitles) + view.body = FakeBody() + view.comments_panel = FakePanel() + + view.open_comment({"spans": [{"id": 1, "start": 0, "end": 3}]}) + view.caret_comment({"id": 1, "cm": 0, "click": True}) + + def test_without_a_panel_nothing_happens(self, view, editor): + view.comments_panel = None + + view.open_comment({"spans": [{"id": 1, "start": 0, "end": 3}]}) + view.caret_comment({"id": 1, "cm": 0}) + + @pytest.mark.parametrize( + "args", + [ + None, + "x", + {}, + {"spans": None}, + {"spans": "all"}, + {"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, panel, editor, args): + view.open_comment(args) + + assert panel.container.visible is False + assert all(not caption.comments for caption in editor.captions) + + def test_the_caret_in_a_comment_shows_it(self, view, panel, editor): + editor.captions[1].comments = [comment({1}, "b")] + + view.caret_comment({"id": 2, "cm": 0, "click": True}) + + assert panel.text.value == "b" + + def test_a_click_is_told_from_a_key(self, view, panel, editor): + editor.captions[1].comments = [comment({1}, "b")] + + view.caret_comment({"id": 2, "cm": 0, "click": False}) + view.caret_comment({"id": 2, "cm": 0}) + view.caret_comment({"id": 2, "cm": 0, "click": "yes"}) + + assert panel.container.visible is False + + @pytest.mark.parametrize( + "args", + [ + None, + {}, + {"id": 2}, + {"id": 2, "cm": "0"}, + {"id": 2, "cm": True}, + {"id": 2, "cm": 5}, + {"id": 2, "cm": -1}, + {"id": 9, "cm": 0}, + ], + ) + def test_a_bad_caret_report_is_harmless(self, view, panel, editor, args): + editor.captions[1].comments = [comment({1}, "b")] + + view.caret_comment(args) + + assert panel.container.visible is False + + +class TestTheButton: + def page(self): + return pathlib.Path("pages/srt.py").read_text() + + def button(self): + page = self.page() + start = page.index('with ui.button("Comment", icon="sym_o_add_comment")') + + return page[start : start + 1400] + + def test_it_asks_the_component_for_the_selection(self, view): + view.comment_selection() + + assert view.body.asked == 1 + + def test_it_is_harmless_before_the_editor_is_built(self, editor): + TranscriptEditor(editor).comment_selection() + + def test_it_sits_beside_highlight_in_the_transcription_toolbar(self): + page = self.page() + + assert page.index('with ui.button("Highlight"') < page.index( + 'with ui.button("Comment"' + ) + + def test_it_is_a_labelled_quiet_toolbar_button_naming_its_key(self): + button = self.button() + + assert "aria-keyshortcuts=Control+Shift+K" in button + assert "editor-btn editor-toolbar-btn editor-quiet" in button + assert "(Ctrl + Shift + K)" in button + + def test_a_mouse_press_does_not_take_the_selection_with_it(self): + assert ( + '"mousedown", js_handler="(event) => event.preventDefault()"' + in self.button() + ) + + def test_a_click_is_the_same_as_the_key(self): + assert '"click", lambda: transcript.comment_selection()' in self.button() + + def test_the_icon_is_a_material_symbol_that_exists(self): + """ + sym_o_ is Material Symbols Outlined, which ships with NiceGUI and + has add_comment; a name the font lacks would draw its own letters. + """ + + assert 'icon="sym_o_add_comment"' in self.page() + + +class TestThePanelSource: + def source(self): + return pathlib.Path("utils/comments_panel.py").read_text() + + def test_it_is_drawn_like_the_validation_card_in_its_own_colour(self): + source = self.source() + + assert '"validation-panel is-comment w-full"' in source + assert "validation-panel-head" in source and "validation-panel-step" in source + + def test_it_has_previous_next_position_and_close_with_names(self): + source = self.source() + + for label in ( + 'aria-label="Previous comment"', + 'aria-label="Next comment"', + 'aria-label="Close comments panel"', + ): + assert label in source + + def test_the_heading_names_the_section_and_can_take_focus(self): + source = self.source() + + assert 'aria-labelledby="comments-panel-heading"' in source + assert "id=comments-panel-heading tabindex=-1" in source + + def test_the_position_is_spoken_in_a_live_region(self): + assert '"role=status aria-live=polite aria-atomic=true"' in self.source() + + def test_the_text_is_limited_as_the_server_limits_it(self): + assert "maxlength={COMMENT_MAX_LENGTH}" in self.source() + + def test_keys_typed_in_the_text_stay_in_it(self): + source = self.source() + + assert '"keydown", js_handler="(event) => event.stopPropagation()"' in source + + def test_escape_abandons_and_ctrl_or_cmd_enter_saves(self): + source = self.source() + + assert 'self.text.on("keydown.escape", self.cancel)' in source + assert 'self.text.on("keydown.ctrl.enter", self.save)' in source + assert 'self.text.on("keydown.meta.enter", self.save)' in source + + def test_it_starts_shut(self): + assert "self.container.set_visibility(False)" in self.source() + + def test_the_page_builds_it_for_transcriptions_and_opens_a_file_that_has_comments(self): + page = pathlib.Path("pages/srt.py").read_text() + start = page.index("transcript.comments_panel = CommentsPanel(transcript)") + block = page[start - 900 : start] + + assert "else:" in block + assert "transcript.comments_panel.open_first()" in page[start : start + 200] + assert "data_format" not in page[start : start + 200] + + def test_the_validation_panel_is_still_subtitles_only(self): + page = pathlib.Path("pages/srt.py").read_text() + start = page.index("editor.validation_panel = ValidationPanel(") + + assert 'if data_format == "srt":' in page[start - 200 : start] + + +class TestWhereItIs: + """ + A transcription does not show a caption's number, so the card says where + a comment is by what is shown beside the text, and the text marks it. + """ + + def test_it_is_the_speaker_and_the_start_time_as_the_editor_shows_them( + self, panel, editor + ): + editor.captions[1].speaker = "Speaker 2" + editor.captions[1].comments = [comment({0}, "here")] + + panel.show(editor.captions[1], editor.captions[1].comments[0]) + + from utils.transcript_editor import format_time_label + + assert panel.detail.text == ( + "Speaker 2 \u00b7 " + format_time_label(editor.captions[1].get_start_seconds()) + ) + assert "Paragraph" not in panel.detail.text + + def test_without_a_speaker_it_is_the_time_alone(self, panel, editor): + from utils.comments_panel import where_text + + editor.captions[0].speaker = "" + + assert where_text(editor.captions[0]) == "00:00:00.000" + + def test_the_comment_on_show_is_marked_in_the_text(self, panel, editor): + editor.captions[1].comments = [comment({0}, "a"), comment({2}, "b")] + + panel.show(editor.captions[1], editor.captions[1].comments[1]) + + assert panel.transcript.body.comment_mark == (editor.captions[1].index, 1) + + def test_a_comment_being_written_marks_its_caption_only(self, panel, editor): + panel.open_for(editor.captions[0], 5, 9) + + assert panel.transcript.body.comment_mark == (editor.captions[0].index, -1) + + def test_stepping_moves_the_marking_with_the_card(self, panel, editor): + editor.captions[0].comments = [comment({0}, "first")] + editor.captions[1].comments = [comment({1}, "second")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + panel.go_next() + + assert panel.transcript.body.comment_mark == (editor.captions[1].index, 0) + + panel.go_previous() + + assert panel.transcript.body.comment_mark == (editor.captions[0].index, 0) + + def test_following_the_caret_marks_the_comment_it_lands_on(self, panel, editor): + editor.captions[1].comments = [comment({1}, "second")] + + panel.follow(editor.captions[1], editor.captions[1].comments[0], click=True) + + assert panel.transcript.body.comment_mark == (editor.captions[1].index, 0) + + def test_closing_the_card_takes_the_marking_off(self, panel, editor): + editor.captions[0].comments = [comment({0}, "x")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + panel.close() + + assert panel.transcript.body.comment_mark == (-1, -1) + + def test_a_comment_that_is_gone_takes_the_marking_with_it(self, panel, editor): + editor.captions[0].comments = [comment({0}, "x")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + editor.captions[0].comments = [] + + panel.sync() + + assert panel.container.visible is False + assert panel.transcript.body.comment_mark == (-1, -1) + + def test_the_marking_is_set_again_when_the_comment_has_moved_in_its_caption( + self, panel, editor + ): + editor.captions[0].comments = [comment({2}, "late")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + editor.captions[0].comments.insert(0, comment({0}, "early")) + + panel.sync() + + assert panel.transcript.body.comment_mark == (editor.captions[0].index, 1) + + def test_removing_the_last_comment_takes_it_off(self, panel, editor): + editor.captions[0].comments = [comment({1}, "only")] + panel.show(editor.captions[0], editor.captions[0].comments[0]) + + panel.remove() + + assert panel.transcript.body.comment_mark == (-1, -1) + + def test_the_body_keeps_the_marking_and_ignores_a_repeat(self): + from utils.transcript_editor import TranscriptBody + + body = TranscriptBody.__new__(TranscriptBody) + body._props = {"commentId": -1, "commentIndex": -1} + updates = [] + body.update = lambda: updates.append(1) + + body.set_comment_mark(3, 1) + body.set_comment_mark(3, 1) + + assert (body._props["commentId"], body._props["commentIndex"]) == (3, 1) + assert len(updates) == 1 + + def test_the_editor_clears_it_with_none(self, view): + marks = [] + view.body.set_comment_mark = lambda *a: marks.append(a) + + view.mark_comment(None) + view.mark_comment(2, 0) + + assert marks == [(-1, -1), (2, 0)] + + +class TestPanelHelpers: + def test_the_quote_is_one_line(self): + from utils.comments_panel import quote_of + + assert quote_of("two\n lines here") == "two lines here" + + def test_a_long_quote_is_shortened(self): + from utils.comments_panel import QUOTE_LENGTH, quote_of + + quoted = quote_of("word " * 50) + + assert len(quoted) == QUOTE_LENGTH + assert quoted.endswith("\u2026") + + def test_the_heading(self): + from utils.comments_panel import heading_text + + assert heading_text(True, 0, 3) == "New comment" + assert heading_text(False, 1, 3) == "Comment 2 of 3" + + +class TestTheComponent: + def source(self): + return pathlib.Path("utils/transcript_editor.js").read_text() + + def method(self, name): + source = self.source() + body = source[source.index(f"\n {name}(") :] + + return body[: body.index("\n },") + 7] + + def test_the_key_is_ctrl_shift_k_by_physical_key(self): + source = self.source() + start = source.index('event.code === "KeyK"') + block = source[start - 220 : start + 60] + + assert "!this.subtitleMode" in block + assert "event.ctrlKey" in block and "event.shiftKey" in block + assert "!event.altKey" in block + assert "!event.metaKey" in block + + def test_it_is_ahead_of_the_block_scoped_lists(self): + source = self.source() + + assert source.index('"opencomment", { spans }') < source.index( + "this.subtitleShortcut(event, at)" + ) + + def test_the_button_reports_the_selection_as_the_key_does(self): + body = self.method("commentSelection") + + assert "this.flush();" in body + assert '"opencomment", { spans: this.highlightSpans() }' in body + + def test_the_caret_in_a_comment_is_reported_once_per_comment(self): + body = self.method("reportCommentCaret") + + assert "key === this.lastCommentKey && !clicked" in body + assert '"caretcomment", { ...at, click: clicked }' in body + + def test_only_a_collapsed_caret_in_commented_words_counts(self): + body = self.method("commentAtCaret") + + assert "!selection.isCollapsed" in body + assert 'closest?.("[data-comment]")' in body + + def test_keys_report_it_and_a_click_reports_it_as_a_click(self): + update = self.method("updateCaretBlock") + click = self.method("onClick") + + assert "this.reportCommentCaret();" in update + assert "this.reportCommentCaret(true);" in click + + def test_commented_words_are_marked_up_with_what_a_screen_reader_needs(self): + source = self.source() + + assert source.count(':data-comment="run.cm"') == 2 + assert ( + source.count( + """:aria-description="run.cf ? 'Comment: ' + run.ct : null\"""" + ) + == 2 + ) + + def test_the_classes_come_from_the_one_function(self): + source = self.source() + + assert source.count(':class="runClass(run)"') == 2 + assert 'run.cm !== undefined ? "comment-word" : null' in source + assert 'run.cl ? "comment-end" : null' in source + + def test_the_comment_on_show_is_marked_on_its_caption_and_its_words(self): + source = self.source() + + assert "commentId: { type: Number, default: -1 }" in source + assert "commentIndex: { type: Number, default: -1 }" in source + assert "'transcript-cell-commenting': block.id === commentId" in source + assert "block.id === this.commentId" in source + assert "run.cm === this.commentIndex" in source + assert source.count("isCommentOnShow(run, block)") == 3 # two templates and the method + + def test_nothing_of_the_old_box_is_left(self): + source = self.source() + + for gone in ("comment-box", "showComment", "peekcomment", "leaveComment"): + assert gone not 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_colour_in_both_themes(self): + assert self.css().count("--color-comment-line:") == 2 + + def test_remove_and_cancel_do_not_use_the_light_primary_blue(self): + # Quasar's primary blue on white is 3.06:1, under 4.5:1 for 14px + # text. The text takes the brand colour (a dark navy) in light mode. + rule = self.rule(" .body--light .comments-panel-actions .q-btn--flat,") + + assert ".q-btn__content" in rule + assert "color: var(--color-brand-primary) !important;" in rule + + def test_commented_words_have_a_dotted_underline_that_is_not_only_colour(self): + rule = self.rule(" .comment-word {") + + assert "text-decoration-line: underline;" in rule + assert "text-decoration-style: dotted;" in rule + assert "text-decoration-thickness: 3px;" in rule + assert "text-underline-offset: 4px;" in rule + assert "var(--color-comment-line)" in rule + + def test_the_underline_takes_no_room_and_no_box_shadow(self): + rule = self.rule(" .comment-word {") + + assert "padding" not in rule + assert "margin" not in rule + assert "box-shadow" not in rule + assert "::after" not in rule + + def test_nothing_is_added_after_the_last_word(self): + css = self.css() + + assert ".comment-end::after" not in css + assert ".comment-word::after" not in css + + def test_the_caption_on_show_has_a_rule_and_an_outline_in_the_comment_colour(self): + rule = self.rule(" .transcript-cell-commenting {") + + assert "border-left-width: 3px;" in rule + assert "inset 0 0 0 2px var(--color-comment-line)" in rule + assert "var(--color-comment-line)" in rule + + def test_it_comes_after_the_rules_it_has_to_win_over(self): + css = self.css() + at = css.index(" .transcript-cell-commenting {") + + for earlier in ( + " .transcript-cell-editing {", + " .transcript-cell-invalid {", + " .transcript-cell-highlighted {", + ): + assert css.index(earlier) < at + + def test_the_words_on_show_get_a_tint_that_leaves_their_own_wash_showing(self): + rule = self.rule(" .comment-word[data-comment-current] {") + + # background-image paints above background-color, so a highlighted, + # flagged or changed word keeps its own wash underneath. + assert "background-image" in rule + assert "background-color" not in rule + assert "18%" in rule + assert "padding" not in rule + assert "box-shadow" not in rule + + @pytest.mark.parametrize( + "line,walls", + [ + ( + "#a1136a", + [ + ("#ffffff", "#111827"), + ("#fff1a3", "#2b2400"), + ("#ede9fb", "#2f1c66"), + ("#dcf2ee", "#10453d"), + ("#dbe4f0", "#111827"), + ], + ), + ( + "#ff9fd2", + [ + ("#16181d", "#e6e8eb"), + ("#5a4a00", "#fff6cc"), + ("#2f2748", "#ece7ff"), + ("#1d3b37", "#dff5f0"), + ("#1c3252", "#e6e8eb"), + ], + ), + ], + ) + def test_the_tint_keeps_the_text_and_the_line_readable(self, line, walls): + def rgb(colour): + return [int(colour[i : i + 2], 16) for i in (1, 3, 5)] + + def luminance(channels): + linear = [ + (c / 255) / 12.92 + if c / 255 <= 0.03928 + else ((c / 255 + 0.055) / 1.055) ** 2.4 + for c in channels + ] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + + def ratio(a, b): + lighter, darker = sorted((luminance(a), luminance(b)), reverse=True) + return (lighter + 0.05) / (darker + 0.05) + + for wall, text in walls: + tinted = [ + round(w * 0.82 + t * 0.18) for w, t in zip(rgb(wall), rgb(line)) + ] + + assert ratio(rgb(text), tinted) >= 4.5 + assert ratio(rgb(line), tinted) >= 3 + + def test_the_panel_has_the_colour_of_the_underline(self): + rule = self.rule(" .validation-panel.is-comment {") + + assert "--vp-tone: var(--color-comment-line);" in rule + + def test_nothing_of_the_old_box_is_left(self): + assert ".comment-box" not in self.css() + + @pytest.mark.parametrize( + "line,backgrounds", + [ + ("#a1136a", ["#ffffff", "#fff1a3", "#ede9fb", "#dcf2ee", "#dbe4f0"]), + ("#ff9fd2", ["#16181d", "#5a4a00", "#2f2748", "#1d3b37", "#1c3252"]), + ], + ) + def test_the_line_is_visible_on_every_wash_a_word_can_have(self, line, backgrounds): + def luminance(colour): + channels = [int(colour[i : i + 2], 16) / 255 for i in (1, 3, 5)] + linear = [ + c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + for c in channels + ] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + + for background in backgrounds: + lighter, darker = sorted( + (luminance(line), luminance(background)), reverse=True + ) + + assert (lighter + 0.05) / (darker + 0.05) >= 3 + + def test_the_colours_in_the_test_are_the_ones_in_the_stylesheet(self): + css = self.css() + + assert "--color-comment-line: #a1136a;" in css + assert "--color-comment-line: #ff9fd2;" in css diff --git a/tests/test_highlights.py b/tests/test_highlights.py new file mode 100644 index 0000000..511c407 --- /dev/null +++ b/tests/test_highlights.py @@ -0,0 +1,1038 @@ +# 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 + + def test_a_flagged_or_changed_word_keeps_its_own_bar(self): + review = self.rule(" .highlight-word.review-word {") + edit = self.rule(" .highlight-word.edit-word {") + + assert "inset 0 2px 0 var(--color-highlight-frame)" in review + assert "inset 0 -2px 0 var(--color-review-accent)" in review + assert "inset 0 2px 0 var(--color-highlight-frame)" in edit + assert "inset 0 -2px 0 var(--color-edit-accent)" in edit + + 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 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..af91ff0 100644 --- a/tests/test_transcript_editor.py +++ b/tests/test_transcript_editor.py @@ -2819,6 +2819,26 @@ 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", + ), + ], + ), + ( + "Comments", + [ + ( + "Comment on the selection, or open its comment", + "Ctrl + Shift + K", + ), + ("Save the comment you are writing", "Ctrl/⌘ + Enter"), + ("Close the comment without saving", "Esc"), + ], + ), ( "File operations", [ diff --git a/utils/caption.py b/utils/caption.py index 7c621ae..fe232ab 100644 --- a/utils/caption.py +++ b/utils/caption.py @@ -15,7 +15,29 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Dict, Optional, Set +from typing import Any, Dict, List, Optional, Set + + +class WordComment: + """ + A note the reader has attached to a stretch of words. + + Like a highlight it is for the reader and never changes the text: it is + kept as word positions, counted in whitespace separated words from the + start of its caption, and moves with those words through every edit. + Unlike a highlight it carries a text of its own, so each comment is a + separate object -- two neighbouring comments are two notes, not one. + """ + + def __init__(self, words, text: str) -> None: + self.words: Set[int] = set(words) + self.text: str = text + + def copy(self) -> "WordComment": + return WordComment(self.words, self.text) + + def to_dict(self) -> Dict[str, Any]: + return {"words": sorted(self.words), "text": self.text} class SRTCaption: @@ -52,6 +74,17 @@ 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() + + # The reader's comments on stretches of this caption's words. Never + # overlapping: a word belongs to at most one comment. + self.comments: List[WordComment] = [] + def copy(self) -> "SRTCaption": """ Create a deep copy of the caption. @@ -71,6 +104,8 @@ 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) + new_caption.comments = [comment.copy() for comment in self.comments] return new_caption def to_dict(self) -> Dict[str, Any]: @@ -83,6 +118,16 @@ 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 {} + ), + **( + {"comments": [comment.to_dict() for comment in self.comments]} + if self.comments + else {} + ), } def to_srt_format(self) -> str: diff --git a/utils/comments_panel.py b/utils/comments_panel.py new file mode 100644 index 0000000..5db2e57 --- /dev/null +++ b/utils/comments_panel.py @@ -0,0 +1,733 @@ +# 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 comments panel: where a comment is written, read and changed. + +A card under the video, built like the validation panel's and drawn like it -- +a stripe down the left and a tinted header, with Previous, the position and +Next in the header -- in the colour of the underline on the commented words. +It shows one comment at a time and follows the caret: put the caret in +commented words, or click them, and the panel shows that comment. + +It is shut until it is needed: it opens when a comment is written or opened +(Ctrl+Shift+K, the Comment button), when commented words are clicked, and when +a file with comments is opened. What is typed in it is kept when the reader +moves on -- to another comment, to the text, or shutting the panel -- and +abandoned only by Escape or Cancel. +""" + +from typing import Optional, Tuple + +from nicegui import ui + +from utils.caption import SRTCaption, WordComment +from utils.srt_comments import COMMENT_MAX_LENGTH +from utils.transcript_editor import format_time_label + +# The words a comment is on, as shown in the panel and read out. +QUOTE_LENGTH = 80 + + +def quote_of(text: str) -> str: + """ + The words a comment is on, flattened to one line and shortened. + """ + + flat = " ".join(text.split()) + + return flat if len(flat) <= QUOTE_LENGTH else flat[: QUOTE_LENGTH - 1] + "…" + + +def position_text(position: int, total: int) -> str: + return f"{position + 1} of {total}" + + +def heading_text(pending: bool, position: int, total: int) -> str: + if pending: + return "New comment" + + return f"Comment {position_text(position, total)}" + + +def where_text(caption: SRTCaption) -> str: + """ + Where a comment is, in what the reader can see beside the text: who is + speaking and when (the caption's number is not shown in a transcription). + """ + + when = format_time_label(caption.get_start_seconds()) + + return f"{caption.speaker} · {when}" if caption.speaker else when + + +class CommentsPanel: + """ + Built once, hidden, under the video. + + Parameters: + transcript: the TranscriptEditor, to redraw after a change and to put + the selection back on the words. + """ + + def __init__(self, transcript) -> None: + self.transcript = transcript + + # The comment on screen: (caption, comment). While a new comment is + # being written it is the last one that was on screen, and `pending` + # -- (caption, start, end) -- is what the card is about instead. + self.current: Optional[Tuple[SRTCaption, WordComment]] = None + self.pending: Optional[Tuple[SRTCaption, int, int]] = None + + with ui.element("div").classes("validation-panel-wrap w-full") as self.container: + with ui.element("section").classes( + "validation-panel is-comment w-full" + ).props('aria-labelledby="comments-panel-heading"') as self.panel: + with ui.element("div").classes("validation-panel-head"): + self.heading = ( + ui.label("") + .classes("validation-panel-heading") + .props("id=comments-panel-heading tabindex=-1") + ) + + with ui.element("div").classes("validation-panel-nav"): + self.previous = ui.button( + icon="chevron_left", on_click=self.go_previous, color=None + ).props('flat aria-label="Previous comment"').classes( + "validation-panel-step" + ) + self.position_label = ui.label("").classes( + "validation-panel-position" + ) + self.next = ui.button( + icon="chevron_right", on_click=self.go_next, color=None + ).props('flat aria-label="Next comment"').classes( + "validation-panel-step" + ) + + ui.button(icon="close", on_click=self.close).props( + 'flat aria-label="Close comments panel"' + ).classes("editor-btn editor-icon validation-panel-close").tooltip( + "Close" + ) + + # Said as the panel moves between comments, while focus stays + # where it is. + self.spoken = ui.label("").classes("sr-only").props( + "role=status aria-live=polite aria-atomic=true" + ) + + with ui.element("div").classes("validation-panel-body"): + with ui.element("div").classes("validation-panel-issue"): + with ui.element("div").classes("validation-panel-kind"): + ui.icon("comment").props("aria-hidden=true") + ui.label("Comment") + + with ui.element("div").classes("validation-panel-detail"): + self.words_button = ( + ui.button("", on_click=self.go_to_words, color=None) + .props("flat dense no-caps") + .classes("validation-panel-caption comments-panel-words") + ) + self.separator = ui.label("·").props("aria-hidden=true") + self.detail = ui.label("") + + self.text = ( + ui.textarea(label="Comment") + .props(f"outlined autogrow maxlength={COMMENT_MAX_LENGTH}") + .classes("w-full comments-panel-text") + ) + # Every key typed here stays here: the page has + # shortcuts of its own (undo, save, play) that would + # otherwise act on the transcript while a sentence is + # being written. Escape abandons and Ctrl/Cmd+Enter + # saves. + self.text.on( + "keydown", js_handler="(event) => event.stopPropagation()" + ) + self.text.on("keydown.escape", self.cancel) + self.text.on("keydown.ctrl.enter", self.save) + self.text.on("keydown.meta.enter", self.save) + self.text.on_value_change(self.value_changed) + + with ui.element("div").classes("comments-panel-actions"): + self.remove_button = ( + ui.button("Remove", on_click=self.remove) + .props("flat dense no-caps") + .classes("comments-panel-remove") + ) + self.cancel_button = ui.button( + "Cancel", on_click=self.cancel + ).props("flat dense no-caps") + self.save_button = ui.button( + "Save", on_click=self.save + ).props("dense no-caps") + + self.hide() + + # -- What there is ------------------------------------------------------ + + @property + def editor(self): + return self.transcript.editor + + def entries(self) -> list: + """ + Every comment in the transcription, in the order of the text. + """ + + return [ + (caption, comment) + for caption in self.editor.captions + for comment in caption.comments + ] + + def position(self) -> int: + """ + Where the comment on screen is among all of them, or -1. + """ + + if self.current is None: + return -1 + + for at, (caption, comment) in enumerate(self.entries()): + if comment is self.current[1]: + return at + + return -1 + + def span_of(self, caption: SRTCaption, comment: WordComment) -> Optional[tuple]: + """ + The character range of the words a comment is on. + """ + + return self.editor.word_range(caption.text, min(comment.words), max(comment.words)) + + def shown_range(self) -> Optional[tuple]: + """ + (caption, start, end) of what the card is about: the words of the + comment on screen, or of the one being written. + """ + + if self.pending is not None: + return self.pending + + if self.current is not None: + span = self.span_of(*self.current) + + if span is not None: + return self.current[0], span[0], span[1] + + return None + + # -- What was typed ------------------------------------------------------- + + def draft(self) -> str: + return self.editor.clean_comment(self.text.value) + + def dirty(self) -> bool: + """ + Whether there is something typed that is not yet the comment. + """ + + if not self.draft(): + return False + + if self.pending is not None: + return True + + return self.current is not None and self.draft() != self.current[1].text + + def save_draft(self) -> Optional[tuple]: + """ + Keep what is typed as the comment on the words it is about, redraw + the text, and say where: (caption, start, end), or None when nothing + was kept (nothing typed, the same as there, refused). + """ + + shown = self.shown_range() + + if shown is None or not self.draft(): + return None + + caption, start, end = shown + + if not self.editor.set_comment(caption, start, end, self.draft()): + return None + + self.transcript.refresh() + self.transcript.changed() + + return shown + + def commit(self) -> bool: + """ + Keep what is typed, if there is anything: the reader is moving on, and + nothing they wrote is thrown away by it. + """ + + if not self.dirty(): + return False + + return self.save_draft() is not None + + def value_changed(self) -> None: + self.save_button.set_enabled(bool(self.draft())) + + def hide(self) -> None: + """ + Put the card away, and take its marking off the text. + """ + + self.container.set_visibility(False) + self.transcript.mark_comment(None) + + def mark(self) -> None: + """ + Mark in the text what the card is about: the caption, and for a + comment already written, its words. + """ + + if not self.container.visible: + self.transcript.mark_comment(None) + elif self.pending is not None: + self.transcript.mark_comment(self.pending[0].index) + elif self.current is not None: + caption, comment = self.current + at = next( + (i for i, held in enumerate(caption.comments) if held is comment), + -1, + ) + self.transcript.mark_comment(caption.index, at) + else: + self.transcript.mark_comment(None) + + # -- Showing ---------------------------------------------------------------- + + def show( + self, + caption: SRTCaption, + comment: Optional[WordComment] = None, + words: Optional[tuple] = None, + focus: bool = False, + scroll: bool = True, + ) -> None: + """ + Put a comment on the card -- an existing one, or a new one to be + written on `words` (start, end) -- and open the panel. + """ + + if comment is not None: + self.current = (caption, comment) + self.pending = None + self.text.set_value(comment.text) + else: + self.pending = (caption, words[0], words[1]) + self.text.set_value("") + + self.container.set_visibility(True) + self.draw(scroll=scroll) + + if focus: + self.focus_text() + + def open_for(self, caption: SRTCaption, start: int, end: int) -> bool: + """ + The key or the button: write a comment on the words around a + selection, or open the one they already have. False when the + selection takes no word. + """ + + target = self.editor.comment_target(caption, start, end) + + if target is None: + return False + + self.reveal() + self.commit() + + if target["index"] is not None: + self.show(caption, caption.comments[target["index"]], focus=True) + else: + self.show(caption, words=(target["start"], target["end"]), focus=True) + + return True + + def reveal(self) -> None: + """ + The key or the button, while comments are hidden: turn them on again, + and say so. The comment on these words, or the one about to be + written, opens as it always does; nothing is taken away, and what is + hidden is not touched. + + The notice is a toast, which is announced as it appears (Quasar gives + it role=alert) and stays until closed, like the others. + """ + + if self.editor.show_comments: + return + + self.editor.set_show_comments(True) + + ui.notify( + "Comments were hidden and are shown again.", + type="info", + timeout=None, + close_button="Close", + ) + + if self.editor.on_comments_shown is not None: + self.editor.on_comments_shown() + + def put_away(self) -> None: + """ + The comments have been hidden: keep what was typed, as when the panel + is shut, and shut it without moving focus off the switch. + """ + + self.commit() + self.pending = None + self.current = None + self.hide() + + def open_first(self) -> None: + """ + A file with comments has just been opened: show the first, without + taking focus or moving the text. Not while they are hidden. + """ + + if not self.editor.show_comments: + return + + entries = self.entries() + + if entries: + caption, comment = entries[0] + self.show(caption, comment, scroll=False) + + def follow(self, caption: SRTCaption, comment: WordComment, click: bool) -> None: + """ + The caret is in a comment's words: show that comment. + + An open panel follows the caret; a shut one opens only for a click, + so an arrow key that happens to cross commented words does not throw + a card up under someone typing. A comment already on screen is left + alone. + """ + + if not self.editor.show_comments: + return + + if not (self.container.visible or click): + return + + if ( + self.container.visible + and self.pending is None + and self.current is not None + and self.current[1] is comment + ): + return + + self.commit() + self.show(caption, comment, scroll=False) + + def draw(self, scroll: bool = True) -> None: + """ + Redraw the card on what it is about. + """ + + shown = self.shown_range() + entries = self.entries() + total = len(entries) + at = self.position() + pending = self.pending is not None + + self.heading.set_text(heading_text(pending, max(at, 0), total)) + self.position_label.set_text("" if pending else position_text(at, total)) + self.spoken.set_text( + "New comment." if pending else f"Comment {position_text(at, total)}." + ) + + self.previous.set_enabled(not pending and at > 0) + self.next.set_enabled(not pending and 0 <= at < total - 1) + + if shown is not None: + caption, start, end = shown + self.words_button.set_text(f"“{quote_of(caption.text[start:end])}”") + spoken = quote_of(caption.text[start:end]).replace('"', "'") + self.words_button.props(f'aria-label="Go to the words: {spoken}"') + self.detail.set_text(where_text(caption)) + + if scroll and self.transcript.body is not None: + self.transcript.body.scroll_to_block(caption.index) + + self.remove_button.set_visibility(not pending) + self.cancel_button.set_visibility(True) + self.save_button.set_enabled(bool(self.draft())) + self.mark() + + # -- Acting ------------------------------------------------------------------- + + def select_words(self, caption: SRTCaption, start: int, end: int) -> None: + """ + Put the selection, and with it focus, back on the words. + """ + + if self.transcript.body is not None: + self.transcript.body.select_range(caption.index, start, caption.index, end) + + def save(self) -> None: + """ + Save and go back to the text, on the words. + """ + + shown = self.save_draft() + + if shown is None: + return + + caption, start, end = shown + touched = self.editor.words_in_selection(caption.text, start, end) + at = self.editor.comment_index_at(caption, touched[0]) if touched else None + + if at is None: + return + + self.pending = None + self.current = (caption, caption.comments[at]) + self.draw(scroll=False) + self.select_words(caption, start, end) + + def remove(self) -> None: + """ + Take the comment off its words and carry on with the next one. + """ + + shown = self.shown_range() + + if self.pending is not None or shown is None: + return + + caption, start, end = shown + before = self.position() + + if not self.editor.remove_comment(caption, start, end): + return + + self.transcript.refresh() + self.transcript.changed() + + entries = self.entries() + self.current = None + + if entries: + caption_next, comment_next = entries[min(max(before, 0), len(entries) - 1)] + self.show(caption_next, comment_next, scroll=False) + else: + self.hide() + + self.select_words(caption, start, end) + + def cancel(self) -> None: + """ + Abandon what is typed -- the comment goes back to what it was, a new + one is not made -- and go back to the text, on the words. + """ + + shown = self.shown_range() + + if self.pending is not None: + self.pending = None + + if self.position() >= 0: + self.show(*self.current, scroll=False) + else: + self.current = None + self.hide() + elif self.current is not None: + self.text.set_value(self.current[1].text) + + if shown is not None: + self.select_words(*shown) + + def close(self) -> None: + """ + Put the panel away, keeping what was typed, and go back to the text. + """ + + shown = self.shown_range() + self.commit() + self.pending = None + self.hide() + + if shown is not None: + self.select_words(*shown) + else: + self.transcript.focus_caret() + + def go_next(self) -> None: + self.step(1) + + def go_previous(self) -> None: + self.step(-1) + + def step(self, direction: int) -> None: + if self.pending is not None: + return + + self.commit() + + entries = self.entries() + at = self.position() + direction + + if not 0 <= at < len(entries): + return + + caption, comment = entries[at] + self.show(caption, comment, scroll=True) + + # At either end the button just used is disabled; keep focus on + # something that can still take it. + if at == 0: + self.focus(self.next) + elif at == len(entries) - 1: + self.focus(self.previous) + + def go_to_words(self) -> None: + """ + From the card to the text: the words are selected, and the text + scrolled to them. + """ + + shown = self.shown_range() + + if shown is None: + return + + if self.transcript.body is not None: + self.transcript.body.scroll_to_block(shown[0].index) + + self.select_words(*shown) + + # -- Keeping up with the text ------------------------------------------------------ + + def sync(self) -> None: + """ + The text has been redrawn: an undo, a merge, a delete, an edit or a + renamed speaker may have replaced the comment on screen, moved it, or + taken it away. Find it again, or shut the panel. + + The card is drawn again either way, so that what it says about where + the comment is (who is speaking, and when) stays what the text shows. + What is typed in its text box is not touched, so a comment being + written is not lost to a change somewhere else. + """ + + if not self.container.visible: + return + + if self.pending is not None: + if any(caption is self.pending[0] for caption in self.editor.captions): + self.draw(scroll=False) + return + + self.pending = None + self.hide() + return + + if self.current is None: + return + + caption, comment = self.current + + if any(comment is held for held in caption.comments) and any( + caption is held for held in self.editor.captions + ): + self.draw(scroll=False) + return + + found = self.find_again(caption, comment) + + if found is None: + self.current = None + self.hide() + return + + unsaved = self.dirty() + self.current = found + + if not unsaved: + self.text.set_value(found[1].text) + + self.draw(scroll=False) + + def find_again( + self, caption: SRTCaption, comment: WordComment + ) -> Optional[tuple]: + """ + The comment on the card, now that the objects holding it have been + replaced: (caption, comment), or None when it is gone. + + An undo puts back a copy with the same words. An edit of the text + builds new comments whose words have moved with it (a word typed into + the middle of the commented words is one more of them, a word deleted + from them one less), so there the same words are not to be had, and + the comment is the one in the caption that has the same text and + shares the most words with the old one. A caption of the same number + is the same caption. + """ + + for candidate in self.editor.captions: + if candidate.index != caption.index: + continue + + for held in candidate.comments: + if held.words == comment.words: + return candidate, held + + alike = [held for held in candidate.comments if held.text == comment.text] + + if not alike: + continue + + best = max( + alike, + key=lambda held: ( + len(set(held.words) & set(comment.words)), + -abs(min(held.words) - min(comment.words)), + ), + ) + + return candidate, best + + return None + + @staticmethod + def focus(element: ui.element) -> None: + ui.run_javascript( + f"const t = getElement({element.id});" + "const el = t && (t.$el || t);" + "if (el && el.focus) el.focus();" + ) + + def focus_text(self) -> None: + """ + Into the text area, so what is typed next is the comment. + """ + + self.text.run_method("focus") diff --git a/utils/srt.py b/utils/srt.py index 7f2a08f..1b697bc 100644 --- a/utils/srt.py +++ b/utils/srt.py @@ -24,6 +24,7 @@ from utils.caption import SRTCaption from utils.common import get_auth_header from utils.settings import get_settings +from utils.srt_comments import CommentsMixin from utils.srt_export import ExportMixin from utils.srt_render import RenderMixin, CHARACTER_LIMIT_EXCEEDED_COLOR from utils.srt_review import ( @@ -66,7 +67,7 @@ settings = get_settings() -class SRTEditor(ReviewMixin, SearchMixin, ExportMixin, RenderMixin): +class SRTEditor(ReviewMixin, SearchMixin, ExportMixin, RenderMixin, CommentsMixin): def __init__(self, uuid: str, srt_format: str, filename: str): """ Initialize the SRT editor with empty captions and other properties. @@ -123,6 +124,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 comments, 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 comment forgotten for days is worse than having to + # switch them off again. + self.show_comments = True + # Told when a command turns them back on, so the switch can follow. + self.on_comments_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 +692,21 @@ 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) + } + + # And the reader's comments. + restored.comments = self.restore_comments( + segment.get("comments"), segment["text"] + ) + self.captions.append(restored) self.speakers.add(segment.get("speaker", "")) @@ -956,9 +980,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 +1005,18 @@ 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()) + ) + kept_comments, moved_comments = self.split_comments( + caption.comments, len(first_part.split()) + ) # Update first caption caption.text = first_part caption.edited_words = kept + caption.highlighted_words = kept_highlights + caption.comments = kept_comments caption.end_time = self.seconds_to_timestamp(mid_seconds) # Create second caption @@ -998,6 +1028,8 @@ def split_caption( speaker=caption.speaker, ) new_caption.edited_words = moved + new_caption.highlighted_words = moved_highlights + new_caption.comments = moved_comments # Insert new caption caption_index = self.captions.index(caption) @@ -1079,9 +1111,29 @@ 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) + + # A comment stays on the word it was written about: one on the moved + # word goes with it, and one on that word and others is on both sides + # (a copy with the same text), as when a block is split. + moved_comments, caption.comments = self.split_comments(caption.comments, 1) + previous.comments = self.join_comments( + previous.comments, moved_comments, 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 +1188,29 @@ 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) + + # See move_first_word_to_previous: a comment stays on its word. + caption.comments, moved_comments = self.split_comments( + caption.comments, last_index + ) + following.comments = self.join_comments( + moved_comments, following.comments, 1 + ) + 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 +1233,66 @@ 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", 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 "" + + remove = all( + position in caption.highlighted_words + for caption, positions in touched + for position in positions + ) + + 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 add_caption_after(self, caption: SRTCaption) -> None: """ Add a new caption after the selected one. @@ -1270,9 +1402,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 +1457,26 @@ 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) + old_comments = [comment.copy() for comment in first.comments] 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_comments = self.join_comments( + old_comments, caption.comments, 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.comments = self.carry_comments(old_text, merged, old_comments) first.text = merged first.end_time = last.end_time @@ -1378,6 +1520,14 @@ 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()), + ) + caption.comments = self.join_comments( + caption.comments, next_caption.comments, 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 +1576,16 @@ 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()), + ) + previous_caption.comments = self.join_comments( + previous_caption.comments, + caption.comments, + 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_comments.py b/utils/srt_comments.py new file mode 100644 index 0000000..5c9740b --- /dev/null +++ b/utils/srt_comments.py @@ -0,0 +1,318 @@ +# 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 comments: notes attached to a stretch of words. + +A comment never changes the text. It is kept as word positions plus its own +text (see caption.WordComment) and follows its words through every edit the +way a highlight does. The rules that differ from a highlight's: + +- A comment belongs to one caption. A selection reaching across captions is + refused, so a stretch of words and the note about it cannot be torn apart by + a split or a merge that nobody asked about. +- A word has at most one comment. Asking to comment on words that already have + one opens that comment for editing instead of laying a second over it. +- When the last of a comment's words is gone, so is the comment. +""" + +import re +from typing import List, Optional, Tuple + +from nicegui import ui + +from utils.caption import SRTCaption, WordComment + +# Long enough for a thought, short enough that a saved transcription is not +# bloated by a pasted page. +COMMENT_MAX_LENGTH = 1000 + + +class CommentsMixin: + """ + Adding, editing, removing and carrying the reader's comments. + """ + + @staticmethod + def clean_comment(text) -> str: + """ + A comment's text as it is kept: no blank edges, line breaks as typed. + """ + + return text.strip() if isinstance(text, str) else "" + + @staticmethod + def restore_comments(saved, text: str = "") -> List[WordComment]: + """ + Comments as a saved transcription holds them. Anything that is not a + usable comment is skipped rather than failing the whole file: a note + with no words or no text, positions that are not whole numbers, and a + word already taken by an earlier comment (a word has only one). + """ + + if not isinstance(saved, list): + return [] + + restored: List[WordComment] = [] + taken: set = set() + count = len(text.split()) if text else None + + for item in saved: + if not isinstance(item, dict): + continue + + note = item.get("text") + positions = item.get("words") + + if not isinstance(note, str) or not note.strip(): + continue + + if not isinstance(positions, list): + continue + + words = { + position + for position in positions + if isinstance(position, int) + and not isinstance(position, bool) + and position >= 0 + and (count is None or position < count) + and position not in taken + } + + if not words: + continue + + taken |= words + restored.append(WordComment(words, note.strip()[:COMMENT_MAX_LENGTH])) + + restored.sort(key=lambda comment: min(comment.words)) + + return restored + + def carry_comments( + self, previous: str, current: str, comments: List[WordComment] + ) -> List[WordComment]: + """ + Carry a caption's comments across a change to its text. + + Each comment's words move by the same rules as a highlight's (see + carry_highlights, where they are explained), so the two kinds of note + cannot drift apart. A comment none of whose words are left is dropped + with them. + """ + + carried = [] + + for comment in comments: + words = self.carry_highlights(previous, current, comment.words) + + if words: + carried.append(WordComment(words, comment.text)) + + return carried + + @staticmethod + def split_comments( + comments: List[WordComment], at: int + ) -> Tuple[List[WordComment], List[WordComment]]: + """ + Divide a caption's comments where its text is split, the second half + counted from its own first word. + + A comment whose words lie on both sides is kept on both, with the same + text: the note was about all of those words, and each half still holds + some of them. + """ + + first, second = [], [] + + for comment in comments: + before = {index for index in comment.words if index < at} + after = {index - at for index in comment.words if index >= at} + + if before: + first.append(WordComment(before, comment.text)) + if after: + second.append(WordComment(after, comment.text)) + + return first, second + + @staticmethod + def join_comments( + first: List[WordComment], second: List[WordComment], offset: int + ) -> List[WordComment]: + """ + Combine two captions' comments when their text is joined, the second + caption's moved along by however many words now precede it. Comments + stay separate notes, even where one ends and the next begins. + """ + + return [comment.copy() for comment in first] + [ + WordComment({index + offset for index in comment.words}, comment.text) + for comment in second + ] + + @staticmethod + def comment_index_at(caption: SRTCaption, word: int) -> Optional[int]: + """ + Which of the caption's comments the word at this position is in. + """ + + for at, comment in enumerate(caption.comments): + if word in comment.words: + return at + + return None + + @staticmethod + def word_range(text: str, first: int, last: int) -> Optional[Tuple[int, int]]: + """ + The character offsets, in the caption's text, of the stretch from the + word at position `first` to the one at `last`, both included. None when + the text has no such words. + """ + + matches = list(re.finditer(r"\S+", text)) + + if not matches or first < 0 or last >= len(matches) or first > last: + return None + + return matches[first].start(), matches[last].end() + + def comment_target( + self, caption: SRTCaption, start: int, end: int + ) -> Optional[dict]: + """ + What a request to comment on the words around a selection is about. + + If any word taken already has a comment, that comment: the request + becomes a request to edit it, since a word has only one. Otherwise a + new comment on the words taken. None when the selection takes no word. + """ + + touched = self.words_in_selection(caption.text, start, end) + + if not touched: + return None + + existing = None + + for position in touched: + existing = self.comment_index_at(caption, position) + + if existing is not None: + break + + if existing is not None: + words = caption.comments[existing].words + text = caption.comments[existing].text + else: + words = set(range(min(touched), max(touched) + 1)) + text = "" + + span = self.word_range(caption.text, min(words), max(words)) + + if span is None: + return None + + return { + "index": existing, + "text": text, + "start": span[0], + "end": span[1], + } + + def set_comment( + self, caption: SRTCaption, start: int, end: int, text: str + ) -> str: + """ + Put a comment on the words a selection takes, or change the text of the + one they already have. + + Returns "added", "changed", or "" when nothing was done: nothing to + comment on, an empty or over-long text, or a text the comment already + has. Refused for subtitles, which are saved as plain SRT and have + nowhere to keep a comment. + """ + + if self.data_format == "srt": + return "" + + text = self.clean_comment(text) + + if not text: + return "" + + if len(text) > COMMENT_MAX_LENGTH: + ui.notify( + f"A comment can be at most {COMMENT_MAX_LENGTH} characters", + type="warning", + timeout=None, + close_button="Close", + ) + return "" + + target = self.comment_target(caption, start, end) + + if target is None: + ui.notify( + "Select the words to comment on first", + type="warning", + timeout=None, + close_button="Close", + ) + return "" + + if target["index"] is not None: + if caption.comments[target["index"]].text == text: + return "" + + self.save_state_for_undo() + caption.comments[target["index"]].text = text + self.mark_as_changed() + return "changed" + + touched = self.words_in_selection(caption.text, start, end) + + self.save_state_for_undo() + caption.comments.append( + WordComment(range(min(touched), max(touched) + 1), text) + ) + caption.comments.sort(key=lambda comment: min(comment.words)) + self.mark_as_changed() + return "added" + + def remove_comment(self, caption: SRTCaption, start: int, end: int) -> bool: + """ + Take away the comment on the words a selection takes. Says whether + there was one. + """ + + if self.data_format == "srt": + return False + + for position in self.words_in_selection(caption.text, start, end): + at = self.comment_index_at(caption, position) + + if at is not None: + self.save_state_for_undo() + del caption.comments[at] + self.mark_as_changed() + return True + + return False diff --git a/utils/srt_export.py b/utils/srt_export.py index d995c8b..5984619 100644 --- a/utils/srt_export.py +++ b/utils/srt_export.py @@ -25,6 +25,7 @@ """ import json +from datetime import datetime from nicegui import ui @@ -38,6 +39,13 @@ from utils.settings import get_settings from utils.styles import default_styles +# Who a comment in an exported document is from. It is "Scribe" on purpose, not +# the reader's username: the editor could get that (utils/token.get_user_info), +# but it would put a personal identifier into every exported file, and the +# document is only ever the reader's own, so there is no one to tell apart. +RTF_COMMENT_AUTHOR = "Scribe" +RTF_COMMENT_INITIALS = "SC" + settings = get_settings() @@ -160,6 +168,78 @@ 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 rtf_comments_available(self, others: list | None = None) -> bool: + """ + Whether any caption in this editor, or in the others exported along + with it, has a comment for the RTF export to carry. Transcriptions + only, for now. + """ + + return any( + caption.comments + for editor in [self] + list(others or []) + if editor.data_format != "srt" + for caption in editor.captions + ) + + @staticmethod + def rtf_date(moment: datetime) -> int: + """ + A moment in the packed form RTF keeps an annotation's date in: minute, + hour, day, month, years since 1900 and day of the week, in the bits of + one number. + """ + + weekday = (moment.weekday() + 1) % 7 + + return ( + moment.minute + | (moment.hour << 6) + | (moment.day << 11) + | (moment.month << 16) + | ((moment.year - 1900) << 20) + | (weekday << 29) + ) + + def comment_ranges(self, caption) -> list: + """ + The caption's comments as (start, end, text), the offsets counted in + the caption's text, in the order they occur. + """ + + ranges = [] + + for comment in caption.comments: + span = self.word_range(caption.text, min(comment.words), max(comment.words)) + + if span is not None: + ranges.append((span[0], span[1], comment.text)) + + return sorted(ranges) + def export_rtf( self, speakers: bool, @@ -167,11 +247,32 @@ def export_rtf( block_nr: bool, ts_which: str = "both", ts_fmt: str = "srt", + highlights: bool = False, + edits: bool = False, + comments: 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. + + `comments` carries the reader's comments as Word comments on the words + they are on. Off by default for the same reason. + + Transcriptions only, for now: subtitles never carry them. """ + if self.data_format == "srt": + highlights = edits = comments = False + + # One date for the whole export: every comment was made before it. + stamp = self.rtf_date(datetime.now()) + note_count = 0 + def fmt_ts(ts: str, f: str) -> str: """ Format timestamp according to selected format. @@ -210,8 +311,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 +351,69 @@ 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 " - ) + # A comment is anchored by a start and an end marker round the + # words it is on, and its text follows the end. The pieces are cut + # where a comment begins or ends, so a marker never lands inside + # one -- the text between the markers is the caption's own text + # throughout. + notes = self.comment_ranges(caption) if comments else [] + numbers = {} + + for note in notes: + numbers[note] = note_count + note_count += 1 + + cuts = sorted({edge for start, end, _ in notes for edge in (start, end)}) + offset = 0 + + for text, highlighted, edited in self.marked_spans( + caption, highlights, edits + ): + cut_at = [cut - offset for cut in cuts if 0 < cut - offset < len(text)] + segments = [ + text[first:last] + for first, last in zip([0] + cut_at, cut_at + [len(text)]) + ] + + for segment in segments: + here, there = offset, offset + len(segment) + offset = there + + for note in notes: + if note[0] == here: + rtf_data += r"{\*\atrfstart %d}" % numbers[note] + + body = to_rtf_unicode(segment).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 + + for note in notes: + if note[1] == there: + note_text = to_rtf_unicode(note[2]).replace( + "\n", r"\line " + ) + rtf_data += ( + r"{\*\atrfend %d}" % numbers[note] + + "{\\*\\atnid %s}{\\*\\atnauthor %s}\\chatn" + % (RTF_COMMENT_INITIALS, RTF_COMMENT_AUTHOR) + + r"{\*\annotation{\*\atnref %d}{\*\atndate %d}" + % (numbers[note], stamp) + + r"\pard\plain\f0\fs20 " + + note_text + + "}" + ) + + rtf_data += r"\line\line " parts.append(rtf_data) @@ -542,6 +715,31 @@ 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) + rtf_comments_incl = ui.checkbox( + "Include comments", value=False + ) + rtf_comments_incl.set_visibility( + self.rtf_comments_available( + [editor for _, editor in bulk_editors or []] + ) + ) ui.separator() # CSV options (for csv only) @@ -1007,6 +1205,18 @@ 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 + ), + comments=bool( + rtf_comments_incl.visible + and rtf_comments_incl.value + ), ) elif fmt.value == "txt": parts = [] diff --git a/utils/srt_render.py b/utils/srt_render.py index 34afae6..bfc72b1 100644 --- a/utils/srt_render.py +++ b/utils/srt_render.py @@ -712,8 +712,31 @@ 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"), + ] + ) + + # And its comments, also a transcription's alone. + commenting = ( + [] + if subtitles + else [ + ("Comment on the selection, or open its comment", "Ctrl + Shift + K"), + ("Save the comment you are writing", "Ctrl/⌘ + Enter"), + ("Close the comment without saving", "Esc"), + ] + ) + shortcut_groups = [ ("Editing", editing), + *([("Highlighting", highlighting)] if highlighting else []), + *([("Comments", commenting)] if commenting else []), ( "File operations", [ diff --git a/utils/srt_review.py b/utils/srt_review.py index e224be6..d1ac77e 100644 --- a/utils/srt_review.py +++ b/utils/srt_review.py @@ -347,6 +347,181 @@ 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 every kind 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 + ) + caption.comments = self.carry_comments( + caption.text, new_text, caption.comments + ) + + @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 +608,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 comments 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_comments, set in __init__). """ self.show_uncertain_words = bool(show) @@ -445,6 +622,18 @@ def restore_review_state( self.review_sensitivity = sensitivity + def set_show_comments(self, show: bool) -> None: + """ + Show or hide the reader's comments. + + Hiding only stops them being drawn and read out: the comments stay on + their words 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_comments = bool(show) + self.refresh_display(force_full_refresh=True) + def set_show_uncertain_words(self, show: bool) -> None: """ Toggle the review marking on the caption list. @@ -668,17 +857,27 @@ def review_runs( if not source: return [] + highlighted = caption.highlighted_words + # Hidden comments are not drawn: no run carries one, so nothing is + # marked up for a screen reader either. They stay in the caption. + commented = bool(caption.comments) and self.show_comments + if ( not self.show_uncertain_words and not self.show_my_edits and not per_word + and not highlighted + and not commented ): 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, which is always drawn: it is not one + # of the switches under the video. Nor does a comment. + one_run_per_word = ( + per_word or self.show_my_edits or bool(highlighted) or commented + ) words = self.aligned_words(caption, source) runs: list = [] @@ -690,12 +889,58 @@ 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.token_is_highlight(caption, position) + + def comment_of(position: Optional[int]) -> Optional[int]: + if position is None or not self.show_comments: + return None + return self.comment_index_at(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. + # The same goes for a comment, and for the space between two + # words that belong to different comments, which is in neither. + inside = ( + "\n" not in token + and before is not None + and after is not None + ) + space: dict = {"t": token, "flag": False} + + if inside and is_highlighted(before) and is_highlighted(after): + space["hl"] = True + + note = comment_of(before) if inside else None + + if note is not None and note == comment_of(after): + space["cm"] = note + + if len(space) > 2: + flush() + runs.append(space) + else: + plain.append(token) continue word = words[index] if index < len(words) else None @@ -704,22 +949,50 @@ 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) - - if not one_run_per_word and not flagged and not edited: + marked = is_highlighted(index - 1) + note = comment_of(index - 1) + + if ( + not one_run_per_word + and not flagged + and not edited + and not marked + and note is None + ): 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: run["edit"] = True + # A word in a comment says which one, and the first and last word + # of it say so too -- the first carries the text, which is what a + # screen reader is given, and the last is where the comment is + # opened from. Only carried when there is one. + if note is not None: + comment = caption.comments[note] + run["cm"] = note + + if index - 1 == min(comment.words): + run["cf"] = True + run["ct"] = comment.text + + if index - 1 == max(comment.words): + run["cl"] = True + # 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..12237d7 100644 --- a/utils/styles.py +++ b/utils/styles.py @@ -127,6 +127,18 @@ --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; + /* A comment's dotted underline: 7.5:1 on white and at least 6.3:1 on + every wash a word can have. */ + --color-comment-line: #a1136a; + /* 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 +264,14 @@ --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; + /* 4.6:1 on the dark highlight, at least 6.4:1 on the other washes. */ + --color-comment-line: #ff9fd2; + --color-playing-bg: #1c3252; --color-header-bg: #16181d; @@ -1372,6 +1392,75 @@ 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 the word keeps its own bar underneath, so both can be seen. + 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-review-accent); + } + .highlight-word.edit-word { + box-shadow: + inset 0 2px 0 var(--color-highlight-frame), + inset 0 -2px 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-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-edit-accent); + } + + /* A comment. The words it is on are underlined with a dotted line: a + pattern rather than a colour alone (1.4.1), and a different one from + the solid bars of the flagged and changed words. It is 3px thick and + sits 4px below the text, clear of the highlight's own frame lines: + a thinner line closer in was lost against them on words that are + both highlighted and commented. It is drawn with + text-decoration, so it needs no room of its own, shows on any wash a + word can have, and leaves the box-shadows that draw the other markings + alone. Nothing is added to the line: a marker after the last word + would sit where the caret goes. */ + .comment-word { + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-thickness: 3px; + text-decoration-color: var(--color-comment-line); + text-underline-offset: 4px; + text-decoration-skip-ink: none; + } + /* Shared hover message. A CSS box rather than title=, which the browser draws itself and stylesheets cannot reach. @@ -1843,6 +1932,30 @@ box-shadow: inset 0 0 0 2px var(--color-warning-border); } + /* The caption the comments card under the video is on, marked as the + validation panel marks its caption but in the comment colour: a + thicker rule and a full outline, so shape and not only colour say + which. After the editing and invalid rules, so it wins over them. */ + .transcript-cell-commenting { + border-left-color: var(--color-comment-line); + border-left-width: 3px; + padding-left: calc(1rem - 2px); + box-shadow: inset 0 0 0 2px var(--color-comment-line); + } + /* And the words of the comment the card is showing: a tint of the comment + colour laid over whatever else the word has. It is a background-image, + which paints above background-color, so a highlight's or a flagged + word's own wash still shows through it and nothing is taken away. At + 18% the text keeps at least 6.7:1 (light) and 5.9:1 (dark) on every + wash a word can have, and the comment line at least 4.3:1 and 3.4:1 + against it. */ + .comment-word[data-comment-current] { + background-image: linear-gradient( + color-mix(in srgb, var(--color-comment-line) 18%, transparent), + color-mix(in srgb, var(--color-comment-line) 18%, transparent) + ); + } + /* ── Validation panel, under the video (issue #138) ── */ /* Design B: a stripe down the left and a tinted header in the colour of the issue shown -- the panel's own is-warning / is-error / @@ -1868,6 +1981,42 @@ .validation-panel.is-muted { --vp-tone: var(--color-border-subtle); } + /* The comments panel, a sibling of this one: the colour of the underline + on the words it is about, which is also the one colour in the editor + that means nothing else (blue is the playing word, and the + progress marks). */ + .validation-panel.is-comment { + --vp-tone: var(--color-comment-line); + } + .comments-panel-words { + max-width: 100%; + } + .comments-panel-words .q-btn__content { + text-align: left; + white-space: normal; + } + .comments-panel-text { + margin-top: 8px; + } + .comments-panel-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + margin-top: 4px; + } + .comments-panel-remove { + margin-right: auto; + } + /* Remove and Cancel are flat Quasar buttons, which take the primary + blue (#5898d4) for their text: 3.06:1 on the card's white, under the + 4.5:1 that 14px text needs. They take the editor's own text colour + instead, the way the toolbar's quiet buttons do. Light mode only: in + dark mode the global flat-button rule already draws them white. */ + .body--light .comments-panel-actions .q-btn--flat, + .body--light .comments-panel-actions .q-btn--flat .q-btn__content { + color: var(--color-brand-primary) !important; + } /* Which kinds of issue are stepped through: a row of checkboxes under the panel, ticked while that kind is shown. */ .validation-panel-wrap { diff --git a/utils/transcript_editor.js b/utils/transcript_editor.js index 814e30f..6b58f35 100644 --- a/utils/transcript_editor.js +++ b/utils/transcript_editor.js @@ -86,7 +86,7 @@ export default { @keydown="onControlKeydown($event, block.id, 'speaker')" >{{ block.speaker }}:
Delete caption
{{ run.t }}
{{ run.t }}
{{ run.t }}
+ >{{ run.t }}