1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
//! A single editable, removable chip: a bordered inline text edit with an
//! optional non-editable prefix and an `×` close button.
//!
//! Use this when you have one optional value that should appear inline among
//! other content (e.g., an inline filter pill, a path-segment chip, an
//! editable tag in a single-tag form) and the user can clear it by clicking
//! `×` or pressing Escape on an empty input. For multi-value tag inputs,
//! see [`TagInput`](crate::TagInput).
use std::hash::Hash;
use egui::{
pos2, vec2, Color32, CornerRadius, FontId, FontSelection, Id, Rect, Response, Sense, Shape,
Stroke, StrokeKind, TextEdit, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
};
use crate::theme::{themed_input_visuals, with_alpha, with_themed_visuals, Theme};
use crate::Accent;
/// A bordered inline text input with an `×` close button, bound to a single
/// `String`.
///
/// ```no_run
/// # use elegance::RemovableChip;
/// # egui::__run_test_ui(|ui| {
/// let mut suffix = String::from("run-1");
/// let resp = RemovableChip::new(&mut suffix)
/// .prefix("_")
/// .placeholder("run-1")
/// .show(ui);
/// if resp.removed {
/// // caller drops the field
/// }
/// # });
/// ```
///
/// The chip auto-sizes its editor to fit the current text, clamped to
/// [`auto_size`](Self::auto_size). The `removed` flag in the returned
/// [`RemovableChipResponse`] is set when the user clicks `×` or presses
/// Escape on an empty input; the caller decides whether to actually clear
/// or drop the binding.
#[must_use = "Call `.show(ui)` to render the chip."]
pub struct RemovableChip<'a> {
text: &'a mut String,
prefix: Option<WidgetText>,
placeholder: Option<&'a str>,
accent: Accent,
enabled: bool,
min_width: f32,
max_width: f32,
id_salt: Option<Id>,
}
impl<'a> std::fmt::Debug for RemovableChip<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RemovableChip")
.field("prefix", &self.prefix.as_ref().map(|w| w.text()))
.field("placeholder", &self.placeholder)
.field("accent", &self.accent)
.field("enabled", &self.enabled)
.field("min_width", &self.min_width)
.field("max_width", &self.max_width)
.finish()
}
}
impl<'a> RemovableChip<'a> {
/// Create a chip bound to `text`. The chip's value mirrors this `String`.
pub fn new(text: &'a mut String) -> Self {
Self {
text,
prefix: None,
placeholder: None,
accent: Accent::Sky,
enabled: true,
min_width: 50.0,
max_width: 240.0,
id_salt: None,
}
}
/// Show non-editable text inside the chip, before the input. Useful for
/// leading separators (e.g. `"_"` for a path-suffix chip) or for fixed
/// labels that read as part of the value but aren't part of the binding.
pub fn prefix(mut self, text: impl Into<WidgetText>) -> Self {
self.prefix = Some(text.into());
self
}
/// Placeholder text shown when the input is empty.
pub fn placeholder(mut self, text: &'a str) -> Self {
self.placeholder = Some(text);
self
}
/// Border / focus accent colour. Default: [`Accent::Sky`].
pub fn accent(mut self, accent: Accent) -> Self {
self.accent = accent;
self
}
/// Disable the chip. Disabled chips ignore typing and clicks on `×`.
/// Default: enabled.
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Minimum and maximum width (points) for the editor portion. The chip
/// measures the current text and sizes the editor within this range.
/// Default: `50.0..=240.0`.
pub fn auto_size(mut self, range: std::ops::RangeInclusive<f32>) -> Self {
self.min_width = *range.start();
self.max_width = *range.end();
self
}
/// Stable id salt. Useful when several chips share a layout, or when
/// you need to address the chip's state across frames.
pub fn id_salt(mut self, id: impl Hash) -> Self {
self.id_salt = Some(Id::new(id));
self
}
/// Render the chip and return its response.
pub fn show(self, ui: &mut Ui) -> RemovableChipResponse {
let theme = Theme::current(ui.ctx());
let p = &theme.palette;
let t = &theme.typography;
let id_salt = self.id_salt.unwrap_or_else(|| Id::new(ui.next_auto_id()));
let edit_id = ui.make_persistent_id(id_salt);
let pad_x = 6.0;
let pad_y = 2.0;
let close_diam = 16.0;
let gap = 4.0;
// Reserve the background shape index now so we can paint the fill
// and border under the inner content once focus is known.
let bg_idx = ui.painter().add(Shape::Noop);
// Auto-size the editor by measuring the current text (or
// placeholder when empty) at body-font size and clamping into the
// user-supplied range.
let measure_text = if self.text.is_empty() {
self.placeholder.unwrap_or("")
} else {
self.text.as_str()
};
let measured = WidgetText::from(egui::RichText::new(measure_text).size(t.body))
.into_galley(
ui,
Some(egui::TextWrapMode::Extend),
f32::INFINITY,
FontSelection::FontId(FontId::proportional(t.body)),
);
let editor_w = (measured.size().x + 6.0).clamp(self.min_width, self.max_width);
let mut removed = false;
let inner = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(gap, 0.0);
ui.add_space(pad_x);
if let Some(prefix) = &self.prefix {
let rich = egui::RichText::new(prefix.text())
.color(p.text_faint)
.size(t.body);
ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
}
// The editor borrows the chip's outer chrome, so strip its
// per-state strokes and bg fill.
let edit_response = with_themed_visuals(ui, |ui| {
let v = ui.visuals_mut();
themed_input_visuals(v, &theme, Color32::TRANSPARENT);
v.extreme_bg_color = Color32::TRANSPARENT;
for w in [
&mut v.widgets.inactive,
&mut v.widgets.hovered,
&mut v.widgets.active,
&mut v.widgets.open,
] {
w.bg_stroke = Stroke::NONE;
}
v.selection.bg_fill = with_alpha(p.sky, 90);
v.selection.stroke = Stroke::new(1.0, p.sky);
let mut te = TextEdit::singleline(self.text)
.id(edit_id)
.font(FontSelection::FontId(FontId::proportional(t.body)))
.text_color(p.text)
.desired_width(editor_w)
.frame(
egui::Frame::new().inner_margin(egui::Margin::symmetric(0, pad_y as i8)),
);
if let Some(ph) = self.placeholder {
te = te.hint_text(egui::RichText::new(ph).color(p.text_faint));
}
ui.add_enabled(self.enabled, te)
});
// Close (×) button: a small interact area with a hand-drawn
// cross. Hovered state tints the bg with the danger colour to
// signal "click to remove."
let close_size = Vec2::splat(close_diam);
let sense = if self.enabled {
Sense::click()
} else {
Sense::hover()
};
let (close_rect, close_resp) = ui.allocate_exact_size(close_size, sense);
let close_bg = if close_resp.hovered() && self.enabled {
with_alpha(p.danger, 32)
} else {
Color32::TRANSPARENT
};
ui.painter()
.rect_filled(close_rect, CornerRadius::same(3), close_bg);
let cross_color = if !self.enabled {
p.text_faint
} else if close_resp.hovered() {
p.danger
} else {
p.text_muted
};
paint_cross(ui, close_rect, cross_color);
if close_resp.clicked() {
removed = true;
}
ui.add_space((pad_x - gap).max(0.0));
edit_response
});
let edit_response = inner.inner;
let frame_rect = inner.response.rect;
// Escape on an empty editor signals "remove" to the caller.
if self.enabled
&& edit_response.has_focus()
&& self.text.is_empty()
&& ui.input(|i| i.key_pressed(egui::Key::Escape))
{
removed = true;
}
// Paint the chip's frame underneath everything.
let frame_focused = ui.memory(|m| m.has_focus(edit_id));
let frame_hovered = ui.rect_contains_pointer(frame_rect);
let bg_fill = p.input_bg;
let (border_w, border_color) = if !self.enabled {
(1.0, with_alpha(p.border, 160))
} else if frame_focused {
(1.5, p.accent_fill(self.accent))
} else if frame_hovered {
(1.0, p.text_muted)
} else {
(1.0, p.border)
};
let radius = CornerRadius::same(theme.control_radius as u8);
ui.painter()
.set(bg_idx, Shape::rect_filled(frame_rect, radius, bg_fill));
ui.painter().rect_stroke(
frame_rect,
radius,
Stroke::new(border_w, border_color),
StrokeKind::Inside,
);
// Speak the placeholder (or "Removable chip" when none is set) as
// the field label. The current text is announced separately by the
// OS, so the widget label should describe the field's purpose.
let label_for_a11y = self
.placeholder
.map(str::to_owned)
.unwrap_or_else(|| "Removable chip".to_string());
let response = inner.response;
response.widget_info(|| {
WidgetInfo::labeled(WidgetType::TextEdit, self.enabled, &label_for_a11y)
});
RemovableChipResponse { response, removed }
}
}
/// The result of rendering a [`RemovableChip`].
#[derive(Debug)]
pub struct RemovableChipResponse {
/// Outer [`Response`] covering the whole chip rect. Use this to react
/// to hover, click-outside, etc.
pub response: Response,
/// `true` when the user clicked the `×` button or pressed Escape on
/// an empty editor. The caller decides whether to clear the binding,
/// drop the chip, or otherwise react.
pub removed: bool,
}
fn paint_cross(ui: &Ui, rect: Rect, color: Color32) {
let c = rect.center();
let s = 3.0;
let stroke = Stroke::new(1.5, color);
ui.painter()
.line_segment([pos2(c.x - s, c.y - s), pos2(c.x + s, c.y + s)], stroke);
ui.painter()
.line_segment([pos2(c.x - s, c.y + s), pos2(c.x + s, c.y - s)], stroke);
}