Skip to main content

max / makenotwork

25.8 KB · 729 lines History Blame Raw
1 //! What magicmirror knows, and the three tabs derived from it.
2 //!
3 //! The model holds one [`SourceState`] per configured source and nothing about
4 //! what any of them mean. Every row on every tab is computed from the payloads
5 //! alone, which is what keeps the shell from acquiring domain knowledge one
6 //! convenience at a time.
7
8 use chrono::{DateTime, TimeDelta, Utc};
9 use ops_status::{Action, Event, Node, Payload, Status};
10
11 use crate::config::Series;
12 use crate::store::Reading;
13
14 /// One source, as last heard from.
15 pub(crate) struct SourceState {
16 pub name: String,
17 /// Whether this source's declared actions may be fired. Off by default; set
18 /// from the config's per-source `allow_actions`. Kept on the state rather
19 /// than threaded through key handling so the model stays self-contained and
20 /// testable.
21 pub allow_actions: bool,
22 /// The last payload that parsed. Kept across a failed poll so the screen
23 /// shows the last known state alongside the fact that it is now stale,
24 /// rather than going blank.
25 pub payload: Option<Payload>,
26 /// Why the last poll failed, if it did.
27 pub error: Option<String>,
28 /// When a poll last succeeded.
29 pub last_ok: Option<DateTime<Utc>>,
30 /// Age past which this source's answer stops counting as current.
31 pub stale_after: TimeDelta,
32 }
33
34 impl SourceState {
35 pub(crate) fn new(name: impl Into<String>, stale_after: TimeDelta) -> Self {
36 SourceState {
37 name: name.into(),
38 allow_actions: false,
39 payload: None,
40 error: None,
41 last_ok: None,
42 stale_after,
43 }
44 }
45
46 /// Let this source's declared actions be fired. Builder-style so tests and
47 /// `main` set it without a wider constructor.
48 pub(crate) fn with_actions(mut self, allow: bool) -> Self {
49 self.allow_actions = allow;
50 self
51 }
52
53 /// A declared action by key, from the current payload. `None` if the source
54 /// has not answered or no longer offers it — the latter matters because a
55 /// poll between opening a prompt and confirming can retract an action.
56 pub(crate) fn action(&self, key: &str) -> Option<&Action> {
57 self.payload.as_ref().and_then(|p| p.actions.get(key))
58 }
59
60 /// How old the current payload is, if there is one.
61 pub(crate) fn age(&self, now: DateTime<Utc>) -> Option<TimeDelta> {
62 self.payload.as_ref().map(|p| p.age(now))
63 }
64
65 pub(crate) fn is_stale(&self, now: DateTime<Utc>) -> bool {
66 self.age(now).is_some_and(|age| age > self.stale_after)
67 }
68
69 /// This source's line on the live tab.
70 ///
71 /// Three things can be wrong and all three are visible here:
72 ///
73 /// - it cannot be reached at all (`unknown`)
74 /// - it answers, but with something old (`degraded` at minimum, however
75 /// green its contents)
76 /// - it answers freshly and reports trouble (whatever it reports)
77 ///
78 /// The middle case is the one that is normally missed. A backup check that
79 /// answers "ok" about a snapshot taken forty days ago is not ok, and every
80 /// check that existed said it was.
81 pub(crate) fn status(&self, now: DateTime<Utc>) -> Status {
82 let Some(payload) = &self.payload else {
83 return Status::Unknown;
84 };
85 if self.error.is_some() || self.is_stale(now) {
86 return payload.worst_status().max(Status::Degraded);
87 }
88 payload.worst_status()
89 }
90
91 /// A short phrase for why this source reads the way it does.
92 pub(crate) fn summary(&self, now: DateTime<Utc>) -> String {
93 if let Some(error) = &self.error {
94 let last = match self.last_ok {
95 Some(at) => crate::value::relative(at, now),
96 None => "never".into(),
97 };
98 return format!("unreachable ({error}); last ok {last}");
99 }
100 let Some(payload) = &self.payload else {
101 return "waiting for first poll".into();
102 };
103 if self.is_stale(now) {
104 return format!(
105 "stale: last answered {}",
106 crate::value::duration(payload.age(now).num_seconds())
107 );
108 }
109
110 let failing = payload
111 .nodes
112 .iter()
113 .filter(|n| n.status >= Status::Degraded)
114 .count();
115 match (failing, payload.nodes.len()) {
116 (0, 1) => "1 node ok".into(),
117 (0, total) => format!("{total} nodes ok"),
118 (1, _) => "1 node needs attention".into(),
119 (n, _) => format!("{n} nodes need attention"),
120 }
121 }
122
123 /// Nodes in display order: each root followed by its children.
124 ///
125 /// Children are referenced by id rather than nested, so this is where the
126 /// flat list becomes a tree. Depth stops at one: the contract allows deeper
127 /// nesting but nothing emits it, and an unbounded recursion over
128 /// producer-supplied ids is a denial-of-service waiting to happen.
129 pub(crate) fn rows(&self) -> Vec<Row<'_>> {
130 let Some(payload) = &self.payload else {
131 return Vec::new();
132 };
133 let mut rows = Vec::new();
134 for root in payload.roots() {
135 rows.push(Row {
136 node: root,
137 depth: 0,
138 });
139 for child_id in &root.children {
140 if let Some(child) = payload.node(child_id) {
141 rows.push(Row {
142 node: child,
143 depth: 1,
144 });
145 }
146 }
147 }
148 rows
149 }
150
151 /// Record a successful poll.
152 ///
153 /// The cursor is not this type's business any more: with the per-source
154 /// tabs gone there is one cursor, it lives on [`Model`], and it is over a
155 /// list this source is only part of. A poll that shrinks a payload is
156 /// clamped there ([`Model::clamp_selection`]) rather than here.
157 pub(crate) fn observe(&mut self, payload: Payload, at: DateTime<Utc>) {
158 self.payload = Some(payload);
159 self.error = None;
160 self.last_ok = Some(at);
161 }
162
163 /// Record a failed poll, keeping the last known payload.
164 pub(crate) fn observe_error(&mut self, error: impl Into<String>) {
165 self.error = Some(error.into());
166 }
167 }
168
169 /// One of a source's nodes, with how deep in that source's own tree it sits.
170 /// The live tab flattens these under their source ([`Model::live_rows`]).
171 pub(crate) struct Row<'a> {
172 pub node: &'a Node,
173 pub depth: usize,
174 }
175
176 /// Which tab is showing.
177 ///
178 /// Three fixed tabs, not one per source. The per-source tabs this replaces were
179 /// the situation the rollup existed to end -- N screens you still had to visit
180 /// one at a time -- shipping beside the thing that replaced them.
181 ///
182 /// Adding a tab is a line in [`Tab::ALL`], a line in [`Tab::title`] and an arm
183 /// in the renderer. Nothing indexes this by number, so nothing else has to
184 /// learn how many there are.
185 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
186 pub(crate) enum Tab {
187 /// Every source at once, worst first, each source's nodes under it. The tab
188 /// that earns the product.
189 Live,
190 /// What every source has said lately, grouped by which one said it.
191 Logs,
192 /// Series a configured store has recorded. Infra `9d0e7098`.
193 Store,
194 }
195
196 impl Tab {
197 /// Left-to-right order, and the only place that knows how many there are.
198 pub(crate) const ALL: [Tab; 3] = [Tab::Live, Tab::Logs, Tab::Store];
199
200 /// The tab bar, left to right. On [`Tab`] rather than on the model: the
201 /// titles are fixed now, so nothing about them depends on what is
202 /// configured.
203 pub(crate) fn titles() -> Vec<&'static str> {
204 Tab::ALL.iter().map(|tab| tab.title()).collect()
205 }
206
207 pub(crate) fn title(self) -> &'static str {
208 match self {
209 Tab::Live => "live",
210 Tab::Logs => "logs",
211 Tab::Store => "store",
212 }
213 }
214 }
215
216 /// One line in the live tab: a source, or a node belonging to one.
217 ///
218 /// The two are one list rather than two panes because there is one cursor. A
219 /// source line is the rollup line it always was; the node lines under it are
220 /// what the source's own tab used to hold, and are where an action is reachable
221 /// from.
222 pub(crate) enum LiveRow<'a> {
223 Source {
224 index: usize,
225 },
226 Node {
227 /// Which source this node came from, for resolving its actions.
228 index: usize,
229 node: &'a Node,
230 /// 1 for a source's own node, 2 for a child of one. The source line is
231 /// 0, so this is an indent level and not the contract's nesting depth.
232 depth: usize,
233 },
234 }
235
236 impl LiveRow<'_> {
237 /// The source this row belongs to, whichever kind it is.
238 pub(crate) fn source_index(&self) -> usize {
239 match self {
240 LiveRow::Source { index } | LiveRow::Node { index, .. } => *index,
241 }
242 }
243
244 pub(crate) fn node(&self) -> Option<&Node> {
245 match self {
246 LiveRow::Source { .. } => None,
247 LiveRow::Node { node, .. } => Some(node),
248 }
249 }
250 }
251
252 /// One line in the logs tab: an event, and which source said it.
253 pub(crate) struct LogRow<'a> {
254 pub source: &'a str,
255 pub event: &'a Event,
256 }
257
258 /// One configured observation store, as last read.
259 pub(crate) struct StoreState {
260 pub name: String,
261 /// The series this store is configured to show, in the order the operator
262 /// named them. This is where a number's meaning comes from; the store
263 /// itself cannot say.
264 pub series: Vec<Series>,
265 /// The last read that succeeded, kept across a failed one so a producer
266 /// whose file went away still shows what it last recorded.
267 pub readings: Vec<Reading>,
268 /// Why the last read failed, if it did.
269 pub error: Option<String>,
270 pub last_ok: Option<DateTime<Utc>>,
271 }
272
273 impl StoreState {
274 pub(crate) fn new(name: impl Into<String>, series: Vec<Series>) -> Self {
275 StoreState {
276 name: name.into(),
277 series,
278 readings: Vec::new(),
279 error: None,
280 last_ok: None,
281 }
282 }
283
284 pub(crate) fn observe(&mut self, readings: Vec<Reading>, at: DateTime<Utc>) {
285 self.readings = readings;
286 self.error = None;
287 self.last_ok = Some(at);
288 }
289
290 pub(crate) fn observe_error(&mut self, error: impl Into<String>) {
291 self.error = Some(error.into());
292 }
293 }
294
295 /// One line in the store tab.
296 pub(crate) enum StoreRow<'a> {
297 /// The store could not be read. Loud, and above whatever it last said, so
298 /// old numbers are never mistaken for current ones.
299 Unavailable { store: &'a str, reason: &'a str },
300 /// A configured series the store holds no observation of. Shown rather than
301 /// omitted: a soak target that has never reported is exactly the thing you
302 /// want to notice, and silence would hide it.
303 Missing { store: &'a str, spec: &'a Series },
304 /// A configured series, one row per label set it was recorded under.
305 Value {
306 store: &'a str,
307 spec: &'a Series,
308 reading: &'a Reading,
309 },
310 }
311
312 /// A modal step between "I want to run this" and the request going out.
313 ///
314 /// Firing a declared action can move production, so the path to it is explicit
315 /// and never a single keystroke: pick which action, then clear its guard. The
316 /// guard's weight is set by the action itself — a `danger` action is confirmed
317 /// by typing its key, a `confirm` one by a `y`, a plain one not at all.
318 #[derive(Debug, Clone, PartialEq, Eq)]
319 pub(crate) enum Prompt {
320 /// Choosing which of the selected node's actions to run.
321 Pick {
322 source: usize,
323 keys: Vec<String>,
324 selected: usize,
325 },
326 /// A `y`/`n` guard for a `confirm` action that is not `danger`.
327 Confirm { source: usize, key: String },
328 /// The heaviest guard: type the action key to fire a `danger` action. Muscle
329 /// memory cannot type `rollback-b`, which is the point.
330 Type {
331 source: usize,
332 key: String,
333 typed: String,
334 },
335 }
336
337 /// A confirmed request the run loop is to issue. The model records it and stops
338 /// there; resolving the source's URL and token and making the HTTP call is the
339 /// loop's job, which keeps every network effect out of the model.
340 #[derive(Debug, Clone, PartialEq, Eq)]
341 pub(crate) struct FireRequest {
342 pub source: usize,
343 pub key: String,
344 }
345
346 /// What a keypress asked the model to do once the prompt resolved.
347 #[derive(Debug, PartialEq, Eq)]
348 pub(crate) enum PromptStep {
349 /// Still in a prompt (or none was open); nothing to dispatch.
350 Idle,
351 /// The prompt closed with a confirmed request to fire.
352 Fire(FireRequest),
353 /// The prompt closed without firing.
354 Cancelled,
355 }
356
357 pub(crate) struct Model {
358 pub sources: Vec<SourceState>,
359 pub stores: Vec<StoreState>,
360 pub tab: Tab,
361 /// Cursor within the live tab, over [`Model::live_rows`].
362 pub selected: usize,
363 /// How far the logs tab is scrolled, in rows.
364 pub logs_scroll: usize,
365 /// How far the store tab is scrolled, in rows.
366 pub store_scroll: usize,
367 /// Transient message shown in the footer.
368 pub message: Option<String>,
369 /// The open modal, if any.
370 pub prompt: Option<Prompt>,
371 }
372
373 impl Model {
374 pub(crate) fn new(sources: Vec<SourceState>) -> Self {
375 Model {
376 sources,
377 stores: Vec::new(),
378 tab: Tab::Live,
379 selected: 0,
380 logs_scroll: 0,
381 store_scroll: 0,
382 message: None,
383 prompt: None,
384 }
385 }
386
387 /// The configured stores. Separate from [`Model::new`] because a store is
388 /// optional and most configs have none, so the common construction should
389 /// not have to say so.
390 pub(crate) fn with_stores(mut self, stores: Vec<StoreState>) -> Self {
391 self.stores = stores;
392 self
393 }
394
395 // -- Actions -----------------------------------------------------------
396
397 /// Enter on a node in the live tab: open the action picker, or explain why
398 /// not.
399 ///
400 /// This is where the per-source tabs' one irreplaceable job landed. A source
401 /// line does nothing (a source declares no actions; its nodes do), and so
402 /// does a node with none. A source with actions disabled says so rather than
403 /// silently ignoring the key, because a panel that looks like it should be
404 /// able to act and does not is worse than one that says it cannot.
405 pub(crate) fn open_actions(&mut self, now: DateTime<Utc>) {
406 if self.tab != Tab::Live {
407 return;
408 }
409 let rows = self.live_rows(now);
410 let Some(row) = rows.get(self.selected) else {
411 return;
412 };
413 let index = row.source_index();
414 let Some(node) = row.node() else { return };
415 if node.actions.is_empty() {
416 return;
417 }
418 let keys = node.actions.clone();
419 drop(rows);
420 let Some(source) = self.sources.get(index) else {
421 return;
422 };
423 if !source.allow_actions {
424 self.message = Some(format!(
425 "{}: actions are read-only here (set allow_actions to enable)",
426 source.name
427 ));
428 return;
429 }
430 self.prompt = Some(Prompt::Pick {
431 source: index,
432 keys,
433 selected: 0,
434 });
435 }
436
437 /// Move the cursor inside an open picker. No-op for the other prompts.
438 pub(crate) fn prompt_move(&mut self, delta: isize) {
439 if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt {
440 if keys.is_empty() {
441 return;
442 }
443 let next = *selected as isize + delta;
444 *selected = next.clamp(0, keys.len() as isize - 1) as usize;
445 }
446 }
447
448 /// A digit inside a picker jumps straight to that action (1-based).
449 pub(crate) fn prompt_digit(&mut self, n: usize) {
450 if let Some(Prompt::Pick { keys, selected, .. }) = &mut self.prompt
451 && (1..=keys.len()).contains(&n)
452 {
453 *selected = n - 1;
454 }
455 }
456
457 /// A printable character while typing a `danger` action's key.
458 pub(crate) fn prompt_push(&mut self, c: char) {
459 if let Some(Prompt::Type { typed, .. }) = &mut self.prompt {
460 typed.push(c);
461 }
462 }
463
464 /// Backspace while typing.
465 pub(crate) fn prompt_backspace(&mut self) {
466 if let Some(Prompt::Type { typed, .. }) = &mut self.prompt {
467 typed.pop();
468 }
469 }
470
471 /// Enter: advance the picker into a guard, or clear a `Type` guard.
472 ///
473 /// - On a picker, resolves the chosen action and either opens its guard
474 /// (`Type` for danger, `Confirm` for confirm) or fires it outright.
475 /// - On a `Type` guard, fires only when the typed text matches the key.
476 /// - `Confirm` does not respond to Enter; it wants an explicit `y`
477 /// ([`confirm_yes`]), so a stray Enter cannot promote through it.
478 pub(crate) fn prompt_enter(&mut self) -> PromptStep {
479 match self.prompt.take() {
480 Some(Prompt::Pick {
481 source,
482 keys,
483 selected,
484 }) => {
485 let Some(key) = keys.get(selected).cloned() else {
486 return PromptStep::Cancelled;
487 };
488 let Some(action) = self.sources.get(source).and_then(|s| s.action(&key)) else {
489 self.message = Some(format!("{key}: no longer offered"));
490 return PromptStep::Cancelled;
491 };
492 if action.danger {
493 self.prompt = Some(Prompt::Type {
494 source,
495 key,
496 typed: String::new(),
497 });
498 PromptStep::Idle
499 } else if action.confirm {
500 self.prompt = Some(Prompt::Confirm { source, key });
501 PromptStep::Idle
502 } else {
503 self.fire(source, key)
504 }
505 }
506 Some(Prompt::Type { source, key, typed }) => {
507 if typed == key {
508 self.fire(source, key)
509 } else {
510 self.message = Some(format!("type '{key}' exactly to confirm"));
511 self.prompt = Some(Prompt::Type {
512 source,
513 key,
514 typed: String::new(),
515 });
516 PromptStep::Idle
517 }
518 }
519 other => {
520 self.prompt = other;
521 PromptStep::Idle
522 }
523 }
524 }
525
526 /// `y` on a `Confirm` guard fires; anywhere else it is nothing.
527 pub(crate) fn confirm_yes(&mut self) -> PromptStep {
528 if let Some(Prompt::Confirm { source, key }) = self.prompt.take() {
529 self.fire(source, key)
530 } else {
531 PromptStep::Idle
532 }
533 }
534
535 /// Close any open prompt without firing.
536 pub(crate) fn cancel_prompt(&mut self) -> PromptStep {
537 if self.prompt.take().is_some() {
538 PromptStep::Cancelled
539 } else {
540 PromptStep::Idle
541 }
542 }
543
544 /// Record a confirmed request and clear the prompt. Guards checked the
545 /// action still existed, so this only assembles the request; the loop makes
546 /// the call.
547 fn fire(&mut self, source: usize, key: String) -> PromptStep {
548 self.prompt = None;
549 PromptStep::Fire(FireRequest { source, key })
550 }
551
552 /// Source indices ordered worst-first, then by name.
553 ///
554 /// Worst-first is the whole argument for the rollup existing. Sorted any
555 /// other way it is a list you still have to read all of, which is the
556 /// situation it replaces.
557 pub(crate) fn rollup_order(&self, now: DateTime<Utc>) -> Vec<usize> {
558 let mut order: Vec<usize> = (0..self.sources.len()).collect();
559 order.sort_by(|&a, &b| {
560 let (sa, sb) = (self.sources[a].status(now), self.sources[b].status(now));
561 sb.cmp(&sa)
562 .then_with(|| self.sources[a].name.cmp(&self.sources[b].name))
563 });
564 order
565 }
566
567 /// The worst status across every source: the one thing to look at first.
568 pub(crate) fn worst(&self, now: DateTime<Utc>) -> Status {
569 self.sources
570 .iter()
571 .map(|s| s.status(now))
572 .max()
573 .unwrap_or(Status::Unknown)
574 }
575
576 // -- Rows --------------------------------------------------------------
577
578 /// The live tab's lines: each source worst-first, its nodes under it.
579 ///
580 /// One flat list rather than a table over a tree, because the cursor moves
581 /// through it and a cursor that has to know about collapsed subtrees is a
582 /// file browser. Depth is carried per row and the renderer indents by it.
583 pub(crate) fn live_rows(&self, now: DateTime<Utc>) -> Vec<LiveRow<'_>> {
584 let mut rows = Vec::new();
585 for index in self.rollup_order(now) {
586 rows.push(LiveRow::Source { index });
587 for row in self.sources[index].rows() {
588 rows.push(LiveRow::Node {
589 index,
590 node: row.node,
591 depth: row.depth + 1,
592 });
593 }
594 }
595 rows
596 }
597
598 /// The logs tab's lines: every event every source has reported, grouped by
599 /// which source said it and newest first within each.
600 ///
601 /// Sources are in name order, not worst-first. A log whose sections
602 /// rearrange themselves as statuses change is one you cannot read twice, and
603 /// the live tab is where urgency belongs.
604 pub(crate) fn log_rows(&self) -> Vec<LogRow<'_>> {
605 let mut order: Vec<usize> = (0..self.sources.len()).collect();
606 order.sort_by(|&a, &b| self.sources[a].name.cmp(&self.sources[b].name));
607
608 let mut rows = Vec::new();
609 for index in order {
610 let source = &self.sources[index];
611 let Some(payload) = &source.payload else {
612 continue;
613 };
614 let mut events: Vec<&Event> = payload.events.iter().collect();
615 events.sort_by_key(|event| std::cmp::Reverse(event.at));
616 rows.extend(events.into_iter().map(|event| LogRow {
617 source: source.name.as_str(),
618 event,
619 }));
620 }
621 rows
622 }
623
624 /// The store tab's lines: each configured store, then each series it was
625 /// configured to show, in the order the operator named them.
626 ///
627 /// A series the store holds but nobody named is not here. That is the
628 /// ruling, and the reason is that the data cannot say what it means: a
629 /// number rendered without a label and a unit is a table browser with extra
630 /// steps. Silence over noise.
631 pub(crate) fn store_rows(&self) -> Vec<StoreRow<'_>> {
632 let mut rows = Vec::new();
633 for store in &self.stores {
634 if let Some(reason) = &store.error {
635 rows.push(StoreRow::Unavailable {
636 store: &store.name,
637 reason,
638 });
639 }
640 for spec in &store.series {
641 let mut any = false;
642 for reading in store.readings.iter().filter(|r| r.series == spec.name) {
643 any = true;
644 rows.push(StoreRow::Value {
645 store: &store.name,
646 spec,
647 reading,
648 });
649 }
650 if !any {
651 rows.push(StoreRow::Missing {
652 store: &store.name,
653 spec,
654 });
655 }
656 }
657 }
658 rows
659 }
660
661 // -- Tabs and cursor -----------------------------------------------------
662
663 pub(crate) fn tab_index(&self) -> usize {
664 Tab::ALL.iter().position(|t| *t == self.tab).unwrap_or(0)
665 }
666
667 /// Jump to a tab by position. Out of range is ignored rather than clamped:
668 /// a mistyped digit should do nothing, not land somewhere near.
669 pub(crate) fn select_tab(&mut self, index: usize) {
670 if let Some(tab) = Tab::ALL.get(index) {
671 self.tab = *tab;
672 }
673 }
674
675 pub(crate) fn next_tab(&mut self) {
676 self.select_tab((self.tab_index() + 1) % Tab::ALL.len());
677 }
678
679 pub(crate) fn prev_tab(&mut self) {
680 let count = Tab::ALL.len();
681 self.select_tab((self.tab_index() + count - 1) % count);
682 }
683
684 /// Move the cursor in whichever tab is showing.
685 pub(crate) fn move_selection(&mut self, delta: isize, now: DateTime<Utc>) {
686 match self.tab {
687 Tab::Live => {
688 let len = self.live_rows(now).len();
689 self.selected = clamped(self.selected, delta, len);
690 }
691 Tab::Logs => {
692 let len = self.log_rows().len();
693 self.logs_scroll = clamped(self.logs_scroll, delta, len);
694 }
695 Tab::Store => {
696 let len = self.store_rows().len();
697 self.store_scroll = clamped(self.store_scroll, delta, len);
698 }
699 }
700 }
701
702 /// Pull the cursors back in bounds after a poll.
703 ///
704 /// A payload with fewer nodes than the last one shortens the live list under
705 /// the cursor, and a source that dropped its events shortens the log. Called
706 /// once per applied update rather than inside `observe`, because the lists
707 /// span every source and no single one of them can know their length.
708 pub(crate) fn clamp_selection(&mut self, now: DateTime<Utc>) {
709 let live = self.live_rows(now).len();
710 self.selected = self.selected.min(live.saturating_sub(1));
711 let logs = self.log_rows().len();
712 self.logs_scroll = self.logs_scroll.min(logs.saturating_sub(1));
713 let store = self.store_rows().len();
714 self.store_scroll = self.store_scroll.min(store.saturating_sub(1));
715 }
716 }
717
718 /// Move a cursor by `delta` within `len` rows, clamped at both ends.
719 fn clamped(current: usize, delta: isize, len: usize) -> usize {
720 if len == 0 {
721 return 0;
722 }
723 let next = current as isize + delta;
724 next.clamp(0, len as isize - 1) as usize
725 }
726
727 #[cfg(test)]
728 mod tests;
729