use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use rdom_core::{NodeId, Position, Selection};
use crate::TuiDom;
use crate::layout::{Display, Size};
use crate::render::{LayoutExt, Rect};
use crate::runtime::selection::keyboard::try_handle_key;
use crate::style::{CascadeExt, Stylesheet, TuiStyle};
fn prepare(dom: &mut TuiDom, sheet: &Stylesheet, viewport: Rect) {
dom.cascade(sheet);
dom.layout_dom(viewport);
}
fn paragraph(text: &str) -> (TuiDom, NodeId, NodeId) {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p");
let t = dom.create_text_node(text);
dom.append_child(p, t).unwrap();
let span = dom.create_element("span");
dom.append_child(p, span).unwrap();
dom.append_child(root, p).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"p",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(40)),
)
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
prepare(&mut dom, &sheet, Rect::new(0, 0, 60, 10));
(dom, p, t)
}
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
#[test]
fn ctrl_a_with_focused_element_selects_its_text() {
let (mut dom, p, t) = paragraph("hello world");
dom.set_focused(Some(p));
let consumed = try_handle_key(&mut dom, key(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert!(consumed);
let sel = dom.selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 0));
assert_eq!(sel.focus, Position::new(t, "hello world".len()));
}
#[test]
fn ctrl_a_without_focus_selects_whole_document() {
let (mut dom, _p, t) = paragraph("abc");
let consumed = try_handle_key(&mut dom, key(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert!(consumed);
let sel = dom.selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 0));
assert_eq!(sel.focus, Position::new(t, 3));
}
#[test]
fn ctrl_a_uppercase_also_consumed() {
let (mut dom, _p, t) = paragraph("abc");
let consumed = try_handle_key(
&mut dom,
key(
KeyCode::Char('A'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
),
);
assert!(consumed);
let sel = dom.selection().unwrap();
assert_eq!(sel.focus, Position::new(t, 3));
}
#[test]
fn ctrl_a_is_idempotent() {
let (mut dom, _p, _t) = paragraph("abc");
assert!(try_handle_key(
&mut dom,
key(KeyCode::Char('a'), KeyModifiers::CONTROL)
));
let consumed = try_handle_key(&mut dom, key(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert!(!consumed);
}
#[test]
fn ctrl_a_on_empty_document_returns_false() {
let mut dom: TuiDom = TuiDom::new();
prepare(&mut dom, &Stylesheet::bare(), Rect::new(0, 0, 20, 10));
let consumed = try_handle_key(&mut dom, key(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert!(!consumed);
assert!(dom.selection().is_none());
}
#[test]
fn cmd_a_super_modifier_also_consumed() {
let (mut dom, _p, t) = paragraph("abc");
let consumed = try_handle_key(&mut dom, key(KeyCode::Char('a'), KeyModifiers::SUPER));
assert!(consumed);
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 3));
}
#[test]
fn shift_right_extends_focus_forward_one_grapheme() {
let (mut dom, _p, t) = paragraph("hello");
dom.set_selection(Some(Selection::caret(Position::new(t, 2))));
let consumed = try_handle_key(&mut dom, key(KeyCode::Right, KeyModifiers::SHIFT));
assert!(consumed);
let sel = dom.selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 2));
assert_eq!(sel.focus, Position::new(t, 3));
}
#[test]
fn shift_left_extends_focus_backward_one_grapheme() {
let (mut dom, _p, t) = paragraph("hello");
dom.set_selection(Some(Selection::caret(Position::new(t, 3))));
let consumed = try_handle_key(&mut dom, key(KeyCode::Left, KeyModifiers::SHIFT));
assert!(consumed);
let sel = dom.selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 3));
assert_eq!(sel.focus, Position::new(t, 2));
}
#[test]
fn shift_right_on_cjk_skips_full_grapheme() {
let (mut dom, _p, t) = paragraph("中文");
dom.set_selection(Some(Selection::caret(Position::new(t, 0))));
try_handle_key(&mut dom, key(KeyCode::Right, KeyModifiers::SHIFT));
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 3));
}
#[test]
fn shift_right_at_end_of_text_is_noop() {
let (mut dom, _p, t) = paragraph("ab");
dom.set_selection(Some(Selection::caret(Position::new(t, 2))));
let consumed = try_handle_key(&mut dom, key(KeyCode::Right, KeyModifiers::SHIFT));
assert!(!consumed);
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 2));
}
#[test]
fn shift_left_at_start_of_text_is_noop() {
let (mut dom, _p, t) = paragraph("ab");
dom.set_selection(Some(Selection::caret(Position::new(t, 0))));
let consumed = try_handle_key(&mut dom, key(KeyCode::Left, KeyModifiers::SHIFT));
assert!(!consumed);
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 0));
}
#[test]
fn shift_arrow_without_selection_is_noop() {
let (mut dom, _p, _t) = paragraph("hello");
assert!(dom.selection().is_none());
let consumed = try_handle_key(&mut dom, key(KeyCode::Right, KeyModifiers::SHIFT));
assert!(!consumed);
assert!(dom.selection().is_none());
}
#[test]
fn bare_arrow_without_shift_is_not_consumed() {
let (mut dom, _p, t) = paragraph("hello");
dom.set_selection(Some(Selection::caret(Position::new(t, 2))));
let consumed = try_handle_key(&mut dom, key(KeyCode::Right, KeyModifiers::empty()));
assert!(!consumed);
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 2));
}
#[test]
fn shift_right_extends_existing_range_not_collapse() {
let (mut dom, _p, t) = paragraph("hello");
dom.set_selection(Some(Selection::new(
Position::new(t, 1),
Position::new(t, 4),
)));
try_handle_key(&mut dom, key(KeyCode::Right, KeyModifiers::SHIFT));
let sel = dom.selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 1));
assert_eq!(sel.focus, Position::new(t, 5));
}
#[test]
fn shift_ctrl_right_extends_to_next_word_boundary() {
let (mut dom, _p, t) = paragraph("hello world");
dom.set_selection(Some(Selection::caret(Position::new(t, 2))));
try_handle_key(
&mut dom,
key(KeyCode::Right, KeyModifiers::SHIFT | KeyModifiers::CONTROL),
);
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 5));
}
#[test]
fn shift_ctrl_right_from_word_start_goes_to_next_boundary() {
let (mut dom, _p, t) = paragraph("hello world");
dom.set_selection(Some(Selection::caret(Position::new(t, 0))));
try_handle_key(
&mut dom,
key(KeyCode::Right, KeyModifiers::SHIFT | KeyModifiers::CONTROL),
);
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 5));
}
#[test]
fn shift_ctrl_left_goes_to_previous_word_boundary() {
let (mut dom, _p, t) = paragraph("hello world");
dom.set_selection(Some(Selection::caret(Position::new(t, 6))));
try_handle_key(
&mut dom,
key(KeyCode::Left, KeyModifiers::SHIFT | KeyModifiers::CONTROL),
);
assert_eq!(dom.selection().unwrap().focus, Position::new(t, 5));
}
#[test]
fn shift_ctrl_right_at_end_is_noop() {
let (mut dom, _p, t) = paragraph("hi");
dom.set_selection(Some(Selection::caret(Position::new(t, 2))));
let consumed = try_handle_key(
&mut dom,
key(KeyCode::Right, KeyModifiers::SHIFT | KeyModifiers::CONTROL),
);
assert!(!consumed);
}
#[test]
fn shift_ctrl_left_at_start_is_noop() {
let (mut dom, _p, t) = paragraph("hi");
dom.set_selection(Some(Selection::caret(Position::new(t, 0))));
let consumed = try_handle_key(
&mut dom,
key(KeyCode::Left, KeyModifiers::SHIFT | KeyModifiers::CONTROL),
);
assert!(!consumed);
}
#[test]
fn plain_ctrl_b_is_not_consumed() {
let (mut dom, _p, _t) = paragraph("abc");
let consumed = try_handle_key(&mut dom, key(KeyCode::Char('b'), KeyModifiers::CONTROL));
assert!(!consumed);
}
#[test]
fn shift_arrow_inside_user_select_all_does_not_shrink_selection() {
let mut dom: TuiDom = TuiDom::new();
let root = dom.root();
let p = dom.create_element("p");
let t = dom.create_text_node("token");
dom.append_child(p, t).unwrap();
let span = dom.create_element("span");
dom.append_child(p, span).unwrap();
dom.append_child(root, p).unwrap();
let sheet = Stylesheet::bare()
.rule_unchecked(
"p",
TuiStyle::new()
.display(Display::Block)
.width(Size::Fixed(40))
.user_select(crate::layout::UserSelect::All),
)
.rule_unchecked("span", TuiStyle::new().display(Display::Inline));
prepare(&mut dom, &sheet, Rect::new(0, 0, 60, 10));
let full = Selection::new(Position::new(t, 0), Position::new(t, "token".len()));
dom.set_selection(Some(full));
let consumed = try_handle_key(&mut dom, key(KeyCode::Left, KeyModifiers::SHIFT));
assert!(
!consumed,
"Shift+Left inside user-select: all must not be consumed"
);
assert_eq!(
dom.selection().copied(),
Some(full),
"selection must remain at the all-host's full extent"
);
let consumed = try_handle_key(&mut dom, key(KeyCode::Right, KeyModifiers::SHIFT));
assert!(!consumed);
assert_eq!(dom.selection().copied(), Some(full));
}
#[test]
fn shift_up_down_extend_selection_with_line_clamps() {
let (mut dom, _p, t) = paragraph("hello");
dom.set_selection(Some(Selection::caret(Position::new(t, 2))));
let consumed_up = try_handle_key(&mut dom, key(KeyCode::Up, KeyModifiers::SHIFT));
assert!(
consumed_up,
"Shift+Up extends selection focus to line start"
);
let sel = dom.selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 2));
assert_eq!(sel.focus, Position::new(t, 0));
dom.set_selection(Some(Selection::caret(Position::new(t, 2))));
let consumed_down = try_handle_key(&mut dom, key(KeyCode::Down, KeyModifiers::SHIFT));
assert!(consumed_down, "Shift+Down extends to line end");
let sel = dom.selection().unwrap();
assert_eq!(sel.anchor, Position::new(t, 2));
assert_eq!(sel.focus, Position::new(t, 5)); }