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 }