Skip to main content

kitchen_shell/
frame.rs

1//! The frame every app shares ([`Shell`]): an app hands it its registry, its workspaces, its
2//! menus and its Settings sections ([`ShellSpec`]), and the frame draws them the same way in
3//! every app (Affinity's layout):
4//!
5//! - **Title bar**: the app's menu (named after the app: files, Settings) and Help at the left,
6//!   then the workspace switcher; at the right the project, the remote button (where the app
7//!   runs), the layout toggles (commands, as icon buttons) and the theme.
8//! - **Menu row**: the current workspace's menus, built from the registry.
9//! - **Body**: the current workspace's view, a card on the chrome.
10//! - **Status bar**: a status line, the running jobs ([`crate::jobs`]) with a cancel button each,
11//!   and this computer's load (CPU, memory).
12//! - **Settings** (Ctrl+,): sections on the left (Appearance, built in, then the app's), the
13//!   section's settings on the right.
14//! - **Search** (Ctrl+K, F3) over the registry.
15//! - **Window frame**: where the app draws its own frame (client-side decorations on Linux), the
16//!   window has rounded corners while it floats and resizes from its own edge; no shadow margin
17//!   (compositors that do not draw it showed what was behind the window there).
18//! - **Overlay**: one view of the app's over everything ([`Shell::set_overlay`]: a help sheet, a
19//!   dialog).
20//!
21//! The frame adds its own commands to the registry (`shell.search`, `shell.settings`,
22//! `shell.theme`, `shell.workspace.<id>`), so they are in the search and an agent's tools too.
23
24use std::rc::Rc;
25
26use gpui_kit::assets::IconName;
27use gpui_kit::component::button::{Button, ButtonVariants as _};
28use gpui_kit::component::menu::DropdownMenu as _;
29use gpui_kit::component::{Icon, Selectable as _, Sizable as _, TitleBar, h_flex, v_flex};
30use gpui_kit::prelude::FluentBuilder as _;
31use gpui_kit::{
32    AnyElement, AnyView, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, FontWeight, InteractiveElement as _, IntoElement,
33    KeyDownEvent, MouseButton, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, WeakEntity,
34    Window, div, px,
35};
36use kitchen_ui::theme::{self, Look, color};
37use kitchen_ui::widgets::{AccentChanged, AccentPicker, MenuSelect, icon_button, row, stepper};
38use serde_json::Value;
39
40use crate::command::{CommandSpec, MenuItem, MenuSpec, Registry, RunCommand};
41use crate::jobs;
42use crate::search::{Search, SearchEvent};
43
44/// Height of the title bar (px).
45pub const TITLE_BAR_H: f32 = 36.;
46/// Height of the menu row (px).
47pub const MENU_ROW_H: f32 = 32.;
48/// Height of the status bar (px): one 24 px target.
49pub const STATUS_BAR_H: f32 = 24.;
50/// The band at the window's own edge that resizes it while it floats (px).
51pub const RESIZE_BAND: f32 = 6.;
52/// Rounded corners of the window while it floats (px).
53pub const WINDOW_RADIUS: f32 = 10.;
54
55/// A workspace of the app (Explore, Pipeline, Analyze; Sorting…).
56pub struct WorkspaceSpec {
57    /// Its id (`explore`): the command `shell.workspace.<id>` switches to it.
58    pub id: SharedString,
59    /// Its name on the switcher.
60    pub title: SharedString,
61    /// Its icon on the switcher.
62    pub icon: IconName,
63    /// What it is for (the switcher's tooltip).
64    pub hint: SharedString,
65    /// Its menu row.
66    pub menus: Vec<MenuSpec>,
67    /// What it shows.
68    pub view: AnyView,
69    /// A count shown on its switcher button when above zero (changed lines, unread messages).
70    pub badge: Option<Rc<dyn Fn(&App) -> usize>>,
71}
72
73impl WorkspaceSpec {
74    /// A workspace `id` titled `title`, showing `view`.
75    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>, icon: IconName, view: impl Into<AnyView>) -> Self {
76        Self { id: id.into(), title: title.into(), icon, hint: SharedString::default(), menus: Vec::new(), view: view.into(), badge: None }
77    }
78
79    /// Sets what it is for.
80    pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
81        self.hint = hint.into();
82        self
83    }
84
85    /// Sets its menu row.
86    pub fn menus(mut self, menus: Vec<MenuSpec>) -> Self {
87        self.menus = menus;
88        self
89    }
90
91    /// Shows `count` on its switcher button when above zero.
92    pub fn badge(mut self, count: impl Fn(&App) -> usize + 'static) -> Self {
93        self.badge = Some(Rc::new(count));
94        self
95    }
96}
97
98/// Draws a Settings section's body.
99pub type SectionBody = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
100
101/// A section of Settings an app registers (Compute, Models, Plugins…).
102#[derive(Clone)]
103pub struct SettingsSection {
104    /// Its name in the list.
105    pub title: SharedString,
106    /// Its icon in the list.
107    pub icon: IconName,
108    /// Its settings.
109    pub body: SectionBody,
110}
111
112impl SettingsSection {
113    /// A section `title` drawing `body`.
114    pub fn new(title: impl Into<SharedString>, icon: IconName, body: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self {
115        Self { title: title.into(), icon, body: Rc::new(body) }
116    }
117}
118
119/// Where the app runs, at the right of the title bar (as VS Code's remote indicator): this
120/// computer, or a server. Its menu lists the places.
121#[derive(Clone, Debug, PartialEq)]
122pub struct RemoteSpec {
123    /// Where it runs now ("This computer").
124    pub label: SharedString,
125    /// Its icon.
126    pub icon: IconName,
127    /// The places to choose from (commands).
128    pub menu: Vec<MenuItem>,
129}
130
131/// What an app hands the frame.
132pub struct ShellSpec {
133    /// The app's name: its menu's title.
134    pub app_name: SharedString,
135    /// Its commands.
136    pub registry: Registry,
137    /// The app menu's entries (files, projects…; Settings is added at its end).
138    pub app_menu: Vec<MenuItem>,
139    /// The Help menu's entries (the search and Settings are added).
140    pub help_menu: Vec<MenuItem>,
141    /// Its workspaces, in the switcher's order.
142    pub workspaces: Vec<WorkspaceSpec>,
143    /// Its Settings sections (after the built-in Appearance).
144    pub settings: Vec<SettingsSection>,
145    /// Commands shown as icon buttons at the right of the title bar (side panels…).
146    pub layout_toggles: Vec<SharedString>,
147    /// Where the app runs.
148    pub remote: Option<RemoteSpec>,
149    /// Whether the status bar shows this computer's load (off in tests).
150    pub meters: bool,
151    /// The app's own readings in the status bar, before the jobs (the frame's numbers, a device).
152    pub status_items: Option<StatusItems>,
153}
154
155/// Draws the app's readings in the status bar.
156pub type StatusItems = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
157
158impl ShellSpec {
159    /// An app named `app_name` with `registry` and `workspaces`, the rest empty.
160    pub fn new(app_name: impl Into<SharedString>, registry: Registry, workspaces: Vec<WorkspaceSpec>) -> Self {
161        Self {
162            app_name: app_name.into(),
163            registry,
164            app_menu: Vec::new(),
165            help_menu: Vec::new(),
166            workspaces,
167            settings: Vec::new(),
168            layout_toggles: Vec::new(),
169            remote: None,
170            meters: true,
171            status_items: None,
172        }
173    }
174}
175
176/// What the frame tells the app.
177#[derive(Clone, Debug, PartialEq)]
178pub enum ShellEvent {
179    /// The user changed the look in the title bar or Settings: store it in the preferences.
180    LookChanged(Look),
181    /// The user switched workspace.
182    WorkspaceChanged(SharedString),
183}
184
185/// The frame.
186pub struct Shell {
187    app_name: SharedString,
188    registry: Registry,
189    app_menu: MenuSpec,
190    help_menu: MenuSpec,
191    workspaces: Vec<WorkspaceSpec>,
192    sections: Vec<SettingsSection>,
193    layout_toggles: Vec<SharedString>,
194    remote: Option<RemoteSpec>,
195    status_items: Option<StatusItems>,
196    overlay: Option<AnyView>,
197    current: usize,
198    search: Option<(Entity<Search>, Subscription)>,
199    /// The Settings section shown, when Settings is open.
200    settings: Option<usize>,
201    accent: Entity<AccentPicker>,
202    status: SharedString,
203    project: Option<SharedString>,
204    /// This computer's load: CPU %, memory used and installed (bytes).
205    meters: Option<(f32, u64, u64)>,
206    focus: FocusHandle,
207    _subs: Vec<Subscription>,
208}
209
210impl EventEmitter<ShellEvent> for Shell {}
211
212impl Shell {
213    /// The frame for `spec`; adds the frame's commands to the registry and focuses the frame.
214    /// Bind the keys after ([`Registry::bind_keys`]), once every command is registered.
215    pub fn new(spec: ShellSpec, window: &mut Window, cx: &mut Context<Self>) -> Self {
216        let me = cx.entity().downgrade();
217        spec.registry.extend(frame_commands(&me, &spec.workspaces));
218        let mut app_items = spec.app_menu;
219        if !app_items.is_empty() {
220            app_items.push(MenuItem::Separator);
221        }
222        app_items.push(MenuItem::command("shell.settings"));
223        let mut help_items = spec.help_menu;
224        if !help_items.is_empty() {
225            help_items.push(MenuItem::Separator);
226        }
227        help_items.push(MenuItem::command("shell.search"));
228        let accent = cx.new(|cx| AccentPicker::new(theme::look().accent, window, cx));
229        let mut subs = vec![cx.subscribe(&accent, |_, _, e: &AccentChanged, cx| {
230            let look = theme::look().with_accent(e.0);
231            theme::use_look(look, cx);
232            cx.emit(ShellEvent::LookChanged(look));
233            cx.notify();
234        })];
235        subs.push(cx.observe_window_appearance(window, |_, _, cx| cx.notify()));
236        let focus = cx.focus_handle();
237        window.focus(&focus, cx);
238        let shell = Self {
239            app_name: spec.app_name.clone(),
240            registry: spec.registry,
241            app_menu: MenuSpec::new(spec.app_name, app_items),
242            help_menu: MenuSpec::new("Help", help_items),
243            workspaces: spec.workspaces,
244            sections: spec.settings,
245            layout_toggles: spec.layout_toggles,
246            remote: spec.remote,
247            status_items: spec.status_items,
248            overlay: None,
249            current: 0,
250            search: None,
251            settings: None,
252            accent,
253            status: "Ready".into(),
254            project: None,
255            meters: None,
256            focus,
257            _subs: subs,
258        };
259        shell.watch_jobs(cx);
260        if spec.meters {
261            shell.watch_meters(cx);
262        }
263        shell
264    }
265
266    /// Redraws when a job starts, moves or ends.
267    fn watch_jobs(&self, cx: &mut Context<Self>) {
268        cx.spawn(async move |this, cx| {
269            let mut seen = jobs::generation();
270            loop {
271                cx.background_executor().timer(std::time::Duration::from_millis(250)).await;
272                let now = jobs::generation();
273                if now != seen {
274                    seen = now;
275                    if this.update(cx, |_, cx| cx.notify()).is_err() {
276                        break;
277                    }
278                }
279            }
280        })
281        .detach();
282    }
283
284    /// CPU and memory meters (as VS Code's resource monitor), read off the UI thread every 2 s.
285    fn watch_meters(&self, cx: &mut Context<Self>) {
286        cx.spawn(async move |this, cx| {
287            // No meters where the system cannot be read
288            let Ok(mut sys) = sysinfo::System::new() else { return };
289            loop {
290                let (s, m) = cx
291                    .background_executor()
292                    .spawn(async move {
293                        sys.refresh_cpu_usage();
294                        sys.refresh_memory();
295                        let m = (sys.global_cpu_usage(), sys.used_memory(), sys.total_memory());
296                        (sys, m)
297                    })
298                    .await;
299                sys = s;
300                let alive = this.update(cx, |s, cx| {
301                    s.meters = Some(m);
302                    cx.notify();
303                });
304                if alive.is_err() {
305                    break;
306                }
307                cx.background_executor().timer(std::time::Duration::from_secs(2)).await;
308            }
309        })
310        .detach();
311    }
312
313    /// The registry (the app's commands and the frame's).
314    pub fn registry(&self) -> &Registry {
315        &self.registry
316    }
317
318    /// The workspace shown.
319    pub fn workspace(&self) -> &WorkspaceSpec {
320        &self.workspaces[self.current]
321    }
322
323    /// Shows the workspace `id`; whether there is one.
324    pub fn set_workspace(&mut self, id: &str, cx: &mut Context<Self>) -> bool {
325        let Some(i) = self.workspaces.iter().position(|w| w.id == id) else { return false };
326        if i != self.current {
327            self.current = i;
328            cx.emit(ShellEvent::WorkspaceChanged(self.workspaces[i].id.clone()));
329        }
330        cx.notify();
331        true
332    }
333
334    /// The id of the workspace shown.
335    pub fn workspace_id(&self) -> SharedString {
336        self.workspaces[self.current].id.clone()
337    }
338
339    /// Replaces the menu row of the workspace `id`; whether there is one.
340    pub fn set_menus(&mut self, id: &str, menus: Vec<MenuSpec>, cx: &mut Context<Self>) -> bool {
341        let Some(w) = self.workspaces.iter_mut().find(|w| w.id == id) else { return false };
342        w.menus = menus;
343        cx.notify();
344        true
345    }
346
347    /// Shows `view` over everything (a help sheet, a dialog), or takes it away (`None`).
348    pub fn set_overlay(&mut self, view: Option<AnyView>, window: &mut Window, cx: &mut Context<Self>) {
349        self.overlay = view;
350        if self.overlay.is_none() {
351            window.focus(&self.focus, cx);
352        }
353        cx.notify();
354    }
355
356    /// Whether an overlay is shown.
357    pub fn has_overlay(&self) -> bool {
358        self.overlay.is_some()
359    }
360
361    /// Whether the Settings section `title` exists (0 is Appearance, then the app's).
362    pub fn settings_index(&self, title: &str) -> Option<usize> {
363        if title == "Appearance" {
364            return Some(0);
365        }
366        self.sections.iter().position(|s| s.title == title).map(|i| i + 1)
367    }
368
369    /// Sets the status line.
370    pub fn set_status(&mut self, status: impl Into<SharedString>, cx: &mut Context<Self>) {
371        self.status = status.into();
372        cx.notify();
373    }
374
375    /// The status line.
376    pub fn status(&self) -> &SharedString {
377        &self.status
378    }
379
380    /// Sets the project named in the title bar (`None`: none open).
381    pub fn set_project(&mut self, project: Option<SharedString>, cx: &mut Context<Self>) {
382        self.project = project;
383        cx.notify();
384    }
385
386    /// The search, when open.
387    pub fn search(&self) -> Option<Entity<Search>> {
388        self.search.as_ref().map(|(s, _)| s.clone())
389    }
390
391    /// Opens the search over every command.
392    pub fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
393        self.open_search_with(None, window, cx);
394    }
395
396    /// Opens the search over the commands `filter` accepts, titled `title` (Process ▸ "Find an
397    /// operation…": one kind of command); every command when `None`. Open already: closes it.
398    pub fn open_search_with(&mut self, filter: Option<(SharedString, crate::search::Filter)>, window: &mut Window, cx: &mut Context<Self>) {
399        if self.search.take().is_some() {
400            window.focus(&self.focus, cx);
401            cx.notify();
402            return;
403        }
404        let registry = self.registry.clone();
405        let search = cx.new(|cx| {
406            let s = Search::new(registry, window, cx);
407            match filter {
408                Some((title, f)) => s.title(title).filter(move |c| f(c)),
409                None => s,
410            }
411        });
412        let sub = cx.subscribe_in(&search, window, |this, _, e: &SearchEvent, window, cx| {
413            this.search = None;
414            window.focus(&this.focus, cx);
415            if let SearchEvent::Run(id) = e {
416                // Run once this update is over: the command may change the frame itself
417                let (registry, id) = (this.registry.clone(), id.clone());
418                let me = cx.entity().downgrade();
419                window.defer(cx, move |window, cx| {
420                    if let Err(e) = registry.run(&id, Value::Null, window, cx) {
421                        let _ = me.update(cx, |s, cx| s.set_status(format!("{id}: {e}"), cx));
422                    }
423                });
424            }
425            cx.notify();
426        });
427        self.search = Some((search, sub));
428        cx.notify();
429    }
430
431    /// The Settings section shown, when Settings is open (0 is Appearance).
432    pub fn settings_section(&self) -> Option<usize> {
433        self.settings
434    }
435
436    /// Opens Settings at `section` (0: Appearance, then the app's in order), or closes it.
437    pub fn show_settings(&mut self, section: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
438        self.settings = section.map(|s| s.min(self.sections.len()));
439        if self.settings.is_none() {
440            window.focus(&self.focus, cx);
441        }
442        cx.notify();
443    }
444
445    /// Sets the look and tells the app.
446    pub fn set_look(&mut self, look: Look, cx: &mut Context<Self>) {
447        theme::use_look(look, cx);
448        cx.emit(ShellEvent::LookChanged(look));
449        cx.notify();
450    }
451
452    fn title_bar(&self, rounded: bool, cx: &mut Context<Self>) -> impl IntoElement {
453        let app = self.registry.menu_button("menu-app", &MenuSpec { hint: "Files, projects, Settings".into(), ..self.app_menu.clone() });
454        let help = self.registry.menu_button("menu-help", &self.help_menu);
455        let switcher = h_flex().gap(px(2.)).p(px(2.)).rounded(px(theme::RADIUS)).bg(color::control().opacity(0.6)).children(self.workspaces.iter().enumerate().map(|(i, w)| {
456            let id = w.id.clone();
457            let count = w.badge.as_ref().map_or(0, |f| f(cx));
458            let pill = (count > 0).then(|| {
459                div().px(px(theme::XS)).rounded(px(theme::RADIUS_SM)).bg(color::accent()).text_color(color::on_accent()).text_xs().font_weight(FontWeight::SEMIBOLD).child(if count > 999 { "999+".to_string() } else { count.to_string() })
460            });
461            Button::new(SharedString::from(format!("workspace-{}", w.id)))
462                .small()
463                .ghost()
464                .icon(w.icon)
465                .label(w.title.clone())
466                .children(pill)
467                .tooltip(w.hint.clone())
468                .selected(i == self.current)
469                .on_click(cx.listener(move |this, _, _, cx| {
470                    this.set_workspace(&id, cx);
471                }))
472        }));
473        let toggles = self.layout_toggles.iter().filter_map(|id| self.registry.get(id)).map(|c| {
474            let (registry, id) = (self.registry.clone(), c.id.clone());
475            let tip = match c.key_label() {
476                Some(k) => format!("{} ({k})", c.title),
477                None => c.title.to_string(),
478            };
479            icon_button(SharedString::from(format!("toggle-{}", c.id)), c.icon, tip).selected(c.checked.as_ref().is_some_and(|f| f(cx))).on_click(move |_, window, cx| {
480                let _ = registry.run(&id, Value::Null, window, cx);
481            })
482        });
483        let remote = self.remote.clone().map(|r| {
484            let (registry, items) = (self.registry.clone(), Rc::new(r.menu.clone()));
485            Button::new("remote")
486                .ghost()
487                .small()
488                .icon(r.icon)
489                .label(r.label.clone())
490                .tooltip("Where the app runs: this computer or a server")
491                .dropdown_menu(move |m, window, cx| registry.fill_menu(m.label("Run on"), &items, window, cx))
492        });
493        let dark = theme::is_dark();
494        let theme_button = icon_button("title-theme", if dark { IconName::Sun } else { IconName::Moon }, if dark { "Light theme" } else { "Dark theme" }).on_click(cx.listener(|this, _, _, cx| {
495            let l = theme::look();
496            this.set_look(l.with_dark(!l.dark), cx);
497        }));
498        let project = self.project.clone().unwrap_or_else(|| "No project".into());
499        // GPUI clips to rectangles: the bars round their own corners inside a rounded frame
500        TitleBar::new().when(rounded, |t| t.rounded_t(px(WINDOW_RADIUS - 1.0))).child(
501            h_flex()
502                .w_full()
503                .h(px(TITLE_BAR_H))
504                .gap(px(theme::MD))
505                .pr(px(theme::SM))
506                .items_center()
507                .child(h_flex().gap(px(2.)).child(div().font_weight(FontWeight::SEMIBOLD).child(app)).child(help))
508                .child(switcher)
509                .child(div().flex_1())
510                .child(div().min_w_0().text_sm().text_color(color::text_muted()).truncate().child(project))
511                .children(remote)
512                .children(toggles)
513                .child(theme_button),
514        )
515    }
516
517    fn menu_row(&self) -> impl IntoElement {
518        let w = self.workspace();
519        h_flex()
520            .flex_none()
521            .w_full()
522            .h(px(MENU_ROW_H))
523            .px(px(theme::XS))
524            .gap(px(2.))
525            .items_center()
526            .bg(color::panel())
527            .border_b_1()
528            .border_color(color::border())
529            .children(w.menus.iter().enumerate().map(|(i, m)| self.registry.menu_button(format!("menu-{}-{i}", w.id), m)))
530    }
531
532    fn status_bar(&self, rounded: bool, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
533        let pct = |f: Option<f32>| f.map_or(String::new(), |f| format!(" {:.0} %", f * 100.0));
534        let items = self.status_items.clone().map(|f| f(window, cx));
535        let jobs = jobs::running().into_iter().map(|j| {
536            let id = j.id;
537            h_flex()
538                .gap(px(theme::XS))
539                .items_center()
540                .child(div().max_w(px(320.)).truncate().text_color(color::accent_mark()).child(format!("{}{}", j.name, pct(j.progress))))
541                .child(icon_button(SharedString::from(format!("job-cancel-{id}")), IconName::Close, format!("Stop: {}", j.name)).xsmall().on_click(cx.listener(move |_, _, _, cx| {
542                    jobs::cancel_id(id);
543                    cx.notify();
544                })))
545        });
546        let meter = |icon: IconName, text: String, tip: &'static str| {
547            h_flex().id(tip).gap(px(theme::XS)).items_center().text_color(color::text_muted()).child(Icon::new(icon).small()).child(text).tooltip(move |w, cx| gpui_kit::component::tooltip::Tooltip::new(tip).build(w, cx))
548        };
549        h_flex()
550            .flex_none()
551            .h(px(STATUS_BAR_H))
552            .px(px(theme::MD))
553            .gap(px(theme::LG))
554            .items_center()
555            .bg(color::chrome())
556            .when(rounded, |d| d.rounded_b(px(WINDOW_RADIUS - 1.0)))
557            .text_xs()
558            .child(div().min_w_0().flex_1().truncate().child(self.status.clone()))
559            .children(items)
560            .children(jobs)
561            .children(self.meters.map(|(cpu, used, total)| {
562                let gb = |b: u64| b as f64 / 1e9;
563                h_flex()
564                    .gap(px(theme::MD))
565                    .child(meter(IconName::Cpu, format!("{cpu:.0} %"), "CPU in use, all cores"))
566                    .child(meter(IconName::MemoryStick, format!("{:.1} / {:.1} GB", gb(used), gb(total)), "Memory in use / installed"))
567            }))
568    }
569
570    fn appearance(&self, cx: &mut Context<Self>) -> AnyElement {
571        let look = theme::look();
572        let me = cx.entity().downgrade();
573        let mode = MenuSelect::new("settings-theme", vec!["Dark".into(), "Light".into()], Some(if look.dark { 0 } else { 1 }), move |i, _, cx| {
574            let _ = me.update(cx, |s, cx| s.set_look(theme::look().with_dark(i == 0), cx));
575        });
576        let me = cx.entity().downgrade();
577        let size = stepper("settings-text", format!("{} px", look.font_size), move |step, _, cx| {
578            let _ = me.update(cx, |s, cx| {
579                let l = theme::look();
580                s.set_look(l.with_font_size(l.font_size + step), cx)
581            });
582        });
583        v_flex()
584            .gap(px(theme::MD))
585            .child(row("Theme", div().w_full().child(mode)))
586            .child(row("Accent", self.accent.clone()))
587            .child(row("Text size", size))
588            .child(div().text_xs().text_color(color::text_muted()).child(format!("The accent defaults to {}'s; any colour is adjusted until text on it and its marks pass contrast.", self.app_name)))
589            .into_any_element()
590    }
591
592    fn settings_view(&self, section: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
593        let mut nav: Vec<(SharedString, IconName)> = vec![("Appearance".into(), IconName::Palette)];
594        nav.extend(self.sections.iter().map(|s| (s.title.clone(), s.icon)));
595        let body = if section == 0 { self.appearance(cx) } else { (self.sections[section - 1].body)(window, cx) };
596        let close = cx.entity().downgrade();
597        let list = v_flex().w(px(200.)).flex_none().gap(px(2.)).p(px(theme::SM)).border_r_1().border_color(color::border()).children(nav.iter().enumerate().map(|(i, (title, icon))| {
598            Button::new(SharedString::from(format!("settings-section-{i}"))).ghost().small().w_full().justify_start().icon(*icon).label(title.clone()).selected(i == section).on_click(cx.listener(move |this, _, window, cx| this.show_settings(Some(i), window, cx)))
599        }));
600        div()
601            .id("settings-backdrop")
602            .absolute()
603            .inset_0()
604            .flex()
605            .items_center()
606            .justify_center()
607            .p(px(theme::XL))
608            .bg(gpui_kit::black().opacity(0.35))
609            .on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| this.show_settings(None, window, cx)))
610            .child(
611                v_flex()
612                    .id("settings")
613                    .occlude()
614                    .w_full()
615                    .max_w(px(820.))
616                    .h_full()
617                    .max_h(px(600.))
618                    .rounded(px(theme::RADIUS_LG))
619                    .border_1()
620                    .border_color(color::border())
621                    .bg(color::raised())
622                    .text_color(color::text())
623                    .shadow_lg()
624                    .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
625                    .child(
626                        h_flex()
627                            .flex_none()
628                            .px(px(theme::LG))
629                            .py(px(theme::SM))
630                            .border_b_1()
631                            .border_color(color::border())
632                            .child(div().flex_1().text_base().font_weight(FontWeight::SEMIBOLD).child("Settings"))
633                            .child(icon_button("settings-close", IconName::Close, "Close (Esc)").on_click(move |_, window, cx| {
634                                let _ = close.update(cx, |s, cx| s.show_settings(None, window, cx));
635                            })),
636                    )
637                    .child(h_flex().flex_1().min_h_0().items_start().child(list).child(v_flex().id("settings-body").flex_1().min_w_0().h_full().overflow_y_scroll().p(px(theme::LG)).gap(px(theme::MD)).child(div().text_base().font_weight(FontWeight::SEMIBOLD).child(nav[section].0.clone())).child(body))),
638            )
639            .into_any_element()
640    }
641}
642
643/// The frame's own commands.
644fn frame_commands(me: &WeakEntity<Shell>, workspaces: &[WorkspaceSpec]) -> Vec<CommandSpec> {
645    let (m1, m2, m3) = (me.clone(), me.clone(), me.clone());
646    let mut out = vec![
647        CommandSpec::new("shell.search", "Search…", IconName::Search, move |window, cx| {
648            let _ = m1.update(cx, |s, cx| s.open_search(window, cx));
649        })
650        .group("App")
651        .hint("Find any command by name")
652        .key("secondary-k")
653        .read_only(),
654        CommandSpec::new("shell.settings", "Settings…", IconName::Settings, move |window, cx| {
655            let _ = m2.update(cx, |s, cx| {
656                let open = if s.settings.is_some() { None } else { Some(0) };
657                s.show_settings(open, window, cx)
658            });
659        })
660        .group("App")
661        .hint("Theme, accent, text size and the app's settings")
662        .key("secondary-,")
663        .read_only(),
664        CommandSpec::new("shell.theme", "Dark theme", IconName::Moon, move |_, cx| {
665            let _ = m3.update(cx, |s, cx| {
666                let l = theme::look();
667                s.set_look(l.with_dark(!l.dark), cx)
668            });
669        })
670        .group("View")
671        .hint("Switch between the dark and the light theme")
672        .checked(|_| theme::is_dark()),
673    ];
674    for (i, w) in workspaces.iter().enumerate() {
675        let (m, id) = (me.clone(), w.id.clone());
676        let mut c = CommandSpec::new(format!("shell.workspace.{}", w.id), format!("Go to {}", w.title), w.icon, move |_, cx| {
677            let _ = m.update(cx, |s, cx| s.set_workspace(&id, cx));
678        })
679        .group("Workspaces")
680        .hint(if w.hint.is_empty() { format!("Show the {} workspace", w.title).into() } else { w.hint.clone() })
681        .read_only();
682        if i < 9 {
683            c = c.key(format!("secondary-{}", i + 1));
684        }
685        out.push(c);
686    }
687    out
688}
689
690/// The edge a press at `p` (window px) resizes, within [`RESIZE_BAND`] of a `size` window's edge.
691pub fn resize_edge(p: gpui_kit::Point<gpui_kit::Pixels>, size: gpui_kit::Size<gpui_kit::Pixels>) -> Option<gpui_kit::ResizeEdge> {
692    use gpui_kit::ResizeEdge as E;
693    let band = RESIZE_BAND;
694    let (x, y, w, h) = (p.x.as_f32(), p.y.as_f32(), size.width.as_f32(), size.height.as_f32());
695    let (l, r, t, b) = (x < band, x > w - band, y < band, y > h - band);
696    Some(match (l, r, t, b) {
697        (true, _, true, _) => E::TopLeft,
698        (_, true, true, _) => E::TopRight,
699        (true, _, _, true) => E::BottomLeft,
700        (_, true, _, true) => E::BottomRight,
701        (true, ..) => E::Left,
702        (_, true, ..) => E::Right,
703        (_, _, true, _) => E::Top,
704        (_, _, _, true) => E::Bottom,
705        _ => return None,
706    })
707}
708
709/// Whether the window draws its own rounded frame: client-side decorations on a floating window
710/// (square when maximised, full screen or tiled).
711pub fn frame_rounded(window: &Window) -> bool {
712    match window.window_decorations() {
713        gpui_kit::Decorations::Client { tiling } => !(tiling.top || tiling.left || tiling.right || tiling.bottom) && !window.is_maximized() && !window.is_fullscreen(),
714        gpui_kit::Decorations::Server => false,
715    }
716}
717
718impl Render for Shell {
719    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
720        let registry = self.registry.clone();
721        let settings = self.settings.map(|s| self.settings_view(s, window, cx));
722        // Linux draws the frame itself (client-side decorations): no inset for a shadow margin
723        if matches!(window.window_decorations(), gpui_kit::Decorations::Client { .. }) {
724            window.set_client_inset(px(0.0));
725        }
726        let rounded = frame_rounded(window);
727        let shell = v_flex()
728            .id("shell")
729            .key_context("Shell")
730            .track_focus(&self.focus)
731            .size_full()
732            .relative()
733            // The chrome under everything: the panes are cards on it
734            .bg(color::chrome())
735            .text_color(color::text())
736            // Not a listener: a command may update the frame itself
737            .on_action(move |a: &RunCommand, window, cx| {
738                if let Err(e) = registry.run(&a.id, Value::Null, window, cx) {
739                    eprintln!("{}: {e}", a.id);
740                }
741            })
742            .on_key_down(cx.listener(|this, e: &KeyDownEvent, window, cx| {
743                if e.keystroke.key == "escape" && this.settings.is_some() {
744                    this.show_settings(None, window, cx);
745                    cx.stop_propagation();
746                }
747            }))
748            .child(self.title_bar(rounded, cx))
749            .child(self.menu_row())
750            .child(div().flex_1().min_h_0().p(px(theme::PANE_GAP)).child(div().size_full().rounded(px(theme::RADIUS_LG)).overflow_hidden().bg(color::panel()).child(self.workspace().view.clone())))
751            .child(self.status_bar(rounded, window, cx))
752            .children(self.overlay.clone())
753            .children(settings)
754            .children(self.search.as_ref().map(|(s, _)| s.clone()))
755            // No outline: the compositor's shadow sets a floating window apart
756            .when(rounded, |d| d.rounded(px(WINDOW_RADIUS)).overflow_hidden());
757        div()
758            .id("window-frame")
759            .size_full()
760            .when(rounded, |d| {
761                d.on_mouse_down(MouseButton::Left, |e, window, _| {
762                    if let Some(edge) = resize_edge(e.position, window.viewport_size()) {
763                        window.start_window_resize(edge);
764                    }
765                })
766            })
767            .child(shell)
768    }
769}