From 7144a3b92c981639ad71b65dfaad15fd87ff9af6 Mon Sep 17 00:00:00 2001 From: Phil Kulak Date: Mon, 3 Apr 2023 17:38:50 -0700 Subject: [PATCH] Sending a message is a nice feature to have. --- Cargo.lock | 1 + Cargo.toml | 1 + src/app.rs | 19 +++++++++------ src/event.rs | 54 ++++++++++++++++++++++++++++++++--------- src/handler.rs | 30 +++++++++++++++++++++-- src/lib.rs | 3 +++ src/main.rs | 11 +++++---- src/matrix/matrix.rs | 25 ++++++++++++++++--- src/spawn.rs | 39 +++++++++++++++++++++++++++++ src/tui.rs | 13 +++++----- src/widgets/chat.rs | 36 ++++++++++++++++++++++++++- src/widgets/error.rs | 2 ++ src/widgets/progress.rs | 2 ++ 13 files changed, 200 insertions(+), 36 deletions(-) create mode 100644 src/spawn.rs diff --git a/Cargo.lock b/Cargo.lock index 6f51087..e3d1639 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1702,6 +1702,7 @@ dependencies = [ "ruma", "serde", "simple-logging", + "tempfile", "textwrap", "tokio", "tui", diff --git a/Cargo.toml b/Cargo.toml index c749b16..7a2bdb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ once_cell = "1.17" rand = "0.8.5" serde = { version = "1.0", features = ["derive"] } simple-logging = "2.0" +tempfile = "3" textwrap = "0.16" tokio = { version = "1.24.2", features = ["rt-multi-thread"] } tui = "0.19.0" diff --git a/src/app.rs b/src/app.rs index f6877db..91b3fdf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use matrix_sdk::encryption::verification::SasVerification; use std::sync::mpsc::Sender; use std::sync::Mutex; -use crate::event::Event; +use crate::event::{Event, EventHandler}; use crate::matrix::matrix::Matrix; use crate::widgets::chat::Chat; use crate::widgets::confirm::Confirm; @@ -17,6 +17,8 @@ static mut SENDER: Mutex>> = Mutex::new(None); /// Application. pub struct App { + pub events: EventHandler, + /// Is the application running? pub running: bool, @@ -33,14 +35,14 @@ pub struct App { /// And our single Matrix client and channel pub matrix: Matrix, - pub send: Sender, + pub sender: Sender, /// We'll hold on to any in-progress verifications here pub sas: Option, } impl App { - pub fn new(send: Sender) -> Self { + pub fn new(send: Sender, events: EventHandler) -> Self { let matrix = Matrix::new(send.clone()); // Save the sender for future threads. @@ -48,6 +50,7 @@ impl App { unsafe { SENDER = Mutex::new(Some(send.clone())) } Self { + events, running: true, timestamp: 0, progress: None, @@ -57,7 +60,7 @@ impl App { rooms: None, chat: None, matrix, - send, + sender: send, sas: None, } } @@ -100,6 +103,10 @@ impl App { /// Renders the user interface widgets. pub fn render(&mut self, frame: &mut Frame<'_, B>) { + if let Some(c) = &self.chat { + frame.render_widget(c.widget(), frame.size()) + } + if let Some(w) = &self.error { frame.render_widget(w.widget(), frame.size()); return; @@ -120,10 +127,6 @@ impl App { return; } - if let Some(c) = &self.chat { - frame.render_widget(c.widget(), frame.size()) - } - if let Some(r) = &self.rooms { frame.render_widget(r.widget(), frame.size()) } diff --git a/src/event.rs b/src/event.rs index 69a8e53..4a24963 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,7 +1,7 @@ use crate::handler::MatuiEvent; use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; -use std::sync::mpsc; -use std::sync::mpsc::Sender; +use std::ops::Sub; +use std::sync::mpsc::{channel, Receiver, Sender}; use std::thread; use std::time::{Duration, Instant}; @@ -10,6 +10,8 @@ use std::time::{Duration, Instant}; pub enum Event { /// Terminal tick. Tick, + /// Force a clear and full re-draw. + Redraw, /// Key press. Key(KeyEvent), /// Mouse click/scroll. @@ -25,35 +27,64 @@ pub enum Event { #[derive(Debug)] pub struct EventHandler { /// Event sender channel. - sender: mpsc::Sender, + sender: Sender, /// Event receiver channel. - receiver: mpsc::Receiver, + receiver: Receiver, + /// Park sender. + pk_sender: Sender, /// Event handler thread. handler: thread::JoinHandle<()>, } impl EventHandler { + pub fn park(&self) { + self.pk_sender.send(true).expect("could send park event"); + } + + pub fn unpark(&self) { + self.handler.thread().unpark(); + } + /// Constructs a new instance of [`EventHandler`]. pub fn new(tick_rate: u64) -> Self { let tick_rate = Duration::from_millis(tick_rate); - let (sender, receiver) = mpsc::channel(); + let (sender, receiver) = channel(); + let (pk_sender, pk_receiver) = channel(); let handler = { let sender = sender.clone(); thread::spawn(move || { let mut last_tick = Instant::now(); + let mut last_park = Instant::now().sub(Duration::from_secs(10)); + loop { let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or(tick_rate); + if let Ok(_) = pk_receiver.try_recv() { + thread::park(); + last_park = Instant::now() + } + if event::poll(timeout).expect("no events available") { - match event::read().expect("unable to read event") { - CrosstermEvent::Key(e) => sender.send(Event::Key(e)), - CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)), - CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)), - _ => unimplemented!(), + let event = event::read().expect("unable to read event"); + + if let Ok(_) = pk_receiver.try_recv() { + thread::park(); + last_park = Instant::now() + } + + // right after we unpark, we can get a stream of + // garbage events + if last_park.elapsed() > Duration::from_millis(250) { + match event { + CrosstermEvent::Key(e) => sender.send(Event::Key(e)), + CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)), + CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)), + _ => unimplemented!(), + } + .expect("failed to send terminal event") } - .expect("failed to send terminal event") } if last_tick.elapsed() >= tick_rate { @@ -66,6 +97,7 @@ impl EventHandler { Self { sender, receiver, + pk_sender, handler, } } diff --git a/src/handler.rs b/src/handler.rs index 8db0356..e9bfa7e 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -9,6 +9,7 @@ use crate::widgets::signin::Signin; use crate::widgets::Action::{ButtonNo, ButtonYes, SelectRoom}; use crate::widgets::EventResult::Consumed; use crossterm::event::{KeyCode, KeyEvent}; + use matrix_sdk::encryption::verification::{Emoji, SasVerification}; use matrix_sdk::room::RoomMember; use ruma::events::AnyTimelineEvent; @@ -20,6 +21,8 @@ pub enum MatuiEvent { LoginRequired, LoginStarted, Member(RoomMember), + ProgressStarted(String), + ProgressComplete, SyncComplete, SyncStarted(SyncType), Timeline(AnyTimelineEvent), @@ -57,6 +60,8 @@ pub fn handle_app_event(event: MatuiEvent, app: &mut App) { c.room_member_event(rm); } } + MatuiEvent::ProgressStarted(msg) => app.progress = Some(Progress::new(&msg)), + MatuiEvent::ProgressComplete => app.progress = None, MatuiEvent::SyncStarted(st) => { app.error = None; match st { @@ -120,6 +125,7 @@ pub fn handle_key_event(key_event: KeyEvent, app: &mut App) -> anyhow::Result<() if let Consumed(ButtonYes) = w.input(&key_event) { app.matrix .login(w.id.value().as_str(), w.password.value().as_str()); + return Ok(()); } } @@ -131,6 +137,8 @@ pub fn handle_key_event(key_event: KeyEvent, app: &mut App) -> anyhow::Result<() room.set_room(joined); app.chat = Some(room); + + return Ok(()); } } @@ -142,18 +150,36 @@ pub fn handle_key_event(key_event: KeyEvent, app: &mut App) -> anyhow::Result<() app.confirm = None; app.progress = Some(Progress::new("Waiting for your other device to confirm.")); + return Ok(()); } Consumed(ButtonNo) => { app.matrix.mismatched_verification(sas); app.confirm = None; + return Ok(()); } _ => {} } } } - if app.signin.is_none() && app.rooms.is_none() && key_event.code == KeyCode::Char('k') { - app.rooms = Some(Rooms::new(app.matrix.clone())); + // there's probably a more elegant way to do this... + if app.signin.is_none() + && app.rooms.is_none() + && app.confirm.is_none() + && app.progress.is_none() + && app.error.is_none() + { + if let Some(chat) = &app.chat { + match chat.input(&app, &key_event) { + Err(err) => app.error = Some(Error::new(err.to_string())), + _ => {} + } + } + + match key_event.code { + KeyCode::Char('k') => app.rooms = Some(Rooms::new(app.matrix.clone())), + _ => {} + } } } } diff --git a/src/lib.rs b/src/lib.rs index 081210e..8c72828 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,3 +17,6 @@ pub mod widgets; /// Matrix pub mod matrix; + +/// Using external apps to do our bidding +pub mod spawn; diff --git a/src/main.rs b/src/main.rs index 8f5331e..271494e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,23 +15,24 @@ fn main() -> anyhow::Result<()> { let terminal = Terminal::new(backend)?; let events = EventHandler::new(250); let sender = events.sender(); - let mut tui = Tui::new(terminal, events); + let mut tui = Tui::new(terminal); tui.init()?; // Create an application. - let mut app = App::new(sender); + let mut app = App::new(sender, events); // Start the main loop. while app.running { // Handle events. - match tui.events.next()? { + match app.events.next()? { Event::Tick => { app.tick(); - tui.draw(&mut app)?; + tui.draw(&mut app, false)?; } + Event::Redraw => tui.draw(&mut app, true)?, Event::Key(key_event) => { handle_key_event(key_event, &mut app)?; - tui.draw(&mut app)?; + tui.draw(&mut app, false)?; } Event::Mouse(_) => {} Event::Resize(_, _) => {} diff --git a/src/matrix/matrix.rs b/src/matrix/matrix.rs index db2e0cf..e0ea869 100644 --- a/src/matrix/matrix.rs +++ b/src/matrix/matrix.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use crate::app::App; use crate::event::Event; use crate::event::Event::{Matui, Tick}; -use crate::handler::MatuiEvent::{Error, VerificationCompleted, VerificationStarted}; +use crate::handler::MatuiEvent::{ + Error, ProgressComplete, ProgressStarted, VerificationCompleted, VerificationStarted, +}; use crate::handler::{MatuiEvent, SyncType}; use crate::matrix::roomcache::{DecoratedRoom, RoomCache}; use anyhow::{bail, Context}; @@ -30,6 +32,7 @@ use matrix_sdk::{Client, LoopCtrl, ServerName, Session}; use once_cell::sync::OnceCell; use rand::rngs::OsRng; use rand::{distributions::Alphanumeric, Rng}; +use ruma::events::room::message::RoomMessageEventContent; use ruma::events::{AnySyncTimelineEvent, AnyTimelineEvent}; use ruma::UInt; use serde::{Deserialize, Serialize}; @@ -112,8 +115,6 @@ impl Matrix { .set(client.clone()) .expect("could not set client"); - add_default_handlers(client.clone()); - if let Err(err) = sync_once(client.clone(), token, &session_file).await { matrix.send(Error(err.to_string())); return; @@ -162,6 +163,7 @@ impl Matrix { } pub fn sync(&self) { + add_default_handlers(self.client()); add_verification_handlers(self.client()); let client = self.client(); @@ -279,6 +281,23 @@ impl Matrix { }); } + pub fn send_text_message(&self, room: Joined, message: String) { + let matrix = self.clone(); + + self.rt.spawn(async move { + matrix.send(ProgressStarted("Sending message.".to_string())); + + if let Err(err) = room + .send(RoomMessageEventContent::text_plain(message), None) + .await + { + matrix.send(Error(err.to_string())); + } + + matrix.send(ProgressComplete); + }); + } + pub fn timeline_event(&self, event: &AnyTimelineEvent) { let matrix = self.clone(); let event = event.clone(); diff --git a/src/spawn.rs b/src/spawn.rs new file mode 100644 index 0000000..9c7fa0e --- /dev/null +++ b/src/spawn.rs @@ -0,0 +1,39 @@ +use anyhow::bail; +use std::env::var; +use std::io::Read; +use std::process::Command; +use tempfile::NamedTempFile; + +pub fn get_text() -> anyhow::Result> { + let editor = &var("EDITOR")?; + let mut tmpfile = NamedTempFile::new()?; + + let mut command = Command::new(editor); + + // xterm1 is a terminfo that explicitly ignores the alternate screen, + // which is great for us, because an editor forcing us back to the + // main screen is not at all ideal + command.env("TERM", "xterm1"); + + if editor.ends_with("vim") || editor.ends_with("vi") { + // for vim, open in insert, and map enter to save and quit + command.arg("+star"); + command.arg("-c"); + command.arg("imap :wq"); + } + + let status = command.arg(tmpfile.path()).status()?; + + if !status.success() { + bail!("Invalid status code.") + } + + let mut contents = String::new(); + tmpfile.read_to_string(&mut contents)?; + + if contents.trim().is_empty() { + return Ok(None); + } + + Ok(Some(contents)) +} diff --git a/src/tui.rs b/src/tui.rs index 9ac5ef3..9471b22 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,5 +1,4 @@ use crate::app::App; -use crate::event::EventHandler; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use std::io; @@ -14,14 +13,12 @@ use tui::Terminal; pub struct Tui { /// Interface to the Terminal. terminal: Terminal, - /// Terminal event handler. - pub events: EventHandler, } impl Tui { /// Constructs a new instance of [`Tui`]. - pub fn new(terminal: Terminal, events: EventHandler) -> Self { - Self { terminal, events } + pub fn new(terminal: Terminal) -> Self { + Self { terminal } } /// Initializes the terminal interface. @@ -39,7 +36,11 @@ impl Tui { /// /// [`Draw`]: tui::Terminal::draw /// [`rendering`]: crate::app::App::render - pub fn draw(&mut self, app: &mut App) -> anyhow::Result<()> { + pub fn draw(&mut self, app: &mut App, clear: bool) -> anyhow::Result<()> { + if clear { + self.terminal.clear()?; + } + self.terminal.draw(|frame| app.render(frame))?; Ok(()) } diff --git a/src/widgets/chat.rs b/src/widgets/chat.rs index 653fcb0..81eff7e 100644 --- a/src/widgets/chat.rs +++ b/src/widgets/chat.rs @@ -1,5 +1,12 @@ +use crate::app::App; +use crate::event::Event; use crate::matrix::matrix::Matrix; -use crate::widgets::get_margin; +use crate::spawn::get_text; +use crate::widgets::Action::Typing; +use crate::widgets::EventResult::Consumed; +use crate::widgets::{get_margin, EventResult}; +use anyhow::bail; +use crossterm::event::{KeyCode, KeyEvent}; use matrix_sdk::room::{Joined, RoomMember}; use ruma::events::room::message::MessageType::Text; use ruma::events::room::message::TextMessageEventContent; @@ -25,6 +32,7 @@ pub struct Chat { list_state: Cell, } +#[allow(dead_code)] pub struct Message { id: OwnedEventId, body: String, @@ -99,6 +107,32 @@ impl Chat { self.list_state.set(state); } + pub fn input(&self, app: &App, input: &KeyEvent) -> anyhow::Result { + match input.code { + KeyCode::Char('i') => { + app.events.park(); + let result = get_text(); + app.events.unpark(); + + // make sure we redraw the whole app when we come back + app.sender.send(Event::Redraw)?; + + if let Ok(input) = result { + if let Some(input) = input { + self.matrix + .send_text_message(self.room.clone().unwrap(), input); + return Ok(Consumed(Typing)); + } else { + bail!("Ignoring blank message.") + } + } else { + bail!("Couldn't read from editor.") + } + } + _ => return Ok(EventResult::Ignored), + }; + } + pub fn timeline_event(&mut self, event: &AnyTimelineEvent) { if self.room.is_none() || event.room_id() != self.room.as_ref().unwrap().room_id() { return; diff --git a/src/widgets/error.rs b/src/widgets/error.rs index b4db6ba..5ef51df 100644 --- a/src/widgets/error.rs +++ b/src/widgets/error.rs @@ -37,6 +37,8 @@ impl Widget for ErrorWidget<'_> { .constraints([Constraint::Percentage(100)].as_ref()) .split(area)[0]; + buf.merge(&Buffer::empty(area)); + let splits = Layout::default() .direction(Direction::Vertical) .horizontal_margin(4) diff --git a/src/widgets/progress.rs b/src/widgets/progress.rs index b46992e..ee639bb 100644 --- a/src/widgets/progress.rs +++ b/src/widgets/progress.rs @@ -50,6 +50,8 @@ impl Widget for ProgressWidget<'_> { .constraints([Constraint::Length(5)].as_ref()) .split(area)[0]; + buf.merge(&Buffer::empty(area)); + Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded)