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 shell.watch_jobs(cx);
260 if spec.meters {
261 shell.watch_meters(cx);
262 }
263 shell
264 }
265
266 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 fn watch_meters(&self, cx: &mut Context<Self>) {
286 cx.spawn(async move |this, cx| {
287 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 pub fn registry(&self) -> &Registry {
315 &self.registry
316 }
317
318 pub fn workspace(&self) -> &WorkspaceSpec {
320 &self.workspaces[self.current]
321 }
322
323 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 pub fn workspace_id(&self) -> SharedString {
336 self.workspaces[self.current].id.clone()
337 }
338
339 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 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 pub fn has_overlay(&self) -> bool {
358 self.overlay.is_some()
359 }
360
361 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 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 pub fn status(&self) -> &SharedString {
377 &self.status
378 }
379
380 pub fn set_project(&mut self, project: Option<SharedString>, cx: &mut Context<Self>) {
382 self.project = project;
383 cx.notify();
384 }
385
386 pub fn search(&self) -> Option<Entity<Search>> {
388 self.search.as_ref().map(|(s, _)| s.clone())
389 }
390
391 pub fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
393 self.open_search_with(None, window, cx);
394 }
395
396 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 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 pub fn settings_section(&self) -> Option<usize> {
433 self.settings
434 }
435
436 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 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 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
643fn 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
690pub 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
709pub 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 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 .bg(color::chrome())
735 .text_color(color::text())
736 .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 .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}