mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* 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:
@@ -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": "明度(百分比)"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user