Skip to main content

bobatea/
lib.rs

1use {
2    crate::{
3        animator::Animator,
4        events::{Cancellable, EventTarget, SubscriptionHandle, SubscriptionPriority},
5        theme::Theme,
6    },
7    crossterm::{
8        ExecutableCommand,
9        event::{Event as CrosstermEvent, EventStream as CrosstermEventStream, KeyEvent, MouseEvent},
10        terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
11    },
12    futures::StreamExt,
13    futures_signals::signal::Mutable,
14    ratatui::{DefaultTerminal, Frame, backend::CrosstermBackend},
15    std::{io::stdout, ops::Deref, sync::Arc},
16};
17
18pub mod animator;
19pub mod components;
20pub mod events;
21pub mod surface;
22pub mod theme;
23
24// Re-export the most commonly used layout types for ergonomic access.
25pub use surface::{
26    Cell, Position, Surface, clip, fit_height, fit_width, join_horizontal, join_vertical, place, place_filled, total_width,
27};
28
29/// Root trait for a full-screen view.
30///
31/// Implement this trait on your struct, then pass it to [`App::new`].
32/// `mount` is called once before the event loop; `render` is called every frame.
33///
34/// # Example
35///
36/// ```
37/// use bobatea::{App, View, theme::Theme};
38/// use ratatui::Frame;
39///
40/// struct MyView;
41/// impl View for MyView {
42///     fn render(&self, ctx: &mut Frame<'_>, theme: &Theme) {
43///         ctx.buffer_mut().reset();
44///     }
45/// }
46/// ```
47pub trait View {
48    /// Title shown in the terminal window title bar (via OSC).
49    fn title(&self) -> &'static str { env!("CARGO_CRATE_NAME") }
50
51    /// Called once before the event loop starts.
52    ///
53    /// Subscribe to app-level events here via `app.on(...)`.
54    fn mount(&self, _app: &EventTarget<AppEvent>) {}
55
56    /// Called every time the view needs to be redrawn.
57    fn render(&self, ctx: &mut Frame<'_>, theme: &Theme);
58
59    /// Called for every mouse event that does not hit a focused component.
60    ///
61    /// Default implementation is a no-op.
62    fn on_mouse(&self, _ev: &MouseEvent) {}
63}
64
65/// Application lifecycle events emitted by the boba runtime.
66#[derive(Debug, Clone, Copy)]
67pub enum AppEvent {
68    /// Request an immediate re-render (no other data changed).
69    Quit,
70
71    /// Internal: tick the animation clock and re-render if anything is alive.
72    RequestAnimationFrame,
73
74    /// Change the active theme palette by index (0 = default, 1 = light, …).
75    SetTheme(usize),
76
77    /// Terminal was resized — new width and height in columns × rows.
78    Resize(u16, u16),
79
80    /// A key was pressed.
81    KeyEvent(crossterm::event::KeyEvent),
82
83    /// A mouse event occurred.
84    MouseEvent(crossterm::event::MouseEvent),
85}
86
87/// RAII guard that restores terminal state on drop.
88struct TerminalGuard;
89
90impl Drop for TerminalGuard {
91    fn drop(&mut self) {
92        let _ = stdout().execute(crossterm::event::DisableMouseCapture);
93        let _ = stdout().execute(LeaveAlternateScreen);
94        let _ = disable_raw_mode();
95    }
96}
97
98/// The boba application runner.
99///
100/// Create an `App` with your [`View`] implementation, configure its theme,
101/// then `run` it asynchronously. The terminal is managed automatically:
102/// raw mode is enabled, an alternate screen is used, and state is restored on exit.
103pub struct App {
104    inner: Arc<dyn View>,
105    ev: EventTarget<AppEvent>,
106    /// The currently active [`Theme`]. Change it with [`App::set_theme`].
107    pub theme: Mutable<Arc<Theme>>,
108    pub animator: std::sync::Mutex<Animator>,
109}
110
111impl App {
112    /// Construct an app wrapping `view`.
113    pub fn new(v: impl View + 'static) -> Self {
114        Self {
115            inner: Arc::new(v),
116            ev: EventTarget::new("app"),
117            theme: Mutable::new(Arc::new(Theme::default())),
118            animator: std::sync::Mutex::new(Animator::new()),
119        }
120    }
121
122    /// Replace the active theme at runtime.
123    pub fn set_theme(&self, theme: Theme) { self.theme.set(Arc::new(theme)); }
124
125    /// Run the app until a [`AppEvent::Quit`] is emitted.
126    ///
127    /// Initializes raw mode, the alternate screen, and mouse capture;
128    /// restores everything on exit.
129    pub async fn run(self) -> anyhow::Result<()> {
130        enable_raw_mode()?;
131        stdout().execute(EnterAlternateScreen)?;
132        stdout().execute(crossterm::event::EnableMouseCapture)?;
133        let _guard = TerminalGuard;
134
135        let mut dt = DefaultTerminal::new(CrosstermBackend::new(stdout()))?;
136
137        self.inner.mount(&self.ev);
138
139        // Initial draw
140        let theme = self.theme.get_cloned();
141        dt.draw(|f| {
142            self.inner.render(f, &theme);
143        })?;
144
145        let mut tui = self.ev.as_stream(SubscriptionPriority::Low).fuse();
146        let mut io = CrosstermEventStream::new().fuse();
147        let mut anim_timer = tokio::time::interval(std::time::Duration::from_millis(50));
148
149        loop {
150            tokio::select! {
151                _ = anim_timer.tick() => {
152                    let alive = {
153                        let mut animator = self.animator.lock().unwrap();
154                        animator.tick()
155                    };
156                    if alive {
157                        let theme = self.theme.get_cloned();
158                        dt.draw(|f| {
159                            self.inner.render(f, &theme);
160                        })?;
161                    }
162                }
163
164                ev = tui.next() => {
165                    if let Some(ev) = ev {
166                        match *ev {
167                            AppEvent::RequestAnimationFrame => {
168                                let theme = self.theme.get_cloned();
169                                dt.draw(|f| { self.inner.render(f, &theme); })?;
170                            }
171                            AppEvent::SetTheme(idx) => {
172                                let preset = match idx {
173                                    0 => Theme::default(),
174                                    1 => Theme::light(),
175                                    2 => Theme::ocean(),
176                                    3 => Theme::solarized(),
177                                    4 => Theme::high_contrast(),
178                                    _ => Theme::default(),
179                                };
180                                self.theme.set(Arc::new(preset));
181                                let theme = self.theme.get_cloned();
182                                dt.draw(|f| { self.inner.render(f, &theme); })?;
183                            }
184                            AppEvent::Quit => break,
185                            _ => {}
186                        }
187                    }
188                }
189
190                ev = io.next() => {
191                    match ev {
192                        Some(Ok(CrosstermEvent::Key(key))) => {
193                            self.ev.emit(AppEvent::KeyEvent(key));
194                            let theme = self.theme.get_cloned();
195                            dt.draw(|f| { self.inner.render(f, &theme); })?;
196                        }
197                        Some(Ok(CrosstermEvent::Mouse(mouse))) => {
198                            self.inner.on_mouse(&mouse);
199                            self.ev.emit(AppEvent::MouseEvent(mouse));
200                            let theme = self.theme.get_cloned();
201                            dt.draw(|f| { self.inner.render(f, &theme); })?;
202                        }
203                        Some(Ok(CrosstermEvent::Resize(w, h))) => {
204                            self.ev.emit(AppEvent::Resize(w, h));
205                            let theme = self.theme.get_cloned();
206                            dt.draw(|f| { self.inner.render(f, &theme); })?;
207                        }
208                        Some(Ok(_)) => {
209                            let theme = self.theme.get_cloned();
210                            dt.draw(|f| { self.inner.render(f, &theme); })?;
211                        }
212                        Some(Err(e)) => return Err(e.into()),
213                        None => break,
214                    }
215                }
216            }
217        }
218
219        Ok(())
220    }
221}
222
223impl Deref for App {
224    type Target = EventTarget<AppEvent>;
225
226    fn deref(&self) -> &Self::Target { &self.ev }
227}
228
229/// Convenience helpers for event targets that dispatch [`AppEvent`].
230impl EventTarget<AppEvent> {
231    /// Subscribe only to `AppEvent::KeyEvent` payloads.
232    ///
233    /// The handler receives the original event (so it can call [`Cancellable::cancel`])
234    /// plus the extracted [`KeyEvent`].
235    pub fn on_key(
236        &self,
237        priority: SubscriptionPriority,
238        handler: impl Fn(Arc<Cancellable<AppEvent>>, KeyEvent) + Send + Sync + 'static,
239    ) -> SubscriptionHandle<AppEvent> {
240        self.on(priority, move |ev| {
241            if let AppEvent::KeyEvent(key) = **ev {
242                handler(ev, key);
243            }
244        })
245    }
246}