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