silk_layout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SilkLayout
4
+ module Layout
5
+ class FlexLayout
6
+ Item = Struct.new(
7
+ :box,
8
+ :base_content,
9
+ :target_content,
10
+ :outer_main,
11
+ :line
12
+ )
13
+
14
+ Line = Struct.new(:items, :cross_size, :main_size)
15
+
16
+ def self.layout(box, context, cursor_y = 0, parent_x = 0, containing_width = nil)
17
+ new(box, context, cursor_y, parent_x, containing_width).layout
18
+ end
19
+
20
+ def initialize(box, context, cursor_y, parent_x, containing_width)
21
+ @box = box
22
+ @context = context
23
+ @cursor_y = cursor_y
24
+ @parent_x = parent_x
25
+ @containing_width = containing_width
26
+ end
27
+
28
+ def layout
29
+ setup_container
30
+
31
+ if column?
32
+ layout_column
33
+ else
34
+ layout_row
35
+ end
36
+
37
+ @box.width =
38
+ @content_width +
39
+ @box.padding[:left] + @box.padding[:right] +
40
+ @box.border[:left] + @box.border[:right]
41
+
42
+ @box.height =
43
+ @content_height +
44
+ @box.padding[:top] + @box.padding[:bottom] +
45
+ @box.border[:top] + @box.border[:bottom]
46
+ end
47
+
48
+ private
49
+
50
+ def setup_container
51
+ @box.x = @parent_x + @box.margin[:left]
52
+ @box.y = @cursor_y + @box.margin[:top]
53
+
54
+ @content_x = @box.x + @box.border[:left] + @box.padding[:left]
55
+ @content_y = @box.y + @box.border[:top] + @box.padding[:top]
56
+
57
+ available_width = @containing_width || @context.width
58
+ @content_width =
59
+ if @box.explicit_width
60
+ @box.width
61
+ else
62
+ available_width -
63
+ @box.margin[:left] - @box.margin[:right] -
64
+ @box.border[:left] - @box.border[:right] -
65
+ @box.padding[:left] - @box.padding[:right]
66
+ end
67
+
68
+ @content_width = 0 if @content_width < 0
69
+ @content_height = @box.explicit_height ? @box.height : 0
70
+ end
71
+
72
+ def layout_row
73
+ items = @box.children.map { |child| build_row_item(child) }
74
+ items.reverse! if direction == "row-reverse"
75
+ lines = row_lines(items)
76
+
77
+ cursor_y = @content_y
78
+ lines.each do |line|
79
+ distribute_row_space(line)
80
+ layout_row_line(line)
81
+ position_row_line(line, cursor_y)
82
+ cursor_y += line.cross_size + row_gap
83
+ end
84
+
85
+ used_height = lines.sum(&:cross_size) + row_gap * [lines.length - 1, 0].max
86
+ @content_height = [@content_height, used_height].max
87
+ @content_width = shrink_to_row_width(lines) if inline_flex_without_width?
88
+ end
89
+
90
+ def build_row_item(child)
91
+ base = row_base_content(child)
92
+ outer = base + horizontal_edges(child)
93
+
94
+ Item.new(
95
+ box: child,
96
+ base_content: base,
97
+ target_content: base,
98
+ outer_main: outer
99
+ )
100
+ end
101
+
102
+ def row_lines(items)
103
+ return [Line.new(items: [], cross_size: 0, main_size: 0)] if items.empty?
104
+ return [Line.new(items: items)] unless wrap?
105
+
106
+ lines = []
107
+ current = []
108
+ current_width = 0
109
+
110
+ items.each do |item|
111
+ next_width = current_width
112
+ next_width += column_gap unless current.empty?
113
+ next_width += item.outer_main
114
+
115
+ if current.any? && next_width > @content_width
116
+ lines << Line.new(items: current)
117
+ current = []
118
+ current_width = 0
119
+ end
120
+
121
+ current_width += column_gap unless current.empty?
122
+ current_width += item.outer_main
123
+ current << item
124
+ end
125
+
126
+ lines << Line.new(items: current) if current.any?
127
+ lines
128
+ end
129
+
130
+ def distribute_row_space(line)
131
+ occupied =
132
+ line.items.sum { |item| item.base_content + horizontal_edges(item.box) } +
133
+ column_gap * [line.items.length - 1, 0].max
134
+
135
+ free = @content_width - occupied
136
+
137
+ if free.positive?
138
+ grow_sum = line.items.sum { |item| item.box.flex[:grow] }
139
+ if grow_sum.positive?
140
+ line.items.each do |item|
141
+ item.target_content =
142
+ item.base_content + (free * item.box.flex[:grow] / grow_sum)
143
+ end
144
+ end
145
+ elsif free.negative?
146
+ shrink_sum = line.items.sum { |item| item.box.flex[:shrink] * item.base_content }
147
+ if shrink_sum.positive?
148
+ line.items.each do |item|
149
+ scaled = item.box.flex[:shrink] * item.base_content
150
+ item.target_content =
151
+ [item.base_content + (free * scaled / shrink_sum), 0].max
152
+ end
153
+ end
154
+ end
155
+
156
+ line.main_size =
157
+ line.items.sum { |item| item.target_content + horizontal_edges(item.box) } +
158
+ column_gap * [line.items.length - 1, 0].max
159
+ end
160
+
161
+ def layout_row_line(line)
162
+ line.items.each do |item|
163
+ layout_child(
164
+ item.box,
165
+ width: item.target_content,
166
+ height: nil
167
+ )
168
+ end
169
+
170
+ line.cross_size =
171
+ line.items.map { |item| item.box.height + vertical_margins(item.box) }.max || 0
172
+ line.cross_size = [line.cross_size, @content_height].max if !wrap? && @box.explicit_height
173
+
174
+ if align_items == "stretch"
175
+ line.items.each do |item|
176
+ child = item.box
177
+ next if child.explicit_height
178
+
179
+ stretched = line.cross_size - vertical_margins(child)
180
+ child.height = stretched if stretched > child.height
181
+ end
182
+ end
183
+ end
184
+
185
+ def position_row_line(line, cursor_y)
186
+ cursor_x = @content_x + justify_offset(line)
187
+ item_gap = justified_gap(line)
188
+
189
+ line.items.each do |item|
190
+ child = item.box
191
+ target_x = cursor_x + child.margin[:left]
192
+ target_y = cursor_y + child.margin[:top] + align_offset(child, line.cross_size)
193
+
194
+ move_subtree(child, target_x - child.x, target_y - child.y)
195
+
196
+ cursor_x += child.width + horizontal_margins(child) + item_gap
197
+ end
198
+ end
199
+
200
+ def layout_column
201
+ items = @box.children.map { |child| build_column_item(child) }
202
+ items.reverse! if direction == "column-reverse"
203
+ occupied =
204
+ items.sum { |item| item.base_content + vertical_edges(item.box) } +
205
+ row_gap * [items.length - 1, 0].max
206
+
207
+ distribute_column_space(items, occupied)
208
+
209
+ cursor_y = @content_y + column_justify_offset(items)
210
+ item_gap = column_justified_gap(items)
211
+ max_width = 0
212
+
213
+ items.each do |item|
214
+ child = item.box
215
+ child_width = column_child_width(child)
216
+
217
+ layout_child(
218
+ child,
219
+ width: child_width,
220
+ height: item.target_content
221
+ )
222
+
223
+ target_x = @content_x + child.margin[:left] + column_align_offset(child)
224
+ target_y = cursor_y + child.margin[:top]
225
+ move_subtree(child, target_x - child.x, target_y - child.y)
226
+
227
+ cursor_y += child.height + vertical_margins(child) + item_gap
228
+ max_width = [max_width, child.width + horizontal_margins(child)].max
229
+ end
230
+
231
+ used_height =
232
+ items.sum { |item| item.box.height + vertical_margins(item.box) } +
233
+ row_gap * [items.length - 1, 0].max
234
+
235
+ @content_height = [@content_height, used_height].max
236
+ @content_width = max_width if inline_flex_without_width?
237
+ end
238
+
239
+ def build_column_item(child)
240
+ base = column_base_content(child)
241
+
242
+ Item.new(
243
+ box: child,
244
+ base_content: base,
245
+ target_content: base,
246
+ outer_main: base + vertical_edges(child)
247
+ )
248
+ end
249
+
250
+ def distribute_column_space(items, occupied)
251
+ return unless @box.explicit_height
252
+
253
+ free = @content_height - occupied
254
+
255
+ if free.positive?
256
+ grow_sum = items.sum { |item| item.box.flex[:grow] }
257
+ if grow_sum.positive?
258
+ items.each do |item|
259
+ item.target_content =
260
+ item.base_content + (free * item.box.flex[:grow] / grow_sum)
261
+ end
262
+ end
263
+ elsif free.negative?
264
+ shrink_sum = items.sum { |item| item.box.flex[:shrink] * item.base_content }
265
+ if shrink_sum.positive?
266
+ items.each do |item|
267
+ scaled = item.box.flex[:shrink] * item.base_content
268
+ item.target_content =
269
+ [item.base_content + (free * scaled / shrink_sum), 0].max
270
+ end
271
+ end
272
+ end
273
+ end
274
+
275
+ def layout_child(child, width:, height:)
276
+ child.explicit_width = true
277
+ child.width = [width, 0].max
278
+
279
+ if height
280
+ child.explicit_height = true
281
+ child.height = [height, 0].max
282
+ end
283
+
284
+ BlockLayout.layout(child, @context, 0, 0, width)
285
+ end
286
+
287
+ def row_base_content(child)
288
+ basis = basis_px(child)
289
+ return basis if basis
290
+ return child.width if child.explicit_width
291
+
292
+ intrinsic_width(child)
293
+ end
294
+
295
+ def column_base_content(child)
296
+ basis = basis_px(child)
297
+ return basis if basis
298
+ return child.height if child.explicit_height
299
+
300
+ layout_child(child, width: column_child_width(child), height: nil)
301
+ [child.height - vertical_box_edges(child), 0].max
302
+ end
303
+
304
+ def column_child_width(child)
305
+ if align_items == "stretch" && !child.explicit_width
306
+ [@content_width - horizontal_edges(child), 0].max
307
+ elsif child.explicit_width
308
+ child.width
309
+ else
310
+ intrinsic_width(child)
311
+ end
312
+ end
313
+
314
+ def basis_px(child)
315
+ basis = child.flex[:basis]
316
+ return nil if basis.nil? || basis == "auto"
317
+
318
+ px(basis)
319
+ end
320
+
321
+ def intrinsic_width(box)
322
+ return measure_text(box) if box.is_a?(TextBox)
323
+ return box.width if box.explicit_width
324
+
325
+ child_widths = box.children.map { |child| intrinsic_width(child) }
326
+ box.is_a?(InlineBox) ? child_widths.sum : (child_widths.max || 0)
327
+ end
328
+
329
+ def measure_text(box)
330
+ SilkLayout::Render::FontLibrary.measure_text(
331
+ box.text,
332
+ font_size: box.font_size,
333
+ font_family: box.font_family,
334
+ font_weight: box.font_weight,
335
+ font_style: box.font_style
336
+ )
337
+ end
338
+
339
+ def shrink_to_row_width(lines)
340
+ lines.map(&:main_size).max || 0
341
+ end
342
+
343
+ def inline_flex_without_width?
344
+ @box.display == "inline-flex" && !@box.explicit_width
345
+ end
346
+
347
+ def column?
348
+ %w[column column-reverse].include?(direction)
349
+ end
350
+
351
+ def wrap?
352
+ %w[wrap wrap-reverse].include?(@box.flex[:wrap])
353
+ end
354
+
355
+ def direction
356
+ @box.flex[:direction]
357
+ end
358
+
359
+ def align_items
360
+ @box.flex[:align_items]
361
+ end
362
+
363
+ def row_gap
364
+ @box.flex[:row_gap]
365
+ end
366
+
367
+ def column_gap
368
+ @box.flex[:column_gap]
369
+ end
370
+
371
+ def justify_content
372
+ @box.flex[:justify_content]
373
+ end
374
+
375
+ def justify_offset(line)
376
+ free = [@content_width - line.main_size, 0].max
377
+
378
+ case justify_content
379
+ when "flex-end", "end", "right"
380
+ free
381
+ when "center"
382
+ free / 2.0
383
+ when "space-around"
384
+ line.items.empty? ? 0 : free / line.items.length / 2.0
385
+ when "space-evenly"
386
+ line.items.empty? ? 0 : free / (line.items.length + 1)
387
+ else
388
+ 0
389
+ end
390
+ end
391
+
392
+ def justified_gap(line)
393
+ free = [@content_width - line.main_size, 0].max
394
+ base = column_gap
395
+
396
+ case justify_content
397
+ when "space-between"
398
+ (line.items.length > 1) ? base + (free / (line.items.length - 1)) : base
399
+ when "space-around"
400
+ line.items.any? ? base + (free / line.items.length) : base
401
+ when "space-evenly"
402
+ line.items.any? ? base + (free / (line.items.length + 1)) : base
403
+ else
404
+ base
405
+ end
406
+ end
407
+
408
+ def align_offset(child, line_cross_size)
409
+ free = [line_cross_size - child.height - vertical_margins(child), 0].max
410
+
411
+ case align_items
412
+ when "flex-end", "end", "bottom"
413
+ free
414
+ when "center"
415
+ free / 2.0
416
+ else
417
+ 0
418
+ end
419
+ end
420
+
421
+ def column_justify_offset(items)
422
+ free = [@content_height - column_main_size(items), 0].max
423
+
424
+ case justify_content
425
+ when "flex-end", "end", "bottom"
426
+ free
427
+ when "center"
428
+ free / 2.0
429
+ when "space-around"
430
+ items.empty? ? 0 : free / items.length / 2.0
431
+ when "space-evenly"
432
+ items.empty? ? 0 : free / (items.length + 1)
433
+ else
434
+ 0
435
+ end
436
+ end
437
+
438
+ def column_justified_gap(items)
439
+ free = [@content_height - column_main_size(items), 0].max
440
+ base = row_gap
441
+
442
+ case justify_content
443
+ when "space-between"
444
+ (items.length > 1) ? base + (free / (items.length - 1)) : base
445
+ when "space-around"
446
+ items.any? ? base + (free / items.length) : base
447
+ when "space-evenly"
448
+ items.any? ? base + (free / (items.length + 1)) : base
449
+ else
450
+ base
451
+ end
452
+ end
453
+
454
+ def column_main_size(items)
455
+ items.sum { |item| item.target_content + vertical_edges(item.box) } +
456
+ row_gap * [items.length - 1, 0].max
457
+ end
458
+
459
+ def column_align_offset(child)
460
+ free = [@content_width - child.width - horizontal_margins(child), 0].max
461
+
462
+ case align_items
463
+ when "flex-end", "end", "right"
464
+ free
465
+ when "center"
466
+ free / 2.0
467
+ else
468
+ 0
469
+ end
470
+ end
471
+
472
+ def move_subtree(box, dx, dy)
473
+ box.x += dx
474
+ box.y += dy
475
+ box.children.each { |child| move_subtree(child, dx, dy) }
476
+ end
477
+
478
+ def horizontal_edges(box)
479
+ horizontal_margins(box) + box.padding[:left] + box.padding[:right] + box.border[:left] + box.border[:right]
480
+ end
481
+
482
+ def vertical_edges(box)
483
+ vertical_margins(box) + box.padding[:top] + box.padding[:bottom] + box.border[:top] + box.border[:bottom]
484
+ end
485
+
486
+ def vertical_box_edges(box)
487
+ box.padding[:top] + box.padding[:bottom] + box.border[:top] + box.border[:bottom]
488
+ end
489
+
490
+ def horizontal_margins(box)
491
+ box.margin[:left] + box.margin[:right]
492
+ end
493
+
494
+ def vertical_margins(box)
495
+ box.margin[:top] + box.margin[:bottom]
496
+ end
497
+
498
+ def px(value)
499
+ return 0 unless value
500
+
501
+ raw = value.to_s.strip
502
+ return 0 if raw.empty? || raw == "auto"
503
+
504
+ raw.delete_suffix("px").to_f
505
+ end
506
+ end
507
+ end
508
+ end