mod cells;
mod interaction;
mod named;
mod render;
pub use named::NAMED_COLORS;
use cells::{mode_cells, nearest, palette_entries};
use interaction::handle;
use render::{box_size, render_box};
use std::{cell::Cell, io};
use ratatui::Frame;
use super::{
color_picker, layout::centered_rect, modal::ModalSignal, overlay::popup,
terminal::Tui,
};
use crate::theme::{Color, Skin};
const GRID_LIGHT_DEFAULT: f32 = 0.5;
const GRID_LIGHT_STEP: f32 = 0.08;
impl Mode {
const ALL: [Mode; 4] =
[Mode::Names, Mode::Grid, Mode::Grays, Mode::Palette];
fn label(self) -> &'static str {
match self {
Mode::Names => "Names",
Mode::Grid => "Grid",
Mode::Grays => "Grays",
Mode::Palette => "Palette",
}
}
fn next(self) -> Mode {
match self {
Mode::Names => Mode::Grid,
Mode::Grid => Mode::Grays,
Mode::Grays => Mode::Palette,
Mode::Palette => Mode::Names,
}
}
fn is_list(self) -> bool {
matches!(self, Mode::Names | Mode::Palette)
}
}
const GRID_COLS: usize = 18;
const GRID_ROWS: usize = 8;
const GRAY_STEPS: usize = 16;
const VISIBLE_ROWS: u16 = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Names,
Grid,
Grays,
Palette,
}
#[derive(Clone)]
struct Swatch {
color: Color,
name: Option<String>,
}
enum Choice {
Pick(Color),
Edit(Color),
}
struct State {
mode: Mode,
cursor: usize,
offset: Cell<usize>,
cells: Vec<Swatch>,
cols: usize,
palette: Vec<(&'static str, Color)>,
grid_light: f32,
filter: String,
filtering: bool,
}
impl State {
fn rebuild(&mut self) {
let (cells, cols) =
mode_cells(self.mode, &self.palette, self.grid_light, &self.filter);
self.cells = cells;
self.cols = cols.max(1);
if self.cursor >= self.cells.len() {
self.cursor = self.cells.len().saturating_sub(1);
}
}
fn focus_color(&self) -> Color {
self.cells
.get(self.cursor)
.map_or(Color::Default, |cell| cell.color)
}
fn switch_mode(&mut self) {
let current = self.focus_color();
self.mode = self.mode.next();
self.filter.clear();
self.filtering = false;
self.rebuild();
self.cursor = nearest(&self.cells, current);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Start {
Swatches,
Picker,
}
enum SwatchExit {
Pick(Color),
Edit(Color),
Cancel,
Quit,
}
#[derive(Clone, Copy)]
struct SwatchMemory {
mode: Mode,
grid_light: f32,
}
impl Default for SwatchMemory {
fn default() -> Self {
Self {
mode: Mode::Names,
grid_light: GRID_LIGHT_DEFAULT,
}
}
}
pub fn swatch_picker(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: Option<Color>,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<Color>> {
color_chooser(tui, skin, title, initial, Start::Swatches, render_bg)
}
pub fn color_chooser(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: Option<Color>,
start: Start,
render_bg: impl Fn(&mut Frame),
) -> io::Result<ModalSignal<Color>> {
let mut view = start;
let mut color = initial;
let mut memory = SwatchMemory::default();
let mut swatch_seen = matches!(start, Start::Swatches);
loop {
match view {
Start::Swatches => {
swatch_seen = true;
let (exit, updated) =
run_swatch(tui, skin, title, color, memory, &render_bg)?;
memory = updated;
match exit {
SwatchExit::Pick(chosen) => {
return Ok(ModalSignal::Value(chosen));
}
SwatchExit::Edit(chosen) => {
color = Some(chosen);
view = Start::Picker;
}
SwatchExit::Cancel => return Ok(ModalSignal::Cancelled),
SwatchExit::Quit => return Ok(ModalSignal::Quit),
}
}
Start::Picker => {
let exit = color_picker::color_picker(
tui, skin, title, color, &render_bg,
)?;
match exit {
color_picker::ColorExit::Done(chosen) => {
return Ok(ModalSignal::Value(chosen));
}
color_picker::ColorExit::Swatches(chosen) => {
color = Some(chosen);
view = Start::Swatches;
}
color_picker::ColorExit::Back(chosen) => {
if swatch_seen {
color = Some(chosen);
view = Start::Swatches;
} else {
return Ok(ModalSignal::Cancelled);
}
}
color_picker::ColorExit::Quit => {
return Ok(ModalSignal::Quit);
}
}
}
}
}
}
fn run_swatch(
tui: &mut Tui,
skin: &Skin,
title: &str,
initial: Option<Color>,
memory: SwatchMemory,
render_bg: impl Fn(&mut Frame),
) -> io::Result<(SwatchExit, SwatchMemory)> {
let mut state = State {
mode: memory.mode,
cursor: 0,
offset: Cell::new(0),
cells: Vec::new(),
cols: 1,
palette: palette_entries(&skin.palette),
grid_light: memory.grid_light,
filter: String::new(),
filtering: false,
};
state.rebuild();
if let Some(color) = initial {
state.cursor = nearest(&state.cells, color);
}
let signal = popup(
tui,
&mut state,
|area, state: &State| {
centered_rect(box_size(state).0, box_size(state).1, area)
},
|frame, _| render_bg(frame),
|frame, rect, state: &State| {
render_box(frame, rect, skin, title, state);
},
handle,
)?;
let memory = SwatchMemory {
mode: state.mode,
grid_light: state.grid_light,
};
let exit = match signal {
ModalSignal::Value(Choice::Pick(color)) => SwatchExit::Pick(color),
ModalSignal::Value(Choice::Edit(color)) => SwatchExit::Edit(color),
ModalSignal::Cancelled => SwatchExit::Cancel,
ModalSignal::Quit => SwatchExit::Quit,
};
Ok((exit, memory))
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{
interaction::{done, move_horizontal, move_vertical},
*,
};
use crate::overlay::PopupFlow;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn state() -> State {
let mut state = State {
mode: Mode::Names,
cursor: 0,
offset: Cell::new(0),
cells: Vec::new(),
cols: 1,
palette: vec![("accent", Color::hex("#8bd3cd"))],
grid_light: GRID_LIGHT_DEFAULT,
filter: String::new(),
filtering: false,
};
state.rebuild();
state
}
#[test]
fn names_mode_matches_named_colors() {
let state = state();
assert_eq!(state.cells.len(), NAMED_COLORS.len());
assert_eq!(state.cells[0].color, NAMED_COLORS[0].1);
}
#[test]
fn m_cycles_all_modes() {
let mut state = state();
let seen: Vec<Mode> = (0..4)
.map(|_| {
let mode = state.mode;
handle(&mut state, key(KeyCode::Char('m')));
mode
})
.collect();
assert_eq!(seen, Mode::ALL);
assert_eq!(state.mode, Mode::Names);
}
#[test]
fn switching_mode_carries_the_color_close() {
let mut state = state();
state.cursor = 6; let before = state.focus_color();
handle(&mut state, key(KeyCode::Char('m'))); assert_eq!(state.mode, Mode::Grid);
assert!(state.focus_color().distance(before) < 0.2);
}
#[test]
fn grid_left_wraps_hue_but_up_clamps() {
let mut state = state();
state.mode = Mode::Grid;
state.rebuild();
state.cursor = 0; move_horizontal(&mut state, -1);
assert_eq!(state.cursor, GRID_COLS - 1); state.cursor = 0;
move_vertical(&mut state, -1);
assert_eq!(state.cursor, 0); }
#[test]
fn list_up_wraps_to_the_end() {
let mut state = state();
handle(&mut state, key(KeyCode::Up));
assert_eq!(state.cursor, NAMED_COLORS.len() - 1);
}
#[test]
fn brackets_change_the_grid_lightness() {
let mut state = state();
state.mode = Mode::Grid;
state.rebuild();
let before = state.grid_light;
let color_before = state.focus_color();
handle(&mut state, key(KeyCode::Char(']')));
assert!(state.grid_light > before);
assert_ne!(state.focus_color(), color_before);
}
#[test]
fn filter_narrows_the_named_list() {
let mut state = state();
handle(&mut state, key(KeyCode::Char('/')));
assert!(state.filtering);
for ch in "crim".chars() {
handle(&mut state, key(KeyCode::Char(ch)));
}
assert!(state.cells.len() < NAMED_COLORS.len());
assert_eq!(state.cursor, 0);
assert!(
state
.cells
.iter()
.any(|cell| cell.name.as_deref() == Some("Crimson"))
);
}
#[test]
fn space_picks_and_enter_edits() {
let mut state = state();
state.cursor = 3;
match done(&state, true) {
PopupFlow::Done(Choice::Pick(color)) => {
assert_eq!(color, NAMED_COLORS[3].1);
}
_ => panic!("expected a direct pick"),
}
match done(&state, false) {
PopupFlow::Done(Choice::Edit(color)) => {
assert_eq!(color, NAMED_COLORS[3].1);
}
_ => panic!("expected an edit hand-off"),
}
}
#[test]
fn ctrl_chords_do_not_pick_move_or_switch_mode() {
let mut state = state();
state.cursor = 3;
assert!(matches!(
handle(&mut state, ctrl(KeyCode::Char(' '))),
PopupFlow::Continue,
));
handle(&mut state, ctrl(KeyCode::Char('j')));
assert_eq!(state.cursor, 3);
handle(&mut state, ctrl(KeyCode::Char('m')));
assert_eq!(state.mode, Mode::Names);
}
#[test]
fn a_ctrl_chord_does_not_type_into_the_filter() {
let mut state = state();
handle(&mut state, key(KeyCode::Char('/')));
handle(&mut state, ctrl(KeyCode::Char('u')));
assert_eq!(state.filter, "", "Ctrl+U typed a character");
handle(&mut state, key(KeyCode::Char('u')));
assert_eq!(state.filter, "u");
}
#[test]
fn altgr_characters_still_reach_the_filter() {
let mut state = state();
handle(&mut state, key(KeyCode::Char('/')));
for ch in ['@', '\\', '['] {
handle(
&mut state,
KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::CONTROL | KeyModifiers::ALT,
),
);
}
assert_eq!(state.filter, "@\\[");
}
}