1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT
//! Box layout manager — arranges items in a single row or column.
use super::{Layout, LayoutConstraints, LayoutContext, Orientation, SizePolicy};
use crate::compat::{Any, Vec};
use crate::core::{ObjectId, Rect};
#[derive(Debug)]
struct BoxLayoutItem {
widget_id: Option<ObjectId>,
stretch: u32,
constraints: LayoutConstraints,
policy: SizePolicy,
}
/// Linear layout that arranges items in one direction.
#[derive(Debug)]
pub struct BoxLayout {
orientation: Orientation,
spacing: u32,
margin: u32,
items: Vec<BoxLayoutItem>,
}
impl BoxLayout {
/// Create a box layout with orientation, spacing and margin.
pub fn new(orientation: Orientation, spacing: u32, margin: u32) -> Self {
Self { orientation, spacing, margin, items: Vec::new() }
}
/// Returns layout orientation.
pub fn orientation(&self) -> Orientation {
self.orientation
}
/// Returns inter-item spacing.
pub fn spacing(&self) -> u32 {
self.spacing
}
/// Updates inter-item spacing.
pub fn set_spacing(&mut self, spacing: u32) {
self.spacing = spacing;
}
/// Returns outer margin.
pub fn margin(&self) -> u32 {
self.margin
}
/// Updates outer margin.
pub fn set_margin(&mut self, margin: u32) {
self.margin = margin;
}
/// Returns number of managed items (widgets + spacers).
pub fn item_count(&self) -> usize {
self.items.len()
}
/// Adds an empty spacer item with the provided stretch factor.
pub fn add_spacer(&mut self, stretch: u32) {
self.items.push(BoxLayoutItem {
widget_id: None,
stretch: stretch.max(1),
constraints: LayoutConstraints::new(0, None),
policy: SizePolicy::Expanding,
});
}
/// Sets size constraints for an existing widget item.
pub fn set_constraints(&mut self, widget_id: ObjectId, constraints: LayoutConstraints) {
if let Some(item) = self.items.iter_mut().find(|item| item.widget_id == Some(widget_id)) {
item.constraints = constraints;
}
}
/// Sets size policy for an existing widget item.
pub fn set_size_policy(&mut self, widget_id: ObjectId, policy: SizePolicy) {
if let Some(item) = self.items.iter_mut().find(|item| item.widget_id == Some(widget_id)) {
item.policy = policy;
}
}
/// Splits `primary` pixels across the items, honouring each item's constraints.
///
/// # The two invariants this must not break
///
/// 1. `sum(assigned) <= primary` — children that together need more than the parent
/// must not be placed partly outside it. Overflow here is visible as a control
/// painted over its neighbour, and it is reachable from the public
/// `set_constraints` API, so it cannot be left to the caller to avoid.
/// 2. Each item's `min` is honoured *when the space can satisfy all of them*. When it
/// cannot — two 80px minima in a 100px row — no assignment satisfies both, so the
/// shortfall is distributed proportionally to the minima instead of being applied
/// inconsistently (the previous single-pass shrink loop reduced some items below
/// their minimum while leaving others at it, so the result depended on item order).
fn allocate_major_lengths(&self, primary: u32) -> Vec<u32> {
if self.items.is_empty() {
return Vec::new();
}
let total_stretch: u32 = self.items.iter().map(|item| item.stretch).sum::<u32>().max(1);
let mut assigned = Vec::with_capacity(self.items.len());
for item in &self.items {
let mut major = if item.policy == SizePolicy::Fixed {
item.constraints.max.unwrap_or(item.constraints.min)
} else {
primary.saturating_mul(item.stretch) / total_stretch
};
major = major.max(item.constraints.min);
if let Some(max) = item.constraints.max {
major = major.min(max.max(item.constraints.min));
}
assigned.push(major);
}
// `min` is a hard floor only while the parent can pay for every floor. When the
// floors alone exceed `primary`, they are scaled down proportionally: every item
// then falls short by the same fraction, which is the only order-independent
// answer, and invariant 1 is restored before the grow/shrink passes run.
let total_min: u32 = self.items.iter().map(|item| item.constraints.min).sum();
if total_min > primary {
let budget = primary;
let mut scaled = Vec::with_capacity(self.items.len());
let mut consumed = 0u32;
for (index, item) in self.items.iter().enumerate() {
// The last item takes the remainder rather than its own rounded share, so
// the pieces always add up to exactly `budget`.
let share = if index + 1 == self.items.len() {
budget.saturating_sub(consumed)
} else {
(budget.saturating_mul(item.constraints.min) / total_min.max(1))
.min(budget.saturating_sub(consumed))
};
consumed = consumed.saturating_add(share);
scaled.push(share);
}
return scaled;
}
let mut total_assigned: u32 = assigned.iter().sum();
while total_assigned < primary {
let mut grew = false;
for (index, item) in self.items.iter().enumerate() {
if total_assigned >= primary {
break;
}
let max_allowed =
item.constraints.max.unwrap_or(u32::MAX).max(item.constraints.min);
if assigned[index] < max_allowed {
assigned[index] = assigned[index].saturating_add(1);
total_assigned = total_assigned.saturating_add(1);
grew = true;
}
}
if !grew {
break;
}
}
while total_assigned > primary {
let mut shrank = false;
for (index, _item) in self.items.iter().enumerate().rev() {
if total_assigned <= primary {
break;
}
let min_allowed = self.items[index].constraints.min;
if assigned[index] > min_allowed {
assigned[index] = assigned[index].saturating_sub(1);
total_assigned = total_assigned.saturating_sub(1);
shrank = true;
}
}
if !shrank {
// Nothing is above its minimum and the total is still too large, which
// can now only happen if a `max` below the summed minima was pinned above
// its own minimum. Reducing from the largest allocation keeps the sum
// inside `primary` instead of returning an overflowing vector.
let Some((largest_index, _)) = assigned
.iter()
.enumerate()
.filter(|(_, value)| **value > 0)
.max_by_key(|(_, value)| **value)
else {
break;
};
assigned[largest_index] = assigned[largest_index].saturating_sub(1);
total_assigned = total_assigned.saturating_sub(1);
}
}
assigned
}
}
impl Layout for BoxLayout {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn update_with_context(
&self,
rect: Rect,
context: &LayoutContext,
widgets: &mut dyn FnMut(ObjectId, Rect),
) {
if self.items.is_empty() {
return;
}
// Spacing follows the **larger** of the layout scale and the text scale.
//
// `LayoutContext::font_scale` is the device's text-size preference, and the two are
// separate facts: a HiDPI screen needs more logical spacing, and a device whose text is set
// larger needs more room between controls even at the same DPI. Taking the maximum is the
// conservative reading — a control whose font grew but whose padding did not would have its
// text touching its own border, which is the defect the field exists to let a layout avoid.
//
// The field had no reader at all before this, so a 2x text preference grew the glyphs (via
// the theme's font token) and left every gap at its nominal size.
let scale = context.layout_scale.max(context.font_scale);
let scaled_spacing = (self.spacing as f32 * scale).round() as u32;
let scaled_margin = (self.margin as f32 * scale).round() as u32;
let gaps = (self.items.len().saturating_sub(1)) as u32;
let primary = match self.orientation {
Orientation::Horizontal => rect.width,
Orientation::Vertical => rect.height,
}
.saturating_sub(scaled_margin * 2)
.saturating_sub(gaps * scaled_spacing);
let majors = self.allocate_major_lengths(primary);
let mut cursor_x = rect.x + scaled_margin as i32;
let mut cursor_y = rect.y + scaled_margin as i32;
for (index, item) in self.items.iter().enumerate() {
let major = majors.get(index).copied().unwrap_or(0);
let child_rect = match self.orientation {
Orientation::Horizontal => Rect::new(
cursor_x,
cursor_y,
major,
rect.height.saturating_sub(scaled_margin * 2),
),
Orientation::Vertical => Rect::new(
cursor_x,
cursor_y,
rect.width.saturating_sub(scaled_margin * 2),
major,
),
};
if let Some(widget_id) = item.widget_id {
// Grown to the device class's minimum touch area, as the flex layout does — the two
// must agree or the same controls would be addressable in one container and not the
// other. The cursor advances by the *allocated* major length either way, so growing
// a child cannot push its siblings around.
widgets(
widget_id,
crate::layout::types::grow_to_min_touch_size(
child_rect,
context.min_touch_size,
),
);
}
match self.orientation {
Orientation::Horizontal => cursor_x += (major + scaled_spacing) as i32,
Orientation::Vertical => cursor_y += (major + scaled_spacing) as i32,
}
}
}
fn as_any(&self) -> &dyn Any {
self
}
fn child_ids(&self) -> Vec<ObjectId> {
self.items.iter().filter_map(|item| item.widget_id).collect()
}
fn has_child(&self, id: ObjectId) -> bool {
self.items.iter().any(|item| item.widget_id == Some(id))
}
fn clear(&mut self) {
self.items.clear();
}
fn add_widget(&mut self, widget_id: ObjectId, stretch: u32) {
self.items.push(BoxLayoutItem {
widget_id: Some(widget_id),
stretch: stretch.max(1),
constraints: LayoutConstraints::new(0, None),
policy: SizePolicy::Expanding,
});
}
fn remove_widget(&mut self, widget_id: ObjectId) {
self.items.retain(|item| item.widget_id != Some(widget_id));
}
fn update(&self, rect: Rect, widgets: &mut dyn FnMut(ObjectId, Rect)) {
if self.items.is_empty() {
return;
}
let gaps = (self.items.len().saturating_sub(1)) as u32;
let primary = match self.orientation {
Orientation::Horizontal => rect.width,
Orientation::Vertical => rect.height,
}
.saturating_sub(self.margin * 2)
.saturating_sub(gaps * self.spacing);
let majors = self.allocate_major_lengths(primary);
let mut cursor_x = rect.x + self.margin as i32;
let mut cursor_y = rect.y + self.margin as i32;
for (index, item) in self.items.iter().enumerate() {
let major = majors.get(index).copied().unwrap_or(0);
let child_rect = match self.orientation {
Orientation::Horizontal => Rect::new(
cursor_x,
cursor_y,
major,
rect.height.saturating_sub(self.margin * 2),
),
Orientation::Vertical => {
Rect::new(cursor_x, cursor_y, rect.width.saturating_sub(self.margin * 2), major)
}
};
if let Some(widget_id) = item.widget_id {
widgets(widget_id, child_rect);
}
match self.orientation {
Orientation::Horizontal => cursor_x += (major + self.spacing) as i32,
Orientation::Vertical => cursor_y += (major + self.spacing) as i32,
}
}
}
}