1use crate::ActiveTheme;
12use gpui_kit::{
13 AnyElement, App, Context, Div, ElementId, Hsla, InteractiveElement, Interactivity, IntoElement,
14 ParentElement, Pixels, RenderOnce, SharedString, Stateful, StatefulInteractiveElement,
15 StyleRefinement, Styled, Subscription, Window, canvas, div, px, rems,
16};
17use std::rc::Rc;
18
19#[cfg(target_os = "macos")]
20mod macos;
21mod panel;
22mod portal;
23pub use panel::{
24 GlassPanel, GlassPanelHandle, GlassPanelOptions, NativeGlassPopup, install_glass_popup,
25 open_glass_panel,
26};
27pub use portal::install_glass_popup_presenter;
28
29pub fn glass_available(window: &Window) -> bool {
32 #[cfg(target_os = "macos")]
33 return macos::glass_available(window);
34 #[cfg(not(target_os = "macos"))]
35 {
36 let _ = window;
37 false
38 }
39}
40
41pub fn is_glass_popup(window: &Window) -> bool {
44 #[cfg(target_os = "macos")]
45 return macos::is_glass_popup(window);
46 #[cfg(not(target_os = "macos"))]
47 {
48 let _ = window;
49 false
50 }
51}
52
53#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct SidebarMetrics {
57 pub row_height: Pixels,
58 pub icon_column_width: Pixels,
59 pub label_size: Pixels,
60}
61
62impl Default for SidebarMetrics {
63 fn default() -> Self {
64 Self {
65 row_height: px(32.),
66 icon_column_width: px(24.),
67 label_size: px(13.),
68 }
69 }
70}
71
72pub fn sidebar_metrics() -> SidebarMetrics {
75 #[cfg(target_os = "macos")]
76 return macos::sidebar_metrics();
77 #[cfg(not(target_os = "macos"))]
78 SidebarMetrics::default()
79}
80
81pub fn sidebar_inactive_selection(cx: &App) -> Hsla {
85 let theme = cx.macos();
86 let dark = theme.is_dark();
87 if !theme.reduce_transparency && !theme.increased_contrast {
88 return gpui_kit::rgb(0)
91 .opacity(if dark { 0.18 } else { 6. / 255. })
92 .into();
93 }
94 #[cfg(target_os = "macos")]
95 if let Some(color) = macos::sidebar_inactive_selection(dark) {
96 return color;
97 }
98 gpui_kit::rgb(if dark { 0x464646 } else { 0xdcdcdc }).into()
99}
100
101pub(crate) fn refresh_sidebar_metrics() {
102 #[cfg(target_os = "macos")]
103 macos::refresh_sidebar_metrics();
104}
105
106#[derive(IntoElement)]
117pub struct NativeGlass {
118 id: ElementId,
119 base: Stateful<Div>,
120 clear: bool,
121 tint: Option<Hsla>,
122}
123
124pub fn native_glass(id: impl Into<ElementId>, cx: &App) -> NativeGlass {
126 let id = id.into();
127 let t = cx.macos();
128 NativeGlass {
129 base: div()
130 .id(id.clone())
131 .relative()
132 .rounded(rems(0.75))
133 .border_color(t.glass_border())
134 .font_family(t.font.clone())
135 .text_size(rems(0.8125))
136 .text_color(t.foreground),
137 id,
138 clear: false,
139 tint: None,
140 }
141}
142
143impl NativeGlass {
144 pub fn corner_radius(mut self, radius: Pixels) -> Self {
146 let value = f32::from(radius);
147 let radius = px(if value.is_finite() { value.max(0.) } else { 0. });
148 self.base = self.base.rounded(radius);
149 self
150 }
151
152 pub fn clear(mut self, clear: bool) -> Self {
154 self.clear = clear;
155 self
156 }
157
158 pub fn tint(mut self, tint: Hsla) -> Self {
160 self.tint = Some(tint);
161 self
162 }
163}
164
165impl Styled for NativeGlass {
166 fn style(&mut self) -> &mut StyleRefinement {
167 self.base.style()
168 }
169}
170impl ParentElement for NativeGlass {
171 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
172 self.base.extend(elements);
173 }
174}
175impl InteractiveElement for NativeGlass {
176 fn interactivity(&mut self) -> &mut Interactivity {
177 self.base.interactivity()
178 }
179}
180impl StatefulInteractiveElement for NativeGlass {}
181
182impl RenderOnce for NativeGlass {
183 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
184 if !cx.macos().reduce_transparency && is_glass_popup(window) {
185 return self.base;
188 }
189 #[cfg(target_os = "macos")]
190 if !cx.macos().reduce_transparency && glass_available(window) {
191 let state = window.use_keyed_state((self.id, "native-glass-host"), cx, |window, _| {
192 macos::GlassUnderlay::new(window).ok()
193 });
194 if state.read(cx).is_some() {
195 let radius = self
196 .base
197 .style()
198 .corner_radii
199 .top_left
200 .unwrap_or(px(0.).into());
201 let rem_size = window.rem_size();
202 let clear = self.clear;
203 let tint = self.tint;
204 return self.base.child(
205 canvas(
206 |bounds, window, _| bounds.intersect(&window.content_mask().bounds),
207 move |bounds, visible, window, cx| {
208 if let Some(host) = state.read(cx) {
209 host.update(
210 bounds,
211 visible,
212 native_corner_radius(radius, rem_size, bounds.size),
213 clear,
214 tint,
215 window,
216 );
217 }
218 },
219 )
220 .absolute()
221 .inset_0(),
222 );
223 }
224 }
225 let _ = window;
226 self.base = self.base.bg(cx.macos().glass_fill());
227 self.base
228 }
229}
230
231fn native_corner_radius(
232 radius: gpui_kit::AbsoluteLength,
233 rem_size: Pixels,
234 size: gpui_kit::Size<Pixels>,
235) -> Pixels {
236 let radius = f32::from(radius.to_pixels(rem_size));
237 px(if radius.is_finite() {
238 radius
239 .max(0.)
240 .min(f32::from(size.width).max(0.) / 2.)
241 .min(f32::from(size.height).max(0.) / 2.)
242 } else {
243 0.
244 })
245}
246
247#[derive(Clone, Debug)]
249pub enum NativeToolbarItem {
250 Button {
251 id: SharedString,
252 label: SharedString,
253 symbol: SharedString,
254 enabled: bool,
255 },
256 Search {
257 id: SharedString,
258 label: SharedString,
259 placeholder: SharedString,
260 width: Pixels,
261 enabled: bool,
262 },
263 FlexibleSpace,
264}
265
266impl NativeToolbarItem {
267 pub fn button(
268 id: impl Into<SharedString>,
269 label: impl Into<SharedString>,
270 sf_symbol: impl Into<SharedString>,
271 ) -> Self {
272 Self::Button {
273 id: id.into(),
274 label: label.into(),
275 symbol: sf_symbol.into(),
276 enabled: true,
277 }
278 }
279
280 pub fn disabled(mut self, disabled: bool) -> Self {
281 match &mut self {
282 Self::Button { enabled, .. } | Self::Search { enabled, .. } => *enabled = !disabled,
283 Self::FlexibleSpace => {}
284 }
285 self
286 }
287
288 pub fn search(
290 id: impl Into<SharedString>,
291 label: impl Into<SharedString>,
292 placeholder: impl Into<SharedString>,
293 width: Pixels,
294 ) -> Self {
295 Self::Search {
296 id: id.into(),
297 label: label.into(),
298 placeholder: placeholder.into(),
299 width,
300 enabled: true,
301 }
302 }
303}
304
305pub struct NativeToolbarHandle {
308 #[cfg(target_os = "macos")]
309 inner: macos::ToolbarHost,
310}
311
312impl NativeToolbarHandle {
313 pub fn set_enabled(&self, id: &str, enabled: bool) -> bool {
315 #[cfg(target_os = "macos")]
316 return self.inner.set_enabled(id, enabled);
317 #[cfg(not(target_os = "macos"))]
318 {
319 let _ = (id, enabled);
320 false
321 }
322 }
323
324 pub fn search_text(&self, id: &str) -> Option<SharedString> {
327 #[cfg(target_os = "macos")]
328 return self.inner.search_text(id);
329 #[cfg(not(target_os = "macos"))]
330 {
331 let _ = id;
332 None
333 }
334 }
335
336 pub fn set_title(&self, title: impl Into<SharedString>) {
337 let title = title.into();
338 #[cfg(target_os = "macos")]
339 self.inner.set_title(&title);
340 #[cfg(not(target_os = "macos"))]
341 let _ = title;
342 }
343
344 pub fn set_sidebar_width(&self, width: Pixels) {
348 #[cfg(target_os = "macos")]
349 self.inner.set_sidebar_width(width);
350 #[cfg(not(target_os = "macos"))]
351 let _ = width;
352 }
353}
354
355type Command = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
356
357pub fn install_native_toolbar(
366 id: impl Into<SharedString>,
367 title: impl Into<SharedString>,
368 items: Vec<NativeToolbarItem>,
369 window: &mut Window,
370 cx: &mut App,
371 on_command: impl Fn(SharedString, &mut Window, &mut App) + 'static,
372) -> anyhow::Result<NativeToolbarHandle> {
373 let id = id.into();
374 let title = title.into();
375 let command: Command = Rc::new(on_command);
376 #[cfg(target_os = "macos")]
377 return macos::install_toolbar(id, title, items, window, cx, command)
378 .map(|inner| NativeToolbarHandle { inner });
379 #[cfg(not(target_os = "macos"))]
380 {
381 let _ = (id, title, items, window, cx, command);
382 anyhow::bail!("Native AppKit toolbars require macOS")
383 }
384}
385
386pub fn native_toolbar_inset(window: &Window) -> Pixels {
389 #[cfg(target_os = "macos")]
390 return macos::toolbar_inset(window);
391 #[cfg(not(target_os = "macos"))]
392 {
393 let _ = window;
394 Pixels::ZERO
395 }
396}
397
398pub fn observe_activation<V: 'static>(
401 window: &mut Window,
402 cx: &mut Context<V>,
403 mut callback: impl FnMut(&mut V, bool, &mut Window, &mut Context<V>) + 'static,
404) -> Subscription {
405 cx.observe_window_activation(window, move |view, window, cx| {
406 callback(view, window.is_window_active(), window, cx);
407 })
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::{ButtonVariant, Theme, button};
414 use gpui_kit::{Render, TestAppContext, size};
415 use std::cell::Cell;
416
417 struct FallbackHarness {
418 clicks: usize,
419 }
420
421 #[test]
422 fn native_glass_corner_geometry_scales_and_matches_capsule_bounds() {
423 let bounds = size(px(200.), px(40.));
424 assert_eq!(
425 native_corner_radius(rems(0.75).into(), px(16.), bounds),
426 px(12.)
427 );
428 assert_eq!(
429 native_corner_radius(rems(0.75).into(), px(24.), bounds),
430 px(18.)
431 );
432 assert_eq!(
433 native_corner_radius(rems(999.).into(), px(16.), bounds),
434 px(20.)
435 );
436 assert_eq!(
437 native_corner_radius(px(f32::NAN).into(), px(16.), bounds),
438 Pixels::ZERO
439 );
440 }
441
442 impl Render for FallbackHarness {
443 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
444 native_glass("fallback-surface", cx)
445 .w(px(240.))
446 .h(px(96.))
447 .p(px(12.))
448 .corner_radius(px(8.))
449 .child(
450 button("native-content", "Continue", ButtonVariant::Secondary, cx)
451 .debug_selector(|| "native-content".into())
452 .on_click(cx.listener(|this, _, _, cx| {
453 this.clicks += 1;
454 cx.notify();
455 })),
456 )
457 }
458 }
459
460 #[gpui_kit::test]
461 fn headless_glass_paints_accessible_fallback_and_preserves_child_interaction(
462 cx: &mut TestAppContext,
463 ) {
464 cx.update(crate::init);
465 let (view, cx) = cx.add_window_view(|_, _| FallbackHarness { clicks: 0 });
466 for dark in [false, true] {
467 for reduce_transparency in [false, true] {
468 cx.update(|window, cx| {
469 let theme = if dark { Theme::dark() } else { Theme::light() };
470 theme
471 .with_reduce_transparency(reduce_transparency)
472 .apply(cx);
473 assert!(!glass_available(window));
474 assert_eq!(native_toolbar_inset(window), Pixels::ZERO);
475 window.draw(cx).clear(cx);
476 let fill = cx.macos().glass_fill();
477 let surface = window
478 .painted_quads()
479 .into_iter()
480 .find(|quad| {
481 quad.bounds.size == size(px(240.), px(96.)).scale(window.scale_factor())
482 })
483 .expect("Fallback must paint the laid-out material surface");
484 assert_eq!(surface.background.as_solid(), Some(fill));
485 if reduce_transparency {
486 assert_eq!(fill.a, 1., "Reduced transparency must be fully opaque");
487 }
488 });
489 }
490 }
491 let command = cx.debug_bounds("native-content").unwrap().center();
492 cx.simulate_click(command, Default::default());
493 cx.update(|window, cx| {
494 window.draw(cx).clear(cx);
495 assert_eq!(view.read(cx).clicks, 1);
496 });
497 }
498
499 #[gpui_kit::test]
500 fn headless_toolbar_install_is_rejected_without_emitting_commands(cx: &mut TestAppContext) {
501 cx.update(crate::init);
502 let (_, cx) = cx.add_window_view(|_, _| FallbackHarness { clicks: 0 });
503 let calls = Rc::new(Cell::new(0));
504 let observed = calls.clone();
505 cx.update(|window, cx| {
506 let result = install_native_toolbar(
507 "headless-toolbar",
508 "Test",
509 vec![
510 NativeToolbarItem::button("add", "Add", "plus"),
511 NativeToolbarItem::search("search", "Search", "Search", px(200.)),
512 ],
513 window,
514 cx,
515 move |_, _, _| observed.set(observed.get() + 1),
516 );
517 assert!(
518 result.is_err(),
519 "Headless windows have no AppKit toolbar host"
520 );
521 assert_eq!(native_toolbar_inset(window), Pixels::ZERO);
522 window.draw(cx).clear(cx);
523 });
524 assert_eq!(calls.get(), 0);
525 }
526}