GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/color_selector.rs (41.8K)
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::window_runner::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
249 // The text field has NO face of its own — a frame over the host plate,
250 // like a relief TextBox well (transparent fill, the outline defines
251 // it) and the closed-dropdown convention. The first colorless pass
252 // used the textbox background here, but under the DE's relief themes
253 // real text wells draw no fill, so even a neutral one read as "the
254 // color selector has a background". Neutral greys for the frame; the
255 // caret is the editing affordance.
256 let border_color = crate::colors::well_frame_color(self.hovered, self.editing);
257
258 // A real frame, not a border-quad-under-fill-quad: with no fill, the
259 // old full-rect border quad would read as a solid slab. Rounded at the
260 // selector's radius like the well it stands in for.
261 if !self.recessed() {
262 let fr = crate::layout::color_selector_corner_radius();
263 ctx.border(rect, (fr, fr, fr, fr), [0.0; 4], border_color, 1.0);
264 }
265
266 if self.editing {
267 let font_size = 12.0;
268 let text_w = self.glyph_offsets.get(self.cursor_idx).copied().unwrap_or_else(|| {
269 let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
270 crate::widget::display::measure_text(&cursor_text, font_size)
271 });
272 let caret_x = rect.x + crate::layout::CONTROL_TEXT_INSET + text_w;
273 let caret_h = font_size * 1.15;
274 let caret_y = rect.y + (visual_h - caret_h) / 2.0;
275 quads.push((caret_x, caret_y, 1.5, caret_h, [0.80, 0.80, 0.85, 1.0]));
276 }
277
278 let linear_c = colors::to_linear([
279 self.color[0] as f32 / 255.0,
280 self.color[1] as f32 / 255.0,
281 self.color[2] as f32 / 255.0,
282 self.alpha as f32 / 255.0,
283 ]);
284 let border_w = 1.0;
285 let border_c = colors::color_borders_color();
286
287 let r = border_c[0];
288 let g = border_c[1];
289 let b = border_c[2];
290
291 let preview_radius = crate::layout::color_selector_preview_corner_radius();
292 let preview_margin = crate::layout::color_selector_preview_margin();
293
294 let px = pick_x + preview_margin;
295 let py = rect.y + preview_margin;
296 let pw = (pick_w - 2.0 * preview_margin).max(0.0);
297 let ph = (visual_h - 2.0 * preview_margin).max(0.0);
298
299 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| {
300 let radius = radius.min(w * 0.5).min(h * 0.5);
301 if radius <= 0.5 {
302 quads.push((x, y, w, h, color));
303 return;
304 }
305
306 quads.push((x + radius, y, w - 2.0 * radius, h, color));
307 quads.push((x, y + radius, radius, h - 2.0 * radius, color));
308 quads.push((x + w - radius, y + radius, radius, h - 2.0 * radius, color));
309
310 let steps = radius.round() as i32;
311 for i in 0..steps {
312 let dy = i as f32;
313 let next_dy = (i + 1) as f32;
314
315 let cx = (radius * radius - (radius - dy) * (radius - dy)).sqrt();
316 let next_cx = (radius * radius - (radius - next_dy) * (radius - next_dy)).sqrt();
317 let avg_cx = (cx + next_cx) * 0.5;
318
319 let strip_w = avg_cx;
320 let strip_h = 1.0f32;
321
322 if strip_w > 0.0 {
323 quads.push((x + radius - strip_w, y + dy, strip_w, strip_h, color));
324 quads.push((x + w - radius, y + dy, strip_w, strip_h, color));
325 quads.push((x + radius - strip_w, y + h - dy - strip_h, strip_w, strip_h, color));
326 quads.push((x + w - radius, y + h - dy - strip_h, strip_w, strip_h, color));
327 }
328 }
329 };
330
331 if self.recessed() {
332 // The Breadcrumb composition: ONE well across the whole control,
333 // the hex text on its floor at the left and the swatch as the
334 // colour laid flush on the floor's right segment (the well's
335 // rounded end is its own), the two parted by a seam groove. The
336 // caret quad first (it rides on the floor), the carve after the
337 // fills so the walls' shading falls over both, the seam last so it
338 // dies into the well's rolled edge. Over a checker where the
339 // colour carries alpha, so the transparency reads through.
340 for (qx, qy, qw, qh, qc) in quads.drain(..) {
341 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
342 }
343 let Some((wx, wy, ww, wh, radius, depth)) = self.field_relief(rect) else {
344 return;
345 };
346 let well = Rect { x: wx, y: wy, width: ww, height: wh };
347 // The floor: past the wall's inner half-span (the ProgressBar's inset).
348 let floor = Rect {
349 x: rect.x + depth,
350 y: rect.y + depth,
351 width: (rect.width - 2.0 * depth).max(0.0),
352 height: (visual_h - 2.0 * depth).max(0.0),
353 };
354 let seam_x = pick_x;
355 let swatch = Rect { x: seam_x, y: floor.y, width: (floor.x + floor.width - seam_x).max(0.0), height: floor.height };
356 let sr = crate::layout::textbox_corner_radius().min(swatch.height * 0.5);
357 let right_end = (false, true, true, false);
358 if self.with_alpha {
359 ctx.rounded_rect(swatch, sr, right_end, [0.8, 0.8, 0.8, 1.0]);
360 // The white cells, those at the right edge trimmed to the end's
361 // arc at their own row (the Breadcrumb's banded-wash sampling).
362 //
363 // Zero-radius rounded rects, NOT plain quads. The adapter's
364 // legacy plain-quad view (`own_plain_quads`) is this paint
365 // filtered to `Prim::Quad`, and a host that reads that view
366 // beside the paint — the designer, through ParametersBg's
367 // `plain_quads` — draws it AFTER the prims. As quads the
368 // cells came back a second time OVER the colour fill below,
369 // so every colour but white showed a checker as if it were
370 // transparent (invisible with the default white, 2026-09-21).
371 let grid = 6.0;
372 let cols = (swatch.width / grid).ceil() as i32;
373 let rows = (swatch.height / grid).ceil() as i32;
374 let arc = |yc: f32| -> f32 {
375 let dy = if yc < swatch.y + sr {
376 sr - (yc - swatch.y)
377 } else if yc > swatch.y + swatch.height - sr {
378 yc - (swatch.y + swatch.height - sr)
379 } else {
380 return 0.0;
381 };
382 sr - (sr * sr - dy * dy).max(0.0).sqrt()
383 };
384 for r in 0..rows {
385 for c in 0..cols {
386 if (r + c) % 2 == 1 {
387 let qx = swatch.x + c as f32 * grid;
388 let qy = swatch.y + r as f32 * grid;
389 let qh = grid.min(swatch.y + swatch.height - qy);
390 let right_edge = swatch.x + swatch.width - arc(qy + qh * 0.5);
391 let qw = grid.min(right_edge - qx);
392 if qw > 0.0 && qh > 0.0 {
393 ctx.rounded_rect(
394 Rect { x: qx, y: qy, width: qw, height: qh },
395 0.0,
396 (false, false, false, false),
397 [1.0, 1.0, 1.0, 1.0],
398 );
399 }
400 }
401 }
402 }
403 }
404 ctx.rounded_rect(swatch, sr, right_end, linear_c);
405 let radii = (radius, radius, radius, radius);
406 if self.editing {
407 let hc = crate::color::highlight_primary_color();
408 ctx.recess_tinted(well, radii, depth, [hc[0], hc[1], hc[2]]);
409 } else {
410 ctx.recess(well, radii, depth);
411 }
412 ctx.groove(
413 (seam_x, well.y),
414 (seam_x, well.y + well.height),
415 crate::widget::Breadcrumb::SEAM_WIDTH,
416 depth,
417 well,
418 );
419 let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
420 // Bounded by the seam: the field is the well left of it, and while
421 // `editing` this holds whatever has been typed, not a 7-character
422 // hex code.
423 ctx.text_with(
424 hex,
425 rect.x + crate::layout::CONTROL_TEXT_INSET,
426 crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
427 12.0,
428 [0xcc, 0xcc, 0xd4],
429 None,
430 Some([rect.x, rect.y, seam_x, rect.y + rect.height]),
431 );
432 return;
433 }
434
435 let steps = 6;
436 for i in (1..=steps).rev() {
437 let offset = i as f32 * 0.75;
438 let rx = px - offset;
439 let ry = py - offset;
440 let rw = pw + 2.0 * offset;
441 let rh = ph + 2.0 * offset;
442 let alpha = 0.08 * (1.0 - (i as f32 / steps as f32).powf(1.5));
443 if alpha > 0.001 {
444 add_rounded_rect(&mut quads, [r, g, b, alpha], rx, ry, rw, rh, preview_radius + offset);
445 }
446 }
447
448 add_rounded_rect(&mut quads, border_c, px, py, pw, ph, preview_radius);
449
450 if self.with_alpha {
451 add_rounded_rect(
452 &mut quads,
453 [0.8, 0.8, 0.8, 1.0],
454 px + border_w,
455 py + border_w,
456 (pw - 2.0 * border_w).max(0.0),
457 (ph - 2.0 * border_w).max(0.0),
458 (preview_radius - border_w).max(0.0),
459 );
460
461 let grid_size = 6.0;
462 let start_x = px + border_w;
463 let start_y = py + border_w;
464 let inner_w = (pw - 2.0 * border_w).max(0.0);
465 let inner_h = (ph - 2.0 * border_w).max(0.0);
466
467 let cols = (inner_w / grid_size).ceil() as i32;
468 let rows = (inner_h / grid_size).ceil() as i32;
469 for r in 0..rows {
470 for c in 0..cols {
471 if (r + c) % 2 == 1 {
472 let qx = start_x + c as f32 * grid_size;
473 let qy = start_y + r as f32 * grid_size;
474 let qw = grid_size.min(start_x + inner_w - qx);
475 let qh = grid_size.min(start_y + inner_h - qy);
476 if qw > 0.0 && qh > 0.0 {
477 quads.push((qx, qy, qw, qh, [1.0, 1.0, 1.0, 1.0]));
478 }
479 }
480 }
481 }
482 }
483
484 add_rounded_rect(
485 &mut quads,
486 linear_c,
487 px + border_w,
488 py + border_w,
489 (pw - 2.0 * border_w).max(0.0),
490 (ph - 2.0 * border_w).max(0.0),
491 (preview_radius - border_w).max(0.0),
492 );
493
494 for (qx, qy, qw, qh, qc) in quads {
495 ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
496 }
497
498 let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
499 ctx.text_with(
500 hex,
501 rect.x + crate::layout::CONTROL_TEXT_INSET,
502 crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
503 12.0,
504 [0xcc, 0xcc, 0xd4],
505 None,
506 Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]),
507 );
508 }
509 }
510
511 impl Input for ColorSelector {
512 fn focus_role(&self) -> crate::widget::FocusRole {
513 crate::widget::FocusRole::Well
514 }
515 fn opens_context_menu(&self) -> bool {
516 true
517 }
518
519 fn take_click(&mut self) -> bool {
520 if self.just_clicked { self.just_clicked = false; true } else { false }
521 }
522
523 fn take_change(&mut self) -> bool {
524 let ret = self.just_changed;
525 self.just_changed = false;
526 ret
527 }
528
529 fn value_string(&self) -> Option<String> {
530 Some(self.value_hex())
531 }
532
533 fn set_value_string(&mut self, val: &str) -> bool {
534 if let Some(c) = parse_hex(val) {
535 let target_color = [c[0], c[1], c[2]];
536 let target_alpha = if self.with_alpha { c[3] } else { 255 };
537 if self.color != target_color || (self.with_alpha && self.alpha != target_alpha) {
538 self.color = target_color;
539 self.alpha = target_alpha;
540 self.just_changed = true;
541 if self.editing {
542 self.edit_buffer = self.value_hex();
543 self.cursor_idx = self.edit_buffer.chars().count();
544 }
545 return true;
546 }
547 }
548 false
549
550 }
551
552 fn wants_tick(&self) -> bool {
553 true
554 }
555
556 fn tick(&mut self, _dt: f32, _rect: Rect) -> bool {
557 // Apply streamed picker lines as they arrive — the color changes live
558 // while the editor stays open. The picker's Apply prints a final line
559 // (already applied here); Cancel prints `cancel`, restoring the value
560 // the picker launched with.
561 let mut redraw = false;
562 if let Some(rx) = &self.live_rx {
563 // A live picker session is activity: the runner sleeps between
564 // ticks when nothing is animating, and these lines come from a
565 // reader thread it cannot see, so keep the frame cadence for as
566 // long as the picker is open.
567 redraw = true;
568 let mut lines = Vec::new();
569 let mut disconnected = false;
570 loop {
571 match rx.try_recv() {
572 Ok(line) => lines.push(line),
573 Err(std::sync::mpsc::TryRecvError::Empty) => break,
574 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
575 disconnected = true;
576 break;
577 }
578 }
579 }
580 if disconnected {
581 self.live_rx = None;
582 }
583 for line in lines {
584 let line = line.trim().to_string();
585 let target = if line == "cancel" {
586 self.revert_hex.clone().and_then(|h| parse_hex(&h))
587 } else {
588 parse_hex(&line)
589 };
590 if let Some(c) = target {
591 let color = [c[0], c[1], c[2]];
592 let alpha = if self.with_alpha { c[3] } else { 255 };
593 if self.color != color || self.alpha != alpha {
594 self.color = color;
595 self.alpha = alpha;
596 self.just_changed = true;
597 redraw = true;
598 }
599 }
600 }
601 }
602 let mut child_opt = self.child.lock().unwrap();
603 if let Some(ref mut child) = *child_opt {
604 match child.try_wait() {
605 Ok(Some(_status)) => {
606 // The reader thread owns stdout and has already forwarded
607 // every line (including Apply's final one) — just reap.
608 *child_opt = None;
609 }
610 Ok(None) => {}
611 Err(e) => {
612 log::error!("Error checking color selector child process: {:?}", e);
613 *child_opt = None;
614 }
615 }
616 }
617 redraw
618 }
619
620 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
621 match event {
622 Event::MouseButton { button, state, x, y, .. } => {
623 if *button != MouseButton::Left {
624 return false;
625 }
626 if *state != ElementState::Pressed {
627 return false;
628 }
629 let (px, py) = (*x, *y);
630 let _ = py;
631 let rect = ectx.rect;
632 if px >= rect.x + rect.width * 0.65 {
633 let hex = self.value_hex();
634 let mut child_guard = self.child.lock().unwrap();
635 if let Some(mut old_child) = child_guard.take() {
636 let _ = old_child.kill();
637 }
638
639 let cmd_path = if let Ok(mut exe_path) = std::env::current_exe() {
640 exe_path.pop(); // remove executable name
641 let local_path = exe_path.join(&self.command);
642 if local_path.exists() {
643 local_path.to_string_lossy().into_owned()
644 } else {
645 let home = std::env::var("HOME").unwrap_or_default();
646 let local_bin = std::path::Path::new(&home).join(".local/bin").join(&self.command);
647 if local_bin.exists() {
648 local_bin.to_string_lossy().into_owned()
649 } else {
650 self.command.clone()
651 }
652 }
653 } else {
654 self.command.clone()
655 };
656
657 // Ask the compositor to open the picker at this control instead of
658 // its remembered position: the pointer is on the swatch right now,
659 // so its location IS the control's location. One-shot, best-effort
660 // (`place-next` consumed at the picker's map; ignored off-cce).
661 if let Ok(reply) = crate::ipc::send_command("cce", "pointer-location") {
662 let mut px = None;
663 let mut py = None;
664 for tok in reply.split_whitespace() {
665 if let Some(v) = tok.strip_prefix("x=") {
666 px = v.parse::<f64>().ok();
667 } else if let Some(v) = tok.strip_prefix("y=") {
668 py = v.parse::<f64>().ok();
669 }
670 }
671 if let (Some(x), Some(y)) = (px, py) {
672 let app_id = std::path::Path::new(&self.command)
673 .file_name()
674 .map(|n| n.to_string_lossy().into_owned())
675 .unwrap_or_else(|| self.command.clone());
676 let _ = crate::ipc::send_command(
677 "cce",
678 &format!("place-next {} {:.0} {:.0}", app_id, x, y),
679 );
680 }
681 }
682
683 let mut cmd = std::process::Command::new(&cmd_path);
684 cmd.arg(&hex);
685 if self.with_alpha {
686 cmd.arg("--alpha");
687 }
688 // Live picking: the picker streams every change on stdout; a
689 // reader thread forwards lines so `tick` applies them while the
690 // editor stays open. `cancel` restores the launch value.
691 cmd.arg("--stream");
692 if let Ok(mut child) = cmd.stdout(std::process::Stdio::piped()).spawn() {
693 if let Some(stdout) = child.stdout.take() {
694 let (tx, rx) = std::sync::mpsc::channel::<String>();
695 std::thread::spawn(move || {
696 use std::io::BufRead;
697 let reader = std::io::BufReader::new(stdout);
698 for line in reader.lines().map_while(Result::ok) {
699 if tx.send(line).is_err() {
700 break;
701 }
702 }
703 });
704 self.live_rx = Some(rx);
705 self.revert_hex = Some(hex.clone());
706 }
707 *child_guard = Some(child);
708 }
709 return true;
710 }
711 ectx.request_focus();
712 true
713 }
714 Event::KeyInput(event) => {
715 if event.state != ElementState::Pressed {
716 return false;
717 }
718 if !self.editing {
719 return false;
720 }
721
722
723
724 let mut state = TextEditorState {
725 buffer: self.edit_buffer.clone(),
726 cursor_idx: self.cursor_idx,
727 select_anchor: None,
728 all_selected: false,
729 };
730
731 let mut handled = false;
732 match &event.logical_key {
733 Key::Named(NamedKey::Backspace) => {
734 state.delete_backwards();
735 handled = true;
736 }
737 Key::Named(NamedKey::Delete) => {
738 state.delete_forwards();
739 handled = true;
740 }
741 Key::Named(NamedKey::ArrowLeft) => {
742 state.move_cursor_left(false);
743 handled = true;
744 }
745 Key::Named(NamedKey::ArrowRight) => {
746 state.move_cursor_right(false);
747 handled = true;
748 }
749 Key::Named(NamedKey::Enter) => {
750 if let Some(c) = parse_hex(&state.buffer) {
751 self.color = [c[0], c[1], c[2]];
752 self.alpha = if self.with_alpha { c[3] } else { 255 };
753 }
754 self.editing = false;
755 handled = true;
756 }
757 Key::Named(NamedKey::Escape) => {
758 self.editing = false;
759 handled = true;
760 }
761 _ => {
762 if let Some(text) = &event.text {
763 for ch in text.chars() {
764 match ch {
765 '#' => {
766 if state.buffer.is_empty() {
767 state.insert_text("#");
768 handled = true;
769 } else if state.cursor_idx == 0 && !state.buffer.starts_with('#') {
770 state.insert_text("#");
771 handled = true;
772 }
773 }
774 '0'..='9' | 'a'..='f' | 'A'..='F' => {
775 let count = state.buffer.chars().count();
776 let max_len = if state.buffer.starts_with('#') {
777 if self.with_alpha { 9 } else { 7 }
778 } else {
779 if self.with_alpha { 8 } else { 6 }
780 };
781 if count < max_len {
782 state.insert_text(&ch.to_ascii_lowercase().to_string());
783 handled = true;
784 }
785 }
786 _ => {}
787 }
788 }
789 }
790 }
791 }
792
793 if self.editing {
794 self.edit_buffer = state.buffer;
795 self.cursor_idx = state.cursor_idx;
796 }
797 handled
798
799 }
800 Event::MouseEnter => {
801 self.hovered = true;
802 false
803 }
804 Event::MouseLeave => {
805 self.hovered = false;
806 false
807 }
808 Event::FocusIn => {
809 self.begin_edit();
810 false
811 }
812 Event::FocusOut => {
813 if self.editing {
814 self.editing = false;
815 if let Some(c) = parse_hex(&self.edit_buffer) {
816 self.color = [c[0], c[1], c[2]];
817 self.alpha = if self.with_alpha { c[3] } else { 255 };
818 }
819 }
820 false
821 }
822 _ => false,
823 }
824 }
825 }
826
827 fn parse_hex(s: &str) -> Option<[u8; 4]> {
828 crate::color::parse_hex_bytes(s)
829 }
830
831 #[cfg(test)]
832 mod tests {
833 use super::*;
834
835 #[test]
836 fn test_colorselector_keyboard_navigation() {
837 let mut dummy = crate::context::UiContext::new();
838 let mut cs = ColorSelector::new([255, 0, 0]);
839 assert_eq!(cs.color, [255, 0, 0]);
840 assert_eq!(cs.alpha, 255);
841 assert!(!cs.editing);
842
843 // 1. Focus color selector
844 cs.focus();
845 assert!(cs.editing);
846 assert_eq!(cs.edit_buffer, "#ff0000");
847 assert_eq!(cs.cursor_idx, 7);
848
849 // 2. Backspace deletes character before cursor
850 let backspace_ev = KeyEvent {
851 state: ElementState::Pressed,
852 logical_key: Key::Named(NamedKey::Backspace),
853 text: None,
854 repeat: false,
855 ctrl: false,
856 shift: false,
857 alt: false,
858 };
859 let handled = cs.keyboard_input(&backspace_ev, &mut dummy);
860 assert!(handled);
861 assert_eq!(cs.edit_buffer, "#ff000");
862 assert_eq!(cs.cursor_idx, 6);
863
864 // 3. ArrowLeft moves cursor index
865 let left_ev = KeyEvent {
866 state: ElementState::Pressed,
867 logical_key: Key::Named(NamedKey::ArrowLeft),
868 text: None,
869 repeat: false,
870 ctrl: false,
871 shift: false,
872 alt: false,
873 };
874 let handled = cs.keyboard_input(&left_ev, &mut dummy);
875 assert!(handled);
876 assert_eq!(cs.cursor_idx, 5);
877
878 // 4. Backspace at cursor index 5
879 let handled = cs.keyboard_input(&backspace_ev, &mut dummy);
880 assert!(handled);
881 assert_eq!(cs.edit_buffer, "#ff00");
882 assert_eq!(cs.cursor_idx, 4);
883
884 // 5. ArrowRight moves cursor index
885 let right_ev = KeyEvent {
886 state: ElementState::Pressed,
887 logical_key: Key::Named(NamedKey::ArrowRight),
888 text: None,
889 repeat: false,
890 ctrl: false,
891 shift: false,
892 alt: false,
893 };
894 let handled = cs.keyboard_input(&right_ev, &mut dummy);
895 assert!(handled);
896 assert_eq!(cs.cursor_idx, 5);
897
898 // 6. Delete deletes character at cursor
899 let delete_ev = KeyEvent {
900 state: ElementState::Pressed,
901 logical_key: Key::Named(NamedKey::Delete),
902 text: None,
903 repeat: false,
904 ctrl: false,
905 shift: false,
906 alt: false,
907 };
908 // Move cursor to index 3
909 let handled = cs.keyboard_input(&left_ev, &mut dummy); // 4
910 assert!(handled);
911 let handled = cs.keyboard_input(&left_ev, &mut dummy); // 3
912 assert!(handled);
913 assert_eq!(cs.cursor_idx, 3);
914 // buffer is "#ff0", index 3 points to the first '0'. Let's delete it.
915 let handled = cs.keyboard_input(&delete_ev, &mut dummy);
916 assert!(handled);
917 assert_eq!(cs.edit_buffer, "#ff0");
918 assert_eq!(cs.cursor_idx, 3);
919
920 // 7. Typing hex character inserts at cursor index
921 let type_b_ev = KeyEvent {
922 state: ElementState::Pressed,
923 logical_key: Key::Character("b".to_string()),
924 text: Some("b".to_string()),
925 repeat: false,
926 ctrl: false,
927 shift: false,
928 alt: false,
929 };
930 let handled = cs.keyboard_input(&type_b_ev, &mut dummy);
931 assert!(handled);
932 assert_eq!(cs.edit_buffer, "#ffb0");
933 assert_eq!(cs.cursor_idx, 4);
934
935 // Move to index 1 and insert a 'c'
936 for _ in 0..3 {
937 let handled = cs.keyboard_input(&left_ev, &mut dummy);
938 assert!(handled);
939 }
940 assert_eq!(cs.cursor_idx, 1);
941 let type_c_ev = KeyEvent {
942 state: ElementState::Pressed,
943 logical_key: Key::Character("c".to_string()),
944 text: Some("c".to_string()),
945 repeat: false,
946 ctrl: false,
947 shift: false,
948 alt: false,
949 };
950 let handled = cs.keyboard_input(&type_c_ev, &mut dummy);
951 assert!(handled);
952 assert_eq!(cs.edit_buffer, "#cffb0");
953 assert_eq!(cs.cursor_idx, 2);
954
955 // 8. Enter commits the color
956 let type_5_ev = KeyEvent {
957 state: ElementState::Pressed,
958 logical_key: Key::Character("5".to_string()),
959 text: Some("5".to_string()),
960 repeat: false,
961 ctrl: false,
962 shift: false,
963 alt: false,
964 };
965 // Move to index 5
966 for _ in 0..4 {
967 cs.keyboard_input(&right_ev, &mut dummy);
968 }
969 assert_eq!(cs.cursor_idx, 6);
970 cs.keyboard_input(&type_5_ev, &mut dummy);
971 assert_eq!(cs.edit_buffer, "#cffb05");
972 assert_eq!(cs.cursor_idx, 7);
973
974 let enter_ev = KeyEvent {
975 state: ElementState::Pressed,
976 logical_key: Key::Named(NamedKey::Enter),
977 text: None,
978 repeat: false,
979 ctrl: false,
980 shift: false,
981 alt: false,
982 };
983 let handled = cs.keyboard_input(&enter_ev, &mut dummy);
984 assert!(handled);
985 assert!(!cs.editing);
986 assert_eq!(cs.color, [0xcf, 0xfb, 0x05]);
987 assert_eq!(cs.alpha, 255);
988 }
989
990 #[test]
991 fn test_colorselector_alpha_support() {
992 let mut dummy = crate::context::UiContext::new();
993 let mut cs = ColorSelector::new_rgba([255, 0, 0, 128]);
994 assert_eq!(cs.color, [255, 0, 0]);
995 assert_eq!(cs.alpha, 128);
996 assert!(cs.with_alpha);
997
998 cs.focus();
999 assert!(cs.editing);
1000 assert_eq!(cs.edit_buffer, "#ff000080");
1001 assert_eq!(cs.cursor_idx, 9);
1002
1003 let type_a_ev = KeyEvent {
1004 state: ElementState::Pressed,
1005 logical_key: Key::Character("a".to_string()),
1006 text: Some("a".to_string()),
1007 repeat: false,
1008 ctrl: false,
1009 shift: false,
1010 alt: false,
1011 };
1012 let backspace_ev = KeyEvent {
1013 state: ElementState::Pressed,
1014 logical_key: Key::Named(NamedKey::Backspace),
1015 text: None,
1016 repeat: false,
1017 ctrl: false,
1018 shift: false,
1019 alt: false,
1020 };
1021 cs.keyboard_input(&backspace_ev, &mut dummy);
1022 cs.keyboard_input(&backspace_ev, &mut dummy);
1023 assert_eq!(cs.edit_buffer, "#ff0000");
1024
1025 let type_b_ev = KeyEvent {
1026 state: ElementState::Pressed,
1027 logical_key: Key::Character("b".to_string()),
1028 text: Some("b".to_string()),
1029 repeat: false,
1030 ctrl: false,
1031 shift: false,
1032 alt: false,
1033 };
1034 cs.keyboard_input(&type_a_ev, &mut dummy);
1035 cs.keyboard_input(&type_b_ev, &mut dummy);
1036 assert_eq!(cs.edit_buffer, "#ff0000ab");
1037
1038 let enter_ev = KeyEvent {
1039 state: ElementState::Pressed,
1040 logical_key: Key::Named(NamedKey::Enter),
1041 text: None,
1042 repeat: false,
1043 ctrl: false,
1044 shift: false,
1045 alt: false,
1046 };
1047 cs.keyboard_input(&enter_ev, &mut dummy);
1048 assert!(!cs.editing);
1049 assert_eq!(cs.color, [255, 0, 0]);
1050 assert_eq!(cs.alpha, 171);
1051 }
1052 /// The alpha preview's checker must not reach the legacy plain-quad
1053 /// view: a host that draws that view beside the paint draws it AFTER the
1054 /// prims, and the white cells then landed over the colour fill — a black
1055 /// swatch read as a black-and-white checker. The paint keeps its order
1056 /// (base, cells, colour on top) and the plain view carries no cells.
1057 #[test]
1058 fn alpha_checker_stays_out_of_the_plain_quad_view() {
1059 use crate::scene::paint::Prim;
1060 let mut cs = ColorSelector::new_rgba([0, 0, 0, 255]);
1061 cs.recessed = Some(true);
1062 let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 40.0 };
1063 crate::widget::WidgetHost::set_rect(&mut cs, rect.x, rect.y, rect.width, rect.height);
1064 let mut pc = PaintCtx::new();
1065 let inner: &ColorSelector = &cs;
1066 crate::widget::Paint::paint(inner, rect, &mut pc);
1067 let items = pc.finish().items;
1068 let last_fill = items
1069 .iter()
1070 .rposition(|it| matches!(it.prim, Prim::RoundedRect { color, .. } if color == [0.0, 0.0, 0.0, 1.0]))
1071 .expect("the colour fill is painted");
1072 let white_cells: Vec<usize> = items
1073 .iter()
1074 .enumerate()
1075 .filter(|(_, it)| matches!(it.prim, Prim::RoundedRect { color, radius, .. } if color == [1.0; 4] && radius == 0.0))
1076 .map(|(i, _)| i)
1077 .collect();
1078 assert!(!white_cells.is_empty(), "the checker is painted");
1079 assert!(white_cells.iter().all(|&i| i < last_fill), "every cell is under the colour fill");
1080 assert!(
1081 !items.iter().any(|it| matches!(it.prim, Prim::Quad { color, .. } if color == [1.0; 4])),
1082 "no cell is a plain quad, so the legacy plain view cannot carry it"
1083 );
1084 let plain = crate::widget::WidgetHost::extra_quads(&cs);
1085 assert!(plain.iter().all(|q| q.4 != [1.0, 1.0, 1.0, 1.0]), "the plain view has no white cells: {plain:?}");
1086 }
1087
1088 }
1089
1090