aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/client/event.rs2
-rw-r--r--src/client/handler.rs39
-rw-r--r--src/client/mod.rs21
-rw-r--r--src/client/state.rs20
-rw-r--r--src/main.rs46
-rw-r--r--src/tui/app.rs14
-rw-r--r--src/tui/ui.rs24
7 files changed, 53 insertions, 113 deletions
diff --git a/src/client/event.rs b/src/client/event.rs
index d34908d..26e3f86 100644
--- a/src/client/event.rs
+++ b/src/client/event.rs
@@ -14,7 +14,7 @@ pub enum Event {
},
/// We joined a channel
- Joined { channel: String },
+ Joined { channel: String, nick: String },
/// We or someone else left a channel
Parted {
diff --git a/src/client/handler.rs b/src/client/handler.rs
index 91aa5ea..6b61fed 100644
--- a/src/client/handler.rs
+++ b/src/client/handler.rs
@@ -61,8 +61,7 @@ pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<
})
.collect();
- let ch = state.channel_mut(channel);
- ch.members.extend(members.clone());
+ state.channel.members.extend(members.clone());
events.push(Event::Names {
channel: channel.clone(),
@@ -74,7 +73,7 @@ pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<
// --- 332: topic on join ---
Command::Numeric(332) => {
if let (Some(channel), Some(topic)) = (msg.params.get(1), msg.params.get(2)) {
- state.channel_mut(channel).topic = Some(topic.clone());
+ state.channel.topic = Some(topic.clone());
events.push(Event::Topic {
channel: channel.clone(),
topic: topic.clone(),
@@ -86,15 +85,12 @@ pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<
Command::Join => {
let nick = nick_from_prefix(&msg.prefix);
if let Some(channel) = msg.params.first() {
- if nick == state.nick {
- // We joined
- state.channel_mut(channel);
- events.push(Event::Joined {
- channel: channel.clone(),
- });
- } else {
- // Someone else joined
- state.channel_mut(channel).members.insert(nick);
+ events.push(Event::Joined {
+ channel: channel.clone(),
+ nick: nick.clone(),
+ });
+ if nick != state.nick {
+ state.channel.members.insert(nick);
}
}
}
@@ -105,10 +101,8 @@ pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<
let channel = msg.params.first().cloned().unwrap_or_default();
let reason = msg.params.get(1).cloned();
- if nick == state.nick {
- state.remove_channel(&channel);
- } else {
- state.channel_mut(&channel).members.remove(&nick);
+ if nick != state.nick {
+ state.channel.members.remove(&nick);
}
events.push(Event::Parted {
@@ -123,10 +117,7 @@ pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<
let nick = nick_from_prefix(&msg.prefix);
let reason = msg.params.first().cloned();
- // Remove them from all channels
- for ch in state.channels.values_mut() {
- ch.members.remove(&nick);
- }
+ state.channel.members.remove(&nick);
events.push(Event::Quit { nick, reason });
}
@@ -141,10 +132,8 @@ pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<
}
// Update in all channels
- for ch in state.channels.values_mut() {
- if ch.members.remove(&old_nick) {
- ch.members.insert(new_nick.clone());
- }
+ if state.channel.members.remove(&old_nick) {
+ state.channel.members.insert(new_nick.clone());
}
events.push(Event::NickChanged { old_nick, new_nick });
@@ -168,7 +157,7 @@ pub fn handle(msg: IrcMessage, state: &mut ClientState, sender: &Sender) -> Vec<
// --- TOPIC (live change) ---
Command::Topic => {
if let (Some(channel), Some(topic)) = (msg.params.first(), msg.params.get(1)) {
- state.channel_mut(channel).topic = Some(topic.clone());
+ state.channel.topic = Some(topic.clone());
events.push(Event::Topic {
channel: channel.clone(),
topic: topic.clone(),
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 6d32017..f02f8e5 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -1,5 +1,4 @@
use tokio::sync::mpsc;
-use tracing::info;
use crate::client::event::Event;
use crate::client::handler::handle;
@@ -23,8 +22,6 @@ pub struct Config {
pub realname: String,
/// Optional server password
pub password: Option<String>,
- /// Channels to auto-join after registration
- pub autojoin: Vec<String>,
}
/// The main IRC client.
@@ -100,16 +97,6 @@ impl Client {
let msg = self.inbox.recv().await?;
let events = handle(msg, &mut self.state, &self.sender);
- // Handle auto-join after registration
- for event in &events {
- if let Event::Connected { .. } = event {
- for channel in &self.config.autojoin.clone() {
- info!("Auto-joining {}", channel);
- self.join(channel);
- }
- }
- }
-
// Return the first event; re-queue the rest
// (simple approach: process one at a time via recursive buffering)
if let Some(first) = events.into_iter().next() {
@@ -160,14 +147,6 @@ impl Client {
let msg = self.inbox.try_recv().ok()?;
let mut events = handle(msg, &mut self.state, &self.sender);
- for event in &events {
- if let Event::Connected { .. } = event {
- for channel in &self.config.autojoin.clone() {
- self.join(channel);
- }
- }
- }
-
if !events.is_empty() {
return Some(events.remove(0));
}
diff --git a/src/client/state.rs b/src/client/state.rs
index b987509..6945d4e 100644
--- a/src/client/state.rs
+++ b/src/client/state.rs
@@ -1,10 +1,10 @@
-use std::collections::{HashMap, HashSet};
+use std::collections::HashSet;
/// The full state of a connected IRC client.
#[derive(Debug, Default)]
pub struct ClientState {
pub nick: String,
- pub channels: HashMap<String, Channel>,
+ pub channel: Channel,
pub caps: HashSet<String>,
pub server_name: Option<String>,
pub reg: RegistrationState,
@@ -17,20 +17,6 @@ impl ClientState {
..Default::default()
}
}
-
- pub fn channel(&self, name: &str) -> Option<&Channel> {
- self.channels.get(&name.to_lowercase())
- }
-
- pub fn channel_mut(&mut self, name: &str) -> &mut Channel {
- self.channels
- .entry(name.to_lowercase())
- .or_insert_with(|| Channel::new(name))
- }
-
- pub fn remove_channel(&mut self, name: &str) {
- self.channels.remove(&name.to_lowercase());
- }
}
/// State of the registration handshake.
@@ -44,7 +30,7 @@ pub enum RegistrationState {
}
/// A joined channel and its current state.
-#[derive(Debug)]
+#[derive(Debug, Default)]
pub struct Channel {
pub name: String,
pub members: HashSet<String>,
diff --git a/src/main.rs b/src/main.rs
index b7d4365..2759304 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,15 +11,12 @@ use ratatui::{Terminal, backend::CrosstermBackend};
use irc_client::client::event::Event as IrcEvent;
use irc_client::client::{Client, Config};
use irc_client::proto::message::{Command, IrcMessage};
-use tui::app::{AppState, CHANNEL};
+use tui::app::AppState;
use tui::ui;
mod tui;
-const NICK: &str = "";
-
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
- // ── Terminal setup ────────────────────────────────────────────────────
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
@@ -29,7 +26,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let result = run(&mut terminal).await;
- // ── Restore terminal ──────────────────────────────────────────────────
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
@@ -44,16 +40,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<(), Box<dyn std::error::Error>> {
- let mut app = AppState::new(NICK, CHANNEL);
+ let mut app = AppState::new();
- // ── Connect to IRC ────────────────────────────────────────────────────
+ // Connect to IRC
let config = Config {
server: "irc.libera.chat:6667".to_string(),
- nick: NICK.to_string(),
+ nick: "".to_string(),
user: "speakez".to_string(),
realname: "speakez".to_string(),
password: None,
- autojoin: vec![CHANNEL.to_string()],
};
let mut client = Client::connect(config).await?;
@@ -76,7 +71,9 @@ async fn run(
(_, KeyCode::Enter) => {
let text = app.take_input();
if !text.is_empty() {
- handle_input(&text, &mut app, &mut client);
+ if handle_input(&text, &mut app, &mut client) {
+ break;
+ };
}
}
@@ -89,11 +86,6 @@ async fn run(
(_, KeyCode::Right) => app.cursor_right(),
(_, KeyCode::Home) => app.cursor = 0,
(_, KeyCode::End) => app.cursor = app.input.len(),
-
- // Scroll
- (_, KeyCode::PageUp) => app.scroll = app.scroll.saturating_add(5),
- (_, KeyCode::PageDown) => app.scroll = app.scroll.saturating_sub(5),
-
_ => {}
}
}
@@ -113,7 +105,7 @@ async fn run(
}
/// Handle a line entered in the input box.
-fn handle_input(text: &str, app: &mut AppState, client: &mut Client) {
+fn handle_input(text: &str, app: &mut AppState, client: &mut Client) -> bool {
if let Some(cmd) = text.strip_prefix('/') {
// It's a command
let mut parts = cmd.splitn(2, ' ');
@@ -123,6 +115,7 @@ fn handle_input(text: &str, app: &mut AppState, client: &mut Client) {
match verb.as_str() {
"JOIN" => {
client.join(args.trim());
+ app.channel = args.trim().to_string();
}
"PART" => {
let channel = if args.is_empty() {
@@ -131,12 +124,14 @@ fn handle_input(text: &str, app: &mut AppState, client: &mut Client) {
args.trim()
};
client.part(channel, None);
+ app.channel = "".to_string();
}
"NICK" => {
client.nick(args.trim());
}
"QUIT" => {
client.send(IrcMessage::new(Command::Quit, vec![args.to_string()]));
+ return true;
}
"ME" => {
// CTCP ACTION
@@ -156,10 +151,13 @@ fn handle_input(text: &str, app: &mut AppState, client: &mut Client) {
}
}
} else {
- // Regular chat message to active channel
- client.privmsg(&app.channel, text);
- app.push_message(&app.nick.clone(), text, true);
+ if app.connected && !app.channel.is_empty() {
+ // Regular chat message to active channel
+ client.privmsg(&app.channel, text);
+ app.push_message(&app.nick.clone(), text, true);
+ }
}
+ false
}
/// Apply an IRC event to the app state.
@@ -172,8 +170,14 @@ fn handle_irc_event(event: IrcEvent, app: &mut AppState) {
app.push_system(&format!("Connected to {} as {}", server, nick));
}
- IrcEvent::Joined { channel } => {
- app.push_system(&format!("You joined {}", channel));
+ IrcEvent::Joined { channel, nick } => {
+ if nick == app.nick {
+ app.push_system(&format!("You joined {}", channel));
+ } else {
+ app.push_system(&format!("{} joined {}", nick, channel));
+ app.members.push(nick);
+ app.sort_members();
+ }
}
IrcEvent::Message {
diff --git a/src/tui/app.rs b/src/tui/app.rs
index 1e7d021..0b5d77e 100644
--- a/src/tui/app.rs
+++ b/src/tui/app.rs
@@ -1,6 +1,3 @@
-/// Compile-time channel to join on startup
-pub const CHANNEL: &str = "#speakez";
-
/// A single chat message in the log
#[derive(Clone)]
pub struct ChatLine {
@@ -28,8 +25,6 @@ pub struct AppState {
pub input: String,
/// Cursor position within `input` (byte index)
pub cursor: usize,
- /// Scroll offset from the bottom (0 = pinned to latest)
- pub scroll: usize,
/// Status line text (connection state, errors, etc.)
pub status: String,
/// Whether we've fully registered
@@ -37,16 +32,15 @@ pub struct AppState {
}
impl AppState {
- pub fn new(nick: impl Into<String>, channel: impl Into<String>) -> Self {
+ pub fn new() -> Self {
Self {
- nick: nick.into(),
- channel: channel.into(),
+ nick: String::new(),
+ channel: String::new(),
messages: Vec::new(),
members: Vec::new(),
input: String::new(),
cursor: 0,
- scroll: 0,
- status: "Connecting...".into(),
+ status: "Set nick with /nick to connect.".into(),
connected: false,
}
}
diff --git a/src/tui/ui.rs b/src/tui/ui.rs
index a38834a..4a243ad 100644
--- a/src/tui/ui.rs
+++ b/src/tui/ui.rs
@@ -90,37 +90,25 @@ fn draw_center(f: &mut Frame, area: Rect, state: &AppState) {
}
fn draw_chat_log(f: &mut Frame, area: Rect, state: &AppState) {
- let inner_height = area.height.saturating_sub(2) as usize; // subtract borders
-
- // Build all rendered lines first so we can scroll from the bottom
let lines: Vec<Line> = state
.messages
.iter()
.map(|msg| render_chat_line(msg))
.collect();
- // Apply scroll offset
- // scroll=0 means pinned to bottom (newest). scroll=N means N lines back from bottom.
- let total = lines.len();
- let max_scroll = total.saturating_sub(inner_height);
- let scroll = state.scroll.min(max_scroll);
- let visible_start = total.saturating_sub(inner_height + scroll);
- let visible: Vec<Line> = lines
- .into_iter()
- .skip(visible_start)
- .take(inner_height)
- .collect();
-
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(Style::default().fg(ORANGE))
.style(Style::default().bg(BG));
+ let inner_height = area.height.saturating_sub(2) as usize;
+ let scroll = lines.len().saturating_sub(inner_height);
f.render_widget(
- Paragraph::new(Text::from(visible))
+ Paragraph::new(Text::from(lines))
.block(block)
- .wrap(Wrap { trim: false }),
+ .wrap(Wrap { trim: false })
+ .scroll((scroll as u16, 0)),
area,
);
}
@@ -232,7 +220,7 @@ fn draw_statusbar(f: &mut Frame, area: Rect, state: &AppState) {
Span::styled(" │ ", Style::default().fg(FG)),
Span::styled(&state.status, Style::default().fg(FG)),
Span::styled(" │ ", Style::default().fg(FG)),
- Span::styled("PgUp/Dn scroll Ctrl-C quit", Style::default().fg(FG)),
+ Span::styled("Ctrl-C quit", Style::default().fg(FG)),
]);
f.render_widget(Paragraph::new(line).style(Style::default()), area);