1use 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
44pub const TITLE_BAR_H: f32 = 36.;
46pub const MENU_ROW_H: f32 = 32.;
48pub const STATUS_BAR_H: f32 = 24.;
50pub const RESIZE_BAND: f32 = 6.;
52pub const WINDOW_RADIUS: f32 = 10.;
54
55pub struct WorkspaceSpec {
57 pub id: SharedString,
59 pub title: SharedString,
61 pub icon: IconName,
63 pub hint: SharedString,
65 pub menus: Vec<MenuSpec>,
67 pub view: AnyView,
69 pub badge: Option<Rc<dyn Fn(&App) -> usize>>,
71}
72
73impl WorkspaceSpec {
74 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 pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
81 self.hint = hint.into();
82 self
83 }
84
85 pub fn menus(mut self, menus: Vec<MenuSpec>) -> Self {
87 self.menus = menus;
88 self
89 }
90
91 pub fn badge(mut self, count: impl Fn(&App) -> usize + 'static) -> Self {
93 self.badge = Some(Rc::new(count));
94 self
95 }
96}
97
98pub type SectionBody = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
100
101#[derive(Clone)]
103pub struct SettingsSection {
104 pub title: SharedString,
106 pub icon: IconName,
108 pub body: SectionBody,
110}
111
112impl SettingsSection {
113 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#[derive(Clone, Debug, PartialEq)]
122pub struct RemoteSpec {
123 pub label: SharedString,
125 pub icon: IconName,
127 pub menu: Vec<MenuItem>,
129}
130
131pub struct ShellSpec {
133 pub app_name: SharedString,
135 pub registry: Registry,
137 pub app_menu: Vec<MenuItem>,
139 pub help_menu: Vec<MenuItem>,
141 pub workspaces: Vec<WorkspaceSpec>,
143 pub settings: Vec<SettingsSection>,
145 pub layout_toggles: Vec<SharedString>,
147 pub remote: Option<RemoteSpec>,
149 pub meters: bool,
151 pub status_items: Option<StatusItems>,
153}
154
155pub type StatusItems = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
157
158impl ShellSpec {
159 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#[derive(Clone, Debug, PartialEq)]
178pub enum ShellEvent {
179 LookChanged(Look),
181 WorkspaceChanged(SharedString),
183}
184
185pub 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 settings: Option<usize>,
201 accent: Entity<AccentPicker>,
202 status: SharedString,
203 project: Option<SharedString>,
204 meters: Option<(f32, u64, u64)>,
206 focus: FocusHandle,
207 _subs: Vec<Subscription>,
208}
209
210impl EventEmitter<ShellEvent> for Shell {}
211
212impl Shell {
213 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 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 fn watch_meters(&self, cx: &mut Context<Self>) {
289 cx.spawn(async move |this, cx| {
290 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 pub fn registry(&self) -> &Registry {
318 &self.registry
319 }
320
321 pub fn workspace(&self) -> &WorkspaceSpec {
323 &self.workspaces[self.current]
324 }
325
326 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 pub fn workspace_id(&self) -> SharedString {
340 self.workspaces[self.current].id.clone()
341 }
342
343 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 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 pub fn has_overlay(&self) -> bool {
362 self.overlay.is_some()
363 }
364
365 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 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 pub fn status(&self) -> &SharedString {
381 &self.status
382 }
383
384 pub fn set_project(&mut self, project: Option<SharedString>, cx: &mut Context<Self>) {
386 self.project = project;
387 cx.notify();
388 }
389
390 pub fn search(&self) -> Option<Entity<Search>> {
392 self.search.as_ref().map(|(s, _)| s.clone())
393 }
394
395 pub fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
397 self.open_search_with(None, window, cx);
398 }
399
400 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 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 pub fn settings_section(&self) -> Option<usize> {
437 self.settings
438 }
439
440 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 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 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
647fn 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
694pub 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
713pub 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 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 .bg(color::chrome())
739 .text_color(color::text())
740 .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 .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}