跳转到内容

指针事件

mouse 示例把工具栏、可拖动分隔条、释放点击区域、悬停反馈和弹窗放在同一界面。Button 在按下时立即更新计数;独立点击区域则在释放后显示单击、双击、三击计数。

真实鼠标交互

cargo run --example mouse

左键拖动分隔条,点击工具栏打开弹窗。弹窗内按钮消费点击并保持弹窗打开;未处理的空白区域会关闭弹窗。Esc 关闭弹窗,q 在弹窗打开时也能退出。录制通过 docs/tapes/mouse.tape 与 docs/scripts/mouse-demo.py 向运行真实示例的 PTY 发送 SGR 鼠标报告。

cargo build --example mouse
vhs docs/tapes/mouse.tape

指针 API 属于核心能力,无需 feature;终端捕获由运行选项控制:

#[tokio::main]
async fn main() {
    element!(App)
        .fullscreen_with(RunOptions { mouse: true })
        .await
        .expect("Failed to run the application");
}

fullscreen() 默认不开鼠标。内嵌视口可用 render_loop_with(terminal_options, RunOptions { mouse: true })。框架退出时恢复鼠标模式。部分终端可按住 Shift 进行原生拖选。

未开启捕获就注册指针 Hook,debug 构建会断言并提示入口用法;release 构建中,普通终端在未开启捕获时不会投递鼠标报告。

场景API触发时机
可见操作按钮Button左键 Down
按下并在同一组件内释放use_clickDown + Up,支持所有鼠标按键
区域内视觉反馈use_hover() -> bool移入或移出绘制区域
拖动或调整尺寸use_drag左键 Start / Move / End / Cancel
自绘列表条目悬停use_hover_row绘制坐标映射条目 ID,返回 State<Option<T>>
自绘内容滚动条use_scrollbarScrollbarOptions + on_seek(usize)
自定义过滤、传播use_pointerPointerEvent,回调返回 EventResult

所有 Hook 都必须无条件、按固定顺序调用。启停交互可在回调中判断,或挂载/卸载子组件,不要在同一组件中条件调用 Hook。

let mut clicks = hooks.use_state(|| 0u8);
hooks.use_click(move |click| clicks.set(click.count));

Click 包含 count、button、position、modifiers。计数从一开始,300ms 窗口内邻近位置连续点击递增到二、三,上限三。每次完成点击都立即回调:双击先收到一,再收到二。在区域外释放不合成点击。use_click 消费配对的按下/释放;需要自行控制传播时用 use_pointer。

let hovered = hooks.use_hover();
let style = if hovered { hover_style } else { base_style };

悬停遵守活跃输入层,阻断型弹窗排除背景追踪。首次注册 hover 或 track_move 后,框架才请求全量移动上报;启用后保持到退出。区域内移动而悬停集合不变时可跳过重绘。终端不支持移动上报时,按下和拖拽仍可工作。

hooks.use_drag(move |d| {
    if d.phase == DragPhase::Move {
        let delta = i32::from(d.to.x) - i32::from(d.from.x);
        split.set((i32::from(split.get()) + delta).clamp(10, 60) as u16);
    }
});

DragUpdate::from 是上一事件的位置,不是整个手势的初始点;to 是当前点。两者都是终端单元格绝对坐标,用差值修改宽度可避免容器 margin 引起的偏移。

消费 Down 会捕获该按键;后续 Drag/Up 即使在区域外也独占投递给同一 handler,先于 Global 和层内链。Up 释放捕获;组件卸载或所在层失活也会使捕获失效。各按键独立捕获。

hooks.use_pointer(PointerOptions::default(), move |event| {
    match event.kind {
        PointerKind::Down(MouseButton::Left) => EventResult::Consumed,
        _ => EventResult::Ignored,
    }
});

默认选项仅选择左键、不开移动追踪。多个按键可用 MouseButtons::LEFT.union(MouseButtons::RIGHT),track_move: true 开启 Enter / Move / Leave。滚轮不受按键集合过滤。PointerEvent 包含 kind、position、screen_position、modifiers 和最近绘制的 area。PointerOptions.layer: None 跟随最近的 Current 层,Some(layer) 显式归属当前帧的层。能够配对按下时,Up 后还会合成 Click;消费 Down 可通过捕获保证释放送回当前 handler。

除已捕获的 Drag/Up 外,Global handler 先执行。活跃层中层级优先于 priority;同优先级鼠标 handler 按绘制序倒排,内层、后画的兄弟先收到。命中事件的 Consumed 截断,Ignored 继续传播;Enter/Move/Leave 独立观察,消费只请求重绘。键盘仍按注册序投递。

示例在根组件调用 use_input_layer(open, true),把同一 handle 传给 Modal(layer: Some(layer))。Low 优先级的 Layer(layer) handler 收到未消费的左键 Down 后关闭弹窗。内部 Button 先消费点击;面板空白刻意放行,因此也会关闭。键盘隔离见输入层。

API 值坐标空间
PointerEvent.position、area组件绘制坐标,ScrollView 内为内容缓冲坐标
PointerEvent.screen_position终端绝对坐标
Click.position、DragUpdate.from/to、原始 Event::Mouse终端绝对坐标
use_hover_row 命中闭包组件绘制坐标

将 event.position 直接传给同一绘制缓冲内的 widget 命中方法。只有 widget 明确要求相对区域坐标时才减去 event.area.x/y,不要自行添加滚动偏移。框架统一组合嵌套 ScrollView 的变换与祖先视口裁剪,屏幕外内容不能命中;已捕获的拖拽仍可在区域外收到事件。

布局移动、滚动、resize 和输入层变化后,框架使用最后鼠标位置同步进入、离开与 use_hover,不需要再动鼠标。use_hover_row 和内置列表还会核对条目映射变化;公开 use_pointer(track_move: true) 不会在区域与内容坐标均不变时每帧伪造 Move。

直接绘制第三方 widget 时,用 use_hover_row 映射到稳定条目 ID;返回状态在条目渲染闭包中读取:

let hover = hooks.use_hover_row(move |position| {
    let hit = list.read().hit_test(position.x, position.y);
    match hit {
        Some(Hit::Item(index)) if !loading && index < item_count => Some(index),
        _ => None,
    }
});
// 条目渲染: hover.get() == Some(row_index)

加载和空态可能保留 widget 的旧命中表,必须同时判断当前内容可用性及索引范围。先把命中结果存入局部变量再写 state:match list.read().hit_test(...) 的读守卫会存活到整个 match 结束,与分支里的写借用冲突。

开启鼠标后,ScrollView 自动支持滑块拖动、轨道跳转和箭头步进。TreeSelect(scrollbar: ScrollbarStyle::default()) 使用相同实现,active: false 时仍能拖动滚动条,仅改变视口,不选择或确认章节。箭头每次移动一个内容单位,抓住滑块后捕获持续到释放或取消。

自绘正文或列表,用 use_scrollbar 注册在轨道所贴靠的组件上:

let mut offset = hooks.use_state(|| 0usize);
hooks.use_scrollbar(
    ScrollbarOptions {
        content_length: total_lines,
        viewport_length: visible_lines,
        position: offset.get(),
        placement: TrackPlacement::RightInward(1),
        scrollbar: ScrollbarStyle::default(),
    },
    move |target| offset.set(target),
);

正文也使用这个 offset。内容长度、可见长度、当前位置和 on_seek 必须共用单位。TrackPlacement 另有 LeftInward、TopInward、BottomInward,参数表示沿轨道轴缩进;未开启鼠标时仍可绘制,内容完全可见时自动隐藏。不要只绘制原生 Scrollbar 后期待它自动接入拖动。

ScrollbarStyle 在三种滚动条入口共用 style、thumb_style、track_style、hover_style、drag_style、symbols 和 arrows。拖拽样式优先于悬停,默认悬停加粗、拖拽加粗并反色;业务主题可从 palette 集中派生后复用。运行 cargo run --example custom_scrollbar 查看效果。

ScrollView 统一解析内容、视口与滚动条显隐:主轴 Percentage/Ratio/Fill 参照原始 block.inner,交叉轴参照最终视口,子节点绘制前裁剪 offset。over_border 需要真正的 RIGHT 和 BOTTOM 边框,padding 和底部标题不算边框;轨道落在 outer 的右/下边缘。

  • Button 或直接指针 Hook 挂载前先开启捕获;disabled、active、on_seek 或外部 state 改变时保持 Hook 顺序稳定。
  • 手写 Component::update 先接 hooks.with_context_stack(updater.component_context_stack());函数组件自动接入。布局属性写在函数组件返回的根元素上,Hook 区域跟随这个根元素。
  • 编辑层/弹窗自己的指针处理器使用 PointerOptions { layer: Some(layer), ..Default::default() };InputLayer 在当前帧创建与使用,不存到 state。
  • DragPhase::Cancel / PointerKind::Cancel(button) 只清除临时拖拽态,不提交拖放动作。隐藏或层失活会取消捕获;卸载组件不再回调已释放的状态。
  • 悬停值变化时才写响应式状态;Enter/Move/Leave 独立投递,Consumed 只请求重绘,不阻止其他组件清理悬停。
  • 验证两个独立面板、嵌套滚动、部分边框与 padding、轨道两端、拖出后释放、加载空态,以及鼠标静止时的布局变化。

鼠标点击不会自动建立键盘焦点;嵌套 ScrollView 到边缘后也不会自动把滚轮向外层传递。需要此行为时在业务层明确设计。