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/tui.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/tui.rs')
| -rw-r--r-- | src/tui.rs | 233 |
1 files changed, 233 insertions, 0 deletions
diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..8188985 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,233 @@ + +use std::{ + io::{Stdout, stdout}, + ops::{Deref, DerefMut}, + time::Duration, +}; + +use crossterm::{ + cursor, + event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event as CrosstermEvent, EventStream, KeyEvent, KeyEventKind, MouseEvent, + }, + terminal::{EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures::{FutureExt, StreamExt}; +use ratatui::backend::CrosstermBackend as Backend; +use serde::{Deserialize, Serialize}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, + time::interval, +}; +use tokio_util::sync::CancellationToken; +use tracing::error; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Event { + Init, + Quit, + Error, + Closed, + Tick, + Render, + FocusGained, + FocusLost, + Paste(String), + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct Tui { + pub terminal: ratatui::Terminal<Backend<Stdout>>, + pub task: JoinHandle<()>, + pub cancellation_token: CancellationToken, + pub event_rx: UnboundedReceiver<Event>, + pub event_tx: UnboundedSender<Event>, + pub frame_rate: f64, + pub tick_rate: f64, + pub mouse: bool, + pub paste: bool, +} + +impl Tui { + pub fn new() -> color_eyre::Result<Self> { + let (event_tx, event_rx) = mpsc::unbounded_channel(); + Ok(Self { + terminal: ratatui::Terminal::new(Backend::new(stdout()))?, + task: tokio::spawn(async {}), + cancellation_token: CancellationToken::new(), + event_rx, + event_tx, + frame_rate: 60.0, + tick_rate: 4.0, + mouse: false, + paste: false, + }) + } + + pub fn tick_rate(mut self, tick_rate: f64) -> Self { + self.tick_rate = tick_rate; + self + } + + pub fn frame_rate(mut self, frame_rate: f64) -> Self { + self.frame_rate = frame_rate; + self + } + + pub fn mouse(mut self, mouse: bool) -> Self { + self.mouse = mouse; + self + } + + pub fn paste(mut self, paste: bool) -> Self { + self.paste = paste; + self + } + + pub fn start(&mut self) { + self.cancel(); // Cancel any existing task + self.cancellation_token = CancellationToken::new(); + let event_loop = Self::event_loop( + self.event_tx.clone(), + self.cancellation_token.clone(), + self.tick_rate, + self.frame_rate, + ); + self.task = tokio::spawn(async { + event_loop.await; + }); + } + + async fn event_loop( + event_tx: UnboundedSender<Event>, + cancellation_token: CancellationToken, + tick_rate: f64, + frame_rate: f64, + ) { + let mut event_stream = EventStream::new(); + let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); + let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); + + // if this fails, then it's likely a bug in the calling code + event_tx + .send(Event::Init) + .expect("failed to send init event"); + loop { + let event = tokio::select! { + _ = cancellation_token.cancelled() => { + break; + } + _ = tick_interval.tick() => Event::Tick, + _ = render_interval.tick() => Event::Render, + crossterm_event = event_stream.next().fuse() => match crossterm_event { + Some(Ok(event)) => match event { + CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Event::Key(key), + CrosstermEvent::Mouse(mouse) => Event::Mouse(mouse), + CrosstermEvent::Resize(x, y) => Event::Resize(x, y), + CrosstermEvent::FocusLost => Event::FocusLost, + CrosstermEvent::FocusGained => Event::FocusGained, + CrosstermEvent::Paste(s) => Event::Paste(s), + _ => continue, // ignore other events + } + Some(Err(_)) => Event::Error, + None => break, // the event stream has stopped and will not produce any more events + }, + }; + if event_tx.send(event).is_err() { + // the receiver has been dropped, so there's no point in continuing the loop + break; + } + } + cancellation_token.cancel(); + } + + pub fn stop(&self) -> color_eyre::Result<()> { + self.cancel(); + let mut counter = 0; + while !self.task.is_finished() { + std::thread::sleep(Duration::from_millis(1)); + counter += 1; + if counter > 50 { + self.task.abort(); + } + if counter > 100 { + error!("Failed to abort task in 100 milliseconds for unknown reason"); + break; + } + } + Ok(()) + } + + pub fn enter(&mut self) -> color_eyre::Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(stdout(), EnterAlternateScreen, cursor::Hide)?; + if self.mouse { + crossterm::execute!(stdout(), EnableMouseCapture)?; + } + if self.paste { + crossterm::execute!(stdout(), EnableBracketedPaste)?; + } + self.start(); + Ok(()) + } + + pub fn exit(&mut self) -> color_eyre::Result<()> { + self.stop()?; + if crossterm::terminal::is_raw_mode_enabled()? { + self.flush()?; + if self.paste { + crossterm::execute!(stdout(), DisableBracketedPaste)?; + } + if self.mouse { + crossterm::execute!(stdout(), DisableMouseCapture)?; + } + crossterm::execute!(stdout(), LeaveAlternateScreen, cursor::Show)?; + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) + } + + pub fn cancel(&self) { + self.cancellation_token.cancel(); + } + + pub fn suspend(&mut self) -> color_eyre::Result<()> { + self.exit()?; + #[cfg(not(windows))] + signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; + Ok(()) + } + + pub fn resume(&mut self) -> color_eyre::Result<()> { + self.enter()?; + Ok(()) + } + + pub async fn next_event(&mut self) -> Option<Event> { + self.event_rx.recv().await + } +} + +impl Deref for Tui { + type Target = ratatui::Terminal<Backend<Stdout>>; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Tui { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Tui { + fn drop(&mut self) { + self.exit().unwrap(); + } +} |
