max / quasi
7 files changed,
+1971 insertions,
-112 deletions
| @@ -30,24 +30,39 @@ | |||
| 30 | 30 | //! description and nothing else, which is worth knowing before reaching for the | |
| 31 | 31 | //! trait's name. | |
| 32 | 32 | //! | |
| 33 | - | //! Not an event loop either, yet. This half is drawing: a screen in, a buffer | |
| 34 | - | //! out, no state. Focus order, scroll position, field editing and history are | |
| 35 | - | //! the interaction runtime, and they are the half that is real work. | |
| 36 | - | //! | |
| 37 | 33 | //! # The shape | |
| 38 | 34 | //! | |
| 39 | 35 | //! Flow layout, top to bottom. Every node answers a height for a width and then | |
| 40 | 36 | //! draws into the rect it was given, which is the smallest thing that composes | |
| 41 | 37 | //! and is what a description with no geometry in it can support. Nothing here | |
| 42 | 38 | //! measures twice. | |
| 39 | + | //! | |
| 40 | + | //! # Drawing takes two arguments, and that is the first finding it produced | |
| 41 | + | //! | |
| 42 | + | //! A screen and a [`View`]. The description says what the app offers; the view | |
| 43 | + | //! says what the user has done to it since it arrived — what is typed, what has | |
| 44 | + | //! focus, how far a pane is scrolled. None of the three is in a description and | |
| 45 | + | //! none of them belongs there, and a webview never had to say so because the | |
| 46 | + | //! browser holds all three without being asked. `39057019` is the finding and | |
| 47 | + | //! [`View`] carries the argument. | |
| 48 | + | //! | |
| 49 | + | //! A host with nothing to say passes `&View::new()`, which draws exactly what | |
| 50 | + | //! the description says. | |
| 43 | 51 | ||
| 52 | + | mod focus; | |
| 44 | 53 | mod node; | |
| 45 | 54 | mod region; | |
| 55 | + | mod runtime; | |
| 46 | 56 | mod text; | |
| 57 | + | mod view; | |
| 47 | 58 | ||
| 48 | 59 | #[cfg(test)] | |
| 49 | 60 | mod tests; | |
| 50 | 61 | ||
| 62 | + | pub use focus::{FieldSpot, Spot, spots}; | |
| 63 | + | pub use runtime::{Key, Runtime, Step}; | |
| 64 | + | pub use view::View; | |
| 65 | + | ||
| 51 | 66 | use makeover_layout as layout; | |
| 52 | 67 | use makeover_tui::table::TableStyle; | |
| 53 | 68 | use makeover_tui::{Fidelity, Palette, Theme}; | |
| @@ -92,7 +107,7 @@ | |||
| 92 | 107 | &self.palette | |
| 93 | 108 | } | |
| 94 | 109 | ||
| 95 | - | /// Draw a whole screen into `area`. | |
| 110 | + | /// Draw a whole screen into `area`, in the state `view` says it is in. | |
| 96 | 111 | /// | |
| 97 | 112 | /// The title is not drawn. A window title is the host's to set, the same | |
| 98 | 113 | /// way a webview host puts it in `<title>` rather than in the document, and | |
| @@ -102,7 +117,12 @@ | |||
| 102 | 117 | /// [`Screen::discovery`] is declined outright: og:type, an indexability | |
| 103 | 118 | /// flag and a canonical URL are facts about being crawled, and nothing | |
| 104 | 119 | /// crawls a terminal. | |
| 105 | - | pub fn screen(&self, screen: &Screen, area: Rect, buf: &mut Buffer) { | |
| 120 | + | pub fn screen(&self, screen: &Screen, view: &View, area: Rect, buf: &mut Buffer) { | |
| 121 | + | let mut pass = Pass { | |
| 122 | + | tui: self, | |
| 123 | + | view, | |
| 124 | + | seq: 0, | |
| 125 | + | }; | |
| 106 | 126 | let mut rest = area; | |
| 107 | 127 | ||
| 108 | 128 | // Notices first and at the top, because a notice belongs to the screen | |
| @@ -111,20 +131,30 @@ | |||
| 111 | 131 | // deciding, and the top of the screen is the one place a message about | |
| 112 | 132 | // the whole screen can go without claiming a region. | |
| 113 | 133 | for notice in &screen.notices { | |
| 114 | - | let used = self.node(notice, rest, buf); | |
| 134 | + | let used = node::draw(&mut pass, notice, rest, buf); | |
| 115 | 135 | rest = below(rest, used); | |
| 116 | 136 | } | |
| 117 | 137 | ||
| 118 | - | region::screen_regions(self, screen, rest, buf); | |
| 138 | + | region::screen_regions(&mut pass, screen, rest, buf); | |
| 119 | 139 | } | |
| 120 | 140 | ||
| 121 | 141 | /// Draw one node into `area`, and answer the rows it used. | |
| 122 | 142 | /// | |
| 123 | 143 | /// Never draws outside `area` and never below it: a node handed less room | |
| 124 | 144 | /// than it wants is cut off at the bottom, which is what a terminal does | |
| 125 | - | /// with everything. Scrolling is the runtime's, not the drawing's. | |
| 126 | - | pub fn node(&self, node: &Node, area: Rect, buf: &mut Buffer) -> u16 { | |
| 127 | - | node::draw(self, node, area, buf) | |
| 145 | + | /// with everything. A [`Node::Region`] is the one that scrolls, and it | |
| 146 | + | /// reads its offset off the view. | |
| 147 | + | pub fn node(&self, node: &Node, view: &View, area: Rect, buf: &mut Buffer) -> u16 { | |
| 148 | + | node::draw( | |
| 149 | + | &mut Pass { | |
| 150 | + | tui: self, | |
| 151 | + | view, | |
| 152 | + | seq: 0, | |
| 153 | + | }, | |
| 154 | + | node, | |
| 155 | + | area, | |
| 156 | + | buf, | |
| 157 | + | ) | |
| 128 | 158 | } | |
| 129 | 159 | ||
| 130 | 160 | /// The rows `node` wants at `width`. | |
| @@ -165,6 +195,42 @@ | |||
| 165 | 195 | } | |
| 166 | 196 | } | |
| 167 | 197 | ||
| 198 | + | /// One drawing, as it walks the screen. | |
| 199 | + | /// | |
| 200 | + | /// Carries the count of reachable things passed so far, which is how the | |
| 201 | + | /// drawing knows whether the thing it is about to draw is the focused one. The | |
| 202 | + | /// count has to advance at exactly the points [`focus::spots`] records one, and | |
| 203 | + | /// that agreement is asserted by a test rather than trusted: the two walks are | |
| 204 | + | /// separate because one needs a rect and the other does not, and a walk that | |
| 205 | + | /// counted differently would light the wrong control. | |
| 206 | + | pub(crate) struct Pass<'a> { | |
| 207 | + | tui: &'a Tui, | |
| 208 | + | view: &'a View, | |
| 209 | + | seq: usize, | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | impl Pass<'_> { | |
| 213 | + | /// Take the next reachable position, and say whether it is the focused one. | |
| 214 | + | fn claim(&mut self) -> bool { | |
| 215 | + | let mine = self.seq; | |
| 216 | + | self.seq += 1; | |
| 217 | + | mine == self.view.focus() | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// The style that says "this is the one you are on". | |
| 221 | + | /// | |
| 222 | + | /// Reversed video, which is the affordance a cell has left after colour is | |
| 223 | + | /// spent on tone and bold on weight. A webview says it with an outline; a | |
| 224 | + | /// terminal has no outline that is not four more cells. | |
| 225 | + | fn focused(focused: bool, style: Style) -> Style { | |
| 226 | + | if focused { | |
| 227 | + | style.add_modifier(Modifier::REVERSED) | |
| 228 | + | } else { | |
| 229 | + | style | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 168 | 234 | /// What is left of `area` after `used` rows from the top. | |
| 169 | 235 | fn below(area: Rect, used: u16) -> Rect { | |
| 170 | 236 | let used = used.min(area.height); |
| @@ -12,7 +12,7 @@ | |||
| 12 | 12 | use ratatui::style::{Modifier, Style}; | |
| 13 | 13 | use ratatui::text::{Line, Span}; | |
| 14 | 14 | ||
| 15 | - | use crate::{Tui, below, text}; | |
| 15 | + | use crate::{Pass, Tui, below, text}; | |
| 16 | 16 | ||
| 17 | 17 | /// The rows `node` wants at `width`. | |
| 18 | 18 | pub(crate) fn height(tui: &Tui, node: &Node, width: u16) -> u16 { | |
| @@ -23,9 +23,9 @@ | |||
| 23 | 23 | Node::Rich { source } => { | |
| 24 | 24 | text::spans_height(&rich_spans(tui, source, rich_base(tui)), width) | |
| 25 | 25 | } | |
| 26 | - | Node::Act(act) => text::line_height(&act_line(tui, act), width), | |
| 26 | + | Node::Act(act) => text::line_height(&act_line(tui, act, false), width), | |
| 27 | 27 | Node::Link { text: label, .. } => text::height(label, width), | |
| 28 | - | Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag)), width), | |
| 28 | + | Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag, false)), width), | |
| 29 | 29 | Node::Figure(figure) => figure_height(tui, figure, width), | |
| 30 | 30 | Node::Notice { text: content, .. } => text::height(content, width), | |
| 31 | 31 | Node::StandIn { message, act, .. } => { | |
| @@ -44,14 +44,14 @@ | |||
| 44 | 44 | let gutter = list_gutter(rows); | |
| 45 | 45 | rows.iter() | |
| 46 | 46 | .map(|row| { | |
| 47 | - | text::line_height(&row_line(tui, row), width.saturating_sub(gutter)).max(1) | |
| 47 | + | text::line_height(&row_line(tui, row, &[]), width.saturating_sub(gutter)).max(1) | |
| 48 | 48 | }) | |
| 49 | 49 | .sum::<u16>() | |
| 50 | 50 | + u16::from(more.is_some()) | |
| 51 | 51 | } | |
| 52 | 52 | Node::Table { columns, rows } => table_height(columns, rows), | |
| 53 | 53 | Node::Select { options, .. } => { | |
| 54 | - | text::line_height(&Line::from(select_spans(tui, options, None)), width) | |
| 54 | + | text::line_height(&Line::from(select_spans(tui, options, None, &[])), width) | |
| 55 | 55 | } | |
| 56 | 56 | Node::Meter(meter) => text::line_height(&meter_line(tui, meter), width), | |
| 57 | 57 | Node::Stats { figures } => figures | |
| @@ -63,11 +63,23 @@ | |||
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | 65 | /// Draw `node` at the top of `area`, and answer the rows it used. | |
| 66 | - | pub(crate) fn draw(tui: &Tui, node: &Node, area: Rect, buf: &mut Buffer) -> u16 { | |
| 66 | + | /// | |
| 67 | + | /// The reachable things are counted as they are passed, in the order | |
| 68 | + | /// [`crate::focus::spots`] records them, so that the one whose number matches | |
| 69 | + | /// the view's focus can be drawn lit. A node that is not reachable does not | |
| 70 | + | /// count, and a node that is drawn but unreachable — a disabled control, a | |
| 71 | + | /// hidden field — does not count either. | |
| 72 | + | pub(crate) fn draw(pass: &mut Pass<'_>, node: &Node, area: Rect, buf: &mut Buffer) -> u16 { | |
| 67 | 73 | if area.width == 0 || area.height == 0 { | |
| 74 | + | // A node with no room still holds its place in the count. The screen is | |
| 75 | + | // the same screen whether or not the terminal is tall enough to show | |
| 76 | + | // all of it, and a focus order that changed as the window was resized | |
| 77 | + | // would move the user's place under them. | |
| 78 | + | count(pass, node); | |
| 68 | 79 | return 0; | |
| 69 | 80 | } | |
| 70 | 81 | ||
| 82 | + | let tui = pass.tui; | |
| 71 | 83 | match node { | |
| 72 | 84 | Node::Heading { level, text: title } => text::draw(title, tui.heading(*level), area, buf), | |
| 73 | 85 | ||
| @@ -89,22 +101,24 @@ | |||
| 89 | 101 | text::draw_spans(&rich_spans(tui, source, rich_base(tui)), area, buf) | |
| 90 | 102 | } | |
| 91 | 103 | ||
| 92 | - | Node::Act(act) => text::draw_line(&act_line(tui, act), area, buf), | |
| 104 | + | Node::Act(act) => { | |
| 105 | + | let focused = claim_act(pass, act); | |
| 106 | + | text::draw_line(&act_line(tui, act, focused), area, buf) | |
| 107 | + | } | |
| 93 | 108 | ||
| 94 | 109 | // A link is text and an address, and a terminal cannot put the address | |
| 95 | 110 | // under the words the way an anchor does. Underlined, which is the one | |
| 96 | 111 | // affordance a cell has that says "this goes somewhere", and the | |
| 97 | 112 | // address is the runtime's to follow when the link has focus. | |
| 98 | - | Node::Link { text: label, .. } => text::draw( | |
| 99 | - | label, | |
| 100 | - | Style::default() | |
| 101 | - | .fg(tui.theme().action_primary) | |
| 102 | - | .add_modifier(Modifier::UNDERLINED), | |
| 103 | - | area, | |
| 104 | - | buf, | |
| 105 | - | ), | |
| 113 | + | Node::Link { text: label, .. } => { | |
| 114 | + | let focused = pass.claim(); | |
| 115 | + | text::draw(label, Pass::focused(focused, link_style(tui)), area, buf) | |
| 116 | + | } | |
| 106 | 117 | ||
| 107 | - | Node::Token(tag) => text::draw_line(&Line::from(tag_span(tui, tag)), area, buf), | |
| 118 | + | Node::Token(tag) => { | |
| 119 | + | let focused = claim_tag(pass, tag); | |
| 120 | + | text::draw_line(&Line::from(tag_span(tui, tag, focused)), area, buf) | |
| 121 | + | } | |
| 108 | 122 | ||
| 109 | 123 | Node::Figure(figure) => draw_figure(tui, figure, area, buf), | |
| 110 | 124 | ||
| @@ -132,27 +146,34 @@ | |||
| 132 | 146 | }; | |
| 133 | 147 | let used = text::draw(message, style, area, buf); | |
| 134 | 148 | match act { | |
| 135 | - | Some(act) => used + text::draw_line(&act_line(tui, act), below(area, used), buf), | |
| 149 | + | Some(act) => { | |
| 150 | + | let focused = claim_act(pass, act); | |
| 151 | + | used + text::draw_line(&act_line(tui, act, focused), below(area, used), buf) | |
| 152 | + | } | |
| 136 | 153 | None => used, | |
| 137 | 154 | } | |
| 138 | 155 | } | |
| 139 | 156 | ||
| 140 | - | Node::Field(field) => draw_field(tui, field, area, buf), | |
| 157 | + | Node::Field(field) => draw_field(pass, field, area, buf), | |
| 141 | 158 | ||
| 142 | 159 | Node::Form { submit, fields, .. } => { | |
| 143 | 160 | let mut used = 0; | |
| 144 | 161 | for field in fields { | |
| 145 | - | used += draw_field(tui, field, below(area, used), buf); | |
| 162 | + | used += draw_field(pass, field, below(area, used), buf); | |
| 146 | 163 | } | |
| 147 | 164 | // The submit, drawn as the act it is. The form's own action is not | |
| 148 | 165 | // drawn: an address is not a thing a cell can show, and the runtime | |
| 149 | 166 | // is what follows it. | |
| 167 | + | let focused = pass.claim(); | |
| 150 | 168 | used + text::draw_line( | |
| 151 | 169 | &Line::from(vec![Span::styled( | |
| 152 | 170 | format!("[ {submit} ]"), | |
| 153 | - | Style::default() | |
| 154 | - | .fg(tui.theme().selection_on) | |
| 155 | - | .bg(tui.theme().action_primary), | |
| 171 | + | Pass::focused( | |
| 172 | + | focused, | |
| 173 | + | Style::default() | |
| 174 | + | .fg(tui.theme().selection_on) | |
| 175 | + | .bg(tui.theme().action_primary), | |
| 176 | + | ), | |
| 156 | 177 | )]), | |
| 157 | 178 | below(area, used), | |
| 158 | 179 | buf, | |
| @@ -168,12 +189,17 @@ | |||
| 168 | 189 | let gutter = list_gutter(rows); | |
| 169 | 190 | let mut used = 0; | |
| 170 | 191 | for row in rows { | |
| 171 | - | let line = row_line(tui, row); | |
| 192 | + | // Claimed before the room is checked, because the count is a | |
| 193 | + | // fact about the description and the room is a fact about the | |
| 194 | + | // window. | |
| 195 | + | let focused = claim_row(pass, row); | |
| 196 | + | let parts = claim_parts(pass, row); | |
| 197 | + | let line = row_line(tui, row, &parts); | |
| 172 | 198 | let at = below(area, used); | |
| 173 | 199 | if at.height == 0 { | |
| 174 | - | break; | |
| 200 | + | continue; | |
| 175 | 201 | } | |
| 176 | - | draw_gutter(tui, row, at, buf); | |
| 202 | + | draw_gutter(tui, row, focused, at, buf); | |
| 177 | 203 | let body = Rect { | |
| 178 | 204 | x: at.x + gutter, | |
| 179 | 205 | width: at.width.saturating_sub(gutter), | |
| @@ -183,13 +209,14 @@ | |||
| 183 | 209 | } | |
| 184 | 210 | match more { | |
| 185 | 211 | Some(rest) => { | |
| 212 | + | let focused = pass.claim(); | |
| 186 | 213 | let label = rest.remaining.map_or_else( | |
| 187 | 214 | || "More".to_string(), | |
| 188 | 215 | |remaining| format!("{remaining} more"), | |
| 189 | 216 | ); | |
| 190 | 217 | used + text::draw( | |
| 191 | 218 | &label, | |
| 192 | - | Style::default().fg(tui.theme().action_primary), | |
| 219 | + | Pass::focused(focused, Style::default().fg(tui.theme().action_primary)), | |
| 193 | 220 | below(area, used), | |
| 194 | 221 | buf, | |
| 195 | 222 | ) | |
| @@ -198,17 +225,27 @@ | |||
| 198 | 225 | } | |
| 199 | 226 | } | |
| 200 | 227 | ||
| 201 | - | Node::Table { columns, rows } => draw_table(tui, columns, rows, area, buf), | |
| 228 | + | Node::Table { columns, rows } => draw_table(pass, columns, rows, area, buf), | |
| 202 | 229 | ||
| 203 | 230 | Node::Select { | |
| 204 | - | options, chosen, .. | |
| 231 | + | options, | |
| 232 | + | chosen, | |
| 233 | + | action, | |
| 234 | + | .. | |
| 205 | 235 | } => { | |
| 206 | 236 | // Segmented, toggle and tabs draw the same here: a row of labels | |
| 207 | 237 | // with the chosen one lit. The three differ in how much room they | |
| 208 | 238 | // claim and how they are grouped, which is a geometry question, and | |
| 209 | 239 | // a terminal has one cell size and no groups. | |
| 240 | + | let reachable: Vec<bool> = options | |
| 241 | + | .iter() | |
| 242 | + | .map(|(_, own)| { | |
| 243 | + | let calls = own.is_some() || action.is_some(); | |
| 244 | + | calls && pass.claim() | |
| 245 | + | }) | |
| 246 | + | .collect(); | |
| 210 | 247 | text::draw_line( | |
| 211 | - | &Line::from(select_spans(tui, options, chosen.as_deref())), | |
| 248 | + | &Line::from(select_spans(tui, options, chosen.as_deref(), &reachable)), | |
| 212 | 249 | area, | |
| 213 | 250 | buf, | |
| 214 | 251 | ) | |
| @@ -229,18 +266,75 @@ | |||
| 229 | 266 | used | |
| 230 | 267 | } | |
| 231 | 268 | ||
| 232 | - | Node::Region(slot) => crate::region::draw(tui, slot, area, buf), | |
| 269 | + | Node::Region(slot) => crate::region::draw(pass, slot, area, buf), | |
| 233 | 270 | } | |
| 234 | 271 | } | |
| 235 | 272 | ||
| 273 | + | /// Advance the count past a node that was not drawn, so that a screen too tall | |
| 274 | + | /// for its terminal keeps the focus order it had when it fit. | |
| 275 | + | fn count(pass: &mut Pass<'_>, node: &Node) { | |
| 276 | + | let mut found = Vec::new(); | |
| 277 | + | // The region's name does not matter here: only how many things were passed. | |
| 278 | + | crate::focus::node_spots(node, "", &mut found); | |
| 279 | + | pass.seq += found.len(); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// Claim a control, unless it is disabled and therefore unreachable. | |
| 283 | + | fn claim_act(pass: &mut Pass<'_>, act: &Act) -> bool { | |
| 284 | + | !act.state.is_some_and(layout::State::suppresses_interaction) && pass.claim() | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /// Claim a chip, which is the only tag that answers anything. | |
| 288 | + | fn claim_tag(pass: &mut Pass<'_>, tag: &Tag) -> bool { | |
| 289 | + | matches!(tag.kind, layout::Token::Chip { .. }) && tag.action.is_some() && pass.claim() | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// Claim a row, when the description gives it something to do. | |
| 293 | + | fn claim_row(pass: &mut Pass<'_>, row: &Row) -> bool { | |
| 294 | + | let reachable = row.activate.is_some() | |
| 295 | + | || row.toggle.is_some() | |
| 296 | + | || row.selected.is_some() | |
| 297 | + | || !row.menu.is_empty(); | |
| 298 | + | reachable && pass.claim() | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | /// Claim whatever the row's own run carries, one answer per part. | |
| 302 | + | fn claim_parts(pass: &mut Pass<'_>, row: &Row) -> Vec<bool> { | |
| 303 | + | row.parts | |
| 304 | + | .iter() | |
| 305 | + | .map(|Part { node, .. }| match node { | |
| 306 | + | Node::Act(act) => claim_act(pass, act), | |
| 307 | + | Node::Link { .. } => pass.claim(), | |
| 308 | + | Node::Token(tag) => claim_tag(pass, tag), | |
| 309 | + | _ => false, | |
| 310 | + | }) | |
| 311 | + | .collect() | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | /// The style text that goes somewhere takes. | |
| 315 | + | fn link_style(tui: &Tui) -> Style { | |
| 316 | + | Style::default() | |
| 317 | + | .fg(tui.theme().action_primary) | |
| 318 | + | .add_modifier(Modifier::UNDERLINED) | |
| 319 | + | } | |
| 320 | + | ||
| 236 | 321 | /// The columns a list spends before its rows. | |
| 237 | 322 | /// | |
| 238 | - | /// Four for a tick, because `[x] ` is four cells; two for the current marker | |
| 239 | - | /// alone; none when the list says neither. | |
| 323 | + | /// Four for a tick, because `[x] ` is four cells; two for the marker alone; | |
| 324 | + | /// none when the list needs neither. | |
| 325 | + | /// | |
| 326 | + | /// A row that can be reached takes the marker's two columns whether or not it | |
| 327 | + | /// is the current one, because focus is drawn there and a gutter of zero would | |
| 328 | + | /// put the caret over the first word. That is the drawing paying for an | |
| 329 | + | /// interaction, which is what a gutter is: the description says the row can be | |
| 330 | + | /// opened, and this is the terminal's way of showing which one is about to be. | |
| 240 | 331 | fn list_gutter(rows: &[Row]) -> u16 { | |
| 241 | 332 | if rows.iter().any(|row| row.selected.is_some()) { | |
| 242 | 333 | 4 | |
| 243 | - | } else if rows.iter().any(|row| row.current) { | |
| 334 | + | } else if rows | |
| 335 | + | .iter() | |
| 336 | + | .any(|row| row.current || row.activate.is_some() || !row.menu.is_empty()) | |
| 337 | + | { | |
| 244 | 338 | 2 | |
| 245 | 339 | } else { | |
| 246 | 340 | 0 | |
| @@ -248,7 +342,12 @@ | |||
| 248 | 342 | } | |
| 249 | 343 | ||
| 250 | 344 | /// The tick and the current marker, in the columns before a row. | |
| 251 | - | fn draw_gutter(tui: &Tui, row: &Row, area: Rect, buf: &mut Buffer) { | |
| 345 | + | /// | |
| 346 | + | /// The focus lands here rather than on the row's words. A row is a whole line | |
| 347 | + | /// and reversing all of it turns a list into a slab; the gutter is the column | |
| 348 | + | /// the affordances already live in, so it is where "you are on this one" can be | |
| 349 | + | /// said without repainting the content. | |
| 350 | + | fn draw_gutter(tui: &Tui, row: &Row, focused: bool, area: Rect, buf: &mut Buffer) { | |
| 252 | 351 | let tick = match row.selected { | |
| 253 | 352 | Some(true) => "[x]", | |
| 254 | 353 | Some(false) => "[ ]", | |
| @@ -260,17 +359,17 @@ | |||
| 260 | 359 | area.y, | |
| 261 | 360 | tick, | |
| 262 | 361 | 3, | |
| 263 | - | Style::default().fg(tui.theme().content_secondary), | |
| 362 | + | Pass::focused(focused, Style::default().fg(tui.theme().content_secondary)), | |
| 264 | 363 | ); | |
| 265 | 364 | return; | |
| 266 | 365 | } | |
| 267 | - | if row.current { | |
| 366 | + | if row.current || focused { | |
| 268 | 367 | buf.set_stringn( | |
| 269 | 368 | area.x, | |
| 270 | 369 | area.y, | |
| 271 | 370 | ">", | |
| 272 | 371 | 1, | |
| 273 | - | Style::default().fg(tui.theme().action_primary), | |
| 372 | + | Pass::focused(focused, Style::default().fg(tui.theme().action_primary)), | |
| 274 | 373 | ); | |
| 275 | 374 | } | |
| 276 | 375 | } | |
| @@ -281,13 +380,16 @@ | |||
| 281 | 380 | /// terminal had to know the fixed sequence -- primary, secondary, meta, bar, | |
| 282 | 381 | /// tokens, actions -- and hardcode it; here it reads what the description says, | |
| 283 | 382 | /// in the order it says it, and the role picks the style. | |
| 284 | - | fn row_line(tui: &Tui, row: &Row) -> Line<'static> { | |
| 383 | + | /// `focus` carries one answer per part, in the run's own order, and is empty | |
| 384 | + | /// for the callers that are measuring rather than drawing. | |
| 385 | + | fn row_line(tui: &Tui, row: &Row, focus: &[bool]) -> Line<'static> { | |
| 285 | 386 | let mut spans = Vec::new(); | |
| 286 | - | for Part { role, node } in &row.parts { | |
| 387 | + | for (index, Part { role, node }) in row.parts.iter().enumerate() { | |
| 287 | 388 | if !spans.is_empty() { | |
| 288 | 389 | spans.push(Span::raw(" ")); | |
| 289 | 390 | } | |
| 290 | - | spans.extend(inline_spans(tui, node, part_style(tui, *role))); | |
| 391 | + | let focused = focus.get(index).copied().unwrap_or(false); | |
| 392 | + | spans.extend(inline_spans(tui, node, part_style(tui, *role), focused)); | |
| 291 | 393 | } | |
| 292 | 394 | // `Row::menu` is not drawn, and that is the description's own instruction: | |
| 293 | 395 | // a menu is reached by right-click on a pointer host, long-press on a touch | |
| @@ -310,7 +412,7 @@ | |||
| 310 | 412 | } | |
| 311 | 413 | ||
| 312 | 414 | /// One leaf of a run as spans, under the run's own style. | |
| 313 | - | fn inline_spans(tui: &Tui, node: &Node, inherited: Style) -> Vec<Span<'static>> { | |
| 415 | + | fn inline_spans(tui: &Tui, node: &Node, inherited: Style, focused: bool) -> Vec<Span<'static>> { | |
| 314 | 416 | match node { | |
| 315 | 417 | Node::Text { text, tone } => { | |
| 316 | 418 | let style = match tone { | |
| @@ -320,13 +422,11 @@ | |||
| 320 | 422 | vec![Span::styled(text.clone(), style)] | |
| 321 | 423 | } | |
| 322 | 424 | Node::Rich { source } => rich_spans(tui, source, inherited), | |
| 323 | - | Node::Token(tag) => vec![tag_span(tui, tag)], | |
| 324 | - | Node::Act(act) => act_line(tui, act).spans, | |
| 425 | + | Node::Token(tag) => vec![tag_span(tui, tag, focused)], | |
| 426 | + | Node::Act(act) => act_line(tui, act, focused).spans, | |
| 325 | 427 | Node::Link { text, .. } => vec![Span::styled( | |
| 326 | 428 | text.clone(), | |
| 327 | - | Style::default() | |
| 328 | - | .fg(tui.theme().action_primary) | |
| 329 | - | .add_modifier(Modifier::UNDERLINED), | |
| 429 | + | Pass::focused(focused, link_style(tui)), | |
| 330 | 430 | )], | |
| 331 | 431 | Node::Meter(meter) => meter_line(tui, meter).spans, | |
| 332 | 432 | Node::Figure(figure) => vec![Span::styled( | |
| @@ -436,12 +536,17 @@ | |||
| 436 | 536 | } | |
| 437 | 537 | ||
| 438 | 538 | /// A tag as one span. | |
| 439 | - | fn tag_span(tui: &Tui, tag: &Tag) -> Span<'static> { | |
| 539 | + | /// | |
| 540 | + | /// A latched chip and a focused one both read as reversed, which is a collision | |
| 541 | + | /// a terminal cannot avoid: latched is "this filter is on" and focused is "you | |
| 542 | + | /// are here", and there is one spare axis for two facts. Filed rather than | |
| 543 | + | /// resolved by inventing a third look nobody would read. | |
| 544 | + | fn tag_span(tui: &Tui, tag: &Tag, focused: bool) -> Span<'static> { | |
| 440 | 545 | let style = tui.tone(tag.tone); | |
| 441 | 546 | let style = if tag.latched { | |
| 442 | 547 | style.add_modifier(Modifier::REVERSED) | |
| 443 | 548 | } else { | |
| 444 | - | style | |
| 549 | + | Pass::focused(focused, style) | |
| 445 | 550 | }; | |
| 446 | 551 | // A chip's removable half is not drawn. The `x` a webview hangs on a chip | |
| 447 | 552 | // is a second control inside one span, and a terminal reaches a control by | |
| @@ -457,12 +562,12 @@ | |||
| 457 | 562 | } | |
| 458 | 563 | ||
| 459 | 564 | /// A control as a line. | |
| 460 | - | fn act_line(tui: &Tui, act: &Act) -> Line<'static> { | |
| 565 | + | fn act_line(tui: &Tui, act: &Act, focused: bool) -> Line<'static> { | |
| 461 | 566 | let disabled = act.state.is_some_and(layout::State::suppresses_interaction); | |
| 462 | 567 | let style = if disabled { | |
| 463 | 568 | Style::default().fg(tui.theme().content_muted) | |
| 464 | 569 | } else { | |
| 465 | - | tui.tone(act.tone) | |
| 570 | + | Pass::focused(focused, tui.tone(act.tone)) | |
| 466 | 571 | }; | |
| 467 | 572 | ||
| 468 | 573 | // The key is the one place the description already anticipated a terminal, | |
| @@ -547,7 +652,7 @@ | |||
| 547 | 652 | label + body + note | |
| 548 | 653 | } | |
| 549 | 654 | ||
| 550 | - | fn draw_field(tui: &Tui, field: &Field, area: Rect, buf: &mut Buffer) -> u16 { | |
| 655 | + | fn draw_field(pass: &mut Pass<'_>, field: &Field, area: Rect, buf: &mut Buffer) -> u16 { | |
| 551 | 656 | // A hidden field is data travelling with the form, so there is nothing to | |
| 552 | 657 | // draw and the runtime submits it. The one field kind a terminal and a | |
| 553 | 658 | // webview agree on completely. | |
| @@ -555,6 +660,16 @@ | |||
| 555 | 660 | return 0; | |
| 556 | 661 | } | |
| 557 | 662 | ||
| 663 | + | let focused = pass.claim(); | |
| 664 | + | let tui = pass.tui; | |
| 665 | + | // What is in the box, which is the view's answer and not the description's. | |
| 666 | + | // See this crate's header, and `39057019`. | |
| 667 | + | let held = pass.view.showing(&field.name, field.value.as_deref()); | |
| 668 | + | ||
| 669 | + | if area.width == 0 || area.height == 0 { | |
| 670 | + | return 0; | |
| 671 | + | } | |
| 672 | + | ||
| 558 | 673 | let label = if field.required { | |
| 559 | 674 | format!("{} *", field.label) | |
| 560 | 675 | } else { | |
| @@ -567,25 +682,21 @@ | |||
| 567 | 682 | buf, | |
| 568 | 683 | ); | |
| 569 | 684 | ||
| 570 | - | let value = field.value.clone().unwrap_or_default(); | |
| 571 | 685 | let placeholder = field.placeholder.clone().unwrap_or_default(); | |
| 572 | - | let well = Style::default().fg(tui.theme().content_primary); | |
| 686 | + | let well = Pass::focused(focused, Style::default().fg(tui.theme().content_primary)); | |
| 573 | 687 | let muted = Style::default().fg(tui.theme().content_muted); | |
| 574 | 688 | ||
| 575 | 689 | used += match field.kind { | |
| 576 | - | layout::FieldKind::Checkbox => { | |
| 577 | - | let ticked = field.value.as_deref() == Some(Node::SELECTED); | |
| 578 | - | text::draw( | |
| 579 | - | if ticked { "[x]" } else { "[ ]" }, | |
| 580 | - | well, | |
| 581 | - | below(area, used), | |
| 582 | - | buf, | |
| 583 | - | ) | |
| 584 | - | } | |
| 690 | + | layout::FieldKind::Checkbox => text::draw( | |
| 691 | + | if held == Node::SELECTED { "[x]" } else { "[ ]" }, | |
| 692 | + | well, | |
| 693 | + | below(area, used), | |
| 694 | + | buf, | |
| 695 | + | ), | |
| 585 | 696 | layout::FieldKind::Select | layout::FieldKind::Radio => { | |
| 586 | 697 | let mut rows = 0; | |
| 587 | 698 | for choice in &field.options { | |
| 588 | - | let chosen = field.value.as_deref() == Some(choice.value.as_str()); | |
| 699 | + | let chosen = held == choice.value; | |
| 589 | 700 | let mark = if chosen { "(*)" } else { "( )" }; | |
| 590 | 701 | rows += text::draw( | |
| 591 | 702 | &format!("{mark} {}", choice.label), | |
| @@ -596,23 +707,26 @@ | |||
| 596 | 707 | } | |
| 597 | 708 | rows | |
| 598 | 709 | } | |
| 599 | - | // A secret has no described value, ever: `Field::value` drops what it | |
| 600 | - | // is handed when the kind is `Secret`, deliberately, because a password | |
| 601 | - | // that comes back down the wire is a password in a page and in a proxy | |
| 602 | - | // log. So there is nothing here to dot out, and an empty well is the | |
| 603 | - | // whole of what a drawing can say. | |
| 604 | - | // | |
| 605 | - | // The finding underneath it: a browser owns the contents of an `input`, | |
| 606 | - | // so a webview never needed the description to carry them. A terminal | |
| 607 | - | // owns nothing, so what the user has typed lives in the runtime's | |
| 608 | - | // buffer and the renderer has to be handed it. This is the first node | |
| 609 | - | // whose drawing is not a function of the description alone. | |
| 610 | - | layout::FieldKind::Secret => text::draw("[ ]", muted, below(area, used), buf), | |
| 710 | + | // A secret's dots come from the view's buffer and can come from nowhere | |
| 711 | + | // else. `Field::value` drops what it is handed when the kind is | |
| 712 | + | // `Secret`, deliberately, because a password that comes back down the | |
| 713 | + | // wire is a password in a page and in a proxy log, so the description | |
| 714 | + | // carries nothing to dot out and this is the one node that would be | |
| 715 | + | // undrawable without the second argument. `39057019`. | |
| 716 | + | layout::FieldKind::Secret if held.is_empty() => { | |
| 717 | + | empty_well(&placeholder, muted, well, focused, below(area, used), buf) | |
| 718 | + | } | |
| 719 | + | layout::FieldKind::Secret => { | |
| 720 | + | let dots = "*".repeat(held.chars().count()); | |
| 721 | + | text::draw(&dots, well, below(area, used), buf).max(1) | |
| 722 | + | } | |
| 611 | 723 | // A file field has no way back on a terminal any more than it has on an | |
| 612 | 724 | // HTTP host, which `3b830122` already filed. The name is drawn and | |
| 613 | 725 | // picking one is the runtime's. | |
| 614 | - | _ if value.is_empty() => text::draw(&placeholder, muted, below(area, used), buf).max(1), | |
| 615 | - | _ => text::draw(&value, well, below(area, used), buf), | |
| 726 | + | _ if held.is_empty() => { | |
| 727 | + | empty_well(&placeholder, muted, well, focused, below(area, used), buf) | |
| 728 | + | } | |
| 729 | + | _ => text::draw(held, well, below(area, used), buf), | |
| 616 | 730 | }; | |
| 617 | 731 | ||
| 618 | 732 | // The error wins over the hint, the same order a webview uses: a hint is | |
| @@ -631,14 +745,42 @@ | |||
| 631 | 745 | } | |
| 632 | 746 | } | |
| 633 | 747 | ||
| 748 | + | /// A box with nothing in it: the ghost text, and the caret when it has focus. | |
| 749 | + | /// | |
| 750 | + | /// The caret is not decoration. An empty field under a style is an empty field, | |
| 751 | + | /// so a focused one with no placeholder drew literally nothing and there was no | |
| 752 | + | /// way to tell the box was where the typing would go. A browser has a blinking | |
| 753 | + | /// bar for this and gets it without asking; a terminal has one cell of reversed | |
| 754 | + | /// video, put on the first column of the box, which is where the first | |
| 755 | + | /// character will land. | |
| 756 | + | fn empty_well( | |
| 757 | + | placeholder: &str, | |
| 758 | + | muted: Style, | |
| 759 | + | well: Style, | |
| 760 | + | focused: bool, | |
| 761 | + | area: Rect, |
Lines truncated
| @@ -26,7 +26,7 @@ | |||
| 26 | 26 | use ratatui::layout::Rect; | |
| 27 | 27 | use ratatui::style::{Modifier, Style}; | |
| 28 | 28 | ||
| 29 | - | use crate::{Tui, below, text}; | |
| 29 | + | use crate::{Pass, Tui, below, text}; | |
| 30 | 30 | ||
| 31 | 31 | /// How wide a sidebar is, in columns. | |
| 32 | 32 | /// | |
| @@ -37,7 +37,7 @@ | |||
| 37 | 37 | const LIST_SHARE: u16 = 40; | |
| 38 | 38 | ||
| 39 | 39 | /// Lay a screen's regions out and draw them. | |
| 40 | - | pub(crate) fn screen_regions(tui: &Tui, screen: &Screen, area: Rect, buf: &mut Buffer) { | |
| 40 | + | pub(crate) fn screen_regions(pass: &mut Pass<'_>, screen: &Screen, area: Rect, buf: &mut Buffer) { | |
| 41 | 41 | let mut rest = area; | |
| 42 | 42 | ||
| 43 | 43 | // Bands stack at the top, full width, in the order they were said. A band | |
| @@ -48,15 +48,11 @@ | |||
| 48 | 48 | .iter() | |
| 49 | 49 | .filter(|slot| matches!(slot.kind, RegionKind::Band)) | |
| 50 | 50 | { | |
| 51 | - | let used = draw(tui, slot, rest, buf); | |
| 51 | + | let used = draw(pass, slot, rest, buf); | |
| 52 | 52 | rest = below(rest, used); | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | - | let body: Vec<&Slot> = screen | |
| 56 | - | .slots | |
| 57 | - | .iter() | |
| 58 | - | .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal)) | |
| 59 | - | .collect(); | |
| 55 | + | let body = body_slots(screen); | |
| 60 | 56 | ||
| 61 | 57 | match screen.arrangement { | |
| 62 | 58 | layout::Arrangement::SidebarContent => { | |
| @@ -65,28 +61,29 @@ | |||
| 65 | 61 | let mut content = right; | |
| 66 | 62 | for slot in &body { | |
| 67 | 63 | if matches!(slot.kind, RegionKind::Sidebar) { | |
| 68 | - | let used = draw(tui, slot, below(left, sidebars), buf); | |
| 64 | + | let used = draw(pass, slot, below(left, sidebars), buf); | |
| 69 | 65 | sidebars += used; | |
| 70 | 66 | } else { | |
| 71 | - | let used = draw(tui, slot, content, buf); | |
| 67 | + | let used = draw(pass, slot, content, buf); | |
| 72 | 68 | content = below(content, used); | |
| 73 | 69 | } | |
| 74 | 70 | } | |
| 75 | 71 | } | |
| 76 | 72 | layout::Arrangement::ListDetail { tabbed } => { | |
| 77 | 73 | if tabbed { | |
| 78 | - | // One at a time, and nothing says which. See the module header. | |
| 74 | + | // One at a time, and nothing says which. `body_slots` has | |
| 75 | + | // already cut the rest away, so this is the one region there is. | |
| 79 | 76 | if let Some(first) = body.first() { | |
| 80 | - | draw(tui, first, rest, buf); | |
| 77 | + | draw(pass, first, rest, buf); | |
| 81 | 78 | } | |
| 82 | 79 | } else { | |
| 83 | 80 | let (left, right) = split(rest, rest.width * LIST_SHARE / 100); | |
| 84 | 81 | let mut detail = right; | |
| 85 | 82 | for (index, slot) in body.iter().enumerate() { | |
| 86 | 83 | if index == 0 { | |
| 87 | - | draw(tui, slot, left, buf); | |
| 84 | + | draw(pass, slot, left, buf); | |
| 88 | 85 | } else { | |
| 89 | - | let used = draw(tui, slot, detail, buf); | |
| 86 | + | let used = draw(pass, slot, detail, buf); | |
| 90 | 87 | detail = below(detail, used); | |
| 91 | 88 | } | |
| 92 | 89 | } | |
| @@ -102,10 +99,57 @@ | |||
| 102 | 99 | .iter() | |
| 103 | 100 | .filter(|slot| matches!(slot.kind, RegionKind::Modal)) | |
| 104 | 101 | { | |
| 105 | - | draw(tui, slot, centred(area), buf); | |
| 102 | + | draw(pass, slot, centred(area), buf); | |
| 106 | 103 | } | |
| 107 | 104 | } | |
| 108 | 105 | ||
| 106 | + | /// The regions that fill the body, after the arrangement has had its say. | |
| 107 | + | /// | |
| 108 | + | /// The tabbed cut lives here and only here. It is the one place a described | |
| 109 | + | /// region can be on the screen or not, so the drawing and the focus walk have | |
| 110 | + | /// to agree about it, and two copies of "the first one, and nothing says which" | |
| 111 | + | /// is two chances to disagree. | |
| 112 | + | fn body_slots(screen: &Screen) -> Vec<&Slot> { | |
| 113 | + | let body = screen | |
| 114 | + | .slots | |
| 115 | + | .iter() | |
| 116 | + | .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal)); | |
| 117 | + | ||
| 118 | + | match screen.arrangement { | |
| 119 | + | layout::Arrangement::ListDetail { tabbed: true } => body.take(1).collect(), | |
| 120 | + | _ => body.collect(), | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Every region the user can see, in the order it is drawn. | |
| 125 | + | /// | |
| 126 | + | /// What the focus walk reads. A region that is not drawn holds nothing | |
| 127 | + | /// reachable, which is why this is a question about slots rather than about | |
| 128 | + | /// nodes: a tab that is not showing has controls in it, and stopping on one | |
| 129 | + | /// would move focus to a place with nothing on screen. | |
| 130 | + | /// | |
| 131 | + | /// **A modal takes the whole of it.** A dialog you can tab out of is not a | |
| 132 | + | /// dialog, and this is the one place the drawing order and the focus order | |
| 133 | + | /// deliberately differ: the screen behind a modal is still painted, because | |
| 134 | + | /// covering it costs rows and says nothing, and it is still unreachable. | |
| 135 | + | pub(crate) fn reachable(screen: &Screen) -> Vec<&Slot> { | |
| 136 | + | let modals: Vec<&Slot> = screen | |
| 137 | + | .slots | |
| 138 | + | .iter() | |
| 139 | + | .filter(|slot| matches!(slot.kind, RegionKind::Modal)) | |
| 140 | + | .collect(); | |
| 141 | + | if !modals.is_empty() { | |
| 142 | + | return modals; | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | screen | |
| 146 | + | .slots | |
| 147 | + | .iter() | |
| 148 | + | .filter(|slot| matches!(slot.kind, RegionKind::Band)) | |
| 149 | + | .chain(body_slots(screen)) | |
| 150 | + | .collect() | |
| 151 | + | } | |
| 152 | + | ||
| 109 | 153 | /// The rows a region wants at `width`. | |
| 110 | 154 | pub(crate) fn height(tui: &Tui, slot: &Slot, width: u16) -> u16 { | |
| 111 | 155 | let inner = width.saturating_sub(2); | |
| @@ -119,11 +163,22 @@ | |||
| 119 | 163 | } | |
| 120 | 164 | ||
| 121 | 165 | /// Draw one region, and answer the rows it used. | |
| 122 | - | pub(crate) fn draw(tui: &Tui, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 { | |
| 166 | + | pub(crate) fn draw(pass: &mut Pass<'_>, slot: &Slot, area: Rect, buf: &mut Buffer) -> u16 { | |
| 167 | + | // A region still loading holds nothing reachable, here and in the focus | |
| 168 | + | // walk both: what is on the screen is the word "Loading", and a control | |
| 169 | + | // counted under it would be a place the caret could go with nothing to see. | |
| 170 | + | let pending = matches!(slot.readiness, layout::Readiness::Pending); | |
| 171 | + | ||
| 123 | 172 | if area.width == 0 || area.height == 0 { | |
| 173 | + | if !pending { | |
| 174 | + | for node in &slot.body { | |
| 175 | + | crate::node::draw(pass, node, area, buf); | |
| 176 | + | } | |
| 177 | + | } | |
| 124 | 178 | return 0; | |
| 125 | 179 | } | |
| 126 | 180 | ||
| 181 | + | let tui = pass.tui; | |
| 127 | 182 | // The frame, from the depth the region's kind implies. This is the whole | |
| 128 | 183 | // reason `makeover-tui` is a dependency rather than a nice-to-have: a | |
| 129 | 184 | // raised region is drawn the same way here as in every other terminal app | |
| @@ -139,7 +194,7 @@ | |||
| 139 | 194 | // `Readiness` is the loading axis, and a terminal has no spinner that is | |
| 140 | 195 | // not a clock. It says so in words instead, which loses the motion and | |
| 141 | 196 | // keeps the fact. | |
| 142 | - | if matches!(slot.readiness, layout::Readiness::Pending) { | |
| 197 | + | if pending { | |
| 143 | 198 | let used = text::draw( | |
| 144 | 199 | "Loading", | |
| 145 | 200 | Style::default() | |
| @@ -156,10 +211,7 @@ | |||
| 156 | 211 | // them is the host's to draw, and this renderer has no fill mechanism to | |
| 157 | 212 | // offer it. That is a gap rather than a decline: `Webview::with_fill` has | |
| 158 | 213 | // no counterpart here. | |
| 159 | - | let mut used = 0; | |
| 160 | - | for node in &slot.body { | |
| 161 | - | used += crate::node::draw(tui, node, below(inner, used), buf); | |
| 162 | - | } | |
| 214 | + | let used = body(pass, slot, inner, buf); | |
| 163 | 215 | ||
| 164 | 216 | // `Slot::id` is not drawn anywhere. It is a fragment address, and a | |
| 165 | 217 | // terminal redraws rather than swapping, so it costs nothing and says | |
| @@ -167,6 +219,62 @@ | |||
| 167 | 219 | used + if framed(depth) { 2 } else { 0 } | |
| 168 | 220 | } | |
| 169 | 221 | ||
| 222 | + | /// A region's contents, at the offset the view is holding it at. | |
| 223 | + | /// | |
| 224 | + | /// Scrolling is the runtime's and the clipping is the drawing's, and this is | |
| 225 | + | /// where the two meet. Flow layout draws from the top of the rect it is given, | |
| 226 | + | /// so an offset cannot be honoured by moving the rect: a node starting above | |
| 227 | + | /// the window would draw its first row at the window's first row. What works is | |
| 228 | + | /// to draw the region at its full height into a buffer of its own and copy the | |
| 229 | + | /// window out, which costs an allocation per scrolled region and nothing at all | |
| 230 | + | /// for a region sitting at the top, which is every region until someone | |
| 231 | + | /// scrolls. | |
| 232 | + | /// | |
| 233 | + | /// The offset is clamped here rather than in [`crate::View`], because how far a | |
| 234 | + | /// region can scroll is how tall it is at the width it was given, and the width | |
| 235 | + | /// is not known until this point. | |
| 236 | + | fn body(pass: &mut Pass<'_>, slot: &Slot, inner: Rect, buf: &mut Buffer) -> u16 { | |
| 237 | + | let offset = pass.view.scroll(&slot.id); | |
| 238 | + | if offset == 0 { | |
| 239 | + | let mut used = 0; | |
| 240 | + | for node in &slot.body { | |
| 241 | + | used += crate::node::draw(pass, node, below(inner, used), buf); | |
| 242 | + | } | |
| 243 | + | return used.min(inner.height); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | let content: u16 = slot | |
| 247 | + | .body | |
| 248 | + | .iter() | |
| 249 | + | .map(|node| crate::node::height(pass.tui, node, inner.width)) | |
| 250 | + | .sum(); | |
| 251 | + | let offset = offset.min(content.saturating_sub(inner.height)); | |
| 252 | + | ||
| 253 | + | let tall = Rect { | |
| 254 | + | height: content.max(inner.height), | |
| 255 | + | ..inner | |
| 256 | + | }; | |
| 257 | + | let mut scratch = Buffer::empty(tall); | |
| 258 | + | let mut used = 0; | |
| 259 | + | for node in &slot.body { | |
| 260 | + | used += crate::node::draw(pass, node, below(tall, used), &mut scratch); | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | let shown = inner.height.min(content.saturating_sub(offset)); | |
| 264 | + | for row in 0..shown { | |
| 265 | + | for column in 0..inner.width { | |
| 266 | + | let from = (inner.x + column, inner.y + offset + row); | |
| 267 | + | let to = (inner.x + column, inner.y + row); | |
| 268 | + | if let Some(cell) = scratch.cell(from).cloned() | |
| 269 | + | && let Some(target) = buf.cell_mut(to) | |
| 270 | + | { | |
| 271 | + | *target = cell; | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | shown | |
| 276 | + | } | |
| 277 | + | ||
| 170 | 278 | /// Whether a depth is drawn with a border. | |
| 171 | 279 | /// | |
| 172 | 280 | /// Flat is not: a band and a plain pane are arrangement, and boxing every one |
| @@ -15,7 +15,7 @@ | |||
| 15 | 15 | use ratatui::layout::Rect; | |
| 16 | 16 | use ratatui::style::Modifier; | |
| 17 | 17 | ||
| 18 | - | use crate::Tui; | |
| 18 | + | use crate::{Tui, View}; | |
| 19 | 19 | ||
| 20 | 20 | /// A renderer in a shipped theme, at full colour. | |
| 21 | 21 | /// | |
| @@ -46,10 +46,15 @@ | |||
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | /// Draw one node into a buffer of this size. | |
| 49 | + | /// | |
| 50 | + | /// With an empty [`View`], which is what "the description and nothing else" | |
| 51 | + | /// looks like now that drawing takes two arguments: nothing typed, nothing | |
| 52 | + | /// focused yet, nothing scrolled. Every assertion in this file that predates | |
| 53 | + | /// the interaction runtime still reads the same picture through it. | |
| 49 | 54 | fn buffer(node: &Node, width: u16, height: u16) -> Buffer { | |
| 50 | 55 | let area = Rect::new(0, 0, width, height); | |
| 51 | 56 | let mut buf = Buffer::empty(area); | |
| 52 | - | tui().node(node, area, &mut buf); | |
| 57 | + | tui().node(node, &View::new(), area, &mut buf); | |
| 53 | 58 | buf | |
| 54 | 59 | } | |
| 55 | 60 | ||
| @@ -75,7 +80,7 @@ | |||
| 75 | 80 | fn shown(screen: &Screen, width: u16, height: u16) -> Vec<String> { | |
| 76 | 81 | let area = Rect::new(0, 0, width, height); | |
| 77 | 82 | let mut buf = Buffer::empty(area); | |
| 78 | - | tui().screen(screen, area, &mut buf); | |
| 83 | + | tui().screen(screen, &View::new(), area, &mut buf); | |
| 79 | 84 | rows(&buf) | |
| 80 | 85 | } | |
| 81 | 86 | ||
| @@ -349,3 +354,605 @@ | |||
| 349 | 354 | assert!(out.iter().any(|row| row.contains("Loading")), "{out:?}"); | |
| 350 | 355 | assert!(!out.iter().any(|row| row.contains("Ready")), "{out:?}"); | |
| 351 | 356 | } | |
| 357 | + | ||
| 358 | + | // The interaction runtime. Everything below is the half the browser was | |
| 359 | + | // supplying: focus order, what is typed, where a key goes, what comes back. | |
| 360 | + | ||
| 361 | + | use crate::focus::{Reach, Spot}; | |
| 362 | + | use crate::{Key, Runtime, Step}; | |
| 363 | + | use quasi_router::{Address, Message, Method, Outcome, Request, Response, Rest}; | |
| 364 | + | ||
| 365 | + | /// A screen with one region holding these nodes. | |
| 366 | + | fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen { | |
| 367 | + | Screen::sidebar_content("Test").with( | |
| 368 | + | nodes | |
| 369 | + | .into_iter() | |
| 370 | + | .fold(Slot::new("main", RegionKind::Pane), Slot::with), | |
| 371 | + | ) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | /// The path a step is calling, for a step that calls one. | |
| 375 | + | fn calling(step: &Step) -> Option<&str> { | |
| 376 | + | match step { | |
| 377 | + | Step::Call(request) => Some(request.path.as_str()), | |
| 378 | + | _ => None, | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | #[test] | |
| 383 | + | fn the_focus_walk_and_the_drawing_count_the_same_things() { | |
| 384 | + | // The one invariant holding the two walks together. `focus.rs` decides how | |
| 385 | + | // many reachable things a screen has and `node.rs` counts them as it draws, | |
| 386 | + | // and if they ever disagree the caret lights a different control from the | |
| 387 | + | // one Enter would call. Asserted over a screen carrying one of everything | |
| 388 | + | // that can be reached. | |
| 389 | + | let screen = screen_of([ | |
| 390 | + | Node::Act(Act::new("Save", Action::post("/save"))), | |
| 391 | + | Node::Act(Act::new("Gone", Action::post("/gone")).disabled()), | |
| 392 | + | Node::Link { | |
| 393 | + | text: "Docs".into(), | |
| 394 | + | action: Action::get("/docs"), | |
| 395 | + | }, | |
| 396 | + | Node::field(Field::new(layout::FieldKind::Text, "name", "Name")), | |
| 397 | + | Node::field(Field::new(layout::FieldKind::Hidden, "csrf", "")), | |
| 398 | + | Node::Form { | |
| 399 | + | action: Action::post("/new"), | |
| 400 | + | submit: "Create".into(), | |
| 401 | + | fields: vec![ | |
| 402 | + | Field::new(layout::FieldKind::Text, "title", "Title"), | |
| 403 | + | Field::new(layout::FieldKind::Secret, "password", "Password"), | |
| 404 | + | ], | |
| 405 | + | }, | |
| 406 | + | Node::list([ | |
| 407 | + | Row::new("Open me").activate(Action::get("/one")), | |
| 408 | + | Row::new("Just words"), | |
| 409 | + | ]) | |
| 410 | + | .and_more(Rest::more(Action::get("/more"))), | |
| 411 | + | Node::Select { | |
| 412 | + | kind: layout::Selector::Tabs, | |
| 413 | + | options: vec![ | |
| 414 | + | (Choice::plain("a"), Some(Action::get("/a"))), | |
| 415 | + | (Choice::plain("b"), None), | |
| 416 | + | ], | |
| 417 | + | chosen: Some("a".into()), | |
| 418 | + | action: None, | |
| 419 | + | }, | |
| 420 | + | Node::Table { | |
| 421 | + | columns: vec![Column::new("Name")], | |
| 422 | + | rows: vec![Cells::new(["one"]).activate(Action::get("/row"))], | |
| 423 | + | }, | |
| 424 | + | ]); | |
| 425 | + | ||
| 426 | + | let expected = crate::focus::spots(&screen).len(); | |
| 427 | + | ||
| 428 | + | // The count the drawing keeps is private, so it is read through the only | |
| 429 | + | // thing it drives: focusing the nth reachable thing has to change the | |
| 430 | + | // picture. The baseline is focus one past the end, where nothing is lit. | |
| 431 | + | // | |
| 432 | + | // What this catches is the walks drifting apart. If the drawing counted | |
| 433 | + | // fewer things than `spots` records, the last indices would light nothing | |
| 434 | + | // and come back identical to the baseline; if it counted them in another | |
| 435 | + | // order, the caret would still move but a later test would find it on the | |
| 436 | + | // wrong control. This is the cheap half, and it is the half that breaks | |
| 437 | + | // silently. | |
| 438 | + | let area = Rect::new(0, 0, 60, 40); | |
| 439 | + | let draw = |view: &View| { | |
| 440 | + | let mut buf = Buffer::empty(area); | |
| 441 | + | tui().screen(&screen, view, area, &mut buf); | |
| 442 | + | buf | |
| 443 | + | }; | |
| 444 | + | ||
| 445 | + | let mut past = View::new(); | |
| 446 | + | past.focus_on(expected, expected + 1); | |
| 447 | + | let unlit = draw(&past); | |
| 448 | + | ||
| 449 | + | for at in 0..expected { | |
| 450 | + | let mut view = View::new(); | |
| 451 | + | view.focus_on(at, expected); | |
| 452 | + | assert_ne!( | |
| 453 | + | draw(&view), | |
| 454 | + | unlit, | |
| 455 | + | "focusing {at} of {expected} changed nothing on the screen" | |
| 456 | + | ); | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | #[test] | |
| 461 | + | fn a_secret_field_draws_what_was_typed_and_the_description_never_carries_it() { | |
| 462 | + | // `39057019`. The description refuses to hold a password, so the dots can | |
| 463 | + | // only come from the view, and this is the node that would be undrawable | |
| 464 | + | // without the second argument. | |
| 465 | + | let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2"); | |
| 466 | + | assert_eq!(field.value, None, "a secret refuses a described value"); | |
| 467 | + | ||
| 468 | + | let node = Node::field(field); | |
| 469 | + | let area = Rect::new(0, 0, 30, 4); | |
| 470 | + | ||
| 471 | + | let mut buf = Buffer::empty(area); | |
| 472 | + | tui().node(&node, &View::new(), area, &mut buf); | |
| 473 | + | assert!( | |
| 474 | + | !rows(&buf).iter().any(|row| row.contains('*')), | |
| 475 | + | "nothing typed yet" | |
| 476 | + | ); | |
| 477 | + | ||
| 478 | + | let mut view = View::new(); | |
| 479 | + | view.set("password", "hunter2"); | |
| 480 | + | let mut buf = Buffer::empty(area); | |
| 481 | + | tui().node(&node, &view, area, &mut buf); | |
| 482 | + | assert!( | |
| 483 | + | rows(&buf).iter().any(|row| row.contains("*******")), | |
| 484 | + | "{:?}", | |
| 485 | + | rows(&buf) | |
| 486 | + | ); | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn tab_walks_the_screen_and_enter_calls_what_it_lands_on() { | |
| 491 | + | let mut runtime = Runtime::new(screen_of([ | |
| 492 | + | Node::Act(Act::new("First", Action::post("/first"))), | |
| 493 | + | Node::Act(Act::new("Second", Action::post("/second"))), | |
| 494 | + | ])); | |
| 495 | + | ||
| 496 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); | |
| 497 | + | assert_eq!(runtime.key(Key::Tab), Step::Idle); | |
| 498 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/second")); | |
| 499 | + | // Wrapping, because a dead stop at the end reads as a broken key. | |
| 500 | + | runtime.key(Key::Tab); | |
| 501 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | #[test] | |
| 505 | + | fn a_disabled_control_is_drawn_and_never_landed_on() { | |
| 506 | + | let mut runtime = Runtime::new(screen_of([ | |
| 507 | + | Node::Act(Act::new("Gone", Action::post("/gone")).disabled()), | |
| 508 | + | Node::Act(Act::new("Live", Action::post("/live"))), | |
| 509 | + | ])); | |
| 510 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/live")); | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | #[test] | |
| 514 | + | fn the_description_says_which_control_to_start_on() { | |
| 515 | + | // `layout::State::Focus` is a fact the screen can state, and this is the one | |
| 516 | + | // place it is honoured: where focus starts, never where it goes back to. | |
| 517 | + | let mut runtime = Runtime::new(screen_of([ | |
| 518 | + | Node::Act(Act::new("First", Action::post("/first"))), | |
| 519 | + | Node::Act(Act { | |
| 520 | + | state: Some(layout::State::Focus), | |
| 521 | + | ..Act::new("Wanted", Action::post("/wanted")) | |
| 522 | + | }), | |
| 523 | + | ])); | |
| 524 | + | assert_eq!(calling(&runtime.key(Key::Enter)), Some("/wanted")); | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | #[test] | |
| 528 | + | fn a_key_the_description_named_reaches_its_control_from_anywhere() { | |
| 529 | + | // `Act::key` is the one place the vocabulary already anticipated a | |
| 530 | + | // terminal, and this is the renderer that binds it. | |
| 531 | + | let mut runtime = Runtime::new(screen_of([ | |
| 532 | + | Node::Act(Act::new("First", Action::post("/first"))), | |
| 533 | + | Node::Act(Act::new("New", Action::get("/new")).key("n")), | |
| 534 | + | ])); | |
| 535 | + | assert_eq!(calling(&runtime.key(Key::Char('n'))), Some("/new")); | |
| 536 | + | // A key nothing claimed does nothing rather than something surprising. | |
| 537 | + | assert_eq!(runtime.key(Key::Char('z')), Step::Idle); | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | #[test] | |
| 541 | + | fn a_control_that_asks_first_is_not_called_until_it_is_answered() { | |
| 542 | + | let mut runtime = Runtime::new(screen_of([Node::Act( | |
| 543 | + | Act::new("Delete", Action::post("/delete")).confirm("Delete this?"), | |
| 544 | + | )])); | |
| 545 | + | ||
| 546 | + | assert_eq!( | |
| 547 | + | runtime.key(Key::Enter), | |
| 548 | + | Step::Ask("Delete this?".to_string()) | |
| 549 | + | ); | |
| 550 | + | assert!(runtime.asking()); | |
| 551 | + | assert_eq!(runtime.key(Key::Char('n')), Step::Idle); | |
| 552 | + | ||
| 553 | + | assert!(matches!(runtime.key(Key::Enter), Step::Ask(_))); | |
| 554 | + | assert_eq!(calling(&runtime.key(Key::Char('y'))), Some("/delete")); | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | #[test] | |
| 558 | + | fn typing_fills_a_box_and_a_form_submits_what_is_in_it() { | |
| 559 | + | let mut runtime = Runtime::new(screen_of([Node::Form { | |
| 560 | + | action: Action::post("/new"), | |
| 561 | + | submit: "Create".into(), | |
| 562 | + | fields: vec![ | |
| 563 | + | Field::new(layout::FieldKind::Text, "title", "Title"), | |
| 564 | + | Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent"), | |
| 565 | + | ], | |
| 566 | + | }])); | |
| 567 | + | ||
| 568 | + | assert!(runtime.editing()); | |
| 569 | + | for ch in "Ship".chars() { | |
| 570 | + | runtime.key(Key::Char(ch)); | |
| 571 | + | } | |
| 572 | + | runtime.key(Key::Backspace); | |
| 573 | + | ||
| 574 | + | // Onto the checkbox, which takes any key as a flip rather than as a | |
| 575 | + | // character, then onto the submit. | |
| 576 | + | runtime.key(Key::Tab); | |
| 577 | + | runtime.key(Key::Char(' ')); | |
| 578 | + | runtime.key(Key::Tab); | |
| 579 | + | ||
| 580 | + | let Step::Call(request) = runtime.key(Key::Enter) else { | |
| 581 | + | panic!("the submit calls its route"); | |
| 582 | + | }; | |
| 583 | + | assert_eq!(request.path, "/new"); | |
| 584 | + | assert_eq!(request.method, Method::Post); | |
| 585 | + | assert_eq!(request.payload.get("title"), Some("Shi")); | |
| 586 | + | assert_eq!(request.payload.get("urgent"), Some(Node::SELECTED)); | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | #[test] | |
| 590 | + | fn an_unticked_box_sends_nothing_the_way_a_browser_sends_nothing() { | |
| 591 | + | let mut runtime = Runtime::new(screen_of([Node::Form { | |
| 592 | + | action: Action::post("/new"), | |
| 593 | + | submit: "Create".into(), | |
| 594 | + | fields: vec![Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent")], | |
| 595 | + | }])); | |
| 596 | + | runtime.key(Key::Tab); | |
| 597 | + | let Step::Call(request) = runtime.key(Key::Enter) else { | |
| 598 | + | panic!("the submit calls its route"); | |
| 599 | + | }; | |
| 600 | + | assert!(!request.payload.contains("urgent"), "{:?}", request.payload); | |
| 601 | + | } | |
| 602 | + | ||
| 603 | + | #[test] | |
| 604 | + | fn a_field_that_writes_as_it_changes_writes_on_the_keystroke() { | |
| 605 | + | // `Field::changes` says the change is the write, and a terminal has no | |
| 606 | + | // `input` event to debounce, so every keystroke is one call. That is the | |
| 607 | + | // description read literally, and the cost of reading it literally is | |
| 608 | + | // filed rather than papered over with a delay this renderer invented. | |
| 609 | + | let mut runtime = Runtime::new(screen_of([Node::field( | |
| 610 | + | Field::new(layout::FieldKind::Text, "query", "Search").changes(Action::post("/search")), | |
| 611 | + | )])); | |
| 612 | + | let Step::Call(request) = runtime.key(Key::Char('a')) else { | |
| 613 | + | panic!("a change writes"); | |
| 614 | + | }; | |
| 615 | + | assert_eq!(request.path, "/search"); | |
| 616 | + | assert_eq!(request.payload.get("query"), Some("a")); | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | #[test] | |
| 620 | + | fn a_screen_is_a_place_and_a_write_is_not() { | |
| 621 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 622 | + | ||
| 623 | + | // A read that answered a screen is somewhere to come back to. | |
| 624 | + | runtime.apply( | |
| 625 | + | &Request::get("/two"), | |
| 626 | + | Response { | |
| 627 | + | outcome: Outcome::Screen(screen_of([Node::text("second")])), | |
| 628 | + | notice: None, | |
| 629 | + | address: None, | |
| 630 | + | }, | |
| 631 | + | ); | |
| 632 | + | runtime.apply( | |
| 633 | + | &Request::get("/three"), | |
| 634 | + | Response { | |
| 635 | + | outcome: Outcome::Screen(screen_of([Node::text("third")])), | |
| 636 | + | notice: None, | |
| 637 | + | address: None, | |
| 638 | + | }, | |
| 639 | + | ); | |
| 640 | + | ||
| 641 | + | assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two")); | |
| 642 | + | ||
| 643 | + | // A write is not a place, so it leaves nothing behind to go back to. | |
| 644 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 645 | + | runtime.apply( | |
| 646 | + | &Request::post("/save"), | |
| 647 | + | Response { | |
| 648 | + | outcome: Outcome::Screen(screen_of([Node::text("saved")])), | |
| 649 | + | notice: None, | |
| 650 | + | address: None, | |
| 651 | + | }, | |
| 652 | + | ); | |
| 653 | + | assert_eq!(runtime.key(Key::Escape), Step::Idle); | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | #[test] | |
| 657 | + | fn a_response_can_say_it_is_not_a_place_when_the_derivation_would_say_it_is() { | |
| 658 | + | let mut runtime = Runtime::new(screen_of([Node::text("first")])); | |
| 659 | + | runtime.apply( | |
| 660 | + | &Request::get("/transient"), | |
| 661 | + | Response { | |
| 662 | + | outcome: Outcome::Screen(screen_of([Node::text("transient")])), | |
| 663 | + | notice: None, | |
| 664 | + | address: Some(Address::Unchanged), | |
| 665 | + | }, | |
| 666 | + | ); | |
| 667 | + | assert_eq!(runtime.key(Key::Escape), Step::Idle); | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | #[test] | |
| 671 | + | fn a_fragment_replaces_one_region_and_keeps_the_rest_of_the_screen() { | |
| 672 | + | let screen = Screen::sidebar_content("Test") | |
| 673 | + | .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("kept"))) | |
| 674 | + | .with(Slot::new("main", RegionKind::Pane).with(Node::text("old"))); | |
| 675 | + | let mut runtime = Runtime::new(screen); | |
| 676 | + | ||
| 677 | + | let follow = runtime.apply( | |
| 678 | + | &Request::post("/change"), | |
| 679 | + | Response { | |
| 680 | + | outcome: Outcome::Fragment { | |
| 681 | + | region: "main".into(), | |
| 682 | + | node: Node::text("new"), | |
| 683 | + | }, | |
| 684 | + | notice: None, | |
| 685 | + | address: None, | |
| 686 | + | }, | |
| 687 | + | ); | |
| 688 | + | assert!(follow.is_none()); | |
| 689 | + | ||
| 690 | + | let out = shown(runtime.screen(), 40, 8); | |
| 691 | + | assert!(out.iter().any(|row| row.contains("kept")), "{out:?}"); | |
| 692 | + | assert!(out.iter().any(|row| row.contains("new")), "{out:?}"); | |
| 693 | + | assert!(!out.iter().any(|row| row.contains("old")), "{out:?}"); | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | #[test] | |
| 697 | + | fn a_fragment_naming_a_region_that_is_not_there_says_so() { | |
| 698 | + | // `Screen::replace` answers false rather than panicking, and the caller is | |
| 699 | + | // the one that can act on it. A terminal drawing nothing would look like a | |
| 700 | + | // control that does nothing at all. | |
| 701 | + | let mut runtime = Runtime::new(screen_of([Node::text("here")])); | |
| 702 | + | runtime.apply( | |
| 703 | + | &Request::post("/change"), | |
| 704 | + | Response { | |
| 705 | + | outcome: Outcome::Fragment { | |
| 706 | + | region: "nowhere".into(), | |
| 707 | + | node: Node::text("new"), | |
| 708 | + | }, | |
| 709 | + | notice: None, | |
| 710 | + | address: None, | |
| 711 | + | }, | |
| 712 | + | ); | |
| 713 | + | let out = shown(runtime.screen(), 60, 8); | |
| 714 | + | assert!(out.iter().any(|row| row.contains("nowhere")), "{out:?}"); | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | #[test] | |
| 718 | + | fn going_somewhere_else_is_a_second_request_the_host_performs() { | |
| 719 | + | let mut runtime = Runtime::new(screen_of([Node::text("here")])); | |
| 720 | + | let follow = runtime.apply( | |
| 721 | + | &Request::post("/delete"), | |
| 722 | + | Response { | |
| 723 | + | outcome: Outcome::Goto(Action::get("/list")), | |
| 724 | + | notice: None, | |
| 725 | + | address: None, | |
| 726 | + | }, | |
| 727 | + | ); | |
| 728 | + | assert_eq!( | |
| 729 | + | follow.map(|request| request.path), | |
| 730 | + | Some("/list".to_string()) | |
| 731 | + | ); | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | #[test] | |
| 735 | + | fn what_a_response_says_lands_on_the_screen_it_belongs_to() { | |
| 736 | + | let mut runtime = Runtime::new(screen_of([Node::text("here")])); | |
| 737 | + | runtime.apply( | |
| 738 | + | &Request::post("/save"), | |
| 739 | + | Response { | |
| 740 | + | outcome: Outcome::Screen(screen_of([Node::text("after")])), | |
| 741 | + | notice: Some(Message { | |
| 742 | + | kind: layout::Notice::Banner, | |
| 743 | + | tone: layout::Tone::Success, | |
| 744 | + | text: "Saved".into(), | |
| 745 | + | undo: None, | |
| 746 | + | }), | |
| 747 | + | address: None, | |
| 748 | + | }, | |
| 749 | + | ); | |
| 750 | + | let out = shown(runtime.screen(), 40, 6); | |
| 751 | + | assert_eq!(out[0], "Saved"); | |
| 752 | + | } | |
| 753 | + | ||
| 754 | + | #[test] | |
| 755 | + | fn a_new_screen_forgets_what_was_typed_into_the_old_one() { | |
| 756 | + | // Two screens can name the same field, and carrying a buffer across would | |
| 757 | + | // put what was typed into one box into a different box that happens to | |
| 758 | + | // share its name. | |
| 759 | + | let mut runtime = Runtime::new(screen_of([Node::field(Field::new( | |
| 760 | + | layout::FieldKind::Text, | |
| 761 | + | "name", | |
| 762 | + | "Name", | |
| 763 | + | ))])); | |
| 764 | + | runtime.key(Key::Char('a')); | |
| 765 | + | assert_eq!(runtime.view().edit("name"), Some("a")); | |
| 766 | + | ||
| 767 | + | runtime.apply( | |
| 768 | + | &Request::get("/other"), | |
| 769 | + | Response { | |
| 770 | + | outcome: Outcome::Screen(screen_of([Node::field(Field::new( | |
| 771 | + | layout::FieldKind::Text, | |
| 772 | + | "name", | |
| 773 | + | "Different question, same name", | |
| 774 | + | ))])), | |
| 775 | + | notice: None, | |
| 776 | + | address: None, | |
| 777 | + | }, | |
| 778 | + | ); | |
| 779 | + | assert_eq!(runtime.view().edit("name"), None); | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | #[test] | |
| 783 | + | fn a_scrolled_region_shows_the_rows_under_the_ones_it_started_with() { | |
| 784 | + | let slot = | |
| 785 | + | Slot::new("main", RegionKind::Pane).extend((0..10).map(|n| Node::text(format!("row {n}")))); | |
| 786 | + | let node = Node::Region(slot); | |
| 787 | + | let area = Rect::new(0, 0, 20, 4); | |
| 788 | + | ||
| 789 | + | // Row 0 of the buffer is the region's own frame, so the contents start on | |
| 790 | + | // row 1 and the window is what is left after the frame takes two. | |
| 791 | + | let mut buf = Buffer::empty(area); | |
| 792 | + | tui().node(&node, &View::new(), area, &mut buf); | |
| 793 | + | assert!(rows(&buf)[1].contains("row 0"), "{:?}", rows(&buf)); | |
| 794 | + | ||
| 795 | + | let mut view = View::new(); | |
| 796 | + | view.scrolled_to("main", 3); | |
| 797 | + | let mut buf = Buffer::empty(area); | |
| 798 | + | tui().node(&node, &view, area, &mut buf); | |
| 799 | + | let out = rows(&buf); | |
| 800 | + | assert!(out[1].contains("row 3"), "{out:?}"); | |
| 801 | + | assert!(!out.iter().any(|row| row.contains("row 0")), "{out:?}"); | |
| 802 | + | } | |
| 803 | + | ||
| 804 | + | #[test] | |
| 805 | + | fn scrolling_stops_at_the_bottom_of_what_there_is() { | |
| 806 | + | // The view holds a number and the drawing clamps it, because how far a | |
| 807 | + | // region can scroll is how tall it is at the width it was handed, and the | |
| 808 | + | // width is not known until it is drawn. | |
| 809 | + | let slot = | |
| 810 | + | Slot::new("main", RegionKind::Pane).extend((0..6).map(|n| Node::text(format!("row {n}")))); | |
| 811 | + | let node = Node::Region(slot); | |
| 812 | + | let area = Rect::new(0, 0, 20, 4); | |
| 813 | + | ||
| 814 | + | // Six rows into a window of two, so the furthest down it can go is row 4 | |
| 815 | + | // at the top: an offset past the end shows the last screenful and not a | |
| 816 | + | // blank region. | |
| 817 | + | let mut view = View::new(); | |
| 818 | + | view.scrolled_to("main", 99); | |
| 819 | + | let mut buf = Buffer::empty(area); | |
| 820 | + | tui().node(&node, &view, area, &mut buf); | |
| 821 | + | let out = rows(&buf); |
Lines truncated
| @@ -1,0 +1,384 @@ | |||
| 1 | + | //! What the user can reach, and the order they reach it in. | |
| 2 | + | //! | |
| 3 | + | //! Nothing in a description says this. A webview never had to ask: the browser | |
| 4 | + | //! builds the tab order out of the document, and the document is the drawing, so | |
| 5 | + | //! the order falls out of the markup a renderer already emitted. A terminal | |
| 6 | + | //! draws cells, and a cell knows nothing about the one before it. | |
| 7 | + | //! | |
| 8 | + | //! So focus order is this renderer's policy, and the policy is: **draw order**. | |
| 9 | + | //! A thing is reachable when the description gives it something to call, and it | |
| 10 | + | //! comes after whatever was drawn above it. That is the same rule the browser | |
| 11 | + | //! applies to a document with no `tabindex` in it, which is the shape every | |
| 12 | + | //! screen here has. | |
| 13 | + | //! | |
| 14 | + | //! The walk below mirrors [`crate::node::draw`] step for step, and it has to: | |
| 15 | + | //! the drawing counts reachable things as it passes them and lights the one | |
| 16 | + | //! whose number matches, so a walk that visited them in another order would | |
| 17 | + | //! light the wrong one. The two are kept together deliberately rather than | |
| 18 | + | //! being derived from one traversal, because the drawing needs a rect and this | |
| 19 | + | //! needs nothing, and threading a rect through a walk that has no use for one | |
| 20 | + | //! was the worse of the two couplings. | |
| 21 | + | //! | |
| 22 | + | //! # What is reachable | |
| 23 | + | //! | |
| 24 | + | //! Anything the description gives an address to, plus the two affordances that | |
| 25 | + | //! are addresses in everything but name: a row that can be ticked, and a field | |
| 26 | + | //! that takes typing. A [`Node::Meter`] and a [`Node::Figure`] are readouts and | |
| 27 | + | //! are skipped, and a disabled [`Act`] is drawn and passed over, which is what | |
| 28 | + | //! `disabled` means on every host. | |
| 29 | + | ||
| 30 | + | use makeover_layout as layout; | |
| 31 | + | use quasi_router::{Act, Action, Field, Node, Part, Row, Screen, Slot}; | |
| 32 | + | ||
| 33 | + | /// One thing the user can reach, and what reaching it offers. | |
| 34 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 35 | + | pub enum Spot { | |
| 36 | + | /// A control. Enter calls it, after its confirmation when it has one. | |
| 37 | + | Act { | |
| 38 | + | /// What it calls. | |
| 39 | + | action: Action, | |
| 40 | + | /// What to ask first, if anything. | |
| 41 | + | confirm: Option<String>, | |
| 42 | + | /// The key that reaches it without walking there. | |
| 43 | + | /// | |
| 44 | + | /// The one place the description already anticipated a terminal, and | |
| 45 | + | /// the runtime is what finally binds it. | |
| 46 | + | key: Option<String>, | |
| 47 | + | /// Whether the description says this is the control to start on. | |
| 48 | + | /// | |
| 49 | + | /// [`layout::State::Focused`] is a fact two parties now claim: the | |
| 50 | + | /// screen says which control matters, and the view holds where the user | |
| 51 | + | /// has walked to since. The rule is that the description decides where | |
| 52 | + | /// focus *starts* and never moves it afterwards, because a redraw that | |
| 53 | + | /// pulled the caret back would take the keyboard off the user. | |
| 54 | + | wants_focus: bool, | |
| 55 | + | }, | |
| 56 | + | /// Text that goes somewhere. Enter follows it. | |
| 57 | + | Link { | |
| 58 | + | /// Where it goes. | |
| 59 | + | action: Action, | |
| 60 | + | }, | |
| 61 | + | /// A question. Typing edits it; Enter leaves it alone. | |
| 62 | + | Field(Box<FieldSpot>), | |
| 63 | + | /// The control that answers a whole form. | |
| 64 | + | Submit { | |
| 65 | + | /// Where the answers go. | |
| 66 | + | action: Action, | |
| 67 | + | /// The names the form submits, in order, so the runtime can gather the | |
| 68 | + | /// values it is holding for them. | |
| 69 | + | names: Vec<String>, | |
| 70 | + | }, | |
| 71 | + | /// A row of a list. | |
| 72 | + | Row { | |
| 73 | + | /// What opening it calls. | |
| 74 | + | activate: Option<Action>, | |
| 75 | + | /// What ticking it calls, when the tick is itself the write. | |
| 76 | + | toggle: Option<Action>, | |
| 77 | + | /// Whether it is ticked, and whether it can be. | |
| 78 | + | ticked: Option<bool>, | |
| 79 | + | /// What it offers without showing: reached by a key here, by | |
| 80 | + | /// right-click on a pointer host. | |
| 81 | + | menu: Vec<Act>, | |
| 82 | + | }, | |
| 83 | + | /// One option of a selector. | |
| 84 | + | Choice { | |
| 85 | + | /// What picking it calls. | |
| 86 | + | action: Action, | |
| 87 | + | }, | |
| 88 | + | /// The way to the rows a list is not showing. | |
| 89 | + | More { | |
| 90 | + | /// What asking for more calls. | |
| 91 | + | action: Action, | |
| 92 | + | }, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// A question, and everything the runtime needs to hold what is typed into it. | |
| 96 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 97 | + | pub struct FieldSpot { | |
| 98 | + | /// The name the value is submitted under. | |
| 99 | + | pub name: String, | |
| 100 | + | /// What kind of value it takes. | |
| 101 | + | pub kind: layout::FieldKind, | |
| 102 | + | /// What the description offers back, which is what an untouched buffer | |
| 103 | + | /// starts from. | |
| 104 | + | /// | |
| 105 | + | /// Always `None` for a [`layout::FieldKind::Secret`], and that is the whole | |
| 106 | + | /// of `39057019`: the description refuses to carry one, on purpose, so the | |
| 107 | + | /// runtime's buffer is the only place the typed value has ever lived. | |
| 108 | + | pub value: Option<String>, | |
| 109 | + | /// The values on offer, for the kinds that offer any. | |
| 110 | + | pub options: Vec<String>, | |
| 111 | + | /// The longest value it will take, in characters. | |
| 112 | + | pub max_length: Option<u32>, | |
| 113 | + | /// What changing it calls, for a control that writes on its own. | |
| 114 | + | pub changes: Option<Action>, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | impl Spot { | |
| 118 | + | /// What Enter does here, when it does anything. | |
| 119 | + | /// | |
| 120 | + | /// A field answers `None`: Enter in a text box is not a submit here, the | |
| 121 | + | /// way it is in a browser, because a terminal has no implicit submit and | |
| 122 | + | /// guessing one would fire a form from the first field the user typed in. | |
| 123 | + | #[must_use] | |
| 124 | + | pub fn enters(&self) -> Option<&Action> { | |
| 125 | + | match self { | |
| 126 | + | Self::Act { action, .. } | |
| 127 | + | | Self::Link { action } | |
| 128 | + | | Self::Submit { action, .. } | |
| 129 | + | | Self::Choice { action } | |
| 130 | + | | Self::More { action } => Some(action), | |
| 131 | + | Self::Row { activate, .. } => activate.as_ref(), | |
| 132 | + | Self::Field(_) => None, | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// The question this stands on, when it is one. | |
| 137 | + | #[must_use] | |
| 138 | + | pub const fn field(&self) -> Option<&FieldSpot> { | |
| 139 | + | match self { | |
| 140 | + | Self::Field(spot) => Some(spot), | |
| 141 | + | _ => None, | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// A reachable thing, and the region it is in. | |
| 147 | + | /// | |
| 148 | + | /// The region is here because scrolling needs it. A key that scrolls has to | |
| 149 | + | /// scroll something, and the only non-arbitrary answer is the region the user is | |
| 150 | + | /// working in, which is the region their focus is in. Carrying it on the walk | |
| 151 | + | /// that already visits every reachable thing is cheaper than a second walk that | |
| 152 | + | /// would be free to disagree with this one. | |
| 153 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 154 | + | pub struct Reach { | |
| 155 | + | /// The [`Slot::id`] of the region holding it. | |
| 156 | + | pub region: String, | |
| 157 | + | /// What it is. | |
| 158 | + | pub spot: Spot, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Everything reachable on `screen`, in draw order, with its region. | |
| 162 | + | #[must_use] | |
| 163 | + | pub fn reaches(screen: &Screen) -> Vec<Reach> { | |
| 164 | + | let mut found = Vec::new(); | |
| 165 | + | for slot in crate::region::reachable(screen) { | |
| 166 | + | slot_spots(slot, &mut found); | |
| 167 | + | } | |
| 168 | + | found | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Everything reachable on `screen`, in draw order. | |
| 172 | + | #[must_use] | |
| 173 | + | pub fn spots(screen: &Screen) -> Vec<Spot> { | |
| 174 | + | reaches(screen) | |
| 175 | + | .into_iter() | |
| 176 | + | .map(|reach| reach.spot) | |
| 177 | + | .collect() | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// A region's reachable things. | |
| 181 | + | /// | |
| 182 | + | /// A region that is still loading has none. It is drawn as the word "Loading" | |
| 183 | + | /// and nothing under it is on screen, so anything counted here would be a | |
| 184 | + | /// focusable the user cannot see. | |
| 185 | + | fn slot_spots(slot: &Slot, found: &mut Vec<Reach>) { | |
| 186 | + | if matches!(slot.readiness, layout::Readiness::Pending) { | |
| 187 | + | return; | |
| 188 | + | } | |
| 189 | + | for node in &slot.body { | |
| 190 | + | node_spots(node, &slot.id, found); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /// One node's reachable things, in the order it draws them. | |
| 195 | + | pub(crate) fn node_spots(node: &Node, region: &str, found: &mut Vec<Reach>) { | |
| 196 | + | // Everything below reads better saying what it found rather than how it is | |
| 197 | + | // recorded, and the region is the same for every one of them. | |
| 198 | + | macro_rules! push { | |
| 199 | + | ($spot:expr) => { | |
| 200 | + | found.push(Reach { | |
| 201 | + | region: region.to_string(), | |
| 202 | + | spot: $spot, | |
| 203 | + | }) | |
| 204 | + | }; | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | match node { | |
| 208 | + | Node::Act(act) => push_act(act, region, found), | |
| 209 | + | ||
| 210 | + | Node::Link { action, .. } => push!(Spot::Link { | |
| 211 | + | action: action.clone(), | |
| 212 | + | }), | |
| 213 | + | ||
| 214 | + | // A chip carries a route and is drawn as a bracketed label with no | |
| 215 | + | // second target in it, which `node.rs` already declined: the `x` a | |
| 216 | + | // webview hangs on a chip is a control inside a span. Reaching the chip | |
| 217 | + | // is reaching its action, which is the part a terminal can honour. | |
| 218 | + | Node::Token(tag) => { | |
| 219 | + | if let layout::Token::Chip { .. } = tag.kind | |
| 220 | + | && let Some(action) = tag.action.clone() | |
| 221 | + | { | |
| 222 | + | push!(Spot::Act { | |
| 223 | + | action, | |
| 224 | + | confirm: None, | |
| 225 | + | key: None, | |
| 226 | + | wants_focus: false, | |
| 227 | + | }); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | Node::StandIn { act, .. } => { | |
| 232 | + | if let Some(act) = act { | |
| 233 | + | push_act(act, region, found); | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | Node::Field(field) => push_field(field, region, found), | |
| 238 | + | ||
| 239 | + | Node::Form { | |
| 240 | + | action, | |
| 241 | + | fields, | |
| 242 | + | submit: _, | |
| 243 | + | } => { | |
| 244 | + | for field in fields { | |
| 245 | + | push_field(field, region, found); | |
| 246 | + | } | |
| 247 | + | push!(Spot::Submit { | |
| 248 | + | action: action.clone(), | |
| 249 | + | names: fields.iter().map(|field| field.name.clone()).collect(), | |
| 250 | + | }); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | Node::List { rows, more } => { | |
| 254 | + | for row in rows { | |
| 255 | + | push_row(row, region, found); | |
| 256 | + | } | |
| 257 | + | if let Some(rest) = more { | |
| 258 | + | push!(Spot::More { | |
| 259 | + | action: rest.action.clone(), | |
| 260 | + | }); | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // A table's rows are reachable and its cells are not. A cell holding a | |
| 265 | + | // control is drawn through `makeover_tui::table`, which lays cells out | |
| 266 | + | // by column width and answers no coordinates back, so there is nothing | |
| 267 | + | // here that could say where inside a row a control ended up. Reaching | |
| 268 | + | // the row is what a terminal can do honestly; reaching the third | |
| 269 | + | // control in the fourth cell is a finding. | |
| 270 | + | Node::Table { rows, .. } => { | |
| 271 | + | for cells in rows { | |
| 272 | + | if let Some(activate) = cells.activate.clone() { | |
| 273 | + | push!(Spot::Row { | |
| 274 | + | activate: Some(activate), | |
| 275 | + | toggle: None, | |
| 276 | + | ticked: None, | |
| 277 | + | menu: Vec::new(), | |
| 278 | + | }); | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | Node::Select { | |
| 284 | + | options, action, .. | |
| 285 | + | } => { | |
| 286 | + | for (choice, own) in options { | |
| 287 | + | // An option carrying nothing falls back to the strip's action | |
| 288 | + | // with its value substituted, which is what the description | |
| 289 | + | // says the fallback is. An option with neither is a label. | |
| 290 | + | let call = own.clone().or_else(|| { | |
| 291 | + | action | |
| 292 | + | .clone() | |
| 293 | + | .map(|action| action.with(Node::SELECTED, choice.value.clone())) | |
| 294 | + | }); | |
| 295 | + | if let Some(action) = call { | |
| 296 | + | push!(Spot::Choice { action }); | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | Node::Region(slot) => slot_spots(slot, found), | |
| 302 | + | ||
| 303 | + | // Readouts and prose. Nothing to call, so nothing to stop on. | |
| 304 | + | Node::Heading { .. } | |
| 305 | + | | Node::Text { .. } | |
| 306 | + | | Node::Rich { .. } | |
| 307 | + | | Node::Figure(_) | |
| 308 | + | | Node::Notice { .. } | |
| 309 | + | | Node::Meter(_) | |
| 310 | + | | Node::Stats { .. } => {} | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | /// A control, unless it is disabled. | |
| 315 | + | fn push_act(act: &Act, region: &str, found: &mut Vec<Reach>) { | |
| 316 | + | if act.state.is_some_and(layout::State::suppresses_interaction) { | |
| 317 | + | return; | |
| 318 | + | } | |
| 319 | + | found.push(Reach { | |
| 320 | + | region: region.to_string(), | |
| 321 | + | spot: Spot::Act { | |
| 322 | + | action: act.action.clone(), | |
| 323 | + | confirm: act.confirm.clone(), | |
| 324 | + | key: act.key.clone(), | |
| 325 | + | wants_focus: matches!(act.state, Some(layout::State::Focus)), | |
| 326 | + | }, | |
| 327 | + | }); | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | /// A question, unless it is hidden. | |
| 331 | + | /// | |
| 332 | + | /// A hidden field draws nothing and is submitted with the form, so stopping on | |
| 333 | + | /// it would be a stop on a blank row. | |
| 334 | + | fn push_field(field: &Field, region: &str, found: &mut Vec<Reach>) { | |
| 335 | + | if matches!(field.kind, layout::FieldKind::Hidden) { | |
| 336 | + | return; | |
| 337 | + | } | |
| 338 | + | found.push(Reach { | |
| 339 | + | region: region.to_string(), | |
| 340 | + | spot: Spot::Field(Box::new(FieldSpot { | |
| 341 | + | name: field.name.clone(), | |
| 342 | + | kind: field.kind, | |
| 343 | + | value: field.value.clone(), | |
| 344 | + | options: field | |
| 345 | + | .options | |
| 346 | + | .iter() | |
| 347 | + | .map(|choice| choice.value.clone()) | |
| 348 | + | .collect(), | |
| 349 | + | max_length: field.max_length, | |
| 350 | + | changes: field.changes.clone(), | |
| 351 | + | })), | |
| 352 | + | }); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// A row: the row itself when the description gives it something to do, then | |
| 356 | + | /// whatever its run carries. | |
| 357 | + | /// | |
| 358 | + | /// Two stops and not one, because they are two things. A row that opens a | |
| 359 | + | /// detail pane and also shows a Remove button offers both, and a terminal that | |
| 360 | + | /// collapsed them would make the button unreachable or the row unopenable. A | |
| 361 | + | /// row that only shows things is passed over entirely, which is the difference | |
| 362 | + | /// between a list and a menu. | |
| 363 | + | /// | |
| 364 | + | /// The row comes first because it is the whole line and the controls sit on it. | |
| 365 | + | fn push_row(row: &Row, region: &str, found: &mut Vec<Reach>) { | |
| 366 | + | if row.activate.is_some() | |
| 367 | + | || row.toggle.is_some() | |
| 368 | + | || row.selected.is_some() | |
| 369 | + | || !row.menu.is_empty() | |
| 370 | + | { | |
| 371 | + | found.push(Reach { | |
| 372 | + | region: region.to_string(), | |
| 373 | + | spot: Spot::Row { | |
| 374 | + | activate: row.activate.clone(), | |
| 375 | + | toggle: row.toggle.clone(), | |
| 376 | + | ticked: row.selected, | |
| 377 | + | menu: row.menu.clone(), | |
| 378 | + | }, | |
| 379 | + | }); | |
| 380 | + | } | |
| 381 | + | for Part { node, .. } in &row.parts { | |
| 382 | + | node_spots(node, region, found); | |
| 383 | + | } | |
| 384 | + | } |
| @@ -1,0 +1,486 @@ | |||
| 1 | + | //! The half a webview host never writes. | |
| 2 | + | //! | |
| 3 | + | //! `quasi-axum` and `quasi-tauri` both answer a request with markup and stop. | |
| 4 | + | //! Everything between one request and the next — which control is under the | |
| 5 | + | //! caret, what the user has typed into it, what a key means, where the back | |
| 6 | + | //! button goes — is the browser's, and neither adapter contains a line of it. | |
| 7 | + | //! A terminal has no browser under it, so this is that half, written out. | |
| 8 | + | //! | |
| 9 | + | //! # It does not own the router | |
| 10 | + | //! | |
| 11 | + | //! [`Runtime`] turns keys into [`Request`]s and applies [`Response`]s, and it | |
| 12 | + | //! never calls a handler. The host holds the router and the state and does the | |
| 13 | + | //! calling, which keeps this free of the state type and makes every binding | |
| 14 | + | //! below testable without standing up an app. | |
| 15 | + | //! | |
| 16 | + | //! ```text | |
| 17 | + | //! key ──► Runtime::key ──► Step::Call(request) | |
| 18 | + | //! │ | |
| 19 | + | //! host: router.handle(&state, request) | |
| 20 | + | //! │ | |
| 21 | + | //! Runtime::apply ◄── Response | |
| 22 | + | //! ``` | |
| 23 | + | //! | |
| 24 | + | //! # The bindings are this renderer's, and the description reaches two of them | |
| 25 | + | //! | |
| 26 | + | //! Nothing in a description says what Tab does, so the table below is policy. | |
| 27 | + | //! The two exceptions are the two the vocabulary already carries: [`Act::key`] | |
| 28 | + | //! names the key that reaches a control, and [`Act::confirm`] names the question | |
| 29 | + | //! to ask before doing it. Both were drawn and declined by the drawing half, | |
| 30 | + | //! and this is where they are honoured. | |
| 31 | + | //! | |
| 32 | + | //! | Key | What it does | | |
| 33 | + | //! |---|---| | |
| 34 | + | //! | Tab, Down | the next reachable thing | | |
| 35 | + | //! | `BackTab`, Up | the previous one | | |
| 36 | + | //! | Enter | call what is under the caret | | |
| 37 | + | //! | Space | tick the row under the caret | | |
| 38 | + | //! | `PageUp`, `PageDown` | scroll the region the caret is in | | |
| 39 | + | //! | Backspace | take a character back out of a field | | |
| 40 | + | //! | printable | type into a field, or reach the control that named the key | | |
| 41 | + | //! | Escape | back, or dismiss the question | | |
| 42 | + | ||
| 43 | + | use makeover_layout as layout; | |
| 44 | + | use quasi_router::{Action, Message, Method, Node, Outcome, Params, Request, Response, Screen}; | |
| 45 | + | use ratatui::buffer::Buffer; | |
| 46 | + | use ratatui::layout::Rect; | |
| 47 | + | ||
| 48 | + | use crate::focus::{Reach, Spot}; | |
| 49 | + | use crate::{Tui, View}; | |
| 50 | + | ||
| 51 | + | /// A key, named the way this crate wants to talk about one. | |
| 52 | + | /// | |
| 53 | + | /// Not crossterm's, deliberately. A host maps its own events onto this in a | |
| 54 | + | /// dozen lines, and in exchange the bindings below are testable without a | |
| 55 | + | /// terminal and this crate does not make every consumer take a backend it might | |
| 56 | + | /// not be using. | |
| 57 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 58 | + | pub enum Key { | |
| 59 | + | /// A character the user typed. | |
| 60 | + | Char(char), | |
| 61 | + | /// Confirm, follow, submit. | |
| 62 | + | Enter, | |
| 63 | + | /// Forward through the reachable things. | |
| 64 | + | Tab, | |
| 65 | + | /// Backward through them. | |
| 66 | + | BackTab, | |
| 67 | + | /// Take a character back. | |
| 68 | + | Backspace, | |
| 69 | + | /// Out, back, never mind. | |
| 70 | + | Escape, | |
| 71 | + | /// Up one reachable thing. | |
| 72 | + | Up, | |
| 73 | + | /// Down one reachable thing. | |
| 74 | + | Down, | |
| 75 | + | /// A screen's worth backwards. | |
| 76 | + | PageUp, | |
| 77 | + | /// A screen's worth forwards. | |
| 78 | + | PageDown, | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// What the host should do about a key. | |
| 82 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 83 | + | pub enum Step { | |
| 84 | + | /// Nothing left to do but redraw. | |
| 85 | + | Idle, | |
| 86 | + | /// Ask the router this, then hand the answer to [`Runtime::apply`]. | |
| 87 | + | Call(Request), | |
| 88 | + | /// Ask this question. The next key answers it: `y` or Enter does the thing, | |
| 89 | + | /// anything else does not. | |
| 90 | + | Ask(String), | |
| 91 | + | /// Somewhere outside the app. The host opens it, and nothing comes back. | |
| 92 | + | Open(String), | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// A screen, what the user has done to it, and how they got here. | |
| 96 | + | #[derive(Debug, Clone)] | |
| 97 | + | pub struct Runtime { | |
| 98 | + | screen: Screen, | |
| 99 | + | view: View, | |
| 100 | + | /// The places behind this one, most recent last. | |
| 101 | + | /// | |
| 102 | + | /// Requests rather than addresses, because going back means asking again | |
| 103 | + | /// and a request is what asking takes. [`Address`](quasi_router::Address) | |
| 104 | + | /// carries a string for a browser's address bar, which a terminal does not | |
| 105 | + | /// have. | |
| 106 | + | history: Vec<Request>, | |
| 107 | + | /// The request that produced the screen currently showing. | |
| 108 | + | here: Option<Request>, | |
| 109 | + | /// A control waiting on its own question being answered. | |
| 110 | + | asked: Option<Action>, | |
| 111 | + | /// Something to say once the screen it belongs to has arrived. | |
| 112 | + | saying: Option<Message>, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | impl Runtime { | |
| 116 | + | /// Start on this screen, with nothing typed and nothing behind it. | |
| 117 | + | #[must_use] | |
| 118 | + | pub fn new(screen: Screen) -> Self { | |
| 119 | + | let mut runtime = Self { | |
| 120 | + | screen, | |
| 121 | + | view: View::new(), | |
| 122 | + | history: Vec::new(), | |
| 123 | + | here: None, | |
| 124 | + | asked: None, | |
| 125 | + | saying: None, | |
| 126 | + | }; | |
| 127 | + | runtime.start_focus(); | |
| 128 | + | runtime | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// The screen being shown. | |
| 132 | + | #[must_use] | |
| 133 | + | pub const fn screen(&self) -> &Screen { | |
| 134 | + | &self.screen | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// What the user has done to it. | |
| 138 | + | #[must_use] | |
| 139 | + | pub const fn view(&self) -> &View { | |
| 140 | + | &self.view | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Everything reachable on it, in focus order. | |
| 144 | + | #[must_use] | |
| 145 | + | pub fn reaches(&self) -> Vec<Reach> { | |
| 146 | + | crate::focus::reaches(&self.screen) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Whether the caret is in a field, which is what decides whether a | |
| 150 | + | /// printable key is a shortcut or a character. | |
| 151 | + | /// | |
| 152 | + | /// A host wanting `q` to quit asks this first. Quitting is the host's and | |
| 153 | + | /// not a binding here, because a key that closes the app is a fact about | |
| 154 | + | /// the app rather than about the screen. | |
| 155 | + | #[must_use] | |
| 156 | + | pub fn editing(&self) -> bool { | |
| 157 | + | self.focused().is_some_and(|spot| spot.field().is_some()) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// Whether a question is waiting to be answered. | |
| 161 | + | #[must_use] | |
| 162 | + | pub const fn asking(&self) -> bool { | |
| 163 | + | self.asked.is_some() | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Draw it. | |
| 167 | + | pub fn draw(&self, tui: &Tui, area: Rect, buf: &mut Buffer) { | |
| 168 | + | tui.screen(&self.screen, &self.view, area, buf); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// What is under the caret. | |
| 172 | + | #[must_use] | |
| 173 | + | pub fn focused(&self) -> Option<Spot> { | |
| 174 | + | let mut reaches = crate::focus::reaches(&self.screen); | |
| 175 | + | if self.view.focus() >= reaches.len() { | |
| 176 | + | return None; | |
| 177 | + | } | |
| 178 | + | Some(reaches.swap_remove(self.view.focus()).spot) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// Take a key, and say what the host should do about it. | |
| 182 | + | pub fn key(&mut self, key: Key) -> Step { | |
| 183 | + | // A question owns the keyboard until it is answered. Anything that is | |
| 184 | + | // not yes is no, which is the safe way round for a prompt that is only | |
| 185 | + | // ever raised by something destructive. | |
| 186 | + | if let Some(action) = self.asked.take() { | |
| 187 | + | return match key { | |
| 188 | + | Key::Char('y' | 'Y') | Key::Enter => Self::call(&action), | |
| 189 | + | _ => Step::Idle, | |
| 190 | + | }; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | let reaches = crate::focus::reaches(&self.screen); | |
| 194 | + | let count = reaches.len(); | |
| 195 | + | let here = reaches | |
| 196 | + | .get(self.view.focus()) | |
| 197 | + | .map(|reach| reach.spot.clone()); | |
| 198 | + | ||
| 199 | + | match key { | |
| 200 | + | Key::Tab | Key::Down => { | |
| 201 | + | self.view.advance(1, count); | |
| 202 | + | Step::Idle | |
| 203 | + | } | |
| 204 | + | Key::BackTab | Key::Up => { | |
| 205 | + | self.view.advance(-1, count); | |
| 206 | + | Step::Idle | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | Key::PageDown | Key::PageUp => { | |
| 210 | + | // The region the caret is in, because it is the one the user is | |
| 211 | + | // working in. A screen with focus nowhere scrolls nothing, | |
| 212 | + | // which is honest: there is no "the pane" on a screen with | |
| 213 | + | // several. | |
| 214 | + | if let Some(reach) = reaches.get(self.view.focus()) { | |
| 215 | + | let rows = if matches!(key, Key::PageDown) { | |
| 216 | + | 10 | |
| 217 | + | } else { | |
| 218 | + | -10 | |
| 219 | + | }; | |
| 220 | + | self.view.scroll_by(&reach.region, rows); | |
| 221 | + | } | |
| 222 | + | Step::Idle | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | Key::Escape => self.back(), | |
| 226 | + | ||
| 227 | + | Key::Backspace => { | |
| 228 | + | if let Some(field) = here.as_ref().and_then(Spot::field) { | |
| 229 | + | self.view.backspace(field); | |
| 230 | + | } | |
| 231 | + | Step::Idle | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | Key::Enter => match here { | |
| 235 | + | Some(Spot::Act { | |
| 236 | + | action, confirm, .. | |
| 237 | + | }) => match confirm { | |
| 238 | + | Some(prompt) => { | |
| 239 | + | self.asked = Some(action); | |
| 240 | + | Step::Ask(prompt) | |
| 241 | + | } | |
| 242 | + | None => Self::call(&action), | |
| 243 | + | }, | |
| 244 | + | Some(Spot::Submit { action, names }) => { | |
| 245 | + | let payload = self.view.submission( | |
| 246 | + | &names, | |
| 247 | + | &reaches | |
| 248 | + | .iter() | |
| 249 | + | .map(|reach| reach.spot.clone()) | |
| 250 | + | .collect::<Vec<_>>(), | |
| 251 | + | ); | |
| 252 | + | Self::send(&action, payload) | |
| 253 | + | } | |
| 254 | + | // A field takes Enter and does nothing with it. A browser | |
| 255 | + | // submits the form around it, and doing that here would fire a | |
| 256 | + | // write from the first box the user finished typing in; the | |
| 257 | + | // submit is one Tab away and says what it does. | |
| 258 | + | Some(Spot::Field(_)) | None => Step::Idle, | |
| 259 | + | Some(other) => match other.enters() { | |
| 260 | + | Some(action) => Self::call(&action.clone()), | |
| 261 | + | None => Step::Idle, | |
| 262 | + | }, | |
| 263 | + | }, | |
| 264 | + | ||
| 265 | + | Key::Char(' ') if !self.editing() => match here { | |
| 266 | + | // A tick is a write when the description says it is, and | |
| 267 | + | // client-side selection when it does not. The second is not | |
| 268 | + | // wired to anything yet: nothing names what a selection is | |
| 269 | + | // *for*, which is `033ff3ca`, so a tick with no `toggle` moves | |
| 270 | + | // no state a route could read. | |
| 271 | + | Some(Spot::Row { | |
| 272 | + | toggle: Some(action), | |
| 273 | + | .. | |
| 274 | + | }) => Self::call(&action), | |
| 275 | + | _ => Step::Idle, | |
| 276 | + | }, | |
| 277 | + | ||
| 278 | + | Key::Char(ch) => { | |
| 279 | + | if let Some(field) = here.as_ref().and_then(Spot::field).cloned() { | |
| 280 | + | self.type_into(&field, ch); | |
| 281 | + | return self.after_typing(here.as_ref()); | |
| 282 | + | } | |
| 283 | + | // Not in a field, so the key is a shortcut if any control on | |
| 284 | + | // the screen claimed it. `Act::key` is text rather than a | |
| 285 | + | // modelled chord, so this is a string comparison against what | |
| 286 | + | // the description wrote, and a name this renderer does not | |
| 287 | + | // understand simply never matches. | |
| 288 | + | let pressed = ch.to_string(); | |
| 289 | + | let claimed = reaches.iter().find_map(|reach| match &reach.spot { | |
| 290 | + | Spot::Act { | |
| 291 | + | action, | |
| 292 | + | key: Some(key), | |
| 293 | + | .. | |
| 294 | + | } if *key == pressed => Some(action.clone()), | |
| 295 | + | _ => None, | |
| 296 | + | }); | |
| 297 | + | match claimed { | |
| 298 | + | Some(action) => Self::call(&action), | |
| 299 | + | None => Step::Idle, | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Put what the router answered onto the screen. | |
| 306 | + | /// | |
| 307 | + | /// Answers with a follow-up request when the response says to go somewhere | |
| 308 | + | /// else, which the host performs the same way it performed the first one. | |
| 309 | + | /// `request` is what was asked, because whether an answer is a place is | |
| 310 | + | /// derived from it: a read that answered a whole screen is somewhere you | |
| 311 | + | /// can come back to, and a write is not. | |
| 312 | + | pub fn apply(&mut self, request: &Request, response: Response) -> Option<Request> { | |
| 313 | + | let Response { | |
| 314 | + | outcome, | |
| 315 | + | notice, | |
| 316 | + | address, | |
| 317 | + | } = response; | |
| 318 | + | self.saying = notice.or(self.saying.take()); | |
| 319 | + | ||
| 320 | + | match outcome { | |
| 321 | + | Outcome::Screen(screen) => { | |
| 322 | + | self.remember(request, address.as_ref()); | |
| 323 | + | self.screen = screen; | |
| 324 | + | self.view.reset(); | |
| 325 | + | self.start_focus(); | |
| 326 | + | self.announce(); | |
| 327 | + | None | |
| 328 | + | } | |
| 329 | + | Outcome::Fragment { region, node } => { | |
| 330 | + | // A region that is not there is the description bug | |
| 331 | + | // `Screen::replace` describes, and a terminal can say so | |
| 332 | + | // rather than swallowing it: the region it named is gone, and | |
| 333 | + | // drawing nothing would look like a control that does nothing. | |
| 334 | + | if !self.screen.replace(®ion, node) { | |
| 335 | + | self.saying = Some(Message { | |
| 336 | + | kind: layout::Notice::Banner, | |
| 337 | + | tone: layout::Tone::Danger, | |
| 338 | + | text: format!("nothing on this screen is called `{region}`"), | |
| 339 | + | undo: None, | |
| 340 | + | }); | |
| 341 | + | } | |
| 342 | + | self.view.prune(&self.screen); | |
| 343 | + | self.announce(); | |
| 344 | + | None | |
| 345 | + | } | |
| 346 | + | Outcome::Goto(action) => match Self::call(&action) { | |
| 347 | + | Step::Call(request) => Some(request), | |
| 348 | + | // An external destination is the host's to open, and there is | |
| 349 | + | // nothing to come back for. | |
| 350 | + | _ => None, | |
| 351 | + | }, | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// Go back, if there is anywhere to go. | |
| 356 | + | fn back(&mut self) -> Step { | |
| 357 | + | match self.history.pop() { | |
| 358 | + | Some(request) => { | |
| 359 | + | self.here = Some(request.clone()); | |
| 360 | + | Step::Call(request) | |
| 361 | + | } | |
| 362 | + | None => Step::Idle, | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /// Note where we were, before we leave it. | |
| 367 | + | /// | |
| 368 | + | /// The derivation the response's own documentation describes: a read that | |
| 369 | + | /// answered a screen is a place, everything else is not, and | |
| 370 | + | /// [`Address`](quasi_router::Address) is the override for the two cases the | |
| 371 | + | /// derivation cannot reach. | |
| 372 | + | fn remember(&mut self, request: &Request, address: Option<&quasi_router::Address>) { | |
| 373 | + | let place = match address { | |
| 374 | + | Some(quasi_router::Address::Enters(_)) => true, | |
| 375 | + | Some(quasi_router::Address::Unchanged) => false, | |
| 376 | + | Some(quasi_router::Address::Replaces(_)) => { | |
| 377 | + | self.here = Some(request.clone()); | |
| 378 | + | return; | |
| 379 | + | } | |
| 380 | + | None => request.method == Method::Get, | |
| 381 | + | }; | |
| 382 | + | if place && let Some(previous) = self.here.replace(request.clone()) { | |
| 383 | + | self.history.push(previous); | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | /// Put whatever the response wanted said onto the screen it belongs to. | |
| 388 | + | fn announce(&mut self) { | |
| 389 | + | // A message's `undo` is dropped, and that is a decline rather than an | |
| 390 | + | // oversight: `Node::Notice` has nowhere to hang a control, so the way | |
| 391 | + | // back that the response offered has no cell to sit in. Filed. | |
| 392 | + | if let Some(Message { | |
| 393 | + | kind, tone, text, .. | |
| 394 | + | }) = self.saying.take() | |
| 395 | + | { | |
| 396 | + | self.screen.notices.push(Node::Notice { kind, tone, text }); | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | /// Start on what the description says matters, when it says anything. | |
| 401 | + | fn start_focus(&mut self) { | |
| 402 | + | let reaches = crate::focus::reaches(&self.screen); | |
| 403 | + | let at = reaches.iter().position(|reach| { | |
| 404 | + | matches!( | |
| 405 | + | reach.spot, | |
| 406 | + | Spot::Act { | |
| 407 | + | wants_focus: true, | |
| 408 | + | .. | |
| 409 | + | } | |
| 410 | + | ) | |
| 411 | + | }); | |
| 412 | + | if let Some(at) = at { | |
| 413 | + | self.view.focus_on(at, reaches.len()); | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// Type into a field, honouring what the description says it will take. | |
| 418 | + | fn type_into(&mut self, field: &crate::FieldSpot, ch: char) { | |
| 419 | + | if matches!(field.kind, layout::FieldKind::Checkbox) { | |
| 420 | + | // A checkbox holds one of two values, so a key does not type into | |
| 421 | + | // it: any key flips it, which is what space does to one in a | |
| 422 | + | // browser and is the only sentence a box with two states can hear. | |
| 423 | + | let ticked = self.view.typed(field) == Node::SELECTED; | |
| 424 | + | let next = if ticked { | |
| 425 | + | String::new() | |
| 426 | + | } else { | |
| 427 | + | Node::SELECTED.to_string() | |
| 428 | + | }; | |
| 429 | + | self.view.set(&field.name, next); | |
| 430 | + | return; | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // `Field::max_length` is a rule the description carries and every | |
| 434 | + | // renderer emits in its host's idiom. A browser stops accepting | |
| 435 | + | // characters, and so does this. | |
| 436 | + | if let Some(limit) = field.max_length | |
| 437 | + | && self.view.typed(field).chars().count() >= limit as usize | |
| 438 | + | { | |
| 439 | + | return; | |
| 440 | + | } | |
| 441 | + | self.view.push(field, ch); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | /// What a keystroke in a field costs, when the field writes as it changes. | |
| 445 | + | fn after_typing(&mut self, here: Option<&Spot>) -> Step { | |
| 446 | + | match here.and_then(Spot::field).and_then(|field| { | |
| 447 | + | field | |
| 448 | + | .changes | |
| 449 | + | .clone() | |
| 450 | + | .map(|action| (action, field.name.clone())) | |
| 451 | + | }) { | |
| 452 | + | // A field that writes on every change writes on every keystroke | |
| 453 | + | // here, which is what `Field::changes` says and is wrong for a text | |
| 454 | + | // box: a webview debounces on `input` and nothing in the | |
| 455 | + | // description says a delay is allowed. Filed rather than debounced | |
| 456 | + | // to a number this renderer made up. | |
| 457 | + | Some((action, name)) => { | |
| 458 | + | let value = self.view.edit(&name).unwrap_or_default().to_string(); | |
| 459 | + | let payload = Params::new().with(name, value); | |
| 460 | + | Self::send(&action, payload) | |
| 461 | + | } | |
| 462 | + | None => Step::Idle, | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /// An action as something the host can ask. | |
| 467 | + | fn call(action: &Action) -> Step { | |
| 468 | + | Self::send(action, Params::new()) | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | /// An action, plus values the control is sending that are not on it. | |
| 472 | + | fn send(action: &Action, extra: Params) -> Step { | |
| 473 | + | let Some(path) = action.destination.route() else { | |
| 474 | + | return Step::Open(action.destination.as_str().to_string()); | |
| 475 | + | }; | |
| 476 | + | let mut payload = extra; | |
| 477 | + | payload.absorb(action.params.clone()); | |
| 478 | + | Step::Call(Request { | |
| 479 | + | method: action.method, | |
| 480 | + | path: path.to_string(), | |
| 481 | + | captures: Params::new(), | |
| 482 | + | payload, | |
| 483 | + | carried: action.carried.clone(), | |
| 484 | + | }) | |
| 485 | + | } | |
| 486 | + | } |
| @@ -1,0 +1,217 @@ | |||
| 1 | + | //! The state a terminal owns because nothing else will. | |
| 2 | + | //! | |
| 3 | + | //! This type is the answer to `39057019`, and the finding is worth restating | |
| 4 | + | //! because the answer only makes sense next to it. `Field::value` is what a | |
| 5 | + | //! handler re-offers after a refused write. It is not what is in the box right | |
| 6 | + | //! now, and for a [`layout::FieldKind::Secret`] it is nothing at all, on | |
| 7 | + | //! purpose: a password that comes back down the wire is a password in a page | |
| 8 | + | //! and in a proxy log. A browser never made anyone notice, because a browser | |
| 9 | + | //! owns the contents of an `<input>` and redraws it on every keystroke without | |
| 10 | + | //! asking the description for permission. | |
| 11 | + | //! | |
| 12 | + | //! A terminal owns nothing. So the drawing of an editable screen is not a | |
| 13 | + | //! function of the description alone, and the two ways to admit that were: | |
| 14 | + | //! hand the renderer a second argument, or have the runtime rewrite the | |
| 15 | + | //! description before drawing it. | |
| 16 | + | //! | |
| 17 | + | //! **The second argument won.** Rewriting keeps [`crate::Tui`] a pure function | |
| 18 | + | //! of one argument by making the runtime lie about what the handler said, and | |
| 19 | + | //! the lie is not free: `Field::value` refuses to hold a secret, so a runtime | |
| 20 | + | //! that wrote the typed password into the description would have had to defeat | |
| 21 | + | //! that refusal to draw the dots. The guarantee that no renderer emits a secret | |
| 22 | + | //! is worth more than the pure signature, and this way the two facts stay | |
| 23 | + | //! separate: the description says what the server offers, and this says what the | |
| 24 | + | //! user has done since. | |
| 25 | + | //! | |
| 26 | + | //! Once it exists it holds the rest of what the browser was quietly providing, | |
| 27 | + | //! because it turns out to be the same discovery four times: what is typed, | |
| 28 | + | //! what has focus, how far a pane is scrolled, and where the back button goes. | |
| 29 | + | //! None of the four is in a description and none of them should be. | |
| 30 | + | ||
| 31 | + | use std::collections::BTreeMap; | |
| 32 | + | ||
| 33 | + | use makeover_layout as layout; | |
| 34 | + | use quasi_router::{Params, Screen}; | |
| 35 | + | ||
| 36 | + | use crate::focus::{FieldSpot, Spot}; | |
| 37 | + | ||
| 38 | + | /// What the user has done to a screen since it arrived. | |
| 39 | + | /// | |
| 40 | + | /// A host makes one beside the screen it is holding and keeps the two together. | |
| 41 | + | /// Empty is the honest starting state and it draws exactly what the description | |
| 42 | + | /// says, which is what every test that predates this passes. | |
| 43 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 44 | + | pub struct View { | |
| 45 | + | /// What has been typed, by [`Field::name`](quasi_router::Field::name). | |
| 46 | + | /// | |
| 47 | + | /// Absent means untouched, which is different from present and empty: one | |
| 48 | + | /// draws the description's value and the other draws a box the user has | |
| 49 | + | /// cleared. | |
| 50 | + | edits: BTreeMap<String, String>, | |
| 51 | + | /// Which reachable thing has focus, as an index into [`crate::focus::spots`]. | |
| 52 | + | focus: usize, | |
| 53 | + | /// How far each region has been scrolled, in rows, by | |
| 54 | + | /// [`Slot::id`](quasi_router::Slot::id). | |
| 55 | + | scroll: BTreeMap<String, u16>, | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | impl View { | |
| 59 | + | /// Nothing typed, the first thing focused, nothing scrolled. | |
| 60 | + | #[must_use] | |
| 61 | + | pub fn new() -> Self { | |
| 62 | + | Self::default() | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// What is in the box: what has been typed, or what the description offers, | |
| 66 | + | /// or nothing. | |
| 67 | + | /// | |
| 68 | + | /// The order is the whole of the type's job. An untouched field shows what | |
| 69 | + | /// the handler put there; a touched one shows what the user did, including | |
| 70 | + | /// when what they did was empty it. | |
| 71 | + | #[must_use] | |
| 72 | + | pub fn typed<'a>(&'a self, field: &'a FieldSpot) -> &'a str { | |
| 73 | + | self.showing(&field.name, field.value.as_deref()) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// [`typed`](Self::typed) for a caller holding the described field itself | |
| 77 | + | /// rather than a walk's record of it, which is what the drawing has. | |
| 78 | + | #[must_use] | |
| 79 | + | pub fn showing<'a>(&'a self, name: &str, described: Option<&'a str>) -> &'a str { | |
| 80 | + | self.edits | |
| 81 | + | .get(name) | |
| 82 | + | .map(String::as_str) | |
| 83 | + | .or(described) | |
| 84 | + | .unwrap_or_default() | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// What has been typed into a field by name, if anything has. | |
| 88 | + | #[must_use] | |
| 89 | + | pub fn edit(&self, name: &str) -> Option<&str> { | |
| 90 | + | self.edits.get(name).map(String::as_str) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Put a value in a box. | |
| 94 | + | pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) { | |
| 95 | + | self.edits.insert(name.into(), value.into()); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Add a character to a box, starting from whatever is showing in it. | |
| 99 | + | pub fn push(&mut self, field: &FieldSpot, ch: char) { | |
| 100 | + | let mut value = self.typed(field).to_string(); | |
| 101 | + | value.push(ch); | |
| 102 | + | self.set(&field.name, value); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Take the last character back out of a box. | |
| 106 | + | pub fn backspace(&mut self, field: &FieldSpot) { | |
| 107 | + | let mut value = self.typed(field).to_string(); | |
| 108 | + | value.pop(); | |
| 109 | + | self.set(&field.name, value); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Which reachable thing has focus. | |
| 113 | + | #[must_use] | |
| 114 | + | pub const fn focus(&self) -> usize { | |
| 115 | + | self.focus | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Move focus by `steps`, wrapping at both ends. | |
| 119 | + | /// | |
| 120 | + | /// Wrapping rather than stopping, because a terminal has no scrollbar to | |
| 121 | + | /// tell you that you are at the end of the reachable things and pressing tab | |
| 122 | + | /// against a dead stop reads as a broken key. | |
| 123 | + | pub fn advance(&mut self, steps: isize, reachable: usize) { | |
| 124 | + | if reachable == 0 { | |
| 125 | + | self.focus = 0; | |
| 126 | + | return; | |
| 127 | + | } | |
| 128 | + | let count = reachable as isize; | |
| 129 | + | let at = self.focus.min(reachable - 1) as isize; | |
| 130 | + | self.focus = (at + steps).rem_euclid(count) as usize; | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// Focus something in particular, if it is there. | |
| 134 | + | pub fn focus_on(&mut self, at: usize, reachable: usize) { | |
| 135 | + | if at < reachable { | |
| 136 | + | self.focus = at; | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// How far a region has been scrolled. | |
| 141 | + | #[must_use] | |
| 142 | + | pub fn scroll(&self, region: &str) -> u16 { | |
| 143 | + | self.scroll.get(region).copied().unwrap_or(0) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// Scroll a region, never above its top. | |
| 147 | + | /// | |
| 148 | + | /// There is no bottom stop here, and that is deliberate: how far a region | |
| 149 | + | /// can scroll is how tall its content is at the width it was given, which | |
| 150 | + | /// is a fact the drawing knows and this does not. [`crate::Tui::clamp`] is | |
| 151 | + | /// where it gets trimmed, once per draw, with the rect in hand. | |
| 152 | + | pub fn scroll_by(&mut self, region: &str, rows: i32) { | |
| 153 | + | let at = i32::from(self.scroll(region)); | |
| 154 | + | let next = u16::try_from((at + rows).max(0)).unwrap_or(u16::MAX); | |
| 155 | + | self.scroll.insert(region.to_string(), next); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// Hold a region at this offset. | |
| 159 | + | pub fn scrolled_to(&mut self, region: &str, rows: u16) { | |
| 160 | + | self.scroll.insert(region.to_string(), rows); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// Forget everything typed and scrolled, and go back to the top. | |
| 164 | + | /// | |
| 165 | + | /// What a whole new screen means. The boxes on it are different boxes, and | |
| 166 | + | /// carrying a buffer across would put what was typed into a password field | |
| 167 | + | /// into whatever field happens to share its name on the next screen. | |
| 168 | + | pub fn reset(&mut self) { | |
| 169 | + | self.edits.clear(); | |
| 170 | + | self.scroll.clear(); | |
| 171 | + | self.focus = 0; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// The values a form submits, gathered for `names` in the order given. | |
| 175 | + | /// | |
| 176 | + | /// A checkbox is here by presence, the way HTML submits one, so a box that | |
| 177 | + | /// is not ticked sends nothing rather than sending an empty string. That is | |
| 178 | + | /// [`Field::value`](quasi_router::Field::value)'s own convention read back | |
| 179 | + | /// out. | |
| 180 | + | #[must_use] | |
| 181 | + | pub fn submission(&self, names: &[String], spots: &[Spot]) -> Params { | |
| 182 | + | let mut params = Params::new(); | |
| 183 | + | for name in names { | |
| 184 | + | let Some(field) = spots | |
| 185 | + | .iter() | |
| 186 | + | .filter_map(Spot::field) | |
| 187 | + | .find(|field| &field.name == name) | |
| 188 | + | else { | |
| 189 | + | continue; | |
| 190 | + | }; | |
| 191 | + | let value = self.typed(field); | |
| 192 | + | if matches!(field.kind, layout::FieldKind::Checkbox) | |
| 193 | + | && value != quasi_router::Node::SELECTED | |
| 194 | + | { | |
| 195 | + | continue; | |
| 196 | + | } | |
| 197 | + | params.insert(name.clone(), value.to_string()); | |
| 198 | + | } | |
| 199 | + | params | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | /// Drop anything held for a field the screen no longer has. | |
| 203 | + | /// | |
| 204 | + | /// A fragment can replace a region holding half a form, and the buffers for | |
| 205 | + | /// the fields that went away would otherwise ride along and be submitted by | |
| 206 | + | /// the next form that happens to name one of them. | |
| 207 | + | pub fn prune(&mut self, screen: &Screen) { | |
| 208 | + | let spots = crate::focus::spots(screen); | |
| 209 | + | let live: Vec<&str> = spots | |
| 210 | + | .iter() | |
| 211 | + | .filter_map(Spot::field) | |
| 212 | + | .map(|field| field.name.as_str()) | |
| 213 | + | .collect(); | |
| 214 | + | self.edits.retain(|name, _| live.contains(&name.as_str())); | |
| 215 | + | self.focus = self.focus.min(spots.len().saturating_sub(1)); | |
| 216 | + | } | |
| 217 | + | } |