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    Selector,
66    /// Dropdown list - click to open a popup showing all options.
67    Dropdown,
68    /// Level meter. Shows one bar per meter ID. Supports mono, stereo, or multi-channel.
69    Meter,
70    /// XY pad. Controls two params - X param stored in `param_id`, Y param in `xy_param_y`.
71    XYPad,
72}
73
74impl KnobDef {
75    /// Knob (default for continuous params, auto-detected anyway).
76    pub fn knob(param_id: impl Into<u32>, label: &'static str) -> Self {
77        Self {
78            param_id: param_id.into(),
79            label,
80            widget: Some(WidgetKind::Knob),
81            span: 1,
82            param_id_y: None,
83            meter_ids: None,
84        }
85    }
86
87    /// Horizontal slider.
88    pub fn slider(param_id: impl Into<u32>, label: &'static str) -> Self {
89        Self {
90            param_id: param_id.into(),
91            label,
92            widget: Some(WidgetKind::Slider),
93            span: 1,
94            param_id_y: None,
95            meter_ids: None,
96        }
97    }
98
99    /// Toggle button.
100    pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> Self {
101        Self {
102            param_id: param_id.into(),
103            label,
104            widget: Some(WidgetKind::Toggle),
105            span: 1,
106            param_id_y: None,
107            meter_ids: None,
108        }
109    }
110
111    /// Selector (click-to-cycle for enum params).
112    pub fn selector(param_id: impl Into<u32>, label: &'static str) -> Self {
113        Self {
114            param_id: param_id.into(),
115            label,
116            widget: Some(WidgetKind::Selector),
117            span: 1,
118            param_id_y: None,
119            meter_ids: None,
120        }
121    }
122
123    /// Dropdown list (click to open a popup showing all options).
124    pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> Self {
125        Self {
126            param_id: param_id.into(),
127            label,
128            widget: Some(WidgetKind::Dropdown),
129            span: 1,
130            param_id_y: None,
131            meter_ids: None,
132        }
133    }
134
135    /// Level meter with one or more channels (display-only, reads from `Plugin::get_meter()`).
136    #[must_use]
137    pub fn meter(ids: &[u32], label: &'static str) -> Self {
138        Self {
139            param_id: ids.first().copied().unwrap_or(0),
140            label,
141            widget: Some(WidgetKind::Meter),
142            span: 1,
143            param_id_y: None,
144            meter_ids: Some(ids.to_vec()),
145        }
146    }
147
148    /// XY pad controlling two parameters.
149    pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> Self {
150        Self {
151            param_id: param_x.into(),
152            label,
153            widget: Some(WidgetKind::XYPad),
154            span: 2,
155            param_id_y: Some(param_y.into()),
156            meter_ids: None,
157        }
158    }
159
160    /// Set the column span for this widget (default 1).
161    #[must_use]
162    pub fn with_span(mut self, span: u32) -> Self {
163        self.span = span;
164        self
165    }
166}
167
168/// A row of widgets with an optional section label.
169#[derive(Clone, Debug)]
170pub struct KnobRow {
171    pub label: Option<&'static str>,
172    pub knobs: Vec<KnobDef>,
173}
174
175/// Layout configuration for a plugin UI.
176#[derive(Clone, Debug)]
177pub struct PluginLayout {
178    pub titles: HeaderTitles,
179    pub rows: Vec<KnobRow>,
180    pub width: u32,
181    pub height: u32,
182    pub knob_size: f32,
183}
184
185impl PluginLayout {
186    /// Calculate default window size based on the layout.
187    // Window dimensions in logical pixels stay well below 2^23, so the
188    // f32 ↔ u32 narrowings are invisible in practice.
189    #[allow(
190        clippy::cast_possible_truncation,
191        clippy::cast_sign_loss,
192        clippy::cast_precision_loss
193    )]
194    #[must_use]
195    pub fn compute_size(rows: &[KnobRow], knob_size: f32, titles: &HeaderTitles) -> (u32, u32) {
196        let header_h = if titles.is_empty() { 0.0 } else { 21.0 };
197        let row_h = knob_size + 19.0;
198        let section_label_h = 14.0;
199        let padding = 7.0;
200
201        let max_knobs = rows
202            .iter()
203            .map(|r| {
204                r.knobs
205                    .iter()
206                    .map(|k| k.span.max(1) as usize)
207                    .sum::<usize>()
208            })
209            .max()
210            .unwrap_or(1);
211        let w = max_knobs as f32 * (knob_size + 7.0) + 13.0;
212
213        let mut h = header_h + padding;
214        for row in rows {
215            if row.label.is_some() {
216                h += section_label_h;
217            }
218            h += row_h + padding;
219        }
220
221        (w as u32, h as u32)
222    }
223
224    /// Build a Rows-style layout with the given header titles.
225    /// Either or both [`HeaderTitles`] slots can be empty (use
226    /// [`HeaderTitles::none`] for a layout with no header band).
227    #[must_use]
228    pub fn build(titles: HeaderTitles, rows: Vec<KnobRow>, knob_size: f32) -> Self {
229        let (w, h) = Self::compute_size(&rows, knob_size, &titles);
230        Self {
231            titles,
232            rows,
233            width: w,
234            height: h,
235            knob_size,
236        }
237    }
238}
239
240// ---------------------------------------------------------------------------
241// Grid Layout
242// ---------------------------------------------------------------------------
243
244/// Sentinel value for auto-placed grid widgets.
245pub const AUTO: u32 = u32::MAX;
246
247// Grid spacing constants. All dimensions in this module are in logical
248// points - the rendering backend (`CpuBackend` / `WgpuBackend`)
249// multiplies by the display scale factor at raster time.
250pub const GRID_GAP: f32 = 19.0;
251pub const GRID_PADDING: f32 = 10.0;
252pub const GRID_HEADER_H: f32 = 21.0;
253pub const GRID_SECTION_H: f32 = 14.0;
254
255/// A widget placed in a grid layout.
256#[derive(Clone, Debug)]
257pub struct GridWidget {
258    /// Grid column (0-indexed, or AUTO for auto-flow).
259    pub col: u32,
260    /// Grid row (0-indexed, or AUTO for auto-flow).
261    pub row: u32,
262    /// Columns spanned (default 1).
263    pub col_span: u32,
264    /// Rows spanned (default 1).
265    pub row_span: u32,
266    /// Parameter ID (or first meter ID for meters).
267    pub param_id: u32,
268    /// Display label.
269    pub label: &'static str,
270    /// Widget type override. None = auto-detect from param range.
271    pub widget: Option<WidgetKind>,
272    /// Second param for XY pad (Y axis).
273    pub param_id_y: Option<u32>,
274    /// Multiple meter IDs for multi-channel level meter.
275    pub meter_ids: Option<Vec<u32>>,
276}
277
278impl GridWidget {
279    pub fn knob(param_id: impl Into<u32>, label: &'static str) -> Self {
280        Self {
281            col: AUTO,
282            row: AUTO,
283            col_span: 1,
284            row_span: 1,
285            param_id: param_id.into(),
286            label,
287            widget: Some(WidgetKind::Knob),
288            param_id_y: None,
289            meter_ids: None,
290        }
291    }
292
293    pub fn slider(param_id: impl Into<u32>, label: &'static str) -> Self {
294        Self {
295            col: AUTO,
296            row: AUTO,
297            col_span: 1,
298            row_span: 1,
299            param_id: param_id.into(),
300            label,
301            widget: Some(WidgetKind::Slider),
302            param_id_y: None,
303            meter_ids: None,
304        }
305    }
306
307    pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> Self {
308        Self {
309            col: AUTO,
310            row: AUTO,
311            col_span: 1,
312            row_span: 1,
313            param_id: param_id.into(),
314            label,
315            widget: Some(WidgetKind::Toggle),
316            param_id_y: None,
317            meter_ids: None,
318        }
319    }
320
321    pub fn selector(param_id: impl Into<u32>, label: &'static str) -> Self {
322        Self {
323            col: AUTO,
324            row: AUTO,
325            col_span: 1,
326            row_span: 1,
327            param_id: param_id.into(),
328            label,
329            widget: Some(WidgetKind::Selector),
330            param_id_y: None,
331            meter_ids: None,
332        }
333    }
334
335    pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> Self {
336        Self {
337            col: AUTO,
338            row: AUTO,
339            col_span: 1,
340            row_span: 1,
341            param_id: param_id.into(),
342            label,
343            widget: Some(WidgetKind::Dropdown),
344            param_id_y: None,
345            meter_ids: None,
346        }
347    }
348
349    #[must_use]
350    pub fn meter(ids: &[u32], label: &'static str) -> Self {
351        Self {
352            col: AUTO,
353            row: AUTO,
354            col_span: 1,
355            row_span: 1,
356            param_id: ids.first().copied().unwrap_or(0),
357            label,
358            widget: Some(WidgetKind::Meter),
359            param_id_y: None,
360            meter_ids: Some(ids.to_vec()),
361        }
362    }
363
364    pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> Self {
365        Self {
366            col: AUTO,
367            row: AUTO,
368            col_span: 2,
369            row_span: 2,
370            param_id: param_x.into(),
371            label,
372            widget: Some(WidgetKind::XYPad),
373            param_id_y: Some(param_y.into()),
374            meter_ids: None,
375        }
376    }
377
378    /// Set the column span.
379    #[must_use]
380    pub fn cols(mut self, n: u32) -> Self {
381        self.col_span = n;
382        self
383    }
384
385    /// Set the row span.
386    #[must_use]
387    pub fn rows(mut self, n: u32) -> Self {
388        self.row_span = n;
389        self
390    }
391
392    /// Set explicit grid position (overrides auto-flow for this widget).
393    #[must_use]
394    pub fn at(mut self, col: u32, row: u32) -> Self {
395        self.col = col;
396        self.row = row;
397        self
398    }
399}
400
401/// A group of widgets with an optional section label.
402///
403/// Used as input to `GridLayout::build()`. Bare `GridWidget`s convert into
404/// ungrouped sections via `From`, so orphan widgets only need `.into()`.
405#[derive(Clone, Debug)]
406pub struct Section {
407    pub label: Option<&'static str>,
408    pub widgets: Vec<GridWidget>,
409}
410
411/// Create a labeled section of widgets for `GridLayout::build()`.
412#[must_use]
413pub fn section(label: &'static str, widgets: Vec<GridWidget>) -> Section {
414    Section {
415        label: Some(label),
416        widgets,
417    }
418}
419
420/// Wrap bare widgets into an unlabeled section (no section header).
421#[must_use]
422pub fn widgets(widgets: Vec<GridWidget>) -> Section {
423    Section {
424        label: None,
425        widgets,
426    }
427}
428
429// -- Short constructors for GridWidget (free functions) --
430
431/// Rotary knob widget.
432pub fn knob(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
433    GridWidget::knob(param_id, label)
434}
435
436/// Horizontal slider widget.
437pub fn slider(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
438    GridWidget::slider(param_id, label)
439}
440
441/// Toggle switch widget.
442pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
443    GridWidget::toggle(param_id, label)
444}
445
446/// Click-to-cycle selector widget.
447pub fn selector(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
448    GridWidget::selector(param_id, label)
449}
450
451/// Dropdown list widget.
452pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
453    GridWidget::dropdown(param_id, label)
454}
455
456/// Level meter widget.
457pub fn meter<I: Into<u32> + Copy>(ids: &[I], label: &'static str) -> GridWidget {
458    let u32_ids: Vec<u32> = ids.iter().map(|id| (*id).into()).collect();
459    GridWidget::meter(&u32_ids, label)
460}
461
462/// XY pad controlling two parameters.
463pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> GridWidget {
464    GridWidget::xy_pad(param_x, param_y, label)
465}
466
467impl From<GridWidget> for Section {
468    fn from(w: GridWidget) -> Self {
469        Section {
470            label: None,
471            widgets: vec![w],
472        }
473    }
474}
475
476/// Title band drawn above a layout. The `title` slot renders
477/// larger / brighter on the left of the band; the `subtitle` slot
478/// renders smaller / dimmer on the right. Each slot is independently
479/// optional - set either, both, or neither.
480///
481/// Use [`HeaderTitles::title`] / [`HeaderTitles::subtitle`] /
482/// [`HeaderTitles::pair`] for the common cases; build the struct
483/// directly only when you want to set a non-default combination
484/// (e.g. via `..` syntax over an existing instance).
485#[derive(Clone, Debug, Default)]
486pub struct HeaderTitles {
487    pub title: Option<&'static str>,
488    pub subtitle: Option<&'static str>,
489}
490
491impl HeaderTitles {
492    /// Both slots empty - no header band is drawn.
493    #[must_use]
494    pub const fn none() -> Self {
495        Self {
496            title: None,
497            subtitle: None,
498        }
499    }
500
501    /// Title only; subtitle slot stays empty.
502    #[must_use]
503    pub const fn title(s: &'static str) -> Self {
504        Self {
505            title: Some(s),
506            subtitle: None,
507        }
508    }
509
510    /// Subtitle only; title slot stays empty.
511    #[must_use]
512    pub const fn subtitle(s: &'static str) -> Self {
513        Self {
514            title: None,
515            subtitle: Some(s),
516        }
517    }
518
519    /// Both slots set.
520    #[must_use]
521    pub const fn pair(title: &'static str, subtitle: &'static str) -> Self {
522        Self {
523            title: Some(title),
524            subtitle: Some(subtitle),
525        }
526    }
527
528    /// `true` when neither slot is set - caller should skip the
529    /// header band entirely.
530    #[must_use]
531    pub const fn is_empty(&self) -> bool {
532        self.title.is_none() && self.subtitle.is_none()
533    }
534}
535
536/// Grid-based layout for a plugin UI.
537#[derive(Clone, Debug)]
538pub struct GridLayout {
539    /// Header band titles. Both slots default to `None`, in which
540    /// case no header is drawn and the grid starts at `y = 0`
541    /// (plus padding).
542    pub titles: HeaderTitles,
543    /// Number of columns in the grid.
544    pub cols: u32,
545    /// Section labels positioned above specific rows: (`row_index`, label).
546    pub sections: Vec<(u32, &'static str)>,
547    /// All widgets placed in the grid.
548    pub widgets: Vec<GridWidget>,
549    /// Cell size in logical points (width and height of one grid cell).
550    pub cell_size: f32,
551    /// Computed width in logical points.
552    pub width: u32,
553    /// Computed height in logical points.
554    pub height: u32,
555    /// Pre-flow widget snapshot - copy of `widgets` before
556    /// `auto_flow_with_breaks` ran. Lets [`Self::with_cols`] reset
557    /// and re-flow against a different column count without
558    /// losing AUTO-vs-explicit placement.
559    original_widgets: Vec<GridWidget>,
560    /// Pre-flow section breaks - `(widget_index, label)` pairs as
561    /// passed to `auto_flow_with_breaks` originally. Stored so
562    /// re-flow recovers the same section labels.
563    original_breaks: Vec<(usize, &'static str)>,
564}
565
566/// Default cell size in logical points when `GridLayout::build` is
567/// called without `.with_cell_size(...)`. Matches the scaffolded
568/// plugin's pre-refactor value so untouched scaffolds render the
569/// same as before.
570pub const GRID_DEFAULT_CELL_SIZE: f32 = 50.0;
571
572impl GridLayout {
573    /// Build a grid layout from sections containing widgets. No
574    /// header is drawn, `cols` defaults to the widest section's
575    /// widget count (extended to fit any explicitly-positioned
576    /// widget), and `cell_size` defaults to
577    /// [`GRID_DEFAULT_CELL_SIZE`]. Override any of those via
578    /// [`Self::with_titles`] / [`Self::with_cols`] /
579    /// [`Self::with_cell_size`].
580    ///
581    /// Each entry is either a `Section` (created with `section("LABEL", vec![...])`)
582    /// or a bare `GridWidget` (auto-wrapped via `From`). Example:
583    ///
584    /// ```ignore
585    /// GridLayout::build(vec![
586    ///     section("LOW", vec![
587    ///         GridWidget::knob(P::Freq, "Freq"),
588    ///         GridWidget::knob(P::Gain, "Gain"),
589    ///     ]),
590    ///     GridWidget::knob(P::Output, "Output").into(),
591    /// ])
592    /// ```
593    #[must_use]
594    pub fn build(entries: Vec<Section>) -> Self {
595        let mut widgets = Vec::new();
596        let mut breaks = Vec::new();
597        let mut max_widgets_per_section = 0usize;
598        for s in entries {
599            if let Some(label) = s.label {
600                breaks.push((widgets.len(), label));
601            }
602            max_widgets_per_section = max_widgets_per_section.max(s.widgets.len());
603            widgets.extend(s.widgets);
604        }
605        // Account for explicitly-positioned widgets that reach
606        // beyond the widest auto-flow row - the grid still has to
607        // be wide enough to seat them.
608        let max_explicit_col = widgets
609            .iter()
610            .filter(|w| w.col != AUTO)
611            .map(|w| w.col + w.col_span)
612            .max()
613            .unwrap_or(0);
614        let cols = len_u32(max_widgets_per_section)
615            .max(max_explicit_col)
616            .max(1);
617
618        let mut layout = Self {
619            titles: HeaderTitles::none(),
620            cols,
621            sections: Vec::new(),
622            widgets: widgets.clone(),
623            cell_size: GRID_DEFAULT_CELL_SIZE,
624            width: 0,
625            height: 0,
626            original_widgets: widgets,
627            original_breaks: breaks,
628        };
629        layout.flow_and_size();
630        layout
631    }
632
633    /// Override the default column count (which is the widest
634    /// section's widget count, or whatever explicit positions
635    /// require - whichever is larger). Use to force wrapping:
636    /// `.with_cols(2)` on a 4-widget section produces a 2×2 grid.
637    /// Recomputes auto-flow placement and window size.
638    #[must_use]
639    pub fn with_cols(mut self, cols: u32) -> Self {
640        self.cols = cols.max(1);
641        self.flow_and_size();
642        self
643    }
644
645    /// Override the default cell size ([`GRID_DEFAULT_CELL_SIZE`]).
646    /// The cell is square - this is both the width and height of
647    /// one grid cell in logical points.
648    #[must_use]
649    pub fn with_cell_size(mut self, cell_size: f32) -> Self {
650        self.cell_size = cell_size;
651        let (w, h) = self.compute_size();
652        self.width = w;
653        self.height = h;
654        self
655    }
656
657    /// Like [`Self::with_cols`] but accepts the cell size in the
658    /// same call - useful when both are non-default. Equivalent to
659    /// `.with_cell_size(s).with_cols(c)`.
660    #[must_use]
661    pub fn with_grid(mut self, cols: u32, cell_size: f32) -> Self {
662        self = self.with_cell_size(cell_size);
663        self.with_cols(cols)
664    }
665
666    /// Set both header slots at once. Replaces any previously
667    /// configured titles. Recomputes the height to account for the
668    /// extra band - width stays the same since the header spans the
669    /// full grid width.
670    ///
671    /// ```ignore
672    /// use truce_gui_types::layout::{GridLayout, HeaderTitles};
673    /// GridLayout::build(sections).with_titles(HeaderTitles::pair("EQ", "v0.1"))
674    /// ```
675    #[must_use]
676    pub fn with_titles(mut self, titles: HeaderTitles) -> Self {
677        self.titles = titles;
678        let (w, h) = self.compute_size();
679        self.width = w;
680        self.height = h;
681        self
682    }
683
684    /// Set the title slot (left, larger / brighter), preserving any
685    /// previously configured subtitle.
686    ///
687    /// ```ignore
688    /// GridLayout::build(sections).with_title("EQ")
689    /// ```
690    #[must_use]
691    pub fn with_title(mut self, title: &'static str) -> Self {
692        self.titles.title = Some(title);
693        let (w, h) = self.compute_size();
694        self.width = w;
695        self.height = h;
696        self
697    }
698
699    /// Set the subtitle slot (right, smaller / dimmer), preserving
700    /// any previously configured title.
701    ///
702    /// ```ignore
703    /// GridLayout::build(sections).with_subtitle("v0.1")
704    /// ```
705    #[must_use]
706    pub fn with_subtitle(mut self, subtitle: &'static str) -> Self {
707        self.titles.subtitle = Some(subtitle);
708        let (w, h) = self.compute_size();
709        self.width = w;
710        self.height = h;
711        self
712    }
713
714    /// Pixel height of the header band, or `0.0` when neither
715    /// title slot is set. Internal helper used by `compute_size`,
716    /// `widgets::draw_grid`, and `interaction::build_regions_grid`
717    /// to keep the "is there a header?" check in one place.
718    pub(crate) fn header_height(&self) -> f32 {
719        if self.titles.is_empty() {
720            0.0
721        } else {
722            GRID_HEADER_H
723        }
724    }
725
726    /// Reset to the pre-flow widget snapshot, run `auto_flow_with_breaks`
727    /// against `self.cols`, then recompute window size. Used by
728    /// `build`, `with_cols`, and `with_cell_size` so the layout
729    /// stays consistent after any configuration change.
730    fn flow_and_size(&mut self) {
731        self.widgets = self.original_widgets.clone();
732        self.sections.clear();
733        let breaks: Vec<(usize, &'static str)> = self.original_breaks.clone();
734        self.auto_flow_with_breaks(&breaks);
735        let (w, h) = self.compute_size();
736        self.width = w;
737        self.height = h;
738    }
739
740    /// Compute the window size from the grid.
741    // Window dimensions in logical pixels stay well below 2^23.
742    #[allow(
743        clippy::cast_possible_truncation,
744        clippy::cast_sign_loss,
745        clippy::cast_precision_loss
746    )]
747    #[must_use]
748    pub fn compute_size(&self) -> (u32, u32) {
749        let max_col = self
750            .widgets
751            .iter()
752            .map(|w| w.col + w.col_span)
753            .max()
754            .unwrap_or(1);
755        let max_row = self
756            .widgets
757            .iter()
758            .map(|w| w.row + w.row_span)
759            .max()
760            .unwrap_or(1);
761        let section_count = self.sections.len() as f32;
762
763        let w = GRID_PADDING * 2.0 + max_col as f32 * (self.cell_size + GRID_GAP) - GRID_GAP;
764        let bottom_label_h = 22.0; // label + value text below the last row of widgets
765        let h = self.header_height() + GRID_PADDING + max_row as f32 * (self.cell_size + GRID_GAP)
766            - GRID_GAP
767            + section_count * GRID_SECTION_H
768            + bottom_label_h
769            + GRID_PADDING;
770
771        (w as u32, h as u32)
772    }
773
774    /// Auto-flow placement with section breaks. Internal helper:
775    /// the public builder API exposes [`Self::with_cols`] /
776    /// [`Self::with_cell_size`] / [`Self::with_grid`] which call
777    /// `flow_and_size` after their field mutation. Previously
778    /// exposed as `pub` (along with a `pub auto_flow()` wrapper for
779    /// the no-breaks case), which mixed in-place mutation into the
780    /// chainable `mut self -> Self` builder surface - confusing.
781    /// Now `pub(crate)`; the no-breaks wrapper is gone since
782    /// internal callers always pass an explicit slice.
783    ///
784    /// Each break is `(widget_index, label)`: when the cursor reaches that
785    /// widget index, it advances to the next row and records a section label.
786    pub(crate) fn auto_flow_with_breaks(&mut self, breaks: &[(usize, &'static str)]) {
787        let mut occupied = std::collections::HashSet::new();
788        let mut cursor_col: u32 = 0;
789        let mut cursor_row: u32 = 0;
790        let mut any_emitted = false;
791
792        // First pass: mark cells occupied by explicitly-placed widgets.
793        for w in &self.widgets {
794            if w.col != AUTO && w.row != AUTO {
795                for c in w.col..w.col + w.col_span {
796                    for r in w.row..w.row + w.row_span {
797                        occupied.insert((c, r));
798                    }
799                }
800            }
801        }
802
803        // Second pass: auto-place widgets.
804        for (i, w) in self.widgets.iter_mut().enumerate() {
805            // Check for section breaks at this widget index.
806            for &(break_idx, label) in breaks {
807                if break_idx == i {
808                    if any_emitted || cursor_col > 0 {
809                        cursor_row += 1;
810                        cursor_col = 0;
811                    }
812                    self.sections.push((cursor_row, label));
813                    any_emitted = true;
814                }
815            }
816
817            if w.col != AUTO && w.row != AUTO {
818                // Explicitly placed - already marked in first pass.
819                any_emitted = true;
820                continue;
821            }
822
823            // Find next free position that fits this widget.
824            loop {
825                if cursor_col + w.col_span > self.cols {
826                    cursor_col = 0;
827                    cursor_row += 1;
828                }
829                let fits = (0..w.col_span).all(|dc| {
830                    (0..w.row_span)
831                        .all(|dr| !occupied.contains(&(cursor_col + dc, cursor_row + dr)))
832                });
833                if fits {
834                    break;
835                }
836                cursor_col += 1;
837            }
838
839            w.col = cursor_col;
840            w.row = cursor_row;
841
842            for c in w.col..w.col + w.col_span {
843                for r in w.row..w.row + w.row_span {
844                    occupied.insert((c, r));
845                }
846            }
847
848            cursor_col += w.col_span;
849            any_emitted = true;
850        }
851    }
852}
853
854/// Compute cumulative section-label pixel offsets per row.
855///
856/// `offsets[r]` is the total vertical shift (from section labels) for row `r`.
857#[must_use]
858pub fn compute_section_offsets(layout: &GridLayout) -> Vec<f32> {
859    let max_row = layout
860        .widgets
861        .iter()
862        .map(|w| w.row + w.row_span)
863        .max()
864        .unwrap_or(1);
865    let mut offsets = vec![0.0f32; max_row as usize + 1];
866    let mut cumulative = 0.0;
867
868    for row in 0..=max_row {
869        if layout.sections.iter().any(|(r, _)| *r == row) {
870            cumulative += GRID_SECTION_H;
871        }
872        if (row as usize) < offsets.len() {
873            offsets[row as usize] = cumulative;
874        }
875    }
876    offsets
877}
878
879impl From<PluginLayout> for GridLayout {
880    fn from(pl: PluginLayout) -> Self {
881        let cols = pl
882            .rows
883            .iter()
884            .map(|r| r.knobs.iter().map(|k| k.span.max(1)).sum::<u32>())
885            .max()
886            .unwrap_or(1);
887
888        let mut widgets = Vec::new();
889        let mut sections = Vec::new();
890
891        for (grid_row, row) in pl.rows.iter().enumerate() {
892            let grid_row = len_u32(grid_row);
893            if let Some(label) = row.label {
894                sections.push((grid_row, label));
895            }
896            let mut col = 0u32;
897            for knob in &row.knobs {
898                widgets.push(GridWidget {
899                    col,
900                    row: grid_row,
901                    col_span: knob.span.max(1),
902                    row_span: 1,
903                    param_id: knob.param_id,
904                    label: knob.label,
905                    widget: knob.widget,
906                    param_id_y: knob.param_id_y,
907                    meter_ids: knob.meter_ids.clone(),
908                });
909                col += knob.span.max(1);
910            }
911        }
912
913        let mut gl = GridLayout {
914            titles: pl.titles.clone(),
915            cols,
916            sections,
917            widgets: widgets.clone(),
918            cell_size: pl.knob_size,
919            width: 0,
920            height: 0,
921            // PluginLayout drives placement from `rows` directly,
922            // so widgets are already explicitly positioned. The
923            // re-flow stash is the same widgets with no breaks -
924            // calling `with_cols` would re-run auto-flow against
925            // explicit (col,row) values, which is a no-op.
926            original_widgets: widgets,
927            original_breaks: Vec::new(),
928        };
929        let (w, h) = gl.compute_size();
930        gl.width = w;
931        gl.height = h;
932        gl
933    }
934}
935
936/// Layout variant for editor dispatch.
937#[derive(Clone, Debug)]
938pub enum Layout {
939    Rows(PluginLayout),
940    Grid(GridLayout),
941}
942
943impl Layout {
944    #[must_use]
945    pub fn width(&self) -> u32 {
946        match self {
947            Layout::Rows(l) => l.width,
948            Layout::Grid(g) => g.width,
949        }
950    }
951    #[must_use]
952    pub fn height(&self) -> u32 {
953        match self {
954            Layout::Rows(l) => l.height,
955            Layout::Grid(g) => g.height,
956        }
957    }
958    /// Title slot of the editor's header band - left, larger /
959    /// brighter - or `None` when the layout doesn't set one.
960    #[must_use]
961    pub fn title(&self) -> Option<&str> {
962        match self {
963            Layout::Rows(l) => l.titles.title,
964            Layout::Grid(g) => g.titles.title,
965        }
966    }
967    /// Subtitle slot of the editor's header band - right, smaller /
968    /// dimmer - or `None` when the layout doesn't set one.
969    #[must_use]
970    pub fn subtitle(&self) -> Option<&str> {
971        match self {
972            Layout::Rows(l) => l.titles.subtitle,
973            Layout::Grid(g) => g.titles.subtitle,
974        }
975    }
976}