From 0aa6e7d060ba23b5ec49a0c3ae77257b6314db83 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 5 Jun 2026 19:51:13 +0800 Subject: [PATCH 1/5] window: add window glass effect (macOS Liquid Glass / Windows Mica) Add WindowExt::enable_window_glass / disable_window_glass / is_window_glass_enabled to toggle the system glass window background: - macOS 26+: Liquid Glass, by embedding a native NSGlassEffectView behind the window content. - Windows 11 22H2+: Mica backdrop. - Other platforms: no-op that returns false. When enabled, the theme surface colors are made semi-transparent in three tiers (window / container / floating) to let the glass show through. The transform is applied in Theme::change, so it survives theme switching and is fully restored on disable. Also add a "Window Glass" toggle to the story settings menu, a window_glass example, and docs (en + zh-CN). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 17 ++ Cargo.toml | 1 + crates/story/src/lib.rs | 3 +- crates/story/src/stories/alert_story.rs | 26 +- crates/story/src/stories/badge_story.rs | 4 +- crates/story/src/stories/combobox_story.rs | 24 +- crates/story/src/stories/dialog_story.rs | 225 ++++++++++-------- .../src/stories/dropdown_button_story.rs | 4 +- crates/story/src/stories/editor_story.rs | 2 +- crates/story/src/stories/hover_card_story.rs | 9 +- crates/story/src/stories/icon_story.rs | 3 +- crates/story/src/stories/otp_input_story.rs | 9 +- crates/story/src/stories/sheet_story.rs | 39 ++- crates/story/src/stories/theme_story/mod.rs | 2 +- crates/story/src/title_bar.rs | 28 ++- crates/ui/Cargo.toml | 6 + crates/ui/src/global_state.rs | 12 +- crates/ui/src/root.rs | 7 +- crates/ui/src/theme/mod.rs | 7 + crates/ui/src/theme/theme_color.rs | 54 +++++ crates/ui/src/window_ext.rs | 203 +++++++++++++++- docs/docs/root.md | 25 ++ docs/zh-CN/docs/root.md | 25 ++ examples/window_glass/Cargo.toml | 16 ++ examples/window_glass/src/main.rs | 67 ++++++ 25 files changed, 662 insertions(+), 156 deletions(-) create mode 100644 examples/window_glass/Cargo.toml create mode 100644 examples/window_glass/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 1c2812cf62..b92f70fc3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3117,8 +3117,11 @@ dependencies = [ "markup5ever_rcdom", "notify", "num-traits", + "objc2 0.6.4", + "objc2-foundation 0.3.2", "once_cell", "paste", + "raw-window-handle", "regex", "ropey", "rust-i18n", @@ -3167,6 +3170,7 @@ dependencies = [ "tree-sitter-zig", "unicode-segmentation", "uuid", + "windows-version", "zed-sum-tree", ] @@ -5381,6 +5385,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", "objc2-core-foundation", ] @@ -9984,6 +9990,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window_glass" +version = "0.5.1" +dependencies = [ + "anyhow", + "gpui", + "gpui-component", + "gpui-component-assets", + "gpui_platform", +] + [[package]] name = "window_title" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index fa282ec86b..d0ce594651 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "examples/app_assets", "examples/hello_world", "examples/input", + "examples/window_glass", "examples/window_title", "examples/dialog_overlay", "examples/webview", diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 8fdd6e9a87..2a20fce9a1 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -56,7 +56,8 @@ actions!( Tab, TabPrev, ShowPanelInfo, - ToggleListActiveHighlight + ToggleListActiveHighlight, + ToggleWindowGlass ] ); diff --git a/crates/story/src/stories/alert_story.rs b/crates/story/src/stories/alert_story.rs index bb6e085a1a..3052928e9e 100644 --- a/crates/story/src/stories/alert_story.rs +++ b/crates/story/src/stories/alert_story.rs @@ -21,7 +21,11 @@ pub struct AlertStory { impl AlertStory { fn new(_: &mut Window, cx: &mut Context) -> Self { - Self { size: Size::default(), banner_visible: true, focus_handle: cx.focus_handle() } + Self { + size: Size::default(), + banner_visible: true, + focus_handle: cx.focus_handle(), + } } pub fn view(window: &mut Window, cx: &mut App) -> Entity { @@ -67,13 +71,25 @@ impl Render for AlertStory { .outline() .compact() .child( - Button::new("xsmall").label("XSmall").selected(self.size == Size::XSmall), + Button::new("xsmall") + .label("XSmall") + .selected(self.size == Size::XSmall), ) - .child(Button::new("small").label("Small").selected(self.size == Size::Small)) .child( - Button::new("medium").label("Medium").selected(self.size == Size::Medium), + Button::new("small") + .label("Small") + .selected(self.size == Size::Small), + ) + .child( + Button::new("medium") + .label("Medium") + .selected(self.size == Size::Medium), + ) + .child( + Button::new("large") + .label("Large") + .selected(self.size == Size::Large), ) - .child(Button::new("large").label("Large").selected(self.size == Size::Large)) .on_click(cx.listener(|this, selecteds: &Vec, window, cx| { let size = match selecteds[0] { 0 => Size::XSmall, diff --git a/crates/story/src/stories/badge_story.rs b/crates/story/src/stories/badge_story.rs index b78c9c6d3d..5319d39e5a 100644 --- a/crates/story/src/stories/badge_story.rs +++ b/crates/story/src/stories/badge_story.rs @@ -3,8 +3,8 @@ use gpui::{ Styled, Window, }; use gpui_component::{ - avatar::Avatar, badge::Badge, dock::PanelControl, v_flex, ActiveTheme as _, Icon, IconName, - Sizable as _, + ActiveTheme as _, Icon, IconName, Sizable as _, avatar::Avatar, badge::Badge, + dock::PanelControl, v_flex, }; use crate::section; diff --git a/crates/story/src/stories/combobox_story.rs b/crates/story/src/stories/combobox_story.rs index 86e5eddd9a..1918995b2f 100644 --- a/crates/story/src/stories/combobox_story.rs +++ b/crates/story/src/stories/combobox_story.rs @@ -365,7 +365,7 @@ impl ComboboxStory { fn new(window: &mut Window, cx: &mut App) -> Entity { let basic = cx.new(|cx| { ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) - .searchable(true) + .searchable(true) }); let basic_multi = cx.new(|cx| { @@ -376,7 +376,7 @@ impl ComboboxStory { let grouped = cx.new(|cx| { ComboboxState::new(food_groups(), vec![IndexPath::default()], window, cx) - .searchable(true) + .searchable(true) }); let disabled_items = cx.new(|cx| { @@ -387,30 +387,26 @@ impl ComboboxStory { FoodItem::new("Carrots"), FoodItem::new("Broccoli").disabled(), ]); - ComboboxState::new(items, vec![], window, cx) - .searchable(true) + ComboboxState::new(items, vec![], window, cx).searchable(true) }); - let with_icon = cx.new(|cx| { - ComboboxState::new(industries(), vec![], window, cx) - .searchable(true) - }); + let with_icon = + cx.new(|cx| ComboboxState::new(industries(), vec![], window, cx).searchable(true)); let custom_check = cx.new(|cx| { ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) - .searchable(true) + .searchable(true) }); let with_footer = cx.new(|cx| { let items = SearchableVec::new(vec!["Harvard University", "MIT", "Stanford", "Cambridge"]); - ComboboxState::new(items, vec![IndexPath::default()], window, cx) - .searchable(true) + ComboboxState::new(items, vec![IndexPath::default()], window, cx).searchable(true) }); let custom_trigger = cx.new(|cx| { ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) - .searchable(true) + .searchable(true) }); let multi_badges = cx.new(|cx| { @@ -442,7 +438,7 @@ impl ComboboxStory { window, cx, ) - .searchable(true) + .searchable(true) }); let featured = cx.new(|cx| { @@ -452,7 +448,7 @@ impl ComboboxStory { window, cx, ) - .searchable(true) + .searchable(true) }); let multi_expand = cx.new(|cx| { diff --git a/crates/story/src/stories/dialog_story.rs b/crates/story/src/stories/dialog_story.rs index f4a73d8546..f0188d8eff 100644 --- a/crates/story/src/stories/dialog_story.rs +++ b/crates/story/src/stories/dialog_story.rs @@ -110,7 +110,11 @@ impl DialogStory { let date = cx.new(|cx| DatePickerState::new(window, cx)); let select = cx.new(|cx| { SelectState::new( - vec!["Option 1".to_string(), "Option 2".to_string(), "Option 3".to_string()], + vec![ + "Option 1".to_string(), + "Option 2".to_string(), + "Option 3".to_string(), + ], None, window, cx, @@ -239,20 +243,23 @@ impl DialogStory { } fn render_focus_back_test(&self, _cx: &mut Context) -> impl IntoElement { - section("Focus back test").max_w_md().child(Input::new(&self.input2)).child( - Button::new("test-action") - .outline() - .label("Test Action") - .flex_shrink_0() - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(TestAction), cx); - }) - .tooltip( - "This button for test dispatch action, \ + section("Focus back test") + .max_w_md() + .child(Input::new(&self.input2)) + .child( + Button::new("test-action") + .outline() + .label("Test Action") + .flex_shrink_0() + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(TestAction), cx); + }) + .tooltip( + "This button for test dispatch action, \ to make sure when Dialog close,\ \nthis still can handle the action.", - ), - ) + ), + ) } fn render_dialog_without_title(&self, cx: &mut Context) -> impl IntoElement { @@ -260,16 +267,20 @@ impl DialogStory { let overlay_closable = self.overlay_closable; section("Dialog without Title").child( - Button::new("dialog-no-title").outline().label("Dialog without Title").on_click( - cx.listener(move |_, _, window, cx| { + Button::new("dialog-no-title") + .outline() + .label("Dialog without Title") + .on_click(cx.listener(move |_, _, window, cx| { window.open_dialog(cx, move |dialog, _, _| { - dialog.overlay(dialog_overlay).overlay_closable(overlay_closable).child( - "This is a dialog without title, \ + dialog + .overlay(dialog_overlay) + .overlay_closable(overlay_closable) + .child( + "This is a dialog without title, \ you can use it when the title is not necessary.", - ) + ) }); - }), - ), + })), ) } @@ -278,8 +289,10 @@ impl DialogStory { let overlay_closable = self.overlay_closable; section("Custom buttons").child( - Button::new("confirm-dialog1").outline().label("Custom Buttons").on_click(cx.listener( - move |_, _, window, cx| { + Button::new("confirm-dialog1") + .outline() + .label("Custom Buttons") + .on_click(cx.listener(move |_, _, window, cx| { window.open_dialog(cx, move |dialog, _, cx| { dialog .rounded(cx.theme().radius_lg) @@ -326,8 +339,7 @@ impl DialogStory { true }) }); - }, - )), + })), ) } @@ -336,8 +348,10 @@ impl DialogStory { let overlay_closable = self.overlay_closable; section("Scrollable Dialog").child( - Button::new("scrollable-dialog").outline().label("Scrollable Dialog").on_click( - cx.listener(move |_, _, window, cx| { + Button::new("scrollable-dialog") + .outline() + .label("Scrollable Dialog") + .on_click(cx.listener(move |_, _, window, cx| { window.open_dialog(cx, move |dialog, _, _| { dialog .w(px(720.)) @@ -360,8 +374,7 @@ impl DialogStory { ), ) }); - }), - ), + })), ) } @@ -370,35 +383,40 @@ impl DialogStory { let overlay_closable = self.overlay_closable; section("Table in Dialog").child( - Button::new("table-dialog").outline().label("Table Dialog").on_click(cx.listener({ - move |this, _, window, cx| { - window.open_dialog(cx, { - let table = this.table.clone(); - move |dialog, _, _| { - dialog - .w(px(800.)) - .h(px(600.)) - .overlay(dialog_overlay) - .overlay_closable(overlay_closable) - .title("Dialog with Table") - .child( - v_flex() - .size_full() - .gap_3() - .child("This is a dialog contains a table component.") - .child(DataTable::new(&table)), - ) - } - }); - } - })), + Button::new("table-dialog") + .outline() + .label("Table Dialog") + .on_click(cx.listener({ + move |this, _, window, cx| { + window.open_dialog(cx, { + let table = this.table.clone(); + move |dialog, _, _| { + dialog + .w(px(800.)) + .h(px(600.)) + .overlay(dialog_overlay) + .overlay_closable(overlay_closable) + .title("Dialog with Table") + .child( + v_flex() + .size_full() + .gap_3() + .child("This is a dialog contains a table component.") + .child(DataTable::new(&table)), + ) + } + }); + } + })), ) } fn render_custom_paddings(&self, cx: &mut Context) -> impl IntoElement { section("Custom Paddings").child( - Button::new("custom-dialog-paddings").outline().label("Custom Paddings").on_click( - cx.listener(move |_, _, window, cx| { + Button::new("custom-dialog-paddings") + .outline() + .label("Custom Paddings") + .on_click(cx.listener(move |_, _, window, cx| { window.open_dialog(cx, move |dialog, _, _| { dialog.p_3().title("Custom Dialog Title").child( "This is a custom dialog content, we can use \ @@ -406,15 +424,16 @@ impl DialogStory { the dialog.", ) }); - }), - ), + })), ) } fn render_custom_style(&self, cx: &mut Context) -> impl IntoElement { section("Custom Style").child( - Button::new("custom-dialog-style").outline().label("Custom Dialog Style").on_click( - cx.listener(move |_, _, window, cx| { + Button::new("custom-dialog-style") + .outline() + .label("Custom Dialog Style") + .on_click(cx.listener(move |_, _, window, cx| { window.open_dialog(cx, move |dialog, _, cx| { dialog .rounded(cx.theme().radius_lg) @@ -423,58 +442,60 @@ impl DialogStory { .title("Custom Dialog Title") .child("This is a custom dialog content.") }); - }), - ), + })), ) } fn render_dialog_with_content(&self, cx: &mut Context) -> impl IntoElement { - section("Open Dialog with DialogContent").sub_title("Declarative API").child( - Button::new("custom-width-dialog-btn") - .outline() - .label("Custom Width (400px)") - .on_click(cx.listener(move |_, _, window, cx| { - window.open_dialog(cx, move |dialog, _, _| { - dialog.w(px(400.)).content(|content, _, _| { - content - .child( - DialogHeader::new() - .child(DialogTitle::new().child("Custom Width")) - .child( - DialogDescription::new() - .child("This dialog has a custom width of 400px."), - ), - ) - .child( - "Content area with custom width configuration, \ + section("Open Dialog with DialogContent") + .sub_title("Declarative API") + .child( + Button::new("custom-width-dialog-btn") + .outline() + .label("Custom Width (400px)") + .on_click(cx.listener(move |_, _, window, cx| { + window.open_dialog(cx, move |dialog, _, _| { + dialog.w(px(400.)).content(|content, _, _| { + content + .child( + DialogHeader::new() + .child(DialogTitle::new().child("Custom Width")) + .child( + DialogDescription::new().child( + "This dialog has a custom width of 400px.", + ), + ), + ) + .child( + "Content area with custom width configuration, \ and the footer is used flex 1 button widths.", - ) - .child( - DialogFooter::new() - .justify_center() - .child( - Button::new("cancel") - .flex_1() - .outline() - .label("Cancel") - .on_click(|_, window, cx| { - window.close_dialog(cx); - }), - ) - .child( - Button::new("done") - .flex_1() - .primary() - .label("Done") - .on_click(|_, window, cx| { - window.close_dialog(cx); - }), - ), - ) + ) + .child( + DialogFooter::new() + .justify_center() + .child( + Button::new("cancel") + .flex_1() + .outline() + .label("Cancel") + .on_click(|_, window, cx| { + window.close_dialog(cx); + }), + ) + .child( + Button::new("done") + .flex_1() + .primary() + .label("Done") + .on_click(|_, window, cx| { + window.close_dialog(cx); + }), + ), + ) + }) }) - }) - })), - ) + })), + ) } } diff --git a/crates/story/src/stories/dropdown_button_story.rs b/crates/story/src/stories/dropdown_button_story.rs index f5d2589c62..d30501ca1b 100644 --- a/crates/story/src/stories/dropdown_button_story.rs +++ b/crates/story/src/stories/dropdown_button_story.rs @@ -1,5 +1,5 @@ -use gpui::{ Anchor, - Action, App, AppContext as _, Context, Entity, Focusable, IntoElement, +use gpui::{ + Action, Anchor, App, AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement as _, Render, Styled as _, Window, prelude::FluentBuilder as _, }; use serde::Deserialize; diff --git a/crates/story/src/stories/editor_story.rs b/crates/story/src/stories/editor_story.rs index e12054e81b..cfbb71cc64 100644 --- a/crates/story/src/stories/editor_story.rs +++ b/crates/story/src/stories/editor_story.rs @@ -1,6 +1,6 @@ use gpui::{App, AppContext as _, Context, Entity, IntoElement, Render, Styled, Window}; -use gpui_component::{input::*, ActiveTheme}; +use gpui_component::{ActiveTheme, input::*}; const EXAMPLE_CODE: &str = include_str!("./editor_story.rs"); diff --git a/crates/story/src/stories/hover_card_story.rs b/crates/story/src/stories/hover_card_story.rs index 3172b544ca..71904c9209 100644 --- a/crates/story/src/stories/hover_card_story.rs +++ b/crates/story/src/stories/hover_card_story.rs @@ -1,10 +1,9 @@ -use gpui::{ Anchor, - App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, - Window, div, px, relative, +use gpui::{ + Anchor, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + Styled as _, Window, div, px, relative, }; use gpui_component::{ - ActiveTheme, StyledExt, avatar::Avatar, button::Button, h_flex, hover_card::HoverCard, - v_flex, + ActiveTheme, StyledExt, avatar::Avatar, button::Button, h_flex, hover_card::HoverCard, v_flex, }; use std::time::Duration; diff --git a/crates/story/src/stories/icon_story.rs b/crates/story/src/stories/icon_story.rs index a8671b0efd..dca9be3467 100644 --- a/crates/story/src/stories/icon_story.rs +++ b/crates/story/src/stories/icon_story.rs @@ -3,9 +3,10 @@ use gpui::{ Styled, Window, }; use gpui_component::{ + ActiveTheme as _, Icon, IconName, Sizable, button::{Button, ButtonVariant, ButtonVariants}, dock::PanelControl, - h_flex, neutral_500, v_flex, ActiveTheme as _, Icon, IconName, Sizable, + h_flex, neutral_500, v_flex, }; use crate::section; diff --git a/crates/story/src/stories/otp_input_story.rs b/crates/story/src/stories/otp_input_story.rs index 9003d2f607..65c7bf11ec 100644 --- a/crates/story/src/stories/otp_input_story.rs +++ b/crates/story/src/stories/otp_input_story.rs @@ -1,13 +1,14 @@ use gpui::{ - prelude::FluentBuilder as _, px, App, AppContext as _, Context, Entity, Focusable, - InteractiveElement, IntoElement, ParentElement as _, Render, SharedString, Styled, - Subscription, Window, + App, AppContext as _, Context, Entity, Focusable, InteractiveElement, IntoElement, + ParentElement as _, Render, SharedString, Styled, Subscription, Window, + prelude::FluentBuilder as _, px, }; use gpui_component::{ + Disableable as _, Sizable, StyledExt, checkbox::Checkbox, h_flex, input::{InputEvent, OtpInput, OtpState}, - v_flex, Disableable as _, Sizable, StyledExt, + v_flex, }; use crate::section; diff --git a/crates/story/src/stories/sheet_story.rs b/crates/story/src/stories/sheet_story.rs index dfe7e5475d..410123cdd3 100644 --- a/crates/story/src/stories/sheet_story.rs +++ b/crates/story/src/stories/sheet_story.rs @@ -46,7 +46,9 @@ impl ListDelegate for ListItemDeletegate { cx.spawn(async move |this, cx| { // Simulate a slow search. let sleep = (0.05..0.1).fake(); - cx.background_executor().timer(Duration::from_secs_f64(sleep)).await; + cx.background_executor() + .timer(Duration::from_secs_f64(sleep)) + .await; this.update(cx, |this, cx| { this.delegate_mut().matches = this @@ -74,7 +76,12 @@ impl ListDelegate for ListItemDeletegate { let list_item = ListItem::new(("item", ix.row)) .check_icon(IconName::Check) .confirmed(confirmed) - .child(h_flex().items_center().justify_between().child(item.to_string())) + .child( + h_flex() + .items_center() + .justify_between() + .child(item.to_string()), + ) .suffix(|_, _| { Button::new("like") .tab_stop(false) @@ -101,7 +108,11 @@ impl ListDelegate for ListItemDeletegate { ) -> impl IntoElement { v_flex() .size_full() - .child(Icon::new(IconName::Inbox).size(px(50.)).text_color(cx.theme().muted_foreground)) + .child( + Icon::new(IconName::Inbox) + .size(px(50.)) + .text_color(cx.theme().muted_foreground), + ) .child("No matches found") .items_center() .justify_center() @@ -280,12 +291,12 @@ impl SheetStory { .child(Input::new(&input1)) .child(DatePicker::new(&date).placeholder("Date of Birth")) .child( - Button::new("send-notification").child("Test Notification").on_click( - |_, window, cx| { + Button::new("send-notification") + .child("Test Notification") + .on_click(|_, window, cx| { window .push_notification("Hello this is message from Sheet.", cx) - }, - ), + }), ) .child( Button::new("confirm-dialog-from-sheet") @@ -325,9 +336,13 @@ impl SheetStory { window.close_sheet(cx); }, )) - .child(Button::new("cancel").label("Cancel").on_click(|_, window, cx| { - window.close_sheet(cx); - })), + .child( + Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| { + window.close_sheet(cx); + }), + ), ) }); } @@ -461,7 +476,9 @@ impl Render for SheetStory { .when_some(self.selected_value.clone(), |this, selected_value| { this.child( h_flex().gap_1().child("You have selected:").child( - div().child(selected_value.to_string()).text_color(gpui::red()), + div() + .child(selected_value.to_string()) + .text_color(gpui::red()), ), ) }), diff --git a/crates/story/src/stories/theme_story/mod.rs b/crates/story/src/stories/theme_story/mod.rs index dff820ef25..9ddf1b3f76 100644 --- a/crates/story/src/stories/theme_story/mod.rs +++ b/crates/story/src/stories/theme_story/mod.rs @@ -2,4 +2,4 @@ mod checkerboard; mod color_theme_story; mod mapper; -pub use color_theme_story::*; \ No newline at end of file +pub use color_theme_story::*; diff --git a/crates/story/src/title_bar.rs b/crates/story/src/title_bar.rs index 4777b06874..091738a855 100644 --- a/crates/story/src/title_bar.rs +++ b/crates/story/src/title_bar.rs @@ -14,7 +14,10 @@ use gpui_component::{ scroll::ScrollbarShow, }; -use crate::{SelectFont, SelectRadius, SelectScrollbarShow, ToggleListActiveHighlight, app_menus}; +use crate::{ + SelectFont, SelectRadius, SelectScrollbarShow, ToggleListActiveHighlight, ToggleWindowGlass, + app_menus, +}; pub struct AppTitleBar { app_menu_bar: Entity, @@ -152,14 +155,28 @@ impl FontSizeSelector { theme.list.active_highlight = !theme.list.active_highlight; window.refresh(); } + + fn on_toggle_window_glass( + &mut self, + _: &ToggleWindowGlass, + window: &mut Window, + cx: &mut Context, + ) { + if window.is_window_glass_enabled(cx) { + window.disable_window_glass(cx); + } else if !window.enable_window_glass(cx) { + window.push_notification("Window glass requires macOS 26+ or Windows 11 22H2+.", cx); + } + } } impl Render for FontSizeSelector { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focus_handle = self.focus_handle.clone(); let font_size = cx.theme().font_size.as_f32() as i32; let radius = cx.theme().radius.as_f32() as i32; let scroll_show = cx.theme().scrollbar_show; + let window_glass = window.is_window_glass_enabled(cx); div() .id("font-size-selector") @@ -168,6 +185,7 @@ impl Render for FontSizeSelector { .on_action(cx.listener(Self::on_select_radius)) .on_action(cx.listener(Self::on_select_scrollbar_show)) .on_action(cx.listener(Self::on_toggle_list_active_highlight)) + .on_action(cx.listener(Self::on_toggle_window_glass)) .child( Button::new("btn") .small() @@ -218,6 +236,12 @@ impl Render for FontSizeSelector { cx.theme().list.active_highlight, Box::new(ToggleListActiveHighlight), ) + .separator() + .menu_with_check( + "Window Glass", + window_glass, + Box::new(ToggleWindowGlass), + ) }) .anchor(Anchor::TopRight), ) diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index ac5ce71985..2220347253 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -185,6 +185,12 @@ tree-sitter-zig = { version = "1.1.2", optional = true } [target.'cfg(target_os = "macos")'.dependencies] core-text = "=21.0.0" +objc2 = "0.6" +objc2-foundation = "0.3" +raw-window-handle = "0.6" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-version = "0.1" [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/ui/src/global_state.rs b/crates/ui/src/global_state.rs index d800d12d98..8d607f14ae 100644 --- a/crates/ui/src/global_state.rs +++ b/crates/ui/src/global_state.rs @@ -1,4 +1,4 @@ -use gpui::{App, ElementId, Entity, FocusHandle, Global, OwnedMenu}; +use gpui::{App, ElementId, Entity, FocusHandle, Global, OwnedMenu, Window, WindowId}; use std::collections::HashSet; use crate::text::TextViewState; @@ -22,6 +22,9 @@ pub struct GlobalState { /// interaction (e.g. `Input`, `Button`); reset by the selection /// controller in the capture phase of every left mouse down. pub(crate) suppress_text_selection: bool, + /// Windows with the glass background effect enabled, + /// see [`crate::WindowExt::enable_window_glass`]. + pub(crate) glass_windows: HashSet, } impl GlobalState { @@ -31,9 +34,16 @@ impl GlobalState { open_deferred_popovers: HashSet::new(), app_menus: Vec::new(), suppress_text_selection: false, + glass_windows: HashSet::new(), } } + /// Returns true if the glass background effect is enabled for the window. + pub(crate) fn is_window_glass_enabled(&self, window: &Window) -> bool { + self.glass_windows + .contains(&window.window_handle().window_id()) + } + /// Suppress the window-level text selection for the current mouse down. /// /// Call this from a mouse-down handler (bubble phase) of a component that diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 3bab9e3c67..925b0dc8fc 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -11,15 +11,16 @@ use crate::{ }; use gpui::{ Anchor, AnyView, App, AppContext, ClipboardItem, Context, DefiniteLength, ElementId, Entity, - EntityId, FocusHandle, Hitbox, InteractiveElement, IntoElement, KeyBinding, - ParentElement as _, Pixels, Render, StyleRefinement, Styled, WeakEntity, WeakFocusHandle, - Window, actions, div, prelude::FluentBuilder as _, + EntityId, FocusHandle, Hitbox, InteractiveElement, IntoElement, KeyBinding, ParentElement as _, + Pixels, Render, StyleRefinement, Styled, WeakEntity, WeakFocusHandle, Window, actions, div, + prelude::FluentBuilder as _, }; use std::{any::TypeId, collections::HashMap, rc::Rc}; actions!(root, [Tab, TabPrev]); const CONTEXT: &str = "Root"; + pub(crate) fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("tab", Tab, Some(CONTEXT)), diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs index 82b012d713..9dbd9e530b 100644 --- a/crates/ui/src/theme/mod.rs +++ b/crates/ui/src/theme/mod.rs @@ -167,6 +167,10 @@ impl Theme { cx.set_global(theme); } + let window_glass = cx + .try_global::() + .is_some_and(|state| !state.glass_windows.is_empty()); + let theme = cx.global_mut::(); theme.mode = mode; if mode.is_dark() { @@ -174,6 +178,9 @@ impl Theme { } else { theme.apply_config(&theme.light_theme.clone()); } + if window_glass { + theme.colors.apply_window_glass(); + } if let Some(window) = window { window.refresh(); diff --git a/crates/ui/src/theme/theme_color.rs b/crates/ui/src/theme/theme_color.rs index 9f7c1b79db..c1e84d148d 100644 --- a/crates/ui/src/theme/theme_color.rs +++ b/crates/ui/src/theme/theme_color.rs @@ -254,4 +254,58 @@ impl ThemeColor { pub fn dark() -> Arc { DEFAULT_THEME_COLORS[&ThemeMode::Dark].0.clone() } + + /// Make the surface colors semi-transparent to let the window glass + /// background show through, + /// see [`crate::WindowExt::enable_window_glass`]. + pub(crate) fn apply_window_glass(&mut self) { + /// Window level surfaces, e.g.: window background, title bar. + const WINDOW_OPACITY: f32 = 0.5; + /// Container surfaces, e.g.: cards, secondary buttons, list rows. + const CONTAINER_OPACITY: f32 = 0.7; + /// Floating surfaces, e.g.: popovers, menus, notifications. + const FLOATING_OPACITY: f32 = 0.85; + + let window_surfaces = [ + &mut self.background, + &mut self.title_bar, + &mut self.sidebar, + &mut self.tab_bar, + &mut self.table, + &mut self.tiles, + ]; + for color in window_surfaces { + color.a *= WINDOW_OPACITY; + } + + let container_surfaces = [ + &mut self.accent, + &mut self.description_list_label, + &mut self.group_box, + &mut self.list, + &mut self.list_active, + &mut self.list_even, + &mut self.list_head, + &mut self.list_hover, + &mut self.muted, + &mut self.secondary, + &mut self.secondary_active, + &mut self.secondary_hover, + &mut self.sidebar_accent, + &mut self.skeleton, + &mut self.tab, + &mut self.tab_active, + &mut self.tab_bar_segmented, + &mut self.table_active, + &mut self.table_even, + &mut self.table_foot, + &mut self.table_head, + &mut self.table_hover, + ]; + for color in container_surfaces { + color.a *= CONTAINER_OPACITY; + } + + self.popover.a *= FLOATING_OPACITY; + } } diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs index ab19ffdb23..5d79e6e2e1 100644 --- a/crates/ui/src/window_ext.rs +++ b/crates/ui/src/window_ext.rs @@ -1,6 +1,7 @@ use crate::{ - Placement, Root, + Placement, Root, Theme, dialog::{AlertDialog, Dialog}, + global_state::GlobalState, input::InputState, notification::Notification, sheet::Sheet, @@ -95,6 +96,41 @@ pub trait WindowExt: Sized { /// Clears the window-level text selection and all view-local selections. fn clear_text_selection(&mut self, cx: &mut App); + + /// Enables the system glass effect for the window background. + /// + /// - macOS 26 (Tahoe) or later: Liquid Glass, by embedding a native + /// `NSGlassEffectView` behind the window content. + /// - Windows 11 22H2 (build 22621) or later: Mica backdrop. + /// - Other platforms (older systems, Linux): no-op that returns `false`, + /// the window stays opaque. + /// + /// When enabled, the large surface colors of the theme (e.g. `background`, + /// `title_bar`, `sidebar`) are automatically made semi-transparent to let + /// the glass show through, this applies to all windows of the application. + /// + /// # Examples + /// + /// ```ignore + /// let window = cx.open_window(options, |window, cx| { + /// let view = cx.new(|_| Example); + /// cx.new(|cx| Root::new(view, window, cx)) + /// })?; + /// + /// window.update(cx, |_, window, cx| { + /// window.enable_window_glass(cx); + /// })?; + /// ``` + fn enable_window_glass(&mut self, cx: &mut App) -> bool; + + /// Disables the system glass effect for the window background, + /// restoring the opaque background. + /// + /// This is a no-op if the effect is not enabled. + fn disable_window_glass(&mut self, cx: &mut App); + + /// Returns true if the system glass effect is enabled for the window. + fn is_window_glass_enabled(&self, cx: &App) -> bool; } impl WindowExt for Window { @@ -239,4 +275,169 @@ impl WindowExt for Window { }; root.update(cx, |root, cx| root.clear_text_selection(cx)); } + + fn enable_window_glass(&mut self, cx: &mut App) -> bool { + let window_id = self.window_handle().window_id(); + if GlobalState::global(cx).glass_windows.contains(&window_id) { + return true; + } + if !window_glass::enable(self) { + return false; + } + + GlobalState::global_mut(cx).glass_windows.insert(window_id); + // Reapply the theme to make the surface colors semi-transparent. + Theme::change(Theme::global(cx).mode, Some(self), cx); + true + } + + fn disable_window_glass(&mut self, cx: &mut App) { + let window_id = self.window_handle().window_id(); + if !GlobalState::global_mut(cx).glass_windows.remove(&window_id) { + return; + } + + window_glass::disable(self); + // Reapply the theme to restore the original surface colors. + Theme::change(Theme::global(cx).mode, Some(self), cx); + } + + #[inline] + fn is_window_glass_enabled(&self, cx: &App) -> bool { + GlobalState::global(cx).is_window_glass_enabled(self) + } +} + +/// Liquid Glass, embeds a native `NSGlassEffectView` (macOS 26+) behind the +/// window content. +/// +/// This mirrors how GPUI injects an `NSVisualEffectView` for +/// [`WindowBackgroundAppearance::Blurred`](gpui::WindowBackgroundAppearance): +/// the glass view is added to the window's `contentView`, positioned below +/// the GPUI rendered view, with an autoresizing mask to follow window resize. +#[cfg(target_os = "macos")] +mod window_glass { + use gpui::{Window, WindowBackgroundAppearance}; + use objc2::ffi::{NSInteger, NSUInteger}; + use objc2::msg_send; + use objc2::rc::Retained; + use objc2::runtime::{AnyClass, AnyObject}; + use objc2_foundation::NSRect; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + /// `NSViewWidthSizable | NSViewHeightSizable` + const AUTORESIZE_WIDTH_HEIGHT: NSUInteger = (1 << 1) | (1 << 4); + /// `NSWindowBelow` + const NS_WINDOW_BELOW: NSInteger = -1; + + /// Returns the `contentView` of the window's native `NSWindow`. + fn content_view(window: &Window) -> Option<*mut AnyObject> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + + unsafe { + let ns_view: *mut AnyObject = handle.ns_view.as_ptr().cast(); + let ns_window: *mut AnyObject = msg_send![ns_view, window]; + if ns_window.is_null() { + return None; + } + let content_view: *mut AnyObject = msg_send![ns_window, contentView]; + if content_view.is_null() { + return None; + } + + Some(content_view) + } + } + + pub(super) fn enable(window: &mut Window) -> bool { + // The class only exists on macOS 26 (Tahoe) and later, so a runtime + // lookup doubles as the OS version check. + let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") else { + return false; + }; + let Some(content_view) = content_view(window) else { + return false; + }; + + // Let GPUI make the window non-opaque and its Metal layer + // transparent, so the glass below shows through. + window.set_background_appearance(WindowBackgroundAppearance::Transparent); + + unsafe { + let bounds: NSRect = msg_send![content_view, bounds]; + let glass: Retained = msg_send![glass_class, new]; + let _: () = msg_send![&*glass, setFrame: bounds]; + let _: () = msg_send![&*glass, setAutoresizingMask: AUTORESIZE_WIDTH_HEIGHT]; + let _: () = msg_send![ + content_view, + addSubview: &*glass, + positioned: NS_WINDOW_BELOW, + relativeTo: std::ptr::null_mut::() + ]; + } + + true + } + + /// Removes the injected glass views and restores the opaque background. + pub(super) fn disable(window: &mut Window) { + let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") else { + return; + }; + let Some(content_view) = content_view(window) else { + return; + }; + + unsafe { + // The `subviews` getter returns a copy of the array, so it is + // safe to remove views while iterating over it. + let subviews: Retained = msg_send![content_view, subviews]; + let count: usize = msg_send![&*subviews, count]; + for i in 0..count { + let view: *mut AnyObject = msg_send![&*subviews, objectAtIndex: i]; + let is_glass: bool = msg_send![view, isKindOfClass: glass_class]; + if is_glass { + let _: () = msg_send![view, removeFromSuperview]; + } + } + } + + window.set_background_appearance(WindowBackgroundAppearance::Opaque); + } +} + +/// Mica backdrop (Windows 11 22H2+), via GPUI's +/// [`WindowBackgroundAppearance::MicaBackdrop`](gpui::WindowBackgroundAppearance). +#[cfg(target_os = "windows")] +mod window_glass { + use gpui::{Window, WindowBackgroundAppearance}; + + /// The Mica backdrop requires Windows 11 22H2 (build 22621) or later, + /// GPUI silently ignores it on older builds. + const MIN_BUILD_NUMBER: u32 = 22621; + + pub(super) fn enable(window: &mut Window) -> bool { + if windows_version::OsVersion::current().build < MIN_BUILD_NUMBER { + return false; + } + + window.set_background_appearance(WindowBackgroundAppearance::MicaBackdrop); + true + } + + pub(super) fn disable(window: &mut Window) { + window.set_background_appearance(WindowBackgroundAppearance::Opaque); + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +mod window_glass { + pub(super) fn enable(_window: &mut gpui::Window) -> bool { + false + } + + pub(super) fn disable(_window: &mut gpui::Window) {} } diff --git a/docs/docs/root.md b/docs/docs/root.md index bfe792f4d1..5e218a09b2 100644 --- a/docs/docs/root.md +++ b/docs/docs/root.md @@ -56,4 +56,29 @@ impl Render for MyApp { Here the example we used `children` method, it because if there is no opened dialogs, sheets, notifications, these methods will return `None`, so GPUI will not render anything. ::: +## Window Glass + +We can enable the system glass effect for the window background by calling `WindowExt::enable_window_glass`: + +- macOS 26 (Tahoe) or later: Liquid Glass (a native `NSGlassEffectView` embedded behind the window content). +- Windows 11 22H2 or later: Mica backdrop. +- Other platforms (older systems, Linux): no-op that returns `false`, the window stays opaque. + +```rs +use gpui_component::WindowExt as _; + +let window = cx.open_window(options, |window, cx| { + let view = cx.new(|_| Example); + cx.new(|cx| Root::new(view, window, cx)) +})?; + +window.update(cx, |_, window, cx| { + window.enable_window_glass(cx); +})?; +``` + +When enabled, the large surface colors of the theme (e.g. `background`, `title_bar`, `sidebar`) are automatically made semi-transparent to let the glass show through, this applies to all windows of the application. Use `WindowExt::disable_window_glass` to restore the opaque background. + +See the [window_glass example](https://github.com/longbridge/gpui-component/tree/main/examples/window_glass) for a complete example. + [Root]: https://docs.rs/gpui-component/latest/gpui_component/root/struct.Root.html diff --git a/docs/zh-CN/docs/root.md b/docs/zh-CN/docs/root.md index 4b53e00895..d8b4732384 100644 --- a/docs/zh-CN/docs/root.md +++ b/docs/zh-CN/docs/root.md @@ -56,4 +56,29 @@ impl Render for MyApp { 这里使用的是 `children` 而不是 `child`,因为当没有打开的 dialog、sheet 或 notification 时,这些方法会返回 `None`,GPUI 就不会渲染任何内容。 ::: +## Window Glass(窗口玻璃效果) + +调用 `WindowExt::enable_window_glass` 可以为窗口背景启用系统玻璃效果: + +- macOS 26(Tahoe)及以上:Liquid Glass(在窗口内容下方嵌入原生 `NSGlassEffectView`)。 +- Windows 11 22H2 及以上:Mica 背景材质。 +- 其它环境(更低版本系统、Linux):不做任何事并返回 `false`,窗口保持不透明背景。 + +```rs +use gpui_component::WindowExt as _; + +let window = cx.open_window(options, |window, cx| { + let view = cx.new(|_| Example); + cx.new(|cx| Root::new(view, window, cx)) +})?; + +window.update(cx, |_, window, cx| { + window.enable_window_glass(cx); +})?; +``` + +开启后,主题中的大面积表面色(如 `background`、`title_bar`、`sidebar`)会自动变为半透明,让玻璃透出来;该变换对应用的所有窗口生效。调用 `WindowExt::disable_window_glass` 可恢复不透明背景。 + +完整示例参见 [window_glass example](https://github.com/longbridge/gpui-component/tree/main/examples/window_glass)。 + [Root]: https://docs.rs/gpui-component/latest/gpui_component/root/struct.Root.html diff --git a/examples/window_glass/Cargo.toml b/examples/window_glass/Cargo.toml new file mode 100644 index 0000000000..4b18903670 --- /dev/null +++ b/examples/window_glass/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "window_glass" +description = "An example of the system glass window effect (macOS Liquid Glass / Windows Mica) with GPUI Component." +version = "0.5.1" +publish = false +edition.workspace = true + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +gpui_platform.workspace = true +gpui-component = { workspace = true } +gpui-component-assets = { workspace = true } + +[lints] +workspace = true diff --git a/examples/window_glass/src/main.rs b/examples/window_glass/src/main.rs new file mode 100644 index 0000000000..08e9c140c0 --- /dev/null +++ b/examples/window_glass/src/main.rs @@ -0,0 +1,67 @@ +use gpui::*; +use gpui_component::{ + Root, TitleBar, WindowExt, + button::{Button, ButtonVariants}, + h_flex, v_flex, +}; + +pub struct Example; + +impl Render for Example { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .child(TitleBar::new().child("Window Glass")) + .child( + v_flex() + .p_5() + .gap_3() + .size_full() + .items_center() + .justify_center() + .child("System glass window (requires macOS 26+ or Windows 11 22H2+)") + .child( + h_flex() + .gap_2() + .child(Button::new("primary").primary().label("Primary")) + .child(Button::new("outline").outline().label("Outline")), + ), + ) + } +} + +fn main() { + let app = gpui_platform::application().with_assets(gpui_component_assets::Assets); + + app.run(move |cx| { + gpui_component::init(cx); + + cx.spawn(async move |cx| { + let options = WindowOptions { + // Setup GPUI to use custom title bar + titlebar: Some(TitleBar::title_bar_options()), + ..Default::default() + }; + + let window = cx + .open_window(options, |window, cx| { + let view = cx.new(|_| Example); + // This first level on the window, should be a Root. + cx.new(|cx| Root::new(view, window, cx)) + }) + .expect("Failed to open window"); + + window + .update(cx, |_, window, cx| { + if !window.enable_window_glass(cx) { + println!( + "Window glass requires macOS 26+ or Windows 11 22H2+, \ + falling back to the opaque background." + ); + } + }) + .expect("Failed to update window"); + }) + .detach(); + }); +} From 990a67fca6ee7a4b632c040aa803df60788d3256 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 8 Jun 2026 14:49:00 +0800 Subject: [PATCH 2/5] . --- Cargo.lock | 11 ----- Cargo.toml | 1 - crates/story/src/gallery.rs | 3 ++ crates/story/src/title_bar.rs | 4 +- crates/ui/src/global_state.rs | 2 +- crates/ui/src/root.rs | 13 +++++- crates/ui/src/theme/theme_color.rs | 66 ++++++++++------------------- crates/ui/src/window_ext.rs | 54 ++++++++++++------------ docs/docs/root.md | 6 +-- docs/zh-CN/docs/root.md | 6 +-- examples/window_glass/Cargo.toml | 16 ------- examples/window_glass/src/main.rs | 67 ------------------------------ 12 files changed, 74 insertions(+), 175 deletions(-) delete mode 100644 examples/window_glass/Cargo.toml delete mode 100644 examples/window_glass/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index b92f70fc3f..cf1246b082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9990,17 +9990,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "window_glass" -version = "0.5.1" -dependencies = [ - "anyhow", - "gpui", - "gpui-component", - "gpui-component-assets", - "gpui_platform", -] - [[package]] name = "window_title" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d0ce594651..fa282ec86b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ members = [ "examples/app_assets", "examples/hello_world", "examples/input", - "examples/window_glass", "examples/window_title", "examples/dialog_overlay", "examples/webview", diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index adad3e59e2..bfd0b67d20 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -270,6 +270,9 @@ impl Render for Gallery { v_flex() .flex_1() .h_full() + // Content layer: opaque background covers the window glass + // (only the navigation areas reveal it). + .bg(cx.theme().background) .overflow_x_hidden() .child( h_flex() diff --git a/crates/story/src/title_bar.rs b/crates/story/src/title_bar.rs index 091738a855..21b218625a 100644 --- a/crates/story/src/title_bar.rs +++ b/crates/story/src/title_bar.rs @@ -163,8 +163,8 @@ impl FontSizeSelector { cx: &mut Context, ) { if window.is_window_glass_enabled(cx) { - window.disable_window_glass(cx); - } else if !window.enable_window_glass(cx) { + window.set_window_glass(false, cx); + } else if !window.set_window_glass(true, cx) { window.push_notification("Window glass requires macOS 26+ or Windows 11 22H2+.", cx); } } diff --git a/crates/ui/src/global_state.rs b/crates/ui/src/global_state.rs index 8d607f14ae..167b95a2b2 100644 --- a/crates/ui/src/global_state.rs +++ b/crates/ui/src/global_state.rs @@ -23,7 +23,7 @@ pub struct GlobalState { /// controller in the capture phase of every left mouse down. pub(crate) suppress_text_selection: bool, /// Windows with the glass background effect enabled, - /// see [`crate::WindowExt::enable_window_glass`]. + /// see [`crate::WindowExt::set_window_glass`]. pub(crate) glass_windows: HashSet, } diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 925b0dc8fc..4a28053764 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -2,6 +2,7 @@ use crate::{ ActiveTheme, ElementExt, Placement, StyledExt, dialog::{ANIMATION_DURATION, Dialog}, focus_trap::FocusTrapManager, + global_state::GlobalState, input::{Copy, InputState}, notification::{Notification, NotificationList}, sheet::Sheet, @@ -514,6 +515,16 @@ impl Render for Root { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { window.set_rem_size(cx.theme().font_size); + // In glass mode the window background is transparent, so the glass is + // revealed through the navigation areas (sidebar / title bar / tab + // bar), while content areas keep their own opaque surfaces and cover + // it. Otherwise it uses the opaque theme background. + let background = if GlobalState::global(cx).is_window_glass_enabled(window) { + gpui::transparent_black() + } else { + cx.theme().background + }; + window_border().shadow_size(self.window_shadow_size).child( div() .id("root") @@ -524,7 +535,7 @@ impl Render for Root { .relative() .size_full() .font_family(cx.theme().font_family.clone()) - .bg(cx.theme().background) + .bg(background) .text_color(cx.theme().foreground) .refine_style(&self.style) .child(TextSelectionController) diff --git a/crates/ui/src/theme/theme_color.rs b/crates/ui/src/theme/theme_color.rs index c1e84d148d..132e8665ac 100644 --- a/crates/ui/src/theme/theme_color.rs +++ b/crates/ui/src/theme/theme_color.rs @@ -257,55 +257,35 @@ impl ThemeColor { /// Make the surface colors semi-transparent to let the window glass /// background show through, - /// see [`crate::WindowExt::enable_window_glass`]. + /// see [`crate::WindowExt::set_window_glass`]. pub(crate) fn apply_window_glass(&mut self) { - /// Window level surfaces, e.g.: window background, title bar. - const WINDOW_OPACITY: f32 = 0.5; - /// Container surfaces, e.g.: cards, secondary buttons, list rows. - const CONTAINER_OPACITY: f32 = 0.7; - /// Floating surfaces, e.g.: popovers, menus, notifications. - const FLOATING_OPACITY: f32 = 0.85; + /// Opacity for navigation-layer surfaces that reveal the glass behind + /// them (sidebar, title bar, tab bar). + const NAVIGATION_OPACITY: f32 = 0.5; - let window_surfaces = [ - &mut self.background, + // Apple's Liquid Glass guidance: glass belongs to the navigation layer + // (sidebar, title bar, tab bar), never the content layer and never + // stacked on other glass. So only these navigation surfaces are made + // translucent to let the glass show through them. The window + // background stays opaque here — instead the `Root` view paints a + // transparent background in glass mode (see `Root::render`), so the + // glass is revealed only through the navigation areas while content + // areas keep their own opaque surfaces and cover the glass. + // + // Content surfaces (background, cards, list, table, secondary, …) and + // floating surfaces (popover, menu, dialog, notification) are left + // opaque on purpose: they are layered over page content rather than + // the glass, so making them translucent would bleed that content + // through (ghosting). Keeping them opaque also avoids losing alpha + // when those colors are recomputed downstream (e.g. `.opacity()`, + // `.lighten()`, `.alpha()`). + let navigation_surfaces = [ &mut self.title_bar, &mut self.sidebar, &mut self.tab_bar, - &mut self.table, - &mut self.tiles, ]; - for color in window_surfaces { - color.a *= WINDOW_OPACITY; + for color in navigation_surfaces { + color.a *= NAVIGATION_OPACITY; } - - let container_surfaces = [ - &mut self.accent, - &mut self.description_list_label, - &mut self.group_box, - &mut self.list, - &mut self.list_active, - &mut self.list_even, - &mut self.list_head, - &mut self.list_hover, - &mut self.muted, - &mut self.secondary, - &mut self.secondary_active, - &mut self.secondary_hover, - &mut self.sidebar_accent, - &mut self.skeleton, - &mut self.tab, - &mut self.tab_active, - &mut self.tab_bar_segmented, - &mut self.table_active, - &mut self.table_even, - &mut self.table_foot, - &mut self.table_head, - &mut self.table_hover, - ]; - for color in container_surfaces { - color.a *= CONTAINER_OPACITY; - } - - self.popover.a *= FLOATING_OPACITY; } } diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs index 5d79e6e2e1..77c6d7a979 100644 --- a/crates/ui/src/window_ext.rs +++ b/crates/ui/src/window_ext.rs @@ -97,7 +97,12 @@ pub trait WindowExt: Sized { /// Clears the window-level text selection and all view-local selections. fn clear_text_selection(&mut self, cx: &mut App); - /// Enables the system glass effect for the window background. + /// Enables or disables the system glass effect for the window background. + /// + /// When `enable` is `false`, the opaque background is restored; this is a + /// no-op if the effect is not currently enabled. + /// + /// When enabling: /// /// - macOS 26 (Tahoe) or later: Liquid Glass, by embedding a native /// `NSGlassEffectView` behind the window content. @@ -109,6 +114,9 @@ pub trait WindowExt: Sized { /// `title_bar`, `sidebar`) are automatically made semi-transparent to let /// the glass show through, this applies to all windows of the application. /// + /// Returns `true` if the requested state was applied successfully. + /// Disabling always returns `true`. + /// /// # Examples /// /// ```ignore @@ -118,16 +126,10 @@ pub trait WindowExt: Sized { /// })?; /// /// window.update(cx, |_, window, cx| { - /// window.enable_window_glass(cx); + /// window.set_window_glass(true, cx); /// })?; /// ``` - fn enable_window_glass(&mut self, cx: &mut App) -> bool; - - /// Disables the system glass effect for the window background, - /// restoring the opaque background. - /// - /// This is a no-op if the effect is not enabled. - fn disable_window_glass(&mut self, cx: &mut App); + fn set_window_glass(&mut self, enable: bool, cx: &mut App) -> bool; /// Returns true if the system glass effect is enabled for the window. fn is_window_glass_enabled(&self, cx: &App) -> bool; @@ -276,30 +278,28 @@ impl WindowExt for Window { root.update(cx, |root, cx| root.clear_text_selection(cx)); } - fn enable_window_glass(&mut self, cx: &mut App) -> bool { + fn set_window_glass(&mut self, enable: bool, cx: &mut App) -> bool { let window_id = self.window_handle().window_id(); - if GlobalState::global(cx).glass_windows.contains(&window_id) { - return true; - } - if !window_glass::enable(self) { - return false; - } + if enable { + if GlobalState::global(cx).glass_windows.contains(&window_id) { + return true; + } + if !window_glass::enable(self) { + return false; + } - GlobalState::global_mut(cx).glass_windows.insert(window_id); - // Reapply the theme to make the surface colors semi-transparent. - Theme::change(Theme::global(cx).mode, Some(self), cx); - true - } + GlobalState::global_mut(cx).glass_windows.insert(window_id); + } else { + if !GlobalState::global_mut(cx).glass_windows.remove(&window_id) { + return true; + } - fn disable_window_glass(&mut self, cx: &mut App) { - let window_id = self.window_handle().window_id(); - if !GlobalState::global_mut(cx).glass_windows.remove(&window_id) { - return; + window_glass::disable(self); } - window_glass::disable(self); - // Reapply the theme to restore the original surface colors. + // Reapply the theme to update the surface colors. Theme::change(Theme::global(cx).mode, Some(self), cx); + true } #[inline] diff --git a/docs/docs/root.md b/docs/docs/root.md index 5e218a09b2..d5579f3833 100644 --- a/docs/docs/root.md +++ b/docs/docs/root.md @@ -58,7 +58,7 @@ Here the example we used `children` method, it because if there is no opened dia ## Window Glass -We can enable the system glass effect for the window background by calling `WindowExt::enable_window_glass`: +We can enable the system glass effect for the window background by calling `WindowExt::set_window_glass`: - macOS 26 (Tahoe) or later: Liquid Glass (a native `NSGlassEffectView` embedded behind the window content). - Windows 11 22H2 or later: Mica backdrop. @@ -73,11 +73,11 @@ let window = cx.open_window(options, |window, cx| { })?; window.update(cx, |_, window, cx| { - window.enable_window_glass(cx); + window.set_window_glass(true, cx); })?; ``` -When enabled, the large surface colors of the theme (e.g. `background`, `title_bar`, `sidebar`) are automatically made semi-transparent to let the glass show through, this applies to all windows of the application. Use `WindowExt::disable_window_glass` to restore the opaque background. +Following Apple's Liquid Glass guidance (glass belongs to the navigation layer, never the content), only the navigation-layer surfaces (`sidebar`, `title_bar`, `tab_bar`) are made semi-transparent to let the glass show through them. The window background itself becomes transparent in glass mode, so **your content areas must paint their own opaque background** (e.g. `bg(cx.theme().background)`) to cover the glass. Content and floating surfaces (cards, lists, tables, popovers, dialogs) stay opaque to keep content readable and avoid ghosting. This applies to all windows of the application. Call `window.set_window_glass(false, cx)` to restore the opaque background. See the [window_glass example](https://github.com/longbridge/gpui-component/tree/main/examples/window_glass) for a complete example. diff --git a/docs/zh-CN/docs/root.md b/docs/zh-CN/docs/root.md index d8b4732384..ca415429c7 100644 --- a/docs/zh-CN/docs/root.md +++ b/docs/zh-CN/docs/root.md @@ -58,7 +58,7 @@ impl Render for MyApp { ## Window Glass(窗口玻璃效果) -调用 `WindowExt::enable_window_glass` 可以为窗口背景启用系统玻璃效果: +调用 `WindowExt::set_window_glass` 可以为窗口背景启用系统玻璃效果: - macOS 26(Tahoe)及以上:Liquid Glass(在窗口内容下方嵌入原生 `NSGlassEffectView`)。 - Windows 11 22H2 及以上:Mica 背景材质。 @@ -73,11 +73,11 @@ let window = cx.open_window(options, |window, cx| { })?; window.update(cx, |_, window, cx| { - window.enable_window_glass(cx); + window.set_window_glass(true, cx); })?; ``` -开启后,主题中的大面积表面色(如 `background`、`title_bar`、`sidebar`)会自动变为半透明,让玻璃透出来;该变换对应用的所有窗口生效。调用 `WindowExt::disable_window_glass` 可恢复不透明背景。 +遵循 Apple Liquid Glass 的原则(玻璃属于导航层,而非内容层),开启后仅导航层表面(`sidebar`、`title_bar`、`tab_bar`)会变为半透明以透出玻璃。玻璃模式下窗口背景本身会变为透明,因此**你的内容区容器必须自己画不透明背景**(如 `bg(cx.theme().background)`)来盖住玻璃。内容与浮层表面(卡片、列表、表格、popover、对话框)保持不透明,以保证内容清晰并避免重影。该行为对应用的所有窗口生效。调用 `window.set_window_glass(false, cx)` 可恢复不透明背景。 完整示例参见 [window_glass example](https://github.com/longbridge/gpui-component/tree/main/examples/window_glass)。 diff --git a/examples/window_glass/Cargo.toml b/examples/window_glass/Cargo.toml deleted file mode 100644 index 4b18903670..0000000000 --- a/examples/window_glass/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "window_glass" -description = "An example of the system glass window effect (macOS Liquid Glass / Windows Mica) with GPUI Component." -version = "0.5.1" -publish = false -edition.workspace = true - -[dependencies] -anyhow.workspace = true -gpui.workspace = true -gpui_platform.workspace = true -gpui-component = { workspace = true } -gpui-component-assets = { workspace = true } - -[lints] -workspace = true diff --git a/examples/window_glass/src/main.rs b/examples/window_glass/src/main.rs deleted file mode 100644 index 08e9c140c0..0000000000 --- a/examples/window_glass/src/main.rs +++ /dev/null @@ -1,67 +0,0 @@ -use gpui::*; -use gpui_component::{ - Root, TitleBar, WindowExt, - button::{Button, ButtonVariants}, - h_flex, v_flex, -}; - -pub struct Example; - -impl Render for Example { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .child(TitleBar::new().child("Window Glass")) - .child( - v_flex() - .p_5() - .gap_3() - .size_full() - .items_center() - .justify_center() - .child("System glass window (requires macOS 26+ or Windows 11 22H2+)") - .child( - h_flex() - .gap_2() - .child(Button::new("primary").primary().label("Primary")) - .child(Button::new("outline").outline().label("Outline")), - ), - ) - } -} - -fn main() { - let app = gpui_platform::application().with_assets(gpui_component_assets::Assets); - - app.run(move |cx| { - gpui_component::init(cx); - - cx.spawn(async move |cx| { - let options = WindowOptions { - // Setup GPUI to use custom title bar - titlebar: Some(TitleBar::title_bar_options()), - ..Default::default() - }; - - let window = cx - .open_window(options, |window, cx| { - let view = cx.new(|_| Example); - // This first level on the window, should be a Root. - cx.new(|cx| Root::new(view, window, cx)) - }) - .expect("Failed to open window"); - - window - .update(cx, |_, window, cx| { - if !window.enable_window_glass(cx) { - println!( - "Window glass requires macOS 26+ or Windows 11 22H2+, \ - falling back to the opaque background." - ); - } - }) - .expect("Failed to update window"); - }) - .detach(); - }); -} From 85799b1f27653dee9852f2aff4af3c5fce655716 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 8 Jun 2026 18:38:34 +0800 Subject: [PATCH 3/5] status_bar: add StatusBar and StatusBarItem components Extract the editor's bottom bar into a reusable StatusBar with left/center/right regions and a StatusBarItem rendered as a ghost xsmall Button. StatusBar sets flex_shrink_0 so it keeps its height at the bottom of a flex column. Wire it into the editor example and the gallery bottom bar, add a StatusBarStory, and document it (en + zh). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/story/examples/editor.rs | 36 ++-- crates/story/src/gallery.rs | 38 +++- crates/story/src/stories/mod.rs | 2 + crates/story/src/stories/status_bar_story.rs | 99 ++++++++++ crates/ui/src/lib.rs | 2 + crates/ui/src/status_bar.rs | 184 +++++++++++++++++++ docs/docs/components/index.md | 1 + docs/docs/components/status-bar.md | 105 +++++++++++ docs/zh-CN/docs/components/index.md | 1 + docs/zh-CN/docs/components/status-bar.md | 105 +++++++++++ 10 files changed, 548 insertions(+), 25 deletions(-) create mode 100644 crates/story/src/stories/status_bar_story.rs create mode 100644 crates/ui/src/status_bar.rs create mode 100644 docs/docs/components/status-bar.md create mode 100644 docs/zh-CN/docs/components/status-bar.md diff --git a/crates/story/examples/editor.rs b/crates/story/examples/editor.rs index d78159c18b..574f4f63ee 100644 --- a/crates/story/examples/editor.rs +++ b/crates/story/examples/editor.rs @@ -20,6 +20,7 @@ use gpui_component::{ }, list::ListItem, resizable::{h_resizable, resizable_panel}, + status_bar::{StatusBar, StatusBarItem}, tree::{TreeItem, TreeState, tree}, v_flex, }; @@ -1118,15 +1119,14 @@ impl Example { let position = self.editor.read(cx).cursor_position(); let cursor = self.editor.read(cx).cursor(); - Button::new("line-column") - .ghost() - .xsmall() + StatusBarItem::new("line-column") .label(format!( "{}:{} ({} byte)", position.line + 1, position.character + 1, cursor )) + .tooltip("Go to Line/Column") .on_click(cx.listener(Self::go_to_line)) } } @@ -1173,27 +1173,15 @@ impl Render for Example { ), ) .child( - h_flex() - .justify_between() - .text_sm() - .bg(cx.theme().background) - .py_1p5() - .px_4() - .border_t_1() - .border_color(cx.theme().border) - .text_color(cx.theme().muted_foreground) - .child( - h_flex() - .gap_3() - .child(self.render_line_number_button(window, cx)) - .child(self.render_soft_wrap_button(window, cx)) - .child(self.render_show_whitespaces_button(window, cx)) - .child(self.render_indent_guides_button(window, cx)) - .child(self.render_folding_button(window, cx)) - .child(self.render_scroll_beyond_last_line_button(window, cx)) - .child(self.render_cursor_surrounding_lines_button(window, cx)), - ) - .child(self.render_go_to_line_button(window, cx)), + StatusBar::new() + .left(self.render_line_number_button(window, cx)) + .left(self.render_soft_wrap_button(window, cx)) + .left(self.render_show_whitespaces_button(window, cx)) + .left(self.render_indent_guides_button(window, cx)) + .left(self.render_folding_button(window, cx)) + .left(self.render_scroll_beyond_last_line_button(window, cx)) + .left(self.render_cursor_surrounding_lines_button(window, cx)) + .right(self.render_go_to_line_button(window, cx)), ), ) } diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index bfd0b67d20..6e12e14764 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -4,6 +4,7 @@ use gpui_component::{ input::{Input, InputEvent, InputState}, resizable::{h_resizable, resizable_panel}, sidebar::{Sidebar, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem}, + status_bar::{StatusBar, StatusBarItem}, v_flex, }; @@ -84,6 +85,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), @@ -162,7 +164,10 @@ impl Render for Gallery { ("".into(), "".into()) }; - h_resizable("gallery-container") + let current_story = story_name.clone(); + let total_components: usize = self.stories.iter().map(|(_, items)| items.len()).sum(); + + let body = h_resizable("gallery-container") .child( resizable_panel() .size(px(255.)) @@ -303,6 +308,37 @@ impl Render for Gallery { }), ) .into_any_element(), + ); + + v_flex() + .size_full() + .child(div().flex_1().min_h_0().child(body)) + .child( + StatusBar::new() + .left( + StatusBarItem::new("components") + .icon(IconName::GalleryVerticalEnd) + .label(format!("{total_components} components")), + ) + .map(|this| { + if current_story.is_empty() { + this + } else { + this.left(StatusBarItem::new("current").label(current_story.clone())) + } + }) + .right( + StatusBarItem::new("version") + .label(format!("v{}", env!("CARGO_PKG_VERSION"))), + ) + .right( + StatusBarItem::new("github") + .icon(IconName::Github) + .tooltip("GitHub") + .on_click(|_, _, cx| { + cx.open_url("https://github.com/longbridge/gpui-component") + }), + ), ) } } diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 7a93c73e39..2674dbbce4 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -49,6 +49,7 @@ mod sidebar_story; mod skeleton_story; mod slider_story; mod spinner_story; +mod status_bar_story; mod stepper_story; mod switch_story; mod table_story; @@ -110,6 +111,7 @@ pub use sidebar_story::SidebarStory; pub use skeleton_story::SkeletonStory; pub use slider_story::SliderStory; pub use spinner_story::SpinnerStory; +pub use status_bar_story::StatusBarStory; pub use stepper_story::StepperStory; pub use switch_story::SwitchStory; pub use table_story::TableStory; diff --git a/crates/story/src/stories/status_bar_story.rs b/crates/story/src/stories/status_bar_story.rs new file mode 100644 index 0000000000..592c2ed98a --- /dev/null +++ b/crates/story/src/stories/status_bar_story.rs @@ -0,0 +1,99 @@ +use gpui::{ + App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, + Styled, Window, +}; +use gpui_component::{ + dock::PanelControl, + status_bar::{StatusBar, StatusBarItem}, + v_flex, IconName, +}; + +use crate::section; + +pub struct StatusBarStory { + focus_handle: gpui::FocusHandle, + clicked: usize, +} + +impl StatusBarStory { + fn new(_: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + clicked: 0, + } + } + + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } +} + +impl super::Story for StatusBarStory { + fn title() -> &'static str { + "StatusBar" + } + + fn description() -> &'static str { + "A horizontal bar with left/center/right regions, usually placed at the bottom." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } + + fn zoomable() -> Option { + None + } +} + +impl Focusable for StatusBarStory { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for StatusBarStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_4() + .child( + section("Left and right").child( + StatusBar::new() + .left(StatusBarItem::new("status").label("Ready")) + .right(StatusBarItem::new("encoding").label("UTF-8")), + ), + ) + .child( + section("Three regions").child( + StatusBar::new() + .left(StatusBarItem::new("branch").icon(IconName::Github).label("main")) + .center(StatusBarItem::new("title").label("README.md")) + .right(StatusBarItem::new("position").label("Ln 1, Col 1")), + ), + ) + .child( + section("With icons").child( + StatusBar::new() + .left(StatusBarItem::new("info").icon(IconName::Info).label("12 issues")) + .left(StatusBarItem::new("bell").icon(IconName::Bell).label("3")) + .right(StatusBarItem::new("lang").icon(IconName::Globe).label("Rust")), + ), + ) + .child( + section("Interactive vs. read-only").child( + StatusBar::new() + .left( + StatusBarItem::new("clickable") + .icon(IconName::CircleCheck) + .label(format!("Clicked: {}", self.clicked)) + .tooltip("Click me") + .on_click(cx.listener(|this, _, _, cx| { + this.clicked += 1; + cx.notify(); + })), + ) + .right(StatusBarItem::new("readonly").label("Read-only")), + ), + ) + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 47d8cf74c7..6115e0156d 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -18,6 +18,7 @@ mod title_bar; mod virtual_list; mod window_border; mod window_ext; +mod window_glass; pub(crate) mod actions; @@ -66,6 +67,7 @@ pub mod sidebar; pub mod skeleton; pub mod slider; pub mod spinner; +pub mod status_bar; pub mod stepper; pub mod switch; pub mod tab; diff --git a/crates/ui/src/status_bar.rs b/crates/ui/src/status_bar.rs new file mode 100644 index 0000000000..d98ae425ba --- /dev/null +++ b/crates/ui/src/status_bar.rs @@ -0,0 +1,184 @@ +use std::rc::Rc; + +use gpui::{ + prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId, IntoElement, + ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, +}; +use smallvec::SmallVec; + +use crate::{ + button::{Button, ButtonVariants as _}, + h_flex, ActiveTheme, Icon, Sizable as _, StyledExt, +}; + +/// A horizontal status bar, usually placed at the bottom of a window or pane. +/// +/// It is split into three regions — `left`, `center`, and `right` — that are +/// distributed with `justify_between`. This mirrors the status bars found in +/// native UI frameworks (Windows `StatusStrip`, WPF `StatusBar`, macOS +/// `NSStatusBar`): a container that holds a row of items aligned to either end. +/// +/// Each region accepts any element, but [`StatusBarItem`] is provided for the +/// common icon + label + click pattern. +/// +/// ``` +/// use gpui_component::status_bar::{StatusBar, StatusBarItem}; +/// +/// let _ = StatusBar::new() +/// .left(StatusBarItem::new("ln").label("Ln 1, Col 1")) +/// .right(StatusBarItem::new("enc").label("UTF-8")); +/// ``` +#[derive(IntoElement)] +pub struct StatusBar { + style: StyleRefinement, + left: SmallVec<[AnyElement; 1]>, + center: SmallVec<[AnyElement; 1]>, + right: SmallVec<[AnyElement; 1]>, +} + +impl StatusBar { + /// Create a new, empty [`StatusBar`]. + pub fn new() -> Self { + Self { + style: StyleRefinement::default(), + left: SmallVec::new(), + center: SmallVec::new(), + right: SmallVec::new(), + } + } + + /// Append a child to the left region. Call multiple times to add more. + pub fn left(mut self, child: impl IntoElement) -> Self { + self.left.push(child.into_any_element()); + self + } + + /// Append a child to the center region. Call multiple times to add more. + pub fn center(mut self, child: impl IntoElement) -> Self { + self.center.push(child.into_any_element()); + self + } + + /// Append a child to the right region. Call multiple times to add more. + pub fn right(mut self, child: impl IntoElement) -> Self { + self.right.push(child.into_any_element()); + self + } +} + +impl Default for StatusBar { + fn default() -> Self { + Self::new() + } +} + +impl Styled for StatusBar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for StatusBar { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let region = || h_flex().items_center().gap_3(); + + h_flex() + .w_full() + // Never let the bar be squeezed to zero height when placed at the + // bottom of a flex column next to a `flex_1` content area. + .flex_shrink_0() + .justify_between() + .items_center() + .py_1p5() + .px_4() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .text_sm() + .text_color(cx.theme().muted_foreground) + .refine_style(&self.style) + .child(region().children(self.left)) + .child(region().children(self.center)) + .child(region().children(self.right)) + } +} + +/// An item for the [`StatusBar`]. +/// +/// Renders an optional icon followed by an optional label as a ghost `xsmall` +/// [`Button`], so items share the exact size, hover, and styling of buttons +/// placed in the same status bar. When an `on_click` handler is set the item +/// triggers it on click. +#[derive(IntoElement)] +pub struct StatusBarItem { + id: ElementId, + style: StyleRefinement, + icon: Option, + label: Option, + tooltip: Option, + on_click: Option>, +} + +impl StatusBarItem { + /// Create a new [`StatusBarItem`] with the given id. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + style: StyleRefinement::default(), + icon: None, + label: None, + tooltip: None, + on_click: None, + } + } + + /// Set the leading icon. + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + + /// Set the label text. + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Set the tooltip text shown on hover. + pub fn tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } + + /// Set the click handler. + pub fn on_click( + mut self, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(on_click)); + self + } +} + +impl Styled for StatusBarItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for StatusBarItem { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + // Render as a ghost `xsmall` Button so items share the exact size, + // hover, and styling of buttons placed in the same status bar. + Button::new(self.id) + .ghost() + .xsmall() + .when_some(self.icon, |this, icon| this.icon(icon)) + .when_some(self.label, |this, label| this.label(label)) + .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) + .when_some(self.on_click, |this, on_click| { + this.on_click(move |event, window, cx| on_click(event, window, cx)) + }) + .refine_style(&self.style) + } +} diff --git a/docs/docs/components/index.md b/docs/docs/components/index.md index c051003d06..e0ec724ff8 100644 --- a/docs/docs/components/index.md +++ b/docs/docs/components/index.md @@ -56,6 +56,7 @@ collapsed: false - [Scrollable](scrollable) - Scrollable containers - [Sheet](sheet) - Slide-in panel from edges - [Sidebar](sidebar) - Navigation sidebar +- [StatusBar](status-bar) - Bottom status bar with left/center/right regions ### Advanced Components diff --git a/docs/docs/components/status-bar.md b/docs/docs/components/status-bar.md new file mode 100644 index 0000000000..eae1954ac2 --- /dev/null +++ b/docs/docs/components/status-bar.md @@ -0,0 +1,105 @@ +--- +title: StatusBar +description: A horizontal status bar with left, center, and right regions, usually placed at the bottom of a window or pane. +--- + +# StatusBar + +StatusBar is a horizontal bar split into three regions — `left`, `center`, and `right` — distributed with `justify_between`. It is usually placed at the bottom of a window or pane to show contextual information and quick actions. + +The design mirrors the status bars found in native UI frameworks: Windows `StatusStrip`, WPF `StatusBar`, and macOS `NSStatusBar`. Each region accepts any element, and the companion `StatusBarItem` covers the common icon + label + click pattern. + +## Import + +```rust +use gpui_component::status_bar::{StatusBar, StatusBarItem}; +``` + +## Usage + +### Left and Right + +```rust +StatusBar::new() + .left(StatusBarItem::new("status").label("Ready")) + .right(StatusBarItem::new("encoding").label("UTF-8")) +``` + +### Three Regions + +Call `left`, `center`, or `right` multiple times to add more items to a region. + +```rust +StatusBar::new() + .left(StatusBarItem::new("branch").icon(IconName::Github).label("main")) + .center(StatusBarItem::new("title").label("README.md")) + .right(StatusBarItem::new("position").label("Ln 1, Col 1")) +``` + +### Items with Icons + +```rust +StatusBar::new() + .left(StatusBarItem::new("info").icon(IconName::Info).label("12 issues")) + .right(StatusBarItem::new("lang").icon(IconName::Globe).label("Rust")) +``` + +### Clickable Items + +Setting `on_click` makes an item trigger the handler when clicked. + +```rust +StatusBar::new() + .left( + StatusBarItem::new("go-to-line") + .icon(IconName::CircleCheck) + .label("Ln 1, Col 1") + .tooltip("Go to Line/Column") + .on_click(cx.listener(|this, _, window, cx| { + // handle click + })), + ) + .right(StatusBarItem::new("encoding").label("UTF-8")) +``` + +### Custom Styling + +`StatusBar` implements `Styled`, so any style method overrides the defaults. + +```rust +StatusBar::new() + .bg(cx.theme().secondary) + .border_color(cx.theme().border) + .left(StatusBarItem::new("status").label("Ready")) +``` + +## API Reference + +### StatusBar + +| Method | Description | +| ---------------- | ----------------------------------------------------- | +| `new()` | Create a new, empty status bar | +| `left(child)` | Append a child to the left region (call to add more) | +| `center(child)` | Append a child to the center region | +| `right(child)` | Append a child to the right region | + +`StatusBar` also implements `Styled`, so style methods (`bg`, `border_color`, `py`, etc.) can override the defaults. + +### StatusBarItem + +A `StatusBarItem` renders as a ghost `xsmall` `Button`, so it matches the size and styling of buttons placed in the same status bar. + +| Method | Description | +| ----------------- | ------------------------------------ | +| `new(id)` | Create a new item with the given id | +| `icon(icon)` | Set the leading icon | +| `label(text)` | Set the label text | +| `tooltip(text)` | Set the tooltip shown on hover | +| `on_click(fn)` | Set the click handler | + +## Notes + +- The three regions are distributed with `justify_between`; an empty `center` keeps `left` and `right` pinned to each end. +- `StatusBar` sets `flex_shrink_0`, so it keeps its height when placed at the bottom of a flex column next to a `flex_1` content area. +- Regions accept any element, so you can place `Button`, `Progress`, or other components alongside `StatusBarItem`. diff --git a/docs/zh-CN/docs/components/index.md b/docs/zh-CN/docs/components/index.md index 64776bbea9..f309407858 100644 --- a/docs/zh-CN/docs/components/index.md +++ b/docs/zh-CN/docs/components/index.md @@ -37,6 +37,7 @@ collapsed: false - [Resizable](resizable) - 可调整大小的面板 - [Scrollable](scrollable) - 可滚动容器 - [Sidebar](sidebar) - 侧边栏导航 +- [StatusBar](status-bar) - 底部状态栏,含左/中/右三区 - [Chart](chart) - 图表组件 - [DataTable](data-table) - 高性能数据表格 - [Tree](tree) - 树形结构组件 diff --git a/docs/zh-CN/docs/components/status-bar.md b/docs/zh-CN/docs/components/status-bar.md new file mode 100644 index 0000000000..d08f698d88 --- /dev/null +++ b/docs/zh-CN/docs/components/status-bar.md @@ -0,0 +1,105 @@ +--- +title: StatusBar +description: 一个分为左、中、右三个区域的水平状态栏,通常放置在窗口或面板底部。 +--- + +# StatusBar + +StatusBar 是一个水平栏,分为 `left`、`center`、`right` 三个区域,使用 `justify_between` 布局。它通常放置在窗口或面板底部,用于显示上下文信息和快捷操作。 + +其设计参考了原生 UI 框架中的状态栏:Windows 的 `StatusStrip`、WPF 的 `StatusBar` 以及 macOS 的 `NSStatusBar`。每个区域都可接受任意元素,配套的 `StatusBarItem` 则覆盖了「图标 + 文本 + 点击」这一常见模式。 + +## 引入 + +```rust +use gpui_component::status_bar::{StatusBar, StatusBarItem}; +``` + +## 用法 + +### 左右两区 + +```rust +StatusBar::new() + .left(StatusBarItem::new("status").label("Ready")) + .right(StatusBarItem::new("encoding").label("UTF-8")) +``` + +### 三个区域 + +多次调用 `left`、`center` 或 `right` 可以向同一区域追加更多项。 + +```rust +StatusBar::new() + .left(StatusBarItem::new("branch").icon(IconName::Github).label("main")) + .center(StatusBarItem::new("title").label("README.md")) + .right(StatusBarItem::new("position").label("Ln 1, Col 1")) +``` + +### 带图标的项 + +```rust +StatusBar::new() + .left(StatusBarItem::new("info").icon(IconName::Info).label("12 issues")) + .right(StatusBarItem::new("lang").icon(IconName::Globe).label("Rust")) +``` + +### 可点击项 + +设置 `on_click` 后,点击该项会触发处理函数。 + +```rust +StatusBar::new() + .left( + StatusBarItem::new("go-to-line") + .icon(IconName::CircleCheck) + .label("Ln 1, Col 1") + .tooltip("Go to Line/Column") + .on_click(cx.listener(|this, _, window, cx| { + // 处理点击 + })), + ) + .right(StatusBarItem::new("encoding").label("UTF-8")) +``` + +### 自定义样式 + +`StatusBar` 实现了 `Styled`,因此任意样式方法都会覆盖默认值。 + +```rust +StatusBar::new() + .bg(cx.theme().secondary) + .border_color(cx.theme().border) + .left(StatusBarItem::new("status").label("Ready")) +``` + +## API 参考 + +### StatusBar + +| 方法 | 说明 | +| ---------------- | ------------------------------------------ | +| `new()` | 创建一个空的状态栏 | +| `left(child)` | 向左侧区域追加一个子元素(可多次调用) | +| `center(child)` | 向中间区域追加一个子元素 | +| `right(child)` | 向右侧区域追加一个子元素 | + +`StatusBar` 还实现了 `Styled`,样式方法(`bg`、`border_color`、`py` 等)可以覆盖默认值。 + +### StatusBarItem + +`StatusBarItem` 会渲染成一个 ghost `xsmall` `Button`,因此尺寸和样式与同一状态栏中的按钮保持一致。 + +| 方法 | 说明 | +| ----------------- | -------------------------- | +| `new(id)` | 使用给定的 id 创建一个项 | +| `icon(icon)` | 设置前置图标 | +| `label(text)` | 设置标签文本 | +| `tooltip(text)` | 设置悬停时显示的提示 | +| `on_click(fn)` | 设置点击处理函数 | + +## 注意事项 + +- 三个区域使用 `justify_between` 分布;即使 `center` 为空,`left` 和 `right` 仍会固定在两端。 +- `StatusBar` 设置了 `flex_shrink_0`,因此放在 flex 列底部、与 `flex_1` 内容区相邻时,它的高度不会被压缩。 +- 各区域可接受任意元素,因此你可以在 `StatusBarItem` 之外放置 `Button`、`Progress` 等组件。 From 24321284bd00c4c07ee80fb87e049cfaa49a98fe Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 8 Jun 2026 19:40:00 +0800 Subject: [PATCH 4/5] Revert "status_bar: add StatusBar and StatusBarItem components" This reverts commit 85799b1f27653dee9852f2aff4af3c5fce655716. --- crates/story/examples/editor.rs | 36 ++-- crates/story/src/gallery.rs | 38 +--- crates/story/src/stories/mod.rs | 2 - crates/story/src/stories/status_bar_story.rs | 99 ---------- crates/ui/src/lib.rs | 2 - crates/ui/src/status_bar.rs | 184 ------------------- docs/docs/components/index.md | 1 - docs/docs/components/status-bar.md | 105 ----------- docs/zh-CN/docs/components/index.md | 1 - docs/zh-CN/docs/components/status-bar.md | 105 ----------- 10 files changed, 25 insertions(+), 548 deletions(-) delete mode 100644 crates/story/src/stories/status_bar_story.rs delete mode 100644 crates/ui/src/status_bar.rs delete mode 100644 docs/docs/components/status-bar.md delete mode 100644 docs/zh-CN/docs/components/status-bar.md diff --git a/crates/story/examples/editor.rs b/crates/story/examples/editor.rs index 574f4f63ee..d78159c18b 100644 --- a/crates/story/examples/editor.rs +++ b/crates/story/examples/editor.rs @@ -20,7 +20,6 @@ use gpui_component::{ }, list::ListItem, resizable::{h_resizable, resizable_panel}, - status_bar::{StatusBar, StatusBarItem}, tree::{TreeItem, TreeState, tree}, v_flex, }; @@ -1119,14 +1118,15 @@ impl Example { let position = self.editor.read(cx).cursor_position(); let cursor = self.editor.read(cx).cursor(); - StatusBarItem::new("line-column") + Button::new("line-column") + .ghost() + .xsmall() .label(format!( "{}:{} ({} byte)", position.line + 1, position.character + 1, cursor )) - .tooltip("Go to Line/Column") .on_click(cx.listener(Self::go_to_line)) } } @@ -1173,15 +1173,27 @@ impl Render for Example { ), ) .child( - StatusBar::new() - .left(self.render_line_number_button(window, cx)) - .left(self.render_soft_wrap_button(window, cx)) - .left(self.render_show_whitespaces_button(window, cx)) - .left(self.render_indent_guides_button(window, cx)) - .left(self.render_folding_button(window, cx)) - .left(self.render_scroll_beyond_last_line_button(window, cx)) - .left(self.render_cursor_surrounding_lines_button(window, cx)) - .right(self.render_go_to_line_button(window, cx)), + h_flex() + .justify_between() + .text_sm() + .bg(cx.theme().background) + .py_1p5() + .px_4() + .border_t_1() + .border_color(cx.theme().border) + .text_color(cx.theme().muted_foreground) + .child( + h_flex() + .gap_3() + .child(self.render_line_number_button(window, cx)) + .child(self.render_soft_wrap_button(window, cx)) + .child(self.render_show_whitespaces_button(window, cx)) + .child(self.render_indent_guides_button(window, cx)) + .child(self.render_folding_button(window, cx)) + .child(self.render_scroll_beyond_last_line_button(window, cx)) + .child(self.render_cursor_surrounding_lines_button(window, cx)), + ) + .child(self.render_go_to_line_button(window, cx)), ), ) } diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index 6e12e14764..bfd0b67d20 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -4,7 +4,6 @@ use gpui_component::{ input::{Input, InputEvent, InputState}, resizable::{h_resizable, resizable_panel}, sidebar::{Sidebar, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem}, - status_bar::{StatusBar, StatusBarItem}, v_flex, }; @@ -85,7 +84,6 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), - StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), @@ -164,10 +162,7 @@ impl Render for Gallery { ("".into(), "".into()) }; - let current_story = story_name.clone(); - let total_components: usize = self.stories.iter().map(|(_, items)| items.len()).sum(); - - let body = h_resizable("gallery-container") + h_resizable("gallery-container") .child( resizable_panel() .size(px(255.)) @@ -308,37 +303,6 @@ impl Render for Gallery { }), ) .into_any_element(), - ); - - v_flex() - .size_full() - .child(div().flex_1().min_h_0().child(body)) - .child( - StatusBar::new() - .left( - StatusBarItem::new("components") - .icon(IconName::GalleryVerticalEnd) - .label(format!("{total_components} components")), - ) - .map(|this| { - if current_story.is_empty() { - this - } else { - this.left(StatusBarItem::new("current").label(current_story.clone())) - } - }) - .right( - StatusBarItem::new("version") - .label(format!("v{}", env!("CARGO_PKG_VERSION"))), - ) - .right( - StatusBarItem::new("github") - .icon(IconName::Github) - .tooltip("GitHub") - .on_click(|_, _, cx| { - cx.open_url("https://github.com/longbridge/gpui-component") - }), - ), ) } } diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 2674dbbce4..7a93c73e39 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -49,7 +49,6 @@ mod sidebar_story; mod skeleton_story; mod slider_story; mod spinner_story; -mod status_bar_story; mod stepper_story; mod switch_story; mod table_story; @@ -111,7 +110,6 @@ pub use sidebar_story::SidebarStory; pub use skeleton_story::SkeletonStory; pub use slider_story::SliderStory; pub use spinner_story::SpinnerStory; -pub use status_bar_story::StatusBarStory; pub use stepper_story::StepperStory; pub use switch_story::SwitchStory; pub use table_story::TableStory; diff --git a/crates/story/src/stories/status_bar_story.rs b/crates/story/src/stories/status_bar_story.rs deleted file mode 100644 index 592c2ed98a..0000000000 --- a/crates/story/src/stories/status_bar_story.rs +++ /dev/null @@ -1,99 +0,0 @@ -use gpui::{ - App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, - Styled, Window, -}; -use gpui_component::{ - dock::PanelControl, - status_bar::{StatusBar, StatusBarItem}, - v_flex, IconName, -}; - -use crate::section; - -pub struct StatusBarStory { - focus_handle: gpui::FocusHandle, - clicked: usize, -} - -impl StatusBarStory { - fn new(_: &mut Window, cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - clicked: 0, - } - } - - pub fn view(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Self::new(window, cx)) - } -} - -impl super::Story for StatusBarStory { - fn title() -> &'static str { - "StatusBar" - } - - fn description() -> &'static str { - "A horizontal bar with left/center/right regions, usually placed at the bottom." - } - - fn new_view(window: &mut Window, cx: &mut App) -> Entity { - Self::view(window, cx) - } - - fn zoomable() -> Option { - None - } -} - -impl Focusable for StatusBarStory { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for StatusBarStory { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .gap_4() - .child( - section("Left and right").child( - StatusBar::new() - .left(StatusBarItem::new("status").label("Ready")) - .right(StatusBarItem::new("encoding").label("UTF-8")), - ), - ) - .child( - section("Three regions").child( - StatusBar::new() - .left(StatusBarItem::new("branch").icon(IconName::Github).label("main")) - .center(StatusBarItem::new("title").label("README.md")) - .right(StatusBarItem::new("position").label("Ln 1, Col 1")), - ), - ) - .child( - section("With icons").child( - StatusBar::new() - .left(StatusBarItem::new("info").icon(IconName::Info).label("12 issues")) - .left(StatusBarItem::new("bell").icon(IconName::Bell).label("3")) - .right(StatusBarItem::new("lang").icon(IconName::Globe).label("Rust")), - ), - ) - .child( - section("Interactive vs. read-only").child( - StatusBar::new() - .left( - StatusBarItem::new("clickable") - .icon(IconName::CircleCheck) - .label(format!("Clicked: {}", self.clicked)) - .tooltip("Click me") - .on_click(cx.listener(|this, _, _, cx| { - this.clicked += 1; - cx.notify(); - })), - ) - .right(StatusBarItem::new("readonly").label("Read-only")), - ), - ) - } -} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 6115e0156d..47d8cf74c7 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -18,7 +18,6 @@ mod title_bar; mod virtual_list; mod window_border; mod window_ext; -mod window_glass; pub(crate) mod actions; @@ -67,7 +66,6 @@ pub mod sidebar; pub mod skeleton; pub mod slider; pub mod spinner; -pub mod status_bar; pub mod stepper; pub mod switch; pub mod tab; diff --git a/crates/ui/src/status_bar.rs b/crates/ui/src/status_bar.rs deleted file mode 100644 index d98ae425ba..0000000000 --- a/crates/ui/src/status_bar.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::rc::Rc; - -use gpui::{ - prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId, IntoElement, - ParentElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, -}; -use smallvec::SmallVec; - -use crate::{ - button::{Button, ButtonVariants as _}, - h_flex, ActiveTheme, Icon, Sizable as _, StyledExt, -}; - -/// A horizontal status bar, usually placed at the bottom of a window or pane. -/// -/// It is split into three regions — `left`, `center`, and `right` — that are -/// distributed with `justify_between`. This mirrors the status bars found in -/// native UI frameworks (Windows `StatusStrip`, WPF `StatusBar`, macOS -/// `NSStatusBar`): a container that holds a row of items aligned to either end. -/// -/// Each region accepts any element, but [`StatusBarItem`] is provided for the -/// common icon + label + click pattern. -/// -/// ``` -/// use gpui_component::status_bar::{StatusBar, StatusBarItem}; -/// -/// let _ = StatusBar::new() -/// .left(StatusBarItem::new("ln").label("Ln 1, Col 1")) -/// .right(StatusBarItem::new("enc").label("UTF-8")); -/// ``` -#[derive(IntoElement)] -pub struct StatusBar { - style: StyleRefinement, - left: SmallVec<[AnyElement; 1]>, - center: SmallVec<[AnyElement; 1]>, - right: SmallVec<[AnyElement; 1]>, -} - -impl StatusBar { - /// Create a new, empty [`StatusBar`]. - pub fn new() -> Self { - Self { - style: StyleRefinement::default(), - left: SmallVec::new(), - center: SmallVec::new(), - right: SmallVec::new(), - } - } - - /// Append a child to the left region. Call multiple times to add more. - pub fn left(mut self, child: impl IntoElement) -> Self { - self.left.push(child.into_any_element()); - self - } - - /// Append a child to the center region. Call multiple times to add more. - pub fn center(mut self, child: impl IntoElement) -> Self { - self.center.push(child.into_any_element()); - self - } - - /// Append a child to the right region. Call multiple times to add more. - pub fn right(mut self, child: impl IntoElement) -> Self { - self.right.push(child.into_any_element()); - self - } -} - -impl Default for StatusBar { - fn default() -> Self { - Self::new() - } -} - -impl Styled for StatusBar { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for StatusBar { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let region = || h_flex().items_center().gap_3(); - - h_flex() - .w_full() - // Never let the bar be squeezed to zero height when placed at the - // bottom of a flex column next to a `flex_1` content area. - .flex_shrink_0() - .justify_between() - .items_center() - .py_1p5() - .px_4() - .border_t_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .text_sm() - .text_color(cx.theme().muted_foreground) - .refine_style(&self.style) - .child(region().children(self.left)) - .child(region().children(self.center)) - .child(region().children(self.right)) - } -} - -/// An item for the [`StatusBar`]. -/// -/// Renders an optional icon followed by an optional label as a ghost `xsmall` -/// [`Button`], so items share the exact size, hover, and styling of buttons -/// placed in the same status bar. When an `on_click` handler is set the item -/// triggers it on click. -#[derive(IntoElement)] -pub struct StatusBarItem { - id: ElementId, - style: StyleRefinement, - icon: Option, - label: Option, - tooltip: Option, - on_click: Option>, -} - -impl StatusBarItem { - /// Create a new [`StatusBarItem`] with the given id. - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - style: StyleRefinement::default(), - icon: None, - label: None, - tooltip: None, - on_click: None, - } - } - - /// Set the leading icon. - pub fn icon(mut self, icon: impl Into) -> Self { - self.icon = Some(icon.into()); - self - } - - /// Set the label text. - pub fn label(mut self, label: impl Into) -> Self { - self.label = Some(label.into()); - self - } - - /// Set the tooltip text shown on hover. - pub fn tooltip(mut self, tooltip: impl Into) -> Self { - self.tooltip = Some(tooltip.into()); - self - } - - /// Set the click handler. - pub fn on_click( - mut self, - on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_click = Some(Rc::new(on_click)); - self - } -} - -impl Styled for StatusBarItem { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for StatusBarItem { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { - // Render as a ghost `xsmall` Button so items share the exact size, - // hover, and styling of buttons placed in the same status bar. - Button::new(self.id) - .ghost() - .xsmall() - .when_some(self.icon, |this, icon| this.icon(icon)) - .when_some(self.label, |this, label| this.label(label)) - .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) - .when_some(self.on_click, |this, on_click| { - this.on_click(move |event, window, cx| on_click(event, window, cx)) - }) - .refine_style(&self.style) - } -} diff --git a/docs/docs/components/index.md b/docs/docs/components/index.md index e0ec724ff8..c051003d06 100644 --- a/docs/docs/components/index.md +++ b/docs/docs/components/index.md @@ -56,7 +56,6 @@ collapsed: false - [Scrollable](scrollable) - Scrollable containers - [Sheet](sheet) - Slide-in panel from edges - [Sidebar](sidebar) - Navigation sidebar -- [StatusBar](status-bar) - Bottom status bar with left/center/right regions ### Advanced Components diff --git a/docs/docs/components/status-bar.md b/docs/docs/components/status-bar.md deleted file mode 100644 index eae1954ac2..0000000000 --- a/docs/docs/components/status-bar.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: StatusBar -description: A horizontal status bar with left, center, and right regions, usually placed at the bottom of a window or pane. ---- - -# StatusBar - -StatusBar is a horizontal bar split into three regions — `left`, `center`, and `right` — distributed with `justify_between`. It is usually placed at the bottom of a window or pane to show contextual information and quick actions. - -The design mirrors the status bars found in native UI frameworks: Windows `StatusStrip`, WPF `StatusBar`, and macOS `NSStatusBar`. Each region accepts any element, and the companion `StatusBarItem` covers the common icon + label + click pattern. - -## Import - -```rust -use gpui_component::status_bar::{StatusBar, StatusBarItem}; -``` - -## Usage - -### Left and Right - -```rust -StatusBar::new() - .left(StatusBarItem::new("status").label("Ready")) - .right(StatusBarItem::new("encoding").label("UTF-8")) -``` - -### Three Regions - -Call `left`, `center`, or `right` multiple times to add more items to a region. - -```rust -StatusBar::new() - .left(StatusBarItem::new("branch").icon(IconName::Github).label("main")) - .center(StatusBarItem::new("title").label("README.md")) - .right(StatusBarItem::new("position").label("Ln 1, Col 1")) -``` - -### Items with Icons - -```rust -StatusBar::new() - .left(StatusBarItem::new("info").icon(IconName::Info).label("12 issues")) - .right(StatusBarItem::new("lang").icon(IconName::Globe).label("Rust")) -``` - -### Clickable Items - -Setting `on_click` makes an item trigger the handler when clicked. - -```rust -StatusBar::new() - .left( - StatusBarItem::new("go-to-line") - .icon(IconName::CircleCheck) - .label("Ln 1, Col 1") - .tooltip("Go to Line/Column") - .on_click(cx.listener(|this, _, window, cx| { - // handle click - })), - ) - .right(StatusBarItem::new("encoding").label("UTF-8")) -``` - -### Custom Styling - -`StatusBar` implements `Styled`, so any style method overrides the defaults. - -```rust -StatusBar::new() - .bg(cx.theme().secondary) - .border_color(cx.theme().border) - .left(StatusBarItem::new("status").label("Ready")) -``` - -## API Reference - -### StatusBar - -| Method | Description | -| ---------------- | ----------------------------------------------------- | -| `new()` | Create a new, empty status bar | -| `left(child)` | Append a child to the left region (call to add more) | -| `center(child)` | Append a child to the center region | -| `right(child)` | Append a child to the right region | - -`StatusBar` also implements `Styled`, so style methods (`bg`, `border_color`, `py`, etc.) can override the defaults. - -### StatusBarItem - -A `StatusBarItem` renders as a ghost `xsmall` `Button`, so it matches the size and styling of buttons placed in the same status bar. - -| Method | Description | -| ----------------- | ------------------------------------ | -| `new(id)` | Create a new item with the given id | -| `icon(icon)` | Set the leading icon | -| `label(text)` | Set the label text | -| `tooltip(text)` | Set the tooltip shown on hover | -| `on_click(fn)` | Set the click handler | - -## Notes - -- The three regions are distributed with `justify_between`; an empty `center` keeps `left` and `right` pinned to each end. -- `StatusBar` sets `flex_shrink_0`, so it keeps its height when placed at the bottom of a flex column next to a `flex_1` content area. -- Regions accept any element, so you can place `Button`, `Progress`, or other components alongside `StatusBarItem`. diff --git a/docs/zh-CN/docs/components/index.md b/docs/zh-CN/docs/components/index.md index f309407858..64776bbea9 100644 --- a/docs/zh-CN/docs/components/index.md +++ b/docs/zh-CN/docs/components/index.md @@ -37,7 +37,6 @@ collapsed: false - [Resizable](resizable) - 可调整大小的面板 - [Scrollable](scrollable) - 可滚动容器 - [Sidebar](sidebar) - 侧边栏导航 -- [StatusBar](status-bar) - 底部状态栏,含左/中/右三区 - [Chart](chart) - 图表组件 - [DataTable](data-table) - 高性能数据表格 - [Tree](tree) - 树形结构组件 diff --git a/docs/zh-CN/docs/components/status-bar.md b/docs/zh-CN/docs/components/status-bar.md deleted file mode 100644 index d08f698d88..0000000000 --- a/docs/zh-CN/docs/components/status-bar.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: StatusBar -description: 一个分为左、中、右三个区域的水平状态栏,通常放置在窗口或面板底部。 ---- - -# StatusBar - -StatusBar 是一个水平栏,分为 `left`、`center`、`right` 三个区域,使用 `justify_between` 布局。它通常放置在窗口或面板底部,用于显示上下文信息和快捷操作。 - -其设计参考了原生 UI 框架中的状态栏:Windows 的 `StatusStrip`、WPF 的 `StatusBar` 以及 macOS 的 `NSStatusBar`。每个区域都可接受任意元素,配套的 `StatusBarItem` 则覆盖了「图标 + 文本 + 点击」这一常见模式。 - -## 引入 - -```rust -use gpui_component::status_bar::{StatusBar, StatusBarItem}; -``` - -## 用法 - -### 左右两区 - -```rust -StatusBar::new() - .left(StatusBarItem::new("status").label("Ready")) - .right(StatusBarItem::new("encoding").label("UTF-8")) -``` - -### 三个区域 - -多次调用 `left`、`center` 或 `right` 可以向同一区域追加更多项。 - -```rust -StatusBar::new() - .left(StatusBarItem::new("branch").icon(IconName::Github).label("main")) - .center(StatusBarItem::new("title").label("README.md")) - .right(StatusBarItem::new("position").label("Ln 1, Col 1")) -``` - -### 带图标的项 - -```rust -StatusBar::new() - .left(StatusBarItem::new("info").icon(IconName::Info).label("12 issues")) - .right(StatusBarItem::new("lang").icon(IconName::Globe).label("Rust")) -``` - -### 可点击项 - -设置 `on_click` 后,点击该项会触发处理函数。 - -```rust -StatusBar::new() - .left( - StatusBarItem::new("go-to-line") - .icon(IconName::CircleCheck) - .label("Ln 1, Col 1") - .tooltip("Go to Line/Column") - .on_click(cx.listener(|this, _, window, cx| { - // 处理点击 - })), - ) - .right(StatusBarItem::new("encoding").label("UTF-8")) -``` - -### 自定义样式 - -`StatusBar` 实现了 `Styled`,因此任意样式方法都会覆盖默认值。 - -```rust -StatusBar::new() - .bg(cx.theme().secondary) - .border_color(cx.theme().border) - .left(StatusBarItem::new("status").label("Ready")) -``` - -## API 参考 - -### StatusBar - -| 方法 | 说明 | -| ---------------- | ------------------------------------------ | -| `new()` | 创建一个空的状态栏 | -| `left(child)` | 向左侧区域追加一个子元素(可多次调用) | -| `center(child)` | 向中间区域追加一个子元素 | -| `right(child)` | 向右侧区域追加一个子元素 | - -`StatusBar` 还实现了 `Styled`,样式方法(`bg`、`border_color`、`py` 等)可以覆盖默认值。 - -### StatusBarItem - -`StatusBarItem` 会渲染成一个 ghost `xsmall` `Button`,因此尺寸和样式与同一状态栏中的按钮保持一致。 - -| 方法 | 说明 | -| ----------------- | -------------------------- | -| `new(id)` | 使用给定的 id 创建一个项 | -| `icon(icon)` | 设置前置图标 | -| `label(text)` | 设置标签文本 | -| `tooltip(text)` | 设置悬停时显示的提示 | -| `on_click(fn)` | 设置点击处理函数 | - -## 注意事项 - -- 三个区域使用 `justify_between` 分布;即使 `center` 为空,`left` 和 `right` 仍会固定在两端。 -- `StatusBar` 设置了 `flex_shrink_0`,因此放在 flex 列底部、与 `flex_1` 内容区相邻时,它的高度不会被压缩。 -- 各区域可接受任意元素,因此你可以在 `StatusBarItem` 之外放置 `Button`、`Progress` 等组件。 From 3430e71048e275e0a8fa08f3d9c1386211887aab Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 8 Jun 2026 20:57:21 +0800 Subject: [PATCH 5/5] window: split glass into a module, keep it across theme changes Move the platform glass implementation (macOS NSGlassEffectView / Windows Mica / no-op fallback) into a dedicated window_glass module, one file per platform. Mirror the glass-enabled state onto Theme so a full theme swap via apply_config, not just a light/dark mode switch, reapplies the translucent surface adjustments. Sidebar hover now uses an opaque mix so it stays lighter than the active item even over a translucent glass sidebar. Docs synced (en/zh-CN). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ui/src/lib.rs | 1 + crates/ui/src/sidebar/menu.rs | 12 ++- crates/ui/src/theme/mod.rs | 17 ++-- crates/ui/src/theme/schema.rs | 6 ++ crates/ui/src/theme/theme_color.rs | 34 +++---- crates/ui/src/window_ext.rs | 139 +------------------------- crates/ui/src/window_glass/macos.rs | 98 ++++++++++++++++++ crates/ui/src/window_glass/mod.rs | 26 +++++ crates/ui/src/window_glass/windows.rs | 21 ++++ docs/docs/root.md | 4 +- docs/zh-CN/docs/root.md | 4 +- 11 files changed, 197 insertions(+), 165 deletions(-) create mode 100644 crates/ui/src/window_glass/macos.rs create mode 100644 crates/ui/src/window_glass/mod.rs create mode 100644 crates/ui/src/window_glass/windows.rs diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 47d8cf74c7..d127fc37ed 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -18,6 +18,7 @@ mod title_bar; mod virtual_list; mod window_border; mod window_ext; +mod window_glass; pub(crate) mod actions; diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index 8fd72b0ab6..b0bcba7616 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -1,5 +1,5 @@ use crate::{ - ActiveTheme as _, Collapsible, Icon, IconName, Sizable as _, StyledExt, + ActiveTheme as _, Collapsible, Colorize as _, Icon, IconName, Sizable as _, StyledExt, button::{Button, ButtonVariants as _}, h_flex, menu::{ContextMenuExt, PopupMenu}, @@ -269,7 +269,15 @@ impl SidebarItem for SidebarMenuItem { .text_sm() .when(is_hoverable, |this| { this.hover(|this| { - this.bg(cx.theme().sidebar_accent.opacity(0.8)) + // Opaque hover, lighter than the active item: mix + // toward the sidebar for the hue, then force full + // alpha. In glass mode `sidebar` is translucent, so + // the mix would otherwise stay semi-transparent and + // blend with the glass to read darker than active. + let mut hover_bg = + cx.theme().sidebar_accent.mix(cx.theme().sidebar, 0.8); + hover_bg.a = 1.0; + this.bg(hover_bg) .text_color(cx.theme().sidebar_accent_foreground) }) }) diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs index 9dbd9e530b..fdca31f97b 100644 --- a/crates/ui/src/theme/mod.rs +++ b/crates/ui/src/theme/mod.rs @@ -84,6 +84,13 @@ pub struct Theme { pub list: ListSettings, /// The sheet settings. pub sheet: SheetSettings, + /// Whether the system glass background effect is enabled for any window. + /// + /// Mirrored from [`crate::WindowExt::set_window_glass`] so that every theme + /// change (mode switch or full theme swap) reapplies the glass surface + /// adjustments via [`ThemeColor::apply_window_glass`]. + #[serde(skip)] + pub(crate) window_glass: bool, } impl Default for Theme { @@ -167,10 +174,6 @@ impl Theme { cx.set_global(theme); } - let window_glass = cx - .try_global::() - .is_some_and(|state| !state.glass_windows.is_empty()); - let theme = cx.global_mut::(); theme.mode = mode; if mode.is_dark() { @@ -178,9 +181,8 @@ impl Theme { } else { theme.apply_config(&theme.light_theme.clone()); } - if window_glass { - theme.colors.apply_window_glass(); - } + // `apply_config` reapplies the glass adjustments when `window_glass` + // is set, so no extra handling is needed here. if let Some(window) = window { window.refresh(); @@ -240,6 +242,7 @@ impl From<&ThemeColor> for Theme { dark_theme: Rc::new(ThemeConfig::default()), highlight_theme: HighlightTheme::default_light(), sheet: SheetSettings::default(), + window_glass: false, } } } diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index c936be8740..7a1f8c73b5 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -711,5 +711,11 @@ impl Theme { self.colors.apply_config(&config, &default_colors); self.mode = config.mode; + + // `apply_config` just rebuilt the colors, so reapply the window glass + // surface adjustments to keep glass active across theme switches. + if self.window_glass { + self.colors.apply_window_glass(); + } } } diff --git a/crates/ui/src/theme/theme_color.rs b/crates/ui/src/theme/theme_color.rs index 132e8665ac..01d374c897 100644 --- a/crates/ui/src/theme/theme_color.rs +++ b/crates/ui/src/theme/theme_color.rs @@ -259,26 +259,24 @@ impl ThemeColor { /// background show through, /// see [`crate::WindowExt::set_window_glass`]. pub(crate) fn apply_window_glass(&mut self) { - /// Opacity for navigation-layer surfaces that reveal the glass behind - /// them (sidebar, title bar, tab bar). + /// Opacity for the navigation surfaces that reveal the glass. + /// + /// Note: GPUI has no per-element backdrop blur, so a rounded element on + /// top of a translucent navigation surface has its anti-aliased corner + /// edge bleed the glass through (a faint jaggy). The lower this value, + /// the stronger that bleed — keep it fairly high so the glass tint + /// still reads while the corner artifact stays negligible. const NAVIGATION_OPACITY: f32 = 0.5; - // Apple's Liquid Glass guidance: glass belongs to the navigation layer - // (sidebar, title bar, tab bar), never the content layer and never - // stacked on other glass. So only these navigation surfaces are made - // translucent to let the glass show through them. The window - // background stays opaque here — instead the `Root` view paints a - // transparent background in glass mode (see `Root::render`), so the - // glass is revealed only through the navigation areas while content - // areas keep their own opaque surfaces and cover the glass. - // - // Content surfaces (background, cards, list, table, secondary, …) and - // floating surfaces (popover, menu, dialog, notification) are left - // opaque on purpose: they are layered over page content rather than - // the glass, so making them translucent would bleed that content - // through (ghosting). Keeping them opaque also avoids losing alpha - // when those colors are recomputed downstream (e.g. `.opacity()`, - // `.lighten()`, `.alpha()`). + // Only the navigation surfaces (sidebar, title bar, tab bar) are made + // translucent to reveal the glass behind them. The content background + // and all controls / cards (secondary, list, table, group_box, popover, + // dialog, …) stay opaque, so content stays readable and never bleeds + // through (ghosting), and their colors keep full alpha when recomputed + // downstream (e.g. `.opacity()`, `.lighten()`, `.alpha()`). The `Root` + // view paints a transparent window background in glass mode so the + // glass shows through these navigation areas, while the content area + // covers it with its opaque background. let navigation_surfaces = [ &mut self.title_bar, &mut self.sidebar, diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs index 77c6d7a979..19d7f009d7 100644 --- a/crates/ui/src/window_ext.rs +++ b/crates/ui/src/window_ext.rs @@ -5,6 +5,7 @@ use crate::{ input::InputState, notification::Notification, sheet::Sheet, + window_glass, }; use gpui::{App, ElementId, Entity, Window}; use std::rc::Rc; @@ -297,6 +298,10 @@ impl WindowExt for Window { window_glass::disable(self); } + // Mirror the glass state onto the Theme so subsequent theme changes + // (mode switch or full theme swap via `apply_config`) reapply the + // glass surface adjustments. + Theme::global_mut(cx).window_glass = !GlobalState::global(cx).glass_windows.is_empty(); // Reapply the theme to update the surface colors. Theme::change(Theme::global(cx).mode, Some(self), cx); true @@ -307,137 +312,3 @@ impl WindowExt for Window { GlobalState::global(cx).is_window_glass_enabled(self) } } - -/// Liquid Glass, embeds a native `NSGlassEffectView` (macOS 26+) behind the -/// window content. -/// -/// This mirrors how GPUI injects an `NSVisualEffectView` for -/// [`WindowBackgroundAppearance::Blurred`](gpui::WindowBackgroundAppearance): -/// the glass view is added to the window's `contentView`, positioned below -/// the GPUI rendered view, with an autoresizing mask to follow window resize. -#[cfg(target_os = "macos")] -mod window_glass { - use gpui::{Window, WindowBackgroundAppearance}; - use objc2::ffi::{NSInteger, NSUInteger}; - use objc2::msg_send; - use objc2::rc::Retained; - use objc2::runtime::{AnyClass, AnyObject}; - use objc2_foundation::NSRect; - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - - /// `NSViewWidthSizable | NSViewHeightSizable` - const AUTORESIZE_WIDTH_HEIGHT: NSUInteger = (1 << 1) | (1 << 4); - /// `NSWindowBelow` - const NS_WINDOW_BELOW: NSInteger = -1; - - /// Returns the `contentView` of the window's native `NSWindow`. - fn content_view(window: &Window) -> Option<*mut AnyObject> { - let handle = HasWindowHandle::window_handle(window).ok()?; - let RawWindowHandle::AppKit(handle) = handle.as_raw() else { - return None; - }; - - unsafe { - let ns_view: *mut AnyObject = handle.ns_view.as_ptr().cast(); - let ns_window: *mut AnyObject = msg_send![ns_view, window]; - if ns_window.is_null() { - return None; - } - let content_view: *mut AnyObject = msg_send![ns_window, contentView]; - if content_view.is_null() { - return None; - } - - Some(content_view) - } - } - - pub(super) fn enable(window: &mut Window) -> bool { - // The class only exists on macOS 26 (Tahoe) and later, so a runtime - // lookup doubles as the OS version check. - let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") else { - return false; - }; - let Some(content_view) = content_view(window) else { - return false; - }; - - // Let GPUI make the window non-opaque and its Metal layer - // transparent, so the glass below shows through. - window.set_background_appearance(WindowBackgroundAppearance::Transparent); - - unsafe { - let bounds: NSRect = msg_send![content_view, bounds]; - let glass: Retained = msg_send![glass_class, new]; - let _: () = msg_send![&*glass, setFrame: bounds]; - let _: () = msg_send![&*glass, setAutoresizingMask: AUTORESIZE_WIDTH_HEIGHT]; - let _: () = msg_send![ - content_view, - addSubview: &*glass, - positioned: NS_WINDOW_BELOW, - relativeTo: std::ptr::null_mut::() - ]; - } - - true - } - - /// Removes the injected glass views and restores the opaque background. - pub(super) fn disable(window: &mut Window) { - let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") else { - return; - }; - let Some(content_view) = content_view(window) else { - return; - }; - - unsafe { - // The `subviews` getter returns a copy of the array, so it is - // safe to remove views while iterating over it. - let subviews: Retained = msg_send![content_view, subviews]; - let count: usize = msg_send![&*subviews, count]; - for i in 0..count { - let view: *mut AnyObject = msg_send![&*subviews, objectAtIndex: i]; - let is_glass: bool = msg_send![view, isKindOfClass: glass_class]; - if is_glass { - let _: () = msg_send![view, removeFromSuperview]; - } - } - } - - window.set_background_appearance(WindowBackgroundAppearance::Opaque); - } -} - -/// Mica backdrop (Windows 11 22H2+), via GPUI's -/// [`WindowBackgroundAppearance::MicaBackdrop`](gpui::WindowBackgroundAppearance). -#[cfg(target_os = "windows")] -mod window_glass { - use gpui::{Window, WindowBackgroundAppearance}; - - /// The Mica backdrop requires Windows 11 22H2 (build 22621) or later, - /// GPUI silently ignores it on older builds. - const MIN_BUILD_NUMBER: u32 = 22621; - - pub(super) fn enable(window: &mut Window) -> bool { - if windows_version::OsVersion::current().build < MIN_BUILD_NUMBER { - return false; - } - - window.set_background_appearance(WindowBackgroundAppearance::MicaBackdrop); - true - } - - pub(super) fn disable(window: &mut Window) { - window.set_background_appearance(WindowBackgroundAppearance::Opaque); - } -} - -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -mod window_glass { - pub(super) fn enable(_window: &mut gpui::Window) -> bool { - false - } - - pub(super) fn disable(_window: &mut gpui::Window) {} -} diff --git a/crates/ui/src/window_glass/macos.rs b/crates/ui/src/window_glass/macos.rs new file mode 100644 index 0000000000..66933e8ff4 --- /dev/null +++ b/crates/ui/src/window_glass/macos.rs @@ -0,0 +1,98 @@ +//! Liquid Glass, embeds a native `NSGlassEffectView` (macOS 26+) behind the +//! window content. +//! +//! This mirrors how GPUI injects an `NSVisualEffectView` for +//! [`WindowBackgroundAppearance::Blurred`](gpui::WindowBackgroundAppearance): +//! the glass view is added to the window's `contentView`, positioned below +//! the GPUI rendered view, with an autoresizing mask to follow window resize. + +use gpui::{Window, WindowBackgroundAppearance}; +use objc2::ffi::{NSInteger, NSUInteger}; +use objc2::msg_send; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject}; +use objc2_foundation::NSRect; +use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + +/// `NSViewWidthSizable | NSViewHeightSizable` +const AUTORESIZE_WIDTH_HEIGHT: NSUInteger = (1 << 1) | (1 << 4); +/// `NSWindowBelow` +const NS_WINDOW_BELOW: NSInteger = -1; + +/// Returns the `contentView` of the window's native `NSWindow`. +fn content_view(window: &Window) -> Option<*mut AnyObject> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + + unsafe { + let ns_view: *mut AnyObject = handle.ns_view.as_ptr().cast(); + let ns_window: *mut AnyObject = msg_send![ns_view, window]; + if ns_window.is_null() { + return None; + } + let content_view: *mut AnyObject = msg_send![ns_window, contentView]; + if content_view.is_null() { + return None; + } + + Some(content_view) + } +} + +pub(crate) fn enable(window: &mut Window) -> bool { + // The class only exists on macOS 26 (Tahoe) and later, so a runtime + // lookup doubles as the OS version check. + let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") else { + return false; + }; + let Some(content_view) = content_view(window) else { + return false; + }; + + // Let GPUI make the window non-opaque and its Metal layer + // transparent, so the glass below shows through. + window.set_background_appearance(WindowBackgroundAppearance::Transparent); + + unsafe { + let bounds: NSRect = msg_send![content_view, bounds]; + let glass: Retained = msg_send![glass_class, new]; + let _: () = msg_send![&*glass, setFrame: bounds]; + let _: () = msg_send![&*glass, setAutoresizingMask: AUTORESIZE_WIDTH_HEIGHT]; + let _: () = msg_send![ + content_view, + addSubview: &*glass, + positioned: NS_WINDOW_BELOW, + relativeTo: std::ptr::null_mut::() + ]; + } + + true +} + +/// Removes the injected glass views and restores the opaque background. +pub(crate) fn disable(window: &mut Window) { + let Some(glass_class) = AnyClass::get(c"NSGlassEffectView") else { + return; + }; + let Some(content_view) = content_view(window) else { + return; + }; + + unsafe { + // The `subviews` getter returns a copy of the array, so it is + // safe to remove views while iterating over it. + let subviews: Retained = msg_send![content_view, subviews]; + let count: usize = msg_send![&*subviews, count]; + for i in 0..count { + let view: *mut AnyObject = msg_send![&*subviews, objectAtIndex: i]; + let is_glass: bool = msg_send![view, isKindOfClass: glass_class]; + if is_glass { + let _: () = msg_send![view, removeFromSuperview]; + } + } + } + + window.set_background_appearance(WindowBackgroundAppearance::Opaque); +} diff --git a/crates/ui/src/window_glass/mod.rs b/crates/ui/src/window_glass/mod.rs new file mode 100644 index 0000000000..6a23baa9ce --- /dev/null +++ b/crates/ui/src/window_glass/mod.rs @@ -0,0 +1,26 @@ +//! System glass background effect for windows, see +//! [`crate::WindowExt::set_window_glass`]. +//! +//! Platform implementations live in submodules: +//! +//! - macOS 26+ : Liquid Glass via a native `NSGlassEffectView` ([`macos`]). +//! - Windows 11 22H2+ : Mica backdrop ([`windows`]). +//! - Other platforms (older systems, Linux): no-op fallback. + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +pub(crate) use macos::{disable, enable}; + +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +pub(crate) use windows::{disable, enable}; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub(crate) fn enable(_window: &mut gpui::Window) -> bool { + false +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub(crate) fn disable(_window: &mut gpui::Window) {} diff --git a/crates/ui/src/window_glass/windows.rs b/crates/ui/src/window_glass/windows.rs new file mode 100644 index 0000000000..c0d053ecac --- /dev/null +++ b/crates/ui/src/window_glass/windows.rs @@ -0,0 +1,21 @@ +//! Mica backdrop (Windows 11 22H2+), via GPUI's +//! [`WindowBackgroundAppearance::MicaBackdrop`](gpui::WindowBackgroundAppearance). + +use gpui::{Window, WindowBackgroundAppearance}; + +/// The Mica backdrop requires Windows 11 22H2 (build 22621) or later, +/// GPUI silently ignores it on older builds. +const MIN_BUILD_NUMBER: u32 = 22621; + +pub(crate) fn enable(window: &mut Window) -> bool { + if windows_version::OsVersion::current().build < MIN_BUILD_NUMBER { + return false; + } + + window.set_background_appearance(WindowBackgroundAppearance::MicaBackdrop); + true +} + +pub(crate) fn disable(window: &mut Window) { + window.set_background_appearance(WindowBackgroundAppearance::Opaque); +} diff --git a/docs/docs/root.md b/docs/docs/root.md index d5579f3833..f5689a7572 100644 --- a/docs/docs/root.md +++ b/docs/docs/root.md @@ -77,8 +77,8 @@ window.update(cx, |_, window, cx| { })?; ``` -Following Apple's Liquid Glass guidance (glass belongs to the navigation layer, never the content), only the navigation-layer surfaces (`sidebar`, `title_bar`, `tab_bar`) are made semi-transparent to let the glass show through them. The window background itself becomes transparent in glass mode, so **your content areas must paint their own opaque background** (e.g. `bg(cx.theme().background)`) to cover the glass. Content and floating surfaces (cards, lists, tables, popovers, dialogs) stay opaque to keep content readable and avoid ghosting. This applies to all windows of the application. Call `window.set_window_glass(false, cx)` to restore the opaque background. +Following Apple's Liquid Glass guidance (glass belongs to the navigation layer, never the content), only the navigation-layer surfaces (`sidebar`, `title_bar`, `tab_bar`) are made semi-transparent to let the glass show through them. The content area stays opaque so content is readable and never bleeds through (ghosting). The window background itself becomes transparent in glass mode, so **your content areas must paint their own opaque background** (e.g. `bg(cx.theme().background)`) to cover the glass. This applies to all windows of the application. Call `window.set_window_glass(false, cx)` to restore the opaque background. -See the [window_glass example](https://github.com/longbridge/gpui-component/tree/main/examples/window_glass) for a complete example. +The Story gallery has a working demo — toggle it from the title bar's window glass action. [Root]: https://docs.rs/gpui-component/latest/gpui_component/root/struct.Root.html diff --git a/docs/zh-CN/docs/root.md b/docs/zh-CN/docs/root.md index ca415429c7..2878cc0842 100644 --- a/docs/zh-CN/docs/root.md +++ b/docs/zh-CN/docs/root.md @@ -77,8 +77,8 @@ window.update(cx, |_, window, cx| { })?; ``` -遵循 Apple Liquid Glass 的原则(玻璃属于导航层,而非内容层),开启后仅导航层表面(`sidebar`、`title_bar`、`tab_bar`)会变为半透明以透出玻璃。玻璃模式下窗口背景本身会变为透明,因此**你的内容区容器必须自己画不透明背景**(如 `bg(cx.theme().background)`)来盖住玻璃。内容与浮层表面(卡片、列表、表格、popover、对话框)保持不透明,以保证内容清晰并避免重影。该行为对应用的所有窗口生效。调用 `window.set_window_glass(false, cx)` 可恢复不透明背景。 +遵循 Apple Liquid Glass 的原则(玻璃属于导航层,而非内容层),开启后仅导航层表面(`sidebar`、`title_bar`、`tab_bar`)会变为半透明以透出玻璃。内容区保持不透明,以保证内容清晰、不透出下方(重影)。玻璃模式下窗口背景本身会变为透明,因此**你的内容区容器必须自己画不透明背景**(如 `bg(cx.theme().background)`)来盖住玻璃。该行为对应用的所有窗口生效。调用 `window.set_window_glass(false, cx)` 可恢复不透明背景。 -完整示例参见 [window_glass example](https://github.com/longbridge/gpui-component/tree/main/examples/window_glass)。 +Story 画廊中有可运行的演示——从标题栏的 window glass 操作切换即可。 [Root]: https://docs.rs/gpui-component/latest/gpui_component/root/struct.Root.html