//! Regions into rects. //! //! The half of the drawing a webview never has to do. A stylesheet turns //! `list-detail` into two columns and the browser does the arithmetic; here the //! arithmetic is the renderer's, and every place the description does not say //! enough to do it is a finding. //! //! Three of them, and all three are recorded on `179b088d`: //! //! - **A tabbed arrangement does not say which tab is showing.** `Arrangement:: //! ListDetail { tabbed: true }` says the two regions share the space and only //! one is visible; nothing says which. A webview never asked, because a //! stylesheet with `:target` or a class answers it. This draws the first, //! which is a guess. //! - **A tab has no label.** [`Slot::id`] is an address, chosen to be stable //! for fragment targeting, and using it as a heading puts `contacts-detail` //! on screen. //! - **Nothing says a region's share.** A sidebar is 24 columns here and a list //! pane 40% because this renderer picked those numbers. `makeover-geometry` //! has size classes and the description reaches none of them. use makeover_layout as layout; use makeover_tui::{frame, text}; use quasi_router::{Node, RegionKind, Screen, Slot}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::Span; use crate::{Pass, Tui, below}; // The sidebar's 24 columns and the list pane's 40% used to be declared here, // as two numbers this renderer chose with nothing behind them. `e0fd485e`: // they are `Arrangement::share` now, so the terminal and the webview honour one // fact and two hosts showing one screen agree about its proportions. /// Lay a screen's regions out and draw them. pub(crate) fn screen_regions(pass: &mut Pass<'_>, screen: &Screen, area: Rect, buf: &mut Buffer) { let area = measured(screen.measure, area); let mut rest = area; // Bands stack at the top, full width, in the order they were said. A band // is an arrangement rather than a type -- a page header, a toolbar -- so it // takes the rows it needs and gets out of the way. for slot in screen .slots .iter() .filter(|slot| matches!(slot.kind, RegionKind::Band)) { let used = draw(pass, slot, rest, buf); rest = below(rest, used); } let body = body_slots(screen); match screen.arrangement { layout::Arrangement::SidebarContent { share } => { let (left, right) = split(rest, share.of(rest.width)); let mut sidebars = 0; let mut content = right; for slot in &body { if matches!(slot.kind, RegionKind::Sidebar) { let used = draw(pass, slot, below(left, sidebars), buf); sidebars += used; } else { let used = draw(pass, slot, content, buf); content = below(content, used); } } } layout::Arrangement::ListDetail { tabbed, share } => { if tabbed { // One at a time, and nothing says which. `body_slots` has // already cut the rest away, so this is the one region there is. if let Some(first) = body.first() { draw(pass, first, rest, buf); } } else { let (left, right) = split(rest, share.of(rest.width)); let mut detail = right; for (index, slot) in body.iter().enumerate() { if index == 0 { draw(pass, slot, left, buf); } else { let used = draw(pass, slot, detail, buf); detail = below(detail, used); } } } } } // Modals last and over everything, which is what a modal is. Centred in // half the width, because `Depth::Overlay` says it sits above the page and // says nothing about how much of it to cover. for slot in screen .slots .iter() .filter(|slot| matches!(slot.kind, RegionKind::Modal)) { draw(pass, slot, centred(area), buf); } } /// The screen's area, narrowed to the measure it asked for. /// /// `0eccff0d`, the terminal's half of "every renderer owes an answer". The /// description says how wide the content should run and this says what that is /// in columns, the same division the webview makes: the screen chose one of /// three, and what each one comes to is the renderer's. /// /// The two caps are this renderer's numbers, and only the second has a reason /// outside taste: past roughly 75 characters a line costs the reader the return /// sweep, which is why [`layout::Measure::Reading`] is the narrowest. Centred /// rather than left-aligned, because a narrowed column against the left edge of /// a wide terminal reads as a window that failed to resize. /// /// A terminal narrower than the cap is left alone rather than padded. There is /// no measure to enforce when the window is already tighter than it. fn measured(measure: layout::Measure, area: Rect) -> Rect { let cap = match measure { layout::Measure::Reading => 76, layout::Measure::Contained => 100, // Every column there is, which is what `Wide` means. Also the arm a // member added upstream lands in: a measure this renderer has not // learned should show the whole screen, not hide part of it. _ => return area, }; if area.width <= cap { return area; } Rect { x: area.x + (area.width - cap) / 2, width: cap, ..area } } /// The regions that fill the body, after the arrangement has had its say. /// /// The tabbed cut lives here and only here. It is the one place a described /// region can be on the screen or not, so the drawing and the focus walk have /// to agree about it, and two copies of "the first one, and nothing says which" /// is two chances to disagree. fn body_slots(screen: &Screen) -> Vec<&Slot> { let body = screen .slots .iter() .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal)); match screen.arrangement { layout::Arrangement::ListDetail { tabbed: true, .. } => body.take(1).collect(), _ => body.collect(), } } /// Every region the user can see, in the order it is drawn. /// /// What the focus walk reads. A region that is not drawn holds nothing /// reachable, which is why this is a question about slots rather than about /// nodes: a tab that is not showing has controls in it, and stopping on one /// would move focus to a place with nothing on screen. /// /// **A modal takes the whole of it.** A dialog you can tab out of is not a /// dialog, and this is the one place the drawing order and the focus order /// deliberately differ: the screen behind a modal is still painted, because /// covering it costs rows and says nothing, and it is still unreachable. pub(crate) fn reachable(screen: &Screen) -> Vec<&Slot> { let modals: Vec<&Slot> = screen .slots .iter() .filter(|slot| matches!(slot.kind, RegionKind::Modal)) .collect(); if !modals.is_empty() { return modals; } screen .slots .iter() .filter(|slot| matches!(slot.kind, RegionKind::Band)) .chain(body_slots(screen)) .collect() } /// The rows a region wants at `width`. pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 { let inner = width.saturating_sub(2); // A region showing one child at a time is as tall as its tallest child plus // the row that moves between them. Tallest rather than current, because this // has no `View` and so cannot know which child is up -- an over-estimate, // which for the one caller (scroll arithmetic) errs toward letting a region // scroll slightly further than it needs to rather than cutting it off. let body: u16 = if slot.showing.selective() { slot.body .iter() .map(|node| crate::node::height(tui, node, inner)) .max() .unwrap_or(0) + 1 } else { slot.body .iter() .map(|node| crate::node::height(tui, node, inner)) .sum() }; // Two rows for the frame, when the region has one. body + if framed(slot.kind.depth()) { 2 } else { 0 } } /// Draw one region, and answer the rows it used. pub(crate) fn draw(pass: &mut Pass<'_>, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 { // A region still loading holds nothing reachable, here and in the focus // walk both: what is on the screen is the word "Loading", and a control // counted under it would be a place the caret could go with nothing to see. let pending = matches!(slot.readiness, layout::Readiness::Pending); if area.width == 0 || area.height == 0 { if !pending { for node in &slot.body { crate::node::draw(pass, node, area, buf); } } return 0; } let tui = pass.tui; // The frame, from the depth the region's kind implies. This is the whole // reason `makeover-tui` is a dependency rather than a nice-to-have: a // raised region is drawn the same way here as in every other terminal app // in the tree, bevel included, and the depth comes off the vocabulary // rather than off this renderer's taste. let depth = slot.kind.depth(); let inner = if framed(depth) { frame(buf, area, depth, tui.palette()) } else { area }; // `Readiness` is the loading axis, and a terminal has no spinner that is // not a clock. It says so in words instead, which loses the motion and // keeps the fact. if pending { let used = text::draw( "Loading", Style::default() .fg(tui.theme().content_muted) .add_modifier(Modifier::ITALIC), inner, buf, ); return used + if framed(depth) { 2 } else { 0 }; } // A bespoke region is the host's. The description named the place and the // blocks it owns above the fill, so those draw; what the host puts under // them is the host's to draw, and this renderer has no fill mechanism to // offer it. That is a gap rather than a decline: `Webview::with_fill` has // no counterpart here. let used = if slot.showing.selective() { showing_body(pass, slot, inner, buf) } else { body(pass, slot, &slot.body, inner, buf) }; // `Slot::id` is not drawn anywhere. It is a fragment address, and a // terminal redraws rather than swapping, so it costs nothing and says // nothing here. used + if framed(depth) { 2 } else { 0 } } /// A region showing one child at a time, and the chrome that moves between them. /// /// The same derivation quasi-webview makes and for the same reason: nothing here /// reads [`RegionKind::Widget`]'s name. A carousel, a tab group and a disclosure /// are one region that shows some of its children, and which idiom comes out /// falls out of what the children carry. /// /// This is what `c0b63ea9`'s terminal half was waiting for. It was filed as a /// drawing change and it never was one -- until [`layout::Showing`] existed a /// terminal had no way to learn that a stack of pictures was meant to be one /// picture, so it honestly drew the stack. /// /// # The chrome is one row, and it is in flow /// /// `< Prev > 2 / 3 < Next >` under the content, or a strip of labels above it. /// Max chose the row over the overlaid arrows a browser had been drawing, /// 2026-08-14, and the reason it ports is the reason it was chosen: a terminal /// cannot honestly overlay anything, and a dot strip has no form here at all. fn showing_body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 { let at = pass.view.shown(slot); let labels = slot.labels(); let mut used = 0; // A strip sits above the panes it opens; a counter row sits under the // content it counts. The folder semantic, and the same placement the // webview derives. if !labels.is_empty() { used += text::draw_spans( &showing_spans(pass.tui, &labels, at), below(inner, used), buf, ); } // One child, or none at all: `Showing::AtMostOne` closed is the only way to // reach `None` here, and drawing nothing is what closed means. if let Some(index) = at && index < slot.body.len() { used += body( pass, slot, &slot.body[index..=index], below(inner, used), buf, ); } if labels.is_empty() { used += text::draw_spans( &counter_spans(pass.tui, at, slot.body.len()), below(inner, used), buf, ); } used.min(inner.height) } /// A strip of labels, the current one lit. /// /// `select_spans`' styling, deliberately: a derived tab strip and a described /// one are the same thing on the screen, and two spellings of it would drift /// the way `tabs` and `tab` did in the webview. fn showing_spans(tui: &Tui, labels: &[&str], at: Option) -> Vec> { let mut spans = Vec::new(); for (index, label) in labels.iter().enumerate() { if !spans.is_empty() { spans.push(Span::raw(" ")); } let picked = at == Some(index); let style = if picked { Style::default() .fg(tui.theme().selection_on) .bg(tui.theme().action_primary) } else { Style::default().fg(tui.theme().content_secondary) }; spans.push(Span::styled(format!(" {label} "), style)); } spans } /// Previous, where you are, next. /// /// The position reads back one step, which is `picture-caption`'s claim in the /// other renderer: it says where you are among the children and it is not one /// of them. Zero when a dismissible region is closed, which is a true statement /// about how many of its children are showing. fn counter_spans(tui: &Tui, at: Option, total: usize) -> Vec> { let control = Style::default().fg(tui.theme().content_secondary); vec![ Span::styled("< Prev >", control), Span::styled( format!(" {} / {total} ", at.map_or(0, |index| index + 1)), Style::default().fg(tui.theme().content_muted), ), Span::styled("< Next >", control), ] } /// A region's contents, at the offset the view is holding it at. /// /// Scrolling is the runtime's and the clipping is the drawing's, and this is /// where the two meet. Flow layout draws from the top of the rect it is given, /// so an offset cannot be honoured by moving the rect: a node starting above /// the window would draw its first row at the window's first row. What works is /// to draw the region at its full height into a buffer of its own and copy the /// window out, which costs an allocation per scrolled region and nothing at all /// for a region sitting at the top, which is every region until someone /// scrolls. /// /// The offset is clamped here rather than in [`crate::View`], because how far a /// region can scroll is how tall it is at the width it was given, and the width /// is not known until this point. fn body(pass: &mut Pass<'_>, slot: &Slot, nodes: &[Node], inner: Rect, buf: &mut Buffer) -> u16 { let offset = pass.view.scroll(&slot.id); if offset == 0 { let mut used = 0; for node in nodes { used += crate::node::draw(pass, node, below(inner, used), buf); } return used.min(inner.height); } let content: u16 = nodes .iter() .map(|node| crate::node::height(pass.tui, node, inner.width)) .sum(); let offset = offset.min(content.saturating_sub(inner.height)); let tall = Rect { height: content.max(inner.height), ..inner }; let mut scratch = Buffer::empty(tall); let mut used = 0; for node in nodes { used += crate::node::draw(pass, node, below(tall, used), &mut scratch); } let shown = inner.height.min(content.saturating_sub(offset)); for row in 0..shown { for column in 0..inner.width { let from = (inner.x + column, inner.y + offset + row); let to = (inner.x + column, inner.y + row); if let Some(cell) = scratch.cell(from).cloned() && let Some(target) = buf.cell_mut(to) { *target = cell; } } } shown } /// Whether a depth is drawn with a border. /// /// Flat is not: a band and a plain pane are arrangement, and boxing every one /// of them spends two rows and two columns per region on a screen that is /// mostly regions. fn framed(depth: layout::Depth) -> bool { !matches!(depth, layout::Depth::Flat) } /// Split `area` into a left column of `width` and the rest. fn split(area: Rect, width: u16) -> (Rect, Rect) { let width = width.min(area.width); ( Rect { width, ..area }, Rect { x: area.x + width, width: area.width - width, ..area }, ) } /// Half the width and half the height, in the middle. fn centred(area: Rect) -> Rect { let width = area.width / 2; let height = area.height / 2; Rect { x: area.x + width / 2, y: area.y + height / 2, width, height, } }