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