diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:24:55 -0400 |
| commit | 8e16347b0eb329e84892af8ece36886324c95f62 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/app.rs | |
| parent | c6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff) | |
| parent | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff) | |
Establishes the first working baseline: moji <file> opens a file into a
ropey rope and edits it with Helix selection-first modal editing, under a
DO-178C DAL-C requirements and traceability process.
Prior to this, main tracked four files and src/main.rs was still
println!("Hello, world!") — there was no buildable state to build on.
Verified on a fresh clone of the branch with no untracked files:
cargo build; clippy --all-targets -D warnings clean; 294 tests passing;
scripts/check-trace.sh reports 98/98 requirements traced in both
directions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/app.rs')
| -rw-r--r-- | src/app.rs | 176 |
1 files changed, 176 insertions, 0 deletions
diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..063a30a --- /dev/null +++ b/src/app.rs @@ -0,0 +1,176 @@ +use std::path::PathBuf; + +use crossterm::event::KeyEvent; +use ratatui::prelude::Rect; +use tokio::sync::mpsc; +use tracing::{debug, info}; + +use crate::{ + action::Action, + buffer::{command::Command, keymap::Keymap}, + components::{Component, buffer::BufferComponent}, + config::{Config, Mode}, + tui::{Event, Tui}, +}; + +pub struct App { + config: Config, + tick_rate: f64, + frame_rate: f64, + components: Vec<Box<dyn Component>>, + should_quit: bool, + should_suspend: bool, + /// MJB-LLR-203: resolves only the `Global` scope. Every other mode belongs + /// to the buffer — see MJB-DR-004 for why ownership is split. + global_keymap: Keymap, + action_tx: mpsc::UnboundedSender<Action>, + action_rx: mpsc::UnboundedReceiver<Action>, +} + +impl App { + pub fn new(tick_rate: f64, frame_rate: f64, file: Option<PathBuf>) -> color_eyre::Result<Self> { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let config = Config::new()?; + let global_keymap = Keymap::new(&config.keybindings); + + // MJB-LLR-204: the buffer is the only component. The template's + // FpsCounter and Home widgets are gone, not merely unregistered. + let buffer = BufferComponent::new(config.clone(), file)?; + + Ok(Self { + tick_rate, + frame_rate, + components: vec![Box::new(buffer)], + should_quit: false, + should_suspend: false, + config, + global_keymap, + action_tx, + action_rx, + }) + } + + pub async fn run(&mut self) -> color_eyre::Result<()> { + let mut tui = Tui::new()? + .tick_rate(self.tick_rate) + .frame_rate(self.frame_rate); + tui.enter()?; + + for component in self.components.iter_mut() { + component.register_action_handler(self.action_tx.clone())?; + } + for component in self.components.iter_mut() { + component.register_config_handler(self.config.clone())?; + } + for component in self.components.iter_mut() { + component.init(tui.size()?)?; + } + + let action_tx = self.action_tx.clone(); + loop { + self.handle_events(&mut tui).await?; + self.handle_actions(&mut tui)?; + if self.should_suspend { + tui.suspend()?; + action_tx.send(Action::Resume)?; + action_tx.send(Action::ClearScreen)?; + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } + + async fn handle_events(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + let Some(event) = tui.next_event().await else { + return Ok(()); + }; + let action_tx = self.action_tx.clone(); + + match event { + Event::Quit => action_tx.send(Action::Quit)?, + Event::Tick => action_tx.send(Action::Tick)?, + Event::Render => action_tx.send(Action::Render)?, + Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + // MJB-LLR-203: a global binding consumes the key outright. The + // template forwarded every key both here and to every component, + // which would fire `Quit` while typing in insert mode. + Event::Key(key) if self.handle_global_key(key)? => return Ok(()), + _ => {} + } + + for component in self.components.iter_mut() { + if let Some(action) = component.handle_events(Some(event.clone()))? { + action_tx.send(action)?; + } + } + Ok(()) + } + + /// Returns whether the key was consumed by the global keymap. + fn handle_global_key(&mut self, key: KeyEvent) -> color_eyre::Result<bool> { + let Some(command) = self.global_keymap.lookup_single(Mode::Global, key) else { + return Ok(false); + }; + + let action = match command { + Command::Quit => Action::Quit, + Command::Suspend => Action::Suspend, + // Anything else bound globally is not an application concern; let + // the buffer handle it in its own mode. + _ => return Ok(false), + }; + + info!("Global binding matched: {command}"); + self.action_tx.send(action)?; + Ok(true) + } + + fn handle_actions(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + while let Ok(action) = self.action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + debug!("{action:?}"); + } + match action { + // MJB-LLR-205: `Tick` no longer drains a pending-key buffer. + // Chord resolution is time-independent (MJB-LLR-154). + Action::Quit => self.should_quit = true, + Action::Suspend => self.should_suspend = true, + Action::Resume => self.should_suspend = false, + Action::ClearScreen => tui.terminal.clear()?, + Action::Resize(w, h) => self.handle_resize(tui, w, h)?, + Action::Render => self.render(tui)?, + Action::Error(ref err) => tracing::error!(?err), + _ => {} + } + for component in self.components.iter_mut() { + if let Some(action) = component.update(action.clone())? { + self.action_tx.send(action)? + }; + } + } + Ok(()) + } + + fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> color_eyre::Result<()> { + tui.resize(Rect::new(0, 0, w, h))?; + self.render(tui)?; + Ok(()) + } + + fn render(&mut self, tui: &mut Tui) -> color_eyre::Result<()> { + tui.draw(|frame| { + for component in self.components.iter_mut() { + if let Err(err) = component.draw(frame, frame.area()) { + let _ = self + .action_tx + .send(Action::Error(format!("Failed to draw: {:?}", err))); + } + } + })?; + Ok(()) + } +} |
