pub const HEADER_HEIGHT: f32 = 20.0;
pub const ROWS_LAYOUT_TOP: f32 = 24.0;
pub const ROWS_SECTION_LABEL_HEIGHT: f32 = 14.0;
pub const ROWS_COLUMN_GAP: f32 = 7.0;
pub const ROWS_ROW_GAP: f32 = 19.0;
pub const DROPDOWN_BOX_HEIGHT: f32 = 20.0;
use truce_core::cast::len_u32;
#[derive(Clone, Debug)]
pub struct KnobDef {
pub param_id: u32,
pub label: &'static str,
pub widget: Option<WidgetKind>,
pub span: u32,
pub param_id_y: Option<u32>,
pub meter_ids: Option<Vec<u32>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WidgetKind {
Knob,
Slider,
Toggle,
Selector,
Dropdown,
Meter,
XYPad,
}
impl KnobDef {
pub fn knob(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Knob),
span: 1,
param_id_y: None,
meter_ids: None,
}
}
pub fn slider(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Slider),
span: 1,
param_id_y: None,
meter_ids: None,
}
}
pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Toggle),
span: 1,
param_id_y: None,
meter_ids: None,
}
}
#[deprecated(since = "0.56.0", note = "use `dropdown` instead")]
pub fn selector(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Selector),
span: 1,
param_id_y: None,
meter_ids: None,
}
}
pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Dropdown),
span: 1,
param_id_y: None,
meter_ids: None,
}
}
#[must_use]
pub fn meter(ids: &[u32], label: &'static str) -> Self {
Self {
param_id: ids.first().copied().unwrap_or(0),
label,
widget: Some(WidgetKind::Meter),
span: 1,
param_id_y: None,
meter_ids: Some(ids.to_vec()),
}
}
pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> Self {
Self {
param_id: param_x.into(),
label,
widget: Some(WidgetKind::XYPad),
span: 2,
param_id_y: Some(param_y.into()),
meter_ids: None,
}
}
#[must_use]
pub fn with_span(mut self, span: u32) -> Self {
self.span = span;
self
}
}
#[derive(Clone, Debug)]
pub struct KnobRow {
pub label: Option<&'static str>,
pub knobs: Vec<KnobDef>,
}
#[derive(Clone, Debug)]
pub struct PluginLayout {
pub titles: HeaderTitles,
pub rows: Vec<KnobRow>,
pub width: u32,
pub height: u32,
pub knob_size: f32,
}
impl PluginLayout {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
#[must_use]
pub fn compute_size(rows: &[KnobRow], knob_size: f32, titles: &HeaderTitles) -> (u32, u32) {
let header_h = if titles.is_empty() { 0.0 } else { 21.0 };
let row_h = knob_size + 19.0;
let section_label_h = 14.0;
let padding = 7.0;
let max_knobs = rows
.iter()
.map(|r| {
r.knobs
.iter()
.map(|k| k.span.max(1) as usize)
.sum::<usize>()
})
.max()
.unwrap_or(1);
let w = max_knobs as f32 * (knob_size + 7.0) + 13.0;
let mut h = header_h + padding;
for row in rows {
if row.label.is_some() {
h += section_label_h;
}
h += row_h + padding;
}
(w as u32, h as u32)
}
#[must_use]
pub fn build(titles: HeaderTitles, rows: Vec<KnobRow>, knob_size: f32) -> Self {
let (w, h) = Self::compute_size(&rows, knob_size, &titles);
Self {
titles,
rows,
width: w,
height: h,
knob_size,
}
}
}
pub const AUTO: u32 = u32::MAX;
pub const GRID_GAP: f32 = 19.0;
pub const GRID_PADDING: f32 = 10.0;
pub const GRID_HEADER_H: f32 = 21.0;
pub const GRID_SECTION_H: f32 = 14.0;
const SNAP_EPS: f32 = 1e-3;
#[derive(Clone, Debug)]
pub struct GridWidget {
pub col: u32,
pub row: u32,
pub col_span: u32,
pub row_span: u32,
pub param_id: u32,
pub label: &'static str,
pub widget: Option<WidgetKind>,
pub param_id_y: Option<u32>,
pub meter_ids: Option<Vec<u32>>,
}
impl GridWidget {
pub fn knob(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
col: AUTO,
row: AUTO,
col_span: 1,
row_span: 1,
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Knob),
param_id_y: None,
meter_ids: None,
}
}
pub fn slider(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
col: AUTO,
row: AUTO,
col_span: 1,
row_span: 1,
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Slider),
param_id_y: None,
meter_ids: None,
}
}
pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
col: AUTO,
row: AUTO,
col_span: 1,
row_span: 1,
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Toggle),
param_id_y: None,
meter_ids: None,
}
}
#[deprecated(since = "0.56.0", note = "use `dropdown` instead")]
pub fn selector(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
col: AUTO,
row: AUTO,
col_span: 1,
row_span: 1,
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Selector),
param_id_y: None,
meter_ids: None,
}
}
pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> Self {
Self {
col: AUTO,
row: AUTO,
col_span: 1,
row_span: 1,
param_id: param_id.into(),
label,
widget: Some(WidgetKind::Dropdown),
param_id_y: None,
meter_ids: None,
}
}
#[must_use]
pub fn meter(ids: &[u32], label: &'static str) -> Self {
Self {
col: AUTO,
row: AUTO,
col_span: 1,
row_span: 1,
param_id: ids.first().copied().unwrap_or(0),
label,
widget: Some(WidgetKind::Meter),
param_id_y: None,
meter_ids: Some(ids.to_vec()),
}
}
pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> Self {
Self {
col: AUTO,
row: AUTO,
col_span: 2,
row_span: 2,
param_id: param_x.into(),
label,
widget: Some(WidgetKind::XYPad),
param_id_y: Some(param_y.into()),
meter_ids: None,
}
}
#[must_use]
pub fn cols(mut self, n: u32) -> Self {
self.col_span = n;
self
}
#[must_use]
pub fn rows(mut self, n: u32) -> Self {
self.row_span = n;
self
}
#[must_use]
pub fn at(mut self, col: u32, row: u32) -> Self {
self.col = col;
self.row = row;
self
}
}
#[derive(Clone, Debug)]
pub struct Section {
pub label: Option<&'static str>,
pub widgets: Vec<GridWidget>,
}
#[must_use]
pub fn section(label: &'static str, widgets: Vec<GridWidget>) -> Section {
Section {
label: Some(label),
widgets,
}
}
#[must_use]
pub fn widgets(widgets: Vec<GridWidget>) -> Section {
Section {
label: None,
widgets,
}
}
pub fn knob(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
GridWidget::knob(param_id, label)
}
pub fn slider(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
GridWidget::slider(param_id, label)
}
pub fn toggle(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
GridWidget::toggle(param_id, label)
}
#[deprecated(since = "0.56.0", note = "use `dropdown` instead")]
#[allow(deprecated)]
pub fn selector(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
GridWidget::selector(param_id, label)
}
pub fn dropdown(param_id: impl Into<u32>, label: &'static str) -> GridWidget {
GridWidget::dropdown(param_id, label)
}
pub fn meter<I: Into<u32> + Copy>(ids: &[I], label: &'static str) -> GridWidget {
let u32_ids: Vec<u32> = ids.iter().map(|id| (*id).into()).collect();
GridWidget::meter(&u32_ids, label)
}
pub fn xy_pad(param_x: impl Into<u32>, param_y: impl Into<u32>, label: &'static str) -> GridWidget {
GridWidget::xy_pad(param_x, param_y, label)
}
impl From<GridWidget> for Section {
fn from(w: GridWidget) -> Self {
Section {
label: None,
widgets: vec![w],
}
}
}
#[derive(Clone, Debug, Default)]
pub struct HeaderTitles {
pub title: Option<&'static str>,
pub subtitle: Option<&'static str>,
}
impl HeaderTitles {
#[must_use]
pub const fn none() -> Self {
Self {
title: None,
subtitle: None,
}
}
#[must_use]
pub const fn title(s: &'static str) -> Self {
Self {
title: Some(s),
subtitle: None,
}
}
#[must_use]
pub const fn subtitle(s: &'static str) -> Self {
Self {
title: None,
subtitle: Some(s),
}
}
#[must_use]
pub const fn pair(title: &'static str, subtitle: &'static str) -> Self {
Self {
title: Some(title),
subtitle: Some(subtitle),
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.title.is_none() && self.subtitle.is_none()
}
}
#[derive(Clone, Debug)]
pub struct GridLayout {
pub titles: HeaderTitles,
pub cols: u32,
pub sections: Vec<(u32, &'static str)>,
pub widgets: Vec<GridWidget>,
pub cell_size: f32,
pub width: u32,
pub height: u32,
original_widgets: Vec<GridWidget>,
original_breaks: Vec<(usize, &'static str)>,
pub resizable: bool,
pub min_size: (u32, u32),
pub max_size: (u32, u32),
pub rows: u32,
}
pub const GRID_DEFAULT_CELL_SIZE: f32 = 50.0;
impl GridLayout {
#[must_use]
pub fn build(entries: Vec<Section>) -> Self {
let mut widgets = Vec::new();
let mut breaks = Vec::new();
let mut max_widgets_per_section = 0usize;
for s in entries {
if let Some(label) = s.label {
breaks.push((widgets.len(), label));
}
max_widgets_per_section = max_widgets_per_section.max(s.widgets.len());
widgets.extend(s.widgets);
}
let max_explicit_col = widgets
.iter()
.filter(|w| w.col != AUTO)
.map(|w| w.col + w.col_span)
.max()
.unwrap_or(0);
let cols = len_u32(max_widgets_per_section)
.max(max_explicit_col)
.max(1);
let mut layout = Self {
titles: HeaderTitles::none(),
cols,
sections: Vec::new(),
widgets: widgets.clone(),
cell_size: GRID_DEFAULT_CELL_SIZE,
width: 0,
height: 0,
resizable: false,
min_size: (1, 1),
max_size: (u32::MAX, u32::MAX),
rows: 1,
original_widgets: widgets,
original_breaks: breaks,
};
layout.flow_and_size();
layout
}
#[must_use]
pub fn with_cols(mut self, cols: u32) -> Self {
self.cols = cols.max(1);
self.flow_and_size();
self
}
#[must_use]
pub fn with_cell_size(mut self, cell_size: f32) -> Self {
self.cell_size = cell_size;
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
self
}
#[must_use]
pub fn with_grid(mut self, cols: u32, cell_size: f32) -> Self {
self = self.with_cell_size(cell_size);
self.with_cols(cols)
}
#[must_use]
pub fn with_titles(mut self, titles: HeaderTitles) -> Self {
self.titles = titles;
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
self
}
#[must_use]
pub fn with_title(mut self, title: &'static str) -> Self {
self.titles.title = Some(title);
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
self
}
#[must_use]
pub fn with_subtitle(mut self, subtitle: &'static str) -> Self {
self.titles.subtitle = Some(subtitle);
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
self
}
#[must_use]
pub fn resizable(mut self, value: bool) -> Self {
self.resizable = value;
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
self
}
#[must_use]
pub fn min_size(mut self, cells: (u32, u32)) -> Self {
self.min_size = (cells.0.max(1), cells.1.max(1));
self
}
#[must_use]
pub fn max_size(mut self, cells: (u32, u32)) -> Self {
self.max_size = (cells.0.max(1), cells.1.max(1));
self
}
#[must_use]
pub fn with_rows(mut self, rows: u32) -> Self {
self.rows = rows.max(1);
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
self
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
pub fn refit_rows(&mut self, target_h: u32) -> (u32, u32) {
let step = self.cell_size + GRID_GAP;
if step <= 0.0 {
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
return (w, h);
}
let section_count = f32::from(u16::try_from(self.sections.len()).unwrap_or(u16::MAX));
let bottom_label_h = 22.0;
let overhead = self.header_height()
+ GRID_PADDING * 2.0
+ section_count * GRID_SECTION_H
+ bottom_label_h
- GRID_GAP;
let usable = (target_h as f32 - overhead).max(0.0);
let raw = (usable / step + SNAP_EPS).floor();
let new_rows = (raw.max(1.0) as u32).clamp(self.min_size.1.max(1), self.max_size.1.max(1));
self.rows = new_rows;
let (_, grid_h) = self.compute_size();
self.height = if self.max_size.1 == u32::MAX {
target_h.max(grid_h)
} else {
let max_h = self.max_snapped_size().1;
target_h.clamp(grid_h, max_h)
};
(self.width, self.height)
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
#[must_use]
pub fn resize_step(&self) -> u32 {
(self.cell_size + GRID_GAP).round().max(1.0) as u32
}
#[must_use]
pub fn min_snapped_size(&self) -> (u32, u32) {
let mut probe = self.clone();
probe.cols = self.min_size.0.max(1);
probe.rows = self.min_size.1.max(1);
probe.flow_and_size();
(probe.width, probe.height)
}
#[must_use]
pub fn max_snapped_size(&self) -> (u32, u32) {
let cap = 64u32;
let cols = if self.max_size.0 == u32::MAX {
cap
} else {
self.max_size.0.max(1)
};
let rows = if self.max_size.1 == u32::MAX {
cap
} else {
self.max_size.1.max(1)
};
let mut probe = self.clone();
probe.cols = cols;
probe.rows = rows;
probe.flow_and_size();
(probe.width, probe.height)
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
pub fn refit_cols(&mut self, target_w: u32) -> (u32, u32) {
let step = self.cell_size + GRID_GAP;
if step <= 0.0 {
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
return (w, h);
}
let numerator = target_w as f32 + GRID_GAP - GRID_PADDING * 2.0;
let raw = (numerator / step + SNAP_EPS).floor();
let new_cols = (raw.max(1.0) as u32).clamp(self.min_size.0.max(1), self.max_size.0.max(1));
self.cols = new_cols;
self.flow_and_size();
let grid_w = self.width; self.width = if self.max_size.0 == u32::MAX {
target_w.max(grid_w)
} else {
let max_w = self.max_snapped_size().0;
target_w.clamp(grid_w, max_w)
};
(self.width, self.height)
}
pub(crate) fn header_height(&self) -> f32 {
if self.titles.is_empty() {
0.0
} else {
GRID_HEADER_H
}
}
fn flow_and_size(&mut self) {
self.widgets = self.original_widgets.clone();
self.sections.clear();
let breaks: Vec<(usize, &'static str)> = self.original_breaks.clone();
self.auto_flow_with_breaks(&breaks);
let (w, h) = self.compute_size();
self.width = w;
self.height = h;
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
#[must_use]
pub fn compute_size(&self) -> (u32, u32) {
let max_widget_col = self
.widgets
.iter()
.map(|w| w.col + w.col_span)
.max()
.unwrap_or(1);
let max_widget_row = self
.widgets
.iter()
.map(|w| w.row + w.row_span)
.max()
.unwrap_or(1);
let (max_col, max_row) = if self.resizable {
(max_widget_col.max(self.cols), max_widget_row.max(self.rows))
} else {
(max_widget_col, max_widget_row)
};
let section_count = self.sections.len() as f32;
let w = GRID_PADDING * 2.0 + max_col as f32 * (self.cell_size + GRID_GAP) - GRID_GAP;
let bottom_label_h = 22.0; let h = self.header_height() + GRID_PADDING + max_row as f32 * (self.cell_size + GRID_GAP)
- GRID_GAP
+ section_count * GRID_SECTION_H
+ bottom_label_h
+ GRID_PADDING;
(w as u32, h as u32)
}
pub(crate) fn auto_flow_with_breaks(&mut self, breaks: &[(usize, &'static str)]) {
let mut occupied = std::collections::HashSet::new();
let mut cursor_col: u32 = 0;
let mut cursor_row: u32 = 0;
let mut any_emitted = false;
for w in &self.widgets {
if w.col != AUTO && w.row != AUTO {
for c in w.col..w.col + w.col_span {
for r in w.row..w.row + w.row_span {
occupied.insert((c, r));
}
}
}
}
for (i, w) in self.widgets.iter_mut().enumerate() {
for &(break_idx, label) in breaks {
if break_idx == i {
if any_emitted || cursor_col > 0 {
let max_occupied_row = occupied.iter().map(|&(_, r)| r).max().unwrap_or(0);
cursor_row = (cursor_row + 1).max(max_occupied_row + 1);
cursor_col = 0;
}
self.sections.push((cursor_row, label));
any_emitted = true;
}
}
if w.col != AUTO && w.row != AUTO {
any_emitted = true;
continue;
}
loop {
if cursor_col + w.col_span > self.cols {
cursor_col = 0;
cursor_row += 1;
}
let fits = (0..w.col_span).all(|dc| {
(0..w.row_span)
.all(|dr| !occupied.contains(&(cursor_col + dc, cursor_row + dr)))
});
if fits {
break;
}
cursor_col += 1;
}
w.col = cursor_col;
w.row = cursor_row;
for c in w.col..w.col + w.col_span {
for r in w.row..w.row + w.row_span {
occupied.insert((c, r));
}
}
cursor_col += w.col_span;
any_emitted = true;
}
}
}
#[must_use]
pub fn compute_section_offsets(layout: &GridLayout) -> Vec<f32> {
let max_row = layout
.widgets
.iter()
.map(|w| w.row + w.row_span)
.max()
.unwrap_or(1);
let mut offsets = vec![0.0f32; max_row as usize + 1];
let mut cumulative = 0.0;
for row in 0..=max_row {
if layout.sections.iter().any(|(r, _)| *r == row) {
cumulative += GRID_SECTION_H;
}
if (row as usize) < offsets.len() {
offsets[row as usize] = cumulative;
}
}
offsets
}
impl From<PluginLayout> for GridLayout {
fn from(pl: PluginLayout) -> Self {
let cols = pl
.rows
.iter()
.map(|r| r.knobs.iter().map(|k| k.span.max(1)).sum::<u32>())
.max()
.unwrap_or(1);
let mut widgets = Vec::new();
let mut sections = Vec::new();
for (grid_row, row) in pl.rows.iter().enumerate() {
let grid_row = len_u32(grid_row);
if let Some(label) = row.label {
sections.push((grid_row, label));
}
let mut col = 0u32;
for knob in &row.knobs {
widgets.push(GridWidget {
col,
row: grid_row,
col_span: knob.span.max(1),
row_span: 1,
param_id: knob.param_id,
label: knob.label,
widget: knob.widget,
param_id_y: knob.param_id_y,
meter_ids: knob.meter_ids.clone(),
});
col += knob.span.max(1);
}
}
let mut gl = GridLayout {
titles: pl.titles.clone(),
cols,
sections,
widgets: widgets.clone(),
cell_size: pl.knob_size,
width: 0,
height: 0,
resizable: false,
min_size: (1, 1),
max_size: (u32::MAX, u32::MAX),
rows: 1,
original_widgets: widgets,
original_breaks: Vec::new(),
};
let (w, h) = gl.compute_size();
gl.width = w;
gl.height = h;
gl
}
}
#[derive(Clone, Debug)]
pub enum Layout {
Rows(PluginLayout),
Grid(GridLayout),
}
impl Layout {
#[must_use]
pub fn width(&self) -> u32 {
match self {
Layout::Rows(l) => l.width,
Layout::Grid(g) => g.width,
}
}
#[must_use]
pub fn height(&self) -> u32 {
match self {
Layout::Rows(l) => l.height,
Layout::Grid(g) => g.height,
}
}
#[must_use]
pub fn title(&self) -> Option<&str> {
match self {
Layout::Rows(l) => l.titles.title,
Layout::Grid(g) => g.titles.title,
}
}
#[must_use]
pub fn subtitle(&self) -> Option<&str> {
match self {
Layout::Rows(l) => l.titles.subtitle,
Layout::Grid(g) => g.titles.subtitle,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn auto_widgets(n: u32) -> Vec<GridWidget> {
(0..n).map(|i| GridWidget::knob(i, "k")).collect()
}
#[test]
fn refit_cols_snaps_wider_to_more_cols() {
let mut g = GridLayout::build(vec![widgets(auto_widgets(8))])
.with_cols(2)
.resizable(true)
.max_size((8, u32::MAX));
let natural_w = g.width;
let (new_w, _) = g.refit_cols(natural_w * 2);
assert_eq!(g.cols, 4);
assert!(new_w > natural_w);
}
#[test]
fn refit_cols_clamps_to_min_max() {
let mut g = GridLayout::build(vec![widgets(auto_widgets(8))])
.with_cols(4)
.resizable(true)
.min_size((2, 1))
.max_size((4, u32::MAX));
let _ = g.refit_cols(10);
assert_eq!(g.cols, 2, "min_cols clamp");
let _ = g.refit_cols(10_000);
assert_eq!(g.cols, 4, "max_cols clamp");
}
#[test]
fn refit_cols_reflows_widget_positions() {
let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
.with_cols(4)
.resizable(true)
.max_size((1, u32::MAX));
let _ = g.refit_cols(40);
let mut rows: Vec<u32> = g.widgets.iter().map(|w| w.row).collect();
rows.sort_unstable();
assert_eq!(rows, vec![0, 1, 2, 3]);
assert!(g.widgets.iter().all(|w| w.col == 0));
}
#[test]
fn refit_rows_snaps_taller_to_more_rows() {
let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
.with_cols(4)
.resizable(true)
.max_size((u32::MAX, 8));
let natural_h = g.height;
let (_, new_h) = g.refit_rows(natural_h * 2);
assert!(
g.rows > 1,
"rows should grow under doubled height; got {}",
g.rows
);
assert!(new_h > natural_h);
}
#[test]
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn fixed_size_natural_width_matches_flowed_content() {
let g = GridLayout::build(vec![widgets(vec![
GridWidget::knob(0u32, "Drive").at(0, 0),
GridWidget::knob(1u32, "Mix").at(0, 1),
GridWidget::meter(&[2u32, 3u32], "Level").at(1, 0).rows(2),
])])
.with_title("DRY/WET");
let max_widget_col = g.widgets.iter().map(|w| w.col + w.col_span).max().unwrap();
let content_w = (GRID_PADDING * 2.0 + max_widget_col as f32 * (g.cell_size + GRID_GAP)
- GRID_GAP) as u32;
assert_eq!(
g.width, content_w,
"fixed-size natural width must hug the flowed content, no phantom columns"
);
}
#[test]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn resizable_natural_width_still_extends_to_declared_cols() {
let g = GridLayout::build(vec![widgets(auto_widgets(2))])
.with_cols(4)
.resizable(true);
assert_eq!(g.cols, 4);
let two_col_w = (GRID_PADDING * 2.0 + 2.0 * (g.cell_size + GRID_GAP) - GRID_GAP) as u32;
assert!(
g.width > two_col_w,
"resizable layout keeps declared-cols width extension"
);
}
#[test]
fn set_size_feedback_loop_is_stable() {
let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
.with_cols(4)
.with_title("T")
.resizable(true)
.max_size((8, 8));
g.refit_cols(600);
let (mut w, mut h) = g.refit_rows(400);
for i in 0..5 {
g.refit_cols(w);
let (w2, h2) = g.refit_rows(h);
assert_eq!(
(w2, h2),
(w, h),
"feedback iteration {i} drifted: {:?} -> {:?}",
(w, h),
(w2, h2)
);
w = w2;
h = h2;
}
}
#[test]
fn refit_rows_round_trips_through_the_header() {
let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
.with_cols(4)
.with_title("T")
.resizable(true)
.max_size((u32::MAX, 8));
let nat_h = g.height;
let (_, snapped) = g.refit_rows(nat_h);
assert_eq!(snapped, nat_h, "resize to natural height must round-trip");
}
#[test]
fn refit_rows_clamps_to_min_max() {
let mut g = GridLayout::build(vec![widgets(auto_widgets(4))])
.with_cols(4)
.resizable(true)
.min_size((1, 2))
.max_size((u32::MAX, 4));
let _ = g.refit_rows(10);
assert_eq!(g.rows, 2, "min_rows clamp");
let _ = g.refit_rows(10_000);
assert_eq!(g.rows, 4, "max_rows clamp");
}
}