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        if let Some(w) = shell.workspaces.first() {
260            crate::activity::focus(&w.title);
261        }
262        shell.watch_jobs(cx);
263        if spec.meters {
264            shell.watch_meters(cx);
265        }
266        shell
267    }
268
269    /// Redraws when a job starts, moves or ends.
270    fn watch_jobs(&self, cx: &mut Context<Self>) {
271        cx.spawn(async move |this, cx| {
272            let mut seen = jobs::generation();
273            loop {
274                cx.background_executor().timer(std::time::Duration::from_millis(250)).await;
275                let now = jobs::generation();
276                if now != seen {
277                    seen = now;
278                    if this.update(cx, |_, cx| cx.notify()).is_err() {
279                        break;
280                    }
281                }
282            }
283        })
284        .detach();
285    }
286
287    /// CPU and memory meters (as VS Code's resource monitor), read off the UI thread every 2 s.
288    fn watch_meters(&self, cx: &mut Context<Self>) {
289        cx.spawn(async move |this, cx| {
290            // No meters where the system cannot be read
291            let Ok(mut sys) = sysinfo::System::new() else { return };
292            loop {
293                let (s, m) = cx
294                    .background_executor()
295                    .spawn(async move {
296                        sys.refresh_cpu_usage();
297                        sys.refresh_memory();
298                        let m = (sys.global_cpu_usage(), sys.used_memory(), sys.total_memory());
299                        (sys, m)
300                    })
301                    .await;
302                sys = s;
303                let alive = this.update(cx, |s, cx| {
304                    s.meters = Some(m);
305                    cx.notify();
306                });
307                if alive.is_err() {
308                    break;
309                }
310                cx.background_executor().timer(std::time::Duration::from_secs(2)).await;
311            }
312        })
313        .detach();
314    }
315
316    /// The registry (the app's commands and the frame's).
317    pub fn registry(&self) -> &Registry {
318        &self.registry
319    }
320
321    /// The workspace shown.
322    pub fn workspace(&self) -> &WorkspaceSpec {
323        &self.workspaces[self.current]
324    }
325
326    /// Shows the workspace `id`; whether there is one.
327    pub fn set_workspace(&mut self, id: &str, cx: &mut Context<Self>) -> bool {
328        let Some(i) = self.workspaces.iter().position(|w| w.id == id) else { return false };
329        if i != self.current {
330            self.current = i;
331            crate::activity::focus(&self.workspaces[i].title);
332            cx.emit(ShellEvent::WorkspaceChanged(self.workspaces[i].id.clone()));
333        }
334        cx.notify();
335        true
336    }
337
338    /// The id of the workspace shown.
339    pub fn workspace_id(&self) -> SharedString {
340        self.workspaces[self.current].id.clone()
341    }
342
343    /// Replaces the menu row of the workspace `id`; whether there is one.
344    pub fn set_menus(&mut self, id: &str, menus: Vec<MenuSpec>, cx: &mut Context<Self>) -> bool {
345        let Some(w) = self.workspaces.iter_mut().find(|w| w.id == id) else { return false };
346        w.menus = menus;
347        cx.notify();
348        true
349    }
350
351    /// Shows `view` over everything (a help sheet, a dialog), or takes it away (`None`).
352    pub fn set_overlay(&mut self, view: Option<AnyView>, window: &mut Window, cx: &mut Context<Self>) {
353        self.overlay = view;
354        if self.overlay.is_none() {
355            window.focus(&self.focus, cx);
356        }
357        cx.notify();
358    }
359
360    /// Whether an overlay is shown.
361    pub fn has_overlay(&self) -> bool {
362        self.overlay.is_some()
363    }
364
365    /// Whether the Settings section `title` exists (0 is Appearance, then the app's).
366    pub fn settings_index(&self, title: &str) -> Option<usize> {
367        if title == "Appearance" {
368            return Some(0);
369        }
370        self.sections.iter().position(|s| s.title == title).map(|i| i + 1)
371    }
372
373    /// Sets the status line.
374    pub fn set_status(&mut self, status: impl Into<SharedString>, cx: &mut Context<Self>) {
375        self.status = status.into();
376        cx.notify();
377    }
378
379    /// The status line.
380    pub fn status(&self) -> &SharedString {
381        &self.status
382    }
383
384    /// Sets the project named in the title bar (`None`: none open).
385    pub fn set_project(&mut self, project: Option<SharedString>, cx: &mut Context<Self>) {
386        self.project = project;
387        cx.notify();
388    }
389
390    /// The search, when open.
391    pub fn search(&self) -> Option<Entity<Search>> {
392        self.search.as_ref().map(|(s, _)| s.clone())
393    }
394
395    /// Opens the search over every command.
396    pub fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
397        self.open_search_with(None, window, cx);
398    }
399
400    /// Opens the search over the commands `filter` accepts, titled `title` (Process ▸ "Find an
401    /// operation…": one kind of command); every command when `None`. Open already: closes it.
402    pub fn open_search_with(&mut self, filter: Option<(SharedString, crate::search::Filter)>, window: &mut Window, cx: &mut Context<Self>) {
403        if self.search.take().is_some() {
404            window.focus(&self.focus, cx);
405            cx.notify();
406            return;
407        }
408        let registry = self.registry.clone();
409        let search = cx.new(|cx| {
410            let s = Search::new(registry, window, cx);
411            match filter {
412                Some((title, f)) => s.title(title).filter(move |c| f(c)),
413                None => s,
414            }
415        });
416        let sub = cx.subscribe_in(&search, window, |this, _, e: &SearchEvent, window, cx| {
417            this.search = None;
418            window.focus(&this.focus, cx);
419            if let SearchEvent::Run(id) = e {
420                // Run once this update is over: the command may change the frame itself
421                let (registry, id) = (this.registry.clone(), id.clone());
422                let me = cx.entity().downgrade();
423                window.defer(cx, move |window, cx| {
424                    if let Err(e) = registry.run_as(crate::activity::Actor::Person, "search", &id, Value::Null, window, cx) {
425                        let _ = me.update(cx, |s, cx| s.set_status(format!("{id}: {e}"), cx));
426                    }
427                });
428            }
429            cx.notify();
430        });
431        self.search = Some((search, sub));
432        cx.notify();
433    }
434
435    /// The Settings section shown, when Settings is open (0 is Appearance).
436    pub fn settings_section(&self) -> Option<usize> {
437        self.settings
438    }
439
440    /// Opens Settings at `section` (0: Appearance, then the app's in order), or closes it.
441    pub fn show_settings(&mut self, section: Option<usize>, window: &mut Window, cx: &mut Context<Self>) {
442        self.settings = section.map(|s| s.min(self.sections.len()));
443        if self.settings.is_none() {
444            window.focus(&self.focus, cx);
445        }
446        cx.notify();
447    }
448
449    /// Sets the look and tells the app.
450    pub fn set_look(&mut self, look: Look, cx: &mut Context<Self>) {
451        theme::use_look(look, cx);
452        cx.emit(ShellEvent::LookChanged(look));
453        cx.notify();
454    }
455
456    fn title_bar(&self, rounded: bool, cx: &mut Context<Self>) -> impl IntoElement {
457        let app = self.registry.menu_button("menu-app", &MenuSpec { hint: "Files, projects, Settings".into(), ..self.app_menu.clone() });
458        let help = self.registry.menu_button("menu-help", &self.help_menu);
459        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)| {
460            let id = w.id.clone();
461            let count = w.badge.as_ref().map_or(0, |f| f(cx));
462            let pill = (count > 0).then(|| {
463                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() })
464            });
465            Button::new(SharedString::from(format!("workspace-{}", w.id)))
466                .small()
467                .ghost()
468                .icon(w.icon)
469                .label(w.title.clone())
470                .children(pill)
471                .tooltip(w.hint.clone())
472                .selected(i == self.current)
473                .on_click(cx.listener(move |this, _, _, cx| {
474                    this.set_workspace(&id, cx);
475                }))
476        }));
477        let toggles = self.layout_toggles.iter().filter_map(|id| self.registry.get(id)).map(|c| {
478            let (registry, id) = (self.registry.clone(), c.id.clone());
479            let tip = match c.key_label() {
480                Some(k) => format!("{} ({k})", c.title),
481                None => c.title.to_string(),
482            };
483            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| {
484                let _ = registry.run_as(crate::activity::Actor::Person, "title bar", &id, Value::Null, window, cx);
485            })
486        });
487        let remote = self.remote.clone().map(|r| {
488            let (registry, items) = (self.registry.clone(), Rc::new(r.menu.clone()));
489            Button::new("remote")
490                .ghost()
491                .small()
492                .icon(r.icon)
493                .label(r.label.clone())
494                .tooltip("Where the app runs: this computer or a server")
495                .dropdown_menu(move |m, window, cx| registry.fill_menu(m.label("Run on"), &items, window, cx))
496        });
497        let dark = theme::is_dark();
498        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| {
499            let l = theme::look();
500            this.set_look(l.with_dark(!l.dark), cx);
501        }));
502        let project = self.project.clone().unwrap_or_else(|| "No project".into());
503        // GPUI clips to rectangles: the bars round their own corners inside a rounded frame
504        TitleBar::new().when(rounded, |t| t.rounded_t(px(WINDOW_RADIUS - 1.0))).child(
505            h_flex()
506                .w_full()
507                .h(px(TITLE_BAR_H))
508                .gap(px(theme::MD))
509                .pr(px(theme::SM))
510                .items_center()
511                .child(h_flex().gap(px(2.)).child(div().font_weight(FontWeight::SEMIBOLD).child(app)).child(help))
512                .child(switcher)
513                .child(div().flex_1())
514                .child(div().min_w_0().text_sm().text_color(color::text_muted()).truncate().child(project))
515                .children(remote)
516                .children(toggles)
517                .child(theme_button),
518        )
519    }
520
521    fn menu_row(&self) -> impl IntoElement {
522        let w = self.workspace();
523        h_flex()
524            .flex_none()
525            .w_full()
526            .h(px(MENU_ROW_H))
527            .px(px(theme::XS))
528            .gap(px(2.))
529            .items_center()
530            .bg(color::panel())
531            .border_b_1()
532            .border_color(color::border())
533            .children(w.menus.iter().enumerate().map(|(i, m)| self.registry.menu_button(format!("menu-{}-{i}", w.id), m)))
534    }
535
536    fn status_bar(&self, rounded: bool, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
537        let pct = |f: Option<f32>| f.map_or(String::new(), |f| format!(" {:.0} %", f * 100.0));
538        let items = self.status_items.clone().map(|f| f(window, cx));
539        let jobs = jobs::running().into_iter().map(|j| {
540            let id = j.id;
541            h_flex()
542                .gap(px(theme::XS))
543                .items_center()
544                .child(div().max_w(px(320.)).truncate().text_color(color::accent_mark()).child(format!("{}{}", j.name, pct(j.progress))))
545                .child(icon_button(SharedString::from(format!("job-cancel-{id}")), IconName::Close, format!("Stop: {}", j.name)).xsmall().on_click(cx.listener(move |_, _, _, cx| {
546                    jobs::cancel_id(id);
547                    cx.notify();
548                })))
549        });
550        let meter = |icon: IconName, text: String, tip: &'static str| {
551            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))
552        };
553        h_flex()
554            .flex_none()
555            .h(px(STATUS_BAR_H))
556            .px(px(theme::MD))
557            .gap(px(theme::LG))
558            .items_center()
559            .bg(color::chrome())
560            .when(rounded, |d| d.rounded_b(px(WINDOW_RADIUS - 1.0)))
561            .text_xs()
562            .child(div().min_w_0().flex_1().truncate().child(self.status.clone()))
563            .children(items)
564            .children(jobs)
565            .children(self.meters.map(|(cpu, used, total)| {
566                let gb = |b: u64| b as f64 / 1e9;
567                h_flex()
568                    .gap(px(theme::MD))
569                    .child(meter(IconName::Cpu, format!("{cpu:.0} %"), "CPU in use, all cores"))
570                    .child(meter(IconName::MemoryStick, format!("{:.1} / {:.1} GB", gb(used), gb(total)), "Memory in use / installed"))
571            }))
572    }
573
574    fn appearance(&self, cx: &mut Context<Self>) -> AnyElement {
575        let look = theme::look();
576        let me = cx.entity().downgrade();
577        let mode = MenuSelect::new("settings-theme", vec!["Dark".into(), "Light".into()], Some(if look.dark { 0 } else { 1 }), move |i, _, cx| {
578            let _ = me.update(cx, |s, cx| s.set_look(theme::look().with_dark(i == 0), cx));
579        });
580        let me = cx.entity().downgrade();
581        let size = stepper("settings-text", format!("{} px", look.font_size), move |step, _, cx| {
582            let _ = me.update(cx, |s, cx| {
583                let l = theme::look();
584                s.set_look(l.with_font_size(l.font_size + step), cx)
585            });
586        });
587        v_flex()
588            .gap(px(theme::MD))
589            .child(row("Theme", div().w_full().child(mode)))
590            .child(row("Accent", self.accent.clone()))
591            .child(row("Text size", size))
592            .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)))
593            .into_any_element()
594    }
595
596    fn settings_view(&self, section: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
597        let mut nav: Vec<(SharedString, IconName)> = vec![("Appearance".into(), IconName::Palette)];
598        nav.extend(self.sections.iter().map(|s| (s.title.clone(), s.icon)));
599        let body = if section == 0 { self.appearance(cx) } else { (self.sections[section - 1].body)(window, cx) };
600        let close = cx.entity().downgrade();
601        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))| {
602            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)))
603        }));
604        div()
605            .id("settings-backdrop")
606            .absolute()
607            .inset_0()
608            .flex()
609            .items_center()
610            .justify_center()
611            .p(px(theme::XL))
612            .bg(gpui_kit::black().opacity(0.35))
613            .on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| this.show_settings(None, window, cx)))
614            .child(
615                v_flex()
616                    .id("settings")
617                    .occlude()
618                    .w_full()
619                    .max_w(px(820.))
620                    .h_full()
621                    .max_h(px(600.))
622                    .rounded(px(theme::RADIUS_LG))
623                    .border_1()
624                    .border_color(color::border())
625                    .bg(color::raised())
626                    .text_color(color::text())
627                    .shadow_lg()
628                    .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
629                    .child(
630                        h_flex()
631                            .flex_none()
632                            .px(px(theme::LG))
633                            .py(px(theme::SM))
634                            .border_b_1()
635                            .border_color(color::border())
636                            .child(div().flex_1().text_base().font_weight(FontWeight::SEMIBOLD).child("Settings"))
637                            .child(icon_button("settings-close", IconName::Close, "Close (Esc)").on_click(move |_, window, cx| {
638                                let _ = close.update(cx, |s, cx| s.show_settings(None, window, cx));
639                            })),
640                    )
641                    .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))),
642            )
643            .into_any_element()
644    }
645}
646
647/// The frame's own commands.
648fn frame_commands(me: &WeakEntity<Shell>, workspaces: &[WorkspaceSpec]) -> Vec<CommandSpec> {
649    let (m1, m2, m3) = (me.clone(), me.clone(), me.clone());
650    let mut out = vec![
651        CommandSpec::new("shell.search", "Search…", IconName::Search, move |window, cx| {
652            let _ = m1.update(cx, |s, cx| s.open_search(window, cx));
653        })
654        .group("App")
655        .hint("Find any command by name")
656        .key("secondary-k")
657        .read_only(),
658        CommandSpec::new("shell.settings", "Settings…", IconName::Settings, move |window, cx| {
659            let _ = m2.update(cx, |s, cx| {
660                let open = if s.settings.is_some() { None } else { Some(0) };
661                s.show_settings(open, window, cx)
662            });
663        })
664        .group("App")
665        .hint("Theme, accent, text size and the app's settings")
666        .key("secondary-,")
667        .read_only(),
668        CommandSpec::new("shell.theme", "Dark theme", IconName::Moon, move |_, cx| {
669            let _ = m3.update(cx, |s, cx| {
670                let l = theme::look();
671                s.set_look(l.with_dark(!l.dark), cx)
672            });
673        })
674        .group("View")
675        .hint("Switch between the dark and the light theme")
676        .checked(|_| theme::is_dark()),
677    ];
678    for (i, w) in workspaces.iter().enumerate() {
679        let (m, id) = (me.clone(), w.id.clone());
680        let mut c = CommandSpec::new(format!("shell.workspace.{}", w.id), format!("Go to {}", w.title), w.icon, move |_, cx| {
681            let _ = m.update(cx, |s, cx| s.set_workspace(&id, cx));
682        })
683        .group("Workspaces")
684        .hint(if w.hint.is_empty() { format!("Show the {} workspace", w.title).into() } else { w.hint.clone() })
685        .read_only();
686        if i < 9 {
687            c = c.key(format!("secondary-{}", i + 1));
688        }
689        out.push(c);
690    }
691    out
692}
693
694/// The edge a press at `p` (window px) resizes, within [`RESIZE_BAND`] of a `size` window's edge.
695pub fn resize_edge(p: gpui_kit::Point<gpui_kit::Pixels>, size: gpui_kit::Size<gpui_kit::Pixels>) -> Option<gpui_kit::ResizeEdge> {
696    use gpui_kit::ResizeEdge as E;
697    let band = RESIZE_BAND;
698    let (x, y, w, h) = (p.x.as_f32(), p.y.as_f32(), size.width.as_f32(), size.height.as_f32());
699    let (l, r, t, b) = (x < band, x > w - band, y < band, y > h - band);
700    Some(match (l, r, t, b) {
701        (true, _, true, _) => E::TopLeft,
702        (_, true, true, _) => E::TopRight,
703        (true, _, _, true) => E::BottomLeft,
704        (_, true, _, true) => E::BottomRight,
705        (true, ..) => E::Left,
706        (_, true, ..) => E::Right,
707        (_, _, true, _) => E::Top,
708        (_, _, _, true) => E::Bottom,
709        _ => return None,
710    })
711}
712
713/// Whether the window draws its own rounded frame: client-side decorations on a floating window
714/// (square when maximised, full screen or tiled).
715pub fn frame_rounded(window: &Window) -> bool {
716    match window.window_decorations() {
717        gpui_kit::Decorations::Client { tiling } => !(tiling.top || tiling.left || tiling.right || tiling.bottom) && !window.is_maximized() && !window.is_fullscreen(),
718        gpui_kit::Decorations::Server => false,
719    }
720}
721
722impl Render for Shell {
723    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
724        let registry = self.registry.clone();
725        let settings = self.settings.map(|s| self.settings_view(s, window, cx));
726        // Linux draws the frame itself (client-side decorations): no inset for a shadow margin
727        if matches!(window.window_decorations(), gpui_kit::Decorations::Client { .. }) {
728            window.set_client_inset(px(0.0));
729        }
730        let rounded = frame_rounded(window);
731        let shell = v_flex()
732            .id("shell")
733            .key_context("Shell")
734            .track_focus(&self.focus)
735            .size_full()
736            .relative()
737            // The chrome under everything: the panes are cards on it
738            .bg(color::chrome())
739            .text_color(color::text())
740            // Not a listener: a command may update the frame itself
741            .on_action(move |a: &RunCommand, window, cx| {
742                if let Err(e) = registry.run_as(crate::activity::Actor::Person, "key", &a.id, Value::Null, window, cx) {
743                    eprintln!("{}: {e}", a.id);
744                }
745            })
746            .on_key_down(cx.listener(|this, e: &KeyDownEvent, window, cx| {
747                if e.keystroke.key == "escape" && this.settings.is_some() {
748                    this.show_settings(None, window, cx);
749                    cx.stop_propagation();
750                }
751            }))
752            .child(self.title_bar(rounded, cx))
753            .child(self.menu_row())
754            .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())))
755            .child(self.status_bar(rounded, window, cx))
756            .children(self.overlay.clone())
757            .children(settings)
758            .children(self.search.as_ref().map(|(s, _)| s.clone()))
759            // No outline: the compositor's shadow sets a floating window apart
760            .when(rounded, |d| d.rounded(px(WINDOW_RADIUS)).overflow_hidden());
761        div()
762            .id("window-frame")
763            .size_full()
764            .when(rounded, |d| {
765                d.on_mouse_down(MouseButton::Left, |e, window, _| {
766                    if let Some(edge) = resize_edge(e.position, window.viewport_size()) {
767                        window.start_window_resize(edge);
768                    }
769                })
770            })
771            .child(shell)
772    }
773}