# 剩余模块审阅指南
本文按建议顺序逐个讲解**尚未审阅**的模块,供审阅时对照代码使用。
## 审阅进度
已过(16):根目录配置文件、`geometry.rs`、`compositor/niri.rs`、`main.rs`、`lib.rs`、`scene.rs`、`compositor.rs`、`animation.rs`、`capture.rs`、`capture/screencopy.rs`、`test_support.rs`、`output.rs`、`selection.rs`、`capture/image_copy_capture.rs`、`overlay.rs`、`overlay/highlight.rs`。
待审阅(3):
```text
overlay/render.rs 像素计算
overlay/surfaces.rs Wayland surface 机械(最难)
overlay/session.rs 事件循环 + 输入处理
```
建议顺序:render → surfaces → session。
---
## animation.rs —— 弹簧动画
### 职责
让高亮框从一个窗口平滑滑到另一个窗口。核心是 `AnimatedRect`:一个矩形拆成 4 个独立分量(x、y、宽、高),每个分量一个临界阻尼弹簧。
### AnimatedRect 代码讲解
```rust
pub struct AnimatedRect {
components: [Spring; 4], // 依次是 left、top、width、height 四个弹簧
omega: f64, // 刚度,四个弹簧共用,由 settle_time 推导
}
```
**构造 `new(rect, settle_time)`**:把起点矩形就地拆成四个分量,各自建一个弹簧:
```rust
Self {
components: [rect.left(), rect.top(), rect.width(), rect.height()].map(Spring::new),
omega: SETTLE_EXPONENT / settle_time.as_secs_f64(),
}
```
`Spring::new` 初始速度为 0(从静止开始滑向目标);`assert!(!settle_time.is_zero())` 防止除零。
**推进 `advance(target, elapsed) -> Rect`**:目标每次传入,可以中途更换。把 target 拆成四个标量,与四个弹簧 zip 逐个推进,然后取样返回当前矩形:
```rust
let targets = [target.left(), target.top(), target.width(), target.height()];
for (spring, target) in self.components.iter_mut().zip(targets) {
spring.advance(target, self.omega, elapsed.as_secs_f64());
}
self.rect()
```
**取样 `rect()`**:把四个弹簧的 value 重新组装成 Rect,解构命名让"第几个弹簧是什么"一目了然:
```rust
Rect::new(x, y, width, height)
```
**判稳 `is_settled(target)`**:四个弹簧全部稳定才算稳定——调用方据此决定是否停止请求帧回调(动画结束)。
**使用方(highlight.rs 的 SnapAnimation)**:
```rust
struct SnapAnimation {
rect: AnimatedRect,
target: Rect,
last_frame: Instant, // 上一帧时刻,用于算 elapsed
}
fn sample(&mut self, now: Instant) -> (Rect, bool) {
let elapsed = now.duration_since(self.last_frame);
self.last_frame = now;
let rect = self.rect.advance(self.target, elapsed);
if self.rect.is_settled(self.target) {
(self.target, false) // 已稳定:直接贴到目标,动画结束
} else {
(rect, true) // 还在动:返回插值,继续请求下一帧
}
}
```
### 物理模型
经典力学里的**弹簧-阻尼系统**:物体连在弹簧上,弹簧把它拉向目标,阻尼消耗能量防止永远振荡:
```text
ma = -k·x - c·v
↑弹簧拉力 ↑阻尼力
(x = 离目标的距离,k = 刚度,c = 阻尼系数)
```
整理成标准形式:`ẍ + 2ζω₀·ẋ + ω₀²·x = 0`。`ω₀`(代码里的 `omega`)是刚度,决定响应多快;`ζ` 是阻尼比,决定会不会振荡:
| ζ < 1 | 欠阻尼 | 冲过头、来回弹几下才停 |
| ζ = 1 | **临界阻尼** | 最快收敛,且刚好不振荡 |
| ζ > 1 | 过阻尼 | 不振荡,但慢吞吞 |
UI 动画几乎都选临界阻尼:又快又稳,没有弹跳的廉价感。
### 关键结构:临界阻尼的解析解
ζ = 1 时方程有闭式解(`x₀` 初始偏差、`v₀` 初始速度):
```text
x(t) = (x₀ + (v₀ + ω₀·x₀)·t) · e^(-ω₀t)
v(t) = (v₀ - ω₀·(v₀ + ω₀·x₀)·t) · e^(-ω₀t)
```
代码就是这两条公式:
```rust
struct Spring {
value: f64, // 当前值
velocity: f64, // 当前变化速度
}
impl Spring {
fn advance(&mut self, target: f64, omega: f64, seconds: f64) {
let error = self.value - target; // x₀
let coefficient = self.velocity + omega * error; // v₀ + ω₀·x₀
let decay = (-omega * seconds).exp(); // e^(-ω₀t)
self.value = target + (error + coefficient * seconds) * decay; // x(t)
self.velocity = (self.velocity - omega * coefficient * seconds) * decay; // v(t)
}
}
```
### 由此推出的四个关键性质
1. **帧率无关**:这是解析解——直接算"t 秒后"的精确值,不是逐步近似。16ms 两步和 32ms 一步结果完全一样(测试 `converges_without_frame_rate_dependence` 钉死)。欧拉积分逐步累加会随帧率变化。
2. **`settle_time` 不是精确到达时间**:指数衰减渐进收敛,永不精确到达。`SETTLE_EXPONENT = 10` 的含义是 `omega = 10 / settle_time`,t = settle_time 时 `e^(-10) ≈ 0.000045`——误差只剩十万分之四,视觉上到了。真正的"到达"由 `is_settled` 的 epsilon 判定。
3. **中途换目标不跳变**:弹簧状态是 `(位置, 速度)` 这对连续量。换 target 只是改拉力方向,位置速度连续,动画自然拐弯。缓动函数是预设起点终点的曲线,中途换终点必须重启,画面会跳。
4. **跟踪 velocity 的原因**:换目标时速度连续性让过渡平滑;`is_settled` 必须看速度——位置可能恰好等于 target 但正在高速穿过,不能判稳。
### 其他
- `is_settled`:位置和速度都小于阈值才算稳定(`POSITION_EPSILON`/`VELOCITY_EPSILON`)。
- `Rect` 与四个弹簧之间的映射在构造和取样处就地完成:`[left, top, width, height]` 命名明确,不用隐式下标的转换辅助函数。
### 审阅检查点
- 为什么用"临界阻尼"而不是缓动函数(easing)?
- `is_settled` 为什么还要看速度?
- `omega = 10 / settle_time` 里的 10 从哪来?
---
## selection.rs —— 选择状态机
把鼠标交互的所有可能状态收进一个 enum,所有输入事件汇到一次 match 完成转移。四态:`Waiting` / `Hover` / `Pressed`(锁 target) / `Dragging`(不记 target);结果 `Selection::{Window, Region}` + `SelectionResult`。纯逻辑,5 个测试穷举路径。
**详细讲解(七步阅读路径、状态转移全图、测试↔路径映射)见 [`docs/selection.md`](selection.md)。**
## capture.rs —— 裁切管线
### 职责
把选区变成像素的裁切管线,以及它的数据载体(`OutputFrame`/`DesktopFrame`)和后端选择(`CaptureBackend`)。截屏实现们在各自文件。
### 后端选择
```rust
pub trait FrameCapture {
fn capture(&self, scene: &Scene) -> Result<DesktopFrame>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CaptureBackend {
ImageCopyCapture,
Screencopy,
}
```
- `detect()`:读 `advertised_globals()`(wlr-capture 提供的全局列表),优先级 新协议 → 老 screencopy;都没有则返回 `None`,由 `main` 报错(不支持无 Wayland 截屏协议的环境)。
- `connect(self) -> Result<Box<dyn FrameCapture>>`:与 `Compositor::connect` 完全对称——枚举分发到具体实现,消费方只认 trait 对象。
- `FrameCapture` trait 回归的原因:多个实现并存 + 运行时选择,抽象点成立(与之前删掉它时单实现、无选择的局面不同)。
- 统一签名 `&self` 的代价由 `ImageCopyCapture` 承担:内部 `Cell<Option<Client>>`,take → 用 → set 的零开销内部可变性,与 `Niri` 的 socket 同款模式。
- `main` 用 `--capture <BACKEND>` 覆盖,否则 `detect()`。
### 类型
```rust
pub struct OutputFrame {
pub output: OutputId,
pub logical_geometry: Rect, // 全局逻辑坐标
pub image: RgbaImage, // 像素图
}
pub struct DesktopFrame {
pub outputs: Vec<OutputFrame>,
}
```
`OutputFrame::scale_x()/scale_y()` 不读 `Output.scale`,而是**从真实图片尺寸推导**:
```rust
image.width() / logical_geometry.width()
```
这是"compositor 声明的缩放"和"实际捕获密度"之间的交叉校验来源。
### crop(region) —— 三段式
```rust
pub fn crop(&self, region: Rect) -> RgbaImage {
// 1. 找出与选区相交的输出帧
let frames = self.outputs.iter()
.filter_map(|frame| frame.logical_geometry
.intersection(region)
.map(|intersection| (frame, intersection)))
.collect::<Vec<_>>();
assert!(!frames.is_empty(), "the selected region does not intersect any output");
// 2. 恰好落在单个输出内 → 直接裁(快路径)
if let [(frame, intersection)] = frames.as_slice() && *intersection == region {
let source = pixel_rect(*intersection, frame.logical_geometry,
frame.scale_x(), frame.scale_y());
return imageops::crop_imm(&frame.image, ...).to_image();
}
// 3. 跨输出 → 取最高 scale,逐块裁切、缩放、贴到结果画布
// (不同输出的像素密度不同,低密度侧放大对齐)
let scale = frames.iter().map(|(frame, _)| frame.scale())
.reduce(f64::max).expect("frames was checked to be non-empty");
let mut result = RgbaImage::new(
(region.width() * scale).ceil() as u32,
(region.height() * scale).ceil() as u32,
);
for (frame, intersection) in frames {
// 从源帧裁 → 必要时 Lanczos3 缩放 → overlay 贴到目标位置
}
result
}
```
### pixel_rect —— 坐标换算的心脏
```rust
fn pixel_rect(inner: Rect, outer: Rect, scale_x: f64, scale_y: f64) -> PixelRect {
let left = ((inner.left() - outer.left()) * scale_x).floor() as u32;
let top = ((inner.top() - outer.top()) * scale_y).floor() as u32;
let right = ((inner.right() - outer.left()) * scale_x).ceil() as u32;
let bottom = ((inner.bottom() - outer.top()) * scale_y).ceil() as u32;
PixelRect { x: left, y: top, width: right - left, height: bottom - top }
}
```
左边界 `floor`、右边界 `ceil`:选区逻辑边界落在两个物理像素之间时(如 0.4 像素处)**向外取整**,保证逻辑上选中的内容一个像素都不少——这就是"分数缩放不漂移"的实现细节。5 个测试(含 8×9 非均匀密度图)钉死这些舍入行为。
### 审阅检查点
- `PixelRect`(u32 像素)与 `Rect`(f64 逻辑)的分工:转换只发生在这一层。
- 快路径的条件为什么是 `*intersection == region` 而不是"只交一个输出"?(选区跨出屏幕边缘时也走通用路径。)
---
## capture/screencopy.rs —— 截屏实现
### 职责
截屏实现,包装 libwayshot(wayshot 的库形式)。命名与 `Niri` 同逻辑:以数据来源命名——wlr screencopy 协议。`capture` 是 `Screencopy` 的固有方法(曾经有 `FrameCapture` trait,但只有一个实现且无运行时选择,已删除)。
### 流程
```rust
impl Screencopy {
pub fn capture(&self, scene: &Scene) -> Result<DesktopFrame> {
let wayland_outputs = self.connection.get_all_outputs();
let outputs = scene.outputs.iter()
.map(|output| {
let wayland_output = wayland_outputs.iter()
.find(|candidate| candidate.name == output.id.as_str())
.with_context(|| format!(
"Wayland did not advertise output {}", output.id))?;
let image = self.connection
.screenshot_single_output(wayland_output, false) // false = 不带光标
.with_context(|| format!(
"failed to capture output {}", output.id))?
.into_rgba8();
Ok(OutputFrame {
output: output.id.clone(),
logical_geometry: output.logical_geometry,
image,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(DesktopFrame { outputs })
}
}
```
要点:
- `scene` 是参数:捕获的是"Scene 里那些输出"的画面,每个输出一张图。
- 输出在 scene 快照之后被热插拔掉是**真实的外部失败**,用 `Result` + `context` 给用户干净的 `Error: ...`,而不是 panic。
### 审阅检查点
- 为什么这个文件保留 `Result` + context 而 niri 的协议不匹配用 panic?(外部失败 vs 自身不变量。)
---
## capture/image_copy_capture.rs —— 新截屏协议
### 职责
通过 `ext-image-copy-capture-v1`(老 screencopy 的继任者)捕获每块输出。底层用 `wlr-capture` crate(wlr-utils 系列的引擎,`default-features = false` 时是纯 shm CPU 路径,不拉 egui/EGL)。
### 流程
```rust
let mut client = Client::connect()?; // 绑定 ext_image_copy_capture_manager_v1
client.refresh()?; // roundtrip 枚举输出
let outputs = client.outputs(); // 含 name + 全局逻辑位置
for output in scene.outputs {
let frame = client.capture_output_once(wayland_output, CAPTURE_BUDGET)?;
let image = match frame {
Frame::Shm(captured) => RgbaImage::from_raw(...)?, // CPU 像素
Frame::Dmabuf(_) => bail!(...), // 无 GPU 路径,不应出现
};
}
```
要点:
- `capture_output_once` 是一次性会话:开 session → 等一帧(预算 2 秒)→ 关 session。
- trait 要求 `&self`,但会话状态需要 `&mut`——用 `Cell<Option<Client>>` 内部可变性解决:`capture` 里 take 出 client、闭包里完成全部截取、错误路径也算完结果后才 set 回去。
- 无 GPU feature 时 `Frame::Dmabuf` 仍存在于枚举,防御性报错。
### 审阅检查点
- 为什么 `connect()` 里 `Client::connect()` 会失败?—— compositor 不发布 `ext_image_copy_capture_manager_v1` 时(wlroots < 0.19 / 旧合成器)。
- `refresh()` 为什么必要?—— 不 roundtrip 一次,`outputs()` 列表是空的。
---
## output.rs —— 输出
### 类型
```rust
pub enum Target {
File(PathBuf),
Stdout,
Clipboard,
}
```
### 写入逻辑:`Target::write`
编码和分发都收进 `Target` 自己的方法里,`main` 只调 `target.write(&image)?`:
```rust
impl Target {
pub fn write(&self, image: &RgbaImage) -> Result<()> {
// 1. 编码 RGBA → PNG(三个出口共用同一份字节)
// 2. File → fs::write
// Stdout → 写原始 PNG 字节 + flush
// Clipboard → spawn `wl-copy --type image/png`,PNG 写进它的 stdin
}
}
```
剪贴板为什么 spawn 外部进程而不是用 crate:Wayland 剪贴板的硬约束是**提供数据的进程必须活着**,否则 compositor 销毁 selection。`wl-copy` 靠 fork 出后台进程解决;自己用库实现等于重新发明 wl-copy。
### notify
```rust
pub fn notify(body: &str) {
let result = Notification::new()
.appname("latchshot").summary("latchshot").body(body).show();
if let Err(error) = result {
warn!("failed to send a notification: {error}");
}
}
```
best-effort:通知失败只警告,不影响截图结果。
### 审阅检查点
- `Target` 是在 main 里由参数推导的,output.rs 只管"往哪写"。
- `--stdout` 输出的是**原始 PNG 字节**,不是终端图片协议——被重定向时才是正确行为。
---
## overlay.rs —— 模块组织
只有 4 行:
```rust
mod highlight;
mod render;
mod surfaces;
pub mod wayland;
```
可见性设计:只有 `wayland` 对外——`select` 是 `main` 唯一的入口;其余三个是内部实现。核心分层:
```text
render.rs 像素计算(纯函数:调暗、边框、圆角掩码)
highlight.rs 高亮动画状态机(纯逻辑:弹簧、渐入、间隙宽限)
surfaces.rs Wayland surface 机械(buffer、viewport、提交)
session.rs 事件循环 + 输入处理(把上面三个 + Selector 串起来)
```
前两个不碰 Wayland,可单测;后两个绑定协议,只能靠人工验证。这是 DESIGN.md 核心纪律的体现。
---
## overlay/render.rs —— 像素计算
### copy_frame:预乘 alpha + BGRA
```rust
fn copy_frame(frame: &OutputFrame, canvas: &mut [u8], map_channel: impl Fn(u8) -> u8) {
assert_eq!(canvas.len(), frame.image.as_raw().len());
for (target, source) in canvas.as_chunks_mut::<4>().0.iter_mut()
.zip(frame.image.pixels())
{
let [red, green, blue, alpha] = source.0;
target.copy_from_slice(&[
map_channel(premultiply(blue, alpha)), // 注意顺序:B, G, R, A
map_channel(premultiply(green, alpha)),
map_channel(premultiply(red, alpha)),
alpha,
]);
}
}
```
两个细节:
1. **预乘 alpha**:半透明像素先把 RGB 乘上透明度再存(`(channel * alpha + 127) / 255`),合成时颜色才正确。
2. **BGRA 字节序**:Wayland shm buffer 的标准格式 Argb8888 小端,字节顺序实际是 B,G,R,A。
`copy_frame(frame, canvas, map_channel)` 的调用方直接传映射函数(`std::convert::identity` 原样 / `dimmed_channel` 调暗),不包薄皮包装。
### veil 与边框像素
- `veil_pixel(reveal)`:选区上的半透明黑罩,渐入时(reveal 0→1)罩子淡出。
- `border_pixel(reveal)`:边框颜色随 reveal 淡入。
### CornerMask:超采样抗锯齿圆角
```rust
for sample_y in 0..CORNER_SAMPLES {
for sample_x in 0..CORNER_SAMPLES {
let distance = (x - center.0).hypot(y - center.1);
if distance <= radius && distance >= radius - border_width {
covered += 1;
}
}
}
```
每个像素取 4×4=16 个采样点,落在圆环(外半径 `radius`、内半径 `radius - border_width`)内的比例决定覆盖率——一次性预渲染,运行时按 reveal 缩放 alpha。
### 审阅检查点
- 预乘公式的 `+127` 是四舍五入(整除向下取整)。
- CornerMask 为什么预渲染而不是每帧重算?
---
## overlay/highlight.rs —— 高亮动画状态机
### 三个概念
```rust
const SPRING_SETTLE_TIME: Duration = Duration::from_millis(120);
const FADE_TIME: Duration = Duration::from_millis(90);
const WINDOW_GAP_GRACE: Duration = Duration::from_millis(80);
```
- 弹簧 120ms:窗口切换的平滑过渡。
- 渐入 90ms:首次出现时遮罩淡出。
- 间隙宽限 80ms:光标穿过窗口间隙时保留高亮,不闪烁。
### generation 机制(最重要的设计)
```rust
pub(super) struct PendingReveal {
pub(super) generation: u64,
pub(super) target: Rect,
}
```
为什么需要 generation:渐入(reveal)必须等**所有与之相交的输出**都确认"我把新画面画出来了"(frame callback),否则多屏上会出现某块屏还没画新选区、其他屏已经开始淡入的撕裂。
流程:
1. 目标变化 → `generation += 1`(`wrapping_add`)。
2. `pending_reveal()` 提出一次"reveal 请求"(携带 generation 和目标矩形)。
3. 每个相交输出在其 frame callback 里记录确认的 generation(`frame_done`)。
4. `acknowledge_reveal`:所有相交输出都确认当前 generation → `start_reveal` 开始渐入。
5. 旧 generation 的迟到确认会被 `has_acknowledged_reveal(current)` 过滤——不会误触发。
### 其他要点
- `clear_at` 只设一次(`get_or_insert`):间隙宽限不因连续 miss 而延长,测试钉死了这点。
- `clear()` 在动画存在时也递增 generation——防止清除瞬间旧确认误触发。
- `sample(now)` 返回 `(rect, reveal, animating)`,`animating` 决定是否继续请求 frame callback。
### 审阅检查点
- 为什么 `generation` 用 `wrapping_add`?
- `interrupted_first_reveal_waits_for_the_next_highlight` 测试验证的正是代际过滤。
---
## overlay/surfaces.rs —— Wayland surface 机械(最难)
### 概念储备
- **surface**:client 的一块可显示区域,配 buffer(内存像素),commit 后 compositor 才画。
- **subsurface**:子表面,相对父表面定位,有自己的 buffer。
- **layer-shell**:让 surface 出现在系统层级(背景/底层/顶层/overlay)。遮罩是 `Layer::Overlay`(最顶,压住所有窗口)。
- **viewport**(wp_viewport):surface 内容的裁剪窗口 + 目标尺寸缩放——高亮块放大缩小全靠它。
### 渲染拆解:一个输出 = 一块 layer surface + 9 块子 surface
```text
OutputOverlay(一个输出 = 一个 layer surface)
├─ background buffer 整屏冻结画面的调暗版(永远铺底)
├─ highlight_subsurface 选区内的"原亮"画面(从原图开窗显示 → 亮度对比遮罩)
├─ veil (SolidSurface) 选区内的半透明黑罩(渐入时淡出)
├─ borders [4] 四条边框(SolidSurface,1×1 纯色 buffer 拉伸)
└─ corners [4] 四个圆角(CornerSurface,预渲染抗锯齿掩码)
```
**"高亮"的视觉原理**:遮罩层显示调暗版画面;选中区域之上叠一张"原亮版"画面(highlight),再叠半透明黑罩和边框。亮区=原图,暗区=调暗图,自然形成"窗口被点亮"。
### ChildSurface:双 buffer 的生命周期
```rust
struct ChildSurface {
buffers: [Buffer; 2],
visible: bool,
...
}
```
两块 buffer 轮换:compositor 还持有上一帧的 buffer 时,用另一块绘制新内容,避免等待。协议:
- `ready(pool, ...)`:查询"现在能画吗"(有可用 buffer 且内容有变化才需要画)。
- `redraw(pool, fill)`:找到可用 buffer → 填像素 → attach → commit。
`SolidSurface`(纯色)和 `CornerSurface`(圆角掩码)只差画什么,共享机械。
### projected_selection:全局 → 局部
```rust
let local = selection.intersection(frame.logical_geometry)?;
let frame_left = frame.logical_geometry.left();
let frame_top = frame.logical_geometry.top();
// 局部坐标 × (配置尺寸 / 逻辑尺寸) → 本输出 surface 内的像素坐标
```
把全局选区裁到本输出,再换算成本输出的 surface 像素坐标(含 scale)。
### present():状态机的守卫
```rust
let Some(configured_size) = self.configured_size else { return; };
- 未配置 → 等 configure 事件。
- 不脏 → 没变化不画。
- 有 frame callback 未回 → 跳过(等 callback 再画)。
生命周期封装(F-003 的成果):
- `configured_size()` 只读查询;
- `has_acknowledged_reveal(generation)` 代际过滤;
- `mark_dirty()` 输入变化时标脏;
- `frame_done()` frame callback 到达时消费 pending_frame、记录确认、必要时续帧;
- `present()` 自己判断能否画,**只在成功提交后清脏**——失败(缺 buffer、未配置)保持 dirty,下轮重试。
原子性:所有子 surface 的 buffer 都 ready 才一起 commit,避免"边框画了一半"的撕裂(`if !(veil_ready && borders_ready && corners_ready) { return; }`)。
### 审阅检查点
- 为什么 `present()` 失败时不能清 dirty?
- `frame_done()` 为什么自己不清 dirty、只可能置脏?
- 双 buffer 的 `ready/redraw` 协议为什么存在(compositor 持有旧 buffer)。
---
## overlay/session.rs —— 事件循环
交互会话的运行时:连接 Wayland、每输出建遮罩、跑 `blocking_dispatch` 事件循环,把协议事件翻译成对纯逻辑模块的调用。`result`/`failure` 字段承接 FFI 回调无法 return 的结果;六个 handler(Pointer/Keyboard/Compositor/LayerShell/Output/Seat)各管一段输入或生命周期。
**详细讲解(select 生命周期、State 字段、frame_plan/acknowledge_reveal、六个 handler 逐一、样板代码)见 [`docs/wayland.md`](wayland.md)。**
## test_support.rs —— 测试夹具
```rust
pub fn output(id: &str, scale: f64) -> Output {
Output {
id: OutputId::new(id),
logical_geometry: Rect::new(0.0, 0.0, 1920.0, 1080.0),
pixel_size: Size::new(1920.0 * scale, 1080.0 * scale),
scale,
transform: OutputTransform::Normal,
}
}
pub const fn window(geometry: Rect) -> Window {
Window { geometry }
}
```
两个夹具函数供 `scene.rs`、`selection.rs` 等测试构造 Scene。`lib.rs` 里以 `#[cfg(test)] pub(crate)` 暴露,不进公共 API。
### 审阅检查点
- 夹具只构造领域对象,不碰 Wayland/niri——纯逻辑测试不依赖图形环境。