252 lines
8.7 KiB
Rust
252 lines
8.7 KiB
Rust
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
|
|
|
//! Flexoki-based theme for the lore TUI.
|
|
//!
|
|
//! Uses FrankenTUI's `AdaptiveColor::adaptive(light, dark)` for automatic
|
|
//! light/dark mode switching. The palette is [Flexoki](https://stephango.com/flexoki)
|
|
//! by Steph Ango, designed in Oklab perceptual color space for balanced contrast.
|
|
|
|
use ftui::{AdaptiveColor, Color, PackedRgba, Style, Theme};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Flexoki palette constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Base tones
|
|
const PAPER: Color = Color::rgb(0xFF, 0xFC, 0xF0);
|
|
const BASE_50: Color = Color::rgb(0xF2, 0xF0, 0xE5);
|
|
const BASE_100: Color = Color::rgb(0xE6, 0xE4, 0xD9);
|
|
const BASE_200: Color = Color::rgb(0xCE, 0xCD, 0xC3);
|
|
const BASE_300: Color = Color::rgb(0xB7, 0xB5, 0xAC);
|
|
const BASE_400: Color = Color::rgb(0x9F, 0x9D, 0x96);
|
|
const BASE_500: Color = Color::rgb(0x87, 0x85, 0x80);
|
|
const BASE_600: Color = Color::rgb(0x6F, 0x6E, 0x69);
|
|
const BASE_700: Color = Color::rgb(0x57, 0x56, 0x53);
|
|
const BASE_800: Color = Color::rgb(0x40, 0x3E, 0x3C);
|
|
const BASE_850: Color = Color::rgb(0x34, 0x33, 0x31);
|
|
const BASE_900: Color = Color::rgb(0x28, 0x27, 0x26);
|
|
const BLACK: Color = Color::rgb(0x10, 0x0F, 0x0F);
|
|
|
|
// Accent colors — light-600 (for light mode)
|
|
const RED_600: Color = Color::rgb(0xAF, 0x30, 0x29);
|
|
const ORANGE_600: Color = Color::rgb(0xBC, 0x52, 0x15);
|
|
const YELLOW_600: Color = Color::rgb(0xAD, 0x83, 0x01);
|
|
const GREEN_600: Color = Color::rgb(0x66, 0x80, 0x0B);
|
|
const CYAN_600: Color = Color::rgb(0x24, 0x83, 0x7B);
|
|
const BLUE_600: Color = Color::rgb(0x20, 0x5E, 0xA6);
|
|
const PURPLE_600: Color = Color::rgb(0x5E, 0x40, 0x9D);
|
|
|
|
// Accent colors — dark-400 (for dark mode)
|
|
const RED_400: Color = Color::rgb(0xD1, 0x4D, 0x41);
|
|
const ORANGE_400: Color = Color::rgb(0xDA, 0x70, 0x2C);
|
|
const YELLOW_400: Color = Color::rgb(0xD0, 0xA2, 0x15);
|
|
const GREEN_400: Color = Color::rgb(0x87, 0x9A, 0x39);
|
|
const CYAN_400: Color = Color::rgb(0x3A, 0xA9, 0x9F);
|
|
const BLUE_400: Color = Color::rgb(0x43, 0x85, 0xBE);
|
|
const PURPLE_400: Color = Color::rgb(0x8B, 0x7E, 0xC8);
|
|
const MAGENTA_400: Color = Color::rgb(0xCE, 0x5D, 0x97);
|
|
|
|
// Muted fallback as PackedRgba (for Style::fg)
|
|
const MUTED_PACKED: PackedRgba = PackedRgba::rgb(0x87, 0x85, 0x80);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// build_theme
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build the lore TUI theme with Flexoki adaptive colors.
|
|
///
|
|
/// Each of the 19 semantic slots gets an `AdaptiveColor::adaptive(light, dark)`
|
|
/// pair. FrankenTUI detects the terminal background and resolves accordingly.
|
|
#[must_use]
|
|
pub fn build_theme() -> Theme {
|
|
Theme::builder()
|
|
.primary(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
|
.secondary(AdaptiveColor::adaptive(CYAN_600, CYAN_400))
|
|
.accent(AdaptiveColor::adaptive(PURPLE_600, PURPLE_400))
|
|
.background(AdaptiveColor::adaptive(PAPER, BLACK))
|
|
.surface(AdaptiveColor::adaptive(BASE_50, BASE_900))
|
|
.overlay(AdaptiveColor::adaptive(BASE_100, BASE_850))
|
|
.text(AdaptiveColor::adaptive(BASE_700, BASE_200))
|
|
.text_muted(AdaptiveColor::adaptive(BASE_500, BASE_500))
|
|
.text_subtle(AdaptiveColor::adaptive(BASE_400, BASE_600))
|
|
.success(AdaptiveColor::adaptive(GREEN_600, GREEN_400))
|
|
.warning(AdaptiveColor::adaptive(YELLOW_600, YELLOW_400))
|
|
.error(AdaptiveColor::adaptive(RED_600, RED_400))
|
|
.info(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
|
.border(AdaptiveColor::adaptive(BASE_300, BASE_700))
|
|
.border_focused(AdaptiveColor::adaptive(BLUE_600, BLUE_400))
|
|
.selection_bg(AdaptiveColor::adaptive(BASE_100, BASE_800))
|
|
.selection_fg(AdaptiveColor::adaptive(BASE_700, BASE_100))
|
|
.scrollbar_track(AdaptiveColor::adaptive(BASE_50, BASE_900))
|
|
.scrollbar_thumb(AdaptiveColor::adaptive(BASE_300, BASE_700))
|
|
.build()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State colors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Map a GitLab entity state to a display color.
|
|
///
|
|
/// Returns fixed (non-adaptive) colors — state indicators should be
|
|
/// consistent regardless of light/dark mode.
|
|
#[must_use]
|
|
pub fn state_color(state: &str) -> Color {
|
|
match state {
|
|
"opened" => GREEN_400,
|
|
"closed" => RED_400,
|
|
"merged" => PURPLE_400,
|
|
"locked" => YELLOW_400,
|
|
_ => BASE_500,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Event type colors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Map a timeline event type to a display color.
|
|
#[must_use]
|
|
pub fn event_color(event_type: &str) -> Color {
|
|
match event_type {
|
|
"created" => GREEN_400,
|
|
"updated" => BLUE_400,
|
|
"closed" => RED_400,
|
|
"merged" => PURPLE_400,
|
|
"commented" => CYAN_400,
|
|
"labeled" => ORANGE_400,
|
|
"milestoned" => YELLOW_400,
|
|
_ => BASE_500,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Label styling
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert a GitLab label hex color (e.g., "#FF0000" or "FF0000") to a Style.
|
|
///
|
|
/// Falls back to muted text color if the hex string is invalid.
|
|
#[must_use]
|
|
pub fn label_style(hex_color: &str) -> Style {
|
|
let packed = parse_hex_to_packed(hex_color).unwrap_or(MUTED_PACKED);
|
|
Style::default().fg(packed)
|
|
}
|
|
|
|
/// Parse a hex color string like "#RRGGBB" or "RRGGBB" into a `PackedRgba`.
|
|
fn parse_hex_to_packed(s: &str) -> Option<PackedRgba> {
|
|
let hex = s.strip_prefix('#').unwrap_or(s);
|
|
if hex.len() != 6 {
|
|
return None;
|
|
}
|
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
|
Some(PackedRgba::rgb(r, g, b))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_build_theme_compiles() {
|
|
let theme = build_theme();
|
|
// Resolve for dark mode — primary should be Blue-400
|
|
let resolved = theme.resolve(true);
|
|
assert_eq!(resolved.primary, BLUE_400);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_theme_light_mode() {
|
|
let theme = build_theme();
|
|
let resolved = theme.resolve(false);
|
|
assert_eq!(resolved.primary, BLUE_600);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_theme_all_slots_differ_between_modes() {
|
|
let theme = build_theme();
|
|
let dark = theme.resolve(true);
|
|
let light = theme.resolve(false);
|
|
// Background should differ (Paper vs Black)
|
|
assert_ne!(dark.background, light.background);
|
|
// Text should differ
|
|
assert_ne!(dark.text, light.text);
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_color_opened_is_green() {
|
|
assert_eq!(state_color("opened"), GREEN_400);
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_color_closed_is_red() {
|
|
assert_eq!(state_color("closed"), RED_400);
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_color_merged_is_purple() {
|
|
assert_eq!(state_color("merged"), PURPLE_400);
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_color_unknown_returns_muted() {
|
|
assert_eq!(state_color("unknown"), BASE_500);
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_color_created_is_green() {
|
|
assert_eq!(event_color("created"), GREEN_400);
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_color_unknown_returns_muted() {
|
|
assert_eq!(event_color("whatever"), BASE_500);
|
|
}
|
|
|
|
#[test]
|
|
fn test_label_style_valid_hex_with_hash() {
|
|
let style = label_style("#FF0000");
|
|
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_label_style_valid_hex_without_hash() {
|
|
let style = label_style("00FF00");
|
|
assert_eq!(style.fg, Some(PackedRgba::rgb(0x00, 0xFF, 0x00)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_label_style_lowercase_hex() {
|
|
let style = label_style("#ff0000");
|
|
assert_eq!(style.fg, Some(PackedRgba::rgb(0xFF, 0x00, 0x00)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_label_style_invalid_hex_fallback() {
|
|
let style = label_style("invalid");
|
|
assert_eq!(style.fg, Some(MUTED_PACKED));
|
|
}
|
|
|
|
#[test]
|
|
fn test_label_style_empty_fallback() {
|
|
let style = label_style("");
|
|
assert_eq!(style.fg, Some(MUTED_PACKED));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_hex_short_string() {
|
|
assert!(parse_hex_to_packed("#FFF").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_hex_non_hex_chars() {
|
|
assert!(parse_hex_to_packed("#GGHHII").is_none());
|
|
}
|
|
}
|