| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 1 | //! Drawing the tree into a grid of cells. |
| 2 | //! |
| 3 | //! One pass, top to bottom: each node is handed a rect by [`crate::layout`] and |
| 4 | //! paints itself into it. Two things fall out of the walk and are kept — |
| 5 | //! the focus ring, in the order the widgets were painted, and every focusable |
| 6 | //! widget's rect, so a mouse click can be turned back into a node. |
| 7 | //! |
| 8 | //! Overlays are collected rather than drawn in place: a floating panel belongs |
| 9 | //! over the whole screen, so it is painted after everything else at the size it |
| 10 | //! asked for, in the middle. |
| 11 | |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 12 | use crate::graphics; |
| Run the formatter over the tree 3e8c6f0 nandi 13d ago | 13 | use crate::layout::{self, wrap, Align}; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 14 | use crate::screen::{self, attr, Color, Rect, Screen, Style}; |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 15 | use crate::tree::{Props, Tag, Tree}; |
| 16 | |
| 17 | const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']; |
| 18 | |
| 19 | /// What one frame of painting learned about the tree, for the input half to |
| 20 | /// use on the next key or click. |
| 21 | #[derive(Clone, Debug, Default)] |
| 22 | pub struct Painted { |
| 23 | /// Focusable nodes in paint order — the order Tab walks. |
| 24 | pub ring: Vec<u32>, |
| 25 | /// Where each of them ended up. |
| 26 | pub hits: Vec<(u32, Rect)>, |
| 27 | /// How far each scroll node's viewport actually was, after clamping to the |
| 28 | /// content it had. Written back so a caller cannot scroll past the end. |
| Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago | 29 | /// Each `:scroll` painted, as (node, the offset it was painted at, the |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 30 | /// furthest it could have been, and the area it was painted into). The |
| 31 | /// second number is what tells a caller whether it is at the bottom, which |
| 32 | /// is what sticking to it means; the rect is what a wheel is aimed at. |
| 33 | pub scrolled: Vec<(u32, u16, u16, Rect)>, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 34 | /// Where the cursor should sit — the focused entry's caret, if any. |
| 35 | pub cursor: Option<(u16, u16)>, |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 36 | /// The pictures this frame wants on screen, in the cells they were given. |
| 37 | /// Nothing was painted for them: the grid has no pixels, and the terminal |
| 38 | /// is what draws one — see [`crate::graphics`]. |
| 39 | pub images: Vec<graphics::Placement>, |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 40 | } |
| 41 | |
| Measure a node once, and paint only what is on screen c41903b nandi 16d ago | 42 | impl Painted { |
| 43 | /// Move everything down by `rows`. |
| 44 | /// |
| 45 | /// A scroll paints its content into a buffer that starts partway down the |
| 46 | /// column, so what came back is in that buffer's coordinates. This puts it |
| 47 | /// back into the content's, where the viewport's own offset means what it |
| 48 | /// says. |
| 49 | fn shift_down(&mut self, rows: u16) { |
| 50 | if rows == 0 { |
| 51 | return; |
| 52 | } |
| 53 | for (_, rect) in &mut self.hits { |
| 54 | rect.y = rect.y.saturating_add(rows); |
| 55 | } |
| 56 | for (_, _, _, rect) in &mut self.scrolled { |
| 57 | rect.y = rect.y.saturating_add(rows); |
| 58 | } |
| 59 | for placement in &mut self.images { |
| 60 | placement.area.y = placement.area.y.saturating_add(rows); |
| 61 | } |
| 62 | if let Some((_, y)) = &mut self.cursor { |
| 63 | *y = y.saturating_add(rows); |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 68 | struct Painter<'a> { |
| 69 | tree: &'a Tree, |
| 70 | screen: &'a mut Screen, |
| 71 | focus: u32, |
| 72 | /// Where the caret sits in the focused entry's text, in characters. |
| 73 | caret: usize, |
| 74 | tick: u64, |
| 75 | out: Painted, |
| 76 | overlays: Vec<u32>, |
| 77 | } |
| 78 | |
| 79 | /// Paint the whole tree. `focus` is the node the ring is currently on and |
| 80 | /// `tick` advances the spinners. |
| 81 | pub fn frame(tree: &Tree, screen: &mut Screen, focus: u32, caret: usize, tick: u64) -> Painted { |
| 82 | screen.clear(); |
| 83 | let mut painter = Painter { |
| 84 | tree, |
| 85 | screen, |
| 86 | focus, |
| 87 | caret, |
| 88 | tick, |
| 89 | out: Painted::default(), |
| 90 | overlays: Vec::new(), |
| 91 | }; |
| 92 | let area = painter.screen.rect(); |
| 93 | painter.node(tree.root(), area, Style::default(), true); |
| 94 | |
| 95 | // Overlays float above the rest, so they are painted after it — and a |
| 96 | // click landing on one must beat a click on whatever it covers, which is |
| 97 | // what putting their hit rects first does. |
| 98 | let overlays = std::mem::take(&mut painter.overlays); |
| 99 | let below = std::mem::take(&mut painter.out.hits); |
| 100 | for id in overlays { |
| 101 | painter.overlay(id, area); |
| 102 | } |
| 103 | painter.out.hits.extend(below); |
| 104 | painter.out |
| 105 | } |
| 106 | |
| 107 | impl Painter<'_> { |
| 108 | fn style_for(&self, props: &Props, inherited: Style, enabled: bool) -> Style { |
| 109 | let mut style = inherited; |
| 110 | if let Some(fg) = Color::parse(props.str("color")) { |
| 111 | style.fg = fg; |
| 112 | } |
| 113 | if let Some(bg) = Color::parse(props.str("bg")) { |
| 114 | style.bg = bg; |
| 115 | } |
| 116 | for (key, bit) in [ |
| 117 | ("bold", attr::BOLD), |
| 118 | ("dim", attr::DIM), |
| 119 | ("underline", attr::UNDERLINE), |
| 120 | ("reverse", attr::REVERSE), |
| 121 | ("blink", attr::BLINK), |
| 122 | ("italic", attr::ITALIC), |
| 123 | ] { |
| 124 | if props.bool(key, false) { |
| 125 | style.attrs |= bit; |
| 126 | } |
| 127 | } |
| 128 | if !enabled { |
| 129 | // `:sensitive false` dims the widget *and its whole subtree*, which |
| 130 | // is what it means in every other glimmer backend. |
| 131 | style.attrs |= attr::DIM; |
| 132 | } |
| 133 | style |
| 134 | } |
| 135 | |
| 136 | fn node(&mut self, id: u32, area: Rect, inherited: Style, enabled: bool) { |
| 137 | if area.is_empty() || !self.tree.exists(id) { |
| 138 | return; |
| 139 | } |
| 140 | let tag = self.tree.tag(id); |
| 141 | let props = self.tree.props(id); |
| 142 | let enabled = enabled && props.bool("sensitive", true); |
| 143 | let style = self.style_for(&props, inherited, enabled); |
| 144 | if props.has("bg") { |
| 145 | self.screen.fill(area, style); |
| 146 | } |
| 147 | if enabled && tag.focusable() { |
| 148 | self.out.ring.push(id); |
| 149 | self.out.hits.push((id, area)); |
| 150 | } |
| 151 | |
| 152 | let pad = layout::inset(&tag, &props); |
| 153 | let inner = area.shrink(pad); |
| 154 | match tag { |
| 155 | Tag::Overlay => self.overlays.push(id), |
| 156 | Tag::Frame => { |
| 157 | self.border(area, props.label(), style); |
| 158 | self.children(id, inner, style, enabled); |
| 159 | } |
| 160 | Tag::Scroll => self.scroll(id, inner, style, enabled), |
| Share a column's rows out at the width it has, not at its own height 5acc801 nandi 17d ago | 161 | Tag::Box | Tag::Window => self.children(id, inner, style, enabled), |
| 162 | // A tag this backend has not learned paints as a vertical box, so |
| 163 | // whatever is under it still reaches the screen. When there is |
| 164 | // nothing under it, its own text does instead: an unknown *leaf* |
| 165 | // is a widget the caller has and this has not — frq's `:status` |
| 166 | // badge, its `:link` — and painting the box and not the label is |
| 167 | // the one outcome that loses the text altogether. A link vanishing |
| 168 | // out of the middle of a message is not a missing widget; it is a |
| 169 | // missing sentence. |
| 170 | Tag::Unknown(_) => { |
| Let a backlog keep its place, and leave room for what is under it 68910bd nandi 17d ago | 171 | if self.tree.child_count(id) == 0 && !props.has("src") && !props.has("feed") { |
| Share a column's rows out at the width it has, not at its own height 5acc801 nandi 17d ago | 172 | self.wrapped(inner, props.label(), style); |
| 173 | } else { |
| 174 | self.children(id, inner, style, enabled); |
| 175 | } |
| 176 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 177 | Tag::Label => self.wrapped(inner, props.label(), style), |
| 178 | Tag::Title => self.wrapped(inner, props.label(), style.with(attr::BOLD)), |
| 179 | Tag::DimLabel => self.wrapped(inner, props.label(), style.with(attr::DIM)), |
| 180 | Tag::Button => self.button(id, inner, &props, style), |
| 181 | Tag::CheckButton => self.check(id, inner, &props, style), |
| 182 | Tag::Entry => self.entry(id, inner, &props, style), |
| 183 | Tag::Separator => self.separator(inner, style), |
| 184 | Tag::Progress => self.progress(inner, &props, style), |
| 185 | Tag::Spinner => { |
| 186 | let ch = SPINNER[(self.tick as usize) % SPINNER.len()]; |
| 187 | self.screen.set(inner.x, inner.y, ch, style); |
| 188 | } |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 189 | Tag::Image => self.image(id, inner, &props, style), |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 190 | Tag::Reaction => self.reaction(id, inner, &props, style), |
| 191 | // The same glyph with nothing around it: a character in a line, |
| 192 | // and the line is what says anything about it. |
| 193 | Tag::Emoji => { |
| 194 | self.screen |
| 195 | .text(inner.x, inner.y, inner.w, props.str("emoji"), style); |
| 196 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 197 | Tag::Listbox => self.listbox(id, inner, &props, style, enabled), |
| 198 | // A spacer is the absence of anything; the clear at the top of the |
| 199 | // frame has already drawn it. |
| 200 | Tag::Spacer => {} |
| 201 | } |
| 202 | } |
| 203 | |
| Measure a node once, and paint only what is on screen c41903b nandi 16d ago | 204 | /// Put a subtree's focusable nodes into the ring without painting it. |
| 205 | /// |
| 206 | /// What a scroll owes the parts of its content it did not paint. Tab walks |
| 207 | /// the ring, and a reader tabbing onto a button below the fold is how they |
| 208 | /// scroll to it — so a widget being out of sight cannot take it out of the |
| 209 | /// order. It has no rect, which is exactly right: there is nowhere on the |
| 210 | /// screen to click something that is not on the screen. |
| 211 | fn ring_only(&mut self, id: u32, enabled: bool) { |
| 212 | if !self.tree.exists(id) { |
| 213 | return; |
| 214 | } |
| 215 | let enabled = enabled && self.tree.props_of(id).bool("sensitive", true); |
| 216 | if enabled && self.tree.tag_of(id).focusable() { |
| 217 | self.out.ring.push(id); |
| 218 | } |
| 219 | for child in self.tree.children_of(id) { |
| 220 | self.ring_only(*child, enabled); |
| 221 | } |
| 222 | } |
| 223 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 224 | fn children(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { |
| 225 | if area.is_empty() { |
| 226 | return; |
| 227 | } |
| 228 | let rects = layout::children_rects(self.tree, id, area); |
| 229 | for (child, rect) in self.tree.children(id).into_iter().zip(rects) { |
| 230 | // Clip to the parent: a child asking for more rows than are left |
| 231 | // paints what fits rather than over its neighbours. |
| 232 | let bottom = area.y.saturating_add(area.h); |
| 233 | let right = area.x.saturating_add(area.w); |
| 234 | if rect.y >= bottom || rect.x >= right { |
| 235 | continue; |
| 236 | } |
| 237 | let clipped = Rect::new( |
| 238 | rect.x, |
| 239 | rect.y, |
| 240 | rect.w.min(right - rect.x), |
| 241 | rect.h.min(bottom - rect.y), |
| 242 | ); |
| 243 | self.node(child, clipped, style, enabled); |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | fn wrapped(&mut self, area: Rect, text: &str, style: Style) { |
| 248 | for (i, line) in wrap(text, area.w).into_iter().enumerate() { |
| 249 | if i as u16 >= area.h { |
| 250 | break; |
| 251 | } |
| 252 | self.screen |
| 253 | .text(area.x, area.y + i as u16, area.w, &line, style); |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | fn focused(&self, id: u32) -> bool { |
| 258 | self.focus == id |
| 259 | } |
| 260 | |
| 261 | fn button(&mut self, id: u32, area: Rect, props: &Props, style: Style) { |
| 262 | let mut style = match props.str("kind") { |
| 263 | "primary" => style.with(attr::BOLD), |
| 264 | "destructive" => style.fg(Color::parse("red").unwrap_or_default()), |
| 265 | _ => style, |
| 266 | }; |
| 267 | if self.focused(id) { |
| 268 | style = style.with(attr::REVERSE); |
| 269 | } |
| 270 | let label = format!("[ {} ]", props.label()); |
| 271 | self.screen.text(area.x, area.y, area.w, &label, style); |
| 272 | } |
| 273 | |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 274 | /// A picture: the cells it was given, and a note of where they are. |
| 275 | /// |
| 276 | /// Nothing goes in them. The terminal draws the picture over the blank |
| 277 | /// cells when the frame is flushed, which is the only way pixels reach a |
| 278 | /// grid; where there is no protocol for that, the cells carry the note |
| 279 | /// that says a picture is here, and the link above it is the way to it. |
| 280 | fn image(&mut self, id: u32, area: Rect, props: &Props, style: Style) { |
| 281 | let path = props.str("src"); |
| 282 | if path.is_empty() || area.is_empty() { |
| 283 | return; |
| 284 | } |
| 285 | if !graphics::supported() { |
| Run the formatter over the tree 3e8c6f0 nandi 13d ago | 286 | self.screen.text( |
| 287 | area.x, |
| 288 | area.y, |
| 289 | area.w, |
| 290 | layout::PICTURE, |
| 291 | style.with(attr::DIM), |
| 292 | ); |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 293 | return; |
| 294 | } |
| 295 | // The column hands a child its whole width; a picture takes only what |
| 296 | // its shape asks for out of that, so the placement is the picture and |
| 297 | // not the room around it. |
| 298 | let (cols, rows) = layout::image_cells(props, area.w); |
| 299 | let area = Rect::new(area.x, area.y, cols.min(area.w), rows.min(area.h)); |
| 300 | if area.is_empty() { |
| 301 | return; |
| 302 | } |
| 303 | self.out.images.push(graphics::Placement { |
| 304 | node: id, |
| 305 | path: path.to_owned(), |
| 306 | area, |
| 307 | crop_top: 0, |
| 308 | crop_bottom: 0, |
| 309 | }); |
| 310 | } |
| 311 | |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 312 | /// A reaction pill: the glyph, the tally where there is one, and whether |
| 313 | /// you are on it. |
| 314 | /// |
| 315 | /// No border around it. A window draws a lozenge because it has half-cells |
| 316 | /// to draw one in; here brackets would cost two columns of a row that |
| 317 | /// already carries three chips, and would say "button" about a thing whose |
| 318 | /// whole picture is the glyph. Yours is bold, which is the one bit of the |
| 319 | /// pill a reader actually reads off it. |
| 320 | fn reaction(&mut self, id: u32, area: Rect, props: &Props, style: Style) { |
| 321 | let mut style = if props.bool("mine", false) { |
| 322 | style.with(attr::BOLD) |
| 323 | } else { |
| 324 | style |
| 325 | }; |
| 326 | if self.focused(id) { |
| 327 | style = style.with(attr::REVERSE); |
| 328 | } |
| 329 | self.screen |
| 330 | .text(area.x, area.y, area.w, &layout::pill_text(props), style); |
| 331 | } |
| 332 | |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 333 | fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) { |
| 334 | let style = if self.focused(id) { |
| 335 | style.with(attr::REVERSE) |
| 336 | } else { |
| 337 | style |
| 338 | }; |
| 339 | let mark = if props.bool("active", false) { |
| 340 | 'x' |
| 341 | } else { |
| 342 | ' ' |
| 343 | }; |
| 344 | let label = format!("[{mark}] {}", props.label()); |
| 345 | self.screen.text(area.x, area.y, area.w, &label, style); |
| 346 | } |
| 347 | |
| 348 | fn entry(&mut self, id: u32, area: Rect, props: &Props, style: Style) { |
| 349 | let focused = self.focused(id); |
| 350 | let text = props.str("text"); |
| 351 | let showing_placeholder = text.is_empty(); |
| 352 | let shown = layout::entry_text(props); |
| 353 | let mut style = style.with(attr::UNDERLINE); |
| 354 | if showing_placeholder { |
| 355 | style = style.with(attr::DIM); |
| 356 | } |
| 357 | if focused { |
| 358 | style = style.with(attr::REVERSE); |
| 359 | } |
| 360 | // The field is its whole rect, not just the text in it: a reader needs |
| 361 | // to see where it can type before it has typed anything. |
| 362 | self.screen.fill(area, style); |
| 363 | let rows = area.h.max(1); |
| 364 | let lines = if props.cells("rows", 1) > 1 { |
| 365 | wrap(&shown, area.w) |
| 366 | } else { |
| 367 | vec![shown.chars().collect::<String>()] |
| 368 | }; |
| 369 | let caret = self.caret.min(text.chars().count()); |
| 370 | // A line longer than the field scrolls sideways to keep the caret in |
| 371 | // view — the end of it is where someone is usually typing, but not |
| 372 | // always, so it follows the caret rather than the end. |
| 373 | for (i, line) in lines.iter().take(rows as usize).enumerate() { |
| 374 | let len = line.chars().count(); |
| 375 | let last = i + 1 == lines.len().min(rows as usize); |
| 376 | let window = area.w.saturating_sub(1).max(1) as usize; |
| 377 | let from = if last && !showing_placeholder { |
| 378 | caret.saturating_sub(window) |
| 379 | } else { |
| 380 | len.saturating_sub(window) |
| 381 | }; |
| 382 | let visible: String = line.chars().skip(from).collect(); |
| 383 | self.screen |
| 384 | .text(area.x, area.y + i as u16, area.w, &visible, style); |
| 385 | if focused && last { |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 386 | // In columns rather than characters: an emoji typed into the |
| 387 | // line is two cells wide, and a caret counted in characters |
| 388 | // sits a column left of the text for each one. |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 389 | let col = if showing_placeholder { |
| 390 | 0 |
| 391 | } else { |
| Run the formatter over the tree 3e8c6f0 nandi 13d ago | 392 | let typed: String = visible.chars().take(caret.saturating_sub(from)).collect(); |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 393 | (screen::text_cols(&typed)).min(area.w.saturating_sub(1)) |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 394 | }; |
| Paint a reaction, and give an emoji the two columns it takes dce285f nandi 16d ago | 395 | self.out.cursor = Some((area.x.saturating_add(col), area.y + i as u16)); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 396 | } |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | fn separator(&mut self, area: Rect, style: Style) { |
| 401 | for x in area.x..area.x.saturating_add(area.w) { |
| 402 | self.screen.set(x, area.y, '─', style); |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | fn progress(&mut self, area: Rect, props: &Props, style: Style) { |
| 407 | let value = props.num("value", 0.0).clamp(0.0, 1.0); |
| 408 | let filled = (value * area.w as f64).round() as u16; |
| 409 | for x in 0..area.w { |
| 410 | let ch = if x < filled { '█' } else { '░' }; |
| 411 | self.screen.set(area.x + x, area.y, ch, style); |
| 412 | } |
| 413 | let label = props.label(); |
| 414 | if !label.is_empty() { |
| 415 | let at = area.x + (area.w.saturating_sub(label.chars().count() as u16)) / 2; |
| 416 | self.screen.text(at, area.y, area.w, label, style); |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | fn listbox(&mut self, id: u32, area: Rect, props: &Props, style: Style, enabled: bool) { |
| 421 | let items = self.tree.children(id); |
| 422 | // No `:selected` at all means the cursor is on the first row: a list |
| 423 | // with no cursor cannot be moved with the arrows, and a caller that |
| 424 | // wants none says so with -1. |
| 425 | let selected = props.num("selected", 0.0); |
| 426 | let selected = if selected < 0.0 { |
| 427 | None |
| 428 | } else { |
| 429 | Some(selected as usize) |
| 430 | }; |
| 431 | // Keep the cursor on screen: scroll only as far as it takes. |
| 432 | let rows = area.h as usize; |
| 433 | let first = match selected { |
| 434 | Some(sel) if rows > 0 && sel >= rows => sel + 1 - rows, |
| 435 | _ => 0, |
| 436 | }; |
| 437 | for (row, item) in items.iter().skip(first).take(rows).enumerate() { |
| 438 | let y = area.y + row as u16; |
| 439 | let chosen = selected == Some(first + row); |
| 440 | let mut row_style = style; |
| 441 | if chosen { |
| 442 | row_style = row_style.with(if self.focused(id) { |
| 443 | attr::REVERSE |
| 444 | } else { |
| 445 | attr::BOLD |
| 446 | }); |
| 447 | self.screen.fill(Rect::new(area.x, y, area.w, 1), row_style); |
| 448 | } |
| 449 | let marker = if chosen { "› " } else { " " }; |
| 450 | self.screen.text(area.x, y, area.w, marker, row_style); |
| 451 | let cell = Rect::new(area.x + 2, y, area.w.saturating_sub(2), 1); |
| 452 | self.node(*item, cell, row_style, enabled); |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | fn scroll(&mut self, id: u32, area: Rect, style: Style, enabled: bool) { |
| 457 | let props = self.tree.props(id); |
| 458 | // The content is painted at its full height into a screen of its own, |
| 459 | // then the visible window of it is copied across. Doing it this way |
| 460 | // means every widget inside a scroll paints exactly as it would |
| 461 | // outside one — nothing has to know it is being clipped. |
| 462 | let content_h = self |
| 463 | .tree |
| 464 | .children(id) |
| 465 | .iter() |
| 466 | .map(|c| layout::height_for_width(self.tree, *c, area.w)) |
| 467 | .sum::<u16>() |
| 468 | .max(1); |
| 469 | let max_offset = content_h.saturating_sub(area.h); |
| 470 | let offset = props.cells("offset", 0).min(max_offset); |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 471 | self.out.scrolled.push((id, offset, max_offset, area)); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 472 | |
| Measure a node once, and paint only what is on screen c41903b nandi 16d ago | 473 | // Only the part of the content the viewport is showing is painted. |
| 474 | // A backlog is a hundred messages and a screen holds a dozen; painting |
| 475 | // the whole column into a grid that tall and copying a window out of |
| 476 | // it costs the same on the ninetieth message nobody is looking at as |
| 477 | // on the one they are reading — which is what made scrolling a long |
| 478 | // conversation cost more than scrolling a short one. |
| 479 | // |
| 480 | // `band` is the rows worth painting: the visible window, grown to whole |
| 481 | // children at each end so that a message straddling an edge is laid out |
| 482 | // in one piece and cut by the copy rather than by the layout. Its top |
| 483 | // is where the buffer's row 0 is, and everything the pass below learned |
| 484 | // is in the buffer's coordinates — so it is moved back into the |
| 485 | // content's before the rest of this reads it against `offset`. |
| 486 | let full = Rect::new(0, 0, area.w, content_h); |
| 487 | let rects = layout::children_rects(self.tree, id, full); |
| 488 | let kids = self.tree.children(id); |
| 489 | let seen = offset..offset.saturating_add(area.h); |
| 490 | let mut base = seen.start; |
| 491 | let mut foot = seen.end.min(content_h); |
| 492 | // In order, and every child accounted for: the ones on screen are |
| 493 | // painted, and the ones that are not still take their place in the |
| 494 | // focus ring below. |
| 495 | let mut plan = Vec::with_capacity(kids.len()); |
| 496 | for (child, rect) in kids.iter().zip(&rects) { |
| 497 | let shown = rect.y < seen.end && rect.y.saturating_add(rect.h) > seen.start; |
| 498 | if shown { |
| 499 | base = base.min(rect.y); |
| 500 | foot = foot.max(rect.y.saturating_add(rect.h)); |
| 501 | } |
| 502 | plan.push((*child, *rect, shown)); |
| 503 | } |
| 504 | let band = foot.saturating_sub(base).max(1); |
| 505 | |
| 506 | let mut buffer = Screen::new(area.w, band); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 507 | let mut inner = Painter { |
| 508 | tree: self.tree, |
| 509 | screen: &mut buffer, |
| 510 | focus: self.focus, |
| 511 | caret: self.caret, |
| 512 | tick: self.tick, |
| 513 | out: Painted::default(), |
| 514 | overlays: Vec::new(), |
| 515 | }; |
| Measure a node once, and paint only what is on screen c41903b nandi 16d ago | 516 | for (child, rect, shown) in plan { |
| 517 | if !shown { |
| 518 | inner.ring_only(child, enabled); |
| 519 | continue; |
| 520 | } |
| 521 | // The same clip `children` applies, against the content rather than |
| 522 | // the band: a child asking for more than the column has paints what |
| 523 | // fits. Nothing is clipped to the band itself — a child hanging off |
| 524 | // either end of it is what the copy below is for. |
| 525 | let width = rect.w.min(full.w.saturating_sub(rect.x)); |
| 526 | inner.node( |
| 527 | child, |
| 528 | Rect::new(rect.x, rect.y - base, width, rect.h), |
| 529 | style, |
| 530 | enabled, |
| 531 | ); |
| 532 | } |
| 533 | let mut learned = inner.out; |
| 534 | learned.shift_down(base); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 535 | |
| 536 | for y in 0..area.h { |
| 537 | for x in 0..area.w { |
| Measure a node once, and paint only what is on screen c41903b nandi 16d ago | 538 | if let Some(cell) = buffer.cell(x, (y + offset).saturating_sub(base)) { |
| Keep a glyph whole, selector and all, and copy it across whole 3dd441e nandi 16d ago | 539 | self.screen.put(area.x + x, area.y + y, cell.clone()); |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 540 | } |
| 541 | } |
| 542 | } |
| 543 | // Widgets inside keep their place in the focus ring; their rects move |
| 544 | // by the viewport, and the ones scrolled out of sight take no clicks. |
| 545 | self.out.ring.extend(learned.ring); |
| 546 | for (node, rect) in learned.hits { |
| 547 | if rect.y >= offset && rect.y < offset.saturating_add(area.h) { |
| 548 | self.out.hits.push(( |
| 549 | node, |
| 550 | Rect::new( |
| 551 | area.x + rect.x, |
| 552 | area.y + rect.y - offset, |
| 553 | rect.w, |
| 554 | rect.h.min(area.h), |
| 555 | ), |
| 556 | )); |
| 557 | } |
| 558 | } |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 559 | // A scroll inside this one was painted into the buffer, so its area is |
| 560 | // in the buffer's coordinates: move it the way the hits above moved, |
| 561 | // and drop the ones the viewport is not showing. A wheel over a nested |
| 562 | // list has to land on the list under the pointer, and a rect left in |
| 563 | // the wrong space is a wheel aimed at whatever happens to be there. |
| Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago | 564 | // A picture inside a scroll moves with it, and is cut off by the |
| 565 | // viewport rather than painted over what is above or below: the |
| 566 | // protocol crops from the source, so a backlog scrolls past a picture |
| 567 | // a row at a time instead of losing it whole at the edge. |
| 568 | let bottom = offset.saturating_add(area.h); |
| 569 | for mut placement in learned.images { |
| 570 | let top = placement.area.y; |
| 571 | let foot = top.saturating_add(placement.area.h); |
| 572 | let seen_top = top.max(offset); |
| 573 | let seen_foot = foot.min(bottom); |
| 574 | if seen_foot <= seen_top { |
| 575 | continue; |
| 576 | } |
| 577 | placement.crop_top += seen_top - top; |
| 578 | placement.crop_bottom += foot - seen_foot; |
| 579 | placement.area = Rect::new( |
| 580 | area.x + placement.area.x, |
| 581 | area.y + seen_top - offset, |
| 582 | placement.area.w, |
| 583 | seen_foot - seen_top, |
| 584 | ); |
| 585 | self.out.images.push(placement); |
| 586 | } |
| Scroll the list under the pointer, and from where it actually is a785201 nandi 16d ago | 587 | for (node, inner_offset, max, rect) in learned.scrolled { |
| 588 | if rect.y >= offset && rect.y < offset.saturating_add(area.h) { |
| 589 | self.out.scrolled.push(( |
| 590 | node, |
| 591 | inner_offset, |
| 592 | max, |
| 593 | Rect::new( |
| 594 | area.x + rect.x, |
| 595 | area.y + rect.y - offset, |
| 596 | rect.w, |
| 597 | rect.h.min(area.h), |
| 598 | ), |
| 599 | )); |
| 600 | } |
| 601 | } |
| Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago | 602 | if let Some((cx, cy)) = learned.cursor { |
| 603 | if cy >= offset && cy < offset.saturating_add(area.h) { |
| 604 | self.out.cursor = Some((area.x + cx, area.y + cy - offset)); |
| 605 | } |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | fn overlay(&mut self, id: u32, screen: Rect) { |
| 610 | let props = self.tree.props(id); |
| 611 | let w = layout::width(self.tree, id, false).min(screen.w); |
| 612 | let h = layout::height_for_width(self.tree, id, w).min(screen.h); |
| 613 | let (x, y) = ( |
| 614 | screen.x + Align::Center.offset_pub(w, screen.w), |
| 615 | screen.y + Align::Center.offset_pub(h, screen.h), |
| 616 | ); |
| 617 | let area = Rect::new(x, y, w, h); |
| 618 | let style = self.style_for(&props, Style::default(), true); |
| 619 | // Blank what is under it: a floating panel that shows the screen |
| 620 | // through its gaps is unreadable. |
| 621 | for row in area.y..area.y + area.h { |
| 622 | for col in area.x..area.x + area.w { |
| 623 | self.screen.set(col, row, ' ', style); |
| 624 | } |
| 625 | } |
| 626 | self.border(area, props.label(), style); |
| 627 | let pad = layout::inset(&Tag::Overlay, &props); |
| 628 | self.children(id, area.shrink(pad), style, true); |
| 629 | } |
| 630 | |
| 631 | /// A single-line box, with `label` set into the top edge when there is one. |
| 632 | fn border(&mut self, area: Rect, label: &str, style: Style) { |
| 633 | if area.w < 2 || area.h < 2 { |
| 634 | return; |
| 635 | } |
| 636 | let (x1, y1) = (area.x + area.w - 1, area.y + area.h - 1); |
| 637 | for x in area.x..=x1 { |
| 638 | self.screen.set(x, area.y, '─', style); |
| 639 | self.screen.set(x, y1, '─', style); |
| 640 | } |
| 641 | for y in area.y..=y1 { |
| 642 | self.screen.set(area.x, y, '│', style); |
| 643 | self.screen.set(x1, y, '│', style); |
| 644 | } |
| 645 | self.screen.set(area.x, area.y, '┌', style); |
| 646 | self.screen.set(x1, area.y, '┐', style); |
| 647 | self.screen.set(area.x, y1, '└', style); |
| 648 | self.screen.set(x1, y1, '┘', style); |
| 649 | if !label.is_empty() && area.w > 4 { |
| 650 | let text = format!(" {label} "); |
| 651 | self.screen.text( |
| 652 | area.x + 1, |
| 653 | area.y, |
| 654 | area.w - 2, |
| 655 | &text, |
| 656 | style.with(attr::BOLD), |
| 657 | ); |
| 658 | } |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | impl Align { |
| 663 | /// [`Align::offset`] is private to the layout module; overlays are the one |
| 664 | /// caller outside it that centres something by hand. |
| 665 | fn offset_pub(self, size: u16, avail: u16) -> u16 { |
| 666 | layout::place(self, size, avail).0 |
| 667 | } |
| 668 | } |