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 }