Skip to main content

egui_macview/controls/
input_cell.rs

1//! Authentic macOS NSTextFieldCell primitive with Tab-chain and Transactional Buffer.
2//!
3//! Grounded in macOS HIG and W3C DTCG tokens via `MacTheme`.
4//! Fully data-driven: zero hardcoded colors, radiuses, or padding metrics.
5
6use crate::theme::MacTheme;
7use egui::{Color32, CornerRadius, Key, Modifiers, Response, Stroke, StrokeKind, Ui, Vec2};
8use std::ops::RangeInclusive;
9
10fn decimal_places_if_valid(s: &str) -> usize {
11    let t = s.trim();
12    if t.parse::<f64>().is_ok() {
13        t.split_once('.')
14            .map_or(0, |(_, f)| f.split(['e', 'E']).next().unwrap_or("").len())
15    } else {
16        0
17    }
18}
19
20fn format_stepped_number(reference: &str, fallback_ref: &str, step: f64, val: f64) -> String {
21    let ref_decimals = decimal_places_if_valid(reference).max(decimal_places_if_valid(fallback_ref));
22    let step_decimals = if (step - step.round()).abs() > f64::EPSILON {
23        format!("{step}").split_once('.').map_or(1, |(_, f)| f.len())
24    } else {
25        0
26    };
27    let prec = ref_decimals.max(step_decimals);
28    format!("{val:.prec$}")
29}
30
31/// Standard AppKit `NSControlSize` variants for `MacInputCell`.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum MacControlSize {
34    /// `NSControlSizeRegular` (`22pt` height, `13pt` font).
35    Regular,
36    /// `NSControlSizeSmall` (`19pt` height, `11pt` font).
37    Small,
38    /// `NSControlSizeMini` (`16pt` height, `9pt` font).
39    Mini,
40    /// `NSControlSizeLarge` (`28pt` height, `16pt` font).
41    Large,
42    /// Dense inspector / HUD infobox cell (`18pt` height, `11pt` font).
43    #[default]
44    CompactInfobox,
45}
46
47/// Authentic macOS compact text entry cell (AppKit `NSTextFieldCell` style).
48///
49/// Features transactional input buffering, Escape rollback, Return confirmation,
50/// deterministic Tab-chain cyclic focus navigation, and theme-driven focus ring styling.
51///
52/// # Example
53///
54/// ```rust
55/// use egui_macview::{MacInputCell, MacTheme};
56///
57/// # let ctx = egui::Context::default();
58/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
59/// let theme = MacTheme::default();
60/// let mut text = String::from("Hello macOS");
61///
62/// MacInputCell::new("input_demo", &mut text)
63///     .theme(&theme)
64///     .placeholder("Type here...")
65///     .show(ui);
66/// # });
67/// # output.textures_delta.clear();
68/// ```
69pub struct MacInputCell<'a> {
70    id: egui::Id,
71    value: &'a mut String,
72    theme: Option<&'a MacTheme>,
73    width: Option<f32>,
74    height: Option<f32>,
75    font_size: Option<f32>,
76    corner_radius: Option<f32>,
77    control_size: MacControlSize,
78    enabled: bool,
79    next_focus_id: Option<egui::Id>,
80    prev_focus_id: Option<egui::Id>,
81    placeholder: Option<&'a str>,
82    numeric_config: Option<(RangeInclusive<f64>, f64)>,
83}
84
85impl<'a> MacInputCell<'a> {
86    /// Creates a new `MacInputCell` bound to `value` and identified by `id_source`.
87    pub fn new(id_source: impl std::hash::Hash + std::fmt::Debug, value: &'a mut String) -> Self {
88        let id = egui::Id::NULL.with(id_source);
89        Self::with_id(id, value)
90    }
91
92    /// Creates a cell directly with an existing `egui::Id` without double-hashing.
93    pub fn with_id(id: egui::Id, value: &'a mut String) -> Self {
94        Self {
95            id,
96            value,
97            theme: None,
98            width: None,
99            height: None,
100            font_size: None,
101            corner_radius: None,
102            control_size: MacControlSize::CompactInfobox,
103            enabled: true,
104            next_focus_id: None,
105            prev_focus_id: None,
106            placeholder: None,
107            numeric_config: None,
108        }
109    }
110
111    /// Configures numeric arrow-key stepping (`ArrowUp`/`ArrowDown` by `±step`,
112    /// `Shift` by `±10 * step`, `Cmd`/`Ctrl` by `±100 * step`, clamped to `range`).
113    pub fn numeric(mut self, range: RangeInclusive<f64>, step: f64) -> Self {
114        self.numeric_config = Some((range, step));
115        self
116    }
117
118    /// Sets the AppKit control size preset (`Regular`, `Small`, `Mini`, `Large`, `CompactInfobox`).
119    pub fn control_size(mut self, control_size: MacControlSize) -> Self {
120        self.control_size = control_size;
121        self
122    }
123
124    /// Enables or disables user interaction on the input cell.
125    pub fn enabled(mut self, enabled: bool) -> Self {
126        self.enabled = enabled;
127        self
128    }
129
130    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
131    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
132        self.theme = Some(theme);
133        self
134    }
135
136    /// Overrides cell width in points.
137    pub fn width(mut self, width: f32) -> Self {
138        self.width = Some(width);
139        self
140    }
141
142    /// Overrides cell height in points (defaults to token text field height).
143    pub fn height(mut self, height: f32) -> Self {
144        self.height = Some(height);
145        self
146    }
147
148    /// Overrides font size in points.
149    pub fn font_size(mut self, font_size: f32) -> Self {
150        self.font_size = Some(font_size);
151        self
152    }
153
154    /// Sets placeholder text displayed when the text field is empty and unfocused.
155    pub fn placeholder(mut self, text: &'a str) -> Self {
156        self.placeholder = Some(text);
157        self
158    }
159
160    /// Sets the deterministic Tab navigation target.
161    pub fn next_focus(mut self, id: egui::Id) -> Self {
162        self.next_focus_id = Some(id);
163        self
164    }
165
166    /// Sets the deterministic Shift+Tab navigation target.
167    pub fn prev_focus(mut self, id: egui::Id) -> Self {
168        self.prev_focus_id = Some(id);
169        self
170    }
171
172    /// Renders the input cell into `ui` and handles keyboard/focus interaction.
173    pub fn show(self, ui: &mut Ui) -> Response {
174        let fallback_theme;
175        let theme = match self.theme {
176            Some(t) => t,
177            None => {
178                fallback_theme = MacTheme::from_ctx(ui.ctx());
179                &fallback_theme
180            }
181        };
182
183        let tf_tokens = &theme.control_metrics().text_field;
184        let metrics = match self.control_size {
185            MacControlSize::Regular => &tf_tokens.regular,
186            MacControlSize::Small => &tf_tokens.small,
187            MacControlSize::Mini => &tf_tokens.mini,
188            MacControlSize::Large => &tf_tokens.large,
189            MacControlSize::CompactInfobox => &tf_tokens.compact_infobox,
190        };
191        let ring_metrics = theme.focus_ring();
192
193        let height = self.height.unwrap_or_else(|| metrics.height.points());
194        let width = self.width.unwrap_or_else(|| metrics.default_width.points());
195        let font_size = self.font_size.unwrap_or_else(|| metrics.font_size.points());
196        let corner_radius = self
197            .corner_radius
198            .unwrap_or_else(|| metrics.corner_radius.points());
199
200        let desired_size = Vec2::new(width, height);
201        let sense = if self.enabled {
202            egui::Sense::click_and_drag()
203        } else {
204            egui::Sense::hover()
205        };
206        let (rect, mut response) = ui.allocate_exact_size(desired_size, sense);
207
208        if self.enabled && (response.clicked() || response.has_focus()) {
209            ui.memory_mut(|m| m.request_focus(self.id));
210        }
211
212        let is_focused = self.enabled && ui.memory(|m| m.has_focus(self.id));
213        let alpha_factor = if self.enabled {
214            1.0
215        } else {
216            theme.disabled_opacity()
217        };
218
219        // Transactional buffer state management in egui::Memory
220        let active_key = self.id.with("mac_input_editing_active");
221        let snapshot_key = self.id.with("mac_input_snapshot");
222        let buffer_key = self.id.with("mac_input_buffer");
223        let was_active = ui.data(|d| d.get_temp::<bool>(active_key).unwrap_or(false));
224
225        let esc_pressed = (is_focused || was_active)
226            && (ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape))
227                || ui.input(|i| {
228                    i.raw.events.iter().any(|e| {
229                        matches!(
230                            e,
231                            egui::Event::Key {
232                                key: Key::Escape,
233                                pressed: true,
234                                ..
235                            }
236                        )
237                    })
238                }));
239
240        if is_focused && !was_active && !esc_pressed {
241            let initial = self.value.clone();
242            ui.data_mut(|d| {
243                d.insert_temp(active_key, true);
244                d.insert_temp(snapshot_key, initial.clone());
245                d.insert_temp(buffer_key, initial);
246            });
247        } else if !is_focused && was_active && !esc_pressed {
248            if let Some(buf) = ui.data(|d| d.get_temp::<String>(buffer_key)) {
249                *self.value = buf;
250            }
251            ui.data_mut(|d| {
252                d.insert_temp(active_key, false);
253            });
254        }
255
256        // Prevent egui's built-in FocusDirection::Next / Previous from hijacking Tab/Shift+Tab
257        if is_focused || was_active || self.next_focus_id.is_some() || self.prev_focus_id.is_some()
258        {
259            ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
260        }
261
262        // Intercept Escape, Enter, and Tab/Shift+Tab before TextEdit surrenders focus or cascades
263        let mut focus_transition_to: Option<egui::Id> = None;
264        let mut surrender_now = false;
265
266        if esc_pressed {
267            if let Some(snapshot) = ui.data(|d| d.get_temp::<String>(snapshot_key)) {
268                *self.value = snapshot.clone();
269                ui.data_mut(|d| d.insert_temp(buffer_key, snapshot));
270                response.mark_changed();
271            }
272            ui.data_mut(|d| d.insert_temp(active_key, false));
273            ui.memory_mut(|m| m.surrender_focus(self.id));
274            surrender_now = true;
275        } else if is_focused {
276            if let Some((ref range, step)) = self.numeric_config {
277                let stepper_tokens = &theme.control_metrics().stepper;
278                let shift_mult = stepper_tokens.shift_multiplier.points() as f64;
279                let cmd_mult = stepper_tokens.cmd_multiplier.points() as f64;
280                let num_delta = ui.input_mut(|i| {
281                    let mut check_dir = |key: Key| -> Option<f64> {
282                        if i.consume_key(Modifiers::COMMAND, key)
283                            || i.consume_key(Modifiers::CTRL, key)
284                            || i.consume_key(Modifiers::MAC_CMD, key)
285                        {
286                            Some(cmd_mult)
287                        } else if i.consume_key(Modifiers::SHIFT, key) {
288                            Some(shift_mult)
289                        } else if i.consume_key(Modifiers::NONE, key) {
290                            Some(1.0)
291                        } else {
292                            None
293                        }
294                    };
295                    if let Some(m) = check_dir(Key::ArrowUp) {
296                        Some(step * m)
297                    } else {
298                        check_dir(Key::ArrowDown).map(|m| -step * m)
299                    }
300                });
301                if let Some(delta) = num_delta {
302                    let cur_str = ui
303                        .data(|d| d.get_temp::<String>(buffer_key))
304                        .unwrap_or_else(|| self.value.clone());
305                    let snap_str = ui
306                        .data(|d| d.get_temp::<String>(snapshot_key))
307                        .unwrap_or_else(|| self.value.clone());
308                    let cur_val = cur_str
309                        .trim()
310                        .parse::<f64>()
311                        .or_else(|_| snap_str.trim().parse::<f64>())
312                        .unwrap_or(*range.start());
313                    let next_val = (cur_val + delta).clamp(*range.start(), *range.end());
314                    let formatted = format_stepped_number(&cur_str, &snap_str, step, next_val);
315                    *self.value = formatted.clone();
316                    ui.data_mut(|d| {
317                        d.insert_temp(buffer_key, formatted.clone());
318                        d.insert_temp(snapshot_key, formatted);
319                    });
320                    response.mark_changed();
321                }
322            }
323            if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
324                if let Some(buf) = ui.data(|d| d.get_temp::<String>(buffer_key)) {
325                    *self.value = buf;
326                }
327                ui.data_mut(|d| {
328                    d.insert_temp(active_key, false);
329                    d.insert_temp(snapshot_key, self.value.clone());
330                });
331                ui.memory_mut(|m| m.surrender_focus(self.id));
332                surrender_now = true;
333            } else if let Some(prev) = self.prev_focus_id {
334                if ui.input_mut(|i| i.consume_key(Modifiers::SHIFT, Key::Tab)) {
335                    if let Some(buf) = ui.data(|d| d.get_temp::<String>(buffer_key)) {
336                        *self.value = buf;
337                    }
338                    ui.data_mut(|d| d.insert_temp(active_key, false));
339                    ui.memory_mut(|m| m.surrender_focus(self.id));
340                    focus_transition_to = Some(prev);
341                } else if let Some(next) = self.next_focus_id {
342                    if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) {
343                        if let Some(buf) = ui.data(|d| d.get_temp::<String>(buffer_key)) {
344                            *self.value = buf;
345                        }
346                        ui.data_mut(|d| d.insert_temp(active_key, false));
347                        ui.memory_mut(|m| m.surrender_focus(self.id));
348                        focus_transition_to = Some(next);
349                    }
350                }
351            } else if let Some(next) = self.next_focus_id {
352                if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) {
353                    if let Some(buf) = ui.data(|d| d.get_temp::<String>(buffer_key)) {
354                        *self.value = buf;
355                    }
356                    ui.data_mut(|d| d.insert_temp(active_key, false));
357                    ui.memory_mut(|m| m.surrender_focus(self.id));
358                    focus_transition_to = Some(next);
359                }
360            }
361        }
362
363        let painter = ui.painter_at(rect);
364
365        // 1. Draw outer focus ring halo (NSFocusRingPlacementOutside) when focused
366        if is_focused && !surrender_now {
367            let halo_offset = ring_metrics.offset.points();
368            let halo_stroke_width = ring_metrics.stroke_width.points();
369            let halo_radius = corner_radius + ring_metrics.corner_radius_delta.points();
370            let halo_rect = rect.expand(halo_offset);
371
372            painter.rect(
373                halo_rect,
374                CornerRadius::same(halo_radius as u8),
375                Color32::TRANSPARENT,
376                Stroke::new(halo_stroke_width, theme.keyboard_focus_indicator()),
377                StrokeKind::Outside,
378            );
379        }
380
381        // 2. Control background and border (painted BEFORE TextEdit so text is never obscured)
382        let bg_color = theme.control_background().gamma_multiply(alpha_factor);
383        let border_stroke = if is_focused && !surrender_now {
384            Stroke::new(metrics.border_width.points(), theme.control_accent())
385        } else {
386            Stroke::new(
387                metrics.border_width.points(),
388                theme.separator().gamma_multiply(alpha_factor),
389            )
390        };
391
392        painter.rect(
393            rect,
394            CornerRadius::same(corner_radius as u8),
395            bg_color,
396            border_stroke,
397            StrokeKind::Inside,
398        );
399
400        // 3. Inner text edit bound to transactional memory buffer while active
401        let shrink_pad = metrics
402            .padding_horizontal
403            .as_ref()
404            .map(|d| d.points())
405            .unwrap_or_else(|| metrics.corner_radius.points());
406        let mut child_ui = ui.new_child(
407            egui::UiBuilder::new()
408                .max_rect(rect.shrink2(Vec2::new(shrink_pad, 0.0)))
409                .layout(egui::Layout::left_to_right(egui::Align::Center)),
410        );
411
412        let mut edit_buf = if is_focused && !surrender_now {
413            ui.data(|d| d.get_temp::<String>(buffer_key))
414                .unwrap_or_else(|| self.value.clone())
415        } else {
416            self.value.clone()
417        };
418
419        let mut text_edit = egui::TextEdit::singleline(&mut edit_buf)
420            .id(self.id)
421            .font(egui::FontId::proportional(font_size))
422            .text_color(theme.label().gamma_multiply(alpha_factor))
423            .desired_width((width - shrink_pad * 2.0).max(0.0))
424            .frame(egui::Frame::NONE)
425            .lock_focus(true)
426            .interactive(self.enabled);
427
428        if let Some(hint) = self.placeholder {
429            text_edit = text_edit.hint_text(hint);
430        }
431
432        let text_res = child_ui.add(text_edit);
433        if text_res.changed() && !surrender_now {
434            *self.value = edit_buf.clone();
435            ui.data_mut(|d| d.insert_temp(buffer_key, edit_buf));
436            response.mark_changed();
437        }
438
439        if is_focused && !surrender_now {
440            // Consume single-character canvas keys and editing keys so they don't leak after TextEdit processes them
441            ui.input_mut(|i| {
442                let _ = i.consume_key(Modifiers::NONE, Key::Backspace);
443                let _ = i.consume_key(Modifiers::NONE, Key::Delete);
444                let _ = i.consume_key(Modifiers::NONE, Key::Space);
445                let _ = i.consume_key(Modifiers::NONE, Key::ArrowLeft);
446                let _ = i.consume_key(Modifiers::NONE, Key::ArrowRight);
447                if !i.modifiers.command && !i.modifiers.ctrl && !i.modifiers.mac_cmd {
448                    i.keys_down.clear();
449                }
450                i.events.retain(|ev| match ev {
451                    egui::Event::Key { modifiers, .. } => {
452                        modifiers.command || modifiers.ctrl || modifiers.mac_cmd
453                    }
454                    _ => true,
455                });
456            });
457        }
458
459        let post_focused = ui.memory(|m| m.has_focus(self.id));
460        if is_focused && !post_focused && !surrender_now && focus_transition_to.is_none() {
461            // Focus lost via outside click (blur) -> commit transactional buffer
462            if let Some(buf) = ui.data(|d| d.get_temp::<String>(buffer_key)) {
463                *self.value = buf;
464            }
465            ui.data_mut(|d| d.insert_temp(active_key, false));
466        }
467
468        if surrender_now {
469            ui.memory_mut(|m| m.surrender_focus(self.id));
470        } else if let Some(target_id) = focus_transition_to {
471            ui.memory_mut(|m| {
472                m.move_focus(egui::FocusDirection::None);
473                m.request_focus(target_id);
474            });
475        } else if post_focused {
476            ui.memory_mut(|m| {
477                m.set_focus_lock_filter(
478                    self.id,
479                    egui::EventFilter {
480                        tab: true,
481                        horizontal_arrows: true,
482                        vertical_arrows: true,
483                        escape: true,
484                    },
485                );
486            });
487        }
488
489        text_res.union(response).with_new_rect(rect)
490    }
491}