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