T04 window and shell: chrome, bars, rail, tabs, windows, dialogs, tray, badge, zoom

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 03:21:03 -04:00
1 parent 2bdf902bf4
commit 77bcd5be07
37 files changed
+4516 -111

No files matched your search

+26 -31
View File
@@ -5,7 +5,7 @@ use std::{
use anyhow::{Context as _, Result, bail};
use ely_gpui_component::theme::Theme;
use gpui::{App, AsyncApp, WindowHandle, px};
use gpui::{App, AsyncApp, Window, WindowHandle, px};
use crate::{
pages, script,
@@ -39,6 +39,7 @@ async fn shoot_all(
.iter()
.enumerate()
.filter(|(ix, _)| only.is_none_or(|only| only == *ix));
let gallery = window.update(cx, |_, window, _| number(window))??;
for (ix, page) in chosen {
for (choice, name) in [(Choice::Light, "light"), (Choice::Dark, "dark")] {
window.update(cx, |gallery, window, cx| {
@@ -59,7 +60,7 @@ async fn shoot_all(
format!("-{segment}")
};
let path = dir.join(format!("{}-{name}{suffix}.png", page.slug));
snapshot(&path)?;
snapshot(gallery, &path)?;
log::info!("capture: wrote {}", path.display());
if offset >= max {
break;
@@ -79,39 +80,33 @@ async fn shoot_all(
Ok(())
}
/// This process's on-screen window, as CoreGraphics numbers it.
/// A gpui window's number, as AppKit and CoreGraphics know it.
#[cfg(target_os = "macos")]
pub fn window_number() -> Result<u32> {
use core_foundation::{
base::{CFType, TCFType},
dictionary::CFDictionary,
number::CFNumber,
string::CFString,
};
use core_graphics::window;
pub fn number(window: &Window) -> Result<u32> {
use cocoa::base::id;
use objc::{msg_send, sel, sel_impl};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
let number = |dict: &CFDictionary<CFString, CFType>, key: &'static str| {
dict.find(CFString::from_static_string(key))
.and_then(|value| value.downcast::<CFNumber>())
.and_then(|value| value.to_i64())
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("window has no native handle: {error:?}"))?;
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
bail!("window is not an AppKit window");
};
let pid = i64::from(std::process::id());
let list = window::copy_window_info(
window::kCGWindowListOptionOnScreenOnly,
window::kCGNullWindowID,
)
.context("window list unavailable")?;
let id = list
.iter()
.map(|item| unsafe { CFDictionary::<CFString, CFType>::wrap_under_get_rule(*item as _) })
.find(|dict| number(dict, "kCGWindowOwnerPID") == Some(pid))
.and_then(|dict| number(&dict, "kCGWindowNumber"))
.context("gallery window not on screen")?;
u32::try_from(id).context("window number out of range")
let number: i64 = unsafe {
let view = appkit.ns_view.as_ptr() as id;
let ns_window: id = msg_send![view, window];
msg_send![ns_window, windowNumber]
};
u32::try_from(number).context("window number out of range")
}
#[cfg(not(target_os = "macos"))]
pub fn number(_: &Window) -> Result<u32> {
bail!("capture needs macOS window APIs")
}
#[cfg(target_os = "macos")]
pub fn snapshot(path: &Path) -> Result<()> {
pub fn snapshot(number: u32, path: &Path) -> Result<()> {
use core_graphics::{
geometry::{CGPoint, CGRect, CGSize},
window,
@@ -120,7 +115,7 @@ pub fn snapshot(path: &Path) -> Result<()> {
let image = window::create_image(
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
window::kCGWindowListOptionIncludingWindow,
window_number()?,
number,
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
)
.context("window capture returned nothing")?;
@@ -151,6 +146,6 @@ pub fn snapshot(path: &Path) -> Result<()> {
}
#[cfg(not(target_os = "macos"))]
pub fn snapshot(_: &Path) -> Result<()> {
pub fn snapshot(_: u32, _: &Path) -> Result<()> {
bail!("capture needs macOS window APIs")
}
+37 -3
View File
@@ -10,11 +10,44 @@ use std::path::PathBuf;
use anyhow::{Context as _, Result, bail};
use ely_gpui_component::Assets;
use gpui::{
App, AppContext, Application, Bounds, KeyBinding, TitlebarOptions, WindowBounds, WindowOptions,
actions, point, px, size,
App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, TitlebarOptions,
WindowBounds, WindowHandle, WindowOptions, actions, point, px, size,
};
actions!(gallery, [Quit]);
actions!(gallery, [Quit, About, LightMode, DarkMode, NewWindow]);
/// The gallery's menu bar and Dock menu: NativeMenu and JumpList at work.
fn menus(window: WindowHandle<shell::Gallery>, cx: &mut App) {
cx.set_menus(vec![
Menu {
name: "Ely".into(),
items: vec![
MenuItem::action("About Ely", About),
MenuItem::separator(),
MenuItem::action("Quit Ely", Quit),
],
},
Menu {
name: "View".into(),
items: vec![
MenuItem::action("Light", LightMode),
MenuItem::action("Dark", DarkMode),
],
},
]);
cx.set_dock_menu(vec![MenuItem::action("New Window", NewWindow)]);
cx.on_action(|_: &About, cx| pages::open_about(cx));
cx.on_action(|_: &NewWindow, cx| pages::open_managed(cx));
cx.on_action(move |_: &LightMode, cx| choose(window, shell::Choice::Light, cx));
cx.on_action(move |_: &DarkMode, cx| choose(window, shell::Choice::Dark, cx));
}
fn choose(window: WindowHandle<shell::Gallery>, choice: shell::Choice, cx: &mut App) {
if let Err(error) = window.update(cx, |gallery, window, cx| gallery.choose(choice, window, cx))
{
log::error!("gallery: menu could not reach the window: {error:#}");
}
}
struct Args {
page: Option<usize>,
@@ -67,6 +100,7 @@ fn main() -> Result<()> {
cx.new(|cx| shell::Gallery::new(args.page.unwrap_or(0), window, cx))
})
.expect("gallery window failed to open");
menus(window, cx);
cx.activate(true);
if let Some(dir) = args.capture {
capture::run(window, dir, args.page, cx);
+4
View File
@@ -1,6 +1,7 @@
mod buttons;
mod layout;
mod primitives;
mod shell;
mod theme;
mod typography;
@@ -22,10 +23,13 @@ pub const ALL: &[Page] = &[
primitives::PAGE,
typography::PAGE,
layout::PAGE,
shell::PAGE,
buttons::PAGE,
theme::PAGE,
];
pub use shell::{open_about, open_managed};
pub fn find(slug: &str) -> Option<usize> {
ALL.iter().position(|page| page.slug == slug)
}
+231
View File
@@ -0,0 +1,231 @@
use ely_gpui_component::{
buttons::{Button, IconButton},
primitives::IconName,
shell::{
ActivityBar, ActivityItem, StatusBar, StatusBarItem, TabBar, Toolbar, ToolbarGroup,
ToolbarSeparator, WindowTab,
},
theme::{ActiveTheme, ControlSize, TextSize},
typography::Caption,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::chrome::window_frame;
use crate::{probe::probe, ui::section};
fn tool(id: &'static str, icon: IconName) -> IconButton {
IconButton::new(id, icon).size(ControlSize::Sm)
}
pub fn toolbar(cx: &App) -> impl IntoElement + use<> {
section(
"Toolbar / ToolbarGroup / ToolbarSeparator",
"Tools in groups, hairlines between, one action apart.",
cx,
)
.child(
window_frame(560.0, 44.0, cx)
.justify_center()
.bg(cx.theme().colors.bg)
.child(
Toolbar::new()
.w_full()
.child(
ToolbarGroup::new()
.child(tool("tb-undo", IconName::Undo2))
.child(tool("tb-redo", IconName::Redo2)),
)
.child(ToolbarSeparator)
.child(
ToolbarGroup::new()
.child(tool("tb-bold", IconName::Bold))
.child(tool("tb-italic", IconName::Italic))
.child(tool("tb-underline", IconName::Underline)),
)
.child(ToolbarSeparator)
.child(
ToolbarGroup::new()
.child(tool("tb-left", IconName::AlignLeft))
.child(tool("tb-center", IconName::AlignCenter))
.child(tool("tb-right", IconName::AlignRight)),
)
.child(div().flex_1())
.child(
Button::new("tb-share", "Share")
.size(ControlSize::Sm)
.icon(IconName::Share2),
),
),
)
}
pub fn status_bar(cx: &App) -> impl IntoElement + use<> {
section(
"StatusBar / StatusBarItem",
"Small type at the foot. Items that act show it on hover.",
cx,
)
.child(
window_frame(560.0, 96.0, cx).justify_end().child(
StatusBar::new()
.left(
StatusBarItem::new("sb-branch")
.icon(IconName::GitBranch)
.label("main")
.tooltip("Switch branch")
.on_click(|_, _, _| log::info!("gallery: branch")),
)
.left(
StatusBarItem::new("sb-sync")
.icon(IconName::RefreshCw)
.label("Synced"),
)
.right(StatusBarItem::new("sb-cursor").label("Ln 12, Col 4"))
.right(StatusBarItem::new("sb-encoding").label("UTF-8"))
.right(
StatusBarItem::new("sb-bell")
.icon(IconName::Bell)
.tooltip("No notifications")
.on_click(|_, _, _| log::info!("gallery: notifications")),
),
),
)
}
pub fn activity_bar(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let selected =
window.use_keyed_state("activity-selected", cx, |_, _| SharedString::from("files"));
let current = selected.read(cx).clone();
let theme = cx.theme();
section(
"ActivityBar / NavigationRail",
"An icon rail. The mark grows in beside the chosen view.",
cx,
)
.child(
window_frame(560.0, 400.0, cx)
.flex_row()
.child(probe(
"activity",
ActivityBar::new("activity-bar")
.item(ActivityItem::new("files", IconName::Folder, "Explorer"))
.item(ActivityItem::new("search", IconName::Search, "Search"))
.item(ActivityItem::new("git", IconName::GitBranch, "Source Control").badge(3))
.item(ActivityItem::new("debug", IconName::Bug, "Run and Debug"))
.item(ActivityItem::new(
"extensions",
IconName::Puzzle,
"Extensions",
))
.footer(ActivityItem::new("account", IconName::User, "Account"))
.footer(ActivityItem::new(
"settings",
IconName::Settings,
"Settings",
))
.selected(current.clone())
.on_select(move |id, _, cx| {
let id = id.clone();
selected.update(cx, |selected, cx| {
*selected = id;
cx.notify();
})
}),
))
.child(
div()
.flex_1()
.p_5()
.bg(theme.colors.bg)
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(SharedString::from(format!("View: {current}"))),
),
)
.child(Caption::new("NavigationRail is the same rail."))
}
#[derive(Clone)]
struct Tabs {
open: Vec<(SharedString, SharedString, IconName)>,
selected: SharedString,
next: u32,
}
pub fn tab_bar(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = window.use_keyed_state("window-tabs", cx, |_, _| Tabs {
open: vec![
("inbox".into(), "Inbox".into(), IconName::Inbox),
("roadmap".into(), "Roadmap".into(), IconName::Map),
(
"notes".into(),
"Meeting notes".into(),
IconName::NotebookPen,
),
],
selected: "roadmap".into(),
next: 1,
});
let tabs = state.read(cx).clone();
let (select, close, add, reorder) = (state.clone(), state.clone(), state.clone(), state);
let mut bar = TabBar::new("window-tab-bar")
.selected(tabs.selected.clone())
.on_select(move |id, _, cx| {
let id = id.clone();
select.update(cx, |tabs, cx| {
tabs.selected = id;
cx.notify();
})
})
.on_close(move |id, _, cx| {
let id = id.clone();
close.update(cx, |tabs, cx| {
tabs.open.retain(|(tab, _, _)| *tab != id);
if tabs.selected == id {
tabs.selected = tabs
.open
.first()
.map(|(tab, _, _)| tab.clone())
.unwrap_or_default();
}
cx.notify();
})
})
.on_add(move |_, cx| {
add.update(cx, |tabs, cx| {
let id = SharedString::from(format!("draft-{}", tabs.next));
tabs.open.push((
id.clone(),
format!("Draft {}", tabs.next).into(),
IconName::FileText,
));
tabs.next += 1;
tabs.selected = id;
cx.notify();
})
})
.on_reorder(move |from, to, _, cx| {
reorder.update(cx, |tabs, cx| {
let moved = tabs.open.remove(from);
tabs.open.insert(to, moved);
cx.notify();
})
});
for (id, title, icon) in tabs.open {
bar = bar.tab(WindowTab::new(id, title).icon(icon));
}
section(
"TabBar",
"Window tabs. New ones rise in; drag one onto another to reorder.",
cx,
)
.child(
window_frame(560.0, 120.0, cx)
.child(
div()
.p_1p5()
.child(probe("tab-bar", div().w(px(548.0)).child(bar))),
)
.child(div().flex_1().bg(cx.theme().colors.bg)),
)
}
+180
View File
@@ -0,0 +1,180 @@
use ely_gpui_component::{
buttons::IconButton,
primitives::IconName,
shell::{ControlsStyle, TitleBar, WindowControls, drag_region},
theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize},
typography::Caption,
};
use gpui::{App, Div, IntoElement, ParentElement, Styled, Window, div, px};
use crate::ui::{code, row, section, specimen, specimens};
/// A window drawn inside the page.
pub fn window_frame(width: f32, height: f32, cx: &App) -> Div {
let theme = cx.theme();
div()
.w(px(width))
.h(px(height))
.flex()
.flex_col()
.overflow_hidden()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.sunken)
.shadow(theme.elevation(Elevation::Raised))
}
fn demo_bar(id: &'static str, style: ControlsStyle) -> TitleBar {
TitleBar::new(id)
.style(style)
.title("Ely — Projects")
.on_close(|_, _| log::info!("gallery: demo close refused"))
.child(IconButton::new((id, 1usize), IconName::PanelLeft).size(ControlSize::Sm))
.action(IconButton::new((id, 2usize), IconName::Search).size(ControlSize::Sm))
}
pub fn title_bar(cx: &App) -> impl IntoElement + use<> {
let styles = [
("tb-mac", ControlsStyle::Mac, "macOS"),
("tb-windows", ControlsStyle::Windows, "Windows"),
("tb-linux", ControlsStyle::Linux, "Linux"),
];
section(
"TitleBar",
"Title centered, system buttons in their place, empty space drags.",
cx,
)
.child(
div()
.flex()
.flex_col()
.gap_6()
.children(styles.map(|(id, style, name)| {
specimen(
name,
window_frame(560.0, 120.0, cx).child(demo_bar(id, style)),
cx,
)
})),
)
.child(code(
"TitleBar::new(id) with no style leaves room for the real macOS lights.",
cx,
))
}
pub fn window_controls(cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
let plate = || {
div()
.h(theme.titlebar_height())
.px_3()
.flex()
.items_center()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.bg)
};
section(
"WindowControls / TrafficLights",
"Three manners. Hover the lights for their glyphs.",
cx,
)
.child(
specimens()
.child(specimen(
"Mac",
plate().child(
WindowControls::new("wc-mac")
.style(ControlsStyle::Mac)
.on_close(|_, _| log::info!("gallery: demo close refused")),
),
cx,
))
.child(specimen(
"Windows",
plate().px_0().child(
WindowControls::new("wc-windows")
.style(ControlsStyle::Windows)
.on_close(|_, _| log::info!("gallery: demo close refused")),
),
cx,
))
.child(specimen(
"Linux",
plate().child(
WindowControls::new("wc-linux")
.style(ControlsStyle::Linux)
.on_close(|_, _| log::info!("gallery: demo close refused")),
),
cx,
)),
)
.child(code(
"Minimize and zoom act on this window. Close here only logs.",
cx,
))
}
pub fn drag_area(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let strip = drag_region("demo-drag", window, cx);
let theme = cx.theme();
section(
"WindowDragRegion",
"Windows hit-tests it natively, Linux moves by hand, double-click zooms.",
cx,
)
.child(
strip
.w(px(420.0))
.h(theme.control_height(ControlSize::Lg))
.flex()
.items_center()
.justify_center()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_dashed()
.border_color(theme.colors.border_strong)
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_subtle)
.child("Double-click to zoom this window"),
)
.child(code("drag_region(id, window, cx)", cx))
}
pub fn resize_border(cx: &App) -> impl IntoElement + use<> {
section(
"WindowResizeBorder",
"A margin to resize from, a hairline, a shadow, for client-drawn windows.",
cx,
)
.child(Caption::new(
"Linux draws its own edges. This macOS window keeps the system's, so the border lays out only.",
))
.child(code("ResizeBorder::new().child(app)", cx))
}
pub fn menus(cx: &App) -> impl IntoElement + use<> {
section(
"NativeMenu / MenuBar / JumpList",
"One menu model: the system bar, the Dock, and later the window.",
cx,
)
.child(
row()
.child(Caption::new("Look up: Ely and View come from"))
.child(code("cx.set_menus(menus)", cx)),
)
.child(
row()
.child(Caption::new(
"Right-click the Dock icon: New Window comes from",
))
.child(code("cx.set_dock_menu(items)", cx)),
)
.child(Caption::new(
"MenuBar draws the same menus inside the window. It lands with Menus, chapter 8.",
))
}
+128
View File
@@ -0,0 +1,128 @@
mod bars;
mod chrome;
mod status;
mod system;
mod windows;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub use windows::{open_about, open_managed};
pub const PAGE: Page = Page {
number: 4,
slug: "shell",
title: "Window & Shell",
summary: "Title bars, rails, tabs, and the windows around an app.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::Rest,
Step::ClickEnd("tab-bar"),
Step::Wait(400),
Step::Shot("tab-added"),
Step::DownAt("activity", 24.0, 76.0),
Step::UpAt("activity", 24.0, 76.0),
Step::Wait(400),
Step::Shot("activity-search"),
Step::DownAt("activity", 24.0, 28.0),
Step::UpAt("activity", 24.0, 28.0),
Step::Click("offline-offline"),
Step::Wait(400),
Step::Shot("offline"),
Step::Click("offline-reconnecting"),
Step::Wait(400),
Step::Shot("reconnecting"),
Step::Click("offline-online"),
Step::Wait(400),
Step::Shot("back-online"),
Step::ClickEnd("zoom"),
Step::Wait(400),
Step::Shot("zoomed"),
Step::Click("zoom"),
Step::Wait(300),
Step::Click("about-open"),
Step::Wait(700),
Step::KeyWindow("about", "tab"),
Step::Wait(200),
Step::ShotWindow("about", "about-window"),
Step::CloseWindow("about"),
Step::Click("splash-open"),
Step::Wait(700),
Step::ShotWindow("splash", "splash-window"),
Step::CloseWindow("splash"),
Step::Click("update-open"),
Step::Wait(700),
Step::ShotWindow("update", "update-window"),
Step::CloseWindow("update"),
Step::Click("crash-open"),
Step::Wait(700),
Step::ShotWindow("crash", "crash-window"),
Step::CloseWindow("crash"),
Step::Click("managed-open"),
Step::Wait(700),
Step::ShotWindow("managed", "managed-window"),
Step::CloseNative("managed"),
Step::Wait(300),
Step::ShotWindow("managed", "managed-asked"),
Step::Click("switcher-open"),
Step::Wait(400),
Step::Key("tab"),
Step::Wait(200),
Step::Shot("switcher"),
Step::Key("escape"),
Step::Wait(300),
Step::CloseNative("managed"),
Step::Wait(400),
Step::ExpectClosed("managed"),
Step::Click("mini-open"),
Step::Wait(700),
Step::ShotWindow("mini", "mini-window"),
Step::CloseWindow("mini"),
Step::Click("pip-open"),
Step::Wait(700),
Step::ShotWindow("pip", "pip-window"),
Step::CloseWindow("pip"),
Step::Click("tray-show"),
Step::Wait(300),
Step::Click("tray-pick"),
Step::Wait(400),
Step::Shot("tray-picked"),
Step::Click("tray-show"),
Step::Click("badge-set"),
Step::Wait(300),
Step::Shot("badge"),
Step::Click("badge-clear"),
Step::Rest,
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(chrome::title_bar(cx))
.child(chrome::window_controls(cx))
.child(chrome::drag_area(window, cx))
.child(chrome::resize_border(cx))
.child(chrome::menus(cx))
.child(bars::toolbar(cx))
.child(bars::status_bar(cx))
.child(bars::activity_bar(window, cx))
.child(bars::tab_bar(window, cx))
.child(windows::splash_screen(cx))
.child(windows::about_dialog(cx))
.child(system::tray(window, cx))
.child(system::dock(cx))
.child(system::notification(cx))
.child(windows::window_manager(cx))
.child(windows::window_switcher(window, cx))
.child(windows::quick_launcher(cx))
.child(windows::mini_windows(cx))
.child(status::update_banner(cx))
.child(status::update_window(cx))
.child(status::crash_reporter(cx))
.child(status::offline(window, cx))
.child(status::zoom(cx))
.into_any_element()
}
+169
View File
@@ -0,0 +1,169 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
shell::{
Connectivity, CrashReporter, OfflineIndicator, UpdateBanner, UpdateDialog, UpdateState,
ZoomControl,
},
theme::ActiveTheme,
};
use gpui::{App, IntoElement, ParentElement, Styled, Window, div};
use super::chrome::window_frame;
use crate::{
probe::{Opened, probe},
ui::{row, section, specimen},
};
fn log_action(name: &'static str) -> impl Fn(&mut Window, &mut App) + 'static {
move |_, _| log::info!("gallery: update {name}")
}
pub fn update_banner(cx: &App) -> impl IntoElement + use<> {
let banner = |id: &'static str, state| {
UpdateBanner::new(id, "0.2.0", state)
.on_download(log_action("download"))
.on_restart(log_action("restart"))
.on_later(log_action("later"))
};
section(
"UpdateBanner",
"Offer, download, restart. One slim bar, three states.",
cx,
)
.child(
window_frame(560.0, 126.0, cx)
.bg(cx.theme().colors.bg)
.child(banner("banner-available", UpdateState::Available))
.child(banner("banner-downloading", UpdateState::Downloading(0.42)))
.child(banner("banner-ready", UpdateState::Ready)),
)
}
fn update_dialog() -> UpdateDialog {
UpdateDialog::new("Ely", "0.1.0", "0.2.0", UpdateState::Available)
.note("Window chrome for macOS, Windows and Linux.")
.note("Docks remember their layout across launches.")
.note("Sticky headers stay inside their scroll area.")
.note("Drawers follow the pointer outside their bounds.")
.on_download(log_action("download"))
.on_later(|window, _| window.remove_window())
}
pub fn update_window(cx: &App) -> impl IntoElement + use<> {
section("UpdateDialog", "What changed, then one clear action.", cx)
.child(specimen(
"Inline",
window_frame(400.0, 380.0, cx)
.bg(cx.theme().colors.bg)
.pt_6()
.child(update_dialog().on_later(log_action("later"))),
cx,
))
.child(probe(
"update-open",
Button::new("update-open", "Open update window")
.variant(ButtonVariant::Outline)
.on_click(|_, _, cx| match update_dialog().open(cx) {
Ok(handle) => Opened::insert("update", handle.into(), cx),
Err(error) => log::error!("gallery: update window failed: {error:#}"),
}),
))
}
const REPORT: &str = "Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Crashed Thread: 0 main
0 ely 0x0000000104a1c3f0 layout::dock::restore + 112
1 ely 0x0000000104a1b9d4 workspace::apply + 88
2 ely 0x0000000104a0f2a8 app::open_recent + 404
3 libdyld.dylib 0x000000018f2d4f28 start + 4";
pub fn crash_reporter(cx: &App) -> impl IntoElement + use<> {
let reporter = |details: bool| {
CrashReporter::new(
"Ely",
"The report says where it stopped. Sending it helps fix it.",
REPORT,
)
.details(details)
.on_send(|_, _| log::info!("gallery: crash report sent"))
.on_dismiss(|_, _| log::info!("gallery: crash report dismissed"))
};
section(
"CrashReporter",
"Plain words first. The report waits behind a disclosure.",
cx,
)
.child(specimen(
"Inline, report shown",
window_frame(420.0, 520.0, cx)
.bg(cx.theme().colors.bg)
.pt_6()
.child(reporter(true)),
cx,
))
.child(probe(
"crash-open",
Button::new("crash-open", "Open crash window")
.variant(ButtonVariant::Outline)
.on_click(move |_, _, cx| match reporter(false).open(cx) {
Ok(handle) => Opened::insert("crash", handle.into(), cx),
Err(error) => log::error!("gallery: crash window failed: {error:#}"),
}),
))
}
pub fn offline(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = window.use_keyed_state("connectivity", cx, |_, _| Connectivity::Online);
let current = *state.read(cx);
let choices = [
("offline-online", "Online", Connectivity::Online),
("offline-offline", "Offline", Connectivity::Offline),
(
"offline-reconnecting",
"Reconnecting",
Connectivity::Reconnecting,
),
];
section(
"OfflineIndicator",
"Quiet while it matters, gone soon after the network returns.",
cx,
)
.child(row().children(choices.map(|(key, label, choice)| {
let state = state.clone();
probe(
key,
Button::new(key, label)
.size(ely_gpui_component::theme::ControlSize::Sm)
.variant(if current == choice {
ButtonVariant::Subtle
} else {
ButtonVariant::Ghost
})
.on_click(move |_, _, cx| {
state.update(cx, |state, cx| {
*state = choice;
cx.notify();
})
}),
)
})))
.child(
div()
.h(cx
.theme()
.control_height(ely_gpui_component::theme::ControlSize::Lg))
.flex()
.items_center()
.child(OfflineIndicator::new("connectivity-pill", current)),
)
}
pub fn zoom(cx: &App) -> impl IntoElement + use<> {
section(
"ZoomControl",
"Scales the whole window through its rem size. Click the number to reset.",
cx,
)
.child(probe("zoom", ZoomControl::new("zoom-control")))
}
+144
View File
@@ -0,0 +1,144 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
shell::{TrayIcon, TrayItem, dock_badge, set_dock_badge},
typography::Caption,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Window};
use crate::{
probe::probe,
ui::{blocked, row, section},
};
const ITEMS: [&str; 3] = ["Open gallery", "Toggle theme", "Quit"];
pub fn tray(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let tray = window.use_keyed_state("tray", cx, |_, _| None::<TrayIcon>);
let last = window.use_keyed_state("tray-last", cx, |_, _| SharedString::from("nothing yet"));
let shown = tray.read(cx).is_some();
let note = last.read(cx).clone();
let (toggle, pick) = (tray.clone(), tray);
section(
"TrayIcon / TrayMenu",
"An SF Symbol in the menu bar with a menu. macOS only.",
cx,
)
.child(
row()
.child(probe(
"tray-show",
Button::new(
"tray-show",
if shown {
"Remove tray icon"
} else {
"Show tray icon"
},
)
.variant(ButtonVariant::Outline)
.on_click(move |_, _, cx| {
let last = last.clone();
let next = match toggle.read(cx) {
Some(_) => None,
None => {
let items = vec![
TrayItem::Action(ITEMS[0].into()),
TrayItem::Action(ITEMS[1].into()),
TrayItem::Separator,
TrayItem::Action(ITEMS[2].into()),
];
let made = TrayIcon::new(
"circle.hexagongrid",
items,
move |index, cx| {
let label = match index {
0 => ITEMS[0],
1 => ITEMS[1],
3 => ITEMS[2],
other => panic!("tray has no action at {other}"),
};
last.update(cx, |last, cx| {
*last = label.into();
cx.notify();
})
},
cx,
);
match made {
Ok(icon) => Some(icon),
Err(error) => {
log::error!("gallery: tray failed: {error:#}");
None
}
}
}
};
toggle.update(cx, |tray, cx| {
*tray = next;
cx.notify();
})
}),
))
.child(probe(
"tray-pick",
Button::new("tray-pick", "Pick “Open gallery”")
.variant(ButtonVariant::Ghost)
.disabled(!shown)
.on_click(move |_, _, cx| {
if let Some(icon) = pick.read(cx) {
icon.pick(0);
}
}),
))
.child(Caption::new(format!("Last pick: {note}"))),
)
}
pub fn dock(cx: &mut App) -> impl IntoElement + use<> {
let reading = match dock_badge() {
Ok(Some(label)) => format!("The Dock shows “{label}”."),
Ok(None) => "The Dock shows no badge.".to_string(),
Err(error) => format!("{error:#}"),
};
let badge = |label: Option<&'static str>| {
move |_: &gpui::ClickEvent, window: &mut Window, _: &mut App| {
if let Err(error) = set_dock_badge(label) {
log::error!("gallery: badge failed: {error:#}");
}
window.refresh();
}
};
section(
"DockBadge",
"A count on the app's Dock icon. macOS only.",
cx,
)
.child(
row()
.child(probe(
"badge-set",
Button::new("badge-set", "Badge 3")
.variant(ButtonVariant::Outline)
.on_click(badge(Some("3"))),
))
.child(probe(
"badge-clear",
Button::new("badge-clear", "Clear")
.variant(ButtonVariant::Ghost)
.on_click(badge(None)),
))
.child(Caption::new(reading)),
)
}
pub fn notification(cx: &App) -> impl IntoElement + use<> {
section(
"SystemNotification",
"Notices from the system's own center.",
cx,
)
.child(blocked(
"Blocked: an unbundled app has no notification center, and gpui 0.2.2 has no notification API.",
cx,
))
}
+366
View File
@@ -0,0 +1,366 @@
use std::path::Path;
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton},
primitives::{IconName, Image},
shell::{
AboutDialog, Corner, MiniWindow, SplashScreen, TitleBar, WindowManager, WindowSwitcher,
},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Caption, Label, Title},
};
use gpui::{
App, AppContext, Context, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
Window, div, prelude::*, px, size,
};
use super::chrome::window_frame;
use crate::{
probe::{Opened, probe},
ui::{code, row, section, specimen},
};
const DUNES: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/dunes.jpg"
);
const ATRIUM: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/examples/gallery/assets/atrium.jpg"
);
fn about() -> AboutDialog {
AboutDialog::new("Ely", "0.1.0 (2026.09)")
.description("A component library for GPUI, in light and dark.")
.link(
"Website",
"https://github.com/ZacharyZhang-NY/Ely-GPUI-Components",
)
.link(
"License",
"https://github.com/ZacharyZhang-NY/Ely-GPUI-Components#license",
)
.copyright("© 2026 Ely contributors")
}
fn splash() -> SplashScreen {
SplashScreen::new("Ely")
.tagline("Components for GPUI")
.status("Loading workspace…")
}
/// Opens a window and records it for scripts, or logs why it failed.
fn opened<V: 'static>(
key: &'static str,
result: anyhow::Result<gpui::WindowHandle<V>>,
cx: &mut App,
) {
match result {
Ok(handle) => Opened::insert(key, handle.into(), cx),
Err(error) => log::error!("gallery: {key} window failed: {error:#}"),
}
}
fn trigger(
key: &'static str,
label: &'static str,
open: impl Fn(&mut App) + 'static,
) -> impl IntoElement {
probe(
key,
Button::new(key, label)
.variant(ButtonVariant::Outline)
.icon(IconName::AppWindow)
.on_click(move |_, _, cx| open(cx)),
)
}
pub fn splash_screen(cx: &App) -> impl IntoElement + use<> {
section(
"SplashScreen",
"Mark and name rise in; the bar reports progress or sweeps.",
cx,
)
.child(specimen(
"Inline, at 62%",
window_frame(480.0, 280.0, cx)
.bg(cx.theme().colors.bg)
.child(splash().progress(Some(0.62))),
cx,
))
.child(trigger("splash-open", "Show splash window", |cx| {
let result = splash().open(cx);
opened("splash", result, cx)
}))
}
pub fn open_about(cx: &mut App) {
let result = about().open(cx);
opened("about", result, cx)
}
pub fn about_dialog(cx: &App) -> impl IntoElement + use<> {
section(
"AboutDialog",
"Name, version to copy, links, fine print.",
cx,
)
.child(specimen(
"Inline",
window_frame(360.0, 360.0, cx)
.bg(cx.theme().colors.bg)
.pt_6()
.child(about()),
cx,
))
.child(trigger("about-open", "Open About window", open_about))
}
struct Managed {
title: SharedString,
asked: bool,
}
impl Render for Managed {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let draft = cx.entity().downgrade();
let theme = cx.theme();
let asked = self.asked;
div()
.size_full()
.flex()
.flex_col()
.bg(theme.colors.bg)
.text_color(theme.colors.fg)
.font_family(theme.font_family.clone())
.child({
let bar = TitleBar::new("draft-bar").title(self.title.clone());
if asked {
bar
} else {
bar.on_close(move |_, cx| {
let asked = draft.update(cx, |draft, cx| {
draft.asked = true;
cx.notify();
});
if let Err(error) = asked {
log::error!("gallery: draft gone before close: {error:#}");
}
})
}
})
.child(
div()
.flex_1()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_3()
.child(Title::new(self.title.clone()))
.child(Caption::new(if asked {
"The title bar caught the close. The next one goes through."
} else {
"Opened by WindowManager. Its close button asks first."
}))
.when(asked, |body| {
body.child(
Button::new("draft-close", "Close anyway")
.size(ControlSize::Sm)
.on_click(|_, window, _| window.remove_window()),
)
}),
)
}
}
/// Opens the next numbered draft window.
pub fn open_managed(cx: &mut App) {
let number = WindowManager::windows(cx).len() + 1;
let title = SharedString::from(format!("Draft {number}"));
let result = WindowManager::open(title.clone(), size(px(420.0), px(280.0)), cx, |_, cx| {
cx.new(|_| Managed {
title,
asked: false,
})
});
opened("managed", result, cx)
}
pub fn window_manager(cx: &mut App) -> impl IntoElement + use<> {
let open = WindowManager::windows(cx);
section(
"MultiWindow Manager",
"Each new window cascades from the last; the manager knows them by name.",
cx,
)
.child(trigger("managed-open", "New window", open_managed))
.child(
row().children(open.into_iter().enumerate().map(|(ix, (handle, title))| {
Button::new(("managed-focus", ix), title)
.size(ControlSize::Sm)
.variant(ButtonVariant::Subtle)
.on_click(move |_, _, cx| {
if let Err(error) = WindowManager::focus(handle, cx) {
log::error!("gallery: focus failed: {error:#}");
}
})
})),
)
}
pub fn window_switcher(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let shown = window.use_keyed_state("switcher-shown", cx, |_, _| false);
let open = *shown.read(cx);
let (show, hide) = (shown.clone(), shown);
section(
"WindowSwitcher",
"Managed windows over a scrim. Arrows or Tab move, Enter goes.",
cx,
)
.child(probe(
"switcher-open",
Button::new("switcher-open", "Switch windows")
.variant(ButtonVariant::Outline)
.on_click(move |_, _, cx| {
show.update(cx, |shown, cx| {
*shown = true;
cx.notify();
})
}),
))
.when(open, |block| {
block.child(WindowSwitcher::new("switcher", move |_, cx| {
hide.update(cx, |shown, cx| {
*shown = false;
cx.notify();
})
}))
})
}
pub fn quick_launcher(cx: &App) -> impl IntoElement + use<> {
section(
"QuickLauncher",
"A search palette in a window above all others.",
cx,
)
.child(Caption::new(
"It needs text input and the palette, so it lands with Navigation, chapter 7.",
))
}
struct Player {
playing: bool,
}
impl Render for Player {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let playing = self.playing;
div()
.size_full()
.flex()
.items_center()
.gap_3()
.px_3()
.child(
div()
.size(theme.control_height(ControlSize::Lg) * 2.0)
.rounded(theme.radius(Radius::Md))
.overflow_hidden()
.child(
Image::new("player-art", Path::new(DUNES))
.fit(ObjectFit::Cover)
.size_full(),
),
)
.child(
div()
.flex_1()
.flex()
.flex_col()
.child(Label::new("Quiet Hours"))
.child(Caption::new("Low Tide · 2:41")),
)
.child(
IconButton::new(
"player-toggle",
if playing {
IconName::Pause
} else {
IconName::Play
},
)
.on_click(cx.listener(|player, _, _, cx| {
player.playing = !player.playing;
cx.notify();
})),
)
}
}
struct Picture;
impl Render for Picture {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.relative()
.size_full()
.child(
Image::new("pip-frame", Path::new(ATRIUM))
.fit(ObjectFit::Cover)
.size_full(),
)
.child(
div()
.absolute()
.left_3()
.bottom_3()
.px_2()
.rounded(theme.radius(Radius::Sm))
.bg(theme.colors.tooltip_bg.opacity(0.85))
.text_color(theme.colors.tooltip_fg)
.text_size(theme.text_size(TextSize::Xs))
.child("12:04 / 48:10"),
)
}
}
pub fn mini_windows(cx: &App) -> impl IntoElement + use<> {
section(
"MiniWindow / CompactMode / PictureInPicture",
"Small windows above the rest. Hover for the bar; expand returns to the app.",
cx,
)
.child(
row()
.child(trigger("mini-open", "Mini player", |cx| {
let view = cx.new(|_| Player { playing: true });
let result = MiniWindow::open(
"Now playing",
size(px(320.0), px(96.0)),
Some(Corner::BottomRight),
view.into(),
cx,
);
opened("mini", result, cx)
}))
.child(trigger("pip-open", "Picture in picture", |cx| {
let view = cx.new(|_| Picture);
let result = MiniWindow::open(
"Picture in picture",
size(px(384.0), px(216.0)),
Some(Corner::TopRight),
view.into(),
cx,
);
opened("pip", result, cx)
})),
)
.child(code(
"AlwaysOnTop is gpui's WindowOptions { kind: WindowKind::PopUp, .. }",
cx,
))
}
+24 -1
View File
@@ -1,7 +1,9 @@
use std::collections::HashMap;
use ely_gpui_component::primitives::Measure;
use gpui::{App, Bounds, Div, Global, IntoElement, ParentElement, Pixels, Styled, div};
use gpui::{
AnyWindowHandle, App, Bounds, Div, Global, IntoElement, ParentElement, Pixels, Styled, div,
};
/// Window bounds of named demo elements, for scripted capture.
#[derive(Default)]
@@ -25,3 +27,24 @@ pub fn probe(key: &'static str, element: impl IntoElement) -> Div {
.child(element),
)
}
/// Windows the demos opened, by key, for scripted capture.
#[derive(Default)]
pub struct Opened(HashMap<&'static str, AnyWindowHandle>);
impl Global for Opened {}
impl Opened {
pub fn insert(key: &'static str, handle: AnyWindowHandle, cx: &mut App) {
cx.default_global::<Opened>().0.insert(key, handle);
}
pub fn take(key: &str, cx: &mut App) -> Option<AnyWindowHandle> {
cx.default_global::<Opened>().0.remove(key)
}
pub fn get(key: &str, cx: &App) -> Option<AnyWindowHandle> {
cx.try_global::<Opened>()
.and_then(|opened| opened.0.get(key).copied())
}
}
+76 -7
View File
@@ -3,7 +3,11 @@ use std::{path::PathBuf, time::Duration};
use anyhow::{Context as _, Result};
use gpui::{AsyncApp, Bounds, Keystroke, Pixels, Point, WindowHandle, point, px};
use crate::{capture::snapshot, probe::Probes, shell::Gallery};
use crate::{
capture::{number, snapshot},
probe::{Opened, Probes},
shell::Gallery,
};
/// One scripted input, aimed at a probe by key.
pub enum Step {
@@ -22,6 +26,15 @@ pub enum Step {
Key(&'static str),
Wait(u64),
Shot(&'static str),
/// Photographs a window a demo opened, by its key.
ShotWindow(&'static str, &'static str),
CloseWindow(&'static str),
/// Sends a keystroke to a window a demo opened.
KeyWindow(&'static str, &'static str),
/// Asks AppKit to close a window, as its close button would.
CloseNative(&'static str),
/// Fails unless the window is gone.
ExpectClosed(&'static str),
}
const FRAME: Duration = Duration::from_millis(120);
@@ -40,6 +53,11 @@ pub fn park(window: WindowHandle<Gallery>, cx: &mut AsyncApp) -> Result<()> {
send(window, Mouse::Move, point(px(24.0), px(720.0)), cx)
}
fn opened(key: &str, cx: &mut AsyncApp) -> Result<gpui::AnyWindowHandle> {
cx.update(|cx| Opened::get(key, cx))?
.with_context(|| format!("no window opened as {key}"))
}
pub async fn play(
window: WindowHandle<Gallery>,
steps: &[Step],
@@ -114,9 +132,38 @@ pub async fn play(
}
Step::Shot(name) => {
let file = path(name);
snapshot(&file)?;
snapshot(window.update(cx, |_, window, _| number(window))??, &file)?;
log::info!("script: wrote {}", file.display());
}
Step::ShotWindow(key, name) => {
let file = path(name);
let handle = opened(key, cx)?;
snapshot(handle.update(cx, |_, window, _| number(window))??, &file)?;
log::info!("script: wrote {}", file.display());
}
Step::KeyWindow(key, stroke) => {
let stroke =
Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?;
opened(key, cx)?.update(cx, |_, window, cx| {
window.dispatch_keystroke(stroke, cx);
})?;
}
Step::CloseNative(key) => {
let number = opened(key, cx)?.update(cx, |_, window, _| number(window))??;
perform_close(number)?;
}
Step::ExpectClosed(key) => {
let handle = opened(key, cx)?;
let open = cx.update(|cx| cx.windows().contains(&handle))?;
anyhow::ensure!(!open, "window {key} is still open");
cx.update(|cx| Opened::take(key, cx))?;
log::info!("script: {key} closed");
}
Step::CloseWindow(key) => {
let handle = opened(key, cx)?;
handle.update(cx, |_, window, _| window.remove_window())?;
cx.update(|cx| Opened::take(key, cx))?;
}
}
cx.background_executor().timer(FRAME).await;
}
@@ -129,19 +176,20 @@ fn send(
at: Point<Pixels>,
cx: &mut AsyncApp,
) -> Result<()> {
let height = window.update(cx, |_, window, _| window.viewport_size().height)?;
post(kind, f64::from(at.x), f64::from(height - at.y))
let (height, number) = window.update(cx, |_, window, _| {
(window.viewport_size().height, number(window))
})?;
post(kind, f64::from(at.x), f64::from(height - at.y), number?)
}
/// Queues a real mouse event on this app. No permission needed.
#[cfg(target_os = "macos")]
fn post(kind: Mouse, x: f64, y: f64) -> Result<()> {
fn post(kind: Mouse, x: f64, y: f64, number: u32) -> Result<()> {
use cocoa::{
appkit::{NSApp, NSApplication, NSEvent, NSEventModifierFlags, NSEventType},
base::{NO, id, nil},
foundation::NSPoint,
};
let number = crate::capture::window_number()?;
let kind = match kind {
Mouse::Move => NSEventType::NSMouseMoved,
Mouse::Down => NSEventType::NSLeftMouseDown,
@@ -167,8 +215,29 @@ fn post(kind: Mouse, x: f64, y: f64) -> Result<()> {
Ok(())
}
/// Runs `performClose:` on a window, which asks before closing.
#[cfg(target_os = "macos")]
fn perform_close(number: u32) -> Result<()> {
use cocoa::{
appkit::NSApp,
base::{id, nil},
};
use objc::{msg_send, sel, sel_impl};
unsafe {
let window: id = msg_send![NSApp(), windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(window != nil, "no AppKit window numbered {number}");
let _: () = msg_send![window, performClose: nil];
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn post(_: Mouse, _: f64, _: f64) -> Result<()> {
fn perform_close(_: u32) -> Result<()> {
anyhow::bail!("native close needs macOS")
}
#[cfg(not(target_os = "macos"))]
fn post(_: Mouse, _: f64, _: f64, _: u32) -> Result<()> {
anyhow::bail!("scripted mouse input needs macOS")
}