git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/input/font_selector.rs (11.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 use std::sync::{Arc, Mutex};
  7 
  8 /// Font-family picker field (narrow-trait model, Phase 6as leaf sweep): click spawns
  9 /// `cce-fonts --select` and the tick reaps the child, committing its stdout as the new
 10 /// family. The detached control label rides the adapter; the model paints the field,
 11 /// the (possibly fade-truncated) family name, and the picker glyph.
 12 #[derive(Debug, Clone)]
 13 pub struct FontSelector {
 14     pub font_family: String,
 15     just_changed: bool,
 16     pressed: bool,
 17     hovered: bool,
 18     child: Arc<Mutex<Option<std::process::Child>>>,
 19     /// Raised style, the closed Dropdown's: the field is a flush plate with a
 20     /// field run's edge (`PaintCtx::inset_plate`) and a transparent face (the plate shows through), the hover and press
 21     /// states a wash inside it. Defaults to `control_relief()`; the flat style
 22     /// keeps the framed dark field.
 23     raised: Option<bool>,
 24     /// Keyboard focus (FocusIn / FocusOut): lights the plate's rim and arms
 25     /// Enter / Space to open the picker.
 26     focused: bool,
 27 }
 28 
 29 impl FontSelector {
 30     /// The style in force: the per-widget override (`with_raised`) when set, else
 31     /// the DE's `control_relief`, read live so a runtime switch
 32     /// (`layout::set_control_relief`) restyles every control at once.
 33     fn raised(&self) -> bool {
 34         self.raised.unwrap_or_else(crate::layout::control_relief)
 35     }
 36 
 37     pub fn new(font_family: String) -> Adapted<FontSelector> {
 38         Adapted::new(FontSelector {
 39             font_family,
 40             just_changed: false,
 41             pressed: false,
 42             hovered: false,
 43             child: Arc::new(Mutex::new(None)),
 44             raised: None,
 45             focused: false,
 46         })
 47     }
 48 
 49     pub fn take_change(&mut self) -> bool {
 50         let changed = self.just_changed;
 51         self.just_changed = false;
 52         changed
 53     }
 54 
 55     /// The field's own labels at the laid-out rect: the family name (fade-truncated to
 56     /// fit before the picker glyph when too wide) and the glyph itself.
 57     fn field_labels(&self, rect: Rect) -> Vec<TextLabel> {
 58         let mut labels = Vec::new();
 59         let max_w = rect.width - 33.0;
 60         let full_w = TextLabel::estimate_width(&self.font_family, 12.0);
 61         let text_y = crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0);
 62 
 63         if full_w <= max_w {
 64             labels.push(TextLabel {
 65                 text: self.font_family.clone(),
 66                 x: rect.x + 8.0,
 67                 y: text_y,
 68                 font_size: 12.0,
 69                 color: [0xdd, 0xdd, 0xe2],
 70             });
 71         } else {
 72             // Find the prefix that fits in max_w - 30.0
 73             let target_prefix_w = max_w - 30.0;
 74             let mut prefix = String::new();
 75             for c in self.font_family.chars() {
 76                 let mut test_prefix = prefix.clone();
 77                 test_prefix.push(c);
 78                 if TextLabel::estimate_width(&test_prefix, 12.0) > target_prefix_w {
 79                     break;
 80                 }
 81                 prefix.push(c);
 82             }
 83 
 84             let prefix_w = TextLabel::estimate_width(&prefix, 12.0);
 85             labels.push(TextLabel {
 86                 text: prefix.clone(),
 87                 x: rect.x + 8.0,
 88                 y: text_y,
 89                 font_size: 12.0,
 90                 color: [0xdd, 0xdd, 0xe2],
 91             });
 92 
 93             // The next 5 characters fade out
 94             let remaining: Vec<char> = self.font_family.chars().skip(prefix.chars().count()).collect();
 95             let fade_colors = [
 96                 [187, 187, 193],
 97                 [154, 154, 160],
 98                 [120, 120, 128],
 99                 [87, 87, 95],
100                 [53, 53, 62],
101             ];
102             let mut cur_x = rect.x + 8.0 + prefix_w;
103             for i in 0..5 {
104                 if i < remaining.len() {
105                     let c_str = remaining[i].to_string();
106                     let c_w = TextLabel::estimate_width(&c_str, 12.0);
107                     labels.push(TextLabel {
108                         text: c_str,
109                         x: cur_x,
110                         y: text_y,
111                         font_size: 12.0,
112                         color: fade_colors[i],
113                     });
114                     cur_x += c_w;
115                 }
116             }
117         }
118 
119         labels
120     }
121 }
122 
123 impl Adapted<FontSelector> {
124     /// Raised style: see the `raised` field.
125     pub fn with_raised(mut self, raised: bool) -> Self {
126         self.raised = Some(raised);
127         self
128     }
129 }
130 
131 impl Layout for FontSelector {
132     fn intrinsic_size(&self) -> Option<Size> {
133         Some(Size::new(0.0, crate::layout::font_selector_height()))
134     }
135 }
136 
137 impl Paint for FontSelector {
138     fn color(&self) -> [f32; 4] {
139         // The plate shows through in both styles: a transparent-faced flush
140         // plate raised, the shared well frame flat.
141         [0.0, 0.0, 0.0, 0.0]
142     }
143 
144     fn widget_font(&self) -> Option<String> {
145         Some(crate::layout::font_selector_font())
146     }
147 
148     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
149         let r = crate::layout::font_selector_corner_radius();
150         if r > 0.0 {
151             Some((r, (true, true, true, true)))
152         } else {
153             None
154         }
155     }
156 
157     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
158         let r = crate::layout::font_selector_corner_radius();
159         if self.raised() {
160             // The closed-dropdown chrome: a flush control plate with a
161             // transparent face, the state fill rounded to sit inside it.
162             ctx.control_plate(
163                 &crate::widget::ControlPlate::control(rect, r, crate::widget::PlateStance::Flush, None)
164                     .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint)),
165             );
166             let wash = if self.pressed {
167                 Some(colors::button_press_color())
168             } else if self.hovered {
169                 Some(colors::button_hover_color())
170             } else {
171                 None
172             };
173             if let Some(c) = wash {
174                 ctx.rounded_rect(rect, r, (true, true, true, true), c);
175             }
176             self.paint_labels(rect, ctx);
177             return;
178         }
179         // The flat style: the one well frame (`colors::well_frame_color`) over
180         // the plate, lit while pressed, rounded at the selector's radius.
181         ctx.border(rect, (r, r, r, r), [0.0; 4], colors::well_frame_color(self.hovered, self.pressed), 1.0);
182 
183         self.paint_labels(rect, ctx);
184     }
185 }
186 
187 impl FontSelector {
188     /// Labels: family text clipped short of the picker glyph, then the glyph
189     /// itself — the `font` icon in the dropdown arrow's grey, at the right
190     /// end (it was the text "Aa" until 2026-10-05, and an emoji that
191     /// rendered as tofu before that).
192     fn paint_labels(&self, rect: Rect, ctx: &mut PaintCtx) {
193         let font = self.widget_font();
194         let clip_right = rect.x + rect.width - 24.0;
195         let bounds = Some([rect.x, rect.y, clip_right, rect.y + rect.height]);
196         for l in self.field_labels(rect) {
197             ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, font.clone(), bounds);
198         }
199         const SIDE: f32 = 12.0;
200         let g = Rect { x: rect.x + rect.width - 24.0 + 0.5 * (24.0 - SIDE) - 2.0, y: rect.y + 0.5 * (rect.height - SIDE), width: SIDE, height: SIDE };
201         ctx.icon("font", g, [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0]);
202     }
203 }
204 
205 impl FontSelector {
206     /// Spawn `cce-fonts --select` (once — a picker already open keeps it); the
207     /// tick reaps it. The press of this plate, by pointer or by key.
208     fn open_picker(&mut self) {
209         let mut child_guard = self.child.lock().unwrap();
210         if child_guard.is_none() {
211             let home = std::env::var("HOME").unwrap_or_default();
212             let local_fonts = std::path::Path::new(&home).join(".local/bin/cce-fonts");
213             let cmd_path = if local_fonts.exists() {
214                 local_fonts.to_string_lossy().into_owned()
215             } else {
216                 "cce-fonts".to_string()
217             };
218             if let Ok(child) = std::process::Command::new(&cmd_path)
219                 .arg("--select")
220                 .arg(&self.font_family)
221                 .stdout(std::process::Stdio::piped())
222                 .spawn()
223             {
224                 *child_guard = Some(child);
225             }
226         }
227     }
228 }
229 
230 impl Input for FontSelector {
231     fn focus_role(&self) -> crate::widget::FocusRole {
232         crate::widget::FocusRole::Plate
233     }
234 
235     fn wants_tick(&self) -> bool {
236         true
237     }
238 
239     // `try_wait` has reaped the picker by the time its handle is dropped unread.
240     #[allow(clippy::zombie_processes)]
241     fn tick(&mut self, _dt: f32, _rect: Rect) -> bool {
242         let mut child_guard = self.child.lock().unwrap();
243         if let Some(ref mut child) = *child_guard {
244             match child.try_wait() {
245                 Ok(Some(status)) => {
246                     let child_val = child_guard.take().unwrap();
247                     if status.success() {
248                         if let Ok(output) = child_val.wait_with_output() {
249                             let stdout = String::from_utf8_lossy(&output.stdout);
250                             let trimmed = stdout.trim().to_string();
251                             if !trimmed.is_empty() && trimmed != self.font_family {
252                                 self.font_family = trimmed;
253                                 self.just_changed = true;
254                                 return true;
255                             }
256                         }
257                     }
258                 }
259                 Ok(None) => {}
260                 Err(_) => {
261                     *child_guard = None;
262                 }
263             }
264         }
265         false
266     }
267 
268     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
269         match event {
270             Event::FocusIn => {
271                 self.focused = true;
272                 false
273             }
274             Event::FocusOut => {
275                 self.focused = false;
276                 false
277             }
278             Event::KeyInput(key_event) => {
279                 // A focused plate is pressed by Enter / Space, as a Button is.
280                 if !self.focused || key_event.state != ElementState::Pressed {
281                     return false;
282                 }
283                 match key_event.logical_key {
284                     Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
285                         self.open_picker();
286                         true
287                     }
288                     _ => false,
289                 }
290             }
291             Event::MouseButton { button, state, x, y, .. } => {
292                 if *button != MouseButton::Left {
293                     return false;
294                 }
295                 match state {
296                     ElementState::Pressed => {
297                         self.pressed = true;
298                         true
299                     }
300                     ElementState::Released => {
301                         let r = ectx.rect;
302                         let inside = *x >= r.x && *x <= r.x + r.width && *y >= r.y && *y <= r.y + r.height;
303                         if self.pressed && inside {
304                             self.pressed = false;
305                             self.open_picker();
306                             return true;
307                         }
308                         let was = self.pressed;
309                         self.pressed = false;
310                         was
311                     }
312                 }
313             }
314             Event::MouseEnter => {
315                 self.hovered = true;
316                 false
317             }
318             Event::MouseLeave => {
319                 self.hovered = false;
320                 false
321             }
322             _ => false,
323         }
324     }
325 }