lib/ui/src/style/media.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const css = @import("css");
3 const abi = @import("../abi/root.zig");
4
5 pub const ColorScheme = css.media.ColorScheme;
6 pub const ReducedMotion = css.media.ReducedMotion;
7 pub const resolution_media = "ui.lint.resolution_media";
8
9 pub const Environment = struct {
10 width: f32,
11 height: f32,
12 root_font_size: f32,
13 color_scheme: ColorScheme,
14 reduced_motion: ReducedMotion,
15
16 pub fn fromHeader(header: abi.Header, color_scheme: ColorScheme, reduced_motion: ReducedMotion) Environment {
17 return .{
18 .width = header.viewport.width,
19 .height = header.viewport.height,
20 .root_font_size = header.root_font_size,
21 .color_scheme = color_scheme,
22 .reduced_motion = reduced_motion,
23 };
24 }
25
26 pub fn cssEnvironment(self: Environment) css.media.MediaEnvironment {
27 return .{
28 .media_type = .screen,
29 .width = self.width,
30 .height = self.height,
31 .font_size = self.root_font_size,
32 .color_scheme = self.color_scheme,
33 .reduced_motion = self.reduced_motion,
34 };
35 }
36
37 pub fn parse(self: Environment, workspace: *css.Workspace, source: []const u8) !css.StyleSheet {
38 return workspace.parse(source, self.cssEnvironment());
39 }
40 };
41
42 pub fn applies(query_list: []const u8, environment: Environment) bool {
43 return css.media.mediaListApplies(query_list, environment.cssEnvironment());
44 }
45
46 pub const Report = struct {
47 code: []const u8 = resolution_media,
48 start: usize,
49 end: usize,
50 };
51
52 pub fn lintResolutionMedia(query_list: []const u8) ?Report {
53 var index: usize = 0;
54 while (index < query_list.len) {
55 const byte = query_list[index];
56 if (byte == '\'' or byte == '"') {
57 const quote = byte;
58 index += 1;
59 while (index < query_list.len) : (index += 1) {
60 if (query_list[index] == '\\' and index + 1 < query_list.len) {
61 index += 1;
62 continue;
63 }
64 if (query_list[index] == quote) {
65 index += 1;
66 break;
67 }
68 }
69 continue;
70 }
71 if (byte == '/' and index + 1 < query_list.len and query_list[index + 1] == '*') {
72 index += 2;
73 while (index + 1 < query_list.len and !(query_list[index] == '*' and query_list[index + 1] == '/')) : (index += 1) {}
74 index = @min(query_list.len, index + 2);
75 continue;
76 }
77 if (!identifierByte(byte)) {
78 index += 1;
79 continue;
80 }
81 const start = index;
82 while (index < query_list.len and identifierByte(query_list[index])) : (index += 1) {}
83 if (isResolutionFeature(query_list[start..index])) return .{ .start = start, .end = index };
84 }
85 return null;
86 }
87
88 fn isResolutionFeature(raw: []const u8) bool {
89 var name = raw;
90 for ([_][]const u8{ "-webkit-", "-moz-", "-o-", "-ms-" }) |prefix| {
91 if (startsWithIgnoreCase(name, prefix)) {
92 name = name[prefix.len..];
93 break;
94 }
95 }
96 if (startsWithIgnoreCase(name, "min-")) name = name[4..];
97 if (startsWithIgnoreCase(name, "max-")) name = name[4..];
98 return std.ascii.eqlIgnoreCase(name, "resolution") or
99 std.ascii.eqlIgnoreCase(name, "device-pixel-ratio");
100 }
101
102 fn startsWithIgnoreCase(text: []const u8, prefix: []const u8) bool {
103 return text.len >= prefix.len and std.ascii.eqlIgnoreCase(text[0..prefix.len], prefix);
104 }
105
106 fn identifierByte(byte: u8) bool {
107 return std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '_';
108 }
109
110 test "ui media evaluates viewport and preference inputs without unit scale" {
111 var header = abi.Header{
112 .viewport = .{ .width = 1200, .height = 800 },
113 .root_font_size = 16,
114 .unit_scale = 1,
115 };
116 const first = Environment.fromHeader(header, .dark, .reduce);
117 header.unit_scale = 3;
118 const second = Environment.fromHeader(header, .dark, .reduce);
119 try std.testing.expectEqualDeep(first, second);
120 try std.testing.expect(applies("(min-width: 1000px) and (prefers-color-scheme: dark)", first));
121 try std.testing.expect(applies("(aspect-ratio: 3/2) and (prefers-reduced-motion: reduce)", first));
122 try std.testing.expect(!applies("not (resolution: 2dppx)", first));
123 }
124
125 test "ui lint resolution_media reports unsupported feature tokens" {
126 for ([_][]const u8{
127 "(resolution: 2dppx)",
128 "(min-resolution: 96dpi)",
129 "(-webkit-device-pixel-ratio: 2)",
130 "(-moz-max-device-pixel-ratio: 2)",
131 }) |query| {
132 const report = lintResolutionMedia(query).?;
133 try std.testing.expectEqualStrings(resolution_media, report.code);
134 try std.testing.expect(report.start < report.end);
135 }
136 try std.testing.expect(lintResolutionMedia("(prefers-color-scheme: dark)") == null);
137 try std.testing.expect(lintResolutionMedia("(width: 10px) /* resolution */") == null);
138 try std.testing.expect(lintResolutionMedia("(width: 10px) and ('resolution': 2)") == null);
139 }