diff --git a/crates/text/src/justify.rs b/crates/text/src/justify.rs index 33fbe91b3..ef7f13be8 100644 --- a/crates/text/src/justify.rs +++ b/crates/text/src/justify.rs @@ -55,7 +55,7 @@ pub(crate) fn is_word_space(g: &SGlyph) -> bool { /// A glyph letter spacing follows: a visible glyph followed by another one in the same word. fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool { - !g.is_space() && g.inline.is_none() && next.is_some_and(|n| !n.is_space()) + !g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && next.is_some_and(|n| !n.is_space() && n.ch != '\u{2028}') } /// The width of a word space in `g`'s font and size: what letter spacing is a share of. @@ -83,7 +83,7 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g f.word = (base * (w_max - w_des).max(0.0), base * (w_des - w_min).max(0.0)); continue; } - if g.is_space() || g.inline.is_some() { + if g.is_space() || g.inline.is_some() || g.ch == '\u{2028}' { continue; } let base = g.adv; diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index 4ee97c933..b83640d02 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -169,7 +169,7 @@ impl Pen<'_> { pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) { use vectorcraft_doc::TabAlign; let rel = x - origin; - let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect(); + let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').collect(); let width: f64 = seg.iter().map(|g| g.adv).sum(); let target = |pos: f64, align: TabAlign, on: char| match align { TabAlign::Left => pos, @@ -300,9 +300,9 @@ pub(super) fn break_line( cluster_end += 1; } // No Break: the marked characters stay on one line, including across a space inside the run. - if gl.no_break { + if gl.no_break && gl.ch != '\u{2028}' { while let Some(n) = g.get(cluster_end) { - if !n.no_break { + if !n.no_break || n.ch == '\u{2028}' { break; } let b = n.byte; diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 623564346..324352060 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -390,6 +390,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut let mut hyphen_run = 0; let mut line_in_paragraph = 0; let mut index = 0; + let mut emitted_empty_continuation = false; loop { let mut placement = PlacementContext { para: paragraph.para, @@ -415,7 +416,13 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut line_in_paragraph += 1; index = geometry.position.end; if index >= n { - break; + if emitted_empty_continuation { + break; + } + emitted_empty_continuation = paragraph.sg.last().is_some_and(|g| g.ch == '\u{2028}'); + if !emitted_empty_continuation { + break; + } } } pen.pending += paragraph.para.space_after; @@ -794,7 +801,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData } else { position.m.first_baseline(pen.fb, pen.fb_min) }, - start: if index < sg.len() { sg[index].byte } else { paragraph.pr.start }, + start: if index < sg.len() { sg[index].byte } else { paragraph.pr.end }, end: if position.end >= sg.len() { paragraph.pr.end } else { sg[position.end].byte }, glyph_start, glyph_end: cx.out.glyphs.len(), @@ -802,8 +809,13 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData region: if paragraph.regions.is_some() { pen.ri } else { 0 }, }); pen.settled(position.baseline, position.m); - // Further spans of this line band share the settled baseline. - for queued in &mut pen.queued { - queued.0 = position.baseline; + let forced_break = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); + if forced_break { + pen.queued.clear(); + } else { + // Further spans of this line band share the settled baseline. + for queued in &mut pen.queued { + queued.0 = position.baseline; + } } } diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 59caaa476..2c79099b4 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -134,7 +134,10 @@ impl SGlyph { } /// A line may break after this glyph. pub fn break_after(&self) -> bool { - self.is_space() || self.word_end || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN) || is_cjk(self.ch) + self.is_space() + || self.word_end + || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN | '\u{2028}') + || is_cjk(self.ch) } /// A soft (discretionary) hyphen: invisible unless a line breaks after it. pub fn is_soft_hyphen(&self) -> bool { diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 9d25f38b3..345340b52 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1377,3 +1377,20 @@ fn soft_return_in_list_does_not_repeat_marker() { assert!(l0_glyphs.iter().any(|g| g.len == 0), "line 0 has bullet marker"); assert!(!l1_glyphs.iter().any(|g| g.len == 0), "continuation line does not have bullet marker"); } + +#[test] +fn trailing_soft_return_creates_empty_continuation_line() { + let t = TextObject::point(Point::ZERO, "Line 1\u{2028}", style(12.0)); + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2, "trailing soft return produces a second empty line"); + assert_eq!(lay.line_of(t.plain_text().len()), 1, "caret at end sits on the new line"); +} + +#[test] +fn soft_return_breaks_line_even_inside_no_break() { + let mut st = style(12.0); + st.no_break = true; + let t = area("First\u{2028}Second", st, Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left); + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2, "soft return honors line break despite no_break"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0af119f94..9616f7903 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -210,7 +210,18 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } // A key the tool claims (Esc with a loaded place cursor) is only the tool's. let claimed = app.session.tool_claims_key(tk, view); - let mods = ctx.input(|i| crate::canvas::mods(i.modifiers, false)); + let mods = ctx.input(|i| { + let m = i + .events + .iter() + .rev() + .find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k => Some(*modifiers), + _ => None, + }) + .unwrap_or(i.modifiers); + crate::canvas::mods(m, false) + }); let r = app.session.tool_key(tk, mods, view); // Enter also takes what the tool asks of the UI: Rotate, Scale, Reflect and Shear open // their dialog with it.