1use 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
16pub 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 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 pub fn placeholder(mut self, placeholder: &'a str) -> Self {
70 self.placeholder = placeholder;
71 self
72 }
73
74 pub fn max_chars(mut self, max_chars: usize) -> Self {
76 self.max_chars = Some(max_chars);
77 self
78 }
79
80 pub fn width(mut self, width: f32) -> Self {
82 self.width = Some(width);
83 self
84 }
85
86 pub fn enabled(mut self, enabled: bool) -> Self {
88 self.enabled = enabled;
89 self
90 }
91
92 pub fn theme(mut self, theme: &'a MacTheme) -> Self {
94 self.theme = Some(theme);
95 self
96 }
97
98 pub fn show(self, ui: &mut Ui) -> Response {
100 ui.add(self)
101 }
102
103 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 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 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 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}