aboutsummaryrefslogtreecommitdiff
path: root/src/tui.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/tui.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/tui.rs')
-rw-r--r--src/tui.rs233
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();
+ }
+}