Colour popover: step buttons, HSV fields and recent colours (#9234) (#9398)

* claim: #9234

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Colour popover: step buttons, H/S/V fields and recent colours (#9234)

Every colour field of the app opens the same ui_kit popover. It gains:
- step buttons beside each number (R, G, B and the new H, S, V): a press
  steps once, holding repeats, for live fine-tuning;
- H (degrees), S and V (percent) fields on the same gamma HSV as the square;
- a row of the last 12 colours picked in any colour field. A colour joins
  when an edit is finished; fine-tuning in one opening replaces its entry.
  The list is saved with the app's settings (`recent_colors`), hostile
  saved entries dropped and the list capped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Recent colours: keep the shared list outside ui_kit, crate-visible only (#9234)

The store and the per-frame sync with the saved settings move to
crate::recent_colors; the ui_kit colour popover only shows the list and
adds finished picks. No plain pub escape from the gallery's ui_kit rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PaddleStroke
2026-10-31 16:51:56 +00:00
committed by GitHub
parent c392791f2c
commit eaf3acc297
5 changed files with 527 additions and 19 deletions
@@ -0,0 +1,90 @@
{
"en": {
"color.recent": "Recent colors",
"color.recent_help": "Colors you pick in any color field appear here",
"color.saturation": "Saturation (percent)",
"color.step_down": "Step down",
"color.step_up": "Step up",
"color.value": "Value, the brightness (percent)"
},
"en-GB": {
"color.recent": "Recent colours",
"color.recent_help": "Colours you pick in any colour field appear here",
"color.saturation": "Saturation (per cent)",
"color.step_down": "Step down",
"color.step_up": "Step up",
"color.value": "Value, the brightness (per cent)"
},
"ar": {
"color.recent": "الألوان الأخيرة",
"color.recent_help": "تظهر هنا الألوان التي تختارها في أي حقل لون",
"color.saturation": "التشبع (بالمئة)",
"color.step_down": "إنقاص خطوة",
"color.step_up": "زيادة خطوة",
"color.value": "القيمة، أي السطوع (بالمئة)"
},
"de": {
"color.recent": "Zuletzt verwendete Farben",
"color.recent_help": "Farben, die Sie in einem beliebigen Farbfeld wählen, erscheinen hier",
"color.saturation": "Sättigung (Prozent)",
"color.step_down": "Schritt nach unten",
"color.step_up": "Schritt nach oben",
"color.value": "Hellwert (Prozent)"
},
"es": {
"color.recent": "Colores recientes",
"color.recent_help": "Los colores que elija en cualquier campo de color aparecen aquí",
"color.saturation": "Saturación (porcentaje)",
"color.step_down": "Bajar un paso",
"color.step_up": "Subir un paso",
"color.value": "Valor, el brillo (porcentaje)"
},
"fr": {
"color.recent": "Couleurs récentes",
"color.recent_help": "Les couleurs choisies dans n'importe quel champ de couleur apparaissent ici",
"color.saturation": "Saturation (pour cent)",
"color.step_down": "Diminuer d'un pas",
"color.step_up": "Augmenter d'un pas",
"color.value": "Valeur, la luminosité (pour cent)"
},
"he": {
"color.recent": "צבעים אחרונים",
"color.recent_help": "צבעים שבוחרים בכל שדה צבע מופיעים כאן",
"color.saturation": "רוויה (אחוזים)",
"color.step_down": "צעד למטה",
"color.step_up": "צעד למעלה",
"color.value": "ערך, הבהירות (אחוזים)"
},
"it": {
"color.recent": "Colori recenti",
"color.recent_help": "I colori scelti in qualsiasi campo colore compaiono qui",
"color.saturation": "Saturazione (percentuale)",
"color.step_down": "Diminuisci di un passo",
"color.step_up": "Aumenta di un passo",
"color.value": "Valore, la luminosità (percentuale)"
},
"ja": {
"color.recent": "最近使った色",
"color.recent_help": "どの色フィールドで選んだ色もここに表示されます",
"color.saturation": "彩度(パーセント)",
"color.step_down": "1 ステップ下げる",
"color.step_up": "1 ステップ上げる",
"color.value": "明度(パーセント)"
},
"vi": {
"color.recent": "Màu gần đây",
"color.recent_help": "Các màu bạn chọn ở bất kỳ ô màu nào sẽ hiện ở đây",
"color.saturation": "Độ bão hòa (phần trăm)",
"color.step_down": "Giảm một bước",
"color.step_up": "Tăng một bước",
"color.value": "Giá trị, độ sáng (phần trăm)"
},
"zh": {
"color.recent": "最近使用的颜色",
"color.recent_help": "在任意颜色字段中选择的颜色会显示在这里",
"color.saturation": "饱和度(百分比)",
"color.step_down": "减少一步",
"color.step_up": "增加一步",
"color.value": "明度(百分比)"
}
}
+12
View File
@@ -212,6 +212,7 @@ mod print_face_ui;
pub(crate) mod quick_measure;
#[cfg(test)]
mod quick_measure_tests;
pub(crate) mod recent_colors;
pub mod recovery_ui;
pub mod ref_images;
mod render_ui;
@@ -370,6 +371,8 @@ pub struct UiState {
/// The running tool's icon rides beside the pointer in the viewport (View settings; off by
/// default).
pub tool_cursor: bool,
/// The colours picked last in any colour picker, newest first (#9234); kept between runs.
pub recent_colors: Vec<[u8; 3]>,
}
impl UiState {
@@ -407,6 +410,7 @@ impl Default for UiState {
up_axis: Default::default(),
agent_cursor: Default::default(),
tool_cursor: false,
recent_colors: Vec::new(),
}
}
}
@@ -1008,6 +1012,7 @@ impl SolveApp {
"autosave_minutes": self.autosave_minutes,
"ground_shadow": self.ui.ground_shadow,
"agent_cursor": self.ui.agent_cursor,
"recent_colors": self.ui.recent_colors,
"preferences": self.preferences,
"gpu_path_setup_seconds": self.render_workspace.setup_seconds,
});
@@ -1112,6 +1117,12 @@ impl SolveApp {
if let Some(a) = v.get("agent_cursor").and_then(|a| serde_json::from_value(a.clone()).ok()) {
self.ui.agent_cursor = a;
}
// Colours that do not read (not three bytes) are left out; the list is capped.
self.ui.recent_colors = v
.get("recent_colors")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|c| serde_json::from_value::<[u8; 3]>(c.clone()).ok()).take(recent_colors::MAX_RECENT_COLORS).collect())
.unwrap_or_default();
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
@@ -2766,6 +2777,7 @@ impl SolveApp {
pub fn ui(&mut self, ui: &mut egui::Ui) {
value_step::set_enabled(ui.ctx(), self.preferences.step_values);
canvas::set_boxes_shown(ui.ctx(), self.preferences.value_boxes_in_view);
recent_colors::share(ui.ctx(), &mut self.ui.recent_colors);
let t0 = now_ms();
// The first frames draw with the bundled faces while the system's CJK, Hangul and Thai
// faces are read on a thread; the fonts are installed again once they are there.
+55
View File
@@ -0,0 +1,55 @@
//! The colours picked last in any colour field, newest first (#9234). The app keeps the list in
//! its saved settings (`UiState::recent_colors`) and hands it to the colour pickers every frame
//! through egui memory; the shared `ui_kit` colour popover shows it and adds finished picks.
/// How many colours the recent list keeps.
pub(crate) const MAX_RECENT_COLORS: usize = 12;
/// The colours picked last, newest first, shared by every colour picker of the window.
#[derive(Clone, Default)]
struct RecentColors {
colors: Vec<[u8; 3]>,
/// Picked since the app last took the list for its settings.
changed: bool,
}
fn recent_id() -> egui::Id {
egui::Id::new("color_recent_colors")
}
/// The recently picked colours, newest first.
pub(crate) fn recent_colors(ctx: &egui::Context) -> Vec<[u8; 3]> {
ctx.data(|d| d.get_temp::<RecentColors>(recent_id())).map(|r| r.colors).unwrap_or_default()
}
/// Put a finished pick at the front of the recent list (once, capped).
/// `replacing` is the colour the same popover added earlier while it stayed open: it goes if it
/// is still the newest entry.
pub(crate) fn remember_color(ctx: &egui::Context, rgb: [u8; 3], replacing: Option<[u8; 3]>) {
ctx.data_mut(|d| {
let recent = d.get_temp_mut_or_default::<RecentColors>(recent_id());
if replacing.is_some() && recent.colors.first().copied() == replacing {
recent.colors.remove(0);
}
recent.colors.retain(|c| *c != rgb);
recent.colors.insert(0, rgb);
recent.colors.truncate(MAX_RECENT_COLORS);
recent.changed = true;
});
}
/// Keep the saved list and the pickers' list the same; the app calls this every frame. Picks made
/// since the last call are written to `saved`; otherwise `saved` (loaded, reset) is what the
/// pickers show.
pub(crate) fn share(ctx: &egui::Context, saved: &mut Vec<[u8; 3]>) {
saved.truncate(MAX_RECENT_COLORS);
ctx.data_mut(|d| {
let recent = d.get_temp_mut_or_default::<RecentColors>(recent_id());
if recent.changed {
saved.clone_from(&recent.colors);
recent.changed = false;
} else if recent.colors != *saved {
recent.colors.clone_from(saved);
}
});
}
+206 -19
View File
@@ -1,5 +1,6 @@
//! Document RGB and optional separate opacity editing. Interface paint uses tokens; gradients are colour data.
use super::{layout::label, metrics::dimension};
use crate::recent_colors::{MAX_RECENT_COLORS, recent_colors, remember_color};
use crate::ui_kit::{Container, Surface};
use crate::{i18n, scenario, theme::Tokens};
use egui::{Color32, Response, Sense, Ui, ecolor::HsvaGamma, pos2, vec2};
@@ -122,6 +123,7 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio
let mut hexadecimal = ui.ctx().data(|d| d.get_temp::<bool>(format_id)).unwrap_or(false);
let mut linear = ui.global_style().visuals.numeric_color_space == egui::style::NumericColorSpace::Linear;
let mut accepted_mixed = false;
let mut picked_recent = false;
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = t.space_10;
ui.horizontal(|ui| {
@@ -197,27 +199,14 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio
}
});
} else {
let spin = if linear {
Spin { max: 1.0, step: 0.01, speed: 0.003, decimals: 3 }
} else {
Spin { max: 255.0, step: 1.0, speed: 0.5, decimals: 0 }
};
ui.columns(3, |columns| {
for ((ui, channel), name) in columns.iter_mut().zip(channels.iter_mut()).zip(["R", "G", "B"]) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = t.space_3;
ui.add(egui::Label::new(egui::RichText::new(name).size(t.font_small).color(t.form_dim)));
let response = ui.add_sized(
vec2(dimension(ui.available_width()), t.control_height),
egui::DragValue::new(channel)
.range(0.0..=if linear { 1.0 } else { 255.0 })
.speed(if linear { 0.003 } else { 0.5 })
.custom_parser(crate::prefs::parse_number)
.custom_formatter(move |v, _| if linear { format!("{v:.03}") } else { format!("{v:.0}") }),
);
response.widget_info(|| {
let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), *channel);
info.label = Some(name.into());
info
});
publish(&format!("color:channel:{name}"), &response);
edited |= response.changed();
});
edited |= spin_channel(ui, name, None, channel, spin);
}
});
}
@@ -233,6 +222,23 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio
}
// Only graphical edits convert HSV back to RGB. Typed byte values stay exact.
let mut graphical_edit = false;
// Hue in degrees, saturation and value in percent, of the same gamma HSV the square shows.
const HSV: [(&str, &str, f64); 3] = [("H", "color.hue", 360.0), ("S", "color.saturation", 100.0), ("V", "color.value", 100.0)];
let mut hsv_fields = [hsv.h, hsv.s, hsv.v].iter().zip(HSV).map(|(v, (_, _, max))| f64::from(*v) * max).collect::<Vec<_>>();
let mut hsv_edited = false;
ui.columns(3, |columns| {
for ((ui, value), (name, tip, max)) in columns.iter_mut().zip(hsv_fields.iter_mut()).zip(HSV) {
hsv_edited |= spin_channel(ui, name, Some(i18n::tr(tip)), value, Spin { max, step: 1.0, speed: 0.5, decimals: 0 });
}
});
if hsv_edited {
let unit = |at: usize| {
let (value, max) = (hsv_fields.get(at).copied().unwrap_or(0.0), HSV.get(at).map_or(1.0, |h| h.2));
if value.is_finite() { (value / max).clamp(0.0, 1.0) as f32 } else { 0.0 }
};
(hsv.h, hsv.s, hsv.v) = (unit(0), unit(1), unit(2));
graphical_edit = true;
}
let width = dimension(ui.available_width());
let (square, response) = ui.allocate_exact_size(vec2(width, t.control_height * 7.0 + t.space_4), Sense::click_and_drag());
if (response.clicked() || response.dragged())
@@ -274,7 +280,33 @@ fn color_popover_with_opacity(ui: &mut Ui, rgb: &mut [u8; 3], mut opacity: Optio
);
publish("color:current", &response);
response.on_hover_text(i18n::tr("color.selected"));
if let Some(picked) = recent_row(ui, width) {
*rgb = picked;
picked_recent = true;
accepted_mixed = mixed;
hsv = crate::theme::srgb(rgb.map(|c| f32::from(c) / 255.0)).into();
hex.set(*rgb, opacity.as_deref().copied());
}
});
// A colour joins the recent list once an edit is finished: a drag or a held step button is
// released, a typed value is entered. Every picker shares the list (`crate::recent_colors`).
// While the popover stays open, fine-tuning replaces the colour it added rather than filling
// the list with each step; a colour taken from the list starts a new entry.
let session_id = id.with("recent_session");
let pass = ui.ctx().cumulative_pass_nr();
let mut session = ui.ctx().data(|d| d.get_temp::<RecentSession>(session_id)).unwrap_or_default();
if session.pass.saturating_add(1) < pass {
session = RecentSession::default();
}
session.pass = pass;
session.pending |= *rgb != before_rgb;
if session.pending && !ui.input(|i| i.pointer.any_down()) {
let replacing = if picked_recent { None } else { session.entry };
remember_color(ui.ctx(), *rgb, replacing);
session.entry = if picked_recent { None } else { Some(*rgb) };
session.pending = false;
}
ui.ctx().data_mut(|d| d.insert_temp(session_id, session));
hex.sync(*rgb, opacity.as_deref().copied());
ui.ctx().data_mut(|d| d.insert_temp(id, (*rgb, hsv)));
ui.ctx().data_mut(|d| {
@@ -335,6 +367,161 @@ fn parse_hex(text: &str) -> Option<([u8; 3], Option<u8>)> {
Some(([byte(0)?, byte(2)?, byte(4)?], if digits.len() == 8 { Some(byte(6)?) } else { None }))
}
/// How a channel number edits: its range `0..=max`, the step of its step buttons, the drag
/// speed and the decimals shown.
#[derive(Clone, Copy)]
struct Spin {
max: f64,
step: f64,
speed: f64,
decimals: usize,
}
/// One channel of the popover: its name above a drag field (drag, or click and type) and a pair
/// of step buttons for fine adjustment. Returns whether the value changed.
/// Tokens: space_2/3/14, control_height, font_small, form_dim.
fn spin_channel(ui: &mut Ui, name: &str, tip: Option<&str>, value: &mut f64, spin: Spin) -> bool {
let t = Tokens::get();
let Spin { max, step, speed, decimals } = spin;
let mut changed = false;
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = t.space_3;
let label = ui.add(egui::Label::new(egui::RichText::new(name).size(t.font_small).color(t.form_dim)));
if let Some(tip) = tip {
label.on_hover_text(tip);
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = t.space_2;
let width = dimension(ui.available_width() - t.space_14 - t.space_2);
let response = ui.add_sized(
vec2(width, t.control_height),
egui::DragValue::new(value)
.range(0.0..=max)
.speed(speed)
.custom_parser(crate::prefs::parse_number)
.custom_formatter(move |v, _| format!("{v:.decimals$}")),
);
response.widget_info(|| {
let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), *value);
info.label = Some(tip.unwrap_or(name).into());
info
});
publish(&format!("color:channel:{name}"), &response);
let response = match tip {
Some(tip) => response.on_hover_text(tip),
None => response,
};
changed |= response.changed();
let steps = spin_buttons(ui, name, t.control_height);
if steps != 0 {
let current = if value.is_finite() { *value } else { 0.0 };
*value = (current + f64::from(steps) * step).clamp(0.0, max);
changed = true;
}
});
});
changed
}
/// Seconds a step button is held before it repeats, then between repeats.
const REPEAT_DELAY: f64 = 0.4;
const REPEAT_EVERY: f64 = 0.05;
/// Step buttons stacked beside a number: a press steps once, holding repeats. Returns this
/// frame's steps (+ up, − down). Tokens: space_14, control_radius, hover, form_ink, form_dim.
fn spin_buttons(ui: &mut Ui, key: &str, height: f32) -> i32 {
let t = Tokens::get();
let (rect, _) = ui.allocate_exact_size(vec2(t.space_14, height), Sense::hover());
let (top, bottom) = rect.split_top_bottom_at_fraction(0.5);
let mut steps = 0;
for (direction, half, label, end) in [(1, top, "color.step_up", "up"), (-1, bottom, "color.step_down", "down")] {
let id = ui.id().with(("color_spin", key, end));
let response = ui.interact(half, id, Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), i18n::tr(label)));
publish(&format!("color:spin:{key}:{end}"), &response);
let held = response.is_pointer_button_down_on();
if response.hovered() || held {
ui.painter().rect_filled(half, t.control_radius, t.hover);
}
let ink = if response.hovered() || held { t.form_ink } else { t.form_dim };
let side = half.height().min(half.width());
let icon = egui::Rect::from_center_size(half.center(), vec2(side, side));
if direction > 0 {
crate::icons::paint_ink_flipped(ui.painter(), icon, "chevron", ink);
} else {
crate::icons::paint_ink(ui.painter(), icon, "chevron", ink);
}
let repeat = id.with("repeat");
if held {
let now = ui.input(|i| i.time);
let next = ui.ctx().data(|d| d.get_temp::<f64>(repeat));
let due = match next {
None => Some(now + REPEAT_DELAY),
Some(next) if now >= next => Some(now + REPEAT_EVERY),
Some(_) => None,
};
if let Some(due) = due {
steps += direction;
ui.ctx().data_mut(|d| d.insert_temp(repeat, due));
}
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(REPEAT_EVERY));
} else {
ui.ctx().data_mut(|d| d.remove::<f64>(repeat));
}
response.on_hover_text(i18n::tr(label));
}
steps
}
/// One popover's part in the recent list while it stays open (drawn on consecutive passes).
#[derive(Clone, Copy, Default)]
struct RecentSession {
/// The pass the popover was last drawn on; a gap means it was closed and opened again.
pass: u64,
/// The colour changed and the edit is not finished yet (a button is still down).
pending: bool,
/// The colour this opening added, replaced by the next finished edit.
entry: Option<[u8; 3]>,
}
/// The recent colours as a row of samples (empty slots outlined); returns the one clicked.
/// Tokens: space_3/4/8, font_small, form_dim, control_radius/border, border, form_ink.
fn recent_row(ui: &mut Ui, width: f32) -> Option<[u8; 3]> {
let t = Tokens::get();
let colors = recent_colors(ui.ctx());
let mut picked = None;
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = t.space_3;
ui.add(egui::Label::new(egui::RichText::new(i18n::tr("color.recent")).size(t.font_small).color(t.form_dim)));
let gap = t.space_4;
let slots = MAX_RECENT_COLORS as f32;
let side = ((width - gap * (slots - 1.0)) / slots).max(t.space_8);
let (row, response) = ui.allocate_exact_size(vec2(width, side), Sense::hover());
publish("color:recent", &response);
if colors.is_empty() {
response.on_hover_text(i18n::tr("color.recent_help"));
}
for slot in 0..MAX_RECENT_COLORS {
let rect = egui::Rect::from_min_size(row.min + vec2(slot as f32 * (side + gap), 0.0), vec2(side, side));
let Some(&[r, g, b]) = colors.get(slot) else {
ui.painter().rect_stroke(rect, t.control_radius, t.control_border_stroke(t.border), egui::StrokeKind::Inside);
continue;
};
let response = ui.interact(rect, ui.id().with(("color_recent", slot)), Sense::click());
let stroke = if response.hovered() { t.control_border_stroke(t.form_ink) } else { t.control_border_stroke(t.control_border) };
ui.painter().rect(rect, t.control_radius, crate::theme::srgb([r, g, b].map(|c| f32::from(c) / 255.0)), stroke, egui::StrokeKind::Inside);
let hex = format!("#{r:02x}{g:02x}{b:02x}");
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &hex));
publish(&format!("color:recent:{slot}"), &response);
let response = response.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(&hex);
if response.clicked() {
picked = Some([r, g, b]);
}
}
});
picked
}
fn marker(ui: &Ui, at: egui::Pos2, radius: f32) {
let t = Tokens::get();
ui.painter().circle_stroke(at, radius, egui::Stroke::new(t.space_3, t.border));
+164
View File
@@ -337,3 +337,167 @@ fn rendered_square_hue_and_current_strip_leave_rounded_corner_pixels_clear() {
}
crate::theme::set_dark(previous);
}
/// A taller frame than the shared one: the popover's recent row sits below 400 points.
fn tall_in(ctx: &egui::Context, events: Vec<egui::Event>, id: &str, rgb: &mut [u8; 3]) -> bool {
let mut changed = false;
scenario::clear_handles();
let _ = crate::test_support::frame(
ctx,
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 600.0))),
events,
focused: true,
..Default::default()
},
|ui| {
ui.set_width(280.0);
ui.push_id(id, |ui| crate::ui_kit::scope(ui, |ui| changed |= color_popover(ui, rgb)));
},
);
changed
}
fn tall(ctx: &egui::Context, events: Vec<egui::Event>, rgb: &mut [u8; 3]) -> bool {
tall_in(ctx, events, "picker", rgb)
}
fn tall_click(ctx: &egui::Context, key: &str, rgb: &mut [u8; 3]) -> bool {
let at = scenario::handle_at(key).unwrap_or_else(|| panic!("missing {key}"));
tall(ctx, pointer(at, true), rgb) | tall(ctx, pointer(at, false), rgb)
}
fn tall_type(ctx: &egui::Context, field: &str, text: &str, rgb: &mut [u8; 3]) {
tall_click(ctx, &format!("color:channel:{field}"), rgb);
let modifiers = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
tall(
ctx,
vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers }, egui::Event::Text(text.into())],
rgb,
);
tall(ctx, crate::ui_kit::test_support::key(egui::Key::Enter), rgb);
}
fn hsv_bytes(h: f32, s: f32, v: f32) -> [u8; 3] {
let c = Color32::from(HsvaGamma { h, s, v, a: 1.0 });
[c.r(), c.g(), c.b()]
}
#[test]
fn step_buttons_nudge_rgb_and_hsv_channels_by_one() {
let ctx = crate::fonts::test_context();
let mut rgb = [10, 20, 30];
tall(&ctx, vec![], &mut rgb);
assert!(tall_click(&ctx, "color:spin:R:up", &mut rgb));
assert_eq!(rgb, [11, 20, 30]);
assert!(tall_click(&ctx, "color:spin:B:down", &mut rgb));
assert_eq!(rgb, [11, 20, 29]);
// Limits hold: 0 does not step below 0.
let mut rgb = [0, 0, 0];
tall(&ctx, vec![], &mut rgb);
tall_click(&ctx, "color:spin:G:down", &mut rgb);
assert_eq!(rgb, [0, 0, 0]);
// H steps one degree, V one percent, on the same gamma HSV as the square.
let mut rgb = [255, 0, 0];
tall(&ctx, vec![], &mut rgb);
tall_click(&ctx, "color:spin:H:up", &mut rgb);
assert_eq!(rgb, hsv_bytes(1.0 / 360.0, 1.0, 1.0));
tall_click(&ctx, "color:spin:V:down", &mut rgb);
assert_eq!(rgb, hsv_bytes(1.0 / 360.0, 1.0, 0.99));
}
#[test]
fn typed_hsv_values_set_the_colour_within_their_ranges() {
let ctx = crate::fonts::test_context();
let mut rgb = [255, 0, 0];
tall(&ctx, vec![], &mut rgb);
tall_type(&ctx, "H", "120", &mut rgb);
tall_type(&ctx, "S", "50", &mut rgb);
assert_eq!(rgb, hsv_bytes(1.0 / 3.0, 0.5, 1.0));
// A hostile typed value is bounded by the field's range.
tall_type(&ctx, "V", "1e300", &mut rgb);
assert_eq!(rgb, hsv_bytes(1.0 / 3.0, 0.5, 1.0));
}
#[test]
fn finished_edits_join_one_recent_list_that_every_picker_offers() {
let ctx = crate::fonts::test_context();
let mut rgb = [10, 20, 30];
tall(&ctx, vec![], &mut rgb);
assert!(recent_colors(&ctx).is_empty(), "opening a picker records nothing");
// A drag across the square records only its final colour, once released.
let square = scenario::rect_of("color:sv").unwrap();
let end = square.left_top() + vec2(10.0, 10.0);
tall(&ctx, pointer(square.center(), true), &mut rgb);
tall(&ctx, vec![egui::Event::PointerMoved(end)], &mut rgb);
assert!(recent_colors(&ctx).is_empty(), "nothing is recorded mid-drag");
tall(&ctx, pointer(end, false), &mut rgb);
assert_eq!(recent_colors(&ctx), vec![rgb]);
// Fine-tuning in the same opening replaces that entry instead of adding one per step.
tall_click(&ctx, "color:spin:R:up", &mut rgb);
tall_click(&ctx, "color:spin:R:up", &mut rgb);
assert_eq!(recent_colors(&ctx), vec![rgb]);
let tuned = rgb;
// Closed (a pass without the popover) and opened again: the next edit is a new entry.
crate::ui_kit::test_support::frame(&ctx, vec![], |_| {});
tall(&ctx, vec![], &mut rgb);
tall_click(&ctx, "color:spin:G:up", &mut rgb);
assert_eq!(recent_colors(&ctx), vec![rgb, tuned]);
let second = rgb;
// Another picker (another colour field of the app) shows the same list and takes from it.
let mut other = [200, 200, 200];
tall_in(&ctx, vec![], "another_picker", &mut other);
assert!(scenario::handle_at("color:recent:2").is_none(), "empty slots are not buttons");
let slot = scenario::handle_at("color:recent:1").unwrap();
tall_in(&ctx, pointer(slot, true), "another_picker", &mut other);
assert!(tall_in(&ctx, pointer(slot, false), "another_picker", &mut other));
assert_eq!(other, tuned);
assert_eq!(recent_colors(&ctx), vec![tuned, second], "a picked recent colour moves to the front");
// Tuning a colour taken from the list adds a new entry and keeps the one it started from.
let up = scenario::handle_at("color:spin:B:up").unwrap();
tall_in(&ctx, pointer(up, true), "another_picker", &mut other);
tall_in(&ctx, pointer(up, false), "another_picker", &mut other);
assert_eq!(recent_colors(&ctx), vec![other, tuned, second]);
}
#[test]
fn recent_list_is_capped_unique_and_synced_with_the_saved_one() {
let ctx = crate::fonts::test_context();
for i in 0..40u8 {
remember_color(&ctx, [i, 0, 0], None);
}
remember_color(&ctx, [35, 0, 0], None);
let recent = recent_colors(&ctx);
assert_eq!(recent.len(), MAX_RECENT_COLORS);
assert_eq!(recent.first(), Some(&[35, 0, 0]));
assert_eq!(recent.iter().filter(|c| **c == [35, 0, 0]).count(), 1);
// Picks flow to the saved list once…
let mut saved = Vec::new();
crate::recent_colors::share(&ctx, &mut saved);
assert_eq!(saved, recent);
// …and a list loaded from preferences (even an oversized one) is what the pickers show.
let mut loaded: Vec<[u8; 3]> = (0..100u8).map(|i| [0, i, 0]).collect();
crate::recent_colors::share(&ctx, &mut loaded);
assert_eq!(loaded.len(), MAX_RECENT_COLORS);
assert_eq!(recent_colors(&ctx), loaded);
}
#[test]
fn recent_colours_are_saved_with_the_preferences_and_hostile_entries_dropped() {
use serde_json::json;
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
let mut list = vec![json!([1, 2, 3]), json!([300, 0, 0]), json!("red"), json!([1, 2]), json!(null)];
list.extend((0..50).map(|i| json!([i, i, i])));
app.load_prefs(&json!({ "recent_colors": list }).to_string());
assert_eq!(app.ui.recent_colors.len(), MAX_RECENT_COLORS);
assert_eq!(app.ui.recent_colors.first(), Some(&[1, 2, 3]));
let ctx = crate::fonts::test_context();
crate::recent_colors::share(&ctx, &mut app.ui.recent_colors);
remember_color(&ctx, [9, 8, 7], None);
crate::recent_colors::share(&ctx, &mut app.ui.recent_colors);
let mut restored = crate::SolveApp::new(solidcraft_engine::Session::default(), Default::default());
restored.load_prefs(&app.prefs());
assert_eq!(restored.ui.recent_colors.first(), Some(&[9, 8, 7]));
assert_eq!(restored.ui.recent_colors.get(1), Some(&[1, 2, 3]));
assert_eq!(restored.ui.recent_colors.len(), MAX_RECENT_COLORS);
}