Skip to main content

iced_shadcn/
sidebar.rs

1use std::rc::Rc;
2
3use iced::alignment::{Horizontal, Vertical};
4use iced::border::Border;
5use iced::widget::{button as iced_button, column, container, row, text};
6use iced::{Background, Color, Element, Length};
7
8use crate::button::{ButtonProps, ButtonSize, ButtonVariant, button};
9use crate::theme::Theme;
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum SidebarSide {
13    #[default]
14    Left,
15    Right,
16}
17
18pub struct SidebarProviderProps {
19    pub open: bool,
20    pub default_open: bool,
21    pub expanded_width: f32,
22    pub collapsed_width: f32,
23    pub animate: bool,
24}
25
26impl SidebarProviderProps {
27    pub fn new(open: bool) -> Self {
28        let style = crate::theme::ThemeStyles::default().sidebar;
29        Self {
30            open,
31            default_open: true,
32            expanded_width: style.expanded_width,
33            collapsed_width: style.collapsed_width,
34            animate: true,
35        }
36    }
37
38    pub fn default_open(mut self, default_open: bool) -> Self {
39        self.default_open = default_open;
40        self
41    }
42
43    pub fn expanded_width(mut self, width: f32) -> Self {
44        self.expanded_width = width;
45        self
46    }
47
48    pub fn collapsed_width(mut self, width: f32) -> Self {
49        self.collapsed_width = width;
50        self
51    }
52
53    pub fn animate(mut self, animate: bool) -> Self {
54        self.animate = animate;
55        self
56    }
57}
58
59pub struct SidebarContext<'a, Message> {
60    pub open: bool,
61    pub expanded_width: f32,
62    pub collapsed_width: f32,
63    pub animate: bool,
64    on_open_change: Option<Rc<dyn Fn(bool) -> Message + 'a>>,
65}
66
67impl<'a, Message: Clone> SidebarContext<'a, Message> {
68    pub fn is_collapsed(&self) -> bool {
69        !self.open
70    }
71
72    pub fn set_open_message(&self, open: bool) -> Option<Message> {
73        self.on_open_change.as_ref().map(|f| f(open))
74    }
75
76    pub fn toggle_message(&self) -> Option<Message> {
77        self.on_open_change.as_ref().map(|f| f(!self.open))
78    }
79}
80
81#[derive(Clone, Copy, Debug)]
82pub struct SidebarProps {
83    pub side: SidebarSide,
84    pub padding: f32,
85    pub border: bool,
86}
87
88impl SidebarProps {
89    pub fn new() -> Self {
90        Self {
91            side: SidebarSide::Left,
92            padding: 0.0,
93            border: true,
94        }
95    }
96
97    pub fn side(mut self, side: SidebarSide) -> Self {
98        self.side = side;
99        self
100    }
101
102    pub fn padding(mut self, padding: f32) -> Self {
103        self.padding = padding;
104        self
105    }
106
107    pub fn border(mut self, border: bool) -> Self {
108        self.border = border;
109        self
110    }
111}
112
113impl Default for SidebarProps {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119pub fn sidebar_provider<'a, Message: Clone + 'a, F>(
120    props: SidebarProviderProps,
121    on_open_change: Option<F>,
122    add_contents: impl FnOnce(&SidebarContext<'a, Message>) -> Element<'a, Message>,
123) -> Element<'a, Message>
124where
125    F: Fn(bool) -> Message + 'a,
126{
127    let on_open_change = on_open_change.map(|f| Rc::new(f) as Rc<dyn Fn(bool) -> Message + 'a>);
128    let ctx = SidebarContext {
129        open: props.open,
130        expanded_width: props.expanded_width,
131        collapsed_width: props.collapsed_width,
132        animate: props.animate,
133        on_open_change,
134    };
135
136    add_contents(&ctx)
137}
138
139pub fn sidebar<'a, Message: Clone + 'a>(
140    ctx: &SidebarContext<'_, Message>,
141    props: SidebarProps,
142    theme: &Theme,
143    add_contents: impl FnOnce(&SidebarContext<'_, Message>) -> Element<'a, Message>,
144) -> Element<'a, Message> {
145    let width = if ctx.open {
146        ctx.expanded_width
147    } else {
148        ctx.collapsed_width
149    };
150
151    let palette = theme.palette;
152    let border = Border {
153        radius: theme.radius.md.into(),
154        width: if props.border { 1.0 } else { 0.0 },
155        color: palette.sidebar_border,
156    };
157    let theme = theme.clone();
158
159    container(add_contents(ctx))
160        .width(Length::Fixed(width))
161        .height(Length::Fill)
162        .padding(props.padding)
163        .style(move |_t| iced::widget::container::Style {
164            background: Some(Background::Color(theme.palette.sidebar)),
165            text_color: Some(theme.palette.sidebar_foreground),
166            border,
167            ..Default::default()
168        })
169        .into()
170}
171
172pub fn sidebar_trigger<'a, Message: Clone + 'a>(
173    label: impl Into<String>,
174    ctx: &SidebarContext<'_, Message>,
175    theme: &Theme,
176) -> Element<'a, Message> {
177    button(
178        label.into(),
179        ctx.toggle_message(),
180        ButtonProps::new()
181            .variant(ButtonVariant::Ghost)
182            .size(ButtonSize::Size1),
183        theme,
184    )
185    .into()
186}
187
188pub fn sidebar_header<'a, Message: Clone + 'a>(
189    ctx: &SidebarContext<'_, Message>,
190    content: impl Into<Element<'a, Message>>,
191) -> Element<'a, Message> {
192    sidebar_section(
193        ctx,
194        crate::theme::ThemeStyles::default()
195            .sidebar
196            .header_footer_padding,
197        content,
198    )
199}
200
201pub fn sidebar_content<'a, Message: Clone + 'a>(
202    ctx: &SidebarContext<'_, Message>,
203    content: impl Into<Element<'a, Message>>,
204) -> Element<'a, Message> {
205    sidebar_section(
206        ctx,
207        crate::theme::ThemeStyles::default().sidebar.content_padding,
208        content,
209    )
210}
211
212pub fn sidebar_footer<'a, Message: Clone + 'a>(
213    ctx: &SidebarContext<'_, Message>,
214    content: impl Into<Element<'a, Message>>,
215) -> Element<'a, Message> {
216    sidebar_section(
217        ctx,
218        crate::theme::ThemeStyles::default()
219            .sidebar
220            .header_footer_padding,
221        content,
222    )
223}
224
225fn sidebar_section<'a, Message: Clone + 'a>(
226    _ctx: &SidebarContext<'_, Message>,
227    padding: f32,
228    content: impl Into<Element<'a, Message>>,
229) -> Element<'a, Message> {
230    container(content).padding(padding).into()
231}
232
233#[derive(Clone, Copy, Debug)]
234pub struct SidebarGroupProps {
235    pub spacing: f32,
236}
237
238impl SidebarGroupProps {
239    pub fn new() -> Self {
240        Self { spacing: 8.0 }
241    }
242
243    pub fn spacing(mut self, spacing: f32) -> Self {
244        self.spacing = spacing;
245        self
246    }
247}
248
249impl Default for SidebarGroupProps {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255pub fn sidebar_group<'a, Message: Clone + 'a>(
256    _ctx: &SidebarContext<'_, Message>,
257    props: SidebarGroupProps,
258    content: impl Into<Vec<Element<'a, Message>>>,
259) -> Element<'a, Message> {
260    column(content.into()).spacing(props.spacing).into()
261}
262
263#[derive(Clone, Debug)]
264pub struct SidebarGroupLabelProps {
265    pub text: String,
266    pub show_when_collapsed: bool,
267}
268
269impl SidebarGroupLabelProps {
270    pub fn new(text: impl Into<String>) -> Self {
271        Self {
272            text: text.into(),
273            show_when_collapsed: false,
274        }
275    }
276
277    pub fn show_when_collapsed(mut self, show: bool) -> Self {
278        self.show_when_collapsed = show;
279        self
280    }
281}
282
283pub fn sidebar_group_label<'a, Message: Clone + 'a>(
284    props: SidebarGroupLabelProps,
285    ctx: &SidebarContext<'_, Message>,
286    theme: &Theme,
287) -> Element<'a, Message> {
288    if ctx.is_collapsed() && !props.show_when_collapsed {
289        return container(text("")).into();
290    }
291
292    let color = apply_opacity(theme.palette.sidebar_foreground, 0.6);
293
294    text(props.text)
295        .size(11u32)
296        .style(move |_t| iced::widget::text::Style { color: Some(color) })
297        .into()
298}
299
300pub fn sidebar_group_content<'a, Message: Clone + 'a>(
301    content: impl Into<Vec<Element<'a, Message>>>,
302) -> Element<'a, Message> {
303    column(content.into())
304        .spacing(crate::theme::ThemeStyles::default().sidebar.menu_spacing)
305        .into()
306}
307
308pub fn sidebar_menu<'a, Message: Clone + 'a>(
309    content: impl Into<Vec<Element<'a, Message>>>,
310) -> Element<'a, Message> {
311    column(content.into())
312        .spacing(crate::theme::ThemeStyles::default().sidebar.menu_spacing)
313        .into()
314}
315
316pub fn sidebar_menu_item<'a, Message: Clone + 'a>(
317    content: impl Into<Vec<Element<'a, Message>>>,
318) -> Element<'a, Message> {
319    row(content.into()).spacing(0).into()
320}
321
322#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
323pub enum SidebarMenuButtonSize {
324    Sm,
325    #[default]
326    Md,
327    Lg,
328}
329
330impl SidebarMenuButtonSize {
331    fn height(self) -> f32 {
332        match self {
333            SidebarMenuButtonSize::Sm => 28.0,
334            SidebarMenuButtonSize::Md => 32.0,
335            SidebarMenuButtonSize::Lg => 40.0,
336        }
337    }
338
339    fn padding(self) -> [f32; 2] {
340        match self {
341            SidebarMenuButtonSize::Sm => [6.0, 10.0],
342            SidebarMenuButtonSize::Md => [8.0, 12.0],
343            SidebarMenuButtonSize::Lg => [10.0, 12.0],
344        }
345    }
346
347    fn text_size(self) -> u32 {
348        match self {
349            SidebarMenuButtonSize::Sm => 12,
350            SidebarMenuButtonSize::Md => 13,
351            SidebarMenuButtonSize::Lg => 14,
352        }
353    }
354}
355
356#[derive(Clone, Debug)]
357pub struct SidebarMenuButtonProps {
358    pub label: String,
359    pub size: SidebarMenuButtonSize,
360    pub active: bool,
361    pub disabled: bool,
362    pub show_label_when_collapsed: bool,
363}
364
365impl SidebarMenuButtonProps {
366    pub fn new(label: impl Into<String>) -> Self {
367        Self {
368            label: label.into(),
369            size: SidebarMenuButtonSize::Md,
370            active: false,
371            disabled: false,
372            show_label_when_collapsed: true,
373        }
374    }
375
376    pub fn size(mut self, size: SidebarMenuButtonSize) -> Self {
377        self.size = size;
378        self
379    }
380
381    pub fn active(mut self, active: bool) -> Self {
382        self.active = active;
383        self
384    }
385
386    pub fn disabled(mut self, disabled: bool) -> Self {
387        self.disabled = disabled;
388        self
389    }
390
391    pub fn show_label_when_collapsed(mut self, show: bool) -> Self {
392        self.show_label_when_collapsed = show;
393        self
394    }
395}
396
397pub fn sidebar_menu_button<'a, Message: Clone + 'a>(
398    props: SidebarMenuButtonProps,
399    on_press: Option<Message>,
400    ctx: &SidebarContext<'_, Message>,
401    theme: &Theme,
402) -> Element<'a, Message> {
403    let collapsed = ctx.is_collapsed();
404    let mut label = props.label.clone();
405    if collapsed && !props.show_label_when_collapsed {
406        label.truncate(1);
407    }
408
409    let mut content = container(text(label).size(props.size.text_size()))
410        .height(Length::Fixed(props.size.height()))
411        .width(Length::Fill)
412        .align_y(Vertical::Center);
413
414    if collapsed && !props.show_label_when_collapsed {
415        content = content.align_x(Horizontal::Center).padding(0);
416    } else {
417        content = content
418            .align_x(Horizontal::Left)
419            .padding(props.size.padding());
420    }
421
422    let mut button = iced_button(content);
423
424    if let Some(msg) = on_press
425        && !props.disabled
426    {
427        button = button.on_press(msg);
428    }
429
430    let theme = theme.clone();
431    let style_props = props.clone();
432    button = button.style(move |_t, status| {
433        sidebar_menu_button_style(&theme, &style_props, status, collapsed)
434    });
435    button.into()
436}
437
438fn sidebar_menu_button_style(
439    theme: &Theme,
440    props: &SidebarMenuButtonProps,
441    status: iced_button::Status,
442    _collapsed: bool,
443) -> iced_button::Style {
444    let palette = theme.palette;
445    let hovered = matches!(status, iced_button::Status::Hovered);
446    let pressed = matches!(status, iced_button::Status::Pressed);
447
448    let mut background = Color::TRANSPARENT;
449    if props.active || hovered || pressed {
450        background = palette.sidebar_accent;
451    }
452
453    let mut text_color = palette.sidebar_foreground;
454    if props.active || hovered || pressed {
455        text_color = palette.sidebar_accent_foreground;
456    }
457
458    if props.disabled {
459        text_color = palette.sidebar_foreground;
460        background = Color::TRANSPARENT;
461    }
462
463    iced_button::Style {
464        background: Some(Background::Color(background)),
465        text_color,
466        border: Border {
467            radius: theme.radius.sm.into(),
468            width: 0.0,
469            color: Color::TRANSPARENT,
470        },
471        shadow: Default::default(),
472        snap: true,
473    }
474}
475
476fn apply_opacity(color: Color, opacity: f32) -> Color {
477    Color {
478        a: color.a * opacity,
479        ..color
480    }
481}