diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index ac03692b3..a28fd9fbb 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -36,6 +36,24 @@ impl Kind { } } +/// A row dragged in the list: the style's kind and name. Dropped on Create New Style it is +/// duplicated (`charStyle.duplicate`, `paraStyle.duplicate`); dropped on Delete it is deleted +/// (`charStyle.delete`, `paraStyle.delete`) unless it is Normal. Each panel's buttons accept only rows of +/// their own kind. +#[derive(Clone)] +struct RowDrag { + kind: Kind, + name: String, +} + +/// The style row of `kind` released on `button` this frame, if any; `button` is outlined while one +/// is held over it. `take`: whether a row with that name may be dropped there. +fn row_dropped(ui: &Ui, button: &egui::Response, kind: Kind, take: impl Fn(&str) -> bool) -> Option { + let held = button.dnd_hover_payload::().filter(|d| d.kind == kind && take(&d.name))?; + ui.painter().rect_stroke(button.rect, 3.0, egui::Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); + button.dnd_release_payload::().map(|_| held.name.clone()) +} + /// (name, attrs, uses) for every style, Normal first. fn styles(app: &mut VectorcraftApp, kind: Kind) -> Vec<(String, Map, u64)> { let v = app.session.execute(&format!("{}.list", kind.prefix()), &json!({})).unwrap_or_default(); @@ -91,7 +109,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let is_cur = cur_name.as_deref() == Some(name.as_str()); // Overrides: an attribute the style sets that the selection doesn't match. let overridden = is_cur && cur.as_ref().is_some_and(|(_, m)| attrs.iter().any(|(k, v)| m.get(k) != Some(v))); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), RowDrag { kind, name: name.clone() }); + } if is_cur || sel.as_deref() == Some(name.as_str()) { ui.painter().rect_filled(r, 0.0, if is_cur { t.row_selected } else { t.hover }); } else if resp.hovered() { @@ -119,30 +140,37 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let deletable = sel.as_ref().is_some_and(|s| *s != normal); widgets::bottom_bar(ui, |ui| { ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); - if widgets::icon_button_enabled( + let new = widgets::icon_button_enabled( ui, "dc-new-item", &crate::i18n::fmt(tl!("Create New {kind} Style"), &[("kind", kind.what_label())]), false, true, 24.0, - ) - .clicked() - { + ); + if let Some(name) = row_dropped(ui, &new, kind, |_| true) { + if let Ok(r) = app.run(&format!("{}.duplicate", kind.prefix()), json!({ "name": name })) { + set_pstate(ui.ctx(), kind.key(), r["name"].as_str().map(str::to_string)); + } + } else if new.clicked() { end_typing(app); if let Ok(r) = app.run(&format!("{}.new", kind.prefix()), target(app, kind, json!({}))) { set_pstate(ui.ctx(), kind.key(), r["name"].as_str().map(str::to_string)); } } - if widgets::icon_button_enabled( + let delete = widgets::icon_button_enabled( ui, "trash-2", &crate::i18n::fmt(tl!("Delete {kind} Style"), &[("kind", kind.what_label())]), false, deletable, 24.0, - ) - .clicked() + ); + if let Some(name) = row_dropped(ui, &delete, kind, |n| n != normal) { + if app.run(&format!("{}.delete", kind.prefix()), json!({ "name": name })).is_ok() && sel.as_ref() == Some(&name) { + set_pstate::>(ui.ctx(), kind.key(), None); + } + } else if delete.clicked() && let Some(n) = &sel && app.run(&format!("{}.delete", kind.prefix()), json!({ "name": n })).is_ok() { diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index 57c5d03dd..b56318bc7 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -64,6 +64,116 @@ fn styles_made_from_type_mark_only_real_overrides() { } } +/// A document with type that both kinds' style `Body` is applied to, the type selected. +fn drag_app() -> (VectorcraftApp, NodeId) { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 18})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + for kind in [Kind::Char, Kind::Para] { + app.run(&format!("{}.new", kind.prefix()), json!({"name": "Body"})).unwrap(); + app.run(&format!("{}.apply", kind.prefix()), json!({"name": "Body"})).unwrap(); + } + (app, id) +} + +/// Drag the row labeled `label` in `kind`'s panel onto the footer button whose center is +/// `from_right` points left of the panel's right edge (Create New Style: 40, Delete: 12), and +/// release it there. `other`: once the drag has started, a row of the other panel is dragged in its +/// place. → whether the button was outlined while the row was held over it. +fn drag_row_onto_footer(ctx: &egui::Context, app: &mut VectorcraftApp, kind: Kind, label: &str, from_right: f32, other: bool) -> bool { + let button = std::cell::Cell::new(egui::Pos2::ZERO); + let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| { + ui.set_width(280.0); + let right = ui.max_rect().right(); + show(app, ui, kind); + button.set(egui::pos2(right - from_right, ui.min_rect().bottom() - 12.0)); + }; + frame_events(ctx, app, vec![], &mut draw); + let row = text_rect(&frame_events(ctx, app, vec![], &mut draw), label).center(); + let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame_events(ctx, app, vec![egui::Event::PointerMoved(row), press(row, true)], &mut draw); + frame_events(ctx, app, vec![egui::Event::PointerMoved(row + vec2(0.0, 12.0))], &mut draw); + assert!(egui::DragAndDrop::payload::(ctx).is_some_and(|d| d.kind == kind && d.name == label), "the row is dragged"); + if other { + let kind = if kind == Kind::Char { Kind::Para } else { Kind::Char }; + egui::DragAndDrop::set_payload(ctx, RowDrag { kind, name: label.to_string() }); + } + let to = button.get(); + frame_events(ctx, app, vec![egui::Event::PointerMoved(to)], &mut draw); + let accent = Tokens::get(ctx).accent; + let mut out = ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(to)], ..Default::default() }, |ui| draw(app, ui)); + out.textures_delta.clear(); + let outlined = out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.stroke.color == accent && r.rect.contains(to))); + frame_events(ctx, app, vec![press(to, false)], &mut draw); + frame_events(ctx, app, vec![], &mut draw); + assert!(egui::DragAndDrop::payload::(ctx).is_none(), "the drag ended"); + outlined +} + +/// A style row dropped on Create New Style is duplicated as ` copy` in one undo step, the +/// button is outlined while the row is held over it, and the panel then highlights the copy. A row +/// of the other panel is not accepted. +#[test] +fn dragging_a_style_onto_create_new_style_duplicates_it() { + for kind in [Kind::Char, Kind::Para] { + let (mut app, _) = drag_app(); + let ctx = egui::Context::default(); + let before = styles(&mut app, kind); + let undo = app.session.doc().unwrap().history.undo.len(); + let commands = app.run_count; + assert!( + !drag_row_onto_footer(&ctx, &mut app, kind, "Body", 40.0, true), + "{} Styles: New is not outlined for the other panel's row", + kind.what() + ); + assert_eq!((app.run_count, styles(&mut app, kind)), (commands, before.clone()), "New accepts no row of the other panel"); + assert!(drag_row_onto_footer(&ctx, &mut app, kind, "Body", 40.0, false), "{} Styles: New is outlined", kind.what()); + let after = styles(&mut app, kind); + let body = before.iter().find(|s| s.0 == "Body").unwrap(); + let copy = after.iter().find(|s| s.0 == "Body copy"); + assert_eq!(copy.map(|s| &s.1), Some(&body.1), "{} Styles: the copy has Body's attributes: {after:?}", kind.what()); + assert_eq!(after.len(), before.len() + 1); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let selected: Option = pstate(&ctx, kind.key()); + assert_eq!(selected.as_deref(), Some("Body copy")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(styles(&mut app, kind), before); + } +} + +/// A style row dropped on Delete deletes that style in one undo step, also while no style is +/// selected in the panel and the button is dimmed; the type keeps its look. Normal dropped on +/// Delete stays, and Delete is not outlined for it or for a row of the other panel. +#[test] +fn dragging_a_style_onto_delete_deletes_it() { + for kind in [Kind::Char, Kind::Para] { + let (mut app, id) = drag_app(); + let ctx = egui::Context::default(); + let before = styles(&mut app, kind); + let undo = app.session.doc().unwrap().history.undo.len(); + assert_eq!(pstate::>(&ctx, kind.key()), None, "no style is selected in the panel"); + let commands = app.run_count; + assert!( + !drag_row_onto_footer(&ctx, &mut app, kind, "Body", 12.0, true), + "{} Styles: Delete is not outlined for the other panel's row", + kind.what() + ); + assert_eq!((app.run_count, styles(&mut app, kind)), (commands, before.clone()), "Delete accepts no row of the other panel"); + assert!(drag_row_onto_footer(&ctx, &mut app, kind, "Body", 12.0, false), "{} Styles: Delete is outlined", kind.what()); + let after = styles(&mut app, kind); + assert_eq!(after.iter().map(|s| s.0.as_str()).collect::>(), [before[0].0.as_str()], "{} Styles: Body is deleted", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(text_runs(&app, id)[0].1, 18.0, "the type keeps its look"); + let normal = super::super::label_or_name(&before[0].0, true).to_string(); + let commands = app.run_count; + assert!(!drag_row_onto_footer(&ctx, &mut app, kind, &normal, 12.0, false), "{} Styles: Delete is not outlined for Normal", kind.what()); + assert_eq!((app.run_count, styles(&mut app, kind)), (commands, after), "Normal stays"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(styles(&mut app, kind), before); + } +} + fn capture_app() -> (VectorcraftApp, NodeId, egui::Context) { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 509b1d35f..f6b2caf03 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -489,6 +489,7 @@ since 2026-10-01. - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **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 and attributes the new definition leaves out stay. 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`. - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html).