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 inset trough
20 /// with 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 // The picker glyph: "Aa", the font-picker convention, in the dropdown arrow's
120 // grey — a text glyph every face has (the emoji this drew rendered as tofu
121 // wherever no emoji font was installed).
122 labels.push(TextLabel {
123 text: "Aa".to_string(),
124 x: rect.x + rect.width - 24.0,
125 y: crate::layout::align_text_y(rect.y, rect.height, 11.0, 0.0),
126 font_size: 11.0,
127 color: [0x83, 0x83, 0x8a],
128 });
129
130 labels
131 }
132 }
133
134 impl Adapted<FontSelector> {
135 /// Raised style: see the `raised` field.
136 pub fn with_raised(mut self, raised: bool) -> Self {
137 self.raised = Some(raised);
138 self
139 }
140 }
141
142 impl Layout for FontSelector {
143 fn intrinsic_size(&self) -> Option<Size> {
144 Some(Size::new(0.0, crate::layout::font_selector_height()))
145 }
146 }
147
148 impl Paint for FontSelector {
149 fn color(&self) -> [f32; 4] {
150 // The plate shows through in both styles: a transparent-faced flush
151 // plate raised, the shared well frame flat.
152 [0.0, 0.0, 0.0, 0.0]
153 }
154
155 fn widget_font(&self) -> Option<String> {
156 Some(crate::layout::font_selector_font())
157 }
158
159 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
160 let r = crate::layout::font_selector_corner_radius();
161 if r > 0.0 {
162 Some((r, (true, true, true, true)))
163 } else {
164 None
165 }
166 }
167
168 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
169 let r = crate::layout::font_selector_corner_radius();
170 if self.raised() {
171 // The closed-dropdown chrome: a flush control plate with a
172 // transparent face, the state fill rounded to sit inside it.
173 ctx.control_plate(
174 &crate::widget::ControlPlate::control(rect, r, crate::widget::PlateStance::Flush, None)
175 .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint)),
176 );
177 let wash = if self.pressed {
178 Some(colors::button_press_color())
179 } else if self.hovered {
180 Some(colors::button_hover_color())
181 } else {
182 None
183 };
184 if let Some(c) = wash {
185 ctx.rounded_rect(rect, r, (true, true, true, true), c);
186 }
187 self.paint_labels(rect, ctx);
188 return;
189 }
190 // The flat style: the one well frame (`colors::well_frame_color`) over
191 // the plate, lit while pressed, rounded at the selector's radius.
192 ctx.border(rect, (r, r, r, r), [0.0; 4], colors::well_frame_color(self.hovered, self.pressed), 1.0);
193
194 self.paint_labels(rect, ctx);
195 }
196 }
197
198 impl FontSelector {
199 /// Labels: family text clipped short of the picker glyph (the legacy per-label
200 /// bounds), glyph unclipped.
201 fn paint_labels(&self, rect: Rect, ctx: &mut PaintCtx) {
202 let font = self.widget_font();
203 let clip_right = rect.x + rect.width - 24.0;
204 let bounds = Some([rect.x, rect.y, clip_right, rect.y + rect.height]);
205 let labels = self.field_labels(rect);
206 let count = labels.len();
207 for (idx, l) in labels.into_iter().enumerate() {
208 let b = if idx < count - 1 { bounds } else { None };
209 ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, font.clone(), b);
210 }
211 }
212 }
213
214 impl FontSelector {
215 /// Spawn `cce-fonts --select` (once — a picker already open keeps it); the
216 /// tick reaps it. The press of this plate, by pointer or by key.
217 fn open_picker(&mut self) {
218 let mut child_guard = self.child.lock().unwrap();
219 if child_guard.is_none() {
220 let home = std::env::var("HOME").unwrap_or_default();
221 let local_fonts = std::path::Path::new(&home).join(".local/bin/cce-fonts");
222 let cmd_path = if local_fonts.exists() {
223 local_fonts.to_string_lossy().into_owned()
224 } else {
225 "cce-fonts".to_string()
226 };
227 if let Ok(child) = std::process::Command::new(&cmd_path)
228 .arg("--select")
229 .arg(&self.font_family)
230 .stdout(std::process::Stdio::piped())
231 .spawn()
232 {
233 *child_guard = Some(child);
234 }
235 }
236 }
237 }
238
239 impl Input for FontSelector {
240 fn focus_role(&self) -> crate::widget::FocusRole {
241 crate::widget::FocusRole::Plate
242 }
243
244 fn wants_tick(&self) -> bool {
245 true
246 }
247
248 fn tick(&mut self, _dt: f32, _rect: Rect) -> bool {
249 let mut child_guard = self.child.lock().unwrap();
250 if let Some(ref mut child) = *child_guard {
251 match child.try_wait() {
252 Ok(Some(status)) => {
253 let child_val = child_guard.take().unwrap();
254 if status.success() {
255 if let Ok(output) = child_val.wait_with_output() {
256 let stdout = String::from_utf8_lossy(&output.stdout);
257 let trimmed = stdout.trim().to_string();
258 if !trimmed.is_empty() && trimmed != self.font_family {
259 self.font_family = trimmed;
260 self.just_changed = true;
261 return true;
262 }
263 }
264 }
265 }
266 Ok(None) => {}
267 Err(_) => {
268 *child_guard = None;
269 }
270 }
271 }
272 false
273 }
274
275 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
276 match event {
277 Event::FocusIn => {
278 self.focused = true;
279 false
280 }
281 Event::FocusOut => {
282 self.focused = false;
283 false
284 }
285 Event::KeyInput(key_event) => {
286 // A focused plate is pressed by Enter / Space, as a Button is.
287 if !self.focused || key_event.state != ElementState::Pressed {
288 return false;
289 }
290 match key_event.logical_key {
291 Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
292 self.open_picker();
293 true
294 }
295 _ => false,
296 }
297 }
298 Event::MouseButton { button, state, x, y, .. } => {
299 if *button != MouseButton::Left {
300 return false;
301 }
302 match state {
303 ElementState::Pressed => {
304 self.pressed = true;
305 true
306 }
307 ElementState::Released => {
308 let r = ectx.rect;
309 let inside = *x >= r.x && *x <= r.x + r.width && *y >= r.y && *y <= r.y + r.height;
310 if self.pressed && inside {
311 self.pressed = false;
312 self.open_picker();
313 return true;
314 }
315 let was = self.pressed;
316 self.pressed = false;
317 was
318 }
319 }
320 }
321 Event::MouseEnter => {
322 self.hovered = true;
323 false
324 }
325 Event::MouseLeave => {
326 self.hovered = false;
327 false
328 }
329 _ => false,
330 }
331 }
332 }