GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/color_selector.rs (43.1K)
1 use crate::colors;
2 use crate::scene::layout::{Rect, Size};
3 use crate::scene::paint::PaintCtx;
4 use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
5 use crate::widget::*;
6
7 #[derive(Debug)]
8 pub struct ColorSelector {
9 pub color: [u8; 3],
10 pub alpha: u8,
11 just_clicked: bool,
12 pub editing: bool,
13 pub(crate) edit_buffer: String,
14 pub cursor_idx: usize,
15 pub font_family: String,
16 pub command: String,
17 hovered: bool,
18 child: std::sync::Arc<std::sync::Mutex<Option<std::process::Child>>>,
19 pub editor_state: TextEditorState,
20 pub just_changed: bool,
21 pub with_alpha: bool,
22 /// Live color lines from the running picker (`cce-color-editor --stream` prints
23 /// every change), forwarded by a reader thread — the value applies while
24 /// the editor stays open instead of on exit.
25 live_rx: Option<std::sync::mpsc::Receiver<String>>,
26 /// The value at picker launch, restored when the stream reports `cancel`.
27 revert_hex: Option<String>,
28 /// Char-index → x offsets of the drawn hex text, recorded by
29 /// [`Paint::prepare_text`] from the same shaped buffer `ctx.text` draws
30 /// (size 12, default family). The caret reads these; the SVG-rasterized
31 /// `measure_text` prefix it used before reports inked extent, which drifts
32 /// off the glyph advances. Empty until the first shape.
33 glyph_offsets: Vec<f32>,
34 /// Recessed style: the hex field is a well carved into the plate below
35 /// (the TextBox's, rim lit while editing) and the swatch a raised bevel
36 /// plate of its colour, instead of the hairline frame and the flat swatch
37 /// with its glow. Defaults to `control_relief()`.
38 recessed: Option<bool>,
39 }
40
41 impl Clone for ColorSelector {
42 fn clone(&self) -> Self {
43 Self {
44 color: self.color,
45 alpha: self.alpha,
46 just_clicked: self.just_clicked,
47 editing: self.editing,
48 edit_buffer: self.edit_buffer.clone(),
49 cursor_idx: self.cursor_idx,
50 font_family: self.font_family.clone(),
51 command: self.command.clone(),
52 hovered: self.hovered,
53 child: std::sync::Arc::new(std::sync::Mutex::new(None)),
54 editor_state: self.editor_state.clone(),
55 just_changed: self.just_changed,
56 with_alpha: self.with_alpha,
57 live_rx: None,
58 revert_hex: None,
59 glyph_offsets: self.glyph_offsets.clone(),
60 recessed: self.recessed,
61 }
62 }
63 }
64
65 impl ColorSelector {
66 /// The style in force: the per-widget override (`with_recessed`) when set, else
67 /// the DE's `control_relief`, read live so a runtime switch
68 /// (`layout::set_control_relief`) restyles every control at once.
69 fn recessed(&self) -> bool {
70 self.recessed.unwrap_or_else(crate::layout::control_relief)
71 }
72
73 pub fn new(color: [u8; 3]) -> Adapted<ColorSelector> {
74 Adapted::new(ColorSelector {
75 color,
76 alpha: 255,
77 just_clicked: false,
78 editing: false,
79 edit_buffer: String::new(),
80 cursor_idx: 0,
81 font_family: crate::layout::color_selector_font(),
82 command: "cce-color-editor".to_string(),
83 hovered: false,
84 child: std::sync::Arc::new(std::sync::Mutex::new(None)),
85 editor_state: TextEditorState::new(String::new()),
86 just_changed: false,
87 with_alpha: false,
88 live_rx: None,
89 revert_hex: None,
90 glyph_offsets: Vec::new(),
91 recessed: None,
92 })
93 }
94
95 pub fn new_rgba(color: [u8; 4]) -> Adapted<ColorSelector> {
96 Adapted::new(ColorSelector {
97 color: [color[0], color[1], color[2]],
98 alpha: color[3],
99 just_clicked: false,
100 editing: false,
101 edit_buffer: String::new(),
102 cursor_idx: 0,
103 font_family: crate::layout::color_selector_font(),
104 command: "cce-color-editor".to_string(),
105 hovered: false,
106 child: std::sync::Arc::new(std::sync::Mutex::new(None)),
107 editor_state: TextEditorState::new(String::new()),
108 just_changed: false,
109 with_alpha: true,
110 live_rx: None,
111 revert_hex: None,
112 glyph_offsets: Vec::new(),
113 recessed: None,
114 })
115 }
116
117 /// The hex field's well for hosts that draw this control through the legacy
118 /// flat views (see `ParametersBg::reliefs`): (x, y, w, h, radius, depth) over the
119 /// widget's assigned content `rect`, or None when the style is off. The same
120 /// geometry `paint` carves (untinted).
121 pub fn field_relief(&self, rect: Rect) -> Option<(f32, f32, f32, f32, f32, f32)> {
122 if !self.recessed() {
123 return None;
124 }
125 let well_h = crate::layout::color_selector_height().min(rect.height);
126 let radius = crate::layout::textbox_corner_radius();
127 let depth = crate::layout::bevel_width().min(well_h * 0.2);
128 // One well across the whole control: the hex text and the swatch are
129 // segments of its floor (the Breadcrumb composition), not two parts.
130 let (well, radii) = crate::layout::carve_inside(
131 Rect { x: rect.x, y: rect.y, width: rect.width, height: well_h },
132 (radius, radius, radius, radius),
133 depth,
134 );
135 Some((well.x, well.y, well.width, well.height, radii.0, depth))
136 }
137
138 }
139
140 impl Adapted<ColorSelector> {
141 /// Recessed style: see the `recessed` field.
142 pub fn with_recessed(mut self, recessed: bool) -> Self {
143 self.recessed = Some(recessed);
144 self
145 }
146
147 pub fn with_alpha(mut self, with_alpha: bool) -> Self {
148 self.with_alpha = with_alpha;
149 self
150 }
151
152 pub fn with_font_family(mut self, font_family: &str) -> Self {
153 self.font_family = font_family.to_string();
154 self
155 }
156
157 pub fn with_command(mut self, command: &str) -> Self {
158 self.command = command.to_string();
159 self
160 }
161 }
162
163 impl ColorSelector {
164 fn value_hex(&self) -> String {
165 if self.with_alpha {
166 Some(format!("#{:02x}{:02x}{:02x}{:02x}", self.color[0], self.color[1], self.color[2], self.alpha))
167 } else {
168 Some(format!("#{:02x}{:02x}{:02x}", self.color[0], self.color[1], self.color[2]))
169 }
170
171 .unwrap()
172 }
173
174 fn begin_edit(&mut self) {
175 self.editing = true;
176 self.edit_buffer = self.value_hex();
177 self.cursor_idx = self.edit_buffer.chars().count();
178 }
179 }
180
181 impl Layout for ColorSelector {
182 fn intrinsic_size(&self) -> Option<Size> {
183 Some(Size::new(0.0, crate::layout::color_selector_height()))
184 }
185 }
186
187 impl Paint for ColorSelector {
188 fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
189 // Shape the drawn hex string exactly as `ctx.text` draws it (size 12,
190 // default family) and record char-index → x for the caret.
191 let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
192 let clusters =
193 crate::backend::text::shaped_cluster_offsets(fs, &text, 12.0, None);
194 let mut offsets = vec![0.0f32; text.chars().count() + 1];
195 for (byte, x) in clusters {
196 let ci = text[..byte.min(text.len())].chars().count();
197 if ci < offsets.len() {
198 offsets[ci] = x;
199 }
200 }
201 let mut current = 0.0;
202 for off in offsets.iter_mut() {
203 if *off == 0.0 {
204 *off = current;
205 } else {
206 current = *off;
207 }
208 }
209 self.glyph_offsets = offsets;
210 }
211
212 fn color(&self) -> [f32; 4] {
213 colors::to_linear([
214 self.color[0] as f32 / 255.0,
215 self.color[1] as f32 / 255.0,
216 self.color[2] as f32 / 255.0,
217 self.alpha as f32 / 255.0,
218 ])
219 }
220
221 fn widget_font(&self) -> Option<String> {
222 Some(self.font_family.clone())
223 }
224
225 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
226 let r = crate::layout::color_selector_corner_radius();
227 if r > 0.0 {
228 Some((r, (true, true, true, true)))
229 } else {
230 None
231 }
232 }
233
234 /// The legacy `extra_quads` body against the laid-out rect (field, caret while
235 /// editing, and the soft-glow rounded color preview), plus the hex readout label.
236 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
237 // The well is color_selector_height tall, seated at the content rect's TOP —
238 // the label rides in the strip above it and the row's bottom band belongs
239 // to the NEXT row's label. Hosts that hand
240 // over a whole param row (ParametersBg's 40px color rows) get a
241 // standard control-height well instead of a row-tall one.
242 let well_h = crate::layout::color_selector_height().min(rect.height);
243 let rect = Rect { x: rect.x, y: rect.y, width: rect.width, height: well_h };
244 let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
245 let visual_h = rect.height;
246 let pick_x = rect.x + rect.width * 0.65;
247 let pick_w = rect.width * 0.35;
248 // Typing a hex value: the on-screen keyboard follows
249 // (`crate::text_input`).
250 if self.editing {
251 let (ox, oy) = ctx.offset();
252 crate::text_input::claim(rect.x + ox, rect.y + oy, pick_x - rect.x, visual_h);
253 }
254
255 // The text field has NO face of its own — a frame over the host plate,
256 // like a relief TextBox well (transparent fill, the outline defines
257 // it) and the closed-dropdown convention. The first colorless pass
258 // used the textbox background here, but under the DE's relief themes
259 // real text wells draw no fill, so even a neutral one read as "the
260 // color selector has a background". Neutral greys for the frame; the
261 // caret is the editing affordance.
262 let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
263
264 // A real frame, not a border-quad-under-fill-quad: with no fill, the
265 // old full-rect border quad would read as a solid slab. Rounded at the
266 // selector's radius like the well it stands in for.
267 if !self.recessed() {
268 let fr = crate::layout::color_selector_corner_radius();
269 ctx.border(rect, (fr, fr, fr, fr), [0.0; 4], border_color, 1.0);
270 }
271
272 if self.editing {
273 let font_size = 12.0;
274 let text_w = self.glyph_offsets.get(self.cursor_idx).copied().unwrap_or_else(|| {
275 let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
276 crate::widget::display::measure_text(&cursor_text, font_size)
277 });
278 let caret_x = rect.x + crate::layout::CONTROL_TEXT_INSET + text_w;
279 let caret_h = font_size * 1.15;
280 let caret_y = rect.y + (visual_h - caret_h) / 2.0;
281 quads.push((caret_x, caret_y, 1.5, caret_h, [0.80, 0.80, 0.85, 1.0]));
282 }
283
284 let linear_c = colors::to_linear([
285 self.color[0] as f32 / 255.0,
286 self.color[1] as f32 / 255.0,
287 self.color[2] as f32 / 255.0,
288 self.alpha as f32 / 255.0,
289 ]);
290 let border_w = 1.0;
291 let border_c = colors::color_borders_color();
292
293 let r = border_c[0];
294 let g = border_c[1];
295 let b = border_c[2];
296
297 let preview_radius = crate::layout::color_selector_preview_corner_radius();
298 let preview_margin = crate::layout::color_selector_preview_margin();
299
300 let px = pick_x + preview_margin;
301 let py = rect.y + preview_margin;
302 let pw = (pick_w - 2.0 * preview_margin).max(0.0);
303 let ph = (visual_h - 2.0 * preview_margin).max(0.0);
304
305 let add_rounded_rect = |quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, color: [f32; 4], x: f32, y: f32, w: f32, h: f32, radius: f32| {
306 let radius = radius.min(w * 0.5).min(h * 0.5);
307 if radius <= 0.5 {
308 quads.push((x, y, w, h, color));
309 return;
310 }
311
312 quads.push((x + radius, y, w - 2.0 * radius, h, color));
313 quads.push((x, y + radius, radius, h - 2.0 * radius, color));
314 quads.push((x + w - radius, y + radius, radius, h - 2.0 * radius, color));
315
316 let steps = radius.round() as i32;
317 for i in 0..steps {
318 let dy = i as f32;
319 let next_dy = (i + 1) as f32;
320
321 let cx = (radius * radius - (radius - dy) * (radius - dy)).sqrt();
322 let next_cx = (radius * radius - (radius - next_dy) * (radius - next_dy)).sqrt();
323 let avg_cx = (cx + next_cx) * 0.5;
324
325 let strip_w = avg_cx;
326 let strip_h = 1.0f32;
327
328 if strip_w > 0.0 {
329 quads.push((x + radius - strip_w, y + dy, strip_w, strip_h, color));
330 quads.push((x + w - radius, y + dy, strip_w, strip_h, color));
331 quads.push((x + radius - strip_w, y + h - dy - strip_h, strip_w, strip_h, color));
332 quads.push((x + w - radius, y + h - dy - strip_h, strip_w, strip_h, color));
333 }
334 }
335 };
336
337 if self.recessed() {
338 // The Breadcrumb composition: ONE well across the whole control,
339 // the hex text on its floor at the left and the swatch as the
340 // colour laid flush on the floor's right segment (the well's
341 // rounded end is its own), the two parted by a seam groove. The
342 // caret quad first (it rides on the floor), the carve after the
343 // fills so the walls' shading falls over both, the seam last so it
344 // dies into the well's rolled edge. Over a checker where the
345 // colour carries alpha, so the transparency reads through.
346 for (qx, qy, qw, qh, qc) in quads.drain(..) {
347 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
348 }
349 let Some((wx, wy, ww, wh, radius, depth)) = self.field_relief(rect) else {
350 return;
351 };
352 let well = Rect { x: wx, y: wy, width: ww, height: wh };
353 // The floor: past the wall's inner half-span (the ProgressBar's inset).
354 let floor = Rect {
355 x: rect.x + depth,
356 y: rect.y + depth,
357 width: (rect.width - 2.0 * depth).max(0.0),
358 height: (visual_h - 2.0 * depth).max(0.0),
359 };
360 let seam_x = pick_x;
361 let swatch = Rect { x: seam_x, y: floor.y, width: (floor.x + floor.width - seam_x).max(0.0), height: floor.height };
362 let sr = crate::layout::textbox_corner_radius().min(swatch.height * 0.5);
363 let right_end = (false, true, true, false);
364 if self.with_alpha {
365 ctx.rounded_rect(swatch, sr, right_end, [0.8, 0.8, 0.8, 1.0]);
366 // The white cells, those at the right edge trimmed to the end's
367 // arc at their own row (the Breadcrumb's banded-wash sampling).
368 //
369 // Zero-radius rounded rects, NOT plain quads. The adapter's
370 // legacy plain-quad view (`own_plain_quads`) is this paint
371 // filtered to `Prim::Quad`, and a host that reads that view
372 // beside the paint — the designer, through ParametersBg's
373 // `plain_quads` — draws it AFTER the prims. As quads the
374 // cells came back a second time OVER the colour fill below,
375 // so every colour but white showed a checker as if it were
376 // transparent (invisible with the default white, 2026-09-21).
377 let grid = 6.0;
378 let cols = (swatch.width / grid).ceil() as i32;
379 let rows = (swatch.height / grid).ceil() as i32;
380 let arc = |yc: f32| -> f32 {
381 let dy = if yc < swatch.y + sr {
382 sr - (yc - swatch.y)
383 } else if yc > swatch.y + swatch.height - sr {
384 yc - (swatch.y + swatch.height - sr)
385 } else {
386 return 0.0;
387 };
388 sr - (sr * sr - dy * dy).max(0.0).sqrt()
389 };
390 for r in 0..rows {
391 for c in 0..cols {
392 if (r + c) % 2 == 1 {
393 let qx = swatch.x + c as f32 * grid;
394 let qy = swatch.y + r as f32 * grid;
395 let qh = grid.min(swatch.y + swatch.height - qy);
396 let right_edge = swatch.x + swatch.width - arc(qy + qh * 0.5);
397 let qw = grid.min(right_edge - qx);
398 if qw > 0.0 && qh > 0.0 {
399 ctx.rounded_rect(
400 Rect { x: qx, y: qy, width: qw, height: qh },
401 0.0,
402 (false, false, false, false),
403 [1.0, 1.0, 1.0, 1.0],
404 );
405 }
406 }
407 }
408 }
409 }
410 ctx.rounded_rect(swatch, sr, right_end, linear_c);
411 let radii = (radius, radius, radius, radius);
412 if self.editing {
413 let hc = crate::color::highlight_primary_color();
414 ctx.recess_tinted(well, radii, depth, [hc[0], hc[1], hc[2]]);
415 } else {
416 ctx.recess(well, radii, depth);
417 }
418 ctx.groove(
419 (seam_x, well.y),
420 (seam_x, well.y + well.height),
421 crate::widget::Breadcrumb::SEAM_WIDTH,
422 depth,
423 well,
424 );
425 let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
426 // Bounded by the seam: the field is the well left of it, and while
427 // `editing` this holds whatever has been typed, not a 7-character
428 // hex code.
429 ctx.text_with(
430 hex,
431 rect.x + crate::layout::CONTROL_TEXT_INSET,
432 crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
433 12.0,
434 [0xcc, 0xcc, 0xd4],
435 None,
436 Some([rect.x, rect.y, seam_x, rect.y + rect.height]),
437 );
438 return;
439 }
440
441 let steps = 6;
442 for i in (1..=steps).rev() {
443 let offset = i as f32 * 0.75;
444 let rx = px - offset;
445 let ry = py - offset;
446 let rw = pw + 2.0 * offset;
447 let rh = ph + 2.0 * offset;
448 let alpha = 0.08 * (1.0 - (i as f32 / steps as f32).powf(1.5));
449 if alpha > 0.001 {
450 add_rounded_rect(&mut quads, [r, g, b, alpha], rx, ry, rw, rh, preview_radius + offset);
451 }
452 }
453
454 add_rounded_rect(&mut quads, border_c, px, py, pw, ph, preview_radius);
455
456 if self.with_alpha {
457 add_rounded_rect(
458 &mut quads,
459 [0.8, 0.8, 0.8, 1.0],
460 px + border_w,
461 py + border_w,
462 (pw - 2.0 * border_w).max(0.0),
463 (ph - 2.0 * border_w).max(0.0),
464 (preview_radius - border_w).max(0.0),
465 );
466
467 let grid_size = 6.0;
468 let start_x = px + border_w;
469 let start_y = py + border_w;
470 let inner_w = (pw - 2.0 * border_w).max(0.0);
471 let inner_h = (ph - 2.0 * border_w).max(0.0);
472
473 let cols = (inner_w / grid_size).ceil() as i32;
474 let rows = (inner_h / grid_size).ceil() as i32;
475 for r in 0..rows {
476 for c in 0..cols {
477 if (r + c) % 2 == 1 {
478 let qx = start_x + c as f32 * grid_size;
479 let qy = start_y + r as f32 * grid_size;
480 let qw = grid_size.min(start_x + inner_w - qx);
481 let qh = grid_size.min(start_y + inner_h - qy);
482 if qw > 0.0 && qh > 0.0 {
483 quads.push((qx, qy, qw, qh, [1.0, 1.0, 1.0, 1.0]));
484 }
485 }
486 }
487 }
488 }
489
490 add_rounded_rect(
491 &mut quads,
492 linear_c,
493 px + border_w,
494 py + border_w,
495 (pw - 2.0 * border_w).max(0.0),
496 (ph - 2.0 * border_w).max(0.0),
497 (preview_radius - border_w).max(0.0),
498 );
499
500 for (qx, qy, qw, qh, qc) in quads {
501 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
502 }
503
504 let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
505 ctx.text_with(
506 hex,
507 rect.x + crate::layout::CONTROL_TEXT_INSET,
508 crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
509 12.0,
510 [0xcc, 0xcc, 0xd4],
511 None,
512 Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]),
513 );
514 }
515 }
516
517 impl Input for ColorSelector {
518 fn focus_role(&self) -> crate::widget::FocusRole {
519 crate::widget::FocusRole::Well
520 }
521 fn opens_context_menu(&self) -> bool {
522 true
523 }
524
525 fn take_click(&mut self) -> bool {
526 if self.just_clicked { self.just_clicked = false; true } else { false }
527 }
528
529 fn take_change(&mut self) -> bool {
530 let ret = self.just_changed;
531 self.just_changed = false;
532 ret
533 }
534
535 fn value_string(&self) -> Option<String> {
536 Some(self.value_hex())
537 }
538
539 fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
540 // The hex field: what it shows, its caret while it is being typed into.
541 let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
542 let caret = self.cursor_idx.min(text.chars().count());
543 Some(crate::a11y::A11yText {
544 text,
545 selection: self.editing.then_some((caret, caret)),
546 editable: true,
547 kind: Some(crate::l10n::tr("a11y-colour")),
548 ..Default::default()
549 })
550 }
551
552 fn a11y_set_text(&mut self, text: &str) -> bool {
553 // A colour, as the field takes one when typed: a hex it parses, or nothing.
554 let text = text.trim();
555 if self.set_value_string(text) {
556 return true;
557 }
558 // The colour it already holds, set while a half-typed hex is up: the field shows it.
559 if self.editing && parse_hex(text).is_some() && self.edit_buffer != self.value_hex() {
560 self.edit_buffer = self.value_hex();
561 self.cursor_idx = self.edit_buffer.chars().count();
562 return true;
563 }
564 false
565 }
566
567 fn set_value_string(&mut self, val: &str) -> bool {
568 if let Some(c) = parse_hex(val) {
569 let target_color = [c[0], c[1], c[2]];
570 let target_alpha = if self.with_alpha { c[3] } else { 255 };
571 if self.color != target_color || (self.with_alpha && self.alpha != target_alpha) {
572 self.color = target_color;
573 self.alpha = target_alpha;
574 self.just_changed = true;
575 if self.editing {
576 self.edit_buffer = self.value_hex();
577 self.cursor_idx = self.edit_buffer.chars().count();
578 }
579 return true;
580 }
581 }
582 false
583
584 }
585
586 fn wants_tick(&self) -> bool {
587 true
588 }
589
590 fn tick(&mut self, _dt: f32, _rect: Rect) -> bool {
591 // Apply streamed picker lines as they arrive — the color changes live
592 // while the editor stays open. The picker's Apply prints a final line
593 // (already applied here); Cancel prints `cancel`, restoring the value
594 // the picker launched with.
595 let mut redraw = false;
596 if let Some(rx) = &self.live_rx {
597 // A live picker session is activity: the runner sleeps between
598 // ticks when nothing is animating, and these lines come from a
599 // reader thread it cannot see, so keep the frame cadence for as
600 // long as the picker is open.
601 redraw = true;
602 let mut lines = Vec::new();
603 let mut disconnected = false;
604 loop {
605 match rx.try_recv() {
606 Ok(line) => lines.push(line),
607 Err(std::sync::mpsc::TryRecvError::Empty) => break,
608 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
609 disconnected = true;
610 break;
611 }
612 }
613 }
614 if disconnected {
615 self.live_rx = None;
616 }
617 for line in lines {
618 let line = line.trim().to_string();
619 let target = if line == "cancel" {
620 self.revert_hex.clone().and_then(|h| parse_hex(&h))
621 } else {
622 parse_hex(&line)
623 };
624 if let Some(c) = target {
625 let color = [c[0], c[1], c[2]];
626 let alpha = if self.with_alpha { c[3] } else { 255 };
627 if self.color != color || self.alpha != alpha {
628 self.color = color;
629 self.alpha = alpha;
630 self.just_changed = true;
631 redraw = true;
632 }
633 }
634 }
635 }
636 let mut child_opt = self.child.lock().unwrap();
637 if let Some(ref mut child) = *child_opt {
638 match child.try_wait() {
639 Ok(Some(_status)) => {
640 // The reader thread owns stdout and has already forwarded
641 // every line (including Apply's final one) — just reap.
642 *child_opt = None;
643 }
644 Ok(None) => {}
645 Err(e) => {
646 log::error!("Error checking color selector child process: {:?}", e);
647 *child_opt = None;
648 }
649 }
650 }
651 redraw
652 }
653
654 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
655 match event {
656 Event::MouseButton { button, state, x, y, .. } => {
657 if *button != MouseButton::Left {
658 return false;
659 }
660 if *state != ElementState::Pressed {
661 return false;
662 }
663 let (px, py) = (*x, *y);
664 let _ = py;
665 let rect = ectx.rect;
666 if px >= rect.x + rect.width * 0.65 {
667 let hex = self.value_hex();
668 let mut child_guard = self.child.lock().unwrap();
669 if let Some(mut old_child) = child_guard.take() {
670 let _ = old_child.kill();
671 }
672
673 let cmd_path = if let Ok(mut exe_path) = std::env::current_exe() {
674 exe_path.pop(); // remove executable name
675 let local_path = exe_path.join(&self.command);
676 if local_path.exists() {
677 local_path.to_string_lossy().into_owned()
678 } else {
679 let home = std::env::var("HOME").unwrap_or_default();
680 let local_bin = std::path::Path::new(&home).join(".local/bin").join(&self.command);
681 if local_bin.exists() {
682 local_bin.to_string_lossy().into_owned()
683 } else {
684 self.command.clone()
685 }
686 }
687 } else {
688 self.command.clone()
689 };
690
691 place_picker_at_pointer(&self.command);
692
693 let mut cmd = std::process::Command::new(&cmd_path);
694 cmd.arg(&hex);
695 if self.with_alpha {
696 cmd.arg("--alpha");
697 }
698 // Live picking: the picker streams every change on stdout; a
699 // reader thread forwards lines so `tick` applies them while the
700 // editor stays open. `cancel` restores the launch value.
701 cmd.arg("--stream");
702 if let Ok(mut child) = cmd.stdout(std::process::Stdio::piped()).spawn() {
703 if let Some(stdout) = child.stdout.take() {
704 let (tx, rx) = std::sync::mpsc::channel::<String>();
705 std::thread::spawn(move || {
706 use std::io::BufRead;
707 let reader = std::io::BufReader::new(stdout);
708 for line in reader.lines().map_while(Result::ok) {
709 if tx.send(line).is_err() {
710 break;
711 }
712 }
713 });
714 self.live_rx = Some(rx);
715 self.revert_hex = Some(hex.clone());
716 }
717 *child_guard = Some(child);
718 }
719 return true;
720 }
721 ectx.request_focus();
722 true
723 }
724 Event::KeyInput(event) => {
725 if event.state != ElementState::Pressed {
726 return false;
727 }
728 if !self.editing {
729 return false;
730 }
731
732
733
734 let mut state = TextEditorState {
735 buffer: self.edit_buffer.clone(),
736 cursor_idx: self.cursor_idx,
737 select_anchor: None,
738 all_selected: false,
739 };
740
741 let mut handled = false;
742 match &event.logical_key {
743 Key::Named(NamedKey::Backspace) => {
744 state.delete_backwards();
745 handled = true;
746 }
747 Key::Named(NamedKey::Delete) => {
748 state.delete_forwards();
749 handled = true;
750 }
751 Key::Named(NamedKey::ArrowLeft) => {
752 state.move_cursor_left(false);
753 handled = true;
754 }
755 Key::Named(NamedKey::ArrowRight) => {
756 state.move_cursor_right(false);
757 handled = true;
758 }
759 Key::Named(NamedKey::Enter) => {
760 if let Some(c) = parse_hex(&state.buffer) {
761 self.color = [c[0], c[1], c[2]];
762 self.alpha = if self.with_alpha { c[3] } else { 255 };
763 }
764 self.editing = false;
765 handled = true;
766 }
767 Key::Named(NamedKey::Escape) => {
768 self.editing = false;
769 handled = true;
770 }
771 _ => {
772 if let Some(text) = &event.text {
773 for ch in text.chars() {
774 match ch {
775 '#' => {
776 // A "#" only leads the value.
777 if state.buffer.is_empty() || (state.cursor_idx == 0 && !state.buffer.starts_with('#')) {
778 state.insert_text("#");
779 handled = true;
780 }
781 }
782 '0'..='9' | 'a'..='f' | 'A'..='F' => {
783 let count = state.buffer.chars().count();
784 let max_len = if state.buffer.starts_with('#') {
785 if self.with_alpha { 9 } else { 7 }
786 } else {
787 if self.with_alpha { 8 } else { 6 }
788 };
789 if count < max_len {
790 state.insert_text(&ch.to_ascii_lowercase().to_string());
791 handled = true;
792 }
793 }
794 _ => {}
795 }
796 }
797 }
798 }
799 }
800
801 if self.editing {
802 self.edit_buffer = state.buffer;
803 self.cursor_idx = state.cursor_idx;
804 }
805 handled
806
807 }
808 Event::MouseEnter => {
809 self.hovered = true;
810 false
811 }
812 Event::MouseLeave => {
813 self.hovered = false;
814 false
815 }
816 Event::FocusIn => {
817 self.begin_edit();
818 false
819 }
820 Event::FocusOut => {
821 if self.editing {
822 self.editing = false;
823 if let Some(c) = parse_hex(&self.edit_buffer) {
824 self.color = [c[0], c[1], c[2]];
825 self.alpha = if self.with_alpha { c[3] } else { 255 };
826 }
827 }
828 false
829 }
830 _ => false,
831 }
832 }
833 }
834
835 fn parse_hex(s: &str) -> Option<[u8; 4]> {
836 crate::color::parse_hex_bytes(s)
837 }
838
839 /// Ask the compositor to open the picker at this control instead of its
840 /// remembered position. Native only: in a browser there is no compositor to ask.
841 #[cfg(not(target_arch = "wasm32"))]
842 fn place_picker_at_pointer(command: &str) {
843 // The pointer is on the swatch right now, so its location IS the
844 // control's location. One-shot, best-effort (`place-next` consumed at
845 // the picker's map; ignored off-cce).
846 if let Ok(reply) = crate::ipc::send_command("cce", "pointer-location") {
847 let mut px = None;
848 let mut py = None;
849 for tok in reply.split_whitespace() {
850 if let Some(v) = tok.strip_prefix("x=") {
851 px = v.parse::<f64>().ok();
852 } else if let Some(v) = tok.strip_prefix("y=") {
853 py = v.parse::<f64>().ok();
854 }
855 }
856 if let (Some(x), Some(y)) = (px, py) {
857 let app_id = std::path::Path::new(&command)
858 .file_name()
859 .map(|n| n.to_string_lossy().into_owned())
860 .unwrap_or_else(|| command.to_string());
861 let _ = crate::ipc::send_command(
862 "cce",
863 &format!("place-next {} {:.0} {:.0}", app_id, x, y),
864 );
865 }
866 }
867 }
868
869 #[cfg(target_arch = "wasm32")]
870 fn place_picker_at_pointer(_command: &str) {}
871
872 #[cfg(test)]
873 mod tests {
874 use super::*;
875
876 #[test]
877 fn test_colorselector_keyboard_navigation() {
878 let mut dummy = crate::context::UiContext::new();
879 let mut cs = ColorSelector::new([255, 0, 0]);
880 assert_eq!(cs.color, [255, 0, 0]);
881 assert_eq!(cs.alpha, 255);
882 assert!(!cs.editing);
883
884 // 1. Focus color selector
885 cs.focus();
886 assert!(cs.editing);
887 assert_eq!(cs.edit_buffer, "#ff0000");
888 assert_eq!(cs.cursor_idx, 7);
889
890 // 2. Backspace deletes character before cursor
891 let backspace_ev = KeyEvent {
892 state: ElementState::Pressed,
893 logical_key: Key::Named(NamedKey::Backspace),
894 text: None,
895 repeat: false,
896 ctrl: false,
897 shift: false,
898 alt: false,
899 };
900 let handled = cs.keyboard_input(&backspace_ev, &mut dummy);
901 assert!(handled);
902 assert_eq!(cs.edit_buffer, "#ff000");
903 assert_eq!(cs.cursor_idx, 6);
904
905 // 3. ArrowLeft moves cursor index
906 let left_ev = KeyEvent {
907 state: ElementState::Pressed,
908 logical_key: Key::Named(NamedKey::ArrowLeft),
909 text: None,
910 repeat: false,
911 ctrl: false,
912 shift: false,
913 alt: false,
914 };
915 let handled = cs.keyboard_input(&left_ev, &mut dummy);
916 assert!(handled);
917 assert_eq!(cs.cursor_idx, 5);
918
919 // 4. Backspace at cursor index 5
920 let handled = cs.keyboard_input(&backspace_ev, &mut dummy);
921 assert!(handled);
922 assert_eq!(cs.edit_buffer, "#ff00");
923 assert_eq!(cs.cursor_idx, 4);
924
925 // 5. ArrowRight moves cursor index
926 let right_ev = KeyEvent {
927 state: ElementState::Pressed,
928 logical_key: Key::Named(NamedKey::ArrowRight),
929 text: None,
930 repeat: false,
931 ctrl: false,
932 shift: false,
933 alt: false,
934 };
935 let handled = cs.keyboard_input(&right_ev, &mut dummy);
936 assert!(handled);
937 assert_eq!(cs.cursor_idx, 5);
938
939 // 6. Delete deletes character at cursor
940 let delete_ev = KeyEvent {
941 state: ElementState::Pressed,
942 logical_key: Key::Named(NamedKey::Delete),
943 text: None,
944 repeat: false,
945 ctrl: false,
946 shift: false,
947 alt: false,
948 };
949 // Move cursor to index 3
950 let handled = cs.keyboard_input(&left_ev, &mut dummy); // 4
951 assert!(handled);
952 let handled = cs.keyboard_input(&left_ev, &mut dummy); // 3
953 assert!(handled);
954 assert_eq!(cs.cursor_idx, 3);
955 // buffer is "#ff0", index 3 points to the first '0'. Let's delete it.
956 let handled = cs.keyboard_input(&delete_ev, &mut dummy);
957 assert!(handled);
958 assert_eq!(cs.edit_buffer, "#ff0");
959 assert_eq!(cs.cursor_idx, 3);
960
961 // 7. Typing hex character inserts at cursor index
962 let type_b_ev = KeyEvent {
963 state: ElementState::Pressed,
964 logical_key: Key::Character("b".to_string()),
965 text: Some("b".to_string()),
966 repeat: false,
967 ctrl: false,
968 shift: false,
969 alt: false,
970 };
971 let handled = cs.keyboard_input(&type_b_ev, &mut dummy);
972 assert!(handled);
973 assert_eq!(cs.edit_buffer, "#ffb0");
974 assert_eq!(cs.cursor_idx, 4);
975
976 // Move to index 1 and insert a 'c'
977 for _ in 0..3 {
978 let handled = cs.keyboard_input(&left_ev, &mut dummy);
979 assert!(handled);
980 }
981 assert_eq!(cs.cursor_idx, 1);
982 let type_c_ev = KeyEvent {
983 state: ElementState::Pressed,
984 logical_key: Key::Character("c".to_string()),
985 text: Some("c".to_string()),
986 repeat: false,
987 ctrl: false,
988 shift: false,
989 alt: false,
990 };
991 let handled = cs.keyboard_input(&type_c_ev, &mut dummy);
992 assert!(handled);
993 assert_eq!(cs.edit_buffer, "#cffb0");
994 assert_eq!(cs.cursor_idx, 2);
995
996 // 8. Enter commits the color
997 let type_5_ev = KeyEvent {
998 state: ElementState::Pressed,
999 logical_key: Key::Character("5".to_string()),
1000 text: Some("5".to_string()),
1001 repeat: false,
1002 ctrl: false,
1003 shift: false,
1004 alt: false,
1005 };
1006 // Move to index 5
1007 for _ in 0..4 {
1008 cs.keyboard_input(&right_ev, &mut dummy);
1009 }
1010 assert_eq!(cs.cursor_idx, 6);
1011 cs.keyboard_input(&type_5_ev, &mut dummy);
1012 assert_eq!(cs.edit_buffer, "#cffb05");
1013 assert_eq!(cs.cursor_idx, 7);
1014
1015 let enter_ev = KeyEvent {
1016 state: ElementState::Pressed,
1017 logical_key: Key::Named(NamedKey::Enter),
1018 text: None,
1019 repeat: false,
1020 ctrl: false,
1021 shift: false,
1022 alt: false,
1023 };
1024 let handled = cs.keyboard_input(&enter_ev, &mut dummy);
1025 assert!(handled);
1026 assert!(!cs.editing);
1027 assert_eq!(cs.color, [0xcf, 0xfb, 0x05]);
1028 assert_eq!(cs.alpha, 255);
1029 }
1030
1031 #[test]
1032 fn test_colorselector_alpha_support() {
1033 let mut dummy = crate::context::UiContext::new();
1034 let mut cs = ColorSelector::new_rgba([255, 0, 0, 128]);
1035 assert_eq!(cs.color, [255, 0, 0]);
1036 assert_eq!(cs.alpha, 128);
1037 assert!(cs.with_alpha);
1038
1039 cs.focus();
1040 assert!(cs.editing);
1041 assert_eq!(cs.edit_buffer, "#ff000080");
1042 assert_eq!(cs.cursor_idx, 9);
1043
1044 let type_a_ev = KeyEvent {
1045 state: ElementState::Pressed,
1046 logical_key: Key::Character("a".to_string()),
1047 text: Some("a".to_string()),
1048 repeat: false,
1049 ctrl: false,
1050 shift: false,
1051 alt: false,
1052 };
1053 let backspace_ev = KeyEvent {
1054 state: ElementState::Pressed,
1055 logical_key: Key::Named(NamedKey::Backspace),
1056 text: None,
1057 repeat: false,
1058 ctrl: false,
1059 shift: false,
1060 alt: false,
1061 };
1062 cs.keyboard_input(&backspace_ev, &mut dummy);
1063 cs.keyboard_input(&backspace_ev, &mut dummy);
1064 assert_eq!(cs.edit_buffer, "#ff0000");
1065
1066 let type_b_ev = KeyEvent {
1067 state: ElementState::Pressed,
1068 logical_key: Key::Character("b".to_string()),
1069 text: Some("b".to_string()),
1070 repeat: false,
1071 ctrl: false,
1072 shift: false,
1073 alt: false,
1074 };
1075 cs.keyboard_input(&type_a_ev, &mut dummy);
1076 cs.keyboard_input(&type_b_ev, &mut dummy);
1077 assert_eq!(cs.edit_buffer, "#ff0000ab");
1078
1079 let enter_ev = KeyEvent {
1080 state: ElementState::Pressed,
1081 logical_key: Key::Named(NamedKey::Enter),
1082 text: None,
1083 repeat: false,
1084 ctrl: false,
1085 shift: false,
1086 alt: false,
1087 };
1088 cs.keyboard_input(&enter_ev, &mut dummy);
1089 assert!(!cs.editing);
1090 assert_eq!(cs.color, [255, 0, 0]);
1091 assert_eq!(cs.alpha, 171);
1092 }
1093 /// The alpha preview's checker must not reach the legacy plain-quad
1094 /// view: a host that draws that view beside the paint draws it AFTER the
1095 /// prims, and the white cells then landed over the colour fill — a black
1096 /// swatch read as a black-and-white checker. The paint keeps its order
1097 /// (base, cells, colour on top) and the plain view carries no cells.
1098 #[test]
1099 fn alpha_checker_stays_out_of_the_plain_quad_view() {
1100 use crate::scene::paint::Prim;
1101 let mut cs = ColorSelector::new_rgba([0, 0, 0, 255]);
1102 cs.recessed = Some(true);
1103 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 40.0 };
1104 crate::widget::WidgetHost::set_rect(&mut cs, rect.x, rect.y, rect.width, rect.height);
1105 let mut pc = PaintCtx::new();
1106 let inner: &ColorSelector = &cs;
1107 crate::widget::Paint::paint(inner, rect, &mut pc);
1108 let items = pc.finish().items;
1109 let last_fill = items
1110 .iter()
1111 .rposition(|it| matches!(it.prim, Prim::RoundedRect { color, .. } if color == [0.0, 0.0, 0.0, 1.0]))
1112 .expect("the colour fill is painted");
1113 let white_cells: Vec<usize> = items
1114 .iter()
1115 .enumerate()
1116 .filter(|(_, it)| matches!(it.prim, Prim::RoundedRect { color, radius, .. } if color == [1.0; 4] && radius == 0.0))
1117 .map(|(i, _)| i)
1118 .collect();
1119 assert!(!white_cells.is_empty(), "the checker is painted");
1120 assert!(white_cells.iter().all(|&i| i < last_fill), "every cell is under the colour fill");
1121 assert!(
1122 !items.iter().any(|it| matches!(it.prim, Prim::Quad { color, .. } if color == [1.0; 4])),
1123 "no cell is a plain quad, so the legacy plain view cannot carry it"
1124 );
1125 let plain = crate::widget::shown_quads(&cs);
1126 assert!(plain.iter().all(|q| q.4 != [1.0, 1.0, 1.0, 1.0]), "the plain view has no white cells: {plain:?}");
1127 }
1128
1129 }