diff --git a/Cargo.toml b/Cargo.toml index d7b7730..fc6ef15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,21 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +# CORE chrono = "0.4.31" -crossterm = "0.27.0" git2 = "0.18.1" rand = "0.8.5" -ratatui = { version = "0.24.0", features = ["all-widgets"] } +clap = { version = "4.4.8", features = ["derive"] } +# TUI +crossterm = { version = "0.27", optional = true } +ratatui = { version = "0.24", features = ["all-widgets"], optional = true } +# GUI +eframe = { version = "0.24", optional = true } +egui_plot = { version = "0.24", optional = true } + + + +[features] +default = ["tui", "gui"] +tui = ["dep:crossterm", "dep:ratatui"] +gui = ["dep:eframe", "dep:egui_plot"] diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..c6c2120 --- /dev/null +++ b/src/gui.rs @@ -0,0 +1,108 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Local, Utc, NaiveDateTime}; +use eframe::{egui::{self, Layout}, emath::{Vec2b, Align}}; +use egui_plot::{Plot, Legend, Line, PlotPoints}; + +use crate::CommitActivityData; + +fn tuple_to_vec(vec: Vec<(f64, f64)>) -> Vec<[f64; 2]> { + vec.iter().map(|(x,y)| [*x,*y]).collect() +} + + +pub fn display(data: CommitActivityData) -> eframe::Result<()> { + eframe::run_native( + "gitts", + eframe::NativeOptions::default(), + Box::new(|_cc| Box::new(GitStatsApp::new(data))), + ) +} + +struct GitStatsApp { + total_data: Vec<[f64; 2]>, + author_data: HashMap>, + lock: bool, +} + +impl GitStatsApp { + fn new(data: CommitActivityData) -> Self { + let mut author_data = HashMap::new(); + for (author, data) in data.per_author { + author_data.insert(author, tuple_to_vec(data)); + } + + Self { + total_data: tuple_to_vec(data.total), + author_data, + lock: true, + } + } +} + +impl eframe::App for GitStatsApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let mut plot_rect = None; + egui::CentralPanel::default().show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label("git activity"); + ui.separator(); + ui.checkbox(&mut self.lock, "lock"); + ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { + if ui.button("x").clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }); + }); + + let plot = Plot::new("Commit Activity per week") + .legend(Legend::default()) + .x_axis_formatter(|x, _maxc, _range| timestamp_to_str(x as i64, true, false)) + .label_formatter(|name, value| { + if name.is_empty() { + format!("t = {}", timestamp_to_str(value.x as i64, true, false)) + } else { + format!("{}\nt = {}\ncommits/week = {:.0}", name, timestamp_to_str(value.x as i64, true, false), value.y) + } + }); + + // let's create a dummy line in the plot + let inner = plot.show(ui, |plot_ui| { + plot_ui.line( + Line::new(PlotPoints::from(self.total_data.clone())) + .name("[total]") + ); + for (author, data) in &self.author_data { + plot_ui.line( + Line::new(PlotPoints::from(data.clone())) + .name(author) + ); + } + if self.lock { + plot_ui.set_auto_bounds(Vec2b { x: true, y: true }); + } + }); + // Remember the position of the plot + plot_rect = Some(inner.response.rect); + }); + } +} + +pub fn timestamp_to_str(t: i64, date: bool, time: bool) -> String { + format!( + "{}", + DateTime::::from(DateTime::::from_utc( + NaiveDateTime::from_timestamp(t, 0), + Utc + )) + .format(if date && time { + "%Y/%m/%d %H:%M:%S" + } else if date { + "%Y/%m/%d" + } else if time { + "%H:%M:%S" + } else { + "%s" + }) + ) +} diff --git a/src/main.rs b/src/main.rs index ceb7d30..21f8f09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,43 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; -use git2::{Repository, Commit}; -use chrono::DateTime; +use clap::{Parser, Subcommand}; +use git2::{Repository, Commit, Oid}; +use chrono::{DateTime, Utc, Duration}; +#[cfg(feature = "tui")] mod tui; +#[cfg(feature = "gui")] +mod gui; + +#[derive(Parser)] +struct CliArgs { + path: Option, + + #[clap(subcommand)] + mode: Option, +} + +#[derive(Subcommand)] +enum CliViewMode { + + Basic, + + #[cfg(feature = "tui")] + Tui { + + #[clap(long, default_value_t = false)] + no_total: bool, + }, + + #[cfg(feature = "gui")] + Gui, +} + fn main() { - let repo = Repository::open(std::env::args().nth(1).unwrap_or(".".to_string())).unwrap(); + let args = CliArgs::parse(); + + let repo = Repository::open(args.path.as_deref().unwrap_or(".")).unwrap(); let mut rev = repo.revwalk().unwrap(); rev.set_sorting(git2::Sort::TOPOLOGICAL).unwrap(); @@ -25,7 +56,19 @@ fn main() { } } - tui::display(store, false).unwrap(); + let data = process(store).unwrap(); + + match args.mode.unwrap_or(CliViewMode::Basic) { + + CliViewMode::Basic => basic_display(data), + + #[cfg(feature = "tui")] + CliViewMode::Tui { no_total } => tui::display(data, !no_total).unwrap(), + + #[cfg(feature = "gui")] + CliViewMode::Gui => gui::display(data).unwrap(), + + } } pub fn author_display(commit: &Commit, full: bool) -> String { @@ -35,3 +78,73 @@ pub fn author_display(commit: &Commit, full: bool) -> String { commit.author().name().unwrap_or("").to_string() } } + +type Data = Vec<(f64, f64)>; + +pub struct CommitActivityData { + pub total: Data, + pub per_author: HashMap, + pub oldest: DateTime, + pub max_count: f64, +} + +pub fn process( + commits: HashMap, Commit)>, +) -> Result> { + let now = Utc::now(); + let min = *commits.values().map(|(t, _c)| t).min().unwrap(); + let bins = (now - min).num_weeks() as usize; + + let mut data = vec![(0f64, 0f64); bins + 1]; + let authors : HashSet = commits.values().map(|(_t, c)| crate::author_display(c, false)).collect(); + let mut authors_data : HashMap> = HashMap::new(); + for author in authors { + authors_data.insert(author, vec![(0f64, -1.0f64); bins + 1]); + } + + let mut y_max = 0.; + for (t, c) in commits.values() { + let bin = (*t - min).num_weeks() as usize; + data[bin].1 += 1.; + if data[bin].1 > y_max { + y_max = data[bin].1; + } + if let Some(x) = authors_data.get_mut(&crate::author_display(c, false)) { + if x[bin].1 < 0. { x[bin].1 = 0. }; + if bin > 0 && x[bin-1].1 < 0. { x[bin-1].1 = 0. }; + if bin < x.len() - 1 && x[bin+1].1 < 0. { x[bin+1].1 = 0. }; + x[bin].1 += 1.; + } + } + + for (_name, author_d) in authors_data.iter_mut() { + let mut min_mut = min; + for (x, _) in author_d.iter_mut() { + *x = min_mut.timestamp() as f64; + min_mut += Duration::weeks(1); + } + } + + let mut min_mut = min; + for (x, _) in data.iter_mut() { + *x = min_mut.timestamp() as f64; + min_mut += Duration::weeks(1); + } + + Ok(CommitActivityData { + total: data, + per_author: authors_data, + oldest: min, + max_count: y_max, + }) +} + +pub fn basic_display(data: CommitActivityData) { + let total : u64 = data.total.iter().map(|(_x, y)| *y as u64).sum(); + println!("total commits: {}", total); + + for (name, author_data) in data.per_author { + let author_total : u64 = author_data.iter().map(|(_x, y)| *y as u64).sum(); + println!("{} commits: {}", name, author_total); + } +} diff --git a/src/tui.rs b/src/tui.rs index eeb7ac5..7f6d3b5 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,9 +1,6 @@ -use std::collections::{HashMap, HashSet}; - use rand::seq::SliceRandom; -use chrono::{Utc, Duration, DateTime}; +use chrono::Utc; use crossterm::{terminal::{enable_raw_mode, EnterAlternateScreen, disable_raw_mode, LeaveAlternateScreen}, execute, event}; -use git2::{Commit, Oid}; use ratatui::{prelude::*, widgets::{Dataset, GraphType, Chart, Block, Axis}}; const COLORS : [Color; 10] = [ @@ -19,58 +16,11 @@ const COLORS : [Color; 10] = [ Color::LightMagenta, ]; -pub fn display( - commits: HashMap, Commit)>, - total: bool, -) -> Result<(), Box> { - let now = Utc::now(); - let min = *commits.values().map(|(t, _c)| t).min().unwrap(); - let bins = (now - min).num_weeks() as usize; - - - let mut data = vec![(0f64, 0f64); bins + 1]; - let authors : HashSet = commits.values().map(|(_t, c)| crate::author_display(c, false)).collect(); - let mut authors_data : HashMap> = HashMap::new(); - for author in authors { - authors_data.insert(author, vec![(0f64, -1.0f64); bins + 1]); - } - - let mut y_max = 0.; - for (t, c) in commits.values() { - let bin = (*t - min).num_weeks() as usize; - data[bin].1 += 1.; - if data[bin].1 > y_max { - y_max = data[bin].1; - } - if let Some(x) = authors_data.get_mut(&crate::author_display(c, false)) { - if x[bin].1 < 0. { x[bin].1 = 0. }; - if bin > 0 && x[bin-1].1 < 0. { x[bin-1].1 = 0. }; - if bin < x.len() - 1 && x[bin+1].1 < 0. { x[bin+1].1 = 0. }; - x[bin].1 += 1.; - } - } - - let x_min = min.timestamp() as f64; - let x_max = Utc::now().timestamp() as f64; - - for (_name, author_d) in authors_data.iter_mut() { - let mut min_mut = min; - for (x, _) in author_d.iter_mut() { - *x = min_mut.timestamp() as f64; - min_mut += Duration::weeks(1); - } - } - - let mut min_mut = min; - for (x, _) in data.iter_mut() { - *x = min_mut.timestamp() as f64; - min_mut += Duration::weeks(1); - } - +pub fn display(data: crate::CommitActivityData, total: bool) -> Result<(), Box> { // prepare chart widget let mut datasets = Vec::new(); - for (name, author_d) in authors_data.iter() { + for (name, author_d) in data.per_author.iter() { datasets.push( Dataset::default() .name(name.clone()) @@ -87,18 +37,21 @@ pub fn display( .marker(symbols::Marker::Braille) .graph_type(GraphType::Line) .style(Style::default().fg(Color::Red)) - .data(&data) + .data(&data.total) ); } let x_labels = [ - min.format("%Y/%m/%d").to_string(), + data.oldest.format("%Y/%m/%d").to_string(), Utc::now().date_naive().format("%Y/%m/%d").to_string(), ]; let y_labels = [ - "0".into(), format!("{}", y_max) + "0".into(), format!("{}", data.max_count) ]; + let x_max = Utc::now().timestamp() as f64; + let x_min = data.oldest.timestamp() as f64; + let chart = Chart::new(datasets) .block(Block::default().title("Commits")) .x_axis(Axis::default() @@ -110,7 +63,7 @@ pub fn display( .y_axis(Axis::default() .title(Span::styled("Y Axis", Style::default().fg(Color::Cyan))) .style(Style::default().fg(Color::White)) - .bounds([-0.0, y_max + 1.]) + .bounds([-0.0, data.max_count + 1.]) .labels(y_labels.iter().map(Span::from).collect()) );