feat: added gui frontend and options and features
This commit is contained in:
parent
719ab13b2a
commit
69319bedb2
4 changed files with 251 additions and 64 deletions
17
Cargo.toml
17
Cargo.toml
|
@ -6,8 +6,21 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# CORE
|
||||||
chrono = "0.4.31"
|
chrono = "0.4.31"
|
||||||
crossterm = "0.27.0"
|
|
||||||
git2 = "0.18.1"
|
git2 = "0.18.1"
|
||||||
rand = "0.8.5"
|
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"]
|
||||||
|
|
108
src/gui.rs
Normal file
108
src/gui.rs
Normal file
|
@ -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<String, Vec<[f64; 2]>>,
|
||||||
|
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::<Local>::from(DateTime::<Utc>::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"
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
123
src/main.rs
123
src/main.rs
|
@ -1,12 +1,43 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use git2::{Repository, Commit};
|
use clap::{Parser, Subcommand};
|
||||||
use chrono::DateTime;
|
use git2::{Repository, Commit, Oid};
|
||||||
|
use chrono::{DateTime, Utc, Duration};
|
||||||
|
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
mod tui;
|
mod tui;
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
mod gui;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
struct CliArgs {
|
||||||
|
path: Option<String>,
|
||||||
|
|
||||||
|
#[clap(subcommand)]
|
||||||
|
mode: Option<CliViewMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum CliViewMode {
|
||||||
|
|
||||||
|
Basic,
|
||||||
|
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
Tui {
|
||||||
|
|
||||||
|
#[clap(long, default_value_t = false)]
|
||||||
|
no_total: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
Gui,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
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();
|
let mut rev = repo.revwalk().unwrap();
|
||||||
rev.set_sorting(git2::Sort::TOPOLOGICAL).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 {
|
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()
|
commit.author().name().unwrap_or("").to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Data = Vec<(f64, f64)>;
|
||||||
|
|
||||||
|
pub struct CommitActivityData {
|
||||||
|
pub total: Data,
|
||||||
|
pub per_author: HashMap<String, Data>,
|
||||||
|
pub oldest: DateTime<Utc>,
|
||||||
|
pub max_count: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process(
|
||||||
|
commits: HashMap<Oid, (DateTime<Utc>, Commit)>,
|
||||||
|
) -> Result<CommitActivityData, Box<dyn std::error::Error>> {
|
||||||
|
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<String> = commits.values().map(|(_t, c)| crate::author_display(c, false)).collect();
|
||||||
|
let mut authors_data : HashMap<String,Vec<(f64, f64)>> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
67
src/tui.rs
67
src/tui.rs
|
@ -1,9 +1,6 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
use rand::seq::SliceRandom;
|
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 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}};
|
use ratatui::{prelude::*, widgets::{Dataset, GraphType, Chart, Block, Axis}};
|
||||||
|
|
||||||
const COLORS : [Color; 10] = [
|
const COLORS : [Color; 10] = [
|
||||||
|
@ -19,58 +16,11 @@ const COLORS : [Color; 10] = [
|
||||||
Color::LightMagenta,
|
Color::LightMagenta,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn display(
|
pub fn display(data: crate::CommitActivityData, total: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
commits: HashMap<Oid, (DateTime<Utc>, Commit)>,
|
|
||||||
total: bool,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
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<String> = commits.values().map(|(_t, c)| crate::author_display(c, false)).collect();
|
|
||||||
let mut authors_data : HashMap<String,Vec<(f64, f64)>> = 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepare chart widget
|
// prepare chart widget
|
||||||
let mut datasets = Vec::new();
|
let mut datasets = Vec::new();
|
||||||
|
|
||||||
for (name, author_d) in authors_data.iter() {
|
for (name, author_d) in data.per_author.iter() {
|
||||||
datasets.push(
|
datasets.push(
|
||||||
Dataset::default()
|
Dataset::default()
|
||||||
.name(name.clone())
|
.name(name.clone())
|
||||||
|
@ -87,18 +37,21 @@ pub fn display(
|
||||||
.marker(symbols::Marker::Braille)
|
.marker(symbols::Marker::Braille)
|
||||||
.graph_type(GraphType::Line)
|
.graph_type(GraphType::Line)
|
||||||
.style(Style::default().fg(Color::Red))
|
.style(Style::default().fg(Color::Red))
|
||||||
.data(&data)
|
.data(&data.total)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let x_labels = [
|
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(),
|
Utc::now().date_naive().format("%Y/%m/%d").to_string(),
|
||||||
];
|
];
|
||||||
let y_labels = [
|
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)
|
let chart = Chart::new(datasets)
|
||||||
.block(Block::default().title("Commits"))
|
.block(Block::default().title("Commits"))
|
||||||
.x_axis(Axis::default()
|
.x_axis(Axis::default()
|
||||||
|
@ -110,7 +63,7 @@ pub fn display(
|
||||||
.y_axis(Axis::default()
|
.y_axis(Axis::default()
|
||||||
.title(Span::styled("Y Axis", Style::default().fg(Color::Cyan)))
|
.title(Span::styled("Y Axis", Style::default().fg(Color::Cyan)))
|
||||||
.style(Style::default().fg(Color::White))
|
.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())
|
.labels(y_labels.iter().map(Span::from).collect())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue