From 87f74cc8d286e565ae3804fac7eae894e84811ed Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Sun, 11 Oct 2026 00:38:53 +0200 Subject: [PATCH] Type: tabs advance to Photoshop's default tab stops (#1881) (#2733) #1942 stopped tabs from shaping as .notdef by laying them out as a space, so a tab was only one space wide. Layout now widens each tab to the next default stop (every 36 pt, from the anchor of point text or the left edge of the box). Paragraph text breaks lines with the real tab widths: it starts from a safe upper bound (space + one interval) and re-breaks until the widths agree, so expanded tabs never overflow the box and lines don't wrap early. The text keeps its \t, so PSD round trips are unchanged. Tab in the Type tool now types a tab. Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/text/src/layout.rs | 128 +++++++++++++++++++++++--- crates/text/src/tests.rs | 88 ++++++++++++++++++ crates/ui-egui/src/type_tool.rs | 2 + crates/ui-egui/src/type_tool_tests.rs | 18 ++++ 4 files changed, 225 insertions(+), 11 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 76994673..23d1af5f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -31,6 +31,10 @@ use crate::fonts::FontDb; /// readable hierarchy for every font instead of silently rendering lowercase text unchanged. const SYNTHETIC_SMALL_CAPS_SCALE: f32 = 0.7; +/// Photoshop's default tab stops: every half inch, measured from the anchor of point text or the +/// left edge of a paragraph box. PSD type carries no custom tab stops, so these are the only ones. +pub const DEFAULT_TAB_STOP_PT: f32 = 36.0; + /// The rendering path for a `SmallCaps` character style. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SmallCapsMode { @@ -400,6 +404,10 @@ struct KernSlot { size: f32, rtl: bool, blank: bool, + /// Advance along the line (px, horizontal scale included). + advance: f32, + /// A tab, shaped as a space; it advances to the next tab stop. + tab: bool, /// Vertical type, upright glyph (its outline doesn't run along the column). upright: bool, } @@ -488,6 +496,9 @@ impl Layouter { // They have the same UTF-8 length as their uppercase form, so the layer's byte-based // run and caret offsets remain unchanged. let mut synthetic_small_caps: Vec<(Range, f32)> = Vec::new(); + // Offsets in `ptext` of the spaces standing in for tabs. + let mut tabs: Vec = Vec::new(); + let tab_px = DEFAULT_TAB_STOP_PT * k; for (i, ch) in content.char_indices() { // A forced line break ends the line but not the paragraph. The line breaker knows // it as a newline, which has the same length, so text offsets don't move. @@ -497,8 +508,10 @@ impl Layouter { } // Imported PSD text can contain literal tab controls. Font shaping may // render those as .notdef boxes; a space preserves the one-byte source - // and style/caret offsets while supplying a real whitespace advance. + // and style/caret offsets while supplying a real whitespace advance. The space + // is widened to its tab stop after line breaking (`tab_px` below). if ch == '\t' { + tabs.push(ptext.len()); ptext.push(' '); continue; } @@ -523,8 +536,9 @@ impl Layouter { // fetches it (`served`); it joins the fallback stack once it arrives. crate::served::request_for_text(&ptext); let fallback: Vec = fonts.fallback_stack().map(str::to_string).collect(); - let mut layout: Layout = { - let mut b = self.lcx.ranged_builder(&mut fonts.fcx, &ptext, 1.0, false); + // Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px). + let build = |lcx: &mut LayoutContext, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout { + let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false); // Paragraph-start style as the default (covers the direction mark and empty // paragraphs), then every run piece intersecting this paragraph. let si0 = style_at(prange.start); @@ -571,20 +585,48 @@ impl Layouter { } } } - for (range, size) in synthetic_small_caps { - b.push(StyleProperty::FontSize(size), range); + for (range, size) in &synthetic_small_caps { + b.push(StyleProperty::FontSize(*size), range.clone()); + } + for (&at, &spacing) in tabs.iter().zip(tab_spacing) { + b.push(StyleProperty::LetterSpacing(spacing), at..at + 1); } b.build(&ptext) }; let indent_start = ps.start_indent_pt * k; let indent_end = ps.end_indent_pt * k; - if ps.first_line_indent_pt != 0.0 { - layout.set_text_indent(ps.first_line_indent_pt * k, IndentOptions::default()); - } + let first_indent = ps.first_line_indent_pt * k; // Box extent along the lines: width, or height for vertical type (columns). let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) }; let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None }; - layout.break_all_lines(avail); + let break_lines = |layout: &mut Layout| { + if first_indent != 0.0 { + layout.set_text_indent(first_indent, IndentOptions::default()); + } + layout.break_all_lines(avail); + }; + // Paragraph text: a tab is never wider than a space plus one tab interval, so breaking + // lines at that width keeps the expanded tabs inside the box, but can wrap early. + // Break again with the tab widths that layout gives until they agree; if they don't + // settle, keep the safe layout. + let wide = if is_box { tab_px } else { 0.0 }; + let mut layout = build(&mut self.lcx, &mut fonts.fcx, &vec![wide; tabs.len()]); + break_lines(&mut layout); + if is_box && !tabs.is_empty() { + let first = tab_widths(&layout, &tabs, &out.styles, indent_start, first_indent, tab_px); + let mut want: Vec = first.iter().map(|t| t.width).collect(); + for _ in 0..4 { + let spacing: Vec = first.iter().zip(&want).map(|(t, w)| w / t.scale - (t.advance - tab_px)).collect(); + let mut next = build(&mut self.lcx, &mut fonts.fcx, &spacing); + break_lines(&mut next); + let got: Vec = tab_widths(&next, &tabs, &out.styles, indent_start, first_indent, tab_px).iter().map(|t| t.width).collect(); + if got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 0.01) { + layout = next; + break; + } + want = got; + } + } let alignment = if is_box { match ps.align { TextAlign::Left => Alignment::Left, @@ -669,6 +711,7 @@ impl Layouter { continue; } line_runs.push(run.index()); + let hs = out.styles.get(gr.style().brush.0 as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); for c in run.visual_clusters() { let mut gl = c.glyphs(); let first = gl.next().map(|g| g.id); @@ -684,6 +727,8 @@ impl Layouter { rtl: c.is_rtl(), blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= prefix.len(), upright: vertical && c.first_style().brush.1 != VClass::Rotate as u8, + advance: c.advance() * hs, + tab: tabs.binary_search(&c.text_range().start).is_ok(), }); } } @@ -722,7 +767,22 @@ impl Layouter { } kern_px[j] = units / 1000.0 * a.size; } - let line_kern: f32 = kern_px.iter().sum(); + // Tabs: the "kerning" after a tab takes the pen to the next stop, measured from + // the anchor or the box edge as if the line were left-aligned (alignment then + // moves the whole line). + let mut pos = indent_start + if li == 0 { first_indent } else { 0.0 }; + let mut trailing_tabs = 0.0f32; + for (j, s) in slots.iter().enumerate() { + if s.tab { + kern_px[j] = next_tab_stop(pos, tab_px) - pos - s.advance; + // Trailing whitespace is outside the line's extent (`adv`). + if slots[j + 1..].iter().all(|n| n.blank) { + trailing_tabs += kern_px[j]; + } + } + pos += s.advance + kern_px.get(j).copied().unwrap_or(0.0); + } + let line_kern: f32 = kern_px.iter().sum::() - trailing_tabs; // Per run on the line: (run index, glyph → slot, glyphs emitted, first slot whose // kerning isn't applied yet). let mut cursors: Vec<(usize, Vec, usize, usize)> = Vec::new(); @@ -926,7 +986,7 @@ impl Layouter { range: map(lr.start)..map(lr.end).min(content_end), baseline, x0, - x1: x0 + adv + extra, + x1: x0 + adv + extra - trailing_tabs, ascent, descent, paragraph: pi, @@ -938,6 +998,52 @@ impl Layouter { } } +/// The first tab stop after `pos` (px from the anchor or box edge); stops are `interval` apart. +fn next_tab_stop(pos: f32, interval: f32) -> f32 { + if interval > 0.0 && pos.is_finite() { ((pos + 1e-3) / interval).floor() * interval + interval } else { pos } +} + +/// A tab as line breaking saw it: its shaped advance (before horizontal scale), the horizontal +/// scale, and the width that takes it to its tab stop on its line. +struct TabWidth { + advance: f32, + scale: f32, + width: f32, +} + +/// The width of each tab (offsets `tabs` in the paragraph text) on the broken lines of `layout`, +/// before kerning and alignment. +fn tab_widths(layout: &Layout, tabs: &[usize], styles: &[CharStyle], indent: f32, first_indent: f32, interval: f32) -> Vec { + let mut out: Vec = tabs.iter().map(|_| TabWidth { advance: interval, scale: 1.0, width: interval }).collect(); + for (li, line) in layout.lines().enumerate() { + let mut pos = indent + if li == 0 { first_indent } else { 0.0 }; + let mut seen: Vec = Vec::new(); + for item in line.items() { + let PositionedLayoutItem::GlyphRun(gr) = item else { + continue; + }; + let run = gr.run(); + if seen.contains(&run.index()) { + continue; + } + seen.push(run.index()); + let scale = styles.get(gr.style().brush.0 as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); + for c in run.visual_clusters() { + let advance = c.advance(); + match tabs.binary_search(&c.text_range().start).ok().and_then(|i| out.get_mut(i)) { + Some(t) => { + let stop = next_tab_stop(pos, interval); + *t = TabWidth { advance, scale, width: stop - pos }; + pos = stop; + } + None => pos += advance * scale, + } + } + } + } + out +} + /// Byte ranges of paragraphs (each including its `\r`, `\n` or `\r\n` terminator). Text ending /// with a break yields a final empty paragraph, like Photoshop's trailing empty line. pub fn split_paragraphs(text: &str) -> Vec> { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 30fbe0e7..3f656c21 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -60,6 +60,94 @@ fn literal_psd_tabs_shape_as_whitespace_without_shifting_text_offsets() { assert!(l.caret(2).0 > l.caret(1).0, "caret moves across the tab"); } +/// Pen position of the cluster starting at byte `at`. +fn cluster_x(l: &crate::TextLayout, at: usize) -> f32 { + l.clusters.iter().find(|c| c.range.start == at).map(|c| c.x).unwrap() +} + +/// #1881: a tab advances to Photoshop's next default tab stop (every 36 pt, from the anchor or +/// the box's left edge), not one space width. +#[test] +fn tabs_advance_to_the_next_default_tab_stop() { + let mut e = TextEngine::new(); + let near = |a: f32, b: f32| (a - b).abs() < 0.05; + let l = e.layout(&point("a\tb", 20.0), 72.0); + assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef"); + assert!(near(cluster_x(&l, 2), 36.0), "{}", cluster_x(&l, 2)); + assert!(near(l.caret(2).0, 36.0), "the caret after the tab sits on the stop"); + // Stops are in points: twice the pixels at 144 dpi. + let l = e.layout(&point("a\tb", 20.0), 144.0); + assert!(near(cluster_x(&l, 2), 72.0), "{}", cluster_x(&l, 2)); + // Each tab moves to the following stop; text past a stop skips to the next one. + let l = e.layout(&point("a\t\tb", 20.0), 72.0); + assert!(near(cluster_x(&l, 3), 72.0), "{}", cluster_x(&l, 3)); + let l = e.layout(&point("MMM\tb", 20.0), 72.0); + assert!(cluster_x(&l, 3) > 36.0); + assert!(near(cluster_x(&l, 4), 72.0), "{}", cluster_x(&l, 4)); + // A tab at the start of a line goes to the first stop; the line extent includes it. + let l = e.layout(&point("\tb", 20.0), 72.0); + assert!(near(cluster_x(&l, 1), 36.0), "{}", cluster_x(&l, 1)); + assert!(l.lines[0].x1 > 36.0); + // Paragraph text measures from the box's left edge. + let mut t = point("a\tb", 20.0); + t.shape = TextShape::Box { x: 10.0, y: 0.0, width: 300.0, height: 100.0 }; + let l = e.layout(&t, 72.0); + assert!(near(cluster_x(&l, 2), 46.0), "{}", cluster_x(&l, 2)); +} + +/// The tab stays a tab in the document: PSD type round-trips it unchanged. +#[test] +fn psd_round_trips_tabs() { + for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }] { + let t = TextLayer { shape, ..point("Name\tValue\n\tIndented", 20.0) }; + let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap(); + assert_eq!(back.text, t.text, "{shape:?}"); + } +} + +/// Expanded tabs never push paragraph text past the box's right edge. +#[test] +fn tabs_wrap_inside_the_box() { + let mut e = TextEngine::new(); + let mut t = point("a\tb\tc\td\te\tf\tg", 20.0); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 100.0, height: 1000.0 }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() > 1, "{}", l.lines.len()); + // Lines break at the real tab widths: "c" (stop 72 px) still fits, "d" (stop 108 px) wraps. + let line_of = |at: usize| l.clusters.iter().find(|c| c.range.start == at).map(|c| c.line).unwrap(); + assert_eq!((line_of(4), line_of(6)), (0, 1)); + assert!((cluster_x(&l, 6) - 0.0).abs() < 0.05, "a wrapped line starts at the box edge"); + for c in &l.clusters { + let ch = &t.text[c.range.clone()]; + if ch != "\t" { + assert!(c.x + c.advance <= 100.0 + 1e-3, "{ch:?} at {} overflows", c.x); + } + } +} + +/// Right-aligned and centred point text keep their alignment around the expanded tab. +#[test] +fn tabs_keep_point_text_alignment() { + let mut e = TextEngine::new(); + let r = e.layout(&with_para(point("a\tb", 20.0), ParagraphStyle { align: TextAlign::Right, ..Default::default() }), 72.0); + assert!(r.lines[0].x1.abs() < 0.05, "{:?}", r.lines[0]); + let b = r.clusters.iter().find(|c| c.range.start == 2).unwrap(); + assert!((b.x + b.advance).abs() < 0.05, "b ends at the anchor: {b:?}"); + assert!(r.lines[0].x0 < -36.0); + // Paragraph text: the line still ends at the box's right edge; a trailing tab is + // whitespace outside the line's extent. + for text in ["a\tb", "a\tb\t"] { + let t = with_para( + TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 100.0 }, ..point(text, 20.0) }, + ParagraphStyle { align: TextAlign::Right, ..Default::default() }, + ); + let l = e.layout(&t, 72.0); + let b = l.clusters.iter().find(|c| c.range.start == 2).unwrap(); + assert!((b.x + b.advance - 200.0).abs() < 0.5, "{text:?}: {b:?}"); + assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{text:?}: {:?}", l.lines[0]); + } +} + #[test] fn metrics_are_stable_and_scale_with_dpi() { let mut e = TextEngine::new(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 24a3bb85..3088344a 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -595,6 +595,8 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } Key::Enter if m.command => commit(app), Key::Enter => insert(app, "\n"), + // Photoshop types a tab character; layout advances it to the next tab stop. + Key::Tab if !m.command && !m.alt && !m.shift => insert(app, "\t"), Key::Escape if crate::type_transform::active(app) => crate::type_transform::cancel_drag(app), // Preferences ▸ Type: with "Use Escape to Commit" off, Escape cancels the // session (undoes it, removing a just-created layer) like the Cancel button. diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 9c52f819..650ee4b1 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -486,6 +486,24 @@ fn command_t_while_typing_toggles_the_character_panel() { assert_ne!(crate::view_cmds::checked(h.state(), "window.panel.character"), before, "the Character panel toggled"); } +/// #1881: Tab while typing inserts a tab character (laid out to the next tab stop), as in +/// Photoshop, instead of moving keyboard focus or hiding the panels. +#[test] +fn tab_while_typing_inserts_a_tab() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "HOHO", "size": 60, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = harness(1.0, app); + let p = glyph(&mut h, id, 1, 0.2); + click(&mut h, p); + assert_eq!(selection(&h), (1, 1)); + let panels = h.state().ui.panels.dock; + key(&mut h, egui::Key::Tab); + assert_eq!(text(h.state(), id).text, "H\tOHO"); + assert_eq!(selection(&h), (2, 2)); + assert!(h.state().ui.text_edit.is_some(), "still editing"); + assert_eq!(h.state().ui.panels.dock, panels, "the panels stay as they were"); +} + #[test] fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() { let mut app = new_app();