1use std::rc::Rc;
2
3use sequoia_core::{Color, Edges, Length, Rect, Size};
4use sequoia_input::{CursorIcon, Event, EventPhase, Key, PointerButton, PointerEvent};
5use sequoia_layout::{LayoutCtx, LayoutStyle};
6use sequoia_style::{ChipStyle, Theme, VisualState};
7use sequoia_view::{AssetRegistry, EventCtx, FontWeight, PaintCtx, Unit, View, icon_key};
8
9const PAD_X: f32 = 10.0;
10const PAD_Y: f32 = 4.0;
11const RADIUS: f32 = 999.0;
12const FONT_SIZE: f32 = 12.0;
13const BORDER_W: f32 = 1.0;
14const ICON_PX: f32 = 14.0;
15const ICON_GAP: f32 = 6.0;
16const REMOVE_PX: f32 = 14.0;
17const REMOVE_GAP: f32 = 6.0;
18
19pub fn remove_hit_rect(rect: Rect, pad_x: f32, box_px: f32) -> Rect {
20 let s = box_px.max(0.0);
21 Rect::from_xywh(
22 rect.right() - pad_x - s,
23 rect.top() + (rect.size.height - s) * 0.5,
24 s,
25 s,
26 )
27}
28
29pub struct Chip {
30 label: String,
31 icon: Option<String>,
32 selected: bool,
33 disabled: bool,
34 removable: bool,
35 style: Option<ChipStyle>,
36 background: Option<Color>,
37 border: Option<Color>,
38 text_color: Option<Color>,
39 selected_background: Option<Color>,
40 selected_border: Option<Color>,
41 selected_text: Option<Color>,
42 font_size: Option<f32>,
43 radius: Option<f32>,
44 padding: Option<(f32, f32)>,
45 on_press: Option<Rc<dyn Fn()>>,
46 on_remove: Option<Rc<dyn Fn()>>,
47 margin: Edges,
48}
49
50impl Chip {
51 pub fn new(label: impl Into<String>) -> Self {
52 Self {
53 label: label.into(),
54 icon: None,
55 selected: false,
56 disabled: false,
57 removable: false,
58 style: None,
59 background: None,
60 border: None,
61 text_color: None,
62 selected_background: None,
63 selected_border: None,
64 selected_text: None,
65 font_size: None,
66 radius: None,
67 padding: None,
68 on_press: None,
69 on_remove: None,
70 margin: Edges::ZERO,
71 }
72 }
73
74 pub fn icon(mut self, name: impl Into<String>) -> Self {
75 self.icon = Some(name.into());
76 self
77 }
78
79 pub fn selected(mut self, selected: bool) -> Self {
80 self.selected = selected;
81 self
82 }
83
84 pub fn disabled(mut self, disabled: bool) -> Self {
85 self.disabled = disabled;
86 self
87 }
88
89 pub fn removable(mut self, removable: bool) -> Self {
90 self.removable = removable;
91 self
92 }
93
94 pub fn style(mut self, style: ChipStyle) -> Self {
95 self.style = Some(style);
96 self
97 }
98
99 pub fn background(mut self, color: Color) -> Self {
100 self.background = Some(color);
101 self
102 }
103
104 pub fn border(mut self, color: Color) -> Self {
105 self.border = Some(color);
106 self
107 }
108
109 pub fn text_color(mut self, color: Color) -> Self {
110 self.text_color = Some(color);
111 self
112 }
113
114 pub fn selected_background(mut self, color: Color) -> Self {
115 self.selected_background = Some(color);
116 self
117 }
118
119 pub fn selected_border(mut self, color: Color) -> Self {
120 self.selected_border = Some(color);
121 self
122 }
123
124 pub fn selected_text(mut self, color: Color) -> Self {
125 self.selected_text = Some(color);
126 self
127 }
128
129 pub fn font_size(mut self, size: f32) -> Self {
130 self.font_size = Some(size);
131 self
132 }
133
134 pub fn radius(mut self, radius: f32) -> Self {
135 self.radius = Some(radius);
136 self
137 }
138
139 pub fn padding_xy(mut self, pad_x: f32, pad_y: f32) -> Self {
140 self.padding = Some((pad_x, pad_y));
141 self
142 }
143
144 pub fn on_press(mut self, f: impl Fn() + 'static) -> Self {
145 self.on_press = Some(Rc::new(f));
146 self
147 }
148
149 pub fn on_remove(mut self, f: impl Fn() + 'static) -> Self {
150 self.on_remove = Some(Rc::new(f));
151 self
152 }
153
154 pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
155 let m = margin.into();
156 self.margin = Edges::only(
157 sanitize_margin(m.left),
158 sanitize_margin(m.top),
159 sanitize_margin(m.right),
160 sanitize_margin(m.bottom),
161 );
162 self
163 }
164
165 pub fn margin_xy(self, x: f32, y: f32) -> Self {
166 self.margin(Edges::xy(x, y))
167 }
168
169 pub fn margin_x(mut self, x: f32) -> Self {
170 let x = sanitize_margin(x);
171 self.margin.left = x;
172 self.margin.right = x;
173 self
174 }
175
176 pub fn margin_y(mut self, y: f32) -> Self {
177 let y = sanitize_margin(y);
178 self.margin.top = y;
179 self.margin.bottom = y;
180 self
181 }
182
183 pub fn margin_left(mut self, v: f32) -> Self {
184 self.margin.left = sanitize_margin(v);
185 self
186 }
187
188 pub fn margin_right(mut self, v: f32) -> Self {
189 self.margin.right = sanitize_margin(v);
190 self
191 }
192
193 pub fn margin_top(mut self, v: f32) -> Self {
194 self.margin.top = sanitize_margin(v);
195 self
196 }
197
198 pub fn margin_bottom(mut self, v: f32) -> Self {
199 self.margin.bottom = sanitize_margin(v);
200 self
201 }
202
203 fn interactive(&self) -> bool {
204 !self.disabled && (self.on_press.is_some() || self.on_remove.is_some())
205 }
206
207 fn shows_remove(&self) -> bool {
208 self.removable && self.on_remove.is_some() && !self.disabled
209 }
210
211 fn font_size_resolved(&self, style: ChipStyle) -> f32 {
212 self.font_size.or(style.font_size).unwrap_or(FONT_SIZE)
213 }
214
215 fn padding_resolved(&self, style: ChipStyle) -> (f32, f32) {
216 self.padding.or(style.padding).unwrap_or((PAD_X, PAD_Y))
217 }
218
219 fn resolve(&self, theme: &Theme) -> (Color, Color, Color) {
220 let style = self.style.unwrap_or_default();
221 if self.disabled {
222 return (theme.disabled, theme.border, theme.text_secondary);
223 }
224 if self.selected {
225 (
226 self.selected_background
227 .or(style.selected_background)
228 .unwrap_or(Color::hex(0x3A3A3A)),
229 self.selected_border
230 .or(style.selected_border)
231 .or(self.border)
232 .or(style.border)
233 .unwrap_or(Color::TRANSPARENT),
234 self.selected_text
235 .or(style.selected_text)
236 .or(style.text)
237 .unwrap_or(theme.text),
238 )
239 } else {
240 (
241 self.background
242 .or(style.background)
243 .unwrap_or(theme.surface),
244 self.border.or(style.border).unwrap_or(theme.border),
245 self.text_color.or(style.text).unwrap_or(theme.text),
246 )
247 }
248 }
249
250 fn text_width(&self, ctx: &mut LayoutCtx, size: f32) -> f32 {
251 ctx.text_width_weighted(&self.label, size, FontWeight::MEDIUM)
252 }
253
254 fn content_width(&self, ctx: &mut LayoutCtx, size: f32, pad_x: f32) -> f32 {
255 let mut w = self.text_width(ctx, size);
256 if self.icon.is_some() {
257 w += ICON_PX + ICON_GAP;
258 }
259 if self.shows_remove() {
260 w += REMOVE_GAP + REMOVE_PX;
261 }
262 w + pad_x * 2.0
263 }
264
265 fn press(&self, ctx: &mut EventCtx) {
266 if let Some(f) = &self.on_press {
267 f();
268 }
269 ctx.request_focus();
270 ctx.handled();
271 }
272
273 fn remove(&self, ctx: &mut EventCtx) {
274 if let Some(f) = &self.on_remove {
275 f();
276 }
277 ctx.request_focus();
278 ctx.handled();
279 }
280}
281
282fn sanitize_margin(v: f32) -> f32 {
283 if v.is_finite() { v.max(0.0) } else { 0.0 }
284}
285
286impl View for Chip {
287 fn body(&self) -> impl View + 'static {
288 Unit
289 }
290 fn is_leaf(&self) -> bool {
291 true
292 }
293 fn focusable(&self) -> bool {
294 self.interactive()
295 }
296 fn has_visual_state(&self) -> bool {
297 self.interactive()
298 }
299 fn cursor_icon(&self, _state: VisualState) -> CursorIcon {
300 if self.interactive() {
301 CursorIcon::Pointer
302 } else {
303 CursorIcon::Default
304 }
305 }
306 fn layout_style(&self) -> LayoutStyle {
307 LayoutStyle {
308 width: Length::Fit,
309 height: Length::Fit,
310 margin: self.margin,
311 ..LayoutStyle::default()
312 }
313 }
314 fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
315 let style = self.style.unwrap_or_default();
316 let size = self.font_size_resolved(style);
317 let (pad_x, pad_y) = self.padding_resolved(style);
318 Some(Size::new(
319 self.content_width(ctx, size, pad_x),
320 LayoutCtx::text_line_height(size) + pad_y * 2.0,
321 ))
322 }
323 fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
324 if !self.interactive() || ctx.phase != EventPhase::Target {
325 return;
326 }
327 match event {
328 Event::Pointer(PointerEvent::Up {
329 button: PointerButton::Left,
330 pos,
331 ..
332 }) => {
333 if !(ctx.state.pressed && ctx.hover_self) {
334 return;
335 }
336 if self.shows_remove() {
337 let style = self.style.unwrap_or_default();
338 let (pad_x, _) = self.padding_resolved(style);
339 if remove_hit_rect(ctx.rect, pad_x, REMOVE_PX).contains(*pos) {
340 self.remove(ctx);
341 return;
342 }
343 }
344 if self.on_press.is_some() {
345 self.press(ctx);
346 }
347 }
348 Event::Pointer(PointerEvent::Down {
349 button: PointerButton::Left,
350 pos,
351 ..
352 }) => {
353 if self.shows_remove() {
354 let style = self.style.unwrap_or_default();
355 let (pad_x, _) = self.padding_resolved(style);
356 if remove_hit_rect(ctx.rect, pad_x, REMOVE_PX).contains(*pos) {
357 ctx.handled();
358 }
359 }
360 }
361 Event::Key(key) => {
362 if !ctx.state.focused {
363 return;
364 }
365 match key.key {
366 Key::ENTER | Key::SPACE => {
367 if self.on_press.is_some() {
368 self.press(ctx);
369 }
370 }
371 Key::BACKSPACE | Key::DELETE if self.on_remove.is_some() && !self.disabled => {
372 self.remove(ctx);
373 }
374 _ => {}
375 }
376 }
377 _ => {}
378 }
379 }
380 fn paint_self(&self, ctx: &mut PaintCtx) {
381 let rect = ctx.rect;
382 if rect.size.width <= 0.0 || rect.size.height <= 0.0 || self.label.is_empty() {
383 return;
384 }
385 let theme = ctx.theme();
386 let style = self.style.unwrap_or_default();
387 let size = self.font_size_resolved(style);
388 let (pad_x, _) = self.padding_resolved(style);
389 let radius = self.radius.or(style.radius).unwrap_or(RADIUS);
390 let (bg, border, text) = self.resolve(&theme);
391
392 let mut bg = bg;
393 if ctx.state.hovered && self.interactive() && !self.selected {
394 bg = Color::hex(0x4A4A4A);
395 }
396 ctx.bordered_quad(rect, bg, radius, BORDER_W, border);
397
398 let mut x = rect.left() + pad_x;
399 let line_h = LayoutCtx::text_line_height(size);
400 if let Some(name) = &self.icon {
401 if let Some(assets) = ctx.environment.get::<AssetRegistry>() {
402 let raster_px = ((ICON_PX * ctx.scale_factor()).round() as u32).max(1);
403 if let Ok(sprite) = assets.rasterize_icon(name, raster_px) {
404 let y = rect.top() + (rect.size.height - ICON_PX) * 0.5;
405 ctx.draw_sprite(
406 icon_key(name, raster_px),
407 sprite.width,
408 sprite.height,
409 &sprite.alpha,
410 Rect::from_xywh(x, y, ICON_PX, ICON_PX),
411 text,
412 );
413 }
414 }
415 x += ICON_PX + ICON_GAP;
416 }
417 let y = rect.top() + rect.size.height * 0.5 + size * 0.35;
418 ctx.draw_text_weighted(&self.label, (x, y), size, FontWeight::MEDIUM, text);
419 let _ = line_h;
420
421 if self.shows_remove() {
422 let r = remove_hit_rect(rect, pad_x, REMOVE_PX);
423 let col = theme.text_secondary;
424 let w = ctx.text_width_weighted("×", size, FontWeight::MEDIUM);
425 let x = r.left() + (r.size.width - w) * 0.5;
426 let y = r.top() + r.size.height * 0.5 + size * 0.35;
427 ctx.draw_text_weighted("×", (x, y), size, FontWeight::MEDIUM, col);
428 }
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use sequoia_core::Point;
436
437 #[test]
438 fn remove_zone_hugs_right_edge() {
439 let rect = Rect::from_xywh(0.0, 0.0, 120.0, 24.0);
440 let r = remove_hit_rect(rect, 10.0, 14.0);
441 assert_eq!(r, Rect::from_xywh(96.0, 5.0, 14.0, 14.0));
442 assert!(r.contains(Point::new(100.0, 12.0)));
443 assert!(!r.contains(Point::new(50.0, 12.0)));
444 }
445}