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