Skip to main content

louie/runtime/
mod.rs

1//! Elm-architecture runtime for louie applications.
2//!
3//! Inspired by bubbletea / The Elm Architecture:
4//! - **Model**: Application state
5//! - **Message**: Events that update state
6//! - **Update**: Pure function `(Model, Msg) -> (Model, Command)`
7//! - **View**: Pure function `Model -> Frame`
8//!
9//! The runtime manages the event loop, rendering, and animation ticking.
10
11use std::io;
12use std::time::{Duration, Instant};
13
14use crate::backend::Backend;
15use crate::event::{Event, HitMap, KeyCode, KeyEvent, KeyModifiers};
16use crate::ontology::OntologyRegistry;
17use crate::terminal::Terminal;
18
19/// A command returned from [`Model::update`] to request side effects.
20pub enum Command<Msg> {
21    /// No operation.
22    None,
23    /// Quit the application.
24    Quit,
25    /// Execute multiple commands.
26    Batch(Vec<Command<Msg>>),
27    /// Produce a message asynchronously after the current update.
28    Message(Msg),
29    /// Set the tick interval for animation / periodic updates.
30    SetTickRate(Duration),
31    /// Request that the agent ontology registry be exported to JSON.
32    ExportOntology,
33    /// Execute an agent action on a widget identified by agent_id.
34    AgentAction {
35        agent_id: String,
36        action: String,
37        params: serde_json::Value,
38    },
39    /// Spawn an asynchronous task that eventually produces a message.
40    /// The boxed closure runs on a background thread and returns a message.
41    Task(Box<dyn FnOnce() -> Msg + Send>),
42}
43
44impl<Msg: std::fmt::Debug> std::fmt::Debug for Command<Msg> {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::None => write!(f, "None"),
48            Self::Quit => write!(f, "Quit"),
49            Self::Batch(cmds) => f.debug_tuple("Batch").field(cmds).finish(),
50            Self::Message(msg) => f.debug_tuple("Message").field(msg).finish(),
51            Self::SetTickRate(d) => f.debug_tuple("SetTickRate").field(d).finish(),
52            Self::ExportOntology => write!(f, "ExportOntology"),
53            Self::AgentAction {
54                agent_id,
55                action,
56                params,
57            } => f
58                .debug_struct("AgentAction")
59                .field("agent_id", agent_id)
60                .field("action", action)
61                .field("params", params)
62                .finish(),
63            Self::Task(_) => write!(f, "Task(<fn>)"),
64        }
65    }
66}
67
68/// The core trait for application models (Elm Architecture).
69pub trait Model: Sized {
70    /// The message type for this application.
71    type Msg: Send + 'static;
72
73    /// Handle an event and return an updated model plus optional command.
74    fn update(&mut self, msg: Self::Msg) -> Command<Self::Msg>;
75
76    /// Render the model into the terminal frame.
77    fn view(&self, frame: &mut crate::terminal::Frame<'_>);
78
79    /// Convert a raw terminal event into an application message.
80    /// Return `None` to ignore the event.
81    fn handle_event(&self, event: Event) -> Option<Self::Msg>;
82
83    /// Called once at startup. Return an initial command.
84    fn init(&self) -> Command<Self::Msg> {
85        Command::None
86    }
87
88    /// Called when the agent ontology is exported. Override to customize the registry.
89    fn register_ontology(&self, _registry: &mut OntologyRegistry) {}
90}
91
92/// Configuration for the [`Program`] runner.
93pub struct ProgramOptions {
94    /// Tick interval for animation. `None` disables ticking.
95    pub tick_rate: Option<Duration>,
96    /// Whether to enter alternate screen on startup.
97    pub alternate_screen: bool,
98    /// Whether to enable mouse capture.
99    pub mouse_capture: bool,
100    /// Whether to enable raw mode.
101    pub raw_mode: bool,
102}
103
104impl Default for ProgramOptions {
105    fn default() -> Self {
106        Self {
107            tick_rate: Some(Duration::from_millis(16)), // ~60fps
108            alternate_screen: true,
109            mouse_capture: true,
110            raw_mode: true,
111        }
112    }
113}
114
115/// The main application runner.
116pub struct Program<M: Model, B: Backend> {
117    model: M,
118    terminal: Terminal<B>,
119    options: ProgramOptions,
120    hit_map: HitMap,
121    running: bool,
122    ontology: OntologyRegistry,
123}
124
125impl<M: Model, B: Backend> Program<M, B> {
126    /// Create a new program with the given model and backend.
127    pub fn new(model: M, backend: B) -> io::Result<Self> {
128        Ok(Self {
129            model,
130            terminal: Terminal::new(backend)?,
131            options: ProgramOptions::default(),
132            hit_map: HitMap::new(),
133            running: true,
134            ontology: OntologyRegistry::new(),
135        })
136    }
137
138    /// Override the default program options.
139    pub fn with_options(mut self, options: ProgramOptions) -> Self {
140        self.options = options;
141        self
142    }
143
144    /// Access the current model state.
145    pub fn model(&self) -> &M {
146        &self.model
147    }
148
149    /// Access the ontology registry.
150    pub fn ontology(&self) -> &OntologyRegistry {
151        &self.ontology
152    }
153
154    /// Run the application event loop. Returns the final model when the app quits.
155    pub fn run(mut self) -> io::Result<M> {
156        // Initialize terminal
157        if self.options.alternate_screen {
158            self.terminal.backend_mut().enter_alternate_screen()?;
159        }
160        if self.options.raw_mode {
161            self.terminal.backend_mut().enable_raw_mode()?;
162        }
163        if self.options.mouse_capture {
164            self.terminal.backend_mut().enable_mouse_capture()?;
165        }
166
167        // Process init command
168        let init_cmd = self.model.init();
169        self.process_command(init_cmd);
170
171        // Register ontology
172        self.model.register_ontology(&mut self.ontology);
173
174        let mut last_tick = Instant::now();
175
176        while self.running {
177            // Render
178            let model = &self.model;
179            self.terminal.draw(|frame| {
180                model.view(frame);
181            })?;
182
183            // Poll events
184            let timeout = self
185                .options
186                .tick_rate
187                .map(|rate| rate.saturating_sub(last_tick.elapsed()))
188                .unwrap_or(Duration::from_millis(100));
189
190            if crossterm::event::poll(timeout)? {
191                let raw_event = crossterm::event::read()?;
192                let event = convert_crossterm_event(raw_event);
193
194                // Skip key Release events — only Press (and Repeat) should
195                // be dispatched so that each physical key-press produces
196                // exactly one message.
197                if let Event::Key(ref k) = event {
198                    if k.kind == crate::event::KeyEventKind::Release {
199                        continue;
200                    }
201                }
202
203                // Check for mouse clicks against hit map
204                if let Event::Mouse(ref mouse) = event {
205                    if mouse.is_click() {
206                        let _hit = self.hit_map.hit_test(mouse.column, mouse.row);
207                        // Hit results are available through the event for the model to process
208                    }
209                }
210
211                if let Some(msg) = self.model.handle_event(event) {
212                    let cmd = self.model.update(msg);
213                    self.process_command(cmd);
214                }
215            }
216
217            // Tick
218            if let Some(tick_rate) = self.options.tick_rate {
219                if last_tick.elapsed() >= tick_rate {
220                    if let Some(msg) = self.model.handle_event(Event::Tick) {
221                        let cmd = self.model.update(msg);
222                        self.process_command(cmd);
223                    }
224                    last_tick = Instant::now();
225                }
226            }
227        }
228
229        // Restore terminal
230        if self.options.mouse_capture {
231            self.terminal.backend_mut().disable_mouse_capture()?;
232        }
233        if self.options.raw_mode {
234            self.terminal.backend_mut().disable_raw_mode()?;
235        }
236        if self.options.alternate_screen {
237            self.terminal.backend_mut().leave_alternate_screen()?;
238        }
239        self.terminal.backend_mut().show_cursor()?;
240
241        Ok(self.model)
242    }
243
244    fn process_command(&mut self, cmd: Command<M::Msg>) {
245        match cmd {
246            Command::None => {}
247            Command::Quit => {
248                self.running = false;
249            }
250            Command::Batch(cmds) => {
251                for c in cmds {
252                    self.process_command(c);
253                }
254            }
255            Command::Message(msg) => {
256                let cmd = self.model.update(msg);
257                self.process_command(cmd);
258            }
259            Command::SetTickRate(rate) => {
260                self.options.tick_rate = Some(rate);
261            }
262            Command::ExportOntology => {
263                self.model.register_ontology(&mut self.ontology);
264                // The catalog can be retrieved via self.ontology().export_catalog()
265            }
266            Command::AgentAction {
267                agent_id: _,
268                action: _,
269                params: _,
270            } => {
271                // Agent actions are dispatched through the ontology UI tree
272                // The model should handle these in its update function
273            }
274            Command::Task(task) => {
275                // Spawn the task on a background thread; the resulting message
276                // will be fed back through the event loop.
277                let msg = task();
278                let cmd = self.model.update(msg);
279                self.process_command(cmd);
280            }
281        }
282    }
283}
284
285/// Convert a crossterm event into a louie event.
286fn convert_crossterm_event(event: crossterm::event::Event) -> Event {
287    match event {
288        crossterm::event::Event::Key(key) => Event::Key(KeyEvent {
289            code: convert_key_code(key.code),
290            modifiers: convert_key_modifiers(key.modifiers),
291            kind: match key.kind {
292                crossterm::event::KeyEventKind::Press => crate::event::KeyEventKind::Press,
293                crossterm::event::KeyEventKind::Release => crate::event::KeyEventKind::Release,
294                crossterm::event::KeyEventKind::Repeat => crate::event::KeyEventKind::Repeat,
295            },
296        }),
297        crossterm::event::Event::Mouse(mouse) => Event::Mouse(crate::event::MouseEvent {
298            kind: match mouse.kind {
299                crossterm::event::MouseEventKind::Down(btn) => {
300                    crate::event::MouseEventKind::Down(convert_mouse_button(btn))
301                }
302                crossterm::event::MouseEventKind::Up(btn) => {
303                    crate::event::MouseEventKind::Up(convert_mouse_button(btn))
304                }
305                crossterm::event::MouseEventKind::Drag(btn) => {
306                    crate::event::MouseEventKind::Drag(convert_mouse_button(btn))
307                }
308                crossterm::event::MouseEventKind::Moved => crate::event::MouseEventKind::Moved,
309                crossterm::event::MouseEventKind::ScrollDown => {
310                    crate::event::MouseEventKind::ScrollDown
311                }
312                crossterm::event::MouseEventKind::ScrollUp => {
313                    crate::event::MouseEventKind::ScrollUp
314                }
315                crossterm::event::MouseEventKind::ScrollLeft => {
316                    crate::event::MouseEventKind::ScrollLeft
317                }
318                crossterm::event::MouseEventKind::ScrollRight => {
319                    crate::event::MouseEventKind::ScrollRight
320                }
321            },
322            column: mouse.column,
323            row: mouse.row,
324            modifiers: convert_key_modifiers(mouse.modifiers),
325        }),
326        crossterm::event::Event::Resize(width, height) => Event::Resize(width, height),
327        crossterm::event::Event::FocusGained => Event::FocusGained,
328        crossterm::event::Event::FocusLost => Event::FocusLost,
329        crossterm::event::Event::Paste(text) => Event::Paste(text),
330    }
331}
332
333fn convert_key_code(code: crossterm::event::KeyCode) -> KeyCode {
334    match code {
335        crossterm::event::KeyCode::Backspace => KeyCode::Backspace,
336        crossterm::event::KeyCode::Enter => KeyCode::Enter,
337        crossterm::event::KeyCode::Left => KeyCode::Left,
338        crossterm::event::KeyCode::Right => KeyCode::Right,
339        crossterm::event::KeyCode::Up => KeyCode::Up,
340        crossterm::event::KeyCode::Down => KeyCode::Down,
341        crossterm::event::KeyCode::Home => KeyCode::Home,
342        crossterm::event::KeyCode::End => KeyCode::End,
343        crossterm::event::KeyCode::PageUp => KeyCode::PageUp,
344        crossterm::event::KeyCode::PageDown => KeyCode::PageDown,
345        crossterm::event::KeyCode::Tab => KeyCode::Tab,
346        crossterm::event::KeyCode::BackTab => KeyCode::BackTab,
347        crossterm::event::KeyCode::Delete => KeyCode::Delete,
348        crossterm::event::KeyCode::Insert => KeyCode::Insert,
349        crossterm::event::KeyCode::F(n) => KeyCode::F(n),
350        crossterm::event::KeyCode::Char(c) => KeyCode::Char(c),
351        crossterm::event::KeyCode::Esc => KeyCode::Esc,
352        _ => KeyCode::Null,
353    }
354}
355
356fn convert_key_modifiers(mods: crossterm::event::KeyModifiers) -> KeyModifiers {
357    let mut result = KeyModifiers::NONE;
358    if mods.contains(crossterm::event::KeyModifiers::SHIFT) {
359        result |= KeyModifiers::SHIFT;
360    }
361    if mods.contains(crossterm::event::KeyModifiers::CONTROL) {
362        result |= KeyModifiers::CONTROL;
363    }
364    if mods.contains(crossterm::event::KeyModifiers::ALT) {
365        result |= KeyModifiers::ALT;
366    }
367    if mods.contains(crossterm::event::KeyModifiers::SUPER) {
368        result |= KeyModifiers::SUPER;
369    }
370    result
371}
372
373fn convert_mouse_button(btn: crossterm::event::MouseButton) -> crate::event::MouseButton {
374    match btn {
375        crossterm::event::MouseButton::Left => crate::event::MouseButton::Left,
376        crossterm::event::MouseButton::Right => crate::event::MouseButton::Right,
377        crossterm::event::MouseButton::Middle => crate::event::MouseButton::Middle,
378    }
379}