aboutsummaryrefslogtreecommitdiff
path: root/src/app.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/app.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
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.rs176
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(())
+ }
+}