lib/gui/src/model.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const filigree = @import("filigree");
3 const flex_layout = @import("arrange");
4
5 pub const WidgetKind = enum(u8) {
6 container,
7 label,
8 button,
9 text_input,
10 };
11
12 pub const ui_state_selected: u64 = 1 << 0;
13 pub const ui_state_disabled: u64 = 1 << 1;
14 pub const ui_state_expanded: u64 = 1 << 2;
15 pub const ui_state_checked: u64 = 1 << 3;
16 pub const ui_state_default: u64 = 1 << 4;
17 pub const ui_state_danger: u64 = 1 << 5;
18
19 pub const UiOverflow = enum(u8) {
20 visible,
21 clip,
22 };
23
24 pub const UiColor = struct {
25 r: u8 = 0,
26 g: u8 = 0,
27 b: u8 = 0,
28 a: u8 = 255,
29 };
30
31 pub const UiPoint = struct {
32 x: f32 = 0,
33 y: f32 = 0,
34 };
35
36 pub const UiLinearGradient = struct {
37 start: UiPoint = .{},
38 end: UiPoint = .{ .x = 1 },
39 start_color: UiColor = .{},
40 end_color: UiColor = .{},
41 };
42
43 pub const UiPaint = struct {
44 background: ?UiColor = null,
45 linear_gradient: ?UiLinearGradient = null,
46 foreground: ?UiColor = null,
47 border: ?UiColor = null,
48 border_width: f32 = 0,
49 corner_roundness: f32 = 0,
50 border_edges: UiBorderPaint = .{},
51 shadow: UiShadowPaint = .{},
52 image: ?UiImagePaint = null,
53 };
54
55 pub const UiImagePaint = struct {
56 index: u32,
57 source: flex_layout.Rect = .{},
58 tint: ?UiColor = null,
59 opacity: u8 = 255,
60 };
61
62 pub const UiImage = struct {
63 width: u32,
64 height: u32,
65 pixels: []const u32,
66
67 pub fn pixelCount(self: UiImage) usize {
68 return @as(usize, self.width) * @as(usize, self.height);
69 }
70
71 pub fn validate(self: UiImage) !void {
72 if (self.width == 0 or self.height == 0) return error.InvalidImage;
73 if (self.pixels.len < self.pixelCount()) return error.BufferTooSmall;
74 }
75 };
76
77 pub const UiImageSet = struct {
78 images: []const UiImage = &.{},
79 };
80
81 pub const UiBorderEdgePaint = struct {
82 color: ?UiColor = null,
83 width: f32 = 0,
84 };
85
86 pub const UiBorderPaint = struct {
87 top: UiBorderEdgePaint = .{},
88 right: UiBorderEdgePaint = .{},
89 bottom: UiBorderEdgePaint = .{},
90 left: UiBorderEdgePaint = .{},
91 };
92
93 pub const UiShadowPaint = struct {
94 color: ?UiColor = null,
95 offset_x: f32 = 0,
96 offset_y: f32 = 0,
97 blur_radius: f32 = 0,
98 spread: f32 = 0,
99 };
100
101 pub const UiScroll = struct {
102 overflow_x: UiOverflow = .visible,
103 overflow_y: UiOverflow = .visible,
104 scroll_x: f32 = 0,
105 scroll_y: f32 = 0,
106 };
107
108 pub fn nonNegativeFinite(value: f32) f32 {
109 if (!std.math.isFinite(value) or value <= 0) return 0;
110 return value;
111 }
112
113 pub const UiTextStyle = struct {
114 font_asset_id: u64 = 0,
115 point_size: f64 = 16.0,
116 };
117
118 pub const UiTextRun = struct {
119 byte_start: usize,
120 byte_end: usize,
121 style_slot: u16 = 0,
122 foreground: ?UiColor = null,
123 background: ?UiColor = null,
124 underline: bool = false,
125 strikethrough: bool = false,
126 };
127
128 pub const UiText = struct {
129 content: []const u8,
130 runs: []const UiTextRun = &.{},
131 styles: []const UiTextStyle = &.{},
132 font_asset_id: u64 = 0,
133 font_weight: u16 = ui_font_weight_normal,
134 point_size: f64 = 16.0,
135 line_height: f64 = 1.2,
136 wrap_width: f64 = 0.0,
137 horizontal_align: UiTextAlign = .start,
138 vertical_align: UiTextAlign = .start,
139 };
140
141 pub const UiTextAlign = enum(u8) {
142 start,
143 center,
144 end,
145 };
146
147 pub const UiTextSelection = struct {
148 cursor_visible: bool = false,
149 cursor_block: bool = false,
150 cursor_byte_offset: usize = 0,
151 selection_active: bool = false,
152 selection_anchor_byte_offset: usize = 0,
153 selection_focus_byte_offset: usize = 0,
154 };
155
156 pub const ui_font_weight_normal: u16 = 400;
157 pub const ui_font_weight_min: u16 = 100;
158 pub const ui_font_weight_max: u16 = 900;
159
160 pub const TextRunValidator = struct {
161 previous_end: usize = 0,
162
163 pub fn validate(
164 self: *TextRunValidator,
165 content: []const u8,
166 byte_start: usize,
167 byte_end: usize,
168 ) error{InvalidTextRun}!void {
169 if (byte_start < self.previous_end or byte_start >= byte_end) return error.InvalidTextRun;
170 if (byte_end > content.len) return error.InvalidTextRun;
171 if (!textByteBoundary(content, byte_start) or !textByteBoundary(content, byte_end)) return error.InvalidTextRun;
172 self.previous_end = byte_end;
173 }
174 };
175
176 pub fn validateTextRuns(content: []const u8, runs: []const UiTextRun) error{InvalidTextRun}!void {
177 var validator = TextRunValidator{};
178 for (runs) |run| {
179 try validator.validate(content, run.byte_start, run.byte_end);
180 }
181 }
182
183 pub fn validateText(text: UiText) error{InvalidTextRun}!void {
184 if (text.styles.len > std.math.maxInt(u16)) return error.InvalidTextRun;
185 for (text.styles) |style| {
186 if (!validTextPointSize(style.point_size)) return error.InvalidTextRun;
187 }
188 try validateTextRuns(text.content, text.runs);
189 for (text.runs, 0..) |run, index| {
190 if (run.style_slot > text.styles.len) return error.InvalidTextRun;
191 const style = resolvedTextStyle(text, run.style_slot);
192 const before = if (index > 0 and text.runs[index - 1].byte_end == run.byte_start)
193 resolvedTextStyle(text, text.runs[index - 1].style_slot)
194 else
195 resolvedTextStyle(text, 0);
196 const after = if (index + 1 < text.runs.len and text.runs[index + 1].byte_start == run.byte_end)
197 resolvedTextStyle(text, text.runs[index + 1].style_slot)
198 else
199 resolvedTextStyle(text, 0);
200 if (!textStylesEqual(before, style) and
201 !textGraphemeBoundary(text.content, run.byte_start))
202 {
203 return error.InvalidTextRun;
204 }
205 if (!textStylesEqual(style, after) and
206 !textGraphemeBoundary(text.content, run.byte_end))
207 {
208 return error.InvalidTextRun;
209 }
210 }
211 }
212
213 pub fn resolvedTextStyle(text: UiText, style_slot: u16) UiTextStyle {
214 if (style_slot == 0 or style_slot > text.styles.len) {
215 return .{
216 .font_asset_id = text.font_asset_id,
217 .point_size = normalizedTextPointSize(text.point_size),
218 };
219 }
220 return text.styles[style_slot - 1];
221 }
222
223 pub fn textStylesEqual(left: UiTextStyle, right: UiTextStyle) bool {
224 return left.font_asset_id == right.font_asset_id and
225 @as(u64, @bitCast(left.point_size)) == @as(u64, @bitCast(right.point_size));
226 }
227
228 pub fn normalizedTextPointSize(value: f64) f64 {
229 return if (validTextPointSize(value)) value else 16.0;
230 }
231
232 pub fn validTextPointSize(value: f64) bool {
233 return std.math.isFinite(value) and value > 0;
234 }
235
236 pub fn textGraphemeBoundary(content: []const u8, index: usize) bool {
237 if (index == 0 or index == content.len) return true;
238 if (index > content.len) return false;
239 var iterator = filigree.unicode.SourceIterator.init(.{ .utf8 = content }, 0) catch return false;
240 var state: filigree.unicode.GraphemeState = .{};
241 while (iterator.next() catch return false) |scalar| {
242 const boundary = !state.consume(scalar.codepoint);
243 const offset: usize = @intCast(scalar.source.start);
244 if (offset == index) return boundary;
245 if (offset > index) return false;
246 }
247 return false;
248 }
249
250 fn textByteBoundary(content: []const u8, index: usize) bool {
251 if (index == 0 or index == content.len) return true;
252 return content[index] & 0b1100_0000 != 0b1000_0000;
253 }
254
255 pub const UiNode = struct {
256 widget_id: u64,
257 kind: WidgetKind = .container,
258 style: flex_layout.Style = .{},
259 size: flex_layout.Dimensions = .{},
260 paint: UiPaint = .{},
261 scroll: UiScroll = .{},
262 focusable: bool = false,
263 layer: u8 = 0,
264 text: ?UiText = null,
265 text_selection: UiTextSelection = .{},
266 action: []const u8 = &.{},
267 role: []const u8 = &.{},
268 state_flags: u64 = 0,
269 children: []const UiNode = &.{},
270 };
271
272 pub const UiSurfaceTree = struct {
273 available_size: flex_layout.Size,
274 root: UiNode,
275 };
276
277 pub const RootFrame = struct {
278 root_id: u64,
279 surface_revision: u64,
280 rect: flex_layout.Rect,
281 widget_start: usize,
282 widget_count: usize,
283 };
284
285 pub const WidgetFrame = struct {
286 root_id: u64,
287 widget_id: u64,
288 kind: WidgetKind,
289 rect: flex_layout.Rect,
290 visible_rect: flex_layout.Rect,
291 paint: UiPaint,
292 scroll: UiScroll,
293 constraints: flex_layout.Constraints,
294 content_size: flex_layout.Size,
295 focusable: bool,
296 layer: u8 = 0,
297 hovered: bool = false,
298 captured: bool = false,
299 focused: bool = false,
300 has_text: bool = false,
301 action: []const u8 = &.{},
302 role: []const u8 = &.{},
303 state_flags: u64 = 0,
304 text_ref: u64 = 0,
305 text: ?UiText = null,
306 text_selection: UiTextSelection = .{},
307 subtree_size: usize = 1,
308 };
309
310 pub const UiFrame = struct {
311 revision: u64 = 0,
312 root_count: usize = 0,
313 roots: []const RootFrame = &.{},
314 widget_count: usize = 0,
315 widgets: []const WidgetFrame = &.{},
316 text_layout_revision: u64 = 0,
317 text_layout_ptr: ?*anyopaque = null,
318 };
319
320 pub fn pointInRect(rect: flex_layout.Rect, x: f32, y: f32) bool {
321 return x >= rect.x and y >= rect.y and x <= rect.x + rect.width and y <= rect.y + rect.height;
322 }
323
324 test "text runs require ordered nonempty UTF-8 byte ranges" {
325 const content = "a\u{00e9}z";
326 const valid = [_]UiTextRun{
327 .{ .byte_start = 0, .byte_end = 1 },
328 .{ .byte_start = 1, .byte_end = 3 },
329 .{ .byte_start = 3, .byte_end = 4 },
330 };
331 try validateTextRuns(content, &valid);
332 try std.testing.expectError(error.InvalidTextRun, validateTextRuns(content, &.{
333 .{ .byte_start = 1, .byte_end = 1 },
334 }));
335 try std.testing.expectError(error.InvalidTextRun, validateTextRuns(content, &.{
336 .{ .byte_start = 1, .byte_end = 2 },
337 }));
338 try std.testing.expectError(error.InvalidTextRun, validateTextRuns(content, &.{
339 .{ .byte_start = 0, .byte_end = 3 },
340 .{ .byte_start = 2, .byte_end = 4 },
341 }));
342 try std.testing.expectError(error.InvalidTextRun, validateTextRuns(content, &.{
343 .{ .byte_start = 3, .byte_end = 5 },
344 }));
345 }
346
347 test "metric text runs require valid slots and grapheme boundaries" {
348 const content = "a\u{0301}b";
349 const styles = [_]UiTextStyle{
350 .{ .font_asset_id = 7, .point_size = 18 },
351 .{ .point_size = 16 },
352 };
353 try validateText(.{
354 .content = content,
355 .styles = &styles,
356 .runs = &.{
357 .{ .byte_start = 0, .byte_end = 3, .style_slot = 1 },
358 .{ .byte_start = 3, .byte_end = 4, .style_slot = 2 },
359 },
360 });
361 try validateText(.{
362 .content = content,
363 .styles = &styles,
364 .runs = &.{
365 .{ .byte_start = 0, .byte_end = 1, .style_slot = 1 },
366 .{ .byte_start = 1, .byte_end = 3, .style_slot = 1 },
367 },
368 });
369 try std.testing.expectError(error.InvalidTextRun, validateText(.{
370 .content = content,
371 .styles = &styles,
372 .runs = &.{
373 .{ .byte_start = 0, .byte_end = 1, .style_slot = 1 },
374 .{ .byte_start = 1, .byte_end = 3, .style_slot = 2 },
375 },
376 }));
377 try std.testing.expectError(error.InvalidTextRun, validateText(.{
378 .content = content,
379 .styles = &styles,
380 .runs = &.{
381 .{ .byte_start = 1, .byte_end = 3, .style_slot = 1 },
382 },
383 }));
384 try std.testing.expectError(error.InvalidTextRun, validateText(.{
385 .content = content,
386 .styles = &styles,
387 .runs = &.{
388 .{ .byte_start = 0, .byte_end = 3, .style_slot = 3 },
389 },
390 }));
391 try std.testing.expectError(error.InvalidTextRun, validateText(.{
392 .content = content,
393 .styles = &.{.{ .point_size = std.math.nan(f64) }},
394 }));
395 }