Skip to main content

sequoia_elements/
chip.rs

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}