cranpose-ui 0.1.164

UI primitives for Cranpose
Documentation
use std::{cell::RefCell, sync::Arc};

use cranpose_core::{DefaultScheduler, Runtime};

use super::*;
use crate::{
    key_event::{KeyCode, KeyEvent, KeyEventType, Modifiers},
    text_field_focus,
};

fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
    let _runtime = Runtime::new(Arc::new(DefaultScheduler));
    f()
}

fn key_down(key_code: KeyCode, text: &str) -> KeyEvent {
    KeyEvent::new(key_code, text, Modifiers::NONE, KeyEventType::KeyDown)
}

fn focused_state(
    initial: &str,
    line_limits: TextFieldLineLimits,
) -> (TextFieldState, Rc<RefCell<bool>>) {
    let state = TextFieldState::new(initial);
    let handler = TextFieldHandler::new(
        state,
        None,
        line_limits,
        CaretGeometryRefs {
            node_origin: Rc::new(Cell::new(Point { x: 0.0, y: 0.0 })),
            content_offset: Rc::new(Cell::new(0.0)),
            content_y_offset: Rc::new(Cell::new(0.0)),
            scroll_offset: Rc::new(Cell::new(0.0)),
            style: TextStyle::default(),
        },
    );
    let focus = Rc::new(RefCell::new(false));
    text_field_focus::request_focus(focus.clone(), handler, 0);
    (state, focus)
}

#[test]
fn caret_geometry_exposed_for_single_visual_line() {
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (_state, focus) = focused_state(
            "abc",
            TextFieldLineLimits::MultiLine {
                min_lines: 1,
                max_lines: 3,
            },
        );
        let geom = text_field_focus::focused_caret_geometry().expect("caret geometry");
        assert_eq!(geom.caret_xs.len(), "abc".chars().count() + 1);
        drop(focus);
    });

    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (_state, focus) = focused_state(
            "a\nb",
            TextFieldLineLimits::MultiLine {
                min_lines: 1,
                max_lines: 3,
            },
        );
        assert!(text_field_focus::focused_caret_geometry().is_none());
        drop(focus);
    });
}

#[test]
fn android_style_key_events_commit_text() {
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state(
            "",
            TextFieldLineLimits::MultiLine {
                min_lines: 1,
                max_lines: usize::MAX,
            },
        );

        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::H,
            "h"
        )));
        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::I,
            "i"
        )));
        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::Unknown,
            "\u{00f6}"
        )));
        assert_eq!(state.text(), "hi\u{00f6}");

        let key_up = KeyEvent::new(KeyCode::H, "h", Modifiers::NONE, KeyEventType::KeyUp);
        assert!(!text_field_focus::dispatch_key_event(&key_up));
        assert_eq!(state.text(), "hi\u{00f6}");

        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::Backspace,
            ""
        )));
        assert_eq!(state.text(), "hi");

        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::Enter,
            ""
        )));
        assert_eq!(state.text(), "hi\n");

        text_field_focus::clear_focus();
    });
}

#[test]
fn select_all_dispatch_selects_entire_field() {
    use cranpose_foundation::text::TextRange;
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("hello world", TextFieldLineLimits::SingleLine);
        state.edit(cranpose_foundation::text::TextFieldBuffer::place_cursor_at_end);

        assert!(text_field_focus::dispatch_select_all());
        assert_eq!(state.selection(), TextRange::new(0, "hello world".len()));

        text_field_focus::clear_focus();
        assert!(!text_field_focus::dispatch_select_all());
    });
}

#[test]
fn clipboard_fallback_round_trips() {
    use crate::clipboard_session::{clipboard_read_text, clipboard_write_text};
    let _app_context = crate::render_state::app_context_test_scope();
    assert_eq!(clipboard_read_text(), None);
    clipboard_write_text("copied text");
    assert_eq!(clipboard_read_text(), Some("copied text".to_string()));
}

#[test]
fn spacebar_swipe_arrow_keys_scrub_caret() {
    use cranpose_foundation::text::TextRange;
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("hello", TextFieldLineLimits::SingleLine);
        state.edit(cranpose_foundation::text::TextFieldBuffer::place_cursor_at_end);
        assert_eq!(state.selection(), TextRange::new(5, 5));

        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::ArrowLeft,
            ""
        )));
        assert_eq!(state.selection(), TextRange::new(4, 4));
        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::ArrowLeft,
            ""
        )));
        assert_eq!(state.selection(), TextRange::new(3, 3));

        assert!(text_field_focus::dispatch_key_event(&key_down(
            KeyCode::ArrowRight,
            ""
        )));
        assert_eq!(state.selection(), TextRange::new(4, 4));

        assert_eq!(state.text(), "hello");
        text_field_focus::clear_focus();
    });
}

#[test]
fn cursor_move_via_set_selection_resets_blink_to_solid() {
    use crate::cursor_animation::{BLINK_INTERVAL_MS, is_cursor_visible, start_cursor_blink};
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("hello world", TextFieldLineLimits::SingleLine);
        state.edit(cranpose_foundation::text::TextFieldBuffer::place_cursor_at_end);

        start_cursor_blink();
        let hidden_at =
            web_time::Instant::now() + std::time::Duration::from_millis(BLINK_INTERVAL_MS + 1);
        crate::render_state::with_cursor_animation(|state| state.tick(hidden_at));
        assert!(!is_cursor_visible(), "caret should be hidden mid-blink");

        assert!(text_field_focus::dispatch_ime_set_selection(2, 2));
        assert!(
            is_cursor_visible(),
            "a cursor-move must reset the blink phase to solid (visible)"
        );

        text_field_focus::clear_focus();
    });
}

#[test]
fn ime_set_selection_scrubs_caret_without_editing() {
    use cranpose_foundation::text::TextRange;
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("hello world", TextFieldLineLimits::SingleLine);
        state.edit(cranpose_foundation::text::TextFieldBuffer::place_cursor_at_end);

        assert!(text_field_focus::dispatch_ime_set_selection(2, 2));
        assert_eq!(state.selection(), TextRange::new(2, 2));
        assert_eq!(state.text(), "hello world");

        assert!(text_field_focus::dispatch_ime_set_selection(0, 5));
        assert_eq!(state.selection(), TextRange::new(0, 5));

        text_field_focus::clear_focus();
        assert!(!text_field_focus::dispatch_ime_set_selection(1, 1));
    });
}

#[test]
fn successive_preedit_updates_replace_previous_composition() {
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("ab", TextFieldLineLimits::SingleLine);
        state.edit(cranpose_foundation::text::TextFieldBuffer::place_cursor_at_end);

        assert!(text_field_focus::dispatch_ime_preedit("k", Some((1, 1))));
        assert_eq!(state.text(), "abk");
        assert!(text_field_focus::dispatch_ime_preedit("ka", Some((2, 2))));
        assert_eq!(state.text(), "abka");
        assert!(text_field_focus::dispatch_ime_preedit(
            "\u{304b}",
            Some((3, 3))
        ));
        assert_eq!(state.text(), "ab\u{304b}");
        let comp = state.composition().expect("composition active");
        assert_eq!((comp.min(), comp.max()), (2, 2 + "\u{304b}".len()));

        assert!(text_field_focus::dispatch_ime_preedit("", None));
        assert_eq!(state.text(), "ab");
        assert!(text_field_focus::dispatch_paste("\u{304b}\u{306a}"));
        assert_eq!(state.text(), "ab\u{304b}\u{306a}");
        assert_eq!(state.composition(), None);

        text_field_focus::clear_focus();
    });
}

#[test]
fn finish_composition_keeps_text() {
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("", TextFieldLineLimits::SingleLine);

        assert!(text_field_focus::dispatch_ime_preedit("hello", None));
        assert_eq!(state.text(), "hello");
        assert!(state.composition().is_some());

        assert!(text_field_focus::dispatch_ime_finish_composing());
        assert_eq!(state.text(), "hello");
        assert_eq!(state.composition(), None);

        assert!(text_field_focus::dispatch_ime_finish_composing());
        assert_eq!(state.text(), "hello");

        text_field_focus::clear_focus();
    });
}

#[test]
fn set_composing_region_marks_text_without_changing_it() {
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("hello world", TextFieldLineLimits::SingleLine);

        assert!(text_field_focus::dispatch_ime_set_composing_region(0, 5));
        assert_eq!(state.text(), "hello world");
        let comp = state.composition().expect("composition active");
        assert_eq!((comp.min(), comp.max()), (0, 5));

        assert!(text_field_focus::dispatch_ime_preedit("Hello", None));
        assert_eq!(state.text(), "Hello world");

        assert!(text_field_focus::dispatch_ime_preedit("", None));
        state.edit(|buffer| {
            buffer.clear();
            buffer.insert("\u{00e9}x");
        });
        assert!(text_field_focus::dispatch_ime_set_composing_region(1, 3));
        let comp = state.composition().expect("composition active");
        assert_eq!((comp.min(), comp.max()), (0, 3));

        assert!(text_field_focus::dispatch_ime_set_composing_region(1, 1));
        assert_eq!(state.composition(), None);
        assert_eq!(state.text(), "\u{00e9}x");

        text_field_focus::clear_focus();
    });
}

#[test]
fn editor_state_reflects_text_selection_and_composition() {
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("hi", TextFieldLineLimits::SingleLine);
        state.edit(cranpose_foundation::text::TextFieldBuffer::place_cursor_at_end);

        let snapshot = text_field_focus::focused_editor_state().expect("focused field");
        assert_eq!(snapshot.text, "hi");
        assert_eq!(
            (snapshot.selection_start, snapshot.selection_end),
            (2usize, 2usize)
        );
        assert_eq!(snapshot.composition, None);
        assert!(snapshot.single_line);

        assert!(text_field_focus::dispatch_ime_preedit("ab", None));
        let snapshot = text_field_focus::focused_editor_state().expect("focused field");
        assert_eq!(snapshot.text, "hiab");
        assert_eq!(snapshot.composition, Some((2, 4)));

        text_field_focus::clear_focus();
        assert_eq!(text_field_focus::focused_editor_state(), None);
    });
}

#[test]
fn enter_on_single_line_field_is_not_consumed() {
    let _app_context = crate::render_state::app_context_test_scope();
    with_test_runtime(|| {
        let (state, _focus) = focused_state("abc", TextFieldLineLimits::SingleLine);

        assert!(!text_field_focus::dispatch_key_event(&key_down(
            KeyCode::Enter,
            ""
        )));
        assert_eq!(state.text(), "abc");

        text_field_focus::clear_focus();
    });
}