use std::sync::{Arc, Mutex};
use web_time::Instant;
use blinc_animation::{try_get_scheduler, TickCallbackId};
use crate::div::FontWeight;
use crate::styled_text::StyledLine;
use crate::widgets::cursor::{cursor_state, SharedCursorState};
use super::cursor::{ActiveFormat, DocPosition, Selection};
use super::document::RichDocument;
#[derive(Clone, Debug)]
pub struct RunGeometry {
pub source_col: usize,
pub text: String,
pub x_in_line: f32,
pub width: f32,
pub font_family: crate::div::FontFamily,
pub font_size: f32,
pub weight: FontWeight,
pub italic: bool,
}
#[derive(Clone, Debug)]
pub struct LineGeometry {
pub start: DocPosition,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub runs: Vec<RunGeometry>,
}
impl LineGeometry {
pub fn full_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
pub fn total_chars(&self) -> usize {
self.runs.iter().map(|r| r.text.chars().count()).sum()
}
pub fn contains(&self, local_x: f32, local_y: f32) -> bool {
local_y >= self.y
&& local_y < self.y + self.height
&& local_x >= self.x
&& local_x < self.x + self.width.max(1.0)
}
pub fn contains_y(&self, local_y: f32) -> bool {
local_y >= self.y && local_y < self.y + self.height
}
}
#[derive(Clone, Debug)]
pub struct UndoEntry {
pub document: RichDocument,
pub cursor: DocPosition,
pub selection: Option<Selection>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub enum PickerState {
#[default]
None,
Color,
Link {
draft: String,
},
Heading,
}
#[derive(Debug)]
pub struct RichTextData {
pub document: RichDocument,
pub cursor: DocPosition,
pub selection: Option<Selection>,
pub active_format: ActiveFormat,
pub focused: bool,
pub line_index: Vec<LineGeometry>,
pub cursor_state: SharedCursorState,
pub tick_callback_id: Option<TickCallbackId>,
pub editor_bounds: (f32, f32, f32, f32),
pub picker: PickerState,
pub last_click_time: Option<Instant>,
pub toolbar_rect: Option<(f32, f32, f32, f32)>,
pub suppress_next_outer_click: bool,
pub undo_stack: Vec<UndoEntry>,
pub redo_stack: Vec<UndoEntry>,
}
impl Default for RichTextData {
fn default() -> Self {
Self::new(RichDocument::new())
}
}
impl Drop for RichTextData {
fn drop(&mut self) {
if let Some(id) = self.tick_callback_id.take() {
if let Some(scheduler) = try_get_scheduler() {
scheduler.remove_tick_callback(id);
}
}
}
}
impl RichTextData {
pub fn new(document: RichDocument) -> Self {
Self {
document,
cursor: DocPosition::ZERO,
selection: None,
active_format: ActiveFormat::default(),
focused: false,
line_index: Vec::new(),
cursor_state: cursor_state(),
tick_callback_id: None,
editor_bounds: (0.0, 0.0, 0.0, 0.0),
picker: PickerState::None,
last_click_time: None,
toolbar_rect: None,
suppress_next_outer_click: false,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
}
}
pub fn selection_bounds(&self) -> Option<(f32, f32, f32, f32)> {
let sel = self.selection?;
if sel.is_empty() {
return None;
}
let (start, end) = sel.ordered();
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
for g in &self.line_index {
let line_chars = g.total_chars();
let line_end_col = g.start.col + line_chars;
let on_block = g.start.block;
let on_line = g.start.line;
let after_start =
(on_block, on_line, line_end_col) >= (start.block, start.line, start.col);
let before_end = (on_block, on_line, g.start.col) <= (end.block, end.line, end.col);
if !(after_start && before_end) {
continue;
}
let line_start_pos = (on_block, on_line, g.start.col);
let line_end_pos = (on_block, on_line, line_end_col);
let sel_start_pos = (start.block, start.line, start.col);
let sel_end_pos = (end.block, end.line, end.col);
let sx = sel_start_pos.max(line_start_pos);
let ex = sel_end_pos.min(line_end_pos);
if sx >= ex {
continue;
}
let local_start = sx.2 - g.start.col;
let local_end = ex.2 - g.start.col;
let prefix_w = pixel_x_for_local_col(g, local_start);
let end_w = pixel_x_for_local_col(g, local_end);
let mid_w = end_w - prefix_w;
if mid_w <= 0.0 {
continue;
}
let x0 = g.x + prefix_w;
let x1 = x0 + mid_w;
let y0 = g.y;
let y1 = g.y + g.height;
if x0 < min_x {
min_x = x0;
}
if y0 < min_y {
min_y = y0;
}
if x1 > max_x {
max_x = x1;
}
if y1 > max_y {
max_y = y1;
}
}
if !min_x.is_finite() {
return None;
}
Some((min_x, min_y, max_x - min_x, max_y - min_y))
}
pub fn set_focus(&mut self, focused: bool) {
if self.focused == focused {
return;
}
self.focused = focused;
self.set_cursor_visible(focused);
if focused {
crate::widgets::text_input::increment_focus_count();
if self.tick_callback_id.is_none() {
if let Some(scheduler) = try_get_scheduler() {
self.tick_callback_id = scheduler.register_tick_callback(|_dt| {});
}
}
} else {
crate::widgets::text_input::decrement_focus_count();
crate::widgets::text_input::clear_focused_editable_node();
if let Some(id) = self.tick_callback_id.take() {
if let Some(scheduler) = try_get_scheduler() {
scheduler.remove_tick_callback(id);
}
}
}
}
pub fn set_line_index(&mut self, index: Vec<LineGeometry>) {
self.line_index = index;
}
pub fn reset_cursor_blink(&self) {
if let Ok(mut cs) = self.cursor_state.lock() {
cs.reset_blink();
}
}
pub fn set_cursor_visible(&self, visible: bool) {
if let Ok(mut cs) = self.cursor_state.lock() {
cs.set_visible(visible);
}
}
pub fn push_undo(&mut self) {
const MAX_UNDO: usize = 200;
self.undo_stack.push(UndoEntry {
document: self.document.clone(),
cursor: self.cursor,
selection: self.selection,
});
if self.undo_stack.len() > MAX_UNDO {
self.undo_stack.remove(0);
}
self.redo_stack.clear();
}
pub fn undo(&mut self) -> bool {
let Some(entry) = self.undo_stack.pop() else {
return false;
};
self.redo_stack.push(UndoEntry {
document: self.document.clone(),
cursor: self.cursor,
selection: self.selection,
});
self.document = entry.document;
self.cursor = entry.cursor.clamp(&self.document);
self.selection = entry.selection;
self.active_format = ActiveFormat::from_position(&self.document, self.cursor);
self.reset_cursor_blink();
true
}
pub fn redo(&mut self) -> bool {
let Some(entry) = self.redo_stack.pop() else {
return false;
};
self.undo_stack.push(UndoEntry {
document: self.document.clone(),
cursor: self.cursor,
selection: self.selection,
});
self.document = entry.document;
self.cursor = entry.cursor.clamp(&self.document);
self.selection = entry.selection;
self.active_format = ActiveFormat::from_position(&self.document, self.cursor);
self.reset_cursor_blink();
true
}
pub fn set_cursor(&mut self, pos: DocPosition) {
let clamped = pos.clamp(&self.document);
self.cursor = clamped;
self.active_format = ActiveFormat::from_position(&self.document, clamped);
self.reset_cursor_blink();
}
pub fn move_cursor(&mut self, pos: DocPosition, extend: bool) {
let clamped = pos.clamp(&self.document);
if extend {
let anchor = self.selection.map(|s| s.anchor).unwrap_or(self.cursor);
self.selection = Some(Selection {
anchor,
head: clamped,
});
} else {
self.selection = None;
}
self.cursor = clamped;
self.active_format = ActiveFormat::from_position(&self.document, clamped);
self.reset_cursor_blink();
}
pub fn line_at_y(&self, local_y: f32) -> Option<&LineGeometry> {
if self.line_index.is_empty() {
return None;
}
if let Some(g) = self.line_index.iter().find(|g| g.contains_y(local_y)) {
return Some(g);
}
if local_y < self.line_index[0].y {
return Some(&self.line_index[0]);
}
self.line_index.last()
}
pub fn cursor_geometry(&self) -> Option<(f32, f32, f32)> {
let cursor = self.cursor;
let mut chosen: Option<&LineGeometry> = None;
for g in &self.line_index {
if g.start.block == cursor.block && g.start.line == cursor.line {
let line_end_col = g.start.col + g.total_chars();
if cursor.col >= g.start.col && cursor.col <= line_end_col {
chosen = Some(g);
break;
}
if cursor.col > line_end_col {
chosen = Some(g);
}
}
}
let g = chosen?;
let local_col = cursor.col.saturating_sub(g.start.col);
let mut consumed = 0usize;
for run in &g.runs {
let run_chars = run.text.chars().count();
if local_col <= consumed + run_chars {
let in_run = local_col - consumed;
let prefix: String = run.text.chars().take(in_run).collect();
let prefix_w = measure_width(
&prefix,
run.font_size,
run.weight,
run.italic,
Some(&run.font_family),
);
return Some((g.x + run.x_in_line + prefix_w, g.y, g.height));
}
consumed += run_chars;
}
if let Some(last) = g.runs.last() {
return Some((g.x + last.x_in_line + last.width, g.y, g.height));
}
Some((g.x, g.y, g.height))
}
pub fn position_from_click(&self, local_x: f32, local_y: f32) -> Option<DocPosition> {
let g = self.line_at_y(local_y)?.clone();
let inside_x = (local_x - g.x).max(0.0);
let mut consumed_chars = 0usize;
for run in &g.runs {
let run_chars = run.text.chars().count();
let run_left = run.x_in_line;
let run_right = run_left + run.width;
if inside_x < run_right || run_chars == 0 {
let target = (inside_x - run_left).max(0.0);
let in_run = column_at_x(
&run.text,
target,
run.font_size,
run.weight,
run.italic,
Some(&run.font_family),
);
return Some(DocPosition::new(
g.start.block,
g.start.line,
g.start.col + consumed_chars + in_run,
));
}
consumed_chars += run_chars;
}
Some(DocPosition::new(
g.start.block,
g.start.line,
g.start.col + consumed_chars,
))
}
}
pub type RichTextState = Arc<Mutex<RichTextData>>;
pub fn rich_text_state(document: RichDocument) -> RichTextState {
Arc::new(Mutex::new(RichTextData::new(document)))
}
fn take_chars(text: &str, n: usize) -> String {
text.chars().take(n).collect()
}
pub(crate) fn pixel_x_for_local_col(g: &LineGeometry, local_col: usize) -> f32 {
let mut consumed = 0usize;
for run in &g.runs {
let run_chars = run.text.chars().count();
if local_col <= consumed + run_chars {
let in_run = local_col - consumed;
let prefix: String = run.text.chars().take(in_run).collect();
let prefix_w = measure_width(
&prefix,
run.font_size,
run.weight,
run.italic,
Some(&run.font_family),
);
return run.x_in_line + prefix_w;
}
consumed += run_chars;
}
g.runs.last().map(|r| r.x_in_line + r.width).unwrap_or(0.0)
}
pub(crate) fn measure_width(
text: &str,
font_size: f32,
weight: FontWeight,
italic: bool,
font_family: Option<&crate::div::FontFamily>,
) -> f32 {
let mut options = crate::text_measure::TextLayoutOptions::new();
options.font_weight = weight.weight();
options.italic = italic;
if let Some(family) = font_family {
options.font_name = family.name.clone();
options.generic_font = family.generic;
}
crate::text_measure::measure_text_with_options(text, font_size, &options).width
}
pub(crate) fn column_at_x(
text: &str,
target_x: f32,
font_size: f32,
weight: FontWeight,
italic: bool,
font_family: Option<&crate::div::FontFamily>,
) -> usize {
if target_x <= 0.0 || text.is_empty() {
return 0;
}
let mut prev_width = 0.0;
let mut col = 0;
for (i, _ch) in text.char_indices() {
let upto = &text[..i];
let after_idx = next_char_index(text, i);
let upto_inclusive = &text[..after_idx];
let w_before = measure_width(upto, font_size, weight, italic, font_family);
let w_after = measure_width(upto_inclusive, font_size, weight, italic, font_family);
let mid = (w_before + w_after) * 0.5;
if target_x < mid {
return col;
}
prev_width = w_after;
col += 1;
if w_after >= target_x && col > 0 {
return col;
}
}
let _ = prev_width; text.chars().count()
}
fn next_char_index(text: &str, byte_idx: usize) -> usize {
text[byte_idx..]
.char_indices()
.nth(1)
.map(|(i, _)| byte_idx + i)
.unwrap_or(text.len())
}
pub(crate) fn synth_line(text: &str, color: blinc_core::Color) -> StyledLine {
StyledLine::plain(text, color)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::rich_text_editor::document::Block;
use blinc_core::Color;
fn make_run(text: &str, source_col: usize, x_in_line: f32) -> RunGeometry {
let width = measure_width(text, 14.0, FontWeight::Normal, false, None);
RunGeometry {
source_col,
text: text.to_string(),
x_in_line,
width,
font_family: crate::div::FontFamily::default(),
font_size: 14.0,
weight: FontWeight::Normal,
italic: false,
}
}
fn sample_state() -> RichTextData {
let doc = RichDocument::from_blocks(vec![
Block::paragraph("hello world", Color::WHITE),
Block::paragraph("second block", Color::WHITE),
]);
let mut state = RichTextData::new(doc);
state.set_line_index(vec![
LineGeometry {
start: DocPosition::new(0, 0, 0),
x: 0.0,
y: 0.0,
width: 200.0,
height: 20.0,
runs: vec![make_run("hello world", 0, 0.0)],
},
LineGeometry {
start: DocPosition::new(1, 0, 0),
x: 0.0,
y: 24.0,
width: 200.0,
height: 20.0,
runs: vec![make_run("second block", 0, 0.0)],
},
]);
state
}
#[test]
fn click_inside_first_line_finds_block_zero() {
let state = sample_state();
let pos = state.position_from_click(40.0, 5.0).unwrap();
assert_eq!(pos.block, 0);
assert_eq!(pos.line, 0);
}
#[test]
fn click_in_second_line_finds_block_one() {
let state = sample_state();
let pos = state.position_from_click(40.0, 30.0).unwrap();
assert_eq!(pos.block, 1);
}
#[test]
fn click_above_first_line_snaps_to_start() {
let state = sample_state();
let pos = state.position_from_click(40.0, -100.0).unwrap();
assert_eq!(pos.block, 0);
}
#[test]
fn click_below_last_line_snaps_to_end() {
let state = sample_state();
let pos = state.position_from_click(40.0, 9999.0).unwrap();
assert_eq!(pos.block, 1);
}
#[test]
fn click_at_x_zero_returns_col_zero() {
let state = sample_state();
let pos = state.position_from_click(0.0, 5.0).unwrap();
assert_eq!(pos.col, 0);
}
#[test]
fn click_past_right_edge_returns_end_col() {
let state = sample_state();
let pos = state.position_from_click(10000.0, 5.0).unwrap();
assert_eq!(pos.col, 11);
}
#[test]
fn move_cursor_extends_selection_when_requested() {
let mut state = sample_state();
state.set_cursor(DocPosition::new(0, 0, 0));
state.move_cursor(DocPosition::new(0, 0, 5), true);
assert!(state.selection.is_some());
let sel = state.selection.unwrap();
assert_eq!(sel.anchor, DocPosition::new(0, 0, 0));
assert_eq!(sel.head, DocPosition::new(0, 0, 5));
state.move_cursor(DocPosition::new(0, 0, 8), true);
let sel = state.selection.unwrap();
assert_eq!(sel.anchor, DocPosition::new(0, 0, 0));
assert_eq!(sel.head, DocPosition::new(0, 0, 8));
}
#[test]
fn move_cursor_clears_selection_when_not_extending() {
let mut state = sample_state();
state.move_cursor(DocPosition::new(0, 0, 5), true);
assert!(state.selection.is_some());
state.move_cursor(DocPosition::new(0, 0, 7), false);
assert!(state.selection.is_none());
}
#[test]
fn cursor_geometry_returns_position_for_known_line() {
let mut state = sample_state();
state.set_cursor(DocPosition::new(0, 0, 5));
let (x, y, h) = state.cursor_geometry().unwrap();
assert!(x > 0.0);
assert_eq!(y, 0.0);
assert!(h > 0.0);
}
#[test]
fn synth_line_helper_used_for_test_round_trip() {
let _l = synth_line("a", Color::WHITE);
}
}