diff --git a/checkgit/src/helper.rs b/checkgit/src/helper.rs new file mode 100644 index 0000000..63384ca --- /dev/null +++ b/checkgit/src/helper.rs @@ -0,0 +1,14 @@ +use std::io::{Write, stdout}; + +pub fn clear_screen() { + print!("\x1B[2J\x1B[1;1H"); + stdout().flush().unwrap(); +} + +pub fn move_cursor_up(lines: u16) { + print!("\x1B[{}A", lines); +} + +pub fn move_cursor_right(cols: u16) { + print!("\x1B[{}C", cols); +} \ No newline at end of file diff --git a/checkgit/src/main.rs b/checkgit/src/main.rs index 566544b..da3c422 100644 --- a/checkgit/src/main.rs +++ b/checkgit/src/main.rs @@ -1,11 +1,13 @@ use checkgit_core::get_user_profile; +mod helper; mod token_cli; +mod render; use clap::Parser; -use colored::*; use token_cli::*; -use viuer::{print, Config}; +use render::*; + #[tokio::main] async fn main() { @@ -32,60 +34,4 @@ async fn main() { Ok(profile) => render(profile), Err(e) => eprintln!("Error: {}", e), } -} - -fn render(profile: checkgit_core::UserProfile) { - print( - &profile.avatar_image, - &Config { - width: Some(40), - ..Default::default() - }, - ) - .unwrap(); - - println!(); - - let name = profile - .display_name - .clone() - .unwrap_or(profile.username.clone()); - - println!("{}", name.bold().bright_white()); - println!("{}", format!("@{}", profile.username).bright_black()); - - println!(); - println!("{} {}", "Followers:".bright_blue(), profile.followers); - println!("{} {}", "Following:".bright_blue(), profile.following); - println!("{} {}", "Repos:".bright_blue(), profile.repo_count); - println!("{} {}", "Stars:".bright_blue(), profile.total_stars); - - println!(); - println!("{}", "Top Repositories".bold().bright_white()); - - for (name, stars) in profile.top_repos { - println!(" ★ {:<20} {}", name, stars.to_string().yellow()); - } - - println!(); - println!("{}", "Contribution Heatmap".bold().bright_white()); - println!(); - - render_heatmap(profile.contribution_matrix); -} - -fn render_heatmap(matrix: Vec>) { - for row in matrix { - for value in row { - let block = match value { - 0 => " ".on_bright_black(), - 1..=2 => " ".on_green(), - 3..=5 => " ".on_bright_green(), - 6..=10 => " ".on_truecolor(0, 255, 0), - _ => " ".on_truecolor(0, 200, 0), - }; - print!("{}", block); - } - println!(); - } } \ No newline at end of file diff --git a/checkgit/src/render.rs b/checkgit/src/render.rs new file mode 100644 index 0000000..526b9c9 --- /dev/null +++ b/checkgit/src/render.rs @@ -0,0 +1,271 @@ +use crate::helper::{clear_screen, move_cursor_right, move_cursor_up}; +use colored::Colorize; +use viuer::{Config, print}; + +pub fn render(profile: checkgit_core::UserProfile) { + clear_screen(); + + let avatar_width: u32 = 25; + + print( + &profile.avatar_image, + &Config { + width: Some(avatar_width), + use_kitty: true, + use_iterm: true, + absolute_offset: false, + ..Default::default() + }, + ) + .unwrap(); + + let avatar_height_rows = (avatar_width / 2) + 2; + + move_cursor_up(avatar_height_rows as u16); + + let col: u16 = (avatar_width + 4) as u16; + + let name = profile + .display_name + .clone() + .unwrap_or(profile.username.clone()); + + move_cursor_right(col); + println!("{}", name.bold().truecolor(230, 237, 243)); + + move_cursor_right(col); + println!( + "{}", + format!("@{}", profile.username).truecolor(125, 133, 144) + ); + + if let Some(ref bio) = profile.bio { + move_cursor_right(col); + println!("{}", bio.truecolor(173, 186, 199)); + } + + move_cursor_right(col); + println!(); + + move_cursor_right(col); + print!("{} ", "◉".truecolor(125, 133, 144)); + print!( + "{} ", + profile + .followers + .to_string() + .bold() + .truecolor(230, 237, 243) + ); + print!("{}", "followers".truecolor(125, 133, 144)); + print!(" {} ", "·".truecolor(48, 54, 61)); + print!( + "{} ", + profile + .following + .to_string() + .bold() + .truecolor(230, 237, 243) + ); + println!("{}", "following".truecolor(125, 133, 144)); + + move_cursor_right(col); + print!("{} ", "⊞".truecolor(125, 133, 144)); + print!( + "{} ", + profile + .repo_count + .to_string() + .bold() + .truecolor(230, 237, 243) + ); + print!("{}", "repos".truecolor(125, 133, 144)); + print!(" "); + print!("{} ", "★".truecolor(210, 153, 34)); + print!( + "{} ", + profile + .total_stars + .to_string() + .bold() + .truecolor(230, 237, 243) + ); + println!("{}", "stars".truecolor(125, 133, 144)); + + move_cursor_right(col); + println!(); + + move_cursor_right(col); + println!("{}", "Popular repositories".bold().truecolor(230, 237, 243)); + move_cursor_right(col); + println!("{}", "─".repeat(34).truecolor(48, 54, 61)); + + for (repo, stars) in &profile.top_repos { + move_cursor_right(col); + print!("{} ", "◈".truecolor(88, 166, 255)); + print!("{:<24}", repo.truecolor(88, 166, 255)); + print!("{} ", "★".truecolor(210, 153, 34)); + println!("{}", stars.to_string().truecolor(125, 133, 144)); + } + + let profile_lines_printed: u16 = + 10 + profile.top_repos.len() as u16 + if profile.bio.is_some() { 1 } else { 0 }; + + let remaining = (avatar_height_rows as u16).saturating_sub(profile_lines_printed); + for _ in 0..remaining { + println!(); + } + + println!(); + render_heatmap(profile.contribution_matrix); +} + +pub fn render_heatmap(matrix: Vec>) { + if matrix.is_empty() { + return; + } + + let weeks = matrix.iter().map(|r| r.len()).max().unwrap_or(0); + let total: u32 = matrix.iter().flatten().sum(); + + println!( + "{} contributions in the last year\n", + total.to_string().bold().truecolor(230, 237, 243) + ); + + fn level_color(level: u8) -> (u8, u8, u8) { + match level { + 0 => (80, 80, 80), + 1 => (0, 120, 0), + 2 => (0, 160, 0), + 3 => (0, 200, 0), + _ => (0, 255, 0), + } + } + + fn level(value: u32) -> u8 { + match value { + 0 => 0, + 1..=3 => 1, + 4..=7 => 2, + 8..=15 => 3, + _ => 4, + } + } + + let months = [ + "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar", + ]; + + let mut month_positions = vec![None; weeks]; + + for (i, m) in months.iter().enumerate() { + let pos = (i * weeks) / 12; + if pos < weeks { + month_positions[pos] = Some(*m); + } + } + + print!(" "); + + for w in 0..weeks { + if let Some(m) = month_positions[w] { + print!("{}", m.truecolor(125, 133, 144)); + for _ in 0..(3 - m.len()) { + print!(" "); + } + } else { + print!(" "); + } + } + + println!(); + + for day in 0..7 { + let label = match day { + 1 => "Mon", + 3 => "Wed", + 5 => "Fri", + _ => " ", + }; + + print!("{} ", label.truecolor(125, 133, 144)); + + if let Some(row) = matrix.get(day) { + for value in row { + let lvl = level(*value); + let (r, g, b) = level_color(lvl); + + print!("{}", "██".truecolor(r, g, b)); + print!(" "); + } + } + + println!(); + } + + + println!(); + + print!("{}", "Less ".truecolor(125, 133, 144)); + + for lvl in 0..=4 { + let (r, g, b) = level_color(lvl); + print!("{}", " ".on_truecolor(r, g, b)); + print!(" "); + } + + println!("{}", "More".truecolor(125, 133, 144)); + + + let mut longest = 0; + let mut current = 0; + let mut running = 0; + + for week in 0..weeks { + for day in 0..7 { + let v = matrix + .get(day) + .and_then(|r| r.get(week)) + .copied() + .unwrap_or(0); + + if v > 0 { + running += 1; + longest = longest.max(running); + } else { + running = 0; + } + } + } + + for week in (0..weeks).rev() { + for day in (0..7).rev() { + let v = matrix + .get(day) + .and_then(|r| r.get(week)) + .copied() + .unwrap_or(0); + + if v > 0 { + current += 1; + } else { + break; + } + } + if current == 0 { + break; + } + } + + println!(); + println!( + "{} {} {} {}", + "Current streak:".truecolor(125, 133, 144), + current.to_string().bold(), + "Longest streak:".truecolor(125, 133, 144), + longest.to_string().bold() + ); + + println!(); +} diff --git a/checkgit_core/src/github.rs b/checkgit_core/src/github.rs index 54c1679..bef3cca 100644 --- a/checkgit_core/src/github.rs +++ b/checkgit_core/src/github.rs @@ -2,6 +2,7 @@ use reqwest::{Client, StatusCode}; use serde::Deserialize; use crate::{error::CheckGitError, models::GraphQLResponse}; +use image::{DynamicImage, imageops::FilterType}; #[derive(Debug, Deserialize)] pub struct GithubUserResponse { @@ -28,7 +29,6 @@ pub struct GithubClient { impl GithubClient { pub fn new(token: Option) -> Result { let client = Client::builder().user_agent("checkgit").build()?; - Ok(Self { client, token }) } @@ -74,14 +74,19 @@ impl GithubClient { pub async fn fetch_avatar_image( &self, avatar_url: &str, - ) -> Result { - let response = self.client.get(avatar_url).send().await?; + ) -> Result { + let hi_res_url = if avatar_url.contains('?') { + format!("{}&s=460", avatar_url) + } else { + format!("{}?s=460", avatar_url) + }; + + let response = self.client.get(&hi_res_url).send().await?; let bytes = response.bytes().await?; let img = image::load_from_memory(&bytes) .map_err(|e| CheckGitError::ImageError(e.to_string()))?; - // Center square crop let size = img.width().min(img.height()); let cropped = img.crop_imm( (img.width() - size) / 2, @@ -90,8 +95,13 @@ impl GithubClient { size, ); - Ok(cropped) + let resized = cropped.resize(460, 460, FilterType::Lanczos3); + + let sharpened = resized.unsharpen(0.8, 2); + + Ok(sharpened) } + pub async fn fetch_contributions( &self, username: &str, @@ -128,16 +138,11 @@ impl GithubClient { .await?; let response = self.handle_status(response).await?; - let text = response.text().await?; let parsed: GraphQLResponse = serde_json::from_str(&text).map_err(|_| CheckGitError::InvalidResponse)?; - if parsed.errors.is_some() || parsed.data.is_none() { - return Err(CheckGitError::InvalidResponse); - } - let weeks = parsed .data .unwrap() @@ -162,4 +167,4 @@ impl GithubClient { pub fn calculate_total_stars(repos: &[GithubRepoResponse]) -> u32 { repos.iter().map(|r| r.stargazers_count).sum() -} +} \ No newline at end of file diff --git a/checkgit_core/src/models.rs b/checkgit_core/src/models.rs index e07e5ac..9661510 100644 --- a/checkgit_core/src/models.rs +++ b/checkgit_core/src/models.rs @@ -3,7 +3,6 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct GraphQLResponse { pub data: Option, - pub errors: Option>, } #[derive(Debug, Deserialize)] @@ -38,9 +37,4 @@ pub struct Week { #[serde(rename_all = "camelCase")] pub struct ContributionDay { pub contribution_count: u32, -} - -#[derive(Debug, Deserialize)] -pub struct GraphQLError { - pub message: String, } \ No newline at end of file