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

+9 -1
View File
@@ -7,12 +7,13 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Rust 1.95, edition 2024. One crate: `ely-gpui-component`.
- gpui 0.2.2 from crates.io. Feature `runtime_shaders` is on by default: machines without Xcode lack the Metal compiler.
- Assets, embedded with `rust-embed`: Lucide 1.48.0 icons (ISC), Inter 4.1 and JetBrains Mono 2.304 (OFL).
- macOS extras (tray icon, Dock badge) call AppKit through `cocoa` 0.26 and `objc` 0.2, the crates gpui already links.
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components.
## Commands
- `cargo run --example gallery` opens the gallery. `-- --page <slug>` starts on a page.
- `cargo run --example gallery -- --capture <dir>` writes PNGs of every page, light and dark, top to bottom, then each page's scripted states. macOS only.
- `cargo run --example gallery -- --capture <dir>` writes PNGs of every page, light and dark, top to bottom, then each page's scripted states, including windows the demos open. macOS only.
- `cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`.
- `rm -rf target/debug/incremental` after each task item keeps the disk lean.
- `scripts/icons.sh <lucide-name>...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`.
@@ -39,6 +40,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` and focus its handle.
- gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue.
- `hover()` needs an element id.
- gpui sends a drag's moves to every listener of its type. Each drag payload carries its owner's `EntityId`, and handlers check it.
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags.
- The capture harness finds windows by gpui handle through `raw-window-handle`, never by scanning the process's windows.
- Posted events cannot move a macOS window, so window drags are not scripted. `drag_region` follows Zed's title bar.
- Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders.
- `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
@@ -50,3 +55,6 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 2026-09-24: the gallery photographs its own window with `CGWindowListCreateImage`. No Screen Recording permission needed.
- 2026-09-24: license MIT OR Apache-2.0.
- 2026-09-25: layouts persist as versioned JSON through serde. Unknown fields are ignored. Newer versions, unknown panels and bad pane trees are refused, and restore is all or nothing.
- 2026-09-25: an entry built from a later chapter's parts lands with that chapter; its line points there. MenuBar goes to Menus, QuickLauncher to Navigation.
- 2026-09-25: SystemNotification is blocked. An unbundled app has no notification center (probed on macOS 27.2), and gpui 0.2.2 has no API.
- 2026-09-25: `unexpected_cfgs` declares `feature = "cargo-clippy"`, which the objc 0.2 macros test.
Generated
+2
View File
@@ -1528,6 +1528,8 @@ dependencies = [
"image",
"jiff",
"log",
"objc",
"raw-window-handle",
"regex",
"rust-embed",
"serde",
+8 -1
View File
@@ -25,14 +25,21 @@ serde_json = "1"
smallvec = "1"
unicode-segmentation = "1"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"
objc = "0.2"
[dev-dependencies]
env_logger = "0.11"
[target.'cfg(target_os = "macos")'.dev-dependencies]
cocoa = "0.26"
core-foundation = "0.10"
core-graphics = "0.24"
image = { version = "0.25", default-features = false, features = ["png"] }
raw-window-handle = "0.6"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
[lints.clippy]
all = { level = "warn", priority = -1 }
+4 -1
View File
@@ -20,7 +20,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T01 Primitives — `tasks/ch01-10.md`
- [x] T02 Typography
- [x] T03 Layout
- [ ] T04 Window & Shell
- [x] T04 Window & Shell
- [ ] T05 Buttons & Actions
- [ ] T06a Forms · Text
- [ ] T06b Forms · Selection
@@ -113,3 +113,6 @@ Per-component lines live in `tasks/`. Tags there:
| T03 | 1 | FAIL | restore kept cached split and float state; drags lacked an owner; weights unchecked; pane id overflow broke atomic restore; sticky copy escaped the clip; wheel propagated past the viewport; drawer pull lost the pointer; no top dock; grip sizes raw |
| T03 | 2 | FAIL | closing a pane made NaN or infinite weights; drag ghost text black in dark |
| T03 | 3 | FAIL | even split of a denormal weight underflowed; fixed after the cap by normalizing on close and split, verified by tests and capture, no fourth review |
| T04 | 1 | FAIL | system close skipped TitleBar::on_close; hosted windows had no Tab scope; switcher lost Tab to an ancestor, dropped focus on close, kept a stale index |
| T04 | 2 | FAIL | a removed on_close kept blocking the system close; switcher keys bubbled past it |
| T04 | 3 | PASS | |
+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-app-window"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M10 4v4" />
<path d="M2 8h20" />
<path d="M6 4v4" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-picture-in-picture-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 9V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v10c0 1.1.9 2 2 2h4" />
<rect width="10" height="7" x="12" y="13" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 425 B

+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")
}
+3 -34
View File
@@ -7,8 +7,8 @@ use gpui::{
use super::{Dock, DockSide, Spot};
use crate::{
layout::{FloatingPanel, resize_handle},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
primitives::{DragGhost, Icon},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
struct DraggedPanel {
@@ -16,36 +16,6 @@ struct DraggedPanel {
id: SharedString,
}
struct PanelGhost {
title: SharedString,
icon: IconName,
}
impl Render for PanelGhost {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.items_center()
.gap_1p5()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.overlay)
.border_1()
.border_color(theme.colors.border)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg)
.child(
Icon::new(self.icon)
.size(IconSize::Sm)
.color(theme.colors.fg_muted),
)
.child(self.title.clone())
}
}
struct DockResize {
dock: EntityId,
side: DockSide,
@@ -98,8 +68,7 @@ impl Dock {
.child(title.clone())
.on_click(cx.listener(move |dock, _, _, cx| dock.activate(&id, cx)))
.on_drag(drag, move |_, _, _, cx| {
let (title, icon) = (title.clone(), icon);
cx.new(|_| PanelGhost { title, icon })
DragGhost::new(title.clone(), Some(icon), cx)
})
}))
.into_any_element()
+1
View File
@@ -4,6 +4,7 @@ pub mod buttons;
pub mod layout;
pub mod motion;
pub mod primitives;
pub mod shell;
pub mod theme;
pub mod typography;
+46
View File
@@ -0,0 +1,46 @@
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, prelude::*,
};
use super::{Icon, IconName};
use crate::theme::{ActiveTheme, Elevation, IconSize, Radius, TextSize};
/// What follows the pointer while a tab or panel drags.
pub struct DragGhost {
title: SharedString,
icon: Option<IconName>,
}
impl DragGhost {
pub fn new(title: SharedString, icon: Option<IconName>, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self { title, icon })
}
}
impl Render for DragGhost {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.flex()
.items_center()
.gap_1p5()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.bg(theme.colors.overlay)
.border_1()
.border_color(theme.colors.border)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg)
.when_some(self.icon, |ghost, icon| {
ghost.child(
Icon::new(icon)
.size(IconSize::Sm)
.color(theme.colors.fg_muted),
)
})
.child(self.title.clone())
}
}
+21 -1
View File
@@ -1,4 +1,7 @@
use gpui::{App, Hsla, IntoElement, Radians, RenderOnce, Transformation, Window, prelude::*, svg};
use gpui::{
App, Hsla, IntoElement, Radians, RenderOnce, SharedString, Transformation, Window, prelude::*,
svg,
};
use crate::theme::{ActiveTheme, IconSize};
@@ -36,6 +39,7 @@ icons! {
AlignCenter => "align-center",
AlignLeft => "align-left",
AlignRight => "align-right",
AppWindow => "app-window",
Archive => "archive",
ArrowDownLeft => "arrow-down-left",
ArrowDownWideNarrow => "arrow-down-wide-narrow",
@@ -182,6 +186,7 @@ icons! {
Pencil => "pencil",
PhoneOff => "phone-off",
Phone => "phone",
PictureInPicture2 => "picture-in-picture-2",
PinOff => "pin-off",
Pin => "pin",
Play => "play",
@@ -254,6 +259,7 @@ pub struct Icon {
name: IconName,
size: IconSize,
color: Option<Hsla>,
hover: Option<(SharedString, Hsla)>,
rotation: Option<Radians>,
}
@@ -263,6 +269,7 @@ impl Icon {
name,
size: IconSize::Md,
color: None,
hover: None,
rotation: None,
}
}
@@ -278,6 +285,16 @@ impl Icon {
self
}
/// Color while the pointer is over `group`.
pub fn group_hover_color(
mut self,
group: impl Into<SharedString>,
color: impl Into<Hsla>,
) -> Self {
self.hover = Some((group.into(), color.into()));
self
}
pub fn rotate(mut self, angle: impl Into<Radians>) -> Self {
self.rotation = Some(angle.into());
self
@@ -299,6 +316,9 @@ impl RenderOnce for Icon {
.size(theme.icon_size(self.size))
.flex_none()
.text_color(color)
.when_some(self.hover, |svg, (group, hover)| {
svg.group_hover(group, |style| style.text_color(hover))
})
.when_some(self.rotation, |svg, angle| {
svg.with_transformation(Transformation::rotate(angle))
})
+2
View File
@@ -1,6 +1,7 @@
mod backdrop;
mod divider;
mod focus;
mod ghost;
mod icon;
mod image;
mod measure;
@@ -10,6 +11,7 @@ mod tooltip;
pub use backdrop::{Backdrop, Place};
pub use divider::Divider;
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
pub use ghost::DragGhost;
pub use icon::{Icon, IconName};
pub use image::Image;
pub use measure::{IntersectionObserver, Measure};
+184
View File
@@ -0,0 +1,184 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use crate::{
motion,
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, IconSize, TextSize},
};
/// One destination on an activity bar.
#[derive(Clone)]
pub struct ActivityItem {
id: SharedString,
icon: IconName,
label: SharedString,
badge: Option<u32>,
}
impl ActivityItem {
pub fn new(
id: impl Into<SharedString>,
icon: IconName,
label: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
icon,
label: label.into(),
badge: None,
}
}
/// A count; zero hides it.
pub fn badge(mut self, count: u32) -> Self {
self.badge = (count > 0).then_some(count);
self
}
}
type OnSelect = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// Icon rail at a window's edge. Main items top, footer items bottom.
#[derive(IntoElement)]
pub struct ActivityBar {
id: ElementId,
items: Vec<ActivityItem>,
footer: Vec<ActivityItem>,
selected: Option<SharedString>,
on_select: Option<OnSelect>,
}
impl ActivityBar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
items: Vec::new(),
footer: Vec::new(),
selected: None,
on_select: None,
}
}
pub fn item(mut self, item: ActivityItem) -> Self {
self.items.push(item);
self
}
pub fn footer(mut self, item: ActivityItem) -> Self {
self.footer.push(item);
self
}
pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
self.selected = Some(id.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ActivityBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let side = theme.rail_width();
let mark = theme.icon_size(IconSize::Lg);
let grow = motion::duration(motion::BASE, cx);
let entry = |item: ActivityItem, cx: &App| {
let theme = cx.theme();
let colors = &theme.colors;
let selected = self.selected.as_ref() == Some(&item.id);
let group = SharedString::from(format!("activity-{}", item.id));
let on_select = self.on_select.clone();
let id = item.id.clone();
div()
.id(item.id.clone())
.group(group.clone())
.relative()
.flex()
.items_center()
.justify_center()
.size(side)
.cursor_pointer()
.tooltip(Tooltip::text(item.label.clone()))
.on_click(move |_, window, cx| {
log::info!("activity bar: {id}");
if let Some(select) = &on_select {
select(&id, window, cx);
}
})
.child(
Icon::new(item.icon)
.size(IconSize::Lg)
.color(if selected {
colors.fg
} else {
colors.fg_subtle
})
.group_hover_color(group, colors.fg),
)
.when(selected, |entry| {
entry.child(
div()
.absolute()
.left_0()
.top_0()
.bottom_0()
.flex()
.items_center()
.child(
div()
.h(mark)
.border_l_2()
.border_color(colors.fg)
.with_animation(
SharedString::from(format!("activity-mark-{}", item.id)),
Animation::new(grow).with_easing(motion::ease_out_cubic),
move |mark_line, t| mark_line.h(mark * t),
),
),
)
})
.when_some(item.badge, |entry, count| {
entry.child(
div()
.absolute()
.top_1()
.right_1()
.px_1()
.rounded_full()
.bg(colors.accent)
.text_color(colors.on_accent)
.text_size(theme.text_size(TextSize::Xs))
.line_height(theme.text_size(TextSize::Md))
.child(SharedString::from(count.to_string())),
)
})
};
let column = || div().flex().flex_col();
div()
.id(self.id)
.flex()
.flex_col()
.flex_none()
.justify_between()
.w(side)
.h_full()
.py_1()
.bg(theme.colors.surface)
.border_r_1()
.border_color(theme.colors.border)
.child(column().children(self.items.clone().into_iter().map(|item| entry(item, cx))))
.child(column().children(self.footer.clone().into_iter().map(|item| entry(item, cx))))
}
}
+238
View File
@@ -0,0 +1,238 @@
use gpui::{
AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
prelude::*,
};
use smallvec::SmallVec;
use crate::{
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// A row of tools, grouped and divided.
#[derive(IntoElement)]
pub struct Toolbar {
base: Div,
children: SmallVec<[AnyElement; 4]>,
}
impl Toolbar {
pub fn new() -> Self {
Self {
base: div(),
children: SmallVec::new(),
}
}
}
impl Default for Toolbar {
fn default() -> Self {
Self::new()
}
}
impl Styled for Toolbar {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl ParentElement for Toolbar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Toolbar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
self.base
.flex()
.flex_none()
.items_center()
.gap_1()
.h(cx.theme().control_height(ControlSize::Lg))
.px_1()
.children(self.children)
}
}
/// Tools that belong together.
#[derive(IntoElement, Default)]
pub struct ToolbarGroup {
children: SmallVec<[AnyElement; 4]>,
}
impl ToolbarGroup {
pub fn new() -> Self {
Self::default()
}
}
impl ParentElement for ToolbarGroup {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ToolbarGroup {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
div()
.flex()
.items_center()
.gap_0p5()
.children(self.children)
}
}
/// Hairline between toolbar groups.
#[derive(IntoElement)]
pub struct ToolbarSeparator;
impl RenderOnce for ToolbarSeparator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.mx_1()
.h(theme.icon_size(IconSize::Md))
.border_l_1()
.border_color(theme.colors.border)
}
}
/// Thin bar along a window's foot: items left and right.
#[derive(IntoElement)]
pub struct StatusBar {
base: Div,
left: SmallVec<[AnyElement; 4]>,
right: SmallVec<[AnyElement; 4]>,
}
impl StatusBar {
pub fn new() -> Self {
Self {
base: div(),
left: SmallVec::new(),
right: SmallVec::new(),
}
}
pub fn left(mut self, item: impl IntoElement) -> Self {
self.left.push(item.into_any_element());
self
}
pub fn right(mut self, item: impl IntoElement) -> Self {
self.right.push(item.into_any_element());
self
}
}
impl Default for StatusBar {
fn default() -> Self {
Self::new()
}
}
impl Styled for StatusBar {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for StatusBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let side = || div().flex().items_center().gap_0p5().h_full();
self.base
.flex()
.flex_none()
.items_center()
.justify_between()
.h(theme.control_height(ControlSize::Sm))
.px_1()
.bg(theme.colors.bg)
.border_t_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.child(side().children(self.left))
.child(side().children(self.right))
}
}
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
/// One status: an icon, a label, or both. Clickable when given a handler.
#[derive(IntoElement)]
pub struct StatusBarItem {
id: ElementId,
icon: Option<IconName>,
label: Option<SharedString>,
tooltip: Option<SharedString>,
on_click: Option<ClickHandler>,
}
impl StatusBarItem {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
icon: None,
label: None,
tooltip: None,
on_click: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl RenderOnce for StatusBarItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let hover = theme.colors.hover;
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.h_full()
.px_1p5()
.rounded(theme.radius(Radius::Sm))
.when_some(self.icon, |item, icon| {
item.child(
Icon::new(icon)
.size(IconSize::Xs)
.color(theme.colors.fg_muted),
)
})
.when_some(self.label, |item, label| item.child(label))
.when_some(self.tooltip, |item, text| item.tooltip(Tooltip::text(text)))
.when_some(self.on_click, |item, handler| {
item.cursor_pointer()
.hover(|style| style.bg(hover))
.on_click(handler)
})
}
}
+422
View File
@@ -0,0 +1,422 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement, Styled, Window,
WindowControlArea, div, prelude::*,
};
use smallvec::SmallVec;
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
/// Which system's window buttons to draw.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ControlsStyle {
Mac,
Windows,
Linux,
}
impl ControlsStyle {
/// The style of the system this build targets.
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::Mac
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Linux
}
}
}
/// Drags the window, and zooms it on double-click.
pub fn drag_region(id: impl Into<ElementId>, window: &mut Window, cx: &mut App) -> Stateful<Div> {
let id = id.into();
let pressed = window.use_keyed_state(id.clone(), cx, |_, _| false);
let (down, moved, up, out) = (pressed.clone(), pressed.clone(), pressed.clone(), pressed);
div()
.id(id)
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
down.update(cx, |pressed, _| *pressed = true)
})
.on_mouse_move(move |_, window, cx| {
if *moved.read(cx) {
moved.update(cx, |pressed, _| *pressed = false);
window.start_window_move();
}
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| {
up.update(cx, |pressed, _| *pressed = false)
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
out.update(cx, |pressed, _| *pressed = false)
})
.on_click(|event, window, _| {
if !event.standard_click() || event.click_count() != 2 {
return;
}
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else if cfg!(target_os = "linux") {
window.zoom_window();
}
})
}
#[derive(Clone, Copy, Debug)]
enum Control {
Minimize,
Maximize,
Close,
}
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
fn run(control: Control, on_close: Option<&OnClose>, window: &mut Window, cx: &mut App) {
log::info!("window controls: {control:?}");
match (control, on_close) {
(Control::Minimize, _) => window.minimize_window(),
(Control::Maximize, _) => window.zoom_window(),
(Control::Close, Some(close)) => close(window, cx),
(Control::Close, None) => window.remove_window(),
}
}
/// Minimize, maximize and close, drawn in one system's manner.
#[derive(IntoElement)]
pub struct WindowControls {
id: ElementId,
style: ControlsStyle,
on_close: Option<OnClose>,
}
impl WindowControls {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: ControlsStyle::current(),
on_close: None,
}
}
pub fn style(mut self, style: ControlsStyle) -> Self {
self.style = style;
self
}
/// Replaces closing the window, e.g. to confirm first.
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
}
impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
match self.style {
ControlsStyle::Mac => traffic_lights(self.id, self.on_close, window, cx),
ControlsStyle::Windows => caption_buttons(self.id, self.on_close, window, cx),
ControlsStyle::Linux => round_buttons(self.id, self.on_close, window, cx),
}
}
}
fn traffic_lights(
id: ElementId,
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let active = window.is_window_active();
let lights = [
(Control::Close, colors.danger, "×"),
(Control::Minimize, colors.warning, "−"),
(Control::Maximize, colors.success, "+"),
];
let group = SharedString::from(format!("traffic-{id}"));
div()
.id(id)
.group(group.clone())
.flex()
.items_center()
.gap_2()
.children(lights.map(|(control, tint, glyph)| {
let on_close = on_close.clone();
div()
.id(SharedString::from(format!("{control:?}")))
.size(theme.traffic_light())
.flex()
.items_center()
.justify_center()
.rounded_full()
.bg(if active { tint } else { colors.border_strong })
.border_1()
.border_color(colors.shadow.opacity(0.12))
.text_size(theme.text_size(TextSize::Xs))
.line_height(theme.traffic_light())
.font_weight(FontWeight::BOLD)
.text_color(gpui::transparent_black())
.group_hover(group.clone(), |style| {
style.text_color(colors.shadow.opacity(0.6))
})
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(glyph)
}))
.into_any_element()
}
fn caption_buttons(
id: ElementId,
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let maximize = if window.is_maximized() {
IconName::Copy
} else {
IconName::Square
};
let buttons = [
(Control::Minimize, IconName::Minus, WindowControlArea::Min),
(Control::Maximize, maximize, WindowControlArea::Max),
(Control::Close, IconName::X, WindowControlArea::Close),
];
div()
.id(id)
.flex()
.h_full()
.children(buttons.map(|(control, icon, area)| {
let on_close = on_close.clone();
let group = SharedString::from(format!("caption-{control:?}"));
let (hover, glyph) = match control {
Control::Close => (colors.danger, colors.on_accent),
_ => (colors.hover, colors.fg),
};
div()
.id(group.clone())
.group(group.clone())
.flex()
.items_center()
.justify_center()
.w(theme.caption_button_width())
.h_full()
.window_control_area(area)
.hover(|style| style.bg(hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(
Icon::new(icon)
.size(IconSize::Sm)
.color(colors.fg_muted)
.group_hover_color(group, glyph),
)
}))
.into_any_element()
}
fn round_buttons(
id: ElementId,
on_close: Option<OnClose>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let offered = window.window_controls();
let buttons = [
(Control::Minimize, IconName::Minus, offered.minimize),
(Control::Maximize, IconName::Square, offered.maximize),
(Control::Close, IconName::X, true),
];
div()
.id(id)
.flex()
.items_center()
.gap_2()
.children(
buttons
.into_iter()
.filter(|(_, _, shown)| *shown)
.map(|(control, icon, _)| {
let on_close = on_close.clone();
div()
.id(SharedString::from(format!("{control:?}")))
.flex()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Sm))
.rounded_full()
.bg(colors.hover)
.hover(|style| style.bg(colors.active))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| run(control, on_close.as_ref(), window, cx))
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg))
}),
)
.into_any_element()
}
/// The live close handler, read by the window's close request.
struct CloseRoute {
handler: Option<OnClose>,
registered: bool,
}
/// Sends system closes to `handler` while the bar lives and holds one.
fn route_close(id: &ElementId, handler: Option<OnClose>, window: &mut Window, cx: &mut App) {
let route = window.use_keyed_state(id.clone(), cx, |_, _| CloseRoute {
handler: None,
registered: false,
});
let register = handler.is_some() && !route.read(cx).registered;
route.update(cx, |route, _| {
route.handler = handler;
route.registered |= register;
});
if register {
let live = route.downgrade();
window.on_window_should_close(cx, move |window, cx| {
let handler = live
.upgrade()
.and_then(|route| route.read(cx).handler.clone());
let Some(close) = handler else {
return true;
};
log::info!("title bar: system close routed to on_close");
close(window, cx);
false
});
}
}
/// Window title bar. Its empty space drags the window.
#[derive(IntoElement)]
pub struct TitleBar {
id: ElementId,
title: Option<SharedString>,
style: Option<ControlsStyle>,
on_close: Option<OnClose>,
leading: SmallVec<[AnyElement; 2]>,
actions: SmallVec<[AnyElement; 2]>,
}
impl TitleBar {
/// On macOS the system draws the traffic lights; the bar leaves them room.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
title: None,
style: None,
on_close: None,
leading: SmallVec::new(),
actions: SmallVec::new(),
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Draws this style's controls instead of the system's.
pub fn style(mut self, style: ControlsStyle) -> Self {
self.style = Some(style);
self
}
/// Runs instead of closing; without a forced style, system closes run it too.
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// Right-aligned content before the window controls.
pub fn action(mut self, action: impl IntoElement) -> Self {
self.actions.push(action.into_any_element());
self
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.leading.extend(elements);
}
}
impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.style.is_none() {
route_close(&self.id, self.on_close.clone(), window, cx);
}
let bar = drag_region(self.id, window, cx);
let theme = cx.theme();
let system = self.style.is_none() && cfg!(target_os = "macos");
let style = self.style.unwrap_or_else(ControlsStyle::current);
let mut trailing = (!system).then_some(WindowControls {
id: "window-controls".into(),
style,
on_close: self.on_close,
});
let leading = if style == ControlsStyle::Mac {
trailing.take()
} else {
None
};
let title = self.title.map(|title| {
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.text_color(theme.colors.fg_muted)
.child(title)
});
let centered = style != ControlsStyle::Windows;
let inset = theme.traffic_light_inset();
let fullscreen = window.is_fullscreen();
let bar = bar
.relative()
.flex()
.flex_none()
.items_center()
.gap_2()
.w_full()
.h(theme.titlebar_height())
.bg(theme.colors.bg)
.border_b_1()
.border_color(theme.colors.border);
let bar = match (system, style) {
(true, _) if fullscreen => bar.pl_3().pr_2(),
(true, _) => bar.pl(inset).pr_2(),
(false, ControlsStyle::Mac) => bar.pl_3().pr_2().children(leading),
(false, ControlsStyle::Windows) => bar.pl_3(),
(false, ControlsStyle::Linux) => bar.pl_3().pr_2(),
};
let bar = bar.children(self.leading);
let bar = match (centered, title) {
(false, Some(title)) => bar.child(title),
(_, title) => bar.when_some(title, |bar, title| {
bar.child(
div()
.absolute()
.inset_0()
.flex()
.items_center()
.justify_center()
.child(title),
)
}),
};
bar.child(div().flex_1())
.children(self.actions)
.children(trailing)
}
}
+163
View File
@@ -0,0 +1,163 @@
use std::rc::Rc;
use gpui::{
App, ClipboardItem, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window,
WindowHandle, div,
};
use super::{Hosted, open_hosted};
use crate::{
buttons::{Button, ButtonVariant},
layout::{Collapsible, ScrollArea},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::{Paragraph, Title},
};
type Action = Rc<dyn Fn(&mut Window, &mut App)>;
/// After a crash: what happened, the report, and a choice to send it.
#[derive(IntoElement, Clone)]
pub struct CrashReporter {
name: SharedString,
summary: SharedString,
report: SharedString,
details: bool,
on_send: Option<Action>,
on_dismiss: Option<Action>,
}
impl CrashReporter {
pub fn new(
name: impl Into<SharedString>,
summary: impl Into<SharedString>,
report: impl Into<SharedString>,
) -> Self {
Self {
name: name.into(),
summary: summary.into(),
report: report.into(),
details: false,
on_send: None,
on_dismiss: None,
}
}
/// Opens the report at first.
pub fn details(mut self, open: bool) -> Self {
self.details = open;
self
}
pub fn on_send(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_send = Some(Rc::new(handler));
self
}
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_dismiss = Some(Rc::new(handler));
self
}
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<CrashReporter>>> {
let size = cx.theme().dialog_window();
open_hosted(format!("{} crashed", self.name), size, self, cx)
}
}
impl RenderOnce for CrashReporter {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let initial = self.details;
let shown = window.use_keyed_state("crash-details", cx, |_, _| initial);
let open = *shown.read(cx);
let theme = cx.theme();
let colors = &theme.colors;
let action = |button: Button, action: Option<Action>| {
action.map(|action| button.on_click(move |_, window, cx| action(window, cx)))
};
div()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.gap_4()
.px_8()
.pb_6()
.child(
Icon::new(IconName::TriangleAlert)
.size(IconSize::Xl)
.color(colors.fg_muted),
)
.child(Title::new(format!("{} quit unexpectedly", self.name)))
.child(Paragraph::new(self.summary))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
Button::new(
"crash-details",
if open { "Hide report" } else { "Show report" },
)
.variant(ButtonVariant::Link)
.trailing_icon(if open {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.on_click(move |_, _, cx| {
shown.update(cx, |open, cx| {
*open = !*open;
cx.notify();
})
}),
)
.child({
let report = self.report.clone();
Button::new("crash-copy", "Copy report")
.variant(ButtonVariant::Ghost)
.icon(IconName::Copy)
.on_click(move |_, _, cx| {
log::info!("crash reporter: report copied");
cx.write_to_clipboard(ClipboardItem::new_string(
report.to_string(),
));
})
}),
)
.child(
Collapsible::new("crash-report", open).child(
div()
.h(theme.pane_min())
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(
ScrollArea::new("crash-report-scroll")
.size_full()
.child(div().p_3().child(self.report)),
),
),
)
.child(div().flex_1())
.child(
div()
.flex()
.justify_end()
.gap_2()
.children(action(
Button::new("crash-dismiss", "Don't send").variant(ButtonVariant::Ghost),
self.on_dismiss,
))
.children(action(
Button::new("crash-send", "Send report").primary(),
self.on_send,
)),
)
}
}
+287
View File
@@ -0,0 +1,287 @@
use gpui::{
Animation, AnimationExt, AnyElement, App, AppContext, Bounds, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, WindowBounds, WindowHandle, WindowOptions, div,
prelude::*, relative,
};
use super::{Hosted, open_hosted};
use crate::{
motion,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{Caption, CopyableText, ExternalLink, Paragraph, Title},
};
fn mark(name: &SharedString, icon: Option<IconName>, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
let initial: SharedString = name.chars().take(1).collect::<String>().into();
div()
.flex()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Lg) * 2.0)
.rounded(theme.radius(Radius::Xl))
.bg(theme.colors.sunken)
.border_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Xxl))
.text_color(theme.colors.fg)
.map(|tile| match icon {
Some(icon) => tile.child(Icon::new(icon).size(IconSize::Xxl)),
None => tile.child(initial),
})
}
/// A thin track: `Some` fills to the value, `None` sweeps.
pub(super) fn progress_track(value: Option<f32>, cx: &App) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let fill = div()
.absolute()
.top_0()
.h_full()
.rounded_full()
.bg(colors.fg);
let track = div()
.relative()
.w_full()
.h(theme.progress_thickness())
.rounded_full()
.overflow_hidden()
.bg(colors.border);
match value {
Some(value) => {
assert!(
(0.0..=1.0).contains(&value),
"progress {value} is not 0..=1"
);
track
.child(fill.left_0().w(relative(value)))
.into_any_element()
}
None if theme.reduced_motion => track
.child(fill.left(relative(0.35)).w(relative(0.3)))
.into_any_element(),
None => track
.child(
fill.w(relative(0.3)).with_animation(
"progress-sweep",
Animation::new(motion::duration(motion::SLOW, cx) * 4)
.repeat()
.with_easing(motion::ease_in_out_cubic),
|bar, t| bar.left(relative(-0.3 + 1.3 * t)),
),
)
.into_any_element(),
}
}
/// Name, version, credits: the About window's content.
#[derive(IntoElement, Clone)]
pub struct AboutDialog {
name: SharedString,
version: SharedString,
icon: Option<IconName>,
description: Option<SharedString>,
links: Vec<(SharedString, SharedString)>,
copyright: Option<SharedString>,
}
impl AboutDialog {
pub fn new(name: impl Into<SharedString>, version: impl Into<SharedString>) -> Self {
Self {
name: name.into(),
version: version.into(),
icon: None,
description: None,
links: Vec::new(),
copyright: None,
}
}
/// App glyph; the name's initial stands in without one.
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn description(mut self, text: impl Into<SharedString>) -> Self {
self.description = Some(text.into());
self
}
pub fn link(mut self, label: impl Into<SharedString>, url: impl Into<SharedString>) -> Self {
self.links.push((label.into(), url.into()));
self
}
pub fn copyright(mut self, text: impl Into<SharedString>) -> Self {
self.copyright = Some(text.into());
self
}
/// Shows it in its own window; Escape closes it.
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<AboutDialog>>> {
let size = cx.theme().about_window();
open_hosted(format!("About {}", self.name), size, self, cx)
}
}
impl RenderOnce for AboutDialog {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.flex()
.flex_col()
.items_center()
.gap_2()
.px_8()
.pt_4()
.pb_8()
.w_full()
.child(mark(&self.name, self.icon, cx))
.child(div().pt_3().child(Title::new(self.name.clone())))
.child(CopyableText::new(
"about-version",
format!("Version {}", self.version),
))
.when_some(self.description, |about, text| {
about.child(div().pt_2().text_center().child(Paragraph::new(text)))
})
.when(!self.links.is_empty(), |about| {
about.child(
div()
.flex()
.gap_4()
.pt_2()
.children(
self.links
.into_iter()
.enumerate()
.map(|(ix, (label, url))| {
ExternalLink::new(("about-link", ix), label, url)
}),
),
)
})
.when_some(self.copyright, |about, text| {
about.child(div().pt_4().child(Caption::new(text)))
})
}
}
/// First screen while an app loads: mark, name, progress.
#[derive(IntoElement, Clone)]
pub struct SplashScreen {
name: SharedString,
icon: Option<IconName>,
tagline: Option<SharedString>,
status: Option<SharedString>,
progress: Option<f32>,
}
impl SplashScreen {
pub fn new(name: impl Into<SharedString>) -> Self {
Self {
name: name.into(),
icon: None,
tagline: None,
status: None,
progress: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
pub fn tagline(mut self, text: impl Into<SharedString>) -> Self {
self.tagline = Some(text.into());
self
}
pub fn status(mut self, text: impl Into<SharedString>) -> Self {
self.status = Some(text.into());
self
}
/// 0 to 1; `None` shows a moving bar.
pub fn progress(mut self, progress: Option<f32>) -> Self {
if let Some(value) = progress {
assert!(
(0.0..=1.0).contains(&value),
"splash progress {value} is not 0..=1"
);
}
self.progress = progress;
self
}
/// Shows it borderless and centered. Close it with `window.remove_window()`.
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<SplashScreen>>> {
let size = cx.theme().splash_window();
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, size, cx))),
titlebar: None,
is_movable: false,
is_resizable: false,
is_minimizable: false,
..Default::default()
};
let handle = cx.open_window(options, |window, cx| {
let focus = cx.focus_handle();
window.focus(&focus);
cx.new(|_| Hosted::new(self, focus))
})?;
log::info!("splash: shown");
Ok(handle)
}
}
impl RenderOnce for SplashScreen {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let enter = motion::duration(motion::SLOW, cx);
let track = progress_track(self.progress, cx);
div()
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.px_10()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_2()
.child(mark(&self.name, self.icon, cx))
.child(div().pt_2().child(Title::new(self.name.clone())))
.when_some(self.tagline, |block, text| block.child(Caption::new(text)))
.with_animation(
"splash-in",
Animation::new(enter).with_easing(motion::ease_out_cubic),
|block, t| block.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)),
),
)
.child(
div()
.w(relative(0.6))
.pt_8()
.flex()
.flex_col()
.items_center()
.gap_2()
.child(track)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(self.status.unwrap_or_default()),
),
)
}
}
+137
View File
@@ -0,0 +1,137 @@
use gpui::{
AnyElement, App, Bounds, CursorStyle, Decorations, HitboxBehavior, IntoElement, MouseButton,
ParentElement, Pixels, Point, RenderOnce, ResizeEdge, Size, Styled, Window, canvas, div, point,
prelude::*,
};
use smallvec::SmallVec;
use crate::theme::{ActiveTheme, Elevation, Radius};
/// Which edge or corner `pos` grabs, within `inset` of the window's rim.
fn resize_edge(pos: Point<Pixels>, inset: Pixels, size: Size<Pixels>) -> Option<ResizeEdge> {
let (top, bottom) = (pos.y < inset, pos.y > size.height - inset);
let (left, right) = (pos.x < inset, pos.x > size.width - inset);
Some(match (top, bottom, left, right) {
(true, _, true, _) => ResizeEdge::TopLeft,
(true, _, _, true) => ResizeEdge::TopRight,
(true, ..) => ResizeEdge::Top,
(_, true, true, _) => ResizeEdge::BottomLeft,
(_, true, _, true) => ResizeEdge::BottomRight,
(_, true, ..) => ResizeEdge::Bottom,
(.., true, _) => ResizeEdge::Left,
(.., true) => ResizeEdge::Right,
_ => return None,
})
}
fn cursor(edge: ResizeEdge) -> CursorStyle {
match edge {
ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
}
}
/// Resize margin, hairline and shadow for a client-drawn window; else a plain box.
#[derive(IntoElement, Default)]
pub struct ResizeBorder {
children: SmallVec<[AnyElement; 2]>,
}
impl ResizeBorder {
pub fn new() -> Self {
Self::default()
}
}
impl ParentElement for ResizeBorder {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizeBorder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let Decorations::Client { tiling } = window.window_decorations() else {
return div().size_full().children(self.children).into_any_element();
};
let theme = cx.theme();
let inset = theme.window_inset().to_pixels(window.rem_size());
window.set_client_inset(inset);
let radius = theme.radius(Radius::Lg);
let floating = !tiling.is_tiled();
div()
.id("resize-border")
.size_full()
.child(
canvas(
|_, window, _| {
let size = window.window_bounds().get_bounds().size;
window.insert_hitbox(
Bounds::new(point(Pixels::ZERO, Pixels::ZERO), size),
HitboxBehavior::Normal,
)
},
move |_, hitbox, window, _| {
let size = window.window_bounds().get_bounds().size;
if let Some(edge) = resize_edge(window.mouse_position(), inset, size) {
window.set_cursor_style(cursor(edge), &hitbox);
}
},
)
.absolute()
.size_full(),
)
.when(!tiling.top, |edge| edge.pt(inset))
.when(!tiling.bottom, |edge| edge.pb(inset))
.when(!tiling.left, |edge| edge.pl(inset))
.when(!tiling.right, |edge| edge.pr(inset))
.on_mouse_move(|_, window, _| window.refresh())
.on_mouse_down(MouseButton::Left, move |event, window, _| {
let size = window.window_bounds().get_bounds().size;
if let Some(edge) = resize_edge(event.position, inset, size) {
log::info!("window: resize from {edge:?}");
window.start_window_resize(edge);
}
})
.child(
div()
.size_full()
.overflow_hidden()
.cursor(CursorStyle::Arrow)
.bg(theme.colors.bg)
.when(floating, |pane| {
pane.rounded(radius)
.border_1()
.border_color(theme.colors.border_strong)
.shadow(theme.elevation(Elevation::Modal))
})
.on_mouse_move(|_, _, cx| cx.stop_propagation())
.children(self.children),
)
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use gpui::{ResizeEdge, point, px, size};
use super::resize_edge;
#[test]
fn corners_win_over_edges_and_the_middle_is_free() {
let window = size(px(400.0), px(300.0));
let at = |x: f32, y: f32| resize_edge(point(px(x), px(y)), px(10.0), window);
assert_eq!(at(2.0, 2.0), Some(ResizeEdge::TopLeft));
assert_eq!(at(398.0, 5.0), Some(ResizeEdge::TopRight));
assert_eq!(at(200.0, 3.0), Some(ResizeEdge::Top));
assert_eq!(at(1.0, 299.0), Some(ResizeEdge::BottomLeft));
assert_eq!(at(395.0, 295.0), Some(ResizeEdge::BottomRight));
assert_eq!(at(200.0, 295.0), Some(ResizeEdge::Bottom));
assert_eq!(at(4.0, 150.0), Some(ResizeEdge::Left));
assert_eq!(at(396.0, 150.0), Some(ResizeEdge::Right));
assert_eq!(at(200.0, 150.0), None);
}
}
+25
View File
@@ -0,0 +1,25 @@
mod activity;
mod bars;
mod chrome;
mod crash;
mod dialogs;
mod frame;
mod native;
mod status;
mod switcher;
mod tabs;
mod update;
mod windows;
pub use activity::{ActivityBar, ActivityItem};
pub use bars::{StatusBar, StatusBarItem, Toolbar, ToolbarGroup, ToolbarSeparator};
pub use chrome::{ControlsStyle, TitleBar, WindowControls, drag_region};
pub use crash::CrashReporter;
pub use dialogs::{AboutDialog, SplashScreen};
pub use frame::ResizeBorder;
pub use native::{TrayIcon, TrayItem, dock_badge, set_dock_badge};
pub use status::{Connectivity, OfflineIndicator, ZoomControl};
pub use switcher::WindowSwitcher;
pub use tabs::{TabBar, WindowTab};
pub use update::{UpdateBanner, UpdateDialog, UpdateState};
pub use windows::{Corner, Hosted, MiniWindow, WindowManager, open_hosted};
+245
View File
@@ -0,0 +1,245 @@
use std::rc::Rc;
use gpui::{App, SharedString};
/// One entry in a tray menu.
#[derive(Clone, Debug)]
pub enum TrayItem {
Action(SharedString),
Separator,
}
type OnPick = Rc<dyn Fn(usize, &mut App)>;
/// An icon in the system menu bar with a menu. Dropping it removes it.
pub struct TrayIcon {
#[cfg(target_os = "macos")]
inner: mac::Tray,
}
impl TrayIcon {
/// `symbol` names an SF Symbol. `on_pick` gets the index of the chosen item.
pub fn new(
symbol: &str,
items: Vec<TrayItem>,
on_pick: impl Fn(usize, &mut App) + 'static,
cx: &mut App,
) -> anyhow::Result<Self> {
let on_pick: OnPick = Rc::new(on_pick);
#[cfg(target_os = "macos")]
return Ok(Self {
inner: mac::Tray::new(symbol, &items, on_pick, cx)?,
});
#[cfg(not(target_os = "macos"))]
{
let _ = (symbol, items, on_pick, cx);
anyhow::bail!("tray icons need macOS in this build")
}
}
/// Chooses item `index` as a click would.
pub fn pick(&self, index: usize) {
#[cfg(target_os = "macos")]
self.inner.pick(index);
#[cfg(not(target_os = "macos"))]
let _ = index;
}
}
/// Sets or clears the badge on the app's Dock icon.
pub fn set_dock_badge(label: Option<&str>) -> anyhow::Result<()> {
#[cfg(target_os = "macos")]
{
mac::set_badge(label);
log::info!("dock badge: {label:?}");
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
let _ = label;
anyhow::bail!("dock badges need macOS in this build")
}
}
/// The badge the Dock shows now.
pub fn dock_badge() -> anyhow::Result<Option<String>> {
#[cfg(target_os = "macos")]
return Ok(mac::badge());
#[cfg(not(target_os = "macos"))]
anyhow::bail!("dock badges need macOS in this build")
}
#[cfg(target_os = "macos")]
mod mac {
use std::{ffi::CStr, sync::Once};
use cocoa::{
appkit::{
NSApp, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSVariableStatusItemLength,
},
base::{id, nil},
foundation::{NSAutoreleasePool, NSString},
};
use gpui::{App, AsyncApp};
use objc::{
class,
declare::ClassDecl,
msg_send,
runtime::{Class, Object, Sel},
sel, sel_impl,
};
use super::{OnPick, TrayItem};
const TARGET: &str = "ElyTrayTarget";
const ROUTE: &str = "elyRoute";
struct Route {
app: AsyncApp,
on_pick: OnPick,
}
impl Route {
fn pick(&self, index: usize) {
log::info!("tray: picked item {index}");
let on_pick = self.on_pick.clone();
self.app
.spawn(async move |cx| {
if let Err(error) = cx.update(|cx| on_pick(index, cx)) {
log::error!("tray: app gone before item {index} ran: {error:#}");
}
})
.detach();
}
}
extern "C" fn picked(this: &Object, _: Sel, item: id) {
unsafe {
let route = *this.get_ivar::<*const std::ffi::c_void>(ROUTE) as *const Route;
let tag: isize = msg_send![item, tag];
(*route).pick(tag as usize);
}
}
fn target_class() -> &'static Class {
static REGISTER: Once = Once::new();
REGISTER.call_once(|| {
let mut decl = ClassDecl::new(TARGET, class!(NSObject))
.unwrap_or_else(|| panic!("objc class {TARGET} already exists"));
decl.add_ivar::<*const std::ffi::c_void>(ROUTE);
unsafe {
decl.add_method(sel!(elyPicked:), picked as extern "C" fn(&Object, Sel, id));
}
decl.register();
});
Class::get(TARGET).expect("registered above")
}
fn string(text: &str) -> id {
unsafe { NSString::alloc(nil).init_str(text).autorelease() }
}
pub struct Tray {
item: id,
menu: id,
target: id,
_route: Box<Route>,
}
impl Tray {
pub fn new(
symbol: &str,
items: &[TrayItem],
on_pick: OnPick,
cx: &mut App,
) -> anyhow::Result<Self> {
let route = Box::new(Route {
app: cx.to_async(),
on_pick,
});
unsafe {
let image: id = msg_send![class!(NSImage),
imageWithSystemSymbolName: string(symbol)
accessibilityDescription: nil];
anyhow::ensure!(image != nil, "no SF Symbol named {symbol}");
let target: id = msg_send![target_class(), new];
(*target).set_ivar(ROUTE, &*route as *const Route as *const std::ffi::c_void);
let menu = NSMenu::new(nil);
for (index, entry) in items.iter().enumerate() {
let entry = match entry {
TrayItem::Separator => NSMenuItem::separatorItem(nil),
TrayItem::Action(label) => {
let entry = NSMenuItem::alloc(nil)
.initWithTitle_action_keyEquivalent_(
string(label),
sel!(elyPicked:),
string(""),
)
.autorelease();
entry.setTarget_(target);
entry
}
};
let _: () = msg_send![entry, setTag: index as isize];
menu.addItem_(entry);
}
let item = NSStatusBar::systemStatusBar(nil)
.statusItemWithLength_(NSVariableStatusItemLength);
let item: id = msg_send![item, retain];
let button = item.button();
anyhow::ensure!(button != nil, "status item has no button");
let _: () = msg_send![button, setImage: image];
let _: () = msg_send![item, setMenu: menu];
log::info!("tray: shown with {} items", items.len());
Ok(Self {
item,
menu,
target,
_route: route,
})
}
}
pub fn pick(&self, index: usize) {
unsafe {
let count: isize = msg_send![self.menu, numberOfItems];
assert!((index as isize) < count, "tray has no item {index}");
let _: () = msg_send![self.menu, performActionForItemAtIndex: index as isize];
}
}
}
impl Drop for Tray {
fn drop(&mut self) {
unsafe {
NSStatusBar::systemStatusBar(nil).removeStatusItem_(self.item);
let _: () = msg_send![self.item, release];
let _: () = msg_send![self.menu, release];
let _: () = msg_send![self.target, release];
}
log::info!("tray: removed");
}
}
fn tile() -> id {
unsafe { msg_send![NSApp(), dockTile] }
}
pub fn set_badge(label: Option<&str>) {
unsafe {
let text = label.map_or(nil, string);
let _: () = msg_send![tile(), setBadgeLabel: text];
}
}
pub fn badge() -> Option<String> {
unsafe {
let label: id = msg_send![tile(), badgeLabel];
(label != nil).then(|| {
CStr::from_ptr(label.UTF8String())
.to_string_lossy()
.into_owned()
})
}
}
}
+238
View File
@@ -0,0 +1,238 @@
use std::time::{Duration, Instant};
use gpui::{
Animation, AnimationExt, App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Task,
Window, div, prelude::*,
};
use crate::{
buttons::{ButtonVariant, IconButton},
motion,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::AnimatedNumber,
};
/// Network state as the host sees it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Connectivity {
Online,
Offline,
Reconnecting,
}
/// How long "Back online" stays.
const HOLD: Duration = Duration::from_millis(2400);
struct Seen {
last: Connectivity,
back_at: Option<Instant>,
generation: u64,
_hide: Option<Task<()>>,
}
/// A quiet pill while offline or reconnecting, and briefly once back.
#[derive(IntoElement)]
pub struct OfflineIndicator {
id: ElementId,
state: Connectivity,
}
impl OfflineIndicator {
pub fn new(id: impl Into<ElementId>, state: Connectivity) -> Self {
Self {
id: id.into(),
state,
}
}
}
impl RenderOnce for OfflineIndicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state;
let seen = window.use_keyed_state(self.id.clone(), cx, |_, _| Seen {
last: state,
back_at: None,
generation: 0,
_hide: None,
});
if seen.read(cx).last != state {
let returned = state == Connectivity::Online;
let hide = returned.then(|| {
window.spawn(cx, async move |cx| {
cx.background_executor().timer(HOLD).await;
if let Err(error) = cx.update(|window, _| window.refresh()) {
log::error!("offline indicator: window gone before hiding: {error:#}");
}
})
});
log::info!("connectivity: {:?} -> {state:?}", seen.read(cx).last);
seen.update(cx, |seen, _| {
seen.last = state;
seen.back_at = returned.then(Instant::now);
seen.generation += 1;
seen._hide = hide;
});
}
let (back_at, generation) = (seen.read(cx).back_at, seen.read(cx).generation);
let theme = cx.theme();
let colors = &theme.colors;
let (icon, dot, label) = match state {
Connectivity::Offline => (
IconName::WifiOff,
colors.fg_subtle,
"Offline · changes stay on this device",
),
Connectivity::Reconnecting => (IconName::Wifi, colors.warning, "Reconnecting…"),
Connectivity::Online => (IconName::Wifi, colors.success, "Back online"),
};
let showing =
state != Connectivity::Online || back_at.is_some_and(|at| at.elapsed() < HOLD);
if !showing {
return div().id(self.id).into_any_element();
}
let enter = motion::duration(motion::BASE, cx);
let still = theme.reduced_motion;
div()
.id(self.id)
.flex()
.items_center()
.gap_2()
.px_3()
.py_1()
.rounded_full()
.bg(colors.surface)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(crate::theme::Elevation::Raised))
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child({
let light = div()
.size(theme.icon_size(IconSize::Xs) / 2.0)
.rounded_full()
.bg(dot);
if state == Connectivity::Reconnecting && !still {
light
.with_animation(
"reconnect-pulse",
Animation::new(motion::duration(motion::SLOW, cx) * 3)
.repeat()
.with_easing(motion::ease_in_out_cubic),
|dot, t| dot.opacity(0.35 + 0.65 * (1.0 - (2.0 * t - 1.0).abs())),
)
.into_any_element()
} else {
light.into_any_element()
}
})
.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
.child(label)
.with_animation(
("connectivity", generation),
Animation::new(if state == Connectivity::Online && !still {
HOLD
} else {
enter
}),
move |pill, t| match state {
Connectivity::Online if !still => pill.opacity(if t < 0.85 {
(t / 0.1).min(1.0)
} else {
(1.0 - t) / 0.15
}),
_ => pill.opacity(t).mt(motion::NUDGE * (1.0 - t)),
},
)
.into_any_element()
}
}
/// Zoom steps, in percent.
const STEPS: [u32; 11] = [50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200];
/// The step after `current` in `direction`, or `None` at the end.
fn step(current: u32, direction: i8) -> Option<u32> {
match direction {
1 => STEPS.iter().copied().find(|step| *step > current),
_ => STEPS.iter().rev().copied().find(|step| *step < current),
}
}
/// Minus, percentage, plus. Scales the window's rem size, and all it holds.
#[derive(IntoElement)]
pub struct ZoomControl {
id: ElementId,
}
impl ZoomControl {
pub fn new(id: impl Into<ElementId>) -> Self {
Self { id: id.into() }
}
}
fn apply(percent: u32, window: &mut Window, cx: &mut App) {
let base = cx.theme().base_rem();
log::info!("zoom: {percent}%");
window.set_rem_size(base * (percent as f32 / 100.0));
window.refresh();
}
impl RenderOnce for ZoomControl {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let percent = (window.rem_size() / theme.base_rem() * 100.0).round() as u32;
let (down, up) = (step(percent, -1), step(percent, 1));
div()
.id(self.id)
.flex()
.items_center()
.gap_0p5()
.child(
IconButton::new("zoom-out", IconName::ZoomOut)
.size(ControlSize::Sm)
.disabled(down.is_none())
.when_some(down, |button, next| {
button.on_click(move |_, window, cx| apply(next, window, cx))
}),
)
.child(
div()
.id("zoom-reset")
.flex()
.items_center()
.justify_center()
.min_w(theme.control_height(ControlSize::Lg) * 1.5)
.h(theme.control_height(ControlSize::Sm))
.rounded_full()
.cursor_pointer()
.hover(|style| style.bg(theme.colors.hover))
.on_click(|_, window, cx| apply(100, window, cx))
.child(AnimatedNumber::new("zoom-value", percent as f64).size(TextSize::Sm))
.child(div().text_size(theme.text_size(TextSize::Sm)).child("%")),
)
.child(
IconButton::new("zoom-in", IconName::ZoomIn)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.disabled(up.is_none())
.when_some(up, |button, next| {
button.on_click(move |_, window, cx| apply(next, window, cx))
}),
)
}
}
#[cfg(test)]
mod tests {
use super::step;
#[test]
fn steps_walk_the_scale_and_stop_at_its_ends() {
assert_eq!(step(100, 1), Some(110));
assert_eq!(step(100, -1), Some(90));
assert_eq!(step(103, -1), Some(100));
assert_eq!(step(200, 1), None);
assert_eq!(step(50, -1), None);
}
}
+180
View File
@@ -0,0 +1,180 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyWindowHandle, App, ElementId, Entity, FocusHandle,
InteractiveElement, IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use super::WindowManager;
use crate::{
motion,
primitives::{Backdrop, FocusNext, FocusPrev, Icon, IconName, Place},
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
typography::Caption,
};
struct Switch {
selected: usize,
focus: FocusHandle,
opened: bool,
previous: Option<FocusHandle>,
}
type Close = Rc<dyn Fn(&mut Window, &mut App)>;
/// Lists managed windows over a scrim. Arrows or Tab move, Enter switches.
#[derive(IntoElement)]
pub struct WindowSwitcher {
id: ElementId,
on_close: Close,
}
impl WindowSwitcher {
pub fn new(
id: impl Into<ElementId>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
on_close: Rc::new(on_close),
}
}
}
fn step(state: &Entity<Switch>, by: isize, count: usize, cx: &mut App) {
if count == 0 {
return;
}
state.update(cx, |switch, cx| {
switch.selected = (switch.selected as isize + by).rem_euclid(count as isize) as usize;
cx.notify();
});
}
impl RenderOnce for WindowSwitcher {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let windows = WindowManager::windows(cx);
let count = windows.len();
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| Switch {
selected: 0,
focus: cx.focus_handle(),
opened: false,
previous: None,
});
if !state.read(cx).opened {
let previous = window.focused(cx);
window.focus(&state.read(cx).focus.clone());
state.update(cx, |switch, _| {
switch.opened = true;
switch.previous = previous;
});
}
if count > 0 && state.read(cx).selected >= count {
state.update(cx, |switch, _| switch.selected = count - 1);
}
let selected = state.read(cx).selected;
let close: Close = {
let (state, on_close) = (state.clone(), self.on_close);
Rc::new(move |window, cx| {
match state.read(cx).previous.clone() {
Some(previous) => {
window.focus(&previous);
log::info!("window switcher: closed, focus handed back");
}
None => log::info!("window switcher: closed, nothing was focused before"),
}
on_close(window, cx)
})
};
let theme = cx.theme();
let colors = &theme.colors;
let rows: Vec<_> = windows
.iter()
.enumerate()
.map(|(ix, (handle, title))| {
let (handle, close) = (*handle, close.clone());
div()
.id(("switch", ix))
.flex()
.items_center()
.gap_3()
.h(theme.control_height(ControlSize::Lg))
.px_3()
.rounded(theme.radius(Radius::Md))
.text_size(theme.text_size(TextSize::Base))
.when(ix == selected, |row| row.bg(colors.hover))
.cursor_pointer()
.on_click(move |_, window, cx| switch_to(handle, &close, window, cx))
.child(
Icon::new(IconName::AppWindow)
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child(title.clone())
})
.collect();
let handles: Vec<AnyWindowHandle> = windows.iter().map(|(handle, _)| *handle).collect();
let (next, prev, keys) = (state.clone(), state.clone(), state.clone());
let (close_keys, close_scrim) = (close.clone(), close);
let focus = state.read(cx).focus.clone();
let panel = div()
.id("window-switcher")
.track_focus(&focus)
.w(theme.sheet_size())
.p_2()
.flex()
.flex_col()
.gap_0p5()
.rounded(theme.radius(Radius::Xl))
.bg(colors.overlay)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Modal))
.on_action(move |_: &FocusNext, _, cx| step(&next, 1, count, cx))
.on_action(move |_: &FocusPrev, _, cx| step(&prev, -1, count, cx))
.on_key_down(move |event, window, cx| {
match event.keystroke.key.as_str() {
"down" => step(&keys, 1, count, cx),
"up" => step(&keys, -1, count, cx),
"enter" => {
if let Some(handle) = handles.get(keys.read(cx).selected) {
switch_to(*handle, &close_keys, window, cx);
}
}
"escape" => close_keys(window, cx),
_ => return,
}
cx.stop_propagation();
})
.children(rows)
.when(count == 0, |panel| {
panel.child(
div()
.p_3()
.child(Caption::new("No managed windows are open.")),
)
})
.with_animation(
"switcher-in",
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
|panel, t| panel.opacity(t).mt(motion::NUDGE * (1.0 - t)),
);
Backdrop::new(self.id)
.place(Place::Center)
.on_dismiss(move |window, cx| close_scrim(window, cx))
.child(panel)
}
}
fn switch_to(handle: AnyWindowHandle, close: &Close, window: &mut Window, cx: &mut App) {
close(window, cx);
if handle == window.window_handle() {
window.activate_window();
return;
}
if let Err(error) = WindowManager::focus(handle, cx) {
log::error!("window switcher: {error:#}");
}
}
+249
View File
@@ -0,0 +1,249 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, ElementId, EntityId, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use crate::{
buttons::IconButton,
motion,
primitives::{DragGhost, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// One window-level tab.
#[derive(Clone)]
pub struct WindowTab {
id: SharedString,
title: SharedString,
icon: Option<IconName>,
}
impl WindowTab {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
icon: None,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
}
struct TabDrag {
owner: EntityId,
from: usize,
}
type OnTab = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnAdd = Rc<dyn Fn(&mut Window, &mut App)>;
type OnReorder = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Browser-style tabs across the top of a window.
#[derive(IntoElement)]
pub struct TabBar {
id: ElementId,
tabs: Vec<WindowTab>,
selected: Option<SharedString>,
on_select: Option<OnTab>,
on_close: Option<OnTab>,
on_add: Option<OnAdd>,
on_reorder: Option<OnReorder>,
}
impl TabBar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
tabs: Vec::new(),
selected: None,
on_select: None,
on_close: None,
on_add: None,
on_reorder: None,
}
}
pub fn tab(mut self, tab: WindowTab) -> Self {
self.tabs.push(tab);
self
}
pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
self.selected = Some(id.into());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// Shows a close button on the selected and hovered tab.
pub fn on_close(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// Shows a new-tab button after the tabs.
pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_add = Some(Rc::new(handler));
self
}
/// Lets tabs drag; reports `(from, to)` indexes on drop.
pub fn on_reorder(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reorder = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TabBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let owner = window
.use_keyed_state(self.id.clone(), cx, |_, _| ())
.entity_id();
let theme = cx.theme();
let (narrow, wide) = theme.tab_width();
let enter = motion::duration(motion::BASE, cx);
let nudge = motion::NUDGE;
let tabs = self.tabs.into_iter().enumerate().map(|(ix, tab)| {
let theme = cx.theme();
let colors = &theme.colors;
let selected = self.selected.as_ref() == Some(&tab.id);
let group = SharedString::from(format!("window-tab-{}", tab.id));
let (select, close, reorder) = (
self.on_select.clone(),
self.on_close.clone(),
self.on_reorder.clone(),
);
let (id, close_id) = (tab.id.clone(), tab.id.clone());
let (title, icon) = (tab.title.clone(), tab.icon);
let (lit, edge) = (colors.hover, colors.focus);
div()
.id(tab.id.clone())
.group(group.clone())
.flex()
.flex_1()
.items_center()
.gap_2()
.min_w(narrow)
.max_w(wide)
.h(theme.control_height(ControlSize::Md))
.pl_3()
.pr_1()
.rounded(theme.radius(Radius::Md))
.border_1()
.text_size(theme.text_size(TextSize::Sm))
.map(|el| {
if selected {
el.bg(colors.surface)
.border_color(colors.border)
.text_color(colors.fg)
.font_weight(FontWeight::MEDIUM)
} else {
el.border_color(gpui::transparent_black())
.text_color(colors.fg_muted)
.hover(|style| style.bg(lit))
}
})
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("tab bar: select {id}");
if let Some(select) = &select {
select(&id, window, cx);
}
})
.when_some(reorder, |el, reorder| {
el.on_drag(TabDrag { owner, from: ix }, move |_, _, _, cx| {
DragGhost::new(title.clone(), icon, cx)
})
.drag_over::<TabDrag>(move |style, drag, _, _| {
if drag.owner == owner {
style.border_color(edge)
} else {
style
}
})
.on_drop(move |drag: &TabDrag, window, cx| {
if drag.owner == owner && drag.from != ix {
log::info!("tab bar: move {} -> {ix}", drag.from);
reorder(drag.from, ix, window, cx);
}
})
})
.when_some(tab.icon, |el, icon| {
el.child(Icon::new(icon).size(IconSize::Sm).color(if selected {
colors.fg
} else {
colors.fg_subtle
}))
})
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(tab.title.clone()),
)
.when_some(close, |el, close| {
el.child(
div()
.when(!selected, |slot| {
slot.invisible().group_hover(group, |style| style.visible())
})
.child(
IconButton::new(("close", ix), IconName::X)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
cx.stop_propagation();
log::info!("tab bar: close {close_id}");
close(&close_id, window, cx);
}),
),
)
})
.with_animation(
SharedString::from(format!("tab-in-{}", tab.id)),
Animation::new(enter).with_easing(motion::ease_out_cubic),
move |el, t| el.opacity(t).mt(nudge * (1.0 - t)),
)
});
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.w_full()
.min_w_0()
.children(tabs)
.when_some(self.on_add, |bar, add| {
bar.child(
IconButton::new("new-tab", IconName::Plus)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("tab bar: new tab");
add(window, cx)
}),
)
})
}
}
+270
View File
@@ -0,0 +1,270 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Window, WindowHandle, div, prelude::*,
};
use super::{Hosted, dialogs::progress_track, open_hosted};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
layout::ScrollArea,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{Caption, Label, Title},
};
/// Where an update stands.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum UpdateState {
Available,
/// 0 to 1.
Downloading(f32),
Ready,
}
type Action = Rc<dyn Fn(&mut Window, &mut App)>;
/// Handlers for the update flow. Unset ones hide their button.
#[derive(Clone, Default)]
struct Actions {
download: Option<Action>,
restart: Option<Action>,
later: Option<Action>,
}
fn progress_line(state: UpdateState, cx: &App) -> Option<AnyElement> {
match state {
UpdateState::Downloading(value) => Some(progress_track(Some(value), cx)),
_ => None,
}
}
fn buttons(state: UpdateState, actions: &Actions, size: ControlSize) -> Vec<Button> {
let wire = |button: Button, action: &Option<Action>| {
action.clone().map(|action| {
button
.size(size)
.on_click(move |_, window, cx| action(window, cx))
})
};
let later = wire(
Button::new("update-later", "Later").variant(ButtonVariant::Ghost),
&actions.later,
);
let main = match state {
UpdateState::Available => wire(
Button::new("update-download", "Download").primary(),
&actions.download,
),
UpdateState::Downloading(_) => None,
UpdateState::Ready => wire(
Button::new("update-restart", "Restart to update").primary(),
&actions.restart,
),
};
later.into_iter().chain(main).collect()
}
macro_rules! update_actions {
() => {
/// Starts the download while `Available`.
pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.actions.download = Some(Rc::new(handler));
self
}
/// Relaunches once `Ready`.
pub fn on_restart(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.actions.restart = Some(Rc::new(handler));
self
}
pub fn on_later(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.actions.later = Some(Rc::new(handler));
self
}
};
}
/// A slim bar that offers a new version, shows the download, then asks to restart.
#[derive(IntoElement, Clone)]
pub struct UpdateBanner {
id: ElementId,
version: SharedString,
state: UpdateState,
actions: Actions,
}
impl UpdateBanner {
pub fn new(
id: impl Into<ElementId>,
version: impl Into<SharedString>,
state: UpdateState,
) -> Self {
Self {
id: id.into(),
version: version.into(),
state,
actions: Actions::default(),
}
}
update_actions!();
}
impl RenderOnce for UpdateBanner {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let message = match self.state {
UpdateState::Available => format!("Version {} is available.", self.version),
UpdateState::Downloading(value) => {
format!("Downloading {}… {}%", self.version, (value * 100.0).round())
}
UpdateState::Ready => format!("Version {} is ready.", self.version),
};
let dismiss = self.actions.later.clone();
div()
.id(self.id)
.relative()
.flex()
.items_center()
.gap_3()
.w_full()
.py_2()
.pl_4()
.pr_2()
.bg(theme.colors.surface)
.border_b_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Sm))
.child(
Icon::new(IconName::Download)
.size(IconSize::Sm)
.color(theme.colors.fg_muted),
)
.child(div().flex_1().child(message))
.children(buttons(
self.state,
&Actions {
later: None,
..self.actions.clone()
},
ControlSize::Sm,
))
.when_some(dismiss, |banner, dismiss| {
banner.child(
IconButton::new("update-dismiss", IconName::X)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| dismiss(window, cx)),
)
})
.when_some(progress_line(self.state, cx), |banner, line| {
banner.child(div().absolute().left_0().right_0().bottom_0().child(line))
})
}
}
/// The update window: what changed, and what to do.
#[derive(IntoElement, Clone)]
pub struct UpdateDialog {
name: SharedString,
current: SharedString,
version: SharedString,
notes: Vec<SharedString>,
state: UpdateState,
actions: Actions,
}
impl UpdateDialog {
pub fn new(
name: impl Into<SharedString>,
current: impl Into<SharedString>,
version: impl Into<SharedString>,
state: UpdateState,
) -> Self {
Self {
name: name.into(),
current: current.into(),
version: version.into(),
notes: Vec::new(),
state,
actions: Actions::default(),
}
}
/// One line of release notes.
pub fn note(mut self, line: impl Into<SharedString>) -> Self {
self.notes.push(line.into());
self
}
pub fn state(mut self, state: UpdateState) -> Self {
self.state = state;
self
}
update_actions!();
pub fn open(self, cx: &mut App) -> anyhow::Result<WindowHandle<Hosted<UpdateDialog>>> {
let size = cx.theme().dialog_window();
open_hosted("Software Update", size, self, cx)
}
}
impl RenderOnce for UpdateDialog {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let headline = match self.state {
UpdateState::Ready => format!("{} {} is ready", self.name, self.version),
_ => format!("{} {} is available", self.name, self.version),
};
div()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.gap_4()
.px_8()
.pb_6()
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(Title::new(headline))
.child(Caption::new(format!("You have {}.", self.current))),
)
.child(
div()
.flex_1()
.min_h_0()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.sunken)
.child(
ScrollArea::new("update-notes").size_full().child(
div().flex().flex_col().gap_2().p_4().children(
self.notes.into_iter().map(|line| {
div()
.flex()
.gap_2()
.child(Label::new("·"))
.child(Label::new(line))
}),
),
),
),
)
.when_some(progress_line(self.state, cx), |dialog, line| {
dialog.child(line)
})
.child(div().flex().justify_end().gap_2().children(buttons(
self.state,
&self.actions,
ControlSize::Md,
)))
}
}
+267
View File
@@ -0,0 +1,267 @@
use anyhow::Context as _;
use gpui::{
AnyView, AnyWindowHandle, App, AppContext, Bounds, Context, Entity, FocusHandle, Global,
InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, SharedString, Size,
Styled, TitlebarOptions, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions, div,
point, prelude::*,
};
use super::drag_region;
use crate::{
buttons::IconButton,
primitives::{FocusScope, IconName},
theme::{ActiveTheme, ControlSize, TextSize},
};
fn titlebar(title: SharedString, cx: &App) -> TitlebarOptions {
TitlebarOptions {
title: Some(title),
appears_transparent: true,
traffic_light_position: Some(cx.theme().traffic_light_origin()),
}
}
/// A window whose content is one component. Escape closes it.
pub struct Hosted<T> {
pub content: T,
focus: FocusHandle,
}
impl<T> Hosted<T> {
pub(crate) fn new(content: T, focus: FocusHandle) -> Self {
Self { content, focus }
}
}
impl<T: IntoElement + Clone + 'static> Render for Hosted<T> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let bar = drag_region("hosted-bar", window, cx);
let theme = cx.theme();
div()
.id("hosted")
.size_full()
.on_key_down(|event, window, _| {
if event.keystroke.key == "escape" {
window.remove_window();
}
})
.child(
FocusScope::new(&self.focus)
.size_full()
.flex()
.flex_col()
.bg(theme.colors.bg)
.text_color(theme.colors.fg)
.font_family(theme.font_family.clone())
.child(bar.flex_none().h(theme.titlebar_height()))
.child(self.content.clone()),
)
}
}
/// Opens `content` alone in a fixed-size window, centered.
pub fn open_hosted<T: IntoElement + Clone + 'static>(
title: impl Into<SharedString>,
size: Size<Pixels>,
content: T,
cx: &mut App,
) -> anyhow::Result<WindowHandle<Hosted<T>>> {
let title = title.into();
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, size, cx))),
titlebar: Some(titlebar(title.clone(), cx)),
is_resizable: false,
is_minimizable: false,
..Default::default()
};
let handle = cx.open_window(options, |window, cx| {
let focus = cx.focus_handle();
window.focus(&focus);
cx.new(|_| Hosted::new(content, focus))
})?;
log::info!("window: opened {title}");
Ok(handle)
}
/// Windows opened through it, oldest first, with where each opened.
#[derive(Default)]
pub struct WindowManager {
windows: Vec<(AnyWindowHandle, SharedString, Point<Pixels>)>,
}
impl Global for WindowManager {}
impl WindowManager {
/// Opens a window one title bar below and right of the newest.
pub fn open<V: Render + 'static>(
title: impl Into<SharedString>,
size: Size<Pixels>,
cx: &mut App,
build: impl FnOnce(&mut Window, &mut App) -> Entity<V>,
) -> anyhow::Result<WindowHandle<V>> {
let title = title.into();
Self::windows(cx);
let theme = cx.theme();
let step = theme.titlebar_height().to_pixels(theme.base_rem());
let newest = cx
.default_global::<WindowManager>()
.windows
.last()
.map(|(_, _, at)| *at);
let bounds = match newest {
Some(at) => Bounds::new(at + point(step, step), size),
None => Bounds::centered(None, size, cx),
};
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(titlebar(title.clone(), cx)),
..Default::default()
};
let handle = cx.open_window(options, build)?;
cx.default_global::<WindowManager>().windows.push((
handle.into(),
title.clone(),
bounds.origin,
));
log::info!("window manager: opened {title}");
Ok(handle)
}
/// Managed windows still open, oldest first.
pub fn windows(cx: &mut App) -> Vec<(AnyWindowHandle, SharedString)> {
let open = cx.windows();
let manager = cx.default_global::<WindowManager>();
manager.windows.retain(|(handle, ..)| open.contains(handle));
manager
.windows
.iter()
.map(|(handle, title, _)| (*handle, title.clone()))
.collect()
}
pub fn focus(handle: AnyWindowHandle, cx: &mut App) -> anyhow::Result<()> {
log::info!("window manager: focus {:?}", handle.window_id());
handle.update(cx, |_, window, _| window.activate_window())
}
}
/// Screen corner for a mini window.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Corner {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
/// A small window above all others, around a host view.
pub struct MiniWindow {
title: SharedString,
view: AnyView,
back: Option<AnyWindowHandle>,
}
impl MiniWindow {
/// Pinned to a display corner or centered; expanding returns to the opener.
pub fn open(
title: impl Into<SharedString>,
size: Size<Pixels>,
corner: Option<Corner>,
view: AnyView,
cx: &mut App,
) -> anyhow::Result<WindowHandle<MiniWindow>> {
let title = title.into();
let back = cx.active_window();
let area = cx
.primary_display()
.context("a mini window needs a display")?
.bounds();
let margin = cx.theme().window_margin();
let (left, top) = (area.left() + margin, area.top() + margin * 2.0);
let (right, bottom) = (
area.right() - margin - size.width,
area.bottom() - margin - size.height,
);
let bounds = match corner {
Some(Corner::TopLeft) => Bounds::new(point(left, top), size),
Some(Corner::TopRight) => Bounds::new(point(right, top), size),
Some(Corner::BottomLeft) => Bounds::new(point(left, bottom), size),
Some(Corner::BottomRight) => Bounds::new(point(right, bottom), size),
None => Bounds::centered(None, size, cx),
};
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: None,
kind: WindowKind::PopUp,
is_resizable: false,
is_minimizable: false,
..Default::default()
};
let handle = cx.open_window(options, |_, cx| {
cx.new(|_| MiniWindow {
title: title.clone(),
view,
back,
})
})?;
log::info!("mini window: opened {title} at {corner:?}");
Ok(handle)
}
}
impl Render for MiniWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let bar = drag_region("mini-bar", window, cx);
let theme = cx.theme();
let back = self.back;
div()
.id("mini-window")
.group("mini-window")
.relative()
.size_full()
.bg(theme.colors.bg)
.text_color(theme.colors.fg)
.font_family(theme.font_family.clone())
.child(self.view.clone())
.child(
bar.absolute()
.top_0()
.left_0()
.right_0()
.flex()
.items_center()
.gap_1()
.h(theme.control_height(ControlSize::Lg))
.px_2()
.bg(theme.colors.overlay.opacity(0.92))
.border_b_1()
.border_color(theme.colors.border)
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted)
.invisible()
.group_hover("mini-window", |style| style.visible())
.child(div().flex_1().child(self.title.clone()))
.when_some(back, |bar, back| {
bar.child(
IconButton::new("mini-expand", IconName::Maximize2)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
window.remove_window();
let returned =
back.update(cx, |_, window, _| window.activate_window());
if let Err(error) = returned {
log::warn!(
"mini window: no window to return to: {error:#}"
);
}
}),
)
})
.child(
IconButton::new("mini-close", IconName::X)
.size(ControlSize::Sm)
.on_click(|_, window, _| window.remove_window()),
),
)
}
}
+66 -1
View File
@@ -1,4 +1,4 @@
use gpui::{BoxShadow, Hsla, Rems, Size, point, px, rems, size};
use gpui::{BoxShadow, Hsla, Pixels, Point, Rems, Size, point, px, rems, size};
use super::{Mode, Theme};
@@ -139,6 +139,71 @@ impl Theme {
px_to_rems(160.0)
}
pub fn titlebar_height(&self) -> Rems {
px_to_rems(38.0)
}
/// Room kept for the macOS traffic lights.
pub fn traffic_light_inset(&self) -> Rems {
px_to_rems(78.0)
}
/// Where the system traffic lights sit in a window this crate opens.
pub fn traffic_light_origin(&self) -> Point<Pixels> {
point(px(14.0), px(13.0))
}
pub fn progress_thickness(&self) -> Rems {
px_to_rems(2.0)
}
pub fn about_window(&self) -> Size<Pixels> {
size(px(360.0), px(400.0))
}
/// Update and crash windows.
pub fn dialog_window(&self) -> Size<Pixels> {
size(px(400.0), px(480.0))
}
pub fn splash_window(&self) -> Size<Pixels> {
size(px(480.0), px(300.0))
}
/// A window's rem size at 100% zoom; gpui's own default.
pub fn base_rem(&self) -> Pixels {
px(16.0)
}
/// Gap between a mini window and the screen edge.
pub fn window_margin(&self) -> Pixels {
px(24.0)
}
/// One drawn traffic light.
pub fn traffic_light(&self) -> Rems {
px_to_rems(12.0)
}
/// A Windows caption button.
pub fn caption_button_width(&self) -> Rems {
px_to_rems(46.0)
}
/// Resize margin of a client-drawn window.
pub fn window_inset(&self) -> Rems {
px_to_rems(10.0)
}
pub fn rail_width(&self) -> Rems {
px_to_rems(48.0)
}
/// Narrowest and widest window tab.
pub fn tab_width(&self) -> (Rems, Rems) {
(px_to_rems(96.0), px_to_rems(200.0))
}
/// Grab bar on a drawer.
pub fn grip(&self) -> Size<Rems> {
size(px_to_rems(36.0), px_to_rems(4.0))
+30 -30
View File
@@ -112,34 +112,34 @@
## T04 Window & Shell — `src/shell`
- [ ] TitleBar
- [ ] WindowControls — macOS, Windows, Linux
- [ ] TrafficLights → `shell::WindowControls`
- [ ] WindowDragRegion — native `window_control_area`
- [ ] WindowResizeBorder — prove: client-side decorations
- [ ] MenuBar
- [ ] NativeMenu — native `cx.set_menus()`
- [ ] Toolbar / ToolbarGroup / ToolbarSeparator
- [ ] StatusBar / StatusBarItem
- [ ] ActivityBar
- [ ] NavigationRail → `shell::ActivityBar`
- [ ] TabBar
- [ ] SplashScreen
- [ ] AboutDialog
- [ ] TrayIcon / TrayMenu — prove
- [ ] DockBadge — prove
- [ ] JumpList — native `cx.set_dock_menu()`
- [ ] SystemNotification — prove
- [ ] MultiWindow Manager
- [ ] WindowSwitcher
- [ ] QuickLauncher
- [ ] MiniWindow / CompactMode
- [ ] AlwaysOnTop — native `WindowKind::PopUp`
- [ ] PictureInPicture — host content
- [ ] UpdateBanner / UpdateDialog
- [ ] CrashReporter
- [ ] OfflineIndicator
- [ ] ZoomControl
- [x] TitleBar
- [x] WindowControls — macOS, Windows, Linux
- [x] TrafficLights → `shell::WindowControls`
- [x] WindowDragRegion — `shell::drag_region`: native drag area, Linux move, double-click zoom
- [x] WindowResizeBorder — `shell::ResizeBorder`: client decorations only; macOS and Windows resize natively
- [x] MenuBar → `menus::MenuBar` (T08): the NativeMenu definitions, drawn in the window
- [x] NativeMenu — native `cx.set_menus()`
- [x] Toolbar / ToolbarGroup / ToolbarSeparator
- [x] StatusBar / StatusBarItem
- [x] ActivityBar
- [x] NavigationRail → `shell::ActivityBar`
- [x] TabBar
- [x] SplashScreen
- [x] AboutDialog
- [x] TrayIcon / TrayMenu — macOS `NSStatusItem`; other systems get an error
- [x] DockBadge — macOS dock tile; other systems get an error
- [x] JumpList — native `cx.set_dock_menu()`
- [x] SystemNotification — blocked: an unbundled app has no notification center; gpui 0.2.2 has no API
- [x] MultiWindow Manager
- [x] WindowSwitcher
- [x] QuickLauncher → `navigation::SearchPalette` in an always-on-top window (T07)
- [x] MiniWindow / CompactMode
- [x] AlwaysOnTop — native `WindowKind::PopUp`
- [x] PictureInPicture → `shell::MiniWindow` at a screen corner; the host supplies the view
- [x] UpdateBanner / UpdateDialog
- [x] CrashReporter
- [x] OfflineIndicator
- [x] ZoomControl
## T05 Buttons & Actions — `src/buttons`
@@ -298,7 +298,7 @@
- [ ] QuickOpen
- [ ] QuickSwitcher
- [ ] SearchPalette
- [ ] SpotlightSearch → `navigation::SearchPalette`
- [ ] SpotlightSearch → `navigation::SearchPalette`; T04 QuickLauncher opens it always on top
- [ ] Tour / Onboarding
## T08 Menus — `src/menus`
@@ -311,7 +311,7 @@
- [ ] MenuItemIcon
- [ ] ContextMenu
- [ ] DropdownMenu
- [ ] MenuBar Menu → `shell::MenuBar`
- [ ] MenuBar Menu — `menus::MenuBar`, also T04 MenuBar: the native menus, drawn in the window
- [ ] OverflowMenu
- [ ] MegaMenu
- [ ] SearchableMenu