Skip to main content

rpi_tui/
bordered_loader.rs

1//! Loader wrapped in [`DynamicBorder`]s — port of `bordered-loader.ts`.
2//!
3//! A display-only wrapper: the caller owns the [`Loader`] (or
4//! [`CancellableLoader`]) and drives its lifecycle; this component merely
5//! renders borders around it and an optional cancel hint line. There is no
6//! async/signal plumbing (the TS `AbortController`/`signal` has no Rust
7//! equivalent in this codebase — cancellation flows through the key loop).
8
9use std::any::Any;
10use std::sync::Arc;
11
12use super::component::Component;
13use crate::dynamic_border::DynamicBorder;
14use crate::keybinding_hints::key_hint;
15use crate::loader::{CancellableLoader, Loader};
16use crate::spacer::Spacer;
17use crate::theme::{theme, Color};
18
19/// Which loader kind the [`BorderedLoader`] wraps.
20pub enum BorderedLoaderInner {
21    /// A non-cancellable loader.
22    Plain(Arc<Loader>),
23    /// A cancellable loader.
24    Cancellable(Arc<CancellableLoader>),
25}
26
27/// A loader framed by top and bottom [`DynamicBorder`]s, with an optional
28/// `cancel` hint line (when the inner loader is cancellable and running).
29pub struct BorderedLoader {
30    inner: BorderedLoaderInner,
31    border_color: Option<Color>,
32}
33
34impl BorderedLoader {
35    /// Wrap a plain (non-cancellable) [`Loader`].
36    pub fn plain(loader: Arc<Loader>) -> Self {
37        Self {
38            inner: BorderedLoaderInner::Plain(loader),
39            border_color: None,
40        }
41    }
42
43    /// Wrap a [`CancellableLoader`].
44    pub fn cancellable(loader: Arc<CancellableLoader>) -> Self {
45        Self {
46            inner: BorderedLoaderInner::Cancellable(loader),
47            border_color: None,
48        }
49    }
50
51    /// Override the border color (default: theme border).
52    pub fn with_border_color(mut self, color: Color) -> Self {
53        self.border_color = Some(color);
54        self
55    }
56
57    fn border(&self) -> DynamicBorder {
58        match self.border_color {
59            Some(c) => DynamicBorder::with_color(c),
60            None => DynamicBorder::new(),
61        }
62    }
63
64    fn is_cancellable_running(&self) -> bool {
65        match &self.inner {
66            BorderedLoaderInner::Cancellable(l) => l.is_running(),
67            _ => false,
68        }
69    }
70}
71
72impl Component for BorderedLoader {
73    fn render(&self, width: usize) -> Vec<String> {
74        let _ = theme(); // ensure global theme is initialized for hint colors
75        let mut lines = Vec::new();
76
77        // Top border
78        lines.extend(self.border().render(width));
79
80        // Loader body
81        let body = match &self.inner {
82            BorderedLoaderInner::Plain(l) => l.render(width),
83            BorderedLoaderInner::Cancellable(l) => l.render(width),
84        };
85        lines.extend(body);
86
87        // Cancel hint (cancellable + running only) — mirrors the TS layout:
88        // Spacer + Text(keyHint("tui.select.cancel","cancel"))
89        if self.is_cancellable_running() {
90            lines.extend(Spacer::new(1).render(width));
91            lines.push(key_hint("tui.select.cancel", "cancel"));
92        }
93
94        // Bottom border
95        lines.extend(Spacer::new(1).render(width));
96        lines.extend(self.border().render(width));
97
98        lines
99    }
100
101    fn invalidate(&self) {
102        match &self.inner {
103            BorderedLoaderInner::Plain(l) => l.invalidate(),
104            BorderedLoaderInner::Cancellable(l) => l.invalidate(),
105        }
106    }
107
108    fn as_any(&self) -> &dyn Any {
109        self
110    }
111}
112
113// (no internal helper module needed — borders are inline DynamicBorders.)
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_plain_bordered_loader_renders_borders() {
121        let loader = Arc::new(Loader::with_text("Working"));
122        loader.start();
123        let bl = BorderedLoader::plain(loader);
124        let lines = bl.render(40);
125        // top border, loader line, spacer, bottom border
126        assert!(lines.len() >= 3);
127        assert!(lines.first().unwrap().contains('─'));
128        assert!(lines.last().unwrap().contains('─'));
129    }
130
131    #[test]
132    fn test_cancellable_adds_hint_when_running() {
133        let loader = Arc::new(CancellableLoader::with_text("Working"));
134        loader.start();
135        let bl = BorderedLoader::cancellable(loader);
136        let lines = bl.render(40);
137        let joined = lines.join("\n");
138        assert!(joined.contains("cancel"));
139    }
140}