diff --git a/ROADMAP.md b/ROADMAP.md index 36da2211..753e8740 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,7 +63,7 @@ _Last updated: 2026-10-02._ | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟑 Export for Screens (PNG/JPG/WebP/SVG/PDF Γ— scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | | M5 | Performance | 🟑 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3Γ— faster (50k paths: 722 β†’ 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | βœ… mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟑 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects done; Tabs panel, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟑 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟑 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | | M9 | Live effects (+ 3D & Materials) | 🟑 2D effects done incl. Effect β†’ Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟑 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -97,7 +97,7 @@ are counted bottom-up. | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 | -| Type advanced | 4 | 25% | vertical type/CJK, Tabs panel, spell check (open dictionary), Touch Type, Retype | 30–40 | +| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 | | Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 | | Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish | 6–10 | @@ -119,7 +119,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c **~115–190 h** to full parity. _Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 69 of 79 -tools implemented (missing: Slice Γ—2, Touch Type, vertical type Γ—3, Print Tiling); 31 panels of ~45; 44 live +tools implemented (missing: Slice Γ—2, Touch Type, vertical type Γ—3, Print Tiling); 32 panels of ~45; 44 live effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests. _Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 12d91930..9d4b9cc0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -38,7 +38,8 @@ pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, N pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; pub use text::{ - AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, + AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, + WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 7aac5fcb..7abe79fd 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -99,6 +99,40 @@ impl CharStyle { } } +/// Tab stop alignment (Tabs panel). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TabAlign { + #[default] + Left, + Center, + Right, + /// Aligns on the first `align_on` character (a decimal point by default). + Decimal, +} + +/// A tab stop, measured from the left edge of the text (area type: the frame's left edge). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabStop { + pub position: f64, + #[serde(default)] + pub align: TabAlign, + /// Leader characters repeated across the tab's gap (e.g. ". "). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub leader: String, + /// Decimal tabs align on this character. + #[serde(default = "default_align_on")] + pub align_on: char, +} + +fn default_align_on() -> char { + '.' +} + +/// Distance between default tab stops when no explicit stop applies (Β½ inch). +pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; + /// Paragraph attributes (the Paragraph panel). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ParaStyle { @@ -116,6 +150,9 @@ pub struct ParaStyle { pub space_after: f64, #[serde(default)] pub hyphenate: bool, + /// Tab stops (Tabs panel), sorted by position. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tabs: Vec, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. #[serde(default, skip_serializing_if = "Option::is_none")] pub style_name: Option, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8b865ea5..375fb7da 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -31,6 +31,7 @@ pub mod prefscmds; pub mod rasterfx; mod recolor; mod select; +pub(crate) mod tabs; mod textedit; pub mod textstyles; pub(crate) mod textwrap; @@ -169,6 +170,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(rasterfx::specs()); v.extend(views::specs()); v.extend(maskedit::specs()); + v.extend(tabs::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs new file mode 100644 index 00000000..3c732fc6 --- /dev/null +++ b/crates/engine/src/cmd/tabs.rs @@ -0,0 +1,100 @@ +//! Window β†’ Type β†’ Tabs: tab stops of the selected text objects' paragraphs. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeKind, TabAlign, TabStop}; + +use super::typecmd::{refresh_bounds, text_targets}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "text.tabs.set", + "Set Tab Stops", + ["Window", "Tabs"], + None, + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?} replace the tab stops of the selected text", + has_selection, + set + ), + cmd!(query "text.tabs.get", "Tab Stops", [], None, "{ids?} β†’ {stops} of the first selected text object", has_selection, get), + cmd!("text.tabs.clear", "Clear All Tabs", ["Window", "Tabs"], None, "{ids?}", has_selection, |s, p| apply(s, p, vec![], "Clear All Tabs")), + ] +} + +fn parse_stops(p: &Value) -> Result> { + const C: &str = "text.tabs.set"; + let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?; + let mut stops = vec![]; + for s in arr { + let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; + if !(0.0..=100_000.0).contains(&position) { + return Err(bad(C, "positions must be between 0 and 100000 pt")); + } + let align = match s.get("align").and_then(Value::as_str).unwrap_or("left").to_ascii_lowercase().as_str() { + "left" => TabAlign::Left, + "center" | "centre" => TabAlign::Center, + "right" => TabAlign::Right, + "decimal" => TabAlign::Decimal, + other => return Err(bad(C, format!("unknown align `{other}`"))), + }; + let leader = s.get("leader").and_then(Value::as_str).unwrap_or("").chars().take(8).collect(); + let align_on = s.get("alignOn").and_then(Value::as_str).and_then(|a| a.chars().next()).unwrap_or('.'); + stops.push(TabStop { position, align, leader, align_on }); + } + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); + Ok(stops) +} + +fn set(s: &mut Session, p: &Value) -> Result { + let stops = parse_stops(p)?; + apply(s, p, stops, "Tab Stops") +} + +fn apply(s: &mut Session, p: &Value, stops: Vec, label: &str) -> Result { + let ids = text_targets(s, p, "text.tabs.set")?; + let n = stops.len(); + s.edit(label, |d, _| { + for id in &ids { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + t.para.tabs = stops.clone(); + refresh_bounds(t); + } + } + Ok(()) + })?; + Ok(json!({ "stops": n })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let ids = text_targets(s, p, "text.tabs.get")?; + let st = s.doc()?; + let tabs = ids.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.para.tabs.clone()), + _ => None, + }); + Ok(json!({ "stops": serde_json::to_value(tabs.unwrap_or_default()).unwrap_or(Value::Null) })) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn set_get_clear_tabs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Item\t9.99"})).unwrap(); + s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "decimal"}, {"position": 80, "leader": "."}]})).unwrap(); + let g = s.execute("text.tabs.get", &json!({})).unwrap(); + assert_eq!(g["stops"][0]["position"], json!(80.0)); + assert_eq!(g["stops"][0]["leader"], json!(".")); + assert_eq!(g["stops"][1]["align"], json!("decimal")); + assert!(s.execute("text.tabs.set", &json!({"stops": [{"position": 10, "align": "diagonal"}]})).is_err()); + s.execute("text.tabs.clear", &json!({})).unwrap(); + assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([])); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8e81ce60..facb80ec 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -36,7 +36,8 @@ impl Ctx<'_> { fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { let src = self.db.outline(&g.face, g.gid); - let outline = if src.elements().is_empty() || g.is_soft_hyphen() { + // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). + let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() { BezPath::new() } else { let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); @@ -403,6 +404,32 @@ impl Pen<'_> { } } +/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then +/// every Β½ inch). Right, centre and decimal stops align the text that follows (`rest`, up to the +/// next tab) on the stop. +fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> f64 { + use vectorcraft_doc::TabAlign; + let rel = x - origin; + let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect(); + let width: f64 = seg.iter().map(|g| g.adv).sum(); + let target = |pos: f64, align: TabAlign, on: char| match align { + TabAlign::Left => pos, + TabAlign::Right => pos - width, + TabAlign::Center => pos - width / 2.0, + TabAlign::Decimal => pos - seg.iter().take_while(|g| g.ch != on).map(|g| g.adv).sum::(), + }; + let explicit = tabs.iter().find_map(|t| { + let at = target(t.position, t.align, t.align_on); + (at > rel + 1e-6).then_some(at) + }); + let at = explicit.unwrap_or_else(|| { + let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); + let base = rel.max(last); + ((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL + }); + (at - rel).max(0.0) +} + /// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -> (usize, bool) { if !width.is_finite() { @@ -653,9 +680,13 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt let glyph_start = cx.out.glyphs.len(); let mut x = start_x; let mut x_end = start_x; + // Tab stops are measured from the frame's left edge (point type: the origin). + let tab_origin = if regions.is_some() { x0 } else { 0.0 }; for (j, g) in sg.iter().enumerate().take(end).skip(i) { let mut adv = g.adv; - if j < trimmed { + if g.ch == '\t' { + adv = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); + } else if j < trimmed { if g.is_space() { adv += per_space; } else if j + 1 < trimmed { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f267e28a..1a62bfe7 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -389,3 +389,33 @@ fn layout_is_fast() { let budget = if cfg!(debug_assertions) { 100.0 } else { 5.0 }; assert!(per < budget, "{per} ms"); } + +#[test] +fn tab_stops_position_text() { + use vectorcraft_doc::{TabAlign, TabStop}; + let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' }; + let x_of = |t: &TextObject, byte: usize| layout(db(), t).glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap(); + // Default stops every 36 pt. + let mut t = point("a\tb", style(12.0)); + assert!((x_of(&t, 2) - 36.0).abs() < 1e-6); + // A left stop at 100. + t.para.tabs = vec![stop(100.0, TabAlign::Left)]; + assert!((x_of(&t, 2) - 100.0).abs() < 1e-6); + // A right stop: the text after the tab ends at 200. + let mut r = point("a\t12345", style(12.0)); + r.para.tabs = vec![stop(200.0, TabAlign::Right)]; + let lay = layout(db(), &r); + let last = lay.glyphs.last().unwrap(); + assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6); + // A decimal stop: the decimal points of two lines line up. + let mut d = point("x\t12.5\ny\t1234.75", style(12.0)); + d.para.tabs = vec![stop(150.0, TabAlign::Decimal)]; + let lay = layout(db(), &d); + let dots: Vec = lay.glyphs.iter().filter(|g| d.plain_text()[g.byte..].starts_with('.')).map(|g| g.origin.x).collect(); + assert_eq!(dots.len(), 2); + assert!((dots[0] - 150.0).abs() < 1e-6 && (dots[1] - 150.0).abs() < 1e-6, "{dots:?}"); + // Past the last explicit stop, default stops resume. + let mut p = point("a\tb\tc", style(12.0)); + p.para.tabs = vec![stop(50.0, TabAlign::Left)]; + assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b4e84737..d8bb7a43 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1210,7 +1210,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("OpenType", "openType"), panel("Paragraph", "paragraph"), panel("Paragraph Styles", "paraStyles"), - todo("Tabs"), + panel("Tabs", "tabs"), ], ), todo("Variables"), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 34e15d60..0b163e51 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -30,6 +30,7 @@ pub mod separations; pub mod stroke; pub mod swatches; pub mod symbols; +pub mod tabs; pub mod text_styles; pub mod transform; pub mod transparency; @@ -85,6 +86,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "charStyles" => text_styles::show(app, ui, text_styles::Kind::Char), "paraStyles" => text_styles::show(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::show(app, ui), + "tabs" => tabs::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -122,6 +124,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "charStyles" => text_styles::menu(app, ui, text_styles::Kind::Char), "paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::menu(app, ui), + "tabs" => tabs::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs new file mode 100644 index 00000000..3116ba2c --- /dev/null +++ b/crates/ui-egui/src/panels/tabs.rs @@ -0,0 +1,260 @@ +//! Tabs panel: tab-stop alignment, X position, leader and "align on", and a ruler on which a click +//! adds a stop, dragging moves one and dragging it off the ruler deletes it. + +use egui::{Sense, Stroke, Ui, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeKind, TabAlign, TabStop, Unit}; + +use super::{first_selected, pstate, set_pstate}; +use crate::VectorcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +const ALIGNS: [(TabAlign, &str, &str); 4] = [ + (TabAlign::Left, "β‡₯", "Left-Justified Tab"), + (TabAlign::Center, "β‡Ή", "Center-Justified Tab"), + (TabAlign::Right, "⇀", "Right-Justified Tab"), + (TabAlign::Decimal, ".β‡₯", "Decimal-Justified Tab"), +]; + +fn align_id(a: TabAlign) -> &'static str { + match a { + TabAlign::Left => "left", + TabAlign::Center => "center", + TabAlign::Right => "right", + TabAlign::Decimal => "decimal", + } +} + +/// The first selected text object's stops and the ruler span in points (the frame width for area type). +fn current(app: &VectorcraftApp) -> Option<(Vec, f64)> { + let n = first_selected(app)?; + let NodeKind::Text(t) = &n.kind else { return None }; + let span = match &t.kind { + vectorcraft_doc::TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()), + _ => 360.0, + }; + Some((t.para.tabs.clone(), span.max(72.0))) +} + +fn stops_json(stops: &[TabStop]) -> Value { + Value::Array( + stops + .iter() + .map(|s| json!({"position": s.position, "align": align_id(s.align), "leader": s.leader, "alignOn": s.align_on.to_string()})) + .collect(), + ) +} + +fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) { + if let Some((id, _, _)) = super::character::text_editing(app) { + super::character::end_typing(app); + app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0]})).ok(); + } else { + app.run("text.tabs.set", json!({"stops": stops_json(stops)})).ok(); + } +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some((mut stops, span)) = current(app) else { + super::empty_state(ui, "pilcrow", "No text selected", "Select a text object to set its tab stops."); + return; + }; + let mut sel: usize = pstate(ui.ctx(), "tabs-sel"); + if sel >= stops.len() { + sel = stops.len().saturating_sub(1); + } + let mut align_idx: usize = pstate(ui.ctx(), "tabs-align"); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + for (i, (a, label, tip)) in ALIGNS.iter().enumerate() { + let on = stops.get(sel).map_or(align_idx == i, |s| s.align == *a); + if ui.add(egui::Button::new(egui::RichText::new(*label).size(13.0)).selected(on).min_size(vec2(28.0, 24.0))).on_hover_text(*tip).clicked() + { + align_idx = i; + set_pstate(ui.ctx(), "tabs-align", i); + if let Some(s) = stops.get_mut(sel) { + s.align = *a; + apply(app, &stops); + } + } + } + }); + ui.add_space(4.0); + egui::Grid::new("tabs-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { + ui.label(egui::RichText::new("X:").color(t.text)); + let x = stops.get(sel).map(|s| s.position); + if let Some(v) = widgets::spin_field(ui, "tabs-x", x, Unit::Points, 90.0, 1.0, 0.0, &[]) + && let Some(s) = stops.get_mut(sel) + { + s.position = v.max(0.0); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + apply(app, &stops); + } + ui.end_row(); + ui.label(egui::RichText::new("Leader:").color(t.text)); + let mut leader = stops.get(sel).map(|s| s.leader.clone()).unwrap_or_default(); + if ui.add_enabled(stops.get(sel).is_some(), egui::TextEdit::singleline(&mut leader).desired_width(90.0)).lost_focus() + && let Some(s) = stops.get_mut(sel) + && s.leader != leader + { + s.leader = leader.chars().take(8).collect(); + apply(app, &stops); + } + ui.end_row(); + ui.label(egui::RichText::new("Align On:").color(t.text)); + let decimal = stops.get(sel).is_some_and(|s| s.align == TabAlign::Decimal); + let mut on = stops.get(sel).map(|s| s.align_on.to_string()).unwrap_or_else(|| ".".into()); + if ui.add_enabled(decimal, egui::TextEdit::singleline(&mut on).desired_width(30.0)).lost_focus() + && let (Some(s), Some(c)) = (stops.get_mut(sel), on.chars().next()) + && s.align_on != c + { + s.align_on = c; + apply(app, &stops); + } + ui.end_row(); + }); + ui.add_space(6.0); + // The ruler. + let w = ui.available_width().max(120.0); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click_and_drag()); + let p = ui.painter_at(rect.expand(2.0)); + p.rect_filled(rect, 2.0, t.input); + p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + let scale = (w as f64 - 8.0) / span; + let to_x = |pt: f64| rect.left() + 4.0 + (pt * scale) as f32; + let to_pt = |x: f32| (((x - rect.left() - 4.0) as f64) / scale).max(0.0); + let mut tick = 0.0; + while tick <= span + 1e-6 { + let x = to_x(tick); + let major = (tick / 36.0).round() * 36.0 == tick && ((tick / 36.0) as i64) % 2 == 0; + p.line_segment([egui::pos2(x, rect.bottom() - if major { 10.0 } else { 5.0 }), egui::pos2(x, rect.bottom())], Stroke::new(1.0, t.text_dim)); + if major { + p.text( + egui::pos2(x + 2.0, rect.top() + 1.0), + egui::Align2::LEFT_TOP, + format!("{}", (tick / 72.0 * 100.0).round() / 100.0), + egui::FontId::proportional(9.0), + t.text_dim, + ); + } + tick += 18.0; + } + for (i, s) in stops.iter().enumerate() { + let x = to_x(s.position); + let c = if i == sel { t.accent } else { t.text }; + let y = rect.bottom() - 2.0; + // An original marker: a stem with a foot showing which way the text runs. + p.line_segment([egui::pos2(x, y - 12.0), egui::pos2(x, y)], Stroke::new(1.5, c)); + let foot = match s.align { + TabAlign::Left => [egui::pos2(x, y), egui::pos2(x + 6.0, y)], + TabAlign::Right => [egui::pos2(x - 6.0, y), egui::pos2(x, y)], + TabAlign::Center | TabAlign::Decimal => [egui::pos2(x - 4.0, y), egui::pos2(x + 4.0, y)], + }; + p.line_segment(foot, Stroke::new(1.5, c)); + if s.align == TabAlign::Decimal { + p.circle_filled(egui::pos2(x + 3.0, y - 6.0), 1.5, c); + } + } + let drag: Option = pstate(ui.ctx(), "tabs-drag"); + if resp.drag_started() + && let Some(pos) = resp.interact_pointer_pos() + { + let hit = stops.iter().position(|s| (to_x(s.position) - pos.x).abs() <= 5.0); + set_pstate(ui.ctx(), "tabs-drag", hit); + if let Some(i) = hit { + set_pstate(ui.ctx(), "tabs-sel", i); + } + } + if resp.drag_stopped() + && let (Some(i), Some(pos)) = (drag, resp.interact_pointer_pos()) + { + set_pstate::>(ui.ctx(), "tabs-drag", None); + if i < stops.len() { + if pos.y > rect.bottom() + 20.0 { + // Dragged off the ruler: delete. + stops.remove(i); + } else { + stops[i].position = to_pt(pos.x); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + apply(app, &stops); + } + } else if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + { + match stops.iter().position(|s| (to_x(s.position) - pos.x).abs() <= 5.0) { + Some(i) => set_pstate(ui.ctx(), "tabs-sel", i), + None => { + let position = to_pt(pos.x); + stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' }); + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + let i = stops.iter().position(|s| s.position == position).unwrap_or(0); + set_pstate(ui.ctx(), "tabs-sel", i); + apply(app, &stops); + } + } + } + resp.on_hover_text("Click to add a tab stop, drag to move it, drag it off the ruler to delete it"); +} + +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some((mut stops, _)) = current(app) else { + ui.add_enabled(false, egui::Button::new("Select text").frame(false)); + return; + }; + let sel: usize = pstate(ui.ctx(), "tabs-sel"); + if menu_item(ui, "Clear All Tabs", false, !stops.is_empty()) { + app.run("text.tabs.clear", json!({})).ok(); + } + if menu_item(ui, "Delete Tab", false, sel < stops.len()) { + stops.remove(sel); + apply(app, &stops); + } + // Repeat Tab: copies of the selected stop at its distance from the previous one, across the ruler. + if menu_item(ui, "Repeat Tab", false, sel < stops.len()) { + let prev = if sel == 0 { 0.0 } else { stops[sel - 1].position }; + let step = (stops[sel].position - prev).max(1.0); + let base = stops[sel].clone(); + stops.truncate(sel + 1); + let mut pos = base.position + step; + while pos <= base.position + step * 20.0 && pos < 2000.0 { + stops.push(TabStop { position: pos, ..base.clone() }); + pos += step; + } + apply(app, &stops); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(app, ui); + menu(app, ui); + }); + out.textures_delta.clear(); + } + + #[test] + fn panel_draws_empty_with_text_and_with_stops() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({})).unwrap(); + frame(&mut app); + app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a\tb", "area": {"width": 300, "height": 100}})).unwrap(); + frame(&mut app); + app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 120, "align": "decimal"}]})).unwrap(); + frame(&mut app); + let (stops, span) = current(&app).unwrap(); + assert_eq!(stops.len(), 2); + assert_eq!(span, 300.0); + // The stops survive a JSON round trip through the panel's encoding. + let back: Vec = stops_json(&stops).as_array().unwrap().clone(); + assert_eq!(back[1]["align"], json!("decimal")); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6557ee67..72c26630 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -79,6 +79,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("charStyles", "Character Styles", "type"), ("openType", "OpenType", "dc-touch-type"), ("paraStyles", "Paragraph Styles", "pilcrow"), + ("tabs", "Tabs", "align-horizontal-justify-start"), ("history", "History", "history"), ("actions", "Actions", "dc-actions"), ("info", "Info", "info"),