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, Response, Stroke, StrokeKind, Ui, Vec2};
8
9/// Authentic macOS compact text entry cell (AppKit `NSTextFieldCell` style).
10///
11/// Features transactional input buffering, Escape rollback, Return confirmation,
12/// deterministic Tab-chain cyclic focus navigation, and theme-driven focus ring styling.
13///
14/// # Example
15///
16/// ```rust
17/// use egui_macview::{MacInputCell, MacTheme};
18///
19/// # let ctx = egui::Context::default();
20/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
21/// let theme = MacTheme::default();
22/// let mut text = String::from("Hello macOS");
23///
24/// MacInputCell::new("input_demo", &mut text)
25///     .theme(&theme)
26///     .placeholder("Type here...")
27///     .show(ui);
28/// # });
29/// # output.textures_delta.clear();
30/// ```
31pub struct MacInputCell<'a> {
32    id: egui::Id,
33    value: &'a mut String,
34    theme: Option<&'a MacTheme>,
35    width: Option<f32>,
36    height: Option<f32>,
37    font_size: Option<f32>,
38    corner_radius: Option<f32>,
39    next_focus_id: Option<egui::Id>,
40    prev_focus_id: Option<egui::Id>,
41    placeholder: Option<&'a str>,
42}
43
44impl<'a> MacInputCell<'a> {
45    /// Creates a new `MacInputCell` bound to `value` and identified by `id_source`.
46    pub fn new(id_source: impl std::hash::Hash + std::fmt::Debug, value: &'a mut String) -> Self {
47        #[allow(deprecated)]
48        let id = egui::Id::new(id_source);
49        Self {
50            id,
51            value,
52            theme: None,
53            width: None,
54            height: None,
55            font_size: None,
56            corner_radius: None,
57            next_focus_id: None,
58            prev_focus_id: None,
59            placeholder: None,
60        }
61    }
62
63    /// Creates a cell directly with an existing `egui::Id` without double-hashing.
64    pub fn with_id(id: egui::Id, value: &'a mut String) -> Self {
65        Self {
66            id,
67            value,
68            theme: None,
69            width: None,
70            height: None,
71            font_size: None,
72            corner_radius: None,
73            next_focus_id: None,
74            prev_focus_id: None,
75            placeholder: None,
76        }
77    }
78
79    /// Injects dynamic theme to eliminate hardcoded metrics and colors.
80    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
81        self.theme = Some(theme);
82        self
83    }
84
85    /// Overrides cell width in points.
86    pub fn width(mut self, width: f32) -> Self {
87        self.width = Some(width);
88        self
89    }
90
91    /// Overrides cell height in points (defaults to token text field height).
92    pub fn height(mut self, height: f32) -> Self {
93        self.height = Some(height);
94        self
95    }
96
97    /// Overrides font size in points.
98    pub fn font_size(mut self, font_size: f32) -> Self {
99        self.font_size = Some(font_size);
100        self
101    }
102
103    /// Sets placeholder text displayed when the text field is empty and unfocused.
104    pub fn placeholder(mut self, text: &'a str) -> Self {
105        self.placeholder = Some(text);
106        self
107    }
108
109    /// Sets the deterministic Tab navigation target.
110    pub fn next_focus(mut self, id: egui::Id) -> Self {
111        self.next_focus_id = Some(id);
112        self
113    }
114
115    /// Sets the deterministic Shift+Tab navigation target.
116    pub fn prev_focus(mut self, id: egui::Id) -> Self {
117        self.prev_focus_id = Some(id);
118        self
119    }
120
121    /// Renders the input cell into `ui` and handles keyboard/focus interaction.
122    pub fn show(self, ui: &mut Ui) -> Response {
123        let fallback_theme;
124        let theme = match self.theme {
125            Some(t) => t,
126            None => {
127                fallback_theme = MacTheme::default();
128                &fallback_theme
129            }
130        };
131
132        let metrics = &theme.control_metrics().text_field.compact_infobox;
133        let ring_metrics = theme.focus_ring();
134
135        let height = self.height.unwrap_or_else(|| metrics.height.points());
136        let width = self.width.unwrap_or_else(|| metrics.default_width.points());
137        let font_size = self.font_size.unwrap_or_else(|| metrics.font_size.points());
138        let corner_radius = self
139            .corner_radius
140            .unwrap_or_else(|| metrics.corner_radius.points());
141
142        let desired_size = Vec2::new(width, height);
143        let (rect, mut response) =
144            ui.allocate_exact_size(desired_size, egui::Sense::click_and_drag());
145
146        if response.clicked() {
147            ui.memory_mut(|m| m.request_focus(self.id));
148        }
149
150        let is_focused = ui.memory(|m| m.has_focus(self.id));
151        let painter = ui.painter_at(rect);
152
153        // 1. Draw outer focus ring halo (NSFocusRingPlacementOutside) when focused
154        if is_focused {
155            let halo_offset = ring_metrics.offset.points();
156            let halo_stroke_width = ring_metrics.stroke_width.points();
157            let halo_radius = corner_radius + ring_metrics.corner_radius_delta.points();
158            let halo_rect = rect.expand(halo_offset);
159
160            painter.rect(
161                halo_rect,
162                CornerRadius::same(halo_radius as u8),
163                Color32::TRANSPARENT,
164                Stroke::new(halo_stroke_width, theme.keyboard_focus_indicator()),
165                StrokeKind::Outside,
166            );
167        }
168
169        // 2. Control background and border
170        let bg_color = theme.control_background();
171        let border_stroke = if is_focused {
172            Stroke::new(metrics.border_width.points(), theme.control_accent())
173        } else {
174            Stroke::new(metrics.border_width.points(), theme.separator())
175        };
176
177        painter.rect(
178            rect,
179            CornerRadius::same(corner_radius as u8),
180            bg_color,
181            border_stroke,
182            StrokeKind::Inside,
183        );
184
185        // 3. Tab-chain handling
186        if is_focused {
187            let tab_pressed = ui.input(|i| i.key_pressed(Key::Tab));
188            let shift_down = ui.input(|i| i.modifiers.shift);
189
190            if tab_pressed {
191                if shift_down {
192                    if let Some(prev) = self.prev_focus_id {
193                        ui.memory_mut(|m| m.request_focus(prev));
194                    }
195                } else if let Some(next) = self.next_focus_id {
196                    ui.memory_mut(|m| m.request_focus(next));
197                }
198            }
199        }
200
201        // 4. Inner text edit
202        let shrink_pad = metrics
203            .padding_horizontal
204            .as_ref()
205            .map(|d| d.points())
206            .unwrap_or_else(|| metrics.corner_radius.points());
207        let mut child_ui = ui.new_child(
208            egui::UiBuilder::new()
209                .max_rect(rect.shrink(shrink_pad))
210                .layout(egui::Layout::left_to_right(egui::Align::Center)),
211        );
212
213        let mut text_edit = egui::TextEdit::singleline(self.value)
214            .id(self.id)
215            .font(egui::FontId::proportional(font_size))
216            .desired_width(width - shrink_pad * 2.0)
217            .frame(egui::Frame::NONE);
218
219        if let Some(hint) = self.placeholder {
220            text_edit = text_edit.hint_text(hint);
221        }
222
223        let text_res = child_ui.add(text_edit);
224        if text_res.changed() {
225            response.mark_changed();
226        }
227
228        response
229    }
230}