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}