latchshot 0.1.0

A lightweight yet intelligent window-aware screenshot tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
# 剩余模块审阅指南

本文按建议顺序逐个讲解**尚未审阅**的模块,供审阅时对照代码使用。

## 审阅进度

已过(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
let [x, y, width, height] = self.components.map(|spring| spring.value);

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; };
if !self.dirty || self.pending_frame.is_some() { 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——纯逻辑测试不依赖图形环境。