use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rdom_core::{NodeId, Position, Selection};
use crate::TuiDom;
use crate::render::inline::cell_of_position;
use crate::runtime::editing::perform::{Edit, EditOutcome, perform_edit_as};
use crate::runtime::hit_test::HitTestExt;
use crate::runtime::selection::keyboard::{
next_grapheme_byte, next_word_byte, prev_grapheme_byte, prev_word_byte,
};
pub(crate) fn try_handle_movement_key(dom: &mut TuiDom, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::SUPER);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
if shift {
return false;
}
match key.code {
KeyCode::Up => move_vertical(dom, -1),
KeyCode::Down => move_vertical(dom, 1),
KeyCode::Backspace => {
clear_focused_sticky_x(dom);
delete_back(dom)
}
KeyCode::Delete => {
clear_focused_sticky_x(dom);
delete_forward(dom)
}
KeyCode::Left if ctrl => {
clear_focused_sticky_x(dom);
move_caret(dom, caret_word_left)
}
KeyCode::Right if ctrl => {
clear_focused_sticky_x(dom);
move_caret(dom, caret_word_right)
}
KeyCode::Left => {
clear_focused_sticky_x(dom);
move_caret_left_collapse_or_grapheme(dom)
}
KeyCode::Right => {
clear_focused_sticky_x(dom);
move_caret_right_collapse_or_grapheme(dom)
}
KeyCode::Home if ctrl => {
clear_focused_sticky_x(dom);
move_caret(dom, caret_doc_start)
}
KeyCode::End if ctrl => {
clear_focused_sticky_x(dom);
move_caret(dom, caret_doc_end)
}
KeyCode::Home => {
clear_focused_sticky_x(dom);
move_caret(dom, caret_line_start)
}
KeyCode::End => {
clear_focused_sticky_x(dom);
move_caret(dom, caret_line_end)
}
_ => false,
}
}
pub(crate) fn clear_focused_sticky_x(dom: &mut TuiDom) {
let Some(focused) = dom.focused() else { return };
let Some(editable) = crate::node::nearest_editable_ancestor(dom, focused) else {
return;
};
let mut node = dom.node_mut(editable);
let Some(ext) = node.ext_mut() else { return };
if let Some(state) = ext.editor_state.as_mut() {
state.clear_sticky_x();
}
}
fn move_vertical(dom: &mut TuiDom, delta_y: i32) -> bool {
let Some(sel) = dom.selection().copied() else {
return false;
};
let from = sel.focus;
let new_pos = vertical_motion(dom, from, delta_y);
if let Some(to) = new_pos
&& to != from
{
dom.set_selection(Some(Selection::caret(to)));
crate::runtime::scrollbar::reveal_caret(dom);
}
true
}
pub(crate) fn vertical_motion(dom: &mut TuiDom, from: Position, delta_y: i32) -> Option<Position> {
use crate::render::inline::inline_flow_for_text;
let from_flow = inline_flow_for_text(dom, from.node)?;
let (current_x, current_y) = cell_of_position(dom, from)?;
let editable = crate::node::nearest_editable_ancestor(dom, from.node);
let stored_sticky = editable.and_then(|id| {
dom.node(id)
.ext()
.and_then(|e| e.editor_state.as_ref())
.and_then(|s| s.sticky_x())
});
let target_x = stored_sticky.unwrap_or(current_x);
let target_y_i32 = current_y as i32 + delta_y;
let new_pos = compute_vertical_target(dom, from_flow, target_x, target_y_i32, delta_y);
if let Some(id) = editable
&& let Some(ext) = dom.node_mut(id).ext_mut()
{
let state = ext.editor_state.get_or_insert_with(|| {
Box::new(crate::runtime::editing::editor_state::EditorState::new())
});
state.set_sticky_x(target_x);
}
new_pos
}
pub(crate) fn line_edge_position(dom: &TuiDom, from: Position, forward: bool) -> Option<Position> {
let flow = crate::render::inline::inline_flow_for_text(dom, from.node)?;
let (_, y) = cell_of_position(dom, from)?;
let (layout, content) = crate::render::inline::inline_flow_layout(dom, flow)?;
let line_idx = (y as i32 - content.y) as usize;
let target_line = layout.lines.get(line_idx)?;
if forward {
target_line
.fragments
.last()
.map(|f| Position::new(f.text_node, f.source_byte_offset + f.text.len()))
} else {
target_line
.fragments
.first()
.map(|f| Position::new(f.text_node, f.source_byte_offset))
}
}
fn compute_vertical_target(
dom: &TuiDom,
from_flow: crate::render::inline::InlineFlow,
target_x: u16,
target_y_i32: i32,
delta_y: i32,
) -> Option<Position> {
use crate::render::inline::{inline_flow_for_text, inline_flow_layout};
let (layout, content) = inline_flow_layout(dom, from_flow)?;
if target_y_i32 < content.y {
if delta_y < 0
&& let Some(first_line) = layout.lines.first()
&& let Some(first_frag) = first_line.fragments.first()
{
return Some(Position {
node: first_frag.text_node,
offset: first_frag.source_byte_offset,
});
}
return None;
}
let target_line_idx = (target_y_i32 - content.y) as usize;
if target_line_idx >= layout.lines.len() {
if delta_y > 0
&& let Some(last_line) = layout.lines.last()
&& let Some(last_frag) = last_line.fragments.last()
{
return Some(Position {
node: last_frag.text_node,
offset: last_frag.source_byte_offset + last_frag.text.len(),
});
}
return None;
}
let target_y = target_y_i32 as u16;
if let Some(pos) = dom.position_at(target_x, target_y)
&& inline_flow_for_text(dom, pos.node) == Some(from_flow)
{
return Some(pos);
}
let target_line = &layout.lines[target_line_idx];
if let Some(last_frag) = target_line.fragments.last() {
return Some(Position {
node: last_frag.text_node,
offset: last_frag.source_byte_offset + last_frag.text.len(),
});
}
None
}
fn delete_back(dom: &mut TuiDom) -> bool {
let Some(sel) = dom.selection().copied() else {
return false;
};
let edit = if !sel.is_collapsed() {
let Some((node, start, end)) = ordered_range(&sel) else {
return false;
};
Edit {
node,
range: start..end,
text: String::new(),
}
} else {
let node = sel.focus.node;
let text = match dom.node(node).node_value() {
Some(s) => s.to_string(),
None => return false,
};
let offset = sel.focus.offset.min(text.len());
let Some(prev) = prev_grapheme_byte(&text, offset) else {
return true;
};
Edit {
node,
range: prev..offset,
text: String::new(),
}
};
apply_edit(dom, edit, rdom_core::InputType::DeleteContentBackward)
}
fn delete_forward(dom: &mut TuiDom) -> bool {
let Some(sel) = dom.selection().copied() else {
return false;
};
let edit = if !sel.is_collapsed() {
let Some((node, start, end)) = ordered_range(&sel) else {
return false;
};
Edit {
node,
range: start..end,
text: String::new(),
}
} else {
let node = sel.focus.node;
let text = match dom.node(node).node_value() {
Some(s) => s.to_string(),
None => return false,
};
let offset = sel.focus.offset.min(text.len());
let Some(next) = next_grapheme_byte(&text, offset) else {
return true;
};
Edit {
node,
range: offset..next,
text: String::new(),
}
};
apply_edit(dom, edit, rdom_core::InputType::DeleteContentForward)
}
fn apply_edit(dom: &mut TuiDom, edit: Edit, input_type: rdom_core::InputType) -> bool {
matches!(
perform_edit_as(dom, edit, input_type),
EditOutcome::Applied | EditOutcome::Prevented
)
}
fn move_caret_left_collapse_or_grapheme(dom: &mut TuiDom) -> bool {
let Some(sel) = dom.selection().copied() else {
return false;
};
if !sel.is_collapsed() {
let (start, _) = ordered_positions(&sel);
dom.set_selection(Some(Selection::caret(start)));
crate::runtime::scrollbar::reveal_caret(dom);
return true;
}
move_caret(dom, caret_grapheme_left)
}
fn move_caret_right_collapse_or_grapheme(dom: &mut TuiDom) -> bool {
let Some(sel) = dom.selection().copied() else {
return false;
};
if !sel.is_collapsed() {
let (_, end) = ordered_positions(&sel);
dom.set_selection(Some(Selection::caret(end)));
crate::runtime::scrollbar::reveal_caret(dom);
return true;
}
move_caret(dom, caret_grapheme_right)
}
fn move_caret<F>(dom: &mut TuiDom, compute: F) -> bool
where
F: FnOnce(&TuiDom, Position) -> Option<Position>,
{
let Some(sel) = dom.selection().copied() else {
return false;
};
let from = sel.focus;
if let Some(to) = compute(dom, from)
&& to != from
{
dom.set_selection(Some(Selection::caret(to)));
crate::runtime::scrollbar::reveal_caret(dom);
}
true
}
fn caret_grapheme_left(dom: &TuiDom, from: Position) -> Option<Position> {
let text = dom.node(from.node).node_value()?;
let offset = from.offset.min(text.len());
let new = prev_grapheme_byte(text, offset)?;
Some(Position::new(from.node, new))
}
fn caret_grapheme_right(dom: &TuiDom, from: Position) -> Option<Position> {
let text = dom.node(from.node).node_value()?;
let offset = from.offset.min(text.len());
let new = next_grapheme_byte(text, offset)?;
Some(Position::new(from.node, new))
}
fn caret_word_left(dom: &TuiDom, from: Position) -> Option<Position> {
let text = dom.node(from.node).node_value()?;
let offset = from.offset.min(text.len());
let new = prev_word_byte(text, offset)?;
Some(Position::new(from.node, new))
}
fn caret_word_right(dom: &TuiDom, from: Position) -> Option<Position> {
let text = dom.node(from.node).node_value()?;
let offset = from.offset.min(text.len());
let new = next_word_byte(text, offset)?;
Some(Position::new(from.node, new))
}
fn caret_doc_start(dom: &TuiDom, from: Position) -> Option<Position> {
let _ = dom; Some(Position::new(from.node, 0))
}
fn caret_doc_end(dom: &TuiDom, from: Position) -> Option<Position> {
let text = dom.node(from.node).node_value()?;
Some(Position::new(from.node, text.len()))
}
fn caret_line_start(dom: &TuiDom, from: Position) -> Option<Position> {
let flow = crate::render::inline::inline_flow_for_text(dom, from.node)?;
let (_, y) = cell_of_position(dom, from)?;
let (layout, content) = crate::render::inline::inline_flow_layout(dom, flow)?;
let line_idx = (y as i32 - content.y) as usize;
let target_line = layout.lines.get(line_idx)?;
if let Some(first_frag) = target_line.fragments.first() {
Some(Position::new(
first_frag.text_node,
first_frag.source_byte_offset,
))
} else {
Some(from)
}
}
fn caret_line_end(dom: &TuiDom, from: Position) -> Option<Position> {
let flow = crate::render::inline::inline_flow_for_text(dom, from.node)?;
let (_, y) = cell_of_position(dom, from)?;
let (layout, content) = crate::render::inline::inline_flow_layout(dom, flow)?;
let line_idx = (y as i32 - content.y) as usize;
let target_line = layout.lines.get(line_idx)?;
if let Some(last_frag) = target_line.fragments.last() {
Some(Position::new(
last_frag.text_node,
last_frag.source_byte_offset + last_frag.text.len(),
))
} else {
Some(from)
}
}
fn ordered_range(sel: &Selection) -> Option<(NodeId, usize, usize)> {
if sel.anchor.node != sel.focus.node {
return None;
}
let (start, end) = if sel.anchor.offset <= sel.focus.offset {
(sel.anchor.offset, sel.focus.offset)
} else {
(sel.focus.offset, sel.anchor.offset)
};
Some((sel.anchor.node, start, end))
}
fn ordered_positions(sel: &Selection) -> (Position, Position) {
if sel.anchor.node == sel.focus.node && sel.anchor.offset <= sel.focus.offset {
(sel.anchor, sel.focus)
} else if sel.anchor.node == sel.focus.node {
(sel.focus, sel.anchor)
} else {
(sel.anchor, sel.focus)
}
}
#[cfg(test)]
mod tests;