diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 040ff676c..81abe5c68 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -1,7 +1,8 @@ //! Tabs panel: tab-stop alignment, X position (Y in vertical type), leader and "align on", and a //! ruler numbered in the General unit on which a click adds a stop, dragging moves one and dragging -//! it off the ruler deletes it. A stop placed or dragged on the ruler lands on the nearest whole -//! point. With Snap to Unit on, or Shift held, it lands on the ruler's nearest tick. +//! it off the ruler deletes it. Ctrl-dragging a stop (Command on macOS) moves all the stops by the +//! same distance. A stop placed or dragged on the ruler lands on the nearest whole point. With Snap +//! to Unit on, or Shift held, it lands on the ruler's nearest tick. use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -40,6 +41,9 @@ fn align_id(a: TabAlign) -> &'static str { /// The panel body's margin and the ruler's own inset: the ruler's 0 is this far right of the panel. const RULER_LEFT: f32 = 10.0 + 4.0; +/// The largest stop position `text.tabs.set` takes, in points. +const MAX_STOP: f64 = 100_000.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum IndentMarker { Upper, @@ -302,6 +306,29 @@ fn apply_moved(app: &mut VectorcraftApp, ctx: &egui::Context, stops: &mut Vec Option> { + let from = stops.get(i)?.position; + let lowest = stops.iter().map(|s| s.position).fold(f64::INFINITY, f64::min); + let highest = stops.iter().map(|s| s.position).fold(f64::NEG_INFINITY, f64::max); + let offset = (position - from).min(MAX_STOP - highest).max(-lowest); + Some(stops.iter().map(|s| TabStop { position: s.position + offset, ..s.clone() }).collect()) +} + +/// Move every stop as [`moved_all`] does, in one `text.tabs.set`, and select stop `i`. Returns its +/// index, or `None` when the stops are not set and `stops` and the selection stay as they were. +fn apply_moved_all(app: &mut VectorcraftApp, ctx: &egui::Context, stops: &mut Vec, i: usize, position: f64) -> Option { + let moved = moved_all(stops, i, position)?; + apply(app, &moved).then(|| { + *stops = moved; + set_pstate(ctx, "tabs-sel", i); + i + }) +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some((node, para, span)) = current_para(app) else { @@ -429,7 +456,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Some((marker, value, both)) }); paint_markers(ui, &p, &t, MarkerPaint { rect, span, para: ¶, mixed_left, selected: selected_marker, drag: marker_drag, preview }, to_x); - for (i, s) in stops.iter().enumerate() { + let drag: Option = pstate(ui.ctx(), "tabs-drag"); + let drag_all = drag.is_some() && pstate::(ui.ctx(), "tabs-drag-all"); + // While a Ctrl-drag (Command on macOS) is on the ruler, the stops are drawn where the drop puts them. + let all_preview = drag.filter(|_| drag_all).and_then(|i| { + let pointer = ui.input(|input| input.pointer.any_down().then(|| input.pointer.interact_pos()).flatten())?; + (pointer.y <= rect.bottom() + 20.0).then(|| moved_all(&stops, i, place(pointer.x))).flatten() + }); + for (i, s) in all_preview.as_deref().unwrap_or(&stops).iter().enumerate() { let x = to_x(s.position); let c = if i == sel && selected_marker.is_none() { t.accent } else { t.text }; let y = rect.bottom() - 2.0; @@ -445,7 +479,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { p.circle_filled(egui::pos2(x + 3.0, y - 6.0), 1.5, c); } } - let drag: Option = pstate(ui.ctx(), "tabs-drag"); // The stop within 5 px of `x`. let hit_at = |x: f32| stops.iter().position(|s| (to_x(s.position) - x).abs() <= 5.0); let cancel_drag = ui.input(|i| i.key_pressed(egui::Key::Escape)); @@ -466,6 +499,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else { let hit = hit_at(pos.x); set_pstate(ui.ctx(), "tabs-drag", hit); + set_pstate(ui.ctx(), "tabs-drag-all", hit.is_some() && ui.input(|i| i.modifiers.command)); if let Some(i) = hit { set_pstate::>(ui.ctx(), "tabs-marker-sel", None); set_pstate(ui.ctx(), "tabs-sel", i); @@ -489,6 +523,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Dragged off the ruler: delete. stops.remove(i); apply(app, &stops); + } else if drag_all { + apply_moved_all(app, ui.ctx(), &mut stops, i, place(pos.x)); } else { apply_moved(app, ui.ctx(), &mut stops, i, place(pos.x)); } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index ad8f86a52..b38a64c04 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -619,6 +619,90 @@ fn a_stop_dragged_past_another_stays_selected() { assert!(texts.iter().any(|(t, _)| *t == shown), "{shown} in {texts:?}"); } +/// Press on the stop at `from` (points), move to `to` and release there with Ctrl (Command on +/// macOS) held → the stems' x positions painted in the frame before the release. +fn command_drag_on_ruler(ctx: &egui::Context, app: &mut VectorcraftApp, from: f64, to: f64) -> Vec { + let labels = ruler_frame(ctx, app, vec![], false); + let [(zero, a), (next, b), ..] = &labels[..] else { panic!("ruler labels {labels:?}") }; + assert_eq!(zero, "0"); + let per = next.parse::().unwrap() * app.session.general_unit().points(); + let x = |pt: f64| a.x - 2.0 + (pt / per) as f32 * (b.x - a.x); + let y = a.y + 15.0; + let command = egui::Event::ModifiersChanged(egui::Modifiers::COMMAND); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND }; + let (press, end) = (egui::pos2(x(from), y), egui::pos2(x(to), y)); + ruler_frame(ctx, app, vec![command.clone(), egui::Event::PointerMoved(press), button(press, true)], false); + for f in [0.25, 0.5] { + ruler_frame(ctx, app, vec![command.clone(), egui::Event::PointerMoved(press.lerp(end, f))], false); + } + let stems = stems_frame(ctx, app, vec![command.clone(), egui::Event::PointerMoved(end)]); + ruler_frame(ctx, app, vec![command, button(end, false)], false); + ruler_frame(ctx, app, vec![], false); + stems.into_iter().map(|(x, _)| x).collect() +} + +/// Ctrl-dragging a stop (Command on macOS) moves every stop by the same distance in one +/// `text.tabs.set`, one undo step, keeping each stop's alignment and leader, and the ruler shows +/// them moving during the drag. A plain drag still moves one stop. +#[test] +fn command_drag_moves_all_the_stops_together() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b c", "area": {"width": 300, "height": 100}})).unwrap(); + app.session + .execute( + "text.tabs.set", + &json!({"stops": [{"position": 30, "leader": "."}, {"position": 50, "align": "right"}, {"position": 80, "align": "decimal", "alignOn": ","}]}), + ) + .unwrap(); + let ctx = egui::Context::default(); + let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::>(); + let before = app.session.active().unwrap().history.undo.len(); + let during = command_drag_on_ruler(&ctx, &mut app, 50.0, 70.0); + assert_eq!(positions(&app), [50.0, 70.0, 100.0]); + let stops = current(&app).unwrap().1; + assert_eq!(stops.iter().map(|s| s.align).collect::>(), [TabAlign::Left, TabAlign::Right, TabAlign::Decimal]); + assert_eq!((stops[0].leader.as_str(), stops[2].align_on), (".", ',')); + assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1); + assert_eq!(pstate::(&ctx, "tabs-sel"), 1); + let after: Vec = stems_frame(&ctx, &mut app, vec![]).into_iter().map(|(x, _)| x).collect(); + assert_eq!(during.len(), 3, "{during:?}"); + assert!(during.iter().zip(&after).all(|(d, a)| (d - a).abs() < 0.5), "during the drag {during:?}, after it {after:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(positions(&app), [30.0, 50.0, 80.0]); + drag_on_ruler(&ctx, &mut app, 50.0, 70.0, false); + assert_eq!(positions(&app), [30.0, 70.0, 80.0]); +} + +/// A Ctrl-drag (Command on macOS) that would put a stop below 0 stops when the leftmost stop +/// reaches 0, so the stops keep their spacing. +#[test] +fn command_drag_stops_at_zero_and_keeps_the_spacing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 30}, {"position": 50}]})).unwrap(); + let ctx = egui::Context::default(); + command_drag_on_ruler(&ctx, &mut app, 50.0, 5.0); + assert_eq!(current(&app).unwrap().1.iter().map(|s| s.position).collect::>(), [0.0, 20.0]); + assert_eq!(moved_all(¤t(&app).unwrap().1, 1, 0.0).unwrap().iter().map(|s| s.position).collect::>(), [0.0, 20.0]); + assert!(moved_all(&[], 0, 10.0).is_none()); +} + +/// A Ctrl-drag (Command on macOS) that would put a stop past 100,000 pt stops when the rightmost +/// stop reaches 100,000 pt, so the stops move and keep their spacing. +#[test] +fn command_drag_stops_at_the_largest_position_and_keeps_the_spacing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 99_990}]})).unwrap(); + let ctx = egui::Context::default(); + command_drag_on_ruler(&ctx, &mut app, 50.0, 70.0); + assert_eq!(current(&app).unwrap().1.iter().map(|s| s.position).collect::>(), [60.0, 100_000.0]); +} + /// A stop placed by a click on the ruler or dragged on it lands on the nearest whole point, and /// the moved stop stays selected. A position typed in the X field keeps its hundredths of a point. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f6ac63f8e..27ebc9383 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -507,6 +507,7 @@ since 2026-10-01. - **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **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). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Set options (except for Actions, Brushes, Swatches, and Symbols panels)". +- **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing` and `command_drag_stops_at_the_largest_position_and_keeps_the_spacing`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. - **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). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands. diff --git a/docs/type-parity.md b/docs/type-parity.md index 521d21685..6346c1b86 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -43,7 +43,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests` | | Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` | | Hyphenation… and Justification… options | done | Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | -| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877) | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | |