add text input component

This commit is contained in:
Joe Ardent 2025-08-05 18:57:59 -07:00
parent 95fd86e851
commit 65a193f6e7
5 changed files with 71 additions and 20 deletions

11
Cargo.lock generated
View file

@ -1175,6 +1175,7 @@ dependencies = [
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tower-http", "tower-http",
"tui-input",
"tui-logger", "tui-logger",
] ]
@ -2377,6 +2378,16 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 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]] [[package]]
name = "tui-logger" name = "tui-logger"
version = "0.17.3" version = "0.17.3"

View file

@ -26,4 +26,5 @@ sha256 = "1.6"
thiserror = "2" thiserror = "2"
tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] } tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] }
tower-http = { version = "0.6", features = ["limit"] } tower-http = { version = "0.6", features = ["limit"] }
tui-input = "0.14.0"
tui-logger = { version = "0.17", features = ["crossterm"] } tui-logger = { version = "0.17", features = ["crossterm"] }

View file

@ -11,6 +11,7 @@ use ratatui::{
}; };
use ratatui_explorer::FileExplorer; use ratatui_explorer::FileExplorer;
use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::UnboundedReceiver;
use tui_input::{Input, backend::crossterm::EventHandler};
pub mod widgets; pub mod widgets;
@ -23,18 +24,19 @@ pub struct Peer {
pub struct App { pub struct App {
pub service: JoecalService, pub service: JoecalService,
pub screen: Vec<CurrentScreen>,
pub events: EventStream, pub events: EventStream,
// addr -> (alias, fingerprint) // addr -> (alias, fingerprint)
pub peers: Vec<Peer>, pub peers: Vec<Peer>,
pub peer_state: ListState, pub peer_state: ListState,
pub receive_requests: BTreeMap<Julid, ReceiveRequest>, pub receive_requests: BTreeMap<Julid, ReceiveRequest>,
screen: Vec<CurrentScreen>,
receiving_state: TableState, receiving_state: TableState,
// for getting messages back from the web server or web client about things we've done; the // for getting messages back from the web server or web client about things we've done; the
// other end is held by the service // other end is held by the service
event_listener: UnboundedReceiver<TransferEvent>, event_listener: UnboundedReceiver<TransferEvent>,
file_picker: FileExplorer, file_picker: FileExplorer,
text: Option<String>, text: Option<String>,
input: Input,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -66,6 +68,7 @@ impl App {
peer_state: Default::default(), peer_state: Default::default(),
receive_requests: Default::default(), receive_requests: Default::default(),
receiving_state: Default::default(), receiving_state: Default::default(),
input: Default::default(),
} }
} }
@ -102,6 +105,13 @@ impl App {
Ok(()) 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) { async fn handle_key_event(&mut self, key_event: KeyEvent, event: crossterm::event::Event) {
let code = key_event.code; let code = key_event.code;
let mode = self.screen.last_mut().unwrap(); let mode = self.screen.last_mut().unwrap();
@ -143,11 +153,13 @@ impl App {
KeyCode::Down => self.peer_state.select_next(), 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 { CurrentScreen::Sending(sending_screen) => match sending_screen {
SendingScreen::Text => match code { SendingScreen::Text => match code {
KeyCode::Tab => *sending_screen = SendingScreen::Peers, KeyCode::Tab => *sending_screen = SendingScreen::Peers,
@ -156,11 +168,21 @@ impl App {
self.text = None; self.text = None;
*sending_screen = SendingScreen::Files; *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 => {} CurrentScreen::Stopping => {}
} }
} }

View file

@ -104,6 +104,17 @@ static CONTENT_SEND_PEERS_MENU: LazyLock<Line> = LazyLock::new(|| {
]) ])
}); });
static CONTENT_SEND_TEXT_MENU: LazyLock<Line> = LazyLock::new(|| {
Line::from(vec![
" Send Text ".into(),
"<ENTER>".blue().bold(),
" Peers ".into(),
"<TAB>".blue().bold(),
" Cancel ".into(),
"<ESC>".blue().bold(),
])
});
impl Widget for &mut App { impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let main_layout = Layout::vertical([Constraint::Min(5), Constraint::Min(3)]); 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 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 { match current_screen {
CurrentScreen::Main => { CurrentScreen::Main => {
let rx_reqs: Vec<_> = self.receive_requests.values().collect(); let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(*current_screen, &MAIN_MENU, area, buf); outer_frame(&current_screen, &MAIN_MENU, area, buf);
logger(header_right.inner(header_margin), buf); logger(header_right.inner(header_margin), buf);
peers( peers(
&self.peers, &self.peers,
@ -143,12 +155,12 @@ impl Widget for &mut App {
); );
} }
CurrentScreen::Logging => { CurrentScreen::Logging => {
outer_frame(*current_screen, &LOGGING_MENU, area, buf); outer_frame(&current_screen, &LOGGING_MENU, area, buf);
logger(area.inner(subscreen_margin), buf); logger(area.inner(subscreen_margin), buf);
} }
CurrentScreen::Receiving => { CurrentScreen::Receiving => {
let rx_reqs: Vec<_> = self.receive_requests.values().collect(); let rx_reqs: Vec<_> = self.receive_requests.values().collect();
outer_frame(*current_screen, &CONTENT_RECEIVE_MENU, area, buf); outer_frame(&current_screen, &CONTENT_RECEIVE_MENU, area, buf);
receive_requests( receive_requests(
&rx_reqs, &rx_reqs,
&mut self.receiving_state, &mut self.receiving_state,
@ -160,12 +172,14 @@ impl Widget for &mut App {
CurrentScreen::Sending(s) => { CurrentScreen::Sending(s) => {
match s { match s {
SendingScreen::Files => { SendingScreen::Files => {
outer_frame(*current_screen, &CONTENT_SEND_FILE_MENU, area, buf) outer_frame(&current_screen, &CONTENT_SEND_FILE_MENU, area, buf)
} }
SendingScreen::Peers => { SendingScreen::Peers => {
outer_frame(*current_screen, &CONTENT_SEND_PEERS_MENU, area, buf) outer_frame(&current_screen, &CONTENT_SEND_PEERS_MENU, area, buf)
}
SendingScreen::Text => {
outer_frame(&current_screen, &CONTENT_SEND_TEXT_MENU, area, buf);
} }
SendingScreen::Text => {}
} }
self.file_picker self.file_picker
@ -179,15 +193,20 @@ impl Widget for &mut App {
bottom.inner(subscreen_margin), bottom.inner(subscreen_margin),
buf, 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(&current_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 title = Line::from(" Joecalsend ".bold());
let block = Block::bordered() let block = Block::bordered()
.title(title.centered()) .title(title.centered())
@ -349,7 +368,7 @@ impl Widget for NetworkInfoWidget {
} }
// helpers // 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 horizontal = Layout::horizontal([Constraint::Percentage(width_pct)]).flex(Flex::Center);
let vertical = Layout::vertical([Constraint::Percentage(height_pct)]).flex(Flex::Center); let vertical = Layout::vertical([Constraint::Percentage(height_pct)]).flex(Flex::Center);
let [area] = vertical.areas(area); let [area] = vertical.areas(area);

View file

@ -47,9 +47,7 @@ async fn start_and_run(
terminal.draw(|frame| app.draw(frame))?; terminal.draw(|frame| app.draw(frame))?;
app.handle_events().await?; app.handle_events().await?;
if let Some(&top) = app.screen.last() if app.screen() == CurrentScreen::Stopping {
&& top == CurrentScreen::Stopping
{
app.service.stop().await; app.service.stop().await;
break; break;
} }