Skip to content

Commit b5e1315

Browse files
author
wangnaihe
committed
feat: add @media queries, container queries, and adaptive layout
Responsive design engine for multi-device support (like HarmonyOS/SwiftUI but using pure W3C standards): Media Query Engine (media.rs): - @media condition types: min-width, max-width, min-height, max-height, orientation, min-resolution, prefers-color-scheme - Compound queries: And, Or, Not - Viewport helper: orientation(), size_class() (Compact/Medium/Expanded) - Parse @media query strings: "(min-width: 600px) and (max-width: 1024px)" - 14 unit tests Container Queries: - ContainerCondition: min-width, max-width, min-height, max-height, And - matches_container(condition, width, height) evaluator - Enables component-level responsive layout (like SwiftUI GeometryReader) Adaptive Layout Example: - Dashboard app that works on phone/tablet/desktop with zero @media rules - Uses CSS flex-wrap + minWidth + flexGrow for natural responsiveness - Stats cards: 4-column on desktop, 2 on tablet, 1 on phone - Sidebar: side panel on wide, stacks on narrow Made-with: Cursor
1 parent 2220f4e commit b5e1315

File tree

3 files changed

+481
-0
lines changed

3 files changed

+481
-0
lines changed

crates/w3cos-runtime/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
pub mod dom;
2+
#[cfg(feature = "devtools")]
3+
pub mod devtools;
24
pub mod fetch;
35
pub mod fs;
46
pub mod history;
7+
pub mod image_loader;
58
pub mod layout;
69
pub mod manifest;
10+
pub mod media;
711
pub mod multi_window;
812
pub mod notification;
913
pub mod process;

crates/w3cos-runtime/src/media.rs

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
/// Viewport information for evaluating @media queries.
4+
#[derive(Debug, Clone, Copy)]
5+
pub struct Viewport {
6+
pub width: f32,
7+
pub height: f32,
8+
pub device_pixel_ratio: f32,
9+
}
10+
11+
impl Viewport {
12+
pub fn new(width: f32, height: f32, dpr: f32) -> Self {
13+
Self {
14+
width,
15+
height,
16+
device_pixel_ratio: dpr,
17+
}
18+
}
19+
20+
pub fn orientation(&self) -> Orientation {
21+
if self.width >= self.height {
22+
Orientation::Landscape
23+
} else {
24+
Orientation::Portrait
25+
}
26+
}
27+
28+
/// Classify viewport into a size class (like SwiftUI/HarmonyOS breakpoints).
29+
pub fn size_class(&self) -> SizeClass {
30+
if self.width < 600.0 {
31+
SizeClass::Compact
32+
} else if self.width < 1024.0 {
33+
SizeClass::Medium
34+
} else {
35+
SizeClass::Expanded
36+
}
37+
}
38+
}
39+
40+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41+
pub enum Orientation {
42+
Portrait,
43+
Landscape,
44+
}
45+
46+
/// Breakpoint size classes (similar to HarmonyOS breakpoints / SwiftUI SizeClass).
47+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48+
pub enum SizeClass {
49+
Compact, // < 600px (phone)
50+
Medium, // 600-1024px (tablet)
51+
Expanded, // > 1024px (desktop)
52+
}
53+
54+
/// A CSS @media query condition.
55+
#[derive(Debug, Clone, Serialize, Deserialize)]
56+
pub enum MediaCondition {
57+
MinWidth(f32),
58+
MaxWidth(f32),
59+
MinHeight(f32),
60+
MaxHeight(f32),
61+
Orientation(Orientation),
62+
MinResolution(f32),
63+
PrefersColorScheme(ColorScheme),
64+
And(Vec<MediaCondition>),
65+
Or(Vec<MediaCondition>),
66+
Not(Box<MediaCondition>),
67+
}
68+
69+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70+
pub enum ColorScheme {
71+
Light,
72+
Dark,
73+
}
74+
75+
/// A CSS @media rule: condition + associated style overrides.
76+
#[derive(Debug, Clone, Serialize, Deserialize)]
77+
pub struct MediaRule {
78+
pub condition: MediaCondition,
79+
pub styles: Vec<(String, Vec<(String, String)>)>,
80+
}
81+
82+
/// A CSS container query condition.
83+
#[derive(Debug, Clone, Serialize, Deserialize)]
84+
pub enum ContainerCondition {
85+
MinWidth(f32),
86+
MaxWidth(f32),
87+
MinHeight(f32),
88+
MaxHeight(f32),
89+
And(Vec<ContainerCondition>),
90+
}
91+
92+
/// Evaluate a @media condition against the current viewport.
93+
pub fn matches_media(condition: &MediaCondition, viewport: &Viewport) -> bool {
94+
match condition {
95+
MediaCondition::MinWidth(w) => viewport.width >= *w,
96+
MediaCondition::MaxWidth(w) => viewport.width <= *w,
97+
MediaCondition::MinHeight(h) => viewport.height >= *h,
98+
MediaCondition::MaxHeight(h) => viewport.height <= *h,
99+
MediaCondition::Orientation(o) => viewport.orientation() == *o,
100+
MediaCondition::MinResolution(dpr) => viewport.device_pixel_ratio >= *dpr,
101+
MediaCondition::PrefersColorScheme(_scheme) => {
102+
// Default to dark for W3C OS
103+
matches!(_scheme, ColorScheme::Dark)
104+
}
105+
MediaCondition::And(conditions) => conditions.iter().all(|c| matches_media(c, viewport)),
106+
MediaCondition::Or(conditions) => conditions.iter().any(|c| matches_media(c, viewport)),
107+
MediaCondition::Not(c) => !matches_media(c, viewport),
108+
}
109+
}
110+
111+
/// Evaluate a container query against a container's actual size.
112+
pub fn matches_container(condition: &ContainerCondition, width: f32, height: f32) -> bool {
113+
match condition {
114+
ContainerCondition::MinWidth(w) => width >= *w,
115+
ContainerCondition::MaxWidth(w) => width <= *w,
116+
ContainerCondition::MinHeight(h) => height >= *h,
117+
ContainerCondition::MaxHeight(h) => height <= *h,
118+
ContainerCondition::And(conditions) => {
119+
conditions.iter().all(|c| matches_container(c, width, height))
120+
}
121+
}
122+
}
123+
124+
/// Parse a simple @media query string into a condition.
125+
///
126+
/// Supports:
127+
/// (min-width: 600px)
128+
/// (max-width: 1024px)
129+
/// (orientation: portrait)
130+
/// (min-width: 600px) and (max-width: 1024px)
131+
pub fn parse_media_query(query: &str) -> Option<MediaCondition> {
132+
let query = query.trim();
133+
134+
if query.contains(") and (") {
135+
let parts: Vec<&str> = query.split(") and (").collect();
136+
let conditions: Vec<MediaCondition> = parts
137+
.iter()
138+
.filter_map(|p| {
139+
let clean = p.trim_matches(|c| c == '(' || c == ')');
140+
parse_single_condition(clean)
141+
})
142+
.collect();
143+
if conditions.is_empty() {
144+
return None;
145+
}
146+
return Some(MediaCondition::And(conditions));
147+
}
148+
149+
let clean = query.trim_matches(|c: char| c == '(' || c == ')');
150+
parse_single_condition(clean)
151+
}
152+
153+
fn parse_single_condition(s: &str) -> Option<MediaCondition> {
154+
let parts: Vec<&str> = s.splitn(2, ':').collect();
155+
if parts.len() != 2 {
156+
return None;
157+
}
158+
let prop = parts[0].trim();
159+
let val = parts[1].trim();
160+
161+
match prop {
162+
"min-width" => parse_px(val).map(MediaCondition::MinWidth),
163+
"max-width" => parse_px(val).map(MediaCondition::MaxWidth),
164+
"min-height" => parse_px(val).map(MediaCondition::MinHeight),
165+
"max-height" => parse_px(val).map(MediaCondition::MaxHeight),
166+
"orientation" => match val {
167+
"portrait" => Some(MediaCondition::Orientation(Orientation::Portrait)),
168+
"landscape" => Some(MediaCondition::Orientation(Orientation::Landscape)),
169+
_ => None,
170+
},
171+
"min-resolution" => {
172+
val.strip_suffix("dppx")
173+
.or_else(|| val.strip_suffix("x"))
174+
.and_then(|n| n.trim().parse::<f32>().ok())
175+
.map(MediaCondition::MinResolution)
176+
}
177+
"prefers-color-scheme" => match val {
178+
"dark" => Some(MediaCondition::PrefersColorScheme(ColorScheme::Dark)),
179+
"light" => Some(MediaCondition::PrefersColorScheme(ColorScheme::Light)),
180+
_ => None,
181+
},
182+
_ => None,
183+
}
184+
}
185+
186+
fn parse_px(val: &str) -> Option<f32> {
187+
val.strip_suffix("px")
188+
.and_then(|n| n.trim().parse::<f32>().ok())
189+
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
fn desktop() -> Viewport {
196+
Viewport::new(1920.0, 1080.0, 1.0)
197+
}
198+
fn tablet() -> Viewport {
199+
Viewport::new(768.0, 1024.0, 2.0)
200+
}
201+
fn phone() -> Viewport {
202+
Viewport::new(375.0, 812.0, 3.0)
203+
}
204+
205+
#[test]
206+
fn size_class_breakpoints() {
207+
assert_eq!(phone().size_class(), SizeClass::Compact);
208+
assert_eq!(tablet().size_class(), SizeClass::Medium);
209+
assert_eq!(desktop().size_class(), SizeClass::Expanded);
210+
}
211+
212+
#[test]
213+
fn orientation_detection() {
214+
assert_eq!(desktop().orientation(), Orientation::Landscape);
215+
assert_eq!(phone().orientation(), Orientation::Portrait);
216+
}
217+
218+
#[test]
219+
fn min_width_query() {
220+
let cond = MediaCondition::MinWidth(600.0);
221+
assert!(matches_media(&cond, &desktop()));
222+
assert!(matches_media(&cond, &tablet()));
223+
assert!(!matches_media(&cond, &phone()));
224+
}
225+
226+
#[test]
227+
fn max_width_query() {
228+
let cond = MediaCondition::MaxWidth(600.0);
229+
assert!(!matches_media(&cond, &desktop()));
230+
assert!(!matches_media(&cond, &tablet()));
231+
assert!(matches_media(&cond, &phone()));
232+
}
233+
234+
#[test]
235+
fn orientation_query() {
236+
let portrait = MediaCondition::Orientation(Orientation::Portrait);
237+
assert!(!matches_media(&portrait, &desktop()));
238+
assert!(matches_media(&portrait, &phone()));
239+
}
240+
241+
#[test]
242+
fn and_query() {
243+
let cond = MediaCondition::And(vec![
244+
MediaCondition::MinWidth(600.0),
245+
MediaCondition::MaxWidth(1024.0),
246+
]);
247+
assert!(!matches_media(&cond, &desktop())); // 1920 > 1024
248+
assert!(matches_media(&cond, &tablet())); // 768 in range
249+
assert!(!matches_media(&cond, &phone())); // 375 < 600
250+
}
251+
252+
#[test]
253+
fn not_query() {
254+
let cond = MediaCondition::Not(Box::new(MediaCondition::MinWidth(600.0)));
255+
assert!(!matches_media(&cond, &desktop()));
256+
assert!(matches_media(&cond, &phone()));
257+
}
258+
259+
#[test]
260+
fn resolution_query() {
261+
let cond = MediaCondition::MinResolution(2.0);
262+
assert!(!matches_media(&cond, &desktop())); // 1x
263+
assert!(matches_media(&cond, &tablet())); // 2x
264+
assert!(matches_media(&cond, &phone())); // 3x
265+
}
266+
267+
#[test]
268+
fn parse_simple_min_width() {
269+
let cond = parse_media_query("(min-width: 600px)").unwrap();
270+
assert!(matches_media(&cond, &desktop()));
271+
assert!(!matches_media(&cond, &phone()));
272+
}
273+
274+
#[test]
275+
fn parse_and_query() {
276+
let cond = parse_media_query("(min-width: 600px) and (max-width: 1024px)").unwrap();
277+
assert!(matches_media(&cond, &tablet()));
278+
assert!(!matches_media(&cond, &desktop()));
279+
}
280+
281+
#[test]
282+
fn parse_orientation() {
283+
let cond = parse_media_query("(orientation: portrait)").unwrap();
284+
assert!(matches_media(&cond, &phone()));
285+
assert!(!matches_media(&cond, &desktop()));
286+
}
287+
288+
#[test]
289+
fn parse_color_scheme() {
290+
let cond = parse_media_query("(prefers-color-scheme: dark)").unwrap();
291+
assert!(matches_media(&cond, &desktop()));
292+
}
293+
294+
#[test]
295+
fn container_query_basic() {
296+
let cond = ContainerCondition::MinWidth(300.0);
297+
assert!(matches_container(&cond, 400.0, 200.0));
298+
assert!(!matches_container(&cond, 200.0, 200.0));
299+
}
300+
301+
#[test]
302+
fn container_query_and() {
303+
let cond = ContainerCondition::And(vec![
304+
ContainerCondition::MinWidth(300.0),
305+
ContainerCondition::MaxWidth(800.0),
306+
]);
307+
assert!(matches_container(&cond, 500.0, 400.0));
308+
assert!(!matches_container(&cond, 900.0, 400.0));
309+
assert!(!matches_container(&cond, 200.0, 400.0));
310+
}
311+
}

0 commit comments

Comments
 (0)