diff --git a/assets/locales/pending/color-popover-hsv-recent.json b/assets/locales/pending/color-popover-hsv-recent.json new file mode 100644 index 0000000000..5184bc13cb --- /dev/null +++ b/assets/locales/pending/color-popover-hsv-recent.json @@ -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": "明度(百分比)" + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955a833a81..55ca5177c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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) -> 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. diff --git a/crates/ui-egui/src/recent_colors.rs b/crates/ui-egui/src/recent_colors.rs new file mode 100644 index 0000000000..f77376b8c9 --- /dev/null +++ b/crates/ui-egui/src/recent_colors.rs @@ -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::(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::(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::(recent_id()); + if recent.changed { + saved.clone_from(&recent.colors); + recent.changed = false; + } else if recent.colors != *saved { + recent.colors.clone_from(saved); + } + }); +} diff --git a/crates/ui-egui/src/ui_kit/color.rs b/crates/ui-egui/src/ui_kit/color.rs index aaf5a54532..dfef5e72aa 100644 --- a/crates/ui-egui/src/ui_kit/color.rs +++ b/crates/ui-egui/src/ui_kit/color.rs @@ -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::(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::>(); + 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::(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)> { 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::(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::(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)); diff --git a/crates/ui-egui/src/ui_kit/color_tests.rs b/crates/ui-egui/src/ui_kit/color_tests.rs index 07bb7f1e2d..ffc05c0f05 100644 --- a/crates/ui-egui/src/ui_kit/color_tests.rs +++ b/crates/ui-egui/src/ui_kit/color_tests.rs @@ -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, 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, 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); +}