diff options
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/client/mod.rs | 51 | ||||
| -rw-r--r-- | src/connection/mod.rs | 30 | ||||
| -rw-r--r-- | src/main.rs | 99 |
5 files changed, 108 insertions, 75 deletions
@@ -255,6 +255,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix 0.38.44", @@ -20,7 +20,7 @@ thiserror = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } ratatui = "0.30" -crossterm = "0.28" +crossterm = { version = "0.28", features = ["event-stream"] } unicode-width = "0.2.2" clap = { version = "4.5.60", features = ["derive"] } diff --git a/src/client/mod.rs b/src/client/mod.rs index f02f8e5..0199395 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -51,38 +51,9 @@ impl Client { Ok(client) } - /// Send a raw `IrcMessage` to the server. - pub fn send(&self, msg: IrcMessage) { - self.sender.send(msg); - } - - /// Send a PRIVMSG to a channel or user. - pub fn privmsg(&self, target: &str, text: &str) { - self.sender.send(IrcMessage::new( - Command::Privmsg, - vec![target.to_string(), text.to_string()], - )); - } - - /// Join a channel. - pub fn join(&self, channel: &str) { - self.sender - .send(IrcMessage::new(Command::Join, vec![channel.to_string()])); - } - - /// Part a channel. - pub fn part(&self, channel: &str, reason: Option<&str>) { - let mut params = vec![channel.to_string()]; - if let Some(r) = reason { - params.push(r.to_string()); - } - self.sender.send(IrcMessage::new(Command::Part, params)); - } - - /// Change nick. - pub fn nick(&self, new_nick: &str) { - self.sender - .send(IrcMessage::new(Command::Nick, vec![new_nick.to_string()])); + /// Offer a clone of the sender + pub fn sender(&self) -> Sender { + self.sender.clone() } /// Read-only view of current client state. @@ -137,19 +108,3 @@ impl Client { )); } } - -impl Client { - /// Non-blocking version of `next_event`. - /// Returns `Some(event)` if one is immediately available, `None` otherwise. - /// Used by the TUI loop to drain events without blocking the render tick. - pub fn next_event_nowait(&mut self) -> Option<Event> { - loop { - let msg = self.inbox.try_recv().ok()?; - let mut events = handle(msg, &mut self.state, &self.sender); - - if !events.is_empty() { - return Some(events.remove(0)); - } - } - } -} diff --git a/src/connection/mod.rs b/src/connection/mod.rs index bc837c0..10e8ec9 100644 --- a/src/connection/mod.rs +++ b/src/connection/mod.rs @@ -5,7 +5,7 @@ use tokio_util::codec::Framed; use tracing::{debug, error, info}; use crate::proto::codec::IrcCodec; -use crate::proto::message::IrcMessage; +use crate::proto::message::{Command, IrcMessage}; /// A handle to send messages to the server. /// Cheaply cloneable — pass it wherever you need to write. @@ -15,10 +15,38 @@ pub struct Sender { } impl Sender { + /// Send a raw `IrcMessage` to the server. pub fn send(&self, msg: IrcMessage) { // Only fails if the connection task has shut down let _ = self.tx.send(msg); } + + /// Send a PRIVMSG to a channel or user. + pub fn privmsg(&self, target: &str, text: &str) { + self.send(IrcMessage::new( + Command::Privmsg, + vec![target.to_string(), text.to_string()], + )); + } + + /// Join a channel. + pub fn join(&self, channel: &str) { + self.send(IrcMessage::new(Command::Join, vec![channel.to_string()])); + } + + /// Part a channel. + pub fn part(&self, channel: &str, reason: Option<&str>) { + let mut params = vec![channel.to_string()]; + if let Some(r) = reason { + params.push(r.to_string()); + } + self.send(IrcMessage::new(Command::Part, params)); + } + + /// Change nick. + pub fn nick(&self, new_nick: &str) { + self.send(IrcMessage::new(Command::Nick, vec![new_nick.to_string()])); + } } /// Establish a TCP connection and return: diff --git a/src/main.rs b/src/main.rs index b81eff8..f66ce13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,21 @@ use clap::Parser; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, + KeyModifiers, + }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; +use futures::StreamExt; use ratatui::{Terminal, backend::CrosstermBackend}; use std::io; use std::net::ToSocketAddrs; -use std::time::Duration; +use tokio::sync::mpsc; -use irc_client::client::event::Event as IrcEvent; use irc_client::client::{Client, Config}; use irc_client::proto::message::{Command, IrcMessage}; +use irc_client::{client::event::Event as IrcEvent, connection::Sender}; use tui::app::AppState; use tui::ui; mod tui; @@ -35,6 +39,11 @@ struct Args { pass: String, } +enum AppEvent { + Key(KeyEvent), + Irc(IrcEvent), +} + #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { enable_raw_mode()?; @@ -82,26 +91,59 @@ async fn run( }; let mut client = Client::connect(config).await?; + let sender = client.sender(); + + let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>(); + // Spawn keyboard task — blocks on crossterm's async read, + // zero CPU until a key is actually pressed + + let kb_tx = tx.clone(); + tokio::spawn(async move { + loop { + // event::EventStream is crossterm's async Stream adapter + match EventStream::new().next().await { + Some(Ok(Event::Key(key))) => { + if kb_tx.send(AppEvent::Key(key)).is_err() { + break; + } + } + None => break, + _ => {} + } + } + }); + + // Spawn IRC task — awaits silently until the server sends something + let irc_tx = tx.clone(); + tokio::spawn(async move { + while let Some(event) = client.next_event().await { + if irc_tx.send(AppEvent::Irc(event)).is_err() { + break; + } + } + }); + // Draw once at startup + terminal.draw(|f| ui::draw(f, &mut app))?; // We poll both IRC events and keyboard events with short timeouts so // neither blocks the other. - loop { - terminal.draw(|f| ui::draw(f, &mut app))?; + // Main loop: sleeps until an event arrives, redraws only on state change + while let Some(event) = rx.recv().await { + let mut dirty = true; // set false for events that don't change visible state - if event::poll(Duration::from_millis(20))? { - if let Event::Key(key) = event::read()? { + match event { + AppEvent::Key(key) => { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, - (_, KeyCode::Enter) => { let text = app.take_input(); if !text.is_empty() { - if handle_input(&text, &mut app, &mut client) { + // need client here — see note below about Arc<Mutex<Client>> + if handle_input(&text, &mut app, &sender) { break; - }; + } } } - (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => { app.input_insert(c); } @@ -114,16 +156,23 @@ async fn run( (_, KeyCode::Down) => app.scroll_down(), (_, KeyCode::Home) => app.cursor = 0, (_, KeyCode::End) => app.cursor = app.input.len(), + _ => { + dirty = false; + } + } + } + + AppEvent::Irc(irc_event) => { + match &irc_event { + IrcEvent::Raw(_) => dirty = false, _ => {} } + handle_irc_event(irc_event, &mut app); } } - for _ in 0..16 { - match client.next_event_nowait() { - Some(irc_event) => handle_irc_event(irc_event, &mut app), - None => break, - } + if dirty { + terminal.draw(|f| ui::draw(f, &mut app))?; } } @@ -131,7 +180,7 @@ async fn run( } /// Handle a line entered in the input box. -fn handle_input(text: &str, app: &mut AppState, client: &mut Client) -> bool { +fn handle_input(text: &str, app: &mut AppState, sender: &Sender) -> bool { if let Some(cmd) = text.strip_prefix('/') { // It's a command let mut parts = cmd.splitn(2, ' '); @@ -141,11 +190,11 @@ fn handle_input(text: &str, app: &mut AppState, client: &mut Client) -> bool { match verb.as_str() { "JOIN" => { if !app.channel.is_empty() { - client.part(&app.channel, None); + sender.part(&app.channel, None); } app.messages.clear(); app.members.clear(); - client.join(args.trim()); + sender.join(args.trim()); app.channel = args.trim().to_string(); } "PART" => { @@ -154,27 +203,27 @@ fn handle_input(text: &str, app: &mut AppState, client: &mut Client) -> bool { } else { args.trim() }; - client.part(channel, None); + sender.part(channel, None); app.channel = "".to_string(); app.members.clear(); } "NICK" => { - client.nick(args.trim()); + sender.nick(args.trim()); } "QUIT" => { - client.send(IrcMessage::new(Command::Quit, vec![args.to_string()])); + sender.send(IrcMessage::new(Command::Quit, vec![args.to_string()])); return true; } "ME" => { // CTCP ACTION let ctcp = format!("\x01ACTION {}\x01", args); - client.privmsg(&app.channel, &ctcp); + sender.privmsg(&app.channel, &ctcp); app.push_system(&format!("* {} {}", app.nick, args)); } "MSG" => { let mut p = args.splitn(2, ' '); if let (Some(target), Some(msg)) = (p.next(), p.next()) { - client.privmsg(target, msg); + sender.privmsg(target, msg); app.push_message(&format!("You → {target}:"), &msg); } } @@ -185,7 +234,7 @@ fn handle_input(text: &str, app: &mut AppState, client: &mut Client) -> bool { } else { if app.connected && !app.channel.is_empty() { // Regular chat message to active channel - client.privmsg(&app.channel, text); + sender.privmsg(&app.channel, text); app.push_message(&app.nick.clone(), text); } } |
