use oxideav_scribe::bidi::{
BidiClass, bidi_class, process_paragraph_classes_with_brackets, reorder_line,
reset_trailing_levels,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum TextDirection {
#[default]
Auto,
Ltr,
Rtl,
}
impl TextDirection {
pub(crate) fn base_level(self) -> Option<u8> {
match self {
TextDirection::Auto => None,
TextDirection::Ltr => Some(0),
TextDirection::Rtl => Some(1),
}
}
pub fn is_rtl_for(self, text: &str) -> bool {
match self {
TextDirection::Ltr => false,
TextDirection::Rtl => true,
TextDirection::Auto => {
let para = text.split('\n').next().unwrap_or("");
needs_bidi(para, self) && oxideav_scribe::bidi::paragraph_level(para) == 1
}
}
}
}
pub(crate) fn has_strong(para: &str) -> bool {
para.chars()
.any(|c| matches!(bidi_class(c), BidiClass::L | BidiClass::R | BidiClass::AL))
}
pub(crate) fn needs_bidi(para: &str, direction: TextDirection) -> bool {
if direction == TextDirection::Rtl {
return true;
}
para.chars().any(|c| {
(c as u32) >= 0x0590
&& matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::RLE
| BidiClass::RLO
| BidiClass::RLI
| BidiClass::FSI
)
})
}
#[derive(Clone, Debug)]
pub(crate) struct ParagraphLevels {
pub base: u8,
classes: Vec<BidiClass>,
levels: Vec<u8>,
}
impl ParagraphLevels {
pub fn new(para: &str, direction: TextDirection) -> Self {
let chars: Vec<char> = para.chars().collect();
if !needs_bidi(para, direction) {
return Self {
base: 0,
classes: Vec::new(),
levels: vec![0; chars.len()],
};
}
let classes: Vec<BidiClass> = chars.iter().copied().map(bidi_class).collect();
let carrier =
process_paragraph_classes_with_brackets(&classes, &chars, direction.base_level());
Self {
base: carrier.paragraph_level & 1,
classes: carrier.classes,
levels: carrier.levels,
}
}
pub fn line_levels(&self, range: std::ops::Range<usize>) -> Vec<u8> {
let mut levels = self.levels[range.clone()].to_vec();
if !self.classes.is_empty() {
reset_trailing_levels(&self.classes[range], &mut levels, self.base);
}
levels
}
}
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
if levels.iter().all(|l| l & 1 == 0) {
return (0..levels.len()).collect();
}
reorder_line(levels)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct VisualChar {
pub start: usize,
pub end: usize,
pub level: u8,
}
impl VisualChar {
fn rtl(&self) -> bool {
self.level & 1 == 1
}
fn at_left(&self) -> usize {
if self.rtl() { self.end } else { self.start }
}
fn at_right(&self) -> usize {
if self.rtl() { self.start } else { self.end }
}
}
pub(crate) fn boundary_carets(vis: &[VisualChar], empty: usize) -> Vec<usize> {
if vis.is_empty() {
return vec![empty];
}
let n = vis.len();
(0..=n)
.map(|v| match (v.checked_sub(1).map(|l| vis[l]), vis.get(v)) {
(Some(l), Some(r)) if l.level > r.level => l.at_right(),
(_, Some(r)) => r.at_left(),
(Some(l), None) => l.at_right(),
(None, None) => empty,
})
.collect()
}
pub(crate) fn caret_boundary(vis: &[VisualChar], carets: &[usize], caret: usize) -> usize {
if let Some(v) = carets.iter().position(|&c| c == caret) {
return v;
}
if let Some(v) = vis.iter().position(|c| c.start == caret) {
return if vis[v].rtl() { v + 1 } else { v };
}
if let Some(v) = vis.iter().position(|c| c.end == caret) {
return if vis[v].rtl() { v } else { v + 1 };
}
0
}
pub(crate) fn visual_chars(levels: &[u8]) -> Vec<VisualChar> {
visual_order(levels)
.into_iter()
.map(|i| VisualChar {
start: i,
end: i + 1,
level: levels[i],
})
.collect()
}
pub(crate) fn visual_step(levels: &[u8], caret: usize, right: bool) -> Option<usize> {
let vis = visual_chars(levels);
let carets = boundary_carets(&vis, 0);
let n = vis.len();
let mut v = caret_boundary(&vis, &carets, caret.min(n));
loop {
if right {
if v >= n {
return None;
}
v += 1;
} else {
if v == 0 {
return None;
}
v -= 1;
}
if carets[v] != caret {
return Some(carets[v]);
}
}
}
pub(crate) fn visual_step_in(
line: &str,
caret: usize,
direction: TextDirection,
right: bool,
) -> Option<usize> {
let offsets: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
let n = offsets.len();
let byte_of = |c: usize| offsets.get(c).copied().unwrap_or(line.len());
let ci = offsets.partition_point(|&o| o < caret);
let para = ParagraphLevels::new(line, direction);
let levels = para.line_levels(0..n);
visual_step(&levels, ci, right).map(byte_of)
}
#[cfg(test)]
mod tests {
use super::*;
fn levels(s: &str, dir: TextDirection) -> Vec<u8> {
let p = ParagraphLevels::new(s, dir);
p.line_levels(0..s.chars().count())
}
fn visual(s: &str, dir: TextDirection) -> String {
let chars: Vec<char> = s.chars().collect();
let l = levels(s, dir);
visual_order(&l).iter().map(|&i| chars[i]).collect()
}
#[test]
fn mixed_line_reverses_only_the_hebrew() {
assert_eq!(
visual("abc אבג 123 def", TextDirection::Auto),
"abc 123 גבא def"
);
assert_eq!(visual("אבג 123 def", TextDirection::Auto), "def 123 גבא");
assert_eq!(visual("abc", TextDirection::Rtl), "abc");
}
#[test]
fn auto_direction_follows_the_first_strong_character() {
assert!(!TextDirection::Auto.is_rtl_for("abc אבג"));
assert!(TextDirection::Auto.is_rtl_for("123 אבג abc"));
assert!(TextDirection::Auto.is_rtl_for("مرحبا"));
assert!(!TextDirection::Auto.is_rtl_for("123"));
assert!(TextDirection::Rtl.is_rtl_for("abc"));
assert!(!TextDirection::Ltr.is_rtl_for("אבג"));
}
#[test]
fn visual_steps_visit_every_position() {
for (s, dir) in [
("abc אבג 123 def", TextDirection::Auto),
("אבג abc", TextDirection::Auto),
("abc", TextDirection::Rtl),
("שלום", TextDirection::Auto),
] {
let l = levels(s, dir);
let n = l.len();
let vis = visual_chars(&l);
let mut seen = vec![boundary_carets(&vis, 0)[0]];
let mut caret = seen[0];
while let Some(next) = visual_step(&l, caret, true) {
caret = next;
seen.push(caret);
assert!(seen.len() <= n + 1, "{s}: cycle {seen:?}");
}
let mut sorted = seen.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), n + 1, "{s}: {seen:?}");
let mut back = vec![caret];
while let Some(prev) = visual_step(&l, caret, false) {
caret = prev;
back.push(caret);
assert!(back.len() <= n + 1, "{s}: cycle {back:?}");
}
back.reverse();
assert_eq!(back, seen, "{s}");
}
}
#[test]
fn left_moves_forward_through_hebrew() {
let l = levels("abc אבג", TextDirection::Auto);
assert_eq!(visual_step(&l, 3, true), Some(7));
assert_eq!(visual_step(&l, 7, true), Some(6));
assert_eq!(visual_step(&l, 5, true), Some(4));
assert_eq!(visual_step(&l, 4, true), None);
let l = levels("שלום", TextDirection::Auto);
assert_eq!(visual_step(&l, 0, false), Some(1));
assert_eq!(visual_step(&l, 0, true), None);
}
#[test]
fn visual_step_in_works_on_bytes() {
assert_eq!(
visual_step_in("שלום", 0, TextDirection::Auto, false),
Some(2)
);
assert_eq!(visual_step_in("ab", 1, TextDirection::Auto, true), Some(2));
assert_eq!(visual_step_in("", 0, TextDirection::Auto, true), None);
}
}