fix(graphics): Style buttons show and toggle the Type-tool selection (#3673)

* fix(graphics): Style buttons show and toggle the Type-tool selection

With characters selected by the Type tool, the Faux Bold, Faux Italic,
All Caps, Small Caps, Underline and Strikethrough buttons showed the
layer's value, not the selection's. A click wrote the inverse of the
layer value to the selection, so a second click could not turn a style
off again.

The buttons are now on when every selected character has the style
(its own override, else the layer's value). New
graphic_design::range_styles lists the overrides of a character range.

generated with Claude Code

* fix(graphics): Style buttons show the layer's style without a selection

Without a Type-tool selection the list of selected characters is empty,
and "every selected character has the style" was true for it. So an
unstyled layer showed Faux Bold, Faux Italic, Underline and
Strikethrough as on, and a click wrote false. The buttons now show the
layer's value when no characters are selected (found by CodeRabbit).

generated with Claude Code

* perf(project): range_styles builds only the selected range

The Style row reads the selection's styles on every redraw, once per button.
range_styles expanded every character of the text and then sliced; it now
allocates only start..end and merges the runs that overlap it, in their
order. A test compares it with the expanded text for every range, including
overlapping and out-of-range runs; the caret and past-the-end cases are
tested too.

generated with Claude Code

* test(graphics): cover Small Caps and a mixed-caps selection

The selected-characters test toggles Small Caps too, and the mixed-selection
test makes "Dr" All Caps before extending to "Drive", so both caps buttons
are off and a click makes all of it All Caps.

generated with Claude Code
This commit is contained in:
alexanderalber
2026-11-01 21:15:36 +01:00
committed by GitHub
parent af28e9acee
commit 7800a0467e
3 changed files with 194 additions and 11 deletions
@@ -1,5 +1,5 @@
use super::*;
use filmcraft_project::graphic_design::char_style_at;
use filmcraft_project::graphic_design::{CharStyle, char_style_at, range_styles};
// ---------------------------------------------------------------------------------------------
// Properties / Essential Graphics
@@ -89,16 +89,47 @@ impl Ctx<'_> {
};
own.unwrap_or(layer)
}
/// Whether the Style row's button for the boolean property `id` (bold, italic, underline) is on.
fn flag(&self, id: &str) -> bool {
self.value(id).as_bool().unwrap_or(false)
/// The character overrides of each character of the Type-tool selection (empty without one).
fn selected(&self) -> Vec<CharStyle> {
let (Some((a, b)), Some(x)) = (self.range, self.e.layer.as_ref()) else { return Vec::new() };
range_styles(&x.runs, graphic::text_chars(self.e, self.mt), a, b)
}
/// The caps the All Caps / Small Caps buttons show: 0 none, 1 all caps, 2 small caps.
/// Whether the Style row's button for the boolean property `id` (bold, italic, underline,
/// strikethrough) is on. With a Type-tool selection it is on only when every selected
/// character has the style (its own override, else the layer's value), so a click on a mixed
/// selection styles all of it instead of unstyling the characters that had it.
fn flag(&self, id: &str) -> bool {
let chars = self.selected();
if chars.is_empty() {
return self.value(id).as_bool().unwrap_or(false);
}
let layer = pv(self.e, id, self.mt).as_bool().unwrap_or(false);
chars.iter().all(|c| {
match id {
"faux_bold" => c.faux_bold,
"faux_italic" => c.faux_italic,
"underline" => c.underline,
"strikethrough" => c.strikethrough,
_ => None,
}
.unwrap_or(layer)
})
}
/// The caps the All Caps / Small Caps buttons show: 0 none, 1 all caps, 2 small caps. With a
/// Type-tool selection the mode all selected characters share, 0 when they mix modes.
fn caps(&self) -> u32 {
match self.value("caps") {
let choice = |v: ParamValue| match v {
ParamValue::Choice(c) => c,
_ => 0,
};
let chars = self.selected();
if chars.is_empty() {
return choice(self.value("caps"));
}
let layer = choice(pv(self.e, "caps", self.mt));
let mut caps = chars.iter().map(|c| c.caps.unwrap_or(layer));
let first = caps.next().unwrap_or(layer);
if caps.all(|c| c == first) { first } else { 0 }
}
fn set(&mut self, id: &str, v: Value) {
if let (Some((a, b)), Some(k)) = (self.range, char_style_key(id)) {
@@ -673,15 +704,14 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
cx.number(ui, tl!("Text Box Width"), NumberField { id: "box_width", speed: 2.0, range: (0.0, 100_000.0), decimals: 0, unit: "" });
cx.number(ui, tl!("Text Box Height"), NumberField { id: "box_height", speed: 2.0, range: (0.0, 100_000.0), decimals: 0, unit: "" });
let (_, mut vui) = cx.row(ui, tl!("Style"));
let flag = |id: &str| cx.flag(id);
let caps = cx.caps();
for (id, label, tip, on, font) in [
("faux_bold", "T", tl!("Faux Bold"), flag("faux_bold"), Tokens::semibold(14.0)),
("faux_italic", "T", tl!("Faux Italic"), flag("faux_italic"), egui::FontId::new(14.0, egui::FontFamily::Proportional)),
("faux_bold", "T", tl!("Faux Bold"), cx.flag("faux_bold"), Tokens::semibold(14.0)),
("faux_italic", "T", tl!("Faux Italic"), cx.flag("faux_italic"), egui::FontId::new(14.0, egui::FontFamily::Proportional)),
("all_caps", "TT", tl!("All Caps"), caps == 1, Tokens::ui(12.0)),
("small_caps", "Tt", tl!("Small Caps"), caps == 2, Tokens::ui(12.0)),
("underline", "U", tl!("Underline"), flag("underline"), Tokens::ui(13.0)),
("strikethrough", "S", tl!("Strikethrough"), flag("strikethrough"), Tokens::ui(13.0)),
("underline", "U", tl!("Underline"), cx.flag("underline"), Tokens::ui(13.0)),
("strikethrough", "S", tl!("Strikethrough"), cx.flag("strikethrough"), Tokens::ui(13.0)),
] {
let r = letter_button(&mut vui, label, tip, on, &t, font);
letter_button_mark(&vui, id, &r, on, &t);
+111
View File
@@ -320,6 +320,117 @@ fn shadow_color_drag_is_one_undo_step() {
assert_eq!(color(&mut d), before, "one undo restores the original color");
}
/// With a Type-tool selection the Style buttons show and toggle the selected characters' style,
/// not the layer's: a second click turns Faux Bold (or All Caps, or Small Caps) off again.
#[test]
fn style_buttons_toggle_the_selected_characters() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace":"Captions and Graphics","tool":"Type"}));
d.exec("playhead.set", json!({"seconds":2}));
d.frames(3);
let p = d.rect("program.picture");
d.ok("ui.click", json!({"x":p[0]+p[2]*0.2,"y":p[1]+p[3]*0.35}));
d.ok("ui.type", json!({"text":"Night Drive"}));
for _ in 0..5 {
d.ok("ui.key", json!({"key":"Shift+ArrowLeft"}));
}
d.frames(3);
let styles = |d: &mut Driver| d.exec("graphics.list", json!({}))["layers"][0]["styles"].clone();
for (id, key, on, off) in
[("faux_bold", "fauxBold", json!(true), json!(false)), ("all_caps", "caps", json!(1), json!(0)), ("small_caps", "caps", json!(2), json!(0))]
{
d.ok("ui.click", json!({"id": format!("graphics.prop.{id}")}));
d.frames(3);
assert_eq!(styles(&mut d)[0][key], on, "first click styles the selection: {}", styles(&mut d));
d.ok("ui.click", json!({"id": format!("graphics.prop.{id}")}));
d.frames(3);
let st = styles(&mut d);
assert_eq!((st[0]["start"].clone(), st[0]["end"].clone(), st[0][key].clone()), (json!(6), json!(11), off), "second click unstyles it: {st}");
}
}
/// A selection whose characters mix a style shows its button off (on only when every selected
/// character has it), so a click styles all of them; reading the first character alone showed it
/// on, and the click unstyled the characters that had it. The same for the caps buttons, which
/// are both off when the selection mixes caps modes.
#[test]
fn style_button_on_a_mixed_selection_styles_all_of_it() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace":"Captions and Graphics","tool":"Type"}));
d.exec("playhead.set", json!({"seconds":2}));
d.frames(3);
let p = d.rect("program.picture");
d.ok("ui.click", json!({"x":p[0]+p[2]*0.2,"y":p[1]+p[3]*0.35}));
d.ok("ui.type", json!({"text":"Night Drive"}));
for _ in 0..5 {
d.ok("ui.key", json!({"key":"ArrowLeft"}));
}
for _ in 0..2 {
d.ok("ui.key", json!({"key":"Shift+ArrowRight"}));
}
d.frames(3);
let styles = |d: &mut Driver| d.exec("graphics.list", json!({}))["layers"][0]["styles"].clone();
d.ok("ui.click", json!({"id":"graphics.prop.faux_bold"}));
d.frames(3);
let st = styles(&mut d);
assert_eq!((st[0]["start"].clone(), st[0]["end"].clone(), st[0]["fauxBold"].clone()), (json!(6), json!(8), json!(true)), "\"Dr\" is bold: {st}");
d.ok("ui.click", json!({"id":"graphics.prop.all_caps"}));
d.frames(3);
let st = styles(&mut d);
assert_eq!((st[0]["start"].clone(), st[0]["end"].clone(), st[0]["caps"].clone()), (json!(6), json!(8), json!(1)), "\"Dr\" is All Caps: {st}");
for _ in 0..3 {
d.ok("ui.key", json!({"key":"Shift+ArrowRight"}));
}
d.frames(3);
// the runs over "Drive" (6..11, contiguous), as (start, end, value of `key`)
let runs = |st: &serde_json::Value, key: &str| -> Vec<(u64, u64, serde_json::Value)> {
let runs: Vec<_> =
st.as_array().into_iter().flatten().map(|r| (r["start"].as_u64().unwrap_or(0), r["end"].as_u64().unwrap_or(0), r[key].clone())).collect();
assert_eq!(runs.first().map(|r| r.0), Some(6), "{st}");
assert_eq!(runs.last().map(|r| r.1), Some(11), "{st}");
assert!(runs.windows(2).all(|w| w[0].1 == w[1].0), "{st}");
runs
};
d.ok("ui.click", json!({"id":"graphics.prop.faux_bold"}));
d.frames(3);
let st = styles(&mut d);
assert!(runs(&st, "fauxBold").iter().all(|r| r.2 == json!(true)), "the click bolds all of \"Drive\": {st}");
// "Dr" is All Caps, "ive" is not: both caps buttons are off, so a click makes it all All Caps
d.ok("ui.click", json!({"id":"graphics.prop.all_caps"}));
d.frames(3);
let st = styles(&mut d);
assert!(runs(&st, "caps").iter().all(|r| r.2 == json!(1)), "the click makes all of \"Drive\" All Caps: {st}");
assert!(runs(&st, "fauxBold").iter().all(|r| r.2 == json!(true)), "and keeps it bold: {st}");
}
/// Without a Type-tool selection the Style buttons show and toggle the layer's style: an unstyled
/// layer's buttons are off, so a click turns the style on (an empty selection counted as "every
/// selected character has it", so the click wrote `false`).
#[test]
fn style_buttons_toggle_the_layer_without_a_selection() {
let mut d = Driver::demo();
d.ok("ui.set", json!({"workspace": "Captions and Graphics"}));
let r = d.exec("graphics.newText", json!({"text": "Title"}));
let clip = r["clip"].as_u64().unwrap();
d.exec("graphics.selectLayer", json!({"clip": clip, "layers": [0]}));
d.frames(4);
for id in ["faux_bold", "faux_italic", "underline", "strikethrough"] {
let on = |d: &mut Driver| text_param(d, clip, id).and_then(|v| v.as_bool()).unwrap_or(false);
// near the button's left edge: in the test window the panel's scrollbar covers the
// middle of the last button of the row
let click = |d: &mut Driver| {
let b = d.rect(&format!("graphics.prop.{id}"));
d.ok("ui.click", json!({"x": b[0] + 4.0, "y": b[1] + b[3] / 2.0}));
d.frames(3);
};
assert!(!on(&mut d), "{id} starts off");
click(&mut d);
assert!(on(&mut d), "first click turns {id} on");
click(&mut d);
assert!(!on(&mut d), "second click turns {id} off");
}
}
/// Character ranges use setCharStyle, but must share the same one-gesture undo rule as a layer.
#[test]
fn selected_character_size_drag_is_one_undo_step() {