use crate::{sanitize_str, sanitize_title, Insets, Rect};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
pub const LAYOUT_RECIPE_MAX_SLOTS: usize = 24;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum AppRecipeKind {
AiChat,
DeveloperAssistant,
AudioMixer,
AudioPlugin,
Dashboard,
DocumentEditor,
Preferences,
Browser,
CreativeTool,
CommandPaletteFirst,
}
impl AppRecipeKind {
pub fn label(self) -> &'static str {
match self {
Self::AiChat => "ai_chat",
Self::DeveloperAssistant => "developer_assistant",
Self::AudioMixer => "audio_mixer",
Self::AudioPlugin => "audio_plugin",
Self::Dashboard => "dashboard",
Self::DocumentEditor => "document_editor",
Self::Preferences => "preferences",
Self::Browser => "browser",
Self::CreativeTool => "creative_tool",
Self::CommandPaletteFirst => "command_palette_first",
}
}
}
pub fn built_in_recipe_kinds() -> Vec<AppRecipeKind> {
vec![
AppRecipeKind::AiChat,
AppRecipeKind::DeveloperAssistant,
AppRecipeKind::AudioMixer,
AppRecipeKind::AudioPlugin,
AppRecipeKind::Dashboard,
AppRecipeKind::DocumentEditor,
AppRecipeKind::Preferences,
AppRecipeKind::Browser,
AppRecipeKind::CreativeTool,
AppRecipeKind::CommandPaletteFirst,
]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum LayoutRecipeSlotKind {
Header,
Navigation,
Sidebar,
Main,
Inspector,
Prompt,
Status,
Toolbar,
Transport,
Mixer,
Timeline,
Preview,
Palette,
Editor,
Canvas,
Metrics,
Overlay,
Footer,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct LayoutRecipeSlot {
pub id: String,
pub kind: LayoutRecipeSlotKind,
pub label: String,
pub rect: Rect,
pub min_width: f32,
pub min_height: f32,
pub emphasis: f32,
}
impl LayoutRecipeSlot {
pub fn new(
id: impl Into<String>,
kind: LayoutRecipeSlotKind,
label: impl Into<String>,
rect: Rect,
) -> Self {
Self {
id: sanitize_recipe_id(&id.into()),
kind,
label: sanitize_title(&label.into(), 96),
rect: sanitize_rect(rect),
min_width: 0.0,
min_height: 0.0,
emphasis: 0.5,
}
}
pub fn with_min_size(mut self, min_width: f32, min_height: f32) -> Self {
self.min_width = finite_range(min_width, 0.0, 4096.0, 0.0);
self.min_height = finite_range(min_height, 0.0, 4096.0, 0.0);
self
}
pub fn with_emphasis(mut self, emphasis: f32) -> Self {
self.emphasis = finite_range(emphasis, 0.0, 1.0, 0.5);
self
}
pub fn sanitized(mut self) -> Self {
self.id = sanitize_recipe_id(&self.id);
self.label = sanitize_title(&self.label, 96);
self.rect = sanitize_rect(self.rect);
self.min_width = finite_range(self.min_width, 0.0, 4096.0, 0.0);
self.min_height = finite_range(self.min_height, 0.0, 4096.0, 0.0);
self.emphasis = finite_range(self.emphasis, 0.0, 1.0, 0.5);
self
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct LayoutRecipe {
pub kind: AppRecipeKind,
pub viewport: Rect,
pub slots: Vec<LayoutRecipeSlot>,
}
impl LayoutRecipe {
pub fn slot(&self, id: &str) -> Option<&LayoutRecipeSlot> {
let id = sanitize_recipe_id(id);
self.slots.iter().find(|slot| slot.id == id)
}
pub fn slots_by_kind(&self, kind: LayoutRecipeSlotKind) -> Vec<&LayoutRecipeSlot> {
self.slots.iter().filter(|slot| slot.kind == kind).collect()
}
pub fn content_bounds(&self) -> Rect {
self.slots
.iter()
.map(|slot| slot.rect)
.filter(|rect| rect.is_finite() && !rect.is_empty())
.reduce(Rect::union)
.unwrap_or(Rect::ZERO)
}
pub fn sanitized(mut self) -> Self {
self.viewport = sanitize_rect(self.viewport);
self.slots.truncate(LAYOUT_RECIPE_MAX_SLOTS);
self.slots
.iter_mut()
.for_each(|slot| *slot = slot.clone().sanitized());
self.slots.retain(|slot| {
!slot.rect.is_empty()
&& slot
.rect
.intersection(self.viewport)
.is_some_and(|intersection| intersection == slot.rect)
});
self
}
}
pub fn built_in_layout_recipes(viewport: Rect) -> Vec<LayoutRecipe> {
built_in_recipe_kinds()
.into_iter()
.map(|kind| build_layout_recipe(kind, viewport))
.collect()
}
pub fn build_layout_recipe(kind: AppRecipeKind, viewport: Rect) -> LayoutRecipe {
let viewport = sanitize_rect(viewport);
if viewport.is_empty() {
return LayoutRecipe {
kind,
viewport,
slots: Vec::new(),
};
}
let slots = match kind {
AppRecipeKind::AiChat => ai_chat_slots(viewport),
AppRecipeKind::DeveloperAssistant => developer_slots(viewport),
AppRecipeKind::AudioMixer => audio_mixer_slots(viewport),
AppRecipeKind::AudioPlugin => audio_plugin_slots(viewport),
AppRecipeKind::Dashboard => dashboard_slots(viewport),
AppRecipeKind::DocumentEditor => document_editor_slots(viewport),
AppRecipeKind::Preferences => preferences_slots(viewport),
AppRecipeKind::Browser => browser_slots(viewport),
AppRecipeKind::CreativeTool => creative_tool_slots(viewport),
AppRecipeKind::CommandPaletteFirst => command_palette_slots(viewport),
};
LayoutRecipe {
kind,
viewport,
slots,
}
.sanitized()
}
fn ai_chat_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(16.0));
let header = top(content, 58.0);
let status = bottom(content, 32.0);
let prompt = above(status, 118.0, 12.0, content);
let body = between(header, prompt, 12.0, content);
let nav_w = if viewport.width >= 980.0 { 276.0 } else { 0.0 };
let inspector_w = if viewport.width >= 1260.0 { 316.0 } else { 0.0 };
let nav = left(body, nav_w);
let inspector = right(body, inspector_w);
let main = Rect::new(
body.x + if nav_w > 0.0 { nav_w + 12.0 } else { 0.0 },
body.y,
(body.width - nav_w - inspector_w - gap_if(nav_w) - gap_if(inspector_w)).max(0.0),
body.height,
);
collect_slots([
slot(
"header",
LayoutRecipeSlotKind::Header,
"Session chrome",
header,
480.0,
48.0,
0.35,
),
slot(
"navigation",
LayoutRecipeSlotKind::Navigation,
"Threads",
nav,
240.0,
240.0,
0.40,
),
slot(
"transcript",
LayoutRecipeSlotKind::Main,
"Transcript",
main,
360.0,
360.0,
1.0,
),
slot(
"inspector",
LayoutRecipeSlotKind::Inspector,
"Context inspector",
inspector,
280.0,
240.0,
0.55,
),
slot(
"prompt",
LayoutRecipeSlotKind::Prompt,
"Composer",
prompt,
420.0,
92.0,
0.9,
),
slot(
"status",
LayoutRecipeSlotKind::Status,
"Model status",
status,
320.0,
28.0,
0.25,
),
])
}
fn developer_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(14.0));
let toolbar = top(content, 54.0);
let prompt = bottom(content, 106.0);
let body = between(toolbar, prompt, 12.0, content);
let files = left(body, if viewport.width >= 1020.0 { 250.0 } else { 0.0 });
let assistant = right(body, if viewport.width >= 1180.0 { 340.0 } else { 0.0 });
let editor_x = body.x
+ if files.width > 0.0 {
files.width + 12.0
} else {
0.0
};
let editor_w = (body.width
- files.width
- assistant.width
- gap_if(files.width)
- gap_if(assistant.width))
.max(0.0);
let editor = Rect::new(editor_x, body.y, editor_w, body.height * 0.66);
let output = Rect::new(
editor.x,
editor.bottom() + 12.0,
editor.width,
(body.bottom() - editor.bottom() - 12.0).max(0.0),
);
collect_slots([
slot(
"toolbar",
LayoutRecipeSlotKind::Toolbar,
"Project toolbar",
toolbar,
480.0,
48.0,
0.35,
),
slot(
"files",
LayoutRecipeSlotKind::Sidebar,
"Files",
files,
220.0,
260.0,
0.42,
),
slot(
"editor",
LayoutRecipeSlotKind::Editor,
"Code editor",
editor,
420.0,
320.0,
1.0,
),
slot(
"output",
LayoutRecipeSlotKind::Main,
"Output",
output,
420.0,
120.0,
0.72,
),
slot(
"assistant",
LayoutRecipeSlotKind::Inspector,
"Assistant",
assistant,
300.0,
260.0,
0.78,
),
slot(
"prompt",
LayoutRecipeSlotKind::Prompt,
"Command prompt",
prompt,
420.0,
82.0,
0.85,
),
])
}
fn audio_mixer_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(12.0));
let transport = top(content, 64.0);
let timeline = bottom(content, 118.0);
let body = between(transport, timeline, 12.0, content);
let browser = left(body, if viewport.width >= 1100.0 { 250.0 } else { 0.0 });
let inspector = right(body, if viewport.width >= 1360.0 { 290.0 } else { 0.0 });
let mixer = Rect::new(
body.x
+ if browser.width > 0.0 {
browser.width + 12.0
} else {
0.0
},
body.y,
(body.width
- browser.width
- inspector.width
- gap_if(browser.width)
- gap_if(inspector.width))
.max(0.0),
body.height,
);
collect_slots([
slot(
"transport",
LayoutRecipeSlotKind::Transport,
"Transport",
transport,
500.0,
56.0,
0.5,
),
slot(
"browser",
LayoutRecipeSlotKind::Sidebar,
"Tracks",
browser,
220.0,
260.0,
0.4,
),
slot(
"mixer",
LayoutRecipeSlotKind::Mixer,
"Mixer strips",
mixer,
520.0,
360.0,
1.0,
),
slot(
"inspector",
LayoutRecipeSlotKind::Inspector,
"Channel inspector",
inspector,
260.0,
260.0,
0.58,
),
slot(
"timeline",
LayoutRecipeSlotKind::Timeline,
"Arrangement",
timeline,
480.0,
92.0,
0.72,
),
])
}
fn audio_plugin_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(16.0));
let header = top(content, 54.0);
let footer = bottom(content, 58.0);
let body = between(header, footer, 12.0, content);
let meter = right(body, 124.0);
let controls = bottom(
Rect::new(body.x, body.y, body.width - meter.width - 12.0, body.height),
166.0,
);
let preview = Rect::new(
body.x,
body.y,
controls.width,
(controls.y - body.y - 12.0).max(0.0),
);
collect_slots([
slot(
"header",
LayoutRecipeSlotKind::Header,
"Plugin header",
header,
420.0,
48.0,
0.4,
),
slot(
"preview",
LayoutRecipeSlotKind::Preview,
"Analyzer",
preview,
420.0,
260.0,
0.82,
),
slot(
"meter",
LayoutRecipeSlotKind::Metrics,
"Meter bridge",
meter,
96.0,
260.0,
0.62,
),
slot(
"controls",
LayoutRecipeSlotKind::Main,
"Macro controls",
controls,
420.0,
132.0,
1.0,
),
slot(
"footer",
LayoutRecipeSlotKind::Footer,
"Preset strip",
footer,
420.0,
44.0,
0.35,
),
])
}
fn dashboard_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(18.0));
let header = top(content, 60.0);
let nav = left(
below(header, 12.0, content),
if viewport.width >= 1050.0 { 236.0 } else { 0.0 },
);
let main_area = Rect::new(
content.x
+ if nav.width > 0.0 {
nav.width + 14.0
} else {
0.0
},
header.bottom() + 12.0,
(content.width - nav.width - gap_if(nav.width)).max(0.0),
(content.bottom() - header.bottom() - 12.0).max(0.0),
);
let metrics = top(main_area, 112.0);
let charts = Rect::new(
main_area.x,
metrics.bottom() + 14.0,
main_area.width * 0.62,
(main_area.bottom() - metrics.bottom() - 14.0).max(0.0),
);
let activity = Rect::new(
charts.right() + 14.0,
charts.y,
(main_area.right() - charts.right() - 14.0).max(0.0),
charts.height,
);
collect_slots([
slot(
"header",
LayoutRecipeSlotKind::Header,
"Dashboard chrome",
header,
520.0,
52.0,
0.36,
),
slot(
"navigation",
LayoutRecipeSlotKind::Navigation,
"Workspace nav",
nav,
210.0,
300.0,
0.4,
),
slot(
"metrics",
LayoutRecipeSlotKind::Metrics,
"KPI row",
metrics,
420.0,
92.0,
0.78,
),
slot(
"charts",
LayoutRecipeSlotKind::Main,
"Charts",
charts,
420.0,
320.0,
1.0,
),
slot(
"activity",
LayoutRecipeSlotKind::Inspector,
"Activity",
activity,
260.0,
260.0,
0.58,
),
])
}
fn document_editor_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(16.0));
let toolbar = top(content, 56.0);
let status = bottom(content, 30.0);
let body = between(toolbar, status, 12.0, content);
let outline = left(body, if viewport.width >= 1020.0 { 240.0 } else { 0.0 });
let inspector = right(body, if viewport.width >= 1280.0 { 292.0 } else { 0.0 });
let page = Rect::new(
body.x
+ if outline.width > 0.0 {
outline.width + 14.0
} else {
0.0
},
body.y,
(body.width
- outline.width
- inspector.width
- gap_if(outline.width)
- gap_if(inspector.width))
.max(0.0),
body.height,
);
collect_slots([
slot(
"toolbar",
LayoutRecipeSlotKind::Toolbar,
"Editor toolbar",
toolbar,
480.0,
48.0,
0.35,
),
slot(
"outline",
LayoutRecipeSlotKind::Sidebar,
"Outline",
outline,
210.0,
280.0,
0.35,
),
slot(
"page",
LayoutRecipeSlotKind::Editor,
"Document page",
page,
420.0,
420.0,
1.0,
),
slot(
"inspector",
LayoutRecipeSlotKind::Inspector,
"Format inspector",
inspector,
260.0,
280.0,
0.55,
),
slot(
"status",
LayoutRecipeSlotKind::Status,
"Word count",
status,
360.0,
26.0,
0.2,
),
])
}
fn preferences_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(20.0));
let footer = bottom(content, 58.0);
let body = Rect::new(
content.x,
content.y,
content.width,
(footer.y - content.y - 14.0).max(0.0),
);
let sidebar = left(body, if viewport.width >= 760.0 { 230.0 } else { 0.0 });
let form = Rect::new(
body.x
+ if sidebar.width > 0.0 {
sidebar.width + 16.0
} else {
0.0
},
body.y,
(body.width - sidebar.width - gap_if(sidebar.width)).max(0.0),
body.height,
);
collect_slots([
slot(
"sidebar",
LayoutRecipeSlotKind::Navigation,
"Settings sections",
sidebar,
200.0,
280.0,
0.45,
),
slot(
"form",
LayoutRecipeSlotKind::Main,
"Settings form",
form,
360.0,
320.0,
1.0,
),
slot(
"footer",
LayoutRecipeSlotKind::Footer,
"Action row",
footer,
360.0,
48.0,
0.35,
),
])
}
fn browser_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(12.0));
let toolbar = top(content, 58.0);
let status = bottom(content, 28.0);
let body = between(toolbar, status, 10.0, content);
let devtools = bottom(
body,
if viewport.height >= 820.0 {
body.height * 0.30
} else {
0.0
},
);
let page = Rect::new(
body.x,
body.y,
body.width,
(body.height - devtools.height - gap_if(devtools.height)).max(0.0),
);
collect_slots([
slot(
"toolbar",
LayoutRecipeSlotKind::Toolbar,
"Tabs and address",
toolbar,
460.0,
48.0,
0.42,
),
slot(
"page",
LayoutRecipeSlotKind::Main,
"Page content",
page,
420.0,
320.0,
1.0,
),
slot(
"devtools",
LayoutRecipeSlotKind::Inspector,
"Dev tools",
devtools,
420.0,
160.0,
0.6,
),
slot(
"status",
LayoutRecipeSlotKind::Status,
"Network status",
status,
320.0,
24.0,
0.18,
),
])
}
fn creative_tool_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(14.0));
let toolbar = top(content, 56.0);
let timeline = bottom(content, if viewport.height >= 760.0 { 118.0 } else { 0.0 });
let body = between(toolbar, timeline, 12.0, content);
let palette = left(body, if viewport.width >= 1100.0 { 224.0 } else { 0.0 });
let properties = right(body, if viewport.width >= 1280.0 { 286.0 } else { 0.0 });
let canvas = Rect::new(
body.x
+ if palette.width > 0.0 {
palette.width + 12.0
} else {
0.0
},
body.y,
(body.width
- palette.width
- properties.width
- gap_if(palette.width)
- gap_if(properties.width))
.max(0.0),
body.height,
);
collect_slots([
slot(
"toolbar",
LayoutRecipeSlotKind::Toolbar,
"Tool strip",
toolbar,
480.0,
48.0,
0.36,
),
slot(
"palette",
LayoutRecipeSlotKind::Palette,
"Tools",
palette,
200.0,
260.0,
0.46,
),
slot(
"canvas",
LayoutRecipeSlotKind::Canvas,
"Canvas",
canvas,
420.0,
360.0,
1.0,
),
slot(
"properties",
LayoutRecipeSlotKind::Inspector,
"Properties",
properties,
250.0,
260.0,
0.58,
),
slot(
"timeline",
LayoutRecipeSlotKind::Timeline,
"Timeline",
timeline,
420.0,
94.0,
0.7,
),
])
}
fn command_palette_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
let content = viewport.inset(Insets::all(16.0));
let header = top(content, 54.0);
let status = bottom(content, 30.0);
let main = between(header, status, 12.0, content);
let palette_w = main.width.clamp(320.0, 760.0);
let palette_h = main.height.clamp(180.0, 420.0);
let palette = Rect::new(
main.x + (main.width - palette_w) * 0.5,
main.y + (main.height - palette_h) * 0.35,
palette_w,
palette_h,
);
collect_slots([
slot(
"header",
LayoutRecipeSlotKind::Header,
"Minimal chrome",
header,
360.0,
48.0,
0.28,
),
slot(
"workspace",
LayoutRecipeSlotKind::Main,
"Workspace",
main,
420.0,
320.0,
0.62,
),
slot(
"palette",
LayoutRecipeSlotKind::Overlay,
"Command palette",
palette,
320.0,
180.0,
1.0,
),
slot(
"status",
LayoutRecipeSlotKind::Status,
"Hints",
status,
320.0,
24.0,
0.2,
),
])
}
fn collect_slots<const N: usize>(slots: [LayoutRecipeSlot; N]) -> Vec<LayoutRecipeSlot> {
slots
.into_iter()
.filter(|slot| !slot.rect.is_empty())
.take(LAYOUT_RECIPE_MAX_SLOTS)
.collect()
}
fn slot(
id: &str,
kind: LayoutRecipeSlotKind,
label: &str,
rect: Rect,
min_width: f32,
min_height: f32,
emphasis: f32,
) -> LayoutRecipeSlot {
LayoutRecipeSlot::new(id, kind, label, rect)
.with_min_size(min_width, min_height)
.with_emphasis(emphasis)
}
fn top(rect: Rect, height: f32) -> Rect {
Rect::new(rect.x, rect.y, rect.width, height.min(rect.height).max(0.0))
}
fn bottom(rect: Rect, height: f32) -> Rect {
let height = height.min(rect.height).max(0.0);
Rect::new(rect.x, rect.bottom() - height, rect.width, height)
}
fn left(rect: Rect, width: f32) -> Rect {
Rect::new(rect.x, rect.y, width.min(rect.width).max(0.0), rect.height)
}
fn right(rect: Rect, width: f32) -> Rect {
let width = width.min(rect.width).max(0.0);
Rect::new(rect.right() - width, rect.y, width, rect.height)
}
fn above(anchor: Rect, height: f32, gap: f32, bounds: Rect) -> Rect {
Rect::new(bounds.x, anchor.y - gap - height, bounds.width, height).clamp_within(bounds)
}
fn below(anchor: Rect, gap: f32, bounds: Rect) -> Rect {
Rect::new(
bounds.x,
anchor.bottom() + gap,
bounds.width,
(bounds.bottom() - anchor.bottom() - gap).max(0.0),
)
}
fn between(top_rect: Rect, bottom_rect: Rect, gap: f32, bounds: Rect) -> Rect {
let y = top_rect.bottom() + gap;
let bottom = bottom_rect.y - gap;
Rect::new(bounds.x, y, bounds.width, (bottom - y).max(0.0)).clamp_within(bounds)
}
fn gap_if(size: f32) -> f32 {
if size > 0.0 {
12.0
} else {
0.0
}
}
fn sanitize_recipe_id(input: &str) -> String {
let id = sanitize_str(input, 96).trim().replace(' ', "_");
if id.is_empty() {
"slot".to_string()
} else {
id
}
}
fn sanitize_rect(rect: Rect) -> Rect {
if rect.is_finite() && rect.width >= 0.0 && rect.height >= 0.0 {
rect
} else {
Rect::ZERO
}
}
fn finite_range(value: f32, min: f32, max: f32, fallback: f32) -> f32 {
if value.is_finite() {
value.clamp(min, max)
} else {
fallback
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn built_in_recipe_kinds_are_stable_and_cover_requested_shells() {
let labels = built_in_recipe_kinds()
.iter()
.map(|kind| kind.label())
.collect::<Vec<_>>();
assert_eq!(
labels,
[
"ai_chat",
"developer_assistant",
"audio_mixer",
"audio_plugin",
"dashboard",
"document_editor",
"preferences",
"browser",
"creative_tool",
"command_palette_first",
]
);
}
#[test]
fn recipes_have_finite_slots_inside_viewport() {
let viewport = Rect::new(0.0, 0.0, 1440.0, 900.0);
for recipe in built_in_layout_recipes(viewport) {
assert!(!recipe.slots.is_empty(), "{:?}", recipe.kind);
assert!(recipe.content_bounds().is_finite());
for slot in recipe.slots {
assert!(slot.rect.is_finite(), "{}", slot.id);
assert!(!slot.rect.is_empty(), "{}", slot.id);
assert_eq!(
slot.rect.intersection(viewport),
Some(slot.rect),
"{}",
slot.id
);
assert!((0.0..=1.0).contains(&slot.emphasis));
}
}
}
#[test]
fn compact_viewports_drop_optional_slots_but_keep_main_content() {
let recipe = build_layout_recipe(AppRecipeKind::AiChat, Rect::new(0.0, 0.0, 720.0, 620.0));
assert!(recipe.slot("navigation").is_none());
assert!(recipe.slot("inspector").is_none());
assert!(recipe.slot("transcript").is_some());
assert!(recipe.slot("prompt").is_some());
}
#[test]
fn invalid_viewports_return_empty_recipes() {
let recipe = build_layout_recipe(
AppRecipeKind::Dashboard,
Rect::new(f32::NAN, 0.0, 10.0, 10.0),
);
assert_eq!(recipe.viewport, Rect::ZERO);
assert!(recipe.slots.is_empty());
}
#[cfg(feature = "serde")]
#[test]
fn layout_recipes_round_trip_through_serde() {
let recipe = build_layout_recipe(
AppRecipeKind::AudioMixer,
Rect::new(0.0, 0.0, 1440.0, 900.0),
);
let encoded = serde_json::to_string(&recipe).unwrap();
let decoded: LayoutRecipe = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.kind, AppRecipeKind::AudioMixer);
assert_eq!(decoded.slots.len(), recipe.slots.len());
}
}