Skip to main content

rae/
recipes.rs

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}