From 65a193f6e72cf9f517b3b0ea3d8e85c40483cd72 Mon Sep 17 00:00:00 2001 From: Joe Ardent Date: Tue, 5 Aug 2025 18:57:59 -0700 Subject: [PATCH] add text input component --- Cargo.lock | 11 +++++++++++ Cargo.toml | 1 + src/app/mod.rs | 34 ++++++++++++++++++++++++++++------ src/app/widgets.rs | 41 ++++++++++++++++++++++++++++++----------- src/main.rs | 4 +--- 5 files changed, 71 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0dcae59..6e8fcb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1175,6 +1175,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "tower-http", + "tui-input", "tui-logger", ] @@ -2377,6 +2378,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-input" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "tui-logger" version = "0.17.3" diff --git a/Cargo.toml b/Cargo.toml index a32ad3a..b4d2c00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,4 +26,5 @@ sha256 = "1.6" thiserror = "2" tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] } tower-http = { version = "0.6", features = ["limit"] } +tui-input = "0.14.0" tui-logger = { version = "0.17", features = ["crossterm"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index 0c61e87..302dcf2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,6 +11,7 @@ use ratatui::{ }; use ratatui_explorer::FileExplorer; use tokio::sync::mpsc::UnboundedReceiver; +use tui_input::{Input, backend::crossterm::EventHandler}; pub mod widgets; @@ -23,18 +24,19 @@ pub struct Peer { pub struct App { pub service: JoecalService, - pub screen: Vec, pub events: EventStream, // addr -> (alias, fingerprint) pub peers: Vec, pub peer_state: ListState, pub receive_requests: BTreeMap, + screen: Vec, receiving_state: TableState, // for getting messages back from the web server or web client about things we've done; the // other end is held by the service event_listener: UnboundedReceiver, file_picker: FileExplorer, text: Option, + input: Input, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -66,6 +68,7 @@ impl App { peer_state: Default::default(), receive_requests: Default::default(), receiving_state: Default::default(), + input: Default::default(), } } @@ -102,6 +105,13 @@ impl App { Ok(()) } + pub fn screen(&self) -> CurrentScreen { + *self.screen.last().unwrap() + } + pub fn screen_mut(&mut self) -> &mut CurrentScreen { + self.screen.last_mut().unwrap() + } + async fn handle_key_event(&mut self, key_event: KeyEvent, event: crossterm::event::Event) { let code = key_event.code; let mode = self.screen.last_mut().unwrap(); @@ -143,11 +153,13 @@ impl App { KeyCode::Down => self.peer_state.select_next(), _ => {} }, - _ => unreachable!(), + SendingScreen::Text => unreachable!(), }, - _ => {} + CurrentScreen::Main => {} + CurrentScreen::Stopping => unreachable!(), }, }, + // we only need to deal with sending text now CurrentScreen::Sending(sending_screen) => match sending_screen { SendingScreen::Text => match code { KeyCode::Tab => *sending_screen = SendingScreen::Peers, @@ -156,11 +168,21 @@ impl App { self.text = None; *sending_screen = SendingScreen::Files; } - _ => { /* todo: add input widget to handle key input here */ } + _ => { + if let Some(changed) = self.input.handle_event(&event) + && changed.value + { + if self.input.value().is_empty() { + self.text = None; + } else { + self.text = Some(self.input.to_string()); + } + } + } }, - _ => unreachable!(), + // we've already handled the other sending modes + SendingScreen::Files | SendingScreen::Peers => unreachable!(), }, - CurrentScreen::Stopping => {} } } diff --git a/src/app/widgets.rs b/src/app/widgets.rs index f14692c..3b2c329 100644 --- a/src/app/widgets.rs +++ b/src/app/widgets.rs @@ -104,6 +104,17 @@ static CONTENT_SEND_PEERS_MENU: LazyLock = LazyLock::new(|| { ]) }); +static CONTENT_SEND_TEXT_MENU: LazyLock = LazyLock::new(|| { + Line::from(vec![ + " Send Text ".into(), + "".blue().bold(), + " Peers ".into(), + "".blue().bold(), + " Cancel ".into(), + "".blue().bold(), + ]) +}); + impl Widget for &mut App { fn render(self, area: Rect, buf: &mut Buffer) { let main_layout = Layout::vertical([Constraint::Min(5), Constraint::Min(3)]); @@ -121,12 +132,13 @@ impl Widget for &mut App { let subscreen_margin = Margin::new(2, 4); - let current_screen = self.screen.last().unwrap(); - + // it's safe to call `unwrap()` here because we ensure there's always at least + // one element in `self.screen`; see the `self.pop()` method + let current_screen = self.screen(); match current_screen { CurrentScreen::Main => { let rx_reqs: Vec<_> = self.receive_requests.values().collect(); - outer_frame(*current_screen, &MAIN_MENU, area, buf); + outer_frame(¤t_screen, &MAIN_MENU, area, buf); logger(header_right.inner(header_margin), buf); peers( &self.peers, @@ -143,12 +155,12 @@ impl Widget for &mut App { ); } CurrentScreen::Logging => { - outer_frame(*current_screen, &LOGGING_MENU, area, buf); + outer_frame(¤t_screen, &LOGGING_MENU, area, buf); logger(area.inner(subscreen_margin), buf); } CurrentScreen::Receiving => { let rx_reqs: Vec<_> = self.receive_requests.values().collect(); - outer_frame(*current_screen, &CONTENT_RECEIVE_MENU, area, buf); + outer_frame(¤t_screen, &CONTENT_RECEIVE_MENU, area, buf); receive_requests( &rx_reqs, &mut self.receiving_state, @@ -160,12 +172,14 @@ impl Widget for &mut App { CurrentScreen::Sending(s) => { match s { SendingScreen::Files => { - outer_frame(*current_screen, &CONTENT_SEND_FILE_MENU, area, buf) + outer_frame(¤t_screen, &CONTENT_SEND_FILE_MENU, area, buf) } SendingScreen::Peers => { - outer_frame(*current_screen, &CONTENT_SEND_PEERS_MENU, area, buf) + outer_frame(¤t_screen, &CONTENT_SEND_PEERS_MENU, area, buf) + } + SendingScreen::Text => { + outer_frame(¤t_screen, &CONTENT_SEND_TEXT_MENU, area, buf); } - SendingScreen::Text => {} } self.file_picker @@ -179,15 +193,20 @@ impl Widget for &mut App { bottom.inner(subscreen_margin), buf, ); + + if s == SendingScreen::Text { + let rect = centered_rect(area, 90, 80); + // TODO: display the text widget + } } _ => { - outer_frame(*current_screen, &MAIN_MENU, area, buf); + outer_frame(¤t_screen, &MAIN_MENU, area, buf); } } } } -fn outer_frame(screen: CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer) { +fn outer_frame(screen: &CurrentScreen, menu: &Line, area: Rect, buf: &mut Buffer) { let title = Line::from(" Joecalsend ".bold()); let block = Block::bordered() .title(title.centered()) @@ -349,7 +368,7 @@ impl Widget for NetworkInfoWidget { } // helpers -fn _centered_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect { +fn centered_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect { let horizontal = Layout::horizontal([Constraint::Percentage(width_pct)]).flex(Flex::Center); let vertical = Layout::vertical([Constraint::Percentage(height_pct)]).flex(Flex::Center); let [area] = vertical.areas(area); diff --git a/src/main.rs b/src/main.rs index be34164..962397a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,9 +47,7 @@ async fn start_and_run( terminal.draw(|frame| app.draw(frame))?; app.handle_events().await?; - if let Some(&top) = app.screen.last() - && top == CurrentScreen::Stopping - { + if app.screen() == CurrentScreen::Stopping { app.service.stop().await; break; }