use crate::{
EventResult, Hooks, PointerKind, PointerOptions, State, UseState,
hooks::use_pointer::{pointer_enabled, use_pointer_with_layout_sync},
};
use ratatui::layout::Position;
mod private {
pub trait Sealed {}
impl Sealed for crate::hooks::Hooks<'_, '_> {}
}
pub trait UseHoverRow: private::Sealed {
fn use_hover_row<T, F>(&mut self, hit: F) -> State<Option<T>>
where
T: PartialEq + Send + Sync + Unpin + 'static,
F: Fn(Position) -> Option<T> + 'static;
}
impl UseHoverRow for Hooks<'_, '_> {
fn use_hover_row<T, F>(&mut self, hit: F) -> State<Option<T>>
where
T: PartialEq + Send + Sync + Unpin + 'static,
F: Fn(Position) -> Option<T> + 'static,
{
let hover = self.use_state(|| None::<T>);
if !pointer_enabled(self) {
return hover;
}
use_pointer_with_layout_sync(
self,
PointerOptions {
track_move: true,
..Default::default()
},
{
let mut hover = hover;
move |ev| match ev.kind {
PointerKind::Enter | PointerKind::Move => {
let next = hit(ev.position);
let changed = hover.read().ne(&next);
if changed {
hover.set(next);
EventResult::Consumed
} else {
EventResult::Ignored
}
}
PointerKind::Leave => {
let empty = hover.read().is_none();
if !empty {
hover.set(None);
}
EventResult::Ignored
}
_ => EventResult::Ignored,
}
},
);
hover
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mouse_test::Mounted;
use crate::prelude::{Fragment, Text};
use crate::{AnyElement, UsePointer, UseState};
use crossterm::event::MouseEventKind;
use ratatui_kit_macros::{Props, component, element};
use std::cell::RefCell;
use std::rc::Rc;
fn hit(pos: Position) -> Option<usize> {
(pos.y < 5).then_some(pos.y as usize)
}
#[derive(Clone, Default, Props)]
struct HarnessProps {
label: &'static str,
}
#[component]
fn Harness(props: &HarnessProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let hover = hooks.use_hover_row(hit);
let text = format!("{}:{:?}", props.label, *hover.read());
element!(Fragment {
Text(text: crate::components::TextParagraph::from(text))
})
}
#[test]
fn tracks_hovered_row_and_clears_when_moving_off() {
let mut m = Mounted::mount(crate::element!(Harness(label: "h")), true);
m.dispatch(MouseEventKind::Moved, 5, 2);
assert!(
crate::mouse_test::find_text(&m.buffer(), "h:Some(2)").is_some(),
"悬停行 2 应映射为 Some(2)"
);
m.dispatch(MouseEventKind::Moved, 5, 7);
assert!(
crate::mouse_test::find_text(&m.buffer(), "h:None").is_some(),
"移出条目区应清为 None"
);
}
#[test]
fn moving_within_the_same_row_does_not_request_redraw() {
let mut m = Mounted::mount(crate::element!(Harness(label: "h")), true);
assert!(m.dispatch(MouseEventKind::Moved, 5, 2));
assert!(!m.dispatch(MouseEventKind::Moved, 6, 2));
assert!(m.dispatch(MouseEventKind::Moved, 6, 3));
}
#[component]
fn ChangingRows(_props: &HarnessProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
use crate::{EventResult, UseEventHandler};
let mut base = hooks.use_state(|| 0usize);
hooks.use_event_handler(crate::input::EventScope::Current, crate::input::EventPriority::Normal, move |event| {
if matches!(event, crossterm::event::Event::Key(key) if key.code == crossterm::event::KeyCode::Char('n')) {
base.set(10);
EventResult::Consumed
} else {
EventResult::Ignored
}
});
let hover = hooks.use_hover_row(move |position| Some(base.get() + position.y as usize));
element!(Text(text: format!("row:{:?}", *hover.read())))
}
#[test]
fn stationary_pointer_tracks_changed_row_mapping() {
let mut mounted = Mounted::mount(element!(ChangingRows), true);
mounted.dispatch(MouseEventKind::Moved, 3, 2);
assert!(crate::mouse_test::find_text(&mounted.buffer(), "row:Some(2)").is_some());
mounted.dispatch_key(crossterm::event::KeyCode::Char('n'));
assert!(crate::mouse_test::find_text(&mounted.buffer(), "row:Some(12)").is_some());
}
#[derive(Default, Props)]
struct PairProps;
#[component]
fn Pair(_props: &PairProps, hooks: Hooks) -> impl Into<AnyElement<'static>> {
element!(crate::components::View(flex_direction: ratatui::layout::Direction::Horizontal) {
Harness(label: "left")
Harness(label: "right")
})
}
#[test]
fn moving_between_siblings_clears_previous_hover_in_the_same_event() {
let mut mounted = Mounted::mount(element!(Pair), true);
mounted.dispatch(MouseEventKind::Moved, 1, 2);
assert!(crate::mouse_test::find_text(&mounted.buffer(), "left:Some(2)").is_some());
mounted.dispatch(MouseEventKind::Moved, 21, 2);
assert!(crate::mouse_test::find_text(&mounted.buffer(), "left:None").is_some());
assert!(crate::mouse_test::find_text(&mounted.buffer(), "right:Some(2)").is_some());
mounted.dispatch(MouseEventKind::Moved, 1, 3);
assert!(crate::mouse_test::find_text(&mounted.buffer(), "right:None").is_some());
}
#[derive(Clone, Default)]
struct ItemAreas(Rc<RefCell<Vec<ratatui::layout::Rect>>>);
#[derive(Default, Props)]
struct ReproListProps {
areas: ItemAreas,
}
struct ReproList {
hover: Option<State<Option<usize>>>,
areas: ItemAreas,
}
impl crate::Component for ReproList {
type Props<'a>
= ReproListProps
where
Self: 'a;
fn new(props: &Self::Props<'_>) -> Self {
Self {
hover: None,
areas: props.areas.clone(),
}
}
fn update(
&mut self,
props: &mut Self::Props<'_>,
mut hooks: Hooks,
updater: &mut crate::ComponentUpdater,
) {
let mut hooks = hooks.with_context_stack(updater.component_context_stack());
let areas = props.areas.clone();
let hover = hooks
.use_hover_row(move |pos| areas.0.borrow().iter().position(|r| r.contains(pos)));
*self = Self {
hover: Some(hover),
areas: props.areas.clone(),
};
}
fn draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
let mut areas = Vec::new();
for i in 0..3u16 {
let rect = ratatui::layout::Rect::new(
drawer.area.x,
drawer.area.y + i * 3,
drawer.area.width,
3,
);
areas.push(rect);
let hovered = self.hover.as_ref().and_then(|h| h.get()) == Some(i as usize);
let mark = if hovered { "H" } else { "-" };
let text = ratatui::widgets::Paragraph::new(format!("{mark}item{i}"));
drawer.render_widget(text, rect);
}
*self.areas.0.borrow_mut() = areas;
}
}
#[derive(Default, Props)]
struct WrapProps {
areas: ItemAreas,
}
#[component]
fn WrapList(props: &WrapProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
use crate::input::EventResult as _;
hooks.use_pointer(PointerOptions::default(), |ev| match ev.kind {
PointerKind::Click { .. } | PointerKind::ScrollUp | PointerKind::ScrollDown => {
EventResult::Consumed
}
_ => EventResult::Ignored,
});
element!(ReproList(areas: props.areas.clone()))
}
#[test]
fn hover_row_survives_pointer_wrapper_component() {
let areas = ItemAreas::default();
let mut m = Mounted::mount(element!(WrapList(areas: areas.clone())), true);
m.dispatch(MouseEventKind::Moved, 5, 1);
assert!(
crate::mouse_test::find_text(&m.buffer(), "Hitem0").is_some(),
"悬停首条目应显示 H 标记"
);
m.dispatch(MouseEventKind::Moved, 5, 4);
assert!(
crate::mouse_test::find_text(&m.buffer(), "Hitem1").is_some(),
"移到第二条目"
);
m.dispatch(MouseEventKind::Moved, 5, 9);
assert!(
crate::mouse_test::find_text(&m.buffer(), "-item0").is_some()
&& crate::mouse_test::find_text(&m.buffer(), "Hitem").is_none(),
"移出条目区应清为无悬停"
);
}
}