nandi/jolt-nativepublic Fork 0
e40b795acdf08fdae31fc3f95e29fcfc01e29eea
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Run the formatter over the tree 3e8c6f0 · on e40b795acdf08fdae31fc3f95e29fcfc01e29eea · nandi · 13d ago
paint.rs · 668 lines · 27.0 KBRust Blame HistoryRaw
  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
//! Drawing the tree into a grid of cells.
//!
//! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and
//! paints itself into it. Two things fall out of the walk and are kept —
//! the focus ring, in the order the widgets were painted, and every focusable
//! widget's rect, so a mouse click can be turned back into a node.
//!
//! Overlays are collected rather than drawn in place: a floating panel belongs
//! over the whole screen, so it is painted after everything else at the size it
//! asked for, in the middle.

use crate::graphics;
use crate::layout::{self, wrap, Align};
use crate::screen::{self, attr, Color, Rect, Screen, Style};
use crate::tree::{Props, Tag, Tree};

const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];

/// What one frame of painting learned about the tree, for the input half to
/// use on the next key or click.
#[derive(Clone, Debug, Default)]
pub struct Painted {
    /// Focusable nodes in paint order — the order Tab walks.
    pub ring: Vec<u32>,
    /// Where each of them ended up.
    pub hits: Vec<(u32, Rect)>,
    /// How far each scroll node's viewport actually was, after clamping to the
    /// content it had. Written back so a caller cannot scroll past the end.
    /// Each `:scroll` painted, as (node, the offset it was painted at, the
    /// furthest it could have been, and the area it was painted into). The
    /// second number is what tells a caller whether it is at the bottom, which
    /// is what sticking to it means; the rect is what a wheel is aimed at.
    pub scrolled: Vec<(u32, u16, u16, Rect)>,
    /// Where the cursor should sit — the focused entry's caret, if any.
    pub cursor: Option<(u16, u16)>,
    /// The pictures this frame wants on screen, in the cells they were given.
    /// Nothing was painted for them: the grid has no pixels, and the terminal
    /// is what draws one — see [`crate::graphics`].
    pub images: Vec<graphics::Placement>,
}

impl Painted {
    /// Move everything down by `rows`.
    ///
    /// A scroll paints its content into a buffer that starts partway down the
    /// column, so what came back is in that buffer's coordinates. This puts it
    /// back into the content's, where the viewport's own offset means what it
    /// says.
    fn shift_down(&mut self, rows: u16) {
        if rows == 0 {
            return;
        }
        for (_, rect) in &mut self.hits {
            rect.y = rect.y.saturating_add(rows);
        }
        for (_, _, _, rect) in &mut self.scrolled {
            rect.y = rect.y.saturating_add(rows);
        }
        for placement in &mut self.images {
            placement.area.y = placement.area.y.saturating_add(rows);
        }
        if let Some((_, y)) = &mut self.cursor {
            *y = y.saturating_add(rows);
        }
    }
}

struct Painter<'a> {
    tree: &'a Tree,
    screen: &'a mut Screen,
    focus: u32,
    /// Where the caret sits in the focused entry's text, in characters.
    caret: usize,
    tick: u64,
    out: Painted,
    overlays: Vec<u32>,
}

/// Paint the whole tree. `focus` is the node the ring is currently on and
/// `tick` advances the spinners.
pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted {
    screen.clear();
    let mut painter = Painter {
        tree,
        screen,
        focus,
        caret,
        tick,
        out: Painted::default(),
        overlays: Vec::new(),
    };
    let area = painter.screen.rect();
    painter.node(tree.root(), area, Style::default(), true);

    // Overlays float above the rest, so they are painted after it — and a
    // click landing on one must beat a click on whatever it covers, which is
    // what putting their hit rects first does.
    let overlays = std::mem::take(&mut painter.overlays);
    let below = std::mem::take(&mut painter.out.hits);
    for id in overlays {
        painter.overlay(id, area);
    }
    painter.out.hits.extend(below);
    painter.out
}

impl Painter<'_> {
    fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style {
        let mut style = inherited;
        if let Some(fg) = Color::parse(props.str("color")) {
            style.fg = fg;
        }
        if let Some(bg) = Color::parse(props.str("bg")) {
            style.bg = bg;
        }
        for (key, bit) in [
            ("bold", attr::BOLD),
            ("dim", attr::DIM),
            ("underline", attr::UNDERLINE),
            ("reverse", attr::REVERSE),
            ("blink", attr::BLINK),
            ("italic", attr::ITALIC),
        ] {
            if props.bool(key, false) {
                style.attrs |= bit;
            }
        }
        if !enabled {
            // `:sensitive false` dims the widget *and its whole subtree*, which
            // is what it means in every other glimmer backend.
            style.attrs |= attr::DIM;
        }
        style
    }

    fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) {
        if area.is_empty() || !self.tree.exists(id) {
            return;
        }
        let tag = self.tree.tag(id);
        let props = self.tree.props(id);
        let enabled = enabled && props.bool("sensitive", true);
        let style = self.style_for(&props, inherited, enabled);
        if props.has("bg") {
            self.screen.fill(area, style);
        }
        if enabled && tag.focusable() {
            self.out.ring.push(id);
            self.out.hits.push((id, area));
        }

        let pad = layout::inset(&tag, &props);
        let inner = area.shrink(pad);
        match tag {
            Tag::Overlay => self.overlays.push(id),
            Tag::Frame => {
                self.border(area, props.label(), style);
                self.children(id, inner, style, enabled);
            }
            Tag::Scroll => self.scroll(id, inner, style, enabled),
            Tag::Box | Tag::Window => self.children(id, inner, style, enabled),
            // A tag this backend has not learned paints as a vertical box, so
            // whatever is under it still reaches the screen. When there is
            // nothing under it, its own text does instead: an unknown *leaf*
            // is a widget the caller has and this has not — frq's `:status`
            // badge, its `:link` — and painting the box and not the label is
            // the one outcome that loses the text altogether. A link vanishing
            // out of the middle of a message is not a missing widget; it is a
            // missing sentence.
            Tag::Unknown(_) => {
                if self.tree.child_count(id) == 0 && !props.has("src") && !props.has("feed") {
                    self.wrapped(inner, props.label(), style);
                } else {
                    self.children(id, inner, style, enabled);
                }
            }
            Tag::Label => self.wrapped(inner, props.label(), style),
            Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)),
            Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)),
            Tag::Button => self.button(id, inner, &props, style),
            Tag::CheckButton => self.check(id, inner, &props, style),
            Tag::Entry => self.entry(id, inner, &props, style),
            Tag::Separator => self.separator(inner, style),
            Tag::Progress => self.progress(inner, &props, style),
            Tag::Spinner => {
                let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
                self.screen.set(inner.x, inner.y, ch, style);
            }
            Tag::Image => self.image(id, inner, &props, style),
            Tag::Reaction => self.reaction(id, inner, &props, style),
            // The same glyph with nothing around it: a character in a line,
            // and the line is what says anything about it.
            Tag::Emoji => {
                self.screen
                    .text(inner.x, inner.y, inner.w, props.str("emoji"), style);
            }
            Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
            // A spacer is the absence of anything; the clear at the top of the
            // frame has already drawn it.
            Tag::Spacer => {}
        }
    }

    /// Put a subtree's focusable nodes into the ring without painting it.
    ///
    /// What a scroll owes the parts of its content it did not paint. Tab walks
    /// the ring, and a reader tabbing onto a button below the fold is how they
    /// scroll to it — so a widget being out of sight cannot take it out of the
    /// order. It has no rect, which is exactly right: there is nowhere on the
    /// screen to click something that is not on the screen.
    fn ring_only(&mut self, id: u32, enabled: bool) {
        if !self.tree.exists(id) {
            return;
        }
        let enabled = enabled && self.tree.props_of(id).bool("sensitive", true);
        if enabled && self.tree.tag_of(id).focusable() {
            self.out.ring.push(id);
        }
        for child in self.tree.children_of(id) {
            self.ring_only(*child, enabled);
        }
    }

    fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
        if area.is_empty() {
            return;
        }
        let rects = layout::children_rects(self.tree, id, area);
        for (child, rect) in self.tree.children(id).into_iter().zip(rects) {
            // Clip to the parent: a child asking for more rows than are left
            // paints what fits rather than over its neighbours.
            let bottom = area.y.saturating_add(area.h);
            let right = area.x.saturating_add(area.w);
            if rect.y >= bottom || rect.x >= right {
                continue;
            }
            let clipped = Rect::new(
                rect.x,
                rect.y,
                rect.w.min(right - rect.x),
                rect.h.min(bottom - rect.y),
            );
            self.node(child, clipped, style, enabled);
        }
    }

    fn wrapped(&mut self, area: Rect, text: &str, style: Style) {
        for (i, line) in wrap(text, area.w).into_iter().enumerate() {
            if i as u16 >= area.h {
                break;
            }
            self.screen
                .text(area.x, area.y + i as u16, area.w, &line, style);
        }
    }

    fn focused(&self, id: u32) -> bool {
        self.focus == id
    }

    fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
        let mut style = match props.str("kind") {
            "primary" => style.with(attr::BOLD),
            "destructive" => style.fg(Color::parse("red").unwrap_or_default()),
            _ => style,
        };
        if self.focused(id) {
            style = style.with(attr::REVERSE);
        }
        let label = format!("[ {} ]", props.label());
        self.screen.text(area.x, area.y, area.w, &label, style);
    }

    /// A picture: the cells it was given, and a note of where they are.
    ///
    /// Nothing goes in them. The terminal draws the picture over the blank
    /// cells when the frame is flushed, which is the only way pixels reach a
    /// grid; where there is no protocol for that, the cells carry the note
    /// that says a picture is here, and the link above it is the way to it.
    fn image(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
        let path = props.str("src");
        if path.is_empty() || area.is_empty() {
            return;
        }
        if !graphics::supported() {
            self.screen.text(
                area.x,
                area.y,
                area.w,
                layout::PICTURE,
                style.with(attr::DIM),
            );
            return;
        }
        // The column hands a child its whole width; a picture takes only what
        // its shape asks for out of that, so the placement is the picture and
        // not the room around it.
        let (cols, rows) = layout::image_cells(props, area.w);
        let area = Rect::new(area.x, area.y, cols.min(area.w), rows.min(area.h));
        if area.is_empty() {
            return;
        }
        self.out.images.push(graphics::Placement {
            node: id,
            path: path.to_owned(),
            area,
            crop_top: 0,
            crop_bottom: 0,
        });
    }

    /// A reaction pill: the glyph, the tally where there is one, and whether
    /// you are on it.
    ///
    /// No border around it. A window draws a lozenge because it has half-cells
    /// to draw one in; here brackets would cost two columns of a row that
    /// already carries three chips, and would say "button" about a thing whose
    /// whole picture is the glyph. Yours is bold, which is the one bit of the
    /// pill a reader actually reads off it.
    fn reaction(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
        let mut style = if props.bool("mine", false) {
            style.with(attr::BOLD)
        } else {
            style
        };
        if self.focused(id) {
            style = style.with(attr::REVERSE);
        }
        self.screen
            .text(area.x, area.y, area.w, &layout::pill_text(props), style);
    }

    fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
        let style = if self.focused(id) {
            style.with(attr::REVERSE)
        } else {
            style
        };
        let mark = if props.bool("active", false) {
            'x'
        } else {
            ' '
        };
        let label = format!("[{mark}] {}", props.label());
        self.screen.text(area.x, area.y, area.w, &label, style);
    }

    fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
        let focused = self.focused(id);
        let text = props.str("text");
        let showing_placeholder = text.is_empty();
        let shown = layout::entry_text(props);
        let mut style = style.with(attr::UNDERLINE);
        if showing_placeholder {
            style = style.with(attr::DIM);
        }
        if focused {
            style = style.with(attr::REVERSE);
        }
        // The field is its whole rect, not just the text in it: a reader needs
        // to see where it can type before it has typed anything.
        self.screen.fill(area, style);
        let rows = area.h.max(1);
        let lines = if props.cells("rows", 1) > 1 {
            wrap(&shown, area.w)
        } else {
            vec![shown.chars().collect::<String>()]
        };
        let caret = self.caret.min(text.chars().count());
        // A line longer than the field scrolls sideways to keep the caret in
        // view — the end of it is where someone is usually typing, but not
        // always, so it follows the caret rather than the end.
        for (i, line) in lines.iter().take(rows as usize).enumerate() {
            let len = line.chars().count();
            let last = i + 1 == lines.len().min(rows as usize);
            let window = area.w.saturating_sub(1).max(1) as usize;
            let from = if last && !showing_placeholder {
                caret.saturating_sub(window)
            } else {
                len.saturating_sub(window)
            };
            let visible: String = line.chars().skip(from).collect();
            self.screen
                .text(area.x, area.y + i as u16, area.w, &visible, style);
            if focused && last {
                // In columns rather than characters: an emoji typed into the
                // line is two cells wide, and a caret counted in characters
                // sits a column left of the text for each one.
                let col = if showing_placeholder {
                    0
                } else {
                    let typed: String = visible.chars().take(caret.saturating_sub(from)).collect();
                    (screen::text_cols(&typed)).min(area.w.saturating_sub(1))
                };
                self.out.cursor = Some((area.x.saturating_add(col), area.y + i as u16));
            }
        }
    }

    fn separator(&mut self, area: Rect, style: Style) {
        for x in area.x..area.x.saturating_add(area.w) {
            self.screen.set(x, area.y, '─', style);
        }
    }

    fn progress(&mut self, area: Rect, props: &Props, style: Style) {
        let value = props.num("value", 0.0).clamp(0.0, 1.0);
        let filled = (value * area.w as f64).round() as u16;
        for x in 0..area.w {
            let ch = if x < filled { '█' } else { '░' };
            self.screen.set(area.x + x, area.y, ch, style);
        }
        let label = props.label();
        if !label.is_empty() {
            let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2;
            self.screen.text(at, area.y, area.w, label, style);
        }
    }

    fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) {
        let items = self.tree.children(id);
        // No `:selected` at all means the cursor is on the first row: a list
        // with no cursor cannot be moved with the arrows, and a caller that
        // wants none says so with -1.
        let selected = props.num("selected", 0.0);
        let selected = if selected < 0.0 {
            None
        } else {
            Some(selected as usize)
        };
        // Keep the cursor on screen: scroll only as far as it takes.
        let rows = area.h as usize;
        let first = match selected {
            Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows,
            _ => 0,
        };
        for (row, item) in items.iter().skip(first).take(rows).enumerate() {
            let y = area.y + row as u16;
            let chosen = selected == Some(first + row);
            let mut row_style = style;
            if chosen {
                row_style = row_style.with(if self.focused(id) {
                    attr::REVERSE
                } else {
                    attr::BOLD
                });
                self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style);
            }
            let marker = if chosen { "" } else { "  " };
            self.screen.text(area.x, y, area.w, marker, row_style);
            let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1);
            self.node(*item, cell, row_style, enabled);
        }
    }

    fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) {
        let props = self.tree.props(id);
        // The content is painted at its full height into a screen of its own,
        // then the visible window of it is copied across. Doing it this way
        // means every widget inside a scroll paints exactly as it would
        // outside one — nothing has to know it is being clipped.
        let content_h = self
            .tree
            .children(id)
            .iter()
            .map(|c| layout::height_for_width(self.tree, *c, area.w))
            .sum::<u16>()
            .max(1);
        let max_offset = content_h.saturating_sub(area.h);
        let offset = props.cells("offset", 0).min(max_offset);
        self.out.scrolled.push((id, offset, max_offset, area));

        // Only the part of the content the viewport is showing is painted.
        // A backlog is a hundred messages and a screen holds a dozen; painting
        // the whole column into a grid that tall and copying a window out of
        // it costs the same on the ninetieth message nobody is looking at as
        // on the one they are reading — which is what made scrolling a long
        // conversation cost more than scrolling a short one.
        //
        // `band` is the rows worth painting: the visible window, grown to whole
        // children at each end so that a message straddling an edge is laid out
        // in one piece and cut by the copy rather than by the layout. Its top
        // is where the buffer's row 0 is, and everything the pass below learned
        // is in the buffer's coordinates — so it is moved back into the
        // content's before the rest of this reads it against `offset`.
        let full = Rect::new(0, 0, area.w, content_h);
        let rects = layout::children_rects(self.tree, id, full);
        let kids = self.tree.children(id);
        let seen = offset..offset.saturating_add(area.h);
        let mut base = seen.start;
        let mut foot = seen.end.min(content_h);
        // In order, and every child accounted for: the ones on screen are
        // painted, and the ones that are not still take their place in the
        // focus ring below.
        let mut plan = Vec::with_capacity(kids.len());
        for (child, rect) in kids.iter().zip(&rects) {
            let shown = rect.y < seen.end && rect.y.saturating_add(rect.h) > seen.start;
            if shown {
                base = base.min(rect.y);
                foot = foot.max(rect.y.saturating_add(rect.h));
            }
            plan.push((*child, *rect, shown));
        }
        let band = foot.saturating_sub(base).max(1);

        let mut buffer = Screen::new(area.w, band);
        let mut inner = Painter {
            tree: self.tree,
            screen: &mut buffer,
            focus: self.focus,
            caret: self.caret,
            tick: self.tick,
            out: Painted::default(),
            overlays: Vec::new(),
        };
        for (child, rect, shown) in plan {
            if !shown {
                inner.ring_only(child, enabled);
                continue;
            }
            // The same clip `children` applies, against the content rather than
            // the band: a child asking for more than the column has paints what
            // fits. Nothing is clipped to the band itself — a child hanging off
            // either end of it is what the copy below is for.
            let width = rect.w.min(full.w.saturating_sub(rect.x));
            inner.node(
                child,
                Rect::new(rect.x, rect.y - base, width, rect.h),
                style,
                enabled,
            );
        }
        let mut learned = inner.out;
        learned.shift_down(base);

        for y in 0..area.h {
            for x in 0..area.w {
                if let Some(cell) = buffer.cell(x, (y + offset).saturating_sub(base)) {
                    self.screen.put(area.x + x, area.y + y, cell.clone());
                }
            }
        }
        // Widgets inside keep their place in the focus ring; their rects move
        // by the viewport, and the ones scrolled out of sight take no clicks.
        self.out.ring.extend(learned.ring);
        for (node, rect) in learned.hits {
            if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
                self.out.hits.push((
                    node,
                    Rect::new(
                        area.x + rect.x,
                        area.y + rect.y - offset,
                        rect.w,
                        rect.h.min(area.h),
                    ),
                ));
            }
        }
        // A scroll inside this one was painted into the buffer, so its area is
        // in the buffer's coordinates: move it the way the hits above moved,
        // and drop the ones the viewport is not showing. A wheel over a nested
        // list has to land on the list under the pointer, and a rect left in
        // the wrong space is a wheel aimed at whatever happens to be there.
        // A picture inside a scroll moves with it, and is cut off by the
        // viewport rather than painted over what is above or below: the
        // protocol crops from the source, so a backlog scrolls past a picture
        // a row at a time instead of losing it whole at the edge.
        let bottom = offset.saturating_add(area.h);
        for mut placement in learned.images {
            let top = placement.area.y;
            let foot = top.saturating_add(placement.area.h);
            let seen_top = top.max(offset);
            let seen_foot = foot.min(bottom);
            if seen_foot <= seen_top {
                continue;
            }
            placement.crop_top += seen_top - top;
            placement.crop_bottom += foot - seen_foot;
            placement.area = Rect::new(
                area.x + placement.area.x,
                area.y + seen_top - offset,
                placement.area.w,
                seen_foot - seen_top,
            );
            self.out.images.push(placement);
        }
        for (node, inner_offset, max, rect) in learned.scrolled {
            if rect.y >= offset && rect.y < offset.saturating_add(area.h) {
                self.out.scrolled.push((
                    node,
                    inner_offset,
                    max,
                    Rect::new(
                        area.x + rect.x,
                        area.y + rect.y - offset,
                        rect.w,
                        rect.h.min(area.h),
                    ),
                ));
            }
        }
        if let Some((cx, cy)) = learned.cursor {
            if cy >= offset && cy < offset.saturating_add(area.h) {
                self.out.cursor = Some((area.x + cx, area.y + cy - offset));
            }
        }
    }

    fn overlay(&mut self, id: u32, screen: Rect) {
        let props = self.tree.props(id);
        let w = layout::width(self.tree, id, false).min(screen.w);
        let h = layout::height_for_width(self.tree, id, w).min(screen.h);
        let (x, y) = (
            screen.x + Align::Center.offset_pub(w, screen.w),
            screen.y + Align::Center.offset_pub(h, screen.h),
        );
        let area = Rect::new(x, y, w, h);
        let style = self.style_for(&props, Style::default(), true);
        // Blank what is under it: a floating panel that shows the screen
        // through its gaps is unreadable.
        for row in area.y..area.y + area.h {
            for col in area.x..area.x + area.w {
                self.screen.set(col, row, ' ', style);
            }
        }
        self.border(area, props.label(), style);
        let pad = layout::inset(&Tag::Overlay, &props);
        self.children(id, area.shrink(pad), style, true);
    }

    /// A single-line box, with `label` set into the top edge when there is one.
    fn border(&mut self, area: Rect, label: &str, style: Style) {
        if area.w < 2 || area.h < 2 {
            return;
        }
        let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1);
        for x in area.x..=x1 {
            self.screen.set(x, area.y, '─', style);
            self.screen.set(x, y1, '─', style);
        }
        for y in area.y..=y1 {
            self.screen.set(area.x, y, '│', style);
            self.screen.set(x1, y, '│', style);
        }
        self.screen.set(area.x, area.y, '┌', style);
        self.screen.set(x1, area.y, '┐', style);
        self.screen.set(area.x, y1, '└', style);
        self.screen.set(x1, y1, '┘', style);
        if !label.is_empty() && area.w > 4 {
            let text = format!(" {label} ");
            self.screen.text(
                area.x + 1,
                area.y,
                area.w - 2,
                &text,
                style.with(attr::BOLD),
            );
        }
    }
}

impl Align {
    /// [`Align::offset`] is private to the layout module; overlays are the one
    /// caller outside it that centres something by hand.
    fn offset_pub(self, size: u16, avail: u16) -> u16 {
        layout::place(self, size, avail).0
    }
}