use teksilo_core::event::{EventResponse, Key, WidgetEvent};
use teksilo_core::widget::EventContext;
use teksilo_text::text_document::{MoveMode, MoveOperation, SelectionType};
use super::clipboard;
use super::completion::{self, Trigger};
use super::policy::CodeCommand;
use super::semantics;
use super::state::{CodeEditorState, SharedState};
use super::sync_cursor_signals;
use crate::common::editor_runtime::CaretPolicy;
use crate::common::text_nav::{CaretStep, LineStep, caret_step, deletes_word, line_step};
#[derive(Copy, Clone, PartialEq, Eq)]
enum KeyAction {
ClearPreferredX,
KeepPreferredX,
Unhandled,
}
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 alt = modifiers.alt();
let mode = if shift {
MoveMode::KeepAnchor
} else {
MoveMode::MoveAnchor
};
if (ctrl || modifiers.ctrl()) && matches!(key, Key::Space) {
completion::react(state, ctx, Trigger::Forced);
return EventResponse::Handled;
}
if state.borrow().completion.is_open() {
match key {
Key::ArrowDown if !ctrl && !alt => {
completion::move_selection(state, 1);
ctx.request_frame();
return EventResponse::Handled;
}
Key::ArrowUp if !ctrl && !alt => {
completion::move_selection(state, -1);
ctx.request_frame();
return EventResponse::Handled;
}
Key::PageDown => {
completion::move_selection(state, 5);
ctx.request_frame();
return EventResponse::Handled;
}
Key::PageUp => {
completion::move_selection(state, -5);
ctx.request_frame();
return EventResponse::Handled;
}
Key::Enter | Key::Tab if !shift && !ctrl && !modifiers.ctrl() => {
completion::accept_selected(state, ctx);
return EventResponse::Handled;
}
Key::Escape => {
completion::dismiss_suppress(state, ctx);
return EventResponse::Handled;
}
_ => {}
}
}
let action = {
let mut st = state.borrow_mut();
let filter = st.policy.command_filter;
match key {
Key::ArrowUp if ctrl && alt && filter.accepts(CodeCommand::AddCaretAbove) => {
semantics::add_caret_above(&mut st);
KeyAction::KeepPreferredX
}
Key::ArrowDown if ctrl && alt && filter.accepts(CodeCommand::AddCaretBelow) => {
semantics::add_caret_below(&mut st);
KeyAction::KeepPreferredX
}
Key::ArrowUp if alt && filter.accepts(CodeCommand::MoveLineUp) => {
semantics::move_lines(&mut st, semantics::MoveDir::Up);
KeyAction::ClearPreferredX
}
Key::ArrowDown if alt && filter.accepts(CodeCommand::MoveLineDown) => {
semantics::move_lines(&mut st, semantics::MoveDir::Down);
KeyAction::ClearPreferredX
}
Key::Tab if !shift && filter.accepts(CodeCommand::IndentLines) => {
semantics::indent_or_tab(&mut st);
KeyAction::ClearPreferredX
}
Key::Tab if shift && filter.accepts(CodeCommand::DedentLines) => {
semantics::dedent(&mut st);
KeyAction::ClearPreferredX
}
Key::Character('/') if ctrl && filter.accepts(CodeCommand::ToggleLineComment) => {
semantics::toggle_line_comment(&mut st);
KeyAction::ClearPreferredX
}
Key::D if ctrl && !shift && filter.accepts(CodeCommand::DuplicateSelection) => {
semantics::duplicate(&mut st);
KeyAction::ClearPreferredX
}
Key::C if ctrl && filter.accepts(CodeCommand::Copy) => {
clipboard::copy(&st, ctx);
KeyAction::ClearPreferredX
}
Key::X if ctrl && filter.accepts(CodeCommand::Cut) => {
clipboard::cut(&mut st, ctx);
KeyAction::ClearPreferredX
}
Key::V if ctrl && filter.accepts(CodeCommand::Paste) => {
clipboard::paste(&mut st, ctx);
KeyAction::ClearPreferredX
}
Key::ArrowLeft if filter.accepts(horizontal_command(caret_step(*modifiers), false)) => {
match caret_step(*modifiers) {
CaretStep::LineEdge => smart_home(&mut st, mode),
CaretStep::Word => move_every_caret(&mut st, MoveOperation::WordLeft, mode),
CaretStep::Character => move_every_caret(&mut st, MoveOperation::Left, mode),
}
KeyAction::ClearPreferredX
}
Key::ArrowRight if filter.accepts(horizontal_command(caret_step(*modifiers), true)) => {
let op = match caret_step(*modifiers) {
CaretStep::LineEdge => MoveOperation::EndOfLine,
CaretStep::Word => MoveOperation::WordRight,
CaretStep::Character => MoveOperation::Right,
};
move_every_caret(&mut st, op, mode);
KeyAction::ClearPreferredX
}
Key::ArrowUp
if matches!(line_step(*modifiers), LineStep::Document)
&& filter.accepts(CodeCommand::MoveDocStart) =>
{
st.clear_extra_carets();
st.cursor.move_position(MoveOperation::Start, mode, 1);
KeyAction::ClearPreferredX
}
Key::ArrowDown
if matches!(line_step(*modifiers), LineStep::Document)
&& filter.accepts(CodeCommand::MoveDocEnd) =>
{
st.clear_extra_carets();
st.cursor.move_position(MoveOperation::End, mode, 1);
KeyAction::ClearPreferredX
}
Key::ArrowUp if filter.accepts(CodeCommand::MoveUp) => {
move_every_caret(&mut st, MoveOperation::Up, mode);
KeyAction::KeepPreferredX
}
Key::ArrowDown if filter.accepts(CodeCommand::MoveDown) => {
move_every_caret(&mut st, MoveOperation::Down, mode);
KeyAction::KeepPreferredX
}
Key::PageUp if filter.accepts(CodeCommand::PageUp) => {
move_page(&mut st, -1, mode);
KeyAction::KeepPreferredX
}
Key::PageDown if filter.accepts(CodeCommand::PageDown) => {
move_page(&mut st, 1, mode);
KeyAction::KeepPreferredX
}
Key::Home if ctrl && filter.accepts(CodeCommand::MoveDocStart) => {
st.clear_extra_carets();
st.cursor.move_position(MoveOperation::Start, mode, 1);
KeyAction::ClearPreferredX
}
Key::Home if filter.accepts(CodeCommand::MoveLineStart) => {
smart_home(&mut st, mode);
KeyAction::ClearPreferredX
}
Key::End if ctrl && filter.accepts(CodeCommand::MoveDocEnd) => {
st.clear_extra_carets();
st.cursor.move_position(MoveOperation::End, mode, 1);
KeyAction::ClearPreferredX
}
Key::End if filter.accepts(CodeCommand::MoveLineEnd) => {
move_every_caret(&mut st, MoveOperation::EndOfLine, mode);
KeyAction::ClearPreferredX
}
Key::A if ctrl && filter.accepts(CodeCommand::SelectAll) => {
st.clear_extra_carets();
st.cursor.select(SelectionType::Document);
KeyAction::ClearPreferredX
}
Key::Escape => {
if st.clear_extra_carets() {
KeyAction::ClearPreferredX
} else {
KeyAction::Unhandled
}
}
Key::Backspace if filter.accepts(CodeCommand::DeletePrev) => {
let word = deletes_word(*modifiers);
if !word && semantics::try_pair_backspace(&mut st) {
KeyAction::ClearPreferredX
} else {
delete_at_every_caret(&mut st, Direction::Backward, word);
KeyAction::ClearPreferredX
}
}
Key::Delete if filter.accepts(CodeCommand::DeleteNext) => {
delete_at_every_caret(&mut st, Direction::Forward, deletes_word(*modifiers));
KeyAction::ClearPreferredX
}
Key::Enter if filter.accepts(CodeCommand::InsertNewline) => {
semantics::newline(&mut st);
KeyAction::ClearPreferredX
}
Key::Z if ctrl && !shift && filter.accepts(CodeCommand::Undo) => {
st.clear_extra_carets();
let _ = st.document.undo();
KeyAction::ClearPreferredX
}
Key::Y if ctrl && filter.accepts(CodeCommand::Redo) => {
st.clear_extra_carets();
let _ = st.document.redo();
KeyAction::ClearPreferredX
}
Key::Z if ctrl && shift && filter.accepts(CodeCommand::Redo) => {
st.clear_extra_carets();
let _ = st.document.redo();
KeyAction::ClearPreferredX
}
_ => {
if ctrl {
KeyAction::Unhandled
} else if let Some(t) = text.as_deref() {
if filter.accepts(CodeCommand::InsertChar) {
let clean: String = t.chars().filter(|c| !c.is_control()).collect();
if clean.is_empty() {
KeyAction::Unhandled
} else if semantics::wants_bracket_handling(&st, &clean) {
let ch = clean.chars().next().expect("non-empty");
semantics::type_bracket_char(&mut st, ch);
KeyAction::ClearPreferredX
} else {
st.pending_chars.push_str(&clean);
KeyAction::ClearPreferredX
}
} else {
KeyAction::Unhandled
}
} else {
KeyAction::Unhandled
}
}
}
};
let response = match action {
KeyAction::Unhandled => EventResponse::Ignored,
KeyAction::ClearPreferredX => {
state.borrow_mut().preferred_x = None;
caret_moved(state, ctx)
}
KeyAction::KeepPreferredX => caret_moved(state, ctx),
};
if action != KeyAction::Unhandled
&& let Some(trigger) = completion_trigger(key, ctrl)
{
completion::react(state, ctx, trigger);
}
response
}
fn completion_trigger(key: &Key, ctrl: bool) -> Option<Trigger> {
Some(match key {
Key::Backspace | Key::Delete | Key::Space => Trigger::Edited,
Key::ArrowLeft
| Key::ArrowRight
| Key::Home
| Key::End
| Key::ArrowUp
| Key::ArrowDown
| Key::PageUp
| Key::PageDown => Trigger::Moved,
_ if ctrl => Trigger::Edited,
_ if key.to_char().is_some() => Trigger::Typed,
_ => return None,
})
}
fn horizontal_command(step: CaretStep, forward: bool) -> CodeCommand {
match (step, forward) {
(CaretStep::Character, false) => CodeCommand::MoveLeft,
(CaretStep::Character, true) => CodeCommand::MoveRight,
(CaretStep::Word, false) => CodeCommand::MoveWordLeft,
(CaretStep::Word, true) => CodeCommand::MoveWordRight,
(CaretStep::LineEdge, false) => CodeCommand::MoveLineStart,
(CaretStep::LineEdge, true) => CodeCommand::MoveLineEnd,
}
}
fn caret_moved(state: &SharedState, ctx: &mut EventContext) -> EventResponse {
ensure_caret_visible(state);
sync_cursor_signals(state);
report_ime_cursor_area(state, ctx);
ctx.request_frame();
EventResponse::Handled
}
fn move_every_caret(st: &mut CodeEditorState, op: MoveOperation, mode: MoveMode) {
st.cursor.move_position(op, mode, 1);
for c in &mut st.extra_carets {
c.move_position(op, mode, 1);
}
st.merge_collided_carets();
}
fn smart_home(st: &mut CodeEditorState, mode: MoveMode) {
let primary_target = home_target(st, st.cursor.position());
st.cursor.set_position(primary_target, mode);
for i in 0..st.extra_carets.len() {
let pos = st.extra_carets[i].position();
let t = home_target(st, pos);
st.extra_carets[i].set_position(t, mode);
}
st.merge_collided_carets();
}
fn home_target(st: &CodeEditorState, pos: usize) -> usize {
let Some(block) = st
.document
.snapshot_block_at_position_without_highlights(pos)
else {
return pos;
};
let line_start = block.position;
let indent_col = leading_whitespace_columns(&block.text);
let indent_pos = line_start + indent_col;
if pos == indent_pos {
line_start
} else {
indent_pos
}
}
fn leading_whitespace_columns(line: &str) -> usize {
let indent = line.chars().take_while(|c| c.is_whitespace()).count();
if indent == line.chars().count() {
0
} else {
indent
}
}
fn move_page(st: &mut CodeEditorState, direction: i32, mode: MoveMode) {
let line_h = st.engine.default_line_height().max(1.0);
let lines = ((st.viewport_height / line_h).floor() as i32 - 1).max(1);
let op = if direction < 0 {
MoveOperation::Up
} else {
MoveOperation::Down
};
st.cursor.move_position(op, mode, lines as usize);
for c in &mut st.extra_carets {
c.move_position(op, mode, lines as usize);
}
st.merge_collided_carets();
}
#[derive(Copy, Clone, PartialEq, Eq)]
enum Direction {
Backward,
Forward,
}
fn delete_at_every_caret(st: &mut CodeEditorState, dir: Direction, by_word: bool) {
let multi = !st.extra_carets.is_empty();
if multi {
st.cursor.begin_edit_block();
}
let mut order: Vec<usize> = (0..=st.extra_carets.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(caret_pos(st, i)));
for i in order {
delete_one(st, i, dir, by_word);
}
if multi {
st.cursor.end_edit_block();
}
st.merge_collided_carets();
}
fn delete_one(st: &mut CodeEditorState, i: usize, dir: Direction, by_word: bool) {
let c = caret_at(st, i);
if by_word && !c.has_selection() {
let op = match dir {
Direction::Backward => MoveOperation::WordLeft,
Direction::Forward => MoveOperation::WordRight,
};
c.move_position(op, MoveMode::KeepAnchor, 1);
}
if c.has_selection() {
let _ = c.remove_selected_text();
return;
}
match dir {
Direction::Backward => {
let _ = c.delete_previous_char();
}
Direction::Forward => {
let _ = c.delete_char();
}
}
}
fn caret_pos(st: &CodeEditorState, i: usize) -> usize {
if i == 0 {
st.cursor.position()
} else {
st.extra_carets[i - 1].position()
}
}
fn caret_at(st: &mut CodeEditorState, i: usize) -> &mut teksilo_text::text_document::TextCursor {
if i == 0 {
&mut st.cursor
} else {
&mut st.extra_carets[i - 1]
}
}
pub(super) 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);
if let Some(new_off) = st.engine.ensure_caret_visible() {
st.scroll_y.set_if_changed(new_off);
}
}
fn handle_ime_composition(
state: &SharedState,
ctx: &mut EventContext,
text: &str,
) -> EventResponse {
let filter = state.borrow().policy.command_filter;
if !filter.accepts(CodeCommand::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.clear_extra_carets();
st.cursor.begin_edit_block();
remove_preedit(&mut st);
if clean.is_empty() {
st.ime_preedit = None;
} else {
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);
}
st.cursor.end_edit_block();
st.preferred_x = None;
st.cursor_affinity = teksilo_text::CursorAffinity::Downstream;
st.pending_text_changed = true;
}
report_ime_cursor_area(state, ctx);
ctx.request_frame();
EventResponse::Handled
}
fn remove_preedit(st: &mut CodeEditorState) {
let Some(range) = st.ime_preedit_range.take() else {
return;
};
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();
}
}
pub(super) fn clear_ime_preedit(state: &SharedState) {
let mut st = state.borrow_mut();
remove_preedit(&mut st);
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(CodeCommand::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_chars.push_str(&clean);
st.preferred_x = None;
st.cursor_affinity = teksilo_text::CursorAffinity::Downstream;
}
report_ime_cursor_area(state, ctx);
ctx.request_frame();
EventResponse::Handled
}
pub(super) fn report_ime_cursor_area(state: &SharedState, ctx: &mut EventContext) {
let rect = {
let st = state.borrow();
if !st.has_focus
|| st.policy.is_read_only()
|| matches!(st.policy.caret_policy, CaretPolicy::Hidden)
|| !st.engine.has_full_layout()
{
return;
}
let c = st
.engine
.caret_rect(st.cursor.position(), st.cursor_affinity);
teksilo_canvas::Rect::new(
st.viewport_origin.x + c[0] - st.scroll_x.get(),
st.viewport_origin.y + c[1] - st.scroll_y.get(),
c[2].max(1.0),
c[3],
)
};
let changed = {
let st = state.borrow();
st.last_ime_area != Some(rect)
};
if !changed {
return;
}
state.borrow_mut().last_ime_area = Some(rect);
ctx.set_ime_cursor_area(rect);
}
pub(super) fn window_rect_at(st: &CodeEditorState, pos: usize) -> Option<teksilo_canvas::Rect> {
if !st.engine.has_full_layout() {
return None;
}
let c = st.engine.caret_rect(pos, st.cursor_affinity);
Some(teksilo_canvas::Rect::new(
st.viewport_origin.x + c[0] - st.scroll_x.get(),
st.viewport_origin.y + c[1] - st.scroll_y.get(),
c[2].max(1.0),
c[3],
))
}
#[cfg(test)]
pub(super) fn smart_home_for_test(state: &SharedState) {
let mut st = state.borrow_mut();
smart_home(&mut st, MoveMode::MoveAnchor);
}
#[cfg(test)]
mod motion_tests {
use super::*;
#[test]
fn every_caret_step_maps_to_its_own_command() {
assert_eq!(
horizontal_command(CaretStep::Character, false),
CodeCommand::MoveLeft
);
assert_eq!(
horizontal_command(CaretStep::Character, true),
CodeCommand::MoveRight
);
assert_eq!(
horizontal_command(CaretStep::Word, false),
CodeCommand::MoveWordLeft
);
assert_eq!(
horizontal_command(CaretStep::Word, true),
CodeCommand::MoveWordRight
);
assert_eq!(
horizontal_command(CaretStep::LineEdge, false),
CodeCommand::MoveLineStart
);
assert_eq!(
horizontal_command(CaretStep::LineEdge, true),
CodeCommand::MoveLineEnd
);
}
#[test]
fn the_command_a_step_reports_is_the_one_that_runs() {
for forward in [false, true] {
assert_ne!(
horizontal_command(CaretStep::Word, forward),
horizontal_command(CaretStep::Character, forward),
"a word motion must not be filtered as a character motion"
);
assert_ne!(
horizontal_command(CaretStep::LineEdge, forward),
horizontal_command(CaretStep::Character, forward),
);
}
}
}