M7.6: tab stops and the Tabs panel — left/center/right/decimal stops (align-on character), default stops every ½ inch, text.tabs.set/get/clear, ruler (click adds, drag moves, drag off deletes), Repeat Tab / Delete Tab / Clear All Tabs; control characters no longer draw .notdef boxes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 10:13:24 -04:00
parent 437320509d
commit 7b37dd1da5
11 changed files with 472 additions and 7 deletions

View File

@ -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 `<pattern>`/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,

View File

@ -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;

View File

@ -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<TabStop>,
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style_name: Option<String>,

View File

@ -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());

View File

@ -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<CommandSpec> {
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<Vec<TabStop>> {
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<Value> {
let stops = parse_stops(p)?;
apply(s, p, stops, "Tab Stops")
}
fn apply(s: &mut Session, p: &Value, stops: Vec<TabStop>, label: &str) -> Result<Value> {
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<Value> {
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!([]));
}
}

View File

@ -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::<f64>(),
};
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<usize>], 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(&para.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 {

View File

@ -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<f64> = 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);
}

View File

@ -1210,7 +1210,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
panel("OpenType", "openType"),
panel("Paragraph", "paragraph"),
panel("Paragraph Styles", "paraStyles"),
todo("Tabs"),
panel("Tabs", "tabs"),
],
),
todo("Variables"),

View File

@ -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));
}

View File

@ -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<TabStop>, 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<usize> = 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::<Option<usize>>(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<serde_json::Value> = stops_json(&stops).as_array().unwrap().clone();
assert_eq!(back[1]["align"], json!("decimal"));
}
}

View File

@ -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"),