sickle_ui/widgets/layout/
floating_panel.rs

1use std::ops::DerefMut;
2
3use bevy::{
4    prelude::*,
5    ui::{ContentSize, FocusPolicy, RelativeCursorPosition},
6    window::{PrimaryWindow, WindowResized},
7};
8
9use sickle_ui_scaffold::{prelude::*, ui_commands::RefreshThemeExt};
10
11use crate::widgets::layout::{
12    container::UiContainerExt,
13    label::{LabelConfig, SetLabelTextExt, UiLabelExt},
14    panel::UiPanelExt,
15    resize_handles::{ResizeDirection, ResizeHandle, UiResizeHandlesExt},
16    scroll_view::UiScrollViewExt,
17};
18
19use super::column::UiColumnExt;
20
21const MIN_PANEL_SIZE: Vec2 = Vec2 { x: 150., y: 100. };
22const MIN_FLOATING_PANEL_Z_INDEX: usize = 1000;
23const PRIORITY_FLOATING_PANEL_Z_INDEX: usize = 10000;
24const WINDOW_RESIZE_PADDING: f32 = 20.;
25
26pub struct FloatingPanelPlugin;
27
28impl Plugin for FloatingPanelPlugin {
29    fn build(&self, app: &mut App) {
30        app.configure_sets(
31            Update,
32            FloatingPanelUpdate
33                .after(DroppableUpdate)
34                .after(FluxInteractionUpdate),
35        )
36        .add_plugins(ComponentThemePlugin::<FloatingPanel>::default())
37        .add_systems(PreUpdate, update_floating_panel_panel_id)
38        .add_systems(
39            Update,
40            (
41                index_floating_panels.run_if(panel_added),
42                process_panel_close_pressed,
43                process_panel_fold_pressed,
44                update_panel_size_on_resize,
45                update_panel_on_title_drag,
46                handle_window_resize.run_if(window_resized),
47                update_panel_layout,
48                touch_new_floating_panels.run_if(panel_added),
49            )
50                .chain()
51                .in_set(FloatingPanelUpdate),
52        );
53    }
54}
55
56#[derive(SystemSet, Clone, Eq, Debug, Hash, PartialEq)]
57pub struct FloatingPanelUpdate;
58
59// TODO: Extract widget interaction to separate plugins, i.e. "tab_popout"
60// TODO: Re-verify system scheduling, be extra careful of theming not being applied in cases
61// when context entity is set late, like below
62// TODO: Consider using an observer to do this
63fn update_floating_panel_panel_id(
64    mut q_floating_panels: Query<
65        (Entity, &mut FloatingPanel, &UpdateFloatingPanelPanelId),
66        Added<UpdateFloatingPanelPanelId>,
67    >,
68    mut commands: Commands,
69) {
70    for (entity, mut floating_panel, update_ref) in &mut q_floating_panels {
71        commands
72            .entity(entity)
73            .remove::<UpdateFloatingPanelPanelId>();
74
75        if update_ref.panel_id == floating_panel.content_panel {
76            warn!("Tried setting floating panel id to its current panel!");
77            continue;
78        }
79
80        commands
81            .entity(floating_panel.content_panel)
82            .despawn_recursive();
83
84        commands
85            .entity(update_ref.panel_id)
86            .set_parent(floating_panel.content_panel_container);
87
88        commands.style(update_ref.panel_id).show();
89
90        floating_panel.content_panel = update_ref.panel_id;
91        commands.entity(entity).refresh_theme::<FloatingPanel>();
92    }
93}
94
95fn panel_added(q_panels: Query<Entity, Added<FloatingPanel>>) -> bool {
96    q_panels.iter().count() > 0
97}
98
99fn index_floating_panels(mut q_panels: Query<&mut FloatingPanel>) {
100    let max = if let Some(Some(m)) = q_panels.iter().map(|p| p.z_index).max() {
101        m
102    } else {
103        0
104    };
105
106    let mut offset = 1;
107    for mut panel in &mut q_panels.iter_mut() {
108        if panel.z_index.is_none() {
109            panel.z_index = (MIN_FLOATING_PANEL_Z_INDEX + max + offset).into();
110            offset += 1;
111        }
112    }
113}
114
115fn process_panel_close_pressed(
116    q_buttons: Query<(&FloatingPanelCloseButton, &FluxInteraction), Changed<FluxInteraction>>,
117    mut commands: Commands,
118) {
119    for (button, interaction) in &q_buttons {
120        if *interaction == FluxInteraction::Released {
121            commands.entity(button.panel).despawn_recursive();
122        }
123    }
124}
125
126fn process_panel_fold_pressed(
127    q_buttons: Query<
128        (Entity, &FloatingPanelFoldButton, &FluxInteraction),
129        Changed<FluxInteraction>,
130    >,
131    mut q_panel_configs: Query<&mut FloatingPanelConfig>,
132) {
133    for (entity, button, interaction) in &q_buttons {
134        if *interaction == FluxInteraction::Released {
135            let Ok(mut config) = q_panel_configs.get_mut(button.panel) else {
136                warn!("Missing floating panel config for fold button {}", entity);
137                continue;
138            };
139
140            config.folded = !config.folded;
141        }
142    }
143}
144
145fn update_panel_size_on_resize(
146    q_draggable: Query<(&Draggable, &ResizeHandle, &FloatingPanelResizeHandle), Changed<Draggable>>,
147    mut q_panels: Query<&mut FloatingPanel>,
148) {
149    if let Some(_) = q_panels.iter().find(|p| p.priority) {
150        return;
151    }
152
153    for (draggable, handle, handle_ref) in &q_draggable {
154        let Ok(mut panel) = q_panels.get_mut(handle_ref.panel) else {
155            continue;
156        };
157
158        if draggable.state == DragState::Inactive
159            || draggable.state == DragState::MaybeDragged
160            || draggable.state == DragState::DragCanceled
161        {
162            panel.resizing = false;
163            continue;
164        }
165
166        let Some(diff) = draggable.diff else {
167            continue;
168        };
169
170        let size_diff = handle.direction().to_size_diff(diff);
171
172        let old_size = panel.size;
173        panel.resizing = true;
174        panel.size += size_diff;
175        if draggable.state == DragState::DragEnd {
176            if panel.size.x < MIN_PANEL_SIZE.x {
177                panel.size.x = MIN_PANEL_SIZE.x;
178            }
179            if panel.size.y < MIN_PANEL_SIZE.y {
180                panel.size.y = MIN_PANEL_SIZE.y;
181            }
182        }
183
184        let pos_diff = match handle.direction() {
185            ResizeDirection::North => Vec2 {
186                x: 0.,
187                y: clip_position_change(diff.y, MIN_PANEL_SIZE.y, old_size.y, panel.size.y),
188            },
189            ResizeDirection::NorthEast => Vec2 {
190                x: 0.,
191                y: clip_position_change(diff.y, MIN_PANEL_SIZE.y, old_size.y, panel.size.y),
192            },
193            ResizeDirection::East => Vec2::ZERO,
194            ResizeDirection::SouthEast => Vec2::ZERO,
195            ResizeDirection::South => Vec2::ZERO,
196            ResizeDirection::SouthWest => Vec2 {
197                x: clip_position_change(diff.x, MIN_PANEL_SIZE.x, old_size.x, panel.size.x),
198                y: 0.,
199            },
200            ResizeDirection::West => Vec2 {
201                x: clip_position_change(diff.x, MIN_PANEL_SIZE.x, old_size.x, panel.size.x),
202                y: 0.,
203            },
204            ResizeDirection::NorthWest => Vec2 {
205                x: clip_position_change(diff.x, MIN_PANEL_SIZE.x, old_size.x, panel.size.x),
206                y: clip_position_change(diff.y, MIN_PANEL_SIZE.y, old_size.y, panel.size.y),
207            },
208        };
209
210        panel.position += pos_diff;
211    }
212}
213
214fn clip_position_change(diff: f32, min: f32, old_size: f32, new_size: f32) -> f32 {
215    let mut new_diff = diff;
216    if old_size <= min && new_size <= min {
217        new_diff = 0.;
218    } else if old_size > min && new_size <= min {
219        new_diff -= min - new_size;
220    } else if old_size < min && new_size >= min {
221        new_diff += min - old_size;
222    }
223
224    new_diff
225}
226
227fn update_panel_on_title_drag(
228    q_draggable: Query<
229        (
230            &Draggable,
231            AnyOf<(&FloatingPanelTitle, &FloatingPanelDragHandle)>,
232        ),
233        Changed<Draggable>,
234    >,
235    mut q_panels: Query<(Entity, &mut FloatingPanel)>,
236) {
237    if let Some(_) = q_panels.iter().find(|(_, p)| p.priority) {
238        return;
239    }
240
241    let max_index = if let Some(Some(m)) = q_panels.iter().map(|(_, p)| p.z_index).max() {
242        m
243    } else {
244        0
245    };
246    let mut offset = 1;
247
248    let mut panel_updated = false;
249
250    for (draggable, (panel_title, drag_handle)) in &q_draggable {
251        let panel_id = if let Some(panel_title) = panel_title {
252            panel_title.panel
253        } else if let Some(drag_handle) = drag_handle {
254            drag_handle.panel
255        } else {
256            continue;
257        };
258
259        let Ok((_, mut panel)) = q_panels.get_mut(panel_id) else {
260            continue;
261        };
262
263        if panel.resizing {
264            continue;
265        }
266
267        if draggable.state == DragState::Inactive
268            || draggable.state == DragState::MaybeDragged
269            || draggable.state == DragState::DragCanceled
270        {
271            panel.moving = false;
272            continue;
273        }
274
275        panel.moving = true;
276        let Some(diff) = draggable.diff else {
277            continue;
278        };
279
280        panel.z_index = Some(max_index + offset);
281        panel.position += diff;
282        offset += 1;
283        panel_updated = true;
284    }
285
286    if !panel_updated {
287        return;
288    }
289
290    let mut panel_indices: Vec<(Entity, Option<usize>)> = q_panels
291        .iter()
292        .map(|(entity, panel)| (entity, panel.z_index))
293        .collect();
294    panel_indices.sort_by(|(_, a), (_, b)| a.cmp(b));
295
296    for (i, (entity, _)) in panel_indices.iter().enumerate() {
297        if let Some((_, mut panel)) = q_panels.iter_mut().find(|(e, _)| e == entity) {
298            panel.z_index = (MIN_FLOATING_PANEL_Z_INDEX + i + 1).into();
299        };
300    }
301}
302
303fn window_resized(e_resized: EventReader<WindowResized>) -> bool {
304    e_resized.len() > 0
305}
306
307// TODO: Use the panel's render window
308fn handle_window_resize(
309    q_window: Query<&Window, With<PrimaryWindow>>,
310    mut q_panels: Query<(&mut FloatingPanel, &Node, &GlobalTransform)>,
311) {
312    let Ok(window) = q_window.get_single() else {
313        return;
314    };
315
316    for (mut panel, node, transform) in &mut q_panels {
317        let position = transform.translation().truncate() - (node.size() / 2.);
318
319        if position.x > window.width() - WINDOW_RESIZE_PADDING {
320            panel.position.x = (panel.position.x - panel.size.x + WINDOW_RESIZE_PADDING).max(0.);
321            if position.y > window.height() - panel.size.y {
322                let overflow = position.y - (window.height() - panel.size.y);
323                panel.position.y = (panel.position.y - overflow).max(0.);
324            }
325        }
326        if position.y > window.height() - WINDOW_RESIZE_PADDING {
327            panel.position.y = (panel.position.y - panel.size.y + WINDOW_RESIZE_PADDING).max(0.);
328
329            if position.x > window.width() - panel.size.x {
330                let overflow = position.x - (window.width() - panel.size.x);
331                panel.position.x = (panel.position.x - overflow).max(0.);
332            }
333        }
334    }
335}
336
337fn update_panel_layout(
338    q_panels: Query<
339        (Entity, &FloatingPanel, Ref<FloatingPanelConfig>),
340        Or<(Changed<FloatingPanel>, Changed<FloatingPanelConfig>)>,
341    >,
342    mut commands: Commands,
343) {
344    for (entity, panel, config) in &q_panels {
345        if config.is_changed() {
346            commands
347                .style(panel.title_container)
348                .render(config.title.is_some());
349
350            if let Some(title) = config.title.clone() {
351                commands.entity(panel.title).set_label_text(title);
352            } else {
353                commands.style(panel.drag_handle).render(config.draggable);
354            }
355
356            commands.style(panel.content_view).render(!config.folded);
357            if config.folded {
358                commands
359                    .entity(entity)
360                    .add_pseudo_state(PseudoState::Folded);
361            } else {
362                commands
363                    .entity(entity)
364                    .remove_pseudo_state(PseudoState::Folded);
365            }
366        }
367
368        let render_resize_handles = !config.folded && config.resizable && !panel.moving;
369        if render_resize_handles {
370            commands
371                .entity(panel.resize_handles)
372                .insert(PseudoStates::from(vec![
373                    PseudoState::Resizable(CardinalDirection::North),
374                    PseudoState::Resizable(CardinalDirection::East),
375                    PseudoState::Resizable(CardinalDirection::South),
376                    PseudoState::Resizable(CardinalDirection::West),
377                ]));
378        } else {
379            commands
380                .entity(panel.resize_handles)
381                .remove::<PseudoStates>();
382        }
383
384        let policy = match panel.moving {
385            true => FocusPolicy::Pass,
386            false => FocusPolicy::Block,
387        };
388
389        commands.style(entity).focus_policy(policy);
390        commands
391            .style(panel.title_container)
392            .focus_policy(policy)
393            .flux_interaction_enabled(!panel.resizing && config.draggable);
394        commands
395            .style(panel.drag_handle)
396            .focus_policy(policy)
397            .flux_interaction_enabled(!panel.resizing && config.draggable);
398
399        commands
400            .style(panel.fold_button)
401            .flux_interaction_enabled(!(panel.moving || panel.resizing));
402        commands
403            .style(panel.close_button)
404            .flux_interaction_enabled(!(panel.moving || panel.resizing));
405
406        if panel.resizing {
407            commands
408                .style(entity)
409                .width(Val::Px(panel.size.x.max(MIN_PANEL_SIZE.x)))
410                .height(Val::Px(panel.size.y.max(MIN_PANEL_SIZE.y)));
411        }
412
413        if panel.moving || panel.resizing {
414            commands.style(entity).absolute_position(panel.position);
415        }
416
417        if panel.priority {
418            commands
419                .style(entity)
420                .z_index(ZIndex::Global(PRIORITY_FLOATING_PANEL_Z_INDEX as i32));
421        } else if let Some(index) = panel.z_index {
422            commands.style(entity).z_index(ZIndex::Global(index as i32));
423        }
424    }
425}
426
427// New floating panels don't have node sizes calculated which prevents resize handles to be placed properly
428// This is a crude way of re-triggering systems that are based on Changed<FloatingPanel>s
429fn touch_new_floating_panels(mut q_panels: Query<&mut FloatingPanel, Added<FloatingPanel>>) {
430    for mut panel in &mut q_panels {
431        panel.deref_mut();
432    }
433}
434
435#[derive(Component, Clone, Debug, Reflect)]
436#[reflect(Component)]
437pub struct FloatingPanelResizeHandle {
438    panel: Entity,
439}
440
441impl Default for FloatingPanelResizeHandle {
442    fn default() -> Self {
443        Self {
444            panel: Entity::PLACEHOLDER,
445        }
446    }
447}
448
449#[derive(Component, Debug, Reflect)]
450#[reflect(Component)]
451pub struct FloatingPanelTitle {
452    panel: Entity,
453}
454
455impl Default for FloatingPanelTitle {
456    fn default() -> Self {
457        Self {
458            panel: Entity::PLACEHOLDER,
459        }
460    }
461}
462
463impl FloatingPanelTitle {
464    pub fn panel(&self) -> Entity {
465        self.panel
466    }
467}
468
469#[derive(Component, Debug, Reflect)]
470#[reflect(Component)]
471pub struct FloatingPanelDragHandle {
472    panel: Entity,
473}
474
475impl Default for FloatingPanelDragHandle {
476    fn default() -> Self {
477        Self {
478            panel: Entity::PLACEHOLDER,
479        }
480    }
481}
482
483#[derive(Component, Debug, Reflect)]
484#[reflect(Component)]
485pub struct FloatingPanelFoldButton {
486    panel: Entity,
487}
488
489impl Default for FloatingPanelFoldButton {
490    fn default() -> Self {
491        Self {
492            panel: Entity::PLACEHOLDER,
493        }
494    }
495}
496
497#[derive(Component, Debug, Reflect)]
498#[reflect(Component)]
499pub struct FloatingPanelCloseButton {
500    panel: Entity,
501}
502
503impl Default for FloatingPanelCloseButton {
504    fn default() -> Self {
505        Self {
506            panel: Entity::PLACEHOLDER,
507        }
508    }
509}
510
511#[derive(Component, Clone, Debug, Reflect)]
512pub struct FloatingPanelConfig {
513    pub title: Option<String>,
514    pub draggable: bool,
515    pub resizable: bool,
516    pub foldable: bool,
517    pub folded: bool,
518    pub closable: bool,
519    pub restrict_scroll: Option<ScrollAxis>,
520}
521
522impl Default for FloatingPanelConfig {
523    fn default() -> Self {
524        Self {
525            title: None,
526            draggable: true,
527            resizable: true,
528            foldable: true,
529            folded: false,
530            closable: true,
531            restrict_scroll: None,
532        }
533    }
534}
535
536impl FloatingPanelConfig {
537    pub fn title(&self) -> Option<String> {
538        self.title.clone()
539    }
540}
541
542#[derive(Component, Debug, Reflect)]
543#[reflect(Component)]
544pub struct FloatingPanel {
545    size: Vec2,
546    position: Vec2,
547    z_index: Option<usize>,
548    drag_handle: Entity,
549    fold_button: Entity,
550    title_container: Entity,
551    title: Entity,
552    close_button_container: Entity,
553    close_button: Entity,
554    content_view: Entity,
555    content_panel_container: Entity,
556    content_panel: Entity,
557    resize_handles: Entity,
558    resizing: bool,
559    moving: bool,
560    pub priority: bool,
561}
562
563impl Default for FloatingPanel {
564    fn default() -> Self {
565        Self {
566            size: Default::default(),
567            position: Default::default(),
568            z_index: Default::default(),
569            drag_handle: Entity::PLACEHOLDER,
570            fold_button: Entity::PLACEHOLDER,
571            title_container: Entity::PLACEHOLDER,
572            title: Entity::PLACEHOLDER,
573            close_button_container: Entity::PLACEHOLDER,
574            close_button: Entity::PLACEHOLDER,
575            content_view: Entity::PLACEHOLDER,
576            content_panel_container: Entity::PLACEHOLDER,
577            content_panel: Entity::PLACEHOLDER,
578            resize_handles: Entity::PLACEHOLDER,
579            resizing: Default::default(),
580            moving: Default::default(),
581            priority: Default::default(),
582        }
583    }
584}
585
586impl UiContext for FloatingPanel {
587    fn get(&self, target: &str) -> Result<Entity, String> {
588        match target {
589            FloatingPanel::DRAG_HANDLE => Ok(self.drag_handle),
590            FloatingPanel::TITLE_CONTAINER => Ok(self.title_container),
591            FloatingPanel::TITLE => Ok(self.title),
592            FloatingPanel::FOLD_BUTTON => Ok(self.fold_button),
593            FloatingPanel::CLOSE_BUTTON_CONTAINER => Ok(self.close_button_container),
594            FloatingPanel::CLOSE_BUTTON => Ok(self.close_button),
595            FloatingPanel::CONTENT_VIEW => Ok(self.content_view),
596            _ => Err(format!(
597                "{} doesn't exist for FloatingPanel. Possible contexts: {:?}",
598                target,
599                Vec::from_iter(self.contexts())
600            )),
601        }
602    }
603
604    fn contexts(&self) -> impl Iterator<Item = &str> + '_ {
605        [
606            FloatingPanel::DRAG_HANDLE,
607            FloatingPanel::TITLE_CONTAINER,
608            FloatingPanel::TITLE,
609            FloatingPanel::FOLD_BUTTON,
610            FloatingPanel::CLOSE_BUTTON_CONTAINER,
611            FloatingPanel::CLOSE_BUTTON,
612            FloatingPanel::CONTENT_VIEW,
613        ]
614        .into_iter()
615    }
616}
617
618impl DefaultTheme for FloatingPanel {
619    fn default_theme() -> Option<Theme<FloatingPanel>> {
620        FloatingPanel::theme().into()
621    }
622}
623
624impl FloatingPanel {
625    pub const DRAG_HANDLE: &'static str = "DragHandle";
626    pub const TITLE_CONTAINER: &'static str = "TitleContainer";
627    pub const TITLE: &'static str = "Title";
628    pub const FOLD_BUTTON: &'static str = "FoldButton";
629    pub const CLOSE_BUTTON_CONTAINER: &'static str = "CloseButtonContainer";
630    pub const CLOSE_BUTTON: &'static str = "CloseButton";
631    pub const CONTENT_VIEW: &'static str = "ContentView";
632
633    pub fn theme() -> Theme<FloatingPanel> {
634        let base_theme = PseudoTheme::deferred_context(None, FloatingPanel::primary_style);
635        let folded_theme =
636            PseudoTheme::deferred_context(vec![PseudoState::Folded], FloatingPanel::folded_style);
637
638        Theme::new(vec![base_theme, folded_theme])
639    }
640
641    fn primary_style(
642        style_builder: &mut StyleBuilder,
643        panel: &FloatingPanel,
644        theme_data: &ThemeData,
645    ) {
646        let theme_spacing = theme_data.spacing;
647        let colors = theme_data.colors();
648
649        style_builder
650            .absolute_position(panel.position)
651            .border(UiRect::all(Val::Px(theme_spacing.borders.extra_small)))
652            .border_color(colors.accent(Accent::Shadow))
653            .background_color(colors.surface(Surface::Surface))
654            .border_radius(BorderRadius::all(Val::Px(
655                theme_spacing.corners.extra_small,
656            )));
657
658        style_builder
659            .animated()
660            .height(AnimatedVals {
661                idle: Val::Px(panel.size.y.max(MIN_PANEL_SIZE.y)),
662                enter_from: Val::Px(theme_spacing.areas.small).into(),
663                ..default()
664            })
665            .copy_from(theme_data.enter_animation);
666
667        style_builder
668            .animated()
669            .width(AnimatedVals {
670                idle: Val::Px(panel.size.x.max(MIN_PANEL_SIZE.x)),
671                enter_from: Val::Px(theme_spacing.areas.extra_large).into(),
672                ..default()
673            })
674            .copy_from(theme_data.enter_animation);
675
676        style_builder
677            .switch_target(FloatingPanel::TITLE_CONTAINER)
678            .width(Val::Percent(100.))
679            .align_items(AlignItems::Center)
680            .justify_content(JustifyContent::Start)
681            .background_color(colors.container(Container::SurfaceMid))
682            .border_radius(BorderRadius::top(Val::Px(
683                theme_spacing.corners.extra_small,
684            )));
685
686        style_builder
687            .switch_target(FloatingPanel::TITLE)
688            .flex_grow(1.)
689            .margin(UiRect::px(
690                theme_spacing.gaps.small,
691                theme_spacing.gaps.extra_large,
692                theme_spacing.gaps.small,
693                theme_spacing.gaps.extra_small,
694            ))
695            .sized_font(
696                theme_data
697                    .text
698                    .get(FontStyle::Body, FontScale::Large, FontType::Regular),
699            )
700            .font_color(colors.on(OnColor::Surface));
701
702        style_builder
703            .switch_target(FloatingPanel::CLOSE_BUTTON_CONTAINER)
704            .right(Val::Px(0.))
705            .background_color(colors.container(Container::SurfaceMid))
706            .border_radius(BorderRadius::top_right(Val::Px(
707                theme_spacing.corners.extra_small,
708            )));
709
710        style_builder
711            .switch_target(FloatingPanel::CONTENT_VIEW)
712            .width(Val::Percent(100.))
713            .height(Val::Percent(100.))
714            .border_radius(BorderRadius::bottom(Val::Px(
715                theme_spacing.corners.extra_small,
716            )));
717
718        style_builder
719            .switch_context(FloatingPanel::DRAG_HANDLE, None)
720            .width(Val::Percent(100.))
721            .height(Val::Px(theme_spacing.borders.small * 2.))
722            .border(UiRect::bottom(Val::Px(theme_spacing.borders.small)))
723            .border_color(colors.accent(Accent::Shadow))
724            .animated()
725            .background_color(AnimatedVals {
726                idle: colors.surface(Surface::Surface),
727                hover: colors.surface(Surface::SurfaceVariant).into(),
728                ..default()
729            })
730            .copy_from(theme_data.interaction_animation);
731
732        style_builder
733            .switch_context(FloatingPanel::FOLD_BUTTON, None)
734            .size(Val::Px(theme_spacing.icons.small))
735            .margin(UiRect::all(Val::Px(theme_spacing.gaps.small)))
736            .icon(
737                theme_data
738                    .icons
739                    .expand_more
740                    .with(colors.on(OnColor::Surface), theme_spacing.icons.small),
741            )
742            .animated()
743            .font_color(AnimatedVals {
744                idle: colors.on(OnColor::SurfaceVariant),
745                hover: colors.on(OnColor::Surface).into(),
746                ..default()
747            })
748            .copy_from(theme_data.interaction_animation);
749
750        style_builder
751            .switch_context(FloatingPanel::CLOSE_BUTTON, None)
752            .size(Val::Px(theme_spacing.icons.small))
753            .margin(UiRect::all(Val::Px(theme_spacing.gaps.small)))
754            .icon(
755                theme_data
756                    .icons
757                    .close
758                    .with(colors.on(OnColor::Surface), theme_spacing.icons.small),
759            )
760            .animated()
761            .font_color(AnimatedVals {
762                idle: colors.on(OnColor::SurfaceVariant),
763                hover: colors.on(OnColor::Surface).into(),
764                ..default()
765            })
766            .copy_from(theme_data.interaction_animation);
767    }
768
769    fn folded_style(
770        style_builder: &mut StyleBuilder,
771        panel: &FloatingPanel,
772        theme_data: &ThemeData,
773    ) {
774        let theme_spacing = theme_data.spacing;
775        let colors = theme_data.colors();
776
777        style_builder
778            .height(Val::Auto)
779            .animated()
780            .width(AnimatedVals {
781                idle: Val::Px(theme_spacing.areas.extra_large),
782                enter_from: Val::Px(panel.size.x.max(MIN_PANEL_SIZE.x)).into(),
783                ..default()
784            })
785            .copy_from(theme_data.enter_animation);
786
787        style_builder
788            .switch_target(FloatingPanel::CONTENT_VIEW)
789            .animated()
790            .height(AnimatedVals {
791                idle: Val::Percent(0.),
792                enter_from: Val::Percent(100.).into(),
793                ..default()
794            })
795            .copy_from(theme_data.enter_animation);
796
797        style_builder
798            .switch_target(FloatingPanel::FOLD_BUTTON)
799            .icon(
800                theme_data
801                    .icons
802                    .chevron_right
803                    .with(colors.on(OnColor::Surface), theme_spacing.icons.small),
804            );
805    }
806
807    pub fn content_panel_container(&self) -> Entity {
808        self.content_panel_container
809    }
810
811    pub fn content_panel_id(&self) -> Entity {
812        self.content_panel
813    }
814
815    pub fn title_container_id(&self) -> Entity {
816        self.title_container
817    }
818
819    fn frame(title: String) -> impl Bundle {
820        (
821            Name::new(format!("Floating Panel [{}]", title)),
822            NodeBundle {
823                style: Style {
824                    position_type: PositionType::Absolute,
825                    flex_direction: FlexDirection::Column,
826                    align_items: AlignItems::Start,
827                    overflow: Overflow::clip(),
828                    ..default()
829                },
830                focus_policy: bevy::ui::FocusPolicy::Block,
831                ..default()
832            },
833            LockedStyleAttributes::from_vec(vec![
834                LockableStyleAttribute::PositionType,
835                LockableStyleAttribute::FlexDirection,
836                LockableStyleAttribute::AlignItems,
837                LockableStyleAttribute::Overflow,
838            ]),
839        )
840    }
841
842    fn title_container(panel: Entity) -> impl Bundle {
843        (
844            Name::new("Title Container"),
845            ButtonBundle::default(),
846            FloatingPanelTitle { panel },
847            TrackedInteraction::default(),
848            Draggable::default(),
849            RelativeCursorPosition::default(),
850        )
851    }
852
853    fn fold_button(panel: Entity) -> impl Bundle {
854        (
855            Name::new("Fold Button"),
856            ButtonBundle::default(),
857            ContentSize::default(),
858            TrackedInteraction::default(),
859            FloatingPanelFoldButton { panel },
860        )
861    }
862
863    fn drag_handle() -> impl Bundle {
864        (
865            Name::new("Drag Handle"),
866            ButtonBundle::default(),
867            TrackedInteraction::default(),
868            Draggable::default(),
869            RelativeCursorPosition::default(),
870        )
871    }
872
873    fn close_button_container() -> impl Bundle {
874        (
875            Name::new("Close Button Container"),
876            NodeBundle {
877                style: Style {
878                    position_type: PositionType::Absolute,
879                    ..default()
880                },
881                focus_policy: bevy::ui::FocusPolicy::Block,
882                ..default()
883            },
884            LockedStyleAttributes::from_vec(vec![
885                LockableStyleAttribute::PositionType,
886                LockableStyleAttribute::FocusPolicy,
887            ]),
888        )
889    }
890
891    fn close_button(panel: Entity) -> impl Bundle {
892        (
893            Name::new("Close Button"),
894            ButtonBundle::default(),
895            ContentSize::default(),
896            TrackedInteraction::default(),
897            FloatingPanelCloseButton { panel },
898        )
899    }
900}
901
902#[derive(Debug)]
903pub struct FloatingPanelLayout {
904    pub size: Vec2,
905    pub position: Option<Vec2>,
906    pub droppable: bool,
907}
908
909impl Default for FloatingPanelLayout {
910    fn default() -> Self {
911        Self {
912            size: Vec2 { x: 300., y: 500. },
913            position: Default::default(),
914            droppable: false,
915        }
916    }
917}
918
919impl FloatingPanelLayout {
920    pub fn min() -> Self {
921        Self {
922            size: MIN_PANEL_SIZE,
923            ..default()
924        }
925    }
926}
927
928#[derive(Component)]
929#[component(storage = "SparseSet")]
930pub struct UpdateFloatingPanelPanelId {
931    pub panel_id: Entity,
932}
933
934pub trait UiFloatingPanelExt {
935    fn floating_panel<'a>(
936        &'a mut self,
937        config: FloatingPanelConfig,
938        layout: FloatingPanelLayout,
939        spawn_children: impl FnOnce(&mut UiBuilder<Entity>),
940    ) -> UiBuilder<Entity>;
941}
942
943impl<T: UiContainerExt> UiFloatingPanelExt for T {
944    /// A floating panel that can be optionally dragable, foldable, and closable.
945    ///
946    /// ### PseudoState usage
947    /// - `PseudoState::Folded` is used when the panel is folded
948    /// - `PseudoState::Resizable(_)` is transiently used by its resize handles
949    fn floating_panel<'a>(
950        &'a mut self,
951        config: FloatingPanelConfig,
952        layout: FloatingPanelLayout,
953        spawn_children: impl FnOnce(&mut UiBuilder<Entity>),
954    ) -> UiBuilder<Entity> {
955        let restrict_to = config.restrict_scroll;
956        let title_text = if let Some(text) = config.title.clone() {
957            text
958        } else {
959            "Untitled".into()
960        };
961
962        let mut floating_panel = FloatingPanel {
963            size: layout.size.max(MIN_PANEL_SIZE),
964            position: layout.position.unwrap_or_default(),
965            z_index: None,
966            ..default()
967        };
968
969        let mut frame = self.container(FloatingPanel::frame(title_text.clone()), |container| {
970            let panel = container.id();
971            floating_panel.resize_handles = container
972                .resize_handles(FloatingPanelResizeHandle { panel }, |_| {})
973                .id();
974
975            let mut title_builder =
976                container.container(FloatingPanel::title_container(panel), |container| {
977                    floating_panel.fold_button = container
978                        .spawn(FloatingPanel::fold_button(panel))
979                        .style()
980                        .render(config.foldable)
981                        .id();
982
983                    floating_panel.title = container
984                        .label(LabelConfig {
985                            label: title_text.clone(),
986                            ..default()
987                        })
988                        .id();
989
990                    floating_panel.close_button_container = container
991                        .container(
992                            FloatingPanel::close_button_container(),
993                            |close_button_container| {
994                                floating_panel.close_button = close_button_container
995                                    .spawn(FloatingPanel::close_button(panel))
996                                    .style()
997                                    .render(config.closable)
998                                    .id();
999                            },
1000                        )
1001                        .id();
1002                });
1003            title_builder.style().render(config.title.is_some());
1004
1005            if layout.droppable {
1006                title_builder.insert(Droppable);
1007            }
1008
1009            floating_panel.title_container = title_builder.id();
1010
1011            floating_panel.drag_handle = container
1012                .spawn((
1013                    FloatingPanel::drag_handle(),
1014                    FloatingPanelDragHandle { panel },
1015                ))
1016                .style()
1017                .render(config.title.is_none())
1018                .id();
1019
1020            floating_panel.content_view = container
1021                .column(|column| {
1022                    column.scroll_view(restrict_to, |scroll_view| {
1023                        floating_panel.content_panel_container = scroll_view.id();
1024                        floating_panel.content_panel = scroll_view
1025                            .panel(
1026                                config.title.clone().unwrap_or("Untitled".into()),
1027                                spawn_children,
1028                            )
1029                            .id();
1030                    });
1031                })
1032                .style()
1033                .render(config.folded)
1034                .id();
1035        });
1036
1037        if config.folded {
1038            frame.insert(PseudoStates::from(vec![PseudoState::Folded]));
1039        }
1040
1041        frame.insert((config, floating_panel));
1042        frame
1043    }
1044}