Skip to main content

egui_macview/controls/
token_field.rs

1//! Authentic macOS AppKit Token Field (`NSTokenField` equivalent, SPEC-011).
2//!
3//! Grounded in Apple AppKit `NSTokenField` / `NSTokenFieldCell` and W3C DTCG tokens via [`MacTheme`].
4//! Features `.rounded` 16pt token capsules, flow-wrapping layout, delimiter-driven commit (`,` or `Enter`),
5//! `Backspace` and `×` button removal, outer focus ring halo, and optional character budget counter.
6
7use crate::layout::subpixel_snap_rect;
8use crate::theme::MacTheme;
9use egui::{
10    Align2, CornerRadius, FontId, Id, Key, Modifiers, Pos2, Rect, Response, Sense, Stroke,
11    StrokeKind, TextEdit, Ui, UiBuilder, Vec2, Widget,
12};
13use std::fmt::Debug;
14use std::hash::Hash;
15
16/// Authentic macOS AppKit token pill field (`NSTokenField` equivalent).
17///
18/// # Example
19///
20/// ```rust
21/// use egui_macview::{MacTheme, MacTokenField};
22///
23/// # let ctx = egui::Context::default();
24/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
25/// let theme = MacTheme::default();
26/// let mut tokens = vec!["AppKit".to_string(), "AVKit".to_string()];
27/// let mut input = String::new();
28///
29/// MacTokenField::new("keywords", &mut tokens, &mut input)
30///     .theme(&theme)
31///     .max_chars(120)
32///     .show(ui);
33/// # });
34/// # output.textures_delta.clear();
35/// ```
36pub struct MacTokenField<'a> {
37    id: Id,
38    tokens: &'a mut Vec<String>,
39    input_buffer: &'a mut String,
40    placeholder: &'a str,
41    max_chars: Option<usize>,
42    width: Option<f32>,
43    enabled: bool,
44    theme: Option<&'a MacTheme>,
45}
46
47impl<'a> MacTokenField<'a> {
48    /// Creates a new `MacTokenField` bound to `tokens` and `input_buffer`.
49    pub fn new(
50        id_src: impl Hash + Debug,
51        tokens: &'a mut Vec<String>,
52        input_buffer: &'a mut String,
53    ) -> Self {
54        #[allow(deprecated)]
55        let id = Id::new(id_src);
56        Self {
57            id,
58            tokens,
59            input_buffer,
60            placeholder: "Add token...",
61            max_chars: None,
62            width: None,
63            enabled: true,
64            theme: None,
65        }
66    }
67
68    /// Sets placeholder text shown when no tokens or input text are present.
69    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
70        self.placeholder = placeholder;
71        self
72    }
73
74    /// Configures an optional character budget counter (`current / max_chars`) at bottom-right.
75    pub fn max_chars(mut self, max_chars: usize) -> Self {
76        self.max_chars = Some(max_chars);
77        self
78    }
79
80    /// Overrides the control width in points.
81    pub fn width(mut self, width: f32) -> Self {
82        self.width = Some(width);
83        self
84    }
85
86    /// Enables or disables the token field.
87    pub fn enabled(mut self, enabled: bool) -> Self {
88        self.enabled = enabled;
89        self
90    }
91
92    /// Injects dynamic [`MacTheme`].
93    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
94        self.theme = Some(theme);
95        self
96    }
97
98    /// Renders the token field in `ui`.
99    pub fn show(self, ui: &mut Ui) -> Response {
100        ui.add(self)
101    }
102
103    /// Queries the currently selected token pill index for `id_source` in `ui`.
104    pub fn selected_token(ui: &Ui, id_source: impl Hash + Debug) -> Option<usize> {
105        #[allow(deprecated)]
106        let id = Id::new(id_source).with("selected_token");
107        ui.data(|d| d.get_temp::<Option<usize>>(id)).flatten()
108    }
109}
110
111impl<'a> Widget for MacTokenField<'a> {
112    fn ui(self, ui: &mut Ui) -> Response {
113        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
114        let theme: &MacTheme = &theme_cow;
115        let tfm = theme.token_field();
116        let pad_h = tfm.padding_horizontal.points();
117        let pad_v = tfm.padding_vertical.points();
118        let pill_h = tfm.pill_height.points();
119        let pill_r = CornerRadius::same(tfm.pill_corner_radius.points() as u8);
120        let pill_pad_h = tfm.pill_padding_horizontal.points();
121        let pill_spacing = tfm.pill_spacing.points();
122        let row_spacing = tfm.row_spacing.points();
123        let font_sz = tfm.font_size.points();
124        let remove_sz = tfm.remove_icon_size.points();
125        let min_input_w = tfm.min_input_width.points();
126        let width = self.width.unwrap_or_else(|| tfm.default_width.points());
127        let mut changed = false;
128
129        // 1. Pre-process comma or newline delimiters in input_buffer
130        if self.enabled && (self.input_buffer.contains(',') || self.input_buffer.contains('\n')) {
131            let raw = std::mem::take(self.input_buffer);
132            let segments: Vec<&str> = raw.split([',', '\n', '\r']).collect();
133            for (idx, seg) in segments.iter().enumerate() {
134                let trimmed = seg.trim();
135                if idx + 1 < segments.len() {
136                    if !trimmed.is_empty() {
137                        self.tokens.push(trimmed.to_string());
138                        changed = true;
139                    }
140                } else {
141                    *self.input_buffer = trimmed.to_string();
142                }
143            }
144        }
145
146        let input_id = self.id.with("token_input");
147        let sel_id = self.id.with("selected_token");
148        let raw_sel = ui.data(|d| d.get_temp::<Option<usize>>(sel_id)).flatten();
149        let mut selected_tok = raw_sel.filter(|&i| i < self.tokens.len());
150
151        // 2. Handle Enter, Delete, Backspace, and Left/Right arrow keys
152        if self.enabled {
153            let has_text = !self.input_buffer.trim().is_empty();
154            if has_text && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
155                self.tokens.push(self.input_buffer.trim().to_string());
156                self.input_buffer.clear();
157                selected_tok = None;
158                changed = true;
159            } else if let Some(sel_idx) = selected_tok {
160                let del = ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete));
161                let bs = ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace));
162                if del || bs {
163                    self.tokens.remove(sel_idx);
164                    selected_tok = None;
165                    changed = true;
166                } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowLeft)) {
167                    selected_tok = Some(sel_idx.saturating_sub(1));
168                } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowRight)) {
169                    selected_tok = (sel_idx + 1 < self.tokens.len()).then_some(sel_idx + 1);
170                }
171            } else if self.input_buffer.is_empty()
172                && !self.tokens.is_empty()
173                && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace))
174            {
175                self.tokens.pop();
176                changed = true;
177            }
178        }
179
180        // 3. Measure pill widths and compute flow-wrapping layout
181        let font_id = FontId::proportional(font_sz);
182        let max_inner_w = (width - pad_h * 2.0).max(min_input_w);
183        let mut pill_layouts = Vec::with_capacity(self.tokens.len());
184        let (mut cursor_x, mut cursor_y) = (0.0f32, 0.0f32);
185
186        for (idx, tok) in self.tokens.iter().enumerate() {
187            let galley = ui.painter().layout_no_wrap(
188                tok.clone(),
189                font_id.clone(),
190                egui::Color32::PLACEHOLDER,
191            );
192            let text_w = galley.size().x;
193            let w = (pill_pad_h * 2.0 + text_w + remove_sz).min(max_inner_w);
194            if cursor_x > 0.0 && cursor_x + w > max_inner_w {
195                cursor_x = 0.0;
196                cursor_y += pill_h + row_spacing;
197            }
198            pill_layouts.push((idx, cursor_x, cursor_y, w, galley));
199            cursor_x += w + pill_spacing;
200        }
201
202        if cursor_x > 0.0 && cursor_x + min_input_w > max_inner_w {
203            cursor_x = 0.0;
204            cursor_y += pill_h + row_spacing;
205        }
206        let (input_rel_x, input_rel_y) = (cursor_x, cursor_y);
207        let input_w = (max_inner_w - input_rel_x).max(min_input_w);
208        let cnt_h = self
209            .max_chars
210            .map_or(0.0, |_| tfm.counter_row_height.points());
211        let total_h = (input_rel_y + pill_h + pad_v * 2.0 + cnt_h).max(tfm.min_height.points());
212
213        let alloc_sz = Vec2::new(width, total_h);
214        let (raw_rect, response) = ui.allocate_exact_size(alloc_sz, Sense::click());
215        let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
216        if response.clicked() && self.enabled {
217            ui.memory_mut(|m| m.request_focus(input_id));
218        }
219
220        let in_x = rect.left() + pad_h + input_rel_x;
221        let in_y = rect.top() + pad_v + input_rel_y;
222        let input_rect = Rect::from_min_size(Pos2::new(in_x, in_y), Vec2::new(input_w, pill_h));
223        let txt_col = if self.enabled {
224            theme.label()
225        } else {
226            theme.placeholder_text()
227        };
228        let mut child_ui = ui.new_child(
229            UiBuilder::new()
230                .id_salt((self.id, "token_field_inner"))
231                .max_rect(input_rect),
232        );
233        let edit_w = TextEdit::singleline(self.input_buffer)
234            .id(input_id)
235            .font(font_id.clone())
236            .text_color(txt_col)
237            .frame(egui::Frame::NONE)
238            .interactive(self.enabled);
239        let edit_resp = child_ui.add_sized(input_rect.size(), edit_w);
240
241        if edit_resp.changed() && self.input_buffer.contains(',') {
242            for part in std::mem::take(self.input_buffer).split(',') {
243                if !part.trim().is_empty() {
244                    self.tokens.push(part.trim().to_string());
245                    changed = true;
246                }
247            }
248        }
249
250        let has_mem_focus = ui.memory(|m| m.has_focus(input_id));
251        let is_focused = edit_resp.has_focus() || response.has_focus() || has_mem_focus;
252        let painter = ui.painter_at(rect);
253        let bezel_r = tfm.corner_radius.points();
254
255        if self.enabled && is_focused {
256            theme.paint_focus_ring(&painter, rect, bezel_r);
257        }
258
259        let border_col = if is_focused {
260            theme.control_accent()
261        } else {
262            theme.separator()
263        };
264        let border_s = Stroke::new(tfm.border_width.points(), border_col);
265        let bezel_cr = CornerRadius::same(bezel_r as u8);
266        let ctrl_bg = theme.control_background();
267        painter.rect(rect, bezel_cr, ctrl_bg, border_s, StrokeKind::Inside);
268
269        let sense = if self.enabled {
270            Sense::click()
271        } else {
272            Sense::hover()
273        };
274        let sel_fg = theme
275            .selected_control_background_style()
276            .primary_text_color(theme);
277        let pill_border = Stroke::new(tfm.border_width.points(), theme.separator());
278        let span = tfm.remove_cross_span.points();
279        let rm_size_vec = Vec2::splat(remove_sz);
280        let align_lc = Align2::LEFT_CENTER;
281        let mut remove_idx = None;
282
283        for (idx, rel_x, rel_y, w, galley) in pill_layouts {
284            if idx >= self.tokens.len() {
285                continue;
286            }
287            let pill_pos = Pos2::new(rect.left() + pad_h + rel_x, rect.top() + pad_v + rel_y);
288            let pill_rect = Rect::from_min_size(pill_pos, Vec2::new(w, pill_h));
289            let rm_x = pill_rect.right() - pill_pad_h * 0.5 - remove_sz * 0.5;
290            let rm_rect =
291                Rect::from_center_size(Pos2::new(rm_x, pill_rect.center().y), rm_size_vec);
292            let rm_resp = ui.interact(rm_rect, self.id.with(("rm_tok", idx)), sense);
293            if rm_resp.clicked() && self.enabled {
294                remove_idx = Some(idx);
295            }
296            let body_max = Pos2::new(rm_rect.left().max(pill_rect.left()), pill_rect.max.y);
297            let body_rect = Rect::from_min_max(pill_rect.min, body_max);
298            let body_resp = ui.interact(body_rect, self.id.with(("pill_body", idx)), sense);
299            if body_resp.clicked() && self.enabled {
300                selected_tok = Some(idx);
301            }
302
303            let (pill_bg, pill_fg, x_fg) = if selected_tok == Some(idx) && self.enabled {
304                (theme.control_accent(), sel_fg, sel_fg)
305            } else if rm_resp.hovered() && self.enabled {
306                (theme.selected_control(), sel_fg, sel_fg)
307            } else {
308                (theme.control(), theme.label(), theme.secondary_label())
309            };
310            painter.rect(pill_rect, pill_r, pill_bg, pill_border, StrokeKind::Inside);
311
312            let clip_r = (rm_rect.left() - 2.0).max(pill_rect.left());
313            let text_clip = Rect::from_min_max(pill_rect.min, Pos2::new(clip_r, pill_rect.max.y));
314            let text_anchor = Pos2::new(pill_rect.left() + pill_pad_h, pill_rect.center().y);
315            let text_pos = align_lc.anchor_size(text_anchor, galley.size()).min;
316            let clip_p = painter.with_clip_rect(text_clip);
317            clip_p.galley(text_pos, galley, pill_fg);
318
319            let c = rm_rect.center();
320            let x_stroke = Stroke::new(tfm.remove_icon_stroke.points(), x_fg);
321            let (x0, x1, y0, y1) = (c.x - span, c.x + span, c.y - span, c.y + span);
322            painter.line_segment([Pos2::new(x0, y0), Pos2::new(x1, y1)], x_stroke);
323            painter.line_segment([Pos2::new(x0, y1), Pos2::new(x1, y0)], x_stroke);
324        }
325
326        if let Some(idx) = remove_idx.filter(|&i| i < self.tokens.len()) {
327            self.tokens.remove(idx);
328            selected_tok = None;
329            changed = true;
330        }
331        let next_sel = selected_tok.filter(|&i| i < self.tokens.len());
332        if next_sel != raw_sel {
333            if next_sel.is_some() {
334                ui.data_mut(|d| d.insert_temp(sel_id, next_sel));
335            } else {
336                ui.data_mut(|d| d.remove_temp::<Option<usize>>(sel_id));
337            }
338        }
339
340        if self.tokens.is_empty() && self.input_buffer.is_empty() && !is_focused {
341            let ph_pos = Pos2::new(input_rect.left(), input_rect.center().y);
342            painter.text(
343                ph_pos,
344                align_lc,
345                self.placeholder,
346                font_id,
347                theme.placeholder_text(),
348            );
349        }
350
351        if let Some(max_c) = self.max_chars {
352            let tok_c: usize = self.tokens.iter().map(|t| t.chars().count()).sum();
353            let total_c = tok_c + self.input_buffer.chars().count();
354            let col = if total_c > max_c {
355                theme.system_red()
356            } else {
357                theme.secondary_label()
358            };
359            let cnt_pos = Pos2::new(rect.right() - pad_h, rect.bottom() - pad_v * 0.5);
360            let cnt_f = FontId::proportional(tfm.counter_font_size.points());
361            let cnt_txt = format!("{total_c} / {max_c}");
362            painter.text(cnt_pos, Align2::RIGHT_BOTTOM, cnt_txt, cnt_f, col);
363        }
364
365        let mut final_resp = edit_resp.union(response);
366        if changed {
367            final_resp.mark_changed();
368        }
369        final_resp
370    }
371}