mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
fix: keep Type arrows on grapheme boundaries
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Codex
parent
ecaae7ee46
commit
de4c6b1a6d
@@ -484,6 +484,18 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
|
||||
/// Going right, the caret takes them in increasing order on left-to-right glyphs and in
|
||||
/// decreasing order on right-to-left glyphs; going left, it takes them in the reverse order.
|
||||
pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize {
|
||||
caret_horizontal_filtered(layout, byte, right, |_| true)
|
||||
}
|
||||
|
||||
/// Like [`caret_horizontal`], but exclude glyph stops inside a grapheme of `text`.
|
||||
pub fn caret_horizontal_cluster(layout: &TextLayout, text: &str, byte: usize, right: bool) -> usize {
|
||||
use unicode_segmentation::GraphemeCursor;
|
||||
caret_horizontal_filtered(layout, byte, right, |b| {
|
||||
text.is_char_boundary(b) && GraphemeCursor::new(b, text.len(), true).is_boundary(text, 0).unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
fn caret_horizontal_filtered(layout: &TextLayout, byte: usize, right: bool, valid: impl Fn(usize) -> bool) -> usize {
|
||||
let li = layout.line_of(byte);
|
||||
let Some(line) = layout.lines.get(li) else { return byte };
|
||||
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default();
|
||||
@@ -500,6 +512,7 @@ pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize
|
||||
stops.dedup();
|
||||
stops
|
||||
.into_iter()
|
||||
.filter(|&b| valid(b))
|
||||
.filter_map(|b| {
|
||||
// Both the distance and the place among stops at one x are counted in the step's direction.
|
||||
let (bx, bo) = stop(b);
|
||||
|
||||
@@ -97,21 +97,30 @@ fn pre_base_matra_is_drawn_before_its_consonant() {
|
||||
#[test]
|
||||
fn syllables_map_to_their_source_bytes_and_caret_stops() {
|
||||
let Some(face) = devanagari_test_face("syllables_map_to_their_source_bytes_and_caret_stops") else { return };
|
||||
// हि + न्दी: the second syllable's matra stays with the conjunct it follows.
|
||||
// हि + न्दी: raw font clusters can subdivide a Unicode editing grapheme.
|
||||
let s = "हिन्दी";
|
||||
let l = devanagari_layout(&face, s);
|
||||
let second = "हि".len();
|
||||
assert_eq!(source_clusters(&l), vec![(0, second), (second, s.len() - second)]);
|
||||
let clusters = source_clusters(&l);
|
||||
assert_eq!(clusters.first(), Some(&(0, second)), "{clusters:?}");
|
||||
let mut end = 0;
|
||||
for &(byte, len) in &clusters {
|
||||
assert_eq!(byte, end, "source clusters must cover input without gaps or overlaps: {clusters:?}");
|
||||
assert!(len > 0, "empty source cluster: {clusters:?}");
|
||||
end = byte.checked_add(len).expect("source cluster range overflow");
|
||||
assert!(s.get(byte..end).is_some(), "source cluster must span valid UTF-8: {clusters:?}");
|
||||
}
|
||||
assert_eq!(end, s.len(), "source clusters must cover the whole input: {clusters:?}");
|
||||
// The first syllable's i matra is drawn before its consonant.
|
||||
assert_eq!(l.glyphs[1].gid, face.glyph_for('ह'), "{:?}", l.glyphs);
|
||||
assert!(l.glyphs[0].origin.x < l.glyphs[1].origin.x, "{:?}", l.glyphs);
|
||||
// Left and Right arrows pass over the same syllables the shaper made.
|
||||
// Editing arrows follow Unicode graphemes, regardless of raw cluster subdivision.
|
||||
assert_eq!(edit::next_cluster(s, 0), second);
|
||||
assert_eq!(edit::next_cluster(s, second), s.len());
|
||||
assert_eq!(edit::prev_cluster(s, s.len()), second);
|
||||
assert_eq!(caret_horizontal(&l, 0, true), second);
|
||||
assert_eq!(caret_horizontal(&l, second, true), s.len());
|
||||
assert_eq!(caret_horizontal(&l, s.len(), false), second);
|
||||
assert_eq!(caret_horizontal_cluster(&l, s, 0, true), second);
|
||||
assert_eq!(caret_horizontal_cluster(&l, s, second, true), s.len());
|
||||
assert_eq!(caret_horizontal_cluster(&l, s, s.len(), false), second);
|
||||
assert!((caret_position(&l, second).0.x - cluster_extent(&l, 0).1).abs() < 1e-9);
|
||||
}
|
||||
|
||||
|
||||
@@ -633,7 +633,7 @@ impl Tool for TypeTool {
|
||||
edit::prev_word(&text, self.caret)
|
||||
} else {
|
||||
if lay.glyphs.iter().any(|g| g.rtl) {
|
||||
vectorcraft_text::caret_horizontal(&lay, self.caret, false)
|
||||
vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, false)
|
||||
} else {
|
||||
edit::prev_cluster(&text, self.caret)
|
||||
}
|
||||
@@ -653,7 +653,7 @@ impl Tool for TypeTool {
|
||||
edit::next_word(&text, self.caret)
|
||||
} else {
|
||||
if lay.glyphs.iter().any(|g| g.rtl) {
|
||||
vectorcraft_text::caret_horizontal(&lay, self.caret, true)
|
||||
vectorcraft_text::caret_horizontal_cluster(&lay, &text, self.caret, true)
|
||||
} else {
|
||||
edit::next_cluster(&text, self.caret)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,80 @@ fn apply(d: &mut Document, snapshot: &Document, acts: &[Action]) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_arrows_keep_devanagari_clusters_with_an_rtl_suffix() {
|
||||
// Use the text tests' installed-family order, requiring coverage of the unchanged Hindi input.
|
||||
// The Hebrew suffix selects the Type tool's existing visual-arrow route.
|
||||
let hindi = "हिन्दी";
|
||||
let second = "हि".len();
|
||||
assert_eq!((second, hindi.len()), (6, 18));
|
||||
let db = FontDb::global();
|
||||
let face = ["Kohinoor Devanagari", "ITF Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Nirmala UI", "Mangal"]
|
||||
.into_iter()
|
||||
.filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name))
|
||||
.find(|face| hindi.chars().all(|ch| face.covers(ch)));
|
||||
let Some(face) = face else {
|
||||
eprintln!("SKIPPED: type_arrows_keep_devanagari_clusters_with_an_rtl_suffix; no installed candidate font covers the Hindi input");
|
||||
return;
|
||||
};
|
||||
eprintln!(
|
||||
"TYPE_ARROW_FONT: {} {}; version={}; bytes={}; face_index={}",
|
||||
face.family,
|
||||
face.style,
|
||||
face.version,
|
||||
face.file_data().len(),
|
||||
face.face_index()
|
||||
);
|
||||
assert!(hindi.chars().all(|ch| face.covers(ch)), "the selected face covers the unchanged Hindi prefix");
|
||||
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..Default::default() };
|
||||
|
||||
// The plain-LTR control runs first. Each case uses a fresh document and tool.
|
||||
for (case, text, visual_route) in [("plain_ltr", "हिन्दी", false), ("mixed_rtl", "हिन्दी שלום", true)] {
|
||||
let (mut d, id, mut tool) = editing(text);
|
||||
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text fixture") };
|
||||
for run in &mut t.runs {
|
||||
run.style = style.clone();
|
||||
}
|
||||
assert_eq!(t.plain_text(), text);
|
||||
assert_eq!(&text[..hindi.len()], hindi);
|
||||
let lay = vectorcraft_text::layout(db, t);
|
||||
assert_eq!(lay.lines.len(), 1, "{case}: one ordinary horizontal line");
|
||||
assert!(!lay.vertical, "{case}: horizontal Type");
|
||||
let mut hindi_spans: Vec<_> = lay.glyphs.iter().filter(|g| g.byte < hindi.len()).map(|g| (g.byte, g.len)).collect();
|
||||
hindi_spans.sort_unstable();
|
||||
hindi_spans.dedup();
|
||||
// Raw shaping spans are diagnostic; editing boundaries do not depend on their count.
|
||||
assert!(!hindi_spans.is_empty(), "{case}: Hindi glyph source spans are present");
|
||||
for glyph in lay.glyphs.iter().filter(|g| g.byte < hindi.len()) {
|
||||
let used = db.face_by_id(glyph.font_id).expect("the Hindi glyph's loaded face");
|
||||
assert!(used.file_data() == face.file_data(), "{case}: Hindi uses the selected face bytes");
|
||||
assert_eq!(used.face_index(), face.face_index(), "{case}: Hindi uses the selected face index");
|
||||
assert_ne!(glyph.gid, 0, "{case}: Hindi glyph is present");
|
||||
assert!(!glyph.rtl, "{case}: Hindi prefix remains LTR");
|
||||
}
|
||||
assert_eq!(lay.glyphs.iter().any(|g| g.rtl), visual_route, "{case}: actual Type arrow-route predicate");
|
||||
if visual_route {
|
||||
assert!(lay.glyphs.iter().any(|g| g.rtl && g.byte > hindi.len()), "{case}: the ordinary suffix supplies RTL glyphs");
|
||||
}
|
||||
assert_eq!(edit::next_cluster(text, second), hindi.len(), "{case}: logical cluster end");
|
||||
assert_eq!(edit::prev_cluster(text, hindi.len()), second, "{case}: logical cluster start");
|
||||
tool.caret = second;
|
||||
tool.anchor = second;
|
||||
assert_eq!(tool.editing, Some(id));
|
||||
assert_eq!(tool.sel(), (second, second), "{case}: collapsed caret before Right");
|
||||
assert!(tool.typing.is_none(), "{case}: no pending typing session");
|
||||
eprintln!(
|
||||
"TYPE_ARROW_PRE_ASSERT_READY: case={case}; hindi_spans={hindi_spans:?}; visual_route={visual_route}; caret={}; anchor={}",
|
||||
tool.caret, tool.anchor
|
||||
);
|
||||
key(&mut tool, &d, ToolKey::Right, Mods::default());
|
||||
assert_eq!((tool.caret, tool.anchor), (hindi.len(), hindi.len()), "{case}: Right must move 6 -> 18 across the Hindi cluster");
|
||||
key(&mut tool, &d, ToolKey::Left, Mods::default());
|
||||
assert_eq!((tool.caret, tool.anchor), (second, second), "{case}: Left must move 18 -> 6 across the Hindi cluster");
|
||||
eprintln!("TYPE_ARROW_CASE_COMPLETE: {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_is_one_coalesced_preview() {
|
||||
let (mut d, id, mut tool) = editing("Hello");
|
||||
|
||||
Reference in New Issue
Block a user