Skip to main content

bee_tui/
app.rs

1use std::sync::Arc;
2
3use color_eyre::eyre::eyre;
4use crossterm::event::KeyEvent;
5use ratatui::prelude::Rect;
6use serde::{Deserialize, Serialize};
7use tokio::sync::mpsc;
8use tokio_util::sync::CancellationToken;
9use tracing::{debug, info};
10
11use crate::{
12    action::Action,
13    api::ApiClient,
14    components::{Component, command_log::CommandLog, health::Health, stamps::Stamps},
15    config::Config,
16    log_capture,
17    tui::{Event, Tui},
18    watch::BeeWatch,
19};
20
21pub struct App {
22    config: Config,
23    tick_rate: f64,
24    frame_rate: f64,
25    /// Top-level screens, in display order. Tab cycles among them
26    /// (v0.4 will replace this with a k9s-style `:command` resource
27    /// switcher).
28    screens: Vec<Box<dyn Component>>,
29    /// Index into [`Self::screens`] for the currently visible screen.
30    current_screen: usize,
31    /// Always-on bottom strip; not part of `screens` because it
32    /// renders alongside whatever screen is active.
33    command_log: Box<dyn Component>,
34    should_quit: bool,
35    should_suspend: bool,
36    mode: Mode,
37    last_tick_key_events: Vec<KeyEvent>,
38    action_tx: mpsc::UnboundedSender<Action>,
39    action_rx: mpsc::UnboundedReceiver<Action>,
40    /// Root cancellation token. Children: BeeWatch hub → per-resource
41    /// pollers. Cancelling this on quit unwinds every spawned task.
42    root_cancel: CancellationToken,
43    /// Active Bee node connection; cheap to clone (`Arc<Inner>` under
44    /// the hood). Read by future header bar + multi-node switcher.
45    #[allow(dead_code)]
46    api: Arc<ApiClient>,
47    /// Watch / informer hub feeding screens.
48    watch: BeeWatch,
49}
50
51/// Names the top-level screens. Index matches position in
52/// [`App::screens`].
53const SCREEN_NAMES: &[&str] = &["Health", "Stamps"];
54
55#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub enum Mode {
57    #[default]
58    Home,
59}
60
61impl App {
62    pub fn new(tick_rate: f64, frame_rate: f64) -> color_eyre::Result<Self> {
63        let (action_tx, action_rx) = mpsc::unbounded_channel();
64        let config = Config::new()?;
65
66        // Pick the active node profile and build an ApiClient for it.
67        let node = config
68            .active_node()
69            .ok_or_else(|| eyre!("no Bee node configured (config.nodes is empty)"))?;
70        let api = Arc::new(ApiClient::from_node(node)?);
71
72        // Spawn the watch / informer hub. Pollers attach to children
73        // of `root_cancel`, so quitting cancels everything in one go.
74        let root_cancel = CancellationToken::new();
75        let watch = BeeWatch::start(api.clone(), &root_cancel);
76
77        // S1 Health is the default screen for v0.1.
78        let health = Health::new(api.clone(), watch.health());
79        // S2 Stamps is the second screen — Tab to switch.
80        let stamps = Stamps::new(watch.stamps());
81        // S10 Command-log subscribes to the bee::http capture set up
82        // by logging::init. If logging hasn't initialised the capture
83        // (e.g. running in a test harness), the pane just shows
84        // "waiting for first request…".
85        let command_log: Box<dyn Component> = Box::new(CommandLog::new(log_capture::handle()));
86
87        Ok(Self {
88            tick_rate,
89            frame_rate,
90            // Order matters — the SCREEN_NAMES table assumes index 0
91            // is Health, index 1 is Stamps.
92            screens: vec![Box::new(health), Box::new(stamps)],
93            current_screen: 0,
94            command_log,
95            should_quit: false,
96            should_suspend: false,
97            config,
98            mode: Mode::Home,
99            last_tick_key_events: Vec::new(),
100            action_tx,
101            action_rx,
102            root_cancel,
103            api,
104            watch,
105        })
106    }
107
108    pub async fn run(&mut self) -> color_eyre::Result<()> {
109        let mut tui = Tui::new()?
110            // .mouse(true) // uncomment this line to enable mouse support
111            .tick_rate(self.tick_rate)
112            .frame_rate(self.frame_rate);
113        tui.enter()?;
114
115        let tx = self.action_tx.clone();
116        let cfg = self.config.clone();
117        let size = tui.size()?;
118        for component in self.iter_components_mut() {
119            component.register_action_handler(tx.clone())?;
120            component.register_config_handler(cfg.clone())?;
121            component.init(size)?;
122        }
123
124        let action_tx = self.action_tx.clone();
125        loop {
126            self.handle_events(&mut tui).await?;
127            self.handle_actions(&mut tui)?;
128            if self.should_suspend {
129                tui.suspend()?;
130                action_tx.send(Action::Resume)?;
131                action_tx.send(Action::ClearScreen)?;
132                // tui.mouse(true);
133                tui.enter()?;
134            } else if self.should_quit {
135                tui.stop()?;
136                break;
137            }
138        }
139        // Unwind every spawned task before tearing down the terminal.
140        self.watch.shutdown();
141        self.root_cancel.cancel();
142        tui.exit()?;
143        Ok(())
144    }
145
146    async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
147        let Some(event) = tui.next_event().await else {
148            return Ok(());
149        };
150        let action_tx = self.action_tx.clone();
151        match event {
152            Event::Quit => action_tx.send(Action::Quit)?,
153            Event::Tick => action_tx.send(Action::Tick)?,
154            Event::Render => action_tx.send(Action::Render)?,
155            Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
156            Event::Key(key) => self.handle_key_event(key)?,
157            _ => {}
158        }
159        for component in self.iter_components_mut() {
160            if let Some(action) = component.handle_events(Some(event.clone()))? {
161                action_tx.send(action)?;
162            }
163        }
164        Ok(())
165    }
166
167    /// Iterate every component (screens + command-log strip) for
168    /// uniform lifecycle ticks. Doesn't conflict with rendering,
169    /// which only draws the active screen.
170    fn iter_components_mut(&mut self) -> impl Iterator<Item = &mut Box<dyn Component>> {
171        self.screens
172            .iter_mut()
173            .chain(std::iter::once(&mut self.command_log))
174    }
175
176    fn handle_key_event(&mut self, key: KeyEvent) -> color_eyre::Result<()> {
177        let action_tx = self.action_tx.clone();
178        // Hard-coded screen-switch hotkey for v0.1; v0.2 routes this
179        // through the regular keybinding table once the `:command`
180        // bar lands.
181        if matches!(key.code, crossterm::event::KeyCode::Tab) {
182            if !self.screens.is_empty() {
183                self.current_screen = (self.current_screen + 1) % self.screens.len();
184                debug!(
185                    "switched to screen {}",
186                    SCREEN_NAMES.get(self.current_screen).unwrap_or(&"?")
187                );
188            }
189            return Ok(());
190        }
191        let Some(keymap) = self.config.keybindings.0.get(&self.mode) else {
192            return Ok(());
193        };
194        match keymap.get(&vec![key]) {
195            Some(action) => {
196                info!("Got action: {action:?}");
197                action_tx.send(action.clone())?;
198            }
199            _ => {
200                // If the key was not handled as a single key action,
201                // then consider it for multi-key combinations.
202                self.last_tick_key_events.push(key);
203
204                // Check for multi-key combinations
205                if let Some(action) = keymap.get(&self.last_tick_key_events) {
206                    info!("Got action: {action:?}");
207                    action_tx.send(action.clone())?;
208                }
209            }
210        }
211        Ok(())
212    }
213
214    fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
215        while let Ok(action) = self.action_rx.try_recv() {
216            if action != Action::Tick && action != Action::Render {
217                debug!("{action:?}");
218            }
219            match action {
220                Action::Tick => {
221                    self.last_tick_key_events.drain(..);
222                }
223                Action::Quit => self.should_quit = true,
224                Action::Suspend => self.should_suspend = true,
225                Action::Resume => self.should_suspend = false,
226                Action::ClearScreen => tui.terminal.clear()?,
227                Action::Resize(w, h) => self.handle_resize(tui, w, h)?,
228                Action::Render => self.render(tui)?,
229                _ => {}
230            }
231            let tx = self.action_tx.clone();
232            for component in self.iter_components_mut() {
233                if let Some(action) = component.update(action.clone())? {
234                    tx.send(action)?
235                };
236            }
237        }
238        Ok(())
239    }
240
241    fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> {
242        tui.resize(Rect::new(0, 0, w, h))?;
243        self.render(tui)?;
244        Ok(())
245    }
246
247    fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> {
248        let active = self.current_screen;
249        let tx = self.action_tx.clone();
250        let screens = &mut self.screens;
251        let command_log = &mut self.command_log;
252        tui.draw(|frame| {
253            use ratatui::layout::{Constraint, Layout};
254            use ratatui::style::{Color, Modifier, Style};
255            use ratatui::text::{Line, Span};
256            use ratatui::widgets::Paragraph;
257
258            let chunks = Layout::vertical([
259                Constraint::Length(1), // top-bar tabs
260                Constraint::Min(0),    // active screen
261                Constraint::Length(8), // command-log strip
262            ])
263            .split(frame.area());
264
265            // Top-bar: tab strip with the active screen highlighted.
266            let mut tabs = Vec::with_capacity(SCREEN_NAMES.len() * 2);
267            for (i, name) in SCREEN_NAMES.iter().enumerate() {
268                let style = if i == active {
269                    Style::default()
270                        .fg(Color::Black)
271                        .bg(Color::Yellow)
272                        .add_modifier(Modifier::BOLD)
273                } else {
274                    Style::default().fg(Color::DarkGray)
275                };
276                tabs.push(Span::styled(format!(" {name} "), style));
277                tabs.push(Span::raw(" "));
278            }
279            tabs.push(Span::styled(
280                "Tab to switch",
281                Style::default().fg(Color::DarkGray),
282            ));
283            frame.render_widget(Paragraph::new(Line::from(tabs)), chunks[0]);
284
285            // Active screen
286            if let Some(screen) = screens.get_mut(active) {
287                if let Err(err) = screen.draw(frame, chunks[1]) {
288                    let _ = tx.send(Action::Error(format!("Failed to draw screen: {err:?}")));
289                }
290            }
291            // Command-log strip
292            if let Err(err) = command_log.draw(frame, chunks[2]) {
293                let _ = tx.send(Action::Error(format!("Failed to draw log: {err:?}")));
294            }
295        })?;
296        Ok(())
297    }
298}