diff --git a/src/gui/chat.rs b/src/gui/chat.rs index c18305d..d11d340 100644 --- a/src/gui/chat.rs +++ b/src/gui/chat.rs @@ -190,9 +190,13 @@ impl ChatWindow { body.heterogeneous_rows(heights, |mut row| { let row_index = row.index(); last_visible_row = row_index; - row.col(|ui| { - self.show_regular_chat_single_message(ui, state, ch, row_index); - }); + if state.filter.matches(&ch.messages[row_index]) { + row.col(|ui| { + self.show_regular_chat_single_message( + ui, state, ch, row_index, + ); + }); + } }); } else { match state.active_chat_tab_name.as_str() { diff --git a/src/gui/filter.rs b/src/gui/filter.rs new file mode 100644 index 0000000..b8f6c01 --- /dev/null +++ b/src/gui/filter.rs @@ -0,0 +1,144 @@ +use eframe::egui; + +use steel_core::chat::Message; + +use super::state::UIState; + +// FIXME: This works on a premise that `input` of all filters is always lowercase, which isn't necessarily true +// (since a user can modify it directly). + +// FIXME: A proper solution is keep a copy of all messages in lowercase somewhere to avoid doing that on every iteration. + +pub trait FilterCondition: Sized { + fn matches(&self, message: &Message) -> bool; + fn reset(&mut self); +} + +#[derive(Debug, Default)] +pub struct UsernameFilter { + pub input: String, +} + +impl From<&str> for UsernameFilter { + fn from(value: &str) -> Self { + Self { + input: value.to_lowercase().to_owned(), + } + } +} + +impl FilterCondition for UsernameFilter { + fn matches(&self, message: &Message) -> bool { + if self.input.is_empty() { + return true; + } + message.username.to_lowercase().contains(&self.input) + } + + fn reset(&mut self) { + self.input.clear(); + } +} + +#[derive(Debug, Default)] +pub struct TextFilter { + pub input: String, +} + +impl From<&str> for TextFilter { + fn from(value: &str) -> Self { + Self { + input: value.to_lowercase().to_owned(), + } + } +} + +impl FilterCondition for TextFilter { + fn matches(&self, message: &Message) -> bool { + if self.input.is_empty() { + return true; + } + message.text.to_lowercase().contains(&self.input) + } + + fn reset(&mut self) { + self.input.clear(); + } +} + +#[derive(Debug, Default)] +pub struct FilterCollection { + pub username: UsernameFilter, + pub text: TextFilter, + pub active: bool, +} + +impl FilterCollection { + pub fn matches(&self, message: &Message) -> bool { + if !self.active { + return true; + } + self.username.matches(message) && self.text.matches(message) + } + + pub fn reset(&mut self) { + self.username.reset(); + self.text.reset(); + } +} + +#[derive(Default)] +pub struct FilterWindow { + show_ui: bool, +} + +impl FilterWindow { + pub fn show(&mut self, ctx: &egui::Context, state: &mut UIState) { + self.show_ui = state.filter.active; // Handle menu clicks (chat > find...). + let mut activated_now = false; // Handle Ctrl-F presses that happened during the current frame. + + if !self.show_ui && ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) { + self.show_ui = true; + state.filter.active = true; + activated_now = true; + } + + if self.show_ui && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + self.show_ui = false; + activated_now = false; + } + + egui::Window::new("chat filter") + .auto_sized() + .open(&mut self.show_ui) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("message"); + let resp = ui.add( + egui::TextEdit::singleline(&mut state.filter.text.input) + .desired_width(150.), + ); + if activated_now { + resp.request_focus(); + } + }); + ui.horizontal(|ui| { + ui.label("username"); + ui.add( + egui::TextEdit::singleline(&mut state.filter.username.input) + .desired_width(150.), + ); + }); + if ui.button("reset").clicked() { + state.filter.reset(); + } + }); + }); + + // Deactivate the filter if the window has been closed during the current frame. + if !self.show_ui { + state.filter.active = false; + } + } +} diff --git a/src/gui/menu.rs b/src/gui/menu.rs index e00239d..c4db8c0 100644 --- a/src/gui/menu.rs +++ b/src/gui/menu.rs @@ -110,6 +110,13 @@ impl Menu { response_widget_id: &mut Option, ) { ui.menu_button("chat", |ui| { + if ui.button("find...").clicked() { + state.filter.active = true; + ui.close_menu(); + } + + ui.separator(); + let (action, enabled) = match state.connection { ConnectionStatus::Disconnected { .. } => ("connect".to_owned(), true), ConnectionStatus::InProgress => ("connecting...".to_owned(), false), diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 71babf0..331e7b6 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -7,6 +7,7 @@ pub mod about; pub mod chat; pub mod chat_tabs; pub mod command; +pub mod filter; pub mod highlights; pub mod menu; pub mod settings; diff --git a/src/gui/state/mod.rs b/src/gui/state/mod.rs index c5f4f73..d0a1495 100644 --- a/src/gui/state/mod.rs +++ b/src/gui/state/mod.rs @@ -11,6 +11,7 @@ use crate::core::settings::Settings; use crate::gui::highlights; +use super::filter::FilterCollection; use super::{HIGHLIGHTS_SEPARATOR, HIGHLIGHTS_TAB_NAME, SERVER_TAB_NAME}; #[derive(Debug)] @@ -42,6 +43,8 @@ pub struct UIState { #[cfg(feature = "glass")] pub glass: glass::Glass, + + pub filter: FilterCollection, } impl UIState { @@ -60,6 +63,8 @@ impl UIState { #[cfg(feature = "glass")] glass: glass::Glass::default(), + + filter: FilterCollection::default(), } } diff --git a/src/gui/window.rs b/src/gui/window.rs index f2a8f70..f88c110 100644 --- a/src/gui/window.rs +++ b/src/gui/window.rs @@ -136,6 +136,7 @@ pub struct ApplicationWindow { ui_queue: Receiver, s: UIState, date_announcer: DateAnnouncer, + filter_ui: gui::filter::FilterWindow, } impl ApplicationWindow { @@ -157,6 +158,7 @@ impl ApplicationWindow { ui_queue, s: UIState::new(app_queue_handle), date_announcer: DateAnnouncer::default(), + filter_ui: gui::filter::FilterWindow::default(), } } @@ -345,6 +347,9 @@ impl eframe::App for ApplicationWindow { self.menu .show(ctx, frame, &mut self.s, &mut self.chat.response_widget_id); self.chat_tabs.show(ctx, &mut self.s); + + self.filter_ui.show(ctx, &mut self.s); + self.chat.show(ctx, &self.s); if !self.menu.dialogs_visible() {