use crate::cli::Palette;
use crate::overlay::input::{KeyHint, OverlayInputHandler};
use crate::palette_manager;
use crate::render::palette::{
interpolate_gradient, oklch_to_rgb, oklch_to_srgb, srgb_to_oklch, GradientStop, OklchColor,
RgbColor,
};
use crate::render::panel::{
footer_hints, Padding, PanelBuilder, RenderedOverlay, RichCell, TextAlignment,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub const PALETTE_COLOR_COUNT: usize = 11;
pub const KEYBIND_LABEL_GAP: usize = 3;
const INNER_W: usize = 52;
const CONTENT_OFFSET: usize = 2;
const TRACK_LEN: usize = 38;
const MAX_CHROMA: f32 = 0.4;
mod rows {
pub const STOP_SELECTOR: usize = 3;
pub const LIGHTNESS_SLIDER: usize = 9;
pub const CHROMA_SLIDER: usize = 12;
pub const HUE_SLIDER: usize = 15;
pub const HINT_ARROWS: usize = 18;
pub const HINT_ADJUST: usize = 19;
pub const HINT_TAB: usize = 20;
pub const GRADIENT_STRIP: usize = 27;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorComponent {
Lightness,
Chroma,
Hue,
}
impl EditorComponent {
pub fn next(self) -> Self {
match self {
Self::Lightness => Self::Chroma,
Self::Chroma => Self::Hue,
Self::Hue => Self::Lightness,
}
}
pub fn prev(self) -> Self {
match self {
Self::Lightness => Self::Hue,
Self::Chroma => Self::Lightness,
Self::Hue => Self::Chroma,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorMode {
Editing,
SaveDialog,
LoadDialog,
}
#[derive(Debug, Clone)]
pub struct PaletteEditorState {
pub mode: EditorMode,
pub selected_color_index: usize,
pub selected_component: EditorComponent,
pub colors: [RgbColor; PALETTE_COLOR_COUNT],
pub original_colors: [RgbColor; PALETTE_COLOR_COUNT],
pub base_palette_name: String,
pub is_modified: bool,
pub save_name_input: tui_input::Input,
pub saved_palette_index: usize,
pub saved_palettes_list: Vec<crate::palette_manager::SavedPalette>,
pub stored_hues: [f32; PALETTE_COLOR_COUNT],
}
impl PaletteEditorState {
pub fn new(palette: &Palette) -> Self {
let colors = get_palette_colors(palette);
let base_palette_name = palette.name().to_string();
let mut stored_hues = [0.0f32; PALETTE_COLOR_COUNT];
for (i, &color) in colors.iter().enumerate() {
let oklch = srgb_to_oklch(color);
stored_hues[i] = if oklch.h.is_nan() { 0.0 } else { oklch.h };
}
Self {
mode: EditorMode::Editing,
selected_color_index: PALETTE_COLOR_COUNT,
selected_component: EditorComponent::Lightness,
colors,
original_colors: colors,
base_palette_name,
is_modified: false,
save_name_input: tui_input::Input::default(),
saved_palette_index: 0,
saved_palettes_list: Vec::new(),
stored_hues,
}
}
pub fn is_all_selected(&self) -> bool {
self.selected_color_index == PALETTE_COLOR_COUNT
}
fn average_oklch(&self) -> OklchColor {
use crate::render::palette::OKLCH_EPSILON;
let mut sin_sum = 0.0f32;
let mut cos_sum = 0.0f32;
let mut l_sum = 0.0f32;
let mut c_sum = 0.0f32;
let mut hue_count = 0;
for (i, &color) in self.colors.iter().enumerate() {
let oklch = srgb_to_oklch(color);
l_sum += oklch.l;
c_sum += oklch.c;
let hue = if oklch.c >= OKLCH_EPSILON {
oklch.h
} else {
self.stored_hues[i]
};
if hue.is_finite() {
let h_rad = hue.to_radians();
sin_sum += h_rad.sin();
cos_sum += h_rad.cos();
hue_count += 1;
}
}
let n = PALETTE_COLOR_COUNT as f32;
let avg_h = if hue_count > 0 {
let avg = sin_sum.atan2(cos_sum).to_degrees();
if avg < 0.0 {
avg + 360.0
} else {
avg
}
} else {
0.0 };
OklchColor {
l: l_sum / n,
c: c_sum / n,
h: avg_h,
}
}
pub fn current_oklch(&self) -> OklchColor {
use crate::render::palette::OKLCH_EPSILON;
if self.is_all_selected() {
self.average_oklch()
} else {
let idx = self.selected_color_index;
let mut oklch = srgb_to_oklch(self.colors[idx]);
if oklch.c < OKLCH_EPSILON || oklch.h.is_nan() {
oklch.h = self.stored_hues[idx];
}
oklch
}
}
pub fn set_current_color(&mut self, rgb: RgbColor) {
if self.selected_color_index < PALETTE_COLOR_COUNT {
self.colors[self.selected_color_index] = rgb;
self.is_modified = true;
}
}
fn adjust_oklch<F: Fn(&mut OklchColor)>(&mut self, f: F) {
use crate::render::palette::OKLCH_EPSILON;
if self.is_all_selected() {
for i in 0..PALETTE_COLOR_COUNT {
let mut oklch = srgb_to_oklch(self.colors[i]);
let old_c = oklch.c;
f(&mut oklch);
if old_c < OKLCH_EPSILON && oklch.c >= OKLCH_EPSILON {
oklch.h = self.stored_hues[i];
}
self.colors[i] = oklch_to_rgb(oklch);
if oklch.c >= OKLCH_EPSILON {
self.stored_hues[i] = oklch.h;
}
}
self.is_modified = true;
} else {
let idx = self.selected_color_index;
let mut oklch = srgb_to_oklch(self.colors[idx]);
let old_c = oklch.c;
f(&mut oklch);
if old_c < OKLCH_EPSILON && oklch.c >= OKLCH_EPSILON {
oklch.h = self.stored_hues[idx];
}
self.set_current_color(oklch_to_rgb(oklch));
if oklch.c >= OKLCH_EPSILON {
self.stored_hues[idx] = oklch.h;
}
}
}
pub fn adjust_hue(&mut self, delta: f32) {
self.adjust_oklch_with_hue_override(delta);
}
fn adjust_oklch_with_hue_override(&mut self, delta: f32) {
use crate::render::palette::OKLCH_EPSILON;
if self.is_all_selected() {
for i in 0..PALETTE_COLOR_COUNT {
let mut oklch = srgb_to_oklch(self.colors[i]);
let base_hue = if oklch.h.is_nan() || oklch.c < OKLCH_EPSILON {
self.stored_hues[i]
} else {
oklch.h
};
let new_hue = (base_hue + delta + 360.0) % 360.0;
oklch.h = new_hue;
self.colors[i] = oklch_to_rgb(oklch);
self.stored_hues[i] = new_hue;
}
self.is_modified = true;
} else {
let idx = self.selected_color_index;
let mut oklch = srgb_to_oklch(self.colors[idx]);
let base_hue = if oklch.h.is_nan() || oklch.c < OKLCH_EPSILON {
self.stored_hues[idx]
} else {
oklch.h
};
let new_hue = (base_hue + delta + 360.0) % 360.0;
oklch.h = new_hue;
self.colors[idx] = oklch_to_rgb(oklch);
self.stored_hues[idx] = new_hue;
self.is_modified = true;
}
}
pub fn adjust_chroma(&mut self, delta: f32) {
self.adjust_oklch(|oklch| oklch.c = (oklch.c + delta).clamp(0.0, MAX_CHROMA));
}
pub fn adjust_lightness(&mut self, delta: f32) {
self.adjust_oklch(|oklch| oklch.l = (oklch.l + delta).clamp(0.0, 1.0));
}
pub fn adjust_selected_component(&mut self, delta: f32) {
match self.selected_component {
EditorComponent::Lightness => self.adjust_lightness(delta),
EditorComponent::Chroma => self.adjust_chroma(delta),
EditorComponent::Hue => self.adjust_hue(delta * 360.0),
}
}
pub fn reset_to_original(&mut self) {
self.colors = self.original_colors;
for (i, &color) in self.colors.iter().enumerate() {
let oklch = srgb_to_oklch(color);
self.stored_hues[i] = if oklch.h.is_nan() { 0.0 } else { oklch.h };
}
self.is_modified = false;
}
pub fn select_next_color(&mut self) {
self.selected_color_index = (self.selected_color_index + 1) % (PALETTE_COLOR_COUNT + 1);
}
pub fn select_prev_color(&mut self) {
self.selected_color_index = if self.selected_color_index == 0 {
PALETTE_COLOR_COUNT } else {
self.selected_color_index - 1
};
}
pub fn display_name(&self) -> String {
if self.is_modified {
format!("{} (modified)", self.base_palette_name)
} else {
self.base_palette_name.clone()
}
}
pub fn to_palette(&self) -> Palette {
Palette::Custom(self.colors.to_vec())
}
}
impl OverlayInputHandler for PaletteEditorState {
fn handle_key(&mut self, key: &KeyEvent) -> bool {
match key.code {
KeyCode::Esc => {
match self.mode {
EditorMode::SaveDialog => {
self.save_name_input.reset();
self.mode = EditorMode::Editing;
}
EditorMode::LoadDialog => {
self.mode = EditorMode::Editing;
}
EditorMode::Editing => {
return false; }
}
true
}
KeyCode::Left => {
if matches!(self.mode, EditorMode::SaveDialog) {
self.save_name_input
.handle(tui_input::InputRequest::GoToPrevChar);
} else {
self.select_prev_color();
}
true
}
KeyCode::Right => {
if matches!(self.mode, EditorMode::SaveDialog) {
self.save_name_input
.handle(tui_input::InputRequest::GoToNextChar);
} else {
self.select_next_color();
}
true
}
KeyCode::Up => {
if matches!(self.mode, EditorMode::LoadDialog) {
if self.saved_palette_index > 0 {
self.saved_palette_index -= 1;
}
} else {
match self.selected_component {
EditorComponent::Lightness => self.adjust_lightness(0.02),
EditorComponent::Chroma => self.adjust_chroma(0.01),
EditorComponent::Hue => self.adjust_hue(5.0),
}
}
true
}
KeyCode::Down => {
if matches!(self.mode, EditorMode::LoadDialog) {
if self.saved_palette_index + 1 < self.saved_palettes_list.len() {
self.saved_palette_index += 1;
}
} else {
match self.selected_component {
EditorComponent::Lightness => self.adjust_lightness(-0.02),
EditorComponent::Chroma => self.adjust_chroma(-0.01),
EditorComponent::Hue => self.adjust_hue(-5.0),
}
}
true
}
KeyCode::Tab => {
self.selected_component = self.selected_component.next();
true
}
KeyCode::Char('h') | KeyCode::Char('H') => {
self.selected_component = EditorComponent::Hue;
true
}
KeyCode::Char('c') | KeyCode::Char('C') => {
self.selected_component = EditorComponent::Chroma;
true
}
KeyCode::Char('l') => {
self.selected_component = EditorComponent::Lightness;
true
}
KeyCode::Char('L') => {
if let Ok(palettes) = palette_manager::list_palettes() {
self.saved_palettes_list = palettes;
self.saved_palette_index = 0;
}
self.mode = EditorMode::LoadDialog;
true
}
KeyCode::Char('r') | KeyCode::Char('R') => {
self.reset_to_original();
true
}
KeyCode::Char('s') | KeyCode::Char('S') => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
self.mode = EditorMode::SaveDialog;
}
true
}
KeyCode::Enter => {
match self.mode {
EditorMode::Editing => {
return false; }
EditorMode::SaveDialog => {
if !self.save_name_input.value().is_empty() {
let palette = palette_manager::SavedPalette::new(
self.save_name_input.value().to_string(),
self.colors,
);
if let Err(e) = palette_manager::save_palette(palette) {
eprintln!("Failed to save palette: {}", e);
}
}
self.save_name_input.reset();
self.mode = EditorMode::Editing;
}
EditorMode::LoadDialog => {
if let Some(palette) =
self.saved_palettes_list.get(self.saved_palette_index)
{
self.colors = palette.to_rgb_colors();
self.original_colors = palette.to_rgb_colors();
self.base_palette_name = palette.name.clone();
self.is_modified = false;
}
self.mode = EditorMode::Editing;
}
}
true
}
KeyCode::Char(c) => {
if matches!(self.mode, EditorMode::SaveDialog)
&& !key.modifiers.contains(KeyModifiers::CONTROL)
{
if self.save_name_input.value().chars().count() < 24 {
self.save_name_input
.handle(tui_input::InputRequest::InsertChar(c));
}
true
} else {
false
}
}
KeyCode::Backspace => {
if matches!(self.mode, EditorMode::SaveDialog) {
self.save_name_input
.handle(tui_input::InputRequest::DeletePrevChar);
true
} else {
false
}
}
KeyCode::Delete => {
if matches!(self.mode, EditorMode::SaveDialog) {
self.save_name_input
.handle(tui_input::InputRequest::DeleteNextChar);
true
} else {
false
}
}
KeyCode::Home => {
if matches!(self.mode, EditorMode::SaveDialog) {
self.save_name_input
.handle(tui_input::InputRequest::GoToStart);
true
} else {
false
}
}
KeyCode::End => {
if matches!(self.mode, EditorMode::SaveDialog) {
self.save_name_input
.handle(tui_input::InputRequest::GoToEnd);
true
} else {
false
}
}
_ => false,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint::new("←/→", "Select color"),
KeyHint::new("↑/↓", "Adjust value"),
KeyHint::new("Tab", "Cycle L/C/H"),
KeyHint::new("Enter", "Apply/Confirm"),
KeyHint::new("Ctrl+S", "Save palette"),
KeyHint::new("Esc", "Discard/Cancel"),
KeyHint::new("r", "Reset"),
KeyHint::new("L", "Load palette"),
]
}
}
fn get_palette_colors(palette: &Palette) -> [RgbColor; PALETTE_COLOR_COUNT] {
use crate::render::palette::get_gradient_stops;
let stops = get_gradient_stops(palette);
let mut colors = [RgbColor { r: 0, g: 0, b: 0 }; PALETTE_COLOR_COUNT];
for (i, color) in colors.iter_mut().enumerate() {
let t = i as f32 / (PALETTE_COLOR_COUNT - 1) as f32;
*color = interpolate_gradient(&stops, t);
}
colors
}
fn build_swatches_str(selected: usize) -> String {
let mut s = String::with_capacity(48);
for i in 0..PALETTE_COLOR_COUNT {
if i == selected {
s.push_str(" ◉ ");
} else {
s.push_str(" ○ ");
}
}
if selected == PALETTE_COLOR_COUNT {
s.push_str(" ◉ ");
} else {
s.push_str(" ○ ");
}
s
}
fn build_swatch_labels_str() -> String {
let mut s = String::with_capacity(48);
for i in 1..=PALETTE_COLOR_COUNT {
if i < 10 {
s.push_str(&format!(" {i} "));
} else {
s.push_str(&format!("{i} "));
}
}
s.push_str("ALL ");
s
}
fn build_slider_label(is_active: bool, comp: char, value_str: &str) -> String {
if is_active {
format!("◀ {} {} ▶", comp, value_str)
} else {
format!(" {} {} ", comp, value_str)
}
}
fn build_slider_bar(frac: f32) -> String {
let cursor_pos =
((frac.clamp(0.0, 1.0) * (TRACK_LEN - 1) as f32).round() as usize).min(TRACK_LEN - 1);
(0..TRACK_LEN)
.map(|i| if i == cursor_pos { '◆' } else { '█' })
.collect()
}
fn build_editor_rich_lines(
state: &PaletteEditorState,
lines: &[String],
oklch: OklchColor,
text_primary: RgbColor,
accent: RgbColor,
panel_bg: RgbColor,
) -> Vec<Vec<RichCell>> {
let mut rich: Vec<Vec<RichCell>> = lines
.iter()
.map(|l| l.chars().map(|c| (c, None, None)).collect())
.collect();
let stops: Vec<GradientStop> = state
.colors
.iter()
.enumerate()
.map(|(i, &color)| GradientStop {
position: i as f32 / (PALETTE_COLOR_COUNT - 1) as f32,
color,
})
.collect();
if rich.len() > rows::STOP_SELECTOR {
for i in 0..PALETTE_COLOR_COUNT {
let col = CONTENT_OFFSET + 2 + i * 4 + 1;
if col < rich[rows::STOP_SELECTOR].len() {
rich[rows::STOP_SELECTOR][col].1 = Some(state.colors[i]);
}
}
let all_col = CONTENT_OFFSET + 2 + PALETTE_COLOR_COUNT * 4 + 1;
if all_col < rich[rows::STOP_SELECTOR].len() {
let all_color = if state.is_all_selected() {
RgbColor {
r: 255,
g: 255,
b: 255,
}
} else {
RgbColor {
r: 140,
g: 140,
b: 140,
}
};
rich[rows::STOP_SELECTOR][all_col].1 = Some(all_color);
}
}
if rich.len() > rows::HINT_ARROWS {
for (c, fg, _) in rich[rows::HINT_ARROWS].iter_mut() {
if *c == '←' || *c == '→' {
*fg = Some(accent);
}
}
}
if rich.len() > rows::HINT_ADJUST {
for (c, fg, _) in rich[rows::HINT_ADJUST].iter_mut() {
if *c == '↑' || *c == '↓' {
*fg = Some(accent);
}
}
}
if rich.len() > rows::HINT_TAB {
let tab_line = &mut rich[rows::HINT_TAB];
let tab_pos = (0..tab_line.len().saturating_sub(2)).find(|&i| {
tab_line[i].0 == 'T' && tab_line[i + 1].0 == 'a' && tab_line[i + 2].0 == 'b'
});
if let Some(pos) = tab_pos {
for col in pos..(pos + 3).min(tab_line.len()) {
tab_line[col].1 = Some(accent);
}
}
for (c, fg, _) in tab_line.iter_mut() {
if *c == '→' {
*fg = Some(accent);
}
}
}
let hint_keys = ["r", "Enter", "Ctrl+S", "Esc"];
for (i, &line_idx) in [21usize, 22, 23, 24].iter().enumerate() {
if line_idx >= rich.len() {
continue;
}
let key_chars: Vec<char> = hint_keys[i].chars().collect();
let key_len = key_chars.len();
let line = &mut rich[line_idx];
let pos = (0..line.len().saturating_sub(key_len.saturating_sub(1))).find(|&j| {
key_chars
.iter()
.enumerate()
.all(|(k, &c)| line.get(j + k).map(|(ch, _, _)| *ch == c).unwrap_or(false))
});
if let Some(pos) = pos {
for col in pos..pos + key_len {
if col < line.len() {
line[col].1 = Some(accent);
}
}
}
}
let l_cursor = (oklch.l * (TRACK_LEN - 1) as f32).round() as usize;
if rich.len() > rows::LIGHTNESS_SLIDER {
let l_start = CONTENT_OFFSET + 7; for i in 0..TRACK_LEN {
let col = l_start + i;
if col < rich[rows::LIGHTNESS_SLIDER].len() {
let l = i as f32 / (TRACK_LEN - 1) as f32;
let color = oklch_to_srgb(l, oklch.c.min(0.15), oklch.h);
if i == l_cursor {
rich[rows::LIGHTNESS_SLIDER][col] = ('▓', Some(text_primary), Some(color));
} else {
rich[rows::LIGHTNESS_SLIDER][col] = ('░', Some(color), Some(panel_bg));
}
}
}
}
let c_frac = (oklch.c / MAX_CHROMA).clamp(0.0, 1.0);
let c_cursor = (c_frac * (TRACK_LEN - 1) as f32).round() as usize;
if rich.len() > rows::CHROMA_SLIDER {
let c_start = CONTENT_OFFSET + 7;
for i in 0..TRACK_LEN {
let col = c_start + i;
if col < rich[rows::CHROMA_SLIDER].len() {
let c = (i as f32 / (TRACK_LEN - 1) as f32) * MAX_CHROMA;
let color = oklch_to_srgb(oklch.l.max(0.4), c, oklch.h);
if i == c_cursor {
rich[rows::CHROMA_SLIDER][col] = ('▓', Some(text_primary), Some(color));
} else {
rich[rows::CHROMA_SLIDER][col] = ('░', Some(color), Some(panel_bg));
}
}
}
}
let h_cursor = ((oklch.h / 360.0) * (TRACK_LEN - 1) as f32).round() as usize;
if rich.len() > rows::HUE_SLIDER {
let h_start = CONTENT_OFFSET + 7;
for i in 0..TRACK_LEN {
let col = h_start + i;
if col < rich[rows::HUE_SLIDER].len() {
let h = i as f32 / (TRACK_LEN - 1) as f32 * 360.0;
let color = oklch_to_srgb(oklch.l.max(0.5), oklch.c.max(0.08), h);
if i == h_cursor {
rich[rows::HUE_SLIDER][col] = ('▓', Some(text_primary), Some(color));
} else {
rich[rows::HUE_SLIDER][col] = ('░', Some(color), Some(panel_bg));
}
}
}
}
if rich.len() > rows::GRADIENT_STRIP {
for i in 0..INNER_W {
let col = CONTENT_OFFSET + i;
if col < rich[rows::GRADIENT_STRIP].len() {
let t = i as f32 / (INNER_W - 1).max(1) as f32;
let t_next = (t + 1.5 / INNER_W as f32).min(1.0);
let fg_color = interpolate_gradient(&stops, t);
let bg_color = interpolate_gradient(&stops, t_next);
rich[rows::GRADIENT_STRIP][col] = ('▄', Some(fg_color), Some(bg_color));
}
}
}
rich
}
pub struct PaletteEditorOverlay;
impl PaletteEditorOverlay {
pub const WIDTH: usize = INNER_W + 4;
pub const HEIGHT: usize = 29;
pub fn build_overlay(
state: &PaletteEditorState,
panel_style: &crate::render::theme::PanelStyle,
accent: RgbColor,
) -> RenderedOverlay {
match state.mode {
EditorMode::Editing => Self::build_editing_overlay(state, panel_style, accent),
EditorMode::SaveDialog => Self::build_save_dialog_overlay(state, panel_style),
EditorMode::LoadDialog => {
Self::build_load_dialog_overlay(state, &state.saved_palettes_list)
}
}
}
fn build_editing_overlay(
state: &PaletteEditorState,
panel_style: &crate::render::theme::PanelStyle,
accent: RgbColor,
) -> RenderedOverlay {
let oklch = state.current_oklch();
let gradient_str = "▄".repeat(INNER_W);
let swatches_str = build_swatches_str(state.selected_color_index);
let labels_str = build_swatch_labels_str();
let l_active = state.selected_component == EditorComponent::Lightness;
let c_active = state.selected_component == EditorComponent::Chroma;
let h_active = state.selected_component == EditorComponent::Hue;
let l_label = build_slider_label(l_active, 'L', &format!("{:.3}", oklch.l));
let l_bar = build_slider_bar(oklch.l);
let c_label = build_slider_label(c_active, 'C', &format!("{:.3}", oklch.c));
let c_bar = build_slider_bar((oklch.c / MAX_CHROMA).clamp(0.0, 1.0));
let h_label = build_slider_label(h_active, 'H', &format!("{:.1}°", oklch.h));
let h_bar = build_slider_bar(oklch.h / 360.0);
let first_hint_line = "← select →";
let second_hint_line = "↑ adjust ↓";
let tab_hint_line = "Tab L → C → H";
let hints = [
("r", "reset"),
("Enter", "apply"),
("Ctrl+S", "save palette"),
("Esc", "discard"),
];
let key_label_lines: Vec<String> = hints
.iter()
.map(|(key, label)| format!("{key} {label}"))
.collect();
let mut overlay = PanelBuilder::new(INNER_W, None)
.with_padding(Padding::COMPACT)
.with_title("PALETTE EDITOR")
.with_title_box()
.add_empty() .add_empty() .add_single(swatches_str, TextAlignment::Center) .add_single(labels_str, TextAlignment::Center) .add_empty() .add_separator() .add_empty() .add_single(l_label, TextAlignment::Center) .add_single(l_bar, TextAlignment::Center) .add_empty() .add_single(c_label, TextAlignment::Center) .add_single(c_bar, TextAlignment::Center) .add_empty() .add_single(h_label, TextAlignment::Center) .add_single(h_bar, TextAlignment::Center) .add_empty() .add_separator() .add_single(first_hint_line.to_string(), TextAlignment::Center) .add_single(second_hint_line.to_string(), TextAlignment::Center) .add_single(tab_hint_line.to_string(), TextAlignment::Center) .add_single(key_label_lines[0].clone(), TextAlignment::Center) .add_single(key_label_lines[1].clone(), TextAlignment::Center) .add_single(key_label_lines[2].clone(), TextAlignment::Center) .add_single(key_label_lines[3].clone(), TextAlignment::Center) .add_empty() .add_separator() .add_single(gradient_str, TextAlignment::Left) .build_overlay();
overlay.rich_lines = Some(build_editor_rich_lines(
state,
&overlay.lines,
oklch,
panel_style.text_primary,
accent,
panel_style.bg_color,
));
overlay
}
fn build_save_dialog_overlay(
state: &PaletteEditorState,
panel_style: &crate::render::theme::PanelStyle,
) -> RenderedOverlay {
const LABEL: &str = "Name: ";
const FIELD_WIDTH: usize = 25;
let name_str = format!("{LABEL}{:<FIELD_WIDTH$}", state.save_name_input.value());
let mut overlay = PanelBuilder::new(38, None)
.with_padding(Padding::COMPACT)
.with_title("SAVE PALETTE")
.with_title_box()
.add_empty()
.add_single(name_str, TextAlignment::Left)
.add_empty()
.add_single(
format!(" {}", footer_hints(&[("↵", "save"), ("esc", "cancel")])),
TextAlignment::Left,
)
.build_overlay();
crate::render::ratatui_adapter::stamp_caret(
&mut overlay,
LABEL,
FIELD_WIDTH,
state.save_name_input.cursor(),
panel_style,
);
overlay
}
fn build_load_dialog_overlay(
state: &PaletteEditorState,
saved_palettes: &[crate::palette_manager::SavedPalette],
) -> RenderedOverlay {
let mut builder = PanelBuilder::new(38, None)
.with_padding(Padding::COMPACT)
.with_title("LOAD PALETTE")
.with_title_box()
.add_empty();
if saved_palettes.is_empty() {
builder =
builder.add_single(" No saved palettes yet".to_string(), TextAlignment::Left);
} else {
const MAX_VISIBLE: usize = 8;
let total = saved_palettes.len();
let start = crate::render::ratatui_adapter::scroll_start(
total,
state.saved_palette_index,
MAX_VISIBLE,
);
let end = (start + MAX_VISIBLE).min(total);
for (i, palette) in saved_palettes.iter().enumerate().take(end).skip(start) {
let marker = if i == state.saved_palette_index {
"›"
} else {
" "
};
let truncated = if palette.name.len() > 28 {
&palette.name[..28]
} else {
&palette.name
};
let entry = format!(" {} {:2}. {}", marker, i + 1, truncated);
builder = builder.add_single(entry, TextAlignment::Left);
}
}
let hint = if saved_palettes.is_empty() {
footer_hints(&[("esc", "cancel")])
} else {
footer_hints(&[("↑↓", "navigate"), ("↵", "load"), ("esc", "cancel")])
};
builder
.add_empty()
.add_single(format!(" {hint}"), TextAlignment::Left)
.build_overlay()
}
pub fn calculate_position(term_width: usize, term_height: usize) -> (usize, usize) {
let x = (term_width.saturating_sub(Self::WIDTH)) / 2;
let y = (term_height.saturating_sub(Self::HEIGHT + 1)) / 2 + 1;
(x, y)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adjust_hue_updates_stored_hues() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.selected_color_index = 3;
let initial_stored_hue = state.stored_hues[3];
state.adjust_hue(45.0);
assert!(
(state.stored_hues[3] - (initial_stored_hue + 45.0) % 360.0).abs() < 0.1,
"Stored hue should be updated when adjusting hue"
);
let current_hue = state.current_oklch().h;
let hue_diff = (current_hue - state.stored_hues[3]).abs();
assert!(
hue_diff < 5.0,
"Current hue {} should be close to stored hue {} (diff={})",
current_hue,
state.stored_hues[3],
hue_diff
);
}
#[test]
fn test_color_navigation() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.select_next_color();
assert_eq!(state.selected_color_index, 0);
state.select_prev_color();
assert_eq!(state.selected_color_index, PALETTE_COLOR_COUNT);
state.select_prev_color();
assert_eq!(state.selected_color_index, 10);
}
#[test]
fn test_color_navigation_wraps_forward_through_all() {
let mut state = PaletteEditorState::new(&Palette::Forest);
for _ in 0..(PALETTE_COLOR_COUNT + 1) {
state.select_next_color();
}
assert_eq!(state.selected_color_index, PALETTE_COLOR_COUNT);
state.select_next_color();
assert_eq!(state.selected_color_index, 0);
}
#[test]
fn test_is_all_selected() {
let state = PaletteEditorState::new(&Palette::Forest);
assert!(state.is_all_selected());
let mut state2 = PaletteEditorState::new(&Palette::Forest);
state2.selected_color_index = 0;
assert!(!state2.is_all_selected());
}
#[test]
fn test_all_selected_adjust_applies_to_all_stops() {
let mut state = PaletteEditorState::new(&Palette::Forest);
let original_colors = state.colors;
state.selected_color_index = PALETTE_COLOR_COUNT; state.adjust_hue(90.0);
assert!(state.is_modified);
for (i, _) in original_colors.iter().enumerate().take(PALETTE_COLOR_COUNT) {
assert_ne!(
state.colors[i], original_colors[i],
"Stop {} should have changed after ALL hue adjust",
i
);
}
}
#[test]
fn test_average_oklch_calculation() {
let mut state = PaletteEditorState::new(&Palette::Forest);
for color in state.colors.iter_mut() {
*color = RgbColor { r: 255, g: 0, b: 0 };
}
let avg = state.average_oklch();
assert!(avg.c > 0.2, "Average chroma of pure red should be high");
assert!(
avg.l > 0.4,
"Average lightness of pure red should be moderate"
);
}
#[test]
fn test_oklch_adjustment() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.selected_color_index = 0;
state.colors[0] = RgbColor {
r: 100,
g: 150,
b: 80,
};
let original_hue = state.current_oklch().h;
state.adjust_hue(45.0);
assert!(state.is_modified);
let new_hue = state.current_oklch().h;
let actual_shift = ((new_hue - original_hue + 540.0) % 360.0) - 180.0;
assert!(
(actual_shift - 45.0).abs() < 10.0,
"hue should shift by ~45°: original={}, new={}, actual_shift={}",
original_hue,
new_hue,
actual_shift
);
}
#[test]
fn test_chroma_clamping() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.selected_color_index = 5;
state.adjust_chroma(-2.0);
assert!(
state.current_oklch().c < 0.02,
"Chroma should clamp near 0, got {}",
state.current_oklch().c
);
state.adjust_chroma(2.0);
assert!(
state.current_oklch().c > 0.15,
"Chroma should be high after large positive adjustment, got {}",
state.current_oklch().c
);
}
#[test]
fn test_reset() {
let mut state = PaletteEditorState::new(&Palette::Forest);
let original = state.colors[0];
state.adjust_hue(45.0);
assert!(state.is_modified);
state.reset_to_original();
assert!(!state.is_modified);
assert_eq!(state.colors[0], original);
}
#[test]
fn test_component_cycle() {
assert_eq!(EditorComponent::Lightness.next(), EditorComponent::Chroma);
assert_eq!(EditorComponent::Chroma.next(), EditorComponent::Hue);
assert_eq!(EditorComponent::Hue.next(), EditorComponent::Lightness);
assert_eq!(EditorComponent::Lightness.prev(), EditorComponent::Hue);
assert_eq!(EditorComponent::Hue.prev(), EditorComponent::Chroma);
assert_eq!(EditorComponent::Chroma.prev(), EditorComponent::Lightness);
}
#[test]
fn test_build_overlay_produces_lines() {
let state = PaletteEditorState::new(&Palette::Forest);
let panel_style = crate::render::theme::GRUVBOX_DARK;
let accent =
crate::render::palette::palette_accent_color(&Palette::Forest, false, false, 0.0, None);
let overlay = PaletteEditorOverlay::build_overlay(&state, &panel_style, accent);
assert!(!overlay.lines.is_empty());
for (i, line) in overlay.lines.iter().enumerate() {
assert_eq!(
line.chars().count(),
PaletteEditorOverlay::WIDTH,
"Line {} has wrong width: {} ('{}')",
i,
line.chars().count(),
line
);
}
}
#[test]
fn test_build_overlay_height() {
let state = PaletteEditorState::new(&Palette::Forest);
let panel_style = crate::render::theme::GRUVBOX_DARK;
let accent =
crate::render::palette::palette_accent_color(&Palette::Forest, false, false, 0.0, None);
let overlay = PaletteEditorOverlay::build_overlay(&state, &panel_style, accent);
assert_eq!(overlay.lines.len(), PaletteEditorOverlay::HEIGHT);
}
#[test]
fn test_swatches_str_width() {
let s = build_swatches_str(0);
assert_eq!(s.chars().count(), 48); }
#[test]
fn test_swatches_str_all_selected_width() {
let s = build_swatches_str(PALETTE_COLOR_COUNT); assert_eq!(s.chars().count(), 48);
}
#[test]
fn test_swatch_labels_str_width() {
let s = build_swatch_labels_str();
assert_eq!(s.chars().count(), 48);
}
fn build_hex_str(rgb: RgbColor) -> String {
format!("#{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
}
#[test]
fn test_hex_str_width() {
let rgb = RgbColor {
r: 255,
g: 128,
b: 0,
};
let s = build_hex_str(rgb);
assert_eq!(s.chars().count(), 7); }
#[test]
fn test_slider_label_width() {
let s = build_slider_label(true, 'H', "180.0°");
assert!(s.chars().count() > 0);
}
#[test]
fn test_slider_bar_width() {
let s = build_slider_bar(0.5);
assert_eq!(s.chars().count(), TRACK_LEN);
}
#[test]
fn test_slider_bar_cursor_position() {
let s = build_slider_bar(0.0);
assert!(s.starts_with('◆'));
let s = build_slider_bar(1.0);
assert!(s.ends_with('◆'));
}
#[test]
fn test_build_overlay_all_selected() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.selected_color_index = PALETTE_COLOR_COUNT; let panel_style = crate::render::theme::GRUVBOX_DARK;
let accent =
crate::render::palette::palette_accent_color(&Palette::Forest, false, false, 0.0, None);
let overlay = PaletteEditorOverlay::build_overlay(&state, &panel_style, accent);
assert_eq!(overlay.lines.len(), PaletteEditorOverlay::HEIGHT);
for (i, line) in overlay.lines.iter().enumerate() {
assert_eq!(
line.chars().count(),
PaletteEditorOverlay::WIDTH,
"Line {} has wrong width in ALL mode",
i
);
}
}
#[test]
fn test_chroma_zero_preserves_hue_single_color() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.selected_color_index = 5;
let initial_hue = state.current_oklch().h;
assert!(
initial_hue > 10.0,
"Initial hue should be non-red for this test, got {}",
initial_hue
);
state.adjust_chroma(-2.0);
assert!(
state.current_oklch().c < 0.02,
"Chroma should be near 0, got {}",
state.current_oklch().c
);
assert!(
(state.stored_hues[5] - initial_hue).abs() < 1.0,
"Stored hue should be preserved when chroma=0, stored={}, initial={}",
state.stored_hues[5],
initial_hue
);
state.adjust_chroma(0.1);
let restored_hue = state.current_oklch().h;
assert!(
restored_hue > 10.0,
"Restored hue should not be red (0°), got {}. Hue was not preserved during chroma=0 transition!",
restored_hue
);
}
#[test]
fn test_chroma_zero_preserves_hue_all_selected() {
let mut state = PaletteEditorState::new(&Palette::Forest);
let _initial_avg_hue = state.current_oklch().h;
state.adjust_chroma(-2.0);
assert!(
state.current_oklch().c < 0.02,
"Average chroma should be near 0"
);
state.adjust_chroma(0.15);
let restored_hue = state.current_oklch().h;
assert!(
restored_hue > 20.0 || restored_hue < 340.0,
"Restored average hue should not be near red (0°), got {}. Stored hues were not preserved!",
restored_hue
);
}
#[test]
fn test_stored_hues_initialized_correctly() {
let state = PaletteEditorState::new(&Palette::Forest);
for (i, &color) in state.colors.iter().enumerate() {
let oklch = srgb_to_oklch(color);
let expected_hue = if oklch.h.is_nan() { 0.0 } else { oklch.h };
assert!(
(state.stored_hues[i] - expected_hue).abs() < 0.1,
"Stored hue {} should match actual hue {} for color {}",
state.stored_hues[i],
expected_hue,
i
);
}
}
#[test]
fn test_reset_restores_stored_hues() {
let mut state = PaletteEditorState::new(&Palette::Forest);
let initial_stored_hues = state.stored_hues;
state.adjust_hue(90.0);
state.reset_to_original();
for (i, &initial_hue) in initial_stored_hues
.iter()
.enumerate()
.take(PALETTE_COLOR_COUNT)
{
assert!(
(state.stored_hues[i] - initial_hue).abs() < 0.1,
"Stored hue {} should be reset to original",
i
);
}
}
#[test]
fn save_dialog_renders_caret() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.mode = EditorMode::SaveDialog;
state.save_name_input = tui_input::Input::new("abc".to_string());
let style = crate::render::theme::PanelStyle::default();
let overlay =
PaletteEditorOverlay::build_overlay(&state, &style, RgbColor::new(255, 128, 0));
let text = overlay.lines.join("\n");
assert!(text.contains("Name: abc"), "field text missing:\n{text}");
let rich = overlay
.rich_lines
.expect("save dialog needs caret rich_lines");
let caret = rich
.iter()
.flatten()
.filter(|c| c.2 == Some(style.accent_active))
.count();
assert_eq!(caret, 1, "expected exactly one caret cell");
}
#[test]
fn load_dialog_keeps_selection_visible() {
let mut state = PaletteEditorState::new(&Palette::Forest);
state.mode = EditorMode::LoadDialog;
state.saved_palettes_list = (0..15)
.map(|i| palette_manager::SavedPalette::new(format!("pal{i:02}"), state.colors))
.collect();
state.saved_palette_index = 14;
let style = crate::render::theme::PanelStyle::default();
let overlay =
PaletteEditorOverlay::build_overlay(&state, &style, RgbColor::new(255, 128, 0));
let text = overlay.lines.join("\n");
assert!(
text.contains("pal14"),
"selected last palette must stay visible (old take(8) bug):\n{text}"
);
}
}