diff --git a/crates/ui-egui/src/dialogs/char_style_options.rs b/crates/ui-egui/src/dialogs/char_style_options.rs index 41530de0e..86f8fe453 100644 --- a/crates/ui-egui/src/dialogs/char_style_options.rs +++ b/crates/ui-egui/src/dialogs/char_style_options.rs @@ -101,7 +101,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "section", ] }, - min_width: 650.0, + min_width: 720.0, + max_width: Some(720.0), ..DialogSpec::FORM }; @@ -885,40 +886,81 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own +/// id, so the stored value is found on every frame. +fn footer_id(ui: &egui::Ui) -> egui::Id { + ui.layer_id().id.with("char-style-footer") +} + +/// The dialog's body: the section list beside the selected section's content, which scrolls when it +/// is taller than the window (General lists every attribute of the style and OpenType every +/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's +/// error. The content's height is the space the footer leaves, measured last frame, so the buttons +/// stay on screen at any interface scale; the section list is outside the scroll area and stays put +/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes +/// from the button row in `dialogs::show`). fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + // The content scrolls inside a shorter window: General lists every attribute of the style and + // OpenType every feature, so the window stays within the screen and its buttons stay visible. + // The footer was measured last frame, so the room left for the content keeps it on screen at any + // interface scale; the section list stays put while the content scrolls. + let footer = ui.ctx().data(|m| m.get_temp::(footer_id(ui)).unwrap_or(120.0)); + let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0); ui.horizontal(|ui| { section_list(ui, d); ui.separator(); - ui.vertical(|ui| { - if d.str("section") == "basic" { - basic_body(ui, d); - } else if d.str("section") == "advanced" { - advanced_body(ui, d); - } else if d.str("section") == "color" { - color_body(ui, d); - } else if d.str("section") == "opentype" { - opentype_body(ui, d); - } else { - ui.horizontal(|ui| { - ui.label(tl!("Style Name:")); - form::text(ui, d, "name", 190.0); - }); - ui.separator(); - if let Ok(attrs) = edited_attrs(d) { - for (key, value) in attrs { - ui.label(format!("{}: {}", super::humanize(&key), value)); + // The section list handles a click before this frame's content is laid out, so the section + // read here is the one just picked: its own scroll offset is used from the first frame. + let section = d.str("section"); + let content_w = ui.available_width(); + // Long sections scroll both ways: at a large interface scale the window has fewer points, and + // a wide row (a character-format grid) must stay inside it rather than widen the window. + egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show( + ui, + |ui| { + ui.vertical(|ui| { + if d.str("section") == "basic" { + basic_body(ui, d); + } else if d.str("section") == "advanced" { + advanced_body(ui, d); + } else if d.str("section") == "color" { + color_body(ui, d); + } else if d.str("section") == "opentype" { + opentype_body(ui, d); + } else { + ui.horizontal(|ui| { + ui.label(tl!("Style Name:")); + form::text(ui, d, "name", 190.0); + }); + ui.separator(); + if let Ok(attrs) = edited_attrs(d) { + for (key, value) in attrs { + ui.label(format!("{}: {}", super::humanize(&key), value)); + } + } } - } - } - }); + }); + }, + ); }); - reset_button(ui, d); - match edited_attrs(d) { - Ok(attrs) => { - let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs}); - form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params); - } - Err(why) => attrs_error(ui, &why), + let footer_h = ui + .scope(|ui| { + reset_button(ui, d); + match edited_attrs(d) { + Ok(attrs) => { + let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs}); + form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params); + } + Err(why) => attrs_error(ui, &why), + } + }) + .response + .rect + .height(); + let was = ui.ctx().data(|m| m.get_temp::(footer_id(ui))); + ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h)); + if was != Some(footer_h) { + ui.ctx().request_repaint(); } false } @@ -952,6 +994,257 @@ mod tests { out.textures_delta.clear(); } + /// The texts a frame drew, with their screen rects. + fn texts_of(out: &egui::FullOutput) -> Vec<(String, egui::Rect)> { + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + for c in &out.shapes { + collect(&c.shape, &mut texts); + } + texts + } + + /// The texts of a few headless frames in `ctx` at a screen `w` by `h`, with their screen rects: + /// the dialog lays its scroll area out and measures its footer over the first frames. + fn frame_texts_in(ctx: &egui::Context, app: &mut VectorcraftApp, w: f32, h: f32) -> Vec<(String, egui::Rect)> { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h)); + let mut texts = vec![]; + for _ in 0..5 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + texts = texts_of(&out); + } + texts + } + + /// The texts of a few headless frames at a screen `w` by `h`, and the context they were drawn in. + fn frame_texts_at(app: &mut VectorcraftApp, w: f32, h: f32) -> (egui::Context, Vec<(String, egui::Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame_texts_in(&ctx, app, w, h); + (ctx, texts) + } + + /// The General section lists every attribute of the style, so the sections scroll inside a + /// window shorter than that list: the buttons stay on the screen, the window keeps its width in + /// every section, and the content is clipped by the scroll area's viewport. + #[test] + fn the_sections_scroll_in_a_window_shorter_than_the_general_list() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + let (ctx, texts) = frame_texts_at(&mut app, 900.0, 560.0); + let in_screen = |label: &str| { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1; + r.top() >= 0.0 && r.bottom() <= 560.0 + 1.0 + }; + assert!(in_screen("OK") && in_screen("Cancel"), "the buttons stay on a 560-point screen"); + let dialog = egui::Id::unique(("dialog", "charStyleOptions")); + let width = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width(); + assert!((700.0..=820.0).contains(&width), "the dialog keeps its 720-point width: {width}"); + let reset = texts.iter().find(|(text, _)| text == "Reset Panel").expect("the footer is drawn").1.top(); + assert!( + texts.iter().any(|(text, rect)| text.contains(':') && rect.bottom() > reset + 1.0), + "the General list is taller than the scroll area and runs under the footer: {texts:?}" + ); + // A short section uses the same width. + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("basic")); + let _ = frame_texts_in(&ctx, &mut app, 900.0, 560.0); + let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width(); + assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}"); + } + /// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every + /// section the section list, OK and Cancel are on the screen. + #[test] + fn every_section_and_the_buttons_fit_the_smallest_window() { + let mut app = app(); + app.run("charStyle.new", json!({"name": "Body"})).unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + const W: f32 = 800.0; + const H: f32 = 500.0; + let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"]; + for section in ["general", "basic", "advanced", "color", "opentype"] { + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section)); + let texts = frame_texts_in(&ctx, &mut app, W, H); + for label in labels { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1; + assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays on an 800 by 500 screen in {section}: {r:?}"); + } + } + } + + /// At 2x UI scale the smallest window (800 by 500 pixels) holds only 400 by 250 points: in + /// every section the section list and both buttons stay on the screen. + #[test] + fn every_section_and_the_buttons_fit_the_smallest_window_at_twice_the_scale() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.set_zoom_factor(2.0); + const W: f32 = 400.0; + const H: f32 = 250.0; + let labels = ["General", "Basic Character Formats", "Advanced Character Formats", "Character Color", "OpenType Features", "OK", "Cancel"]; + for section in ["general", "basic", "advanced", "color", "opentype"] { + app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section)); + let texts = frame_texts_in(&ctx, &mut app, W, H); + for label in labels { + let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1; + assert!( + r.top() >= 0.0 && r.bottom() <= H + 1.0 && r.left() >= 0.0 && r.right() <= W + 1.0, + "{label} stays on a 400 by 250 point screen at 2x in {section}: {r:?}" + ); + } + + // A wheel over the content scrolls it; the section list and both buttons stay put. + let at = egui::Pos2::new(300.0, 60.0); + let mut after = texts.clone(); + for _ in 0..8 { + let events = vec![ + egui::Event::PointerMoved(at), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(-40.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }, + ]; + let mut out = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(W, H))), events, ..Default::default() }, + |ui| super::super::show(&mut app, ui.ctx()), + ); + out.textures_delta.clear(); + after = texts_of(&out); + } + for label in labels { + let r = after.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn in {section}")).1; + assert!(r.top() >= 0.0 && r.bottom() <= H + 1.0, "{label} stays after scrolling in {section}: {r:?}"); + } + } + } + + /// Switching from a scrolled General section to a shorter one and back keeps General's own + /// scroll offset: the section is read after the list handles the click, so the click frame + /// stores no offset for the section that is leaving. + #[test] + fn a_scrolled_section_keeps_its_offset_after_switching_away_and_back() { + let mut app = app(); + app.run( + "charStyle.new", + json!({"name": "Body", "attrs": { + "font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0, + "kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null, + "position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false, + "strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline", + "h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0, + "language": "English: USA", "overprint_fill": false, "overprint_stroke": false, + "stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"] + }}), + ) + .unwrap(); + open(&mut app, "Body").unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 560.0)); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + texts_of(&out) + }; + let mut texts = vec![]; + for _ in 0..5 { + texts = frame(&mut app, vec![]); + } + let y = |texts: &[(String, egui::Rect)], label: &str| { + texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.top() + }; + eprintln!("DEBUG initial texts: {texts:?}"); + let general_top = y(&texts, "Style Name:"); + let before = texts.clone(); + // Scroll General down with the wheel. + let at = egui::Pos2::new(600.0, 200.0); + for _ in 0..8 { + let events = vec![ + egui::Event::PointerMoved(at), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }, + ]; + texts = frame(&mut app, events); + } + // Let the smooth wheel scroll finish before changing sections. + for _ in 0..15 { + texts = frame(&mut app, vec![]); + } + let scrolled = texts.clone(); + assert!( + before.iter().any(|(label, _)| !scrolled.iter().any(|(other, _)| other == label)) + && scrolled.iter().any(|(label, _)| !before.iter().any(|(other, _)| other == label)), + "General scrolls to other attributes" + ); + // Click Basic Character Formats, then General. + let click = |app: &mut VectorcraftApp, label: &str| { + let texts = frame(app, vec![]); + let pos = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(pos), button(true), button(false)] { + let _ = frame(app, vec![e]); + } + let _ = frame(app, vec![]); + }; + click(&mut app, "Basic Character Formats"); + let basic = frame(&mut app, vec![]); + assert!(y(&basic, "Font Family:") < general_top + 40.0, "a short section opens at its top"); + click(&mut app, "General"); + let back = frame(&mut app, vec![]); + let mut same = 0; + for (label, old) in &scrolled { + if let Some((_, new)) = back.iter().find(|(text, _)| text == label) { + assert!((new.top() - old.top()).abs() < 1.0, "General keeps its offset at {label}: {old:?} -> {new:?}"); + same += 1; + } + } + assert!(same > 0, "General draws its content again: {back:?}"); + } #[test] fn unchanged_basic_fields_preserve_original_keys() { for attrs in [ diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index eb2908400..241d13ea2 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -1,9 +1,12 @@ //! Character Styles and Paragraph Styles panels (one implementation, two kinds). //! //! Click a style to apply it to the selected text (or the Type tool's selected characters); -//! Alt-click applies it and clears overrides. The style of the selection is highlighted, with a -//! "+" when the text overrides some of its attributes. New makes a style from the selected text; -//! Alt-click on New first asks for its name (`ui.newCharStyle`, `ui.newParaStyle`). +//! Alt-click applies it and clears overrides. Double-clicking a character style's row opens its +//! Character Style Options (`ui.charStyleOptions`), as the panel menu does; a paragraph style's row +//! opens its Paragraph Style Options, the rename dialog the panel menu opens. The style of the +//! selection is highlighted, with a "+" when the text overrides some of its attributes. New makes +//! a style from the selected text; Alt-click on New first asks for its name (`ui.newCharStyle`, +//! `ui.newParaStyle`). use std::cell::RefCell; @@ -225,6 +228,19 @@ fn row_look(is_cur: bool, in_highlight: bool, hovered: bool, t: &Tokens) -> (Opt (fill, is_cur && in_highlight) } +/// Opens `name`'s options: Character Style Options for a character style, or the Paragraph Style +/// Options the Paragraph Styles panel menu opens, which rename the style (`ui.paramDialog` runs +/// `paraStyle.rename`). +fn open_style_options(app: &mut VectorcraftApp, kind: Kind, name: &str) { + if kind == Kind::Char { + app.run("ui.charStyleOptions", json!({ "name": name })).ok(); + } else { + let label = format!("{} Style Options", kind.what()); + app.run("ui.paramDialog", json!({ "command": format!("{}.rename", kind.prefix()), "label": label, "params": { "name": name, "to": name } })) + .ok(); + } +} + /// The style list and its New and Delete buttons. Shift-click highlights every style from the /// last one clicked without Shift through this one, and Cmd/Ctrl-click adds or removes one; both /// leave the text as it is. Delete deletes every highlighted style but the Normal style. @@ -235,6 +251,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let normal = all.first().map(|s| s.0.clone()).unwrap_or_default(); let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone())); let mut clicked: Option<(String, egui::Modifiers)> = None; + // A double-clicked Character Style's row, whose options open after the list is laid out. + let mut options: Option = None; + // Set on a double-click's second click, which must not apply the style again. + let mut double_click = false; let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect(); // A highlighted style that is no longer listed (renamed in Style Options, deleted by a command // or undone) is no longer highlighted. @@ -279,6 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if resp.clicked() { clicked = Some((name.clone(), ui.input(|i| i.modifiers))); } + // A double-click on a user character style opens its Character Style Options, as the + // panel menu's item does; with Shift, Cmd/Ctrl or Alt held it only follows the click + // rules. The second click never applies the style again, also on the Normal style, + // which has no options to open. + if resp.double_clicked() { + double_click = true; + let m = ui.input(|i| i.modifiers); + if *name != normal && !(m.shift || m.command || m.alt) { + options = Some(name.clone()); + } + } } }); if scroll_to.is_some() { @@ -287,12 +318,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let has_text = cur.is_some(); if let Some((name, m)) = clicked { sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m); - // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. - if has_text && !(m.shift || m.command) { + // Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel. The second + // click of a double-click opens the options instead of applying the style again. + if has_text && !(m.shift || m.command) && options.is_none() && !double_click { let p = apply_target(app, kind, json!({ "name": name, "clearOverrides": m.alt })); app.run(&format!("{}.apply", kind.prefix()), p).ok(); } } + if let Some(name) = options { + open_style_options(app, kind, &name); + } set_highlighted(ui.ctx(), kind, sel.clone()); let doomed: Vec<&String> = sel.iter().filter(|s| **s != normal).collect(); let deletable = !doomed.is_empty(); @@ -389,12 +424,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { if menu_item(ui, &crate::i18n::fmt(tl!("{kind} Style Options…"), &[("kind", kind.what_label())]), user, false) && let Some(n) = &sel { - if kind == Kind::Char { - app.run("ui.charStyleOptions", json!({ "name": n })).ok(); - } else { - let label = format!("{} Style Options", kind.what()); - app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok(); - } + open_style_options(app, kind, n); } ui.separator(); // As commands, so a file dialog shown off the UI thread loads the styles when it answers. diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index f3218bc35..2fe4ab11f 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -261,6 +261,66 @@ fn click_label(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str, mut d app.run_count.wrapping_sub(before) } +/// Double-clicking a user character style's row opens its Character Style Options once: the first +/// click applies the style to the selected text, the second opens the dialog without applying it +/// again. A paragraph style's row opens none, and the built-in Normal style opens none either. +#[test] +fn double_clicking_a_character_style_row_opens_its_options() { + for kind in [Kind::Char, Kind::Para] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Body"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, kind); + frame_events(&ctx, &mut app, vec![], &mut draw); + let texts = frame_events(&ctx, &mut app, vec![], &mut draw); + let pos = text_rect(&texts, "Body").center(); + let before = app.run_count; + click(&ctx, &mut app, pos, &mut draw); + click(&ctx, &mut app, pos, &mut draw); + let dialog = app.ui.dialog.as_ref(); + if kind == Kind::Char { + let d = dialog.expect("double-clicking a Character Style's row opens its options"); + assert_eq!(d.kind, "charStyleOptions"); + assert_eq!(d.str("__style"), "Body"); + // One apply from the first click and the dialog from the second: no second apply. + assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog"); + } else { + let d = dialog.expect("double-clicking a Paragraph Style's row opens its options"); + assert_eq!(d.kind, "command"); + assert_eq!(d.str("__command"), "paraStyle.rename"); + assert_eq!(d.str("__label"), "Paragraph Style Options"); + // One apply from the first click and the rename dialog from the second. + assert_eq!(app.run_count.wrapping_sub(before), 2, "one apply and the dialog"); + } + } +} + +/// A double-click on the built-in Normal style opens no options, and its second click applies it +/// only once, as a user style's does. +#[test] +fn double_clicking_the_normal_style_opens_no_options() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 40, "text": "Hi"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| show(app, ui, Kind::Char); + frame_events(&ctx, &mut app, vec![], &mut draw); + let texts = frame_events(&ctx, &mut app, vec![], &mut draw); + let normal = texts.iter().find(|(text, _)| text.contains("Normal")).map(|(_, r)| r.center()); + let pos = normal.expect("the Normal style's row is drawn"); + let before = app.run_count; + click(&ctx, &mut app, pos, &mut draw); + click(&ctx, &mut app, pos, &mut draw); + assert!(app.ui.dialog.is_none(), "the Normal style has no Character Style Options"); + assert_eq!(app.run_count.wrapping_sub(before), 1, "the second click applies nothing"); +} + /// The panel menu's Duplicate highlights the copy and its Delete clears the highlight, as the /// panel's New and Delete buttons do; after a menu delete, Duplicate runs nothing. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index cf3690098..a43e3af5a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (M2: Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked, #6123; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b2ac2a6c..e11eb0f7a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Pen with Cmd/Ctrl held: Copy and Cut take the anchors the lent Direct Selection picked, #6123; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). Once some corners are picked, Shift-click still adds or removes one. Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | @@ -586,6 +587,8 @@ since 2026-10-01. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Character Style Options, Advanced Character Formats:** Character Style Options and New Character Style set a style's Horizontal Scale and Vertical Scale (1% to 10,000%), Baseline Shift (-1296 pt to 1296 pt), Character Rotation (stored from -180° to 180°) and Language; text that uses the style takes them and keeps its local overrides. Empty fields remain unset, an explicit 100%, 0° or English: USA stays distinct from unset, and the section's Reset Panel clears only these five. Language sets the language the characters are shaped in, not hyphenation or spelling. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). +- **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options, the rename dialog (`ui.paramDialog` running `paraStyle.rename`) the panel menu opens. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. +- **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy ` copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`. diff --git a/docs/type-parity.md b/docs/type-parity.md index 5675a804d..dcf0eeaed 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Alt + keypad codes with NumLock off no longer move the caret, follow-up to #5939) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -29,7 +29,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | | Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show) | -| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing | +| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` | | Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) | | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | @@ -82,6 +82,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-07 | minor | Alt + numeric-keypad codes with NumLock off: the keypad keys arrive as Insert, End, arrows, Clear, Home and Page Up/Down, which Windows still composes into the code; with Alt held they now count as digits and are taken out of the frame instead of moving the caret, while the separate navigation keys (extended-key flag) keep their meaning (follow-up to #5939). Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-06 | minor | Alt + numeric-keypad codes (`Alt+0176` → °) type their character at the caret on Windows, and `type.insert` inserts a character by `code` or `unicode` (#5108); scores and estimates unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 30ed2ed66..62f6e496d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Pen with Cmd/Ctrl held: Copy and Cut take the anchors the lent Direct Selection picked, #6123; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -39,6 +39,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-10-30 | minor | Home screen: welcome, New / Open and Community are centred and the large Discord button uses the theme accent, part of #4684; scores and estimates unchanged (estimated) | | 2026-11-06 | minor | Snap to Grid: a straight segment dragged with Direct Selection lands its anchors on the grid, not the pressed point (#3835); scores and estimates unchanged | @@ -436,6 +437,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Color Effects add-on (VectorCraft addition): Object › Plug-ins › Color Effects › Find Nearest Swatch… picks a Library (Document Swatches, a built-in library, or a library or colour book the user saved or loaded) and Fill and Stroke, lists each colour of the selection with its three closest swatches (chip, name, ΔE 2000), and Apply gives each colour its nearest swatch (global and spot swatches linked; a library's swatches added to the document first), one undo step | done | #4923; `addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, `crates/engine/src/cmd/addons/color/nearest.rs`, `crates/ui-egui/src/addons/color/nearest.rs` and their tests; not a feature of the reference app, so it is not scored | | Selection Tools add-on (VectorCraft addition): Object › Plug-ins › Selection Tools › Select by Artboards… lists every artboard (number, name, width × height in the document's units), filters them by name, chooses several (click, Shift-click for a range, Cmd/Ctrl-click to toggle; Select All / None act on the rows shown; a count of those chosen), fits a row's artboard in the window on double-click and makes it active, and on OK selects the objects overlapping, fully inside or fully outside the chosen artboards (Overlapping is All on Active Artboard's test) | done | #5525, #5548, #5549; `addon.select.byArtboards`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select.rs` and their tests; not a feature of the reference app | | Selection Tools add-on (VectorCraft addition): Select Linked, Select Embedded, Select Missing and Select Modified in Object › Plug-ins › Selection Tools and the Links panel menu (off when no image has that status; Shift adds to the selection) select the images and placed documents with that link status, leaving out hidden, locked, template-layer and outside-isolation ones, which the status bar counts (e.g. "Selected 9 missing images; 3 more are hidden or locked"); Select by Link Status… lists them with name, status and format, searches by name, filters by status, picks with checkboxes and All / None, and selects with Zoom to Selection on by default | done | #5526; `addon.select.byLinkStatus`, `crates/engine/src/cmd/addons/select.rs`, `crates/ui-egui/src/addons/select/link_status.rs` and their tests; not a feature of the reference app | +| Style panels: a double-click on a user character style's row opens its Character Style Options, and on a paragraph style's row its Paragraph Style Options (rename), and the Character Style Options dialog keeps a fixed width, its sections scrolling when the window is shorter so OK and Cancel stay on screen | done | `ui.charStyleOptions`, `ui.paramDialog` (`paraStyle.rename`); `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` | ## Look and feel