From ad6e77f6ef857832a65df69a22093c7423c42ca6 Mon Sep 17 00:00:00 2001 From: lloyd <34131158+LloydNicholson@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:13:40 +0200 Subject: [PATCH] Issue #48: rotated text with Wrap Text draws upright (grid and print) Narrow columns with tall, upright headers (mark sheets) set both a text rotation and Wrap Text. The wrap path drew the text flat and returned before the rotation was applied, so headers came out as 3-4 character fragments. - Rotated text no longer takes the flat wrap path. Upright (+/-90 deg) text wraps to the row height, its lines stacking across the column; other angles keep one line, as before. - Downward text was anchored at the bottom-left and ran out of the cell (over the row headers); it now starts at the top, one block-height in. - Upright text no longer overflows into empty neighbours: its width is its line height, and the overflow repainted their gridlines. - Print/PDF gets the same three fixes. Tests: ui-egui interaction test (shapes drawn at +/-90 deg, inside a one-column clip) and a print test (every text block on the page rotated, a few long lines each). Each failed before the fix. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/print.rs | 49 +++++++++++++++++++++++++---- crates/ui-egui/src/grid.rs | 26 ++++++++++++--- crates/ui-egui/tests/interaction.rs | 39 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a51a2f1..09ea606 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -923,7 +923,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 HAlign::Justify | HAlign::Fill => HAlign::Left, other => other, }; - let lines: Vec = if wrap { wrap_lines(&text, font, size, avail.max(size)) } else { vec![text] }; + let rot = st.align.rotation; + let rotated = rot != 0 && rot != 255; + // Rotated text wraps along its own direction: upright (±90°) text to the row height. + let lines: Vec = match (wrap, rotated) { + (true, false) => wrap_lines(&text, font, size, avail.max(size)), + (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), + (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], + (false, _) => vec![text], + }; let line_h = size * 1.2; let n = lines.len().max(1) as f32; let first_baseline = match st.align.v { @@ -933,7 +941,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 }; // Overflow into empty neighbours (single-line text, not merged). let (mut clip_l, mut clip_r) = (x, x + w); - if !wrap && !merged && !is_num && tw + 2.0 * pad + indent > w { + if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w { let need = tw + 2.0 * pad + indent - w; let grow = |dir: i64, mut need: f32| -> f32 { let mut ext = 0.0; @@ -966,13 +974,20 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let clip_r = clip_r.min(limits.1); pg.save(); pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); - let rot = st.align.rotation; - if rot != 0 && rot != 255 && !wrap { + if rotated { // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. + // Wrapped lines stack rightward for upward text and leftward for downward text, so + // downward text starts its first line far enough in for the last to end at the left pad. let deg = (rot as f32).clamp(-90.0, 90.0); let angle = -deg.to_radians(); - let (ox, oy) = if deg > 0.0 { (x + pad + size * 0.8 * deg.to_radians().sin(), y + h - pad) } else { (x + pad, y + pad + size * 0.8) }; - if let Some(t) = lines.first() { + let last = lines.len().saturating_sub(1) as f32; + for (i, t) in lines.iter().enumerate() { + let i = i as f32; + let (ox, oy) = if deg > 0.0 { + (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) + } else { + (x + pad + (last - i) * line_h, y + pad + size * 0.8) + }; pg.text_rotated(ox, oy, size, font, color, t, angle); } pg.restore(); @@ -1762,4 +1777,26 @@ mod tests { let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); assert!(w.len() > 2); } + + #[test] + fn rotated_wrapped_text_prints_rotated() { + // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. + let mut s = s(); + let header = "Term 1 : Drama : Continuous Assessment"; + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + let pdf = String::from_utf8_lossy(&pdf); + // The page holds only these two cells, so every text block on it is a piece of a header. + let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect(); + let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count(); + let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count(); + // Every piece of both headers is rotated, each wrapped along the row height into a few long lines. + assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); + assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); + } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..7362626 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -577,7 +577,9 @@ fn paint_quadrant( }; let indent = st.align.indent as f32 * 9.0 * z; let pad = 2.5 * z; - if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap { + let rotated = st.align.rotation != 0 && st.align.rotation != 255; + // Rotated text wraps along its own direction (below), not across the column width. + if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); job.halign = match halign { @@ -622,9 +624,9 @@ fn paint_quadrant( galley = p.layout_no_wrap(s, font.clone(), color); w = galley.size().x; } - // Overflow into empty neighbours for text. + // Overflow into empty neighbours for text (not upright text, whose width is its line height). let mut clip = *rect; - if !numeric && !matches!(v, Value::Number(_)) && w > rect.width() - 2.0 * pad && in_merge(c).is_none() { + if !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad && in_merge(c).is_none() { let mut right = rect.right(); let mut left = rect.left(); if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { @@ -667,9 +669,23 @@ fn paint_quadrant( VAlign::Bottom => rect.bottom() - gh - 0.5 * z, }; let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); - if st.align.rotation != 0 && st.align.rotation != 255 { + if rotated { let angle = -(st.align.rotation as f32).to_radians(); - let mut shape = egui::epaint::TextShape::new(pos2(rect.left() + pad, rect.bottom() - pad), galley.clone(), color); + // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. + let galley = if st.align.wrap && st.align.rotation.abs() == 90 { + let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); + p.layout_job(job) + } else { + galley.clone() + }; + // Upward text grows up and right from the bottom-left corner; downward text grows down + // and left from its anchor, so that anchor sits one block-height in from the top-left. + let pos = if angle > 0.0 { + pos2(rect.left() + pad + galley.size().y * angle.sin(), rect.top() + pad) + } else { + pos2(rect.left() + pad, rect.bottom() - pad) + }; + let mut shape = egui::epaint::TextShape::new(pos, galley, color); shape.angle = angle; cp.add(shape); } else { diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb4..15d0fff 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -146,3 +146,42 @@ fn column_autocomplete_completes_on_enter() { key(&mut h, Key::Enter, Modifiers::NONE); assert_eq!(value(&h, "A5"), Value::from("Ea")); } + +fn text_shapes(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(egui::epaint::TextShape, egui::Rect)>) { + match shape { + egui::Shape::Text(t) => out.push((t.clone(), clip)), + egui::Shape::Vec(v) => v.iter().for_each(|s| text_shapes(s, clip, out)), + _ => {} + } +} + +#[test] +fn rotated_wrapped_text_is_drawn_rotated_inside_its_cell() { + // Narrow columns with tall, upright headers (mark sheets): rotation and Wrap Text together. + let header = "Term 1 : Drama : Continuous Assessment"; + let mut s = blank(); + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + s.execute("selection.set", json!({"cell": "C3"})).unwrap(); + let h = harness(s); + let mut found = Vec::new(); + for c in &h.output().shapes { + text_shapes(&c.shape, c.clip_rect, &mut found); + } + let rotated: Vec<_> = found.iter().filter(|(t, _)| t.galley.job.text == header).collect(); + assert_eq!(rotated.len(), 2, "both headers drawn as one text block each"); + for (t, clip) in rotated { + assert!((t.angle.abs() - std::f32::consts::FRAC_PI_2).abs() < 1e-3, "drawn at ±90°, got {}", t.angle); + // Wrapped along the row height into a couple of long lines, not across the narrow column. + assert!(t.galley.rows.len() <= 3, "{} lines", t.galley.rows.len()); + let bb = t.visual_bounding_rect(); + assert!(bb.height() > bb.width(), "upright, not flat: {bb:?}"); + assert!(clip.expand(1.0).contains_rect(bb), "inside its cell: {bb:?} not in {clip:?}"); + // Upright text never spills into the neighbouring columns: the clip is the cell itself. + assert!(clip.width() < 70.0, "clip spans one 6-character column, got {clip:?}"); + } +}