Skip to main content

rich_interact/components/
color.rs

1//! A colour picker (#459, `rich color`): rich's named colours, the 256
2//! palette, or a colour typed as hex or RGB, with a live swatch.
3//!
4//! Typing filters the named colours; text that is itself a colour
5//! (`#ff8800`, `rgb(255,136,0)`, `color(208)`) is offered first. Tab
6//! switches to the palette, a 16 by 16 grid moved with the arrows. Enter
7//! picks. The answer is a colour string rich parses, as hex (`#ff8800`),
8//! rgb (`rgb(255,136,0)`) or a name (`dark_orange`, or `color(N)` or hex
9//! for a colour without one): see [`ColorFormat`].
10
11use std::cell::Cell;
12
13use rich::color::ColorType;
14use rich::{Color, ColorTriplet, Segment, Style};
15
16use crate::component::{Component, Context, Flow, View};
17use crate::components::{Theme, fit, highlight, plain, question, text};
18use crate::event::{Button, Event, Key, KeyCode, Modifiers, MouseKind};
19use crate::fuzzy::rank;
20use crate::keymap::{Keymap, keys};
21use crate::names::COLORS;
22use crate::policy::{LineIo, NotInteractive};
23
24/// How a picked colour is written.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ColorFormat {
27    /// `#rrggbb`.
28    #[default]
29    Hex,
30    /// A rich colour name when it has one (`dark_orange`), else
31    /// `color(N)` for a palette colour, else hex.
32    Name,
33    /// `rgb(r,g,b)`.
34    Rgb,
35}
36
37impl ColorFormat {
38    pub fn parse(name: &str) -> Option<ColorFormat> {
39        Some(match name {
40            "hex" => ColorFormat::Hex,
41            "name" => ColorFormat::Name,
42            "rgb" => ColorFormat::Rgb,
43            _ => return None,
44        })
45    }
46}
47
48/// A colour and the name it was picked by, if any.
49#[derive(Clone, Debug, PartialEq, Eq)]
50struct Picked {
51    color: Color,
52    name: Option<String>,
53}
54
55impl Picked {
56    fn named(name: &str) -> Option<Picked> {
57        Some(Picked {
58            color: Color::parse(name).ok()?,
59            name: Some(name.to_string()),
60        })
61    }
62
63    /// `text` as a colour: a name, hex, `rgb(…)` or `color(N)`.
64    fn parse(text: &str) -> Option<Picked> {
65        let text = text.trim();
66        let lower = text.to_lowercase();
67        if COLORS.contains(&lower.as_str()) {
68            return Picked::named(&lower);
69        }
70        let color = Color::parse(&lower).ok()?;
71        if color.kind == ColorType::Default {
72            return None;
73        }
74        Some(Picked { color, name: None })
75    }
76
77    fn palette(number: u8) -> Picked {
78        Picked {
79            color: Color::from_ansi(number),
80            name: None,
81        }
82    }
83
84    fn triplet(&self) -> ColorTriplet {
85        self.color
86            .get_truecolor()
87            .unwrap_or(ColorTriplet::new(0, 0, 0))
88    }
89
90    /// The first name rich has for this palette colour.
91    fn palette_name(&self) -> Option<&'static str> {
92        let number = match self.color.kind {
93            ColorType::Standard | ColorType::EightBit => self.color.number?,
94            _ => return None,
95        };
96        COLORS
97            .iter()
98            .copied()
99            .find(|name| Color::parse(name).is_ok_and(|color| color.number == Some(number)))
100    }
101
102    fn format(&self, format: ColorFormat) -> String {
103        let triplet = self.triplet();
104        match format {
105            ColorFormat::Hex => triplet.hex(),
106            ColorFormat::Rgb => format!("rgb({},{},{})", triplet.red, triplet.green, triplet.blue),
107            ColorFormat::Name => match (&self.name, self.palette_name(), self.color.number) {
108                (Some(name), _, _) => name.clone(),
109                (None, Some(name), _) => name.to_string(),
110                (None, None, Some(number)) if self.color.kind == ColorType::EightBit => {
111                    format!("color({number})")
112                }
113                _ => triplet.hex(),
114            },
115        }
116    }
117
118    /// A background of this colour.
119    fn swatch(&self) -> Style {
120        Style::parse(&format!("on {}", self.triplet().hex())).expect("a hex colour")
121    }
122}
123
124/// Pick a colour. Returns it as a string in the [`ColorFormat`] chosen.
125#[must_use = "a component does nothing until it is run or mounted"]
126pub struct ColorPicker {
127    prompt: String,
128    query: String,
129    /// Indices into [`COLORS`] that match, with the characters matched.
130    matches: Vec<(usize, Vec<usize>)>,
131    /// The query as a colour, when it is one and not a listed name.
132    custom: Option<Picked>,
133    focus: usize,
134    offset: usize,
135    height: usize,
136    grid: bool,
137    cell: u8,
138    format: ColorFormat,
139    default: Option<String>,
140    theme: Theme,
141    mouse: bool,
142    /// The row (a grid cell, or a list position) the last click focused,
143    /// until a key: a click on it again picks it.
144    clicked: Option<(bool, usize)>,
145    /// Rows on screen, from the last context: the list is cut to fit.
146    space: Cell<usize>,
147    answer: Option<Option<String>>,
148    /// Keys rebound on this picker ([`ColorPicker::rebind`]).
149    rebound: Keymap,
150}
151
152/// Rows above the list or grid: the question and the swatch.
153const TOP: usize = 2;
154
155impl ColorPicker {
156    pub fn new(prompt: impl Into<String>) -> ColorPicker {
157        let mut picker = ColorPicker {
158            prompt: prompt.into(),
159            query: String::new(),
160            matches: Vec::new(),
161            custom: None,
162            focus: 0,
163            offset: 0,
164            height: 10,
165            grid: false,
166            cell: 0,
167            format: ColorFormat::Hex,
168            default: None,
169            theme: Theme::default(),
170            mouse: false,
171            clicked: None,
172            space: Cell::new(usize::MAX),
173            answer: None,
174            rebound: Keymap::new("color"),
175        };
176        picker.refilter();
177        picker
178    }
179
180    pub fn format(mut self, format: ColorFormat) -> Self {
181        self.format = format;
182        self
183    }
184
185    /// Start with this typed: a name to filter by, or a colour.
186    pub fn value(mut self, value: impl Into<String>) -> Self {
187        self.query = value.into();
188        self.refilter();
189        self
190    }
191
192    /// The colour returned without a terminal, and for an empty line at a
193    /// line prompt. One that is not a colour ([`ColorPicker::is_color`])
194    /// is reported when the line prompt starts, and is no default.
195    pub fn default(mut self, color: impl Into<String>) -> Self {
196        self.default = Some(color.into());
197        self
198    }
199
200    /// Whether `text` is a colour the picker takes: a rich name, `#rrggbb`,
201    /// `rgb(r,g,b)` or `color(N)`. Check a default with it up front.
202    pub fn is_color(text: &str) -> bool {
203        Picked::parse(text).is_some()
204    }
205
206    /// The default, when it is a colour, in the picker's format.
207    fn valid_default(&self) -> Result<Option<String>, NotInteractive> {
208        let Some(default) = self.default.as_deref() else {
209            return Ok(None);
210        };
211        Picked::parse(default)
212            .map(|picked| Some(picked.format(self.format)))
213            .ok_or_else(|| {
214                NotInteractive::Invalid(format!("the default is not a colour: {default:?}"))
215            })
216    }
217
218    /// Show at most `rows` colours at once (default 10).
219    pub fn height(mut self, rows: usize) -> Self {
220        self.height = rows.max(1);
221        self
222    }
223
224    /// Start on the palette grid.
225    pub fn palette(mut self, on: bool) -> Self {
226        self.grid = on;
227        self
228    }
229
230    /// Make `keys` do `action` (in context `color`; see
231    /// [`keymap`](Component::keymap)) on this picker only; no keys unbinds
232    /// it.
233    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
234        self.rebound.rebind(action, keys);
235        self
236    }
237
238    pub fn theme(mut self, theme: Theme) -> Self {
239        self.theme = theme;
240        self
241    }
242
243    /// Report the mouse: a click focuses a colour, a second click picks it.
244    pub fn with_mouse(mut self, on: bool) -> Self {
245        self.mouse = on;
246        self
247    }
248
249    fn refilter(&mut self) {
250        self.matches = rank(&self.query, COLORS.iter().copied())
251            .into_iter()
252            .map(|(index, found)| (index, found.positions))
253            .collect();
254        let exact = COLORS.contains(&self.query.trim().to_lowercase().as_str());
255        self.custom = Picked::parse(&self.query).filter(|_| !exact);
256        self.focus = 0;
257        self.offset = 0;
258    }
259
260    fn count(&self) -> usize {
261        self.matches.len() + usize::from(self.custom.is_some())
262    }
263
264    /// Rows the list takes: the height asked for, cut to what fits on
265    /// screen beside the question, the swatch and the hint.
266    fn rows(&self) -> usize {
267        self.height
268            .min(self.space.get().saturating_sub(TOP + 1))
269            .max(1)
270    }
271
272    /// The colour on list row `position`.
273    fn at(&self, position: usize) -> Option<Picked> {
274        match (&self.custom, position) {
275            (Some(custom), 0) => Some(custom.clone()),
276            (Some(_), position) => Picked::named(COLORS[self.matches.get(position - 1)?.0]),
277            (None, position) => Picked::named(COLORS[self.matches.get(position)?.0]),
278        }
279    }
280
281    fn focused(&self) -> Option<Picked> {
282        if self.grid {
283            Some(Picked::palette(self.cell))
284        } else {
285            self.at(self.focus)
286        }
287    }
288
289    fn step(&mut self, delta: isize) {
290        let count = self.count();
291        if count == 0 {
292            return;
293        }
294        self.focus = self.focus.saturating_add_signed(delta).min(count - 1);
295        if self.focus < self.offset {
296            self.offset = self.focus;
297        } else if self.focus >= self.offset + self.rows() {
298            self.offset = self.focus + 1 - self.rows();
299        }
300    }
301
302    fn pick(&mut self) -> Flow<String> {
303        match self.focused() {
304            Some(picked) => {
305                let answer = picked.format(self.format);
306                self.answer = Some(Some(answer.clone()));
307                Flow::Done(answer)
308            }
309            None => Flow::Continue,
310        }
311    }
312
313    fn move_cell(&mut self, columns: i16, rows: i16) {
314        let column = (i16::from(self.cell % 16) + columns).clamp(0, 15);
315        let row = (i16::from(self.cell / 16) + rows).clamp(0, 15);
316        self.cell = (row * 16 + column) as u8;
317    }
318
319    fn click(&mut self, column: usize, row: usize) -> Flow<String> {
320        let Some(row) = row.checked_sub(TOP) else {
321            return Flow::Continue;
322        };
323        if self.grid {
324            let column = column.saturating_sub(2) / 2;
325            if row < 16 && column < 16 {
326                let cell = (row * 16 + column) as u8;
327                if self.clicked == Some((true, cell.into())) && cell == self.cell {
328                    return self.pick();
329                }
330                self.cell = cell;
331                self.clicked = Some((true, cell.into()));
332            }
333        } else if row < self.rows() {
334            let position = self.offset + row;
335            if position < self.count() {
336                if self.clicked == Some((false, position)) && position == self.focus {
337                    return self.pick();
338                }
339                self.focus = position;
340                self.clicked = Some((false, position));
341            }
342        }
343        Flow::Continue
344    }
345
346    fn swatch_line(&self, width: usize) -> Vec<Segment> {
347        let theme = &self.theme;
348        let mut line = vec![plain("  ")];
349        match self.focused() {
350            Some(picked) => {
351                line.push(Segment::new("        ", Some(picked.swatch())));
352                let triplet = picked.triplet();
353                let mut about = format!(
354                    "  {}  rgb({},{},{})",
355                    triplet.hex(),
356                    triplet.red,
357                    triplet.green,
358                    triplet.blue
359                );
360                if let Some(name) = picked.name.as_deref().or(picked.palette_name()) {
361                    about.push_str(&format!("  {name}"));
362                }
363                if let Some(number) = picked.color.number {
364                    about.push_str(&format!("  color({number})"));
365                }
366                line.push(text(about, &theme.hint));
367            }
368            None => line.push(text("no colour", &theme.hint)),
369        }
370        fit(line, width)
371    }
372
373    fn list(&self, width: usize) -> Vec<Vec<Segment>> {
374        let theme = &self.theme;
375        let mut lines = Vec::new();
376        for position in (self.offset..self.count()).take(self.rows()) {
377            let Some(picked) = self.at(position) else {
378                break;
379            };
380            let focused = position == self.focus;
381            let mut line = if focused {
382                vec![text(format!("{} ", theme.pointer), &theme.pointer_style)]
383            } else {
384                vec![plain("  ")]
385            };
386            line.push(Segment::new("    ", Some(picked.swatch())));
387            line.push(plain(" "));
388            let base = focused.then_some(&theme.focused);
389            match (&picked.name, self.custom.is_some() && position == 0) {
390                (_, true) => line.push(text(self.query.trim().to_string(), &theme.answer)),
391                (Some(name), false) => {
392                    let positions = &self.matches[position - usize::from(self.custom.is_some())].1;
393                    line.extend(highlight(name, positions, base, &theme.matched));
394                }
395                (None, false) => {}
396            }
397            line.push(text(format!("  {}", picked.triplet().hex()), &theme.hint));
398            lines.push(fit(line, width));
399        }
400        if self.count() == 0 {
401            lines.push(vec![text("  no matches", &theme.hint)]);
402        }
403        lines.resize_with(self.rows(), Vec::new);
404        lines
405    }
406
407    fn palette_grid(&self, width: usize) -> Vec<Vec<Segment>> {
408        let mut lines = Vec::with_capacity(16);
409        for row in 0..16u8 {
410            let mut line = vec![plain("  ")];
411            for column in 0..16u8 {
412                let number = row * 16 + column;
413                let picked = Picked::palette(number);
414                let mark = if number == self.cell {
415                    let triplet = picked.triplet();
416                    let light = u32::from(triplet.red) * 299
417                        + u32::from(triplet.green) * 587
418                        + u32::from(triplet.blue) * 114
419                        > 128_000;
420                    let ink = if light { "black" } else { "white" };
421                    let style = Style::parse(&format!("bold {ink}"))
422                        .expect("style")
423                        .combine(&picked.swatch());
424                    Segment::new("<>", Some(style))
425                } else {
426                    Segment::new("  ", Some(picked.swatch()))
427                };
428                line.push(mark);
429            }
430            lines.push(fit(line, width));
431        }
432        lines
433    }
434}
435
436impl Component for ColorPicker {
437    type Output = String;
438
439    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<String> {
440        self.space.set(context.height);
441        if let Event::Mouse(mouse) = event {
442            match mouse.kind {
443                MouseKind::Down(Button::Left) => {
444                    return self.click(mouse.column as usize, mouse.row as usize);
445                }
446                MouseKind::ScrollUp if self.grid => self.move_cell(0, -1),
447                MouseKind::ScrollDown if self.grid => self.move_cell(0, 1),
448                MouseKind::ScrollUp => self.step(-1),
449                MouseKind::ScrollDown => self.step(1),
450                _ => {}
451            }
452            return Flow::Continue;
453        }
454        if let Event::Paste(pasted) = event {
455            self.clicked = None;
456            self.grid = false;
457            self.query.push_str(&crate::components::pasted(pasted, " "));
458            self.refilter();
459            return Flow::Continue;
460        }
461        let Some(key) = event.key() else {
462            return Flow::Continue;
463        };
464        // A click after a key is a first click again.
465        self.clicked = None;
466        let keymap = self.keymap();
467        // A key with modifiers that is not bound as it is does what it does
468        // without them, unless it types.
469        let action = keymap.action(key).or_else(|| {
470            let typing = matches!(key.code, KeyCode::Char(_));
471            (!typing && key.modifiers != Modifiers::NONE)
472                .then(|| keymap.action(Key::new(key.code)))
473                .flatten()
474        });
475        match action.map(str::to_string).as_deref() {
476            Some("pick") => return self.pick(),
477            Some("cancel") => {
478                self.answer = Some(None);
479                return Flow::Cancel;
480            }
481            Some("grid") => self.grid = !self.grid,
482            Some("up") if self.grid => self.move_cell(0, -1),
483            Some("down") if self.grid => self.move_cell(0, 1),
484            Some("left") if self.grid => self.move_cell(-1, 0),
485            Some("right") if self.grid => self.move_cell(1, 0),
486            Some("first") if self.grid => self.cell = 0,
487            Some("last") if self.grid => self.cell = 255,
488            Some("up") => self.step(-1),
489            Some("down") => self.step(1),
490            Some("page-up") => self.step(-(self.rows() as isize)),
491            Some("page-down") => self.step(self.rows() as isize),
492            Some("clear") => {
493                self.query.clear();
494                self.refilter();
495            }
496            Some("delete") => {
497                self.grid = false;
498                if self.query.pop().is_some() {
499                    self.refilter();
500                }
501            }
502            _ => match key.code {
503                KeyCode::Char(c) if !key.modifiers.ctrl && !key.modifiers.alt => {
504                    self.grid = false;
505                    self.query.push(c);
506                    self.refilter();
507                }
508                _ => return Flow::Ignored,
509            },
510        }
511        Flow::Continue
512    }
513
514    fn keymap(&self) -> Keymap {
515        let mut keymap = Keymap::new("color")
516            .bind("pick", keys("enter"), "pick")
517            .bind("cancel", keys("escape"), "cancel")
518            .bind(
519                "grid",
520                keys("tab shift+tab"),
521                "switch between the list and the grid",
522            )
523            .bind("up", keys("up"), "move up")
524            .bind("down", keys("down"), "move down")
525            .bind("left", keys("left"), "move left (grid)")
526            .bind("right", keys("right"), "move right (grid)")
527            .bind("page-up", keys("pageup"), "page up")
528            .bind("page-down", keys("pagedown"), "page down")
529            .bind("first", keys("home"), "first cell (grid)")
530            .bind("last", keys("end"), "last cell (grid)")
531            .bind("clear", keys("ctrl+u"), "clear the filter")
532            .bind("delete", keys("backspace"), "delete a character");
533        keymap.extend(self.rebound.clone());
534        keymap
535    }
536
537    fn render(&self, context: &Context<'_>) -> View {
538        self.space.set(context.height);
539        let width = context.width;
540        let theme = &self.theme;
541        let mut header = question(theme, &self.prompt);
542        if let Some(answer) = &self.answer {
543            match (answer, self.focused()) {
544                (Some(answer), Some(picked)) => {
545                    header.push(Segment::new("  ", Some(picked.swatch())));
546                    header.push(plain(" "));
547                    header.push(text(answer.clone(), &theme.answer));
548                }
549                (Some(answer), None) => header.push(text(answer.clone(), &theme.answer)),
550                (None, _) => header.push(text("cancelled", &theme.hint)),
551            }
552            return View::new(vec![fit(header, width)]);
553        }
554        let column = crate::components::width(&header) + rich::cells::cell_len(&self.query);
555        header.push(plain(self.query.clone()));
556        let mut lines = vec![fit(header, width), self.swatch_line(width)];
557        if self.grid {
558            lines.extend(self.palette_grid(width));
559        } else {
560            lines.extend(self.list(width));
561        }
562        let hint = if self.grid {
563            "  ←↑↓→ move · tab names · enter pick · esc cancel".to_string()
564        } else {
565            format!(
566                "  {}/{} · type a name, #hex or rgb(r,g,b) · ↑↓ move · tab palette · enter pick · \
567                 esc cancel",
568                self.count(),
569                COLORS.len()
570            )
571        };
572        lines.push(fit(vec![text(hint, &theme.hint)], width));
573        View::new(lines).with_cursor(0, column.min(width.saturating_sub(1)))
574    }
575
576    fn mouse(&self) -> bool {
577        self.mouse
578    }
579
580    fn default_value(&self) -> Option<String> {
581        self.valid_default().ok().flatten()
582    }
583
584    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<String>, NotInteractive> {
585        // A bad default fails before anything is asked.
586        let default = self.valid_default()?;
587        let mut prompt = format!("{} (a name, #rrggbb, rgb(r,g,b) or color(N))", self.prompt);
588        if let Some(default) = &self.default {
589            prompt.push_str(&format!(" [{default}]"));
590        }
591        io.write(&format!("{prompt}: "));
592        let line = io.read_line().unwrap_or_default();
593        let line = line.trim();
594        // End of input or an empty line: the default, or no answer.
595        if line.is_empty() {
596            return default.map(Some).ok_or(NotInteractive::Ended);
597        }
598        Picked::parse(line)
599            .map(|picked| Some(picked.format(self.format)))
600            .ok_or_else(|| NotInteractive::Invalid(format!("not a colour: {line:?}")))
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn formats_colours() {
610        let red = Picked::parse("red").unwrap();
611        assert_eq!(red.format(ColorFormat::Name), "red");
612        assert_eq!(red.format(ColorFormat::Hex), "#800000");
613        let orange = Picked::parse("#FF8800").unwrap();
614        assert_eq!(orange.format(ColorFormat::Hex), "#ff8800");
615        assert_eq!(orange.format(ColorFormat::Rgb), "rgb(255,136,0)");
616        assert_eq!(orange.format(ColorFormat::Name), "#ff8800");
617        let palette = Picked::palette(208);
618        assert_eq!(palette.format(ColorFormat::Name), "dark_orange");
619        assert_eq!(
620            Picked::parse("rgb(1, 2, 3)")
621                .unwrap()
622                .format(ColorFormat::Hex),
623            "#010203"
624        );
625        assert!(Picked::parse("not a colour").is_none());
626        assert!(Picked::parse("default").is_none());
627    }
628
629    #[test]
630    fn every_listed_name_is_a_colour() {
631        for name in COLORS {
632            assert!(Color::parse(name).is_ok(), "{name}");
633        }
634    }
635}