use std::time::Instant;
use rdom_core::{NodeId, Position, Selection};
use crate::node::nearest_editable_ancestor;
use crate::runtime::editing::editor_state::{EditEntry, EditKind, EditorState};
use crate::tui_event::TuiDispatchExt;
use crate::{TuiDom, TuiEvent};
#[derive(Debug, Clone)]
pub struct Edit {
pub node: NodeId,
pub range: std::ops::Range<usize>,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditOutcome {
Applied,
Prevented,
NoEditableTarget,
}
pub fn perform_edit(dom: &mut TuiDom, edit: Edit) -> EditOutcome {
let editable = match nearest_editable_ancestor(dom, edit.node) {
Some(id) => id,
None => return EditOutcome::NoEditableTarget,
};
let caret_before = dom
.selection()
.map(|s| s.focus)
.unwrap_or_else(|| Position::new(edit.node, edit.range.start));
let old_text = match dom.node(edit.node).node_value() {
Some(s) => {
let start = edit.range.start.min(s.len());
let end = edit.range.end.min(s.len()).max(start);
s[start..end].to_string()
}
None => return EditOutcome::NoEditableTarget,
};
let input_type = classify_input_type(&edit);
let data = if edit.text.is_empty() {
None
} else {
Some(edit.text.clone())
};
let mut before = TuiEvent::before_input(input_type.clone(), data.clone());
let _ = dom.dispatch_tui_event(editable, &mut before);
if before.event.default_prevented() {
return EditOutcome::Prevented;
}
if dom.node(editable).has_attribute("readonly") {
return EditOutcome::Prevented;
}
if dom
.node_mut(edit.node)
.edit_text(edit.range.start, edit.range.end, &edit.text)
.is_err()
{
return EditOutcome::Prevented;
}
let caret_offset = edit.range.start + edit.text.len();
let caret_after = Position::new(edit.node, caret_offset);
dom.set_selection(Some(Selection::caret(caret_after)));
if let Some(ext) = dom.node_mut(editable).ext_mut() {
let entry = EditEntry {
node: edit.node,
range: edit.range.clone(),
old: old_text,
new: edit.text.clone(),
caret_before,
caret_after,
kind: classify_kind(&edit),
};
ext.editor_state
.get_or_insert_with(|| Box::new(EditorState::new()))
.record(entry, Instant::now());
}
crate::runtime::builtins::input::mirror_to_attribute(dom, editable);
let mut after = TuiEvent::input(input_type, data);
let _ = dom.dispatch_tui_event(editable, &mut after);
EditOutcome::Applied
}
fn classify_input_type(edit: &Edit) -> rdom_core::InputType {
let has_text = !edit.text.is_empty();
let has_range = edit.range.start != edit.range.end;
match (has_text, has_range) {
(true, false) => rdom_core::InputType::InsertText,
(true, true) => rdom_core::InputType::InsertReplacementText,
(false, true) => rdom_core::InputType::DeleteContentBackward,
(false, false) => rdom_core::InputType::InsertText,
}
}
fn classify_kind(edit: &Edit) -> EditKind {
let range_empty = edit.range.start == edit.range.end;
match (range_empty, edit.text.is_empty()) {
(true, false) => EditKind::Insert,
(false, true) => EditKind::Delete,
_ => EditKind::Replace,
}
}
pub fn insert_at_selection(dom: &mut TuiDom, text: &str) -> EditOutcome {
let Some(sel) = dom.selection().copied() else {
return EditOutcome::NoEditableTarget;
};
if sel.anchor.node == sel.focus.node {
let (start, end) = if sel.anchor.offset <= sel.focus.offset {
(sel.anchor.offset, sel.focus.offset)
} else {
(sel.focus.offset, sel.anchor.offset)
};
return perform_edit(
dom,
Edit {
node: sel.anchor.node,
range: start..end,
text: text.to_string(),
},
);
}
perform_cross_node_edit(dom, sel.anchor, sel.focus, text)
}
pub fn perform_cross_node_edit(
dom: &mut TuiDom,
anchor: Position,
focus: Position,
text: &str,
) -> EditOutcome {
let host = match nearest_editable_ancestor(dom, anchor.node) {
Some(id) => id,
None => return EditOutcome::NoEditableTarget,
};
if nearest_editable_ancestor(dom, focus.node) != Some(host) {
return EditOutcome::NoEditableTarget;
}
let doc_order = doc_order_under(dom, host);
let (start, end) = match order_positions_from_walk(&doc_order, anchor, focus) {
Some(pair) => pair,
None => return EditOutcome::NoEditableTarget,
};
if start.node == end.node {
return perform_edit(
dom,
Edit {
node: start.node,
range: start.offset..end.offset,
text: text.to_string(),
},
);
}
let input_type = if text.is_empty() {
rdom_core::InputType::DeleteContentBackward
} else {
rdom_core::InputType::InsertReplacementText
};
let data = if text.is_empty() {
None
} else {
Some(text.to_string())
};
let mut before = TuiEvent::before_input(input_type.clone(), data.clone());
let _ = dom.dispatch_tui_event(host, &mut before);
if before.event.default_prevented() {
return EditOutcome::Prevented;
}
if dom.node(host).has_attribute("readonly") {
return EditOutcome::Prevented;
}
let start_len = match dom.node(start.node).node_value() {
Some(s) => s.len(),
None => return EditOutcome::NoEditableTarget,
};
if dom
.node_mut(start.node)
.edit_text(start.offset.min(start_len), start_len, text)
.is_err()
{
return EditOutcome::Prevented;
}
for node in text_nodes_between_in_walk(&doc_order, start.node, end.node, |n| {
dom.node(n).node_type() == rdom_core::NodeType::Text
}) {
let len = dom.node(node).node_value().map(|s| s.len()).unwrap_or(0);
if len > 0 {
let _ = dom.node_mut(node).edit_text(0, len, "");
}
}
let end_len = dom
.node(end.node)
.node_value()
.map(|s| s.len())
.unwrap_or(0);
if end.offset > 0 {
let _ = dom
.node_mut(end.node)
.edit_text(0, end.offset.min(end_len), "");
}
let caret_offset = start.offset + text.len();
dom.set_selection(Some(Selection::caret(Position::new(
start.node,
caret_offset,
))));
let mut after = TuiEvent::input(input_type, data);
let _ = dom.dispatch_tui_event(host, &mut after);
EditOutcome::Applied
}
fn selection_to_single_node_range(sel: &Selection) -> Result<Option<(NodeId, usize, usize)>, ()> {
if sel.anchor.node != sel.focus.node {
return Ok(None);
}
let (start, end) = if sel.anchor.offset <= sel.focus.offset {
(sel.anchor.offset, sel.focus.offset)
} else {
(sel.focus.offset, sel.anchor.offset)
};
Ok(Some((sel.anchor.node, start, end)))
}
pub fn caret_after(edit: &Edit) -> Position {
Position::new(edit.node, edit.range.start + edit.text.len())
}
pub fn edit_for_selection(dom: &TuiDom, text: String) -> Option<Edit> {
let sel = dom.selection()?;
let (node, start, end) = selection_to_single_node_range(sel).ok()??;
Some(Edit {
node,
range: start..end,
text,
})
}
fn doc_order_under(dom: &TuiDom, root: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = vec![root];
while let Some(id) = stack.pop() {
out.push(id);
let kids: Vec<NodeId> = dom.node(id).child_nodes().map(|c| c.id()).collect();
for child in kids.into_iter().rev() {
stack.push(child);
}
}
out
}
fn order_positions_from_walk(
doc_order: &[NodeId],
a: Position,
b: Position,
) -> Option<(Position, Position)> {
if a.node == b.node {
return Some(if a.offset <= b.offset { (a, b) } else { (b, a) });
}
let pa = doc_order.iter().position(|&n| n == a.node)?;
let pb = doc_order.iter().position(|&n| n == b.node)?;
Some(if pa <= pb { (a, b) } else { (b, a) })
}
fn text_nodes_between_in_walk(
doc_order: &[NodeId],
start: NodeId,
end: NodeId,
mut is_text: impl FnMut(NodeId) -> bool,
) -> Vec<NodeId> {
let mut between = Vec::new();
let mut state = 0u8; for &n in doc_order {
match state {
0 if n == start => state = 1,
1 => {
if n == end {
state = 2;
} else if is_text(n) {
between.push(n);
}
}
_ => {}
}
}
between
}
#[cfg(test)]
mod tests;