use crate::bidi;
use crate::segment::{grapheme_boundaries, word_boundaries};
#[must_use]
pub fn move_right(text: &str, current: usize) -> usize {
if text.is_empty() {
return 0;
}
if current >= text.len() {
return text.len();
}
let boundaries = grapheme_boundaries(text);
for &b in &boundaries {
if b > current {
return b;
}
}
text.len()
}
#[must_use]
pub fn move_left(text: &str, current: usize) -> usize {
if text.is_empty() || current == 0 {
return 0;
}
let boundaries = grapheme_boundaries(text);
let mut prev = 0usize;
for &b in &boundaries {
if b >= current {
break;
}
prev = b;
}
prev
}
#[must_use]
pub fn delete_backward(text: &str, current: usize) -> (String, usize) {
if text.is_empty() || current == 0 {
return (text.to_owned(), current);
}
let start = move_left(text, current);
let mut result = String::with_capacity(text.len());
result.push_str(&text[..start]);
result.push_str(&text[current..]);
(result, start)
}
#[must_use]
pub fn delete_forward(text: &str, current: usize) -> (String, usize) {
if text.is_empty() || current >= text.len() {
return (text.to_owned(), current);
}
let next = move_right(text, current);
if next == current {
return (text.to_owned(), current);
}
let mut result = String::with_capacity(text.len());
result.push_str(&text[..current]);
result.push_str(&text[next..]);
(result, current)
}
#[must_use]
pub fn select_word(text: &str, current: usize) -> (usize, usize) {
if text.is_empty() || current > text.len() {
return (current, current);
}
let mut bounds = word_boundaries(text, None);
if bounds.is_empty() {
return (current, current);
}
if *bounds.last().unwrap() != text.len() {
bounds.push(text.len());
}
for window in bounds.windows(2) {
let s = window[0];
let e = window[1];
if current >= s && current < e {
return (s, e);
}
}
let mut start = 0usize;
for &b in &bounds {
if b > current {
break;
}
start = b;
}
let mut end = text.len();
for &b in &bounds {
if b > current {
end = b;
break;
}
}
if start <= current && current <= end {
(start, end)
} else {
(current, current)
}
}
struct ClusterInfo {
byte_start: usize,
byte_end: usize,
level: u8,
}
fn build_clusters(text: &str) -> Vec<ClusterInfo> {
if text.is_empty() {
return vec![];
}
let bidi_info = bidi::resolve(text, None);
let mut boundaries = grapheme_boundaries(text);
if boundaries.is_empty() || boundaries[0] != 0 {
boundaries.insert(0, 0);
}
if *boundaries.last().unwrap() != text.len() {
boundaries.push(text.len());
}
let char_offsets: Vec<usize> = text.char_indices().map(|(i, _)| i).collect();
let mut clusters = Vec::with_capacity(boundaries.len() - 1);
for window in boundaries.windows(2) {
let byte_start = window[0];
let byte_end = window[1];
if byte_start >= byte_end {
continue;
}
let char_idx = match char_offsets.binary_search(&byte_start) {
Ok(i) => i,
Err(i) => i.min(char_offsets.len().saturating_sub(1)),
};
let level = if char_idx < bidi_info.levels.len() {
let lev = bidi_info.levels[char_idx];
if lev == u8::MAX {
bidi_info.paragraph_level
} else {
lev
}
} else {
bidi_info.paragraph_level
};
clusters.push(ClusterInfo {
byte_start,
byte_end,
level,
});
}
clusters
}
fn visual_order(clusters: &[ClusterInfo]) -> Vec<usize> {
let n = clusters.len();
if n == 0 {
return vec![];
}
let mut order: Vec<usize> = (0..n).collect();
let max_level = clusters.iter().map(|c| c.level).max().unwrap_or(0);
let min_level = clusters.iter().map(|c| c.level).min().unwrap_or(0);
let start_level = if min_level % 2 == 0 {
min_level + 1
} else {
min_level
};
let mut level = max_level;
while level >= start_level && level > 0 {
let mut i = 0;
while i < n {
if clusters[order[i]].level >= level {
let run_start = i;
while i < n && clusters[order[i]].level >= level {
i += 1;
}
order[run_start..i].reverse();
} else {
i += 1;
}
}
if level == 0 {
break;
}
level -= 1;
}
order
}
#[must_use]
pub fn visual_cursor_stops(text: &str) -> Vec<usize> {
if text.is_empty() {
return vec![0];
}
let clusters = build_clusters(text);
if clusters.is_empty() {
return vec![0];
}
let vis = visual_order(&clusters);
let raw = build_visual_stops(&clusters, &vis, text.len());
let mut seen = std::collections::HashSet::new();
raw.into_iter().filter(|b| seen.insert(*b)).collect()
}
fn build_visual_stops(
clusters: &[ClusterInfo],
vis: &[usize],
_text_len: usize,
) -> Vec<usize> {
if vis.is_empty() {
return vec![0];
}
let mut stops: Vec<usize> = Vec::new();
let first = &clusters[vis[0]];
let first_left = if first.level % 2 == 1 {
first.byte_end
} else {
first.byte_start
};
stops.push(first_left);
for vi in 0..vis.len() {
let c = &clusters[vis[vi]];
let right_edge = if c.level % 2 == 1 {
c.byte_start
} else {
c.byte_end
};
if vi + 1 < vis.len() {
let next = &clusters[vis[vi + 1]];
let next_left = if next.level % 2 == 1 {
next.byte_end
} else {
next.byte_start
};
if right_edge != next_left {
if stops.last() != Some(&right_edge) {
stops.push(right_edge);
}
if next.level > c.level {
stops.push(next_left);
}
} else {
if stops.last() != Some(&right_edge) {
stops.push(right_edge);
}
}
} else {
if stops.last() != Some(&right_edge) {
stops.push(right_edge);
}
}
}
stops
}
#[must_use]
pub fn move_right_visual(text: &str, current: usize) -> usize {
move_right_visual_indexed(text, current, usize::MAX).0
}
#[must_use]
pub fn move_right_visual_indexed(
text: &str,
current: usize,
stop_hint: usize,
) -> (usize, usize) {
if text.is_empty() {
return (0, 0);
}
let clusters = build_clusters(text);
if clusters.is_empty() {
return (text.len(), 0);
}
let vis = visual_order(&clusters);
let stops = build_visual_stops(&clusters, &vis, text.len());
if stops.is_empty() {
return (text.len(), 0);
}
let idx = resolve_stop_index(&stops, current, stop_hint, &clusters);
if idx + 1 < stops.len() {
(stops[idx + 1], idx + 1)
} else {
(*stops.last().unwrap(), stops.len() - 1)
}
}
#[must_use]
pub fn move_left_visual(text: &str, current: usize) -> usize {
move_left_visual_indexed(text, current, usize::MAX).0
}
#[must_use]
pub fn move_left_visual_indexed(
text: &str,
current: usize,
stop_hint: usize,
) -> (usize, usize) {
if text.is_empty() {
return (0, 0);
}
let clusters = build_clusters(text);
if clusters.is_empty() {
return (0, 0);
}
let vis = visual_order(&clusters);
let stops = build_visual_stops(&clusters, &vis, text.len());
if stops.is_empty() {
return (0, 0);
}
let idx = resolve_stop_index(&stops, current, stop_hint, &clusters);
if idx > 0 {
(stops[idx - 1], idx - 1)
} else {
(stops[0], 0)
}
}
fn resolve_stop_index(
stops: &[usize],
current: usize,
stop_hint: usize,
clusters: &[ClusterInfo],
) -> usize {
if stop_hint < stops.len() && stops[stop_hint] == current {
return stop_hint;
}
let matches: Vec<usize> = stops
.iter()
.enumerate()
.filter(|&(_, &s)| s == current)
.map(|(i, _)| i)
.collect();
if matches.is_empty() {
return stops
.iter()
.position(|&s| s >= current)
.unwrap_or(stops.len().saturating_sub(1));
}
if matches.len() == 1 {
return matches[0];
}
let level_before = cluster_level_before(clusters, current);
let level_after = cluster_level_at(clusters, current);
if level_before < level_after {
matches[0]
} else if level_before > level_after {
*matches.last().unwrap()
} else {
matches[0]
}
}
fn cluster_level_before(clusters: &[ClusterInfo], byte_offset: usize) -> u8 {
for c in clusters.iter().rev() {
if c.byte_end <= byte_offset && c.byte_end > 0 {
return c.level;
}
}
0
}
fn cluster_level_at(clusters: &[ClusterInfo], byte_offset: usize) -> u8 {
for c in clusters {
if c.byte_start == byte_offset {
return c.level;
}
if c.byte_start > byte_offset {
break;
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn move_across_ascii() {
let s = "hello";
assert_eq!(move_right(s, 0), 1);
assert_eq!(move_right(s, 1), 2);
assert_eq!(move_left(s, 5), 4);
assert_eq!(move_left(s, 1), 0);
}
#[test]
fn move_across_emoji_cluster() {
let s = "x👨\u{200d}👩\u{200d}👧\u{200d}👦y";
let emoji_start = "x".len();
let y_start = emoji_start + "👨\u{200d}👩\u{200d}👧\u{200d}👦".len();
let after_emoji = move_right(s, emoji_start);
assert_eq!(after_emoji, y_start, "move_right should land at 'y'");
assert_eq!(move_left(s, y_start), emoji_start);
}
#[test]
fn delete_backward_basic() {
let s = "hello";
let (t, pos) = delete_backward(s, 5);
assert_eq!(t, "hell");
assert_eq!(pos, 4);
}
#[test]
fn delete_forward_basic() {
let s = "hello";
let (t, pos) = delete_forward(s, 0);
assert_eq!(t, "ello");
assert_eq!(pos, 0);
}
#[test]
fn select_word_ascii() {
let s = "hello world";
let (start, end) = select_word(s, 1);
assert_eq!(&s[start..end], "hello");
let (start2, end2) = select_word(s, 8);
assert_eq!(&s[start2..end2], "world");
}
#[test]
fn visual_move_pure_ltr() {
let s = "abc";
assert_eq!(move_right_visual(s, 0), 1);
assert_eq!(move_right_visual(s, 1), 2);
assert_eq!(move_right_visual(s, 2), 3);
assert_eq!(move_left_visual(s, 3), 2);
assert_eq!(move_left_visual(s, 1), 0);
}
#[test]
fn visual_move_pure_rtl() {
let s = "\u{05D0}\u{05D1}\u{05D2}"; let right1 = move_right_visual(s, 0);
assert!(right1 <= s.len());
}
#[test]
fn visual_move_empty() {
assert_eq!(move_right_visual("", 0), 0);
assert_eq!(move_left_visual("", 0), 0);
}
#[test]
fn visual_move_at_boundaries() {
let s = "hello";
assert_eq!(move_right_visual(s, s.len()), s.len());
assert_eq!(move_left_visual(s, 0), 0);
}
#[test]
fn diagnostic_bidi_stops() {
let s = "\u{062B}\u{0645}\u{0646}: 42 \u{062F}\u{0648}\u{0644}\u{0627}\u{0631}";
eprintln!("\n====== TEST 1: RTL paragraph with LTR digits ======");
run_bidi_diagnostic(s);
let s2 = "Hi \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} Ok";
eprintln!("\n====== TEST 2: LTR paragraph with RTL ======");
run_bidi_diagnostic(s2);
}
fn run_bidi_diagnostic(s: &str) {
eprintln!("=== Text: {:?} ({} bytes) ===", s, s.len());
for (i, ch) in s.char_indices() {
let end = i + ch.len_utf8();
eprintln!(
" char {:?} (U+{:04X}) bytes {}..{} level=?",
ch,
ch as u32,
i,
end
);
}
let clusters = build_clusters(s);
eprintln!("\n--- Clusters ({}) ---", clusters.len());
for (ci, c) in clusters.iter().enumerate() {
let slice = &s[c.byte_start..c.byte_end];
eprintln!(
" [{}] bytes {}..{} level={} text={:?}",
ci, c.byte_start, c.byte_end, c.level, slice
);
}
let vis = visual_order(&clusters);
eprintln!("\n--- Visual order ---");
for (vi, &ci) in vis.iter().enumerate() {
let c = &clusters[ci];
let slice = &s[c.byte_start..c.byte_end];
let lr = if c.level % 2 == 1 { "RTL" } else { "LTR" };
eprintln!(
" vis[{}] = cluster[{}] {:?} ({}) bytes {}..{}",
vi, ci, slice, lr, c.byte_start, c.byte_end
);
}
let stops = visual_cursor_stops(s);
eprintln!("\n--- Visual cursor stops ({}) ---", stops.len());
for (si, &byte_off) in stops.iter().enumerate() {
eprintln!(" stop[{}] = byte {}", si, byte_off);
}
let unique_count = {
let mut s = stops.clone();
s.dedup();
s.len()
};
assert_eq!(
stops.len(),
unique_count,
"Stop list should have no consecutive duplicate byte offsets"
);
{
let mut seen = std::collections::HashSet::new();
for &b in &stops {
assert!(
seen.insert(b),
"Duplicate byte offset {} found in stop list",
b
);
}
}
eprintln!("\n--- Walking RIGHT by index (new simple approach) ---");
for i in 0..stops.len() {
eprintln!(" index {} -> byte {}", i, stops[i]);
}
eprintln!("\n--- Walking RIGHT via move_right_visual_indexed ---");
let mut pos = stops[0];
let mut hint = 0usize;
for step in 0..stops.len() + 2 {
let (new_pos, new_hint) =
move_right_visual_indexed(s, pos, hint);
eprintln!(
" step {}: pos={} hint={} -> new_pos={} new_hint={}",
step, pos, hint, new_pos, new_hint
);
if new_pos == pos && new_hint == hint {
eprintln!(" (stuck, stopping)");
break;
}
pos = new_pos;
hint = new_hint;
}
eprintln!("\n--- Walking LEFT via move_left_visual_indexed ---");
pos = *stops.last().unwrap();
hint = stops.len() - 1;
for step in 0..stops.len() + 2 {
let (new_pos, new_hint) =
move_left_visual_indexed(s, pos, hint);
eprintln!(
" step {}: pos={} hint={} -> new_pos={} new_hint={}",
step, pos, hint, new_pos, new_hint
);
if new_pos == pos && new_hint == hint {
eprintln!(" (stuck, stopping)");
break;
}
pos = new_pos;
hint = new_hint;
}
eprintln!("\n--- Disambiguation test: NO hints (usize::MAX) ---");
let no_hint = usize::MAX;
let mut seen = std::collections::HashMap::new();
for (i, &byte_off) in stops.iter().enumerate() {
seen.entry(byte_off).or_insert_with(Vec::new).push(i);
}
let duplicates: Vec<usize> = seen
.iter()
.filter(|(_, indices)| indices.len() > 1)
.map(|(&byte_off, _)| byte_off)
.collect();
for &dup_byte in &duplicates {
let (r, ri) = move_right_visual_indexed(s, dup_byte, no_hint);
let (l, li) = move_left_visual_indexed(s, dup_byte, no_hint);
eprintln!(
" Ambiguous byte {}: Right -> pos={} idx={}, Left -> pos={} idx={}",
dup_byte, r, ri, l, li
);
if ri > 0 && ri < stops.len() - 1 {
assert_ne!(
r, dup_byte,
"Right from ambiguous byte {} should not stay at same byte",
dup_byte
);
}
}
}
}