# selection.rs 详解 —— 选择状态机
> 对应代码:`src/selection.rs`。本文件是纯逻辑模块:不依赖 Wayland、不依赖 niri,输入输出都只是数据。配套的审阅记录见 `docs/review/findings.md`。
## 模块对外承诺什么
读代码从类型开始。这个模块只向外暴露三样东西:
```rust
pub enum Selection {
Window(Rect), // 点中了窗口 → 该窗口的全局几何
Region(Rect), // 拖拽选区 → 归一化后的矩形
}
pub enum SelectionResult {
Selected(Selection),
Cancelled, // Esc 取消
}
pub struct Selector { ... }
```
`Window` 和 `Region` 目前下游处理相同(都取几何去裁切),但变体标签保留**用户意图**——将来窗口截图可以有不同命名/行为。`SelectionResult` 是"一次交互的终态"。
## 内部状态:机器有哪些状态、各携带什么
```rust
enum State {
Waiting, // 光标未命中窗口、未按键
Hover { pointer, target }, // 悬停:pointer=光标,target=命中的窗口下标
Pressed { origin, target }, // 按下左键:origin=按下点,target=按下时锁定的窗口
Dragging { origin, pointer }, // 拖动中:origin=起点,pointer=当前光标
}
```
两个关键设计:
1. **Pressed 同时记 origin 和 target**——release 时要靠它们区分"点击窗口"还是"开始拖拽";target 在按下瞬间锁定,之后光标在窗口内漂移不影响它。
2. **Dragging 故意不记 target**——进入拖拽就是自由选区,吸附概念退出舞台。
## Selector 本体
```rust
pub struct Selector {
scene: Scene,
state: State,
drag_threshold_squared: f64, // 阈值存平方:比较时免开方
}
```
- `scene`:选择要用的世界(窗口几何、遮挡顺序),构造时传入、此后只读;
- `drag_threshold_squared`:`new` 里 `drag_threshold.powi(2)` 预计算,`pointer_moved` 里 `distance_squared >= threshold_squared` 比较,省每次开方。
## 查询方法(不改状态,先读懂"现在是什么")
```rust
pub fn target_geometry(&self) -> Option<Rect> {
let target = match self.state {
State::Hover { target, .. } | State::Pressed { target, .. } => target,
State::Waiting | State::Dragging { .. } => None,
}?;
Some(self.scene.windows[target].geometry)
}
pub fn region(&self) -> Option<Rect> {
let State::Dragging { origin, pointer } = self.state else {
return None;
};
Some(Rect::from_points(origin, pointer))
}
```
- `target_geometry`:只有 Hover/Pressed(吸附还活着)才可能有窗口目标;overlay 每帧拿它画高亮。
- `region`:只有 Dragging 才有选区;`Rect::from_points` 自动归一化方向(任意方向拖都得到合法矩形)。overlay 拖拽时每帧拿它画框。
## 状态转移核心:pointer_moved
所有移动事件汇到这一个方法,一次 match 完成全部转移:
```rust
pub fn pointer_moved(&mut self, pointer: Point) {
self.state = match self.state {
// 分支 1:没在按键——光标移动只是刷新悬停
State::Waiting | State::Hover { .. } => State::Hover {
pointer,
target: self.target_at(pointer),
},
// 分支 2:按着且位移平方超过阈值 → 进入拖拽
State::Pressed { origin, target: _ }
if origin.distance_squared(pointer) >= self.drag_threshold_squared =>
{
State::Dragging { origin, pointer }
}
// 分支 3:按着但没超过阈值 → 保持 Pressed(防手抖)
State::Pressed { origin, target } => State::Pressed { origin, target },
// 分支 4:已在拖拽 → 只更新光标
State::Dragging { origin, .. } => State::Dragging { origin, pointer },
};
}
```
分支 2 的 `if` 是 **match guard**(附加条件):不满足就落到分支 3。注意分支 2 丢弃了 target(`target: _`)——进入拖拽后不再需要。
## 事件到结果的转换
```rust
pub const fn press(&mut self) {
if let State::Hover { pointer, target } = self.state {
self.state = State::Pressed {
origin: pointer,
target,
};
}
}
```
`press` 只在 Hover 时有效:按下点成为 origin,悬停命中的 target 被锁定。
`release` 是三种结局的分水岭:
```rust
pub fn release(&mut self) -> Option<SelectionResult> {
let result = match self.state {
// 结局 1:按下时有窗口目标 → 窗口选区(点击窗口)
State::Pressed { target: Some(target), .. } => self.window_selection(target),
// 结局 2:拖拽中且宽高非零 → 区域选区
State::Dragging { origin, pointer } => {
let region = Rect::from_points(origin, pointer);
if region.width() == 0.0 || region.height() == 0.0 {
self.state = State::Hover {
pointer,
target: self.target_at(pointer),
};
return None;
}
Selection::Region(region)
}
// 结局 3:按下时没有窗口目标(空点)→ 无结果
State::Pressed { origin, target: None } => {
self.state = State::Hover {
pointer: origin,
target: None,
};
return None;
}
State::Waiting | State::Hover { .. } => return None,
};
Some(SelectionResult::Selected(result))
}
```
重点:**所有"无结果"的分支都会把状态拨回 Hover**——失败的选择不留下烂状态,下一次事件仍能正常进入流程(测试 `releasing_a_line_restores_the_window_target` 钉死:划线 release 后 target 恢复)。
## 私有辅助
```rust
fn target_at(&self, point: Point) -> Option<usize> {
self.scene.window_index_at(point)
}
fn window_selection(&self, index: usize) -> Selection {
Selection::Window(self.scene.windows[index].geometry)
}
```
`window_index_at`(scene.rs)从前往后找第一个包含光标的窗口——前面的挡住后面的。
## 状态转移全图
```text
pointer_moved(刷新悬停)
Waiting ────────────────────→ Hover
↑ │ press
│ release(None) ↓
│ Pressed ── release(有target) ──→ Selection::Window
│ │
│ pointer_moved ≥ 阈值
│ ↓
└──────────────────────── Dragging ── release(非零面积) ──→ Selection::Region
Esc(外部,session.rs)──→ SelectionResult::Cancelled
```
## 测试 ↔ 路径映射
每个测试对应转移图里的一条边:
| `click_selects_the_snapped_window` | Hover→Press→release:窗口选区 |
| `dragging_past_the_threshold_selects_a_region` | 阈值内保持 Pressed;超阈值进 Dragging;release 得区域 |
| `releasing_an_empty_click_does_not_start_a_late_drag` | 空点 release 后,迟来的移动不会触发拖拽 |
| `a_line_is_not_a_region` | 水平线选区(宽 0)→ 不算区域 |
| `releasing_a_line_restores_the_window_target` | 划线 release 后状态回到 Hover,target 恢复 |
## 与调用方的关系
```text
overlay/session.rs
PointerHandler → pointer_moved / press / release(鼠标路径)
KeyboardHandler → Esc=Cancelled(写 result 字段)
main.rs
→ 消费 SelectionResult:Selected → 裁切;Cancelled → 退出
```
Selector 自己是纯逻辑:不碰 Wayland 事件类型,输入输出全是 `Point`/`Rect`/`SelectionResult`——所以 5 个测试全部在无图形环境跑。