T01 primitives: image, divider, backdrop, focus, pressable, measure, tooltip
Ely-built primitives plus gallery demos for every chapter-one entry, including gpui natives. The gallery now scripts real input by posting NSEvents to its own queue and shoots each interaction in both modes. Codex gpt-6-astra (max): three rounds; the last finding, a two-line doc comment, was fixed after the three-review cap.
This commit is contained in:
38 files changed
+2165
-157
No files matched your search
@@ -35,7 +35,11 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- `motion::duration()` honors reduced motion. Springs overshoot: use them inside animators, never as gpui easings, because gpui asserts eased values stay in `0..=1`.
|
||||
- `svg()` paints only with its own `text_color`. `Icon` always sets one.
|
||||
- Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them.
|
||||
- The focus ring is a 1px focus-colored border (`FocusRing`). gpui paints shadows under the element, so a shadow ring floods transparent elements. Focusable elements keep a 1px border, transparent at rest.
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
## Decisions
|
||||
|
||||
|
||||
Generated
+2
@@ -1519,6 +1519,7 @@ name = "ely-gpui-component"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cocoa 0.26.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics 0.24.0",
|
||||
"env_logger",
|
||||
@@ -1526,6 +1527,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"rust-embed",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -17,11 +17,13 @@ anyhow = "1"
|
||||
gpui = "0.2.2"
|
||||
log = "0.4"
|
||||
rust-embed = "8.12"
|
||||
smallvec = "1"
|
||||
|
||||
[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"] }
|
||||
|
||||
@@ -43,3 +43,4 @@ cargo run --example gallery -- --capture shots # macOS: PNG of every page, lig
|
||||
## License
|
||||
|
||||
MIT or Apache-2.0, at your option. Lucide icons: ISC. Inter and JetBrains Mono: SIL Open Font License 1.1.
|
||||
The gallery photos in `examples/gallery/assets` were generated for this project with gpt-image-2.
|
||||
@@ -17,7 +17,7 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
## Items
|
||||
|
||||
- [x] T00 Foundation: package, theme, motion, assets, Icon, Button, gallery shell, docs
|
||||
- [ ] T01 Primitives — `tasks/ch01-10.md`
|
||||
- [x] T01 Primitives — `tasks/ch01-10.md`
|
||||
- [ ] T02 Typography
|
||||
- [ ] T03 Layout
|
||||
- [ ] T04 Window & Shell
|
||||
@@ -105,3 +105,6 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T00 | 1 | FAIL | capture swallowed errors; root not focused; raw px sizes; gallery copy; tag conflict |
|
||||
| T00 | 2 | FAIL | AGENTS.md called `border_1` a rem helper |
|
||||
| T00 | 3 | PASS | |
|
||||
| T01 | 1 | FAIL | img loading needs an id; radii lost; focus loop cap; tooltip geometry literals |
|
||||
| T01 | 2 | FAIL | Cover painted outside the box once the clip was removed |
|
||||
| T01 | 3 | FAIL | two-line doc comment; fixed after the cap, verified by scan, no fourth review |
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-image-off"
|
||||
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"
|
||||
>
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
<path d="M10.41 10.41a2 2 0 1 1-2.83-2.83" />
|
||||
<line x1="13.5" x2="6" y1="13.5" y2="21" />
|
||||
<line x1="18" x2="21" y1="12" y2="15" />
|
||||
<path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59" />
|
||||
<path d="M21 15V5a2 2 0 0 0-2-2H9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 597 B |
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -8,7 +8,7 @@ use ely_gpui_component::theme::Theme;
|
||||
use gpui::{App, AsyncApp, WindowHandle, px};
|
||||
|
||||
use crate::{
|
||||
pages,
|
||||
pages, script,
|
||||
shell::{Choice, Gallery},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ async fn shoot_all(window: WindowHandle<Gallery>, dir: &Path, cx: &mut AsyncApp)
|
||||
gallery.select(ix, cx);
|
||||
gallery.choose(choice, window, cx);
|
||||
})?;
|
||||
script::park(window, cx)?;
|
||||
cx.background_executor().timer(SETTLE).await;
|
||||
let (viewport, max) = window.update(cx, |gallery, _, _| gallery.scroll_extent())?;
|
||||
if viewport <= px(0.0) {
|
||||
@@ -58,23 +59,27 @@ async fn shoot_all(window: WindowHandle<Gallery>, dir: &Path, cx: &mut AsyncApp)
|
||||
window.update(cx, |gallery, _, cx| gallery.scroll_to(offset, cx))?;
|
||||
cx.background_executor().timer(SCROLL_SETTLE).await;
|
||||
}
|
||||
if !page.script.is_empty() {
|
||||
window.update(cx, |gallery, _, cx| gallery.select(ix, cx))?;
|
||||
cx.background_executor().timer(SETTLE).await;
|
||||
let shot = |step: &str| dir.join(format!("{}-{step}-{name}.png", page.slug));
|
||||
script::play(window, page.script, shot, cx).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This process's on-screen window, as CoreGraphics numbers it.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn snapshot(path: &Path) -> Result<()> {
|
||||
pub fn window_number() -> Result<u32> {
|
||||
use core_foundation::{
|
||||
base::{CFType, TCFType},
|
||||
dictionary::CFDictionary,
|
||||
number::CFNumber,
|
||||
string::CFString,
|
||||
};
|
||||
use core_graphics::{
|
||||
geometry::{CGPoint, CGRect, CGSize},
|
||||
window,
|
||||
};
|
||||
use core_graphics::window;
|
||||
|
||||
let number = |dict: &CFDictionary<CFString, CFType>, key: &'static str| {
|
||||
dict.find(CFString::from_static_string(key))
|
||||
@@ -93,10 +98,20 @@ fn snapshot(path: &Path) -> Result<()> {
|
||||
.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")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn snapshot(path: &Path) -> Result<()> {
|
||||
use core_graphics::{
|
||||
geometry::{CGPoint, CGRect, CGSize},
|
||||
window,
|
||||
};
|
||||
|
||||
let image = window::create_image(
|
||||
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
|
||||
window::kCGWindowListOptionIncludingWindow,
|
||||
id as u32,
|
||||
window_number()?,
|
||||
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
|
||||
)
|
||||
.context("window capture returned nothing")?;
|
||||
@@ -127,6 +142,6 @@ fn snapshot(path: &Path) -> Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn snapshot(_: &Path) -> Result<()> {
|
||||
pub fn snapshot(_: &Path) -> Result<()> {
|
||||
bail!("capture needs macOS window APIs")
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod capture;
|
||||
mod pages;
|
||||
mod probe;
|
||||
mod script;
|
||||
mod shell;
|
||||
mod ui;
|
||||
|
||||
@@ -12,7 +14,7 @@ use gpui::{
|
||||
actions, point, px, size,
|
||||
};
|
||||
|
||||
actions!(gallery, [Quit, FocusNext, FocusPrev]);
|
||||
actions!(gallery, [Quit]);
|
||||
|
||||
struct Args {
|
||||
page: usize,
|
||||
@@ -47,11 +49,7 @@ fn main() -> Result<()> {
|
||||
.with_assets(Assets)
|
||||
.run(move |cx: &mut App| {
|
||||
ely_gpui_component::init(cx);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("cmd-q", Quit, None),
|
||||
KeyBinding::new("tab", FocusNext, None),
|
||||
KeyBinding::new("shift-tab", FocusPrev, None),
|
||||
]);
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
cx.on_action(|_: &Quit, cx| cx.quit());
|
||||
let bounds = Bounds::centered(None, size(px(1280.0), px(820.0)), cx);
|
||||
let options = WindowOptions {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub const PAGE: Page = Page {
|
||||
title: "Buttons & Actions",
|
||||
summary: "Seven variants, three sizes, one focus ring.",
|
||||
render,
|
||||
script: &[],
|
||||
};
|
||||
|
||||
const VARIANTS: [(ButtonVariant, &str); 7] = [
|
||||
|
||||
@@ -4,12 +4,16 @@ mod theme;
|
||||
|
||||
use gpui::{AnyElement, App, Window};
|
||||
|
||||
use crate::script::Step;
|
||||
|
||||
pub struct Page {
|
||||
pub number: u8,
|
||||
pub slug: &'static str,
|
||||
pub title: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub render: fn(&mut Window, &mut App) -> AnyElement,
|
||||
/// Interactions shot after the static pages.
|
||||
pub script: &'static [Step],
|
||||
}
|
||||
|
||||
pub const ALL: &[Page] = &[primitives::PAGE, buttons::PAGE, theme::PAGE];
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
use ely_gpui_component::{
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
use gpui::{AnyElement, App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::ui::{section, specimen, specimens};
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 1,
|
||||
slug: "primitives",
|
||||
title: "Primitives",
|
||||
summary: "The smallest parts. Icons, dividers, overlays, focus.",
|
||||
render,
|
||||
};
|
||||
|
||||
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let colors = &cx.theme().colors;
|
||||
let sizes = [
|
||||
(IconSize::Xs, "12"),
|
||||
(IconSize::Sm, "14"),
|
||||
(IconSize::Md, "16"),
|
||||
(IconSize::Lg, "20"),
|
||||
(IconSize::Xl, "24"),
|
||||
(IconSize::Xxl, "32"),
|
||||
];
|
||||
let tones = [
|
||||
(colors.fg, "fg"),
|
||||
(colors.fg_muted, "muted"),
|
||||
(colors.focus, "focus"),
|
||||
(colors.success, "success"),
|
||||
(colors.warning, "warning"),
|
||||
(colors.danger, "danger"),
|
||||
];
|
||||
|
||||
div()
|
||||
.child(
|
||||
section(
|
||||
"Icon",
|
||||
"Lucide, drawn as a mask. Color comes from the theme.",
|
||||
cx,
|
||||
)
|
||||
.child(specimens().children(
|
||||
sizes.map(|(size, name)| {
|
||||
specimen(name, Icon::new(IconName::Sparkles).size(size), cx)
|
||||
}),
|
||||
))
|
||||
.child(specimens().children(tones.map(|(color, name)| {
|
||||
specimen(name, Icon::new(IconName::CircleCheck).color(color), cx)
|
||||
}))),
|
||||
)
|
||||
.child(
|
||||
section("Icon set", "Every bundled icon.", cx).child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(IconName::ALL.iter().enumerate().map(|(ix, &name)| {
|
||||
div()
|
||||
.id(ix)
|
||||
.size_9()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_md()
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.child(Icon::new(name).color(colors.fg_muted))
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
mod native;
|
||||
mod parts;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 1,
|
||||
slug: "primitives",
|
||||
title: "Primitives",
|
||||
summary: "The smallest parts. Most are gpui itself; Ely adds the rest.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::Rest,
|
||||
Step::Hover("tip-plain"),
|
||||
Step::Wait(700),
|
||||
Step::Shot("tooltip"),
|
||||
Step::Hover("tip-meta"),
|
||||
Step::Wait(700),
|
||||
Step::Shot("tooltip-meta"),
|
||||
Step::Hover("tip-rich"),
|
||||
Step::Wait(700),
|
||||
Step::Shot("tooltip-rich"),
|
||||
Step::Hover("tip-follow"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("tooltip-follow"),
|
||||
Step::Rest,
|
||||
Step::Down("prim-press"),
|
||||
Step::Wait(700),
|
||||
Step::Shot("pressed"),
|
||||
Step::Up("prim-press"),
|
||||
Step::Rest,
|
||||
Step::Click("portal-toggle"),
|
||||
Step::Shot("portal"),
|
||||
Step::Click("portal-toggle"),
|
||||
Step::Click("backdrop-open"),
|
||||
Step::Wait(300),
|
||||
Step::Shot("backdrop"),
|
||||
Step::Click("backdrop-open"),
|
||||
Step::Click("trap-c"),
|
||||
Step::Key("tab"),
|
||||
Step::Shot("trap-wrap"),
|
||||
Step::Rest,
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(native::boxes(cx))
|
||||
.child(native::text(cx))
|
||||
.child(parts::icon(cx))
|
||||
.child(parts::icon_set(cx))
|
||||
.child(native::svg_demo(cx))
|
||||
.child(parts::image(cx))
|
||||
.child(native::canvas_demo(cx))
|
||||
.child(parts::divider(cx))
|
||||
.child(native::spacer(cx))
|
||||
.child(native::portal(window, cx))
|
||||
.child(parts::backdrop(window, cx))
|
||||
.child(native::slot(cx))
|
||||
.child(native::show(window, cx))
|
||||
.child(native::each(cx))
|
||||
.child(native::fragment(cx))
|
||||
.child(parts::visually_hidden(cx))
|
||||
.child(parts::focus_ring(cx))
|
||||
.child(parts::focus_trap(window, cx))
|
||||
.child(native::click_outside(window, cx))
|
||||
.child(native::hover_area(window, cx))
|
||||
.child(parts::pressable(window, cx))
|
||||
.child(native::keyboard(window, cx))
|
||||
.child(parts::measure(window, cx))
|
||||
.child(parts::intersection(window, cx))
|
||||
.child(native::clipboard(window, cx))
|
||||
.child(parts::tooltip(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, App, ClipboardItem, FontWeight, HighlightStyle, InteractiveElement, IntoElement,
|
||||
ParentElement, PathBuilder, SharedString, StatefulInteractiveElement, Styled, StyledText,
|
||||
Transformation, Window, anchored, canvas, deferred, div, point, prelude::*, px, radians, svg,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{code, row, section},
|
||||
};
|
||||
|
||||
pub fn boxes(cx: &App) -> impl IntoElement + use<> {
|
||||
let colors = &cx.theme().colors;
|
||||
section(
|
||||
"Box / View",
|
||||
"gpui's div() is the box. Ely adds nothing.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.size_16()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface),
|
||||
)
|
||||
.child(div().w_32().h_16().rounded_lg().bg(colors.sunken))
|
||||
.child(code("div()", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn text(cx: &App) -> impl IntoElement + use<> {
|
||||
let bold = HighlightStyle {
|
||||
font_weight: Some(FontWeight::SEMIBOLD),
|
||||
..Default::default()
|
||||
};
|
||||
section("Text", "A string is an element. StyledText adds runs.", cx).child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child("Plain text, as a child.")
|
||||
.child(StyledText::new("One accent, used once.").with_highlights([(4..10, bold)]))
|
||||
.child(code("StyledText::new(…)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn svg_demo(cx: &App) -> impl IntoElement + use<> {
|
||||
let color = cx.theme().colors.fg_muted;
|
||||
section(
|
||||
"Svg",
|
||||
"gpui's svg(). Any path from the asset source, tinted.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(
|
||||
svg()
|
||||
.path(IconName::Compass.path())
|
||||
.size_12()
|
||||
.text_color(color),
|
||||
)
|
||||
.child(
|
||||
svg()
|
||||
.path(IconName::Compass.path())
|
||||
.size_12()
|
||||
.text_color(color)
|
||||
.with_transformation(Transformation::rotate(radians(0.6))),
|
||||
)
|
||||
.child(code("svg().path(…)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn canvas_demo(cx: &App) -> impl IntoElement + use<> {
|
||||
let color = cx.theme().colors.focus;
|
||||
section("Canvas", "gpui's canvas(). Paint with paths.", cx).child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(
|
||||
div().w(px(320.0)).h(px(80.0)).child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let mut wave = PathBuilder::stroke(px(1.5));
|
||||
for step in 0..=96 {
|
||||
let t = step as f32 / 96.0;
|
||||
let x = bounds.origin.x + bounds.size.width * t;
|
||||
let lift = 0.5 - 0.38 * (t * TAU * 1.5).sin() * (1.0 - t * 0.5);
|
||||
let y = bounds.origin.y + bounds.size.height * lift;
|
||||
if step == 0 {
|
||||
wave.move_to(point(x, y));
|
||||
} else {
|
||||
wave.line_to(point(x, y));
|
||||
}
|
||||
}
|
||||
window.paint_path(wave.build().expect("wave path"), color);
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
),
|
||||
)
|
||||
.child(code("canvas(prepaint, paint)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn spacer(cx: &App) -> impl IntoElement + use<> {
|
||||
let colors = &cx.theme().colors;
|
||||
section(
|
||||
"Spacer",
|
||||
"flex_1() takes the slack. No component needed.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.w(px(420.0))
|
||||
.h_10()
|
||||
.px_3()
|
||||
.rounded(cx.theme().radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.child(Icon::new(IconName::Menu).color(colors.fg_muted))
|
||||
.child(div().flex_1())
|
||||
.child(code("div().flex_1()", cx))
|
||||
.child(div().flex_1())
|
||||
.child(Icon::new(IconName::Settings).color(colors.fg_muted)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn portal(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let open = window.use_keyed_state("prim-portal", cx, |_, _| false);
|
||||
let shown = *open.read(cx);
|
||||
let theme = cx.theme();
|
||||
let note = div()
|
||||
.w(px(260.0))
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.bg(theme.colors.overlay)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.shadow(theme.elevation(ely_gpui_component::theme::Elevation::Floating))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child("Painted last, above the clipped box.");
|
||||
section(
|
||||
"Portal",
|
||||
"deferred(anchored()) escapes clipping and paints on top.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.w(px(220.0))
|
||||
.h_16()
|
||||
.p_3()
|
||||
.overflow_hidden()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(probe(
|
||||
"portal-toggle",
|
||||
Button::new(
|
||||
"portal-toggle",
|
||||
if shown { "Hide note" } else { "Show note" },
|
||||
)
|
||||
.on_click(move |_, _, cx| {
|
||||
open.update(cx, |open, cx| {
|
||||
*open = !*open;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
))
|
||||
.when(shown, |clip| {
|
||||
clip.child(deferred(
|
||||
anchored().offset(point(px(0.0), px(8.0))).child(note),
|
||||
))
|
||||
}),
|
||||
)
|
||||
.child(code("deferred(anchored())", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
fn framed(title: &'static str, body: impl IntoElement, cx: &App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.w(px(180.0))
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(title),
|
||||
)
|
||||
.child(body)
|
||||
}
|
||||
|
||||
pub fn slot(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Slot",
|
||||
"Take impl IntoElement. The caller fills the hole.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(framed("slot: icon", Icon::new(IconName::Sparkles), cx))
|
||||
.child(framed(
|
||||
"slot: button",
|
||||
Button::new("slot-button", "Save").primary(),
|
||||
cx,
|
||||
))
|
||||
.child(code("fn framed(body: impl IntoElement)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn show(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let on = window.use_keyed_state("prim-show", cx, |_, _| true);
|
||||
let shown = *on.read(cx);
|
||||
let muted = cx.theme().colors.fg_muted;
|
||||
section(
|
||||
"Show / When",
|
||||
".when() and .when_some() branch inside a builder.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(
|
||||
Button::new("show-toggle", if shown { "Hide" } else { "Show" }).on_click(
|
||||
move |_, _, cx| {
|
||||
on.update(cx, |on, cx| {
|
||||
*on = !*on;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
.when(shown, |row| {
|
||||
row.child(div().text_color(muted).child("Here while true."))
|
||||
})
|
||||
.child(code(".when(cond, |el| …)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn each(cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let steps = ["Draft", "Review", "Ship"];
|
||||
section("For / Each", ".children() takes any iterator.", cx).child(
|
||||
row()
|
||||
.gap_6()
|
||||
.children(steps.iter().enumerate().map(|(ix, step)| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(SharedString::from(format!("{:02}", ix + 1))),
|
||||
)
|
||||
.child(*step)
|
||||
}))
|
||||
.child(code(".children(iter)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
fn pair(cx: &App) -> [AnyElement; 2] {
|
||||
let muted = cx.theme().colors.fg_muted;
|
||||
[
|
||||
Icon::new(IconName::Layers).color(muted).into_any_element(),
|
||||
div()
|
||||
.text_color(muted)
|
||||
.child("two siblings, no wrapper")
|
||||
.into_any_element(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn fragment(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Fragment",
|
||||
"Return several elements; the parent adopts them.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_2()
|
||||
.children(pair(cx))
|
||||
.child(div().w_6())
|
||||
.child(code(".children([a, b])", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn click_outside(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let open = window.use_keyed_state("prim-outside", cx, |_, _| false);
|
||||
let shown = *open.read(cx);
|
||||
let theme = cx.theme();
|
||||
let close = open.clone();
|
||||
section(
|
||||
"ClickOutside",
|
||||
"on_mouse_down_out closes what the pointer left.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(
|
||||
Button::new("outside-open", "Open panel")
|
||||
.disabled(shown)
|
||||
.on_click(move |_, _, cx| {
|
||||
open.update(cx, |open, cx| {
|
||||
*open = true;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.when(shown, |row| {
|
||||
row.child(
|
||||
div()
|
||||
.id("outside-panel")
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.sunken)
|
||||
.child("Click anywhere else.")
|
||||
.on_mouse_down_out(move |_, _, cx| {
|
||||
close.update(cx, |open, cx| {
|
||||
*open = false;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(code(".on_mouse_down_out(…)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn hover_area(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let inside = window.use_keyed_state("prim-hover", cx, |_, _| false);
|
||||
let hovered = *inside.read(cx);
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"HoverArea",
|
||||
"on_hover reports enter and leave. .hover() restyles.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.id("hover-area")
|
||||
.w(px(220.0))
|
||||
.h_16()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.hover(|style| style.bg(theme.colors.hover))
|
||||
.child(if hovered {
|
||||
"Pointer inside"
|
||||
} else {
|
||||
"Pointer outside"
|
||||
})
|
||||
.on_hover(move |hovered, _, cx| {
|
||||
inside.update(cx, |inside, cx| {
|
||||
*inside = *hovered;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(code(".on_hover(…)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn keyboard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let focus = window.use_keyed_state("prim-keys-focus", cx, |_, cx| cx.focus_handle());
|
||||
let last = window.use_keyed_state("prim-keys", cx, |_, _| SharedString::from("—"));
|
||||
let handle = focus.read(cx).clone();
|
||||
let shown = last.read(cx).clone();
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"KeyboardHandler",
|
||||
"on_key_down on a focused element. Click, then type.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.id("keys")
|
||||
.track_focus(&handle)
|
||||
.w(px(220.0))
|
||||
.h_16()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.focus_ring(cx)
|
||||
.child(shown)
|
||||
.on_key_down(move |event, _, cx| {
|
||||
let stroke = SharedString::from(event.keystroke.to_string());
|
||||
last.update(cx, |last, cx| {
|
||||
*last = stroke;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(code(".on_key_down(…)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clipboard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let pasted = window.use_keyed_state("prim-clip", cx, |_, _| None::<SharedString>);
|
||||
let shown = pasted.read(cx).clone();
|
||||
let muted = cx.theme().colors.fg_muted;
|
||||
section(
|
||||
"Clipboard",
|
||||
"write_to_clipboard and read_from_clipboard on App.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_3()
|
||||
.child(
|
||||
Button::new("clip-copy", "Copy “Ely”")
|
||||
.icon(IconName::Copy)
|
||||
.on_click(|_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("Ely".into()))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("clip-paste", "Paste")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.icon(IconName::Clipboard)
|
||||
.on_click(move |_, _, cx| {
|
||||
let text = cx.read_from_clipboard().and_then(|item| item.text());
|
||||
pasted.update(cx, |pasted, cx| {
|
||||
*pasted = Some(match text {
|
||||
Some(text) => text.into(),
|
||||
None => "The clipboard holds no text.".into(),
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.when_some(shown, |row, text| {
|
||||
row.child(div().text_color(muted).child(text))
|
||||
})
|
||||
.child(code("cx.write_to_clipboard(…)", cx)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, IconButton},
|
||||
primitives::{
|
||||
Backdrop, Divider, FocusRing, FocusScope, Icon, IconName, Image, IntersectionObserver,
|
||||
Measure, Pressable, Tooltip, TooltipTrigger,
|
||||
},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
};
|
||||
use gpui::{
|
||||
App, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, SharedString, Size,
|
||||
StatefulInteractiveElement, Styled, Window, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{blocked, code, row, section, specimen, specimens},
|
||||
};
|
||||
|
||||
const ATRIUM: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/atrium.jpg"
|
||||
);
|
||||
|
||||
fn counter(window: &mut Window, cx: &mut App, key: &'static str) -> gpui::Entity<u32> {
|
||||
window.use_keyed_state(key, cx, |_, _| 0u32)
|
||||
}
|
||||
|
||||
pub fn icon(cx: &App) -> impl IntoElement + use<> {
|
||||
let colors = &cx.theme().colors;
|
||||
let sizes = [
|
||||
(IconSize::Xs, "12"),
|
||||
(IconSize::Sm, "14"),
|
||||
(IconSize::Md, "16"),
|
||||
(IconSize::Lg, "20"),
|
||||
(IconSize::Xl, "24"),
|
||||
(IconSize::Xxl, "32"),
|
||||
];
|
||||
let tones = [
|
||||
(colors.fg, "fg"),
|
||||
(colors.fg_muted, "muted"),
|
||||
(colors.focus, "focus"),
|
||||
(colors.success, "success"),
|
||||
(colors.warning, "warning"),
|
||||
(colors.danger, "danger"),
|
||||
];
|
||||
section(
|
||||
"Icon",
|
||||
"Lucide, drawn as a mask. Color comes from the theme.",
|
||||
cx,
|
||||
)
|
||||
.child(specimens().children(
|
||||
sizes.map(|(size, name)| specimen(name, Icon::new(IconName::Sparkles).size(size), cx)),
|
||||
))
|
||||
.child(
|
||||
specimens().children(tones.map(|(color, name)| {
|
||||
specimen(name, Icon::new(IconName::CircleCheck).color(color), cx)
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn icon_set(cx: &App) -> impl IntoElement + use<> {
|
||||
let colors = &cx.theme().colors;
|
||||
section(
|
||||
"IconSet / IconRegistry",
|
||||
"IconName lists every bundled icon. Hover for its name.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(IconName::ALL.iter().map(|&name| {
|
||||
div()
|
||||
.id(name.name())
|
||||
.size_9()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_md()
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.tooltip(Tooltip::text(name.name()))
|
||||
.child(Icon::new(name).color(colors.fg_muted))
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn tile(image: Image, cx: &App) -> Image {
|
||||
image
|
||||
.w(px(198.0))
|
||||
.h(px(132.0))
|
||||
.rounded(cx.theme().radius(Radius::Lg))
|
||||
}
|
||||
|
||||
pub fn image(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Image",
|
||||
"Loading pulses. Failure and emptiness show a quiet mark.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen(
|
||||
"loaded",
|
||||
tile(Image::new("img-atrium", Path::new(ATRIUM)), cx),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"failed",
|
||||
tile(Image::new("img-missing", Path::new("/missing/ely.png")), cx),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"empty",
|
||||
tile(Image::placeholder("img-empty"), cx),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn divider(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Divider / Separator",
|
||||
"One hairline. Across, down, or holding a word.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_5()
|
||||
.w(px(420.0))
|
||||
.child(Divider::horizontal())
|
||||
.child(Divider::horizontal().label("or"))
|
||||
.child(
|
||||
row()
|
||||
.h_6()
|
||||
.child("Left")
|
||||
.child(Divider::vertical())
|
||||
.child("Right"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn backdrop(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let open = window.use_keyed_state("prim-backdrop", cx, |_, _| false);
|
||||
let shown = *open.read(cx);
|
||||
let theme = cx.theme();
|
||||
let (show, dismiss, close) = (open.clone(), open.clone(), open);
|
||||
let set = |state: &gpui::Entity<bool>, value: bool, cx: &mut App| {
|
||||
state.update(cx, |open, cx| {
|
||||
*open = value;
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
let card = div()
|
||||
.w(px(320.0))
|
||||
.p_5()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.rounded(theme.radius(Radius::Xl))
|
||||
.bg(theme.colors.overlay)
|
||||
.shadow(theme.elevation(ely_gpui_component::theme::Elevation::Modal))
|
||||
.child(div().font_weight(FontWeight::SEMIBOLD).child("Backdrop"))
|
||||
.child(
|
||||
div()
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child("Click outside to close."),
|
||||
)
|
||||
.child(div().flex().justify_end().child(
|
||||
Button::new("backdrop-close", "Close").on_click(move |_, _, cx| set(&close, false, cx)),
|
||||
));
|
||||
section(
|
||||
"Overlay / Backdrop",
|
||||
"A scrim that fades in, blocks the page, and hears outside clicks.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(probe(
|
||||
"backdrop-open",
|
||||
Button::new("backdrop-open", "Show backdrop")
|
||||
.on_click(move |_, _, cx| set(&show, true, cx)),
|
||||
))
|
||||
.when(shown, |row| {
|
||||
row.child(
|
||||
Backdrop::new("prim-backdrop-layer")
|
||||
.on_dismiss(move |_, cx| set(&dismiss, false, cx))
|
||||
.child(card),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn visually_hidden(cx: &App) -> impl IntoElement + use<> {
|
||||
section("VisuallyHidden", "Text for screen readers only.", cx).child(blocked(
|
||||
"Blocked: gpui 0.2.2 has no accessibility tree to speak to.",
|
||||
cx,
|
||||
))
|
||||
}
|
||||
|
||||
fn chip(id: &'static str, label: &'static str, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.id(id)
|
||||
.tab_index(0)
|
||||
.px_3()
|
||||
.py_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.focus_ring(cx)
|
||||
.child(label)
|
||||
}
|
||||
|
||||
pub fn focus_ring(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"FocusRing",
|
||||
"Any focusable element. Press Tab to walk these.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(chip("ring-a", "First", cx))
|
||||
.child(chip("ring-b", "Second", cx))
|
||||
.child(chip("ring-c", "Third", cx))
|
||||
.child(code(".focus_ring(cx)", cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn focus_trap(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let handle = window
|
||||
.use_keyed_state("prim-trap", cx, |_, cx| cx.focus_handle())
|
||||
.read(cx)
|
||||
.clone();
|
||||
let theme = cx.theme();
|
||||
section(
|
||||
"FocusScope / FocusTrap",
|
||||
"Tab into the box. Tab then circles inside it.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
FocusScope::new(&handle).trap().flex().child(
|
||||
row()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border_strong)
|
||||
.child(chip("trap-a", "One", cx))
|
||||
.child(chip("trap-b", "Two", cx))
|
||||
.child(probe("trap-c", chip("trap-c", "Three", cx))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pressable(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let taps = counter(window, cx, "prim-taps");
|
||||
let holds = counter(window, cx, "prim-holds");
|
||||
let (tap_count, hold_count) = (*taps.read(cx), *holds.read(cx));
|
||||
let bump = |count: &gpui::Entity<u32>, cx: &mut App| {
|
||||
count.update(cx, |count, cx| {
|
||||
*count += 1;
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
section(
|
||||
"Pressable",
|
||||
"Knows when it is held. Hold half a second for a long press.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(probe(
|
||||
"prim-press",
|
||||
Pressable::new("prim-press", |pressed, _, cx| {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.w(px(160.0))
|
||||
.h_16()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.bg(if pressed {
|
||||
theme.colors.active
|
||||
} else {
|
||||
theme.colors.sunken
|
||||
})
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(if pressed { "Holding" } else { "Press" })
|
||||
.into_any_element()
|
||||
})
|
||||
.on_press(move |_, cx| bump(&taps, cx))
|
||||
.on_long_press(move |_, cx| bump(&holds, cx)),
|
||||
))
|
||||
.child(code(format!("taps {tap_count} · holds {hold_count}"), cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn measure(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let size = window.use_keyed_state("prim-size", cx, |_, _| None::<Size<Pixels>>);
|
||||
let wide = window.use_keyed_state("prim-wide", cx, |_, _| false);
|
||||
let (measured, is_wide) = (*size.read(cx), *wide.read(cx));
|
||||
let theme = cx.theme();
|
||||
let readout = match measured {
|
||||
Some(size) => format!(
|
||||
"{:.0} × {:.0}",
|
||||
f32::from(size.width),
|
||||
f32::from(size.height)
|
||||
),
|
||||
None => "measuring…".to_string(),
|
||||
};
|
||||
section(
|
||||
"Measure",
|
||||
"Reports bounds when they change. This is also the ResizeObserver.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(
|
||||
Button::new("measure-toggle", if is_wide { "Narrow" } else { "Widen" }).on_click(
|
||||
move |_, _, cx| {
|
||||
wide.update(cx, |wide, cx| {
|
||||
*wide = !*wide;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(code(readout, cx)),
|
||||
)
|
||||
.child(
|
||||
Measure::new("prim-measured", move |bounds, _, cx| {
|
||||
size.update(cx, |size, cx| {
|
||||
*size = Some(bounds.size);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.w(px(if is_wide { 420.0 } else { 220.0 }))
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.bg(theme.colors.sunken)
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child("Text wraps to the width it gets. The box reports each new size once."),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn intersection(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let seen = window.use_keyed_state("prim-seen", cx, |_, _| false);
|
||||
let visible = *seen.read(cx);
|
||||
let theme = cx.theme();
|
||||
let spacer = || div().h(px(140.0));
|
||||
let status = if visible { "in view" } else { "out of view" };
|
||||
section(
|
||||
"IntersectionObserver",
|
||||
"Scroll the box. The marker reports entering and leaving.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.id("prim-scroller")
|
||||
.w(px(260.0))
|
||||
.h(px(120.0))
|
||||
.overflow_y_scroll()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(spacer())
|
||||
.child(
|
||||
IntersectionObserver::new("prim-marker", move |visible, _, cx| {
|
||||
seen.update(cx, |seen, cx| {
|
||||
*seen = visible;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.mx_3()
|
||||
.p_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.info_subtle)
|
||||
.text_color(theme.colors.info)
|
||||
.child("Marker"),
|
||||
)
|
||||
.child(spacer()),
|
||||
)
|
||||
.child(code(status, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn tooltip(cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let muted = theme.colors.tooltip_fg.opacity(0.7);
|
||||
let lane = div()
|
||||
.w(px(260.0))
|
||||
.h_12()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child("Move across me");
|
||||
section(
|
||||
"Tooltip Trigger",
|
||||
"gpui's trigger with Ely's body, or Ely's own trigger for delay and tracking.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.gap_2()
|
||||
.child(probe(
|
||||
"tip-plain",
|
||||
div()
|
||||
.id("tip-plain")
|
||||
.child(IconButton::new("tip-a", IconName::Copy))
|
||||
.tooltip(Tooltip::text("Copy")),
|
||||
))
|
||||
.child(probe(
|
||||
"tip-meta",
|
||||
div()
|
||||
.id("tip-meta")
|
||||
.child(IconButton::new("tip-b", IconName::Search))
|
||||
.tooltip(Tooltip::with_meta("Search", "⌘K")),
|
||||
))
|
||||
.child(probe(
|
||||
"tip-rich",
|
||||
div()
|
||||
.id("tip-rich")
|
||||
.child(IconButton::new("tip-c", IconName::Info))
|
||||
.tooltip(Tooltip::rich(move |_, _| {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.child(div().font_weight(FontWeight::MEDIUM).child("Rich content"))
|
||||
.child(div().text_color(muted).child("Any element fits here."))
|
||||
.into_any_element()
|
||||
})),
|
||||
))
|
||||
.child(div().w_4())
|
||||
.child(probe(
|
||||
"tip-follow",
|
||||
TooltipTrigger::new("tip-follow", lane, |_, _| {
|
||||
SharedString::from("No delay. I follow.").into_any_element()
|
||||
})
|
||||
.delay(Duration::ZERO)
|
||||
.follow_mouse(),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child("Ely's trigger waits 400ms by default; gpui's waits 500ms."),
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub const PAGE: Page = Page {
|
||||
title: "Theme",
|
||||
summary: "Tokens every component reads. Light and dark share one grammar.",
|
||||
render,
|
||||
script: &[],
|
||||
};
|
||||
|
||||
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ely_gpui_component::primitives::Measure;
|
||||
use gpui::{App, Bounds, Global, IntoElement, ParentElement, Pixels};
|
||||
|
||||
/// Window bounds of named demo elements, for scripted capture.
|
||||
#[derive(Default)]
|
||||
pub struct Probes(HashMap<&'static str, Bounds<Pixels>>);
|
||||
|
||||
impl Global for Probes {}
|
||||
|
||||
impl Probes {
|
||||
pub fn get(key: &str, cx: &App) -> Option<Bounds<Pixels>> {
|
||||
cx.try_global::<Probes>()
|
||||
.and_then(|probes| probes.0.get(key).copied())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps `element` so scripts can find it by `key`.
|
||||
pub fn probe(key: &'static str, element: impl IntoElement) -> Measure {
|
||||
Measure::new(key, move |bounds, _, cx| {
|
||||
cx.default_global::<Probes>().0.insert(key, bounds);
|
||||
})
|
||||
.child(element)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{AsyncApp, Keystroke, Pixels, Point, WindowHandle, point, px};
|
||||
|
||||
use crate::{capture::snapshot, probe::Probes, shell::Gallery};
|
||||
|
||||
/// One scripted input, aimed at a probe by key.
|
||||
pub enum Step {
|
||||
Rest,
|
||||
Hover(&'static str),
|
||||
Down(&'static str),
|
||||
Up(&'static str),
|
||||
Click(&'static str),
|
||||
Key(&'static str),
|
||||
Wait(u64),
|
||||
Shot(&'static str),
|
||||
}
|
||||
|
||||
const FRAME: Duration = Duration::from_millis(120);
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Mouse {
|
||||
Move,
|
||||
Down,
|
||||
Up,
|
||||
}
|
||||
|
||||
/// Parks the pointer on empty sidebar space.
|
||||
pub fn park(window: WindowHandle<Gallery>, cx: &mut AsyncApp) -> Result<()> {
|
||||
send(window, Mouse::Move, point(px(24.0), px(720.0)), cx)
|
||||
}
|
||||
|
||||
pub async fn play(
|
||||
window: WindowHandle<Gallery>,
|
||||
steps: &[Step],
|
||||
path: impl Fn(&str) -> PathBuf,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<()> {
|
||||
for step in steps {
|
||||
match *step {
|
||||
Step::Rest => park(window, cx)?,
|
||||
Step::Hover(key) => {
|
||||
let at = target(window, key, cx).await?;
|
||||
send(window, Mouse::Move, at, cx)?;
|
||||
}
|
||||
Step::Down(key) => {
|
||||
let at = target(window, key, cx).await?;
|
||||
send(window, Mouse::Move, at, cx)?;
|
||||
send(window, Mouse::Down, at, cx)?;
|
||||
}
|
||||
Step::Up(key) => {
|
||||
let at = target(window, key, cx).await?;
|
||||
send(window, Mouse::Up, at, cx)?;
|
||||
}
|
||||
Step::Click(key) => {
|
||||
let at = target(window, key, cx).await?;
|
||||
send(window, Mouse::Move, at, cx)?;
|
||||
send(window, Mouse::Down, at, cx)?;
|
||||
send(window, Mouse::Up, at, cx)?;
|
||||
}
|
||||
Step::Key(stroke) => {
|
||||
let stroke =
|
||||
Keystroke::parse(stroke).with_context(|| format!("bad keystroke {stroke}"))?;
|
||||
window.update(cx, |_, window, cx| {
|
||||
window.dispatch_keystroke(stroke, cx);
|
||||
})?;
|
||||
}
|
||||
Step::Wait(ms) => {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(ms))
|
||||
.await
|
||||
}
|
||||
Step::Shot(name) => {
|
||||
let file = path(name);
|
||||
snapshot(&file)?;
|
||||
log::info!("script: wrote {}", file.display());
|
||||
}
|
||||
}
|
||||
cx.background_executor().timer(FRAME).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send(
|
||||
window: WindowHandle<Gallery>,
|
||||
kind: Mouse,
|
||||
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))
|
||||
}
|
||||
|
||||
/// Queues a real mouse event on this app. No permission needed.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn post(kind: Mouse, x: f64, y: f64) -> 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,
|
||||
Mouse::Up => NSEventType::NSLeftMouseUp,
|
||||
};
|
||||
unsafe {
|
||||
let event = <id as NSEvent>::mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_(
|
||||
nil,
|
||||
kind,
|
||||
NSPoint::new(x, y),
|
||||
NSEventModifierFlags::empty(),
|
||||
0.0,
|
||||
i64::from(number),
|
||||
nil,
|
||||
0,
|
||||
1,
|
||||
1.0,
|
||||
);
|
||||
anyhow::ensure!(event != nil, "AppKit refused a synthetic mouse event");
|
||||
NSApp().postEvent_atStart_(event, NO);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn post(_: Mouse, _: f64, _: f64) -> Result<()> {
|
||||
anyhow::bail!("scripted mouse input needs macOS")
|
||||
}
|
||||
|
||||
async fn target(
|
||||
window: WindowHandle<Gallery>,
|
||||
key: &str,
|
||||
cx: &mut AsyncApp,
|
||||
) -> Result<Point<Pixels>> {
|
||||
let bounds = cx
|
||||
.update(|cx| Probes::get(key, cx))?
|
||||
.with_context(|| format!("probe {key} never rendered"))?;
|
||||
if window.update(cx, |gallery, _, cx| gallery.reveal(bounds, cx))? {
|
||||
cx.background_executor().timer(FRAME).await;
|
||||
}
|
||||
let bounds = cx
|
||||
.update(|cx| Probes::get(key, cx))?
|
||||
.with_context(|| format!("probe {key} vanished after scrolling"))?;
|
||||
Ok(bounds.center())
|
||||
}
|
||||
+21
-10
@@ -1,15 +1,15 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
primitives::IconName,
|
||||
primitives::{FocusScope, IconName},
|
||||
theme::{ActiveTheme, ControlSize, Mode, Radius, TextSize, Theme},
|
||||
};
|
||||
use gpui::{
|
||||
Context, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels,
|
||||
Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
|
||||
div, point, prelude::*, px,
|
||||
Bounds, Context, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement,
|
||||
Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription,
|
||||
Window, div, point, prelude::*, px,
|
||||
};
|
||||
|
||||
use crate::{FocusNext, FocusPrev, pages};
|
||||
use crate::pages;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Choice {
|
||||
@@ -59,6 +59,21 @@ impl Gallery {
|
||||
)
|
||||
}
|
||||
|
||||
/// Scrolls until `target` sits inside the page. True if it moved.
|
||||
pub fn reveal(&mut self, target: Bounds<Pixels>, cx: &mut Context<Self>) -> bool {
|
||||
let (view, margin) = (self.scroll.bounds(), px(96.0));
|
||||
let shift = if target.top() < view.top() + margin {
|
||||
target.top() - view.top() - margin
|
||||
} else if target.bottom() > view.bottom() - margin {
|
||||
target.bottom() - view.bottom() + margin
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let y = (-self.scroll.offset().y + shift).clamp(px(0.0), self.scroll.max_offset().height);
|
||||
self.scroll_to(y, cx);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn scroll_to(&mut self, y: Pixels, cx: &mut Context<Self>) {
|
||||
self.scroll.set_offset(point(px(0.0), -y));
|
||||
cx.notify();
|
||||
@@ -183,11 +198,7 @@ impl Render for Gallery {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
|
||||
div()
|
||||
.id("gallery")
|
||||
.track_focus(&self.focus)
|
||||
.on_action(|_: &FocusNext, window, _| window.focus_next())
|
||||
.on_action(|_: &FocusPrev, window, _| window.focus_prev())
|
||||
FocusScope::new(&self.focus)
|
||||
.size_full()
|
||||
.flex()
|
||||
.bg(colors.bg)
|
||||
|
||||
@@ -51,3 +51,26 @@ pub fn specimen(caption: impl Into<SharedString>, body: impl IntoElement, cx: &A
|
||||
.child(body)
|
||||
.child(label(caption, cx))
|
||||
}
|
||||
|
||||
/// Monospace hint naming the call.
|
||||
pub fn code(text: impl Into<SharedString>, cx: &App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(text.into())
|
||||
}
|
||||
|
||||
/// Honest note for an entry gpui cannot support.
|
||||
pub fn blocked(text: impl Into<SharedString>, cx: &App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.warning)
|
||||
.child(div().size_1p5().rounded_full().bg(theme.colors.warning))
|
||||
.child(text.into())
|
||||
}
|
||||
+12
-11
@@ -4,7 +4,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Mix, Palette, Radius, TextSize},
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ pub(crate) struct Tone {
|
||||
pub hover: Hsla,
|
||||
pub pressed: Hsla,
|
||||
pub fg: Hsla,
|
||||
pub border: Option<Hsla>,
|
||||
pub border: Hsla,
|
||||
}
|
||||
|
||||
pub(crate) fn tone(variant: ButtonVariant, colors: &Palette) -> Tone {
|
||||
@@ -38,49 +38,49 @@ pub(crate) fn tone(variant: ButtonVariant, colors: &Palette) -> Tone {
|
||||
hover: colors.accent_hover,
|
||||
pressed: colors.accent_hover.mix(&colors.on_accent, 0.12),
|
||||
fg: colors.on_accent,
|
||||
border: None,
|
||||
border: clear,
|
||||
},
|
||||
ButtonVariant::Secondary => Tone {
|
||||
bg: colors.surface,
|
||||
hover: colors.hover,
|
||||
pressed: colors.active,
|
||||
fg: colors.fg,
|
||||
border: Some(colors.border),
|
||||
border: colors.border,
|
||||
},
|
||||
ButtonVariant::Outline => Tone {
|
||||
bg: clear,
|
||||
hover: colors.hover,
|
||||
pressed: colors.active,
|
||||
fg: colors.fg,
|
||||
border: Some(colors.border_strong),
|
||||
border: colors.border_strong,
|
||||
},
|
||||
ButtonVariant::Ghost => Tone {
|
||||
bg: clear,
|
||||
hover: colors.hover,
|
||||
pressed: colors.active,
|
||||
fg: colors.fg,
|
||||
border: None,
|
||||
border: clear,
|
||||
},
|
||||
ButtonVariant::Subtle => Tone {
|
||||
bg: colors.hover,
|
||||
hover: colors.active,
|
||||
pressed: colors.active.mix(&colors.border_strong, 0.5),
|
||||
fg: colors.fg,
|
||||
border: None,
|
||||
border: clear,
|
||||
},
|
||||
ButtonVariant::Danger => Tone {
|
||||
bg: colors.danger,
|
||||
hover: colors.danger.mix(&colors.fg, 0.14),
|
||||
pressed: colors.danger.mix(&colors.fg, 0.24),
|
||||
fg: colors.on_accent,
|
||||
border: None,
|
||||
border: clear,
|
||||
},
|
||||
ButtonVariant::Link => Tone {
|
||||
bg: clear,
|
||||
hover: clear,
|
||||
pressed: clear,
|
||||
fg: colors.link,
|
||||
border: None,
|
||||
border: clear,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,8 @@ impl RenderOnce for Button {
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(tone.fg)
|
||||
.bg(tone.bg)
|
||||
.when_some(tone.border, |el, border| el.border_1().border_color(border))
|
||||
.border_1()
|
||||
.border_color(tone.border)
|
||||
.when(self.full_width, |el| el.w_full())
|
||||
.when_some(self.icon, |el, name| el.child(icon(name)))
|
||||
.child(self.label)
|
||||
@@ -202,7 +203,7 @@ impl RenderOnce for Button {
|
||||
if link { style.underline() } else { style }
|
||||
})
|
||||
.active(|style| style.bg(tone.pressed))
|
||||
.focus(|style| style.shadow(theme.focus_ring()))
|
||||
.focus_ring(cx)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.when_some(self.on_click, |el, handler| el.on_click(handler))
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ use gpui::{
|
||||
|
||||
use super::button::{ButtonVariant, ClickHandler, label_size, tone};
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, Radius},
|
||||
};
|
||||
|
||||
@@ -71,7 +71,8 @@ impl RenderOnce for IconButton {
|
||||
.size(side)
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(tone.bg)
|
||||
.when_some(tone.border, |el, border| el.border_1().border_color(border))
|
||||
.border_1()
|
||||
.border_color(tone.border)
|
||||
.child(Icon::new(self.icon).size(icon_size).color(tone.fg))
|
||||
.map(|el| {
|
||||
if self.disabled {
|
||||
@@ -81,7 +82,7 @@ impl RenderOnce for IconButton {
|
||||
.tab_index(0)
|
||||
.hover(|style| style.bg(tone.hover))
|
||||
.active(|style| style.bg(tone.pressed))
|
||||
.focus(|style| style.shadow(theme.focus_ring()))
|
||||
.focus_ring(cx)
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.when_some(self.on_click, |el, handler| el.on_click(handler))
|
||||
})
|
||||
|
||||
+12
-9
@@ -7,18 +7,21 @@ pub mod theme;
|
||||
|
||||
pub use assets::Assets;
|
||||
|
||||
use gpui::App;
|
||||
use gpui::{App, KeyBinding};
|
||||
|
||||
use crate::primitives::IconName;
|
||||
use crate::primitives::{FocusNext, FocusPrev, IconName};
|
||||
|
||||
/// Loads fonts and the theme. Call once, first.
|
||||
/// Loads fonts and the theme, binds Tab. Call once, first.
|
||||
pub fn init(cx: &mut App) {
|
||||
let probe = IconName::Check.path();
|
||||
let wired = cx.asset_source().load(probe).ok().flatten().is_some();
|
||||
assert!(
|
||||
wired,
|
||||
"ely: pass `ely_gpui_component::Assets` to `Application::with_assets`"
|
||||
);
|
||||
match cx.asset_source().load(IconName::Check.path()) {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => panic!("ely: pass `ely_gpui_component::Assets` to `Application::with_assets`"),
|
||||
Err(error) => panic!("ely: asset source failed: {error:#}"),
|
||||
}
|
||||
assets::load_fonts(cx).expect("ely: embedded fonts failed to register");
|
||||
theme::Theme::init(cx);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("tab", FocusNext, None),
|
||||
KeyBinding::new("shift-tab", FocusPrev, None),
|
||||
]);
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::App;
|
||||
use gpui::{App, Pixels, px};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
@@ -9,6 +9,9 @@ pub const BASE: Duration = Duration::from_millis(200);
|
||||
pub const SLOW: Duration = Duration::from_millis(320);
|
||||
pub const THEME: Duration = Duration::from_millis(280);
|
||||
|
||||
/// Travel of small entrances.
|
||||
pub const NUDGE: Pixels = px(4.0);
|
||||
|
||||
const DAMPING: f32 = 0.75;
|
||||
const OMEGA: f32 = 6.91 / DAMPING;
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Point, RenderOnce, Styled, Window, anchored, deferred, div,
|
||||
prelude::*,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{motion, theme::ActiveTheme};
|
||||
|
||||
type Dismiss = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// Full-window scrim. Children sit centered above it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Backdrop {
|
||||
id: ElementId,
|
||||
on_dismiss: Option<Dismiss>,
|
||||
children: SmallVec<[AnyElement; 1]>,
|
||||
}
|
||||
|
||||
impl Backdrop {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
on_dismiss: None,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs on a click outside the children.
|
||||
pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_dismiss = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Backdrop {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Backdrop {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let viewport = window.viewport_size();
|
||||
let fade = motion::duration(motion::BASE, cx);
|
||||
let scrim = div()
|
||||
.id(self.id.clone())
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors.backdrop)
|
||||
.occlude()
|
||||
.when_some(self.on_dismiss, |scrim, dismiss| {
|
||||
scrim.on_mouse_down(MouseButton::Left, move |_, window, cx| dismiss(window, cx))
|
||||
})
|
||||
.with_animation(
|
||||
self.id,
|
||||
Animation::new(fade).with_easing(motion::ease_out_cubic),
|
||||
|scrim, t| scrim.opacity(t),
|
||||
);
|
||||
|
||||
deferred(
|
||||
anchored().position(Point::default()).child(
|
||||
div()
|
||||
.relative()
|
||||
.w(viewport.width)
|
||||
.h(viewport.height)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(scrim)
|
||||
.children(
|
||||
self.children
|
||||
.into_iter()
|
||||
.map(|child| div().relative().occlude().child(child)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use gpui::{App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div};
|
||||
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
|
||||
/// Hairline rule, across or down.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Divider {
|
||||
vertical: bool,
|
||||
label: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl Divider {
|
||||
pub fn horizontal() -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vertical() -> Self {
|
||||
Self {
|
||||
vertical: true,
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text centered on a horizontal rule.
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Divider {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let line = || div().border_color(theme.colors.border);
|
||||
if self.vertical {
|
||||
return line().h_full().border_l_1();
|
||||
}
|
||||
match self.label {
|
||||
None => line().w_full().border_t_1(),
|
||||
Some(label) => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(line().flex_1().border_t_1())
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(label),
|
||||
)
|
||||
.child(line().flex_1().border_t_1()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Div, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
StyleRefinement, Styled, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
actions!(ely, [FocusNext, FocusPrev]);
|
||||
|
||||
/// Focus-colored border while focused. Give the element a 1px border.
|
||||
pub trait FocusRing: InteractiveElement + Sized {
|
||||
fn focus_ring(self, cx: &App) -> Self {
|
||||
let ring = cx.theme().colors.focus;
|
||||
self.focus(move |style| style.border_color(ring))
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: InteractiveElement> FocusRing for E {}
|
||||
|
||||
/// Owns Tab and Shift-Tab for its subtree. `trap` keeps focus inside.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FocusScope {
|
||||
base: Div,
|
||||
handle: FocusHandle,
|
||||
trap: bool,
|
||||
}
|
||||
|
||||
impl FocusScope {
|
||||
pub fn new(handle: &FocusHandle) -> Self {
|
||||
Self {
|
||||
base: div().track_focus(handle),
|
||||
handle: handle.clone(),
|
||||
trap: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trap(mut self) -> Self {
|
||||
self.trap = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for FocusScope {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for FocusScope {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FocusScope {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let (next, prev) = (self.handle.clone(), self.handle);
|
||||
let trap = self.trap;
|
||||
self.base
|
||||
.on_action(move |_: &FocusNext, window, cx| step(&next, trap, true, window, cx))
|
||||
.on_action(move |_: &FocusPrev, window, cx| step(&prev, trap, false, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn step(scope: &FocusHandle, trap: bool, forward: bool, window: &mut Window, cx: &mut App) {
|
||||
let advance = |window: &mut Window| {
|
||||
if forward {
|
||||
window.focus_next()
|
||||
} else {
|
||||
window.focus_prev()
|
||||
}
|
||||
};
|
||||
if !trap {
|
||||
advance(window);
|
||||
return;
|
||||
}
|
||||
let origin = window.focused(cx);
|
||||
let mut first = None;
|
||||
loop {
|
||||
advance(window);
|
||||
let focused = window.focused(cx);
|
||||
if scope.contains_focused(window, cx) && focused.as_ref() != Some(scope) {
|
||||
return;
|
||||
}
|
||||
if focused.is_none() || focused == origin || (first.is_some() && focused == first) {
|
||||
break;
|
||||
}
|
||||
if first.is_none() {
|
||||
first = focused;
|
||||
}
|
||||
}
|
||||
log::warn!("focus scope: trap holds no tab stop; focus stays");
|
||||
if let Some(origin) = origin {
|
||||
window.focus(&origin);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ macro_rules! icons {
|
||||
$(IconName::$variant => concat!("icons/", $file, ".svg"),)*
|
||||
}
|
||||
}
|
||||
|
||||
/// Lucide name, such as `chevron-down`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
$(IconName::$variant => $file,)*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -124,6 +131,7 @@ icons! {
|
||||
Hourglass => "hourglass",
|
||||
House => "house",
|
||||
Image => "image",
|
||||
ImageOff => "image-off",
|
||||
Inbox => "inbox",
|
||||
Info => "info",
|
||||
Italic => "italic",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, Div, ElementId, ImageSource, InteractiveElement, IntoElement,
|
||||
ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Window, div, img,
|
||||
pulsating_between,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
const PULSE: Duration = Duration::from_millis(1600);
|
||||
|
||||
/// Themed `img()`; a `Cover` crop keeps square corners.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Image {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
source: Option<ImageSource>,
|
||||
fit: ObjectFit,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// The id keeps gpui's loading state across frames.
|
||||
pub fn new(id: impl Into<ElementId>, source: impl Into<ImageSource>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
source: Some(source.into()),
|
||||
fit: ObjectFit::Cover,
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing to load yet.
|
||||
pub fn placeholder(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
source: None,
|
||||
fit: ObjectFit::Cover,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fit(mut self, fit: ObjectFit) -> Self {
|
||||
self.fit = fit;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Image {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Image {
|
||||
fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (sunken, subtle) = (theme.colors.sunken, theme.colors.fg_subtle);
|
||||
let still = theme.reduced_motion;
|
||||
let radii = self.base.style().corner_radii.clone();
|
||||
let pane = {
|
||||
let radii = radii.clone();
|
||||
move || {
|
||||
let mut pane = div().size_full().bg(sunken);
|
||||
pane.style().corner_radii = radii.clone();
|
||||
pane
|
||||
}
|
||||
};
|
||||
let loading_pane = pane.clone();
|
||||
let mark = move |name| {
|
||||
pane()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(Icon::new(name).size(IconSize::Lg).color(subtle))
|
||||
};
|
||||
let Some(source) = self.source else {
|
||||
return self.base.overflow_hidden().child(mark(IconName::Image));
|
||||
};
|
||||
let mut picture = img(source)
|
||||
.id(self.id)
|
||||
.size_full()
|
||||
.object_fit(self.fit)
|
||||
.with_loading(move || {
|
||||
if still {
|
||||
return loading_pane().into_any_element();
|
||||
}
|
||||
loading_pane()
|
||||
.with_animation(
|
||||
"image-loading",
|
||||
Animation::new(PULSE)
|
||||
.repeat()
|
||||
.with_easing(pulsating_between(0.55, 1.0)),
|
||||
|pane, t| pane.opacity(t),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.with_fallback(move || mark(IconName::ImageOff).into_any_element());
|
||||
picture.style().corner_radii = radii;
|
||||
self.base.overflow_hidden().child(picture)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Div, ElementId, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||
StyleRefinement, Styled, Window, canvas, div,
|
||||
};
|
||||
|
||||
type BoundsHandler = Rc<dyn Fn(Bounds<Pixels>, &mut Window, &mut App)>;
|
||||
type VisibilityHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
|
||||
|
||||
/// A div that reports its bounds whenever they change.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Measure {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
on_measure: BoundsHandler,
|
||||
}
|
||||
|
||||
impl Measure {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
on_measure: impl Fn(Bounds<Pixels>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
on_measure: Rc::new(on_measure),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Measure {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for Measure {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Measure {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let last = window.use_keyed_state(self.id, cx, |_, _| None::<Bounds<Pixels>>);
|
||||
let on_measure = self.on_measure;
|
||||
self.base.relative().child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
if *last.read(cx) != Some(bounds) {
|
||||
last.update(cx, |seen, _| *seen = Some(bounds));
|
||||
on_measure(bounds, window, cx);
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A div that reports when it enters or leaves the visible area.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IntersectionObserver {
|
||||
id: ElementId,
|
||||
base: Div,
|
||||
on_change: VisibilityHandler,
|
||||
}
|
||||
|
||||
impl IntersectionObserver {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
on_change: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
base: div(),
|
||||
on_change: Rc::new(on_change),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for IntersectionObserver {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
self.base.style()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for IntersectionObserver {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.base.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for IntersectionObserver {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let last = window.use_keyed_state(self.id, cx, |_, _| None::<bool>);
|
||||
let on_change = self.on_change;
|
||||
self.base.relative().child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
let visible = window.content_mask().bounds.intersects(&bounds);
|
||||
if *last.read(cx) != Some(visible) {
|
||||
last.update(cx, |seen, _| *seen = Some(visible));
|
||||
on_change(visible, window, cx);
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
mod backdrop;
|
||||
mod divider;
|
||||
mod focus;
|
||||
mod icon;
|
||||
mod image;
|
||||
mod measure;
|
||||
mod pressable;
|
||||
mod tooltip;
|
||||
|
||||
pub use backdrop::Backdrop;
|
||||
pub use divider::Divider;
|
||||
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
|
||||
pub use icon::{Icon, IconName};
|
||||
pub use image::Image;
|
||||
pub use measure::{IntersectionObserver, Measure};
|
||||
pub use pressable::Pressable;
|
||||
pub use tooltip::{Tooltip, TooltipTrigger};
|
||||
@@ -0,0 +1,111 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, Task, Window, div,
|
||||
};
|
||||
|
||||
type Handler = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
type Content = Box<dyn Fn(bool, &mut Window, &mut App) -> AnyElement>;
|
||||
|
||||
const LONG_PRESS: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Default)]
|
||||
struct PressState {
|
||||
pressed: bool,
|
||||
long_fired: bool,
|
||||
_timer: Option<Task<()>>,
|
||||
}
|
||||
|
||||
/// Pressed state and long press for any content.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Pressable {
|
||||
id: ElementId,
|
||||
content: Content,
|
||||
on_press: Option<Handler>,
|
||||
on_long_press: Option<Handler>,
|
||||
}
|
||||
|
||||
impl Pressable {
|
||||
/// `content` receives whether the pointer is down.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
content: impl Fn(bool, &mut Window, &mut App) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
content: Box::new(content),
|
||||
on_press: None,
|
||||
on_long_press: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_press(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_press = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_long_press(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_long_press = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Pressable {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| PressState::default());
|
||||
let pressed = state.read(cx).pressed;
|
||||
let content = (self.content)(pressed, window, cx);
|
||||
let (down, up, out) = (state.clone(), state.clone(), state);
|
||||
let on_long_press = self.on_long_press;
|
||||
let on_press = self.on_press;
|
||||
|
||||
div()
|
||||
.id(self.id)
|
||||
.child(content)
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
let timer = on_long_press.clone().map(|handler| {
|
||||
let weak = down.downgrade();
|
||||
window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(LONG_PRESS).await;
|
||||
let fired = cx.update(|window, cx| {
|
||||
weak.update(cx, |press, cx| {
|
||||
press.long_fired = press.pressed;
|
||||
cx.notify();
|
||||
press.long_fired
|
||||
})
|
||||
.map(|fire| fire.then(|| handler(window, cx)))
|
||||
});
|
||||
if let Err(error) = fired.and_then(|inner| inner) {
|
||||
log::error!("pressable: long press lost its window: {error:#}");
|
||||
}
|
||||
})
|
||||
});
|
||||
down.update(cx, |press, cx| {
|
||||
*press = PressState {
|
||||
pressed: true,
|
||||
long_fired: false,
|
||||
_timer: timer,
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, move |_, window, cx| {
|
||||
let released = up.update(cx, |press, cx| {
|
||||
let short = press.pressed && !press.long_fired;
|
||||
*press = PressState::default();
|
||||
cx.notify();
|
||||
short
|
||||
});
|
||||
if let Some(handler) = on_press.as_ref().filter(|_| released) {
|
||||
handler(window, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
|
||||
out.update(cx, |press, cx| {
|
||||
*press = PressState::default();
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::{rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, AnyView, App, AppContext, Context, Div, ElementId,
|
||||
InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Task, Window, anchored, deferred, div, point,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
motion,
|
||||
theme::{ActiveTheme, Elevation, Radius, TextSize},
|
||||
};
|
||||
|
||||
type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
|
||||
|
||||
const DELAY: Duration = Duration::from_millis(400);
|
||||
|
||||
fn surface(content: AnyElement, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let rise = motion::NUDGE;
|
||||
let body = div()
|
||||
.max_w(theme.tooltip_max_width())
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.tooltip_bg)
|
||||
.text_color(theme.colors.tooltip_fg)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.child(content);
|
||||
body.with_animation(
|
||||
"tooltip-in",
|
||||
Animation::new(motion::duration(motion::FAST, cx)).with_easing(motion::ease_out_cubic),
|
||||
move |body, t| body.opacity(t).mt(rise * (1.0 - t)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Tooltip body for gpui's `.tooltip()`.
|
||||
pub struct Tooltip {
|
||||
content: Content,
|
||||
}
|
||||
|
||||
impl Tooltip {
|
||||
pub fn text(title: impl Into<SharedString>) -> impl Fn(&mut Window, &mut App) -> AnyView {
|
||||
let title = title.into();
|
||||
Self::rich(move |_, _| title.clone().into_any_element())
|
||||
}
|
||||
|
||||
/// Title plus a quiet trailing hint, such as a shortcut.
|
||||
pub fn with_meta(
|
||||
title: impl Into<SharedString>,
|
||||
meta: impl Into<SharedString>,
|
||||
) -> impl Fn(&mut Window, &mut App) -> AnyView {
|
||||
let (title, meta) = (title.into(), meta.into());
|
||||
Self::rich(move |_, cx| meta_row(title.clone(), meta.clone(), cx).into_any_element())
|
||||
}
|
||||
|
||||
pub fn rich(
|
||||
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
|
||||
) -> impl Fn(&mut Window, &mut App) -> AnyView {
|
||||
let content: Content = Rc::new(content);
|
||||
move |_, cx| {
|
||||
let content = content.clone();
|
||||
cx.new(|_| Tooltip { content }).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Tooltip {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let content = (self.content)(window, cx);
|
||||
div()
|
||||
.pt(cx.theme().cursor_offset())
|
||||
.child(surface(content, cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn meta_row(title: SharedString, meta: SharedString, cx: &App) -> Div {
|
||||
div().flex().items_center().gap_2().child(title).child(
|
||||
div()
|
||||
.text_color(cx.theme().colors.tooltip_fg.opacity(0.6))
|
||||
.child(meta),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TriggerState {
|
||||
visible: bool,
|
||||
mouse: Point<Pixels>,
|
||||
_pending: Option<Task<()>>,
|
||||
}
|
||||
|
||||
/// Tooltip with its own delay, and optional cursor tracking.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TooltipTrigger {
|
||||
id: ElementId,
|
||||
child: AnyElement,
|
||||
content: Content,
|
||||
delay: Duration,
|
||||
follow: bool,
|
||||
}
|
||||
|
||||
impl TooltipTrigger {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
child: impl IntoElement,
|
||||
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
child: child.into_any_element(),
|
||||
content: Rc::new(content),
|
||||
delay: DELAY,
|
||||
follow: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delay(mut self, delay: Duration) -> Self {
|
||||
self.delay = delay;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn follow_mouse(mut self) -> Self {
|
||||
self.follow = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TooltipTrigger {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| TriggerState::default());
|
||||
let (visible, mouse) = {
|
||||
let read = state.read(cx);
|
||||
(read.visible, read.mouse)
|
||||
};
|
||||
let (hover, track, press) = (state.clone(), state.clone(), state);
|
||||
let (delay, follow) = (self.delay, self.follow);
|
||||
let content = self.content;
|
||||
|
||||
div()
|
||||
.id(self.id)
|
||||
.child(self.child)
|
||||
.on_hover(move |hovered, window, cx| {
|
||||
if !*hovered {
|
||||
hover.update(cx, hide);
|
||||
return;
|
||||
}
|
||||
let weak = hover.downgrade();
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
cx.background_executor().timer(delay).await;
|
||||
let shown = cx.update(|window, cx| {
|
||||
weak.update(cx, |trigger, cx| {
|
||||
trigger.visible = true;
|
||||
trigger.mouse = window.mouse_position();
|
||||
trigger._pending = None;
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
if let Err(error) = shown.and_then(|inner| inner) {
|
||||
log::error!("tooltip: trigger vanished before showing: {error:#}");
|
||||
}
|
||||
});
|
||||
hover.update(cx, |trigger, _| trigger._pending = Some(task));
|
||||
})
|
||||
.on_mouse_move(move |event, _, cx| {
|
||||
track.update(cx, |trigger, cx| {
|
||||
trigger.mouse = event.position;
|
||||
if follow && trigger.visible {
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
})
|
||||
.on_any_mouse_down(move |_, _, cx| press.update(cx, hide))
|
||||
.when(visible, |trigger| {
|
||||
let body = content(window, cx);
|
||||
let below = cx.theme().cursor_offset().to_pixels(window.rem_size());
|
||||
trigger.child(deferred(
|
||||
anchored()
|
||||
.position(mouse + point(Pixels::ZERO, below))
|
||||
.snap_to_window()
|
||||
.child(surface(body, cx)),
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn hide(trigger: &mut TriggerState, cx: &mut Context<TriggerState>) {
|
||||
trigger.visible = false;
|
||||
trigger._pending = None;
|
||||
cx.notify();
|
||||
}
|
||||
@@ -96,6 +96,8 @@ mixable!(
|
||||
info_subtle: Hsla,
|
||||
backdrop: Hsla,
|
||||
shadow: Hsla,
|
||||
tooltip_bg: Hsla,
|
||||
tooltip_fg: Hsla,
|
||||
chart: [Hsla; 8],
|
||||
ansi: [Hsla; 16],
|
||||
syntax: Syntax,
|
||||
@@ -141,6 +143,8 @@ impl Palette {
|
||||
info_subtle: c(0xebf4ff),
|
||||
backdrop: rgba(0x18161352).into(),
|
||||
shadow: c(0x181613),
|
||||
tooltip_bg: c(0x181613),
|
||||
tooltip_fg: c(0xfcfaf7),
|
||||
chart: c8([
|
||||
0x5182c1, 0x009589, 0xaa732b, 0xb8636b, 0x8572bb, 0x539156, 0xb66946, 0x138db1,
|
||||
]),
|
||||
@@ -203,6 +207,8 @@ impl Palette {
|
||||
info_subtle: c(0x19273a),
|
||||
backdrop: rgba(0x00000080).into(),
|
||||
shadow: c(0x000000),
|
||||
tooltip_bg: c(0x373533),
|
||||
tooltip_fg: c(0xf3f1f0),
|
||||
chart: c8([
|
||||
0x79a7e2, 0x4eb9ad, 0xcd995c, 0xdc8a90, 0xa998dd, 0x7cb57d, 0xd9906f, 0x56b2d4,
|
||||
]),
|
||||
|
||||
+9
-9
@@ -101,6 +101,15 @@ impl Theme {
|
||||
px_to_rems(base * self.font_scale)
|
||||
}
|
||||
|
||||
/// Gap between the pointer and a tooltip.
|
||||
pub fn cursor_offset(&self) -> Rems {
|
||||
px_to_rems(18.0)
|
||||
}
|
||||
|
||||
pub fn tooltip_max_width(&self) -> Rems {
|
||||
px_to_rems(256.0)
|
||||
}
|
||||
|
||||
pub fn text_size(&self, size: TextSize) -> Rems {
|
||||
let base = match size {
|
||||
TextSize::Xs => 11.0,
|
||||
@@ -140,15 +149,6 @@ impl Theme {
|
||||
.map(|&(y, blur, alpha)| shadow(self.colors.shadow.alpha(alpha), y, blur))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn focus_ring(&self) -> Vec<BoxShadow> {
|
||||
vec![BoxShadow {
|
||||
color: self.colors.focus.alpha(0.45),
|
||||
offset: point(px(0.0), px(0.0)),
|
||||
blur_radius: px(0.0),
|
||||
spread_radius: px(2.0),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
fn shadow(color: Hsla, y: f32, blur: f32) -> BoxShadow {
|
||||
|
||||
+25
-25
@@ -2,33 +2,33 @@
|
||||
|
||||
## T01 Primitives — `src/primitives`
|
||||
|
||||
- [ ] Box / View — native `div()`
|
||||
- [ ] Text — native: strings and `StyledText` are elements
|
||||
- [x] Box / View — native `div()`
|
||||
- [x] Text — native: strings and `StyledText` are elements
|
||||
- [x] Icon (T00)
|
||||
- [x] IconSet / IconRegistry (T00)
|
||||
- [ ] Svg — native `svg()`
|
||||
- [ ] Image — loading, failed, placeholder over `img()`
|
||||
- [ ] Canvas — native `canvas()`
|
||||
- [ ] Divider / Separator
|
||||
- [ ] Spacer — native `div().flex_1()`
|
||||
- [ ] Portal — native `deferred(anchored())`
|
||||
- [ ] Overlay / Backdrop
|
||||
- [ ] Slot — native `impl IntoElement` params and `ParentElement`
|
||||
- [ ] Show / When — native `.when()` / `.when_some()`
|
||||
- [ ] For / Each — native `.children(iter)`
|
||||
- [ ] Fragment — native: children take iterators
|
||||
- [ ] VisuallyHidden — blocked: gpui 0.2.2 has no accessibility tree
|
||||
- [ ] FocusRing
|
||||
- [ ] FocusScope / FocusTrap
|
||||
- [ ] ClickOutside — native `on_mouse_down_out`
|
||||
- [ ] HoverArea — native `on_hover` / `.hover()`
|
||||
- [ ] Pressable — press state, long press
|
||||
- [ ] KeyboardHandler — native `on_key_down` / `on_action`
|
||||
- [ ] Measure
|
||||
- [ ] ResizeObserver → `primitives::Measure`
|
||||
- [ ] IntersectionObserver
|
||||
- [ ] Clipboard — native `write_to_clipboard` / `read_from_clipboard`
|
||||
- [ ] Tooltip Trigger — owns `Tooltip`: delay, follow mouse, rich content
|
||||
- [x] Svg — native `svg()`
|
||||
- [x] Image — loading, failed, placeholder over `img()`
|
||||
- [x] Canvas — native `canvas()`
|
||||
- [x] Divider / Separator
|
||||
- [x] Spacer — native `div().flex_1()`
|
||||
- [x] Portal — native `deferred(anchored())`
|
||||
- [x] Overlay / Backdrop
|
||||
- [x] Slot — native `impl IntoElement` params and `ParentElement`
|
||||
- [x] Show / When — native `.when()` / `.when_some()`
|
||||
- [x] For / Each — native `.children(iter)`
|
||||
- [x] Fragment — native: children take iterators
|
||||
- [x] VisuallyHidden — blocked: gpui 0.2.2 has no accessibility tree
|
||||
- [x] FocusRing
|
||||
- [x] FocusScope / FocusTrap
|
||||
- [x] ClickOutside — native `on_mouse_down_out`
|
||||
- [x] HoverArea — native `on_hover` / `.hover()`
|
||||
- [x] Pressable — press state, long press
|
||||
- [x] KeyboardHandler — native `on_key_down` / `on_action`
|
||||
- [x] Measure
|
||||
- [x] ResizeObserver → `primitives::Measure`
|
||||
- [x] IntersectionObserver
|
||||
- [x] Clipboard — native `write_to_clipboard` / `read_from_clipboard`
|
||||
- [x] Tooltip Trigger — owns `Tooltip`: delay, follow mouse, rich content
|
||||
|
||||
## T02 Typography — `src/typography`
|
||||
|
||||
|
||||
+1
-1
@@ -289,4 +289,4 @@
|
||||
- [ ] DesignTokenViewer
|
||||
- [ ] IconBrowser
|
||||
- [ ] A11yChecker — contrast audit
|
||||
- [ ] ScreenshotTest Harness — self-capture
|
||||
- [ ] ScreenshotTest Harness — self-capture; scripted input since T01
|
||||
Reference in new issue
Block a user