max / quasi
5 files changed,
+418 insertions,
-21 deletions
| @@ -5514,6 +5514,14 @@ | |||
| 5514 | 5514 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5515 | 5515 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 5516 | 5516 | ||
| 5517 | + | [[patch.unused]] | |
| 5518 | + | name = "synckit-client" | |
| 5519 | + | version = "0.8.0" | |
| 5520 | + | ||
| 5521 | + | [[patch.unused]] | |
| 5522 | + | name = "synckit-config" | |
| 5523 | + | version = "0.2.0" | |
| 5524 | + | ||
| 5517 | 5525 | [[patch.unused]] | |
| 5518 | 5526 | name = "kberg" | |
| 5519 | 5527 | version = "0.1.0" | |
| @@ -5530,14 +5538,6 @@ | |||
| 5530 | 5538 | name = "tagtree" | |
| 5531 | 5539 | version = "0.4.0" | |
| 5532 | 5540 | ||
| 5533 | - | [[patch.unused]] | |
| 5534 | - | name = "synckit-client" | |
| 5535 | - | version = "0.8.0" | |
| 5536 | - | ||
| 5537 | - | [[patch.unused]] | |
| 5538 | - | name = "synckit-config" | |
| 5539 | - | version = "0.2.0" | |
| 5540 | - | ||
| 5541 | 5541 | [[patch.unused]] | |
| 5542 | 5542 | name = "makeover-build" | |
| 5543 | 5543 | version = "0.32.0" |
| @@ -21,10 +21,11 @@ | |||
| 21 | 21 | ||
| 22 | 22 | use makeover_layout as layout; | |
| 23 | 23 | use makeover_tui::{frame, text}; | |
| 24 | - | use quasi_router::{RegionKind, Screen, Slot}; | |
| 24 | + | use quasi_router::{Node, RegionKind, Screen, Slot}; | |
| 25 | 25 | use ratatui::buffer::Buffer; | |
| 26 | 26 | use ratatui::layout::Rect; | |
| 27 | 27 | use ratatui::style::{Modifier, Style}; | |
| 28 | + | use ratatui::text::Span; | |
| 28 | 29 | ||
| 29 | 30 | use crate::{Pass, Tui, below}; | |
| 30 | 31 | ||
| @@ -185,11 +186,24 @@ | |||
| 185 | 186 | /// The rows a region wants at `width`. | |
| 186 | 187 | pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 { | |
| 187 | 188 | let inner = width.saturating_sub(2); | |
| 188 | - | let body: u16 = slot | |
| 189 | - | .body | |
| 190 | - | .iter() | |
| 191 | - | .map(|node| crate::node::height(tui, node, inner)) | |
| 192 | - | .sum(); | |
| 189 | + | // A region showing one child at a time is as tall as its tallest child plus | |
| 190 | + | // the row that moves between them. Tallest rather than current, because this | |
| 191 | + | // has no `View` and so cannot know which child is up -- an over-estimate, | |
| 192 | + | // which for the one caller (scroll arithmetic) errs toward letting a region | |
| 193 | + | // scroll slightly further than it needs to rather than cutting it off. | |
| 194 | + | let body: u16 = if slot.showing.selective() { | |
| 195 | + | slot.body | |
| 196 | + | .iter() | |
| 197 | + | .map(|node| crate::node::height(tui, node, inner)) | |
| 198 | + | .max() | |
| 199 | + | .unwrap_or(0) | |
| 200 | + | + 1 | |
| 201 | + | } else { | |
| 202 | + | slot.body | |
| 203 | + | .iter() | |
| 204 | + | .map(|node| crate::node::height(tui, node, inner)) | |
| 205 | + | .sum() | |
| 206 | + | }; | |
| 193 | 207 | // Two rows for the frame, when the region has one. | |
| 194 | 208 | body + if framed(slot.kind.depth()) { 2 } else { 0 } | |
| 195 | 209 | } | |
| @@ -243,7 +257,11 @@ | |||
| 243 | 257 | // them is the host's to draw, and this renderer has no fill mechanism to | |
| 244 | 258 | // offer it. That is a gap rather than a decline: `Webview::with_fill` has | |
| 245 | 259 | // no counterpart here. | |
| 246 | - | let used = body(pass, slot, inner, buf); | |
| 260 | + | let used = if slot.showing.selective() { | |
| 261 | + | showing_body(pass, slot, inner, buf) | |
| 262 | + | } else { | |
| 263 | + | body(pass, slot, &slot.body, inner, buf) | |
| 264 | + | }; | |
| 247 | 265 | ||
| 248 | 266 | // `Slot::id` is not drawn anywhere. It is a fragment address, and a | |
| 249 | 267 | // terminal redraws rather than swapping, so it costs nothing and says | |
| @@ -251,6 +269,107 @@ | |||
| 251 | 269 | used + if framed(depth) { 2 } else { 0 } | |
| 252 | 270 | } | |
| 253 | 271 | ||
| 272 | + | /// A region showing one child at a time, and the chrome that moves between them. | |
| 273 | + | /// | |
| 274 | + | /// The same derivation quasi-webview makes and for the same reason: nothing here | |
| 275 | + | /// reads [`RegionKind::Widget`]'s name. A carousel, a tab group and a disclosure | |
| 276 | + | /// are one region that shows some of its children, and which idiom comes out | |
| 277 | + | /// falls out of what the children carry. | |
| 278 | + | /// | |
| 279 | + | /// This is what `c0b63ea9`'s terminal half was waiting for. It was filed as a | |
| 280 | + | /// drawing change and it never was one -- until [`layout::Showing`] existed a | |
| 281 | + | /// terminal had no way to learn that a stack of pictures was meant to be one | |
| 282 | + | /// picture, so it honestly drew the stack. | |
| 283 | + | /// | |
| 284 | + | /// # The chrome is one row, and it is in flow | |
| 285 | + | /// | |
| 286 | + | /// `< Prev > 2 / 3 < Next >` under the content, or a strip of labels above it. | |
| 287 | + | /// Max chose the row over the overlaid arrows a browser had been drawing, | |
| 288 | + | /// 2026-08-14, and the reason it ports is the reason it was chosen: a terminal | |
| 289 | + | /// cannot honestly overlay anything, and a dot strip has no form here at all. | |
| 290 | + | fn showing_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 { | |
| 291 | + | let at = pass.view.shown(slot); | |
| 292 | + | let labels = slot.labels(); | |
| 293 | + | let mut used = 0; | |
| 294 | + | ||
| 295 | + | // A strip sits above the panes it opens; a counter row sits under the | |
| 296 | + | // content it counts. The folder semantic, and the same placement the | |
| 297 | + | // webview derives. | |
| 298 | + | if !labels.is_empty() { | |
| 299 | + | used += text::draw_spans( | |
| 300 | + | &showing_spans(pass.tui, &labels, at), | |
| 301 | + | below(inner, used), | |
| 302 | + | buf, | |
| 303 | + | ); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // One child, or none at all: `Showing::AtMostOne` closed is the only way to | |
| 307 | + | // reach `None` here, and drawing nothing is what closed means. | |
| 308 | + | if let Some(index) = at | |
| 309 | + | && index < slot.body.len() | |
| 310 | + | { | |
| 311 | + | used += body( | |
| 312 | + | pass, | |
| 313 | + | slot, | |
| 314 | + | &slot.body[index..=index], | |
| 315 | + | below(inner, used), | |
| 316 | + | buf, | |
| 317 | + | ); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | if labels.is_empty() { | |
| 321 | + | used += text::draw_spans( | |
| 322 | + | &counter_spans(pass.tui, at, slot.body.len()), | |
| 323 | + | below(inner, used), | |
| 324 | + | buf, | |
| 325 | + | ); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | used.min(inner.height) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /// A strip of labels, the current one lit. | |
| 332 | + | /// | |
| 333 | + | /// `select_spans`' styling, deliberately: a derived tab strip and a described | |
| 334 | + | /// one are the same thing on the screen, and two spellings of it would drift | |
| 335 | + | /// the way `tabs` and `tab` did in the webview. | |
| 336 | + | fn showing_spans(tui: &Tui, labels: &[&str], at: Option<usize>) -> Vec<Span<'static>> { | |
| 337 | + | let mut spans = Vec::new(); | |
| 338 | + | for (index, label) in labels.iter().enumerate() { | |
| 339 | + | if !spans.is_empty() { | |
| 340 | + | spans.push(Span::raw(" ")); | |
| 341 | + | } | |
| 342 | + | let picked = at == Some(index); | |
| 343 | + | let style = if picked { | |
| 344 | + | Style::default() | |
| 345 | + | .fg(tui.theme().selection_on) | |
| 346 | + | .bg(tui.theme().action_primary) | |
| 347 | + | } else { | |
| 348 | + | Style::default().fg(tui.theme().content_secondary) | |
| 349 | + | }; | |
| 350 | + | spans.push(Span::styled(format!(" {label} "), style)); | |
| 351 | + | } | |
| 352 | + | spans | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// Previous, where you are, next. | |
| 356 | + | /// | |
| 357 | + | /// The position reads back one step, which is `picture-caption`'s claim in the | |
| 358 | + | /// other renderer: it says where you are among the children and it is not one | |
| 359 | + | /// of them. Zero when a dismissible region is closed, which is a true statement | |
| 360 | + | /// about how many of its children are showing. | |
| 361 | + | fn counter_spans(tui: &Tui, at: Option<usize>, total: usize) -> Vec<Span<'static>> { | |
| 362 | + | let control = Style::default().fg(tui.theme().content_secondary); | |
| 363 | + | vec![ | |
| 364 | + | Span::styled("< Prev >", control), | |
| 365 | + | Span::styled( | |
| 366 | + | format!(" {} / {total} ", at.map_or(0, |index| index + 1)), | |
| 367 | + | Style::default().fg(tui.theme().content_muted), | |
| 368 | + | ), | |
| 369 | + | Span::styled("< Next >", control), | |
| 370 | + | ] | |
| 371 | + | } | |
| 372 | + | ||
| 254 | 373 | /// A region's contents, at the offset the view is holding it at. | |
| 255 | 374 | /// | |
| 256 | 375 | /// Scrolling is the runtime's and the clipping is the drawing's, and this is | |
| @@ -265,18 +384,17 @@ | |||
| 265 | 384 | /// The offset is clamped here rather than in [`crate::View`], because how far a | |
| 266 | 385 | /// region can scroll is how tall it is at the width it was given, and the width | |
| 267 | 386 | /// is not known until this point. | |
| 268 | - | fn body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 { | |
| 387 | + | fn body(pass: &mut Pass<'_>, slot: &Slot, nodes: &[Node], inner: Rect, buf: &mut Buffer) -> u16 { | |
| 269 | 388 | let offset = pass.view.scroll(&slot.id); | |
| 270 | 389 | if offset == 0 { | |
| 271 | 390 | let mut used = 0; | |
| 272 | - | for node in &slot.body { | |
| 391 | + | for node in nodes { | |
| 273 | 392 | used += crate::node::draw(pass, node, below(inner, used), buf); | |
| 274 | 393 | } | |
| 275 | 394 | return used.min(inner.height); | |
| 276 | 395 | } | |
| 277 | 396 | ||
| 278 | - | let content: u16 = slot | |
| 279 | - | .body | |
| 397 | + | let content: u16 = nodes | |
| 280 | 398 | .iter() | |
| 281 | 399 | .map(|node| crate::node::height(pass.tui, node, inner.width)) | |
| 282 | 400 | .sum(); | |
| @@ -288,7 +406,7 @@ | |||
| 288 | 406 | }; | |
| 289 | 407 | let mut scratch = Buffer::empty(tall); | |
| 290 | 408 | let mut used = 0; | |
| 291 | - | for node in &slot.body { | |
| 409 | + | for node in nodes { | |
| 292 | 410 | used += crate::node::draw(pass, node, below(tall, used), &mut scratch); | |
| 293 | 411 | } | |
| 294 | 412 |
| @@ -42,7 +42,7 @@ | |||
| 42 | 42 | ||
| 43 | 43 | use makeover_layout as layout; | |
| 44 | 44 | use quasi_router::{ | |
| 45 | - | Action, Chrome, Message, Method, Node, Outcome, Params, Request, Response, Screen, | |
| 45 | + | Action, Chrome, Message, Method, Node, Outcome, Params, Request, Response, Screen, Slot, | |
| 46 | 46 | }; | |
| 47 | 47 | use ratatui::buffer::Buffer; | |
| 48 | 48 | use ratatui::layout::Rect; | |
| @@ -78,6 +78,10 @@ | |||
| 78 | 78 | PageUp, | |
| 79 | 79 | /// A screen's worth forwards. | |
| 80 | 80 | PageDown, | |
| 81 | + | /// Back one child, in a region showing one at a time. | |
| 82 | + | Left, | |
| 83 | + | /// On one child, in a region showing one at a time. | |
| 84 | + | Right, | |
| 81 | 85 | } | |
| 82 | 86 | ||
| 83 | 87 | /// What the host should do about a key. | |
| @@ -343,6 +347,21 @@ | |||
| 343 | 347 | Step::Idle | |
| 344 | 348 | } | |
| 345 | 349 | ||
| 350 | + | Key::Left | Key::Right => { | |
| 351 | + | // The region the caret is in, when that region shows one child | |
| 352 | + | // at a time, and otherwise the first one on the screen that | |
| 353 | + | // does. The fallback is not a convenience: a carousel's frames | |
| 354 | + | // are pictures, so there is nothing reachable inside one and | |
| 355 | + | // focus can never be in it. Without this the one widget that | |
| 356 | + | // asked for these keys could not be reached by them. | |
| 357 | + | if let Some(slot) = self.moving(&reaches) { | |
| 358 | + | let steps = if matches!(key, Key::Right) { 1 } else { -1 }; | |
| 359 | + | let slot = slot.clone(); | |
| 360 | + | self.view.show_by(&slot, steps); | |
| 361 | + | } | |
| 362 | + | Step::Idle | |
| 363 | + | } | |
| 364 | + | ||
| 346 | 365 | // An overlay first: Escape closes what is on top before it goes | |
| 347 | 366 | // back, which is what Escape means everywhere else it is bound. | |
| 348 | 367 | Key::Escape => { | |
| @@ -580,9 +599,48 @@ | |||
| 580 | 599 | Key::PageUp => "pageup".into(), | |
| 581 | 600 | Key::PageDown => "pagedown".into(), | |
| 582 | 601 | Key::Backspace => "backspace".into(), | |
| 602 | + | Key::Left => "left".into(), | |
| 603 | + | Key::Right => "right".into(), | |
| 583 | 604 | }) | |
| 584 | 605 | } | |
| 585 | 606 | ||
| 607 | + | /// The region the arrow keys move, if the screen has one. | |
| 608 | + | /// | |
| 609 | + | /// The one the caret is in when that region shows one child at a time, and | |
| 610 | + | /// otherwise the first such region in draw order. Two rules rather than one | |
| 611 | + | /// because focus is not always a usable answer here: a carousel holds | |
| 612 | + | /// pictures, nothing in it is reachable, and a rule that only ever asked | |
| 613 | + | /// where the caret was would leave the widget that wanted these keys unable | |
| 614 | + | /// to be reached by them. | |
| 615 | + | /// | |
| 616 | + | /// A screen with two of these and no focus in either moves the first, which | |
| 617 | + | /// is arbitrary and is said out loud rather than hidden. Nothing in the tree | |
| 618 | + | /// has two yet; the screen that does is the one that will want a reachable | |
| 619 | + | /// control instead, and that is a `Spot` rather than a rule here. | |
| 620 | + | fn moving<'a>(&'a self, reaches: &[crate::focus::Reach]) -> Option<&'a Slot> { | |
| 621 | + | let here = reaches | |
| 622 | + | .get(self.view.focus()) | |
| 623 | + | .and_then(|reach| self.find(&reach.region)) | |
| 624 | + | .filter(|slot| slot.showing.selective()); | |
| 625 | + | here.or_else(|| self.screen.slots.iter().find_map(Self::selective)) | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | /// This slot or the first under it that shows one child at a time. | |
| 629 | + | fn selective(slot: &Slot) -> Option<&Slot> { | |
| 630 | + | if slot.showing.selective() { | |
| 631 | + | return Some(slot); | |
| 632 | + | } | |
| 633 | + | slot.body.iter().find_map(|node| match node { | |
| 634 | + | Node::Region(inner) => Self::selective(inner), | |
| 635 | + | _ => None, | |
| 636 | + | }) | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | /// The slot under this address, anywhere on the screen. | |
| 640 | + | fn find(&self, region: &str) -> Option<&Slot> { | |
| 641 | + | self.screen.slots.iter().find_map(|slot| slot.find(region)) | |
| 642 | + | } | |
| 643 | + | ||
| 586 | 644 | /// Close the overlay on top, if there is one. | |
| 587 | 645 | /// | |
| 588 | 646 | /// The layer underneath comes back exactly as it was left: its own focus, |
| @@ -1353,3 +1353,172 @@ | |||
| 1353 | 1353 | ); | |
| 1354 | 1354 | assert!(!runtime.overlaid()); | |
| 1355 | 1355 | } | |
| 1356 | + | ||
| 1357 | + | /// A carousel: three captioned frames, the first up, no label anywhere. | |
| 1358 | + | fn gallery() -> Slot { | |
| 1359 | + | Slot::widget("shots", "carousel") | |
| 1360 | + | .extend((0..3).map(|n| { | |
| 1361 | + | Node::Image(quasi_router::Picture::new( | |
| 1362 | + | format!("/shot-{n}.png"), | |
| 1363 | + | format!("shot {n}"), | |
| 1364 | + | )) | |
| 1365 | + | })) | |
| 1366 | + | .showing_one(0) | |
| 1367 | + | } | |
| 1368 | + | ||
| 1369 | + | /// Draw a screen with a view that has been moved. | |
| 1370 | + | fn with_view(screen: &Screen, view: &View, width: u16, height: u16) -> Vec<String> { | |
| 1371 | + | let area = Rect::new(0, 0, width, height); | |
| 1372 | + | let mut buf = Buffer::empty(area); | |
| 1373 | + | tui().screen(screen, view, area, &mut buf); | |
| 1374 | + | rows(&buf) | |
| 1375 | + | } | |
| 1376 | + | ||
| 1377 | + | #[test] | |
| 1378 | + | fn a_carousel_is_one_frame_and_a_row_rather_than_a_stack() { | |
| 1379 | + | // `c0b63ea9`'s terminal half, and it was never a drawing change: until | |
| 1380 | + | // `Showing` existed a terminal had no way to learn that a stack of pictures | |
| 1381 | + | // was meant to be one picture, so it honestly drew the stack. | |
| 1382 | + | let screen = Screen::list_detail("Product", false).with(gallery()); | |
| 1383 | + | let out = shown(&screen, 90, 12).join("\n"); | |
| 1384 | + | ||
| 1385 | + | assert!(out.contains("shot 0"), "{out}"); | |
| 1386 | + | assert!(!out.contains("shot 1"), "{out}"); | |
| 1387 | + | assert!(out.contains("< Prev >"), "{out}"); | |
| 1388 | + | assert!(out.contains("1 / 3"), "{out}"); | |
| 1389 | + | assert!(out.contains("< Next >"), "{out}"); | |
| 1390 | + | } | |
| 1391 | + | ||
| 1392 | + | #[test] | |
| 1393 | + | fn the_row_is_under_the_frame_and_overlays_nothing() { | |
| 1394 | + | // The reason the chrome ports at all. A terminal cannot honestly overlay | |
| 1395 | + | // anything, so the arrows a browser drew over the picture had no form here | |
| 1396 | + | // -- and Max had already called them cluttered in the browser. | |
| 1397 | + | let screen = Screen::list_detail("Product", false).with(gallery()); | |
| 1398 | + | let out = shown(&screen, 90, 12); | |
| 1399 | + | ||
| 1400 | + | let frame = out.iter().position(|row| row.contains("shot 0")); | |
| 1401 | + | let row = out.iter().position(|row| row.contains("< Prev >")); | |
| 1402 | + | assert!(frame < row, "{out:?}"); | |
| 1403 | + | } | |
| 1404 | + | ||
| 1405 | + | #[test] | |
| 1406 | + | fn a_terminal_draws_the_same_chrome_without_knowing_what_a_carousel_is() { | |
| 1407 | + | // The whole design in one assertion: the widget's name is changed and the | |
| 1408 | + | // chrome is identical, because nothing in this renderer reads it. | |
| 1409 | + | let named = Screen::list_detail("Product", false).with(gallery()); | |
| 1410 | + | let mut other = gallery(); | |
| 1411 | + | other.kind = RegionKind::Widget { | |
| 1412 | + | name: "lookbook".into(), | |
| 1413 | + | }; | |
| 1414 | + | let unnamed = Screen::list_detail("Product", false).with(other); | |
| 1415 | + | ||
| 1416 | + | assert_eq!(shown(&named, 90, 12), shown(&unnamed, 90, 12)); | |
| 1417 | + | } | |
| 1418 | + | ||
| 1419 | + | #[test] | |
| 1420 | + | fn the_arrow_keys_move_a_carousel_the_caret_can_never_be_inside() { | |
| 1421 | + | // A frame is a picture, so nothing in a carousel is reachable and focus can | |
| 1422 | + | // never land in it. That is why `moving` falls back to the first such region | |
| 1423 | + | // rather than only ever asking where the caret is. | |
| 1424 | + | let screen = Screen::list_detail("Product", false).with(gallery()); | |
| 1425 | + | let mut view = View::new(); | |
| 1426 | + | ||
| 1427 | + | let slot = screen.slots[0] | |
| 1428 | + | .find("shots") | |
| 1429 | + | .expect("the carousel is there"); | |
| 1430 | + | view.show_by(slot, 1); | |
| 1431 | + | let out = with_view(&screen, &view, 90, 12).join("\n"); | |
| 1432 | + | ||
| 1433 | + | assert!(out.contains("shot 1"), "{out}"); | |
| 1434 | + | assert!(out.contains("2 / 3"), "{out}"); | |
| 1435 | + | } | |
| 1436 | + | ||
| 1437 | + | #[test] | |
| 1438 | + | fn moving_past_the_last_frame_wraps() { | |
| 1439 | + | // `View::advance`'s reason: a terminal has nothing to show you that you are | |
| 1440 | + | // at the end, so a next key that stops dead reads as a broken key. | |
| 1441 | + | let screen = Screen::list_detail("Product", false).with(gallery()); | |
| 1442 | + | let slot = screen.slots[0] | |
| 1443 | + | .find("shots") | |
| 1444 | + | .expect("the carousel is there"); | |
| 1445 | + | let mut view = View::new(); | |
| 1446 | + | ||
| 1447 | + | view.show_by(slot, -1); | |
| 1448 | + | assert_eq!(view.shown(slot), Some(2)); | |
| 1449 | + | view.show_by(slot, 1); | |
| 1450 | + | assert_eq!(view.shown(slot), Some(0)); | |
| 1451 | + | } | |
| 1452 | + | ||
| 1453 | + | #[test] | |
| 1454 | + | fn labelled_children_draw_a_strip_above_the_pane_it_opens() { | |
| 1455 | + | // `6af6810e`. A tab group had a kind and no way to say which tab was open or | |
| 1456 | + | // what it was called, so this drew the first and used the slot id as a | |
| 1457 | + | // heading. Both halves are answered by the same member. | |
| 1458 | + | let screen = Screen::list_detail("Project", false).with( | |
| 1459 | + | Slot::new("detail", RegionKind::TabGroup) | |
| 1460 | + | .with(Node::Region( | |
| 1461 | + | Slot::new("overview", RegionKind::Pane) | |
| 1462 | + | .label("Overview") | |
| 1463 | + | .with(Node::text("the summary")), | |
| 1464 | + | )) | |
| 1465 | + | .with(Node::Region( | |
| 1466 | + | Slot::new("files", RegionKind::Pane) | |
| 1467 | + | .label("Files") | |
| 1468 | + | .with(Node::text("the files")), | |
| 1469 | + | )) | |
| 1470 | + | .showing_one(1), | |
| 1471 | + | ); | |
| 1472 | + | let out = shown(&screen, 60, 16); | |
| 1473 | + | let joined = out.join("\n"); | |
| 1474 | + | ||
| 1475 | + | assert!(joined.contains("Overview"), "{joined}"); | |
| 1476 | + | assert!(joined.contains("Files"), "{joined}"); | |
| 1477 | + | // The open tab's pane, and only it. | |
| 1478 | + | assert!(joined.contains("the files"), "{joined}"); | |
| 1479 | + | assert!(!joined.contains("the summary"), "{joined}"); | |
| 1480 | + | // A strip, not a counter row. | |
| 1481 | + | assert!(!joined.contains("< Prev >"), "{joined}"); | |
| 1482 | + | ||
| 1483 | + | let strip = out.iter().position(|row| row.contains("Overview")); | |
| 1484 | + | let pane = out.iter().position(|row| row.contains("the files")); | |
| 1485 | + | assert!(strip < pane, "{out:?}"); | |
| 1486 | + | } | |
| 1487 | + | ||
| 1488 | + | #[test] | |
| 1489 | + | fn a_closed_disclosure_draws_its_name_and_nothing_under_it() { | |
| 1490 | + | // `871e7f21`, which turns out to be `AtMostOne` and not a member of its own. | |
| 1491 | + | let disclosure = |shown: Option<usize>| { | |
| 1492 | + | Screen::list_detail("Item", false).with( | |
| 1493 | + | Slot::widget("more", "disclosure") | |
| 1494 | + | .with(Node::Region( | |
| 1495 | + | Slot::new("body", RegionKind::Pane) | |
| 1496 | + | .label("Technical details") | |
| 1497 | + | .with(Node::text("the rest")), | |
| 1498 | + | )) | |
| 1499 | + | .showing_at_most_one(shown), | |
| 1500 | + | ) | |
| 1501 | + | }; | |
| 1502 | + | ||
| 1503 | + | let closed = shown(&disclosure(None), 60, 12).join("\n"); | |
| 1504 | + | assert!(closed.contains("Technical details"), "{closed}"); | |
| 1505 | + | assert!(!closed.contains("the rest"), "{closed}"); | |
| 1506 | + | ||
| 1507 | + | let open = shown(&disclosure(Some(0)), 60, 12).join("\n"); | |
| 1508 | + | assert!(open.contains("the rest"), "{open}"); | |
| 1509 | + | } | |
| 1510 | + | ||
| 1511 | + | #[test] | |
| 1512 | + | fn a_region_showing_everything_draws_what_it_always_drew() { | |
| 1513 | + | // The additive claim, checked from the other renderer's side too. Nothing | |
| 1514 | + | // written before `Showing` existed changes. | |
| 1515 | + | let screen = Screen::list_detail("Tasks", false).with( | |
| 1516 | + | Slot::new("main", RegionKind::Pane) | |
| 1517 | + | .with(Node::text("first")) | |
| 1518 | + | .with(Node::text("second")), | |
| 1519 | + | ); | |
| 1520 | + | let out = shown(&screen, 40, 12).join("\n"); | |
| 1521 | + | ||
| 1522 | + | assert!(out.contains("first") && out.contains("second"), "{out}"); | |
| 1523 | + | assert!(!out.contains("< Prev >"), "{out}"); | |
| 1524 | + | } |
| @@ -90,6 +90,19 @@ | |||
| 90 | 90 | /// screen is a new set. Which set it is does not need storing: the screen | |
| 91 | 91 | /// beside this one says. | |
| 92 | 92 | ticked: BTreeSet<String>, | |
| 93 | + | /// Which child each region is showing, by | |
| 94 | + | /// [`Slot::id`](quasi_router::Slot::id). | |
| 95 | + | /// | |
| 96 | + | /// `4dcd241b`, and the sixth of the same discovery. A description says a | |
| 97 | + | /// region shows one of its children at a time and says which one it started | |
| 98 | + | /// on; where the reader has moved to since is this renderer's, exactly as | |
| 99 | + | /// [`scroll`](Self::scroll) is. A browser owns this too and never made | |
| 100 | + | /// anyone notice, because moving a carousel there is a class on an element | |
| 101 | + | /// the document already holds. | |
| 102 | + | /// | |
| 103 | + | /// Absent means the description's own answer still stands, which is what | |
| 104 | + | /// makes an untouched screen draw what the handler said. | |
| 105 | + | shown: BTreeMap<String, usize>, | |
| 93 | 106 | } | |
| 94 | 107 | ||
| 95 | 108 | impl View { | |
| @@ -197,6 +210,44 @@ | |||
| 197 | 210 | self.scroll.insert(region.to_string(), rows); | |
| 198 | 211 | } | |
| 199 | 212 | ||
| 213 | + | /// Which child a region is showing, given what its description says. | |
| 214 | + | /// | |
| 215 | + | /// [`scroll`](Self::scroll)'s shape with one difference: a scroll has an | |
| 216 | + | /// obvious zero and this does not, so the description's own answer is the | |
| 217 | + | /// floor rather than the top of the region. | |
| 218 | + | #[must_use] | |
| 219 | + | pub fn shown(&self, slot: &quasi_router::Slot) -> Option<usize> { | |
| 220 | + | match self.shown.get(&slot.id) { | |
| 221 | + | Some(at) => Some((*at).min(slot.body.len().saturating_sub(1))), | |
| 222 | + | None => slot.current(), | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | /// Move a region to another of its children, wrapping at both ends. | |
| 227 | + | /// | |
| 228 | + | /// Wrapping for [`advance`](Self::advance)'s reason: a terminal has nothing | |
| 229 | + | /// to show you that you are at the last frame, so a next key that stops | |
| 230 | + | /// dead reads as a broken key rather than as the end of the gallery. | |
| 231 | + | /// | |
| 232 | + | /// A closed dismissible region opens on its first child, which is the only | |
| 233 | + | /// reading of "next" that does anything from closed. | |
| 234 | + | pub fn show_by(&mut self, slot: &quasi_router::Slot, steps: isize) { | |
| 235 | + | let count = slot.body.len(); | |
| 236 | + | if count == 0 { | |
| 237 | + | return; | |
| 238 | + | } | |
| 239 | + | let at = match self.shown(slot) { | |
| 240 | + | Some(at) => (at as isize + steps).rem_euclid(count as isize) as usize, | |
| 241 | + | None => 0, | |
| 242 | + | }; | |
| 243 | + | self.shown.insert(slot.id.clone(), at); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | /// Show a particular child of a region. | |
| 247 | + | pub fn show(&mut self, region: &str, at: usize) { | |
| 248 | + | self.shown.insert(region.to_string(), at); | |
| 249 | + | } | |
| 250 | + | ||
| 200 | 251 | /// Whether this value is ticked. | |
| 201 | 252 | #[must_use] | |
| 202 | 253 | pub fn is_ticked(&self, value: &str) -> bool { | |
| @@ -254,6 +305,7 @@ | |||
| 254 | 305 | self.edits.clear(); | |
| 255 | 306 | self.scroll.clear(); | |
| 256 | 307 | self.ticked.clear(); | |
| 308 | + | self.shown.clear(); | |
| 257 | 309 | self.focus = 0; | |
| 258 | 310 | } | |
| 259 | 311 |