raui_core/widget/component/containers/
responsive_box.rs

1use crate::{
2    PropsData, Scalar, pre_hooks, unpack_named_slots,
3    view_model::{ViewModelProperties, ViewModelValue},
4    widget::{
5        component::{ResizeListenerSignal, use_resize_listener},
6        context::WidgetContext,
7        node::WidgetNode,
8        unit::area::AreaBoxNode,
9        utils::Vec2,
10    },
11};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14
15pub struct MediaQueryContext<'a> {
16    pub widget_width: Scalar,
17    pub widget_height: Scalar,
18    pub view_model: Option<&'a MediaQueryViewModel>,
19}
20
21#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
22pub enum MediaQueryOrientation {
23    #[default]
24    Portrait,
25    Landscape,
26}
27
28#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
29pub enum MediaQueryNumber {
30    Exact(Scalar),
31    Min(Scalar),
32    Max(Scalar),
33    Range { min: Scalar, max: Scalar },
34}
35
36impl Default for MediaQueryNumber {
37    fn default() -> Self {
38        Self::Exact(0.0)
39    }
40}
41
42impl MediaQueryNumber {
43    pub fn is_valid(&self, value: Scalar) -> bool {
44        match self {
45            Self::Exact(v) => (*v - value).abs() < Scalar::EPSILON,
46            Self::Min(v) => *v <= value,
47            Self::Max(v) => *v >= value,
48            Self::Range { min, max } => *min <= value && *max >= value,
49        }
50    }
51}
52
53#[derive(PropsData, Debug, Default, Clone, Serialize, Deserialize)]
54#[props_data(crate::props::PropsData)]
55#[prefab(crate::Prefab)]
56pub enum MediaQueryExpression {
57    #[default]
58    Any,
59    And(Vec<Self>),
60    Or(Vec<Self>),
61    Not(Box<Self>),
62    WidgetOrientation(MediaQueryOrientation),
63    WidgetAspectRatio(MediaQueryNumber),
64    WidgetWidth(MediaQueryNumber),
65    WidgetHeight(MediaQueryNumber),
66    ScreenOrientation(MediaQueryOrientation),
67    ScreenAspectRatio(MediaQueryNumber),
68    ScreenWidth(MediaQueryNumber),
69    ScreenHeight(MediaQueryNumber),
70    HasFlag(String),
71    HasNumber(String),
72    Number(String, MediaQueryNumber),
73}
74
75impl MediaQueryExpression {
76    pub fn is_valid(&self, context: &MediaQueryContext) -> bool {
77        match self {
78            Self::Any => true,
79            Self::And(conditions) => conditions
80                .iter()
81                .all(|condition| condition.is_valid(context)),
82            Self::Or(conditions) => conditions
83                .iter()
84                .any(|condition| condition.is_valid(context)),
85            Self::Not(condition) => !condition.is_valid(context),
86            Self::WidgetOrientation(orientation) => {
87                let is_portrait = context.widget_height > context.widget_width;
88                match orientation {
89                    MediaQueryOrientation::Portrait => is_portrait,
90                    MediaQueryOrientation::Landscape => !is_portrait,
91                }
92            }
93            Self::WidgetAspectRatio(aspect_ratio) => {
94                let ratio = context.widget_width / context.widget_height;
95                aspect_ratio.is_valid(ratio)
96            }
97            Self::WidgetWidth(width) => width.is_valid(context.widget_width),
98            Self::WidgetHeight(height) => height.is_valid(context.widget_height),
99            Self::ScreenOrientation(orientation) => context
100                .view_model
101                .map(|view_model| {
102                    let is_portrait = view_model.screen_size.y > view_model.screen_size.x;
103                    match orientation {
104                        MediaQueryOrientation::Portrait => is_portrait,
105                        MediaQueryOrientation::Landscape => !is_portrait,
106                    }
107                })
108                .unwrap_or_default(),
109            Self::ScreenAspectRatio(aspect_ratio) => context
110                .view_model
111                .map(|view_model| {
112                    let ratio = view_model.screen_size.x / view_model.screen_size.y;
113                    aspect_ratio.is_valid(ratio)
114                })
115                .unwrap_or_default(),
116            Self::ScreenWidth(width) => context
117                .view_model
118                .map(|view_model| width.is_valid(view_model.screen_size.x))
119                .unwrap_or_default(),
120            Self::ScreenHeight(height) => context
121                .view_model
122                .map(|view_model| height.is_valid(view_model.screen_size.y))
123                .unwrap_or_default(),
124            Self::HasFlag(flag) => context
125                .view_model
126                .map(|view_model| view_model.flags.contains(flag))
127                .unwrap_or_default(),
128            Self::HasNumber(name) => context
129                .view_model
130                .map(|view_model| view_model.numbers.contains_key(name))
131                .unwrap_or_default(),
132            Self::Number(name, number) => context
133                .view_model
134                .map(|view_model| {
135                    view_model
136                        .numbers
137                        .get(name)
138                        .map(|value| number.is_valid(*value))
139                        .unwrap_or_default()
140                })
141                .unwrap_or_default(),
142        }
143    }
144}
145
146#[derive(PropsData, Debug, Default, Copy, Clone, Serialize, Deserialize)]
147#[props_data(crate::props::PropsData)]
148#[prefab(crate::Prefab)]
149pub struct ResponsiveBoxState {
150    pub size: Vec2,
151}
152
153pub fn use_responsive_box(context: &mut WidgetContext) {
154    context.life_cycle.mount(|mut context| {
155        if let Some(mut bindings) = context.view_models.bindings(
156            MediaQueryViewModel::VIEW_MODEL,
157            MediaQueryViewModel::NOTIFIER,
158        ) {
159            bindings.bind(context.id.to_owned());
160        }
161    });
162
163    context.life_cycle.unmount(|mut context| {
164        if let Some(mut bindings) = context.view_models.bindings(
165            MediaQueryViewModel::VIEW_MODEL,
166            MediaQueryViewModel::NOTIFIER,
167        ) {
168            bindings.unbind(context.id);
169        }
170    });
171
172    context.life_cycle.change(|context| {
173        for msg in context.messenger.messages {
174            if let Some(ResizeListenerSignal::Change(size)) = msg.as_any().downcast_ref() {
175                let _ = context.state.write(ResponsiveBoxState { size: *size });
176            }
177        }
178    });
179}
180
181#[pre_hooks(use_resize_listener, use_responsive_box)]
182pub fn responsive_box(mut context: WidgetContext) -> WidgetNode {
183    let WidgetContext {
184        id,
185        state,
186        mut listed_slots,
187        view_models,
188        ..
189    } = context;
190
191    let state = state.read_cloned_or_default::<ResponsiveBoxState>();
192    let view_model = view_models
193        .view_model(MediaQueryViewModel::VIEW_MODEL)
194        .and_then(|vm| vm.read::<MediaQueryViewModel>());
195    let ctx = MediaQueryContext {
196        widget_width: state.size.x,
197        widget_height: state.size.y,
198        view_model: view_model.as_deref(),
199    };
200    let item = if let Some(index) = listed_slots.iter().position(|slot| {
201        slot.props()
202            .map(|props| {
203                props
204                    .read::<MediaQueryExpression>()
205                    .ok()
206                    .map(|query| query.is_valid(&ctx))
207                    .unwrap_or(true)
208            })
209            .unwrap_or_default()
210    }) {
211        listed_slots.remove(index)
212    } else {
213        Default::default()
214    };
215
216    AreaBoxNode {
217        id: id.to_owned(),
218        slot: Box::new(item),
219    }
220    .into()
221}
222
223#[pre_hooks(use_resize_listener, use_responsive_box)]
224pub fn responsive_props_box(mut context: WidgetContext) -> WidgetNode {
225    let WidgetContext {
226        id,
227        state,
228        listed_slots,
229        named_slots,
230        view_models,
231        ..
232    } = context;
233    unpack_named_slots!(named_slots => content);
234
235    let state = state.read_cloned_or_default::<ResponsiveBoxState>();
236    let view_model = view_models
237        .view_model(MediaQueryViewModel::VIEW_MODEL)
238        .and_then(|vm| vm.read::<MediaQueryViewModel>());
239    let ctx = MediaQueryContext {
240        widget_width: state.size.x,
241        widget_height: state.size.y,
242        view_model: view_model.as_deref(),
243    };
244
245    let props = listed_slots
246        .iter()
247        .find_map(|slot| {
248            slot.props().and_then(|props| {
249                props
250                    .read::<MediaQueryExpression>()
251                    .ok()
252                    .map(|query| query.is_valid(&ctx))
253                    .unwrap_or(true)
254                    .then_some(props.clone())
255            })
256        })
257        .unwrap_or_default();
258
259    if let Some(p) = content.props_mut() {
260        p.merge_from(props.without::<MediaQueryExpression>());
261    }
262
263    AreaBoxNode {
264        id: id.to_owned(),
265        slot: Box::new(content),
266    }
267    .into()
268}
269
270#[derive(Debug)]
271pub struct MediaQueryViewModel {
272    pub screen_size: ViewModelValue<Vec2>,
273    pub flags: ViewModelValue<HashSet<String>>,
274    pub numbers: ViewModelValue<HashMap<String, Scalar>>,
275}
276
277impl MediaQueryViewModel {
278    pub const VIEW_MODEL: &str = "MediaQueryViewModel";
279    pub const NOTIFIER: &str = "";
280
281    pub fn new(properties: &mut ViewModelProperties) -> Self {
282        let notifier = properties.notifier(Self::NOTIFIER);
283        Self {
284            screen_size: ViewModelValue::new(Default::default(), notifier.clone()),
285            flags: ViewModelValue::new(Default::default(), notifier.clone()),
286            numbers: ViewModelValue::new(Default::default(), notifier.clone()),
287        }
288    }
289}