From d931b540fc741176cc043fbb63d6e0e4e9b83dc2 Mon Sep 17 00:00:00 2001 From: Villads Valur Date: Tue, 10 Mar 2026 16:25:36 +0100 Subject: [PATCH 1/3] feat(theme): add ayu-light, onedark, and system appearance mode --- README.md | 6 +- src/theme/mod.rs | 270 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d9e7a46..d9f76f1 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ Detection order: Jujutsu → Git → Mercurial. Jujutsu is tried first because j | Flag | Description | |------|-------------| | `-r` / `--revisions ` | Commit range/Revision set to review. Exact syntax depends on VCS backend (Git, JJ, Hg) | -| `--theme ` | Color theme override (`dark`, `light`, `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, `catppuccin-mocha`, `gruvbox-dark`, `gruvbox-light`) | +| `--pr` | Review branch changes as a PR diff (`merge-base(base, HEAD)..HEAD`) | +| `--base ` | Base ref for PR mode (implies `--pr`), for example `origin/main` | +| `--theme ` | Color theme override (`dark`, `light`, `ayu-light`, `onedark`, `system`, `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, `catppuccin-mocha`, `gruvbox-dark`, `gruvbox-light`) | | `--stdout` | Output to stdout instead of clipboard when exporting | | `--no-update-check` | Skip checking for updates on startup | @@ -128,6 +130,8 @@ Theme resolution precedence: 2. Config file path above (OS-specific) 3. built-in default (`dark`) +`system` follows your OS appearance (light/dark) at startup. + Notes: - Invalid `--theme` values cause an immediate non-zero exit. - Unknown keys in `config.toml` are ignored with a startup warning. diff --git a/src/theme/mod.rs b/src/theme/mod.rs index aedd76e..c910018 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -2,7 +2,7 @@ //! //! Provides dark and light themes with automatic terminal background detection. -use std::sync::OnceLock; +use std::{process::Command, sync::OnceLock}; use ratatui::style::Color; use two_face::theme::EmbeddedThemeName; @@ -317,6 +317,140 @@ impl Theme { catppuccin_theme(flavor, EmbeddedThemeName::CatppuccinMocha) } + pub fn ayu_light() -> Self { + Self { + highlighter: OnceLock::new(), + + // Base colors + panel_bg: Color::Rgb(250, 250, 250), + bg_highlight: Color::Rgb(240, 238, 228), + fg_primary: Color::Rgb(92, 103, 115), + fg_secondary: Color::Rgb(107, 118, 130), + fg_dim: Color::Rgb(171, 176, 182), + + // Diff colors + diff_add: Color::Rgb(134, 179, 0), + diff_add_bg: Color::Rgb(238, 247, 208), + diff_del: Color::Rgb(240, 113, 120), + diff_del_bg: Color::Rgb(253, 235, 236), + diff_context: Color::Rgb(92, 103, 115), + diff_hunk_header: Color::Rgb(54, 163, 217), + expanded_context_fg: Color::Rgb(130, 140, 153), + + // Syntax highlighting diff backgrounds + syntax_add_bg: Color::Rgb(244, 251, 228), + syntax_del_bg: Color::Rgb(255, 241, 242), + + // Syntect theme for syntax highlighting + syntect_theme: EmbeddedThemeName::OneHalfLight, + + // File status colors + file_added: Color::Rgb(134, 179, 0), + file_modified: Color::Rgb(231, 197, 71), + file_deleted: Color::Rgb(240, 113, 120), + file_renamed: Color::Rgb(163, 122, 204), + + // Review status colors + reviewed: Color::Rgb(134, 179, 0), + pending: Color::Rgb(231, 197, 71), + + // Comment type colors + comment_note: Color::Rgb(54, 163, 217), + comment_suggestion: Color::Rgb(76, 191, 153), + comment_issue: Color::Rgb(240, 113, 120), + comment_praise: Color::Rgb(134, 179, 0), + + // UI element colors + border_focused: Color::Rgb(54, 163, 217), + border_unfocused: Color::Rgb(217, 216, 215), + status_bar_bg: Color::Rgb(255, 255, 255), + cursor_color: Color::Rgb(255, 106, 0), + branch_name: Color::Rgb(54, 163, 217), + help_indicator: Color::Rgb(171, 176, 182), + + // Message/update badge colors + message_info_fg: Color::Black, + message_info_bg: Color::Rgb(140, 220, 255), + message_warning_fg: Color::Black, + message_warning_bg: Color::Rgb(246, 217, 140), + message_error_fg: Color::White, + message_error_bg: Color::Rgb(217, 87, 87), + update_badge_fg: Color::Black, + update_badge_bg: Color::Rgb(246, 217, 140), + + // Mode indicator colors + mode_fg: Color::White, + mode_bg: Color::Rgb(255, 106, 0), + } + } + + pub fn onedark() -> Self { + Self { + highlighter: OnceLock::new(), + + // Base colors + panel_bg: Color::Rgb(40, 44, 52), + bg_highlight: Color::Rgb(62, 68, 82), + fg_primary: Color::Rgb(171, 178, 191), + fg_secondary: Color::Rgb(192, 198, 208), + fg_dim: Color::Rgb(92, 99, 112), + + // Diff colors + diff_add: Color::Rgb(152, 195, 121), + diff_add_bg: Color::Rgb(44, 56, 43), + diff_del: Color::Rgb(224, 108, 117), + diff_del_bg: Color::Rgb(58, 45, 47), + diff_context: Color::Rgb(171, 178, 191), + diff_hunk_header: Color::Rgb(86, 182, 194), + expanded_context_fg: Color::Rgb(92, 99, 112), + + // Syntax highlighting diff backgrounds + syntax_add_bg: Color::Rgb(37, 49, 38), + syntax_del_bg: Color::Rgb(59, 37, 40), + + // Syntect theme for syntax highlighting + syntect_theme: EmbeddedThemeName::OneHalfDark, + + // File status colors + file_added: Color::Rgb(152, 195, 121), + file_modified: Color::Rgb(229, 192, 123), + file_deleted: Color::Rgb(224, 108, 117), + file_renamed: Color::Rgb(198, 120, 221), + + // Review status colors + reviewed: Color::Rgb(152, 195, 121), + pending: Color::Rgb(229, 192, 123), + + // Comment type colors + comment_note: Color::Rgb(97, 175, 239), + comment_suggestion: Color::Rgb(86, 182, 194), + comment_issue: Color::Rgb(224, 108, 117), + comment_praise: Color::Rgb(152, 195, 121), + + // UI element colors + border_focused: Color::Rgb(97, 175, 239), + border_unfocused: Color::Rgb(62, 68, 82), + status_bar_bg: Color::Rgb(33, 37, 43), + cursor_color: Color::Rgb(229, 192, 123), + branch_name: Color::Rgb(86, 182, 194), + help_indicator: Color::Rgb(92, 99, 112), + + // Message/update badge colors + message_info_fg: Color::Black, + message_info_bg: Color::Rgb(86, 182, 194), + message_warning_fg: Color::Black, + message_warning_bg: Color::Rgb(229, 192, 123), + message_error_fg: Color::White, + message_error_bg: Color::Rgb(224, 108, 117), + update_badge_fg: Color::Black, + update_badge_bg: Color::Rgb(229, 192, 123), + + // Mode indicator colors + mode_fg: Color::Rgb(40, 44, 52), + mode_bg: Color::Rgb(97, 175, 239), + } + } + pub fn gruvbox_dark() -> Self { let flavor = GruvboxFlavor { dark: true, @@ -585,6 +719,9 @@ pub enum ThemeArg { #[default] Dark, Light, + AyuLight, + Onedark, + System, CatppuccinLatte, CatppuccinFrappe, CatppuccinMacchiato, @@ -593,9 +730,12 @@ pub enum ThemeArg { GruvboxLight, } -const THEME_CHOICES: [(&str, ThemeArg); 8] = [ +const THEME_CHOICES: [(&str, ThemeArg); 11] = [ ("dark", ThemeArg::Dark), ("light", ThemeArg::Light), + ("ayu-light", ThemeArg::AyuLight), + ("onedark", ThemeArg::Onedark), + ("system", ThemeArg::System), ("catppuccin-latte", ThemeArg::CatppuccinLatte), ("catppuccin-frappe", ThemeArg::CatppuccinFrappe), ("catppuccin-macchiato", ThemeArg::CatppuccinMacchiato), @@ -646,6 +786,9 @@ pub fn resolve_theme(arg: ThemeArg) -> Theme { match arg { ThemeArg::Dark => Theme::dark(), ThemeArg::Light => Theme::light(), + ThemeArg::AyuLight => Theme::ayu_light(), + ThemeArg::Onedark => Theme::onedark(), + ThemeArg::System => resolve_theme(resolve_system_theme()), ThemeArg::CatppuccinLatte => Theme::catppuccin_latte(), ThemeArg::CatppuccinFrappe => Theme::catppuccin_frappe(), ThemeArg::CatppuccinMacchiato => Theme::catppuccin_macchiato(), @@ -655,6 +798,90 @@ pub fn resolve_theme(arg: ThemeArg) -> Theme { } } +fn resolve_system_theme() -> ThemeArg { + if is_system_dark_mode().unwrap_or(true) { + ThemeArg::Dark + } else { + ThemeArg::Light + } +} + +#[cfg(target_os = "macos")] +fn is_system_dark_mode() -> Option { + let output = Command::new("defaults") + .args(["read", "-g", "AppleInterfaceStyle"]) + .output() + .ok()?; + + if !output.status.success() { + return Some(false); + } + + let value = String::from_utf8_lossy(&output.stdout); + Some(value.trim().eq_ignore_ascii_case("dark")) +} + +#[cfg(target_os = "windows")] +fn is_system_dark_mode() -> Option { + let output = Command::new("reg") + .args([ + "query", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + "/v", + "AppsUseLightTheme", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8_lossy(&output.stdout); + if value.contains("0x0") { + Some(true) + } else if value.contains("0x1") { + Some(false) + } else { + None + } +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +fn is_system_dark_mode() -> Option { + let color_scheme = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "color-scheme"]) + .output() + .ok(); + if let Some(output) = color_scheme + && output.status.success() + { + let value = String::from_utf8_lossy(&output.stdout); + if value.contains("prefer-dark") { + return Some(true); + } + if value.contains("default") || value.contains("prefer-light") { + return Some(false); + } + } + + let gtk_theme = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "gtk-theme"]) + .output() + .ok(); + if let Some(output) = gtk_theme + && output.status.success() + { + let value = String::from_utf8_lossy(&output.stdout); + if value.to_ascii_lowercase().contains("dark") { + return Some(true); + } + return Some(false); + } + + None +} + pub fn resolve_theme_arg_with_config( cli_theme: Option, config_theme: Option<&str>, @@ -830,6 +1057,26 @@ mod tests { assert_eq!(parsed.theme, Some(ThemeArg::CatppuccinLatte)); } + #[test] + fn should_parse_ayu_light_theme() { + let parsed = + parse_for_test(&["tuicr", "--theme", "ayu-light"]).expect("parse should succeed"); + assert_eq!(parsed.theme, Some(ThemeArg::AyuLight)); + } + + #[test] + fn should_parse_onedark_theme() { + let parsed = + parse_for_test(&["tuicr", "--theme", "onedark"]).expect("parse should succeed"); + assert_eq!(parsed.theme, Some(ThemeArg::Onedark)); + } + + #[test] + fn should_parse_system_theme() { + let parsed = parse_for_test(&["tuicr", "--theme", "system"]).expect("parse should succeed"); + assert_eq!(parsed.theme, Some(ThemeArg::System)); + } + #[test] fn should_parse_gruvbox_themes() { let parsed = @@ -917,6 +1164,13 @@ mod tests { assert!(warnings.is_empty()); } + #[test] + fn should_use_system_theme_from_config_when_cli_missing() { + let (resolved, warnings) = resolve_theme_arg_with_config(None, Some("system")); + assert_eq!(resolved, ThemeArg::System); + assert!(warnings.is_empty()); + } + #[test] fn should_resolve_catppuccin_mocha_syntect_theme() { let theme = resolve_theme(ThemeArg::CatppuccinMocha); @@ -941,6 +1195,18 @@ mod tests { assert_eq!(theme.syntect_theme, EmbeddedThemeName::GruvboxLight); } + #[test] + fn should_resolve_ayu_light_to_onehalf_light_syntect_theme() { + let theme = resolve_theme(ThemeArg::AyuLight); + assert_eq!(theme.syntect_theme, EmbeddedThemeName::OneHalfLight); + } + + #[test] + fn should_resolve_onedark_to_onehalf_dark_syntect_theme() { + let theme = resolve_theme(ThemeArg::Onedark); + assert_eq!(theme.syntect_theme, EmbeddedThemeName::OneHalfDark); + } + #[test] fn should_use_dark_flavor_base_for_catppuccin_mode_foreground() { let theme = Theme::catppuccin_mocha(); From bd4d7274b5444650fea3f0c2df5d08593eb91c75 Mon Sep 17 00:00:00 2001 From: Villads Valur Date: Tue, 10 Mar 2026 16:58:58 +0100 Subject: [PATCH 2/3] refactor(theme): keep ayu/onedark PR independent from appearance --- README.md | 4 +- src/theme/mod.rs | 104 +---------------------------------------------- 2 files changed, 3 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index d9f76f1..affba86 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Detection order: Jujutsu → Git → Mercurial. Jujutsu is tried first because j | `-r` / `--revisions ` | Commit range/Revision set to review. Exact syntax depends on VCS backend (Git, JJ, Hg) | | `--pr` | Review branch changes as a PR diff (`merge-base(base, HEAD)..HEAD`) | | `--base ` | Base ref for PR mode (implies `--pr`), for example `origin/main` | -| `--theme ` | Color theme override (`dark`, `light`, `ayu-light`, `onedark`, `system`, `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, `catppuccin-mocha`, `gruvbox-dark`, `gruvbox-light`) | +| `--theme ` | Color theme override (`dark`, `light`, `ayu-light`, `onedark`, `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, `catppuccin-mocha`, `gruvbox-dark`, `gruvbox-light`) | | `--stdout` | Output to stdout instead of clipboard when exporting | | `--no-update-check` | Skip checking for updates on startup | @@ -130,8 +130,6 @@ Theme resolution precedence: 2. Config file path above (OS-specific) 3. built-in default (`dark`) -`system` follows your OS appearance (light/dark) at startup. - Notes: - Invalid `--theme` values cause an immediate non-zero exit. - Unknown keys in `config.toml` are ignored with a startup warning. diff --git a/src/theme/mod.rs b/src/theme/mod.rs index c910018..c832d50 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -2,7 +2,7 @@ //! //! Provides dark and light themes with automatic terminal background detection. -use std::{process::Command, sync::OnceLock}; +use std::sync::OnceLock; use ratatui::style::Color; use two_face::theme::EmbeddedThemeName; @@ -721,7 +721,6 @@ pub enum ThemeArg { Light, AyuLight, Onedark, - System, CatppuccinLatte, CatppuccinFrappe, CatppuccinMacchiato, @@ -730,12 +729,11 @@ pub enum ThemeArg { GruvboxLight, } -const THEME_CHOICES: [(&str, ThemeArg); 11] = [ +const THEME_CHOICES: [(&str, ThemeArg); 10] = [ ("dark", ThemeArg::Dark), ("light", ThemeArg::Light), ("ayu-light", ThemeArg::AyuLight), ("onedark", ThemeArg::Onedark), - ("system", ThemeArg::System), ("catppuccin-latte", ThemeArg::CatppuccinLatte), ("catppuccin-frappe", ThemeArg::CatppuccinFrappe), ("catppuccin-macchiato", ThemeArg::CatppuccinMacchiato), @@ -788,7 +786,6 @@ pub fn resolve_theme(arg: ThemeArg) -> Theme { ThemeArg::Light => Theme::light(), ThemeArg::AyuLight => Theme::ayu_light(), ThemeArg::Onedark => Theme::onedark(), - ThemeArg::System => resolve_theme(resolve_system_theme()), ThemeArg::CatppuccinLatte => Theme::catppuccin_latte(), ThemeArg::CatppuccinFrappe => Theme::catppuccin_frappe(), ThemeArg::CatppuccinMacchiato => Theme::catppuccin_macchiato(), @@ -798,90 +795,6 @@ pub fn resolve_theme(arg: ThemeArg) -> Theme { } } -fn resolve_system_theme() -> ThemeArg { - if is_system_dark_mode().unwrap_or(true) { - ThemeArg::Dark - } else { - ThemeArg::Light - } -} - -#[cfg(target_os = "macos")] -fn is_system_dark_mode() -> Option { - let output = Command::new("defaults") - .args(["read", "-g", "AppleInterfaceStyle"]) - .output() - .ok()?; - - if !output.status.success() { - return Some(false); - } - - let value = String::from_utf8_lossy(&output.stdout); - Some(value.trim().eq_ignore_ascii_case("dark")) -} - -#[cfg(target_os = "windows")] -fn is_system_dark_mode() -> Option { - let output = Command::new("reg") - .args([ - "query", - r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", - "/v", - "AppsUseLightTheme", - ]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let value = String::from_utf8_lossy(&output.stdout); - if value.contains("0x0") { - Some(true) - } else if value.contains("0x1") { - Some(false) - } else { - None - } -} - -#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -fn is_system_dark_mode() -> Option { - let color_scheme = Command::new("gsettings") - .args(["get", "org.gnome.desktop.interface", "color-scheme"]) - .output() - .ok(); - if let Some(output) = color_scheme - && output.status.success() - { - let value = String::from_utf8_lossy(&output.stdout); - if value.contains("prefer-dark") { - return Some(true); - } - if value.contains("default") || value.contains("prefer-light") { - return Some(false); - } - } - - let gtk_theme = Command::new("gsettings") - .args(["get", "org.gnome.desktop.interface", "gtk-theme"]) - .output() - .ok(); - if let Some(output) = gtk_theme - && output.status.success() - { - let value = String::from_utf8_lossy(&output.stdout); - if value.to_ascii_lowercase().contains("dark") { - return Some(true); - } - return Some(false); - } - - None -} - pub fn resolve_theme_arg_with_config( cli_theme: Option, config_theme: Option<&str>, @@ -1071,12 +984,6 @@ mod tests { assert_eq!(parsed.theme, Some(ThemeArg::Onedark)); } - #[test] - fn should_parse_system_theme() { - let parsed = parse_for_test(&["tuicr", "--theme", "system"]).expect("parse should succeed"); - assert_eq!(parsed.theme, Some(ThemeArg::System)); - } - #[test] fn should_parse_gruvbox_themes() { let parsed = @@ -1164,13 +1071,6 @@ mod tests { assert!(warnings.is_empty()); } - #[test] - fn should_use_system_theme_from_config_when_cli_missing() { - let (resolved, warnings) = resolve_theme_arg_with_config(None, Some("system")); - assert_eq!(resolved, ThemeArg::System); - assert!(warnings.is_empty()); - } - #[test] fn should_resolve_catppuccin_mocha_syntect_theme() { let theme = resolve_theme(ThemeArg::CatppuccinMocha); From 016b3872720a797712dfbcb6f7fa051b14c0efa5 Mon Sep 17 00:00:00 2001 From: Villads Valur Date: Tue, 10 Mar 2026 17:06:53 +0100 Subject: [PATCH 3/3] docs(readme): remove unrelated pr/base lines from theme branch --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index affba86..d2ae24a 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,6 @@ Detection order: Jujutsu → Git → Mercurial. Jujutsu is tried first because j | Flag | Description | |------|-------------| | `-r` / `--revisions ` | Commit range/Revision set to review. Exact syntax depends on VCS backend (Git, JJ, Hg) | -| `--pr` | Review branch changes as a PR diff (`merge-base(base, HEAD)..HEAD`) | -| `--base ` | Base ref for PR mode (implies `--pr`), for example `origin/main` | | `--theme ` | Color theme override (`dark`, `light`, `ayu-light`, `onedark`, `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato`, `catppuccin-mocha`, `gruvbox-dark`, `gruvbox-light`) | | `--stdout` | Output to stdout instead of clipboard when exporting | | `--no-update-check` | Skip checking for updates on startup |