#![allow(dead_code)]
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Widget};
use ratatui::Frame;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ColorDepth {
TrueColor,
Ansi256,
Ansi16,
}
pub fn detect(colorterm: Option<&str>, term: Option<&str>) -> ColorDepth {
if let Some(ct) = colorterm {
let lower = ct.to_lowercase();
if lower.contains("truecolor") || lower.contains("24bit") {
return ColorDepth::TrueColor;
}
}
if let Some(t) = term {
if t.contains("256color") {
return ColorDepth::Ansi256;
}
}
ColorDepth::Ansi16
}
#[derive(Clone, Copy, Debug)]
pub struct Theme {
pub primary: Color,
pub on_primary: Color,
pub text_strong: Color,
pub text_muted: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub overlay_dim: Color,
pub section_header: Color,
}
pub fn theme(depth: ColorDepth) -> Theme {
match depth {
ColorDepth::TrueColor => Theme {
primary: Color::Rgb(235, 160, 110), on_primary: Color::Rgb(30, 20, 10),
text_strong: Color::Rgb(230, 230, 230),
text_muted: Color::Rgb(140, 140, 140),
success: Color::Rgb(100, 200, 120),
warning: Color::Rgb(240, 200, 80),
error: Color::Rgb(220, 80, 80),
overlay_dim: Color::Rgb(15, 15, 20),
section_header: Color::Rgb(235, 160, 110),
},
ColorDepth::Ansi256 => Theme {
primary: Color::Indexed(216), on_primary: Color::Indexed(232), text_strong: Color::Indexed(255), text_muted: Color::Indexed(245), success: Color::Indexed(114), warning: Color::Indexed(220), error: Color::Indexed(160), overlay_dim: Color::Indexed(233), section_header: Color::Indexed(216),
},
ColorDepth::Ansi16 => Theme {
primary: Color::Yellow,
on_primary: Color::Black,
text_strong: Color::White,
text_muted: Color::DarkGray,
success: Color::Green,
warning: Color::Yellow,
error: Color::Red,
overlay_dim: Color::Black,
section_header: Color::Yellow,
},
}
}
impl Theme {
pub fn selection_style(&self) -> Style {
Style::default()
.bg(self.primary)
.fg(self.on_primary)
.add_modifier(Modifier::BOLD)
}
pub fn section_header(&self, label: &str) -> Line<'static> {
Line::from(vec![Span::styled(
label.to_owned(),
Style::default()
.fg(self.section_header)
.add_modifier(Modifier::BOLD),
)])
}
pub fn label_value(&self, label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(label.to_owned(), Style::default().fg(self.text_muted)),
Span::raw(" "),
Span::styled(value.to_owned(), Style::default().fg(self.text_strong)),
])
}
pub fn footer_line(&self, hint: &str, route: &str) -> Line<'static> {
Line::from(vec![
Span::styled(hint.to_owned(), Style::default().fg(self.text_muted)),
Span::raw(" · "),
Span::styled(route.to_owned(), Style::default().fg(self.text_strong)),
])
}
pub fn centered_rect(&self, pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let pct_x = pct_x.min(100);
let pct_y = pct_y.min(100);
let margem_v = (100 - pct_y) / 2;
let margem_h = (100 - pct_x) / 2;
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(margem_v),
Constraint::Percentage(pct_y),
Constraint::Percentage(margem_v),
])
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(margem_h),
Constraint::Percentage(pct_x),
Constraint::Percentage(margem_h),
])
.split(vertical[1]);
horizontal[1]
}
pub fn overlay_dim(&self, frame: &mut Frame, area: Rect) {
let block = Block::default().style(Style::default().bg(self.overlay_dim));
block.render(area, frame.buffer_mut());
}
pub fn figlet_banner(&self, width: u16) -> Vec<Line<'static>> {
let accent = Style::default()
.fg(self.primary)
.add_modifier(Modifier::BOLD);
let muted = Style::default().fg(self.text_muted);
if width >= 48 {
vec![
Line::from(Span::styled(" ███████╗██████╗ ██████╗ ", accent)),
Line::from(Span::styled(" ██╔════╝██╔══██╗██╔══██╗", accent)),
Line::from(Span::styled(" ███████╗██║ ██║██║ ██║", accent)),
Line::from(Span::styled(" ╚════██║██║ ██║██║ ██║", accent)),
Line::from(vec![
Span::styled(" ███████║██████╔╝██████╔╝", accent),
Span::styled(" Spec-Driven Development", muted),
]),
]
} else {
vec![Line::from(vec![
Span::styled(" SDD", accent),
Span::styled(" · Spec-Driven Development ", muted),
])]
}
}
}
pub fn gradient(stops: &[Color], steps: usize) -> Vec<Color> {
let fallback_color = stops.first().copied().unwrap_or(Color::Indexed(216));
let solid_count = steps.max(1);
if steps == 0 || stops.len() < 2 {
return vec![fallback_color; solid_count];
}
let rgb_stops: Vec<(u8, u8, u8)> = match stops
.iter()
.map(|c| match c {
Color::Rgb(r, g, b) => Some((*r, *g, *b)),
_ => None,
})
.collect::<Option<Vec<_>>>()
{
Some(v) => v,
None => return vec![fallback_color; solid_count],
};
let n_segments = rgb_stops.len() - 1;
let mut result = Vec::with_capacity(steps);
for i in 0..steps {
let t = if steps == 1 {
0.0_f32
} else {
i as f32 / (steps - 1) as f32
};
let seg_f = t * n_segments as f32;
let seg = (seg_f as usize).min(n_segments - 1);
let local_t = seg_f - seg as f32;
let (r0, g0, b0) = rgb_stops[seg];
let (r1, g1, b1) = rgb_stops[seg + 1];
let r = (r0 as f32 + (r1 as f32 - r0 as f32) * local_t).round() as u8;
let g = (g0 as f32 + (g1 as f32 - g0 as f32) * local_t).round() as u8;
let b = (b0 as f32 + (b1 as f32 - b0 as f32) * local_t).round() as u8;
result.push(Color::Rgb(r, g, b));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
#[test]
fn detect_truecolor_via_colorterm() {
assert_eq!(
detect(Some("truecolor"), Some("xterm-256color")),
ColorDepth::TrueColor
);
}
#[test]
fn detect_truecolor_via_24bit() {
assert_eq!(detect(Some("24bit"), Some("xterm")), ColorDepth::TrueColor);
}
#[test]
fn detect_truecolor_case_insensitive() {
assert_eq!(detect(Some("TRUECOLOR"), None), ColorDepth::TrueColor);
assert_eq!(detect(Some("24BIT"), Some("dumb")), ColorDepth::TrueColor);
}
#[test]
fn detect_ansi256_via_term() {
assert_eq!(detect(None, Some("xterm-256color")), ColorDepth::Ansi256);
assert_eq!(
detect(Some(""), Some("screen-256color")),
ColorDepth::Ansi256
);
}
#[test]
fn detect_ansi16_on_dumb_term() {
assert_eq!(detect(None, Some("dumb")), ColorDepth::Ansi16);
}
#[test]
fn detect_ansi16_on_none() {
assert_eq!(detect(None, None), ColorDepth::Ansi16);
}
#[test]
fn detect_ansi16_on_plain_xterm() {
assert_eq!(detect(None, Some("xterm")), ColorDepth::Ansi16);
}
#[test]
fn theme_truecolor_primary_is_rgb() {
let t = theme(ColorDepth::TrueColor);
assert_eq!(t.primary, Color::Rgb(235, 160, 110));
}
#[test]
fn theme_ansi256_primary_is_indexed() {
let t = theme(ColorDepth::Ansi256);
assert_eq!(t.primary, Color::Indexed(216));
}
#[test]
fn theme_ansi16_primary_is_yellow() {
let t = theme(ColorDepth::Ansi16);
assert_eq!(t.primary, Color::Yellow);
}
#[test]
fn theme_all_depths_produce_theme() {
let _ = theme(ColorDepth::TrueColor);
let _ = theme(ColorDepth::Ansi256);
let _ = theme(ColorDepth::Ansi16);
}
#[test]
fn selection_style_bg_is_primary() {
let t = theme(ColorDepth::TrueColor);
let style = t.selection_style();
assert_eq!(style.bg, Some(t.primary));
}
#[test]
fn selection_style_is_bold() {
let t = theme(ColorDepth::Ansi16);
let style = t.selection_style();
assert!(style.add_modifier.contains(Modifier::BOLD));
}
fn dummy_area() -> Rect {
Rect::new(0, 0, 100, 40)
}
#[test]
fn centered_rect_within_area_standard() {
let t = theme(ColorDepth::Ansi16);
let area = dummy_area();
let r = t.centered_rect(60, 50, area);
assert!(r.x >= area.x);
assert!(r.y >= area.y);
assert!(r.x + r.width <= area.x + area.width);
assert!(r.y + r.height <= area.y + area.height);
}
#[test]
fn centered_rect_100_percent_covers_area() {
let t = theme(ColorDepth::Ansi16);
let area = dummy_area();
let r = t.centered_rect(100, 100, area);
assert!(r.x + r.width <= area.x + area.width);
assert!(r.y + r.height <= area.y + area.height);
}
#[test]
fn centered_rect_small_area() {
let t = theme(ColorDepth::Ansi16);
let area = Rect::new(5, 3, 20, 10);
let r = t.centered_rect(80, 80, area);
assert!(r.x >= area.x);
assert!(r.y >= area.y);
assert!(r.x + r.width <= area.x + area.width);
assert!(r.y + r.height <= area.y + area.height);
}
#[test]
fn figlet_banner_wide_returns_multi_line() {
let t = theme(ColorDepth::TrueColor);
let lines = t.figlet_banner(80);
assert!(lines.len() > 1, "width=80 deve retornar banner multi-linha");
}
#[test]
fn figlet_banner_narrow_returns_single_line() {
let t = theme(ColorDepth::TrueColor);
let lines = t.figlet_banner(30);
assert_eq!(
lines.len(),
1,
"width=30 deve retornar linha compacta única"
);
}
#[test]
fn figlet_banner_wide_is_not_empty() {
let t = theme(ColorDepth::Ansi256);
let lines = t.figlet_banner(80);
assert!(!lines.is_empty());
}
#[test]
fn figlet_banner_narrow_is_not_empty() {
let t = theme(ColorDepth::Ansi16);
let lines = t.figlet_banner(30);
assert!(!lines.is_empty());
}
#[test]
fn figlet_banner_boundary_48_is_wide() {
let t = theme(ColorDepth::TrueColor);
let lines = t.figlet_banner(48);
assert!(lines.len() > 1);
}
#[test]
fn figlet_banner_boundary_47_is_compact() {
let t = theme(ColorDepth::TrueColor);
let lines = t.figlet_banner(47);
assert_eq!(lines.len(), 1);
}
#[test]
fn overlay_dim_smoke_test() {
let backend = TestBackend::new(40, 10);
let mut terminal = Terminal::new(backend).unwrap();
let t = theme(ColorDepth::TrueColor);
terminal
.draw(|f| {
let area = Rect::new(0, 0, 20, 5);
t.overlay_dim(f, area);
})
.unwrap();
}
#[test]
fn section_header_contains_label() {
let t = theme(ColorDepth::TrueColor);
let line = t.section_header("Etapas");
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Etapas"));
}
#[test]
fn label_value_contains_both() {
let t = theme(ColorDepth::Ansi256);
let line = t.label_value("Arquivo:", "03-techspec.md");
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("Arquivo:"));
assert!(text.contains("03-techspec.md"));
}
#[test]
fn footer_line_contains_hint_and_route() {
let t = theme(ColorDepth::Ansi16);
let line = t.footer_line("↑↓ navegar", "/techspec");
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("↑↓ navegar"));
assert!(text.contains("/techspec"));
}
#[test]
fn gradient_retorna_quantidade_correta_de_cores() {
let cores = gradient(&[Color::Rgb(0, 0, 0), Color::Rgb(255, 255, 255)], 8);
assert_eq!(
cores.len(),
8,
"gradient deve retornar exatamente `steps` cores"
);
}
#[test]
fn gradient_single_step_retorna_um_elemento() {
let cores = gradient(&[Color::Rgb(100, 100, 100), Color::Rgb(200, 200, 200)], 1);
assert_eq!(cores.len(), 1);
}
#[test]
fn gradient_com_color_indexed_colapsa_para_solido() {
let stop = Color::Indexed(216);
let cores = gradient(&[stop, Color::Indexed(220)], 5);
assert_eq!(
cores.len(),
5,
"deve retornar `steps.max(1)` cores mesmo no fallback"
);
assert!(cores.iter().all(|&c| c == stop));
}
#[test]
fn gradient_com_stops_insuficientes_colapsa_para_solido() {
let stop = Color::Rgb(10, 20, 30);
let cores = gradient(&[stop], 4);
assert_eq!(cores.len(), 4);
assert!(cores.iter().all(|&c| c == stop));
}
#[test]
fn gradient_com_steps_zero_retorna_um_elemento() {
let stop = Color::Rgb(50, 60, 70);
let cores = gradient(&[stop, Color::Rgb(100, 120, 140)], 0);
assert_eq!(cores.len(), 1);
}
#[test]
fn gradient_interpola_extremos_corretamente() {
let inicio = Color::Rgb(0, 0, 0);
let fim = Color::Rgb(100, 200, 50);
let cores = gradient(&[inicio, fim], 2);
assert_eq!(cores[0], inicio);
assert_eq!(cores[1], fim);
}
#[test]
fn gradient_tres_stops_produz_steps_corretos() {
let cores = gradient(
&[
Color::Rgb(0, 0, 0),
Color::Rgb(128, 128, 128),
Color::Rgb(255, 255, 255),
],
6,
);
assert_eq!(cores.len(), 6);
assert_eq!(cores[0], Color::Rgb(0, 0, 0));
assert_eq!(cores[5], Color::Rgb(255, 255, 255));
}
}