diff --git a/.gitignore b/.gitignore index ea8c4bf..64abe43 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +/build +.DS_Store diff --git a/assets/icons/arrow-narrow-down.svg b/assets/icons/arrow-narrow-down.svg new file mode 100644 index 0000000..adb2cf7 --- /dev/null +++ b/assets/icons/arrow-narrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/arrow-narrow-up.svg b/assets/icons/arrow-narrow-up.svg new file mode 100644 index 0000000..3a7a0a8 --- /dev/null +++ b/assets/icons/arrow-narrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/download-01.svg b/assets/icons/download-01.svg new file mode 100644 index 0000000..6f5bf54 --- /dev/null +++ b/assets/icons/download-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/folder-closed.svg b/assets/icons/folder-closed.svg new file mode 100644 index 0000000..d56b30a --- /dev/null +++ b/assets/icons/folder-closed.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg new file mode 100644 index 0000000..bc52b19 --- /dev/null +++ b/assets/icons/folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/git-branch-02.svg b/assets/icons/git-branch-02.svg new file mode 100644 index 0000000..10531bd --- /dev/null +++ b/assets/icons/git-branch-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/git-commit.svg b/assets/icons/git-commit.svg new file mode 100644 index 0000000..19a9349 --- /dev/null +++ b/assets/icons/git-commit.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/minus-circle.svg b/assets/icons/minus-circle.svg new file mode 100644 index 0000000..54d52b8 --- /dev/null +++ b/assets/icons/minus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/minus.svg b/assets/icons/minus.svg new file mode 100644 index 0000000..2a85749 --- /dev/null +++ b/assets/icons/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/plus-circle.svg b/assets/icons/plus-circle.svg new file mode 100644 index 0000000..f15cde3 --- /dev/null +++ b/assets/icons/plus-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 0000000..039f8a2 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/refresh-ccw-01.svg b/assets/icons/refresh-ccw-01.svg new file mode 100644 index 0000000..9ac22d2 --- /dev/null +++ b/assets/icons/refresh-ccw-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg new file mode 100644 index 0000000..4924cb8 --- /dev/null +++ b/assets/icons/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/trash-02.svg b/assets/icons/trash-02.svg new file mode 100644 index 0000000..a7d2ab4 --- /dev/null +++ b/assets/icons/trash-02.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/x-close.svg b/assets/icons/x-close.svg new file mode 100644 index 0000000..2e0009e --- /dev/null +++ b/assets/icons/x-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app.rs b/src/app.rs index 33b8fb1..5c7237d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,9 @@ -use std::{fs, path::PathBuf, sync::Arc}; +use std::{ + fs, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; use ab_glyph::{Font, FontArc, Glyph, GlyphId, PxScale, ScaleFont, point}; use anyhow::Context; @@ -19,15 +24,16 @@ use crate::models::{ }; use crate::render::{ Atlas, GlyphCache, GlyphKey, GlyphUV, QuadVertex, StyledRectInstance, TextVertex, Uniforms, - create_empty_buffer, create_vertex_buffer, push_styled_rect, + create_empty_buffer, create_vertex_buffer, push_styled_rect, push_styled_rect_glow, }; use crate::repo_store; use crate::theme::{ - self, ATLAS_SIZE, COLOR_BG, COLOR_ROW_SELECTED, COLOR_ROW_SELECTED_BORDER, - COLOR_ROW_SELECTED_BOTTOM, COLOR_SELECTION_ACCENT_BAR, FONT_PX, SIDE_PADDING, STATUS_BAR_GAP, - STATUS_BAR_HEIGHT, STATUS_BAR_SIDE_PADDING, TOP_PADDING, + self, ATLAS_SIZE, FONT_PX, SIDE_PADDING, STATUS_BAR_GAP, STATUS_BAR_HEIGHT, + STATUS_BAR_SIDE_PADDING, TOP_PADDING, }; +const TOOLTIP_DELAY: Duration = Duration::from_millis(500); + #[derive(Clone, Copy, Debug)] enum StatusKind { Neutral, @@ -84,6 +90,7 @@ struct State { atlas: Atlas, glyph_cache: GlyphCache, + icons: std::collections::HashMap<&'static str, GlyphUV>, font: FontArc, cell_width: f32, line_height: f32, @@ -128,8 +135,13 @@ struct State { window_controls: Vec, toolbar_buttons: Vec, + hover_toolbar_action: Option, + hover_started_at: Option, + tooltip_drawn: bool, + // ── Settings modal ─────────────────────────────────── settings_index: usize, + theme_index: usize, // ── Panel split ratio ──────────────────────────────── file_pane_ratio: f32, @@ -436,6 +448,7 @@ impl State { uniform_bg, atlas, glyph_cache, + icons: std::collections::HashMap::new(), font, cell_width: FONT_PX * ui_scale, line_height: FONT_PX * ui_scale * 1.3, @@ -469,7 +482,14 @@ impl State { mouse_pos: PhysicalPosition::new(0.0, 0.0), window_controls: Vec::new(), toolbar_buttons: Vec::new(), + hover_toolbar_action: None, + hover_started_at: None, + tooltip_drawn: false, settings_index: 0, + theme_index: theme::bundled_names() + .iter() + .position(|n| n.eq_ignore_ascii_case(theme::palette().name)) + .unwrap_or(0), file_pane_ratio: 0.30, divider_dragging: false, zoom_level: 1.0, @@ -485,6 +505,7 @@ impl State { state.configure_surface(); state.update_uniform_screen(); state.compute_font_metrics(); + state.load_toolbar_icons()?; state.refresh_recent_repos(); state.refresh_repo_tracking()?; state.rebuild_layout(); @@ -577,10 +598,10 @@ impl State { fn status_fill(&self) -> ([f32; 4], [f32; 4], [f32; 4], [f32; 4]) { match self.status.kind { - StatusKind::Neutral => theme::STATUS_NEUTRAL, - StatusKind::Success => theme::STATUS_SUCCESS, - StatusKind::Error => theme::STATUS_ERROR, - StatusKind::Prompt => theme::STATUS_PROMPT, + StatusKind::Neutral => theme::palette().status_neutral, + StatusKind::Success => theme::palette().status_success, + StatusKind::Error => theme::palette().status_error, + StatusKind::Prompt => theme::palette().status_prompt, } } @@ -1062,7 +1083,7 @@ impl State { } /// Total number of rows in the settings modal. - const SETTINGS_COUNT: usize = 3; + const SETTINGS_COUNT: usize = 4; fn handle_settings_input(&mut self, key: &Key) -> anyhow::Result { match key { @@ -1073,12 +1094,14 @@ impl State { Key::Named(NamedKey::ArrowUp) => { if self.settings_index > 0 { self.settings_index -= 1; + self.geometry_dirty = true; } Ok(true) } Key::Named(NamedKey::ArrowDown) => { if self.settings_index + 1 < Self::SETTINGS_COUNT { self.settings_index += 1; + self.geometry_dirty = true; } Ok(true) } @@ -1118,12 +1141,33 @@ impl State { let delta = direction as f32 * 0.10; self.apply_zoom(delta); } + // 3 = Theme + 3 => { + self.cycle_theme(direction); + } _ => {} } self.geometry_dirty = true; Ok(()) } + /// Step through the bundled themes by `direction` (-1 / +1) and + /// install the chosen palette atomically. The next `geometry_dirty` + /// frame redraws every chrome surface in the new colors. + fn cycle_theme(&mut self, direction: i32) { + let names = theme::bundled_names(); + if names.is_empty() { + return; + } + let n = names.len() as i32; + let cur = self.theme_index as i32; + let next = (cur + direction).rem_euclid(n) as usize; + if let Some(p) = theme::bundled(names[next]) { + theme::set_palette(p); + self.theme_index = next; + } + } + /// Build the label and current value for each settings row. fn settings_row_label(&self, index: usize) -> (&'static str, String) { match index { @@ -1141,6 +1185,7 @@ impl State { "Zoom level", format!("{:.0}%", self.zoom_level * 100.0), ), + 3 => ("Theme", theme::palette().name.to_string()), _ => ("", String::new()), } } @@ -1209,6 +1254,11 @@ impl State { self.prompt_discard_confirm(); Ok(true) } + ToolbarAction::Settings => { + self.prompt_settings(); + self.geometry_dirty = true; + Ok(true) + } ToolbarAction::Fetch => Ok(self.execute_action("Repository fetched", |state| { state.git.fetch(None)?; state.refresh_document_from_git()?; @@ -1306,14 +1356,16 @@ impl State { return Ok(()); } - // Full-screen dimming scrim behind all modals + // Full-screen dimming scrim behind all modals — opaque enough + // that the underlying chrome reads as backgrounded rather than + // competing with the modal for attention. let w = self.size.width as f32; let h = self.size.height as f32; push_styled_rect( rect_instances, [0.0, 0.0, w, h], - [0.0, 0.0, 0.0, 0.55], - [0.0, 0.0, 0.0, 0.65], + [0.0, 0.0, 0.0, 0.72], + [0.0, 0.0, 0.0, 0.80], [0.0; 4], [0.0; 4], 0.0, @@ -1350,12 +1402,12 @@ impl State { rect_instances: &mut Vec, ) -> anyhow::Result<()> { let panel = self.modal_panel_rect(self.ui(134.0)); - push_styled_rect( + push_styled_rect_glow( rect_instances, panel, - theme::MODAL_BG_TOP, - theme::MODAL_BG_BOTTOM, - theme::MODAL_BORDER, + theme::palette().modal_bg_top, + theme::palette().modal_bg_bottom, + theme::palette().modal_border, [0.0, 0.0, 0.0, 0.28], self.ui(12.0), 1.0, @@ -1363,6 +1415,7 @@ impl State { self.ui(16.0), [0.0, self.ui(4.0)], self.ui(2.0), + 0.25, ); let mut x = panel[0] + self.ui(16.0); @@ -1481,12 +1534,12 @@ impl State { let visible_count = visible_end.saturating_sub(visible_start); let panel_h = self.ui(92.0) + visible_count as f32 * (self.line_height + self.ui(6.0)); let panel = self.modal_panel_rect(panel_h); - push_styled_rect( + push_styled_rect_glow( rect_instances, panel, - theme::MODAL_BG_TOP, - theme::MODAL_BG_BOTTOM, - theme::MODAL_BORDER, + theme::palette().modal_bg_top, + theme::palette().modal_bg_bottom, + theme::palette().modal_border, [0.0, 0.0, 0.0, 0.28], self.ui(12.0), 1.0, @@ -1494,6 +1547,7 @@ impl State { self.ui(16.0), [0.0, self.ui(4.0)], self.ui(2.0), + 0.25, ); let mut x = panel[0] + self.ui(16.0); @@ -1582,12 +1636,12 @@ impl State { rect_instances: &mut Vec, ) -> anyhow::Result<()> { let panel = self.modal_panel_rect(self.ui(108.0)); - push_styled_rect( + push_styled_rect_glow( rect_instances, panel, - theme::MODAL_DANGER_BG_TOP, - theme::MODAL_DANGER_BG_BOTTOM, - theme::MODAL_DANGER_BORDER, + theme::palette().modal_danger_bg_top, + theme::palette().modal_danger_bg_bottom, + theme::palette().modal_danger_border, [0.0, 0.0, 0.0, 0.32], self.ui(12.0), 1.0, @@ -1595,6 +1649,7 @@ impl State { self.ui(16.0), [0.0, self.ui(4.0)], self.ui(2.0), + 0.25, ); let mut x = panel[0] + self.ui(16.0); @@ -1645,12 +1700,12 @@ impl State { let visible_count = visible_end.saturating_sub(visible_start); let panel_h = self.ui(92.0) + visible_count as f32 * (self.line_height + self.ui(6.0)); let panel = self.modal_panel_rect(panel_h); - push_styled_rect( + push_styled_rect_glow( rect_instances, panel, - theme::MODAL_BG_TOP, - theme::MODAL_BG_BOTTOM, - theme::MODAL_BORDER, + theme::palette().modal_bg_top, + theme::palette().modal_bg_bottom, + theme::palette().modal_border, [0.0, 0.0, 0.0, 0.28], self.ui(12.0), 1.0, @@ -1658,6 +1713,7 @@ impl State { self.ui(16.0), [0.0, self.ui(4.0)], self.ui(2.0), + 0.25, ); let mut x = panel[0] + self.ui(16.0); @@ -1713,7 +1769,7 @@ impl State { label.push_str(" (current)"); } let text_color = if is_current && !selected { - theme::BRANCH_CURRENT_BADGE + theme::palette().branch_current_badge } else if selected { [1.0, 1.0, 1.0, 1.0] } else { @@ -1753,12 +1809,12 @@ impl State { let panel_h = self.ui(92.0) + row_count as f32 * (self.line_height + self.ui(6.0)); let panel = self.modal_panel_rect(panel_h); - push_styled_rect( + push_styled_rect_glow( rect_instances, panel, - theme::MODAL_BG_TOP, - theme::MODAL_BG_BOTTOM, - theme::MODAL_BORDER, + theme::palette().modal_bg_top, + theme::palette().modal_bg_bottom, + theme::palette().modal_border, [0.0, 0.0, 0.0, 0.28], self.ui(12.0), 1.0, @@ -1766,6 +1822,7 @@ impl State { self.ui(16.0), [0.0, self.ui(4.0)], self.ui(2.0), + 0.25, ); // Title @@ -2071,6 +2128,172 @@ impl State { Ok(Some(uv)) } + /// Bundled toolbar icons. Each entry maps a logical icon name to a + /// raw SVG file embedded at compile time. + fn icon_sources() -> &'static [(&'static str, &'static str)] { + &[ + ("plus", include_str!("../assets/icons/plus.svg")), + ("minus", include_str!("../assets/icons/minus.svg")), + ( + "plus-circle", + include_str!("../assets/icons/plus-circle.svg"), + ), + ( + "minus-circle", + include_str!("../assets/icons/minus-circle.svg"), + ), + ( + "arrow-up", + include_str!("../assets/icons/arrow-narrow-up.svg"), + ), + ( + "arrow-down", + include_str!("../assets/icons/arrow-narrow-down.svg"), + ), + ("download", include_str!("../assets/icons/download-01.svg")), + ("commit", include_str!("../assets/icons/git-commit.svg")), + ("trash", include_str!("../assets/icons/trash-02.svg")), + ("folder", include_str!("../assets/icons/folder.svg")), + ( + "folder-closed", + include_str!("../assets/icons/folder-closed.svg"), + ), + ( + "git-branch", + include_str!("../assets/icons/git-branch-02.svg"), + ), + ( + "refresh", + include_str!("../assets/icons/refresh-ccw-01.svg"), + ), + ("settings", include_str!("../assets/icons/settings.svg")), + ("close", include_str!("../assets/icons/x-close.svg")), + ] + } + + /// Rasterize every bundled icon at the current DPI and upload each + /// into the shared glyph atlas. Called once at startup. + fn load_toolbar_icons(&mut self) -> anyhow::Result<()> { + // Render at the same nominal cell size we'll display at, so we + // don't blur from scale-up. 18 px nominal × ui_scale matches the + // toolbar text height. + let target = (18.0 * self.ui_scale).round().max(8.0) as u32; + let pad = 1u32; + + for (name, svg) in Self::icon_sources() { + let Some(d) = crate::icon::extract_path_d(svg) else { + continue; + }; + let (_vx, _vy, vw, vh) = crate::icon::extract_viewbox(svg); + let path = crate::icon::parse_path(d); + + let alpha = crate::icon::rasterize_filled(&path, vw, vh, target, target); + + let gw = target + pad * 2; + let gh = target + pad * 2; + let (x, y) = self + .atlas + .alloc(gw, gh) + .context("icon atlas full while loading toolbar icons")?; + + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bpr = gw.next_multiple_of(align); + let mut tmp = vec![0u8; (bpr * gh) as usize]; + for row in 0..target { + let src = (row * target) as usize; + let dst = ((row + pad) * bpr + pad) as usize; + tmp[dst..dst + target as usize] + .copy_from_slice(&alpha[src..src + target as usize]); + } + + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.atlas.tex, + mip_level: 0, + origin: wgpu::Origin3d { x, y, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + &tmp, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bpr), + rows_per_image: Some(gh), + }, + wgpu::Extent3d { + width: gw, + height: gh, + depth_or_array_layers: 1, + }, + ); + + let uv = GlyphUV { + u0: (x + pad) as f32 / self.atlas.w as f32, + v0: (y + pad) as f32 / self.atlas.h as f32, + u1: (x + pad + target) as f32 / self.atlas.w as f32, + v1: (y + pad + target) as f32 / self.atlas.h as f32, + w: target, + h: target, + bearing_x: 0.0, + bearing_y: 0.0, + }; + self.icons.insert(name, uv); + } + + Ok(()) + } + + /// Emit a textured quad for the named icon at logical (x, y) of the + /// given pixel size, tinted by `color`. Same single-channel R8 + + /// color path as text rendering, so it goes straight into the + /// existing text pipeline. + fn append_icon( + &self, + out: &mut Vec, + x: f32, + y: f32, + size: f32, + name: &str, + color: [f32; 4], + ) { + let Some(uv) = self.icons.get(name).copied() else { + return; + }; + let x0 = x.round(); + let y0 = y.round(); + let x1 = x0 + size; + let y1 = y0 + size; + out.push(TextVertex { + pos: [x0, y0], + uv: [uv.u0, uv.v0], + color, + }); + out.push(TextVertex { + pos: [x1, y0], + uv: [uv.u1, uv.v0], + color, + }); + out.push(TextVertex { + pos: [x0, y1], + uv: [uv.u0, uv.v1], + color, + }); + out.push(TextVertex { + pos: [x0, y1], + uv: [uv.u0, uv.v1], + color, + }); + out.push(TextVertex { + pos: [x1, y0], + uv: [uv.u1, uv.v0], + color, + }); + out.push(TextVertex { + pos: [x1, y1], + uv: [uv.u1, uv.v1], + color, + }); + } + fn selected_file_line_index(&self) -> Option { self.file_index_to_line .get(self.git.selected_index()) @@ -2200,12 +2423,12 @@ impl State { let bw = bar[2]; let bh = bar[3]; - // Titlebar background - push_styled_rect( + // Titlebar background — subtle top-edge chrome lift + push_styled_rect_glow( rect_instances, bar, - theme::COLOR_TITLEBAR_TOP, - theme::COLOR_TITLEBAR_BOTTOM, + theme::palette().titlebar_top, + theme::palette().titlebar_bottom, [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], 0.0, @@ -2214,14 +2437,15 @@ impl State { 0.0, [0.0, 0.0], 0.0, + 0.20, ); // Bottom divider line for titlebar push_styled_rect( rect_instances, [bx, by + bh - 1.0, bw, 1.0], - theme::DIVIDER_COLOR, - theme::DIVIDER_COLOR, + theme::palette().divider, + theme::palette().divider, [0.0; 4], [0.0; 4], 0.0, @@ -2232,57 +2456,71 @@ impl State { 0.0, ); - let controls = [ - ( - WindowControlAction::Close, - [0.95, 0.41, 0.38, 0.96], - [0.77, 0.26, 0.24, 0.96], - ), - ( - WindowControlAction::Minimize, - [0.98, 0.78, 0.41, 0.96], - [0.84, 0.63, 0.25, 0.96], - ), - ( - WindowControlAction::Zoom, - [0.40, 0.83, 0.49, 0.96], - [0.26, 0.68, 0.34, 0.96], - ), - ]; - - let mut cx = bx + self.ui(14.0); - let cy = by + bh * 0.5; - let d = self.ui(12.0); - for (action, top, bottom) in controls { - let x0 = cx - d * 0.5; - let y0 = cy - d * 0.5; - push_styled_rect( - rect_instances, - [x0, y0, d, d], - top, - bottom, - [0.0, 0.0, 0.0, 0.22], - [0.0, 0.0, 0.0, 0.18], - self.ui(6.0), - 1.0, - self.ui(0.8), - self.ui(2.0), - [0.0, self.ui(0.5)], - 0.0, - ); - self.window_controls.push(WindowControlButton { - x0, - y0, - x1: x0 + d, - y1: y0 + d, - action, - }); - cx += self.ui(18.0); + // On macOS we let the native window draw its real traffic lights + // (via NSWindow + titlebar_transparent + fullsize_content_view). + // We just paint our titlebar bg behind them and leave clearance. + // On other platforms we still draw our own controls. + #[cfg(not(target_os = "macos"))] + { + let controls = [ + ( + WindowControlAction::Close, + [0.95, 0.41, 0.38, 0.96], + [0.77, 0.26, 0.24, 0.96], + ), + ( + WindowControlAction::Minimize, + [0.98, 0.78, 0.41, 0.96], + [0.84, 0.63, 0.25, 0.96], + ), + ( + WindowControlAction::Zoom, + [0.40, 0.83, 0.49, 0.96], + [0.26, 0.68, 0.34, 0.96], + ), + ]; + + let mut cx = bx + self.ui(14.0); + let cy = by + bh * 0.5; + let d = self.ui(12.0); + for (action, top, bottom) in controls { + let x0 = cx - d * 0.5; + let y0 = cy - d * 0.5; + push_styled_rect( + rect_instances, + [x0, y0, d, d], + top, + bottom, + [0.0, 0.0, 0.0, 0.22], + [0.0, 0.0, 0.0, 0.18], + self.ui(6.0), + 1.0, + self.ui(0.8), + self.ui(2.0), + [0.0, self.ui(0.5)], + 0.0, + ); + self.window_controls.push(WindowControlButton { + x0, + y0, + x1: x0 + d, + y1: y0 + d, + action, + }); + cx += self.ui(18.0); + } } - // Build titlebar content: "wgit" brand + repo path + branch badge + // Build titlebar content: "wgit" brand + repo path + branch badge. + // macOS native traffic lights occupy roughly the first 80 logical + // px of the titlebar, so we clear them with an extra inset there. let baseline = by + (bh - self.line_height) * 0.5 + self.ascent; - let mut x = bx + self.ui(68.0); // After window controls + let brand_inset = if cfg!(target_os = "macos") { + self.ui(86.0) + } else { + self.ui(68.0) + }; + let mut x = bx + brand_inset; // Brand name self.append_text_run( @@ -2290,7 +2528,7 @@ impl State { &mut x, baseline, "wgit", - theme::TEXT_ACCENT, + theme::palette().text_accent, )?; x += self.cell_width; @@ -2306,24 +2544,46 @@ impl State { &mut x, baseline, &repo_name, - theme::TEXT_SECONDARY, + theme::palette().text_secondary, )?; x += self.cell_width * 2.0; - // Branch badge with background chip - let branch = self.repo_tracking.branch.clone(); - let branch_label = format!("\u{E0A0} {}", branch); // - let branch_chars = branch_label.chars().count() as f32; - let chip_w = branch_chars * self.cell_width + self.ui(14.0); + // Branch badge with background chip. + // We render the text first to capture its actual advance, then + // push the chip rect sized from that. The render pass draws all + // rects before any text, so the chip still appears *behind* the + // glyphs visually even though it's pushed afterwards. This way + // the chip can never desync from the text width. + // The Powerline branch glyph (`\u{E0A0}`) isn't present in the + // bundled Hack font, so prefixing it would consume two cells of + // layout advance while drawing nothing — leaving a phantom gap + // on the left of the chip. Skip the prefix; the chip's + // placement is identification enough. + let branch = self.repo_tracking.branch.trim().to_string(); + let branch_label = branch.clone(); let chip_h = self.line_height - self.ui(4.0); let chip_y = by + (bh - chip_h) * 0.5; + let pad_x = self.ui(8.0); - push_styled_rect( + let text_x_start = x; + self.append_text_run( + text_vertices, + &mut x, + baseline, + &branch_label, + theme::palette().accent_blue, + )?; + let text_advance = x - text_x_start; + + let chip_x = (text_x_start - pad_x).round(); + let chip_w = (text_advance + pad_x * 2.0).round(); + + push_styled_rect_glow( rect_instances, - [x - self.ui(7.0), chip_y, chip_w, chip_h], - [0.18, 0.24, 0.38, 0.70], - [0.14, 0.18, 0.30, 0.65], - theme::ACCENT_BLUE_DIM, + [chip_x, chip_y, chip_w, chip_h], + theme::palette().branch_chip_bg_top, + theme::palette().branch_chip_bg_bottom, + theme::palette().accent_blue_dim, [0.0; 4], self.ui(5.0), 1.0, @@ -2331,15 +2591,9 @@ impl State { 0.0, [0.0, 0.0], 0.0, + 0.30, ); - self.append_text_run( - text_vertices, - &mut x, - baseline, - &branch_label, - theme::ACCENT_BLUE, - )?; x += self.cell_width; // Ahead/behind indicators @@ -2350,7 +2604,7 @@ impl State { &mut x, baseline, &ahead_text, - theme::ACCENT_GREEN, + theme::palette().accent_green, )?; x += self.cell_width; } @@ -2361,7 +2615,7 @@ impl State { &mut x, baseline, &behind_text, - theme::ACCENT_RED, + theme::palette().accent_red, )?; }; @@ -2373,124 +2627,145 @@ impl State { fill_top: [0.16, 0.28, 0.20, 0.50], fill_bottom: [0.12, 0.22, 0.16, 0.45], stroke: [0.36, 0.68, 0.46, 0.50], - text: theme::ACCENT_GREEN, + text: theme::palette().accent_green, }; let blue_btn = ButtonStyle { fill_top: [0.16, 0.22, 0.36, 0.50], fill_bottom: [0.12, 0.17, 0.28, 0.45], stroke: [0.36, 0.50, 0.78, 0.50], - text: theme::ACCENT_BLUE, + text: theme::palette().accent_blue, }; let purple_btn = ButtonStyle { fill_top: [0.22, 0.17, 0.34, 0.50], fill_bottom: [0.17, 0.13, 0.26, 0.45], stroke: [0.50, 0.40, 0.78, 0.50], - text: theme::ACCENT_PURPLE, + text: theme::palette().accent_purple, }; let yellow_btn = ButtonStyle { fill_top: [0.28, 0.24, 0.14, 0.50], fill_bottom: [0.22, 0.18, 0.10, 0.45], stroke: [0.68, 0.56, 0.30, 0.50], - text: theme::ACCENT_YELLOW, + text: theme::palette().accent_yellow, }; let red_btn = ButtonStyle { fill_top: [0.32, 0.14, 0.14, 0.60], fill_bottom: [0.26, 0.10, 0.10, 0.55], stroke: [0.78, 0.34, 0.34, 0.60], - text: theme::ACCENT_RED, + text: theme::palette().accent_red, }; let gray_btn = ButtonStyle { fill_top: [0.18, 0.18, 0.20, 0.40], fill_bottom: [0.14, 0.14, 0.16, 0.35], stroke: [0.40, 0.40, 0.44, 0.35], - text: theme::TEXT_SECONDARY, + text: theme::palette().text_secondary, }; vec![ // ── Staging group ───────────────────── ButtonConfig { - label: String::from("s stage"), + label: String::from("Stage (s)"), + icon: "plus", action: ToolbarAction::Stage, group: ToolbarGroup::Staging, style: green_btn, }, ButtonConfig { - label: String::from("a all"), + label: String::from("Stage all (a)"), + icon: "plus-circle", action: ToolbarAction::StageAll, group: ToolbarGroup::Staging, style: green_btn, }, ButtonConfig { - label: String::from("u unstage"), + label: String::from("Unstage (u)"), + icon: "minus", action: ToolbarAction::Unstage, group: ToolbarGroup::Staging, style: yellow_btn, }, ButtonConfig { - label: String::from("U all"), + label: String::from("Unstage all (U)"), + icon: "minus-circle", action: ToolbarAction::UnstageAll, group: ToolbarGroup::Staging, style: yellow_btn, }, // ── Git ops group ───────────────────── ButtonConfig { - label: String::from("c commit"), + label: String::from("Commit (c)"), + icon: "commit", action: ToolbarAction::Commit, group: ToolbarGroup::GitOps, style: blue_btn, }, ButtonConfig { - label: String::from("f fetch"), + label: String::from("Fetch (f)"), + icon: "download", action: ToolbarAction::Fetch, group: ToolbarGroup::GitOps, style: purple_btn, }, ButtonConfig { - label: String::from("p pull"), + label: String::from("Pull (p)"), + icon: "arrow-down", action: ToolbarAction::Pull, group: ToolbarGroup::GitOps, style: purple_btn, }, ButtonConfig { - label: String::from("P push"), + label: String::from("Push (P)"), + icon: "arrow-up", action: ToolbarAction::Push, group: ToolbarGroup::GitOps, style: purple_btn, }, // ── Danger group ────────────────────── ButtonConfig { - label: String::from("x discard"), + label: String::from("Discard (x)"), + icon: "trash", action: ToolbarAction::Discard, group: ToolbarGroup::Danger, style: red_btn, }, // ── App group ───────────────────────── ButtonConfig { - label: String::from("o repos"), + label: String::from("Repos (o)"), + icon: "folder-closed", action: ToolbarAction::RepoSwitch, group: ToolbarGroup::App, style: gray_btn, }, ButtonConfig { - label: String::from("b browse"), + label: String::from("Browse (b)"), + icon: "folder", action: ToolbarAction::Browse, group: ToolbarGroup::App, style: gray_btn, }, ButtonConfig { - label: String::from("B branch"), + label: String::from("Branch (B)"), + icon: "git-branch", action: ToolbarAction::BranchSwitch, group: ToolbarGroup::App, style: gray_btn, }, ButtonConfig { - label: String::from("r refresh"), + label: String::from("Refresh (r)"), + icon: "refresh", action: ToolbarAction::Refresh, group: ToolbarGroup::App, style: gray_btn, }, ButtonConfig { - label: String::from("q quit"), + label: String::from("Settings (,)"), + icon: "settings", + action: ToolbarAction::Settings, + group: ToolbarGroup::App, + style: gray_btn, + }, + ButtonConfig { + label: String::from("Quit (q)"), + icon: "close", action: ToolbarAction::Quit, group: ToolbarGroup::App, style: gray_btn, @@ -2511,12 +2786,12 @@ impl State { let bw = bar[2]; let bh = bar[3]; - // Toolbar background - push_styled_rect( + // Toolbar background — barely-there lift, weaker than titlebar + push_styled_rect_glow( rect_instances, bar, - theme::COLOR_TOOLBAR_TOP, - theme::COLOR_TOOLBAR_BOTTOM, + theme::palette().toolbar_top, + theme::palette().toolbar_bottom, [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], 0.0, @@ -2525,14 +2800,15 @@ impl State { 0.0, [0.0, 0.0], 0.0, + 0.12, ); // Bottom divider push_styled_rect( rect_instances, [bx, by + bh - 1.0, bw, 1.0], - theme::DIVIDER_COLOR, - theme::DIVIDER_COLOR, + theme::palette().divider, + theme::palette().divider, [0.0; 4], [0.0; 4], 0.0, @@ -2544,28 +2820,28 @@ impl State { ); let mut x = bx + self.ui(14.0); - let baseline = by + (bh - self.line_height) * 0.5 + self.ascent; let text_max_x = bx + bw - self.ui(14.0); let buttons = self.toolbar_button_configs(); let mut prev_group: Option = None; - let chip_pad_h = self.ui(8.0); // horizontal padding inside chip, each side - let chip_gap = self.ui(4.0); // gap between chips in same group - let group_gap = self.ui(14.0); // gap between groups (including separator) + let icon_px = self.ui(18.0); // logical icon size + let chip_pad = self.ui(7.0); // padding around the icon, each side + let chip_w = icon_px + chip_pad * 2.0; + let chip_h = (bh - self.ui(14.0)).max(icon_px + chip_pad); + let chip_gap = self.ui(4.0); + let group_gap = self.ui(14.0); for button in buttons { - // Add separator between groups if let Some(pg) = prev_group { if pg != button.group { - // Vertical separator line between button groups let sep_x = x + (group_gap * 0.5); let sep_y = by + self.ui(12.0); let sep_h = bh - self.ui(24.0); push_styled_rect( rect_instances, [sep_x, sep_y, 1.0, sep_h], - theme::TOOLBAR_SEPARATOR, - theme::TOOLBAR_SEPARATOR, + theme::palette().toolbar_separator, + theme::palette().toolbar_separator, [0.0; 4], [0.0; 4], 0.0, @@ -2581,16 +2857,12 @@ impl State { } } - let label_w = button.label.chars().count() as f32 * self.cell_width; - let chip_w = label_w + chip_pad_h * 2.0; - if x + chip_w > text_max_x { break; } let chip_x0 = x; - let chip_y0 = by + self.ui(8.0); - let chip_h = (bh - self.ui(16.0)).max(1.0); + let chip_y0 = by + (bh - chip_h) * 0.5; push_styled_rect( rect_instances, @@ -2615,25 +2887,108 @@ impl State { action: button.action, }); - // Position text centered inside the chip - let mut text_x = chip_x0 + chip_pad_h; - self.append_text_run( + // Center the icon in the chip + let icon_x = chip_x0 + (chip_w - icon_px) * 0.5; + let icon_y = chip_y0 + (chip_h - icon_px) * 0.5; + self.append_icon( text_vertices, - &mut text_x, - baseline, - &button.label, + icon_x, + icon_y, + icon_px, + button.icon, button.style.text, - )?; + ); - // Advance x to the right edge of the chip x = chip_x0 + chip_w; - prev_group = Some(button.group); } Ok(()) } + fn build_tooltip_geometry( + &mut self, + text_vertices: &mut Vec, + rect_instances: &mut Vec, + ) -> anyhow::Result<()> { + let Some(action) = self.hover_toolbar_action else { + return Ok(()); + }; + let Some(started) = self.hover_started_at else { + return Ok(()); + }; + if started.elapsed() < TOOLTIP_DELAY { + return Ok(()); + } + + let Some(button) = self + .toolbar_buttons + .iter() + .find(|b| b.action == action) + .copied() + else { + return Ok(()); + }; + + let Some(label) = self + .toolbar_button_configs() + .into_iter() + .find(|c| c.action == action) + .map(|c| c.label) + else { + return Ok(()); + }; + + let pad_x = self.ui(8.0); + let pad_y = self.ui(4.0); + let text_w = label.chars().count() as f32 * self.cell_width; + let text_h = self.line_height; + let tip_w = text_w + pad_x * 2.0; + let tip_h = text_h + pad_y * 2.0; + let gap = self.ui(6.0); + + let btn_cx = (button.x0 + button.x1) * 0.5; + let mut tip_x = (btn_cx - tip_w * 0.5).round(); + let tip_y = (button.y1 + gap).round(); + + let screen_w = self.size.width as f32; + let margin = self.ui(4.0); + if tip_x < margin { + tip_x = margin; + } + if tip_x + tip_w > screen_w - margin { + tip_x = (screen_w - margin - tip_w).max(margin); + } + + let bg_top = theme::palette().tooltip_bg_top; + let bg_bottom = theme::palette().tooltip_bg_bottom; + let border = theme::palette().tooltip_border; + let text_color = theme::palette().tooltip_text; + + push_styled_rect_glow( + rect_instances, + [tip_x, tip_y, tip_w, tip_h], + bg_top, + bg_bottom, + border, + [0.0, 0.0, 0.0, 0.45], + self.ui(5.0), + 1.0, + 1.0, + self.ui(10.0), + [0.0, self.ui(3.0)], + self.ui(1.0), + 0.0, + ); + + let baseline = tip_y + pad_y + self.ascent; + let mut x = (tip_x + pad_x).round(); + self.append_text_run(text_vertices, &mut x, baseline, &label, text_color)?; + + self.tooltip_drawn = true; + Ok(()) + } + fn window_control_action_at(&self, pos: PhysicalPosition) -> Option { let x = pos.x as f32; let y = pos.y as f32; @@ -2675,8 +3030,8 @@ impl State { push_styled_rect( &mut rect_instances, content, - theme::COLOR_CONTENT_TOP, - theme::COLOR_CONTENT_BOTTOM, + theme::palette().content_top, + theme::palette().content_bottom, [0.0; 4], [0.0; 4], 0.0, @@ -2691,13 +3046,13 @@ impl State { let files_focused = self.focus_pane == FocusPane::Files; let diff_focused = self.focus_pane == FocusPane::Diff; - // File pane top border (focus indicator) + // File pane top border (focus indicator) — teal accent if files_focused { push_styled_rect( &mut rect_instances, [file_pane[0], file_pane[1], file_pane[2], self.ui(2.0)], - theme::ACCENT_BLUE, - theme::ACCENT_BLUE, + theme::palette().accent_blue, + theme::palette().accent_blue, [0.0; 4], [0.0; 4], 0.0, @@ -2709,13 +3064,13 @@ impl State { ); } - // Diff pane top border (focus indicator) + // Diff pane top border (focus indicator) — teal accent if diff_focused { push_styled_rect( &mut rect_instances, [diff_pane[0], diff_pane[1], diff_pane[2], self.ui(2.0)], - theme::ACCENT_BLUE, - theme::ACCENT_BLUE, + theme::palette().accent_blue, + theme::palette().accent_blue, [0.0; 4], [0.0; 4], 0.0, @@ -2732,8 +3087,8 @@ impl State { push_styled_rect( &mut rect_instances, [divider_x, content[1], self.ui(1.0), content[3]], - theme::DIVIDER_COLOR, - theme::DIVIDER_COLOR, + theme::palette().divider, + theme::palette().divider, [0.0; 4], [0.0; 4], 0.0, @@ -2788,9 +3143,9 @@ impl State { (pane[2] - self.ui(8.0)).max(1.0), self.line_height, ], - COLOR_ROW_SELECTED, - COLOR_ROW_SELECTED_BOTTOM, - COLOR_ROW_SELECTED_BORDER, + theme::palette().row_selected, + theme::palette().row_selected_bottom, + theme::palette().row_selected_border, [0.0; 4], self.ui(5.0), 1.0, @@ -2808,8 +3163,8 @@ impl State { self.ui(3.0), self.line_height - self.ui(4.0), ], - COLOR_SELECTION_ACCENT_BAR, - COLOR_SELECTION_ACCENT_BAR, + theme::palette().selection_accent_bar, + theme::palette().selection_accent_bar, [0.0; 4], [0.0; 4], self.ui(1.5), @@ -2870,8 +3225,8 @@ impl State { push_styled_rect( &mut rect_instances, [pane[0], pane[1], ln_width, pane[3]], - [0.06, 0.065, 0.08, 1.0], - [0.055, 0.06, 0.075, 1.0], + theme::palette().gutter_top, + theme::palette().gutter_bottom, [0.0; 4], [0.0; 4], 0.0, @@ -2886,8 +3241,8 @@ impl State { push_styled_rect( &mut rect_instances, [pane[0] + ln_width, pane[1], 1.0, pane[3]], - theme::DIVIDER_COLOR, - theme::DIVIDER_COLOR, + theme::palette().divider, + theme::palette().divider, [0.0; 4], [0.0; 4], 0.0, @@ -2962,7 +3317,7 @@ impl State { let baseline = screen_y + self.ascent; let mut ln_x = pane[0] + self.ui(4.0); - let ln_color = theme::TEXT_MUTED; + let ln_color = theme::palette().text_muted; self.append_text_run( &mut text_vertices, &mut ln_x, @@ -2988,6 +3343,7 @@ impl State { // ── Bottom chrome + modals (rendered AFTER panes so they overlay) ─ self.build_status_geometry(&mut text_vertices, &mut rect_instances)?; self.build_modal_overlay_geometry(&mut text_vertices, &mut rect_instances)?; + self.build_tooltip_geometry(&mut text_vertices, &mut rect_instances)?; self.text_vbuf = create_vertex_buffer(&self.device, "text_vertices", &text_vertices); self.text_vcount = text_vertices.len() as u32; @@ -3357,7 +3713,7 @@ impl State { depth_slice: None, resolve_target: None, ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(COLOR_BG), + load: wgpu::LoadOp::Clear(theme::palette().bg), store: wgpu::StoreOp::Store, }, })], @@ -3408,10 +3764,29 @@ impl App { impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { - let attrs = Window::default_attributes() - .with_title("wgit") - .with_decorations(false) - .with_transparent(true); + // Base attributes shared across platforms. The window itself is + // opaque — we paint every pixel ourselves, and OS-level alpha + // bleeding to the desktop is jarring under modals/scrims. + let mut attrs = Window::default_attributes().with_title("wgit"); + + // macOS: native frame with the title bar made transparent and + // hidden, content extending into the title bar. macOS draws the + // real traffic lights; our gradient paints behind them. + #[cfg(target_os = "macos")] + { + use winit::platform::macos::WindowAttributesExtMacOS; + attrs = attrs + .with_titlebar_transparent(true) + .with_title_hidden(true) + .with_fullsize_content_view(true); + } + + // Other platforms: keep the borderless, custom-drawn chrome. + #[cfg(not(target_os = "macos"))] + { + attrs = attrs.with_decorations(false); + } + let window = Arc::new(event_loop.create_window(attrs).expect("create window")); let git = self.git.take().expect("git model available"); let state = pollster::block_on(State::new(window.clone(), git)).expect("init state"); @@ -3460,6 +3835,30 @@ impl ApplicationHandler for App { } else { st.window.set_cursor(CursorIcon::Default); } + + let hovered = st.toolbar_action_at(position); + if hovered != st.hover_toolbar_action { + let was_visible = st.tooltip_drawn; + st.hover_toolbar_action = hovered; + st.hover_started_at = hovered.map(|_| Instant::now()); + st.tooltip_drawn = false; + if was_visible { + st.geometry_dirty = true; + needs_redraw = true; + } + } + } + WindowEvent::CursorLeft { .. } => { + if st.hover_toolbar_action.is_some() { + let was_visible = st.tooltip_drawn; + st.hover_toolbar_action = None; + st.hover_started_at = None; + st.tooltip_drawn = false; + if was_visible { + st.geometry_dirty = true; + needs_redraw = true; + } + } } WindowEvent::MouseInput { state, @@ -3602,6 +4001,27 @@ impl ApplicationHandler for App { st.window.request_redraw(); } } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + let Some(st) = self.state.as_mut() else { + return; + }; + let Some(started) = st.hover_started_at else { + event_loop.set_control_flow(ControlFlow::Wait); + return; + }; + let elapsed = started.elapsed(); + if elapsed < TOOLTIP_DELAY { + event_loop + .set_control_flow(ControlFlow::WaitUntil(started + TOOLTIP_DELAY)); + } else if !st.tooltip_drawn { + st.geometry_dirty = true; + st.window.request_redraw(); + event_loop.set_control_flow(ControlFlow::Wait); + } else { + event_loop.set_control_flow(ControlFlow::Wait); + } + } } pub fn run(git: GitModel) -> anyhow::Result<()> { diff --git a/src/git_model.rs b/src/git_model.rs index 5677736..fdfb607 100644 --- a/src/git_model.rs +++ b/src/git_model.rs @@ -1,4 +1,4 @@ -use std::{env, path::Path, path::PathBuf, process::Command}; +use std::{collections::HashMap, env, path::Path, path::PathBuf, process::Command}; use anyhow::Context; use tree_sitter::{Language, Node, Parser}; @@ -6,7 +6,7 @@ use tree_sitter::{Language, Node, Parser}; use crate::models::{ColorSpan, DiffLineNumber, DocLine, Document, GitViewMeta, LineStyle}; /// Which tool to use for generating diff output. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum DiffBackend { #[default] GitDiff, @@ -368,6 +368,10 @@ pub struct GitModel { ts_parser: Parser, diff_backend: DiffBackend, has_difft: bool, + /// Per-(path, backend) cache of `(unstaged, staged)` raw diff text. + /// Populated on demand by `refresh_diff` and cleared whenever the + /// working tree could have changed (`refresh()`). + diff_cache: HashMap<(String, DiffBackend), (String, String)>, } impl GitModel { @@ -394,6 +398,7 @@ impl GitModel { ts_parser: Parser::new(), diff_backend: DiffBackend::default(), has_difft, + diff_cache: HashMap::new(), }; s.refresh()?; @@ -606,7 +611,35 @@ impl GitModel { fn current_status_entries(&self) -> anyhow::Result> { let status = self.run_git(GitCommand::StatusPorcelainV1)?; - Ok(parse_porcelain_status(&status)) + let parsed = parse_porcelain_status(&status); + let mut entries: Vec = Vec::with_capacity(parsed.len()); + for entry in parsed { + let kinds = classify_status_xy(&entry.xy); + if kinds.len() <= 1 { + entries.push(entry); + continue; + } + let chars: Vec = entry.xy.chars().collect(); + let x = chars.first().copied().unwrap_or(' '); + let y = chars.get(1).copied().unwrap_or(' '); + for kind in &kinds { + let xy = match kind { + StatusSectionKind::Staged => format!("{}{}", x, ' '), + StatusSectionKind::Unstaged => format!("{}{}", ' ', y), + StatusSectionKind::Untracked => entry.xy.clone(), + }; + entries.push(GitEntry { + xy, + path: entry.path.clone(), + }); + } + } + entries.sort_by_key(|e| { + primary_status_section_kind(&e.xy) + .map(|k| k.index()) + .unwrap_or(usize::MAX) + }); + Ok(entries) } #[allow(dead_code)] @@ -829,6 +862,9 @@ impl GitModel { self.branch = self.current_branch()?; self.tracking = self.current_tracking(&self.branch)?; self.entries = self.current_status_entries()?; + // Working tree may have changed for any file — drop the diff + // cache so subsequent selections re-fetch. + self.diff_cache.clear(); if self.entries.is_empty() { self.selected = 0; @@ -839,12 +875,45 @@ impl GitModel { self.refresh_diff() } + /// Compose the rendered `# Unstaged / # Staged` diff buffer from + /// the two raw subprocess outputs. + fn compose_diff(unstaged: &str, staged: &str) -> String { + let mut out = String::new(); + if !unstaged.trim().is_empty() { + out.push_str("# Unstaged\n"); + out.push_str(unstaged); + if !unstaged.ends_with('\n') { + out.push('\n'); + } + } + if !staged.trim().is_empty() { + out.push_str("# Staged\n"); + out.push_str(staged); + if !staged.ends_with('\n') { + out.push('\n'); + } + } + if out.trim().is_empty() { + out.push_str("No diff output for selected file."); + } + out + } + fn refresh_diff(&mut self) -> anyhow::Result<()> { let Some(path) = self.entries.get(self.selected).map(|e| e.path.clone()) else { self.diff = String::from("Working tree clean. No changed files."); return Ok(()); }; + // Cache key — file path + backend. The cache is invalidated on + // every `refresh()` so it can never go stale relative to the + // working tree state we're displaying. + let key = (path.clone(), self.diff_backend); + if let Some((unstaged, staged)) = self.diff_cache.get(&key) { + self.diff = Self::compose_diff(unstaged, staged); + return Ok(()); + } + let (unstaged, staged) = match self.diff_backend { DiffBackend::GitDiff => { let u = self.diff_for_path(&path, false)?; @@ -858,25 +927,8 @@ impl GitModel { } }; - let mut out = String::new(); - if !unstaged.trim().is_empty() { - out.push_str("# Unstaged\n"); - out.push_str(&unstaged); - if !unstaged.ends_with('\n') { - out.push('\n'); - } - } - if !staged.trim().is_empty() { - out.push_str("# Staged\n"); - out.push_str(&staged); - if !staged.ends_with('\n') { - out.push('\n'); - } - } - if out.trim().is_empty() { - out.push_str("No diff output for selected file."); - } - self.diff = out; + self.diff = Self::compose_diff(&unstaged, &staged); + self.diff_cache.insert(key, (unstaged, staged)); Ok(()) } diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..2b5c6aa --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,392 @@ +//! Tiny SVG path icon renderer. +//! +//! Parses a small subset of SVG path syntax — `M`, `L`, `H`, `V`, `C`, `Z` +//! and their lowercase relative-form counterparts — flattens cubic +//! Béziers into line segments, and rasterizes the resulting filled +//! polygons to a single-channel R8 alpha bitmap with 4×4 supersampled +//! anti-aliasing using the even-odd fill rule. +//! +//! Strokes are not supported — the bundled icon set is fill-only, +//! so this is enough. + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Pt { + x: f32, + y: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct PathData { + /// Each subpath is a closed polygon (last point == first). + pub subpaths: Vec>, +} + +#[derive(Clone, Copy, Debug)] +enum Token { + Cmd(char), + Num(f32), +} + +/// Parse SVG path `d` attribute into flattened polygons. +pub fn parse_path(d: &str) -> PathData { + let tokens = tokenize(d); + build_paths(&tokens) +} + +fn tokenize(d: &str) -> Vec { + let bytes = d.as_bytes(); + let mut tokens: Vec = Vec::with_capacity(bytes.len() / 4); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + // Whitespace / commas separate tokens. + if b.is_ascii_whitespace() || b == b',' { + i += 1; + continue; + } + if b.is_ascii_alphabetic() { + tokens.push(Token::Cmd(b as char)); + i += 1; + continue; + } + // Number — sign, digits, optional fractional, optional exponent. + let start = i; + if b == b'-' || b == b'+' { + i += 1; + } + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i < bytes.len() && bytes[i] == b'.' { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') { + i += 1; + if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') { + i += 1; + } + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + if i > start { + if let Ok(s) = std::str::from_utf8(&bytes[start..i]) { + if let Ok(n) = s.parse::() { + tokens.push(Token::Num(n)); + continue; + } + } + } + // Unknown byte — skip + i += 1; + } + tokens +} + +fn build_paths(tokens: &[Token]) -> PathData { + let mut subpaths: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut start = Pt::default(); + let mut pos = Pt::default(); + let mut last_cmd = ' '; + let mut i: usize = 0; + + let read_num = |idx: &mut usize| -> Option { + match tokens.get(*idx) { + Some(Token::Num(n)) => { + *idx += 1; + Some(*n) + } + _ => None, + } + }; + + while i < tokens.len() { + let cmd = match tokens[i] { + Token::Cmd(c) => { + i += 1; + c + } + // Implicit repetition of last command. After a moveto, repeats + // become linetos per the SVG spec. + Token::Num(_) => match last_cmd { + 'M' => 'L', + 'm' => 'l', + c => c, + }, + }; + + match cmd { + 'M' | 'm' => { + let Some(x) = read_num(&mut i) else { break }; + let Some(y) = read_num(&mut i) else { break }; + let p = if cmd == 'M' { + Pt { x, y } + } else { + Pt { + x: pos.x + x, + y: pos.y + y, + } + }; + if !current.is_empty() { + let f = current[0]; + if *current.last().unwrap() != f { + current.push(f); + } + subpaths.push(std::mem::take(&mut current)); + } + current.push(p); + pos = p; + start = p; + last_cmd = cmd; + } + 'L' | 'l' => { + let Some(x) = read_num(&mut i) else { break }; + let Some(y) = read_num(&mut i) else { break }; + let p = if cmd == 'L' { + Pt { x, y } + } else { + Pt { + x: pos.x + x, + y: pos.y + y, + } + }; + current.push(p); + pos = p; + last_cmd = cmd; + } + 'H' | 'h' => { + let Some(x) = read_num(&mut i) else { break }; + let nx = if cmd == 'H' { x } else { pos.x + x }; + let p = Pt { x: nx, y: pos.y }; + current.push(p); + pos = p; + last_cmd = cmd; + } + 'V' | 'v' => { + let Some(y) = read_num(&mut i) else { break }; + let ny = if cmd == 'V' { y } else { pos.y + y }; + let p = Pt { x: pos.x, y: ny }; + current.push(p); + pos = p; + last_cmd = cmd; + } + 'C' | 'c' => { + let Some(x1) = read_num(&mut i) else { break }; + let Some(y1) = read_num(&mut i) else { break }; + let Some(x2) = read_num(&mut i) else { break }; + let Some(y2) = read_num(&mut i) else { break }; + let Some(x) = read_num(&mut i) else { break }; + let Some(y) = read_num(&mut i) else { break }; + let (c1, c2, p) = if cmd == 'C' { + (Pt { x: x1, y: y1 }, Pt { x: x2, y: y2 }, Pt { x, y }) + } else { + ( + Pt { + x: pos.x + x1, + y: pos.y + y1, + }, + Pt { + x: pos.x + x2, + y: pos.y + y2, + }, + Pt { + x: pos.x + x, + y: pos.y + y, + }, + ) + }; + flatten_cubic(pos, c1, c2, p, 0.25, &mut current); + pos = p; + last_cmd = cmd; + } + 'Z' | 'z' => { + if !current.is_empty() { + let f = current[0]; + if *current.last().unwrap() != f { + current.push(f); + } + subpaths.push(std::mem::take(&mut current)); + } + pos = start; + last_cmd = cmd; + } + _ => { + // Unsupported command (S, s, Q, q, T, t, A, a) — bail out. + // The bundled icon set only uses M/L/H/V/C/Z. + break; + } + } + } + if !current.is_empty() { + let f = current[0]; + if *current.last().unwrap() != f { + current.push(f); + } + subpaths.push(current); + } + PathData { subpaths } +} + +/// Recursive de Casteljau subdivision until the curve is flat enough. +/// `tol` is the perpendicular-distance threshold relative to the chord +/// length; 0.25 viewBox-units gives smooth-looking 24×24 icons. +fn flatten_cubic(p0: Pt, p1: Pt, p2: Pt, p3: Pt, tol: f32, out: &mut Vec) { + let dx = p3.x - p0.x; + let dy = p3.y - p0.y; + let chord_sq = dx * dx + dy * dy; + + // Distance from p1, p2 to the chord (unnormalized cross-product). + let d1 = ((p1.y - p0.y) * dx - (p1.x - p0.x) * dy).abs(); + let d2 = ((p2.y - p0.y) * dx - (p2.x - p0.x) * dy).abs(); + let len = chord_sq.sqrt().max(1e-6); + + // Stop subdividing when the curve is close to its chord. + if d1.max(d2) / len < tol || chord_sq < tol * tol * 4.0 { + out.push(p3); + return; + } + + let mid = |a: Pt, b: Pt| Pt { + x: (a.x + b.x) * 0.5, + y: (a.y + b.y) * 0.5, + }; + let q0 = mid(p0, p1); + let q1 = mid(p1, p2); + let q2 = mid(p2, p3); + let r0 = mid(q0, q1); + let r1 = mid(q1, q2); + let s = mid(r0, r1); + + flatten_cubic(p0, q0, r0, s, tol, out); + flatten_cubic(s, r1, q2, p3, tol, out); +} + +/// Even-odd point-in-polygon test using the crossing-number method. +fn point_in_polygon(p: Pt, poly: &[Pt]) -> bool { + if poly.len() < 3 { + return false; + } + let mut inside = false; + let n = poly.len(); + let mut j = n - 1; + for i in 0..n { + let a = poly[j]; + let b = poly[i]; + if (a.y > p.y) != (b.y > p.y) { + let dy = b.y - a.y; + if dy.abs() > 1e-12 { + let t = (p.y - a.y) / dy; + let x_cross = a.x + t * (b.x - a.x); + if p.x < x_cross { + inside = !inside; + } + } + } + j = i; + } + inside +} + +/// Rasterize a parsed path into an `pixel_w × pixel_h` R8 alpha bitmap. +/// The path is expected to be in `view_w × view_h` SVG coordinates. +/// Uses 4×4 supersampling and the even-odd fill rule (matches SVG +/// fill-rule "evenodd" exactly — the bundled icons rely on this to +/// punch holes via overlapping subpaths). +pub fn rasterize_filled( + paths: &PathData, + view_w: f32, + view_h: f32, + pixel_w: u32, + pixel_h: u32, +) -> Vec { + let scale_x = pixel_w as f32 / view_w; + let scale_y = pixel_h as f32 / view_h; + let n: u32 = 4; + let inv_n = 1.0 / n as f32; + let total = (n * n) as f32; + + let mut out = vec![0u8; (pixel_w * pixel_h) as usize]; + + // Precompute per-subpath bounding boxes so we can skip cheaply. + let bboxes: Vec<(f32, f32, f32, f32)> = paths + .subpaths + .iter() + .map(|sub| { + let mut x0 = f32::INFINITY; + let mut y0 = f32::INFINITY; + let mut x1 = f32::NEG_INFINITY; + let mut y1 = f32::NEG_INFINITY; + for p in sub { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + (x0, y0, x1, y1) + }) + .collect(); + + for py in 0..pixel_h { + for px in 0..pixel_w { + let mut hits: u32 = 0; + for sy in 0..n { + for sx in 0..n { + let svgx = (px as f32 + (sx as f32 + 0.5) * inv_n) / scale_x; + let svgy = (py as f32 + (sy as f32 + 0.5) * inv_n) / scale_y; + let probe = Pt { x: svgx, y: svgy }; + let mut inside = false; + for (sub, bb) in paths.subpaths.iter().zip(bboxes.iter()) { + if probe.x < bb.0 || probe.x > bb.2 || probe.y < bb.1 || probe.y > bb.3 { + continue; + } + if point_in_polygon(probe, sub) { + inside = !inside; + } + } + if inside { + hits += 1; + } + } + } + let alpha = (hits as f32 / total * 255.0).round() as u8; + out[(py * pixel_w + px) as usize] = alpha; + } + } + + out +} + +/// Extract the first `d="..."` attribute value from an SVG document. +/// The bundled icons have a single `` per file, so the first +/// match is all we need. +pub fn extract_path_d(svg: &str) -> Option<&str> { + let needle = "d=\""; + let start = svg.find(needle)? + needle.len(); + let rest = &svg[start..]; + let end = rest.find('"')?; + Some(&rest[..end]) +} + +/// Extract the `viewBox` (min-x, min-y, width, height) from an SVG. +/// Falls back to (0, 0, 24, 24) — the most common viewBox — if the +/// attribute is missing or malformed. +pub fn extract_viewbox(svg: &str) -> (f32, f32, f32, f32) { + if let Some(start) = svg.find("viewBox=\"") { + let s = &svg[start + "viewBox=\"".len()..]; + if let Some(end) = s.find('"') { + let parts: Vec = s[..end] + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|t| !t.is_empty()) + .filter_map(|t| t.parse::().ok()) + .collect(); + if parts.len() == 4 { + return (parts[0], parts[1], parts[2], parts[3]); + } + } + } + (0.0, 0.0, 24.0, 24.0) +} diff --git a/src/main.rs b/src/main.rs index 75e7c0e..def0182 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod git_model; +mod icon; mod models; mod render; mod repo_store; @@ -14,6 +15,10 @@ use git_model::GitModel; fn main() { env_logger::init(); + if let Err(err) = apply_theme_selection() { + eprintln!("Theme selection failed: {err}"); + } + let git = match open_startup_repo() { Ok(git) => git, Err(err) => { @@ -28,6 +33,63 @@ fn main() { } } +/// Resolve the active theme from `--theme NAME`, `--theme-file PATH`, +/// or `WGIT_THEME` env var. Falls back to the default `Midnight`. +/// Bundled names: `midnight`, `gruvbox`, `vercel`, `dracula`. Anything +/// else is treated as a path to a theme YAML file. +fn apply_theme_selection() -> anyhow::Result<()> { + let mut args = env::args().skip(1).collect::>(); + let mut theme_arg: Option = None; + let mut theme_file: Option = None; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--theme" if i + 1 < args.len() => { + theme_arg = Some(args.remove(i + 1)); + args.remove(i); + } + "--theme-file" if i + 1 < args.len() => { + theme_file = Some(PathBuf::from(args.remove(i + 1))); + args.remove(i); + } + _ => i += 1, + } + } + if theme_arg.is_none() { + theme_arg = env::var("WGIT_THEME").ok(); + } + + if let Some(path) = theme_file { + let palette = theme::load_yaml_file(&path) + .map_err(|e| anyhow::anyhow!("load theme file {}: {}", path.display(), e))?; + theme::set_palette(palette); + return Ok(()); + } + + if let Some(name) = theme_arg { + if let Some(p) = theme::bundled(&name) { + theme::set_palette(p); + return Ok(()); + } + // Treat as filesystem path if it isn't a known bundled name + let path = PathBuf::from(&name); + if path.exists() { + let palette = theme::load_yaml_file(&path) + .map_err(|e| anyhow::anyhow!("load theme {}: {}", path.display(), e))?; + theme::set_palette(palette); + return Ok(()); + } + anyhow::bail!( + "unknown theme {:?}: bundled themes are {:?} or pass a path to a theme YAML", + name, + theme::bundled_names() + ); + } + + Ok(()) +} + fn open_startup_repo() -> anyhow::Result { if let Some(path) = repo_arg() { return open_and_remember(path); @@ -59,11 +121,18 @@ fn open_startup_repo() -> anyhow::Result { fn repo_arg() -> Option { let mut args = env::args_os().skip(1); - match args.next() { - Some(flag) if flag == OsString::from("--repo") => args.next().map(PathBuf::from), - Some(path) => Some(PathBuf::from(path)), - None => None, + while let Some(arg) = args.next() { + if arg == OsString::from("--theme") || arg == OsString::from("--theme-file") { + // Skip the value that follows the flag. + args.next(); + continue; + } + if arg == OsString::from("--repo") { + return args.next().map(PathBuf::from); + } + return Some(PathBuf::from(arg)); } + None } fn open_and_remember(path: impl Into) -> anyhow::Result { diff --git a/src/models.rs b/src/models.rs index b3b7bcd..f8da542 100644 --- a/src/models.rs +++ b/src/models.rs @@ -19,18 +19,18 @@ pub enum LineStyle { impl LineStyle { pub fn color(self) -> [f32; 4] { match self { - Self::Normal => theme::LINE_NORMAL, - Self::Dim => theme::LINE_DIM, - Self::Header => theme::LINE_HEADER, - Self::Selected => theme::LINE_SELECTED, - Self::DiffAdd => theme::LINE_DIFF_ADD, - Self::DiffRemove => theme::LINE_DIFF_REMOVE, - Self::DiffHunk => theme::LINE_DIFF_HUNK, - Self::DiffMeta => theme::LINE_DIM, - Self::DiffFileHeader => theme::TEXT_ACCENT, - Self::SectionStaged => theme::ACCENT_GREEN, - Self::SectionUnstaged => theme::ACCENT_YELLOW, - Self::SectionUntracked => theme::ACCENT_GRAY, + Self::Normal => theme::palette().line_normal, + Self::Dim => theme::palette().line_dim, + Self::Header => theme::palette().line_header, + Self::Selected => theme::palette().line_selected, + Self::DiffAdd => theme::palette().line_diff_add, + Self::DiffRemove => theme::palette().line_diff_remove, + Self::DiffHunk => theme::palette().line_diff_hunk, + Self::DiffMeta => theme::palette().line_dim, + Self::DiffFileHeader => theme::palette().text_accent, + Self::SectionStaged => theme::palette().accent_green, + Self::SectionUnstaged => theme::palette().accent_yellow, + Self::SectionUntracked => theme::palette().accent_gray, } } @@ -53,51 +53,51 @@ impl LineStyle { pub fn background_colors(self) -> ([f32; 4], [f32; 4], [f32; 4]) { match self { Self::DiffAdd => ( - theme::DIFF_ADD_BG_TOP, - theme::DIFF_ADD_BG_BOTTOM, + theme::palette().diff_add_bg_top, + theme::palette().diff_add_bg_bottom, [0.0, 0.0, 0.0, 0.0], ), Self::DiffRemove => ( - theme::DIFF_REMOVE_BG_TOP, - theme::DIFF_REMOVE_BG_BOTTOM, + theme::palette().diff_remove_bg_top, + theme::palette().diff_remove_bg_bottom, [0.0, 0.0, 0.0, 0.0], ), Self::DiffHunk => ( - theme::DIFF_HUNK_BG_TOP, - theme::DIFF_HUNK_BG_BOTTOM, - theme::DIFF_HUNK_BORDER, + theme::palette().diff_hunk_bg_top, + theme::palette().diff_hunk_bg_bottom, + theme::palette().diff_hunk_border, ), Self::DiffMeta => ( - theme::DIFF_META_BG_TOP, - theme::DIFF_META_BG_BOTTOM, + theme::palette().diff_meta_bg_top, + theme::palette().diff_meta_bg_bottom, [0.0, 0.0, 0.0, 0.0], ), Self::DiffFileHeader => ( - theme::DIFF_FILE_HEADER_BG_TOP, - theme::DIFF_FILE_HEADER_BG_BOTTOM, - theme::DIFF_FILE_HEADER_BORDER, + theme::palette().diff_file_header_bg_top, + theme::palette().diff_file_header_bg_bottom, + theme::palette().diff_file_header_border, ), Self::SectionStaged => ( - theme::SECTION_STAGED_BG_TOP, - theme::SECTION_STAGED_BG_BOTTOM, - theme::SECTION_STAGED_BORDER, + theme::palette().section_staged_bg_top, + theme::palette().section_staged_bg_bottom, + theme::palette().section_staged_border, ), Self::SectionUnstaged => ( - theme::SECTION_UNSTAGED_BG_TOP, - theme::SECTION_UNSTAGED_BG_BOTTOM, - theme::SECTION_UNSTAGED_BORDER, + theme::palette().section_unstaged_bg_top, + theme::palette().section_unstaged_bg_bottom, + theme::palette().section_unstaged_border, ), Self::SectionUntracked => ( - theme::SECTION_UNTRACKED_BG_TOP, - theme::SECTION_UNTRACKED_BG_BOTTOM, - theme::SECTION_UNTRACKED_BORDER, + theme::palette().section_untracked_bg_top, + theme::palette().section_untracked_bg_bottom, + theme::palette().section_untracked_border, ), _ => ([0.0; 4], [0.0; 4], [0.0; 4]), } } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ToolbarAction { RepoSwitch, Browse, @@ -112,6 +112,7 @@ pub enum ToolbarAction { Unstage, UnstageAll, Discard, + Settings, Quit, } @@ -125,6 +126,7 @@ pub enum ToolbarGroup { } #[derive(Clone, Copy, Debug)] +#[cfg_attr(target_os = "macos", allow(dead_code))] pub enum WindowControlAction { Close, Minimize, @@ -268,7 +270,10 @@ pub struct ButtonStyle { #[derive(Clone, Debug)] pub struct ButtonConfig { + /// Human-readable label, kept around for future tooltip / a11y use. + #[allow(dead_code)] pub label: String, + pub icon: &'static str, pub action: ToolbarAction, pub group: ToolbarGroup, pub style: ButtonStyle, diff --git a/src/rect.wgsl b/src/rect.wgsl index e9f1732..6caea41 100644 --- a/src/rect.wgsl +++ b/src/rect.wgsl @@ -86,6 +86,9 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { let blur = max(i.params0.w, 0.5); let shadow_offset = i.params1.xy; let shadow_spread = i.params1.z; + // Glassy top-edge highlight intensity (0 = disabled, ~1 = strong). + // Packed into shadow_offset_spread.w; defaults to 0 for legacy callers. + let highlight = clamp(i.params1.w, 0.0, 4.0); let d_fill = sd_round_rect(i.local, i.fill_half, radius); let fill_a = 1.0 - smoothstep(0.0, soft, d_fill); @@ -97,7 +100,47 @@ fn fs_main(i: VsOut) -> @location(0) vec4 { let border_a = max(fill_a - inner_a, 0.0); let grad_t = clamp((i.local.y / max(i.fill_half.y, 1.0)) * 0.5 + 0.5, 0.0, 1.0); - let fill_color = mix(i.fill_top, i.fill_bottom, grad_t); + var fill_color = mix(i.fill_top, i.fill_bottom, grad_t); + + // ── Chrome shading ─────────────────────────────────────────────── + // A 1 px luminous rim at the top edge, a soft ambient lift that + // fades over the upper half, and a 1 px inner shadow at the bottom + // edge. Together these sell the "lifted slice" depth for sidebar + // items / chrome surfaces. Neutral tint, no hue. + if highlight > 0.0001 { + let from_top = i.local.y + i.fill_half.y; + let from_bottom = i.fill_half.y - i.local.y; + + // 1 px hairline rim at the top + let rim_thickness = 1.0; + let rim = (1.0 - smoothstep(0.0, rim_thickness, from_top)) * inner_a; + + // Soft ambient brightening fading out over ~half the height + let ambient_falloff = max(i.fill_half.y * 0.6, 8.0); + let ambient = (1.0 - smoothstep(0.0, ambient_falloff, from_top)) * inner_a; + + // 1 px inner shadow at the bottom (mirror of rim) + let bot_thickness = 1.0; + let bot = (1.0 - smoothstep(0.0, bot_thickness, from_bottom)) * inner_a; + + let rim_strength = clamp(rim * highlight * 0.40, 0.0, 0.55); + let ambient_strength = clamp(ambient * highlight * 0.05, 0.0, 0.14); + let bot_strength = clamp(bot * highlight * 0.30, 0.0, 0.42); + + let lift_color = vec3(1.0, 1.0, 1.0); + let shade_color = vec3(0.0, 0.0, 0.0); + fill_color = vec4( + clamp( + fill_color.rgb + + lift_color * (rim_strength + ambient_strength) + - shade_color * 0.0 + - vec3(bot_strength) * 0.55, + vec3(0.0), + vec3(1.0) + ), + fill_color.a + ); + } let fill_rgba = vec4(fill_color.rgb, fill_color.a * inner_a); let stroke_rgba = vec4(i.stroke.rgb, i.stroke.a * border_a); diff --git a/src/render.rs b/src/render.rs index e1177f7..6601782 100644 --- a/src/render.rs +++ b/src/render.rs @@ -171,6 +171,42 @@ pub fn push_styled_rect( shadow_blur: f32, shadow_offset: [f32; 2], shadow_spread: f32, +) { + push_styled_rect_glow( + out, + rect, + fill_top, + fill_bottom, + stroke, + shadow, + radius, + softness, + border, + shadow_blur, + shadow_offset, + shadow_spread, + 0.0, + ); +} + +/// Same as `push_styled_rect`, but enables the glassy top-edge +/// highlight rim. `highlight` is intensity in [0, 1] for typical +/// use; values up to ~2 are clamped in the shader. +#[allow(clippy::too_many_arguments)] +pub fn push_styled_rect_glow( + out: &mut Vec, + rect: [f32; 4], + fill_top: [f32; 4], + fill_bottom: [f32; 4], + stroke: [f32; 4], + shadow: [f32; 4], + radius: f32, + softness: f32, + border: f32, + shadow_blur: f32, + shadow_offset: [f32; 2], + shadow_spread: f32, + highlight: f32, ) { if rect[2] <= 0.0 || rect[3] <= 0.0 { return; @@ -183,6 +219,6 @@ pub fn push_styled_rect( stroke, shadow, radius_soft_border_blur: [radius, softness, border, shadow_blur], - shadow_offset_spread: [shadow_offset[0], shadow_offset[1], shadow_spread, 0.0], + shadow_offset_spread: [shadow_offset[0], shadow_offset[1], shadow_spread, highlight], }); } diff --git a/src/theme.rs b/src/theme.rs index 7672566..0595697 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,4 +1,17 @@ -// ── Layout constants ────────────────────────────────────────────── +//! Theme system. +//! +//! All chrome colors live on a `Palette` struct; the active palette is +//! held in a `OnceLock` accessed via `palette()`. Layout +//! constants stay as `pub const` because they don't change with theme. +//! +//! The palette can be loaded from a YAML file (terminal-theme schema: +//! 4 base colors + 16 ANSI colors), or selected from the bundled set: +//! `Midnight`, `Gruvbox Dark`, `Vercel`, `Dracula`. + +use std::path::Path; +use std::sync::atomic::{AtomicPtr, Ordering}; + +// ── Layout constants (theme-independent) ───────────────────────── pub const FONT_PX: f32 = 18.0; pub const SIDE_PADDING: f32 = 30.0; pub const TOP_PADDING: f32 = 124.0; @@ -7,182 +20,792 @@ pub const STATUS_BAR_GAP: f32 = 8.0; pub const STATUS_BAR_SIDE_PADDING: f32 = 12.0; pub const ATLAS_SIZE: u32 = 4096; -// ── Surface / background colors ────────────────────────────────── -pub const COLOR_BG: wgpu::Color = wgpu::Color { - r: 0.065, - g: 0.068, - b: 0.082, - a: 1.0, -}; - -/// Titlebar background gradient -pub const COLOR_TITLEBAR_TOP: [f32; 4] = [0.10, 0.11, 0.15, 1.0]; -pub const COLOR_TITLEBAR_BOTTOM: [f32; 4] = [0.08, 0.09, 0.13, 1.0]; - -/// Toolbar background gradient -pub const COLOR_TOOLBAR_TOP: [f32; 4] = [0.12, 0.14, 0.20, 1.0]; -pub const COLOR_TOOLBAR_BOTTOM: [f32; 4] = [0.09, 0.11, 0.16, 1.0]; - -/// Content panel background gradient -pub const COLOR_CONTENT_TOP: [f32; 4] = [0.075, 0.080, 0.105, 1.0]; -pub const COLOR_CONTENT_BOTTOM: [f32; 4] = [0.065, 0.070, 0.095, 1.0]; - -// ── Text hierarchy ─────────────────────────────────────────────── -pub const TEXT_PRIMARY: [f32; 4] = [0.93, 0.95, 0.98, 1.0]; -pub const TEXT_SECONDARY: [f32; 4] = [0.72, 0.76, 0.84, 1.0]; -pub const TEXT_MUTED: [f32; 4] = [0.48, 0.52, 0.60, 1.0]; -pub const TEXT_ACCENT: [f32; 4] = [0.55, 0.70, 1.0, 1.0]; -pub const TEXT_BRIGHT: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; - -// ── Semantic accent colors ─────────────────────────────────────── -/// Green: staged, additions, success -pub const ACCENT_GREEN: [f32; 4] = [0.40, 0.82, 0.52, 1.0]; -pub const ACCENT_GREEN_DIM: [f32; 4] = [0.30, 0.62, 0.40, 0.70]; - -/// Yellow/orange: unstaged, modified, warnings -pub const ACCENT_YELLOW: [f32; 4] = [0.92, 0.78, 0.38, 1.0]; -pub const ACCENT_YELLOW_DIM: [f32; 4] = [0.72, 0.60, 0.28, 0.70]; - -/// Red: danger, deletions, errors, untracked -pub const ACCENT_RED: [f32; 4] = [0.95, 0.45, 0.42, 1.0]; -pub const ACCENT_RED_DIM: [f32; 4] = [0.75, 0.35, 0.32, 0.70]; - -/// Blue: info, links, focused, selected -pub const ACCENT_BLUE: [f32; 4] = [0.45, 0.62, 1.0, 1.0]; -pub const ACCENT_BLUE_DIM: [f32; 4] = [0.35, 0.48, 0.80, 0.70]; - -/// Gray: neutral, untracked -pub const ACCENT_GRAY: [f32; 4] = [0.55, 0.58, 0.64, 1.0]; -pub const ACCENT_GRAY_DIM: [f32; 4] = [0.40, 0.43, 0.48, 0.70]; - -/// Purple: remote ops (push/pull/fetch) -pub const ACCENT_PURPLE: [f32; 4] = [0.68, 0.52, 0.98, 1.0]; -pub const ACCENT_PURPLE_DIM: [f32; 4] = [0.48, 0.36, 0.72, 0.70]; - -// ── Selection / focus ──────────────────────────────────────────── -pub const COLOR_ROW_SELECTED: [f32; 4] = [0.20, 0.30, 0.58, 0.35]; -pub const COLOR_ROW_SELECTED_BOTTOM: [f32; 4] = [0.16, 0.24, 0.48, 0.30]; -pub const COLOR_ROW_SELECTED_BORDER: [f32; 4] = [0.45, 0.60, 0.98, 0.50]; -pub const COLOR_SELECTION_ACCENT_BAR: [f32; 4] = [0.45, 0.65, 1.0, 0.90]; - -// ── Section header backgrounds ─────────────────────────────────── -/// Staged section: green-tinted -pub const SECTION_STAGED_BG_TOP: [f32; 4] = [0.12, 0.20, 0.15, 0.60]; -pub const SECTION_STAGED_BG_BOTTOM: [f32; 4] = [0.09, 0.16, 0.12, 0.50]; -pub const SECTION_STAGED_BORDER: [f32; 4] = [0.30, 0.65, 0.40, 0.40]; - -/// Unstaged section: yellow-tinted -pub const SECTION_UNSTAGED_BG_TOP: [f32; 4] = [0.20, 0.18, 0.10, 0.60]; -pub const SECTION_UNSTAGED_BG_BOTTOM: [f32; 4] = [0.16, 0.14, 0.08, 0.50]; -pub const SECTION_UNSTAGED_BORDER: [f32; 4] = [0.65, 0.55, 0.30, 0.40]; - -/// Untracked section: gray-tinted -pub const SECTION_UNTRACKED_BG_TOP: [f32; 4] = [0.14, 0.14, 0.16, 0.60]; -pub const SECTION_UNTRACKED_BG_BOTTOM: [f32; 4] = [0.11, 0.11, 0.13, 0.50]; -pub const SECTION_UNTRACKED_BORDER: [f32; 4] = [0.45, 0.46, 0.50, 0.40]; - -// ── Diff background tints ──────────────────────────────────────── -pub const DIFF_ADD_BG_TOP: [f32; 4] = [0.12, 0.22, 0.14, 0.40]; -pub const DIFF_ADD_BG_BOTTOM: [f32; 4] = [0.10, 0.18, 0.12, 0.35]; -pub const DIFF_REMOVE_BG_TOP: [f32; 4] = [0.24, 0.12, 0.12, 0.40]; -pub const DIFF_REMOVE_BG_BOTTOM: [f32; 4] = [0.20, 0.10, 0.10, 0.35]; -pub const DIFF_HUNK_BG_TOP: [f32; 4] = [0.14, 0.18, 0.28, 0.50]; -pub const DIFF_HUNK_BG_BOTTOM: [f32; 4] = [0.11, 0.14, 0.22, 0.45]; -pub const DIFF_HUNK_BORDER: [f32; 4] = [0.35, 0.48, 0.78, 0.35]; -pub const DIFF_META_BG_TOP: [f32; 4] = [0.10, 0.12, 0.18, 0.30]; -pub const DIFF_META_BG_BOTTOM: [f32; 4] = [0.08, 0.10, 0.15, 0.25]; - -/// Diff file header (the prominent bar above each file's diff) -pub const DIFF_FILE_HEADER_BG_TOP: [f32; 4] = [0.16, 0.20, 0.30, 0.80]; -pub const DIFF_FILE_HEADER_BG_BOTTOM: [f32; 4] = [0.12, 0.15, 0.24, 0.75]; -pub const DIFF_FILE_HEADER_BORDER: [f32; 4] = [0.38, 0.50, 0.80, 0.50]; - -// ── Toolbar button groups ──────────────────────────────────────── -pub const TOOLBAR_SEPARATOR: [f32; 4] = [0.30, 0.33, 0.40, 0.30]; - -// ── Dividers / borders ─────────────────────────────────────────── -pub const DIVIDER_COLOR: [f32; 4] = [0.22, 0.25, 0.32, 0.50]; - -// ── Modal overlay ──────────────────────────────────────────────── -pub const MODAL_BG_TOP: [f32; 4] = [0.12, 0.14, 0.20, 1.0]; -pub const MODAL_BG_BOTTOM: [f32; 4] = [0.08, 0.10, 0.16, 1.0]; -pub const MODAL_BORDER: [f32; 4] = [0.35, 0.45, 0.70, 0.60]; - -pub const MODAL_DANGER_BG_TOP: [f32; 4] = [0.20, 0.12, 0.12, 1.0]; -pub const MODAL_DANGER_BG_BOTTOM: [f32; 4] = [0.14, 0.08, 0.08, 1.0]; -pub const MODAL_DANGER_BORDER: [f32; 4] = [0.80, 0.35, 0.35, 0.60]; - -// ── Branch switcher ───────────────────────────────────────────── -/// Current branch indicator -pub const BRANCH_CURRENT_BADGE: [f32; 4] = [0.40, 0.82, 0.52, 1.0]; - -// ── Status bar per-kind ────────────────────────────────────────── -pub const STATUS_NEUTRAL: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]) = ( - [0.12, 0.15, 0.22, 0.88], - [0.09, 0.11, 0.17, 0.90], - [0.30, 0.42, 0.65, 0.50], - [0.82, 0.88, 0.96, 1.0], -); - -pub const STATUS_SUCCESS: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]) = ( - [0.12, 0.22, 0.16, 0.90], - [0.09, 0.17, 0.12, 0.92], - [0.28, 0.65, 0.40, 0.55], - [0.82, 0.96, 0.88, 1.0], -); - -pub const STATUS_ERROR: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]) = ( - [0.28, 0.14, 0.14, 0.92], - [0.20, 0.10, 0.10, 0.94], - [0.78, 0.36, 0.36, 0.65], - [1.0, 0.90, 0.90, 1.0], -); - -pub const STATUS_PROMPT: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]) = ( - [0.22, 0.18, 0.10, 0.92], - [0.17, 0.14, 0.07, 0.94], - [0.78, 0.64, 0.30, 0.60], - [1.0, 0.94, 0.82, 1.0], -); - -// ── Line style colors (used by Document/DocLine) ───────────────── -pub const LINE_NORMAL: [f32; 4] = [0.88, 0.90, 0.94, 1.0]; -pub const LINE_DIM: [f32; 4] = [0.50, 0.54, 0.62, 1.0]; -pub const LINE_HEADER: [f32; 4] = [0.78, 0.85, 1.0, 1.0]; -pub const LINE_SELECTED: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; -pub const LINE_DIFF_ADD: [f32; 4] = [0.55, 0.90, 0.60, 1.0]; -pub const LINE_DIFF_REMOVE: [f32; 4] = [0.95, 0.55, 0.55, 1.0]; -pub const LINE_DIFF_HUNK: [f32; 4] = [0.55, 0.72, 1.0, 1.0]; - -// ── Git status badge colors ────────────────────────────────────── -pub const BADGE_MODIFIED: [f32; 4] = [0.92, 0.78, 0.38, 1.0]; // M - yellow -pub const BADGE_ADDED: [f32; 4] = [0.40, 0.82, 0.52, 1.0]; // A - green -pub const BADGE_DELETED: [f32; 4] = [0.95, 0.45, 0.42, 1.0]; // D - red -pub const BADGE_RENAMED: [f32; 4] = [0.68, 0.52, 0.98, 1.0]; // R - purple -pub const BADGE_UNTRACKED: [f32; 4] = [0.55, 0.58, 0.64, 1.0]; // ? - gray -pub const BADGE_COPIED: [f32; 4] = [0.45, 0.62, 1.0, 1.0]; // C - blue - -// ── Helper: git status char → badge color ──────────────────────── +// ───────────────────────────────────────────────────────────────── +// Palette: every color the renderer reads. +// ───────────────────────────────────────────────────────────────── + +#[allow(dead_code)] // some palette slots are reserved for future UI surfaces +#[derive(Clone, Debug)] +pub struct Palette { + pub name: &'static str, + + // Surfaces + pub bg: wgpu::Color, + pub titlebar_top: [f32; 4], + pub titlebar_bottom: [f32; 4], + pub toolbar_top: [f32; 4], + pub toolbar_bottom: [f32; 4], + pub content_top: [f32; 4], + pub content_bottom: [f32; 4], + + // Text + pub text_primary: [f32; 4], + pub text_secondary: [f32; 4], + pub text_muted: [f32; 4], + pub text_accent: [f32; 4], + pub text_bright: [f32; 4], + + // Semantic accents + pub accent_green: [f32; 4], + pub accent_green_dim: [f32; 4], + pub accent_yellow: [f32; 4], + pub accent_yellow_dim: [f32; 4], + pub accent_red: [f32; 4], + pub accent_red_dim: [f32; 4], + pub accent_blue: [f32; 4], + pub accent_blue_dim: [f32; 4], + pub accent_gray: [f32; 4], + pub accent_gray_dim: [f32; 4], + pub accent_purple: [f32; 4], + pub accent_purple_dim: [f32; 4], + pub accent_pink: [f32; 4], + pub accent_pink_dim: [f32; 4], + + // Selection + pub row_selected: [f32; 4], + pub row_selected_bottom: [f32; 4], + pub row_selected_border: [f32; 4], + pub selection_accent_bar: [f32; 4], + + // Section header backgrounds + pub section_staged_bg_top: [f32; 4], + pub section_staged_bg_bottom: [f32; 4], + pub section_staged_border: [f32; 4], + pub section_unstaged_bg_top: [f32; 4], + pub section_unstaged_bg_bottom: [f32; 4], + pub section_unstaged_border: [f32; 4], + pub section_untracked_bg_top: [f32; 4], + pub section_untracked_bg_bottom: [f32; 4], + pub section_untracked_border: [f32; 4], + + // Diff tints + pub diff_add_bg_top: [f32; 4], + pub diff_add_bg_bottom: [f32; 4], + pub diff_remove_bg_top: [f32; 4], + pub diff_remove_bg_bottom: [f32; 4], + pub diff_hunk_bg_top: [f32; 4], + pub diff_hunk_bg_bottom: [f32; 4], + pub diff_hunk_border: [f32; 4], + pub diff_meta_bg_top: [f32; 4], + pub diff_meta_bg_bottom: [f32; 4], + pub diff_file_header_bg_top: [f32; 4], + pub diff_file_header_bg_bottom: [f32; 4], + pub diff_file_header_border: [f32; 4], + + // Chrome + pub toolbar_separator: [f32; 4], + pub divider: [f32; 4], + + // Modals + pub modal_bg_top: [f32; 4], + pub modal_bg_bottom: [f32; 4], + pub modal_border: [f32; 4], + pub modal_danger_bg_top: [f32; 4], + pub modal_danger_bg_bottom: [f32; 4], + pub modal_danger_border: [f32; 4], + + pub tooltip_bg_top: [f32; 4], + pub tooltip_bg_bottom: [f32; 4], + pub tooltip_border: [f32; 4], + pub tooltip_text: [f32; 4], + + // Branch indicator + pub branch_current_badge: [f32; 4], + pub branch_chip_bg_top: [f32; 4], + pub branch_chip_bg_bottom: [f32; 4], + + // Diff line-number gutter — slightly darker than bg + pub gutter_top: [f32; 4], + pub gutter_bottom: [f32; 4], + + // Status bar (top, bottom, border, text) + pub status_neutral: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]), + pub status_success: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]), + pub status_error: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]), + pub status_prompt: ([f32; 4], [f32; 4], [f32; 4], [f32; 4]), + + // Document line styles + pub line_normal: [f32; 4], + pub line_dim: [f32; 4], + pub line_header: [f32; 4], + pub line_selected: [f32; 4], + pub line_diff_add: [f32; 4], + pub line_diff_remove: [f32; 4], + pub line_diff_hunk: [f32; 4], + + // Git status badges + pub badge_modified: [f32; 4], + pub badge_added: [f32; 4], + pub badge_deleted: [f32; 4], + pub badge_renamed: [f32; 4], + pub badge_untracked: [f32; 4], + pub badge_copied: [f32; 4], +} + +// ── Active palette ─────────────────────────────────────────────── +// +// Held as an `AtomicPtr` so themes can be swapped at runtime +// from the settings modal. Each swap leaks one Palette (~600 bytes); +// since switches are user-driven (a click), the leak is bounded. +// `palette()` returns `&'static Palette` so existing field-access +// callsites compile unchanged. +static ACTIVE: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + +/// Returns the active palette. Initialises to `Palette::midnight()` +/// on first call if `set_palette` hasn't been called yet. +pub fn palette() -> &'static Palette { + let ptr = ACTIVE.load(Ordering::Acquire); + if !ptr.is_null() { + // SAFETY: Pointer is only ever set via `Box::into_raw`, and the + // backing allocation is intentionally leaked for `'static`. + return unsafe { &*ptr }; + } + let leaked = Box::into_raw(Box::new(Palette::midnight())); + match ACTIVE.compare_exchange( + std::ptr::null_mut(), + leaked, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => unsafe { &*leaked }, + Err(other) => { + // Lost the race — drop our allocation, use the other thread's. + unsafe { drop(Box::from_raw(leaked)) }; + unsafe { &*other } + } + } +} + +/// Replace the active palette. Returns true (the previous palette is +/// intentionally leaked since outstanding `&'static Palette` references +/// may still hold it). +pub fn set_palette(p: Palette) -> bool { + let leaked = Box::into_raw(Box::new(p)); + let _old = ACTIVE.swap(leaked, Ordering::AcqRel); + true +} + +/// Resolve a theme name to a bundled palette. +pub fn bundled(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "midnight" | "default" | "dark" => Some(Palette::midnight()), + "gruvbox" | "gruvbox dark" | "gruvbox-dark" => Some(Palette::gruvbox_dark()), + "vercel" | "vercel dark" | "vercel-dark" => Some(Palette::vercel_dark()), + "dracula" => Some(Palette::dracula()), + _ => None, + } +} + +/// Names of every bundled theme. +pub fn bundled_names() -> &'static [&'static str] { + &["Midnight", "Gruvbox Dark", "Vercel", "Dracula"] +} + +/// Load a YAML theme from disk and return its palette. +pub fn load_yaml_file(path: &Path) -> Result { + let text = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {}", path.display(), e))?; + load_yaml_str(&text) +} + +/// Parse a YAML theme string and return its palette. +pub fn load_yaml_str(text: &str) -> Result { + let raw = parse_theme_yaml(text)?; + Ok(derive_palette(&raw)) +} + +// ───────────────────────────────────────────────────────────────── +// Theme YAML format +// +// Schema (subset we accept): +// name: ... +// accent: '#hex' +// background: '#hex' +// foreground: '#hex' +// details: 'darker' | 'lighter' +// terminal_colors: +// normal: { black, red, green, yellow, blue, magenta, cyan, white } +// bright: { black, red, green, yellow, blue, magenta, cyan, white } +// ───────────────────────────────────────────────────────────────── + +#[derive(Default, Debug)] +struct AnsiColors { + black: [f32; 3], + red: [f32; 3], + green: [f32; 3], + yellow: [f32; 3], + blue: [f32; 3], + magenta: [f32; 3], + cyan: [f32; 3], + white: [f32; 3], +} + +#[derive(Default, Debug)] +struct ThemeYaml { + name: String, + accent: [f32; 3], + background: [f32; 3], + foreground: [f32; 3], + /// true = "darker" (chrome details darker than bg, typical for + /// medium-dark themes); false = "lighter". + details_darker: bool, + normal: AnsiColors, + bright: AnsiColors, +} + +fn parse_theme_yaml(text: &str) -> Result { + let mut t = ThemeYaml { + details_darker: true, + ..Default::default() + }; + let mut section_path: Vec = Vec::new(); + let mut section_indents: Vec = Vec::new(); + + for (lineno, raw) in text.lines().enumerate() { + let stripped = raw.trim_end(); + // Skip blanks and YAML comments + let trimmed = stripped.trim_start(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let indent = stripped.len() - trimmed.len(); + + // Pop sections whose indent is >= this line's indent + while let Some(&top) = section_indents.last() { + if indent <= top { + section_indents.pop(); + section_path.pop(); + } else { + break; + } + } + + let (key, val) = trimmed + .split_once(':') + .ok_or_else(|| format!("line {}: expected 'key: value'", lineno + 1))?; + let key = key.trim(); + let val = val.trim(); + + if val.is_empty() { + section_path.push(key.to_string()); + section_indents.push(indent); + continue; + } + + let unquoted = val + .trim_start_matches(['\'', '"']) + .trim_end_matches(['\'', '"']); + let qualified = if section_path.is_empty() { + key.to_string() + } else { + format!("{}.{}", section_path.join("."), key) + }; + + apply_yaml_value(&mut t, &qualified, unquoted, lineno + 1)?; + } + + Ok(t) +} + +fn apply_yaml_value(t: &mut ThemeYaml, key: &str, val: &str, lineno: usize) -> Result<(), String> { + match key { + "name" => t.name = val.to_string(), + "accent" => t.accent = parse_hex(val).map_err(|e| format!("line {}: {}", lineno, e))?, + "background" => { + t.background = parse_hex(val).map_err(|e| format!("line {}: {}", lineno, e))? + } + "foreground" => { + t.foreground = parse_hex(val).map_err(|e| format!("line {}: {}", lineno, e))? + } + "details" => t.details_darker = val.eq_ignore_ascii_case("darker"), + // terminal_colors.{normal,bright}.{black|red|green|...} + k if k.starts_with("terminal_colors.normal.") => { + apply_ansi(&mut t.normal, &k["terminal_colors.normal.".len()..], val, lineno)?; + } + k if k.starts_with("terminal_colors.bright.") => { + apply_ansi(&mut t.bright, &k["terminal_colors.bright.".len()..], val, lineno)?; + } + // unknown keys are ignored (forward-compatible) + _ => {} + } + Ok(()) +} + +fn apply_ansi(a: &mut AnsiColors, slot: &str, val: &str, lineno: usize) -> Result<(), String> { + let c = parse_hex(val).map_err(|e| format!("line {}: {}", lineno, e))?; + match slot { + "black" => a.black = c, + "red" => a.red = c, + "green" => a.green = c, + "yellow" => a.yellow = c, + "blue" => a.blue = c, + "magenta" => a.magenta = c, + "cyan" => a.cyan = c, + "white" => a.white = c, + _ => {} // ignore unknown + } + Ok(()) +} + +/// Parse a hex color (`#rrggbb`, `#rgb`, or `0xRRGGBB`) into linear-ish +/// 0..1 RGB. We don't apply gamma — wgit treats inputs as already in +/// the working color space. +fn parse_hex(s: &str) -> Result<[f32; 3], String> { + let s = s.trim(); + let body = s + .strip_prefix('#') + .or_else(|| s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))) + .unwrap_or(s); + let (r, g, b) = match body.len() { + 6 => ( + u8::from_str_radix(&body[0..2], 16), + u8::from_str_radix(&body[2..4], 16), + u8::from_str_radix(&body[4..6], 16), + ), + 3 => { + let exp = |c: char| -> Result { + u8::from_str_radix(&format!("{c}{c}"), 16) + }; + let cs: Vec = body.chars().collect(); + (exp(cs[0]), exp(cs[1]), exp(cs[2])) + } + _ => return Err(format!("invalid hex color {:?}", s)), + }; + let r = r.map_err(|e| format!("invalid hex color {:?}: {}", s, e))?; + let g = g.map_err(|e| format!("invalid hex color {:?}: {}", s, e))?; + let b = b.map_err(|e| format!("invalid hex color {:?}: {}", s, e))?; + Ok([ + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + ]) +} + +// ───────────────────────────────────────────────────────────────── +// Color math helpers +// ───────────────────────────────────────────────────────────────── + +#[inline] +fn rgba(c: [f32; 3], a: f32) -> [f32; 4] { + [c[0], c[1], c[2], a] +} + +#[inline] +fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} + +#[inline] +fn shift(c: [f32; 3], by: f32) -> [f32; 3] { + [ + (c[0] + by).clamp(0.0, 1.0), + (c[1] + by).clamp(0.0, 1.0), + (c[2] + by).clamp(0.0, 1.0), + ] +} + +/// "Toward fg" — useful for deriving softer accent dim variants. +#[inline] +fn toward(c: [f32; 3], target: [f32; 3], t: f32) -> [f32; 3] { + lerp3(c, target, t) +} + +// ───────────────────────────────────────────────────────────────── +// Derive a full Palette from the 4 base + 16 ANSI colors +// ───────────────────────────────────────────────────────────────── + +fn derive_palette(y: &ThemeYaml) -> Palette { + let bg = y.background; + let fg = y.foreground; + + // Direction of chrome shift: "darker" themes have chrome panels + // slightly darker than bg; "lighter" themes have them slightly + // lighter. + let dir: f32 = if y.details_darker { -1.0 } else { 1.0 }; + + let titlebar_top = shift(bg, 0.018 * -dir); // titlebars feel lifted + let titlebar_bottom = shift(bg, 0.000); + let toolbar_top = shift(bg, 0.008 * -dir); + let toolbar_bottom = shift(bg, 0.010 * dir); + let content_top = shift(bg, 0.005 * dir); + let content_bottom = shift(bg, 0.018 * dir); + + let modal_top = shift(bg, 0.020 * -dir); + let modal_bot = shift(bg, 0.018 * dir); + let modal_border = toward(fg, bg, 0.65); + + // Text + let text_primary = fg; + let text_secondary = lerp3(fg, bg, 0.30); + let text_muted = lerp3(fg, bg, 0.55); + let text_accent = y.accent; + + // ANSI semantic accents (use bright for vividness, normal for dim) + let ag = y.bright.green; + let ay = y.bright.yellow; + let ar = y.bright.red; + let ab = y.bright.blue; + let amag = y.bright.magenta; + let acyan = y.bright.cyan; + let agray = y.normal.white; + + let dim = |c: [f32; 3]| rgba(lerp3(c, bg, 0.30), 0.70); + + // Sections — saturated tints. We render with alpha so a section + // band painted over `bg` reads as ≈ alpha × tint_amount of the + // accent. Bumped from the previous ~11% effective tint to ~50% so + // the source themes (gruvbox, dracula, etc.) don't look washed out. + let mk_section = |c: [f32; 3]| { + let t = lerp3(bg, c, 0.55); + let b = lerp3(bg, c, 0.42); + let bd = lerp3(c, bg, 0.20); + ( + [t[0], t[1], t[2], 0.85], + [b[0], b[1], b[2], 0.78], + [bd[0], bd[1], bd[2], 0.65], + ) + }; + + let (sst, ssb, ssbd) = mk_section(ag); + let (ust, usb, usbd) = mk_section(ay); + let (utt, utb, utbd) = mk_section(agray); + + // Diff add/remove tints — visible but not so loud they fight the + // syntax-coloured text on top. + let mk_diff = |c: [f32; 3], top_a: f32, bot_a: f32| { + let t = lerp3(bg, c, 0.45); + let b = lerp3(bg, c, 0.36); + ([t[0], t[1], t[2], top_a], [b[0], b[1], b[2], bot_a]) + }; + let (dat, dab) = mk_diff(ag, 0.55, 0.48); + let (drt, drb) = mk_diff(ar, 0.55, 0.48); + + // Hunk header — definitively a header band, not a faint suggestion. + let dh_t = lerp3(bg, ab, 0.40); + let dh_b = lerp3(bg, ab, 0.28); + let dh_bd = lerp3(ab, bg, 0.20); + let dm_t = lerp3(bg, ab, 0.18); + let dm_b = lerp3(bg, ab, 0.10); + + // Diff file header — clearly lifted, with a foreground stroke. + let dfh_t = lerp3(bg, fg, 0.18); + let dfh_b = lerp3(bg, fg, 0.10); + let dfh_bd = lerp3(fg, bg, 0.40); + + // Selection — solid lift so the active row pops. + let row_top = rgba(lerp3(bg, fg, 0.20), 0.85); + let row_bot = rgba(lerp3(bg, fg, 0.14), 0.78); + let row_bd = rgba(lerp3(fg, bg, 0.30), 0.65); + let sel_bar = rgba(y.accent, 1.0); + + // Branch chip — visibly chipped from the chrome. + let chip_top = rgba(lerp3(bg, fg, 0.20), 0.92); + let chip_bot = rgba(lerp3(bg, fg, 0.14), 0.88); + + // Modal danger — red-tinted bg + let md_top = lerp3(bg, ar, 0.30); + let md_bot = lerp3(bg, ar, 0.18); + let md_border = toward(ar, bg, 0.10); + + // Diff line-number gutter — slightly darker than bg so it reads + // as a visual rail without competing with the diff content. + let gut_t = shift(bg, -0.04 * -dir); + let gut_b = shift(bg, -0.06 * -dir); + + // Status bar variants + let status_n_t = rgba(modal_top, 0.92); + let status_n_b = rgba(shift(modal_bot, 0.0), 0.94); + let status_n_bd = rgba(lerp3(fg, bg, 0.70), 0.50); + let status_n_text = rgba(lerp3(fg, bg, 0.10), 1.0); + + let status_s_t = rgba(lerp3(bg, ag, 0.18), 0.92); + let status_s_b = rgba(lerp3(bg, ag, 0.12), 0.94); + let status_s_bd = rgba(lerp3(ag, bg, 0.45), 0.55); + let status_s_text = rgba(lerp3(ag, [1.0, 1.0, 1.0], 0.45), 1.0); + + let status_e_t = rgba(lerp3(bg, ar, 0.20), 0.92); + let status_e_b = rgba(lerp3(bg, ar, 0.13), 0.94); + let status_e_bd = rgba(lerp3(ar, bg, 0.30), 0.62); + let status_e_text = rgba(lerp3(ar, [1.0, 1.0, 1.0], 0.55), 1.0); + + let status_p_t = rgba(lerp3(bg, ay, 0.20), 0.92); + let status_p_b = rgba(lerp3(bg, ay, 0.13), 0.94); + let status_p_bd = rgba(lerp3(ay, bg, 0.40), 0.58); + let status_p_text = rgba(lerp3(ay, [1.0, 1.0, 1.0], 0.55), 1.0); + + Palette { + name: leak_name(&y.name), + + bg: wgpu::Color { + r: bg[0] as f64, + g: bg[1] as f64, + b: bg[2] as f64, + a: 1.0, + }, + titlebar_top: rgba(titlebar_top, 1.0), + titlebar_bottom: rgba(titlebar_bottom, 1.0), + toolbar_top: rgba(toolbar_top, 1.0), + toolbar_bottom: rgba(toolbar_bottom, 1.0), + content_top: rgba(content_top, 1.0), + content_bottom: rgba(content_bottom, 1.0), + + text_primary: rgba(text_primary, 1.0), + text_secondary: rgba(text_secondary, 1.0), + text_muted: rgba(text_muted, 1.0), + text_accent: rgba(text_accent, 1.0), + text_bright: [1.0, 1.0, 1.0, 1.0], + + accent_green: rgba(ag, 1.0), + accent_green_dim: dim(ag), + accent_yellow: rgba(ay, 1.0), + accent_yellow_dim: dim(ay), + accent_red: rgba(ar, 1.0), + accent_red_dim: dim(ar), + accent_blue: rgba(ab, 1.0), + accent_blue_dim: dim(ab), + accent_gray: rgba(agray, 1.0), + accent_gray_dim: dim(agray), + accent_purple: rgba(amag, 1.0), + accent_purple_dim: dim(amag), + accent_pink: rgba(lerp3(amag, ar, 0.30), 1.0), + accent_pink_dim: dim(lerp3(amag, ar, 0.30)), + + row_selected: row_top, + row_selected_bottom: row_bot, + row_selected_border: row_bd, + selection_accent_bar: sel_bar, + + section_staged_bg_top: sst, + section_staged_bg_bottom: ssb, + section_staged_border: ssbd, + section_unstaged_bg_top: ust, + section_unstaged_bg_bottom: usb, + section_unstaged_border: usbd, + section_untracked_bg_top: utt, + section_untracked_bg_bottom: utb, + section_untracked_border: utbd, + + diff_add_bg_top: dat, + diff_add_bg_bottom: dab, + diff_remove_bg_top: drt, + diff_remove_bg_bottom: drb, + diff_hunk_bg_top: rgba(dh_t, 0.55), + diff_hunk_bg_bottom: rgba(dh_b, 0.45), + diff_hunk_border: rgba(dh_bd, 0.35), + diff_meta_bg_top: rgba(dm_t, 0.30), + diff_meta_bg_bottom: rgba(dm_b, 0.25), + diff_file_header_bg_top: rgba(dfh_t, 0.85), + diff_file_header_bg_bottom: rgba(dfh_b, 0.78), + diff_file_header_border: rgba(dfh_bd, 0.50), + + toolbar_separator: rgba(lerp3(fg, bg, 0.65), 0.32), + divider: rgba(lerp3(fg, bg, 0.70), 0.55), + + modal_bg_top: rgba(modal_top, 1.0), + modal_bg_bottom: rgba(modal_bot, 1.0), + modal_border: rgba(modal_border, 0.55), + modal_danger_bg_top: rgba(md_top, 1.0), + modal_danger_bg_bottom: rgba(md_bot, 1.0), + modal_danger_border: rgba(md_border, 0.60), + + tooltip_bg_top: rgba(lerp3(bg, [0.0, 0.0, 0.0], 0.88), 0.98), + tooltip_bg_bottom: rgba(lerp3(bg, [0.0, 0.0, 0.0], 0.92), 0.98), + tooltip_border: rgba([1.0, 1.0, 1.0], 0.12), + tooltip_text: rgba([0.96, 0.97, 0.99], 1.0), + + branch_current_badge: rgba(ag, 1.0), + branch_chip_bg_top: chip_top, + branch_chip_bg_bottom: chip_bot, + + gutter_top: rgba(gut_t, 1.0), + gutter_bottom: rgba(gut_b, 1.0), + + status_neutral: (status_n_t, status_n_b, status_n_bd, status_n_text), + status_success: (status_s_t, status_s_b, status_s_bd, status_s_text), + status_error: (status_e_t, status_e_b, status_e_bd, status_e_text), + status_prompt: (status_p_t, status_p_b, status_p_bd, status_p_text), + + line_normal: rgba(lerp3(fg, bg, 0.08), 1.0), + line_dim: rgba(text_muted, 1.0), + line_header: rgba(lerp3(fg, ab, 0.22), 1.0), + line_selected: [1.0, 1.0, 1.0, 1.0], + line_diff_add: rgba(ag, 1.0), + line_diff_remove: rgba(ar, 1.0), + line_diff_hunk: rgba(lerp3(fg, ab, 0.30), 1.0), + + badge_modified: rgba(ay, 1.0), + badge_added: rgba(ag, 1.0), + badge_deleted: rgba(ar, 1.0), + badge_renamed: rgba(amag, 1.0), + badge_untracked: rgba(agray, 1.0), + badge_copied: rgba(acyan, 1.0), + } +} + +/// We hold theme names as `&'static str` because the Palette is stored +/// in a `OnceLock`. For loaded YAML themes we leak the parsed name into +/// `'static`. There's at most one leak per process (one set_palette +/// call), so this is bounded. +fn leak_name(s: &str) -> &'static str { + if s.is_empty() { + return "Custom"; + } + Box::leak(s.to_string().into_boxed_str()) +} + +// ───────────────────────────────────────────────────────────────── +// Bundled themes — defined inline and run through the same +// derivation as user-provided themes, so the styling stays uniform. +// ───────────────────────────────────────────────────────────────── + +const MIDNIGHT_YAML: &str = r#" +name: Midnight +accent: '#7daea3' +background: '#1c1f24' +foreground: '#e6e7eb' +details: 'lighter' +terminal_colors: + normal: + black: '#1c1f24' + red: '#cc6666' + green: '#a3be8c' + yellow: '#d8a657' + blue: '#7eafce' + magenta: '#b294bb' + cyan: '#7daea3' + white: '#a0a3a8' + bright: + black: '#5c6370' + red: '#ea6962' + green: '#6ec690' + yellow: '#d8a657' + blue: '#7daea3' + magenta: '#d3869b' + cyan: '#7daea3' + white: '#e6e7eb' +"#; + +const GRUVBOX_DARK_YAML: &str = r#" +name: Gruvbox Dark +accent: '#fe8019' +background: '#282828' +foreground: '#ebdbb2' +details: 'darker' +terminal_colors: + normal: + black: '#282828' + red: '#cc241d' + green: '#98971a' + yellow: '#d79921' + blue: '#458588' + magenta: '#b16286' + cyan: '#689d6a' + white: '#a89984' + bright: + black: '#928374' + red: '#fb4934' + green: '#b8bb26' + yellow: '#fabd2f' + blue: '#83a598' + magenta: '#d3869b' + cyan: '#8ec07c' + white: '#ebdbb2' +"#; + +const VERCEL_DARK_YAML: &str = r#" +name: Vercel +accent: '#0070f3' +background: '#000000' +foreground: '#ededed' +details: 'lighter' +terminal_colors: + normal: + black: '#000000' + red: '#ee0000' + green: '#50e3c2' + yellow: '#f5a623' + blue: '#0070f3' + magenta: '#f81ce5' + cyan: '#79ffe1' + white: '#a0a0a0' + bright: + black: '#666666' + red: '#ff4444' + green: '#7cffd9' + yellow: '#ffcb6b' + blue: '#3291ff' + magenta: '#ff7eea' + cyan: '#aaffe5' + white: '#ededed' +"#; + +const DRACULA_YAML: &str = r#" +name: Dracula +accent: '#ff79c6' +background: '#282a36' +foreground: '#f8f8f2' +details: 'darker' +terminal_colors: + normal: + black: '#21222c' + red: '#ff5555' + green: '#50fa7b' + yellow: '#f1fa8c' + blue: '#bd93f9' + magenta: '#ff79c6' + cyan: '#8be9fd' + white: '#bfbfbf' + bright: + black: '#6272a4' + red: '#ff6e6e' + green: '#69ff94' + yellow: '#ffffa5' + blue: '#d6acff' + magenta: '#ff92df' + cyan: '#a4ffff' + white: '#f8f8f2' +"#; + +impl Palette { + pub fn midnight() -> Self { + load_yaml_str(MIDNIGHT_YAML).expect("bundled Midnight YAML") + } + pub fn gruvbox_dark() -> Self { + load_yaml_str(GRUVBOX_DARK_YAML).expect("bundled Gruvbox YAML") + } + pub fn vercel_dark() -> Self { + load_yaml_str(VERCEL_DARK_YAML).expect("bundled Vercel YAML") + } + pub fn dracula() -> Self { + load_yaml_str(DRACULA_YAML).expect("bundled Dracula YAML") + } +} + +// ───────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────── + pub fn badge_color_for_status(xy: &str) -> [f32; 4] { let chars: Vec = xy.chars().collect(); let x = chars.first().copied().unwrap_or(' '); let y = chars.get(1).copied().unwrap_or(' '); - - // Prefer the index (staged) status if present, else worktree status let c = if x != ' ' && x != '?' { x } else { y }; - + let p = palette(); match c { - 'M' => BADGE_MODIFIED, - 'A' => BADGE_ADDED, - 'D' => BADGE_DELETED, - 'R' => BADGE_RENAMED, - 'C' => BADGE_COPIED, - '?' => BADGE_UNTRACKED, - _ => TEXT_MUTED, + 'M' => p.badge_modified, + 'A' => p.badge_added, + 'D' => p.badge_deleted, + 'R' => p.badge_renamed, + 'C' => p.badge_copied, + '?' => p.badge_untracked, + _ => p.text_muted, } } -/// Human-readable single-char badge for a git status code pub fn badge_char_for_status(xy: &str) -> char { let chars: Vec = xy.chars().collect(); let x = chars.first().copied().unwrap_or(' '); diff --git a/wgitlogo.png b/wgitlogo.png new file mode 100644 index 0000000..8942549 Binary files /dev/null and b/wgitlogo.png differ