T00 foundation: theme, motion, assets, icon, buttons, gallery
Theme tokens in light and dark with high contrast, a palette cross-fade, motion curves and a damped spring, embedded Lucide icons with Inter and JetBrains Mono, Icon, Button, IconButton, and a gallery that captures its own window. Reviewed by codex gpt-6-astra (max): PASS in round 3.
This commit is contained in:
258 files changed
+16908
No files matched your search
@@ -0,0 +1,132 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use gpui::{App, AsyncApp, WindowHandle, px};
|
||||
|
||||
use crate::{
|
||||
pages,
|
||||
shell::{Choice, Gallery},
|
||||
};
|
||||
|
||||
const SETTLE: Duration = Duration::from_millis(700);
|
||||
const SCROLL_SETTLE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Shoots every page in both modes, top to bottom, then quits. Exits 1 on failure.
|
||||
pub fn run(window: WindowHandle<Gallery>, dir: PathBuf, cx: &mut App) {
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
cx.spawn(async move |cx| {
|
||||
let outcome = shoot_all(window, &dir, cx).await;
|
||||
if let Err(error) = outcome.and_then(|()| cx.update(|cx| cx.quit())) {
|
||||
log::error!("capture failed: {error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
async fn shoot_all(window: WindowHandle<Gallery>, dir: &Path, cx: &mut AsyncApp) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
|
||||
for (ix, page) in pages::ALL.iter().enumerate() {
|
||||
for (choice, name) in [(Choice::Light, "light"), (Choice::Dark, "dark")] {
|
||||
window.update(cx, |gallery, window, cx| {
|
||||
gallery.select(ix, cx);
|
||||
gallery.choose(choice, window, cx);
|
||||
})?;
|
||||
cx.background_executor().timer(SETTLE).await;
|
||||
let (viewport, max) = window.update(cx, |gallery, _, _| gallery.scroll_extent())?;
|
||||
if viewport <= px(0.0) {
|
||||
bail!("page {} has no viewport", page.slug);
|
||||
}
|
||||
let mut offset = px(0.0);
|
||||
for segment in 0.. {
|
||||
let suffix = if segment == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{segment}")
|
||||
};
|
||||
let path = dir.join(format!("{}-{name}{suffix}.png", page.slug));
|
||||
snapshot(&path)?;
|
||||
log::info!("capture: wrote {}", path.display());
|
||||
if offset >= max {
|
||||
break;
|
||||
}
|
||||
offset = (offset + viewport).min(max);
|
||||
window.update(cx, |gallery, _, cx| gallery.scroll_to(offset, cx))?;
|
||||
cx.background_executor().timer(SCROLL_SETTLE).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn snapshot(path: &Path) -> Result<()> {
|
||||
use core_foundation::{
|
||||
base::{CFType, TCFType},
|
||||
dictionary::CFDictionary,
|
||||
number::CFNumber,
|
||||
string::CFString,
|
||||
};
|
||||
use core_graphics::{
|
||||
geometry::{CGPoint, CGRect, CGSize},
|
||||
window,
|
||||
};
|
||||
|
||||
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 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")?;
|
||||
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::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
|
||||
)
|
||||
.context("window capture returned nothing")?;
|
||||
if image.bits_per_pixel() != 32 {
|
||||
bail!(
|
||||
"unexpected capture format: {} bits per pixel",
|
||||
image.bits_per_pixel()
|
||||
);
|
||||
}
|
||||
let (width, height, stride) = (image.width(), image.height(), image.bytes_per_row());
|
||||
let data = image.data();
|
||||
let bytes = data.bytes();
|
||||
let mut rgba = Vec::with_capacity(width * height * 4);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let i = y * stride + x * 4;
|
||||
rgba.extend_from_slice(&[bytes[i + 2], bytes[i + 1], bytes[i], bytes[i + 3]]);
|
||||
}
|
||||
}
|
||||
image::save_buffer(
|
||||
path,
|
||||
&rgba,
|
||||
width as u32,
|
||||
height as u32,
|
||||
image::ColorType::Rgba8,
|
||||
)
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn snapshot(_: &Path) -> Result<()> {
|
||||
bail!("capture needs macOS window APIs")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
mod capture;
|
||||
mod pages;
|
||||
mod shell;
|
||||
mod ui;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
actions!(gallery, [Quit, FocusNext, FocusPrev]);
|
||||
|
||||
struct Args {
|
||||
page: usize,
|
||||
capture: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args> {
|
||||
let mut args = Args {
|
||||
page: 0,
|
||||
capture: None,
|
||||
};
|
||||
let mut iter = std::env::args().skip(1);
|
||||
while let Some(flag) = iter.next() {
|
||||
let value = iter
|
||||
.next()
|
||||
.with_context(|| format!("{flag} needs a value"))?;
|
||||
match flag.as_str() {
|
||||
"--page" => {
|
||||
args.page = pages::find(&value).with_context(|| format!("no page {value}"))?
|
||||
}
|
||||
"--capture" => args.capture = Some(PathBuf::from(value)),
|
||||
other => bail!("unknown flag {other}"),
|
||||
}
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
let args = parse_args()?;
|
||||
Application::new()
|
||||
.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.on_action(|_: &Quit, cx| cx.quit());
|
||||
let bounds = Bounds::centered(None, size(px(1280.0), px(820.0)), cx);
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("Ely".into()),
|
||||
appears_transparent: true,
|
||||
traffic_light_position: Some(point(px(16.0), px(18.0))),
|
||||
}),
|
||||
window_min_size: Some(size(px(880.0), px(560.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let window = cx
|
||||
.open_window(options, |window, cx| {
|
||||
cx.new(|cx| shell::Gallery::new(args.page, window, cx))
|
||||
})
|
||||
.expect("gallery window failed to open");
|
||||
cx.activate(true);
|
||||
if let Some(dir) = args.capture {
|
||||
capture::run(window, dir, cx);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use gpui::{AnyElement, App, ElementId, IntoElement, ParentElement, Styled, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::ui::{row, section, specimen, specimens};
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 5,
|
||||
slug: "buttons",
|
||||
title: "Buttons & Actions",
|
||||
summary: "Seven variants, three sizes, one focus ring.",
|
||||
render,
|
||||
};
|
||||
|
||||
const VARIANTS: [(ButtonVariant, &str); 7] = [
|
||||
(ButtonVariant::Primary, "Primary"),
|
||||
(ButtonVariant::Secondary, "Secondary"),
|
||||
(ButtonVariant::Outline, "Outline"),
|
||||
(ButtonVariant::Ghost, "Ghost"),
|
||||
(ButtonVariant::Subtle, "Subtle"),
|
||||
(ButtonVariant::Danger, "Danger"),
|
||||
(ButtonVariant::Link, "Link"),
|
||||
];
|
||||
|
||||
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let sizes = [
|
||||
(ControlSize::Sm, "Small"),
|
||||
(ControlSize::Md, "Medium"),
|
||||
(ControlSize::Lg, "Large"),
|
||||
];
|
||||
|
||||
div()
|
||||
.child(
|
||||
section("Button", "Primary is ink. Color is saved for meaning.", cx)
|
||||
.child(row().children(VARIANTS.map(|(variant, name)| {
|
||||
Button::new(name, name)
|
||||
.variant(variant)
|
||||
.on_click(move |_, _, _| log::info!("gallery: {name} clicked"))
|
||||
})))
|
||||
.child(row().children(VARIANTS.map(|(variant, name)| {
|
||||
Button::new(ElementId::Name(format!("{name}-off").into()), name)
|
||||
.variant(variant)
|
||||
.disabled(true)
|
||||
}))),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"Sizes",
|
||||
"Heights follow density. Tab moves the focus ring.",
|
||||
cx,
|
||||
)
|
||||
.child(row().children(sizes.map(|(size, name)| {
|
||||
Button::new(ElementId::Name(format!("size-{name}").into()), name)
|
||||
.primary()
|
||||
.size(size)
|
||||
}))),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"With icons",
|
||||
"Leading icon for verbs. Trailing for menus.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
row()
|
||||
.child(
|
||||
Button::new("new", "New file")
|
||||
.primary()
|
||||
.icon(IconName::Plus),
|
||||
)
|
||||
.child(Button::new("share", "Share").icon(IconName::Share2))
|
||||
.child(
|
||||
Button::new("sort", "Sort")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.trailing_icon(IconName::ChevronDown),
|
||||
)
|
||||
.child(
|
||||
Button::new("delete", "Delete")
|
||||
.variant(ButtonVariant::Danger)
|
||||
.icon(IconName::Trash2),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Icon button", "Square. Ghost by default.", cx).child(specimens().children(
|
||||
sizes.map(|(size, name)| {
|
||||
specimen(
|
||||
name,
|
||||
row()
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new(
|
||||
ElementId::Name(format!("{name}-a").into()),
|
||||
IconName::Bold,
|
||||
)
|
||||
.size(size),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(
|
||||
ElementId::Name(format!("{name}-b").into()),
|
||||
IconName::Italic,
|
||||
)
|
||||
.size(size),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(
|
||||
ElementId::Name(format!("{name}-c").into()),
|
||||
IconName::Underline,
|
||||
)
|
||||
.size(size)
|
||||
.variant(ButtonVariant::Subtle),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(
|
||||
ElementId::Name(format!("{name}-d").into()),
|
||||
IconName::Link,
|
||||
)
|
||||
.size(size)
|
||||
.variant(ButtonVariant::Outline),
|
||||
),
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
mod buttons;
|
||||
mod primitives;
|
||||
mod theme;
|
||||
|
||||
use gpui::{AnyElement, App, Window};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
pub const ALL: &[Page] = &[primitives::PAGE, buttons::PAGE, theme::PAGE];
|
||||
|
||||
pub fn find(slug: &str) -> Option<usize> {
|
||||
ALL.iter().position(|page| page.slug == slug)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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,324 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ely_gpui_component::{
|
||||
motion,
|
||||
theme::{ActiveTheme, Elevation, Radius, TextSize},
|
||||
};
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, FontStyle, FontWeight, HighlightStyle, Hsla,
|
||||
IntoElement, ParentElement, SharedString, Styled, StyledText, Window, div, px,
|
||||
};
|
||||
|
||||
use super::Page;
|
||||
use crate::ui::{section, specimen, specimens};
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 39,
|
||||
slug: "theme",
|
||||
title: "Theme",
|
||||
summary: "Tokens every component reads. Light and dark share one grammar.",
|
||||
render,
|
||||
};
|
||||
|
||||
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let surfaces = [
|
||||
("bg", colors.bg),
|
||||
("surface", colors.surface),
|
||||
("sunken", colors.sunken),
|
||||
("hover", colors.hover),
|
||||
("active", colors.active),
|
||||
("border", colors.border),
|
||||
("border strong", colors.border_strong),
|
||||
];
|
||||
let inks = [
|
||||
("fg", colors.fg),
|
||||
("muted", colors.fg_muted),
|
||||
("subtle", colors.fg_subtle),
|
||||
("disabled", colors.fg_disabled),
|
||||
("focus", colors.focus),
|
||||
("link", colors.link),
|
||||
];
|
||||
let signals = [
|
||||
("success", colors.success, colors.success_subtle),
|
||||
("warning", colors.warning, colors.warning_subtle),
|
||||
("danger", colors.danger, colors.danger_subtle),
|
||||
("info", colors.info, colors.info_subtle),
|
||||
];
|
||||
|
||||
div()
|
||||
.child(
|
||||
section(
|
||||
"Surfaces",
|
||||
"Warm neutrals. Depth comes from tone, not shadow.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.gap_3()
|
||||
.children(surfaces.map(|(name, color)| swatch(name, color, cx))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Ink", "Four steps of text. Blue marks focus and links.", cx).child(
|
||||
specimens()
|
||||
.gap_3()
|
||||
.children(inks.map(|(name, color)| swatch(name, color, cx))),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Signals", "Status colors, each with a quiet fill.", cx).child(
|
||||
specimens()
|
||||
.gap_3()
|
||||
.children(signals.map(|(name, strong, quiet)| {
|
||||
specimen(name, signal_pair(strong, quiet, cx), cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Series", "Eight chart colors, even in weight.", cx).child(
|
||||
specimens()
|
||||
.gap_2()
|
||||
.children(colors.chart.iter().enumerate().map(|(ix, &color)| {
|
||||
specimen(format!("{}", ix + 1), chip(color, 40.0, cx), cx)
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section("Syntax", "Code tokens sit below text in weight.", cx).child(code_sample(cx)),
|
||||
)
|
||||
.child(
|
||||
section("Type", "Inter for words. JetBrains Mono for code.", cx).child(type_scale(cx)),
|
||||
)
|
||||
.child(
|
||||
section("Shape", "Four radii. Three elevations.", cx)
|
||||
.child(
|
||||
specimens().children([Radius::Sm, Radius::Md, Radius::Lg, Radius::Xl].map(
|
||||
|radius| {
|
||||
specimen(
|
||||
format!("{radius:?}"),
|
||||
div()
|
||||
.size(px(64.0))
|
||||
.rounded(cx.theme().radius(radius))
|
||||
.bg(colors.sunken)
|
||||
.border_1()
|
||||
.border_color(colors.border_strong),
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(specimens().gap_8().pt_4().children(
|
||||
[Elevation::Raised, Elevation::Floating, Elevation::Modal].map(|level| {
|
||||
specimen(
|
||||
format!("{level:?}"),
|
||||
div()
|
||||
.w(px(120.0))
|
||||
.h(px(72.0))
|
||||
.rounded(cx.theme().radius(Radius::Lg))
|
||||
.bg(colors.overlay)
|
||||
.shadow(cx.theme().elevation(level)),
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"Motion",
|
||||
"Ease out to arrive. Springs only where a hand leads. Slowed to one second here.",
|
||||
cx,
|
||||
)
|
||||
.child(track(
|
||||
"ease-out",
|
||||
"ease out cubic",
|
||||
motion::ease_out_cubic,
|
||||
cx,
|
||||
))
|
||||
.child(track(
|
||||
"ease-in-out",
|
||||
"ease in out cubic",
|
||||
motion::ease_in_out_cubic,
|
||||
cx,
|
||||
))
|
||||
.child(track("spring", "spring · damping 0.75", motion::spring, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chip(color: Hsla, side: f32, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.w(px(side))
|
||||
.h(px(side))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(color)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
}
|
||||
|
||||
fn swatch(name: &'static str, color: Hsla, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
specimen(
|
||||
name,
|
||||
div()
|
||||
.w(px(88.0))
|
||||
.h(px(56.0))
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.bg(color)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn signal_pair(strong: Hsla, quiet: Hsla, cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.h(px(40.0))
|
||||
.px_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(quiet)
|
||||
.child(div().size(px(8.0)).rounded_full().bg(strong))
|
||||
.child(
|
||||
div()
|
||||
.text_color(strong)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child("Aa 12"),
|
||||
)
|
||||
}
|
||||
|
||||
fn code_sample(cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let syntax = &theme.colors.syntax;
|
||||
let text = "fn answer() -> u32 { let x = 42; x } // truth";
|
||||
let color = |c: Hsla| HighlightStyle {
|
||||
color: Some(c),
|
||||
..Default::default()
|
||||
};
|
||||
let spans = [
|
||||
(0..2, color(syntax.keyword)),
|
||||
(3..9, color(syntax.function)),
|
||||
(9..11, color(syntax.punctuation)),
|
||||
(12..14, color(syntax.operator)),
|
||||
(15..18, color(syntax.type_name)),
|
||||
(19..20, color(syntax.punctuation)),
|
||||
(21..24, color(syntax.keyword)),
|
||||
(25..26, color(syntax.variable)),
|
||||
(27..28, color(syntax.operator)),
|
||||
(29..31, color(syntax.number)),
|
||||
(31..32, color(syntax.punctuation)),
|
||||
(35..36, color(syntax.punctuation)),
|
||||
(
|
||||
37..45,
|
||||
HighlightStyle {
|
||||
color: Some(syntax.comment),
|
||||
font_style: Some(FontStyle::Italic),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
];
|
||||
div()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.bg(theme.colors.sunken)
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.text_color(theme.colors.fg)
|
||||
.child(StyledText::new(text).with_highlights(spans))
|
||||
}
|
||||
|
||||
fn type_scale(cx: &App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let steps = [
|
||||
(TextSize::Display, "Display 32", FontWeight::SEMIBOLD),
|
||||
(TextSize::Xxl, "Heading 24", FontWeight::SEMIBOLD),
|
||||
(TextSize::Xl, "Title 20", FontWeight::SEMIBOLD),
|
||||
(TextSize::Lg, "Subtitle 16", FontWeight::MEDIUM),
|
||||
(TextSize::Md, "Body large 14", FontWeight::NORMAL),
|
||||
(TextSize::Base, "Body 13", FontWeight::NORMAL),
|
||||
(TextSize::Sm, "Small 12", FontWeight::NORMAL),
|
||||
(TextSize::Xs, "Caption 11", FontWeight::NORMAL),
|
||||
];
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.children(steps.map(|(size, name, weight)| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.w(px(120.0))
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(SharedString::from(name)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(size))
|
||||
.font_weight(weight)
|
||||
.text_color(theme.colors.fg)
|
||||
.child("Quiet interfaces age well."),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn track(
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
ease: fn(f32) -> f32,
|
||||
cx: &App,
|
||||
) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let width = 360.0;
|
||||
let travel = width - 12.0;
|
||||
let knob = div()
|
||||
.absolute()
|
||||
.top(px(6.0))
|
||||
.size(px(12.0))
|
||||
.rounded_full()
|
||||
.bg(theme.colors.fg);
|
||||
let knob = if theme.reduced_motion {
|
||||
knob.left(px(travel)).into_any_element()
|
||||
} else {
|
||||
knob.with_animation(
|
||||
id,
|
||||
Animation::new(Duration::from_millis(2400)).repeat(),
|
||||
move |knob, t| {
|
||||
let offset = match t {
|
||||
t if t < 0.42 => ease(t / 0.42),
|
||||
t if t < 0.5 => 1.0,
|
||||
t if t < 0.92 => 1.0 - ease((t - 0.5) / 0.42),
|
||||
_ => 0.0,
|
||||
};
|
||||
knob.left(px(offset * travel))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
};
|
||||
specimen(
|
||||
name,
|
||||
div()
|
||||
.relative()
|
||||
.w(px(width))
|
||||
.h(px(24.0))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(11.5))
|
||||
.left_0()
|
||||
.w(px(width))
|
||||
.h(px(1.0))
|
||||
.bg(theme.colors.border),
|
||||
)
|
||||
.child(knob),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
primitives::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,
|
||||
};
|
||||
|
||||
use crate::{FocusNext, FocusPrev, pages};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Choice {
|
||||
Light,
|
||||
Dark,
|
||||
System,
|
||||
}
|
||||
|
||||
pub struct Gallery {
|
||||
page: usize,
|
||||
choice: Choice,
|
||||
focus: FocusHandle,
|
||||
scroll: ScrollHandle,
|
||||
_appearance: Subscription,
|
||||
}
|
||||
|
||||
impl Gallery {
|
||||
pub fn new(page: usize, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let appearance = cx.observe_window_appearance(window, |gallery, window, cx| {
|
||||
if gallery.choice == Choice::System {
|
||||
Theme::set_mode(window.appearance().into(), cx);
|
||||
}
|
||||
});
|
||||
let focus = cx.focus_handle();
|
||||
window.focus(&focus);
|
||||
Self {
|
||||
page,
|
||||
choice: Choice::Light,
|
||||
focus,
|
||||
scroll: ScrollHandle::new(),
|
||||
_appearance: appearance,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select(&mut self, page: usize, cx: &mut Context<Self>) {
|
||||
log::info!("gallery: page -> {}", pages::ALL[page].slug);
|
||||
self.page = page;
|
||||
self.scroll.set_offset(point(px(0.0), px(0.0)));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Viewport height and furthest scroll, in pixels.
|
||||
pub fn scroll_extent(&self) -> (Pixels, Pixels) {
|
||||
(
|
||||
self.scroll.bounds().size.height,
|
||||
self.scroll.max_offset().height,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn scroll_to(&mut self, y: Pixels, cx: &mut Context<Self>) {
|
||||
self.scroll.set_offset(point(px(0.0), -y));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn choose(&mut self, choice: Choice, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.choice = choice;
|
||||
let mode = match choice {
|
||||
Choice::Light => Mode::Light,
|
||||
Choice::Dark => Mode::Dark,
|
||||
Choice::System => window.appearance().into(),
|
||||
};
|
||||
Theme::set_mode(mode, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_none()
|
||||
.w(px(248.0))
|
||||
.h_full()
|
||||
.pt(px(56.0))
|
||||
.px_3()
|
||||
.border_r_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.pb_6()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Lg))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(colors.fg)
|
||||
.child("Ely"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child("GPUI Component"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("nav")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.overflow_y_scroll()
|
||||
.children(pages::ALL.iter().enumerate().map(|(ix, page)| {
|
||||
let selected = ix == self.page;
|
||||
div()
|
||||
.id(page.slug)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_3()
|
||||
.h(px(30.0))
|
||||
.px_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.cursor_pointer()
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.text_color(if selected { colors.fg } else { colors.fg_muted })
|
||||
.when(selected, |el| el.bg(colors.hover))
|
||||
.hover(|style| style.bg(colors.hover).text_color(colors.fg))
|
||||
.child(
|
||||
div()
|
||||
.w(px(18.0))
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(SharedString::from(format!("{:02}", page.number))),
|
||||
)
|
||||
.child(page.title)
|
||||
.on_click(cx.listener(move |gallery, _, _, cx| gallery.select(ix, cx)))
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn switcher(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
|
||||
let options = [
|
||||
(Choice::Light, IconName::Sun, "theme-light"),
|
||||
(Choice::Dark, IconName::Moon, "theme-dark"),
|
||||
(Choice::System, IconName::Monitor, "theme-system"),
|
||||
];
|
||||
let colors = &cx.theme().colors;
|
||||
div()
|
||||
.flex()
|
||||
.gap_0p5()
|
||||
.p_0p5()
|
||||
.rounded(cx.theme().radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.children(options.map(|(choice, icon, id)| {
|
||||
let variant = if choice == self.choice {
|
||||
ButtonVariant::Subtle
|
||||
} else {
|
||||
ButtonVariant::Ghost
|
||||
};
|
||||
IconButton::new(id, icon)
|
||||
.variant(variant)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(
|
||||
cx.listener(move |gallery, _, window, cx| {
|
||||
gallery.choose(choice, window, cx)
|
||||
}),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Gallery {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let page = &pages::ALL[self.page];
|
||||
let body = (page.render)(window, cx);
|
||||
let sidebar = self.sidebar(cx);
|
||||
let switcher = self.switcher(cx);
|
||||
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())
|
||||
.size_full()
|
||||
.flex()
|
||||
.bg(colors.bg)
|
||||
.text_color(colors.fg)
|
||||
.font_family(theme.font_family.clone())
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.child(sidebar)
|
||||
.child(
|
||||
div()
|
||||
.id("page")
|
||||
.flex_1()
|
||||
.h_full()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.scroll)
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(960.0))
|
||||
.px(px(56.0))
|
||||
.pt(px(56.0))
|
||||
.pb(px(96.0))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xxl))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(page.title),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(page.summary),
|
||||
),
|
||||
)
|
||||
.child(switcher),
|
||||
)
|
||||
.child(body),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use ely_gpui_component::theme::{ActiveTheme, TextSize};
|
||||
use gpui::{App, Div, FontWeight, IntoElement, ParentElement, SharedString, Styled, div};
|
||||
|
||||
/// Titled block of demos.
|
||||
pub fn section(title: impl Into<SharedString>, note: impl Into<SharedString>, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
div().flex().flex_col().gap_4().pt_10().child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Md))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(theme.colors.fg)
|
||||
.child(title.into()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(note.into()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn row() -> Div {
|
||||
div().flex().flex_wrap().items_center().gap_3()
|
||||
}
|
||||
|
||||
/// Row whose captions share one line.
|
||||
pub fn specimens() -> Div {
|
||||
div().flex().flex_wrap().items_end().gap_6()
|
||||
}
|
||||
|
||||
pub fn label(text: impl Into<SharedString>, cx: &App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(text.into())
|
||||
}
|
||||
|
||||
/// Demo with a caption beneath.
|
||||
pub fn specimen(caption: impl Into<SharedString>, body: impl IntoElement, cx: &App) -> Div {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(body)
|
||||
.child(label(caption, cx))
|
||||
}
|
||||
Reference in new issue
Block a user