Skip to main content

makara/widgets/
scroll.rs

1//! A scrollable container with flex direction set to column.
2
3use bevy::input::mouse::{MouseWheel, MouseScrollUnit};
4use bevy::ui_widgets::observe;
5
6use crate::{events::*, consts::*, utils::*};
7use super::*;
8
9/// Internal resource used to track scroll.
10/// Only entity inside this resource can be scrolled
11#[derive(Resource, Default)]
12pub struct CanBeScrolled {
13    pub entity: Option<Entity>
14}
15
16/// Marker component for `scroll`.
17#[derive(Component)]
18pub struct MakaraScroll;
19
20/// Marker component for `scroll` moving panel.
21/// The panel that will actually move up and down when scroll
22#[derive(Component)]
23pub struct MakaraScrollMovePanel;
24
25/// Marker component for `scroll`'s bar.
26#[derive(Component)]
27pub struct MakaraScrollbar;
28
29/// Component used to store entity of move panel.
30/// This component will be inserted into `scroll` in `detect_scroll_children_added` system.
31#[derive(Component)]
32pub struct ScrollMovePanelEntity(pub Entity);
33
34/// Component used to store entity of `scroll`.
35/// This component will be inserted into move panel and bar in `detect_scroll_children_added` system.
36#[derive(Component)]
37pub struct ScrollEntity(pub Entity);
38
39/// Component used to store entity of scroll bar.
40/// This component will be inserted into `scroll` and move panel in `detect_scroll_children_added` system.
41#[derive(Component)]
42pub struct ScrollBarEntity(pub Entity);
43
44/// Component used to keep track of scrolling.
45/// Used with `scroll`'s move panel.
46#[derive(Component)]
47pub struct MakaraScrollList {
48    pub position: f32,
49    pub scroll_height: f32
50}
51
52impl Default for MakaraScrollList {
53    fn default() -> Self {
54        Self {
55            position: 0.0,
56            scroll_height: DEFAULT_SCROLL_HEIGHT
57        }
58    }
59}
60
61#[derive(Component)]
62pub struct ScrollChildrenNeedTransfer;
63
64#[derive(Component)]
65pub struct TempMovePanelStyle(pub ContainerStyle);
66
67#[derive(Component)]
68pub struct TempScrollBarStyle(pub ContainerStyle);
69
70/// A struct used to mutate components attached to `scroll` widget.
71pub struct ScrollWidget<'a, 'w, 's> {
72    pub entity: Entity,
73    pub panel_entity: Entity,
74    pub class: &'a mut Class,
75    pub style: WidgetStyle<'a>,
76    pub bar_style: WidgetStyle<'a>,
77    pub child_entities: Vec<Entity>, // child entities of move panel, not scroll.
78    pub commands: &'a mut Commands<'w, 's>
79}
80
81impl<'a, 'w, 's> WidgetChildren for ScrollWidget<'a, 'w, 's> {
82    fn add_child(&mut self, child_bundle: impl Bundle) {
83        let child_entity = self.commands.spawn(child_bundle).id();
84        self.commands.entity(self.panel_entity).add_child(child_entity);
85    }
86
87    fn add_children(&mut self, bundles: impl IntoIterator<Item = impl Bundle>) {
88        let mut child_entities = Vec::new();
89
90        for bundle in bundles {
91            let child_entity = self.commands.spawn(bundle).id();
92            child_entities.push(child_entity);
93        }
94        self.commands.entity(self.panel_entity).add_children(&child_entities);
95    }
96
97    fn insert_at(
98        &mut self,
99        index: usize,
100        bundles: impl IntoIterator<Item = impl Bundle>
101    ) {
102        let mut child_entities = Vec::new();
103
104        for bundle in bundles {
105            let child_entity = self.commands.spawn(bundle).id();
106            child_entities.push(child_entity);
107        }
108        self.commands
109            .entity(self.panel_entity)
110            .insert_children(index, &child_entities);
111    }
112
113    fn insert_first(&mut self, bundles: impl IntoIterator<Item = impl Bundle>) {
114        self.insert_at(0, bundles);
115    }
116
117    fn insert_last(&mut self, bundles: impl IntoIterator<Item = impl Bundle>) {
118        let last_index = self.child_entities.len();
119        self.insert_at(last_index, bundles);
120    }
121
122    fn remove_at(&mut self, index: usize) {
123        if let Some(entity) = self.child_entities.get(index) {
124            self.commands.entity(self.panel_entity).detach_child(*entity);
125            self.commands.entity(*entity).despawn();
126        }
127    }
128
129    fn remove_first(&mut self) {
130        self.remove_at(0);
131    }
132
133    fn remove_last(&mut self) {
134        // if list is empty, does nothing.
135        if let Some(last_index) = self.child_entities.len().checked_sub(1) {
136            self.remove_at(last_index);
137        }
138    }
139}
140
141type IsScrollOnly = (
142    (
143        With<MakaraScroll>,
144        Without<MakaraCheckbox>,
145        Without<MakaraCheckboxButton>,
146        Without<MakaraCircular>,
147        Without<MakaraColumn>,
148        Without<MakaraRoot>,
149        Without<MakaraButton>,
150        Without<MakaraDropdown>,
151        Without<MakaraDropdownOverlay>,
152        Without<MakaraImage>,
153        Without<MakaraLink>,
154        Without<MakaraModal>,
155        Without<MakaraModalBackdrop>,
156    ),
157    (
158        Without<MakaraProgressBar>,
159        Without<MakaraRadio>,
160        Without<MakaraRadioGroup>,
161        Without<MakaraRow>,
162        Without<MakaraScrollbar>,
163        Without<MakaraTextInput>,
164        Without<MakaraTextInputCursor>,
165        Without<MakaraSlider>,
166        Without<MakaraSliderThumb>,
167        Without<MakaraSelect>,
168        Without<MakaraSelectOverlay>,
169    )
170);
171
172type IsScrollBarOnly = (
173    (
174        With<MakaraScrollbar>,
175        Without<MakaraCheckbox>,
176        Without<MakaraCheckboxButton>,
177        Without<MakaraCircular>,
178        Without<MakaraColumn>,
179        Without<MakaraRoot>,
180        Without<MakaraButton>,
181        Without<MakaraDropdown>,
182        Without<MakaraDropdownOverlay>,
183        Without<MakaraImage>,
184        Without<MakaraLink>,
185        Without<MakaraModal>,
186        Without<MakaraModalBackdrop>,
187    ),
188    (
189        Without<MakaraProgressBar>,
190        Without<MakaraRadio>,
191        Without<MakaraRadioGroup>,
192        Without<MakaraRow>,
193        Without<MakaraScroll>,
194        Without<MakaraTextInput>,
195        Without<MakaraTextInputCursor>,
196        Without<MakaraSlider>,
197        Without<MakaraSliderThumb>,
198        Without<MakaraSelect>,
199        Without<MakaraSelectOverlay>,
200    )
201);
202
203/// `scroll` system param.
204#[derive(SystemParam)]
205pub struct ScrollQuery<'w, 's> {
206    pub id_class: Query<
207        'w, 's,
208        (Entity, &'static Id, &'static mut Class),
209        IsScrollOnly
210    >,
211    pub scroll_related: Query<'w, 's,
212        (
213            Entity,
214            &'static ScrollBarEntity,
215            &'static ScrollMovePanelEntity
216        ),
217        IsScrollOnly
218    >,
219    pub style: StyleQuery<'w, 's, IsScrollOnly>,
220    pub bar_style: StyleQuery<'w, 's, IsScrollBarOnly>,
221    pub children: Query<'w, 's, Option<&'static Children>, With<MakaraScrollMovePanel>>,
222    pub commands: Commands<'w, 's>
223}
224
225impl<'w, 's> ScrollQuery<'w, 's> {
226    pub fn id_match(&mut self, target_id: &str) -> bool {
227        self.id_class.iter().any(|(_, id, _)| id.0 == target_id)
228    }
229}
230
231impl<'w, 's> WidgetQuery<'w, 's> for ScrollQuery<'w, 's> {
232    type WidgetView<'a> = ScrollWidget<'a, 'w, 's> where Self: 'a;
233
234    fn get_components<'a>(&'a mut self, entity: Entity) -> Option<Self::WidgetView<'a>> {
235        let ScrollQuery {
236            id_class, scroll_related, style, bar_style, children, commands
237        } = self;
238
239        let (_, _, class) = id_class.get_mut(entity).ok()?;
240        let (_, bar_entity, panel_entity) = scroll_related.get_mut(entity).ok()?;
241
242        let bar_bundle = bar_style.query.get_mut(bar_entity.0).ok()?;
243        let (b_node, b_bg, b_border_color, b_shadow, b_z) = bar_bundle;
244
245        let style_bundle = style.query.get_mut(entity).ok()?;
246        let (node, bg, border_color, shadow, z_index) = style_bundle;
247
248        let mut entities: Vec<Entity> = Vec::new();
249        {
250            let children = children.get(panel_entity.0).ok()?;
251            if let Some(children) = children {
252                entities = children.iter().map(|e| e).collect();
253            }
254        }
255
256        return Some(ScrollWidget {
257            entity,
258            panel_entity: panel_entity.0,
259            class: class.into_inner(),
260            style: WidgetStyle {
261                node: node.into_inner(),
262                background_color: bg.into_inner(),
263                border_color: border_color.into_inner(),
264                shadow: shadow.into_inner(),
265                z_index: z_index.into_inner(),
266            },
267            bar_style: WidgetStyle {
268                node: b_node.into_inner(),
269                background_color: b_bg.into_inner(),
270                border_color: b_border_color.into_inner(),
271                shadow: b_shadow.into_inner(),
272                z_index: b_z.into_inner(),
273            },
274            child_entities: entities,
275            commands: commands
276        });
277    }
278
279    fn find_by_id<'a>(&'a mut self, target_id: &str) -> Option<Self::WidgetView<'a>> {
280        let entity = self.id_class.iter()
281            .find(|(_, id, _)| id.0 == target_id)
282            .map(|(e, _, _)| e)?;
283
284        self.get_components(entity)
285    }
286
287    fn find_by_entity<'a>(&'a mut self, target_entity: Entity) -> Option<Self::WidgetView<'a>> {
288        self.get_components(target_entity)
289    }
290
291    fn find_by_class(&self, target_class: &str) -> Vec<Entity> {
292        self.id_class.iter()
293            .filter(|(_, _, class)| class.0.split(" ").any(|word| word == target_class))
294            .map(|(e, _, _)| e)
295            .collect()
296    }
297}
298
299/// Bundle for creating `scroll`.
300pub struct ScrollBundle {
301    pub id_class: IdAndClass,
302    pub style: ContainerStyle,
303    pub move_panel_style: ContainerStyle,
304    pub scroll_bar: ContainerStyle,
305}
306
307impl Default for ScrollBundle {
308    fn default() -> Self {
309        let style = ContainerStyle {
310            node: Node {
311                width: percent(100.0),
312                flex_direction: FlexDirection::Column,
313                align_items: AlignItems::FlexStart,
314                justify_content: JustifyContent::FlexStart,
315                height: Val::Percent(50.0),
316                overflow: Overflow::scroll_y(),
317                ..default()
318            },
319            background_color: BackgroundColor(Color::NONE),
320            shadow: BoxShadow::default(),
321            ..default()
322        };
323
324        let move_panel_style = ContainerStyle {
325            node: Node {
326                width: percent(100.0),
327                position_type: PositionType::Absolute,
328                left: px(0.0),
329                top: px(0.0),
330                flex_direction: FlexDirection::Column,
331                align_items: AlignItems::FlexStart,
332                justify_content: JustifyContent::FlexStart,
333                height: auto(),
334                padding: UiRect {
335                    left: px(0.0),
336                    right: px(0.0),
337                    top: px(2.0),
338                    bottom: px(2.0)
339                },
340                ..default()
341            },
342            background_color: BackgroundColor(Color::NONE),
343            shadow: BoxShadow::default(),
344            ..default()
345        };
346
347        let scroll_bar = ContainerStyle {
348            node: Node {
349                width: px(8),
350                height: px(10),
351                position_type: PositionType::Absolute,
352                right: px(0),
353                top: px(0),
354                border_radius: BorderRadius::all(px(8)),
355                // display: Display::None,
356                ..default()
357            },
358            background_color: BackgroundColor(Color::srgba(0.2, 0.2, 0.2, 0.8)),
359            shadow: BoxShadow::default(),
360            ..default()
361        };
362
363        Self { style, move_panel_style, scroll_bar, id_class: IdAndClass::default() }
364    }
365}
366
367impl Widget for ScrollBundle {
368    /// Build `scroll`.
369    fn build(mut self) -> impl Bundle {
370        process_built_in_spacing_class(&self.id_class.class, &mut self.style.node);
371        (
372            self.id_class,
373            self.style,
374            TempMovePanelStyle(self.move_panel_style),
375            TempScrollBarStyle(self.scroll_bar),
376            MakaraScroll,
377            ScrollChildrenNeedTransfer,
378            observe(on_mouse_over)
379        )
380    }
381}
382
383impl ScrollBundle {
384    /// Set custom style for scroll bar.
385    /// Warning: certain styles can break how scroll bar behave.
386    /// The only recommended styles to change are width, height, background_color and border_radius.
387    pub fn bar_style(mut self, f: impl FnOnce(&mut ContainerStyle)) -> Self {
388        f(&mut self.scroll_bar);
389        self
390    }
391}
392
393impl SetContainerStyle for ScrollBundle {
394    fn container_style(&mut self) -> &mut ContainerStyle {
395        &mut self.style
396    }
397}
398
399impl SetIdAndClass for ScrollBundle {
400    fn id_and_class(&mut self) -> &mut IdAndClass {
401        &mut self.id_class
402    }
403}
404
405fn on_mouse_over(
406    mut over: On<Pointer<Over>>,
407    mut can_be_scrolled: ResMut<CanBeScrolled>
408) {
409    can_be_scrolled.entity = Some(over.entity);
410    over.propagate(false);
411}
412
413// Transfer user provided children to move panel instead.
414pub(crate) fn detect_scroll_children_added(
415    mut commands: Commands,
416    scrolls: Query<
417        (Entity, &Node, Option<&Children>, &TempMovePanelStyle, &TempScrollBarStyle),
418        With<ScrollChildrenNeedTransfer>
419    >,
420) {
421    for (scroll_entity, scroll_node, scroll_children, panel_style, bar_style) in scrolls.iter() {
422        let mut new_panel_style = panel_style.0.clone();
423        new_panel_style.node.align_items = scroll_node.align_items;
424        new_panel_style.node.justify_content = scroll_node.justify_content;
425
426        let panel_entity = commands.spawn((
427            new_panel_style,
428            MakaraScrollMovePanel,
429            MakaraScrollList::default(),
430            ScrollEntity(scroll_entity),
431        ))
432        .id();
433
434        let bar_entity = commands.spawn((
435            bar_style.0.clone(),
436            MakaraScrollbar,
437            ScrollEntity(scroll_entity),
438            Visibility::Hidden,
439            observe(on_scrollbar_drag),
440            observe(on_scrollbar_mouse_over),
441            observe(on_mouse_out)
442        ))
443        .id();
444
445        if let Some(children) = scroll_children {
446            let child_entities = children.iter().map(|e| e).collect::<Vec<Entity>>();
447            commands.entity(panel_entity).add_children(&child_entities);
448        }
449
450        commands.entity(panel_entity).insert(ScrollBarEntity(bar_entity));
451        commands
452            .entity(scroll_entity)
453            .remove::<ScrollChildrenNeedTransfer>()
454            .insert((ScrollMovePanelEntity(panel_entity), ScrollBarEntity(bar_entity)))
455            .add_children(&[panel_entity, bar_entity]);
456    }
457}
458
459pub(crate) fn detect_scroll_height_change(
460    scrolls: Query<
461        (&ComputedNode, &ScrollMovePanelEntity, &ScrollBarEntity),
462        Changed<ComputedNode>
463    >,
464    panels: Query<&ComputedNode, With<MakaraScrollMovePanel>>,
465    mut bars: Query<(&mut Visibility, &mut Node), IsScrollBarOnly>
466) {
467    for (scroll_computed, panel_entity, bar_entity) in scrolls.iter() {
468        if let Ok(panel_computed) = panels.get(panel_entity.0) {
469            if let Ok((mut vis, mut bar_node)) = bars.get_mut(bar_entity.0) {
470                let scroll_height = scroll_computed.size().y * scroll_computed.inverse_scale_factor();
471                let panel_height = panel_computed.size().y * panel_computed.inverse_scale_factor();
472
473                if panel_height <= scroll_height {
474                    *vis = Visibility::Hidden;
475                }
476                else {
477                    let bar_height = (scroll_height / panel_height) * scroll_height;
478                    bar_node.height = px(bar_height);
479                    *vis = Visibility::Visible;
480                }
481            }
482        }
483    }
484}
485
486pub(crate) fn detect_move_panel_height_change(
487    panels: Query<
488        (&ComputedNode, &ScrollEntity),
489        (With<MakaraScrollMovePanel>, Changed<ComputedNode>)
490    >,
491    scrolls: Query<(&ComputedNode, &ScrollBarEntity)>,
492    mut bars: Query<(&mut Visibility, &mut Node), IsScrollBarOnly>
493) {
494    for (panel_computed, scroll_entity) in panels.iter() {
495        if let Ok((scroll_computed, bar_entity)) = scrolls.get(scroll_entity.0) {
496            if let Ok((mut vis, mut bar_node)) = bars.get_mut(bar_entity.0) {
497                let scroll_height = scroll_computed.size().y * scroll_computed.inverse_scale_factor();
498                let panel_height = panel_computed.size().y * panel_computed.inverse_scale_factor();
499
500                if panel_height <= scroll_height {
501                    *vis = Visibility::Hidden;
502                }
503                else {
504                    let bar_height = (scroll_height / panel_height) * scroll_height;
505                    bar_node.height = px(bar_height);
506                    *vis = Visibility::Visible;
507                }
508            }
509        }
510    }
511}
512
513fn on_scrollbar_drag(
514    drag: On<Pointer<Drag>>,
515    scrolls: Query<(&ComputedNode, &ScrollMovePanelEntity)>,
516    mut bars: Query<(&mut Node, &ComputedNode, &ChildOf), Without<MakaraScrollList>>,
517    mut panels: Query<(&mut Node, &ComputedNode, &mut MakaraScrollList)>,
518    mut commands: Commands,
519) {
520    let Ok((mut bar_node, bar_computed, bar_parent)) = bars.get_mut(drag.entity) else {
521        return
522    };
523
524    let Ok((scroll_computed, panel_entity)) = scrolls.get(bar_parent.0) else {
525        return
526    };
527
528    let Ok((mut panel_node, panel_computed, mut scroll_list)) = panels.get_mut(panel_entity.0) else {
529        return
530    };
531
532    let scale = bar_computed.inverse_scale_factor();
533    let container_height = scroll_computed.size().y * scale;
534    let panel_height = panel_computed.size().y * scale;
535    let bar_height = bar_computed.size().y * scale;
536
537    let max_scroll = (panel_height - container_height).max(0.0);
538    let track_space = (container_height - bar_height).max(1.0); // Avoid div by zero
539
540    let current_bar_top = if let Val::Px(p) = bar_node.top {
541        p
542    }
543    else {
544        0.0
545    };
546
547    let new_bar_top = (current_bar_top + drag.delta.y).clamp(0.0, track_space);
548    bar_node.top = Val::Px(new_bar_top);
549
550    // Calculate the ratio 0.0 to 1.0
551    let scroll_ratio = new_bar_top / track_space;
552
553    scroll_list.position = -(scroll_ratio * max_scroll);
554    panel_node.top = Val::Px(scroll_list.position);
555
556    commands.trigger(Scrolling {
557        entity: bar_parent.0,
558        position: scroll_list.position,
559    });
560}
561
562fn on_scrollbar_mouse_over(
563    mut over: On<Pointer<Over>>,
564    mut commands: Commands,
565    window: Single<Entity, With<Window>>,
566) {
567    commands.entity(*window).insert(CursorIcon::System(SystemCursorIcon::Pointer));
568    over.propagate(false);
569}
570
571pub(crate) fn handle_scrolling(
572    mut mouse_wheel: MessageReader<MouseWheel>,
573    scrolls: Query<(&ComputedNode, &ScrollMovePanelEntity, &ScrollBarEntity)>,
574    mut panels: Query<
575        (&mut Node, &ComputedNode, &mut MakaraScrollList),
576        With<MakaraScrollMovePanel>
577    >,
578    mut bars: Query<
579        (&mut Node, &ComputedNode),
580        (With<MakaraScrollbar>, Without<MakaraScrollMovePanel>)
581    >,
582    mut commands: Commands,
583    can_be_scrolled: Res<CanBeScrolled>,
584) {
585    for e in mouse_wheel.read() {
586        let Some(hovered) = can_be_scrolled.entity else {
587            continue;
588        };
589
590        let Ok((scroll_computed, panel_entity, bar_entity)) = scrolls.get(hovered) else {
591            continue;
592        };
593
594        let Ok((mut bar_node, bar_computed)) = bars.get_mut(bar_entity.0) else {
595            continue;
596        };
597
598        // panel
599        if let Ok((mut panel_node, panel_computed, mut scroll_list)) = panels.get_mut(panel_entity.0) {
600            let dy = match e.unit {
601                MouseScrollUnit::Line => e.y * scroll_list.scroll_height,
602                MouseScrollUnit::Pixel => e.y,
603            };
604
605            // calculate max scroll
606            let panel_height = panel_computed.size().y * panel_computed.inverse_scale_factor();
607            let container_height = scroll_computed.size().y * scroll_computed.inverse_scale_factor();
608            let max_scroll = (panel_height - container_height).max(0.0);
609
610            scroll_list.position = (scroll_list.position + dy).clamp(-max_scroll, 0.0);
611            panel_node.top = px(scroll_list.position);
612
613            commands.trigger(Scrolling {
614                entity: hovered,
615                position: scroll_list.position
616            });
617
618            if max_scroll > 0.0 {
619                // Calculate how far down we are (0.0 to 1.0)
620                // We use absolute value because scroll_list.position is negative
621                let scroll_ratio = scroll_list.position.abs() / max_scroll;
622
623                // The track space is the container height minus the bar's own height
624                let bar_height = bar_computed.size().y * bar_computed.inverse_scale_factor();
625                let track_space = container_height - bar_height;
626
627                // Set the bar's top position
628                bar_node.top = px(scroll_ratio * track_space);
629            } else {
630                bar_node.top = px(0.0);
631            }
632        }
633    }
634}
635
636pub(crate) fn detect_scroll_built(
637    mut commands: Commands,
638    q: Query<Entity, Added<MakaraScroll>>
639) {
640    for entity in q.iter() {
641        commands.trigger(WidgetBuilt {
642            entity
643        });
644    }
645}
646
647
648pub(crate) fn detect_scroll_class_change_for_built_in(
649    mut scrolls: Query<(&Class, &mut Node), IsScrollOnly>
650) {
651    for (class, mut node) in scrolls.iter_mut() {
652        process_built_in_spacing_class(class, &mut node);
653    }
654}
655
656/// Create column widget.
657pub fn scroll() -> ScrollBundle {
658    ScrollBundle::default()
659}
660
661pub(crate) fn can_run_scroll_systems(q: Query<&MakaraScroll>) -> bool {
662    q.count() > 0
663}