truce_gui_types/layout.rs
1//! Simple layout helpers for positioning widgets.
2
3// ---------------------------------------------------------------------------
4// Rows-layout shared constants
5// ---------------------------------------------------------------------------
6//
7// Coordinates the rows-layout uses to step through `Row`s. `widgets::draw_rows`
8// (paint side) and `interaction::build_regions` (hit-test side) walk the
9// rows in lock-step, so they have to agree on these step sizes - drift
10// would make hover / drag rectangles miss the painted widget.
11
12/// Pixel height of the title-bar header `widgets::draw_header` paints
13/// at the top of the editor.
14pub const HEADER_HEIGHT: f32 = 20.0;
15
16/// Y-offset of the first row below the header. The 4-pixel gap between
17/// `HEADER_HEIGHT` and `ROWS_LAYOUT_TOP` is the breathing room between
18/// the title bar and the first row of widgets.
19pub const ROWS_LAYOUT_TOP: f32 = 24.0;
20
21/// Vertical pixels reserved for a section label (`Row::label`) drawn
22/// above its row.
23pub const ROWS_SECTION_LABEL_HEIGHT: f32 = 14.0;
24
25/// Horizontal gap between adjacent widgets in a row. The full pitch
26/// between widget origins is `knob_size + ROWS_COLUMN_GAP`.
27pub const ROWS_COLUMN_GAP: f32 = 7.0;
28
29/// Vertical gap below a row. The full pitch between row origins is
30/// `knob_size + ROWS_ROW_GAP`.
31pub const ROWS_ROW_GAP: f32 = 19.0;
32
33// ---------------------------------------------------------------------------
34// Dropdown widget shared constants
35// ---------------------------------------------------------------------------
36
37/// Pixel height of the dropdown button box (the closed state - clicking
38/// this opens the popup). Both `widgets::draw_dropdown` (paint side) and
39/// `interaction::open_dropdown` (popup-anchor math) need to agree.
40pub const DROPDOWN_BOX_HEIGHT: f32 = 20.0;
41
42use truce_core::cast::len_u32;
43
44/// A widget definition for the layout - either explicit type or auto-detected.
45#[derive(Clone, Debug)]
46pub struct KnobDef {
47 pub param_id: u32,
48 pub label: &'static str,
49 /// Explicit widget type override. None = auto-detect from param range.
50 pub widget: Option<WidgetKind>,
51 /// How many grid columns this widget spans. Default = 1.
52 pub span: u32,
53 /// Second parameter ID for XY pad (Y axis). Ignored for other widgets.
54 pub param_id_y: Option<u32>,
55 /// Multiple meter IDs for multi-channel level meter. Ignored for other widgets.
56 pub meter_ids: Option<Vec<u32>>,
57}
58
59/// Explicit widget type for layout overrides.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum WidgetKind {
62 Knob,
63 Slider,
64 Toggle,
65 /// Dropdown list - click to open a popup showing all options.
66 Dropdown,
67 /// Level meter. Shows one bar per meter ID. Supports mono, stereo, or multi-channel.
68 Meter,
69 /// XY pad. Controls two params - X param stored in `param_id`, Y param in `xy_param_y`.
70 XYPad,
71}
72
73impl KnobDef {
74 /// Knob (default for continuous params, auto-detected anyway).
75 pub fn knob(param_id: impl Into<u32>, label: &'static str) -> Self {
76 Self {
77 param_id: param_id.into(),
78 label,
79 widget: Some(WidgetKind::Knob),
80 span: 1,
81 param_id_y: None,
82 meter_ids: None,
83 }
84 }
85
86 /// Horizontal slider.
87 pub fn slider(param_id: impl Into<u32>, label: &'static str) -> Self {
88 Self {
89 param_id: param_id.into(),
90 label,
91 widget: Some(WidgetKind::Slider),
92 span: 1,
93 param_id_y: None,
94 meter_ids: None,
95 }
96 }
97
98 /// Toggle button.
99 pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> Self {
100 Self {
101 param_id: param_id.into(),
102 label,
103 widget: Some(WidgetKind::Toggle),
104 span: 1,
105 param_id_y: None,
106 meter_ids: None,
107 }
108 }
109
110 /// Dropdown list (click to open a popup showing all options).
111 pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> Self {
112 Self {
113 param_id: param_id.into(),
114 label,
115 widget: Some(WidgetKind::Dropdown),
116 span: 1,
117 param_id_y: None,
118 meter_ids: None,
119 }
120 }
121
122 /// Level meter with one or more channels (display-only, reads from `Plugin::get_meter()`).
123 #[must_use]
124 pub fn meter(ids: &[u32], label: &'static str) -> Self {
125 Self {
126 param_id: ids.first().copied().unwrap_or(0),
127 label,
128 widget: Some(WidgetKind::Meter),
129 span: 1,
130 param_id_y: None,
131 meter_ids: Some(ids.to_vec()),
132 }
133 }
134
135 /// XY pad controlling two parameters.
136 pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> Self {
137 Self {
138 param_id: param_x.into(),
139 label,
140 widget: Some(WidgetKind::XYPad),
141 span: 2,
142 param_id_y: Some(param_y.into()),
143 meter_ids: None,
144 }
145 }
146
147 /// Set the column span for this widget (default 1).
148 #[must_use]
149 pub fn with_span(mut self, span: u32) -> Self {
150 self.span = span;
151 self
152 }
153}
154
155/// A row of widgets with an optional section label.
156#[derive(Clone, Debug)]
157pub struct KnobRow {
158 pub label: Option<&'static str>,
159 pub knobs: Vec<KnobDef>,
160}
161
162/// Layout configuration for a plugin UI.
163#[derive(Clone, Debug)]
164pub struct PluginLayout {
165 pub titles: HeaderTitles,
166 pub rows: Vec<KnobRow>,
167 pub width: u32,
168 pub height: u32,
169 pub knob_size: f32,
170}
171
172impl PluginLayout {
173 /// Calculate default window size based on the layout.
174 // Window dimensions in logical pixels stay well below 2^23, so the
175 // f32 ↔ u32 narrowings are invisible in practice.
176 #[allow(
177 clippy::cast_possible_truncation,
178 clippy::cast_sign_loss,
179 clippy::cast_precision_loss
180 )]
181 #[must_use]
182 pub fn compute_size(rows: &[KnobRow], knob_size: f32, titles: &HeaderTitles) -> (u32, u32) {
183 let header_h = if titles.is_empty() { 0.0 } else { 21.0 };
184 let row_h = knob_size + 19.0;
185 let section_label_h = 14.0;
186 let padding = 7.0;
187
188 let max_knobs = rows
189 .iter()
190 .map(|r| {
191 r.knobs
192 .iter()
193 .map(|k| k.span.max(1) as usize)
194 .sum::<usize>()
195 })
196 .max()
197 .unwrap_or(1);
198 let w = max_knobs as f32 * (knob_size + 7.0) + 13.0;
199
200 let mut h = header_h + padding;
201 for row in rows {
202 if row.label.is_some() {
203 h += section_label_h;
204 }
205 h += row_h + padding;
206 }
207
208 (w as u32, h as u32)
209 }
210
211 /// Build a Rows-style layout with the given header titles.
212 /// Either or both [`HeaderTitles`] slots can be empty (use
213 /// [`HeaderTitles::none`] for a layout with no header band).
214 #[must_use]
215 pub fn build(titles: HeaderTitles, rows: Vec<KnobRow>, knob_size: f32) -> Self {
216 let (w, h) = Self::compute_size(&rows, knob_size, &titles);
217 Self {
218 titles,
219 rows,
220 width: w,
221 height: h,
222 knob_size,
223 }
224 }
225}
226
227// ---------------------------------------------------------------------------
228// Grid Layout
229// ---------------------------------------------------------------------------
230
231/// Sentinel value for auto-placed grid widgets.
232pub const AUTO: u32 = u32::MAX;
233
234// Grid spacing constants. All dimensions in this module are in logical
235// points - the rendering backend (`CpuBackend` / `WgpuBackend`)
236// multiplies by the display scale factor at raster time.
237pub const GRID_GAP: f32 = 19.0;
238pub const GRID_PADDING: f32 = 10.0;
239pub const GRID_HEADER_H: f32 = 21.0;
240pub const GRID_SECTION_H: f32 = 14.0;
241
242/// Slack added before flooring a requested size to a whole cell count
243/// in `refit_cols` / `refit_rows`. Large enough to absorb float error
244/// on an exactly cell-aligned request (so it doesn't drop a column),
245/// far smaller than one cell so it never rounds a partial cell up.
246const SNAP_EPS: f32 = 1e-3;
247
248/// A widget placed in a grid layout.
249#[derive(Clone, Debug)]
250pub struct GridWidget {
251 /// Grid column (0-indexed, or AUTO for auto-flow).
252 pub col: u32,
253 /// Grid row (0-indexed, or AUTO for auto-flow).
254 pub row: u32,
255 /// Columns spanned (default 1).
256 pub col_span: u32,
257 /// Rows spanned (default 1).
258 pub row_span: u32,
259 /// Parameter ID (or first meter ID for meters).
260 pub param_id: u32,
261 /// Display label.
262 pub label: &'static str,
263 /// Widget type override. None = auto-detect from param range.
264 pub widget: Option<WidgetKind>,
265 /// Second param for XY pad (Y axis).
266 pub param_id_y: Option<u32>,
267 /// Multiple meter IDs for multi-channel level meter.
268 pub meter_ids: Option<Vec<u32>>,
269}
270
271impl GridWidget {
272 pub fn knob(param_id: impl Into<u32>, label: &'static str) -> Self {
273 Self {
274 col: AUTO,
275 row: AUTO,
276 col_span: 1,
277 row_span: 1,
278 param_id: param_id.into(),
279 label,
280 widget: Some(WidgetKind::Knob),
281 param_id_y: None,
282 meter_ids: None,
283 }
284 }
285
286 pub fn slider(param_id: impl Into<u32>, label: &'static str) -> Self {
287 Self {
288 col: AUTO,
289 row: AUTO,
290 col_span: 1,
291 row_span: 1,
292 param_id: param_id.into(),
293 label,
294 widget: Some(WidgetKind::Slider),
295 param_id_y: None,
296 meter_ids: None,
297 }
298 }
299
300 pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> Self {
301 Self {
302 col: AUTO,
303 row: AUTO,
304 col_span: 1,
305 row_span: 1,
306 param_id: param_id.into(),
307 label,
308 widget: Some(WidgetKind::Toggle),
309 param_id_y: None,
310 meter_ids: None,
311 }
312 }
313
314 pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> Self {
315 Self {
316 col: AUTO,
317 row: AUTO,
318 col_span: 1,
319 row_span: 1,
320 param_id: param_id.into(),
321 label,
322 widget: Some(WidgetKind::Dropdown),
323 param_id_y: None,
324 meter_ids: None,
325 }
326 }
327
328 #[must_use]
329 pub fn meter(ids: &[u32], label: &'static str) -> Self {
330 Self {
331 col: AUTO,
332 row: AUTO,
333 col_span: 1,
334 row_span: 1,
335 param_id: ids.first().copied().unwrap_or(0),
336 label,
337 widget: Some(WidgetKind::Meter),
338 param_id_y: None,
339 meter_ids: Some(ids.to_vec()),
340 }
341 }
342
343 pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> Self {
344 Self {
345 col: AUTO,
346 row: AUTO,
347 col_span: 2,
348 row_span: 2,
349 param_id: param_x.into(),
350 label,
351 widget: Some(WidgetKind::XYPad),
352 param_id_y: Some(param_y.into()),
353 meter_ids: None,
354 }
355 }
356
357 /// Set the column span.
358 #[must_use]
359 pub fn cols(mut self, n: u32) -> Self {
360 self.col_span = n;
361 self
362 }
363
364 /// Set the row span.
365 #[must_use]
366 pub fn rows(mut self, n: u32) -> Self {
367 self.row_span = n;
368 self
369 }
370
371 /// Set explicit grid position (overrides auto-flow for this widget).
372 #[must_use]
373 pub fn at(mut self, col: u32, row: u32) -> Self {
374 self.col = col;
375 self.row = row;
376 self
377 }
378}
379
380/// A group of widgets with an optional section label.
381///
382/// Used as input to `GridLayout::build()`. Bare `GridWidget`s convert into
383/// ungrouped sections via `From`, so orphan widgets only need `.into()`.
384#[derive(Clone, Debug)]
385pub struct Section {
386 pub label: Option<&'static str>,
387 pub widgets: Vec<GridWidget>,
388}
389
390/// Create a labeled section of widgets for `GridLayout::build()`.
391#[must_use]
392pub fn section(label: &'static str, widgets: Vec<GridWidget>) -> Section {
393 Section {
394 label: Some(label),
395 widgets,
396 }
397}
398
399/// Wrap bare widgets into an unlabeled section (no section header).
400#[must_use]
401pub fn widgets(widgets: Vec<GridWidget>) -> Section {
402 Section {
403 label: None,
404 widgets,
405 }
406}
407
408// -- Short constructors for GridWidget (free functions) --
409
410/// Rotary knob widget.
411pub fn knob(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
412 GridWidget::knob(param_id, label)
413}
414
415/// Horizontal slider widget.
416pub fn slider(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
417 GridWidget::slider(param_id, label)
418}
419
420/// Toggle switch widget.
421pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
422 GridWidget::toggle(param_id, label)
423}
424
425/// Dropdown list widget.
426pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
427 GridWidget::dropdown(param_id, label)
428}
429
430/// Level meter widget.
431pub fn meter<I: Into<u32> + Copy>(ids: &[I], label: &'static str) -> GridWidget {
432 let u32_ids: Vec<u32> = ids.iter().map(|id| (*id).into()).collect();
433 GridWidget::meter(&u32_ids, label)
434}
435
436/// XY pad controlling two parameters.
437pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> GridWidget {
438 GridWidget::xy_pad(param_x, param_y, label)
439}
440
441impl From<GridWidget> for Section {
442 fn from(w: GridWidget) -> Self {
443 Section {
444 label: None,
445 widgets: vec![w],
446 }
447 }
448}
449
450/// Title band drawn above a layout. The `title` slot renders
451/// larger / brighter on the left of the band; the `subtitle` slot
452/// renders smaller / dimmer on the right. Each slot is independently
453/// optional - set either, both, or neither.
454///
455/// Use [`HeaderTitles::title`] / [`HeaderTitles::subtitle`] /
456/// [`HeaderTitles::pair`] for the common cases; build the struct
457/// directly only when you want to set a non-default combination
458/// (e.g. via `..` syntax over an existing instance).
459#[derive(Clone, Debug, Default)]
460pub struct HeaderTitles {
461 pub title: Option<&'static str>,
462 pub subtitle: Option<&'static str>,
463}
464
465impl HeaderTitles {
466 /// Both slots empty - no header band is drawn.
467 #[must_use]
468 pub const fn none() -> Self {
469 Self {
470 title: None,
471 subtitle: None,
472 }
473 }
474
475 /// Title only; subtitle slot stays empty.
476 #[must_use]
477 pub const fn title(s: &'static str) -> Self {
478 Self {
479 title: Some(s),
480 subtitle: None,
481 }
482 }
483
484 /// Subtitle only; title slot stays empty.
485 #[must_use]
486 pub const fn subtitle(s: &'static str) -> Self {
487 Self {
488 title: None,
489 subtitle: Some(s),
490 }
491 }
492
493 /// Both slots set.
494 #[must_use]
495 pub const fn pair(title: &'static str, subtitle: &'static str) -> Self {
496 Self {
497 title: Some(title),
498 subtitle: Some(subtitle),
499 }
500 }
501
502 /// `true` when neither slot is set - caller should skip the
503 /// header band entirely.
504 #[must_use]
505 pub const fn is_empty(&self) -> bool {
506 self.title.is_none() && self.subtitle.is_none()
507 }
508}
509
510/// Grid-based layout for a plugin UI.
511#[derive(Clone, Debug)]
512pub struct GridLayout {
513 /// Header band titles. Both slots default to `None`, in which
514 /// case no header is drawn and the grid starts at `y = 0`
515 /// (plus padding).
516 pub titles: HeaderTitles,
517 /// Number of columns in the grid.
518 pub cols: u32,
519 /// Section labels positioned above specific rows: (`row_index`, label).
520 pub sections: Vec<(u32, &'static str)>,
521 /// All widgets placed in the grid.
522 pub widgets: Vec<GridWidget>,
523 /// Cell size in logical points (width and height of one grid cell).
524 pub cell_size: f32,
525 /// Computed width in logical points.
526 pub width: u32,
527 /// Computed height in logical points.
528 pub height: u32,
529 /// Pre-flow widget snapshot - copy of `widgets` before
530 /// `auto_flow_with_breaks` ran. Lets [`Self::with_cols`] reset
531 /// and re-flow against a different column count without
532 /// losing AUTO-vs-explicit placement.
533 original_widgets: Vec<GridWidget>,
534 /// Pre-flow section breaks - `(widget_index, label)` pairs as
535 /// passed to `auto_flow_with_breaks` originally. Stored so
536 /// re-flow recovers the same section labels.
537 original_breaks: Vec<(usize, &'static str)>,
538 /// Whether the host is allowed to drive a resize. Editors honour
539 /// this via `Editor::can_resize`; `false` (default) keeps the
540 /// layout at its built `cols` for fixed-size plugins.
541 pub resizable: bool,
542 /// Whether the standalone host may maximize the window. Editors
543 /// honour this via `Editor::can_maximize`; `false` (default)
544 /// removes the maximize affordance so maximizing can't grow the
545 /// window past the grid's `max_size` into an empty margin. Only
546 /// meaningful for `resizable` layouts - a fixed-size layout is
547 /// pinned regardless. Opt in with `.maximizable(true)` for layouts
548 /// that render correctly at any size.
549 pub maximizable: bool,
550 /// Lower clamp on host-driven resize requests, as `(cols, rows)`
551 /// cell counts. Surfaced via `Editor::min_size` (converted to
552 /// logical points by `compute_size` at the requested cell
553 /// extent). Defaults to `(1, 1)`.
554 ///
555 /// The call shape (`.min_size((a, b))`) mirrors `truce-egui` /
556 /// `truce-iced` / `truce-slint` / `truce-vizia` so cross-backend
557 /// `editor()` impls stay symmetric; the *unit* is different
558 /// because the grid is fundamentally cell-snapped (pixels
559 /// without a cell boundary would just be rounded to one anyway).
560 pub min_size: (u32, u32),
561 /// Upper clamp on host-driven resize requests, as `(cols, rows)`
562 /// cell counts. Defaults to `(u32::MAX, u32::MAX)` - effectively
563 /// unbounded. Same units + call-shape contract as
564 /// [`Self::min_size`].
565 pub max_size: (u32, u32),
566 /// Declared row extent. `compute_size` takes the larger of
567 /// this and the widgets' rightmost row edge, the same way
568 /// `cols` works on the width axis - so `refit_rows` can grow
569 /// the grid past the rightmost widget's row with empty
570 /// trailing space.
571 pub rows: u32,
572}
573
574/// Default cell size in logical points when `GridLayout::build` is
575/// called without `.with_cell_size(...)`. Matches the scaffolded
576/// plugin's pre-refactor value so untouched scaffolds render the
577/// same as before.
578pub const GRID_DEFAULT_CELL_SIZE: f32 = 50.0;
579
580impl GridLayout {
581 /// Build a grid layout from sections containing widgets. No
582 /// header is drawn, `cols` defaults to the widest section's
583 /// widget count (extended to fit any explicitly-positioned
584 /// widget), and `cell_size` defaults to
585 /// [`GRID_DEFAULT_CELL_SIZE`]. Override any of those via
586 /// [`Self::with_titles`] / [`Self::with_cols`] /
587 /// [`Self::with_cell_size`].
588 ///
589 /// Each entry is either a `Section` (created with `section("LABEL", vec![...])`)
590 /// or a bare `GridWidget` (auto-wrapped via `From`). Example:
591 ///
592 /// ```ignore
593 /// GridLayout::build(vec![
594 /// section("LOW", vec![
595 /// GridWidget::knob(P::Freq, "Freq"),
596 /// GridWidget::knob(P::Gain, "Gain"),
597 /// ]),
598 /// GridWidget::knob(P::Output, "Output").into(),
599 /// ])
600 /// ```
601 #[must_use]
602 pub fn build(entries: Vec<Section>) -> Self {
603 let mut widgets = Vec::new();
604 let mut breaks = Vec::new();
605 let mut max_widgets_per_section = 0usize;
606 for s in entries {
607 if let Some(label) = s.label {
608 breaks.push((widgets.len(), label));
609 }
610 max_widgets_per_section = max_widgets_per_section.max(s.widgets.len());
611 widgets.extend(s.widgets);
612 }
613 // Account for explicitly-positioned widgets that reach
614 // beyond the widest auto-flow row - the grid still has to
615 // be wide enough to seat them.
616 let max_explicit_col = widgets
617 .iter()
618 .filter(|w| w.col != AUTO)
619 .map(|w| w.col + w.col_span)
620 .max()
621 .unwrap_or(0);
622 let cols = len_u32(max_widgets_per_section)
623 .max(max_explicit_col)
624 .max(1);
625
626 let mut layout = Self {
627 titles: HeaderTitles::none(),
628 cols,
629 sections: Vec::new(),
630 widgets: widgets.clone(),
631 cell_size: GRID_DEFAULT_CELL_SIZE,
632 width: 0,
633 height: 0,
634 resizable: false,
635 maximizable: false,
636 min_size: (1, 1),
637 max_size: (u32::MAX, u32::MAX),
638 rows: 1,
639 original_widgets: widgets,
640 original_breaks: breaks,
641 };
642 layout.flow_and_size();
643 layout
644 }
645
646 /// Override the default column count (which is the widest
647 /// section's widget count, or whatever explicit positions
648 /// require - whichever is larger). Use to force wrapping:
649 /// `.with_cols(2)` on a 4-widget section produces a 2×2 grid.
650 /// Recomputes auto-flow placement and window size.
651 #[must_use]
652 pub fn with_cols(mut self, cols: u32) -> Self {
653 self.cols = cols.max(1);
654 self.flow_and_size();
655 self
656 }
657
658 /// Override the default cell size ([`GRID_DEFAULT_CELL_SIZE`]).
659 /// The cell is square - this is both the width and height of
660 /// one grid cell in logical points.
661 #[must_use]
662 pub fn with_cell_size(mut self, cell_size: f32) -> Self {
663 self.cell_size = cell_size;
664 let (w, h) = self.compute_size();
665 self.width = w;
666 self.height = h;
667 self
668 }
669
670 /// Like [`Self::with_cols`] but accepts the cell size in the
671 /// same call - useful when both are non-default. Equivalent to
672 /// `.with_cell_size(s).with_cols(c)`.
673 #[must_use]
674 pub fn with_grid(mut self, cols: u32, cell_size: f32) -> Self {
675 self = self.with_cell_size(cell_size);
676 self.with_cols(cols)
677 }
678
679 /// Set both header slots at once. Replaces any previously
680 /// configured titles. Recomputes the height to account for the
681 /// extra band - width stays the same since the header spans the
682 /// full grid width.
683 ///
684 /// ```ignore
685 /// use truce_gui_types::layout::{GridLayout, HeaderTitles};
686 /// GridLayout::build(sections).with_titles(HeaderTitles::pair("EQ", "v0.1"))
687 /// ```
688 #[must_use]
689 pub fn with_titles(mut self, titles: HeaderTitles) -> Self {
690 self.titles = titles;
691 let (w, h) = self.compute_size();
692 self.width = w;
693 self.height = h;
694 self
695 }
696
697 /// Set the title slot (left, larger / brighter), preserving any
698 /// previously configured subtitle.
699 ///
700 /// ```ignore
701 /// GridLayout::build(sections).with_title("EQ")
702 /// ```
703 #[must_use]
704 pub fn with_title(mut self, title: &'static str) -> Self {
705 self.titles.title = Some(title);
706 let (w, h) = self.compute_size();
707 self.width = w;
708 self.height = h;
709 self
710 }
711
712 /// Set the subtitle slot (right, smaller / dimmer), preserving
713 /// any previously configured title.
714 ///
715 /// ```ignore
716 /// GridLayout::build(sections).with_subtitle("v0.1")
717 /// ```
718 #[must_use]
719 pub fn with_subtitle(mut self, subtitle: &'static str) -> Self {
720 self.titles.subtitle = Some(subtitle);
721 let (w, h) = self.compute_size();
722 self.width = w;
723 self.height = h;
724 self
725 }
726
727 /// Opt the layout into host-driven resize. Defaults to `false`
728 /// so existing plugins stay pinned at their built column count.
729 /// When `true`, the editor honours `Editor::set_size` by snapping
730 /// the requested width to the nearest whole cell + gap and
731 /// re-flowing widgets through [`Self::refit_cols`].
732 #[must_use]
733 pub fn resizable(mut self, value: bool) -> Self {
734 self.resizable = value;
735 // `compute_size` reads `resizable` to decide whether the
736 // declared `cols`/`rows` extend the natural size past the
737 // flowed content, so refresh the cached dimensions - keeps
738 // the result independent of where `.resizable()` lands in the
739 // builder chain.
740 let (w, h) = self.compute_size();
741 self.width = w;
742 self.height = h;
743 self
744 }
745
746 /// Opt into the standalone host's maximize button. Defaults to
747 /// `false`: maximize is removed on a `resizable` layout so it can't
748 /// grow the window past the grid's `max_size` and leave an empty
749 /// margin around the clamped editor (edge-drag resize within bounds
750 /// is unaffected). Pass `true` for layouts that render correctly at
751 /// any size. Only the standalone host consults this (plugin formats
752 /// let the DAW own the window frame), and only when
753 /// `resizable(true)`.
754 #[must_use]
755 pub fn maximizable(mut self, value: bool) -> Self {
756 self.maximizable = value;
757 self
758 }
759
760 /// Lower clamp on host-driven resize requests, as
761 /// `(min_cols, min_rows)` - **cell counts, not pixels**.
762 /// Default `(1, 1)`. Set this to keep the editor wide enough
763 /// for the widest explicitly-positioned widget so column drops
764 /// don't clip content, and tall enough for the bottommost
765 /// widget's row.
766 ///
767 /// Call shape mirrors `truce-egui` / `truce-iced` /
768 /// `truce-slint` / `truce-vizia`'s `min_size` (single tuple
769 /// builder) so cross-backend `editor()` impls stay symmetric;
770 /// the unit is cells because the grid is fundamentally
771 /// cell-snapped. `Editor::min_size` reports the corresponding
772 /// pixel size so hosts still see logical-point bounds.
773 #[must_use]
774 pub fn min_size(mut self, cells: (u32, u32)) -> Self {
775 self.min_size = (cells.0.max(1), cells.1.max(1));
776 self
777 }
778
779 /// Upper clamp on host-driven resize requests, as
780 /// `(max_cols, max_rows)` - cell counts, not pixels. Default
781 /// `(u32::MAX, u32::MAX)`. Cap this when the layout looks
782 /// awkward past a certain stretch on either axis.
783 ///
784 /// Same units / call-shape contract as [`Self::min_size`].
785 #[must_use]
786 pub fn max_size(mut self, cells: (u32, u32)) -> Self {
787 self.max_size = (cells.0.max(1), cells.1.max(1));
788 self
789 }
790
791 /// Pin the natural row extent (in **cells**, not pixels). Same
792 /// role on the height axis as [`Self::with_cols`] on the width
793 /// axis: the layout's height is the larger of this and the
794 /// rightmost row edge across all widgets, so the editor reserves
795 /// N cell rows of vertical space even when the widgets don't
796 /// fill them all.
797 ///
798 /// Distinct from [`Self::min_size`] / [`Self::max_size`]
799 /// (resize *bounds* in logical points): `with_rows` declares the
800 /// *natural* row count the editor opens at, while `min_size` /
801 /// `max_size` clamp how far the host can resize away from that.
802 #[must_use]
803 pub fn with_rows(mut self, rows: u32) -> Self {
804 self.rows = rows.max(1);
805 let (w, h) = self.compute_size();
806 self.width = w;
807 self.height = h;
808 self
809 }
810
811 /// Snap `target_h` to a whole row count and refresh the cached
812 /// dimensions. Mirror of `refit_cols` on the height axis. The
813 /// row count is derived from the height left over after the
814 /// header band, sections, padding, and trailing label - so the
815 /// snap is "row-precise" (each row is exactly `cell_size + gap`
816 /// tall).
817 // Same bounded casts as `refit_cols`.
818 #[allow(
819 clippy::cast_possible_truncation,
820 clippy::cast_sign_loss,
821 clippy::cast_precision_loss
822 )]
823 pub fn refit_rows(&mut self, target_h: u32) -> (u32, u32) {
824 let step = self.cell_size + GRID_GAP;
825 if step <= 0.0 {
826 let (w, h) = self.compute_size();
827 self.width = w;
828 self.height = h;
829 return (w, h);
830 }
831 // Solve `compute_size`'s height formula for `rows`:
832 // h = header + 2*PADDING + rows*(cell_size + GAP) - GAP
833 // + section_count*GRID_SECTION_H + bottom_label_h
834 let section_count = f32::from(u16::try_from(self.sections.len()).unwrap_or(u16::MAX));
835 let bottom_label_h = 22.0;
836 let overhead = self.header_height()
837 + GRID_PADDING * 2.0
838 + section_count * GRID_SECTION_H
839 + bottom_label_h
840 - GRID_GAP;
841 let usable = (target_h as f32 - overhead).max(0.0);
842 // Floor (not round) so the snapped height never exceeds the
843 // request - see `refit_cols` for why overshoot makes the window
844 // creep in hosts that skip `gui_adjust_size`.
845 let raw = (usable / step + SNAP_EPS).floor();
846 let new_rows = (raw.max(1.0) as u32).clamp(self.min_size.1.max(1), self.max_size.1.max(1));
847 self.rows = new_rows;
848 // Auto-flow doesn't care about `rows` (it's purely a
849 // bookkeeping value for `compute_size`'s height), so we
850 // skip the full `flow_and_size` round-trip and just
851 // re-read the grid height. Report the requested height as the
852 // canvas (see `refit_cols`); preserve the canvas width the
853 // preceding `refit_cols` set rather than snapping it back to the
854 // cell-aligned grid width, and clamp to `max_size` so the editor
855 // stays self-enforcing regardless of host/WM behaviour.
856 let (_, grid_h) = self.compute_size();
857 self.height = if self.max_size.1 == u32::MAX {
858 target_h.max(grid_h)
859 } else {
860 let max_h = self.max_snapped_size().1;
861 target_h.clamp(grid_h, max_h)
862 };
863 (self.width, self.height)
864 }
865
866 /// Logical-point size of one resize step on either axis -
867 /// `cell_size + GRID_GAP`, the same `step` `refit_cols` /
868 /// `refit_rows` snap to. Both axes share it. Drives the
869 /// standalone X11 host's WM resize-increment hint so edge-drags
870 /// snap to whole cells. Floors at 1 so a degenerate step never
871 /// produces a zero increment.
872 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
873 #[must_use]
874 pub fn resize_step(&self) -> u32 {
875 (self.cell_size + GRID_GAP).round().max(1.0) as u32
876 }
877
878 /// `Editor::min_size` value: the pixel size of the layout at
879 /// the smallest allowed `(cols, rows)` extent declared by
880 /// [`Self::min_size`]. Hosts see this via the format-specific
881 /// resize-hint RPC (CLAP `gui_get_resize_hints`, VST3
882 /// `checkSizeConstraint`, etc.).
883 #[must_use]
884 pub fn min_snapped_size(&self) -> (u32, u32) {
885 let mut probe = self.clone();
886 probe.cols = self.min_size.0.max(1);
887 probe.rows = self.min_size.1.max(1);
888 probe.flow_and_size();
889 (probe.width, probe.height)
890 }
891
892 /// `Editor::max_size` value. `u32::MAX` per axis means "no
893 /// cap" and probes at 64 cells - well past any plugin window
894 /// a host would render, and small enough that the layout math
895 /// doesn't overflow.
896 #[must_use]
897 pub fn max_snapped_size(&self) -> (u32, u32) {
898 let cap = 64u32;
899 let cols = if self.max_size.0 == u32::MAX {
900 cap
901 } else {
902 self.max_size.0.max(1)
903 };
904 let rows = if self.max_size.1 == u32::MAX {
905 cap
906 } else {
907 self.max_size.1.max(1)
908 };
909 let mut probe = self.clone();
910 probe.cols = cols;
911 probe.rows = rows;
912 probe.flow_and_size();
913 (probe.width, probe.height)
914 }
915
916 /// Reflow against a column count derived from the requested
917 /// logical width: snap the width to the nearest whole
918 /// `cell_size + gap` step (so the grid always ends on a cell
919 /// boundary), clamp the result to `[min_cols, max_cols]`, and
920 /// re-run auto-flow against the new column count. Returns the
921 /// resulting `(width, height)`.
922 ///
923 /// The cell pixel size stays put — only the column count
924 /// changes, so widgets stay at their built size and just
925 /// re-pack into a wider or narrower grid. Auto-positioned
926 /// widgets reflow naturally; explicitly-positioned widgets
927 /// stay at their declared `(col, row)` (so dropping below
928 /// their column would clip them, hence the `min_cols`
929 /// safeguard).
930 ///
931 /// `target_w` is interpreted as logical points - same units
932 /// `Editor::set_size` works in.
933 // Widget grid coords stay under 1000 cells and total pixel
934 // dimensions stay under 2^23, so the casts are bounded.
935 #[allow(
936 clippy::cast_possible_truncation,
937 clippy::cast_sign_loss,
938 clippy::cast_precision_loss
939 )]
940 pub fn refit_cols(&mut self, target_w: u32) -> (u32, u32) {
941 // Solve `compute_size`'s width formula for `cols`:
942 // w = 2*PADDING + cols * (cell_size + GAP) - GAP
943 // => cols = (w + GAP - 2*PADDING) / (cell_size + GAP)
944 let step = self.cell_size + GRID_GAP;
945 if step <= 0.0 {
946 let (w, h) = self.compute_size();
947 self.width = w;
948 self.height = h;
949 return (w, h);
950 }
951 let numerator = target_w as f32 + GRID_GAP - GRID_PADDING * 2.0;
952 // Floor (not round) so the snapped width never *exceeds* the
953 // requested width. Rounding up reports a window bigger than the
954 // host asked for; hosts that skip `gui_adjust_size` and call
955 // `set_size` with raw drag dimensions (e.g. Reaper) then grow
956 // their frame to fit, which feeds an even larger drag size back
957 // in - the window creeps/grows without bound. The small epsilon
958 // absorbs float error so an exactly cell-aligned width (the
959 // standalone case, where WM resize-increment hints already
960 // deliver aligned sizes) still lands on the intended column.
961 let raw = (numerator / step + SNAP_EPS).floor();
962 let new_cols = (raw.max(1.0) as u32).clamp(self.min_size.0.max(1), self.max_size.0.max(1));
963 self.cols = new_cols;
964 self.flow_and_size();
965 // Report the *requested* width as the canvas, not the cell-aligned
966 // grid width. Flooring `cols` above guarantees the grid is never
967 // wider than the request, so the leftover (canvas - grid) is
968 // right-edge margin the renderer clears to the background. This
969 // makes the editor fill the host's window exactly (no creep, no
970 // uninitialised-pixel gap in hosts that keep their frame at an
971 // un-quantised size), while the clamp below keeps the editor
972 // self-enforcing: it never reports/fills past `max_size` even when
973 // a host or WM ignores our size hints / skips the wrapper clamp
974 // (the standalone hands raw drag sizes straight to `set_size`).
975 let grid_w = self.width; // grid at the clamped column count (>= min)
976 self.width = if self.max_size.0 == u32::MAX {
977 target_w.max(grid_w)
978 } else {
979 let max_w = self.max_snapped_size().0;
980 target_w.clamp(grid_w, max_w)
981 };
982 (self.width, self.height)
983 }
984
985 /// Pixel height of the header band, or `0.0` when neither
986 /// title slot is set. Internal helper used by `compute_size`,
987 /// `widgets::draw_grid`, and `interaction::build_regions_grid`
988 /// to keep the "is there a header?" check in one place.
989 pub(crate) fn header_height(&self) -> f32 {
990 if self.titles.is_empty() {
991 0.0
992 } else {
993 GRID_HEADER_H
994 }
995 }
996
997 /// Reset to the pre-flow widget snapshot, run `auto_flow_with_breaks`
998 /// against `self.cols`, then recompute window size. Used by
999 /// `build`, `with_cols`, and `with_cell_size` so the layout
1000 /// stays consistent after any configuration change.
1001 fn flow_and_size(&mut self) {
1002 self.widgets = self.original_widgets.clone();
1003 self.sections.clear();
1004 let breaks: Vec<(usize, &'static str)> = self.original_breaks.clone();
1005 self.auto_flow_with_breaks(&breaks);
1006 let (w, h) = self.compute_size();
1007 self.width = w;
1008 self.height = h;
1009 }
1010
1011 /// Compute the window size from the grid.
1012 // Window dimensions in logical pixels stay well below 2^23.
1013 #[allow(
1014 clippy::cast_possible_truncation,
1015 clippy::cast_sign_loss,
1016 clippy::cast_precision_loss
1017 )]
1018 #[must_use]
1019 pub fn compute_size(&self) -> (u32, u32) {
1020 // The natural size always tracks the flowed widget extent so
1021 // a fixed-size editor is exactly as wide/tall as its content.
1022 //
1023 // For *resizable* layouts we additionally let the declared
1024 // `cols`/`rows` extend the size past that extent: an
1025 // explicitly-positioned layout has a fixed rightmost edge, so
1026 // without this arm `refit_cols`/`refit_rows` would bump the
1027 // declared count but the size wouldn't actually grow and the
1028 // editor would visibly stop snapping past the last widget.
1029 //
1030 // Gating on `resizable` matters because `cols` is the widest
1031 // section's widget *count*, which overcounts columns whenever
1032 // widgets stack (explicit `.at` rows) or a spanning widget
1033 // wraps early - e.g. two knobs stacked in col 0 plus a meter
1034 // in col 1 is 3 widgets but only 2 columns. Applying the
1035 // `max` unconditionally padded every such fixed-size built-in
1036 // with a phantom trailing column.
1037 let max_widget_col = self
1038 .widgets
1039 .iter()
1040 .map(|w| w.col + w.col_span)
1041 .max()
1042 .unwrap_or(1);
1043 let max_widget_row = self
1044 .widgets
1045 .iter()
1046 .map(|w| w.row + w.row_span)
1047 .max()
1048 .unwrap_or(1);
1049 let (max_col, max_row) = if self.resizable {
1050 (max_widget_col.max(self.cols), max_widget_row.max(self.rows))
1051 } else {
1052 (max_widget_col, max_widget_row)
1053 };
1054 let section_count = self.sections.len() as f32;
1055
1056 let w = GRID_PADDING * 2.0 + max_col as f32 * (self.cell_size + GRID_GAP) - GRID_GAP;
1057 let bottom_label_h = 22.0; // label + value text below the last row of widgets
1058 let h = self.header_height() + GRID_PADDING + max_row as f32 * (self.cell_size + GRID_GAP)
1059 - GRID_GAP
1060 + section_count * GRID_SECTION_H
1061 + bottom_label_h
1062 + GRID_PADDING;
1063
1064 (w as u32, h as u32)
1065 }
1066
1067 /// Auto-flow placement with section breaks. Internal helper:
1068 /// the public builder API exposes [`Self::with_cols`] /
1069 /// [`Self::with_cell_size`] / [`Self::with_grid`] which call
1070 /// `flow_and_size` after their field mutation. Previously
1071 /// exposed as `pub` (along with a `pub auto_flow()` wrapper for
1072 /// the no-breaks case), which mixed in-place mutation into the
1073 /// chainable `mut self -> Self` builder surface - confusing.
1074 /// Now `pub(crate)`; the no-breaks wrapper is gone since
1075 /// internal callers always pass an explicit slice.
1076 ///
1077 /// Each break is `(widget_index, label)`: when the cursor reaches that
1078 /// widget index, it advances to the next row and records a section label.
1079 pub(crate) fn auto_flow_with_breaks(&mut self, breaks: &[(usize, &'static str)]) {
1080 let mut occupied = std::collections::HashSet::new();
1081 let mut cursor_col: u32 = 0;
1082 let mut cursor_row: u32 = 0;
1083 let mut any_emitted = false;
1084
1085 // First pass: mark cells occupied by explicitly-placed widgets.
1086 for w in &self.widgets {
1087 if w.col != AUTO && w.row != AUTO {
1088 for c in w.col..w.col + w.col_span {
1089 for r in w.row..w.row + w.row_span {
1090 occupied.insert((c, r));
1091 }
1092 }
1093 }
1094 }
1095
1096 // Second pass: auto-place widgets.
1097 for (i, w) in self.widgets.iter_mut().enumerate() {
1098 // Check for section breaks at this widget index. A break
1099 // advances past any previously-occupied row so a new
1100 // section starts strictly below the prior section's
1101 // tallest widget. Bumping by just 1 lets a section pack
1102 // alongside a tall widget from the prior section (the
1103 // 1x2 KTall + sliders case in the GUI zoo).
1104 for &(break_idx, label) in breaks {
1105 if break_idx == i {
1106 if any_emitted || cursor_col > 0 {
1107 let max_occupied_row = occupied.iter().map(|&(_, r)| r).max().unwrap_or(0);
1108 cursor_row = (cursor_row + 1).max(max_occupied_row + 1);
1109 cursor_col = 0;
1110 }
1111 self.sections.push((cursor_row, label));
1112 any_emitted = true;
1113 }
1114 }
1115
1116 if w.col != AUTO && w.row != AUTO {
1117 // Explicitly placed - already marked in first pass.
1118 any_emitted = true;
1119 continue;
1120 }
1121
1122 // Find next free position that fits this widget.
1123 loop {
1124 if cursor_col + w.col_span > self.cols {
1125 cursor_col = 0;
1126 cursor_row += 1;
1127 }
1128 let fits = (0..w.col_span).all(|dc| {
1129 (0..w.row_span)
1130 .all(|dr| !occupied.contains(&(cursor_col + dc, cursor_row + dr)))
1131 });
1132 if fits {
1133 break;
1134 }
1135 cursor_col += 1;
1136 }
1137
1138 w.col = cursor_col;
1139 w.row = cursor_row;
1140
1141 for c in w.col..w.col + w.col_span {
1142 for r in w.row..w.row + w.row_span {
1143 occupied.insert((c, r));
1144 }
1145 }
1146
1147 cursor_col += w.col_span;
1148 any_emitted = true;
1149 }
1150 }
1151}
1152
1153/// Compute cumulative section-label pixel offsets per row.
1154///
1155/// `offsets[r]` is the total vertical shift (from section labels) for row `r`.
1156#[must_use]
1157pub fn compute_section_offsets(layout: &GridLayout) -> Vec<f32> {
1158 // Hard cap so a degenerate widget row can't request a multi-gigabyte
1159 // allocation here and abort the process. Real plugins live well under
1160 // 64 rows.
1161 const MAX_ROW_CAP: u32 = 4096;
1162 let max_row = layout
1163 .widgets
1164 .iter()
1165 .map(|w| w.row.saturating_add(w.row_span))
1166 .max()
1167 .unwrap_or(1)
1168 .min(MAX_ROW_CAP);
1169 let mut offsets = vec![0.0f32; max_row as usize + 1];
1170 let mut cumulative = 0.0;
1171
1172 for row in 0..=max_row {
1173 if layout.sections.iter().any(|(r, _)| *r == row) {
1174 cumulative += GRID_SECTION_H;
1175 }
1176 if (row as usize) < offsets.len() {
1177 offsets[row as usize] = cumulative;
1178 }
1179 }
1180 offsets
1181}
1182
1183impl From<PluginLayout> for GridLayout {
1184 fn from(pl: PluginLayout) -> Self {
1185 let cols = pl
1186 .rows
1187 .iter()
1188 .map(|r| r.knobs.iter().map(|k| k.span.max(1)).sum::<u32>())
1189 .max()
1190 .unwrap_or(1);
1191
1192 let mut widgets = Vec::new();
1193 let mut sections = Vec::new();
1194
1195 for (grid_row, row) in pl.rows.iter().enumerate() {
1196 let grid_row = len_u32(grid_row);
1197 if let Some(label) = row.label {
1198 sections.push((grid_row, label));
1199 }
1200 let mut col = 0u32;
1201 for knob in &row.knobs {
1202 widgets.push(GridWidget {
1203 col,
1204 row: grid_row,
1205 col_span: knob.span.max(1),
1206 row_span: 1,
1207 param_id: knob.param_id,
1208 label: knob.label,
1209 widget: knob.widget,
1210 param_id_y: knob.param_id_y,
1211 meter_ids: knob.meter_ids.clone(),
1212 });
1213 col += knob.span.max(1);
1214 }
1215 }
1216
1217 let mut gl = GridLayout {
1218 titles: pl.titles.clone(),
1219 cols,
1220 sections,
1221 widgets: widgets.clone(),
1222 cell_size: pl.knob_size,
1223 width: 0,
1224 height: 0,
1225 resizable: false,
1226 maximizable: false,
1227 min_size: (1, 1),
1228 max_size: (u32::MAX, u32::MAX),
1229 rows: 1,
1230 // PluginLayout drives placement from `rows` directly,
1231 // so widgets are already explicitly positioned. The
1232 // re-flow stash is the same widgets with no breaks -
1233 // calling `with_cols` would re-run auto-flow against
1234 // explicit (col,row) values, which is a no-op.
1235 original_widgets: widgets,
1236 original_breaks: Vec::new(),
1237 };
1238 let (w, h) = gl.compute_size();
1239 gl.width = w;
1240 gl.height = h;
1241 gl
1242 }
1243}
1244
1245/// Layout variant for editor dispatch.
1246#[derive(Clone, Debug)]
1247pub enum Layout {
1248 Rows(PluginLayout),
1249 Grid(GridLayout),
1250}
1251
1252impl Layout {
1253 #[must_use]
1254 pub fn width(&self) -> u32 {
1255 match self {
1256 Layout::Rows(l) => l.width,
1257 Layout::Grid(g) => g.width,
1258 }
1259 }
1260 #[must_use]
1261 pub fn height(&self) -> u32 {
1262 match self {
1263 Layout::Rows(l) => l.height,
1264 Layout::Grid(g) => g.height,
1265 }
1266 }
1267 /// Title slot of the editor's header band - left, larger /
1268 /// brighter - or `None` when the layout doesn't set one.
1269 #[must_use]
1270 pub fn title(&self) -> Option<&str> {
1271 match self {
1272 Layout::Rows(l) => l.titles.title,
1273 Layout::Grid(g) => g.titles.title,
1274 }
1275 }
1276 /// Subtitle slot of the editor's header band - right, smaller /
1277 /// dimmer - or `None` when the layout doesn't set one.
1278 #[must_use]
1279 pub fn subtitle(&self) -> Option<&str> {
1280 match self {
1281 Layout::Rows(l) => l.titles.subtitle,
1282 Layout::Grid(g) => g.titles.subtitle,
1283 }
1284 }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use super::*;
1290
1291 fn auto_widgets(n: u32) -> Vec<GridWidget> {
1292 (0..n).map(|i| GridWidget::knob(i, "k")).collect()
1293 }
1294
1295 #[test]
1296 fn refit_cols_snaps_wider_to_more_cols() {
1297 let mut g = GridLayout::build(vec![widgets(auto_widgets(8))])
1298 .with_cols(2)
1299 .resizable(true)
1300 .max_size((8, u32::MAX));
1301 let natural_w = g.width;
1302 let (new_w, _) = g.refit_cols(natural_w * 2);
1303 assert_eq!(g.cols, 4);
1304 assert!(new_w > natural_w);
1305 }
1306
1307 #[test]
1308 fn refit_cols_clamps_to_min_max() {
1309 let mut g = GridLayout::build(vec![widgets(auto_widgets(8))])
1310 .with_cols(4)
1311 .resizable(true)
1312 .min_size((2, 1))
1313 .max_size((4, u32::MAX));
1314 let _ = g.refit_cols(10);
1315 assert_eq!(g.cols, 2, "min_cols clamp");
1316 let _ = g.refit_cols(10_000);
1317 assert_eq!(g.cols, 4, "max_cols clamp");
1318 }
1319
1320 #[test]
1321 fn refit_cols_reflows_widget_positions() {
1322 // 4 auto-positioned knobs in a 4-column grid sit in a
1323 // single row; clamping to 1 col packs them into 4 rows.
1324 let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
1325 .with_cols(4)
1326 .resizable(true)
1327 .max_size((1, u32::MAX));
1328 let _ = g.refit_cols(40);
1329 let mut rows: Vec<u32> = g.widgets.iter().map(|w| w.row).collect();
1330 rows.sort_unstable();
1331 assert_eq!(rows, vec![0, 1, 2, 3]);
1332 assert!(g.widgets.iter().all(|w| w.col == 0));
1333 }
1334
1335 #[test]
1336 fn refit_rows_snaps_taller_to_more_rows() {
1337 let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
1338 .with_cols(4)
1339 .resizable(true)
1340 .max_size((u32::MAX, 8));
1341 let natural_h = g.height;
1342 let (_, new_h) = g.refit_rows(natural_h * 2);
1343 assert!(
1344 g.rows > 1,
1345 "rows should grow under doubled height; got {}",
1346 g.rows
1347 );
1348 assert!(new_h > natural_h);
1349 }
1350
1351 #[test]
1352 #[allow(
1353 clippy::cast_precision_loss,
1354 clippy::cast_possible_truncation,
1355 clippy::cast_sign_loss
1356 )]
1357 fn fixed_size_natural_width_matches_flowed_content() {
1358 // block-drywet shape: 3 widgets but only 2 columns occupied
1359 // (two knobs stacked in col 0, a meter spanning 2 rows in
1360 // col 1). The widest-section widget *count* is 3, so the old
1361 // unconditional `max(self.cols)` padded the window with a
1362 // phantom trailing column. A fixed-size layout must be
1363 // exactly as wide as its flowed content.
1364 let g = GridLayout::build(vec![widgets(vec![
1365 GridWidget::knob(0u32, "Drive").at(0, 0),
1366 GridWidget::knob(1u32, "Mix").at(0, 1),
1367 GridWidget::meter(&[2u32, 3u32], "Level").at(1, 0).rows(2),
1368 ])])
1369 .with_title("DRY/WET");
1370 let max_widget_col = g.widgets.iter().map(|w| w.col + w.col_span).max().unwrap();
1371 let content_w = (GRID_PADDING * 2.0 + max_widget_col as f32 * (g.cell_size + GRID_GAP)
1372 - GRID_GAP) as u32;
1373 assert_eq!(
1374 g.width, content_w,
1375 "fixed-size natural width must hug the flowed content, no phantom columns"
1376 );
1377 }
1378
1379 #[test]
1380 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1381 fn resizable_natural_width_still_extends_to_declared_cols() {
1382 // The resize affordance is preserved: an opted-in resizable
1383 // layout still lets `cols` extend the width past the flowed
1384 // extent so `refit_cols` has room to snap into.
1385 let g = GridLayout::build(vec![widgets(auto_widgets(2))])
1386 .with_cols(4)
1387 .resizable(true);
1388 assert_eq!(g.cols, 4);
1389 let two_col_w = (GRID_PADDING * 2.0 + 2.0 * (g.cell_size + GRID_GAP) - GRID_GAP) as u32;
1390 assert!(
1391 g.width > two_col_w,
1392 "resizable layout keeps declared-cols width extension"
1393 );
1394 }
1395
1396 #[test]
1397 fn set_size_feedback_loop_is_stable() {
1398 // Reproduce the Windows resize path: set_size runs
1399 // refit_cols THEN refit_rows, then the editor resizes the
1400 // window to the result, which re-enters set_size with that
1401 // size. A stable loop must converge (no per-resize drift /
1402 // clip). titled => header band is in play.
1403 let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
1404 .with_cols(4)
1405 .with_title("T")
1406 .resizable(true)
1407 .max_size((8, 8));
1408
1409 // simulate one user drag to an arbitrary larger size
1410 g.refit_cols(600);
1411 let (mut w, mut h) = g.refit_rows(400);
1412
1413 // now feed the editor's own size back in, repeatedly, the way
1414 // window.resize -> Resized -> set_size does on Windows.
1415 for i in 0..5 {
1416 g.refit_cols(w);
1417 let (w2, h2) = g.refit_rows(h);
1418 assert_eq!(
1419 (w2, h2),
1420 (w, h),
1421 "feedback iteration {i} drifted: {:?} -> {:?}",
1422 (w, h),
1423 (w2, h2)
1424 );
1425 w = w2;
1426 h = h2;
1427 }
1428 }
1429
1430 #[test]
1431 fn refit_rows_round_trips_through_the_header() {
1432 // Resizing a titled layout to its own natural height must be
1433 // a no-op: `refit_rows` has to subtract the same header band
1434 // `compute_size` adds.
1435 let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
1436 .with_cols(4)
1437 .with_title("T")
1438 .resizable(true)
1439 .max_size((u32::MAX, 8));
1440 let nat_h = g.height;
1441 let (_, snapped) = g.refit_rows(nat_h);
1442 assert_eq!(snapped, nat_h, "resize to natural height must round-trip");
1443 }
1444
1445 #[test]
1446 fn refit_rows_clamps_to_min_max() {
1447 let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
1448 .with_cols(4)
1449 .resizable(true)
1450 .min_size((1, 2))
1451 .max_size((u32::MAX, 4));
1452 let _ = g.refit_rows(10);
1453 assert_eq!(g.rows, 2, "min_rows clamp");
1454 let _ = g.refit_rows(10_000);
1455 assert_eq!(g.rows, 4, "max_rows clamp");
1456 }
1457}