From 1ff495a8bc5415aa82fdb95aa666e10aa04217de Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 16 Oct 2024 22:08:44 +0600 Subject: [PATCH 1/5] Offload table handling to lib --- Cargo.lock | 13 + Cargo.toml | 22 + src/ui_components/processor/states.rs | 68 +- src/ui_components/processor/tg_comms.rs | 2 +- src/ui_components/tab_ui/user_table.rs | 1080 ++++++----------------- src/ui_components/tab_ui/whitelist.rs | 314 ++++--- src/utils.rs | 6 +- 7 files changed, 478 insertions(+), 1027 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 735216c..70bfa85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,6 +1063,17 @@ dependencies = [ "egui", ] +[[package]] +name = "egui-selectable-table" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c48ddca1f87e81072f22d28e31d84e800e62d0874b5026e963e9fcb8ddcba2" +dependencies = [ + "egui", + "egui_extras", + "rayon", +] + [[package]] name = "egui-theme-lerp" version = "0.1.2" @@ -1245,6 +1256,7 @@ dependencies = [ "log", "nohash-hasher", "parking_lot", + "rayon", ] [[package]] @@ -3754,6 +3766,7 @@ dependencies = [ "eframe", "egui-dropdown", "egui-modal", + "egui-selectable-table", "egui-theme-lerp", "egui_extras", "egui_plot", diff --git a/Cargo.toml b/Cargo.toml index 55aca99..978c30a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,3 +48,25 @@ rayon = "1.10.0" csv = "1.3.0" strum = "0.26.3" strum_macros = "0.26.4" +egui-selectable-table = "0.1.0" + +# The profile that 'cargo dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" + +# Config for 'cargo dist' +[workspace.metadata.dist] +# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.22.1" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = [] +# Target platforms to build apps for (Rust target-triple syntax) +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] diff --git a/src/ui_components/processor/states.rs b/src/ui_components/processor/states.rs index 768abfc..459435f 100644 --- a/src/ui_components/processor/states.rs +++ b/src/ui_components/processor/states.rs @@ -45,7 +45,7 @@ pub enum ProcessState { InitialClientConnectionSuccessful(String), Counting(u8), InvalidStartChat, - DataCopied(i32), + DataCopied, AuthorizationError, FileCreationFailed, UnauthorizedClient(String), @@ -107,8 +107,8 @@ impl Display for ProcessState { Ok(()) } ProcessState::InvalidStartChat => write!(f, "Status: Could not detect any valid chat details"), - ProcessState::DataCopied(num) => { - write!(f, "Status: Table data copied. Total cells: {num}",) + ProcessState::DataCopied => { + write!(f, "Status: Selected table data copied.",) } ProcessState::AuthorizationError => write!( f, @@ -156,13 +156,6 @@ impl Display for ProcessState { } } -#[derive(Default)] -pub enum SortOrder { - #[default] - Ascending, - Descending, -} - #[derive(EnumIter, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Copy)] pub enum ColumnName { #[default] @@ -198,61 +191,6 @@ impl fmt::Display for ColumnName { } } -impl ColumnName { - pub fn get_next(&self) -> Self { - match self { - ColumnName::Name => ColumnName::Username, - ColumnName::Username => ColumnName::UserID, - ColumnName::UserID => ColumnName::TotalMessage, - ColumnName::TotalMessage => ColumnName::TotalWord, - ColumnName::TotalWord => ColumnName::TotalChar, - ColumnName::TotalChar => ColumnName::AverageWord, - ColumnName::AverageWord => ColumnName::AverageChar, - ColumnName::AverageChar => ColumnName::FirstMessageSeen, - ColumnName::FirstMessageSeen => ColumnName::LastMessageSeen, - ColumnName::LastMessageSeen => ColumnName::Whitelisted, - ColumnName::Whitelisted => ColumnName::Name, - } - } - - pub fn get_previous(&self) -> Self { - match self { - ColumnName::Name => ColumnName::Whitelisted, - ColumnName::Username => ColumnName::Name, - ColumnName::UserID => ColumnName::Username, - ColumnName::TotalMessage => ColumnName::UserID, - ColumnName::TotalWord => ColumnName::TotalMessage, - ColumnName::TotalChar => ColumnName::TotalWord, - ColumnName::AverageWord => ColumnName::TotalChar, - ColumnName::AverageChar => ColumnName::AverageWord, - ColumnName::FirstMessageSeen => ColumnName::AverageChar, - ColumnName::LastMessageSeen => ColumnName::FirstMessageSeen, - ColumnName::Whitelisted => ColumnName::LastMessageSeen, - } - } - - pub fn from_num(num: i32) -> Self { - match num { - 0 => ColumnName::Name, - 1 => ColumnName::Username, - 2 => ColumnName::UserID, - 3 => ColumnName::TotalMessage, - 4 => ColumnName::TotalWord, - 5 => ColumnName::TotalChar, - 6 => ColumnName::AverageWord, - 7 => ColumnName::AverageChar, - 8 => ColumnName::FirstMessageSeen, - 9 => ColumnName::LastMessageSeen, - 10 => ColumnName::Whitelisted, - _ => unreachable!("Invalid enum variant for number {}", num), - } - } - - pub fn get_last() -> Self { - ColumnName::Whitelisted - } -} - #[derive(Default, PartialEq, Clone, Copy)] pub enum ChartType { #[default] diff --git a/src/ui_components/processor/tg_comms.rs b/src/ui_components/processor/tg_comms.rs index ecf3ffe..bc0626f 100644 --- a/src/ui_components/processor/tg_comms.rs +++ b/src/ui_components/processor/tg_comms.rs @@ -111,7 +111,7 @@ impl MainWindow { } let total_user = self.t_table().get_total_user(); - self.t_count().set_total_user(total_user); + self.t_count().set_total_user(total_user as i32); let total_to_iter = start_from - end_at; let message_value = 100.0 / total_to_iter as f32; diff --git a/src/ui_components/tab_ui/user_table.rs b/src/ui_components/tab_ui/user_table.rs index 522c836..e6c1f4c 100644 --- a/src/ui_components/tab_ui/user_table.rs +++ b/src/ui_components/tab_ui/user_table.rs @@ -1,12 +1,13 @@ use chrono::{NaiveDate, NaiveDateTime}; use eframe::egui::{ - Align, Button, ComboBox, Event, Key, Label, Layout, Response, RichText, SelectableLabel, Sense, - Ui, + Align, Button, ComboBox, Key, Layout, Response, RichText, SelectableLabel, Sense, Ui, +}; +use egui_extras::{Column, DatePickerButton}; +use egui_selectable_table::{ + ColumnOperations, ColumnOrdering, SelectableRow, SelectableTable, SortOrder, }; -use egui_extras::{Column, DatePickerButton, TableBuilder}; use grammers_client::types::{Chat, Message}; use log::info; -use rayon::prelude::*; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::env::current_dir; @@ -14,12 +15,19 @@ use strum::IntoEnumIterator; use crate::ui_components::processor::{ ColumnName, DateNavigator, NavigationType, PackedBlacklistedUser, PackedWhitelistedUser, - ProcessState, SortOrder, + ProcessState, }; use crate::ui_components::widgets::{AnimatedLabel, RowLabel}; use crate::ui_components::MainWindow; use crate::utils::{entry_insert_user, export_table_data, to_chart_name}; +#[derive(Default)] +pub struct Config { + whitelist_rows: bool, + blacklisted_rows: bool, + copy_selected: bool, +} + #[derive(Clone, Serialize)] pub struct UserRowData { name: String, @@ -34,13 +42,171 @@ pub struct UserRowData { last_seen: NaiveDateTime, whitelisted: bool, #[serde(skip_serializing)] - selected_columns: HashSet, - #[serde(skip_serializing)] belongs_to: Option, #[serde(skip_serializing)] seen_by: String, } +impl ColumnOperations for ColumnName { + fn column_text(&self, row: &UserRowData) -> String { + match self { + ColumnName::Name => row.name.to_string(), + ColumnName::Username => row.username.to_string(), + ColumnName::UserID => row.id.to_string(), + ColumnName::TotalMessage => row.total_message.to_string(), + ColumnName::TotalWord => row.total_word.to_string(), + ColumnName::TotalChar => row.total_char.to_string(), + ColumnName::AverageWord => row.average_word.to_string(), + ColumnName::AverageChar => row.average_char.to_string(), + ColumnName::FirstMessageSeen => row.first_seen.to_string(), + ColumnName::LastMessageSeen => row.last_seen.to_string(), + ColumnName::Whitelisted => row.whitelisted.to_string(), + } + } + fn create_header( + &self, + ui: &mut eframe::egui::Ui, + sort_order: Option, + _table: &mut SelectableTable, + ) -> Option { + let mut label_text = self.to_string(); + let hover_text = match self { + ColumnName::Name => "Telegram name of the user. Click to sort by name".to_string(), + ColumnName::Username => { + "Telegram username of the user. Click to sort by username".to_string() + } + ColumnName::UserID => { + "Telegram User ID of the user. Click to sort by user ID".to_string() + } + ColumnName::TotalMessage => { + "Total messages sent by the user. Click to sort by total message".to_string() + } + ColumnName::TotalWord => { + "Total words in the messages. Click to sort by total words".to_string() + } + ColumnName::TotalChar => { + "Total character in the messages. Click to sort by total character".to_string() + } + ColumnName::AverageWord => { + "Average number of words per message. Click to sort by average words".to_string() + } + ColumnName::AverageChar => { + "Average number of characters per message. Click to sort by average characters" + .to_string() + } + + ColumnName::FirstMessageSeen => { + "The day the first message that was sent by this user was observed".to_string() + } + ColumnName::LastMessageSeen => { + "The day the last message that was sent by this user was observed".to_string() + } + ColumnName::Whitelisted => { + "Whether this user is whitelisted. Click to sort by whitelist".to_string() + } + }; + + let is_selected = if let Some(direction) = sort_order { + match direction { + SortOrder::Ascending => label_text.push('↓'), + SortOrder::Descending => label_text.push('↑'), + } + true + } else { + false + }; + + let label_text = RichText::new(label_text).strong(); + + let response = ui + .add_sized( + ui.available_size(), + SelectableLabel::new(is_selected, label_text), + ) + .on_hover_text(hover_text); + Some(response) + } + fn create_table_row( + &self, + ui: &mut Ui, + row: &SelectableRow, + column_selected: bool, + table: &mut SelectableTable, + ) -> Response { + let row_data = &row.row_data; + let mut show_tooltip = false; + let row_text = match self { + ColumnName::Name => { + show_tooltip = true; + row_data.name.clone() + } + ColumnName::Username => { + show_tooltip = true; + row_data.username.clone() + } + ColumnName::UserID => row_data.id.to_string(), + ColumnName::TotalMessage => row_data.total_message.to_string(), + ColumnName::TotalWord => row_data.total_word.to_string(), + ColumnName::TotalChar => row_data.total_char.to_string(), + ColumnName::AverageWord => row_data.average_word.to_string(), + ColumnName::AverageChar => row_data.average_char.to_string(), + ColumnName::FirstMessageSeen => row_data.first_seen.to_string(), + ColumnName::LastMessageSeen => row_data.last_seen.to_string(), + ColumnName::Whitelisted => { + let text = if row_data.whitelisted { "Yes" } else { "No" }; + text.to_string() + } + }; + let is_selected = column_selected; + let is_whitelisted = row_data.whitelisted; + + let mut label = ui + .add_sized( + ui.available_size(), + RowLabel::new(is_selected, is_whitelisted, &row_text), + ) + .interact(Sense::drag()); + + if show_tooltip { + label = label.on_hover_text(row_text); + }; + label.context_menu(|ui| { + if ui.button("Copy selected rows").clicked() { + table.config.copy_selected = true; + ui.close_menu(); + }; + if ui.button("Whitelist selected rows").clicked() { + table.config.whitelist_rows = true; + ui.close_menu(); + }; + + if ui.button("Blacklist selected rows").clicked() { + table.config.blacklisted_rows = true; + ui.close_menu(); + }; + }); + label + } +} + +impl ColumnOrdering for ColumnName { + fn order_by(&self, row_1: &UserRowData, row_2: &UserRowData) -> std::cmp::Ordering { + match self { + ColumnName::Name => row_1.name.cmp(&row_2.name), + ColumnName::Username => row_1.username.cmp(&row_2.username), + ColumnName::UserID => row_1.id.cmp(&row_2.id), + ColumnName::TotalMessage => row_1.total_message.cmp(&row_2.total_message), + ColumnName::TotalWord => row_1.total_word.cmp(&row_2.total_word), + ColumnName::TotalChar => row_1.total_char.cmp(&row_2.total_char), + ColumnName::AverageWord => row_1.average_word.cmp(&row_2.average_word), + ColumnName::AverageChar => row_1.average_char.cmp(&row_2.average_char), + ColumnName::FirstMessageSeen => row_1.first_seen.cmp(&row_2.first_seen), + ColumnName::LastMessageSeen => row_1.last_seen.cmp(&row_2.last_seen), + ColumnName::Whitelisted => row_1.whitelisted.cmp(&row_2.whitelisted), + } + } +} + impl UserRowData { fn new( name: &str, @@ -65,7 +231,6 @@ impl UserRowData { first_seen: date, last_seen: date, whitelisted, - selected_columns: HashSet::new(), belongs_to, seen_by, } @@ -102,80 +267,38 @@ impl UserRowData { fn set_last_seen(&mut self, date: NaiveDateTime) { self.last_seen = date; } - - /// Get the current length of a column of this row - fn get_column_length(&self, column: ColumnName) -> usize { - match column { - ColumnName::Name => self.name.len(), - ColumnName::Username => self.username.len(), - ColumnName::UserID => self.id.to_string().len(), - ColumnName::TotalMessage => self.total_message.to_string().len(), - ColumnName::TotalWord => self.total_word.to_string().len(), - ColumnName::TotalChar => self.total_char.to_string().len(), - ColumnName::AverageWord => self.average_word.to_string().len(), - ColumnName::AverageChar => self.average_char.to_string().len(), - ColumnName::FirstMessageSeen => self.first_seen.to_string().len(), - ColumnName::LastMessageSeen => self.last_seen.to_string().len(), - ColumnName::Whitelisted => self.whitelisted.to_string().len(), - } - } - - /// Get the text of a column of this row - fn get_column_text(&self, column: ColumnName) -> String { - match column { - ColumnName::Name => self.name.to_string(), - ColumnName::Username => self.username.to_string(), - ColumnName::UserID => self.id.to_string(), - ColumnName::TotalMessage => self.total_message.to_string(), - ColumnName::TotalWord => self.total_word.to_string(), - ColumnName::TotalChar => self.total_char.to_string(), - ColumnName::AverageWord => self.average_word.to_string(), - ColumnName::AverageChar => self.average_char.to_string(), - ColumnName::FirstMessageSeen => self.first_seen.to_string(), - ColumnName::LastMessageSeen => self.last_seen.to_string(), - ColumnName::Whitelisted => self.whitelisted.to_string(), - } - } } -#[derive(Default)] pub struct UserTableData { /// Key: The Date where at least one message/User was found /// Value: A hashmap of the founded User with their user id as the key /// Contains all data points and UI points are recreated from here user_data: HashMap>, - // The row data that is currently visible in the UI - rows: HashMap, - /// Rows in the sorted order - formatted_rows: Vec, - /// Column that the rows are sorted by - sorted_by: ColumnName, - /// Whether are sorting by Descending or Ascending - sort_order: SortOrder, - /// The cell where dragging started - drag_started_on: Option<(i64, ColumnName)>, - /// Columns with at least 1 selected row - active_columns: HashSet, - /// Rows with at least 1 selected column - active_rows: HashSet, - /// The row the mouse pointer was on last frame load - last_active_row: Option, - /// The column the mouse pointer was on last frame load - last_active_column: Option, - /// To track whether the mouse pointer went beyond the drag point at least once - beyond_drag_point: bool, - /// User Id to index number in `formatted_rows` - indexed_user_ids: HashMap, + table: SelectableTable, date_nav: DateNavigator, total_whitelisted_user: u32, total_message: u32, total_whitelisted_message: u32, - /// Current offset of the vertical scroll area. - /// Never goes below zero. - v_offset: f32, reload_count: u8, } +impl Default for UserTableData { + fn default() -> Self { + let table = SelectableTable::new(ColumnName::iter().collect()) + .auto_scroll() + .serial_column(); + Self { + user_data: HashMap::new(), + table, + date_nav: DateNavigator::default(), + total_whitelisted_message: 0, + total_message: 0, + total_whitelisted_user: 0, + reload_count: 0, + } + } +} + impl UserTableData { pub fn reload_count(&self) -> u8 { self.reload_count @@ -183,7 +306,6 @@ impl UserTableData { pub fn reset_reload_count(&mut self) { self.reload_count = 0; } - /// Add a user to the table pub fn add_user( &mut self, @@ -279,25 +401,14 @@ impl UserTableData { user_row_data.increment_total_char(total_char); } - pub fn get_total_user(&self) -> i32 { - self.rows.len() as i32 - } - - /// Returns all existing row in the current sorted format in a vector - fn rows(&mut self) -> Vec { - // It needs to be sorted each load otherwise - // `self.rows` gets updated with newer data - // Unless recreated after an update, the UI will show outdated data - if self.formatted_rows.is_empty() || self.formatted_rows.len() != self.rows.len() { - self.formatted_rows = self.sort_rows(); - } - self.formatted_rows.clone() + pub fn get_total_user(&self) -> usize { + self.table.total_displayed_rows() } /// Recreate the rows that will be shown in the UI. Used only when date picker date is updated pub fn create_rows(&mut self) { - let mut row_data = HashMap::new(); - + let mut id_map = HashMap::new(); + self.table.clear_all_rows(); let mut total_message = 0; let mut whitelisted_user = HashSet::new(); let mut whitelisted_message = 0; @@ -315,309 +426,37 @@ impl UserTableData { whitelisted_message += row.total_message; } - if row_data.contains_key(id) { - let user_row_data: &mut UserRowData = row_data.get_mut(id).unwrap(); - if user_row_data.first_seen > row.first_seen { - user_row_data.set_first_seen(row.first_seen); - } + if let Some(row_id) = id_map.get(id) { + self.table.add_modify_row(|rows| { + let target_row = rows.get_mut(row_id).unwrap(); + let user_row_data = &mut target_row.row_data; + if user_row_data.first_seen > row.first_seen { + user_row_data.set_first_seen(row.first_seen); + } - if user_row_data.last_seen < row.last_seen { - user_row_data.set_last_seen(row.last_seen); - } + if user_row_data.last_seen < row.last_seen { + user_row_data.set_last_seen(row.last_seen); + } - let total_char = row.total_char; - let total_word = row.total_word; - let total_message = row.total_message; + let total_char = row.total_char; + let total_word = row.total_word; + let total_message = row.total_message; - user_row_data.increase_message_by(total_message); - user_row_data.increment_total_word(total_word); - user_row_data.increment_total_char(total_char); + user_row_data.increase_message_by(total_message); + user_row_data.increment_total_word(total_word); + user_row_data.increment_total_char(total_char); + None + }); } else { - row_data.insert(*id, row.clone()); + let new_id = self.table.add_modify_row(|_| Some(row.clone())); + id_map.insert(row.id, new_id.unwrap()); } } } - self.rows = row_data; self.total_whitelisted_message = whitelisted_message; self.total_message = total_message; self.total_whitelisted_user = whitelisted_user.len() as u32; - self.formatted_rows.clear(); - self.active_rows.clear(); - self.active_columns.clear(); - } - - /// Marks a single column of a row as selected - fn select_single_row_cell(&mut self, user_id: i64, column_name: ColumnName) { - self.active_columns.insert(column_name); - self.active_rows.insert(user_id); - - let target_index = self.indexed_user_ids.get(&user_id).unwrap(); - - self.formatted_rows - .get_mut(*target_index) - .unwrap() - .selected_columns - .insert(column_name); - } - - /// Continuously called to select rows and columns when dragging has started - fn select_dragged_row_cell( - &mut self, - user_id: i64, - column_name: ColumnName, - is_ctrl_pressed: bool, - ) { - // If both same then the mouse is still on the same column on the same row so nothing to process - if self.last_active_row == Some(user_id) && self.last_active_column == Some(column_name) { - return; - } - - self.active_columns.insert(column_name); - self.beyond_drag_point = true; - - let drag_start = self.drag_started_on.unwrap(); - - // number of the column of drag starting point and the current cell that we are trying to select - let drag_start_num = drag_start.1 as i32; - let ongoing_column_num = column_name as i32; - - let mut new_column_set = HashSet::new(); - - let get_previous = ongoing_column_num > drag_start_num; - let mut ongoing_val = Some(ColumnName::from_num(drag_start_num)); - - // row1: column(drag started here) column column - // row2: column column column - // row3: column column column - // row4: column column column (currently here) - // - // The goal of this is to ensure from the drag starting point to all the columns till the currently here - // are considered selected and the rest are removed from active selection even if it was considered active - // - // During fast mouse movement active rows can contain columns that are not in the range we are targeting - // We go from one point to the other point and ensure except those columns nothing else is selected - // - // No active row removal if ctrl is being pressed! - if is_ctrl_pressed { - self.active_columns.insert(column_name); - } else if ongoing_column_num == drag_start_num { - new_column_set.insert(ColumnName::from_num(drag_start_num)); - self.active_columns = new_column_set; - } else { - while ongoing_val.is_some() { - let col = ongoing_val.unwrap(); - - let next_column = if get_previous { - col.get_next() - } else { - col.get_previous() - }; - - new_column_set.insert(col); - - if next_column == ColumnName::from_num(ongoing_column_num) { - new_column_set.insert(next_column); - ongoing_val = None; - } else { - ongoing_val = Some(next_column); - } - } - self.active_columns = new_column_set; - } - - let current_row_index = self.indexed_user_ids.get(&user_id).unwrap(); - // The row the mouse pointer is on - let current_row = self.formatted_rows.get_mut(*current_row_index).unwrap(); - - // If this row already selects the column that we are trying to select, it means the mouse - // moved backwards from an active column to another active column. - // - // Row: column1 column2 (mouse is here) column3 column4 - // - // In this case, if column 3 or 4 is also found in the active selection then - // the mouse moved backwards - let row_contains_column = current_row.selected_columns.contains(&column_name); - - let mut no_checking = false; - // If we have some data of the last row and column that the mouse was on, then try to unselect - if row_contains_column - && self.last_active_row.is_some() - && self.last_active_column.is_some() - { - let last_active_column = self.last_active_column.unwrap(); - - // Remove the last column selection from the current row where the mouse is if - // the previous row and the current one matches - // - // column column column - // column column column - // column column (mouse is currently here) column(mouse was here) - // - // We unselect the bottom right corner column - if last_active_column != column_name && self.last_active_row.unwrap() == user_id { - current_row.selected_columns.remove(&last_active_column); - self.active_columns.remove(&last_active_column); - } - - // Get the last row where the mouse was - let current_row_index = self - .indexed_user_ids - .get(&self.last_active_row.unwrap()) - .unwrap(); - let last_row = self.formatted_rows.get_mut(*current_row_index).unwrap(); - - self.last_active_row = Some(user_id); - - // If on the same row as the last row, then unselect the column from all other select row - if user_id == last_row.id { - if last_active_column != column_name { - self.last_active_column = Some(column_name); - } - } else { - no_checking = true; - // Mouse went 1 row above or below. So just clear all selection from that previous row - last_row.selected_columns.clear(); - } - } else { - // We are in a new row which we have not selected before - self.active_rows.insert(current_row.id); - self.last_active_row = Some(user_id); - self.last_active_column = Some(column_name); - current_row - .selected_columns - .clone_from(&self.active_columns); - } - - let current_row_index = self.indexed_user_ids.get(&user_id).unwrap().to_owned(); - - // Get the row number where the drag started on - let drag_start_index = self.indexed_user_ids.get(&drag_start.0).unwrap().to_owned(); - - if no_checking { - self.remove_row_selection(current_row_index, drag_start_index, is_ctrl_pressed); - } else { - // If drag started on row 1, currently on row 5, check from row 4 to 1 and select all columns - // else go through all rows till a row without any selected column is found. Applied both by incrementing or decrementing index. - // In case of fast mouse movement following drag started point mitigates the risk of some rows not getting selected - self.check_row_selection(true, current_row_index, drag_start_index); - self.check_row_selection(false, current_row_index, drag_start_index); - self.remove_row_selection(current_row_index, drag_start_index, is_ctrl_pressed); - } - } - - /// Recursively check the rows by either increasing or decreasing the initial index - /// till the end point or an unselected row is found. Add active columns to the rows that have at least one column selected. - fn check_row_selection(&mut self, check_previous: bool, index: usize, drag_start: usize) { - if index == 0 && check_previous { - return; - } - - if index + 1 == self.formatted_rows.len() && !check_previous { - return; - } - - let index = if check_previous { index - 1 } else { index + 1 }; - - let current_row = self.formatted_rows.get(index).unwrap(); - let mut unselected_row = current_row.selected_columns.is_empty(); - - // if for example drag started on row 5 and ended on row 10 but missed drag on row 7 - // Mark the rows as selected till the drag start row is hit (if recursively going that way) - if (check_previous && index >= drag_start) || (!check_previous && index <= drag_start) { - unselected_row = false; - } - - // let target_row = self.rows.get_mut(¤t_row.id).unwrap(); - let target_row = self.formatted_rows.get_mut(index).unwrap(); - - if !unselected_row { - target_row.selected_columns.clone_from(&self.active_columns); - self.active_rows.insert(target_row.id); - - if check_previous { - if index != 0 { - self.check_row_selection(check_previous, index, drag_start); - } - } else if index + 1 != self.formatted_rows.len() { - self.check_row_selection(check_previous, index, drag_start); - } - } - } - - /// Checks the active rows and unselects rows that are not within the given range - fn remove_row_selection( - &mut self, - current_index: usize, - drag_start: usize, - is_ctrl_pressed: bool, - ) { - let active_ids = self.active_rows.clone(); - for id in active_ids { - let ongoing_index = self.indexed_user_ids.get(&id).unwrap().to_owned(); - let target_row = self.formatted_rows.get_mut(ongoing_index).unwrap(); - - if current_index > drag_start { - if ongoing_index >= drag_start && ongoing_index <= current_index { - target_row.selected_columns.clone_from(&self.active_columns); - } else if !is_ctrl_pressed { - target_row.selected_columns = HashSet::new(); - self.active_rows.remove(&target_row.id); - } - } else if ongoing_index <= drag_start && ongoing_index >= current_index { - target_row.selected_columns.clone_from(&self.active_columns); - } else if !is_ctrl_pressed { - target_row.selected_columns = HashSet::new(); - self.active_rows.remove(&target_row.id); - } - } - } - - /// Unselect all rows and columns - fn unselected_all(&mut self) { - for id in &self.active_rows { - let id_index = self.indexed_user_ids.get(id).unwrap(); - let target_row = self.formatted_rows.get_mut(*id_index).unwrap(); - target_row.selected_columns.clear(); - } - self.active_columns.clear(); - self.last_active_row = None; - self.last_active_column = None; - self.active_rows.clear(); - } - - /// Select all rows and columns - fn select_all(&mut self) { - let all_columns: Vec = ColumnName::iter().collect(); - let mut all_rows = Vec::new(); - - for row in self.formatted_rows.iter_mut() { - row.selected_columns.extend(&all_columns); - all_rows.push(row.id); - } - - self.active_columns.extend(all_columns); - self.active_rows.extend(all_rows); - self.last_active_row = None; - self.last_active_column = None; - } - - /// Change the value it is currently sorted by. Called on header column click - fn change_sorted_by(&mut self, sort_by: ColumnName) { - self.unselected_all(); - self.sorted_by = sort_by; - self.sort_order = SortOrder::default(); - self.indexed_user_ids.clear(); - } - - /// Change the order of row sorting. Called on header column click - fn change_sort_order(&mut self) { - self.unselected_all(); - if let SortOrder::Ascending = self.sort_order { - self.sort_order = SortOrder::Descending; - } else { - self.sort_order = SortOrder::Ascending; - } - self.indexed_user_ids.clear(); + self.table.recreate_rows(); } /// Mark a row as whitelisted if exists @@ -657,114 +496,17 @@ impl UserTableData { self.create_rows(); } - /// Sorts row data based on the current sort order - fn sort_rows(&mut self) -> Vec { - // Use rayon to parallelize. After logging, rayon resulted in 47% increased performance - let mut row_data: Vec = self.rows.par_iter().map(|(_, v)| v.clone()).collect(); - - match self.sorted_by { - ColumnName::UserID => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.id.cmp(&b.id)), - SortOrder::Descending => row_data.sort_by(|a, b| a.id.cmp(&b.id).reverse()), - }, - ColumnName::Name => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.name.cmp(&b.name)), - SortOrder::Descending => row_data.sort_by(|a, b| a.name.cmp(&b.name).reverse()), - }, - ColumnName::Username => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.username.cmp(&b.username)), - SortOrder::Descending => { - row_data.sort_by(|a, b| a.username.cmp(&b.username).reverse()); - } - }, - ColumnName::TotalMessage => match self.sort_order { - SortOrder::Ascending => { - row_data.sort_by(|a, b| a.total_message.cmp(&b.total_message)); - } - SortOrder::Descending => { - row_data.sort_by(|a, b| a.total_message.cmp(&b.total_message).reverse()); - } - }, - ColumnName::TotalWord => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.total_word.cmp(&b.total_word)), - SortOrder::Descending => { - row_data.sort_by(|a, b| a.total_word.cmp(&b.total_word).reverse()); - } - }, - ColumnName::TotalChar => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.total_char.cmp(&b.total_char)), - SortOrder::Descending => { - row_data.sort_by(|a, b| a.total_char.cmp(&b.total_char).reverse()); - } - }, - ColumnName::AverageChar => match self.sort_order { - SortOrder::Ascending => { - row_data.sort_by(|a, b| a.average_char.cmp(&b.average_char)); - } - SortOrder::Descending => { - row_data.sort_by(|a, b| a.average_char.cmp(&b.average_char).reverse()); - } - }, - ColumnName::AverageWord => match self.sort_order { - SortOrder::Ascending => { - row_data.sort_by(|a, b| a.average_word.cmp(&b.average_word)); - } - SortOrder::Descending => { - row_data.sort_by(|a, b| a.average_word.cmp(&b.average_word).reverse()); - } - }, - ColumnName::FirstMessageSeen => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.first_seen.cmp(&b.first_seen)), - SortOrder::Descending => { - row_data.sort_by(|a, b| a.first_seen.cmp(&b.first_seen).reverse()); - } - }, - ColumnName::LastMessageSeen => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.last_seen.cmp(&b.last_seen)), - SortOrder::Descending => { - row_data.sort_by(|a, b| a.last_seen.cmp(&b.last_seen).reverse()); - } - }, - ColumnName::Whitelisted => match self.sort_order { - SortOrder::Ascending => row_data.sort_by(|a, b| a.whitelisted.cmp(&b.whitelisted)), - SortOrder::Descending => { - row_data.sort_by(|a, b| a.whitelisted.cmp(&b.whitelisted).reverse()); - } - }, - } - - let indexed_data = row_data - .par_iter() - .enumerate() - .map(|(index, row)| (row.id, index)) - .collect(); - - self.indexed_user_ids = indexed_data; - - row_data - } - fn export_data(&mut self, chat_name: &str) { info!("Starting exporting table data"); - let rows = self.rows(); + let rows = self.table.get_displayed_rows(); export_table_data(rows, chat_name); } } impl MainWindow { pub fn show_user_table_ui(&mut self, ui: &mut Ui) { - let is_ctrl_pressed = ui.ctx().input(|i| i.modifiers.ctrl); - let key_a_pressed = ui.ctx().input(|i| i.key_pressed(Key::A)); - let copy_initiated = ui.ctx().input(|i| i.events.contains(&Event::Copy)); let date_enabled = !self.is_processing && !self.table_i().user_data.is_empty(); - if copy_initiated { - self.copy_selected_cells(ui); - } - if is_ctrl_pressed && key_a_pressed { - self.table().select_all(); - } - let (values, len) = { let names = self.counter.get_chat_list(); @@ -897,355 +639,61 @@ impl MainWindow { ui.add_space(5.0); let mut clip_added = 0; - let pointer_location = ui.input(|i| i.pointer.hover_pos()); - let max_rec = ui.max_rect(); - let ctx = ui.ctx().clone(); - - let mut table = TableBuilder::new(ui) - .striped(true) - .resizable(true) - .cell_layout(Layout::left_to_right(Align::Center)) - .drag_to_scroll(false) - .auto_shrink([false; 2]) - .min_scrolled_height(0.0) - .column(Column::initial(25.0).clip(true)); - - for _ in ColumnName::iter() { - let mut column = Column::initial(100.0); - if clip_added < 2 { - column = column.clip(true); - clip_added += 1; - } - table = table.column(column); - } - if self.table().drag_started_on.is_some() { - if let Some(loc) = pointer_location { - let pointer_y = loc.y; + let to_whitelist_selected = self.table().table.config.whitelist_rows; + let to_blacklist_selected = self.table().table.config.blacklisted_rows; + let to_copy = self.table().table.config.copy_selected; - // Min gets a bit more space as the header is along the way - let min_y = max_rec.min.y + 200.0; - let max_y = max_rec.max.y - 120.0; - - // Whether the mouse is within the space where the vertical scrolling should not happen - let within_y = pointer_y >= min_y && pointer_y <= max_y; - - // Whether the mouse is above the minimum y point - let above_y = pointer_y < min_y; - // Whether the mouse is below the maximum y point - let below_y = pointer_y > max_y; - - let max_distance = 100.0; - let max_speed = 30.0; - - if !within_y { - let speed_factor: f32; - - if above_y { - let distance = (min_y - pointer_y).abs(); - speed_factor = max_speed * (distance / max_distance).clamp(0.1, 1.0); - - self.table().v_offset -= speed_factor; - if self.table().v_offset < 0.0 { - self.table().v_offset = 0.0; - } - } else if below_y { - let distance = (pointer_y - max_y).abs(); - speed_factor = max_speed * (distance / max_distance).clamp(0.1, 1.0); - - self.table().v_offset += speed_factor; - } - - table = table.vertical_scroll_offset(self.table().v_offset); - ctx.request_repaint(); - } - } - }; - let output = table - .header(20.0, |mut header| { - header.col(|ui| { - ui.add_sized(ui.available_size(), Label::new("")); - }); - for val in ColumnName::iter() { - header.col(|ui| { - self.create_header(val, ui); - }); - } - }) - .body(|body| { - let table_rows = self.table().rows(); - body.rows(25.0, table_rows.len(), |mut row| { - let index = row.index(); - let row_data = &table_rows[index]; - row.col(|ui| { - ui.add_sized(ui.available_size(), Label::new(format!("{}", index + 1))); - }); - for val in ColumnName::iter() { - row.col(|ui| self.create_table_row(val, row_data, ui)); - } - }); - }); - let scroll_offset = output.state.offset.y; - self.table().v_offset = scroll_offset; - } - - /// Create a table row from a column name and the row data - fn create_table_row(&mut self, column_name: ColumnName, row_data: &UserRowData, ui: &mut Ui) { - let mut show_tooltip = false; - let row_text = match column_name { - ColumnName::Name => { - show_tooltip = true; - row_data.name.clone() - } - ColumnName::Username => { - show_tooltip = true; - row_data.username.clone() - } - ColumnName::UserID => row_data.id.to_string(), - ColumnName::TotalMessage => row_data.total_message.to_string(), - ColumnName::TotalWord => row_data.total_word.to_string(), - ColumnName::TotalChar => row_data.total_char.to_string(), - ColumnName::AverageWord => row_data.average_word.to_string(), - ColumnName::AverageChar => row_data.average_char.to_string(), - ColumnName::FirstMessageSeen => row_data.first_seen.to_string(), - ColumnName::LastMessageSeen => row_data.last_seen.to_string(), - ColumnName::Whitelisted => { - let text = if row_data.whitelisted { "Yes" } else { "No" }; - text.to_string() - } - }; - - let is_selected = row_data.selected_columns.contains(&column_name); - let is_whitelisted = row_data.whitelisted; - - let mut label = ui - .add_sized( - ui.available_size(), - RowLabel::new(is_selected, is_whitelisted, &row_text), - ) - .interact(Sense::drag()); - - if show_tooltip { - label = label.on_hover_text(row_text); + if to_whitelist_selected { + self.table().table.config.whitelist_rows = false; + self.whitelist_selected_rows(); } - label.context_menu(|ui| { - if ui.button("Copy selected rows").clicked() { - self.copy_selected_cells(ui); - ui.close_menu(); - }; - if ui.button("Whitelist selected rows").clicked() { - self.whitelist_selected_rows(); - ui.close_menu(); - }; - - if ui.button("Blacklist selected rows").clicked() { - self.blacklist_selected_rows(); - ui.close_menu(); - }; - }); - - if label.drag_started() { - // If CTRL is not pressed down and the mouse right click is not pressed, unselect all cells - if !ui.ctx().input(|i| i.modifiers.ctrl) - && !ui.ctx().input(|i| i.pointer.secondary_clicked()) - { - self.table().unselected_all(); - } - self.table().drag_started_on = Some((row_data.id, column_name)); + if to_blacklist_selected { + self.table().table.config.blacklisted_rows = false; + self.blacklist_selected_rows(); } - let pointer_released = ui.input(|a| a.pointer.primary_released()); - - if pointer_released { - self.table().last_active_row = None; - self.table().last_active_column = None; - self.table().drag_started_on = None; - self.table().beyond_drag_point = false; - } - - if label.clicked() { - // If CTRL is not pressed down and the mouse right click is not pressed, unselect all cells - if !ui.ctx().input(|i| i.modifiers.ctrl) - && !ui.ctx().input(|i| i.pointer.secondary_clicked()) - { - self.table().unselected_all(); - } - self.table() - .select_single_row_cell(row_data.id, column_name); + if to_copy { + self.table().table.config.copy_selected = false; + self.copy_selected_cells(ui); } - if ui.ui_contains_pointer() && self.table_i().drag_started_on.is_some() { - if let Some(drag_start) = self.table_i().drag_started_on { - // Only call drag either when not on the starting drag row/column or went beyond the - // drag point at least once. Otherwise normal click would be considered as drag - if drag_start.0 != row_data.id - || drag_start.1 != column_name - || self.table_i().beyond_drag_point - { - let is_ctrl_pressed = ui.ctx().input(|i| i.modifiers.ctrl); - self.table() - .select_dragged_row_cell(row_data.id, column_name, is_ctrl_pressed); + self.table().table.show_ui(ui, |builder| { + let mut table = builder + .striped(true) + .resizable(true) + .cell_layout(Layout::left_to_right(Align::Center)) + .drag_to_scroll(false) + .auto_shrink([false; 2]) + .min_scrolled_height(0.0); + + for _ in ColumnName::iter() { + let mut column = Column::initial(100.0); + if clip_added < 2 { + column = column.clip(true); + clip_added += 1; } + table = table.column(column); } - } - } - - /// Create a header column - fn create_header(&mut self, column_name: ColumnName, ui: &mut Ui) { - let is_selected = self.table_i().sorted_by == column_name; - let (label_text, hover_text) = self.get_header_text(column_name); - - let response = ui - .add_sized( - ui.available_size(), - SelectableLabel::new(is_selected, label_text), - ) - .on_hover_text(hover_text); - - self.handle_header_selection(&response, is_selected, column_name); - } - - /// Handles sort order and value on header click - fn handle_header_selection( - &mut self, - response: &Response, - is_selected: bool, - sort_type: ColumnName, - ) { - if response.clicked() { - if is_selected { - self.table().change_sort_order(); - } else { - self.table().change_sorted_by(sort_type); - } - self.table().create_rows() - } - } - - fn get_header_text(&mut self, header_type: ColumnName) -> (RichText, String) { - let mut text = header_type.to_string(); - let hover_text = match header_type { - ColumnName::Name => "Telegram name of the user. Click to sort by name".to_string(), - ColumnName::Username => { - "Telegram username of the user. Click to sort by username".to_string() - } - ColumnName::UserID => { - "Telegram User ID of the user. Click to sort by user ID".to_string() - } - ColumnName::TotalMessage => { - "Total messages sent by the user. Click to sort by total message".to_string() - } - ColumnName::TotalWord => { - "Total words in the messages. Click to sort by total words".to_string() - } - ColumnName::TotalChar => { - "Total character in the messages. Click to sort by total character".to_string() - } - ColumnName::AverageWord => { - "Average number of words per message. Click to sort by average words".to_string() - } - ColumnName::AverageChar => { - "Average number of characters per message. Click to sort by average characters" - .to_string() - } - - ColumnName::FirstMessageSeen => { - "The day the first message that was sent by this user was observed".to_string() - } - ColumnName::LastMessageSeen => { - "The day the last message that was sent by this user was observed".to_string() - } - ColumnName::Whitelisted => { - "Whether this user is whitelisted. Click to sort by whitelist".to_string() - } - }; - - if header_type == self.table_i().sorted_by { - match self.table_i().sort_order { - SortOrder::Ascending => text.push('↓'), - SortOrder::Descending => text.push('↑'), - }; - } - (RichText::new(text).strong(), hover_text) + table + }); } - /// Copy the selected rows in an organized manner fn copy_selected_cells(&mut self, ui: &mut Ui) { - let mut selected_rows = Vec::new(); - - let mut column_max_length = HashMap::new(); - - // Iter through all the rows and find the rows that have at least one column as selected - // Keep track of the biggest length of a value of a column - for user_id in &self.table_i().active_rows { - let target_index = self.table_i().indexed_user_ids.get(user_id).unwrap(); - - let row = self.table_i().formatted_rows.get(*target_index).unwrap(); - for column in &self.table_i().active_columns { - if row.selected_columns.contains(column) { - let field_length = row.get_column_length(*column); - let entry = column_max_length.entry(column).or_insert(0); - if field_length > *entry { - column_max_length.insert(column, field_length); - } - } - } - selected_rows.push(row); - } - - let mut to_copy = String::new(); - let mut total_cells = 0; - - // Target is to ensure a fixed length after each column value of a row - // If for example highest len is 10 but the current row's - // column value is 5, we will add the column value and add 5 more space after that - // to ensure alignment - for row in selected_rows { - let mut ongoing_column = ColumnName::Name; - let mut row_text = String::new(); - loop { - if self.table_i().active_columns.contains(&ongoing_column) - && row.selected_columns.contains(&ongoing_column) - { - total_cells += 1; - let column_text = row.get_column_text(ongoing_column); - row_text += &format!( - "{: for ColumnName { + fn column_text(&self, row: &WhiteListRowData) -> String { + match self { + ColumnName::Name => row.name.to_string(), + ColumnName::Username => row.username.to_string(), + ColumnName::UserID => row.id.to_string(), + _ => unreachable!(), + } + } + fn create_header( + &self, + ui: &mut eframe::egui::Ui, + _sort_order: Option, + _table: &mut SelectableTable, + ) -> Option { + let label_text = self.to_string(); + let hover_text = match self { + ColumnName::Name => "Telegram name of the user".to_string(), + ColumnName::Username => "Telegram username of the user".to_string(), + + ColumnName::UserID => "Telegram User ID of the user".to_string(), + _ => unreachable!(), + }; + + let label_text = RichText::new(label_text).strong(); + + let response = ui + .add_sized(ui.available_size(), Label::new(label_text)) + .on_hover_text(hover_text); + + Some(response) + } + fn create_table_row( + &self, + ui: &mut Ui, + row: &SelectableRow, + column_selected: bool, + table: &mut SelectableTable, + ) -> Response { + let row_data = &row.row_data; + let mut show_tooltip = false; + let row_text = match self { + ColumnName::Name => { + show_tooltip = true; + row_data.name.clone() + } + ColumnName::Username => { + show_tooltip = true; + row_data.username.clone() + } + ColumnName::UserID => row_data.id.to_string(), + _ => unreachable!(), + }; + let is_selected = column_selected; + + let mut label = ui + .add_sized( + ui.available_size(), + SelectableLabel::new(is_selected, &row_text), + ) + .interact(Sense::drag()); + + if show_tooltip { + label = label.on_hover_text(row_text); + }; + label.context_menu(|ui| { + if ui.button("Deleted Selected").clicked() { + table.config.deleted_selected = true; + ui.close_menu(); + }; + }); + label + } +} + +impl ColumnOrdering for ColumnName { + fn order_by(&self, row_1: &WhiteListRowData, row_2: &WhiteListRowData) -> std::cmp::Ordering { + row_1.name.cmp(&row_2.name) + } +} + #[derive(Clone)] struct WhiteListRowData { name: String, username: String, id: i64, - is_selected: bool, belongs_to: Chat, seen_by: String, } @@ -27,48 +116,40 @@ impl WhiteListRowData { name, username, id, - is_selected: false, belongs_to, seen_by, } } } -#[derive(Default)] pub struct WhitelistData { + table: SelectableTable, target_username: String, - rows: HashMap, - active_rows: HashSet, - /// Only used when initially loading the saved whitelist data - /// and for creating the `ProcessState`. - /// Will never be changed after all whitelists are processed failed_whitelist: i32, + all_ids: HashSet, } -impl WhitelistData { - /// Get all rows in a vector - fn rows(&self) -> Vec { - self.rows.values().cloned().collect() - } - - /// Remove selection from all rows - fn unselected_all(&mut self) { - for (_, row) in self.rows.iter_mut() { - row.is_selected = false; - } - self.active_rows.clear(); - } - - /// Select all rows - fn select_all(&mut self) { - let mut rows = HashSet::new(); - for (_, row) in self.rows.iter_mut() { - row.is_selected = true; - rows.insert(row.id); +impl Default for WhitelistData { + fn default() -> Self { + let table = SelectableTable::new(vec![ + ColumnName::Name, + ColumnName::Username, + ColumnName::UserID, + ]) + .auto_scroll() + .select_full_row() + .serial_column() + .auto_reload(1); + Self { + table, + target_username: String::new(), + failed_whitelist: 0, + all_ids: HashSet::new(), } - self.active_rows = rows; } +} +impl WhitelistData { /// Add a new row to the UI pub fn add_to_whitelist( &mut self, @@ -85,48 +166,65 @@ impl WhitelistData { }; info!("Adding {name} to whitelist, seen by {seen_by}"); - let to_add = WhiteListRowData::new(name, username, id, belongs_to, seen_by); - self.rows.insert(id, to_add); + self.table.add_modify_row(|_rows| { + let to_add = WhiteListRowData::new(name, username, id, belongs_to, seen_by); + Some(to_add) + }); } /// Check if user is whitelisted/in the whitelist UI pub fn is_user_whitelisted(&self, id: i64) -> bool { - self.rows.contains_key(&id) + self.all_ids.contains(&id) } /// Save the current row data in the whitelist json pub fn save_whitelisted_users(&self, overwrite: bool) { let mut packed_chats = Vec::new(); - for row in self.rows.values() { - let hex_value = row.belongs_to.pack().to_hex(); + self.table.get_all_rows().iter().for_each(|(_id, row)| { + let hex_value = row.row_data.belongs_to.pack().to_hex(); packed_chats.push(PackedWhitelistedUser::new( hex_value, - row.seen_by.to_string(), + row.row_data.seen_by.to_string(), )); - } + }); save_whitelisted_users(packed_chats, overwrite); } /// Removes selected row from whitelist and saves the result - fn remove_selected(&mut self) -> HashSet { - let active_rows = self.active_rows.clone(); + fn remove_selected(&mut self) -> Vec { + let active_rows = self.table.get_selected_rows(); + let mut row_ids = Vec::new(); for i in &active_rows { - info!("Removing user {} from whitelist", i); - self.rows.remove(i); + info!( + "Removing user {} | {} from whitelist", + i.row_data.username, i.row_data.id + ); + self.table.add_modify_row(|rows| { + rows.remove(&i.id); + row_ids.push(i.id); + self.all_ids.remove(&i.id); + None + }); } self.save_whitelisted_users(true); - active_rows + row_ids } /// Removes all row from whitelist and saves the result fn remove_all(&mut self) -> Vec { info!("Removing all users from whitelist"); - let row_keys = self.rows.keys().map(ToOwned::to_owned).collect(); - self.rows.clear(); + let row_keys = self + .table + .get_all_rows() + .values() + .map(|row| row.row_data.id) + .collect(); + self.table.clear_all_rows(); self.save_whitelisted_users(true); + self.all_ids.clear(); row_keys } @@ -140,7 +238,7 @@ impl WhitelistData { } pub fn row_len(&self) -> usize { - self.rows.len() + self.table.total_rows() } pub fn failed_whitelist_num(&self) -> i32 { @@ -154,7 +252,18 @@ impl MainWindow { let key_a_pressed = ui.ctx().input(|i| i.key_pressed(Key::A)); if is_ctrl_pressed && key_a_pressed { - self.whitelist.select_all(); + self.whitelist.table.select_all(); + } + + if self.whitelist.table.config.deleted_selected { + self.whitelist.table.config.deleted_selected = false; + let rows = self.whitelist.table.get_selected_rows(); + let total_to_remove = rows.len(); + let deleted_ids: Vec = rows.iter().map(|row| row.row_data.id).collect(); + + self.table().remove_whitelist(&deleted_ids); + self.whitelist.remove_selected(); + self.process_state = ProcessState::WhitelistedUserRemoved(total_to_remove); } Grid::new("Whitelist Grid") @@ -199,17 +308,18 @@ then right click on User Table to whitelist", .on_hover_text("Select all users. Also usable with CTRL + A. Use CTRL + mouse click for manual selection") .clicked() { - self.whitelist.select_all(); + self.whitelist.table.select_all(); }; if ui .button("Delete Selected") .on_hover_text("Delete selected users from whitelist") .clicked() { - let deleted: Vec = self.whitelist.remove_selected().into_iter().collect(); + let deleted: Vec = self.whitelist.remove_selected(); let total_to_remove = deleted.len(); self.table().remove_whitelist(&deleted); + self.chart().reset_saved_bars(); self.process_state = ProcessState::WhitelistedUserRemoved(total_to_remove); }; if ui @@ -225,100 +335,18 @@ then right click on User Table to whitelist", }; }); - ScrollArea::horizontal() - .drag_to_scroll(false) - .show(ui, |ui| { - let column_size = (ui.available_width() - 20.0) / 3.0; - let table = TableBuilder::new(ui) - .striped(true) - .cell_layout(Layout::left_to_right(Align::Center)) - .column(Column::exact(column_size).clip(true)) - .column(Column::exact(column_size)) - .column(Column::exact(column_size)) - .drag_to_scroll(false) - .auto_shrink([false; 2]) - .min_scrolled_height(0.0); - - table - .header(20.0, |mut header| { - header.col(|ui| { - self.create_whitelist_header(ColumnName::Name, ui); - }); - header.col(|ui| { - self.create_whitelist_header(ColumnName::Username, ui); - }); - header.col(|ui| { - self.create_whitelist_header(ColumnName::UserID, ui); - }); - }) - .body(|body| { - let table_rows = self.whitelist.rows(); - body.rows(25.0, table_rows.len(), |mut row| { - let row_data = &table_rows[row.index()]; - row.col(|ui| { - self.create_whitelist_row(ColumnName::Name, row_data, ui); - }); - row.col(|ui| { - self.create_whitelist_row(ColumnName::Username, row_data, ui); - }); - row.col(|ui| { - self.create_whitelist_row(ColumnName::UserID, row_data, ui); - }); - }); - }); - }); - } - - fn create_whitelist_header(&self, column: ColumnName, ui: &mut Ui) { - let text = column.to_string(); - let hover_text = match column { - ColumnName::Name => "Telegram name of the user".to_string(), - ColumnName::Username => "Telegram username of the user".to_string(), - - ColumnName::UserID => "Telegram User ID of the user".to_string(), - - _ => unreachable!(), - }; - - let text = RichText::new(text).strong(); - ui.add_sized(ui.available_size(), Label::new(text)) - .on_hover_text(hover_text); - } - - fn create_whitelist_row( - &mut self, - column: ColumnName, - row_data: &WhiteListRowData, - ui: &mut Ui, - ) { - let row_text = match column { - ColumnName::Name => row_data.name.clone(), - ColumnName::Username => row_data.username.clone(), - ColumnName::UserID => row_data.id.to_string(), - _ => unreachable!(), - }; - - let row = ui.add_sized( - ui.available_size(), - SelectableLabel::new(row_data.is_selected, row_text), - ); - row.context_menu(|ui| { - if ui.button("Delete Selected").clicked() { - let deleted: Vec = self.whitelist.remove_selected().into_iter().collect(); - - self.table().remove_whitelist(&deleted); - ui.close_menu(); - } + let column_size = (ui.available_width() - 20.0) / 3.0; + self.whitelist.table.show_ui(ui, |table| { + table + .striped(true) + .cell_layout(Layout::left_to_right(Align::Center)) + .column(Column::exact(column_size).clip(true)) + .column(Column::exact(column_size)) + .column(Column::exact(column_size)) + .drag_to_scroll(false) + .auto_shrink([false; 2]) + .min_scrolled_height(0.0) }); - - if row.clicked() { - if !ui.ctx().input(|i| i.modifiers.ctrl) { - self.whitelist.unselected_all(); - } - let target_row = self.whitelist.rows.get_mut(&row_data.id).unwrap(); - target_row.is_selected = true; - self.whitelist.active_rows.insert(row_data.id); - }; } pub fn load_whitelisted_users(&mut self) { diff --git a/src/utils.rs b/src/utils.rs index aeec82d..bc5ce46 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ use chrono::{Local, NaiveDate, NaiveDateTime}; +use egui_selectable_table::SelectableRow; use log::{error, info}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::error::Error; @@ -8,7 +9,7 @@ use std::path::PathBuf; use tokio::runtime::{self, Runtime}; use crate::ui_components::processor::{ - ChartTiming, PackedBlacklistedUser, PackedWhitelistedUser, ParsedChat, + ChartTiming, ColumnName, PackedBlacklistedUser, PackedWhitelistedUser, ParsedChat, }; use crate::ui_components::tab_ui::UserRowData; use crate::ui_components::TGKeys; @@ -365,7 +366,7 @@ pub fn create_export_file(export_data: &str, file_name: String) { file.write_all(export_data.as_bytes()).unwrap(); } -pub fn export_table_data(rows: Vec, name: &str) { +pub fn export_table_data(rows: &Vec>, name: &str) { let mut export_file_location = PathBuf::from("."); let current_time = Local::now(); let formatted_time = current_time.format("%Y-%m-%d %H-%M-%S").to_string(); @@ -377,6 +378,7 @@ pub fn export_table_data(rows: Vec, name: &str) { let mut wtr = csv::Writer::from_writer(file); for row in rows { + let row = &row.row_data; if let Err(e) = wtr.serialize(row) { error!("Failed to add one row, skipping. Error: {e}"); } From d36168dd898f9a42dea6aaf432cfe71deaedd71a Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 16 Oct 2024 22:08:53 +0600 Subject: [PATCH 2/5] Add release builder --- .github/workflows/release.yml | 283 ++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5df0c7b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,283 @@ +# This file was autogenerated by cargo-dist: https://opensource.axo.dev/cargo-dist/ +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with cargo-dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't cargo-dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (cargo-dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'cargo dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-20.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cargo-dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.22.1/cargo-dist-installer.sh | sh" + - name: Cache cargo-dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/cargo-dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "cargo dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by cargo-dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to cargo dist + # - install-dist: expression to run to install cargo-dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cargo-dist + run: ${{ matrix.install_dist }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "cargo dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-20.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cached cargo-dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/cargo-dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "cargo dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-20.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install cached cargo-dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/cargo-dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-20.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive From 9957ae0e55eb8bc35b531f1b1cce16bb1439de4e Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Fri, 25 Oct 2024 16:29:58 +0600 Subject: [PATCH 3/5] Offload table handling to lib --- src/ui_components/tab_ui/blacklist.rs | 306 +++++++++++++------------- src/ui_components/tab_ui/whitelist.rs | 22 +- 2 files changed, 162 insertions(+), 166 deletions(-) diff --git a/src/ui_components/tab_ui/blacklist.rs b/src/ui_components/tab_ui/blacklist.rs index bea69b5..df7c204 100644 --- a/src/ui_components/tab_ui/blacklist.rs +++ b/src/ui_components/tab_ui/blacklist.rs @@ -1,22 +1,109 @@ use eframe::egui::{ - Align, Button, Grid, Key, Label, Layout, RichText, ScrollArea, SelectableLabel, TextEdit, Ui, + Align, Button, Grid, Label, Layout, Response, RichText, SelectableLabel, Sense, TextEdit, Ui, +}; +use egui_extras::Column; +use egui_selectable_table::{ + ColumnOperations, ColumnOrdering, SelectableRow, SelectableTable, SortOrder, }; -use egui_extras::{Column, TableBuilder}; use grammers_client::types::Chat; use log::{error, info}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use crate::tg_handler::ProcessStart; use crate::ui_components::processor::{ColumnName, PackedBlacklistedUser, ProcessState}; use crate::ui_components::MainWindow; use crate::utils::{get_blacklisted, save_blacklisted_users, separate_blacklist_by_seen}; +#[derive(Default)] +struct Config { + deleted_selected: bool, +} + +impl ColumnOperations for ColumnName { + fn column_text(&self, row: &BlackListRowData) -> String { + match self { + ColumnName::Name => row.name.to_string(), + ColumnName::Username => row.username.to_string(), + ColumnName::UserID => row.id.to_string(), + _ => unreachable!(), + } + } + fn create_header( + &self, + ui: &mut eframe::egui::Ui, + _sort_order: Option, + _table: &mut SelectableTable, + ) -> Option { + let label_text = self.to_string(); + let hover_text = match self { + ColumnName::Name => "Telegram name of the user".to_string(), + ColumnName::Username => "Telegram username of the user".to_string(), + + ColumnName::UserID => "Telegram User ID of the user".to_string(), + _ => unreachable!(), + }; + + let label_text = RichText::new(label_text).strong(); + + let response = ui + .add_sized(ui.available_size(), Label::new(label_text)) + .on_hover_text(hover_text); + + Some(response) + } + fn create_table_row( + &self, + ui: &mut Ui, + row: &SelectableRow, + column_selected: bool, + table: &mut SelectableTable, + ) -> Response { + let row_data = &row.row_data; + let mut show_tooltip = false; + let row_text = match self { + ColumnName::Name => { + show_tooltip = true; + row_data.name.clone() + } + ColumnName::Username => { + show_tooltip = true; + row_data.username.clone() + } + ColumnName::UserID => row_data.id.to_string(), + _ => unreachable!(), + }; + let is_selected = column_selected; + + let mut label = ui + .add_sized( + ui.available_size(), + SelectableLabel::new(is_selected, &row_text), + ) + .interact(Sense::drag()); + + if show_tooltip { + label = label.on_hover_text(row_text); + }; + label.context_menu(|ui| { + if ui.button("Deleted Selected").clicked() { + table.config.deleted_selected = true; + ui.close_menu(); + }; + }); + label + } +} + +impl ColumnOrdering for ColumnName { + fn order_by(&self, row_1: &BlackListRowData, row_2: &BlackListRowData) -> std::cmp::Ordering { + row_1.name.cmp(&row_2.name) + } +} #[derive(Clone)] struct BlackListRowData { name: String, username: String, id: i64, - is_selected: bool, belongs_to: Chat, seen_by: String, } @@ -27,48 +114,39 @@ impl BlackListRowData { name, username, id, - is_selected: false, belongs_to, seen_by, } } } -#[derive(Default)] pub struct BlacklistData { + table: SelectableTable, target_username: String, - rows: HashMap, - active_rows: HashSet, - /// Only used when initially loading the saved blacklist data - /// and for creating the `ProcessState`. - /// Will never be changed after all blacklist are processed failed_blacklist: i32, + all_ids: HashSet, } - -impl BlacklistData { - /// Get all rows in a vector - fn rows(&self) -> Vec { - self.rows.values().cloned().collect() - } - - /// Remove selection from all rows - fn unselected_all(&mut self) { - for (_, row) in self.rows.iter_mut() { - row.is_selected = false; +impl Default for BlacklistData { + fn default() -> Self { + let table = SelectableTable::new(vec![ + ColumnName::Name, + ColumnName::Username, + ColumnName::UserID, + ]) + .auto_scroll() + .select_full_row() + .serial_column() + .auto_reload(1); + Self { + table, + target_username: String::new(), + failed_blacklist: 0, + all_ids: HashSet::new(), } - self.active_rows.clear(); - } - - /// Select all rows - fn select_all(&mut self) { - let mut rows = HashSet::new(); - for (_, row) in self.rows.iter_mut() { - row.is_selected = true; - rows.insert(row.id); - } - self.active_rows = rows; } +} +impl BlacklistData { /// Add a new row to the UI pub fn add_to_blacklist( &mut self, @@ -85,48 +163,61 @@ impl BlacklistData { }; info!("Adding {name} to blacklist, seen by {seen_by}"); - let to_add = BlackListRowData::new(name, username, id, belongs_to, seen_by); - self.rows.insert(id, to_add); + self.all_ids.insert(id); + self.table.add_modify_row(|_rows| { + let to_add = BlackListRowData::new(name, username, id, belongs_to, seen_by); + Some(to_add) + }); } /// Check if user is blacklisted/in the blacklist UI pub fn is_user_blacklisted(&self, id: i64) -> bool { - self.rows.contains_key(&id) + self.all_ids.contains(&id) } /// Save the current row data in the blacklist json pub fn save_blacklisted_users(&self, overwrite: bool) { let mut packed_chats = Vec::new(); - for row in self.rows.values() { - let hex_value = row.belongs_to.pack().to_hex(); + self.table.get_all_rows().iter().for_each(|(_id, row)| { + let hex_value = row.row_data.belongs_to.pack().to_hex(); packed_chats.push(PackedBlacklistedUser::new( hex_value, - row.seen_by.to_string(), + row.row_data.seen_by.to_string(), )); - } + }); save_blacklisted_users(packed_chats, overwrite); } /// Removes selected row from blacklist and saves the result - fn remove_selected(&mut self) -> HashSet { - let active_rows = self.active_rows.clone(); + fn remove_selected(&mut self) -> Vec { + let active_rows = self.table.get_selected_rows(); + let mut row_ids = Vec::new(); for i in &active_rows { - info!("Removing user {} from blacklist", i); - self.rows.remove(i); + info!( + "Removing user {} | {} from blacklist", + i.row_data.username, i.row_data.id + ); + self.all_ids.remove(&i.row_data.id); + self.table.add_modify_row(|rows| { + rows.remove(&i.id); + row_ids.push(i.row_data.id); + None + }); } self.save_blacklisted_users(true); - active_rows + row_ids } /// Removes all row from blacklist and saves the result fn remove_all(&mut self) -> Vec { info!("Removing all users from blacklist"); - let row_keys = self.rows.keys().map(ToOwned::to_owned).collect(); - self.rows.clear(); + let row_keys = self.all_ids.iter().copied().collect(); + self.table.clear_all_rows(); self.save_blacklisted_users(true); + self.all_ids.clear(); row_keys } @@ -140,7 +231,7 @@ impl BlacklistData { } pub fn row_len(&self) -> usize { - self.rows.len() + self.table.total_rows() } pub fn failed_blacklist_num(&self) -> i32 { @@ -150,12 +241,12 @@ impl BlacklistData { impl MainWindow { pub fn show_blacklist_ui(&mut self, ui: &mut Ui) { - let is_ctrl_pressed = ui.ctx().input(|i| i.modifiers.ctrl); - let key_a_pressed = ui.ctx().input(|i| i.key_pressed(Key::A)); - - if is_ctrl_pressed && key_a_pressed { - self.blacklist.select_all(); - } + if self.blacklist.table.config.deleted_selected { + self.blacklist.table.config.deleted_selected = false; + let deleted = self.blacklist.remove_selected(); + let total_to_remove = deleted.len(); + self.process_state = ProcessState::BlacklistedUserRemoved(total_to_remove); + }; Grid::new("blacklist Grid") .num_columns(2) @@ -199,7 +290,7 @@ then right click on User Table to blacklist", .on_hover_text("Select all users. Also usable with CTRL + A. Use CTRL + mouse click for manual selection") .clicked() { - self.blacklist.select_all(); + self.blacklist.table.select_all(); }; if ui .button("Delete Selected") @@ -220,101 +311,18 @@ then right click on User Table to blacklist", }; }); - ScrollArea::horizontal() - .drag_to_scroll(false) - .show(ui, |ui| { - let column_size = (ui.available_width() - 20.0) / 3.0; - let table = TableBuilder::new(ui) - .striped(true) - .cell_layout(Layout::left_to_right(Align::Center)) - .column(Column::exact(column_size).clip(true)) - .column(Column::exact(column_size)) - .column(Column::exact(column_size)) - .drag_to_scroll(false) - .auto_shrink([false; 2]) - .min_scrolled_height(0.0); - - table - .header(20.0, |mut header| { - header.col(|ui| { - self.create_blacklist_header(ColumnName::Name, ui); - }); - header.col(|ui| { - self.create_blacklist_header(ColumnName::Username, ui); - }); - header.col(|ui| { - self.create_blacklist_header(ColumnName::UserID, ui); - }); - }) - .body(|body| { - let table_rows = self.blacklist.rows(); - body.rows(25.0, table_rows.len(), |mut row| { - let row_data = &table_rows[row.index()]; - row.col(|ui| { - self.create_blacklist_row(ColumnName::Name, row_data, ui); - }); - row.col(|ui| { - self.create_blacklist_row(ColumnName::Username, row_data, ui); - }); - row.col(|ui| { - self.create_blacklist_row(ColumnName::UserID, row_data, ui); - }); - }); - }); - }); - } - - fn create_blacklist_header(&self, column: ColumnName, ui: &mut Ui) { - let (text, hover_text) = match column { - ColumnName::Name => ("Name".to_string(), "Telegram name of the user".to_string()), - ColumnName::Username => ( - "Username".to_string(), - "Telegram username of the user".to_string(), - ), - ColumnName::UserID => ( - "User ID".to_string(), - "Telegram User ID of the user".to_string(), - ), - _ => unreachable!(), - }; - - let text = RichText::new(text).strong(); - ui.add_sized(ui.available_size(), Label::new(text)) - .on_hover_text(hover_text); - } - - fn create_blacklist_row( - &mut self, - column: ColumnName, - row_data: &BlackListRowData, - ui: &mut Ui, - ) { - let row_text = match column { - ColumnName::Name => row_data.name.clone(), - ColumnName::Username => row_data.username.clone(), - ColumnName::UserID => row_data.id.to_string(), - _ => unreachable!(), - }; - - let row = ui.add_sized( - ui.available_size(), - SelectableLabel::new(row_data.is_selected, row_text), - ); - row.context_menu(|ui| { - if ui.button("Delete Selected").clicked() { - let _ = self.blacklist.remove_selected(); - ui.close_menu(); - } + let column_size = (ui.available_width() - 20.0) / 3.0; + self.blacklist.table.show_ui(ui, |table| { + table + .striped(true) + .cell_layout(Layout::left_to_right(Align::Center)) + .column(Column::exact(column_size).clip(true)) + .column(Column::exact(column_size)) + .column(Column::exact(column_size)) + .drag_to_scroll(false) + .auto_shrink([false; 2]) + .min_scrolled_height(0.0) }); - - if row.clicked() { - if !ui.ctx().input(|i| i.modifiers.ctrl) { - self.blacklist.unselected_all(); - } - let target_row = self.blacklist.rows.get_mut(&row_data.id).unwrap(); - target_row.is_selected = true; - self.blacklist.active_rows.insert(row_data.id); - }; } pub fn load_blacklisted_users(&mut self) { diff --git a/src/ui_components/tab_ui/whitelist.rs b/src/ui_components/tab_ui/whitelist.rs index d2d5dd3..3d99642 100644 --- a/src/ui_components/tab_ui/whitelist.rs +++ b/src/ui_components/tab_ui/whitelist.rs @@ -1,6 +1,5 @@ use eframe::egui::{ - Align, Button, Grid, Key, Label, Layout, Response, RichText, SelectableLabel, Sense, TextEdit, - Ui, + Align, Button, Grid, Label, Layout, Response, RichText, SelectableLabel, Sense, TextEdit, Ui, }; use egui_extras::Column; use egui_selectable_table::{ @@ -166,6 +165,7 @@ impl WhitelistData { }; info!("Adding {name} to whitelist, seen by {seen_by}"); + self.all_ids.insert(id); self.table.add_modify_row(|_rows| { let to_add = WhiteListRowData::new(name, username, id, belongs_to, seen_by); Some(to_add) @@ -202,10 +202,10 @@ impl WhitelistData { "Removing user {} | {} from whitelist", i.row_data.username, i.row_data.id ); + self.all_ids.remove(&i.row_data.id); self.table.add_modify_row(|rows| { rows.remove(&i.id); - row_ids.push(i.id); - self.all_ids.remove(&i.id); + row_ids.push(i.row_data.id); None }); } @@ -216,12 +216,7 @@ impl WhitelistData { /// Removes all row from whitelist and saves the result fn remove_all(&mut self) -> Vec { info!("Removing all users from whitelist"); - let row_keys = self - .table - .get_all_rows() - .values() - .map(|row| row.row_data.id) - .collect(); + let row_keys = self.all_ids.iter().copied().collect(); self.table.clear_all_rows(); self.save_whitelisted_users(true); self.all_ids.clear(); @@ -248,13 +243,6 @@ impl WhitelistData { impl MainWindow { pub fn show_whitelist_ui(&mut self, ui: &mut Ui) { - let is_ctrl_pressed = ui.ctx().input(|i| i.modifiers.ctrl); - let key_a_pressed = ui.ctx().input(|i| i.key_pressed(Key::A)); - - if is_ctrl_pressed && key_a_pressed { - self.whitelist.table.select_all(); - } - if self.whitelist.table.config.deleted_selected { self.whitelist.table.config.deleted_selected = false; let rows = self.whitelist.table.get_selected_rows(); From 36867786bab79cfa6aab42815dca40a037c67c22 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Fri, 25 Oct 2024 16:35:28 +0600 Subject: [PATCH 4/5] Update ci --- .github/workflows/release.yml | 14 ++++++-------- Cargo.toml | 8 ++++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5df0c7b..6d8d86c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,8 @@ # * uploads those artifacts to temporary workflow zip # * on success, uploads the artifacts to a GitHub Release # -# Note that the GitHub Release will be created with a generated -# title/body based on your changelogs. +# Note that a GitHub Release with this tag is assumed to exist as a draft +# with the appropriate title/body, and will be undrafted for you. name: Release permissions: @@ -63,7 +63,7 @@ jobs: # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.22.1/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.23.0/cargo-dist-installer.sh | sh" - name: Cache cargo-dist uses: actions/upload-artifact@v4 with: @@ -257,14 +257,12 @@ jobs: - name: Create GitHub Release env: PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" - ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" - ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" RELEASE_COMMIT: "${{ github.sha }}" run: | - # Write and read notes from a file to avoid quoting breaking things - echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + # If we're editing a release in place, we need to upload things ahead of time + gh release upload "${{ needs.plan.outputs.tag }}" artifacts/* - gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + gh release edit "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --draft=false announce: needs: diff --git a/Cargo.toml b/Cargo.toml index 978c30a..669ef96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,12 +53,11 @@ egui-selectable-table = "0.1.0" # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" -lto = "thin" # Config for 'cargo dist' [workspace.metadata.dist] # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.22.1" +cargo-dist-version = "0.23.0" # CI backends to support ci = "github" # The installers to generate for each app @@ -70,3 +69,8 @@ targets = [ "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc", ] + +create-release = false + +[workspace.metadata.dist.github-custom-runners] +x86_64-unknown-linux-gnu = "ubuntu-22.04" From 9bd39d9fff541d82c54cebd297c54cba3b4dacae Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Fri, 25 Oct 2024 16:40:44 +0600 Subject: [PATCH 5/5] Update --- Cargo.lock | 2 +- Cargo.toml | 23 +---------------------- dist-workspace.toml | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 23 deletions(-) create mode 100644 dist-workspace.toml diff --git a/Cargo.lock b/Cargo.lock index 70bfa85..d9fb8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3758,7 +3758,7 @@ dependencies = [ [[package]] name = "talon-gui" -version = "1.0.6" +version = "1.0.7" dependencies = [ "chrono", "csv", diff --git a/Cargo.toml b/Cargo.toml index 669ef96..8fdc0ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "talon-gui" -version = "1.0.6" +version = "1.0.7" edition = "2021" authors = ["TheRustyPickle "] readme = "README.md" @@ -53,24 +53,3 @@ egui-selectable-table = "0.1.0" # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" - -# Config for 'cargo dist' -[workspace.metadata.dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.23.0" -# CI backends to support -ci = "github" -# The installers to generate for each app -installers = [] -# Target platforms to build apps for (Rust target-triple syntax) -targets = [ - "aarch64-apple-darwin", - "x86_64-apple-darwin", - "x86_64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", -] - -create-release = false - -[workspace.metadata.dist.github-custom-runners] -x86_64-unknown-linux-gnu = "ubuntu-22.04" diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..71d59e8 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,18 @@ +[workspace] +members = ["cargo:."] + +# Config for 'cargo dist' +[dist] +# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.23.0" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = [] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Whether cargo-dist should create a Github Release or use an existing draft +create-release = false + +[dist.github-custom-runners] +x86_64-unknown-linux-gnu = "ubuntu-22.04"