extern crate alloc;
#[cfg(feature = "std")]
use crate::app::plugins::StdInstantClockPlugin;
use crate::input::event::scroll::{ScrollAxis, ScrollConfig, ScrollOffset};
#[cfg(feature = "std")]
use crate::prelude::plugin::FpsSummaryPlugin;
use crate::prelude::*;
use crate::types::{Dimension, Transform3D};
use crate::ui;
use crate::ui::root_viewport;
use crate::ui::widgets::{Image, WidgetTransform3D};
pub const DEFAULT_VIEW: (u16, u16) = (640, 360);
const CARD_COUNT: i32 = 5;
pub struct CarouselCard {
pub index: usize,
}
pub struct Carousel;
pub struct CoverFlowBounds {
pub view_w: i32,
pub view_h: i32,
pub card_w: i32,
pub card_h: i32,
pub card_gap: i32,
pub perspective: i32,
}
impl CoverFlowBounds {
fn for_view(view_w: u16, view_h: u16) -> Self {
Self::for_px(view_w as i32, view_h as i32)
}
fn for_px(vw: i32, vh: i32) -> Self {
Self {
view_w: vw,
view_h: vh,
card_w: vw * 7 / 32,
card_h: vh / 2,
card_gap: vw / 8,
perspective: vw.max(vh) * 25 / 64,
}
}
fn content_width(&self) -> i32 {
self.view_w + (self.card_w + self.card_gap) * (CARD_COUNT - 1)
}
}
#[derive(Clone, Copy, PartialEq)]
struct LayoutCache {
view_w: i32,
view_h: i32,
offset: Fixed,
}
#[mirui_macros::system]
pub fn layout_system(world: &mut World) {
let bounds = match root_viewport(world) {
Some(rect) => CoverFlowBounds::for_px(rect.w.to_int(), rect.h.to_int()),
None => match world.resource::<CoverFlowBounds>() {
Some(b) => CoverFlowBounds::for_px(b.view_w, b.view_h),
None => return,
},
};
let CoverFlowBounds {
view_w,
view_h,
card_w,
card_h,
card_gap,
perspective,
} = bounds;
let Some(carousel) = world
.query::<Carousel>()
.iter()
.next()
.map(|(entity, _)| entity)
else {
return;
};
let offset = match world.get::<ScrollOffset>(carousel) {
Some(s) => s.x,
None => return,
};
let frame = LayoutCache {
view_w,
view_h,
offset,
};
if world.resource::<LayoutCache>() == Some(&frame) {
return;
}
world.insert_resource(frame);
if let Some(style) = world.get_mut::<Style>(carousel) {
style.layout.width = Dimension::Px(Fixed::from_int(view_w));
style.layout.height = Dimension::Px(Fixed::from_int(view_h));
}
if let Some(cfg) = world.get_mut::<ScrollConfig>(carousel) {
cfg.content_width = Fixed::from_int(view_w + (card_w + card_gap) * (CARD_COUNT - 1));
}
if world
.storage::<CarouselCard>()
.is_none_or(|storage| storage.entities().is_empty())
{
return;
}
let slot_stride = Fixed::from_int(card_w + card_gap);
let container_center = Fixed::from_int(view_w / 2);
let card_top = Fixed::from_int((view_h - card_h) / 2);
world.for_each_stable::<CarouselCard>(|world, e| {
let idx = match world.get::<CarouselCard>(e) {
Some(c) => c.index as i32,
None => return,
};
let tx =
container_center + Fixed::from_int(idx) * slot_stride - Fixed::from_int(card_w / 2);
ui::set_position(world, e, tx, card_top);
if let Some(style) = world.get_mut::<Style>(e) {
style.layout.width = Dimension::px(card_w);
style.layout.height = Dimension::px(card_h);
}
let relative = Fixed::from_int(idx) - offset / slot_stride;
let tilt_y = Fixed::ZERO - relative * Fixed::from_int(45);
let tilt_x = relative.abs() * Fixed::from_int(20) - Fixed::from_int(15);
let distance = Fixed::from_int(perspective);
let ty3d = Transform3D::rotate_y_perspective(tilt_y, distance);
let tx3d = Transform3D::rotate_x_perspective(tilt_x, distance);
world.insert(e, WidgetTransform3D(ty3d.compose(&tx3d)));
world.invalidate(e);
});
world.invalidate(carousel);
}
#[compose]
pub fn build_widgets(view_w: u16, view_h: u16) {
let bounds = CoverFlowBounds::for_view(view_w, view_h);
let vw = bounds.view_w;
let vh = bounds.view_h;
let card_w = bounds.card_w;
let card_h = bounds.card_h;
let content_width = bounds.content_width();
let initial_offset = (content_width - vw) / 2 - card_w / 4;
cx.world_mut().insert_resource(bounds);
let card_colors = [
Color::rgb(255, 107, 107),
Color::rgb(255, 206, 84),
Color::rgb(136, 216, 176),
Color::rgb(118, 209, 244),
Color::rgb(178, 148, 255),
];
let card_colors_ref = &card_colors;
ui! {
View (
position: Position::Absolute,
left: 0,
top: 0,
width: vw,
height: vh,
bg_color: ColorToken::Surface
) [
Carousel,
ScrollOffset {
x: Fixed::from_int(initial_offset),
y: Fixed::ZERO,
},
ScrollConfig {
direction: ScrollAxis::Horizontal,
elastic: true,
content_width: Fixed::from_int(content_width),
content_height: Fixed::ZERO,
},
] {
walk card_colors_ref.iter().enumerate() with item {
View (
position: Position::Absolute,
left: 0,
top: 0,
width: card_w,
height: card_h,
bg_color: *item.1,
border_radius: 8,
border_color: ColorToken::Outline,
border_width: 5,
align: AlignItems::Center,
justify: JustifyContent::Center
) [
CarouselCard { index: item.0 },
] {
if item.0 % 2 == 1 {
View (
width: 64,
height: 64,
image: Image::new("thumbs_up")
)
}
}
}
}
};
}
#[cfg(feature = "std")]
pub fn setup_app<B, F>(app: &mut App<B, F>, parent: Entity)
where
B: Surface,
F: RendererFactory<B>,
{
let info = app.backend.display_info();
app.add_system(layout_system::system())
.add_plugin(StdInstantClockPlugin)
.add_plugin(FpsSummaryPlugin::default())
.add_plugin(crate::app::plugins::ImageResourcesPlugin::default());
app.compose(parent, |cx| build_widgets(cx, info.width, info.height));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::Children;
use crate::ui::IdMap;
use crate::ui::UiScope;
#[test]
fn build_widgets_smoke() {
let mut world = World::new();
world.insert_resource(IdMap::new());
let parent = WidgetBuilder::new(&mut world).id();
let mut cx = UiScope::new(&mut world, parent);
build_widgets(&mut cx, 640, 360);
drop(cx);
assert!(
world
.get::<Children>(parent)
.is_some_and(|c| !c.0.is_empty()),
);
}
#[cfg(feature = "std")]
#[test]
fn layout_system_tracks_live_viewport() {
use crate::types::Viewport;
use crate::ui::render_system::update_layout;
let mut app = crate::app::App::headless(640, 360);
app.with_default_widgets().with_default_systems();
let root = app.spawn_root().id();
app.compose(root, |cx| build_widgets(cx, 640, 360));
app.set_root(root);
let carousel = app.world.query::<Carousel>().collect()[0];
let card = app.world.query::<CarouselCard>().collect()[0];
let widths_at = |app: &mut crate::app::App<_, _>, w: u16, h: u16| -> (i32, i32) {
let vp = Viewport::new(w, h, Fixed::ONE);
update_layout(&mut app.world, root, &vp);
layout_system(&mut app.world);
let carousel_width = match app.world.get::<Style>(carousel).map(|s| s.layout.width) {
Some(Dimension::Px(px)) => px.to_int(),
_ => -1,
};
let card_width = match app.world.get::<Style>(card).map(|s| s.layout.width) {
Some(Dimension::Px(px)) => px.to_int(),
_ => -1,
};
(carousel_width, card_width)
};
let narrow = widths_at(&mut app, 480, 320);
let wide = widths_at(&mut app, 960, 540);
assert!(
wide.0 > narrow.0,
"carousel width must grow with the live viewport: {} -> {}",
narrow.0,
wide.0,
);
assert!(
wide.1 > narrow.1,
"card width must grow with the live viewport: {} -> {}",
narrow.1,
wide.1,
);
}
#[test]
fn portrait_perspective_keeps_the_complete_card_in_front() {
let bounds = CoverFlowBounds::for_px(360, 640);
assert!(bounds.perspective > bounds.card_h / 2);
}
}