Repository navigation
Expand file tree
/
Copy pathtranscript_view.py
More file actions
1623 lines (1466 loc) · 90.3 KB
/
Copy pathtranscript_view.py
File metadata and controls
1623 lines (1466 loc) · 90.3 KB
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
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""NSScrollView 支撑的转录区: 聊天式滚动 + 视图回收 + 跟随状态机。
坐标约定(关键)
--------------
文档视图**不翻转**(`isFlipped` 保持 False), 所以 `origin.y = 0` 就是**底部**。
为什么不用翻转坐标: 翻转后 y=0 在顶部, 要"贴底"就得把 origin 设成
`docH - clipH`; 内容比视口短时该值是**负数**, AppKit 会 clamp 到 0 并把内容
顶对齐 —— 空白跑到内容**下方**, 正是聊天布局最怕的。不翻转时静止位置恰好是 0,
clamp 边界与静止位置重合, clamp 永远在帮你; 内容短于视口时也无需 padding 技巧。
行几何: `level 0` = 最新句, 在 y=0; `level L` 在 `y = L * row_h`。
一行内部两条标签的上下顺序见 `EN_LINE_ON_TOP`。
为什么固定行高是命门
--------------------
固定 `row_h` 让"滚动偏移 → 行索引"变成 O(1) 的除法。可变行高会逼出前缀和
布局缓存, 并推翻整套回收方案。所以**所有行等高**、不做逐行自适应。
⚠️ "等高"指的是**同一时刻所有行共享同一个高度**, 不是"高度恒为 70":
`set_row_metrics` 会在窗口变窄时把这一组统一尺寸换成 3/4/5 行(中文需要更多行才不
被静默截断), 而 O(1) 除法这条命门仍然成立 —— 因为它只依赖"等高", 不依赖具体值。
回收
----
池里 K 个槽位常驻, 每个槽位记住自己当前的 `level`。滚动一行时只有**一个**
槽位的 level 会离开覆盖区间、也只有**一个**新 level 进入, 于是只搬动一格。
文案更新则靠 `_set_text` 的缓存按键跳过 —— 只有实时行在流式期间会真的写。
"""
from __future__ import annotations
import math
import time
import motion
import objc_own
import edge_fade
BOTTOM_EPS = 3.0 # origin.y <= 此值即视为在底部(橡皮筋的负值也算)
MOVE_EPS = 1.5 # 与期望 origin 相差超过此值 -> 判定为用户/惯性滚动
FLUSH_DT = 0.016 # 渲染合并闸门 = 一帧
# ---- 视口归谁:一条规矩(2026-10-17 修「往上翻被拽回底部」)----
# ⭐ **回到底部只由用户触发**(自己滚到底 / 点「↓ N 句新的」)。两条老规矩都撤了:
# ① 「闲置 6s 自动回底」(`IDLE_S`, 已删) —— 探针实测(两种布局, `scripts/
# probe_scroll_flash.py`)读着读着 5.4s 后整屏被拽走; 一节 50 分钟的课里
# "读着刚才那几段"是常态, 定时器分不清"人在读"和"人走了", 用户代价远大于收益。
# ② 「跟随态钉底无脑 `scrollToPoint_(0)`」—— 它**可以和手势抢**: 程序滚动一旦
# 落在「用户把视口滚走」与「下一个 tick」之间(空闲档让步里 `nextEventMatchingMask`
# 当场派发滚轮就是那条路径), 钉底把视口拽回 0 并把 `_expected_origin` 一起写成 0,
# 于是 tick 读到"没动过" —— 用户的滚动被**吞掉**, 跟随永远翻不了假。
# 现在: **每一次程序滚动之前**都先过 `_sync_user_origin`(`tick` 与 `_paint` 两处调用,
# 判据只有一份), 视口不在期望位置就一律视为用户在滚 -> 交还给他; 钉底只吸收
# `BOTTOM_EPS` 以内的零头(橡皮筋 / 亚像素), 永不远距离往回拽。
# ---- 新行淡入(A5)与译文超限截断(#7)----
#: 上滚时**真正的新行**额外淡入的时长 = `motion.MOVE_S` 的前 55%。
#: 来源: 字体/动效报告 §5 方案 A5(Google 2023 的设计动作逐字: "smooth scrolling and
#: fading of newly added tokens")。位移仍走完整 `MOVE_S`, 淡入**先到 1** ——
#: 「位移还没落位, 新行已经就位」。⚠️ 复用既有的 `_roll_alpha` 机制(`_geom` 里按
#: `level < _roll_fade_n` 施加), 不是第二套动画。
NEW_LINE_FADE_S = motion.MOVE_S * 0.55
#: **半句合并**(跨段尾句合并少一行)那一帧: 被吞掉那一行原地淡出 / 尾句新文字交叉
#: 淡入用的时长。取 `motion.STATE_S` —— 「原地换内容」那一档(`motion.py` 的分档说明:
#: 150–300ms 的 STATE 档),**不是** `NEW_LINE_FADE_S`: 新行是"入场"(先就位),
#: 而这里是**一次交叉淡换**(一出一进要对齐成一个 dissolve,两边必须同一个时长)。
MERGE_FADE_S = motion.STATE_S
#: 译文超出档位行数时补的省略号(单字符, 与 `linebreak` / `_wrap_zh` 无关)。
ELLIPSIS = "…"
def roll_fade_alpha(t0: float, now: float, duration: float = NEW_LINE_FADE_S) -> float:
"""新行淡入的 alpha(0→1), 时长 `duration`(默认 `NEW_LINE_FADE_S`)、ease-out。
**纯函数**(A5 的判据)。合并过渡传 `MERGE_FADE_S` —— 两者走同一条曲线与同一个时钟。"""
return motion.ease_out(motion.progress(t0, now, duration))
def truncate_to_height(text: str, measure, max_h: float, *,
suffix: str = ELLIPSIS, eps: float = 0.5) -> str:
"""把 `text` 截到 `measure(结果) <= max_h + eps`, **从尾部**截断并补 `suffix`。
⭐ 第 7 项(报告 §5 的 F · 译文溢出): AppKit 的 `maximumNumberOfLines_` 超限时
**既不留省略号也不报错**(`overlay.py` 文件头实测过这个坑), 所以"要换行又要省略号"
只能自己截。`measure(s) -> px` 由调用方注入(生产环境 = AppKit 实测的 `_measure_text_h`),
于是本函数是**纯函数**, 测试里可以用假尺子钉住它。
三条不变量:
1. 放得下就**原样返回**(含 `""`)—— 短串一个字节都不动。
2. 结果**恒以 `suffix` 结尾**(除非连 `suffix` 自己都放不下, 那时只返回 `suffix`)。
3. **不改输入**: 返回新字符串, `text` 对象原样(显示层专用; `sessions/` / `livemetrics` /
`writer` 拿到的永远是原文)。
"""
if measure(text) <= max_h + eps:
return text
if measure(suffix) > max_h + eps:
return suffix
# 高度对**前缀长度**单调不减 -> 二分找"最长的、加省略号后仍放得下"的前缀。
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if measure(text[:mid] + suffix) <= max_h + eps:
lo = mid
else:
hi = mid - 1
return text[:lo].rstrip() + suffix
def truncate_head_to_height(text: str, measure, max_h: float, *,
prefix: str = ELLIPSIS, eps: float = 0.5) -> str:
"""`truncate_to_height` 的镜像: **从开头**截、前面补 `prefix`, 留住**最新**的那几个词。
给 D 布局的**当前行英文**用: 草稿英文随老师说话一直往后长, 读者要看的是**刚说的**,
所以截掉的是开头(实时字幕的通行做法, 原型里叫「尾部显示」)。三条不变量与
`truncate_to_height` 逐条对应(放得下原样 / 结果恒以 `prefix` 开头 / 不改输入)。
尽量从词边界切: 切点往后最多挪 14 个字符找空格。"""
if measure(text) <= max_h + eps:
return text
if measure(prefix) > max_h + eps:
return prefix
# 高度对**后缀长度**单调不减 -> 二分找"最长的、加前缀后仍放得下"的后缀。
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if measure(prefix + text[len(text) - mid:]) <= max_h + eps:
lo = mid
else:
hi = mid - 1
cut = len(text) - lo
sp = text.find(" ", cut)
if 0 <= sp - cut < 14 and sp + 1 < len(text):
cut = sp + 1
return prefix + text[cut:].lstrip()
# ---- D 布局(原地卡片流)----
# 2026-10-17 作者在原型 v7 定稿(artifact 5atKbvKuKib8uBLTGwhR8x)。三件事:
# ① 草稿并进最底那一行(由 `caption_flow.caption_rows` 决定喂什么, 本模块只管画);
# ② 新行**原地显影**(淡入 + 模糊→清晰, 零位移), 老行照旧上推 —— 参考 Claude 桌面端
# `_grapheme-reveal`(opacity 0→1 + blur→0, 不位移)。新行晚 `D_ENTER_DELAY_S` 起跑,
# 等被顶上去的那一行先让开(原型实测不等会叠 20–57px)。
# (原型还有一块垫在最底那一行后面的固定底板; 10-17 真窗口 A/B 后作者定: 不要。)
#: 按**视觉层号**(层号减去还没走完的位移)给整行降亮度: 当前 1.0 → 上一句 0.72 → 更早 0.56。
#: 数值取自原型的颜色表(中文 .98 / .68 / .55 的相对比例)。只在跟随态生效 —— 往回翻时全亮。
D_DIM = (1.0, 0.72, 0.56)
#: 当前行是**草稿**时中文再暗一档(原型 plainZh .72 / 定稿 .98); 升级为定稿时按 STATE_S 提亮。
D_DRAFT_ZH_DIM = 0.74
D_ENTER_DELAY_S = 0.08
D_ENTER_S = motion.MOVE_S - D_ENTER_DELAY_S # 与位移同时走完
D_BLUR_PX = 4.0
#: 实时行**新出现的字**从这个透明度淡到 1(原型 v7 的 `dIns`: .2 → 1, 260ms)——
#: 流式中文每次只来两三个字, 直接冒出来就是「跳字」; 淡进来读着是连续的。
D_TYPE_FROM = 0.2
D_TYPE_S = 0.26
#: **改字**(不是往后长)的新字从这个亮度起淡,不从 `D_TYPE_FROM`。
#: ⚠️ Owen 10-17:草稿译文换成定稿译文时「整句突然消失一下再出现」—— 措辞一换,大半个字
#: 同时掉到 20% 再长回来,看上去就是整句闪没了。往后长的新字照旧从 20% 起(那是「新来的」
#: 信号);改掉的字只做一次轻淡换(0.65 → 1),屏上的墨量不再塌下去。
D_REVISE_FROM = 0.65
def _revise_shift() -> float:
"""让 `D_TYPE_FROM` 起的那条曲线**从 `D_REVISE_FROM` 起跑**要把出生时刻往前挪多少秒。"""
target = (D_REVISE_FROM - D_TYPE_FROM) / (1.0 - D_TYPE_FROM)
lo, hi = 0.0, 1.0
for _ in range(40):
mid = (lo + hi) / 2
if motion.ease_out(mid) < target:
lo = mid
else:
hi = mid
return hi * D_TYPE_S
_REVISE_SHIFT = _revise_shift()
def carry_births(prev: str, births: list, display: str, now: float, *,
min_run: int = 2, soft: float = 0.0) -> list:
"""上一版每个字的出现时刻 → 新版每个字的出现时刻(新字记 `now`, 旧字原样带过去)。
按**字**对齐、忽略换行: 中文折行点会随句子变长挪动(`_shape_zh`), 英文开头截断
每来一个词就挪一次 —— 字没变、只是位置变了, 不能当新字再淡一遍(Owen 10-17:
「没改词, 后半句还是刷新一下」)。对齐用 `SequenceMatcher`, 短于 `min_run` 的
巧合匹配(整句换掉时偶然相同的「的」)不算, 免得一句新话里零星几个字不淡。
"""
import difflib
pc = [(c, b) for c, b in zip(prev, births) if c != "\n"]
keep = [i for i, c in enumerate(display) if c != "\n"]
dc = "".join(display[i] for i in keep)
out = [0.0] * len(display)
for i in keep:
out[i] = now
sm = difflib.SequenceMatcher(None, "".join(c for c, _ in pc), dc, autojunk=False)
last_a = last_b = 0 # 最后一段算数的匹配之后,新旧各从哪开始
matched = False
for a, b, n in sm.get_matching_blocks():
if n >= min_run or (n and a == 0 and b == 0):
matched = True
last_a, last_b = a + n, b + n
for k in range(n):
out[keep[b + k]] = pc[a + k][1]
if soft and matched:
# 改字 vs 往后长:新字在最后一段匹配**之前**(夹在中间被换掉的)→ 改字;
# 在之后、且旧版那里本来还有字(尾巴被换掉)→ 改字;旧版尾巴完整、只是后面多出来 → 新长的。
tail_replaced = last_a < len(pc)
for bi, i in enumerate(keep):
if out[i] == now and (bi < last_b or tail_replaced):
out[i] = now - soft
return out
def d_dim(x: float) -> float:
"""视觉层号 → 亮度倍数(分段线性, 位移途中连续变化 —— 不跳亮度)。纯函数。"""
if x <= 0.0:
return D_DIM[0]
if x < 1.0:
return D_DIM[0] + (D_DIM[1] - D_DIM[0]) * x
if x < 2.0:
return D_DIM[1] + (D_DIM[2] - D_DIM[1]) * (x - 1.0)
return D_DIM[2]
# ---- 双语行的竖排顺序 ----
# True = 英文小字在上、中文大字在下(当前) False = 反过来(2026-09-24 之前)
#
# 为什么换: 三条独立同行评审研究(SSLA / Bilingualism: L&C / JoSTrans)都发现双语
# 字幕里 L2 行被系统性略读, 而**唯一被证实能改变注意分配的是"哪一行在上面"**,
# 不是字重/字号/颜色(同批研究还报告注视时长与理解成绩无显著相关)。作者考试是
# 英文的, 所以把 L2 行放上去。想回退改这一个常量。
# 只作用于**双语行**; 答案行与"翻译关闭时英文顶上"的行仍是大字在上(见 _layout)。
EN_LINE_ON_TOP = True
#: 英文小字那一格的行数(3.11,Owen 10-19:「英文最少两行、中文也两行」)。
#: 英文**贴着自己的中文排**(底对齐):只有一行时空出来的那行落在这一行的**顶上**,
#: 成了两段之间的间距 —— 不夹在英文和中文之间(邻近律:英文要离自己的中文更近)。
EN_SLOT_LINES = 2
#: 英文格顶上留的段间距:英文两行写满时,它离**上一段的中文**也得比离自己的中文远
#: (10-19 拍图:不留这一条时上一段中文底 → 英文顶只有 18 px,英文底 → 自己的中文 22 px,分组读反)。
EN_SLOT_TOP = 12.0
def _view_classes():
"""两个 ObjC 视图子类。
⚠️ 类名由 `objc_own` 生成(key `TranscriptScroll` / `TranscriptDoc`)——
**调用方从不提名,所以撞不了名**。以前这里是「模块级全局 + 手挑名字 +
自己写闩锁」,那种写法在 2026-09-26 真出过一次静默事故(见 objc_own 文件头)。
"""
import objc
def scroll_wheel(self, event): # noqa: N802
# 这个开关**不再区分收起/展开**(2026-09-24 解耦,见 set_collapsed):
# 内容永远溢出,所以恒为 True —— 保留它只是为了留一个统一的闸门,
# 以及保住下面这条注释里的坑。
# 不用 setIgnoresMouseEvents_ —— 那会连带杀掉内容区的拖拽移动,
# 且和悬浮窗「鼠标穿透」同类的单向死锁。
# (NSScrollView 没有 setScrollEnabled_,所以开关放在自己的标志位上。
# PyObjC 的 ObjC 子类必须用 objc.super,内置 super() 不认。)
# ⚠️ 用 `cls_of(key)` 取回本类,**别写 `type(self)`** —— 子类化时行为会变。
if getattr(self, "_scroll_enabled", True):
objc.super(objc_own.cls_of("TranscriptScroll"), self).scrollWheel_(event)
def doc_mouse_down(self, event): # noqa: N802
# 整个转录区都是拖拽面。不赌 AppKit「背景拖拽」的启发式认不认
# NSScrollView;滚轮和按键拖拽是两条独立事件流,互不干扰。
win = self.window()
if win is not None:
win.performWindowDragWithEvent_(event)
from AppKit import NSScrollView, NSView
return (
objc_own.own("TranscriptScroll", NSScrollView, {"scrollWheel_": scroll_wheel}),
objc_own.own("TranscriptDoc", NSView, {"mouseDown_": doc_mouse_down}),
)
class LineLog:
"""已定稿字幕行的**身份账本**:行内容 `rows` + 每行一个自增**身份号** `ids`。
为什么要身份号(跨段半句合并,见 `fragment_merge`):流程是「尾句先上屏 →
下一段首句上屏 → 重翻回来后替换尾句那一行、并吞掉首句那一行」。从触发到落地
之间屏上可能又插进了别的行(同段的后几句 / 答案接管换渲染源),**行号会漂**;
身份号不会 —— `replace_pair` 按 id 定位,插多少行都对。
⚠️ 纯逻辑、**不碰 AppKit**:判据可以直接构造它(`tests/test_merge_replace.py`)。
⚠️ `rows` 与 `ids` **永远等长且同序**,且 `replace_pair` **原地改**(保持 list 身份
—— `Overlay._history` 就是 `rows` 本身,外部引用不许失效)。
"""
def __init__(self) -> None:
self.rows: list[tuple[str, str]] = []
self.ids: list[int] = []
self._seq = 0
#: 上一次 `replace_pair` **删掉**的那一行在**调用前**的索引(失败 = `None`)。
#: ⭐ 给半句合并的过渡用:被吞掉那一行的**层号**只能由这个索引 + 当时的行数算
#: 出来(`Overlay._render` 拿不到索引,它只看得见 `rows` 的内容)。
self.last_deleted: int | None = None
def append(self, en: str, zh: str) -> int:
"""追加一行 → 返回它的身份号。"""
self._seq += 1
self.rows.append((en, zh))
self.ids.append(self._seq)
return self._seq
def amend_last(self, en: str, zh: str) -> int | None:
"""把最后一行换成 `(en, zh)`,身份号不变(短尾巴并进上一行用)。没有行 -> `None`。
行数不变 -> `_set_items` 不起滚动画,只是那一行文字长了。"""
if not self.rows:
return None
self.rows[-1] = (en, zh)
return self.ids[-1]
def replace_pair(self, tail_id, next_id, en: str, zh: str) -> bool:
"""把 `tail_id` 那一行换成 `(en, zh)`、**并删掉** `next_id` 那一行(一次原子改两行)。
返回是否真的改了。定位不到 / 两个 id 相同 / `next` 在 `tail` 之前 → 返回 `False`
(**一个字节都不动** —— 重翻回来时行可能已被别的路径改过,宁可不动)。
⚠️ 行数**减一**:`_set_items` 只在**变多**时记「新句到」并起滚,所以替换不会
触发上滚动画(与 Reduce Motion 无关 —— 结构上就没动画)。
⚠️ 成功时顺带把**删除位置**记进 `last_deleted`(给 `transcript_view` 的
半句合并过渡定位"被吞掉那一行"用;返回 `bool` 不变 —— 索引 0 是假值,
改返回值会把 `tests/test_merge_replace.py` 的 `assertTrue/assertFalse` 弄假)。
"""
self.last_deleted = None
if tail_id is None or next_id is None or tail_id == next_id:
return False
try:
i = self.ids.index(tail_id)
j = self.ids.index(next_id)
except ValueError:
return False
if j < i:
return False
self.rows[i] = (en, zh)
del self.rows[j]
del self.ids[j]
self.last_deleted = j
return True
class TranscriptView:
def __init__(self, parent, make_label, font_zh, font_en, font_en_big,
width, pad, en_h, gap, zh_h, row_h, max_scroll_h,
on_follow_change=None, *, measure, wrap_zh=None, zh_line_h=None):
ScrollCls, DocCls = _view_classes()
self._parent = parent
self._make_label = make_label
self._font_zh = font_zh
self._font_en = font_en
self._font_en_big = font_en_big
self._width = width
self._pad = pad
self._en_h, self._gap, self._zh_h, self._row_h = en_h, gap, zh_h, row_h
# ⚠️ 量文字高度的**注入**函数(`measure(text, width, font) -> px`), 与
# `make_label` 同一个套路 —— 本模块**不能** import overlay(会成环),
# 注入才能既拿到同一把尺、又不引入依赖。
# 用途只有一个: 把"文本超出档位行数"这个**静默裁切**变成可观测(见 _set_text)。
self._measure = measure
# 中文那格的**固定行高**(pt;None = 字体自带的 21)。3.11 起 18pt 中文用 1.3 倍行距:
# 渲染(`_zh_attr`)与所有量中文的地方(`_measure_zh`)必须同一个值,否则折行数对不上。
self._zh_line_h = zh_line_h
# 中文折行(`wrap_zh(text, label_w, final) -> 插好换行的文本`), 见 linebreak.py。
# 注入而不是在这里 import: 它要量字宽, 尺子(字体)归 overlay 管。None = 交给 AppKit 逐字折。
self._wrap_zh = wrap_zh
self._wrap_cache: dict = {}
self._wrap_mode = font_zh[2] # 中文那格的折行方式(按词折), 英文两行时借用
self._line_h: dict = {} # 字体 -> 一行高(实测, 懒量)
self._en2_cache: dict = {}
# 观测计数器: 超出档位行数的次数。⚠️ 设它是为了**不再静默** ——
# 档位行数来自一个闭样本(p100 of 4797 句, 见 overlay.py 的行数分档注释),
# 一旦真实文本比样本更长, 原来会**无声无息**被裁掉(既不报错也不给省略号)。
self._overflow_n = 0
self._zh_seen_n = 0 # 分母: 量过的译文次数(同口径, 见 `_note_overflow`)
self._overflow_warned = False
self._on_follow_change = on_follow_change or (lambda follow: None)
self._items: list[tuple[str, str]] = [] # 已定稿 [(en, zh)]
self._live: tuple[str, str] | None = None # 流式当前行
self._live_visible = False
self._follow = True
self._expected_origin = 0.0
#: 离开底部以后**定稿了几句**(0 = 在底部)。给「↓ N 句新的」用;
#: 它只数**定稿追加**(`_set_items` 里 `n > len(_items)`)—— 那是读者关心的
#: "下面又有几句新话",而实时行显形/消失只是**几何**变化(由 `_paint` 的
#: 浏览保护按行数差补位移,见那里)。
self._new_since_leave = 0
self._hold = False # 答案接管期间: 不自动回底(见 set_scroll_hold)
# ---- 新行入列的上滚(见 _start_roll) ----
# 时钟与「减弱动态效果」都可注入: 位移是时间的纯函数, 探针可以确定性地断言每一帧。
self._clock = time.monotonic
self._reduce_motion = motion.reduce_motion
self._animate_ok = lambda: True # 宿主说「有人逐帧推 tick 吗」; 没有就直接落位(见 Overlay)
self._painted_count = 0 # 上一次 _paint 时的行数(含实时行) -> 判「多了几行」
self._painted_items = 0 # 上一次 _paint 时的**历史行**数 -> 判「上一条新行」是谁(A5)
self._painted_live = 0 # 上一次 _paint 时实时行在不在(1/0) -> 同上, 也用于幽灵的层号
# ⭐ 2026-10-18 审查 C11:上一次 _paint 时**实时行的文本** —— 判"这一帧新出现的行"
# 是在 level 0(实时行本身变了 / 刚出现)还是在它**上面一格**(历史多了一行、
# 实时行没变)。`(✓,✓)` 那条路(草稿已是下一段、上一段定稿刚开始流)就是后者。
self._painted_live_text = None
self._count_reset = False # 整体换内容(replace_items)那一轮不算「多了几行」
self._roll_t0 = None # 动画起跑时刻; None = 没在动
self._roll_from = 0.0 # 起跑时内容往下偏多少 px(终点恒为 0; **负数 = 往上偏**)
self._roll_off = 0.0 # 当前帧的偏移, _geom 摆每个槽位时减掉它
self._roll_off_lo = 0 # 偏移**从第几层起**施加(半句合并时最底那行要留在原地)
self._roll_fade_lo = 0 # 淡入名单的**起始层**(A5: 见 _start_roll 的 n/lo)
self._roll_fade_n = 0 # 减弱动态效果: 这几行淡入, 不位移
self._roll_fade_s = NEW_LINE_FADE_S # 淡入时长(半句合并换成 MERGE_FADE_S)
self._roll_alpha = 1.0
self._ghost_j = None # 半句合并: 「被吞掉那一行」占着的槽位(原地淡出)
self._merge_pending = 0 # 跨段半句合并待落地几次(见 note_merge / _paint)
self._merge_deleted = None # 被吞掉那一行在**合并前**的行号(LineLog.last_deleted)
self._verbatim = False # 行 = (大字, 小字) 原样, 不做中英对调(见 set_rows_verbatim)
# ---- D 布局(见模块头「D 布局」)----
self._d = False # set_d_style 打开
self._zh_expected = True # 宿主: 这一档有中文吗(只英 / 原文档 = False)
self._live_kind = "final" # 实时行是草稿还是定稿("draft" / "final")
self._d_enter = False # 这一次上滚是 D 的「原地显影」
self._lzd = 1.0 # 实时行中文的亮度倍数(草稿 0.74 / 定稿 1.0), 带过渡
#: 实时行新字淡入: (槽位, "zh"/"en") -> (显示文本, 每个字的出现时刻)。空 = 没在淡。
self._type: dict = {}
self._lzd_from, self._lzd_to, self._lzd_t0 = 1.0, 1.0, None
self._dirty = False
self._urgent = False
self._last_flush = 0.0
self._doc_h = -1.0
self._doc_w = -1.0
self._scroll = ScrollCls.alloc().initWithFrame_(((0.0, 0.0), (width, 210.0)))
# 默认 NSClipView 会画不透明底色, 会把毛玻璃和 scrim 整个盖掉
# (最可能的"看起来坏了")。两处都要关。
self._scroll.setDrawsBackground_(False)
self._scroll.contentView().setDrawsBackground_(False)
self._scroll.setHasHorizontalScroller_(False)
self._scroll.setHorizontalScrollElasticity_(0) # 0 = None
self._scroll.setUsesPredominantAxisScrolling_(True)
self._scroll.setAutohidesScrollers_(True)
self._scroll._scroll_enabled = True
self._scroll.setVerticalScrollElasticity_(0)
parent.addSubview_(self._scroll)
self._doc = DocCls.alloc().initWithFrame_(((0.0, 0.0), (width, 210.0)))
self._scroll.setDocumentView_(self._doc)
# 池: 一次建好, 按展开态最大视口定初始容量(K = ceil(max_h/row_h)+2)。
# ⚠️ 这只是**初始**容量 —— 若之后换到更大的外接屏, 展开后视口会比 K 高,
# 顶部就会露空白行。所以 _paint 每轮按实际视口高度调 _ensure_pool 兜底。
self._slots = []
self._cache = []
# ⭐ **当前档位的行数上限, 池里每个槽位都得用它** —— 构造参数给的那套
# (ROW_ZH_H / ROW_H) 就是 2 行档, 之后 `set_row_metrics` 会覆盖。
# ⚠️ 必须存成状态, 不能靠 `_add_slot` 写死: `_ensure_pool` 会在**视口变大时**
# 继续新增槽位, 而那些新槽位没有机会被 `set_row_metrics` 回填 —— 窄窗
# (档位 3/4/5 行)下它们只允许 2 行, 第 3 行起**静默裁掉且不给省略号**。
# (2026-09-27 OCR 抓出, 与 overlay.py 的草稿标签是同一类缺陷。)
self._lines = 2
for j in range(int(math.ceil(max_scroll_h / row_h)) + 2):
self._add_slot()
def _add_slot(self) -> None:
zh = self._make_label(18.0, self._font_zh[1], self._lines, self._font_zh[2])
en = self._make_label(11.0, self._font_en[1], 1, self._font_en[2])
# 英文小字可能借中文那格的空行排成两行(见 _en_two_lines)。行尾省略号靠这个开关:
# TruncatingTail 会**强制单行**, 所以换成「按词折 + 最后一行截断」。
try:
en.setLineBreakMode_(self._wrap_mode)
en.cell().setTruncatesLastVisibleLine_(True)
except Exception: # noqa: BLE001
pass
zh.setHidden_(True); en.setHidden_(True)
self._doc.addSubview_(zh)
self._doc.addSubview_(en)
self._slots.append({"zh": zh, "en": en, "level": None, "hidden": True,
"en_top": None, "en2": False, "ghost": False})
self._cache.append({})
def _ensure_pool(self, view_h: float) -> None:
"""按当前视口高度保证池够用(池只增不减 —— 换屏幕是极低频事件)。"""
need = int(math.ceil(view_h / self._row_h)) + 2
while len(self._slots) < need:
self._add_slot()
# ---- 对外: 数据 ----
def set_content(self, items, en: str, zh: str, live_visible: bool,
live_kind: str = "final") -> None:
"""**原子**写入历史 + 实时行, 只画一次。
⚠️ 必须原子。拆成 set_items() + set_live() 两次调用时, 两次绘制之间会出现
两种坏帧 —— 因为固定行高下, 实时行的显隐**不改变布局**, 两者会同时可见:
① 实时行已清、历史还没接上 -> 最新一句凭空少一行(闪丢)
② 实时行既在历史末尾又在实时位 -> 最新一句**重复**(finalize 时必现)
这正是"定稿瞬间屏上多一行、最新句画两遍"的成因。
"""
self._set_live_kind(live_kind if live_visible else "final")
self._set_items(list(items), (en, zh) if live_visible else None,
bool(live_visible), bulk=False)
# ---- 对外: D 布局 ----
def set_d_style(self, on: bool) -> None:
"""打开 / 关掉 D 布局的画法(按层降亮 + 原地显影 + 新字淡入)。
数据怎么喂不归这里管(`caption_flow.caption_rows`); 接管(答疑 / 纲要)期间
宿主照样开着它 —— D 的画法只在 `not _verbatim` 时生效。"""
on = bool(on)
if on == self._d:
return
self._d = on
self._end_roll()
for j, s in enumerate(self._slots):
if s["level"] is not None:
self._geom(j)
self.mark_dirty(urgent=True)
def set_zh_expected(self, on: bool) -> None:
"""宿主告诉视图「这一档会有中文」(双语档 True; 只英 / 原文档 False)。"""
on = bool(on)
if on != self._zh_expected:
self._zh_expected = on
self.mark_dirty(urgent=True)
def _set_live_kind(self, kind: str) -> None:
kind = "draft" if kind == "draft" else "final"
if kind == self._live_kind:
return
self._live_kind = kind
to = D_DRAFT_ZH_DIM if kind == "draft" else 1.0
if self._reduce_motion() or not self._animate_ok():
self._lzd = self._lzd_from = self._lzd_to = to
self._lzd_t0 = None
else:
self._lzd_from, self._lzd_to, self._lzd_t0 = self._lzd, to, self._clock()
def _step_lzd(self) -> None:
e = motion.ease_out(motion.progress(self._lzd_t0, self._clock(), motion.STATE_S))
self._lzd = self._lzd_from + (self._lzd_to - self._lzd_from) * e
if e >= 1.0:
self._lzd_t0 = None
for j, s in enumerate(self._slots):
if s["level"] == 0:
self._geom(j)
def replace_items(self, items) -> None:
"""**整体替换**内容(答案接管 / 退出接管), 不做追加记账。
为什么不能直接用 set_content: 它把"条数变多"一律当成"字幕追加了新句"。
答案接管时条数会从 N 条字幕整体换成 M 条答案行、退出时再换回来, 差值本来
没有语义, 却会凭空记成"来了 N-M 句新话"(「↓ N 句新的」会亮起来一个假数,
浏览保护也会把视口推到一个无意义的位置)。换内容 = 换一整套坐标系,
记账必须清零重来。"""
self._set_items(list(items), None, False, bulk=True)
def _set_items(self, items, live, live_visible: bool, bulk: bool) -> None:
n = len(items)
# ⭐ 2026-10-18 审查 C2:接管期间(`_hold`,答案 / 纲要)**不算追加** ——
# 那些"行"是流式长出来的答案行(`_answer_rows.append`),不是定稿的新句。
# 原来只判 `not _follow`(接管把 `_follow` 也置假了)→ 每帧累加,
# overlay 的「↓ N 句新的」在**读答案**时亮起一个假的数、点它还会跳到底部。
if (not bulk and n > len(self._items) and not self._follow
and not self._hold):
# "只有真的有新内容才算":新句子(定稿)才算 —— 流式增量不算(行高固定,
# 实时行增长不会移动文档)。**在底部时不计**(跟随态没人需要这个数)。
self._new_since_leave += n - len(self._items)
elif bulk:
self._new_since_leave = 0
self._count_reset = True
self._end_roll()
self._items = items
self._live = live
self._live_visible = live_visible
self.mark_dirty(urgent=True)
def set_items(self, items) -> None:
self.set_content(items, *(self._live or ("", "")), self._live_visible)
def set_live(self, en: str, zh: str, visible: bool) -> None:
self.set_content(self._items, en, zh, visible)
def set_rows_verbatim(self, on: bool) -> None:
"""行内容是否**原样解释**为 (大字, 小字)。
默认(False)是字幕卡片的语义: 元组是 (英文, 中文), 中文进 18pt 大字位、
英文进 11pt 小字位, 中文为空时英文顶上去。答案行要的正好相反(英文在
大字位、`中:`点睛在小字位), 所以给它一条显式通道 —— 而不是靠"把中文
塞进第一个字段"这种反向 trick 去骗过 _row_texts。"""
self._verbatim = bool(on)
self.mark_dirty(urgent=True)
def set_scroll_hold(self, on: bool) -> None:
"""接管期间冻住自动滚动: 不自动跟随。
⚠️ 为什么必须显式关: 答案可能比视口长得多, 读长答案时 `tick()` 有一条路径
会**主动移动视口** —— 视口一到 0(内容短于视口, 顶部=底部)就被判成"用户回到
底部"而重新跟随, 内容一长就每帧钉回底部。用户仍然可以自由滚动(回底只由
用户触发, 见文件头「视口归谁」)。"""
self._hold = bool(on)
if on:
self._follow = False
self._new_since_leave = 0
self._expected_origin = self._scroll.contentView().bounds().origin.y
self._on_follow_change(False)
def overflow_stats(self) -> dict:
"""译文超限的**次数与分母**(进测试报告 `blocks.ui.zh_overflow`)。
⚠️ 单位是「译文行**文本变了**的那一次」, 不是句子 —— 同一句草稿译文改几次就量几次。
拿它判「F 值不值得做」时比的是 `overflow / seen`, 别拿 `overflow` 去除句数。
"""
return {"overflow": self._overflow_n, "seen": self._zh_seen_n, "lines": self._lines}
def text_width(self) -> float:
"""文档视图当前宽度 —— **折行实测**用的宽度(不是面板宽度猜出来的值:
展开态挂竖向滚动条时 clip 会比面板窄)。"""
w = self._doc.frame().size.width
return w if w > 40.0 else self._width - 2 * self._pad
def set_row_metrics(self, zh_h: float, lines: int, en_h: float | None = None) -> None:
"""行尺寸随窗口宽度变(窄窗里中文要更多行才不吞字)。
⚠️ 变的只是"等高的那个值", **所有行依然等高** —— 回收池依赖的不变量是
"所有行等高", 不是"高 == 70", 所以换一组统一尺寸是安全的。
池的容量 K=ceil(视口高/行高)+2 会跟着变小, 多余的槽位被 `_paint` 藏起来,
不够时 `_ensure_pool` 会补 —— 两个方向都不用额外处理。
槽位必须**重排**: 清掉每个槽位的 level, `_paint` 会把它们按新行高重新放置。
文字缓存不清(文本没变, 变的只有 frame), 所以 `_set_text` 会照旧跳过。
⚠️ **行距(`row_h`)不收参数, 由行内三段推出来** —— 同一个几何量不能有两个
来源: 上游若改了公式(加余量 / 改字重)而两份各自漂开, 表现是行与行**静默**
重叠或留缝, 没有任何报错。行内三段(en_h / gap / zh_h)构造时就收到了,
推得出来就推。(2026-09-27 OCR 指出这条双来源。)
"""
en_h = self._en_h if en_h is None else float(en_h) # 只英 / 纯转录档收起英文那格
row_h = en_h + self._gap + float(zh_h) # ← 唯一来源, 见 docstring
if abs(float(row_h) - self._row_h) < 0.5:
return
self._end_roll() # 行高一变, 进行中的位移就没有意义了 —— 直接落位
self._en_h = en_h
self._zh_h = float(zh_h)
self._row_h = float(row_h)
self._lines = int(lines) # 见 __init__: 供 _ensure_pool 新增的槽位用
for s in self._slots:
s["level"] = None
s["zh"].setMaximumNumberOfLines_(int(lines))
self._doc_h = -1.0
# ⚠️ 行高变了 -> 内容总高按新 row_h 重算, 而 clip origin 的**数值**不变,
# 它代表的却是另一行了。若不回写 _expected_origin, 下一个 tick 会把这个
# 位移误判成"用户滚动" -> 跟随状态机被无端打断, 可见的句子会跳一下。
# 只在**离开底部**时才需要回写(跟随态本来就钉在 0, 不需要动)。
if not self._follow:
self._expected_origin = self._scroll.contentView().bounds().origin.y
self.mark_dirty(urgent=True)
def set_width(self, width: float) -> None:
"""面板宽度变了。
槽位与文档视图的宽度在 `_paint` 里由 clip 的**实际**宽度决定, 会自动跟上;
这里只需记住新宽度 —— 供 `set_frame()` 和 `text_width()` 的兜底值使用。
行高(`_row_h`)不跟着变: 回收池的不变量是"所有行等高", 与宽度无关。
"""
if abs(float(width) - self._width) < 0.5:
return
self._width = float(width)
self.mark_dirty(urgent=True)
def set_frame(self, y: float, h: float) -> None:
self._scroll.setFrame_(((self._pad, y), (self._width - 2 * self._pad, h)))
# ⭐ 顶边渐隐(作者 2026-10-17):视口一变就重贴一次遮罩 —— 这里是**唯一**
# 知道"字幕区现在多大"的地方。卡片开着时这条边就是**卡片底下那条字幕区的
# 顶边**(被裁的那半行落在这条带里,不再看着像坏了);没卡时它是窗口内容
# 区的顶边 —— 同一条边、同一份 7 档(`edge_fade`)。
edge_fade.apply(self._scroll, edge="top")
self.mark_dirty(urgent=True)
def set_collapsed(self, collapsed: bool) -> None:
from AppKit import NSScrollElasticityAutomatic
# ⚠️ 滚动权限**与收起/展开解耦**(2026-09-24 改): 内容永远溢出(一节真实课
# 1000+ 句 vs 可见 3 行), 所以**始终允许滚动**。
# 原先写成 `_scroll_enabled = not collapsed`, 后果是"把窗口拉大也不能滚"——
# 于是顶栏那个展开按钮被迫承担"解锁滚动"的职责(它的名字里完全没写这件事)。
# 解耦后: 拉窗口 = 看几句; 滚动 = 往回翻。两个正交。
# (橡皮筋一并常开; `setAutohidesScrollers_(True)` 在构造里已设, 滚动条不会常驻。)
self._scroll._scroll_enabled = True
self._scroll.setHasVerticalScroller_(True)
self._scroll.setVerticalScrollElasticity_(NSScrollElasticityAutomatic)
if collapsed:
self.scroll_to_bottom()
self.mark_dirty(urgent=True)
# ---- 对外: 滚动 ----
def is_at_bottom(self) -> bool:
return self._scroll.contentView().bounds().origin.y <= BOTTOM_EPS
def scroll_to_bottom(self) -> None:
clip = self._scroll.contentView()
clip.scrollToPoint_((0.0, 0.0))
self._expected_origin = clip.bounds().origin.y
self._new_since_leave = 0
if not self._follow:
self._follow = True
self._on_follow_change(True)
self._regeom() # ⭐ 审查 C10:回到跟随态 -> 当场恢复按层降亮
def scroll_to_top(self) -> None:
"""滚到文档**顶部**(最早的一行) —— 答案接管时的落点。
字幕流是"最新在最下", 竖着读的文档不是: 答案要从第一行读起。取 doc 视图
自己的高度(不翻转坐标系, origin.y = 0 是底部, 所以顶部 = docH - 视口高)。"""
clip = self._scroll.contentView()
h = self._doc.frame().size.height
clip.scrollToPoint_((0.0, max(0.0, h - clip.bounds().size.height)))
self._expected_origin = clip.bounds().origin.y
self._new_since_leave = 0
def scroll_to_index(self, index: int) -> None:
"""把**第 `index` 条**滚到**视口顶部**(`items[0]` = 屏幕上最上面那行)。
给课堂纲要的「上次看到这里」用: 打开纲要要**落在分隔线上**,
而不是落在最新一行(那正是 `scroll_to_bottom` 干的事)。
⚠️ 三条硬约束 —— 与 `scroll_to_top` / `scroll_to_bottom` **逐字一致**:
1. **所有行等高**是这个回收池的不变量(`_layout` 里 `y = level * row_h`)
→ 位置就是算出来的,不需要问任何一行真实 frame。
2. **坐标不翻转**: `origin.y = 0` 是**底部**,`level L` 在 `y = L * row_h`
→ 「顶」是 y **大**的那边,所以最后要 `clamp` 到 `[0, docH - 视口高]`。
⚠️ 不 clamp 的话,分隔线靠近文档头时 `scrollToPoint_` 会被 AppKit
夹回边界、而**我们记的 `_expected_origin` 就成了假的**。
3. ⚠️⚠️ **滚完必须回写 `self._expected_origin = clip.bounds().origin.y`** ——
不写的话 `tick()` 会把这次**程序滚动**误判成**用户滚动**、顺手把跟随关掉
(`scroll_to_top` / `scroll_to_bottom` 都这么做,这是既有契约)。
⚠️ `index` 越界一律 clamp,**不抛** —— 打开纲要时快照可能刚被换过。
"""
clip = self._scroll.contentView()
n = len(self._items)
index = max(0, min(int(index), max(0, n - 1)))
# `_place` 的约定: 第 level 层 = 第 (count - 1 - level) 行 → 反过来。
level = max(0, n - 1 - index)
vh = clip.bounds().size.height
h = self._doc.frame().size.height
# 目标: 这一行的**上边缘**贴视口顶。行占 `[L*row_h, (L+1)*row_h]`。
y = (level + 1) * self._row_h - vh
y = max(0.0, min(y, max(0.0, h - vh)))
clip.scrollToPoint_((0.0, y))
self._expected_origin = clip.bounds().origin.y
self._new_since_leave = 0
@property
def following(self) -> bool:
return self._follow
@property
def new_since_leave(self) -> int:
"""离开底部以后**定稿了几句**(0 = 在底部 / 没人需要这个数)。
给宿主画「↓ N 句新的」用 —— 那是**提示**,不是自动回底: 回底只由用户
自己滚到底或点那个按钮触发(见文件头「视口归谁」)。"""
return int(self._new_since_leave)
# ---- 对外: 驱动 ----
def mark_dirty(self, urgent: bool = False) -> None:
self._dirty = True
if urgent:
self._urgent = True
self.flush_if_due()
def flush_if_due(self) -> None:
if not self._dirty:
return
if not self._urgent:
now = time.monotonic()
if now - self._last_flush < FLUSH_DT:
return
self._dirty = False
self._urgent = False
self._last_flush = time.monotonic()
self._paint()
def tick(self) -> None:
"""跟随状态机。轮询 clip origin 与期望值比较 —— 不用通知:
①不用持有 ObjC 观察者(弱引用 GC 陷阱); ②没有重入(bounds 通知会在
我们自己 scrollToPoint_ 期间触发, 正是把程序滚动误判成用户滚动的环);
③惯性自然处理: 每个 tick 都在动就持续刷新计时, 停下后计时才开始。
新行入列的上滚也在这里逐帧推进(pump 每轮都调 tick)。
⭐ 2026-10-17: 用户滚动的判定搬进 `_sync_user_origin`(`_paint` 也要过它,
理由见那个函数)。本函数只剩「翻状态」这一半。"""
if self._roll_t0 is not None:
self._step_roll()
if self._lzd_t0 is not None:
self._step_lzd()
if self._type:
self._step_type()
clip = self._scroll.contentView()
if self._sync_user_origin(clip):
# 滚动改变了可见 level 区间, 池必须跟着重算 —— 否则滚过 K-2 行后
# 新露出的位置没有槽位, 会是空白。
# ⚠️ 必须 urgent: 滚动是**离散的用户事件**, 和定稿/术语同级, 不能排在
# 16ms 合并闸门后面。实测非紧急时 scroll→paint 延迟 avg 7.5ms /
# max 17.0ms, 有 11% 的帧里可见区间缺槽位 —— 那就是"滚动帧率低"的真因
# (不是 _paint 慢: 实测单次 0.01ms; tick 0.003ms)。
# 合并闸门是给流式 token 增量用的, 那些才该合并。
self.mark_dirty(urgent=True)
return
if not self._follow:
# ⚠️ `_hold`(答案接管)期间自动回底必须关掉: "视口回到 0" 在答案比视口短时
# **恒成立**(顶部即底部), 一旦据此重新跟随, 内容长过视口后每帧都会被钉回底部。
# ⚠️ 「闲置 6s 回底」那条 2026-10-17 **删了**(探针实测: 读着读着整屏被拽走,
# 见文件头「视口归谁」)—— 回底只由用户触发。
if not self._hold and clip.bounds().origin.y <= BOTTOM_EPS: # 用户自己滚回底部
self._follow = True
self._new_since_leave = 0
self._on_follow_change(True)
# ⭐ 审查 C10:滚回底部要**当场**恢复按层降亮(同上,`_paint` 不会重算
# 已在原槽位的行)。
self._regeom()
def _sync_user_origin(self, clip) -> bool:
"""视口位置与程序记的期望值不符 -> 那是**用户在滚**, 视口归他。返回是否刚接手。
⚠️ 为什么 `tick` 与 `_paint` **都**要过这里: 只在 tick 里判不够 —— 程序滚动
(钉底 / 浏览保护)也能发生在两次 tick 之间。真课路径: 空闲档的让步
(`Overlay._wait_interruptible`)里 `nextEventMatchingMask` 当场派发了滚轮 →
clip 先动, 紧接着 `drain()` 里一个定稿触发 `_paint` → 老代码的钉底把视口拽回
0 **并把 `_expected_origin` 也写成 0** → 下一个 tick 读到"没动过" → 用户的滚动
被吞掉、跟随永远翻不了假(`scripts/probe_scroll_flash.py` 与最小复现都钉着)。
判据放在**每一次程序滚动之前**, 这种时序就不存在了。
⚠️ 只改状态, **不** `mark_dirty` —— `tick` 的调用方要重算槽位池(它自己
`mark_dirty(urgent=True)`), 而 `_paint` 的调用方正在画(再标一次会递归)。
"""
cur = clip.bounds().origin.y
if abs(cur - self._expected_origin) <= MOVE_EPS:
return False
if self._follow and cur > BOTTOM_EPS:
self._end_roll() # 用户一滚就交还给他, 不和他抢
self._follow = False
self._new_since_leave = 0
self._on_follow_change(False)
# ⭐ 2026-10-18 审查 C10:D 的按层降亮在 `_geom` 里写(只在跟随态生效)——
# `_follow` 一翻假,屏上**已在原槽位**的行不会重算(`_paint` 只摆新进来的),
# 于是它们的亮度停在旧值(往回翻时本该全亮)。当场重算一次。
self._regeom()
self._expected_origin = cur
return True
# ---- 渲染 ----
def _row_texts(self, r: int):
"""r 行 → (大字文本, 小字文本, 大字是否中文)。"""
if r < len(self._items):
en, zh = self._items[r]
else:
en, zh = self._live or ("", "")
if self._verbatim:
# 答案行: 元组就是 (大字, 小字) 本身, 不做中英对调。is_zh 只用来选字体,
# 而大字位的两个字体对象是同一个(18pt Medium), 所以这里恒返回 False。
return en, zh, False
if zh:
return self._shape_zh(zh, final=r < len(self._items)), en, True
if self._d and self._zh_expected and r >= len(self._items):
# D: 实时行的译文还没到 —— 英文留在上面的小字位、中文那格空着等(原型的「预留」),
# 不把英文提成大字: 否则译文一到, 这一行的上下顺序和字号整个翻一次。
return "", en, True
# 翻译关闭/失败时 zh 为空: 把英文提到大字行, 否则大字行空白
return en, "", False
def _lh(self, font) -> float:
h = self._line_h.get(id(font))
if h is None:
h = self._line_h[id(font)] = float(self._measure("中Hg", 1e4, font)) or 1.0
return h
def _measure_zh(self, text: str, w: float, font) -> float:
"""中文那格的文字高度(带固定行高)。与 `_zh_attr` 同一个行高。"""
if self._zh_line_h is None:
return self._measure(text, w, font)
return self._measure(text, w, font, self._zh_line_h)
def _lh_zh(self) -> float:
return self._zh_line_h or self._lh(self._font_zh[0])
def _zh_attr(self, lbl, display: str):
"""中文那格的属性串(属性见 `_zh_attrs`)。"""
from AppKit import NSAttributedString
return NSAttributedString.alloc().initWithString_attributes_(display, self._zh_attrs(lbl))
def _zh_attrs(self, lbl, color=None) -> dict:
"""中文那格的属性:字体 / 颜色 / 描边 + 固定行高的段落样式。"""
from AppKit import (NSFontAttributeName, NSForegroundColorAttributeName,
NSShadowAttributeName, NSParagraphStyleAttributeName,
NSMutableParagraphStyle, NSColor, NSBaselineOffsetAttributeName)
ps = NSMutableParagraphStyle.alloc().init()
ps.setLineBreakMode_(lbl.lineBreakMode())
ps.setAlignment_(lbl.alignment())
at = {NSFontAttributeName: lbl.font(), NSParagraphStyleAttributeName: ps,
NSForegroundColorAttributeName: color or lbl.textColor() or NSColor.whiteColor()}
if self._zh_line_h is not None:
ps.setMinimumLineHeight_(self._zh_line_h)
ps.setMaximumLineHeight_(self._zh_line_h)
# 多出来的行距 AppKit 全堆在字的上方;抬一半回去 = 上下各一半(字居中在行里)
extra = self._zh_line_h - self._lh(lbl.font())
if extra > 0:
at[NSBaselineOffsetAttributeName] = extra / 2.0
sh = lbl.shadow()
if sh is not None:
at[NSShadowAttributeName] = sh
return at
def _en_line_h(self) -> float:
return self._lh(self._font_en[0])
def _en_two_lines(self, big: str, sub: str, is_zh: bool) -> int:
"""这一行的英文排几行(0 = 不适用,走老的单格)。
英文那一格本身就有 `EN_SLOT_LINES` 行;还装不下时,中文比档位少用至少一行 →
再借中文那格的一行(老规矩)。中文也占满档位时照旧截断。
"""
if not (EN_LINE_ON_TOP and is_zh and sub) or self._doc_w < 40.0:
return 0
key = (sub, big, self._doc_w, self._lines)
hit = self._en2_cache.get(key)
if hit is not None:
return hit
w = self._doc_w - 4.0
en_n = max(1, round(self._measure(sub, w, self._font_en[0]) / self._en_line_h()))
if "\n" in big:
zh_n = big.count("\n") + 1
else:
zh_n = round(self._measure_zh(big, w, self._font_zh[0]) / self._lh_zh())
if en_n <= EN_SLOT_LINES:
out = en_n
elif zh_n <= self._lines - 1:
out = EN_SLOT_LINES + 1
else:
out = EN_SLOT_LINES
if len(self._en2_cache) > 400:
self._en2_cache.clear()
self._en2_cache[key] = out
return out
def lines_tier(self) -> int:
"""当前档位一行中文最多几行(`LINE_TIERS` 按宽度定)。"""
return int(self._lines)
def zh_lines(self, zh: str) -> int:
"""`zh` 按当前宽度折成几行(与 `fits_row` 同一套口径)。量不了 → 很大(= 当它满了)。"""
if not zh:
return 0
if self._doc_w < 40.0:
return 99
try:
w = self._doc_w - 4.0
shaped = self._shape_zh(zh, final=True)
if "\n" in shaped:
return shaped.count("\n") + 1
return max(1, round(self._measure_zh(zh, w, self._font_zh[0]) / self._lh_zh()))
except Exception: # noqa: BLE001
return 99
def big_lines(self, en: str) -> int:
"""没有中文时英文提成大字,按当前宽度折成几行(只英·校 / 纯转录档的「写满」口径)。"""
if not en:
return 0
if self._doc_w < 40.0:
return 99
try:
return max(1, round(self._measure_zh(en, self._doc_w - 4.0, self._font_en_big[0])
/ self._lh_zh()))
except Exception: # noqa: BLE001
return 99
def fits_row(self, en: str, zh: str) -> bool:
"""`(en, zh)` 放进**一行**(当前宽度与档位)会不会被截。与 `_en_two_lines` 同一套口径:
中文 ≤ 档位行数;英文 1 行,或中文少用一行时英文借它排 2 行。量不了 → False(不并)。"""
if self._doc_w < 40.0:
return False
try:
w = self._doc_w - 4.0
shaped = self._shape_zh(zh, final=True) if zh else ""
if "\n" in shaped:
zh_n = shaped.count("\n") + 1
else:
zh_n = round(self._measure_zh(zh, w, self._font_zh[0]) / self._lh_zh())
en_n = round(self._measure(en, w, self._font_en[0]) / self._en_line_h())
except Exception: # noqa: BLE001
return False
return zh_n <= self._lines and (en_n <= EN_SLOT_LINES
or (en_n <= EN_SLOT_LINES + 1 and zh_n <= self._lines - 1))
def _shape_zh(self, zh: str, *, final: bool) -> str:
"""中文按词组折行(定稿再避开孤尾)。**按 (文本, 宽度, 是否定稿) 记忆化** ——
`_paint` 每帧都会对可见行走到这里, 而折一句要量十几次字宽。"""
if self._wrap_zh is None or self._doc_w < 40.0:
return zh
key = (zh, self._doc_w, final)
out = self._wrap_cache.get(key)
if out is None:
try:
out = self._wrap_zh(zh, self._doc_w, final)