Skip to main content

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}