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 }