aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Lance Borden 2026-03-09 13:49:20 -0400
committerGravatar GitHub 2026-03-09 13:49:20 -0400
commit1f10c5ffe6461946d9ada5d4bc174dcef4630047 (patch)
treeada37580f6e1fd1d77fe83b0d9e1b9700df637b1
parentadd scroll and scroll bar indicator to members list (diff)
parentinitial move to async instead of polling (diff)
Merge pull request #1 from lancebord/async-rework
initial move to async instead of polling
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml2
-rw-r--r--src/client/mod.rs51
-rw-r--r--src/connection/mod.rs30
-rw-r--r--src/main.rs99
5 files changed, 108 insertions, 75 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 5b9592c..ae467fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -255,6 +255,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.0",
"crossterm_winapi",
+ "futures-core",
"mio",
"parking_lot",
"rustix 0.38.44",
diff --git a/Cargo.toml b/Cargo.toml
index 2960a5e..23937c4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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);
}
}