use teksilo_core::event::{EventResponse, Key, ScrollMotion, WidgetEvent};
use teksilo_core::widget::EventContext;
use teksilo_text::text_document::{
BlockFormat, ListFormat, MoveMode, MoveOperation, SelectionType, TableCellRef, TextFormat,
};
use teksilo_text::{CursorAffinity, TextDirection};
use crate::common::text_nav::{self, CaretStep, LineStep};
use super::clipboard;
use super::policy::EditCommandKind;
use super::state::{EditorState, SharedState};
use super::sync_cursor_signals;
#[derive(Copy, Clone, PartialEq, Eq)]
pub(super) enum KeyAction {
ClearPreferredX,
LineEdgeMotion,
KeepPreferredX,
PageMotion,
Unhandled,
}
pub(super) fn collapse_selection_before_insert(state: &EditorState) {
if !state
.policy
.command_filter
.collapses_selection_before_insert()
{
return;
}
if !state.cursor.has_selection() {
return;
}
let end = state.cursor.position().max(state.cursor.anchor());
state.cursor.set_position(end, MoveMode::MoveAnchor);
}
pub(super) fn handle_key(
state: &SharedState,
event: &WidgetEvent,
ctx: &mut EventContext,
) -> EventResponse {
if let WidgetEvent::ImeComposition { text, .. } = event {
return handle_ime_composition(state, ctx, text);
}
if let WidgetEvent::ImeCommit { text } = event {
clear_ime_preedit(state);
return push_pending_chars(state, ctx, text);
}
let WidgetEvent::KeyDown {
key,
modifiers,
text,
..
} = event
else {
return EventResponse::Ignored;
};
let shift = modifiers.shift();
let ctrl = modifiers.command();
let caret_motion = text_nav::caret_step(*modifiers);
let line_motion = text_nav::line_step(*modifiers);
let tab_escape = ctrl || modifiers.ctrl();
let mode = if shift {
MoveMode::KeepAnchor
} else {
MoveMode::MoveAnchor
};
let is_select_all = matches!(key, Key::A) && ctrl;
let action: KeyAction = {
let mut st = state.borrow_mut();
if !is_select_all {
st.select_all_level = 0;
st.select_all_anchor_cell = None;
}
let filter = st.policy.command_filter;
match key {
Key::ArrowLeft if filter.accepts(EditCommandKind::MoveLeft) => {
if shift && try_extend_cell_selection(&mut st, -1, 0) {
KeyAction::ClearPreferredX
} else if caret_motion == CaretStep::LineEdge {
let edge = visual_line_edge(&st, VisualDirection::Left);
move_cursor_to_line_edge(&mut st, edge, mode);
KeyAction::LineEdgeMotion
} else {
let op = visual_move(&st, VisualDirection::Left, caret_motion);
st.cursor.move_position(op, mode, 1);
settle_affinity_at_seam(&mut st, VisualDirection::Left);
KeyAction::LineEdgeMotion
}
}
Key::ArrowRight if filter.accepts(EditCommandKind::MoveRight) => {
if shift && try_extend_cell_selection(&mut st, 1, 0) {
KeyAction::ClearPreferredX
} else if caret_motion == CaretStep::LineEdge {
let edge = visual_line_edge(&st, VisualDirection::Right);
move_cursor_to_line_edge(&mut st, edge, mode);
KeyAction::LineEdgeMotion
} else {
let op = visual_move(&st, VisualDirection::Right, caret_motion);
st.cursor.move_position(op, mode, 1);
settle_affinity_at_seam(&mut st, VisualDirection::Right);
KeyAction::LineEdgeMotion
}
}
Key::ArrowUp if filter.accepts(EditCommandKind::MoveUp) => {
if shift && try_extend_cell_selection(&mut st, 0, -1) {
KeyAction::ClearPreferredX
} else {
match vertical_op(line_motion, false) {
Some(op) => {
st.cursor.move_position(op, mode, 1);
KeyAction::ClearPreferredX
}
None => {
move_cursor_vertical(&mut st, -1, mode);
KeyAction::KeepPreferredX
}
}
}
}
Key::ArrowDown if filter.accepts(EditCommandKind::MoveDown) => {
if shift && try_extend_cell_selection(&mut st, 0, 1) {
KeyAction::ClearPreferredX
} else {
match vertical_op(line_motion, true) {
Some(op) => {
st.cursor.move_position(op, mode, 1);
KeyAction::ClearPreferredX
}
None => {
move_cursor_vertical(&mut st, 1, mode);
KeyAction::KeepPreferredX
}
}
}
}
Key::PageUp if filter.accepts(EditCommandKind::PageUp) => {
move_cursor_page(&mut st, -1, mode);
KeyAction::PageMotion
}
Key::PageDown if filter.accepts(EditCommandKind::PageDown) => {
move_cursor_page(&mut st, 1, mode);
KeyAction::PageMotion
}
Key::Home if filter.accepts(EditCommandKind::MoveHome) => {
if ctrl {
st.cursor.move_position(MoveOperation::Start, mode, 1);
KeyAction::ClearPreferredX
} else {
move_cursor_to_line_edge(&mut st, LineEdge::Start, mode);
KeyAction::LineEdgeMotion
}
}
Key::End if filter.accepts(EditCommandKind::MoveEnd) => {
if ctrl {
st.cursor.move_position(MoveOperation::End, mode, 1);
KeyAction::ClearPreferredX
} else {
move_cursor_to_line_edge(&mut st, LineEdge::End, mode);
KeyAction::LineEdgeMotion
}
}
Key::A if ctrl && filter.accepts(EditCommandKind::SelectAll) => {
apply_select_all_ladder(&mut st);
st.preferred_x = None;
KeyAction::KeepPreferredX
}
Key::C if ctrl && filter.accepts(EditCommandKind::Copy) => {
clipboard::copy(&mut st, ctx);
KeyAction::ClearPreferredX
}
Key::X if ctrl && filter.accepts(EditCommandKind::Cut) => {
clipboard::cut(&mut st, ctx);
KeyAction::ClearPreferredX
}
Key::V if ctrl && shift && filter.accepts(EditCommandKind::PasteUnformatted) => {
clipboard::paste_unformatted(&mut st, ctx);
KeyAction::ClearPreferredX
}
Key::V if ctrl && !shift && filter.accepts(EditCommandKind::Paste) => {
clipboard::paste(&mut st, ctx);
KeyAction::ClearPreferredX
}
Key::Backspace if filter.accepts(EditCommandKind::DeletePrev) => {
if text_nav::deletes_word(*modifiers) {
if !st.cursor.has_selection() {
st.cursor
.move_position(MoveOperation::WordLeft, MoveMode::KeepAnchor, 1);
}
let _ = st.cursor.remove_selected_text();
} else if st.cursor.has_selection() {
let _ = st.cursor.remove_selected_text();
} else if st.cursor.at_block_start()
&& is_cursor_in_list(&st)
&& filter.accepts(EditCommandKind::ExitList)
{
if let Ok(fmt) = st.cursor.block_format() {
let level = fmt.indent.unwrap_or(0);
if level > 0 {
let new_fmt = BlockFormat {
indent: Some(level - 1),
..Default::default()
};
let _ = st.cursor.set_block_format(&new_fmt);
} else {
let _ = st.cursor.remove_current_block_from_list();
}
}
} else if st.cursor.at_block_start()
&& st.cursor.is_in_blockquote()
&& st.cursor.is_first_block_in_current_frame()
&& filter.accepts(EditCommandKind::ExitFrame)
{
let _ = st.cursor.unwrap_current_block_from_blockquote();
} else {
let _ = st.cursor.delete_previous_char();
}
KeyAction::ClearPreferredX
}
Key::Delete if filter.accepts(EditCommandKind::DeleteNext) => {
if text_nav::deletes_word(*modifiers) {
if !st.cursor.has_selection() {
st.cursor
.move_position(MoveOperation::WordRight, MoveMode::KeepAnchor, 1);
}
let _ = st.cursor.remove_selected_text();
} else if st.cursor.has_selection() {
let _ = st.cursor.remove_selected_text();
} else if st.cursor.at_block_end()
&& st.cursor.is_in_blockquote()
&& st.cursor.is_last_block_in_current_frame()
&& filter.accepts(EditCommandKind::ExitFrame)
{
let _ = st.cursor.unwrap_current_block_from_blockquote();
} else {
let _ = st.cursor.delete_char();
}
KeyAction::ClearPreferredX
}
Key::Enter if ctrl && filter.accepts(EditCommandKind::InsertBlockForced) => {
collapse_selection_before_insert(&st);
let _ = st.cursor.insert_block();
KeyAction::ClearPreferredX
}
Key::Enter if !ctrl && filter.accepts(EditCommandKind::InsertBlock) => {
let cell_info = st
.cursor
.current_table_cell()
.map(|c| (c.table.id(), c.row, c.column, c.table.rows()));
if let Some((table_id, row, col, rows)) = cell_info
&& filter.accepts(EditCommandKind::NavigateTableCellDown)
{
navigate_table_cell_down(&mut st, table_id, row, col, rows);
} else if st.cursor.is_in_blockquote()
&& st.cursor.current_block_is_empty()
&& filter.accepts(EditCommandKind::ExitFrame)
{
let _ = st.cursor.unwrap_current_block_from_blockquote();
} else {
collapse_selection_before_insert(&st);
let _ = st.cursor.insert_block();
}
KeyAction::ClearPreferredX
}
Key::Tab
if !tab_escape && shift && filter.accepts(EditCommandKind::NavigateTableCell) =>
{
let cell_info = st.cursor.current_table_cell().map(|c| {
(
c.table.id(),
c.row,
c.column,
c.table.rows(),
c.table.columns(),
)
});
if let Some((table_id, row, col, rows, cols)) = cell_info {
navigate_table_cell(&mut st, table_id, row, col, rows, cols, -1);
} else if is_cursor_in_list(&st) {
dedent_current_block(&mut st);
} else if st.cursor.is_in_blockquote()
&& filter.accepts(EditCommandKind::DecreaseBlockquoteDepth)
{
let _ = st.cursor.decrease_blockquote_depth();
}
KeyAction::ClearPreferredX
}
Key::Tab
if !tab_escape && !shift && filter.accepts(EditCommandKind::NavigateTableCell) =>
{
let cell_info = st.cursor.current_table_cell().map(|c| {
(
c.table.id(),
c.row,
c.column,
c.table.rows(),
c.table.columns(),
)
});
if let Some((table_id, row, col, rows, cols)) = cell_info {
navigate_table_cell(&mut st, table_id, row, col, rows, cols, 1);
} else if is_cursor_in_list(&st) {
indent_current_block(&mut st);
} else if st.cursor.is_in_blockquote()
&& filter.accepts(EditCommandKind::IncreaseBlockquoteDepth)
{
let _ = st.cursor.increase_blockquote_depth();
} else if filter.accepts(EditCommandKind::InsertTab) {
collapse_selection_before_insert(&st);
let _ = st.cursor.insert_text("\t");
}
KeyAction::ClearPreferredX
}
Key::B if ctrl && filter.accepts(EditCommandKind::ToggleBold) => {
toggle_char_format(&mut st, FormatBit::Bold);
KeyAction::ClearPreferredX
}
Key::I if ctrl && filter.accepts(EditCommandKind::ToggleItalic) => {
toggle_char_format(&mut st, FormatBit::Italic);
KeyAction::ClearPreferredX
}
Key::U if ctrl && filter.accepts(EditCommandKind::ToggleUnderline) => {
toggle_char_format(&mut st, FormatBit::Underline);
KeyAction::ClearPreferredX
}
Key::Z if ctrl && !shift && filter.accepts(EditCommandKind::Undo) => {
let _ = st.document.undo();
KeyAction::ClearPreferredX
}
Key::Y if ctrl && filter.accepts(EditCommandKind::Redo) => {
let _ = st.document.redo();
KeyAction::ClearPreferredX
}
Key::Z if ctrl && shift && filter.accepts(EditCommandKind::Redo) => {
let _ = st.document.redo();
KeyAction::ClearPreferredX
}
_ => {
if ctrl {
KeyAction::Unhandled
} else if let Some(t) = text.as_deref() {
if filter.accepts(EditCommandKind::InsertChar) {
let clean: String = t.chars().filter(|c| !c.is_control()).collect();
if !clean.is_empty() {
st.pending_typed_chars += clean.chars().count();
st.pending_chars.push_str(&clean);
KeyAction::ClearPreferredX
} else {
KeyAction::Unhandled
}
} else {
KeyAction::Unhandled
}
} else {
KeyAction::Unhandled
}
}
}
};
match action {
KeyAction::Unhandled => EventResponse::Ignored,
KeyAction::ClearPreferredX => {
{
let mut st = state.borrow_mut();
st.preferred_x = None;
st.cursor_affinity = CursorAffinity::Downstream;
}
caret_moved_epilogue(state, ctx)
}
KeyAction::LineEdgeMotion => {
{
let mut st = state.borrow_mut();
st.preferred_x = None;
}
caret_moved_epilogue(state, ctx)
}
KeyAction::KeepPreferredX => {
caret_moved_epilogue(state, ctx)
}
KeyAction::PageMotion => {
caret_moved_epilogue_with(state, ctx, ScrollMotion::Smooth)
}
}
}
fn caret_moved_epilogue(state: &SharedState, ctx: &mut EventContext) -> EventResponse {
caret_moved_epilogue_with(state, ctx, ScrollMotion::Instant)
}
fn caret_moved_epilogue_with(
state: &SharedState,
ctx: &mut EventContext,
motion: ScrollMotion,
) -> EventResponse {
state.borrow_mut().mouse_anchored = false;
ensure_caret_visible(state);
sync_cursor_signals(state);
report_ime_cursor_area(state, ctx);
chase_caret_into_view_with(state, ctx, motion);
ctx.request_frame();
EventResponse::Handled
}
pub(super) fn caret_window_rect(st: &EditorState) -> Option<teksilo_canvas::Rect> {
if !st.has_focus || !st.engine.has_full_layout() {
return None;
}
let caret = st
.engine
.caret_rect(st.cursor.position(), st.cursor_affinity);
Some(teksilo_canvas::Rect::new(
st.viewport_origin.x + caret[0] - st.scroll_x.get(),
st.viewport_origin.y + caret[1] + vertical_scroll_slip(st),
caret[2].max(1.0),
caret[3],
))
}
fn vertical_scroll_slip(st: &EditorState) -> f32 {
(st.engine.scroll_offset() - st.scroll_y.get()) * st.engine.zoom()
}
pub(super) fn range_content_rect(
st: &EditorState,
start: usize,
end: usize,
) -> Option<teksilo_canvas::Rect> {
if !st.engine.has_full_layout() {
return None;
}
let a = st
.engine
.caret_rect_content(start, CursorAffinity::Downstream);
let b = st
.engine
.caret_rect_content(end, CursorAffinity::Downstream);
let (ay, ah) = (a[1], a[3].max(1.0));
let (by, bh) = (b[1], b[3].max(1.0));
let x0 = a[0].min(b[0]);
let x1 = a[0].max(b[0]);
let y0 = ay.min(by);
let y1 = (ay + ah).max(by + bh);
Some(teksilo_canvas::Rect::new(
x0,
y0,
(x1 - x0).max(1.0),
(y1 - y0).max(ah.max(bh)),
))
}
pub(super) fn range_window_rect(
st: &EditorState,
start: usize,
end: usize,
) -> Option<teksilo_canvas::Rect> {
if !st.engine.has_full_layout() {
return None;
}
let slip = vertical_scroll_slip(st);
let to_window = |c: [f32; 4]| {
(
st.viewport_origin.x + c[0] - st.scroll_x.get(),
st.viewport_origin.y + c[1] + slip,
c[3].max(1.0),
)
};
let (ax, ay, ah) = to_window(st.engine.caret_rect(start, CursorAffinity::Downstream));
let (bx, by, bh) = to_window(st.engine.caret_rect(end, CursorAffinity::Downstream));
let x0 = ax.min(bx);
let y0 = ay.min(by);
let x1 = ax.max(bx);
let y1 = (ay + ah).max(by + bh);
Some(teksilo_canvas::Rect::new(
x0,
y0,
(x1 - x0).max(1.0),
(y1 - y0).max(ah.max(bh)),
))
}
pub(super) fn report_ime_cursor_area(state: &SharedState, ctx: &mut EventContext) {
let area = {
let st = state.borrow();
match caret_window_rect(&st) {
Some(a) => a,
None => return,
}
};
{
let mut st = state.borrow_mut();
if st.last_ime_area == Some(area) {
return;
}
st.last_ime_area = Some(area);
}
ctx.set_ime_cursor_area(area);
}
pub(super) fn chase_caret_into_view(state: &SharedState, ctx: &mut EventContext) {
chase_caret_into_view_with(state, ctx, ScrollMotion::Instant);
}
pub(super) fn chase_caret_into_view_with(
state: &SharedState,
ctx: &mut EventContext,
motion: ScrollMotion,
) {
let (area, pin) = {
let mut st = state.borrow_mut();
if !st.follow_caret_in_page {
return;
}
let pin = match st.typewriter {
Some(f)
if !st.mouse_anchored
&& !matches!(st.drag_state, super::state::DragState::Selecting { .. }) =>
{
Some(f)
}
_ => None,
};
let pos = st.cursor.position();
let rect = match caret_window_rect(&st) {
Some(a) => a,
None => return,
};
let same_pos = st.last_chase_pos == Some(pos);
if pin.is_some() {
let same_y = st
.last_chase_y
.is_some_and(|y| (y - rect.y).abs() <= f32::EPSILON);
if same_pos && same_y {
return;
}
st.last_chase_y = Some(rect.y);
} else {
if same_pos {
return;
}
st.last_chase_y = None;
}
st.last_chase_pos = Some(pos);
(rect, pin)
};
match pin {
Some(fraction) => ctx.ensure_visible_aligned(area, fraction, motion),
None => {
let line = area.height.max(1.0);
let area = teksilo_canvas::Rect::new(area.x, area.y, area.width, area.height + line);
ctx.ensure_visible(area);
}
}
}
pub(super) fn apply_select_all_ladder(st: &mut EditorState) {
use super::state::SelectAllAnchorCell;
let next_level = st.select_all_level + 1;
let cell_info: Option<SelectAllAnchorCell> = if next_level == 1 {
st.cursor.current_table_cell().map(|c| SelectAllAnchorCell {
table_id: c.table.id(),
row: c.row,
column: c.column,
table_rows: c.table.rows(),
table_columns: c.table.columns(),
})
} else {
st.select_all_anchor_cell
};
let Some(cell) = cell_info else {
st.cursor.select(SelectionType::Document);
st.select_all_level = 0;
st.select_all_anchor_cell = None;
return;
};
if next_level == 1 {
st.select_all_anchor_cell = Some(cell);
}
match next_level {
1 => st.cursor.select(SelectionType::BlockUnderCursor),
2 => st
.cursor
.select_table_cell(cell.table_id, cell.row, cell.column),
3 => {
st.cursor.select_cell_range(
cell.table_id,
0,
0,
cell.table_rows.saturating_sub(1),
cell.table_columns.saturating_sub(1),
);
}
_ => {
st.cursor.select(SelectionType::Document);
}
}
st.select_all_level = if next_level >= 4 { 0 } else { next_level };
if st.select_all_level == 0 {
st.select_all_anchor_cell = None;
}
}
#[derive(Copy, Clone)]
enum FormatBit {
Bold,
Italic,
Underline,
}
fn toggle_char_format(st: &mut EditorState, bit: FormatBit) {
let probe = st.document.cursor();
if st.cursor.has_selection() {
let start = st.cursor.selection_start();
probe.set_position(start, MoveMode::MoveAnchor);
} else {
probe.set_position(st.cursor.position(), MoveMode::MoveAnchor);
}
let current = probe.char_format().unwrap_or_default();
let new_value = !match bit {
FormatBit::Bold => current.font_bold.unwrap_or(false),
FormatBit::Italic => current.font_italic.unwrap_or(false),
FormatBit::Underline => current.font_underline.unwrap_or(false),
};
let fmt = match bit {
FormatBit::Bold => TextFormat {
font_bold: Some(new_value),
..Default::default()
},
FormatBit::Italic => TextFormat {
font_italic: Some(new_value),
..Default::default()
},
FormatBit::Underline => TextFormat {
font_underline: Some(new_value),
..Default::default()
},
};
let _ = st.cursor.merge_char_format(&fmt);
st.pending_format_changed = true;
}
fn handle_ime_composition(
state: &SharedState,
ctx: &mut EventContext,
text: &str,
) -> EventResponse {
let filter = state.borrow().policy.command_filter;
if !filter.accepts(EditCommandKind::InsertChar) {
return EventResponse::Ignored;
}
let clean: String = text.chars().filter(|c| !c.is_control()).collect();
if clean.is_empty() && state.borrow().ime_preedit_range.is_none() {
return EventResponse::Ignored;
}
{
let mut st = state.borrow_mut();
st.cursor.begin_edit_block();
if let Some(range) = st.ime_preedit_range.take() {
let doc_end = st.document.character_count();
let start = range.start.min(doc_end);
let end = range.end.min(doc_end);
if start < end {
st.cursor.set_position(start, MoveMode::MoveAnchor);
st.cursor.set_position(end, MoveMode::KeepAnchor);
let _ = st.cursor.remove_selected_text();
}
}
if !clean.is_empty() {
collapse_selection_before_insert(&st);
let start = st.cursor.position();
let _ = st.cursor.insert_text(&clean);
let end = st.cursor.position();
st.ime_preedit = Some(clean);
st.ime_preedit_range = Some(start..end);
} else {
st.ime_preedit = None;
}
st.cursor.end_edit_block();
st.preferred_x = None;
st.cursor_affinity = CursorAffinity::Downstream;
st.pending_text_changed = true;
}
report_ime_cursor_area(state, ctx);
chase_caret_into_view(state, ctx);
ctx.request_frame();
EventResponse::Handled
}
pub(super) fn clear_ime_preedit(state: &SharedState) {
let mut st = state.borrow_mut();
if let Some(range) = st.ime_preedit_range.take() {
let doc_end = st.document.character_count();
let start = range.start.min(doc_end);
let end = range.end.min(doc_end);
if start < end {
st.cursor.set_position(start, MoveMode::MoveAnchor);
st.cursor.set_position(end, MoveMode::KeepAnchor);
let _ = st.cursor.remove_selected_text();
}
}
st.ime_preedit = None;
}
fn push_pending_chars(state: &SharedState, ctx: &mut EventContext, text: &str) -> EventResponse {
if text.is_empty() {
return EventResponse::Ignored;
}
let filter = state.borrow().policy.command_filter;
if !filter.accepts(EditCommandKind::InsertChar) {
return EventResponse::Ignored;
}
let clean: String = text.chars().filter(|c| !c.is_control()).collect();
if clean.is_empty() {
return EventResponse::Ignored;
}
{
let mut st = state.borrow_mut();
st.pending_ime_chars += clean.chars().count();
st.pending_chars.push_str(&clean);
st.preferred_x = None;
st.cursor_affinity = CursorAffinity::Downstream;
}
report_ime_cursor_area(state, ctx);
chase_caret_into_view(state, ctx);
ctx.request_frame();
EventResponse::Handled
}
fn ensure_caret_visible(state: &SharedState) {
let mut st = state.borrow_mut();
if !st.engine.has_full_layout() {
return;
}
let current = st.scroll_y.get();
st.engine.set_scroll_offset(current);
let pos = st.cursor.position();
let affinity = st.cursor_affinity;
if let Some(new_off) = st.engine.ensure_position_visible(pos, affinity) {
st.scroll_y.set(new_off);
}
ensure_caret_h_visible_locked(&mut st);
}
fn ensure_caret_h_visible_locked(st: &mut EditorState) {
if !st.engine.has_full_layout() {
return;
}
let max_x = st.max_scroll_x.get();
if max_x <= 0.0 {
return;
}
let viewport_w = st.viewport_width;
if viewport_w <= 0.0 {
return;
}
let pos = st.cursor.position();
let caret = st.engine.caret_rect(pos, st.cursor_affinity);
let caret_x = caret[0];
let current_x = st.scroll_x.get();
let margin = 20.0_f32;
let screen_x = caret_x - current_x;
let new_x = if screen_x < margin {
(caret_x - margin).max(0.0)
} else if screen_x > viewport_w - margin {
(caret_x - viewport_w + margin).clamp(0.0, max_x)
} else {
return;
};
st.scroll_x.set(new_x.clamp(0.0, max_x));
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum LineEdge {
Start,
End,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum VisualDirection {
Left,
Right,
}
fn visual_move(st: &EditorState, pressed: VisualDirection, step: CaretStep) -> MoveOperation {
horizontal_op(moves_forward(st, pressed), step)
}
fn horizontal_op(forward: bool, step: CaretStep) -> MoveOperation {
match (forward, step) {
(true, CaretStep::Word) => MoveOperation::WordRight,
(false, CaretStep::Word) => MoveOperation::WordLeft,
(true, _) => MoveOperation::Right,
(false, _) => MoveOperation::Left,
}
}
fn vertical_op(step: LineStep, downward: bool) -> Option<MoveOperation> {
match (step, downward) {
(LineStep::Document, false) => Some(MoveOperation::Start),
(LineStep::Document, true) => Some(MoveOperation::End),
(LineStep::Paragraph, false) => Some(MoveOperation::StartOfBlock),
(LineStep::Paragraph, true) => Some(MoveOperation::EndOfBlock),
(LineStep::Line, _) => None,
}
}
fn visual_line_edge(st: &EditorState, pressed: VisualDirection) -> LineEdge {
line_edge_op(moves_forward(st, pressed))
}
fn line_edge_op(forward: bool) -> LineEdge {
if forward {
LineEdge::End
} else {
LineEdge::Start
}
}
fn moves_forward(st: &EditorState, pressed: VisualDirection) -> bool {
let rtl = st.engine.direction_at(st.cursor.position()) == TextDirection::RightToLeft;
match pressed {
VisualDirection::Right => !rtl,
VisualDirection::Left => rtl,
}
}
fn settle_affinity_at_seam(st: &mut EditorState, pressed: VisualDirection) {
let pos = st.cursor.position();
if !st.engine.is_direction_boundary_at(pos) {
st.cursor_affinity = CursorAffinity::Downstream;
return;
}
st.cursor_affinity = match pressed {
VisualDirection::Right => CursorAffinity::Downstream,
VisualDirection::Left => CursorAffinity::Upstream,
};
}
fn move_cursor_to_line_edge(st: &mut EditorState, edge: LineEdge, mode: MoveMode) {
if !st.engine.has_full_layout() {
return;
}
let pos = st.cursor.position();
let Some((line_start, line_end)) = st.engine.visual_line_range_at(pos, st.cursor_affinity)
else {
return;
};
let target = match edge {
LineEdge::Start => line_start,
LineEdge::End => line_end,
};
if target != pos {
st.cursor.set_position(target, mode);
}
let starts_a_wrap_continuation = |st: &EditorState, at: usize| {
matches!(
st.engine.visual_line_range_at(at, CursorAffinity::Downstream),
Some((_, end)) if end == at
)
};
st.cursor_affinity = match edge {
LineEdge::Start if starts_a_wrap_continuation(st, target) => CursorAffinity::Upstream,
LineEdge::Start | LineEdge::End => CursorAffinity::Downstream,
};
}
fn neighbour_line_center_y(
st: &EditorState,
pos: usize,
direction: i32,
caret: [f32; 4],
) -> Option<f32> {
let fallback = || {
let center_y = caret[1] + caret[3] * 0.5;
Some(center_y + (direction as f32) * caret[3].max(16.0))
};
let Some((start, end)) = st.engine.visual_line_range_at(pos, st.cursor_affinity) else {
return fallback();
};
let candidates: [usize; 2] = if direction > 0 {
[end, end.saturating_add(1)]
} else if start == 0 {
return None;
} else {
[start - 1, start.saturating_sub(2)]
};
let affinity = if direction > 0 {
CursorAffinity::Upstream
} else {
CursorAffinity::Downstream
};
for probe in candidates {
let neighbour = st.engine.caret_rect(probe, affinity);
if neighbour[3] <= 0.0 {
continue;
}
let crossed = if direction > 0 {
neighbour[1] > caret[1]
} else {
neighbour[1] < caret[1]
};
if crossed {
return Some(neighbour[1] + neighbour[3] * 0.5);
}
}
fallback()
}
fn move_cursor_vertical(st: &mut EditorState, direction: i32, mode: MoveMode) {
if !st.engine.has_full_layout() {
return;
}
let pos = st.cursor.position();
let caret = st.engine.caret_rect(pos, st.cursor_affinity);
let x = st.preferred_x.unwrap_or(caret[0]);
if st.preferred_x.is_none() {
st.preferred_x = Some(caret[0]);
}
let Some(target_y) = neighbour_line_center_y(st, pos, direction, caret) else {
return;
};
let (content_top, content_bottom) = st.engine.content_band_screen();
if target_y < content_top || target_y > content_bottom {
return;
}
if let Some(hit) = st.engine.hit_test(x, target_y) {
if hit.position != pos {
st.cursor.set_position(hit.position, mode);
}
st.cursor_affinity = hit.affinity;
}
}
fn move_cursor_page(st: &mut EditorState, direction: i32, mode: MoveMode) {
if !st.engine.has_full_layout() {
return;
}
let viewport_h = st.viewport_height;
if viewport_h <= 0.0 {
return;
}
let pos = st.cursor.position();
let caret = st.engine.caret_rect(pos, st.cursor_affinity);
let line_height = caret[3].max(16.0);
let center_y = caret[1] + caret[3] * 0.5;
let x = st.preferred_x.unwrap_or(caret[0]);
if st.preferred_x.is_none() {
st.preferred_x = Some(caret[0]);
}
let page_step = (viewport_h - line_height).max(line_height);
let target_y =
(center_y + (direction as f32) * page_step).clamp(0.0, st.engine.content_height());
if let Some(hit) = st.engine.hit_test(x, target_y) {
if hit.position != pos {
st.cursor.set_position(hit.position, mode);
}
st.cursor_affinity = hit.affinity;
}
let new_scroll =
(st.scroll_y.get() + (direction as f32) * page_step).clamp(0.0, st.max_scroll_y.get());
st.scroll_y.set(new_scroll);
}
fn is_cursor_in_list(st: &EditorState) -> bool {
let pos = st.cursor.position();
st.document
.block_at_position(pos)
.and_then(|b| b.list())
.is_some()
}
fn nest_current_list_item(st: &mut EditorState, target_indent: u8) {
let Some(list) = st.cursor.current_list() else {
return;
};
let style = list.style();
st.cursor.begin_edit_block();
let _ = st.cursor.remove_current_block_from_list();
let _ = st.cursor.create_list(style);
let _ = st.cursor.set_current_list_format(&ListFormat {
indent: Some(target_indent),
..Default::default()
});
st.cursor.end_edit_block();
}
pub(super) fn indent_current_block(st: &mut EditorState) {
let Some(list) = st.cursor.current_list() else {
return;
};
let level = list.indent();
let target = level.saturating_add(1);
if target == level {
return;
}
nest_current_list_item(st, target);
}
pub(super) fn dedent_current_block(st: &mut EditorState) {
let Some(list) = st.cursor.current_list() else {
return;
};
let level = list.indent();
if level == 0 {
return;
}
nest_current_list_item(st, level - 1);
}
fn navigate_table_cell(
st: &mut EditorState,
table_id: usize,
row: usize,
col: usize,
rows: usize,
cols: usize,
direction: i32,
) {
let (target_row, target_col) = if direction > 0 {
if col + 1 < cols {
(row, col + 1)
} else if row + 1 < rows {
(row + 1, 0)
} else {
let _ = st.cursor.insert_row_below();
(row + 1, 0)
}
} else if col > 0 {
(row, col - 1)
} else if row > 0 {
(row - 1, cols.saturating_sub(1))
} else {
return; };
move_cursor_to_cell_first_block(st, table_id, target_row, target_col);
}
fn navigate_table_cell_down(
st: &mut EditorState,
table_id: usize,
row: usize,
col: usize,
rows: usize,
) {
if row + 1 < rows {
move_cursor_to_cell_first_block(st, table_id, row + 1, col);
} else {
move_cursor_after_table(st, table_id);
}
}
fn move_cursor_to_cell_first_block(st: &mut EditorState, table_id: usize, row: usize, col: usize) {
if let Some(table) = find_table_by_id(st, table_id)
&& let Some(cell) = table.cell(row, col)
&& let Some(block) = cell.blocks().first()
{
st.cursor
.set_position(block.position(), MoveMode::MoveAnchor);
}
}
fn move_cursor_after_table(st: &mut EditorState, table_id: usize) {
use teksilo_text::text_document::FlowElement;
let flow = st.document.flow();
let mut found = false;
for element in &flow {
if found {
match element {
FlowElement::Block(block) => {
st.cursor
.set_position(block.position(), MoveMode::MoveAnchor);
return;
}
FlowElement::Table(t) => {
if let Some(cell) = t.cell(0, 0)
&& let Some(block) = cell.blocks().first()
{
st.cursor
.set_position(block.position(), MoveMode::MoveAnchor);
return;
}
}
_ => {}
}
}
if let FlowElement::Table(t) = element
&& t.id() == table_id
{
found = true;
}
}
}
fn find_table_by_id(
st: &EditorState,
table_id: usize,
) -> Option<teksilo_text::text_document::TextTable> {
use teksilo_text::text_document::FlowElement;
for element in st.document.flow() {
if let FlowElement::Table(t) = element
&& t.id() == table_id
{
return Some(t);
}
}
None
}
pub(super) fn try_extend_cell_selection(st: &mut EditorState, dcol: i32, drow: i32) -> bool {
use teksilo_text::text_document::SelectionKind;
if let SelectionKind::Cells(range)
| SelectionKind::Mixed {
cell_range: range, ..
} = st.cursor.selection_kind()
{
let cells = st.cursor.selected_cells();
let Some(first) = cells.first() else {
return false;
};
let rows = first.table.rows();
let cols = first.table.columns();
if rows == 0 || cols == 0 {
return false;
}
let new_end_row = (range.end_row as i32 + drow).clamp(0, rows as i32 - 1) as usize;
let new_end_col = (range.end_col as i32 + dcol).clamp(0, cols as i32 - 1) as usize;
st.cursor.select_cell_range(
range.table_id,
range.start_row,
range.start_col,
new_end_row,
new_end_col,
);
return true;
}
let Some(cell_ref) = st.cursor.current_table_cell() else {
return false;
};
let TableCellRef { table, row, column } = cell_ref;
let at_start = st.cursor.at_block_start();
let at_end = st.cursor.at_block_end();
let should_activate = match (dcol, drow) {
(-1, 0) => at_start && column > 0,
(1, 0) => at_end && column + 1 < table.columns(),
(0, -1) => at_start && row > 0,
(0, 1) => at_end && row + 1 < table.rows(),
_ => false,
};
if !should_activate {
return false;
}
let table_id = table.id();
let target_row = (row as i32 + drow).max(0) as usize;
let target_col = (column as i32 + dcol).max(0) as usize;
st.cursor.select_cell_range(
table_id,
row.min(target_row),
column.min(target_col),
row.max(target_row),
column.max(target_col),
);
true
}
#[cfg(test)]
mod motion_tests {
use super::*;
#[test]
fn a_word_step_walks_words_and_a_character_step_walks_characters() {
assert_eq!(
horizontal_op(true, CaretStep::Word),
MoveOperation::WordRight
);
assert_eq!(
horizontal_op(false, CaretStep::Word),
MoveOperation::WordLeft
);
assert_eq!(
horizontal_op(true, CaretStep::Character),
MoveOperation::Right
);
assert_eq!(
horizontal_op(false, CaretStep::Character),
MoveOperation::Left
);
}
#[test]
fn the_line_edge_follows_the_direction_pressed() {
assert_eq!(line_edge_op(false), LineEdge::Start);
assert_eq!(line_edge_op(true), LineEdge::End);
}
#[test]
fn a_line_edge_step_never_becomes_a_stepping_primitive() {
assert_eq!(
horizontal_op(true, CaretStep::LineEdge),
MoveOperation::Right
);
assert_eq!(
horizontal_op(false, CaretStep::LineEdge),
MoveOperation::Left
);
}
#[test]
fn vertical_document_and_paragraph_steps_reach_their_edges() {
assert_eq!(
vertical_op(LineStep::Document, false),
Some(MoveOperation::Start)
);
assert_eq!(
vertical_op(LineStep::Document, true),
Some(MoveOperation::End)
);
assert_eq!(
vertical_op(LineStep::Paragraph, false),
Some(MoveOperation::StartOfBlock)
);
assert_eq!(
vertical_op(LineStep::Paragraph, true),
Some(MoveOperation::EndOfBlock)
);
}
#[test]
fn an_ordinary_line_step_is_left_to_the_typesetter() {
assert_eq!(vertical_op(LineStep::Line, false), None);
assert_eq!(vertical_op(LineStep::Line, true), None);
}
}