diff --git a/Cargo.lock b/Cargo.lock index 440d2cf..81f6dc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,7 @@ dependencies = [ "thiserror", "tracing", "tracing-subscriber", + "treemap", ] [[package]] @@ -1480,6 +1481,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "memchr", + "serde_json", "smallvec", ] @@ -1796,6 +1798,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "treemap" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1571f89da27a5e1aa83304ee1ab9519ea8c6432b4c8903aaaa6c9a9eecb6f36" + [[package]] name = "ttf-parser" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index b4802e4..8875467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ eyre = "0.6.5" thiserror = "1.0.29" tracing = "0.1.28" tracing-subscriber = "0.2.24" -rusqlite = {version = "0.25.3", features = ["chrono", "trace"]} +rusqlite = {version = "0.25.3", features = ["chrono", "trace", "serde_json"]} regex = "1.5.3" flate2 = "1.0.22" email-parser = { git = "https://github.com/terhechte/email-parser", features = ["sender", "to", "in-reply-to", "date", "subject", "mime", "allow-duplicate-headers"]} @@ -22,6 +22,7 @@ serde = { version = "*", features = ["derive"]} crossbeam-channel = "0.5.1" eframe = { version = "*", optional = true} rsql_builder = "0.1.2" +treemap = "0.3.2" [features] default = ["gui"] diff --git a/src/bin/gui.rs b/src/bin/gui.rs new file mode 100644 index 0000000..7c3db5a --- /dev/null +++ b/src/bin/gui.rs @@ -0,0 +1,12 @@ +use gmaildb; + +#[cfg(feature = "gui")] +fn main() { + let config = gmaildb::make_config(); + gmaildb::gui::run_gui(config); +} + +#[cfg(not(feature = "gui"))] +fn main() { + println!("Gui not selected") +} diff --git a/src/canvas_calc.rs b/src/canvas_calc.rs new file mode 100644 index 0000000..8e1d74c --- /dev/null +++ b/src/canvas_calc.rs @@ -0,0 +1,184 @@ +//! Runs a continuous thread to calculate the canvas. +//! Receives as input the current gui app state and size via a channel, +//! Then performs the SQLite query +//! Then performs the calculation to the `TreeMap` +//! And finally uses a channel to submit the result back to the UI +//! Runs its own connection to the SQLite database. + +use std::convert::{TryFrom, TryInto}; +use std::fmt::Display; +use std::thread::JoinHandle; + +use crossbeam_channel::{unbounded, Receiver, Sender}; +use eframe::egui::Rect as EguiRect; +use eyre::{bail, Report, Result}; +use treemap::{Mappable, Rect, TreemapLayout}; + +use crate::database::{ + query::{DynamicType, Filter, GroupByField, Query, ValueField}, + query_result::QueryResult, + Database, +}; +use crate::gui::state::State; +use crate::types::Config; + +#[derive(Debug, Hash, Clone)] +pub enum Value { + Number(usize), + String(String), + Bool(bool), +} + +impl Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Value::Number(n) => f.write_str(&n.to_string()), + Value::Bool(n) => f.write_str(&n.to_string()), + Value::String(n) => f.write_str(&n), + } + } +} + +#[derive(Debug, Clone)] +pub struct Partition { + pub field: GroupByField, + pub count: usize, + pub value: Value, + /// A TreeMap Rect + pub rect: Rect, +} + +impl Partition { + /// Perform rect conversion from TreeMap to Egui + pub fn layout_rect(&self) -> EguiRect { + use eframe::egui::pos2; + EguiRect { + min: pos2(self.rect.x as f32, self.rect.y as f32), + max: pos2( + self.rect.x as f32 + self.rect.w as f32, + self.rect.y as f32 + self.rect.h as f32, + ), + } + } +} + +/// A small NewType so that we can keep all the `TreeMap` code in here and don't +/// have to do the layout calculation in a widget. +pub struct Partitions { + pub items: Vec, + pub selected: Option, +} + +impl Partitions { + pub fn new(items: Vec) -> Self { + Self { + items, + selected: None, + } + } + + /// Update the layout information in the partitions + /// based on the current size + pub fn update_layout(&mut self, rect: EguiRect) { + let layout = TreemapLayout::new(); + let bounds = Rect::from_points( + rect.left() as f64, + rect.top() as f64, + rect.width() as f64, + rect.height() as f64, + ); + layout.layout_items(&mut self.items, bounds); + } +} + +impl Mappable for Partition { + fn size(&self) -> f64 { + self.count as f64 + } + + fn bounds(&self) -> &Rect { + &self.rect + } + + fn set_bounds(&mut self, bounds: Rect) { + self.rect = bounds; + } +} + +impl<'a> TryFrom<&'a QueryResult<'a>> for Partition { + type Error = Report; + fn try_from(r: &'a QueryResult<'a>) -> Result { + // so far we can only support one group by at a time. + // at least in here. The queries support it + let field = r + .values + .first() + .ok_or(eyre::eyre!("No group by fields available"))?; + + let value = match (field.is_bool(), field.is_str(), field.is_usize()) { + (true, false, false) => Value::Bool(*field.as_bool()), + (false, true, false) => Value::String(field.as_str().to_string()), + (false, false, true) => Value::Number(*field.as_usize()), + _ => bail!("Invalid field: {:?}", &field), + }; + + Ok(Partition { + field: field.as_field(), + count: r.count, + value, + rect: Rect::new(), + }) + } +} + +pub type InputSender = Sender; +pub type OutputReciever = Receiver>; +pub type Handle = JoinHandle>; + +pub fn run(config: &Config) -> Result<(InputSender, OutputReciever, Handle)> { + let database = Database::new(&config.database_path)?; + let (input_sender, input_receiver) = unbounded(); + let (output_sender, output_receiver) = unbounded(); + let handle = std::thread::spawn(move || inner_loop(database, input_receiver, output_sender)); + Ok((input_sender, output_receiver, handle)) +} + +fn inner_loop( + database: Database, + input_receiver: Receiver, + output_sender: Sender>, +) -> Result<()> { + loop { + let task = input_receiver.recv()?; + let filters = convert_filters(&task); + let group_by = vec![GroupByField::Year]; + let query = Query { + filters: &filters, + group_by: &group_by, + }; + let result = database.query(query)?; + let partitions = calculate_partitions(&result)?; + output_sender.send(Ok(Partitions::new(partitions)))? + } +} + +fn calculate_partitions<'a>(result: &[QueryResult<'a>]) -> Result> { + let mut partitions = Vec::new(); + for r in result.iter() { + let partition = r.try_into()?; + partitions.push(partition); + } + + Ok(partitions) +} + +fn convert_filters<'a>(state: &'a State) -> Vec> { + let mut filters = Vec::new(); + if let Some(ref n) = state.domain_filter { + filters.push(Filter::Like(ValueField::SenderDomain(n.into()))); + } + if let Some(n) = state.year_filter { + filters.push(Filter::Is(ValueField::Year(n))); + } + filters +} diff --git a/src/database/conversion.rs b/src/database/conversion.rs index d3a9c33..3a2bddf 100644 --- a/src/database/conversion.rs +++ b/src/database/conversion.rs @@ -1,20 +1,115 @@ -use rusqlite::{self, Error, Row}; +use std::str::FromStr; -pub trait RowConversion: Sized { - fn from_row<'stmt>(row: &Row<'stmt>) -> Result; +use chrono::prelude::*; +use eyre::Result; +use rusqlite::{self, Row}; + +use super::query::{GroupByField, ValueField}; +use super::query_result::QueryResult; +use crate::types::{EmailEntry, EmailMeta}; + +pub trait RowConversion<'a>: Sized { + fn grouped_from_row<'stmt>(fields: &'a [GroupByField], row: &Row<'stmt>) -> Result; } -/*impl RowConversion for EmailEntry { -fn from_row<'stmt>(row: &Row<'stmt>) -> Result { - let path: String = row.get("path")?; - let domain: String = row.get("domain")?; - let local_part: String = row.get("local_part")?; - let year: usize = row.get("year")?; - let month: usize = row.get("month")?; - let day: usize = row.get("day")?; - let created = email_parser::time::DateTime:: - Ok(EmailEntry { - path, domain, local_part, year, month, day - }) +impl<'a> RowConversion<'a> for QueryResult<'a> { + fn grouped_from_row<'stmt>(fields: &'a [GroupByField], row: &Row<'stmt>) -> Result { + let amount: usize = row.get("amount")?; + + let mut values = vec![]; + for field in fields { + use GroupByField::*; + match field { + // Str fields + SenderDomain => values.push(ValueField::SenderDomain( + row.get::<&str, String>(field.into())?.into(), + )), + SenderLocalPart => values.push(ValueField::SenderLocalPart( + row.get::<&str, String>(field.into())?.into(), + )), + SenderName => values.push(ValueField::SenderName( + row.get::<&str, String>(field.into())?.into(), + )), + ToGroup => values.push(ValueField::ToGroup( + row.get::<&str, String>(field.into())?.into(), + )), + ToName => values.push(ValueField::ToName( + row.get::<&str, String>(field.into())?.into(), + )), + ToAddress => values.push(ValueField::ToAddress( + row.get::<&str, String>(field.into())?.into(), + )), + + // usize field + Year => values.push(ValueField::Year( + row.get::<&str, usize>(field.into())?.into(), + )), + Month => values.push(ValueField::Day( + row.get::<&str, usize>(field.into())?.into(), + )), + Day => values.push(ValueField::Day( + row.get::<&str, usize>(field.into())?.into(), + )), + + // bool field + IsReply => values.push(ValueField::IsReply( + row.get::<&str, bool>(field.into())?.into(), + )), + IsSend => values.push(ValueField::IsSend( + row.get::<&str, bool>(field.into())?.into(), + )), + } + } + + Ok(QueryResult { + count: amount, + values, + }) + } +} + +impl EmailEntry { + #[allow(unused)] + fn from_row<'stmt>(row: &Row<'stmt>) -> Result { + let path: String = row.get("path")?; + let path = std::path::PathBuf::from_str(&path)?; + let sender_domain: String = row.get("sender_domain")?; + let sender_local_part: String = row.get("sender_local_part")?; + let sender_name: String = row.get("sender_name")?; + let timestamp: i64 = row.get("timestamp")?; + let datetime = Utc.timestamp(timestamp, 0); + let subject: String = row.get("subject")?; + let to_count: usize = row.get("to_count")?; + let to_group: Option = row.get("to_group")?; + let to_name: Option = row.get("to_name")?; + let to_address: Option = row.get("to_address")?; + + let to_first = to_address.map(|a| (to_name.unwrap_or_default(), a)); + + let is_reply: bool = row.get("is_reply")?; + let is_send: bool = row.get("is_send")?; + + // Parse EmailMeta + let meta_tags: Option = row.get("meta_tags")?; + let meta_is_seen: Option = row.get("meta_is_seen")?; + let meta = match (meta_tags, meta_is_seen) { + (Some(a), Some(b)) => Some(EmailMeta::from(b, &a)), + _ => None, + }; + + Ok(EmailEntry { + path, + sender_domain, + sender_local_part, + sender_name, + datetime, + subject, + to_count, + to_group, + to_first, + is_reply, + is_send, + meta, + }) + } } -*/ diff --git a/src/database/database.rs b/src/database/database.rs index f6ec01e..a6e2a5f 100644 --- a/src/database/database.rs +++ b/src/database/database.rs @@ -1,15 +1,16 @@ use chrono::Datelike; use crossbeam_channel::{unbounded, Sender}; -use eyre::{Report, Result}; +use eyre::{bail, Report, Result}; use rusqlite::{self, params, Connection, Statement}; +use core::panic; use std::{ path::{Path, PathBuf}, thread::JoinHandle, }; -use super::{sql::*, DBMessage}; -use crate::types::EmailEntry; +use super::{query_result::QueryResult, sql::*, DBMessage}; +use crate::{database::RowConversion, types::EmailEntry}; #[derive(Debug)] pub struct Database { @@ -38,6 +39,26 @@ impl Database { }) } + pub fn query<'a>(&self, query: super::query::Query<'a>) -> Result>> { + use rusqlite::params_from_iter; + let c = match &self.connection { + Some(n) => n, + None => bail!("No connection to database available in query"), + }; + let (sql, values) = query.to_sql(); + let mut stmt = c.prepare(&sql)?; + let mut query_results = Vec::new(); + + let p = params_from_iter(values.iter()); + + let mut rows = stmt.query(p)?; + while let Some(row) = rows.next()? { + let result = QueryResult::grouped_from_row(&query.group_by, &row)?; + query_results.push(result); + } + Ok(query_results) + } + /// Begin the data import. /// This will consume the `Database`. A new one has to be opened /// afterwards in order to support multi-threading. @@ -121,6 +142,7 @@ fn insert_mail(statement: &mut Statement, entry: &EmailEntry) -> Result<()> { let year = entry.datetime.date().year(); let month = entry.datetime.date().month(); let day = entry.datetime.date().day(); + let timestamp = entry.datetime.timestamp(); let e = entry; let to_name = e.to_first.as_ref().map(|e| &e.0); let to_address = e.to_first.as_ref().map(|e| &e.1); @@ -134,6 +156,7 @@ fn insert_mail(statement: &mut Statement, entry: &EmailEntry) -> Result<()> { year, month, day, + timestamp, e.subject, e.to_count, e.to_group, diff --git a/src/database/mod.rs b/src/database/mod.rs index ebe8dc5..adaaae1 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,6 +1,8 @@ mod conversion; mod database; mod db_message; +pub mod query; +pub mod query_result; mod sql; pub use conversion::RowConversion; diff --git a/src/database/query.rs b/src/database/query.rs index a0e8281..a16f123 100644 --- a/src/database/query.rs +++ b/src/database/query.rs @@ -40,22 +40,51 @@ pub enum GroupByField { IsSend, } -#[derive(Debug, PartialEq, Eq)] +impl<'a> ValueField<'a> { + pub fn as_field(&self) -> GroupByField { + use GroupByField::*; + match self { + ValueField::SenderDomain(_) => SenderDomain, + ValueField::SenderLocalPart(_) => SenderLocalPart, + ValueField::SenderName(_) => SenderName, + ValueField::Year(_) => Year, + ValueField::Month(_) => Month, + ValueField::Day(_) => Day, + ValueField::ToGroup(_) => ToGroup, + ValueField::ToName(_) => ToName, + ValueField::ToAddress(_) => ToAddress, + ValueField::IsReply(_) => IsReply, + ValueField::IsSend(_) => IsSend, + } + } +} + +/*impl GroupByField { + pub fn make_str<'a>(value: &'a str, field: GroupByField) -> ValueField<'a> { + use GroupByField::*; + match field { + SenderDomain => ValueField::SenderDomain(value.into()), + _ => panic!(), + } + } +}*/ + +#[derive(Debug, PartialEq, Eq, Clone)] pub enum ValueField<'a> { - SenderDomain(&'a str), - SenderLocalPart(&'a str), - SenderName(&'a str), + SenderDomain(Cow<'a, str>), + SenderLocalPart(Cow<'a, str>), + SenderName(Cow<'a, str>), Year(usize), Month(usize), Day(usize), - ToGroup(&'a str), - ToName(&'a str), - ToAddress(&'a str), + ToGroup(Cow<'a, str>), + ToName(Cow<'a, str>), + ToAddress(Cow<'a, str>), IsReply(bool), IsSend(bool), } -trait DynamicType<'a> { +pub trait DynamicType<'a> { type BoolType; type StrType; type UsizeType; @@ -69,7 +98,7 @@ trait DynamicType<'a> { impl<'a> DynamicType<'a> for ValueField<'a> { type BoolType = &'a bool; - type StrType = &'a str; + type StrType = Cow<'a, str>; type UsizeType = &'a usize; fn is_str(&self) -> bool { @@ -94,7 +123,7 @@ impl<'a> DynamicType<'a> for ValueField<'a> { use ValueField::*; match self { SenderDomain(a) | SenderLocalPart(a) | SenderName(a) | ToGroup(a) | ToName(a) - | ToAddress(a) => a, + | ToAddress(a) => a.clone(), _ => panic!(), } } @@ -118,7 +147,7 @@ impl<'a> DynamicType<'a> for ValueField<'a> { impl<'a> DynamicType<'a> for &VecOfMinOne> { type BoolType = Vec; - type StrType = Vec<&'a str>; + type StrType = Vec>; type UsizeType = Vec; fn is_str(&self) -> bool { self.inner[0].is_str() @@ -198,8 +227,8 @@ impl From<&GroupByField> for &str { } pub struct Query<'a> { - filters: &'a [Filter<'a>], - group_by: &'a [GroupByField], + pub filters: &'a [Filter<'a>], + pub group_by: &'a [GroupByField], } impl<'a> Query<'a> { @@ -232,20 +261,21 @@ impl<'a> Query<'a> { whr }; - let group_by = { - let group_by_fields: Vec<&str> = self.group_by.iter().map(|e| e.into()).collect(); - format!("GROUP BY {}", group_by_fields.join(", ")) - }; + let group_by_fields: Vec<&str> = self.group_by.iter().map(|e| e.into()).collect(); + let group_by = format!("GROUP BY {}", &group_by_fields.join(", ")); // If we have a group by, we always include the count let header = if self.group_by.is_empty() { - "SELECT * FROM emails" + "SELECT * FROM emails".to_owned() } else { - "SELECT count(path), * FROM emails" + format!( + "SELECT count(path) as amount, {} FROM emails", + group_by_fields.join(", ") + ) }; let (sql, values) = rsql_builder::B::prepare( - rsql_builder::B::new_sql(header) + rsql_builder::B::new_sql(&header) .push_build(&mut conditions) .push_sql(&group_by), ); @@ -263,8 +293,8 @@ mod tests { let value = format!("bx"); let query = Query { filters: &[ - Filter::Is(ValueField::ToName("bam")), - Filter::Like(ValueField::SenderName(&value)), + Filter::Is(ValueField::ToName("bam".into())), + Filter::Like(ValueField::SenderName(value.into())), Filter::Like(ValueField::Year(2323)), ], group_by: &[GroupByField::Month], diff --git a/src/database/query_result.rs b/src/database/query_result.rs new file mode 100644 index 0000000..04772af --- /dev/null +++ b/src/database/query_result.rs @@ -0,0 +1,10 @@ +use super::query::ValueField; + +#[derive(Debug)] +pub struct QueryResult<'a> { + /// How many items did we find? + pub count: usize, + /// All the itmes that we grouped by including their values. + /// So that we can use each of them to limit the next query. + pub values: Vec>, +} diff --git a/src/database/sql.rs b/src/database/sql.rs index f8eaefd..67e0c0c 100644 --- a/src/database/sql.rs +++ b/src/database/sql.rs @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS emails ( year INTEGER NOT NULL, month INTEGER NOT NULL, day INTEGER NOT NULL, + timestamp INTEGER NOT NULL, subject TEXT NOT NULL, to_count INTEGER NOT NULL, to_group TEXT NULL, @@ -28,7 +29,7 @@ pub const QUERY_EMAILS: &str = r#" INSERT INTO emails ( path, sender_domain, sender_local_part, sender_name, - year, month, day, subject, + year, month, day, timestamp, subject, to_count, to_group, to_name, to_address, is_reply, is_send, meta_tags, meta_is_seen @@ -36,7 +37,7 @@ INSERT INTO emails VALUES ( ?, ?, ?, ?, - ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? diff --git a/src/gui/app.rs b/src/gui/app.rs new file mode 100644 index 0000000..597dec4 --- /dev/null +++ b/src/gui/app.rs @@ -0,0 +1,148 @@ +use eframe::epi::{Frame, Storage}; +use eyre::{Report, Result}; + +use eframe::{egui, epi}; + +use super::state::State; +use super::widgets::Spinner; +use crate::canvas_calc::{run, Handle, InputSender, OutputReciever, Partitions}; +use crate::types::Config; + +struct Link { + input_sender: InputSender, + output_receiver: OutputReciever, + handle: Handle, +} + +pub struct MyApp { + config: Config, + link: Option>, + state: State, + partitions: Vec, + error: Option, + is_rendering: bool, +} + +impl MyApp { + pub fn new(config: &Config) -> Self { + let state = State { + year_filter: None, + domain_filter: None, + }; + Self { + config: config.clone(), + link: None, + state, + partitions: Vec::new(), + error: None, + is_rendering: false, + } + } +} + +impl epi::App for MyApp { + fn name(&self) -> &str { + "My egui App" + } + + fn setup( + &mut self, + _ctx: &egui::CtxRef, + _frame: &mut Frame<'_>, + _storage: Option<&dyn Storage>, + ) { + let link = run(&self.config).map(|(input_sender, output_receiver, handle)| Link { + input_sender, + output_receiver, + handle, + }); + + if let Ok(l) = &link { + l.input_sender.send(self.state.clone()); + } + + self.link = Some(link); + } + + fn update(&mut self, ctx: &egui::CtxRef, frame: &mut epi::Frame<'_>) { + // If we have a selection, load the next one + if let Some(partition) = self.partitions.last() { + if let Some(sel) = &partition.selected { + if let Some(Ok(ref link)) = self.link { + link.input_sender.send(self.state.clone()); + } + self.is_rendering = true; + } + } + + // Receive new data + if let Some(Ok(ref link)) = self.link { + match link.output_receiver.try_recv() { + Ok(Ok(p)) => { + self.partitions.push(p); + self.is_rendering = false; + } + Err(_) => (), + Ok(Err(e)) => self.error = Some(e), + } + + // Check if the thread is still running + // FIXME: Not sure how to do this, joinhandle doesn't expose anything.. + //if link.handle. + } + + let Self { + config, + link, + state, + partitions, + is_rendering, + error, + .. + } = self; + + egui::SidePanel::left("my_left_panel").show(ctx, |ui| { + ui.heading("My egui Application"); + ui.horizontal(|ui| { + ui.label("Search"); + //ui.text_edit_singleline(state.domain_filter); + }); + //ui.add(egui::Slider::new(age, 0..=120).text("age")); + //if ui.button("Click each year").clicked() { + // if let Some(Ok(link)) = link { + // link.input_sender.send(state.clone()); + // *is_rendering = true; + // } + //} + }); + + egui::TopBottomPanel::top("my_panel").show(ctx, |ui| { + ui.label("GmailDB"); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + match (partitions.last_mut(), *is_rendering) { + (_, true) | (None, false) => { + ui.centered_and_justified(|ui| { + ui.add(Spinner::new(egui::vec2(50.0, 50.0))); + }); + } + (Some(p), _) => { + ui.add(super::widgets::rectangles(p)); + } + } + }); + + // Resize the native window to be just the size we need it to be: + frame.set_window_size(ctx.used_size()); + + // If we're waiting for a computation to succeed, + // we re-render again. + // We do this because calling + // ctx.request_repaint(); + // somehow didn't work.. + if *is_rendering == true { + ctx.request_repaint(); + } + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..7bb35b1 --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,10 @@ +use crate::types::Config; + +mod app; +pub(crate) mod state; +pub(crate) mod widgets; + +pub fn run_gui(config: Config) { + let options = eframe::NativeOptions::default(); + eframe::run_native(Box::new(app::MyApp::new(&config)), options); +} diff --git a/src/gui/state.rs b/src/gui/state.rs new file mode 100644 index 0000000..c205977 --- /dev/null +++ b/src/gui/state.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone)] +pub struct State { + pub year_filter: Option, + pub domain_filter: Option, +} diff --git a/src/gui/test.rs b/src/gui/test.rs new file mode 100644 index 0000000..0c550f1 --- /dev/null +++ b/src/gui/test.rs @@ -0,0 +1,63 @@ +/*pub fn toggle_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { + // Widget code can be broken up in four steps: + // 1. Decide a size for the widget + // 2. Allocate space for it + // 3. Handle interactions with the widget (if any) + // 4. Paint the widget + + // 1. Deciding widget size: + // You can query the `ui` how much space is available, + // but in this example we have a fixed size widget based on the height of a standard button: + let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0); + + // 2. Allocating space: + // This is where we get a region of the screen assigned. + // We also tell the Ui to sense clicks in the allocated region. + let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + + // 3. Interact: Time to check for clicks! + if response.clicked() { + *on = !*on; + response.mark_changed(); // report back that the value changed + } + + // Attach some meta-data to the response which can be used by screen readers: + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, *on, "")); + + // 4. Paint! + // First let's ask for a simple animation from egui. + // egui keeps track of changes in the boolean associated with the id and + // returns an animated value in the 0-1 range for how much "on" we are. + let how_on = ui.ctx().animate_bool(response.id, *on); + + // We will follow the current style by asking + // "how should something that is being interacted with be painted?". + // This will, for instance, give us different colors when the widget is hovered or clicked. + let visuals = ui.style().interact_selectable(&response, *on); + + // All coordinates are in absolute screen coordinates so we use `rect` to place the elements. + let rect = rect.expand(visuals.expansion); + let radius = 0.5 * rect.height(); + ui.painter() + .rect(rect, radius, visuals.bg_fill, visuals.bg_stroke); + + // Paint the circle, animating it from left to right with `how_on`: + let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on); + let center = egui::pos2(circle_x, rect.center().y); + ui.painter() + .circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke); + + // All done! Return the interaction response so the user can check what happened + // (hovered, clicked, ...) and maybe show a tooltip: + response +} + +pub fn toggle(on: &mut bool) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| toggle_ui(ui, on) +} + +pub fn url_to_file_source_code() -> String { + format!("https://github.com/emilk/egui/blob/master/{}", file!()) +} + +*/ diff --git a/src/gui/widgets/mod.rs b/src/gui/widgets/mod.rs new file mode 100644 index 0000000..c9fd4cf --- /dev/null +++ b/src/gui/widgets/mod.rs @@ -0,0 +1,5 @@ +mod rectangles; +mod spinner; + +pub use rectangles::rectangles; +pub use spinner::Spinner; diff --git a/src/gui/widgets/rectangles.rs b/src/gui/widgets/rectangles.rs new file mode 100644 index 0000000..9f996be --- /dev/null +++ b/src/gui/widgets/rectangles.rs @@ -0,0 +1,83 @@ +use std::collections::hash_map::DefaultHasher; + +use crate::canvas_calc::{Partition, Partitions}; +use eframe::egui::{self, Align2, Rgba, Stroke, TextStyle}; + +fn partition_to_color(partition: &Partition) -> Rgba { + let mut hasher = DefaultHasher::new(); + use std::hash::{Hash, Hasher}; + partition.value.hash(&mut hasher); + let value = hasher.finish(); + let [r1, r2, g1, g2, b1, b2, _, _] = value.to_be_bytes(); + + Rgba::from_rgb( + (r1 as f32 + r2 as f32) / (u8::MAX as f32 * 2.0), + (g1 as f32 + g2 as f32) / (u8::MAX as f32 * 2.0), + (b1 as f32 + b2 as f32) / (u8::MAX as f32 * 2.0), + ) +} + +fn rectangles_ui(ui: &mut egui::Ui, partitions: &mut Partitions) -> egui::Response { + let size = ui.available_size(); + let (rect, mut response) = ui.allocate_exact_size(size, egui::Sense::click()); + + // let visuals = ui.style().interact_selectable(&response, true); + + partitions.update_layout(rect); + + for item in &partitions.items { + if ui.put(item.layout_rect(), rectangle(&item)).clicked() { + dbg!("clicked"); + partitions.selected = Some(item.clone()); + response.mark_changed(); + } + } + + response +} + +pub fn rectangles(partitions: &mut Partitions) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| rectangles_ui(ui, partitions) +} + +fn rectangle_ui(ui: &mut egui::Ui, partition: &Partition) -> egui::Response { + let size = ui.available_size(); + let (rect, mut response) = ui.allocate_exact_size(size, egui::Sense::click()); + + let visuals = ui.style().interact_selectable(&response, true); + + let stroke = if ui.ui_contains_pointer() { + Stroke::new(4.0, visuals.fg_stroke.color) + } else { + Stroke::default() + }; + + let color = partition_to_color(&partition); + + let painter = ui.painter(); + + painter.rect(rect, 0.0, color, stroke); + let center = rect.center(); + + let label = format!("{}\n{}", &partition.value, &partition.count); + + let style = TextStyle::Body; + + let galley = painter.layout_multiline(style, label.clone(), 32.0); + if galley.size.x < rect.width() && galley.size.y < rect.height() { + // Can't just paint the galley as it has no `anchor` prop.. + painter.text( + center, + Align2::CENTER_CENTER, + &label, + style, + Rgba::BLACK.into(), + ); + } + + response.on_hover_text(&label) +} + +fn rectangle(partition: &Partition) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| rectangle_ui(ui, partition) +} diff --git a/src/gui/widgets/spinner.rs b/src/gui/widgets/spinner.rs new file mode 100644 index 0000000..4ca66ee --- /dev/null +++ b/src/gui/widgets/spinner.rs @@ -0,0 +1,45 @@ +use eframe::egui::{self, lerp, vec2, Color32, Pos2, Response, Shape, Stroke, Vec2, Widget}; + +/// A simple spinner +pub struct Spinner(Vec2); + +impl Spinner { + pub fn new(size: Vec2) -> Self { + Self(size) + } +} + +impl Widget for Spinner { + fn ui(self, ui: &mut egui::Ui) -> Response { + let Spinner(size) = self; + + let (outer_rect, response) = ui.allocate_exact_size(size, egui::Sense::hover()); + let visuals = ui.style().visuals.clone(); + + let corner_radius = outer_rect.height() / 2.0; + + let n_points = 20; + let start_angle = ui.input().time as f64 * 360f64.to_radians(); + let end_angle = start_angle + 240f64.to_radians() * ui.input().time.sin(); + let circle_radius = corner_radius - 2.0; + let points: Vec = (0..n_points) + .map(|i| { + let angle = lerp(start_angle..=end_angle, i as f64 / n_points as f64); + let (sin, cos) = angle.sin_cos(); + outer_rect.right_center() + + circle_radius * vec2(cos as f32, sin as f32) + + vec2(-corner_radius, 0.0) + }) + .collect(); + ui.painter().add(Shape::Path { + points, + closed: false, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(8.0, visuals.strong_text_color()), + }); + + ui.ctx().request_repaint(); + + response + } +} diff --git a/src/lib.rs b/src/lib.rs index 4f4886e..e24b64c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ use tracing_subscriber::EnvFilter; +mod canvas_calc; pub mod database; pub mod filesystem; #[cfg(feature = "gui")] diff --git a/src/types/email.rs b/src/types/email.rs index 7d22617..7e465e2 100644 --- a/src/types/email.rs +++ b/src/types/email.rs @@ -9,9 +9,15 @@ pub struct EmailMeta { pub is_seen: bool, } +const TAG_SEP: &str = ":|:"; + impl EmailMeta { + pub fn from(is_seen: bool, tag_string: &str) -> Self { + let tags = tag_string.split(TAG_SEP).map(|e| e.to_string()).collect(); + EmailMeta { tags, is_seen } + } pub fn tags_string(&self) -> String { - self.tags.join(":|:") + self.tags.join(TAG_SEP) } }