diff options
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 268 |
1 files changed, 268 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b7d4365 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,268 @@ +use std::io; +use std::time::Duration; + +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +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::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)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let result = run(&mut terminal).await; + + // ── Restore terminal ────────────────────────────────────────────────── + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture, + )?; + terminal.show_cursor()?; + + result +} + +async fn run( + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, +) -> Result<(), Box<dyn std::error::Error>> { + let mut app = AppState::new(NICK, CHANNEL); + + // ── Connect to IRC ──────────────────────────────────────────────────── + let config = Config { + server: "irc.libera.chat:6667".to_string(), + nick: 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?; + + // ── Main event loop ─────────────────────────────────────────────────── + // We poll both IRC events and keyboard events with short timeouts so + // neither blocks the other. + loop { + // Draw + terminal.draw(|f| ui::draw(f, &app))?; + + // Poll crossterm for keyboard input (non-blocking, 20ms timeout) + if event::poll(Duration::from_millis(20))? { + if let Event::Key(key) = event::read()? { + match (key.modifiers, key.code) { + // Quit + (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + + // Send message / command + (_, KeyCode::Enter) => { + let text = app.take_input(); + if !text.is_empty() { + handle_input(&text, &mut app, &mut client); + } + } + + // Typing + (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => { + app.input_insert(c); + } + (_, KeyCode::Backspace) => app.input_backspace(), + (_, KeyCode::Left) => app.cursor_left(), + (_, 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), + + _ => {} + } + } + } + + // Drain IRC events (non-blocking — try_recv drains without waiting) + // We use a small loop to process a burst without starving the UI + for _ in 0..16 { + match client.next_event_nowait() { + Some(irc_event) => handle_irc_event(irc_event, &mut app), + None => break, + } + } + } + + Ok(()) +} + +/// Handle a line entered in the input box. +fn handle_input(text: &str, app: &mut AppState, client: &mut Client) { + if let Some(cmd) = text.strip_prefix('/') { + // It's a command + let mut parts = cmd.splitn(2, ' '); + let verb = parts.next().unwrap_or("").to_uppercase(); + let args = parts.next().unwrap_or(""); + + match verb.as_str() { + "JOIN" => { + client.join(args.trim()); + } + "PART" => { + let channel = if args.is_empty() { + &app.channel + } else { + args.trim() + }; + client.part(channel, None); + } + "NICK" => { + client.nick(args.trim()); + } + "QUIT" => { + client.send(IrcMessage::new(Command::Quit, vec![args.to_string()])); + } + "ME" => { + // CTCP ACTION + let ctcp = format!("\x01ACTION {}\x01", args); + client.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); + app.push_system(&format!("→ {}: {}", target, msg)); + } + } + other => { + app.push_system(&format!("Unknown command: /{}", other)); + } + } + } else { + // Regular chat message to active channel + client.privmsg(&app.channel, text); + app.push_message(&app.nick.clone(), text, true); + } +} + +/// Apply an IRC event to the app state. +fn handle_irc_event(event: IrcEvent, app: &mut AppState) { + match event { + IrcEvent::Connected { server, nick } => { + app.nick = nick.clone(); + app.connected = true; + app.status = format!("connected to {}", server); + app.push_system(&format!("Connected to {} as {}", server, nick)); + } + + IrcEvent::Joined { channel } => { + app.push_system(&format!("You joined {}", channel)); + } + + IrcEvent::Message { + from, + target, + text, + is_notice: _, + } => { + // Only show messages for our active channel (or PMs to us) + let is_self = from == app.nick; + if !is_self { + // Don't re-echo our own messages (we already pushed them in handle_input) + if target == app.channel || target == app.nick { + app.push_message(&from, &text, false); + } + } + } + + IrcEvent::Parted { + channel, + nick, + reason, + } => { + app.members.retain(|m| { + let bare = m.trim_start_matches(&['@', '+', '%'][..]); + bare != nick + }); + app.push_system(&format!( + "{} left {}{}", + nick, + channel, + reason.map(|r| format!(" ({})", r)).unwrap_or_default() + )); + } + + IrcEvent::Quit { nick, reason } => { + app.members.retain(|m| { + let bare = m.trim_start_matches(&['@', '+', '%'][..]); + bare != nick + }); + app.push_system(&format!( + "{} quit{}", + nick, + reason.map(|r| format!(" ({})", r)).unwrap_or_default() + )); + } + + IrcEvent::NickChanged { old_nick, new_nick } => { + for m in &mut app.members { + let bare = m.trim_start_matches(&['@', '+', '%'][..]).to_string(); + if bare == old_nick { + let sigil: String = m.chars().take_while(|c| "@+%~&".contains(*c)).collect(); + *m = format!("{}{}", sigil, new_nick); + break; + } + } + if old_nick == app.nick { + app.nick = new_nick.clone(); + } + app.push_system(&format!("{} is now {}", old_nick, new_nick)); + } + + IrcEvent::Topic { channel, topic } => { + app.status = format!("{}: {}", channel, topic); + app.push_system(&format!("Topic: {}", topic)); + } + + IrcEvent::Names { + channel: _, + members, + } => { + for m in members { + if !app + .members + .iter() + .any(|existing| existing.trim_start_matches(&['@', '+', '%'][..]) == m) + { + app.members.push(m); + } + } + app.sort_members(); + } + + IrcEvent::Disconnected => { + app.connected = false; + app.status = "disconnected".to_string(); + app.push_system("--- Disconnected ---"); + } + + IrcEvent::Raw(_) => {} + } +} |
