1use crate::{sanitize_str, sanitize_title, Insets, Rect};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6pub const LAYOUT_RECIPE_MAX_SLOTS: usize = 24;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
11pub enum AppRecipeKind {
12 AiChat,
13 DeveloperAssistant,
14 AudioMixer,
15 AudioPlugin,
16 Dashboard,
17 DocumentEditor,
18 Preferences,
19 Browser,
20 CreativeTool,
21 CommandPaletteFirst,
22}
23
24impl AppRecipeKind {
25 pub fn label(self) -> &'static str {
26 match self {
27 Self::AiChat => "ai_chat",
28 Self::DeveloperAssistant => "developer_assistant",
29 Self::AudioMixer => "audio_mixer",
30 Self::AudioPlugin => "audio_plugin",
31 Self::Dashboard => "dashboard",
32 Self::DocumentEditor => "document_editor",
33 Self::Preferences => "preferences",
34 Self::Browser => "browser",
35 Self::CreativeTool => "creative_tool",
36 Self::CommandPaletteFirst => "command_palette_first",
37 }
38 }
39}
40
41pub fn built_in_recipe_kinds() -> Vec<AppRecipeKind> {
42 vec![
43 AppRecipeKind::AiChat,
44 AppRecipeKind::DeveloperAssistant,
45 AppRecipeKind::AudioMixer,
46 AppRecipeKind::AudioPlugin,
47 AppRecipeKind::Dashboard,
48 AppRecipeKind::DocumentEditor,
49 AppRecipeKind::Preferences,
50 AppRecipeKind::Browser,
51 AppRecipeKind::CreativeTool,
52 AppRecipeKind::CommandPaletteFirst,
53 ]
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
58#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
59pub enum LayoutRecipeSlotKind {
60 Header,
61 Navigation,
62 Sidebar,
63 Main,
64 Inspector,
65 Prompt,
66 Status,
67 Toolbar,
68 Transport,
69 Mixer,
70 Timeline,
71 Preview,
72 Palette,
73 Editor,
74 Canvas,
75 Metrics,
76 Overlay,
77 Footer,
78}
79
80#[derive(Clone, Debug, PartialEq)]
81#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
82pub struct LayoutRecipeSlot {
83 pub id: String,
84 pub kind: LayoutRecipeSlotKind,
85 pub label: String,
86 pub rect: Rect,
87 pub min_width: f32,
88 pub min_height: f32,
89 pub emphasis: f32,
90}
91
92impl LayoutRecipeSlot {
93 pub fn new(
94 id: impl Into<String>,
95 kind: LayoutRecipeSlotKind,
96 label: impl Into<String>,
97 rect: Rect,
98 ) -> Self {
99 Self {
100 id: sanitize_recipe_id(&id.into()),
101 kind,
102 label: sanitize_title(&label.into(), 96),
103 rect: sanitize_rect(rect),
104 min_width: 0.0,
105 min_height: 0.0,
106 emphasis: 0.5,
107 }
108 }
109
110 pub fn with_min_size(mut self, min_width: f32, min_height: f32) -> Self {
111 self.min_width = finite_range(min_width, 0.0, 4096.0, 0.0);
112 self.min_height = finite_range(min_height, 0.0, 4096.0, 0.0);
113 self
114 }
115
116 pub fn with_emphasis(mut self, emphasis: f32) -> Self {
117 self.emphasis = finite_range(emphasis, 0.0, 1.0, 0.5);
118 self
119 }
120
121 pub fn sanitized(mut self) -> Self {
122 self.id = sanitize_recipe_id(&self.id);
123 self.label = sanitize_title(&self.label, 96);
124 self.rect = sanitize_rect(self.rect);
125 self.min_width = finite_range(self.min_width, 0.0, 4096.0, 0.0);
126 self.min_height = finite_range(self.min_height, 0.0, 4096.0, 0.0);
127 self.emphasis = finite_range(self.emphasis, 0.0, 1.0, 0.5);
128 self
129 }
130}
131
132#[derive(Clone, Debug, PartialEq)]
133#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
134pub struct LayoutRecipe {
135 pub kind: AppRecipeKind,
136 pub viewport: Rect,
137 pub slots: Vec<LayoutRecipeSlot>,
138}
139
140impl LayoutRecipe {
141 pub fn slot(&self, id: &str) -> Option<&LayoutRecipeSlot> {
142 let id = sanitize_recipe_id(id);
143 self.slots.iter().find(|slot| slot.id == id)
144 }
145
146 pub fn slots_by_kind(&self, kind: LayoutRecipeSlotKind) -> Vec<&LayoutRecipeSlot> {
147 self.slots.iter().filter(|slot| slot.kind == kind).collect()
148 }
149
150 pub fn content_bounds(&self) -> Rect {
151 self.slots
152 .iter()
153 .map(|slot| slot.rect)
154 .filter(|rect| rect.is_finite() && !rect.is_empty())
155 .reduce(Rect::union)
156 .unwrap_or(Rect::ZERO)
157 }
158
159 pub fn sanitized(mut self) -> Self {
160 self.viewport = sanitize_rect(self.viewport);
161 self.slots.truncate(LAYOUT_RECIPE_MAX_SLOTS);
162 self.slots
163 .iter_mut()
164 .for_each(|slot| *slot = slot.clone().sanitized());
165 self.slots.retain(|slot| {
166 !slot.rect.is_empty()
167 && slot
168 .rect
169 .intersection(self.viewport)
170 .is_some_and(|intersection| intersection == slot.rect)
171 });
172 self
173 }
174}
175
176pub fn built_in_layout_recipes(viewport: Rect) -> Vec<LayoutRecipe> {
177 built_in_recipe_kinds()
178 .into_iter()
179 .map(|kind| build_layout_recipe(kind, viewport))
180 .collect()
181}
182
183pub fn build_layout_recipe(kind: AppRecipeKind, viewport: Rect) -> LayoutRecipe {
184 let viewport = sanitize_rect(viewport);
185 if viewport.is_empty() {
186 return LayoutRecipe {
187 kind,
188 viewport,
189 slots: Vec::new(),
190 };
191 }
192
193 let slots = match kind {
194 AppRecipeKind::AiChat => ai_chat_slots(viewport),
195 AppRecipeKind::DeveloperAssistant => developer_slots(viewport),
196 AppRecipeKind::AudioMixer => audio_mixer_slots(viewport),
197 AppRecipeKind::AudioPlugin => audio_plugin_slots(viewport),
198 AppRecipeKind::Dashboard => dashboard_slots(viewport),
199 AppRecipeKind::DocumentEditor => document_editor_slots(viewport),
200 AppRecipeKind::Preferences => preferences_slots(viewport),
201 AppRecipeKind::Browser => browser_slots(viewport),
202 AppRecipeKind::CreativeTool => creative_tool_slots(viewport),
203 AppRecipeKind::CommandPaletteFirst => command_palette_slots(viewport),
204 };
205
206 LayoutRecipe {
207 kind,
208 viewport,
209 slots,
210 }
211 .sanitized()
212}
213
214fn ai_chat_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
215 let content = viewport.inset(Insets::all(16.0));
216 let header = top(content, 58.0);
217 let status = bottom(content, 32.0);
218 let prompt = above(status, 118.0, 12.0, content);
219 let body = between(header, prompt, 12.0, content);
220 let nav_w = if viewport.width >= 980.0 { 276.0 } else { 0.0 };
221 let inspector_w = if viewport.width >= 1260.0 { 316.0 } else { 0.0 };
222 let nav = left(body, nav_w);
223 let inspector = right(body, inspector_w);
224 let main = Rect::new(
225 body.x + if nav_w > 0.0 { nav_w + 12.0 } else { 0.0 },
226 body.y,
227 (body.width - nav_w - inspector_w - gap_if(nav_w) - gap_if(inspector_w)).max(0.0),
228 body.height,
229 );
230 collect_slots([
231 slot(
232 "header",
233 LayoutRecipeSlotKind::Header,
234 "Session chrome",
235 header,
236 480.0,
237 48.0,
238 0.35,
239 ),
240 slot(
241 "navigation",
242 LayoutRecipeSlotKind::Navigation,
243 "Threads",
244 nav,
245 240.0,
246 240.0,
247 0.40,
248 ),
249 slot(
250 "transcript",
251 LayoutRecipeSlotKind::Main,
252 "Transcript",
253 main,
254 360.0,
255 360.0,
256 1.0,
257 ),
258 slot(
259 "inspector",
260 LayoutRecipeSlotKind::Inspector,
261 "Context inspector",
262 inspector,
263 280.0,
264 240.0,
265 0.55,
266 ),
267 slot(
268 "prompt",
269 LayoutRecipeSlotKind::Prompt,
270 "Composer",
271 prompt,
272 420.0,
273 92.0,
274 0.9,
275 ),
276 slot(
277 "status",
278 LayoutRecipeSlotKind::Status,
279 "Model status",
280 status,
281 320.0,
282 28.0,
283 0.25,
284 ),
285 ])
286}
287
288fn developer_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
289 let content = viewport.inset(Insets::all(14.0));
290 let toolbar = top(content, 54.0);
291 let prompt = bottom(content, 106.0);
292 let body = between(toolbar, prompt, 12.0, content);
293 let files = left(body, if viewport.width >= 1020.0 { 250.0 } else { 0.0 });
294 let assistant = right(body, if viewport.width >= 1180.0 { 340.0 } else { 0.0 });
295 let editor_x = body.x
296 + if files.width > 0.0 {
297 files.width + 12.0
298 } else {
299 0.0
300 };
301 let editor_w = (body.width
302 - files.width
303 - assistant.width
304 - gap_if(files.width)
305 - gap_if(assistant.width))
306 .max(0.0);
307 let editor = Rect::new(editor_x, body.y, editor_w, body.height * 0.66);
308 let output = Rect::new(
309 editor.x,
310 editor.bottom() + 12.0,
311 editor.width,
312 (body.bottom() - editor.bottom() - 12.0).max(0.0),
313 );
314 collect_slots([
315 slot(
316 "toolbar",
317 LayoutRecipeSlotKind::Toolbar,
318 "Project toolbar",
319 toolbar,
320 480.0,
321 48.0,
322 0.35,
323 ),
324 slot(
325 "files",
326 LayoutRecipeSlotKind::Sidebar,
327 "Files",
328 files,
329 220.0,
330 260.0,
331 0.42,
332 ),
333 slot(
334 "editor",
335 LayoutRecipeSlotKind::Editor,
336 "Code editor",
337 editor,
338 420.0,
339 320.0,
340 1.0,
341 ),
342 slot(
343 "output",
344 LayoutRecipeSlotKind::Main,
345 "Output",
346 output,
347 420.0,
348 120.0,
349 0.72,
350 ),
351 slot(
352 "assistant",
353 LayoutRecipeSlotKind::Inspector,
354 "Assistant",
355 assistant,
356 300.0,
357 260.0,
358 0.78,
359 ),
360 slot(
361 "prompt",
362 LayoutRecipeSlotKind::Prompt,
363 "Command prompt",
364 prompt,
365 420.0,
366 82.0,
367 0.85,
368 ),
369 ])
370}
371
372fn audio_mixer_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
373 let content = viewport.inset(Insets::all(12.0));
374 let transport = top(content, 64.0);
375 let timeline = bottom(content, 118.0);
376 let body = between(transport, timeline, 12.0, content);
377 let browser = left(body, if viewport.width >= 1100.0 { 250.0 } else { 0.0 });
378 let inspector = right(body, if viewport.width >= 1360.0 { 290.0 } else { 0.0 });
379 let mixer = Rect::new(
380 body.x
381 + if browser.width > 0.0 {
382 browser.width + 12.0
383 } else {
384 0.0
385 },
386 body.y,
387 (body.width
388 - browser.width
389 - inspector.width
390 - gap_if(browser.width)
391 - gap_if(inspector.width))
392 .max(0.0),
393 body.height,
394 );
395 collect_slots([
396 slot(
397 "transport",
398 LayoutRecipeSlotKind::Transport,
399 "Transport",
400 transport,
401 500.0,
402 56.0,
403 0.5,
404 ),
405 slot(
406 "browser",
407 LayoutRecipeSlotKind::Sidebar,
408 "Tracks",
409 browser,
410 220.0,
411 260.0,
412 0.4,
413 ),
414 slot(
415 "mixer",
416 LayoutRecipeSlotKind::Mixer,
417 "Mixer strips",
418 mixer,
419 520.0,
420 360.0,
421 1.0,
422 ),
423 slot(
424 "inspector",
425 LayoutRecipeSlotKind::Inspector,
426 "Channel inspector",
427 inspector,
428 260.0,
429 260.0,
430 0.58,
431 ),
432 slot(
433 "timeline",
434 LayoutRecipeSlotKind::Timeline,
435 "Arrangement",
436 timeline,
437 480.0,
438 92.0,
439 0.72,
440 ),
441 ])
442}
443
444fn audio_plugin_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
445 let content = viewport.inset(Insets::all(16.0));
446 let header = top(content, 54.0);
447 let footer = bottom(content, 58.0);
448 let body = between(header, footer, 12.0, content);
449 let meter = right(body, 124.0);
450 let controls = bottom(
451 Rect::new(body.x, body.y, body.width - meter.width - 12.0, body.height),
452 166.0,
453 );
454 let preview = Rect::new(
455 body.x,
456 body.y,
457 controls.width,
458 (controls.y - body.y - 12.0).max(0.0),
459 );
460 collect_slots([
461 slot(
462 "header",
463 LayoutRecipeSlotKind::Header,
464 "Plugin header",
465 header,
466 420.0,
467 48.0,
468 0.4,
469 ),
470 slot(
471 "preview",
472 LayoutRecipeSlotKind::Preview,
473 "Analyzer",
474 preview,
475 420.0,
476 260.0,
477 0.82,
478 ),
479 slot(
480 "meter",
481 LayoutRecipeSlotKind::Metrics,
482 "Meter bridge",
483 meter,
484 96.0,
485 260.0,
486 0.62,
487 ),
488 slot(
489 "controls",
490 LayoutRecipeSlotKind::Main,
491 "Macro controls",
492 controls,
493 420.0,
494 132.0,
495 1.0,
496 ),
497 slot(
498 "footer",
499 LayoutRecipeSlotKind::Footer,
500 "Preset strip",
501 footer,
502 420.0,
503 44.0,
504 0.35,
505 ),
506 ])
507}
508
509fn dashboard_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
510 let content = viewport.inset(Insets::all(18.0));
511 let header = top(content, 60.0);
512 let nav = left(
513 below(header, 12.0, content),
514 if viewport.width >= 1050.0 { 236.0 } else { 0.0 },
515 );
516 let main_area = Rect::new(
517 content.x
518 + if nav.width > 0.0 {
519 nav.width + 14.0
520 } else {
521 0.0
522 },
523 header.bottom() + 12.0,
524 (content.width - nav.width - gap_if(nav.width)).max(0.0),
525 (content.bottom() - header.bottom() - 12.0).max(0.0),
526 );
527 let metrics = top(main_area, 112.0);
528 let charts = Rect::new(
529 main_area.x,
530 metrics.bottom() + 14.0,
531 main_area.width * 0.62,
532 (main_area.bottom() - metrics.bottom() - 14.0).max(0.0),
533 );
534 let activity = Rect::new(
535 charts.right() + 14.0,
536 charts.y,
537 (main_area.right() - charts.right() - 14.0).max(0.0),
538 charts.height,
539 );
540 collect_slots([
541 slot(
542 "header",
543 LayoutRecipeSlotKind::Header,
544 "Dashboard chrome",
545 header,
546 520.0,
547 52.0,
548 0.36,
549 ),
550 slot(
551 "navigation",
552 LayoutRecipeSlotKind::Navigation,
553 "Workspace nav",
554 nav,
555 210.0,
556 300.0,
557 0.4,
558 ),
559 slot(
560 "metrics",
561 LayoutRecipeSlotKind::Metrics,
562 "KPI row",
563 metrics,
564 420.0,
565 92.0,
566 0.78,
567 ),
568 slot(
569 "charts",
570 LayoutRecipeSlotKind::Main,
571 "Charts",
572 charts,
573 420.0,
574 320.0,
575 1.0,
576 ),
577 slot(
578 "activity",
579 LayoutRecipeSlotKind::Inspector,
580 "Activity",
581 activity,
582 260.0,
583 260.0,
584 0.58,
585 ),
586 ])
587}
588
589fn document_editor_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
590 let content = viewport.inset(Insets::all(16.0));
591 let toolbar = top(content, 56.0);
592 let status = bottom(content, 30.0);
593 let body = between(toolbar, status, 12.0, content);
594 let outline = left(body, if viewport.width >= 1020.0 { 240.0 } else { 0.0 });
595 let inspector = right(body, if viewport.width >= 1280.0 { 292.0 } else { 0.0 });
596 let page = Rect::new(
597 body.x
598 + if outline.width > 0.0 {
599 outline.width + 14.0
600 } else {
601 0.0
602 },
603 body.y,
604 (body.width
605 - outline.width
606 - inspector.width
607 - gap_if(outline.width)
608 - gap_if(inspector.width))
609 .max(0.0),
610 body.height,
611 );
612 collect_slots([
613 slot(
614 "toolbar",
615 LayoutRecipeSlotKind::Toolbar,
616 "Editor toolbar",
617 toolbar,
618 480.0,
619 48.0,
620 0.35,
621 ),
622 slot(
623 "outline",
624 LayoutRecipeSlotKind::Sidebar,
625 "Outline",
626 outline,
627 210.0,
628 280.0,
629 0.35,
630 ),
631 slot(
632 "page",
633 LayoutRecipeSlotKind::Editor,
634 "Document page",
635 page,
636 420.0,
637 420.0,
638 1.0,
639 ),
640 slot(
641 "inspector",
642 LayoutRecipeSlotKind::Inspector,
643 "Format inspector",
644 inspector,
645 260.0,
646 280.0,
647 0.55,
648 ),
649 slot(
650 "status",
651 LayoutRecipeSlotKind::Status,
652 "Word count",
653 status,
654 360.0,
655 26.0,
656 0.2,
657 ),
658 ])
659}
660
661fn preferences_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
662 let content = viewport.inset(Insets::all(20.0));
663 let footer = bottom(content, 58.0);
664 let body = Rect::new(
665 content.x,
666 content.y,
667 content.width,
668 (footer.y - content.y - 14.0).max(0.0),
669 );
670 let sidebar = left(body, if viewport.width >= 760.0 { 230.0 } else { 0.0 });
671 let form = Rect::new(
672 body.x
673 + if sidebar.width > 0.0 {
674 sidebar.width + 16.0
675 } else {
676 0.0
677 },
678 body.y,
679 (body.width - sidebar.width - gap_if(sidebar.width)).max(0.0),
680 body.height,
681 );
682 collect_slots([
683 slot(
684 "sidebar",
685 LayoutRecipeSlotKind::Navigation,
686 "Settings sections",
687 sidebar,
688 200.0,
689 280.0,
690 0.45,
691 ),
692 slot(
693 "form",
694 LayoutRecipeSlotKind::Main,
695 "Settings form",
696 form,
697 360.0,
698 320.0,
699 1.0,
700 ),
701 slot(
702 "footer",
703 LayoutRecipeSlotKind::Footer,
704 "Action row",
705 footer,
706 360.0,
707 48.0,
708 0.35,
709 ),
710 ])
711}
712
713fn browser_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
714 let content = viewport.inset(Insets::all(12.0));
715 let toolbar = top(content, 58.0);
716 let status = bottom(content, 28.0);
717 let body = between(toolbar, status, 10.0, content);
718 let devtools = bottom(
719 body,
720 if viewport.height >= 820.0 {
721 body.height * 0.30
722 } else {
723 0.0
724 },
725 );
726 let page = Rect::new(
727 body.x,
728 body.y,
729 body.width,
730 (body.height - devtools.height - gap_if(devtools.height)).max(0.0),
731 );
732 collect_slots([
733 slot(
734 "toolbar",
735 LayoutRecipeSlotKind::Toolbar,
736 "Tabs and address",
737 toolbar,
738 460.0,
739 48.0,
740 0.42,
741 ),
742 slot(
743 "page",
744 LayoutRecipeSlotKind::Main,
745 "Page content",
746 page,
747 420.0,
748 320.0,
749 1.0,
750 ),
751 slot(
752 "devtools",
753 LayoutRecipeSlotKind::Inspector,
754 "Dev tools",
755 devtools,
756 420.0,
757 160.0,
758 0.6,
759 ),
760 slot(
761 "status",
762 LayoutRecipeSlotKind::Status,
763 "Network status",
764 status,
765 320.0,
766 24.0,
767 0.18,
768 ),
769 ])
770}
771
772fn creative_tool_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
773 let content = viewport.inset(Insets::all(14.0));
774 let toolbar = top(content, 56.0);
775 let timeline = bottom(content, if viewport.height >= 760.0 { 118.0 } else { 0.0 });
776 let body = between(toolbar, timeline, 12.0, content);
777 let palette = left(body, if viewport.width >= 1100.0 { 224.0 } else { 0.0 });
778 let properties = right(body, if viewport.width >= 1280.0 { 286.0 } else { 0.0 });
779 let canvas = Rect::new(
780 body.x
781 + if palette.width > 0.0 {
782 palette.width + 12.0
783 } else {
784 0.0
785 },
786 body.y,
787 (body.width
788 - palette.width
789 - properties.width
790 - gap_if(palette.width)
791 - gap_if(properties.width))
792 .max(0.0),
793 body.height,
794 );
795 collect_slots([
796 slot(
797 "toolbar",
798 LayoutRecipeSlotKind::Toolbar,
799 "Tool strip",
800 toolbar,
801 480.0,
802 48.0,
803 0.36,
804 ),
805 slot(
806 "palette",
807 LayoutRecipeSlotKind::Palette,
808 "Tools",
809 palette,
810 200.0,
811 260.0,
812 0.46,
813 ),
814 slot(
815 "canvas",
816 LayoutRecipeSlotKind::Canvas,
817 "Canvas",
818 canvas,
819 420.0,
820 360.0,
821 1.0,
822 ),
823 slot(
824 "properties",
825 LayoutRecipeSlotKind::Inspector,
826 "Properties",
827 properties,
828 250.0,
829 260.0,
830 0.58,
831 ),
832 slot(
833 "timeline",
834 LayoutRecipeSlotKind::Timeline,
835 "Timeline",
836 timeline,
837 420.0,
838 94.0,
839 0.7,
840 ),
841 ])
842}
843
844fn command_palette_slots(viewport: Rect) -> Vec<LayoutRecipeSlot> {
845 let content = viewport.inset(Insets::all(16.0));
846 let header = top(content, 54.0);
847 let status = bottom(content, 30.0);
848 let main = between(header, status, 12.0, content);
849 let palette_w = main.width.clamp(320.0, 760.0);
850 let palette_h = main.height.clamp(180.0, 420.0);
851 let palette = Rect::new(
852 main.x + (main.width - palette_w) * 0.5,
853 main.y + (main.height - palette_h) * 0.35,
854 palette_w,
855 palette_h,
856 );
857 collect_slots([
858 slot(
859 "header",
860 LayoutRecipeSlotKind::Header,
861 "Minimal chrome",
862 header,
863 360.0,
864 48.0,
865 0.28,
866 ),
867 slot(
868 "workspace",
869 LayoutRecipeSlotKind::Main,
870 "Workspace",
871 main,
872 420.0,
873 320.0,
874 0.62,
875 ),
876 slot(
877 "palette",
878 LayoutRecipeSlotKind::Overlay,
879 "Command palette",
880 palette,
881 320.0,
882 180.0,
883 1.0,
884 ),
885 slot(
886 "status",
887 LayoutRecipeSlotKind::Status,
888 "Hints",
889 status,
890 320.0,
891 24.0,
892 0.2,
893 ),
894 ])
895}
896
897fn collect_slots<const N: usize>(slots: [LayoutRecipeSlot; N]) -> Vec<LayoutRecipeSlot> {
898 slots
899 .into_iter()
900 .filter(|slot| !slot.rect.is_empty())
901 .take(LAYOUT_RECIPE_MAX_SLOTS)
902 .collect()
903}
904
905fn slot(
906 id: &str,
907 kind: LayoutRecipeSlotKind,
908 label: &str,
909 rect: Rect,
910 min_width: f32,
911 min_height: f32,
912 emphasis: f32,
913) -> LayoutRecipeSlot {
914 LayoutRecipeSlot::new(id, kind, label, rect)
915 .with_min_size(min_width, min_height)
916 .with_emphasis(emphasis)
917}
918
919fn top(rect: Rect, height: f32) -> Rect {
920 Rect::new(rect.x, rect.y, rect.width, height.min(rect.height).max(0.0))
921}
922
923fn bottom(rect: Rect, height: f32) -> Rect {
924 let height = height.min(rect.height).max(0.0);
925 Rect::new(rect.x, rect.bottom() - height, rect.width, height)
926}
927
928fn left(rect: Rect, width: f32) -> Rect {
929 Rect::new(rect.x, rect.y, width.min(rect.width).max(0.0), rect.height)
930}
931
932fn right(rect: Rect, width: f32) -> Rect {
933 let width = width.min(rect.width).max(0.0);
934 Rect::new(rect.right() - width, rect.y, width, rect.height)
935}
936
937fn above(anchor: Rect, height: f32, gap: f32, bounds: Rect) -> Rect {
938 Rect::new(bounds.x, anchor.y - gap - height, bounds.width, height).clamp_within(bounds)
939}
940
941fn below(anchor: Rect, gap: f32, bounds: Rect) -> Rect {
942 Rect::new(
943 bounds.x,
944 anchor.bottom() + gap,
945 bounds.width,
946 (bounds.bottom() - anchor.bottom() - gap).max(0.0),
947 )
948}
949
950fn between(top_rect: Rect, bottom_rect: Rect, gap: f32, bounds: Rect) -> Rect {
951 let y = top_rect.bottom() + gap;
952 let bottom = bottom_rect.y - gap;
953 Rect::new(bounds.x, y, bounds.width, (bottom - y).max(0.0)).clamp_within(bounds)
954}
955
956fn gap_if(size: f32) -> f32 {
957 if size > 0.0 {
958 12.0
959 } else {
960 0.0
961 }
962}
963
964fn sanitize_recipe_id(input: &str) -> String {
965 let id = sanitize_str(input, 96).trim().replace(' ', "_");
966 if id.is_empty() {
967 "slot".to_string()
968 } else {
969 id
970 }
971}
972
973fn sanitize_rect(rect: Rect) -> Rect {
974 if rect.is_finite() && rect.width >= 0.0 && rect.height >= 0.0 {
975 rect
976 } else {
977 Rect::ZERO
978 }
979}
980
981fn finite_range(value: f32, min: f32, max: f32, fallback: f32) -> f32 {
982 if value.is_finite() {
983 value.clamp(min, max)
984 } else {
985 fallback
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 #[test]
994 fn built_in_recipe_kinds_are_stable_and_cover_requested_shells() {
995 let labels = built_in_recipe_kinds()
996 .iter()
997 .map(|kind| kind.label())
998 .collect::<Vec<_>>();
999
1000 assert_eq!(
1001 labels,
1002 [
1003 "ai_chat",
1004 "developer_assistant",
1005 "audio_mixer",
1006 "audio_plugin",
1007 "dashboard",
1008 "document_editor",
1009 "preferences",
1010 "browser",
1011 "creative_tool",
1012 "command_palette_first",
1013 ]
1014 );
1015 }
1016
1017 #[test]
1018 fn recipes_have_finite_slots_inside_viewport() {
1019 let viewport = Rect::new(0.0, 0.0, 1440.0, 900.0);
1020
1021 for recipe in built_in_layout_recipes(viewport) {
1022 assert!(!recipe.slots.is_empty(), "{:?}", recipe.kind);
1023 assert!(recipe.content_bounds().is_finite());
1024 for slot in recipe.slots {
1025 assert!(slot.rect.is_finite(), "{}", slot.id);
1026 assert!(!slot.rect.is_empty(), "{}", slot.id);
1027 assert_eq!(
1028 slot.rect.intersection(viewport),
1029 Some(slot.rect),
1030 "{}",
1031 slot.id
1032 );
1033 assert!((0.0..=1.0).contains(&slot.emphasis));
1034 }
1035 }
1036 }
1037
1038 #[test]
1039 fn compact_viewports_drop_optional_slots_but_keep_main_content() {
1040 let recipe = build_layout_recipe(AppRecipeKind::AiChat, Rect::new(0.0, 0.0, 720.0, 620.0));
1041
1042 assert!(recipe.slot("navigation").is_none());
1043 assert!(recipe.slot("inspector").is_none());
1044 assert!(recipe.slot("transcript").is_some());
1045 assert!(recipe.slot("prompt").is_some());
1046 }
1047
1048 #[test]
1049 fn invalid_viewports_return_empty_recipes() {
1050 let recipe = build_layout_recipe(
1051 AppRecipeKind::Dashboard,
1052 Rect::new(f32::NAN, 0.0, 10.0, 10.0),
1053 );
1054
1055 assert_eq!(recipe.viewport, Rect::ZERO);
1056 assert!(recipe.slots.is_empty());
1057 }
1058
1059 #[cfg(feature = "serde")]
1060 #[test]
1061 fn layout_recipes_round_trip_through_serde() {
1062 let recipe = build_layout_recipe(
1063 AppRecipeKind::AudioMixer,
1064 Rect::new(0.0, 0.0, 1440.0, 900.0),
1065 );
1066
1067 let encoded = serde_json::to_string(&recipe).unwrap();
1068 let decoded: LayoutRecipe = serde_json::from_str(&encoded).unwrap();
1069
1070 assert_eq!(decoded.kind, AppRecipeKind::AudioMixer);
1071 assert_eq!(decoded.slots.len(), recipe.slots.len());
1072 }
1073}