T04 window and shell: chrome, bars, rail, tabs, windows, dialogs, tray, badge, zoom
This commit is contained in:
37 files changed
+4516
-111
No files matched your search
+26
-31
@@ -5,7 +5,7 @@ use std::{
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use gpui::{App, AsyncApp, WindowHandle, px};
|
||||
use gpui::{App, AsyncApp, Window, WindowHandle, px};
|
||||
|
||||
use crate::{
|
||||
pages, script,
|
||||
@@ -39,6 +39,7 @@ async fn shoot_all(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ix, _)| only.is_none_or(|only| only == *ix));
|
||||
let gallery = window.update(cx, |_, window, _| number(window))??;
|
||||
for (ix, page) in chosen {
|
||||
for (choice, name) in [(Choice::Light, "light"), (Choice::Dark, "dark")] {
|
||||
window.update(cx, |gallery, window, cx| {
|
||||
@@ -59,7 +60,7 @@ async fn shoot_all(
|
||||
format!("-{segment}")
|
||||
};
|
||||
let path = dir.join(format!("{}-{name}{suffix}.png", page.slug));
|
||||
snapshot(&path)?;
|
||||
snapshot(gallery, &path)?;
|
||||
log::info!("capture: wrote {}", path.display());
|
||||
if offset >= max {
|
||||
break;
|
||||
@@ -79,39 +80,33 @@ async fn shoot_all(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This process's on-screen window, as CoreGraphics numbers it.
|
||||
/// A gpui window's number, as AppKit and CoreGraphics know it.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn window_number() -> Result<u32> {
|
||||
use core_foundation::{
|
||||
base::{CFType, TCFType},
|
||||
dictionary::CFDictionary,
|
||||
number::CFNumber,
|
||||
string::CFString,
|
||||
};
|
||||
use core_graphics::window;
|
||||
pub fn number(window: &Window) -> Result<u32> {
|
||||
use cocoa::base::id;
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
let number = |dict: &CFDictionary<CFString, CFType>, key: &'static str| {
|
||||
dict.find(CFString::from_static_string(key))
|
||||
.and_then(|value| value.downcast::<CFNumber>())
|
||||
.and_then(|value| value.to_i64())
|
||||
let handle = HasWindowHandle::window_handle(window)
|
||||
.map_err(|error| anyhow::anyhow!("window has no native handle: {error:?}"))?;
|
||||
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
|
||||
bail!("window is not an AppKit window");
|
||||
};
|
||||
let pid = i64::from(std::process::id());
|
||||
let list = window::copy_window_info(
|
||||
window::kCGWindowListOptionOnScreenOnly,
|
||||
window::kCGNullWindowID,
|
||||
)
|
||||
.context("window list unavailable")?;
|
||||
let id = list
|
||||
.iter()
|
||||
.map(|item| unsafe { CFDictionary::<CFString, CFType>::wrap_under_get_rule(*item as _) })
|
||||
.find(|dict| number(dict, "kCGWindowOwnerPID") == Some(pid))
|
||||
.and_then(|dict| number(&dict, "kCGWindowNumber"))
|
||||
.context("gallery window not on screen")?;
|
||||
u32::try_from(id).context("window number out of range")
|
||||
let number: i64 = unsafe {
|
||||
let view = appkit.ns_view.as_ptr() as id;
|
||||
let ns_window: id = msg_send![view, window];
|
||||
msg_send![ns_window, windowNumber]
|
||||
};
|
||||
u32::try_from(number).context("window number out of range")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn number(_: &Window) -> Result<u32> {
|
||||
bail!("capture needs macOS window APIs")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn snapshot(path: &Path) -> Result<()> {
|
||||
pub fn snapshot(number: u32, path: &Path) -> Result<()> {
|
||||
use core_graphics::{
|
||||
geometry::{CGPoint, CGRect, CGSize},
|
||||
window,
|
||||
@@ -120,7 +115,7 @@ pub fn snapshot(path: &Path) -> Result<()> {
|
||||
let image = window::create_image(
|
||||
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
|
||||
window::kCGWindowListOptionIncludingWindow,
|
||||
window_number()?,
|
||||
number,
|
||||
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
|
||||
)
|
||||
.context("window capture returned nothing")?;
|
||||
@@ -151,6 +146,6 @@ pub fn snapshot(path: &Path) -> Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn snapshot(_: &Path) -> Result<()> {
|
||||
pub fn snapshot(_: u32, _: &Path) -> Result<()> {
|
||||
bail!("capture needs macOS window APIs")
|
||||
}
|
||||
@@ -10,11 +10,44 @@ use std::path::PathBuf;
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use ely_gpui_component::Assets;
|
||||
use gpui::{
|
||||
App, AppContext, Application, Bounds, KeyBinding, TitlebarOptions, WindowBounds, WindowOptions,
|
||||
actions, point, px, size,
|
||||
App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, TitlebarOptions,
|
||||
WindowBounds, WindowHandle, WindowOptions, actions, point, px, size,
|
||||
};
|
||||
|
||||
actions!(gallery, [Quit]);
|
||||
actions!(gallery, [Quit, About, LightMode, DarkMode, NewWindow]);
|
||||
|
||||
/// The gallery's menu bar and Dock menu: NativeMenu and JumpList at work.
|
||||
fn menus(window: WindowHandle<shell::Gallery>, cx: &mut App) {
|
||||
cx.set_menus(vec![
|
||||
Menu {
|
||||
name: "Ely".into(),
|
||||
items: vec![
|
||||
MenuItem::action("About Ely", About),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action("Quit Ely", Quit),
|
||||
],
|
||||
},
|
||||
Menu {
|
||||
name: "View".into(),
|
||||
items: vec![
|
||||
MenuItem::action("Light", LightMode),
|
||||
MenuItem::action("Dark", DarkMode),
|
||||
],
|
||||
},
|
||||
]);
|
||||
cx.set_dock_menu(vec![MenuItem::action("New Window", NewWindow)]);
|
||||
cx.on_action(|_: &About, cx| pages::open_about(cx));
|
||||
cx.on_action(|_: &NewWindow, cx| pages::open_managed(cx));
|
||||
cx.on_action(move |_: &LightMode, cx| choose(window, shell::Choice::Light, cx));
|
||||
cx.on_action(move |_: &DarkMode, cx| choose(window, shell::Choice::Dark, cx));
|
||||
}
|
||||
|
||||
fn choose(window: WindowHandle<shell::Gallery>, choice: shell::Choice, cx: &mut App) {
|
||||
if let Err(error) = window.update(cx, |gallery, window, cx| gallery.choose(choice, window, cx))
|
||||
{
|
||||
log::error!("gallery: menu could not reach the window: {error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
struct Args {
|
||||
page: Option<usize>,
|
||||
@@ -67,6 +100,7 @@ fn main() -> Result<()> {
|
||||
cx.new(|cx| shell::Gallery::new(args.page.unwrap_or(0), window, cx))
|
||||
})
|
||||
.expect("gallery window failed to open");
|
||||
menus(window, cx);
|
||||
cx.activate(true);
|
||||
if let Some(dir) = args.capture {
|
||||
capture::run(window, dir, args.page, cx);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod buttons;
|
||||
mod layout;
|
||||
mod primitives;
|
||||
mod shell;
|
||||
mod theme;
|
||||
mod typography;
|
||||
|
||||
@@ -22,10 +23,13 @@ pub const ALL: &[Page] = &[
|
||||
primitives::PAGE,
|
||||
typography::PAGE,
|
||||
layout::PAGE,
|
||||
shell::PAGE,
|
||||
buttons::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
pub use shell::{open_about, open_managed};
|
||||
|
||||
pub fn find(slug: &str) -> Option<usize> {
|
||||
ALL.iter().position(|page| page.slug == slug)
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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.",
|
||||
))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")))
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
shell::{TrayIcon, TrayItem, dock_badge, set_dock_badge},
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Window};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{blocked, row, section},
|
||||
};
|
||||
|
||||
const ITEMS: [&str; 3] = ["Open gallery", "Toggle theme", "Quit"];
|
||||
|
||||
pub fn tray(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let tray = window.use_keyed_state("tray", cx, |_, _| None::<TrayIcon>);
|
||||
let last = window.use_keyed_state("tray-last", cx, |_, _| SharedString::from("nothing yet"));
|
||||
let shown = tray.read(cx).is_some();
|
||||
let note = last.read(cx).clone();
|
||||
let (toggle, pick) = (tray.clone(), tray);
|
||||
section(
|
||||
"TrayIcon / TrayMenu",
|
||||
"An SF Symbol in the menu bar with a menu. macOS only.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(probe(
|
||||
"tray-show",
|
||||
Button::new(
|
||||
"tray-show",
|
||||
if shown {
|
||||
"Remove tray icon"
|
||||
} else {
|
||||
"Show tray icon"
|
||||
},
|
||||
)
|
||||
.variant(ButtonVariant::Outline)
|
||||
.on_click(move |_, _, cx| {
|
||||
let last = last.clone();
|
||||
let next = match toggle.read(cx) {
|
||||
Some(_) => None,
|
||||
None => {
|
||||
let items = vec![
|
||||
TrayItem::Action(ITEMS[0].into()),
|
||||
TrayItem::Action(ITEMS[1].into()),
|
||||
TrayItem::Separator,
|
||||
TrayItem::Action(ITEMS[2].into()),
|
||||
];
|
||||
let made = TrayIcon::new(
|
||||
"circle.hexagongrid",
|
||||
items,
|
||||
move |index, cx| {
|
||||
let label = match index {
|
||||
0 => ITEMS[0],
|
||||
1 => ITEMS[1],
|
||||
3 => ITEMS[2],
|
||||
other => panic!("tray has no action at {other}"),
|
||||
};
|
||||
last.update(cx, |last, cx| {
|
||||
*last = label.into();
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
cx,
|
||||
);
|
||||
match made {
|
||||
Ok(icon) => Some(icon),
|
||||
Err(error) => {
|
||||
log::error!("gallery: tray failed: {error:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
toggle.update(cx, |tray, cx| {
|
||||
*tray = next;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
))
|
||||
.child(probe(
|
||||
"tray-pick",
|
||||
Button::new("tray-pick", "Pick “Open gallery”")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.disabled(!shown)
|
||||
.on_click(move |_, _, cx| {
|
||||
if let Some(icon) = pick.read(cx) {
|
||||
icon.pick(0);
|
||||
}
|
||||
}),
|
||||
))
|
||||
.child(Caption::new(format!("Last pick: {note}"))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn dock(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let reading = match dock_badge() {
|
||||
Ok(Some(label)) => format!("The Dock shows “{label}”."),
|
||||
Ok(None) => "The Dock shows no badge.".to_string(),
|
||||
Err(error) => format!("{error:#}"),
|
||||
};
|
||||
let badge = |label: Option<&'static str>| {
|
||||
move |_: &gpui::ClickEvent, window: &mut Window, _: &mut App| {
|
||||
if let Err(error) = set_dock_badge(label) {
|
||||
log::error!("gallery: badge failed: {error:#}");
|
||||
}
|
||||
window.refresh();
|
||||
}
|
||||
};
|
||||
section(
|
||||
"DockBadge",
|
||||
"A count on the app's Dock icon. macOS only.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(probe(
|
||||
"badge-set",
|
||||
Button::new("badge-set", "Badge 3")
|
||||
.variant(ButtonVariant::Outline)
|
||||
.on_click(badge(Some("3"))),
|
||||
))
|
||||
.child(probe(
|
||||
"badge-clear",
|
||||
Button::new("badge-clear", "Clear")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(badge(None)),
|
||||
))
|
||||
.child(Caption::new(reading)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn notification(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"SystemNotification",
|
||||
"Notices from the system's own center.",
|
||||
cx,
|
||||
)
|
||||
.child(blocked(
|
||||
"Blocked: an unbundled app has no notification center, and gpui 0.2.2 has no notification API.",
|
||||
cx,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
primitives::{IconName, Image},
|
||||
shell::{
|
||||
AboutDialog, Corner, MiniWindow, SplashScreen, TitleBar, WindowManager, WindowSwitcher,
|
||||
},
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
typography::{Caption, Label, Title},
|
||||
};
|
||||
use gpui::{
|
||||
App, AppContext, Context, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled,
|
||||
Window, div, prelude::*, px, size,
|
||||
};
|
||||
|
||||
use super::chrome::window_frame;
|
||||
use crate::{
|
||||
probe::{Opened, probe},
|
||||
ui::{code, row, section, specimen},
|
||||
};
|
||||
|
||||
const DUNES: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/dunes.jpg"
|
||||
);
|
||||
const ATRIUM: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/atrium.jpg"
|
||||
);
|
||||
|
||||
fn about() -> AboutDialog {
|
||||
AboutDialog::new("Ely", "0.1.0 (2026.09)")
|
||||
.description("A component library for GPUI, in light and dark.")
|
||||
.link(
|
||||
"Website",
|
||||
"https://github.com/ZacharyZhang-NY/Ely-GPUI-Components",
|
||||
)
|
||||
.link(
|
||||
"License",
|
||||
"https://github.com/ZacharyZhang-NY/Ely-GPUI-Components#license",
|
||||
)
|
||||
.copyright("© 2026 Ely contributors")
|
||||
}
|
||||
|
||||
fn splash() -> SplashScreen {
|
||||
SplashScreen::new("Ely")
|
||||
.tagline("Components for GPUI")
|
||||
.status("Loading workspace…")
|
||||
}
|
||||
|
||||
/// Opens a window and records it for scripts, or logs why it failed.
|
||||
fn opened<V: 'static>(
|
||||
key: &'static str,
|
||||
result: anyhow::Result<gpui::WindowHandle<V>>,
|
||||
cx: &mut App,
|
||||
) {
|
||||
match result {
|
||||
Ok(handle) => Opened::insert(key, handle.into(), cx),
|
||||
Err(error) => log::error!("gallery: {key} window failed: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger(
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
open: impl Fn(&mut App) + 'static,
|
||||
) -> impl IntoElement {
|
||||
probe(
|
||||
key,
|
||||
Button::new(key, label)
|
||||
.variant(ButtonVariant::Outline)
|
||||
.icon(IconName::AppWindow)
|
||||
.on_click(move |_, _, cx| open(cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn splash_screen(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"SplashScreen",
|
||||
"Mark and name rise in; the bar reports progress or sweeps.",
|
||||
cx,
|
||||
)
|
||||
.child(specimen(
|
||||
"Inline, at 62%",
|
||||
window_frame(480.0, 280.0, cx)
|
||||
.bg(cx.theme().colors.bg)
|
||||
.child(splash().progress(Some(0.62))),
|
||||
cx,
|
||||
))
|
||||
.child(trigger("splash-open", "Show splash window", |cx| {
|
||||
let result = splash().open(cx);
|
||||
opened("splash", result, cx)
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn open_about(cx: &mut App) {
|
||||
let result = about().open(cx);
|
||||
opened("about", result, cx)
|
||||
}
|
||||
|
||||
pub fn about_dialog(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"AboutDialog",
|
||||
"Name, version to copy, links, fine print.",
|
||||
cx,
|
||||
)
|
||||
.child(specimen(
|
||||
"Inline",
|
||||
window_frame(360.0, 360.0, cx)
|
||||
.bg(cx.theme().colors.bg)
|
||||
.pt_6()
|
||||
.child(about()),
|
||||
cx,
|
||||
))
|
||||
.child(trigger("about-open", "Open About window", open_about))
|
||||
}
|
||||
|
||||
struct Managed {
|
||||
title: SharedString,
|
||||
asked: bool,
|
||||
}
|
||||
|
||||
impl Render for Managed {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let draft = cx.entity().downgrade();
|
||||
let theme = cx.theme();
|
||||
let asked = self.asked;
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.bg(theme.colors.bg)
|
||||
.text_color(theme.colors.fg)
|
||||
.font_family(theme.font_family.clone())
|
||||
.child({
|
||||
let bar = TitleBar::new("draft-bar").title(self.title.clone());
|
||||
if asked {
|
||||
bar
|
||||
} else {
|
||||
bar.on_close(move |_, cx| {
|
||||
let asked = draft.update(cx, |draft, cx| {
|
||||
draft.asked = true;
|
||||
cx.notify();
|
||||
});
|
||||
if let Err(error) = asked {
|
||||
log::error!("gallery: draft gone before close: {error:#}");
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.child(Title::new(self.title.clone()))
|
||||
.child(Caption::new(if asked {
|
||||
"The title bar caught the close. The next one goes through."
|
||||
} else {
|
||||
"Opened by WindowManager. Its close button asks first."
|
||||
}))
|
||||
.when(asked, |body| {
|
||||
body.child(
|
||||
Button::new("draft-close", "Close anyway")
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(|_, window, _| window.remove_window()),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the next numbered draft window.
|
||||
pub fn open_managed(cx: &mut App) {
|
||||
let number = WindowManager::windows(cx).len() + 1;
|
||||
let title = SharedString::from(format!("Draft {number}"));
|
||||
let result = WindowManager::open(title.clone(), size(px(420.0), px(280.0)), cx, |_, cx| {
|
||||
cx.new(|_| Managed {
|
||||
title,
|
||||
asked: false,
|
||||
})
|
||||
});
|
||||
opened("managed", result, cx)
|
||||
}
|
||||
|
||||
pub fn window_manager(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let open = WindowManager::windows(cx);
|
||||
section(
|
||||
"MultiWindow Manager",
|
||||
"Each new window cascades from the last; the manager knows them by name.",
|
||||
cx,
|
||||
)
|
||||
.child(trigger("managed-open", "New window", open_managed))
|
||||
.child(
|
||||
row().children(open.into_iter().enumerate().map(|(ix, (handle, title))| {
|
||||
Button::new(("managed-focus", ix), title)
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Subtle)
|
||||
.on_click(move |_, _, cx| {
|
||||
if let Err(error) = WindowManager::focus(handle, cx) {
|
||||
log::error!("gallery: focus failed: {error:#}");
|
||||
}
|
||||
})
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn window_switcher(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let shown = window.use_keyed_state("switcher-shown", cx, |_, _| false);
|
||||
let open = *shown.read(cx);
|
||||
let (show, hide) = (shown.clone(), shown);
|
||||
section(
|
||||
"WindowSwitcher",
|
||||
"Managed windows over a scrim. Arrows or Tab move, Enter goes.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"switcher-open",
|
||||
Button::new("switcher-open", "Switch windows")
|
||||
.variant(ButtonVariant::Outline)
|
||||
.on_click(move |_, _, cx| {
|
||||
show.update(cx, |shown, cx| {
|
||||
*shown = true;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
))
|
||||
.when(open, |block| {
|
||||
block.child(WindowSwitcher::new("switcher", move |_, cx| {
|
||||
hide.update(cx, |shown, cx| {
|
||||
*shown = false;
|
||||
cx.notify();
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn quick_launcher(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"QuickLauncher",
|
||||
"A search palette in a window above all others.",
|
||||
cx,
|
||||
)
|
||||
.child(Caption::new(
|
||||
"It needs text input and the palette, so it lands with Navigation, chapter 7.",
|
||||
))
|
||||
}
|
||||
|
||||
struct Player {
|
||||
playing: bool,
|
||||
}
|
||||
|
||||
impl Render for Player {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let playing = self.playing;
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.child(
|
||||
div()
|
||||
.size(theme.control_height(ControlSize::Lg) * 2.0)
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
Image::new("player-art", Path::new(DUNES))
|
||||
.fit(ObjectFit::Cover)
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(Label::new("Quiet Hours"))
|
||||
.child(Caption::new("Low Tide · 2:41")),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(
|
||||
"player-toggle",
|
||||
if playing {
|
||||
IconName::Pause
|
||||
} else {
|
||||
IconName::Play
|
||||
},
|
||||
)
|
||||
.on_click(cx.listener(|player, _, _, cx| {
|
||||
player.playing = !player.playing;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct Picture;
|
||||
|
||||
impl Render for Picture {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
Image::new("pip-frame", Path::new(ATRIUM))
|
||||
.fit(ObjectFit::Cover)
|
||||
.size_full(),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_3()
|
||||
.bottom_3()
|
||||
.px_2()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(theme.colors.tooltip_bg.opacity(0.85))
|
||||
.text_color(theme.colors.tooltip_fg)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.child("12:04 / 48:10"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mini_windows(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"MiniWindow / CompactMode / PictureInPicture",
|
||||
"Small windows above the rest. Hover for the bar; expand returns to the app.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(trigger("mini-open", "Mini player", |cx| {
|
||||
let view = cx.new(|_| Player { playing: true });
|
||||
let result = MiniWindow::open(
|
||||
"Now playing",
|
||||
size(px(320.0), px(96.0)),
|
||||
Some(Corner::BottomRight),
|
||||
view.into(),
|
||||
cx,
|
||||
);
|
||||
opened("mini", result, cx)
|
||||
}))
|
||||
.child(trigger("pip-open", "Picture in picture", |cx| {
|
||||
let view = cx.new(|_| Picture);
|
||||
let result = MiniWindow::open(
|
||||
"Picture in picture",
|
||||
size(px(384.0), px(216.0)),
|
||||
Some(Corner::TopRight),
|
||||
view.into(),
|
||||
cx,
|
||||
);
|
||||
opened("pip", result, cx)
|
||||
})),
|
||||
)
|
||||
.child(code(
|
||||
"AlwaysOnTop is gpui's WindowOptions { kind: WindowKind::PopUp, .. }",
|
||||
cx,
|
||||
))
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ely_gpui_component::primitives::Measure;
|
||||
use gpui::{App, Bounds, Div, Global, IntoElement, ParentElement, Pixels, Styled, div};
|
||||
use gpui::{
|
||||
AnyWindowHandle, App, Bounds, Div, Global, IntoElement, ParentElement, Pixels, Styled, div,
|
||||
};
|
||||
|
||||
/// Window bounds of named demo elements, for scripted capture.
|
||||
#[derive(Default)]
|
||||
@@ -25,3 +27,24 @@ pub fn probe(key: &'static str, element: impl IntoElement) -> Div {
|
||||
.child(element),
|
||||
)
|
||||
}
|
||||
|
||||
/// Windows the demos opened, by key, for scripted capture.
|
||||
#[derive(Default)]
|
||||
pub struct Opened(HashMap<&'static str, AnyWindowHandle>);
|
||||
|
||||
impl Global for Opened {}
|
||||
|
||||
impl Opened {
|
||||
pub fn insert(key: &'static str, handle: AnyWindowHandle, cx: &mut App) {
|
||||
cx.default_global::<Opened>().0.insert(key, handle);
|
||||
}
|
||||
|
||||
pub fn take(key: &str, cx: &mut App) -> Option<AnyWindowHandle> {
|
||||
cx.default_global::<Opened>().0.remove(key)
|
||||
}
|
||||
|
||||
pub fn get(key: &str, cx: &App) -> Option<AnyWindowHandle> {
|
||||
cx.try_global::<Opened>()
|
||||
.and_then(|opened| opened.0.get(key).copied())
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ use std::{path::PathBuf, time::Duration};
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AsyncApp, Bounds, Keystroke, Pixels, Point, WindowHandle, point, px};
|
||||
|
||||
use crate::{capture::snapshot, probe::Probes, shell::Gallery};
|
||||
use crate::{
|
||||
capture::{number, snapshot},
|
||||
probe::{Opened, Probes},
|
||||
shell::Gallery,
|
||||
};
|
||||
|
||||
/// One scripted input, aimed at a probe by key.
|
||||
pub enum Step {
|
||||
@@ -22,6 +26,15 @@ pub enum Step {
|
||||
Key(&'static str),
|
||||
Wait(u64),
|
||||
Shot(&'static str),
|
||||
/// Photographs a window a demo opened, by its key.
|
||||
ShotWindow(&'static str, &'static str),
|
||||
CloseWindow(&'static str),
|
||||
/// Sends a keystroke to a window a demo opened.
|
||||
KeyWindow(&'static str, &'static str),
|
||||
/// Asks AppKit to close a window, as its close button would.
|
||||
CloseNative(&'static str),
|
||||
/// Fails unless the window is gone.
|
||||
ExpectClosed(&'static str),
|
||||
}
|
||||
|
||||
const FRAME: Duration = Duration::from_millis(120);
|
||||
@@ -40,6 +53,11 @@ pub fn park(window: WindowHandle<Gallery>, cx: &mut AsyncApp) -> Result<()> {
|
||||
send(window, Mouse::Move, point(px(24.0), px(720.0)), cx)
|
||||
}
|
||||
|
||||
fn opened(key: &str, cx: &mut AsyncApp) -> Result<gpui::AnyWindowHandle> {
|
||||
cx.update(|cx| Opened::get(key, cx))?
|
||||
.with_context(|| format!("no window opened as {key}"))
|
||||
}
|
||||
|
||||
pub async fn play(
|
||||
window: WindowHandle<Gallery>,
|
||||
steps: &[Step],
|
||||
@@ -114,9 +132,38 @@ pub async fn play(
|
||||
}
|
||||
Step::Shot(name) => {
|
||||
let file = path(name);
|
||||
snapshot(&file)?;
|
||||
snapshot(window.update(cx, |_, window, _| number(window))??, &file)?;
|
||||
log::info!("script: wrote {}", file.display());
|
||||
}
|
||||
Step::ShotWindow(key, name) => {
|
||||
let file = path(name);
|
||||
let handle = opened(key, cx)?;
|
||||
snapshot(handle.update(cx, |_, window, _| number(window))??, &file)?;
|
||||
log::info!("script: wrote {}", file.display());
|
||||
}
|
||||
Step::KeyWindow(key, stroke) => {
|
||||
let stroke =
|
||||
Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?;
|
||||
opened(key, cx)?.update(cx, |_, window, cx| {
|
||||
window.dispatch_keystroke(stroke, cx);
|
||||
})?;
|
||||
}
|
||||
Step::CloseNative(key) => {
|
||||
let number = opened(key, cx)?.update(cx, |_, window, _| number(window))??;
|
||||
perform_close(number)?;
|
||||
}
|
||||
Step::ExpectClosed(key) => {
|
||||
let handle = opened(key, cx)?;
|
||||
let open = cx.update(|cx| cx.windows().contains(&handle))?;
|
||||
anyhow::ensure!(!open, "window {key} is still open");
|
||||
cx.update(|cx| Opened::take(key, cx))?;
|
||||
log::info!("script: {key} closed");
|
||||
}
|
||||
Step::CloseWindow(key) => {
|
||||
let handle = opened(key, cx)?;
|
||||
handle.update(cx, |_, window, _| window.remove_window())?;
|
||||
cx.update(|cx| Opened::take(key, cx))?;
|
||||
}
|
||||
}
|
||||
cx.background_executor().timer(FRAME).await;
|
||||
}
|
||||
@@ -129,19 +176,20 @@ fn send(
|
||||
at: Point<Pixels>,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
let height = window.update(cx, |_, window, _| window.viewport_size().height)?;
|
||||
post(kind, f64::from(at.x), f64::from(height - at.y))
|
||||
let (height, number) = window.update(cx, |_, window, _| {
|
||||
(window.viewport_size().height, number(window))
|
||||
})?;
|
||||
post(kind, f64::from(at.x), f64::from(height - at.y), number?)
|
||||
}
|
||||
|
||||
/// Queues a real mouse event on this app. No permission needed.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn post(kind: Mouse, x: f64, y: f64) -> Result<()> {
|
||||
fn post(kind: Mouse, x: f64, y: f64, number: u32) -> Result<()> {
|
||||
use cocoa::{
|
||||
appkit::{NSApp, NSApplication, NSEvent, NSEventModifierFlags, NSEventType},
|
||||
base::{NO, id, nil},
|
||||
foundation::NSPoint,
|
||||
};
|
||||
let number = crate::capture::window_number()?;
|
||||
let kind = match kind {
|
||||
Mouse::Move => NSEventType::NSMouseMoved,
|
||||
Mouse::Down => NSEventType::NSLeftMouseDown,
|
||||
@@ -167,8 +215,29 @@ fn post(kind: Mouse, x: f64, y: f64) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs `performClose:` on a window, which asks before closing.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn perform_close(number: u32) -> Result<()> {
|
||||
use cocoa::{
|
||||
appkit::NSApp,
|
||||
base::{id, nil},
|
||||
};
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
unsafe {
|
||||
let window: id = msg_send![NSApp(), windowWithWindowNumber: i64::from(number)];
|
||||
anyhow::ensure!(window != nil, "no AppKit window numbered {number}");
|
||||
let _: () = msg_send![window, performClose: nil];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn post(_: Mouse, _: f64, _: f64) -> Result<()> {
|
||||
fn perform_close(_: u32) -> Result<()> {
|
||||
anyhow::bail!("native close needs macOS")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn post(_: Mouse, _: f64, _: f64, _: u32) -> Result<()> {
|
||||
anyhow::bail!("scripted mouse input needs macOS")
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user