diff --git a/AGENTS.md b/AGENTS.md index 6de1f03..e4808ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` starts on a page. -- `cargo run --example gallery -- --capture ` writes PNGs of every page, light and dark, top to bottom, then each page's scripted states. macOS only. +- `cargo run --example gallery -- --capture ` 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 ...` 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. diff --git a/Cargo.lock b/Cargo.lock index b5981b1..f6bd8e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,6 +1528,8 @@ dependencies = [ "image", "jiff", "log", + "objc", + "raw-window-handle", "regex", "rust-embed", "serde", diff --git a/Cargo.toml b/Cargo.toml index eab184d..015d5f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/TASKS.md b/TASKS.md index 470701c..b45f7cf 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/assets/icons/app-window.svg b/assets/icons/app-window.svg new file mode 100644 index 0000000..b28c600 --- /dev/null +++ b/assets/icons/app-window.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/picture-in-picture-2.svg b/assets/icons/picture-in-picture-2.svg new file mode 100644 index 0000000..3bac41c --- /dev/null +++ b/assets/icons/picture-in-picture-2.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/examples/gallery/capture.rs b/examples/gallery/capture.rs index 9d85da1..194ac71 100644 --- a/examples/gallery/capture.rs +++ b/examples/gallery/capture.rs @@ -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 { - use core_foundation::{ - base::{CFType, TCFType}, - dictionary::CFDictionary, - number::CFNumber, - string::CFString, - }; - use core_graphics::window; +pub fn number(window: &Window) -> Result { + use cocoa::base::id; + use objc::{msg_send, sel, sel_impl}; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; - let number = |dict: &CFDictionary, key: &'static str| { - dict.find(CFString::from_static_string(key)) - .and_then(|value| value.downcast::()) - .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::::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 { + 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") } diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index 89d0a3f..922fda7 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -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, 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, 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, @@ -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); diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index bb10b60..3aaff5b 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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 { ALL.iter().position(|page| page.slug == slug) } diff --git a/examples/gallery/pages/shell/bars.rs b/examples/gallery/pages/shell/bars.rs new file mode 100644 index 0000000..82ffed7 --- /dev/null +++ b/examples/gallery/pages/shell/bars.rs @@ -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)), + ) +} diff --git a/examples/gallery/pages/shell/chrome.rs b/examples/gallery/pages/shell/chrome.rs new file mode 100644 index 0000000..c7386bb --- /dev/null +++ b/examples/gallery/pages/shell/chrome.rs @@ -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.", + )) +} diff --git a/examples/gallery/pages/shell/mod.rs b/examples/gallery/pages/shell/mod.rs new file mode 100644 index 0000000..89ea5b2 --- /dev/null +++ b/examples/gallery/pages/shell/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/shell/status.rs b/examples/gallery/pages/shell/status.rs new file mode 100644 index 0000000..8e44919 --- /dev/null +++ b/examples/gallery/pages/shell/status.rs @@ -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"))) +} diff --git a/examples/gallery/pages/shell/system.rs b/examples/gallery/pages/shell/system.rs new file mode 100644 index 0000000..fc0fb36 --- /dev/null +++ b/examples/gallery/pages/shell/system.rs @@ -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::); + 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, + )) +} diff --git a/examples/gallery/pages/shell/windows.rs b/examples/gallery/pages/shell/windows.rs new file mode 100644 index 0000000..7c301bb --- /dev/null +++ b/examples/gallery/pages/shell/windows.rs @@ -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( + key: &'static str, + result: anyhow::Result>, + 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) -> 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) -> 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) -> 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, + )) +} diff --git a/examples/gallery/probe.rs b/examples/gallery/probe.rs index dadeb7c..373278a 100644 --- a/examples/gallery/probe.rs +++ b/examples/gallery/probe.rs @@ -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::().0.insert(key, handle); + } + + pub fn take(key: &str, cx: &mut App) -> Option { + cx.default_global::().0.remove(key) + } + + pub fn get(key: &str, cx: &App) -> Option { + cx.try_global::() + .and_then(|opened| opened.0.get(key).copied()) + } +} diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index c72b3b0..8760a92 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -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, cx: &mut AsyncApp) -> Result<()> { send(window, Mouse::Move, point(px(24.0), px(720.0)), cx) } +fn opened(key: &str, cx: &mut AsyncApp) -> Result { + cx.update(|cx| Opened::get(key, cx))? + .with_context(|| format!("no window opened as {key}")) +} + pub async fn play( window: WindowHandle, 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, 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") } diff --git a/src/layout/dock/view.rs b/src/layout/dock/view.rs index ebb4ad9..9e840e3 100644 --- a/src/layout/dock/view.rs +++ b/src/layout/dock/view.rs @@ -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) -> 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() diff --git a/src/lib.rs b/src/lib.rs index ab5f80f..cf4450b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/primitives/ghost.rs b/src/primitives/ghost.rs new file mode 100644 index 0000000..ba6f076 --- /dev/null +++ b/src/primitives/ghost.rs @@ -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, +} + +impl DragGhost { + pub fn new(title: SharedString, icon: Option, cx: &mut App) -> Entity { + cx.new(|_| Self { title, icon }) + } +} + +impl Render for DragGhost { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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()) + } +} diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 5d1ff72..15f6dce 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -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, + hover: Option<(SharedString, Hsla)>, rotation: Option, } @@ -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, + color: impl Into, + ) -> Self { + self.hover = Some((group.into(), color.into())); + self + } + pub fn rotate(mut self, angle: impl Into) -> 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)) }) diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 9b1a930..b54e395 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -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}; diff --git a/src/shell/activity.rs b/src/shell/activity.rs new file mode 100644 index 0000000..94666bc --- /dev/null +++ b/src/shell/activity.rs @@ -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, +} + +impl ActivityItem { + pub fn new( + id: impl Into, + icon: IconName, + label: impl Into, + ) -> 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; + +/// Icon rail at a window's edge. Main items top, footer items bottom. +#[derive(IntoElement)] +pub struct ActivityBar { + id: ElementId, + items: Vec, + footer: Vec, + selected: Option, + on_select: Option, +} + +impl ActivityBar { + pub fn new(id: impl Into) -> 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) -> 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)))) + } +} diff --git a/src/shell/bars.rs b/src/shell/bars.rs new file mode 100644 index 0000000..33cce00 --- /dev/null +++ b/src/shell/bars.rs @@ -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) { + 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) { + 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; + +/// One status: an icon, a label, or both. Clickable when given a handler. +#[derive(IntoElement)] +pub struct StatusBarItem { + id: ElementId, + icon: Option, + label: Option, + tooltip: Option, + on_click: Option, +} + +impl StatusBarItem { + pub fn new(id: impl Into) -> 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) -> Self { + self.label = Some(label.into()); + self + } + + pub fn tooltip(mut self, tooltip: impl Into) -> 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) + }) + } +} diff --git a/src/shell/chrome.rs b/src/shell/chrome.rs new file mode 100644 index 0000000..6f2e22f --- /dev/null +++ b/src/shell/chrome.rs @@ -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, window: &mut Window, cx: &mut App) -> Stateful
{ + 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; + +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, +} + +impl WindowControls { + pub fn new(id: impl Into) -> 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, + 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, + 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, + 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, + registered: bool, +} + +/// Sends system closes to `handler` while the bar lives and holds one. +fn route_close(id: &ElementId, handler: Option, 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, + style: Option, + on_close: Option, + 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) -> 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) -> 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) { + 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) + } +} diff --git a/src/shell/crash.rs b/src/shell/crash.rs new file mode 100644 index 0000000..5793410 --- /dev/null +++ b/src/shell/crash.rs @@ -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; + +/// 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, + on_dismiss: Option, +} + +impl CrashReporter { + pub fn new( + name: impl Into, + summary: impl Into, + report: impl Into, + ) -> 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>> { + 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.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, + )), + ) + } +} diff --git a/src/shell/dialogs.rs b/src/shell/dialogs.rs new file mode 100644 index 0000000..4e3adde --- /dev/null +++ b/src/shell/dialogs.rs @@ -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, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let initial: SharedString = name.chars().take(1).collect::().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, 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, + description: Option, + links: Vec<(SharedString, SharedString)>, + copyright: Option, +} + +impl AboutDialog { + pub fn new(name: impl Into, version: impl Into) -> 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) -> Self { + self.description = Some(text.into()); + self + } + + pub fn link(mut self, label: impl Into, url: impl Into) -> Self { + self.links.push((label.into(), url.into())); + self + } + + pub fn copyright(mut self, text: impl Into) -> 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>> { + 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, + tagline: Option, + status: Option, + progress: Option, +} + +impl SplashScreen { + pub fn new(name: impl Into) -> 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) -> Self { + self.tagline = Some(text.into()); + self + } + + pub fn status(mut self, text: impl Into) -> Self { + self.status = Some(text.into()); + self + } + + /// 0 to 1; `None` shows a moving bar. + pub fn progress(mut self, progress: Option) -> 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>> { + 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()), + ), + ) + } +} diff --git a/src/shell/frame.rs b/src/shell/frame.rs new file mode 100644 index 0000000..046dd62 --- /dev/null +++ b/src/shell/frame.rs @@ -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, inset: Pixels, size: Size) -> Option { + 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) { + 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); + } +} diff --git a/src/shell/mod.rs b/src/shell/mod.rs new file mode 100644 index 0000000..75c3d95 --- /dev/null +++ b/src/shell/mod.rs @@ -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}; diff --git a/src/shell/native.rs b/src/shell/native.rs new file mode 100644 index 0000000..c787e96 --- /dev/null +++ b/src/shell/native.rs @@ -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; + +/// 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, + on_pick: impl Fn(usize, &mut App) + 'static, + cx: &mut App, + ) -> anyhow::Result { + 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> { + #[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, + } + + impl Tray { + pub fn new( + symbol: &str, + items: &[TrayItem], + on_pick: OnPick, + cx: &mut App, + ) -> anyhow::Result { + 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 { + unsafe { + let label: id = msg_send![tile(), badgeLabel]; + (label != nil).then(|| { + CStr::from_ptr(label.UTF8String()) + .to_string_lossy() + .into_owned() + }) + } + } +} diff --git a/src/shell/status.rs b/src/shell/status.rs new file mode 100644 index 0000000..1dfdd55 --- /dev/null +++ b/src/shell/status.rs @@ -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, + generation: u64, + _hide: Option>, +} + +/// 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, 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 { + 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) -> 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); + } +} diff --git a/src/shell/switcher.rs b/src/shell/switcher.rs new file mode 100644 index 0000000..ac8683c --- /dev/null +++ b/src/shell/switcher.rs @@ -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, +} + +type Close = Rc; + +/// 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, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + on_close: Rc::new(on_close), + } + } +} + +fn step(state: &Entity, 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 = 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:#}"); + } +} diff --git a/src/shell/tabs.rs b/src/shell/tabs.rs new file mode 100644 index 0000000..6aab688 --- /dev/null +++ b/src/shell/tabs.rs @@ -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, +} + +impl WindowTab { + pub fn new(id: impl Into, title: impl Into) -> 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; +type OnAdd = Rc; +type OnReorder = Rc; + +/// Browser-style tabs across the top of a window. +#[derive(IntoElement)] +pub struct TabBar { + id: ElementId, + tabs: Vec, + selected: Option, + on_select: Option, + on_close: Option, + on_add: Option, + on_reorder: Option, +} + +impl TabBar { + pub fn new(id: impl Into) -> 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) -> 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::(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) + }), + ) + }) + } +} diff --git a/src/shell/update.rs b/src/shell/update.rs new file mode 100644 index 0000000..16fe7a6 --- /dev/null +++ b/src/shell/update.rs @@ -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; + +/// Handlers for the update flow. Unset ones hide their button. +#[derive(Clone, Default)] +struct Actions { + download: Option, + restart: Option, + later: Option, +} + +fn progress_line(state: UpdateState, cx: &App) -> Option { + match state { + UpdateState::Downloading(value) => Some(progress_track(Some(value), cx)), + _ => None, + } +} + +fn buttons(state: UpdateState, actions: &Actions, size: ControlSize) -> Vec