Colour menus: More Colors… and Recent Colors (#928)

The palette every colour menu shares ends with the custom colours last used
(Recent Colors, newest first, ten at most, each once) and a More Colors… button
that opens a picker in the menu: the saturation/value area with its hue bar, egui's
red-green-blue boxes, a hex box (#RRGGBB, RRGGBB, #RGB) with the new colour beside
it, OK and Cancel. OK applies the colour like a swatch and remembers it.

The ribbon's Fill Color and Font Color menus close only on a click outside them or
after a pick, so the picker stays open while it is used; the Format Cells pickers
show it inline. The tab-colour and chart-series menus are sub-menus that close on
any click inside, so they show Recent Colors but not the picker.
This commit is contained in:
NetterSilver
2026-10-13 16:02:06 +02:00
parent 84acf6293b
commit d3cc40ca20
5 changed files with 292 additions and 28 deletions
+1 -1
View File
@@ -241,7 +241,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
ui.label(&name);
ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110)));
ui.menu_button("Fill…", |ui| {
if let Some(c) = crate::widgets::color_palette(ui, &theme.colors, "Automatic") {
if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") {
app.run_or_alert(
"chart.formatSelection",
json!({"chart": id, "series": i, "color": if c == "none" { Json::Null } else { json!(c) }}),
+22 -16
View File
@@ -669,15 +669,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
act(app, "home.fillColor", json!({"color": fill}));
}
let forced = invoke(app, "home.fillColor");
egui::Popup::menu(&fa).open_memory(popup_command(forced, fa.clicked())).show(|ui| {
if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") {
if c != "none" {
app.grid.last_fill = c.clone();
egui::Popup::menu(&fa)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.open_memory(popup_command(forced, fa.clicked()))
.show(|ui| {
if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") {
if c != "none" {
app.grid.last_fill = c.clone();
}
act(app, "home.fillColor", json!({"color": c}));
ui.close();
}
act(app, "home.fillColor", json!({"color": c}));
ui.close();
}
});
});
let fc = app.grid.last_font_color.clone();
let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32);
let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color");
@@ -686,15 +689,18 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
act(app, "home.fontColor", json!({"color": fc}));
}
let forced = invoke(app, "home.fontColor");
egui::Popup::menu(&ca).open_memory(popup_command(forced, ca.clicked())).show(|ui| {
if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") {
if c != "none" {
app.grid.last_font_color = c.clone();
egui::Popup::menu(&ca)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.open_memory(popup_command(forced, ca.clicked()))
.show(|ui| {
if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") {
if c != "none" {
app.grid.last_font_color = c.clone();
}
act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }}));
ui.close();
}
act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }}));
ui.close();
}
});
});
});
});
sep(ui);
+1 -1
View File
@@ -186,7 +186,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
}
ui.menu_button("Tab Color", |ui| {
let colors = app.session.active().map(|d| d.wb.theme.colors).unwrap_or_default();
if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") {
if let Some(c) = crate::widgets::color_palette_fixed(ui, &colors, "No Color") {
app.run_or_alert("sheet.tabColor", json!({"sheet": i, "color": c}));
ui.close();
}
+169 -10
View File
@@ -144,23 +144,84 @@ pub fn committed_text(ui: &mut Ui, id: egui::Id, current: &str, width: Option<f3
None
}
/// Our colour palette (theme row + tints + standard colours), returns a picked hex colour,
/// `Some("none")` for No Fill/Automatic. Arrow keys move a highlight through the swatches and
/// Enter applies; the key events are stripped so the grid behind the popup does not also react.
/// How many custom colours the palette's Recent Colors row remembers.
pub const RECENT_COLORS: usize = 10;
/// `#RRGGBB` for a colour.
pub fn hex_of(rgb: [u8; 3]) -> String {
format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
}
/// A colour typed as `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB` (any case, spaces around it ignored).
pub fn parse_hex(text: &str) -> Option<[u8; 3]> {
let t = text.trim();
let t = t.strip_prefix('#').unwrap_or(t);
let digits: Vec<u32> = t.chars().map(|c| c.to_digit(16)).collect::<Option<_>>()?;
let byte = |hi: u32, lo: u32| u8::try_from(hi * 16 + lo).ok();
match digits[..] {
[r, g, b] => Some([byte(r, r)?, byte(g, g)?, byte(b, b)?]),
[r1, r2, g1, g2, b1, b2] => Some([byte(r1, r2)?, byte(g1, g2)?, byte(b1, b2)?]),
_ => None,
}
}
/// Puts `hex` first in the recent colours, once, keeping at most [`RECENT_COLORS`].
pub fn remember_color(recent: &mut Vec<String>, hex: &str) {
let hex = hex.to_ascii_uppercase();
recent.retain(|h| *h != hex);
recent.insert(0, hex);
recent.truncate(RECENT_COLORS);
}
fn recent_colors_id() -> egui::Id {
egui::Id::new("gridcraft.recent_colors")
}
/// The More Colors… picker's state while it is open in a palette.
#[derive(Clone)]
struct MoreColors {
hsva: egui::ecolor::Hsva,
hex: String,
}
impl MoreColors {
fn new(rgb: [u8; 3]) -> Self {
Self { hsva: egui::ecolor::Hsva::from_srgb(rgb), hex: hex_of(rgb) }
}
}
/// Our colour palette (theme row + tints + standard colours, then Recent Colors and More Colors…),
/// returns a picked hex colour, `Some("none")` for No Fill/Automatic. Arrow keys move a highlight
/// through the swatches and Enter applies; the key events are stripped so the grid behind the popup
/// does not also react (not while More Colors… is open, whose boxes take the keys).
pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option<String> {
palette(ui, theme_colors, none_label, true)
}
/// [`color_palette`] without More Colors…, for a menu that closes on any click inside it (a sub-menu
/// of a context menu), where the picker would vanish as soon as it is used. Recent Colors stays.
pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> Option<String> {
palette(ui, theme_colors, none_label, false)
}
fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option<String> {
let mut picked = None;
const ROWS: usize = 7; // 6 tint rows + 1 standard row
const COLS: usize = 10;
let pal_id = ui.id().with("__color_palette");
let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None);
let more_id = pal_id.with("more");
let mut more: Option<MoreColors> = ui.data_mut(|d| d.get_temp(more_id)).unwrap_or(None);
let (mut dl, mut dr, mut du, mut dd, mut ent) = (false, false, false, false, false);
ui.input(|i| {
dl = i.key_pressed(egui::Key::ArrowLeft);
dr = i.key_pressed(egui::Key::ArrowRight);
du = i.key_pressed(egui::Key::ArrowUp);
dd = i.key_pressed(egui::Key::ArrowDown);
ent = i.key_pressed(egui::Key::Enter);
});
if more.is_none() {
ui.input(|i| {
dl = i.key_pressed(egui::Key::ArrowLeft);
dr = i.key_pressed(egui::Key::ArrowRight);
du = i.key_pressed(egui::Key::ArrowUp);
dd = i.key_pressed(egui::Key::ArrowDown);
ent = i.key_pressed(egui::Key::Enter);
});
}
if dl || dr || du || dd {
cur = match cur {
None => Some((
@@ -266,6 +327,73 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) ->
}
}
});
// Recent Colors: the custom colours picked below, newest first.
let mut recent: Vec<String> = ui.ctx().data(|d| d.get_temp(recent_colors_id())).unwrap_or_default();
if !recent.is_empty() {
ui.label(egui::RichText::new("Recent Colors").small());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for hex in &recent {
let c = parse_hex(hex).map_or(Color32::BLACK, |c| Color32::from_rgb(c[0], c[1], c[2]));
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
ui.painter().rect_filled(r, 1.0, c);
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
if resp.hovered() {
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
}
if resp.on_hover_text(hex).clicked() {
picked = Some(hex.clone());
}
}
});
}
if more_colors {
ui.separator();
}
if more_colors && ui.button("More Colors…").clicked() {
let start = recent.first().and_then(|h| parse_hex(h)).unwrap_or([0x44, 0x72, 0xC4]);
more = Some(MoreColors::new(start));
}
if let Some(m) = more.as_mut() {
let mut done = false;
// The saturation / value area with its hue bar and egui's red-green-blue boxes, then the hex
// box with the new colour beside it.
if egui::color_picker::color_picker_hsva_2d(ui, &mut m.hsva, egui::color_picker::Alpha::Opaque) {
m.hex = hex_of(m.hsva.to_srgb());
}
ui.horizontal(|ui| {
let label = ui.label("Hex");
let r = ui.add(egui::TextEdit::singleline(&mut m.hex).desired_width(72.0).char_limit(7)).labelled_by(label.id);
if r.changed()
&& let Some(c) = parse_hex(&m.hex)
{
m.hsva = egui::ecolor::Hsva::from_srgb(c);
}
let c = m.hsva.to_srgb();
let (sw, _) = ui.allocate_exact_size(vec2(36.0, 18.0), Sense::hover());
ui.painter().rect_filled(sw, 1.0, Color32::from_rgb(c[0], c[1], c[2]));
ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside);
ui.label(egui::RichText::new("New").small());
});
ui.horizontal(|ui| {
if ui.button(" OK ").clicked() {
// A hex still being typed that is not a colour falls back to the picker's.
let c = parse_hex(&m.hex).unwrap_or_else(|| m.hsva.to_srgb());
let hex = hex_of(c);
remember_color(&mut recent, &hex);
picked = Some(hex);
done = true;
}
if ui.button("Cancel").clicked() {
done = true;
}
});
if done {
more = None;
}
}
ui.ctx().data_mut(|d| d.insert_temp(recent_colors_id(), recent));
ui.data_mut(|d| d.insert_temp(more_id, more));
ui.data_mut(|d| d.insert_temp(pal_id, cur));
picked
}
@@ -362,3 +490,34 @@ pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) {
app.notice = None;
}
}
#[cfg(test)]
mod color_tests {
use super::*;
#[test]
fn hex_is_read_in_the_forms_people_type() {
assert_eq!(parse_hex("#1A2b3C"), Some([0x1A, 0x2B, 0x3C]));
assert_eq!(parse_hex(" 1a2b3c "), Some([0x1A, 0x2B, 0x3C]));
assert_eq!(parse_hex("#f80"), Some([0xFF, 0x88, 0x00]));
for bad in ["", "#", "#12", "#1234", "#12345", "#1234567", "#12345G", "##123456", "é12345"] {
assert_eq!(parse_hex(bad), None, "{bad:?}");
}
assert_eq!(hex_of([0, 10, 255]), "#000AFF");
}
#[test]
fn recent_colours_keep_the_newest_first_once_each_and_at_most_ten() {
let mut r = Vec::new();
for i in 0..12u8 {
remember_color(&mut r, &hex_of([i, 0, 0]));
}
assert_eq!(r.len(), RECENT_COLORS);
assert_eq!(r[0], "#0B0000");
remember_color(&mut r, "#050000");
remember_color(&mut r, "#0b0000");
assert_eq!(&r[..2], ["#0B0000", "#050000"]);
assert_eq!(r.iter().filter(|h| *h == "#050000").count(), 1);
assert_eq!(r.len(), RECENT_COLORS);
}
}
+99
View File
@@ -0,0 +1,99 @@
//! The colour menus' More Colors… and Recent Colors, through the real egui input path.
use egui::{Event, Key, Modifiers, os::OperatingSystem};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness() -> Harness {
let mut s = Session::new();
s.new_workbook();
let mut h = egui_kittest::Harness::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
h
}
fn press(h: &mut Harness, key: Key, modifiers: Modifiers) {
let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers };
h.input_mut().events.extend([ev(true), ev(false)]);
h.run_steps(2);
}
/// H, H opens the Fill Color palette.
fn open_fill_palette(h: &mut Harness) {
for k in [Key::F10, Key::H, Key::H] {
press(h, k, Modifiers::NONE);
}
h.run_steps(3);
}
fn type_hex(h: &mut Harness, hex: &str) {
h.get_by_label("Hex").click();
h.run_steps(2);
press(h, Key::A, Modifiers::COMMAND);
h.input_mut().events.push(Event::Text(hex.to_string()));
h.run_steps(3);
}
#[test]
fn more_colors_applies_the_typed_colour_and_remembers_it() {
let mut h = harness();
open_fill_palette(&mut h);
assert!(h.query_by_label("Recent Colors").is_none(), "nothing is recent before a custom colour is used");
h.get_by_label("More Colors…").click();
h.run_steps(3);
type_hex(&mut h, "#12AB34");
h.get_by_label(" OK ").click();
h.run_steps(4);
assert_eq!(h.state().grid.last_fill, "#12AB34");
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "home.fillColor").count(), 1);
assert!(h.query_by_label("Theme Colors").is_none(), "the palette closes after OK");
// The next time any colour menu opens, the colour is the first Recent Color.
open_fill_palette(&mut h);
h.get_by_label("Recent Colors");
assert!(h.query_by_label("Hex").is_none(), "More Colors… starts closed");
}
#[test]
fn a_colour_that_is_not_hex_does_not_apply_and_cancel_applies_nothing() {
let mut h = harness();
open_fill_palette(&mut h);
h.get_by_label("More Colors…").click();
h.run_steps(3);
// Enter in the Hex box does not apply the palette's highlighted swatch or close the menu.
type_hex(&mut h, "#12AB");
press(&mut h, Key::Enter, Modifiers::NONE);
assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor"));
h.get_by_label("Hex");
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor"));
assert!(h.query_by_label("Hex").is_none());
assert!(h.query_by_label("Recent Colors").is_none(), "Cancel remembers nothing");
}
#[test]
fn the_font_colour_menu_has_them_too_and_shares_the_recent_colours() {
let mut h = harness();
open_fill_palette(&mut h);
h.get_by_label("More Colors…").click();
h.run_steps(3);
type_hex(&mut h, "#00FF7F");
h.get_by_label(" OK ").click();
h.run_steps(4);
for k in [Key::F10, Key::H, Key::F, Key::C] {
press(&mut h, k, Modifiers::NONE);
}
h.run_steps(3);
h.get_by_label("Automatic");
h.get_by_label("Recent Colors");
h.get_by_label("More Colors…");
}