mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
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:
@@ -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) }}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…");
|
||||
}
|
||||
Reference in New Issue
Block a user