Merge pull request #4584 from Taha-Mahmoodi/fix/2754-soft-return

Fix #2754: Shift+Enter inserts a soft return (forced line break)
This commit is contained in:
BFlat
2026-10-29 20:59:48 +07:00
committed by GitHub
14 changed files with 186 additions and 32 deletions
+24
View File
@@ -238,3 +238,27 @@ fn ranged_set_format_sets_direction_mojikumi_and_leading_model_per_paragraph() {
s.execute("text.setFormat", &json!({"ids": [id], "direction": "auto"})).unwrap();
assert!(text(&s, id).paragraph_styles().iter().all(|p| p.direction.is_none()));
}
#[test]
fn shift_return_in_the_type_tool_inserts_soft_return() {
let mut s = session();
let v = ViewInfo::default();
let id = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "First Second", "size": 20})).unwrap()["id"].as_u64().unwrap();
s.select_tool("type", v).unwrap();
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
let l0 = &lay.lines[0];
let (x, y) = (100.0 + (l0.x0 + l0.x1) / 2.0, 100.0 + l0.baseline - 5.0);
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
s.tool_key(ToolKey::Home, Mods::default(), v).unwrap();
for _ in 0..6 {
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
}
let shift = Mods { shift: true, ..Default::default() };
s.tool_key(ToolKey::Enter, shift, v).unwrap();
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
assert_eq!(text(&s, id).plain_text(), "First \u{2028}Second");
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
assert_eq!(lay.lines.len(), 2, "broken into two lines in one paragraph");
}
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -182,7 +182,7 @@ impl Ctx<'_> {
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let blank = g.is_soft_hyphen() || g.ch.is_control();
let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}';
// A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or
// COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps.
// Art in the text's colours takes the run's fill (black when it isn't a colour) and
+9 -3
View File
@@ -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,
@@ -282,6 +282,9 @@ pub(super) fn break_line(
kinsoku: Kinsoku,
) -> (usize, bool) {
if !width.is_finite() {
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
return (i + pos + 1, false);
}
return (g.len(), false);
}
let mut x = 0.0;
@@ -297,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;
@@ -314,6 +317,9 @@ pub(super) fn break_line(
} else {
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
};
if gl.ch == '\u{2028}' {
return (cluster_end, false);
}
if j > first && !gl.is_space() && x + cluster_width > width + EPS {
// It ends the line with the spaces after it.
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
+37 -10
View File
@@ -36,7 +36,15 @@ fn compose_para(
// can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs).
// The tab after a list marker always starts the first line and has a fixed width.
let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0);
if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab {
let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}');
if composer != Composer::EveryLine
|| pen.regions.is_none()
|| sg.len() < 2
|| sg.len() > MAX_COMPOSE_GLYPHS
|| may_hang
|| has_tab
|| has_soft_break
{
return None;
}
let m = Metrics::of(&sg[0]);
@@ -382,7 +390,15 @@ 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 pm = if index >= n
&& let Some(last) = paragraph.sg.last()
{
Metrics::of(last)
} else {
paragraph.pm
};
let mut placement = PlacementContext {
para: paragraph.para,
pr: &paragraph.pr,
@@ -390,7 +406,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
continued: paragraph.continued,
rtl: paragraph.rtl,
mirror: paragraph.mirror,
pm: paragraph.pm,
pm,
composed: &paragraph.composed,
hyphen_run,
hyphen_limit: paragraph.hyphen_limit,
@@ -407,7 +423,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;
@@ -422,7 +444,7 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
if paragraph.regions.is_some() { (position.x0 + position.ind_l, position.x1 - position.ind_r) } else { (position.x0, position.x1) };
let width = ax1 - ax0;
let mut trimmed = position.end;
while trimmed > index && sg[trimmed - 1].is_space() {
while trimmed > index && (sg[trimmed - 1].is_space() || sg[trimmed - 1].ch == '\u{2028}') {
trimmed -= 1;
}
let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1]));
@@ -521,7 +543,7 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
// Justification adds nothing to the list marker's glyphs.
let mut held = held;
held.extend(index..paragraph.marker.min(body));
let last_of_para = position.end >= n;
let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}');
LineGeometry {
position,
index,
@@ -786,16 +808,21 @@ 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 },
end: if line.last_of_para { paragraph.pr.end } else { sg[position.end].byte },
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(),
avail: if paragraph.regions.is_some() { (ax0, ax1) } else { (start_x, x_end) },
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;
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ pub(super) fn whole_character_widths(glyphs: &mut [SGlyph]) {
/// before the automatic digit rule.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
pub(super) fn tate_chu_yoko(g: &mut [SGlyph], style: impl Fn(&SGlyph) -> (f64, bool)) {
let manual: Vec<bool> = g.iter().map(|gl| style(gl).1).collect();
let manual: Vec<bool> = g.iter().map(|gl| gl.ch != '\u{2028}' && style(gl).1).collect();
let digits: Vec<bool> = g.iter().map(|gl| gl.inline.is_none() && gl.ch.is_ascii_digit()).collect();
let mut i = 0;
while i < g.len() {
+6 -3
View File
@@ -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 {
@@ -524,7 +527,7 @@ fn drawn_chars(cluster: &str, digits: Digits) -> impl Iterator<Item = char> + '_
cluster
.chars()
.map(move |c| digits.map(c))
.filter(|&c| !(c.is_control() || matches!(c, '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')))
.filter(|&c| !(c.is_control() || matches!(c, '\u{200C}' | '\u{200D}' | '\u{2028}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')))
}
/// Does the face with charmap `map` have a glyph for `c` (any face does when it has no charmap)?
@@ -838,7 +841,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
let ch = first_char(cl);
let mut adv = xa as f64 * k * hs;
if ch == SOFT_HYPHEN {
if ch == SOFT_HYPHEN || ch == '\u{2028}' {
adv = 0.0;
} else if last_in_cluster {
let tracking = gst.tracking / 1000.0 * size;
+55
View File
@@ -1339,3 +1339,58 @@ fn thai_words_are_words_to_the_caret() {
assert_eq!(edit::word_at(s, s.len() - 1), end + 1..s.len());
assert_eq!(edit::next_word("hello world", 0), 5);
}
#[test]
fn soft_return_breaks_line_without_new_paragraph() {
let mut t = TextObject::point(Point::ZERO, "Line 1\u{2028}Line 2", style(12.0));
t.para.space_before = 20.0;
t.para.space_after = 20.0;
let lay = layout(db(), &t);
assert_eq!(lay.lines.len(), 2, "broken into two lines");
let l0 = &lay.lines[0];
let l1 = &lay.lines[1];
let line_gap = l1.baseline - l0.baseline;
assert!((line_gap - 12.0 * 1.2).abs() < 1.0, "normal line leading: {line_gap}");
assert_eq!(&t.plain_text()[l0.start..l0.end], "Line 1\u{2028}");
assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2");
}
#[test]
fn soft_return_in_justified_text_does_not_spread() {
let text = "Short\u{2028}A much longer second line of text inside the frame.";
let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft);
let lay = layout(db(), &t);
assert!(lay.lines.len() >= 2);
let l0 = &lay.lines[0];
assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0);
}
#[test]
fn soft_return_in_list_does_not_repeat_marker() {
use vectorcraft_doc::text::{Bullet, ParaList};
let mut t = area("First line\u{2028}Continuation line", style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left);
t.para.list = Some(ParaList::bulleted(Bullet::Dot));
let lay = layout(db(), &t);
assert_eq!(lay.lines.len(), 2);
let l0_glyphs = &lay.glyphs[lay.lines[0].glyph_start..lay.lines[0].glyph_end];
let l1_glyphs = &lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end];
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");
}
+11
View File
@@ -202,6 +202,17 @@ pub struct TypeTool {
}
impl TypeTool {
pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize) -> Option<Vec<Action>> {
if !mods.alt {
return None;
}
let id = self.editing?;
let (attribute, by) = plain::step_key(key, mods.shift, a == b)?;
let mut out = self.commit();
let by = if mods.cmd { by * 5.0 } else { by };
out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})));
Some(out)
}
pub fn new(id: &str) -> Self {
let mode = match id {
"areaType" | "verticalAreaType" => Mode::Area,
+19
View File
@@ -1469,3 +1469,22 @@ fn ime_surrounding_reports_the_document_when_it_changed_under_the_session() {
assert_eq!(stale, ImeSurrounding::Available { text: "XYZ".into(), cursor: 3, anchor: 3 });
assert!(!tool.ime_session_matches_document(&cx(&d, &sel, &p)), "the commit starts a fresh session");
}
#[test]
fn shift_enter_inserts_soft_return() {
let (mut d, id, mut tool) = editing("Hello World");
let (sel, p) = (Selection::default(), paint());
tool.caret = 5;
tool.anchor = 5;
let snap = d.clone();
let shift = Mods { shift: true, ..Default::default() };
let acts = tool.key(&cx(&d, &sel, &p), ToolKey::Enter, shift);
apply(&mut d, &snap, &acts);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "Hello\u{2028} World");
// Plain Enter inserts standard newline
let acts = tool.key(&cx(&d, &sel, &p), ToolKey::Enter, Mods::default());
apply(&mut d, &snap, &acts);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "Hello\u{2028}\n World");
}
+3 -9
View File
@@ -1,6 +1,6 @@
use super::plain::{
char_range_to_bytes, ime_surrounding_window, plain_ceil_boundary, plain_chars_after, plain_chars_before, plain_floor_boundary, plain_is_boundary,
plain_len, step_key,
plain_len,
};
use super::ports::{ported, ports, ports_shown};
use super::{
@@ -175,13 +175,7 @@ impl Tool for TypeTool {
self.anchor = self.anchor.min(len);
let (a, b) = self.sel();
// Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps).
if mods.alt
&& let Some(id) = self.editing
&& let Some((attribute, by)) = step_key(key, mods.shift, a == b)
{
let mut out = self.commit();
let by = if mods.cmd { by * 5.0 } else { by };
out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})));
if let Some(out) = self.step_attribute(key, mods, a, b) {
return out;
}
let word = mods.cmd || mods.alt;
@@ -211,7 +205,7 @@ impl Tool for TypeTool {
out.push(Action::SwitchTool(target));
out
}
ToolKey::Enter => self.replace(cx, "\n", None),
ToolKey::Enter => self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None),
ToolKey::Tab => self.replace(cx, "\t", None),
ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None),
ToolKey::Backspace if self.caret > 0 => {
+14 -1
View File
@@ -210,7 +210,19 @@ 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 r = app.session.tool_key(tk, Mods::default(), view);
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.
if claimed || (k == Key::Enter && !busy) {
@@ -218,6 +230,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
if claimed {
ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k));
}
if k == Key::Escape && !busy && !claimed {
if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) {
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long