diff --git a/Cargo.lock b/Cargo.lock index 6bf1058..806be8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + [[package]] name = "aliasable" version = "0.1.3" @@ -473,6 +482,7 @@ dependencies = [ "iced", "irc", "once_cell", + "regex", "tokio", ] @@ -2085,6 +2095,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + [[package]] name = "renderdoc-sys" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index bffbb58..636210a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,5 @@ futures = "0.3.29" iced = { version = "0.10.0", features = ["tokio"] } irc = "0.15.0" once_cell = "1.18.0" +regex = "1.10.2" tokio = { version = "1.33.0", features = ["full"] } diff --git a/src/main.rs b/src/main.rs index 2f6e9f6..281a1c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,10 +5,11 @@ use crate::{irc_message::IrcMessage, message_log::MessageLog}; use color_eyre::eyre::Result; use futures::StreamExt; use iced::{ + alignment::{Horizontal, Vertical}, executor, theme::Theme, widget::{column, container, mouse_area, row, text, text_input}, - Application, Background, Color, Length, Settings, + Application, Background, Color, Element, Length, Settings, }; use once_cell::sync::Lazy; use std::cell::RefCell; @@ -92,9 +93,6 @@ pub enum UiMessage { struct Cri { message_rx: RefCell>>, input_tx: RefCell>, - - active_channel: Option, - message_log: MessageLog, input_value: String, } @@ -110,9 +108,6 @@ impl Application for Cri { Self { message_rx: RefCell::new(Some(flags.message_rx)), input_tx: RefCell::new(flags.input_tx), - - active_channel: None, - message_log: MessageLog::new(), input_value: String::new(), }, @@ -134,15 +129,12 @@ impl Application for Cri { match &message.command { Command::JOIN(chanlist, _, _) => { - self.message_log.on_join(chanlist.clone(), &source_nickname); + self.message_log.on_join(chanlist, &source_nickname); } Command::PART(chanlist, comment) => { - self.message_log.on_part( - chanlist.clone(), - &source_nickname, - comment.as_deref(), - ); + self.message_log + .on_part(chanlist, &source_nickname, comment.as_deref()); } Command::NICK(new) => { @@ -157,7 +149,7 @@ impl Application for Cri { Command::PRIVMSG(msgtarget, content) => { let channel = message.response_target().unwrap_or(msgtarget).to_string(); self.message_log - .on_privmsg(channel, &source_nickname, content); + .on_privmsg(&channel, &source_nickname, content); } _ => self.message_log.on_other(&message.to_string()), @@ -165,25 +157,26 @@ impl Application for Cri { } UiMessage::InputChanged(text) => self.input_value = text, UiMessage::InputSubmitted => { - if let Some(active_channel) = &self.active_channel { + if let Some(active_channel) = &self.message_log.active_channel { let command = irc::proto::Command::PRIVMSG( active_channel.to_string(), self.input_value.clone(), ); let message: irc::proto::Message = command.into(); - self.message_log.get_mut(self.active_channel.clone()).push( - IrcMessage::Privmsg { + self.message_log + .get_mut(self.message_log.active_channel.clone()) + .messages + .push(IrcMessage::Privmsg { nickname: String::from("cri"), message: self.input_value.clone(), - }, - ); + }); self.input_tx.borrow().send(message.clone()).unwrap(); } self.input_value.clear(); } - UiMessage::HandleChannelPress(channel) => self.active_channel = channel, + UiMessage::HandleChannelPress(channel) => self.message_log.set_active(channel), } iced::Command::none() } @@ -202,8 +195,9 @@ impl Application for Cri { fn view(&self) -> iced::Element<'_, Self::Message, iced::Renderer> { let dark_grey = Color::new(0.58, 0.65, 0.65, 1.0); let light_blue = Color::new(0.26, 0.62, 0.85, 1.0); + let light_red = Color::new(0.99, 0.36, 0.40, 1.0); - let log = self.message_log.view(&self.active_channel); + let log = self.message_log.view(&self.message_log.active_channel); let message_box = text_input("your magnum opus", &self.input_value) .id(INPUT_ID.clone()) @@ -214,43 +208,113 @@ impl Application for Cri { self.message_log .get_all() .iter() - .map(|(&ref channel, log)| { - let is_active = &self.active_channel == channel; - let channel_name = match channel { + .map(|(channel_name, channel)| { + let is_active = self.message_log.active_channel == **channel_name; + let label = match channel_name { None => "Server", - Some(channel) => channel, + Some(channel_name) => channel_name, }; let text_color = if is_active { Some(Color::WHITE) } else { None }; let nickname_color = if is_active { Color::WHITE } else { light_blue }; let no_message_color = if is_active { Color::WHITE } else { dark_grey }; - let last_message = log - .iter() - .rev() - .find_map(|m| match m { - IrcMessage::Privmsg { nickname, message } => Some(row![ - text(format!("{nickname}: ")).style(nickname_color), - text(message) - ]), - _ => None, - }) - .unwrap_or(row![text("No messages").style(no_message_color)]); + let text_size = 14.0; + let last_message = container( + channel + .messages + .iter() + .rev() + .find_map(|m| -> Option> { + match m { + IrcMessage::Privmsg { nickname, message } => Some( + row![ + text(format!("{nickname}: ")) + .style(nickname_color) + .size(text_size), + text(message).size(text_size) + ] + .into(), + ), + IrcMessage::Join { nickname } => Some( + row![ + text(nickname).style(nickname_color).size(text_size), + text(" joined").style(no_message_color).size(text_size) + ] + .into(), + ), + _ => None, + } + }) + .unwrap_or( + text(if channel_name.is_some() { + "No messages" + } else { + "Server-related messages" + }) + .style(no_message_color) + .size(text_size) + .into(), + ), + ) + .padding([2, 0]); - let container = container(column![text(channel_name), last_message]) + let unread_events = channel.unread_events; + let unread_messages = channel.unread_messages; + let unread_highlights = channel.unread_highlights; + let unread_total = unread_events + unread_messages + unread_highlights; + + let unread_indicator = container( + container( + text(if unread_total > 0 { + unread_total.to_string() + } else { + String::new() + }) + .style(Color::WHITE) + .size(12), + ) + .width(20) + .height(20) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) .style(move |_: &_| container::Appearance { - background: match is_active { - true => Some(Background::Color(light_blue)), - false => None, - }, - text_color, + background: Some(Background::Color(if unread_highlights > 0 { + light_red + } else if unread_messages > 0 { + light_blue + } else if unread_events > 0 { + dark_grey + } else { + Color::TRANSPARENT + })), + border_radius: 12.0.into(), ..Default::default() - }) - .padding([4, 4]) - .width(Length::Fill); + }), + ) + .padding([4, 4]) + .align_y(Vertical::Center) + .height(Length::Fill); + + let container = container(row![ + column![text(label), last_message].width(Length::Fill), + unread_indicator + ]) + .style(move |_: &_| container::Appearance { + background: match is_active { + true => Some(Background::Color(light_blue)), + false => None, + }, + text_color, + ..Default::default() + }) + .padding([4, 4]) + .align_y(Vertical::Center) + .width(Length::Fill) + .height(54); mouse_area(container) - .on_press(UiMessage::HandleChannelPress(channel.clone())) + .on_press(UiMessage::HandleChannelPress((*channel_name).clone())) .into() }) .collect::>(), diff --git a/src/message_log.rs b/src/message_log.rs index bbd7738..c699a98 100644 --- a/src/message_log.rs +++ b/src/message_log.rs @@ -5,47 +5,78 @@ use iced::{ widget::{column, container, scrollable, text, Container}, Background, Color, Length, }; +use regex::Regex; use std::collections::HashMap; -pub struct MessageLog(HashMap, Vec>); +#[derive(Default)] +pub struct Channel { + pub messages: Vec, + pub unread_messages: i32, + pub unread_highlights: i32, + pub unread_events: i32, +} + +pub struct MessageLog { + channels: HashMap, Channel>, + pub active_channel: Option, +} impl<'a> MessageLog { pub fn new() -> Self { - let mut log = HashMap::new(); - log.insert(None, Vec::new()); - Self(log) + let mut channels = HashMap::new(); + channels.insert(None, Default::default()); + Self { + channels, + active_channel: None, + } } - pub fn get_all(&'a self) -> Vec<(&'a Option, &'a Vec)> { - let mut log: Vec<(&Option, &Vec)> = self.0.iter().collect(); + pub fn get_all(&'a self) -> Vec<(&'a Option, &'a Channel)> { + let mut log: Vec<_> = self.channels.iter().collect(); log.sort_unstable_by_key(|(name, _)| name.as_deref()); log } - pub fn get(&self, channel: &Option) -> Option<&Vec> { - self.0.get(channel) + pub fn get(&self, channel: &Option) -> Option<&Channel> { + self.channels.get(channel) } - pub fn get_mut(&mut self, channel: Option) -> &mut Vec { - self.0.entry(channel).or_insert_with(Vec::new) + pub fn get_mut(&mut self, channel_name: Option) -> &mut Channel { + self.channels.entry(channel_name).or_default() } - pub fn on_join(&mut self, channel: String, nickname: &str) { - self.get_mut(Some(channel)).push(IrcMessage::Join { + pub fn on_join(&mut self, channel_name: &str, nickname: &str) { + let is_active = self.active_channel.as_deref() != Some(channel_name); + + let channel = self.get_mut(Some(channel_name.to_string())); + channel.messages.push(IrcMessage::Join { nickname: nickname.to_string(), }); + + if is_active { + channel.unread_events += 1; + } } - pub fn on_part(&mut self, channel: String, nickname: &str, comment: Option<&str>) { - self.get_mut(Some(channel)).push(IrcMessage::Part { + pub fn on_part(&mut self, channel_name: &str, nickname: &str, comment: Option<&str>) { + let is_active = self.active_channel.as_deref() != Some(channel_name); + + let channel = self.get_mut(Some(channel_name.to_string())); + channel.messages.push(IrcMessage::Part { nickname: nickname.to_string(), reason: comment.map(str::to_string), }); + + if is_active { + channel.unread_events += 1; + } } pub fn on_nick(&mut self, old: &str, new: &str) { - for log in self.0.values_mut() { - log.push(IrcMessage::Nick { + // TODO increment event counter for each relevant channel + for log in self.channels.values_mut() { + // TODO only show in relevant channels + log.messages.push(IrcMessage::Nick { old: old.to_string(), new: new.to_string(), }); @@ -53,25 +84,48 @@ impl<'a> MessageLog { } pub fn on_quit(&mut self, nickname: &str, reason: Option<&str>) { - for log in self.0.values_mut() { - log.push(IrcMessage::Quit { + // TODO increment event counter for each relevant channel + for log in self.channels.values_mut() { + // TODO only show in relevant channels + log.messages.push(IrcMessage::Quit { nickname: nickname.to_string(), reason: reason.map(str::to_string), }) } } - pub fn on_privmsg(&mut self, channel: String, nickname: &str, message: &str) { - self.get_mut(Some(channel)).push(IrcMessage::Privmsg { + pub fn on_privmsg(&mut self, channel_name: &str, nickname: &str, message: &str) { + let is_active = self.active_channel.as_deref() == Some(channel_name); + + let channel = self.get_mut(Some(channel_name.to_string())); + channel.messages.push(IrcMessage::Privmsg { nickname: nickname.to_string(), message: message.to_string(), + }); + + if !is_active { + // TODO Configurable nickname + let highlight_regex = Regex::new(r"\bcri\b").unwrap(); + if highlight_regex.is_match(message) { + channel.unread_highlights += 1; + } else { + channel.unread_messages += 1; + } + } + } + + pub fn on_other(&mut self, message: &str) { + self.get_mut(None).messages.push(IrcMessage::Other { + message: message.trim().to_string(), }) } - pub(crate) fn on_other(&mut self, message: &str) { - self.get_mut(None).push(IrcMessage::Other { - message: message.trim().to_string(), - }) + pub fn set_active(&mut self, channel_name: Option) { + self.active_channel = channel_name.clone(); + let channel = self.get_mut(channel_name); + channel.unread_events = 0; + channel.unread_messages = 0; + channel.unread_highlights = 0; } pub fn view(&self, active_channel: &Option) -> Container<'_, crate::UiMessage> { @@ -102,6 +156,7 @@ impl<'a> MessageLog { let messages = self .get(active_channel) .unwrap() + .messages .iter() .flat_map(|message| -> Option> { match message { @@ -197,7 +252,6 @@ impl<'a> MessageLog { IrcMessage::Other { message } => Some(text(message).into()), } }) - .map(|element| element.into()) .collect::>(); container(