Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions crates/sublore-edit/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//! edit, and `verify` holds the parse to what the plan predicted. Nothing here reaches into
//! `sublore-formats`: the parsers stay the only authority on grammar.

use sublore_formats::override_tags::{self, StyleFlag};
use sublore_formats::{
AssEvent, AssEventKind, AssField, Cue, CueDetail, Newline, Segment, SegmentKind, Span, SrtCue,
SubtitleDocument, SubtitleFormat, MAX_TIMECODE_MS,
Expand Down Expand Up @@ -48,6 +49,17 @@ pub enum Edit {
field: AssField,
value: String,
},
/// Turn one of the four inline style flags on or off over a stretch of a cue's text. `from` and
/// `to` are byte offsets into the text as the file spells it, braces included, which is what
/// the panel's own box shows and therefore what its caret reports. Equal offsets are a caret
/// rather than a selection, and the tag then takes effect to the end of the line.
/// See edit-bar-tasks.md B11.
ToggleStyle {
cue: usize,
flag: StyleFlag,
from: usize,
to: usize,
},
/// Turn an ASS event into a `Comment:` or back into a `Dialogue:`. The descriptor is not one
/// of the fields `AssField` can name, and this changes how many cues a player would draw, so it
/// is its own edit. See edit-bar-tasks.md B8.
Expand Down Expand Up @@ -130,6 +142,12 @@ pub fn plan(document: &SubtitleDocument, edit: &Edit) -> Result<Planned, EditErr
} => plan_set_times(document, *cue, *start_ms, *end_ms),
Edit::SetField { cue, field, value } => plan_set_field(document, *cue, *field, value),
Edit::SetComment { cue, comment } => plan_set_comment(document, *cue, *comment),
Edit::ToggleStyle {
cue,
flag,
from,
to,
} => plan_toggle_style(document, *cue, *flag, *from, *to),
Edit::Insert {
before,
start_ms,
Expand Down Expand Up @@ -1084,6 +1102,91 @@ fn validate_field_value(field: AssField, value: &str) -> Result<(), EditError> {
/// cannot reach one whether the field is first, last before the text, or in between.
/// A splice over the event's own descriptor, which is the word before the colon. Nothing else on
/// the line moves, so the times and the text the verifier checks are the ones that were there.
/// The flag's state at the caret, then the opposite of it written there, and the state it had put
/// back at the far end of the selection shifted by whatever the first write inserted. That is the
/// whole of it, and it is why the writer returns a shift.
fn plan_toggle_style(
document: &SubtitleDocument,
index: usize,
flag: StyleFlag,
from: usize,
to: usize,
) -> Result<Planned, EditError> {
let located = locate(document, index)?;
let CueDetail::Ass(event) = &located.cue.detail else {
return Err(EditError::new(
EditErrorKind::NotApplicable,
"only an ASS event carries override tags",
));
};
let text = document.slice(located.cue.text);
if from > text.len()
|| to > text.len()
|| !text.is_char_boundary(from)
|| !text.is_char_boundary(to)
{
return Err(EditError::new(
EditErrorKind::NotApplicable,
format!(
"the {} range {from}..{to} is outside the cue's text or cuts a character",
flag.as_str()
),
));
}
let (start, end) = if from <= to { (from, to) } else { (to, from) };

// Where the line starts from: the style it names, and then any tag of its own before the caret.
let named = event
.field_index(AssField::Style)
.and_then(|at| event.fields.get(at).copied())
.map(|span| document.slice(field_core(document, span)))
.unwrap_or("");
let from_style = document
.ass_styles()
.iter()
.find(|style| document.ass_style_text(style)[0] == named)
.is_some_and(|style| flag.of(style));
let state = override_tags::block_at(text, start)
.and_then(|block| override_tags::value_at(text, block, flag.tag()))
.map_or(from_style, |value| {
override_tags::flag_value(&value, from_style)
});

let (written, shift) =
override_tags::set_tag(text, start, flag.tag(), if state { "0" } else { "1" });
let written = if start == end {
written
} else {
let at = end.saturating_add_signed(shift);
override_tags::set_tag(&written, at, flag.tag(), if state { "1" } else { "0" }).0
};

let write = plan_text_write(document, &located, &written)?;
Ok(Planned {
splice: Splice::new(
write.range.start,
document.slice(write.range).to_owned(),
write.inserted,
),
label: EditLabel {
kind: EditKind::ToggleStyle(flag),
cue: index,
},
expect: Expectation {
from: index,
removed: 1,
cues: vec![ExpectedCue {
text_raw: write.written,
start_ms: located.cue.start.millis(),
end_ms: located.cue.end.millis(),
}],
segments_from: located.segment_index,
segments_removed: 1,
segments_inserted: 1,
},
})
}

fn plan_set_comment(
document: &SubtitleDocument,
index: usize,
Expand Down
3 changes: 3 additions & 0 deletions crates/sublore-edit/src/splice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ pub enum EditKind {
/// Its own kind, never `SetField`: the descriptor is not one of the fields the `Format:` line
/// declares, and turning a line into a comment changes how many cues a player would draw.
SetComment,
/// Which flag is on the label, for the reason `SetField` carries its field: bold and italic on
/// one line must never merge into one undo step.
ToggleStyle(sublore_formats::override_tags::StyleFlag),
Insert,
Delete,
Split,
Expand Down
91 changes: 91 additions & 0 deletions crates/sublore-edit/tests/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use sublore_edit::error::EditErrorKind;
use sublore_edit::history::Run;
use sublore_edit::plan::Edit;
use sublore_edit::session::EditSession;
use sublore_formats::override_tags::StyleFlag;
use sublore_formats::{AssField, SubtitleDocument, SubtitleFormat};

/// A typing pause, well inside `history::COALESCE_WINDOW`.
Expand Down Expand Up @@ -985,6 +986,96 @@ fn a_field_committed_as_whitespace_writes_nothing_however_often_it_is_committed(
);
}

fn toggle_style(cue: usize, flag: StyleFlag, from: usize, to: usize) -> Edit {
Edit::ToggleStyle {
cue,
flag,
from,
to,
}
}

/// The text of one cue as the file spells it, braces included.
fn raw_text(session: &EditSession, cue: usize) -> String {
let document = session.document();
let found = document.cues().nth(cue).expect("the cue is there");
document.slice(found.text).to_owned()
}

#[test]
fn a_style_toggle_over_a_selection_wraps_it_and_leaves_the_rest_alone() {
// B11: the flag is off in the style, so the selection is turned on and turned back off at its
// far end, which is what the writer's shift is for.
let mut session = session("ass/clean/basic.ass");
let text = raw_text(&session, 0);
let at = text
.find("harbour")
.expect("the fixture's first line holds it");
session
.apply(
&toggle_style(0, StyleFlag::Bold, at, at + 7),
Run::New,
Instant::now(),
)
.expect("an ASS event takes an override tag");
assert_eq!(
raw_text(&session, 0),
format!("{}{{\\b1}}harbour{{\\b0}}{}", &text[..at], &text[at + 7..])
);

session.undo().expect("a step to undo").expect("a patch");
assert_eq!(raw_text(&session, 0), text, "one undo puts the line back");
}

#[test]
fn a_style_toggle_at_a_caret_writes_one_tag_and_no_closing_one() {
let mut session = session("ass/clean/basic.ass");
let text = raw_text(&session, 0);
session
.apply(
&toggle_style(0, StyleFlag::Italic, 0, 0),
Run::New,
Instant::now(),
)
.expect("an ASS event takes an override tag");
assert_eq!(raw_text(&session, 0), format!("{{\\i1}}{text}"));
}

#[test]
fn a_second_toggle_of_the_same_flag_turns_it_off_again() {
let mut session = session("ass/clean/basic.ass");
let text = raw_text(&session, 0);
let now = Instant::now();
session
.apply(&toggle_style(0, StyleFlag::Bold, 0, 0), Run::New, now)
.expect("the first toggle turns it on");
assert_eq!(raw_text(&session, 0), format!("{{\\b1}}{text}"));
session
.apply(
&toggle_style(0, StyleFlag::Bold, 5, 5),
Run::New,
now + APART,
)
.expect("the second reads the tag already there");
// The caret is inside the block the first write made, so the tag is replaced where it stood
// rather than a second one being added.
assert_eq!(raw_text(&session, 0), format!("{{\\b0}}{text}"));
}

#[test]
fn a_style_toggle_is_refused_outside_the_cue_and_writes_nothing() {
let mut session = session("ass/clean/basic.ass");
let before = session.to_bytes();
session
.apply(
&toggle_style(0, StyleFlag::Bold, 0, 9999),
Run::New,
Instant::now(),
)
.expect_err("a range past the end of the text is refused");
assert_eq!(session.to_bytes(), before, "a refusal writes nothing");
}

fn set_comment(cue: usize, comment: bool) -> Edit {
Edit::SetComment { cue, comment }
}
Expand Down
63 changes: 61 additions & 2 deletions crates/sublore-formats/src/override_tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,58 @@ pub fn blocks(text: &str) -> Vec<Block> {
out
}

/// One of the four flags a line can be styled with, inline. Closed on purpose: these four are the
/// ones the panel draws, and each is a boolean the style starts and an override tag may change.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StyleFlag {
Bold,
Italic,
Underline,
Strikeout,
}

impl StyleFlag {
/// The tag that carries it, backslash included.
pub fn tag(self) -> &'static str {
match self {
StyleFlag::Bold => "\\b",
StyleFlag::Italic => "\\i",
StyleFlag::Underline => "\\u",
StyleFlag::Strikeout => "\\s",
}
}

/// Its name, for a refusal that has to say which flag it is about.
pub fn as_str(self) -> &'static str {
match self {
StyleFlag::Bold => "bold",
StyleFlag::Italic => "italic",
StyleFlag::Underline => "underline",
StyleFlag::Strikeout => "strikeout",
}
}

/// What a style sets it to, which is where a line starts before any tag of its own.
pub fn of(self, style: &crate::document::AssStyle) -> bool {
match self {
StyleFlag::Bold => style.bold,
StyleFlag::Italic => style.italic,
StyleFlag::Underline => style.underline,
StyleFlag::Strikeout => style.strikeout,
}
}
}

/// Whether a tag's value reads as on. ASS writes `1` for on and `0` for off inside a line, unlike
/// the styles section, which writes `-1`; anything that is not a number leaves the state alone,
/// which is what falling back to `initial` means here.
pub fn flag_value(value: &str, initial: bool) -> bool {
value
.trim()
.parse::<i64>()
.map_or(initial, |number| number != 0)
}

/// One tag inside an override block: the name with its backslash, and the value that follows it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Tag {
Expand Down Expand Up @@ -273,14 +325,21 @@ pub fn set_tag(text: &str, at: usize, name: &str, value: &str) -> (String, isize
/// that brace opens. Raw rather than visible-only because the panel's box shows the line as the
/// file spells it, braces included, so the caret it reports is already an offset into this text.
fn block_at_raw(parsed: &[Block], at: usize) -> Option<usize> {
let braced = |block: &Block| matches!(block.kind, BlockKind::Override | BlockKind::Comment);
for (index, block) in parsed.iter().enumerate() {
if at < block.span.end {
return Some(index);
}
if at == block.span.end {
// On a boundary, the braced side wins. Just past a closing brace the tags of the block
// that closed are the ones in force; just before an opening one, the block it opens is
// where a hand means the tag to go.
if braced(block) {
return Some(index);
}
return Some(match parsed.get(index + 1) {
Some(_) => index + 1,
None => index,
Some(next) if braced(next) => index + 1,
_ => index,
});
}
}
Expand Down
8 changes: 8 additions & 0 deletions e2e/specs/command-registry.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const DECLARED = [
"asr-transcribe",
"edit-undo",
"edit-redo",
"edit-style-bold",
"edit-style-italic",
"edit-style-underline",
"edit-style-strikeout",
"edit-find",
"edit-find-next",
"edit-replace",
Expand Down Expand Up @@ -113,6 +117,10 @@ const FILE_ITEMS = [
const EDIT_ITEMS = [
{ id: "edit-undo", disabled: true },
{ id: "edit-redo", disabled: true },
{ id: "edit-style-bold", disabled: true },
{ id: "edit-style-italic", disabled: true },
{ id: "edit-style-underline", disabled: true },
{ id: "edit-style-strikeout", disabled: true },
{ id: "edit-find", disabled: true },
{ id: "edit-find-next", disabled: true },
{ id: "edit-replace", disabled: true },
Expand Down
Loading