summaryrefslogtreecommitdiff
path: root/src/Messages.zig
blob: eb2fa5e29936a7d828f891493545caeed4b4f1b8 (plain) (blame)
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
//! The message row: messages and statuses a pane shows between its body and
//! its tagline, how each line falls in, lingers and dissolves, the notices
//! built from them every frame, and the log of everything said.
const pardes = @import("pardes.zig");
const memory = @import("memory.zig");
const std = @import("std");
const layout = @import("layout.zig");
const animation = @import("animation.zig");
const ctlfs = @import("ninep/tree.zig");
const panes = @import("panes.zig");
const CellStyle = @import("surface.zig").CellStyle;
const Surface = @import("surface.zig").Surface;
const limits = memory.limits;
const config = @import("config.zig");
const platform = pardes.platform;
const Pane = panes.Pane;
const MAX_PANES = pardes.MAX_PANES;
const BOX_H = pardes.BOX_H;
const Key = pardes.Key;
const Rect = layout.Rect;
const Pardes = pardes.Pardes;

const Messages = @This();

/// The message-row log: a fixed ring, never allocated, never grown. See
/// `logMessage` and the `Messages` builtin.
log: [limits.message_log]LoggedMessage = @splat(.{}),
/// Next slot to write. `len` saturates at the ring's size.
head: usize = 0,
len: usize = 0,

pub const LoggedMessage = struct {
    pub const cap = 256;
    text: [cap]u8 = undefined,
    len: u16 = 0,
    /// The serial of the pane that said it, or 0 for none in particular. A
    /// serial, not a slot: slots are reused, so an old line would name
    /// whichever pane took the slot since.
    serial: u32 = 0,
    /// How many times in a row. Saturating: a key held down against the same
    /// refusal is one event, not four hundred.
    repeats: u16 = 0,

    pub fn slice(m: *const LoggedMessage) []const u8 {
        return m.text[0..m.len];
    }
};

/// `SPC` plus the chord keys typed so far, spaced out the way the help
/// index spells a leader path.
fn leaderText(p: *const Pardes, buf: *[16]u8) []const u8 {
    buf.* = @splat(' ');
    @memcpy(buf[0..3], "SPC");
    var at: usize = 3;
    for (p.leader_keys[0..p.leader_n]) |ch| {
        if (at + 2 > buf.len) break;
        buf[at + 1] = ch;
        at += 2;
    }
    return buf[0..at];
}

/// How wide a notice chip is, in grid columns: its own text plus a blank
/// cell either side. A notice is an OVERLAY over the body's top rows, not
/// a row taken from them, so it is sized to its message and never to the
/// pane. Counted in GRID columns rather than scaled into the tagline face
/// it is drawn in, because the canonical grid is what a terminal client
/// draws and a chip narrower than its own text there would clip it; the
/// tagline face simply leaves a little more room inside the chip.
pub fn noticeCols(p: *const Pardes, text: []const u8, limit: u16) u16 {
    _ = p;
    if (limit == 0) return 0;
    return @intCast(std.math.clamp(panes.File.displayWidth(text) + 2, 1, @as(usize, limit)));
}

const Printed = struct { left: u16, dropped: usize };

/// Prints `text` flush with the right edge of the band, and answers the
/// column it started at so a cursor can follow it. Text wider than the
/// band loses its HEAD: the end is the part that says something -- a
/// path's file name, an error's reason -- and `dropped` is how many
/// columns of it went.
pub fn printRight(s: *Surface, x: u16, row: u16, w: u16, text: []const u8, style: CellStyle) Printed {
    const width = panes.File.displayWidth(text);
    if (width <= w) {
        const left: u16 = @intCast(x + w - width);
        _ = s.print(left, row, @intCast(width), text, style);
        return .{ .left = left, .dropped = 0 };
    }
    // Whole glyphs only: a cut inside a wide one would leave the rest a
    // column too wide and clip its LAST glyph instead of the first.
    var dropped = width - w;
    var start = panes.File.rawAtDisplay(text, dropped);
    if (panes.File.rawDisplayCol(text, start) < dropped) {
        dropped += 1;
        start = panes.File.rawAtDisplay(text, dropped);
    }
    const left: u16 = @intCast(x + w - (width - dropped));
    _ = s.print(left, row, @intCast(width - dropped), text[start..], style);
    return .{ .left = left, .dropped = dropped };
}

pub const Message = struct {
    const libc = std.c;
    const Tm = extern struct {
        sec: c_int,
        min: c_int,
        hour: c_int,
        mday: c_int,
        mon: c_int,
        year: c_int,
        wday: c_int,
        yday: c_int,
        isdst: c_int,
        gmtoff: c_long,
        zone: ?[*:0]const u8,
    };
    extern "c" fn localtime_r(timep: *const libc.time_t, result: *Tm) ?*Tm;

    pub fn body(text: []const u8) []const u8 {
        if (text.len < 10) return text;
        if (text[2] != ':' or text[5] != ':' or text[8] != ' ' or text[9] != ' ') return text;
        for ([_]usize{ 0, 1, 3, 4, 6, 7 }) |i| {
            if (!std.ascii.isDigit(text[i]) and text[i] != '-') return text;
        }
        return text[10..];
    }

    pub fn stamp(buf: []u8, verb: []const u8, subject: []const u8) []const u8 {
        var clock: [8]u8 = "--:--:--".*;
        const notime = if (libc.getenv("PARDES_NOTIME")) |v| std.mem.span(v).len != 0 else false;
        if (!notime) {
            var ts: libc.timespec = undefined;
            _ = libc.clock_gettime(.REALTIME, &ts);
            const secs: libc.time_t = ts.sec;
            var tm: Tm = undefined;
            if (localtime_r(&secs, &tm) != null) {
                _ = std.fmt.bufPrint(&clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{
                    @as(u32, @intCast(tm.hour)),
                    @as(u32, @intCast(tm.min)),
                    @as(u32, @intCast(tm.sec)),
                }) catch {};
            }
        }
        const head = std.fmt.bufPrint(buf, "{s}  {s} ", .{ &clock, verb }) catch return buf[0..0];
        const room = buf.len - head.len;
        const tail = if (subject.len <= room) subject else subject[subject.len - room ..];
        @memcpy(buf[head.len..][0..tail.len], tail);
        return buf[0 .. head.len + tail.len];
    }
};

/// A status updates the message line in place (a progress count does not
/// pile up); a message is an event, and stacks.
pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void {
    showMessage(p, id, text, false);
}

pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void {
    logMessage(p, id, text);
    showMessage(p, id, text, true);
}

fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void {
    if (id >= MAX_PANES) return;
    const pane = p.panes[id] orelse return;
    // One row of printable text: a language server's multi-line report
    // reads as one line, and the cut never leaves half a glyph behind.
    var n: usize = @min(text.len, pane.msg.len);
    if (n < text.len) {
        while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
    }
    const same = pane.msg_len == n and for (text[0..n], pane.msg[0..n]) |c, have| {
        if ((if (c < 0x20 or c == 0x7f) ' ' else c) != have) break false;
    } else true;
    // A new event moves the line on screen up the stack, where it keeps
    // its own timing, and falls in below it.
    // Only an event stacks on an event: an announcement or a status
    // stands in for whatever comes next.
    if (stack and pane.msg_len > 0 and !same and !pane.msg_announcement and !pane.msg_status)
        pane.pushOlderMessage();
    if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot();
    pane.msg_announcement = false;
    pane.msg_sent = false;
    pane.msg_status = !stack;
    const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving;
    for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c;
    pane.msg_len = @intCast(n);
    pane.msg_said_ms = nowMs();
    p.needs_frame = true;
    if (arrives) {
        pane.msg_life = .{ .phase = if (p.settings.message_animation and n > 0) .entering else .shown };
    } else if (pane.msg_life.phase == .lingering or
        (pane.msg_life.phase == .entering and !p.settings.message_animation))
    {
        // The same line again, or a status update: fresh, so it stays.
        pane.msg_life = .{};
    } else {
        pane.msg_life.dismissed = false;
    }
}

fn nowMs() i64 {
    if (comptime !pardes.hosted) return 0;
    var ts: std.c.timespec = undefined;
    if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
    return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
}

/// With no frontend attached no input dismisses a message, and a detached
/// session's /screen and messages kept what was said an hour ago: each
/// pane's lines go once its newest has been up `message_linger_ms`, on the
/// clock, checked as the core is stepped and by the detached session's loop
/// between steps -- not as a 9P request is served, since a read is answered
/// while a step is out and must change nothing.
/// ponytail: a pane's older lines go with its newest, not each on its own
/// clock; the render pipeline's clock will give every line its own.
pub fn expireUnattended(p: *Pardes) void {
    if (!p.unattended) return;
    const now = nowMs();
    for (p.panes) |slot| {
        const pane = slot orelse continue;
        if (pane.msg_len == 0 and pane.msg_older_len == 0) continue;
        if (now - pane.msg_said_ms < p.settings.message_linger_ms) continue;
        pane.msg_len = 0;
        pane.msg_life = .{};
        pane.msg_older_len = 0;
        p.needs_frame = true;
    }
}

/// The key or click that used to clear the message row now starts each
/// line's exit: it stays `message_linger_ms`, then dissolves. A line
/// already on its way out keeps its own timing.
pub fn dismissMessage(p: *Pardes, pane: *Pane) void {
    if (pane.msg_len > 0 and dismissLine(p, &pane.msg_life)) {
        pane.msg_len = 0;
        pane.msg_life = .{};
    }
    var i: usize = 0;
    while (i < pane.msg_older_len) {
        if (dismissLine(p, &pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
    }
}

/// Start one line's exit; true when it is gone at once (no linger, no
/// animation).
fn dismissLine(p: *const Pardes, life: *Pane.MessageLife) bool {
    switch (life.phase) {
        .lingering, .leaving => return false,
        // A falling line lands first, then lingers -- unless falling was
        // just turned off, and nothing will land it.
        .entering => if (p.settings.message_animation) {
            life.dismissed = true;
            return false;
        },
        .shown => {},
    }
    life.frame = 0;
    if (p.settings.message_linger_ms > 0) {
        life.phase = .lingering;
    } else if (p.settings.message_animation) {
        life.phase = .leaving;
    } else return true;
    return false;
}

/// One animation frame of every message line. Each one that moves owes
/// a frame, the one that finishes included: a tick asks for none itself.
pub fn advanceMessages(p: *Pardes) void {
    for (p.panes) |slot| {
        const pane = slot orelse continue;
        if (pane.msg_len > 0 and pane.msg_life.phase != .shown) {
            p.needs_frame = true;
            if (advanceLine(p, &pane.msg_life)) {
                pane.msg_len = 0;
                pane.msg_life = .{};
            }
        }
        var i: usize = 0;
        while (i < pane.msg_older_len) {
            if (pane.msg_older[i].life.phase != .shown) p.needs_frame = true;
            if (advanceLine(p, &pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1;
        }
    }
}

/// One frame of one line's life; true once it has left.
fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool {
    if (life.phase == .shown) return false;
    life.frame +|= 1;
    switch (life.phase) {
        .shown => {},
        .entering => if (life.frame >= messageFrames(p.settings.message_fall_ms)) {
            life.* = .{ .phase = if (life.dismissed) .lingering else .shown };
        },
        .lingering => if (life.frame >= messageFrames(p.settings.message_linger_ms)) {
            if (!p.settings.message_animation) return true;
            life.* = .{ .phase = .leaving };
        },
        .leaving => if (life.frame >= messageFrames(p.settings.message_dissolve_ms)) return true,
    }
    return false;
}

/// Move every lingering message line `frames` along its linger at once:
/// how `advance` jumps a wait instead of stepping it frame by frame.
pub fn holdMessages(p: *Pardes, frames: u64) void {
    const n: u16 = @intCast(@min(frames, std.math.maxInt(u16)));
    for (p.panes) |slot| {
        const pane = slot orelse continue;
        if (pane.msg_len > 0 and pane.msg_life.phase == .lingering) pane.msg_life.frame +|= n;
        for (pane.msg_older[0..pane.msg_older_len]) |*line| {
            if (line.life.phase == .lingering) line.life.frame +|= n;
        }
    }
}

/// Frames until this pane's message lines next change: 1 while a line
/// falls in or dissolves, the rest of its linger while one only waits, null
/// when every line just shows.
pub fn messageWake(p: *const Pardes, pane: *const Pane) ?u64 {
    var soonest: ?u64 = null;
    const linger = messageFrames(p.settings.message_linger_ms);
    for (0..@as(usize, pane.msg_older_len) + 1) |i| {
        const life = if (i < pane.msg_older_len) pane.msg_older[i].life else if (pane.msg_len > 0) pane.msg_life else continue;
        const frames: u64 = switch (life.phase) {
            .shown => continue,
            .entering, .leaving => 1,
            .lingering => @max(1, linger -| life.frame),
        };
        soonest = if (soonest) |n| @min(n, frames) else frames;
    }
    return soonest;
}

/// How present a message is (1 = fully) and how far above its row it
/// still is (rows): a fall that decelerates into place with its colour
/// arriving ahead of it, and a dissolve.
pub const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 };

/// Animation frames a message timing lasts; at least one.
pub fn messageFrames(ms: u16) u16 {
    return @intCast(@max(1, (@as(u32, ms) + animation.frame_ms - 1) / animation.frame_ms));
}

pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
    const fall_frames = messageFrames(p.settings.message_fall_ms);
    const dissolve_frames = messageFrames(p.settings.message_dissolve_ms);
    return switch (life.phase) {
        .shown, .lingering => .{ .alpha = 1, .slide = 0 },
        .entering => blk: {
            const t = @as(f32, @floatFromInt(@min(life.frame, fall_frames))) / @as(f32, @floatFromInt(fall_frames));
            // Out of the gate fast and easing into its row (a cubic
            // ease-out), so it is there the moment it is said and the
            // last frames only settle it. No overshoot: at a row's height
            // and a dozen frames, a follow-through is a pixel or two that
            // reads as a jitter, not a bounce.
            // The drop follows the Motion flavour (its wind-up, overshoot
            // and all) over the fall's length.
            const left = 1 - animation.Motion.of(p.settings.motion).ease(t);
            // The colour leads the motion: solid by half way, so what
            // lands is already legible rather than still fading up.
            const faded = 1 - @min(1, 2 * t);
            // And never behind it, however fast the flavour leaves.
            break :blk .{ .alpha = @max(1 - faded * faded, std.math.clamp(1 - left, 0, 1)), .slide = -left };
        },
        .leaving => blk: {
            // Counted to its last frame rather than past it, so that frame
            // shows it gone and it never blinks out from part way.
            const last = @max(1, dissolve_frames - 1);
            const t = @as(f32, @floatFromInt(@min(life.frame, last))) / @as(f32, @floatFromInt(last));
            // An exit eases in: still readable for a beat, then away
            // faster and faster. It stays where it is -- the lines
            // stacked around it do not move either.
            break :blk .{ .alpha = 1 - t * t, .slide = 0 };
        },
    };
}

/// What notice band `i` says. The grid pass and the band layers both
/// read it here, so a terminal and a pixel shell show the same line.
pub fn noticeText(p: *const Pardes, pane: *const Pane, i: usize, leader_buf: *[16]u8) []const u8 {
    return switch (pane.notices.kinds[i]) {
        .message => pane.msg[0..pane.msg_len],
        .older => pane.msg_older[pane.notices.index[i]].slice(),
        .leader => leaderText(p, leader_buf),
        .prompt => pane.promptText() orelse "",
    };
}

/// The life of the message line notice `i` shows, if it shows one.
pub fn noticeLife(pane: *const Pane, i: usize) ?Pane.MessageLife {
    return switch (pane.notices.kinds[i]) {
        .message => pane.msg_life,
        .older => pane.msg_older[pane.notices.index[i]].life,
        .leader, .prompt => null,
    };
}

pub fn blendRgb(from: [3]u8, to: [3]u8, amount: f32) [3]u8 {
    var out: [3]u8 = undefined;
    for (&out, from, to) |*o, a, b| {
        const af: f32 = @floatFromInt(a);
        const bf: f32 = @floatFromInt(b);
        o.* = @intFromFloat(@round(bf + (af - bf) * std.math.clamp(amount, 0, 1)));
    }
    return out;
}

fn logMessage(p: *Pardes, id: usize, text: []const u8) void {
    if (text.len == 0) return;
    const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
    const kept = text[0..@min(text.len, LoggedMessage.cap)];
    // /log hears every one (a failure of a ctl write as its err record).
    if (!p.fs.unlogged) ctlfs.events.noteMessage(p, serial, text);
    if (p.messages.len > 0) {
        const last = &p.messages.log[(p.messages.head + limits.message_log - 1) % limits.message_log];
        if (last.serial == serial and
            std.mem.eql(u8, Message.body(last.slice()), Message.body(kept)))
        {
            // The NEWEST wording wins, so the row carries the latest clock
            // rather than the moment the run started.
            last.len = @intCast(kept.len);
            @memcpy(last.text[0..last.len], kept);
            last.repeats +|= 1;
            return;
        }
    }
    const slot = &p.messages.log[p.messages.head];
    slot.len = @intCast(kept.len);
    @memcpy(slot.text[0..slot.len], kept);
    slot.serial = serial;
    slot.repeats = 1;
    p.messages.head = (p.messages.head + 1) % limits.message_log;
    p.messages.len = @min(p.messages.len + 1, limits.message_log);
}

/// The log oldest-first, which is reading order.
pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
    if (i >= m.len) return null;
    const first = (m.head + limits.message_log - m.len) % limits.message_log;
    return &m.log[(first + i) % limits.message_log];
}

/// What a route that would open a pane says when every slot is taken.
/// "no space": 9ns's word for ENOSPC (it has none for ENFILE).
pub const no_pane_slots = "no space for a pane: {d} max";
/// ...and when the column it would go in has no rows for one.
pub const no_pane_room = "no space for a pane in that column: each keeps its tag and {d} rows";

/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
    var buf: [256]u8 = undefined;
    var w = std.Io.Writer.fixed(&buf);
    w.print("{s}: ", .{operation}) catch {};
    // Every route that opens a pane says the cap the same way.
    if (err == error.NoPaneSlots or err == error.NoPaneRoom) {
        if (err == error.NoPaneSlots)
            w.print(no_pane_slots, .{pardes.MAX_PANES}) catch {}
        else
            w.print(no_pane_room, .{pardes.layout.min_body_rows}) catch {};
        // A 9P look or exec that would open the pane fails with it too.
        p.fs.no_pane_slot = true;
        @memcpy(p.fs.ename[0..w.buffered().len], w.buffered());
        p.fs.no_pane_slot_len = @intCast(w.buffered().len);
        return reportFailure(p, id, w.buffered());
    }
    for (@errorName(err), 0..) |c, i| {
        if (std.ascii.isUpper(c) and i > 0) w.writeByte(' ') catch break;
        w.writeByte(std.ascii.toLower(c)) catch break;
    }
    reportFailure(p, id, w.buffered());
}

/// The longest start of `text` that fits `max` bytes and ends between
/// words (or, with no space to cut at, between characters).
pub fn clip(text: []const u8, max: usize) []const u8 {
    if (text.len <= max) return text;
    if (std.mem.lastIndexOfScalar(u8, text[0 .. max + 1], ' ')) |sp| return text[0..sp];
    var n = max;
    while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
    return text[0..n];
}

/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
    // A builtin a ctl write runs: its first error is also the write's, cut
    // between words.
    const failing_write = p.fs.no_prompt or p.fs.capturing;
    if (failing_write and p.fs.failure_len == 0) {
        const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text;
        @memcpy(p.fs.failure[0..kept.len], kept);
        var n = kept.len;
        if (kept.len < text.len) {
            @memcpy(p.fs.failure[n..][0..3], "...");
            n += 3;
        }
        p.fs.failure_len = @intCast(n);
    }
    // The write fails with it, and its err record says it: no msg for it,
    // and no Verbose announcement before that, so the same failure again is
    // the same record again, counted (events.pushCounting).
    if (failing_write) {
        const serial: u32 = if (p.fs.session_write) 0 else if (id < MAX_PANES) if (p.panes[id]) |pane| pane.serial else 0 else 0;
        ctlfs.events.dropAnnouncement(p, serial, text);
        p.fs.unlogged = true;
        defer p.fs.unlogged = false;
        return setMessage(p, id, text);
    }
    setMessage(p, id, text);
}

test "an error reads as words and a failure is cut between them" {
    try std.testing.expectEqualStrings("go", clip("go haskell", 4));
    try std.testing.expectEqualStrings("go haskell", clip("go haskell", 10));
    try std.testing.expectEqualStrings("\u{e9}", clip("\u{e9}\u{e9}", 3));
}

/// The one place that decides which transient lines a pane is showing and
/// in what order. Everything downstream reads the answer: the body layer
/// so it does not paint under them, and the paint pass so it can stack
/// them instead of letting the last one win the row.
pub fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void {
    pane.notices.len = 0;
    if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return;
    // Oldest message line on top, so lines already up do not move when a
    // new one lands under them; when rows run out the oldest go first.
    for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot);
    if (pane.msg_len > 0) pane.notices.pushAt(.message, 0, pane.msg_slot);
    if (id == p.active and p.leader_on) pane.notices.push(.leader);
    if (pane.promptText()) |text| if (text.len > 0) pane.notices.push(.prompt);
    // A pane only has so many rows to give. When they do not all fit the
    // LAST ones survive, because the prompt is last and it is the one
    // carrying the cursor: a prompt you cannot see is a prompt you type
    // into blind. A two-row pane spends its only body row on one notice,
    // which is what the single shared row used to do anyway.
    const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - pane.tag_rows));
    const n = &pane.notices;
    if (n.span() <= room) return;
    // Too short for the gaps: close them up in screen order, and if even
    // that does not fit, the top bands go.
    var i: usize = 1;
    while (i < n.len) : (i += 1) {
        var j = i;
        while (j > 0 and n.row[j - 1] > n.row[j]) : (j -= 1) {
            std.mem.swap(Pane.Notices.Kind, &n.kinds[j - 1], &n.kinds[j]);
            std.mem.swap(u8, &n.index[j - 1], &n.index[j]);
            std.mem.swap(u8, &n.row[j - 1], &n.row[j]);
        }
    }
    const drop = n.len -| room;
    for (0..n.len - drop) |k| {
        n.kinds[k] = n.kinds[k + drop];
        n.index[k] = n.index[k + drop];
        n.row[k] = @intCast(k);
    }
    n.len -= @intCast(drop);
}

test "the message log keeps what the row forgets, and collapses repeats" {
    const gpa = std.testing.allocator;
    const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
    defer p.deinit();

    setMessage(p, 0, "14:32:07  saved /x.zig");
    setMessage(p, 0, "14:32:09  saved /x.zig"); // same event, later clock
    setMessage(p, 0, "save: AccessDenied");

    try std.testing.expectEqual(@as(usize, 2), p.messages.len);
    const first = p.messages.messageLog(0).?;
    try std.testing.expectEqual(@as(u16, 2), first.repeats);
    // ...and the NEWEST wording is what survives, so the row carries the last
    // time it happened rather than the first.
    try std.testing.expectEqualStrings("14:32:09  saved /x.zig", first.slice());
    try std.testing.expectEqualStrings("save: AccessDenied", p.messages.messageLog(1).?.slice());
    try std.testing.expect(p.messages.messageLog(2) == null);

    // The same text from a DIFFERENT pane is a different event: one pane's
    // failure must not be recorded as another's.
    p.newScratchBelow(0);
    const other = p.active;
    setMessage(p, other, "save: AccessDenied");
    try std.testing.expectEqual(@as(usize, 3), p.messages.len);
    try std.testing.expectEqual(p.panes[other].?.serial, p.messages.messageLog(2).?.serial);

    // Progress is NOT logged: it arrives several times a second for a whole
    // index and would push everything else out (`setStatus`).
    setStatus(p, 0, "14:32:10  lsp rust-analyzer: Indexing 47%");
    try std.testing.expectEqual(@as(usize, 3), p.messages.len);

    // The ring wraps rather than grows, and still reads oldest-first.
    for (0..limits.message_log + 5) |i| {
        var buf: [32]u8 = undefined;
        setMessage(p, 0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable);
    }
    try std.testing.expectEqual(@as(usize, limits.message_log), p.messages.len);
    try std.testing.expectEqualStrings("line 5", p.messages.messageLog(0).?.slice());
    var last_buf: [32]u8 = undefined;
    const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable;
    try std.testing.expectEqualStrings(want_last, p.messages.messageLog(limits.message_log - 1).?.slice());
}

test "Msg writes the transient row by hand, bare or with text, and input ends it" {
    const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
    defer p.deinit();
    while (p.nextEffect()) |_| {}
    const row = struct {
        fn of(pp: *Pardes, i: usize) []const u8 {
            const pane = pp.panes[i].?;
            return pane.msg[0..pane.msg_len];
        }
    }.of;

    // the whole tail, spaces and all: the row is a sentence, not a word
    try std.testing.expect(p.executeBuiltinLine(2, "Msg saved /etc/hosts"));
    try std.testing.expectEqualStrings("saved /etc/hosts", row(p, 2));
    // ...on the pane that ran it, and nowhere else
    try std.testing.expectEqualStrings("", row(p, 0));

    // Bare, it reports ITSELF through reportError — the `<operation>: <error in words>`
    // every failed save and refused Look arrives in.
    try std.testing.expect(p.executeBuiltinLine(2, "Msg"));
    try std.testing.expectEqualStrings("Msg: no message", row(p, 2));

    // Input starts its exit: it lingers, dissolves, and is gone.
    p.update(.{ .key = .{ .cp = Key.escape } });
    try std.testing.expectEqualStrings("Msg: no message", row(p, 2));
    for (0..200) |_| p.update(.tick);
    try std.testing.expectEqualStrings("", row(p, 2));
    // ...and with neither linger nor animation it is as old as your last input.
    try std.testing.expect(p.executeBuiltinLine(2, "MessageAnimation off"));
    try std.testing.expect(p.executeBuiltinLine(2, "MessageLinger 0"));
    try std.testing.expect(p.executeBuiltinLine(2, "Msg gone"));
    p.update(.{ .key = .{ .cp = 'h' } });
    try std.testing.expectEqualStrings("", row(p, 2));

    // Longer than the row's buffer is CUT, never refused and never overrun:
    // this word is the only way to reach that boundary without a 300-byte path.
    var long: [600]u8 = @splat('x');
    var line: [608]u8 = undefined;
    const cmd = try std.fmt.bufPrint(&line, "Msg {s}", .{&long});
    try std.testing.expect(p.executeBuiltinLine(2, cmd));
    try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len);
}

test "with no frontend attached a message goes once it has lingered, on the clock" {
    const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
    defer p.deinit();
    const pane = p.panes[p.active].?;
    p.setMessage(p.active, "said");
    p.setMessage(p.active, "said again");
    // Attached: it waits for input, however long.
    pane.msg_said_ms -= 60_000;
    p.update(.tick);
    try std.testing.expect(pane.msg_len > 0);
    // Detached with nobody attached: past its linger it is gone, and the
    // line above it with it.
    p.unattended = true;
    // A read served meanwhile changes nothing; the session's loop expires.
    _ = p.serveFs(.{ .tag = 1, .op = .read, .node = @intFromEnum(ctlfs.TopFile.index), .size = 64 });
    try std.testing.expect(pane.msg_len > 0);
    expireUnattended(p);
    try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
    try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
    // A fresh one stays until its linger is up.
    p.setMessage(p.active, "fresh");
    p.update(.tick);
    try std.testing.expect(pane.msg_len > 0);
}

test "a message falls in, lingers past the input that dismisses it, and dissolves" {
    const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
    defer p.deinit();
    while (p.nextEffect()) |_| {}
    const pane = p.panes[p.active].?;
    try std.testing.expect(p.settings.message_animation);
    // Crisp: the flavour whose drop leaves at full speed and lands without
    // passing its row (the others are the Motion tests').
    p.settings.motion = .crisp;
    try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
    try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
    try std.testing.expect(p.animationActive());
    // An ease-out fall: starts a whole row up and unseen, covers most of the
    // way at once and settles into its row, landing exactly on it without
    // passing it. Its colour is whole by half way.
    const start = messageMotion(p, pane.msg_life);
    try std.testing.expectEqual(@as(f32, -1), start.slide);
    try std.testing.expectEqual(@as(f32, 0), start.alpha);
    const fall = messageFrames(p.settings.message_fall_ms);
    var last = start;
    var first_step: f32 = 0;
    var last_step: f32 = 0;
    for (0..fall) |frame| {
        p.needs_frame = false;
        p.update(.tick);
        // Every tick that moves it owes a frame, the landing one included.
        try std.testing.expect(p.needs_frame);
        const now = messageMotion(p, pane.msg_life);
        try std.testing.expect(now.slide >= last.slide and now.slide <= 0);
        try std.testing.expect(now.alpha >= last.alpha);
        if (frame == 0) first_step = now.slide - last.slide;
        // Never slower to colour up than to arrive, and solid by half way.
        try std.testing.expect(now.alpha >= 1 + now.slide);
        if (2 * (frame + 1) >= fall) try std.testing.expectEqual(@as(f32, 1), now.alpha);
        last_step = now.slide - last.slide;
        last = now;
    }
    try std.testing.expect(first_step > 4 * last_step);
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
    try std.testing.expectEqual(@as(f32, 0), messageMotion(p, pane.msg_life).slide);
    try std.testing.expect(!p.animationActive());
    // Shown until input, like always; the same line again does not re-enter.
    for (0..100) |_| p.update(.tick);
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
    // Input (a key that announces nothing): 160 ms of lingering (ten frames), unaffected by more input...
    p.update(.{ .key = .{ .cp = 'h' } });
    try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
    for (0..9) |_| {
        p.update(.tick);
        p.update(.{ .key = .{ .cp = 'h' } });
    }
    try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
    p.update(.tick);
    // ...then an ease-in dissolve: it lets go slowly and goes faster and
    // faster, in place, its last frame showing it gone.
    try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
    const dissolve = messageFrames(p.settings.message_dissolve_ms);
    var alpha: f32 = 1;
    var first_drop: f32 = 0;
    var last_drop: f32 = 0;
    for (0..dissolve) |frame| {
        const now = messageMotion(p, pane.msg_life);
        try std.testing.expect(now.alpha <= alpha);
        try std.testing.expectEqual(@as(f32, 0), now.slide);
        if (frame == 1) first_drop = alpha - now.alpha;
        last_drop = alpha - now.alpha;
        alpha = now.alpha;
        p.needs_frame = false;
        p.update(.tick);
    }
    try std.testing.expectEqual(@as(f32, 0), alpha);
    try std.testing.expect(first_drop > 0 and first_drop < last_drop);
    try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
    try std.testing.expect(!p.animationActive());
    // The tick that clears it still owes the frame that shows it gone.
    try std.testing.expect(p.needs_frame);

    // Dismissed mid-fall: it lands, then lingers.
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
    try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
    p.update(.tick);
    p.update(.{ .key = .{ .cp = 'h' } });
    try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
    for (0..messageFrames(p.settings.message_fall_ms)) |_| p.update(.tick);
    try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
    // Shown again while lingering: fresh, so it stays; a status update to a
    // line on screen swaps its text in place instead of falling again.
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
    setStatus(p, p.active, "progress 50%");
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
    try std.testing.expectEqualStrings("progress 50%", pane.msg[0..pane.msg_len]);
    try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
    // The fall and the dissolve are timings of their own, in milliseconds.
    try std.testing.expect(p.executeBuiltinLine(p.active, "MessageFall 48"));
    try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 0"));
    try std.testing.expectEqual(@as(u16, 3), messageFrames(p.settings.message_fall_ms));
    try std.testing.expectEqual(@as(u16, 1), messageFrames(p.settings.message_dissolve_ms));
    // The Config report reads back as configuration.
    try std.testing.expect(p.executeBuiltinLine(p.active, "MessageAnimation"));
    try std.testing.expect(!p.settings.message_animation);
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg plain"));
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
}

test "messages stack, oldest on top, each on its own timing" {
    const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
    defer p.deinit();
    while (p.nextEffect()) |_| {}
    const pane = p.panes[p.active].?;
    try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
    try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
    // `Verbose off` announced itself before it took effect.
    pane.msg_len = 0;
    pane.msg_life = .{};
    const fall = messageFrames(p.settings.message_fall_ms);
    setMessage(p, p.active, "first");
    for (0..fall) |_| p.update(.tick);
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
    // A second message stacks under the first instead of replacing it.
    setMessage(p, p.active, "second");
    try std.testing.expectEqual(@as(u8, 1), pane.msg_older_len);
    try std.testing.expectEqualStrings("first", pane.msg_older[0].slice());
    try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
    try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
    collectNotices(p, pane, p.rects[p.active], p.active);
    try std.testing.expectEqual(Pane.Notices.Kind.older, pane.notices.kinds[0]);
    try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[1]);
    // One key dismisses both, but each runs its own clock: the first starts
    // lingering now, the second only once it has landed.
    p.update(.{ .key = .{ .cp = 'h' } });
    try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_older[0].life.phase);
    try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
    const linger = messageFrames(p.settings.message_linger_ms);
    const dissolve = messageFrames(p.settings.message_dissolve_ms);
    for (0..linger + dissolve) |_| p.update(.tick);
    // The first is gone; the second, a fall later, is still dissolving.
    try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
    try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]);
    try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
    for (0..fall) |_| p.update(.tick);
    try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
    try std.testing.expect(!p.animationActive());
    // A status ticking in place does not pile up.
    setStatus(p, p.active, "lsp 10%");
    setStatus(p, p.active, "lsp 20%");
    try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
    // Lines keep their rows: when the top one leaves, the one under it does
    // not move up, and the next message fills the gap instead.
    setMessage(p, p.active, "top");
    setMessage(p, p.active, "under");
    try std.testing.expectEqual(@as(u8, 0), pane.msg_older[0].slot);
    try std.testing.expectEqual(@as(u8, 1), pane.msg_slot);
    pane.removeOlderMessage(0);
    collectNotices(p, pane, p.rects[p.active], p.active);
    try std.testing.expectEqual(@as(u8, 1), pane.notices.row[0]);
    try std.testing.expectEqual(@as(u8, 2), pane.notices.span());
    setMessage(p, p.active, "fills");
    try std.testing.expectEqual(@as(u8, 0), pane.msg_slot);
    try std.testing.expectEqual(@as(u8, 1), pane.msg_older[0].slot);
    // The stack keeps the newest few.
    for ([_][]const u8{ "a", "b", "c", "d", "e" }) |text| setMessage(p, p.active, text);
    try std.testing.expectEqual(@as(u8, Pane.message_stack), pane.msg_older_len);
    try std.testing.expectEqualStrings("b", pane.msg_older[0].slice());
    try std.testing.expectEqualStrings("e", pane.msg[0..pane.msg_len]);
}

test "a builtin announces itself on the message row, and Verbose silences it" {
    if (platform == .web) return;
    const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
    defer p.deinit();
    const pane = p.panes[p.active].?;

    p.sync();
    try std.testing.expect(p.settings.verbose);
    // Wrap, not Collapse: a builtin that folds its own pane away leaves no row
    // for its announcement, which is correct and not what this is testing.
    try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
    try std.testing.expectEqualStrings("Wrap", pane.msg[0..pane.msg_len]);
    // A value it does not take is said, in place of the announcement.
    const wrapped = p.settings.wrap;
    try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap maybe"));
    try std.testing.expectEqualStrings("Wrap: takes on or off", pane.msg[0..pane.msg_len]);
    try std.testing.expectEqual(wrapped, p.settings.wrap);

    // The notice list is what the paint pass reads, so the announcement has to
    // reach it, not just the buffer.
    collectNotices(p, pane, p.rects[p.active], p.active);
    try std.testing.expect(pane.notices.len > 0);
    try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[0]);

    // ...and it is carried as a tagline band, not a body row: that is what
    // gives it the tagline's height, its small font and its border.
    var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer frame.deinit();
    _ = try p.render(frame.allocator());
    const r = p.rects[p.active];
    const band = for (p.surface.tagLayers()) |*layer| {
        if (layer.rows != 0 and layer.kind == .notice) break layer;
    } else return error.NoNoticeBand;
    try std.testing.expectEqual(@as(u16, 1), band.viewport.h);
    try std.testing.expectEqual(if (p.settings.tag_bottom) r.y else r.y + BOX_H, band.viewport.y);
    // Right aligned: the text ends at the band's right edge.
    const line = band.cells[0..band.cols];
    var last: usize = line.len;
    while (last > 0 and line[last - 1].grapheme()[0] == ' ') last -= 1;
    try std.testing.expect(last > 0);
    try std.testing.expect(line[0].grapheme()[0] == ' ');

    // Msg owns the row itself, so it does not announce over its own text.
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
    try std.testing.expectEqualStrings("hello", pane.msg[0..pane.msg_len]);

    pane.msg_len = 0;
    p.settings.verbose = false;
    try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
    try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
}

test "notices stack over the body's top rows without moving them" {
    if (platform == .web) return;
    const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
    defer p.deinit();
    const id = p.active;
    const pane = p.panes[id].?;
    p.sync();
    var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer frame.deinit();

    const r = p.rects[id];
    const top = p.bodyTop(pane, r);

    // A message and a leader prefix at once: two chips, in the order
    // collectNotices chose, each one row tall and each one row further down.
    try std.testing.expect(p.executeBuiltinLine(id, "Msg first"));
    p.leader_on = true;
    p.leader_n = 0;
    _ = frame.reset(.retain_capacity);
    _ = try p.render(frame.allocator());
    try std.testing.expectEqual(@as(u8, 2), pane.notices.len);
    // An overlay, not a reservation: the body still starts where it did.
    try std.testing.expectEqual(top, p.bodyTop(pane, r));

    var seen: usize = 0;
    for (p.surface.tagLayers()) |*layer| {
        if (layer.rows == 0 or layer.kind != .notice) continue;
        try std.testing.expectEqual(@as(u16, 1), layer.viewport.h);
        // Sized to its own message, right up against the pane's right edge --
        // not the width of the pane.
        try std.testing.expectEqual(r.x + r.w, layer.viewport.x + layer.viewport.w);
        try std.testing.expect(layer.viewport.w < r.w - config.GUTTER);
        try std.testing.expect(layer.viewport.y == top or layer.viewport.y == top + 1);
        try std.testing.expectEqual(pane.notices.left[layer.viewport.y - top], layer.viewport.x);
        seen += 1;
    }
    try std.testing.expectEqual(@as(usize, 2), seen);
    // "first" is longer than "SPC", so its chip is wider.
    try std.testing.expect(pane.notices.left[0] < pane.notices.left[1]);

    // Gone with the notices: no stale chip left behind.
    p.leader_on = false;
    pane.msg_len = 0;
    _ = frame.reset(.retain_capacity);
    _ = try p.render(frame.allocator());
    for (p.surface.tagLayers()) |*layer|
        try std.testing.expect(layer.rows == 0 or layer.kind != .notice);
}

test "an idle core wants no wake and a lingering message sleeps to its linger's end" {
    const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
    defer p.deinit();
    while (p.nextEffect()) |_| {}
    const frame = animation.frame_ns;
    p.advance(1_000_000);
    try std.testing.expectEqual(@as(?u64, null), p.nextWake());
    const pane = p.panes[p.active].?;
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
    // Falling in: a frame from now.
    try std.testing.expectEqual(@as(?u64, p.stepped_ns + frame), p.nextWake());
    const fall = messageFrames(p.settings.message_fall_ms);
    p.advance(p.stepped_ns + fall * frame);
    try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase);
    try std.testing.expectEqual(@as(?u64, null), p.nextWake());
    // A key starts the 800 ms linger: the core sleeps to its end, not a
    // frame at a time, and the frames it jumps draw nothing.
    p.update(.{ .key = .{ .cp = 'x' } });
    try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
    const linger = messageFrames(p.settings.message_linger_ms);
    const due = p.stepped_ns + linger * frame;
    try std.testing.expectEqual(@as(?u64, due), p.nextWake());
    p.needs_frame = false;
    p.advance(due - frame);
    try std.testing.expectEqual(@as(?u64, due), p.nextWake());
    try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase);
    p.advance(due);
    try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
    try std.testing.expectEqual(@as(?u64, due + frame), p.nextWake());
    p.advance(due + 10 * std.time.ns_per_s);
    try std.testing.expectEqual(@as(usize, 0), pane.msg_len);
    try std.testing.expectEqual(@as(?u64, null), p.nextWake());

    // A long linger ends when it says, jumped in one step, not stretched by
    // the cap on catching up with moving frames.
    try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 5000"));
    try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again"));
    p.advance(p.stepped_ns + fall * frame);
    p.update(.{ .key = .{ .cp = 'x' } });
    const long_due = p.stepped_ns + messageFrames(5000) * frame;
    try std.testing.expectEqual(@as(?u64, long_due), p.nextWake());
    p.advance(long_due);
    try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
}