add button
This commit is contained in:
@@ -6,6 +6,11 @@ description = "The GPUI components every application needs, one cargo feature ea
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
gpui-native-icons = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[features]
|
||||
default = ["full"]
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! The push button: the API template for every control that follows.
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, BoxShadow, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
|
||||
Styled as _, Window, px,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::ActiveTheme as _;
|
||||
|
||||
use crate::common::h_flex;
|
||||
|
||||
/// The single activation handler, shared by pointer, Enter, and Space.
|
||||
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
|
||||
|
||||
/// Which visual treatment a button uses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ButtonVariant {
|
||||
/// Accent-filled; the platform's default action.
|
||||
#[default]
|
||||
Primary,
|
||||
/// Neutral and bordered; the everyday companion to `Primary`.
|
||||
Secondary,
|
||||
/// Unpainted until hovered, for toolbars and dense UI.
|
||||
Ghost,
|
||||
/// The destructive action.
|
||||
Destructive,
|
||||
}
|
||||
|
||||
/// A push button: pointer, Enter, and Space all fire the single `on_click`.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Button {
|
||||
id: ElementId,
|
||||
label: Option<SharedString>,
|
||||
icon: Option<IconRole>,
|
||||
variant: ButtonVariant,
|
||||
disabled: bool,
|
||||
a11y_label: Option<SharedString>,
|
||||
on_click: Option<ClickHandler>,
|
||||
}
|
||||
|
||||
impl Button {
|
||||
/// Creates a primary button.
|
||||
pub fn new<I>(id: I) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: None,
|
||||
icon: None,
|
||||
variant: ButtonVariant::default(),
|
||||
disabled: false,
|
||||
a11y_label: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the visible label.
|
||||
pub fn label<L>(mut self, label: L) -> Self
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
self.label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows an icon beside the label.
|
||||
pub fn icon(mut self, role: IconRole) -> Self {
|
||||
self.icon = Some(role);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the visual treatment.
|
||||
pub fn variant(mut self, variant: ButtonVariant) -> Self {
|
||||
self.variant = variant;
|
||||
self
|
||||
}
|
||||
|
||||
/// Renders the button inert, unfocusable, and dimmed; disabled always wins.
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Overrides the accessibility label, which defaults to the visible label.
|
||||
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||
where
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
self.a11y_label = Some(label.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The single activation handler, fired by pointer, Enter, and Space.
|
||||
pub fn on_click<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.on_click = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Button {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors;
|
||||
let ramps = theme.ramps;
|
||||
|
||||
let on_click = self.on_click;
|
||||
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
|
||||
|
||||
let (background, foreground, hover, pressed) = match self.variant {
|
||||
ButtonVariant::Primary => (
|
||||
Some(ramps.primary.base),
|
||||
colors.primary_foreground,
|
||||
ramps.primary.hover,
|
||||
ramps.primary.pressed,
|
||||
),
|
||||
ButtonVariant::Secondary => (
|
||||
Some(colors.secondary),
|
||||
colors.secondary_foreground,
|
||||
ramps.secondary.hover,
|
||||
ramps.secondary.pressed,
|
||||
),
|
||||
ButtonVariant::Ghost => (
|
||||
None,
|
||||
colors.foreground,
|
||||
colors.muted,
|
||||
ramps.secondary.pressed,
|
||||
),
|
||||
ButtonVariant::Destructive => (
|
||||
Some(ramps.destructive.base),
|
||||
colors.destructive_foreground,
|
||||
ramps.destructive.hover,
|
||||
ramps.destructive.pressed,
|
||||
),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.role(Role::Button)
|
||||
.justify_center()
|
||||
.h_7()
|
||||
.px_4()
|
||||
.gap_1p5()
|
||||
.rounded(theme.radius.control)
|
||||
.text_sm()
|
||||
.when(self.variant == ButtonVariant::Secondary, |button| {
|
||||
button.border_1().border_color(colors.border)
|
||||
})
|
||||
.when_some(background, |button, background| button.bg(background))
|
||||
.text_color(foreground)
|
||||
.when_some(a11y_label, |button, label| button.aria_label(label))
|
||||
.when_some(self.icon, |button, role| {
|
||||
button.child(icon(role).color(foreground))
|
||||
})
|
||||
.when_some(self.label, |button, label| button.child(label))
|
||||
.when(self.disabled, |button| button.opacity(0.5))
|
||||
.when(!self.disabled, |button| {
|
||||
button
|
||||
.cursor_pointer()
|
||||
.focusable()
|
||||
.tab_stop(true)
|
||||
.hover(move |style| style.bg(hover))
|
||||
.active(move |style| style.bg(pressed))
|
||||
.focus_visible(|style| {
|
||||
// Paint-only keyboard ring; the width joins the native
|
||||
// metrics table when that lands.
|
||||
style.shadow(vec![
|
||||
BoxShadow::new(px(0.), px(0.), colors.ring).spread_radius(px(3.)),
|
||||
])
|
||||
})
|
||||
.when_some(on_click, |button, on_click| {
|
||||
button.on_click(move |event, window, cx| on_click(event, window, cx))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Shared element constructors and small style helpers.
|
||||
|
||||
use gpui::{Div, Styled as _, div};
|
||||
|
||||
/// A row whose children center on the cross axis.
|
||||
pub fn h_flex() -> Div {
|
||||
div().flex().flex_row().items_center()
|
||||
}
|
||||
|
||||
/// A column whose children stretch across the cross axis.
|
||||
pub fn v_flex() -> Div {
|
||||
div().flex().flex_col()
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
//! The components every application needs, one cargo feature each.
|
||||
//!
|
||||
//! Components land one per feature: `button`, `checkbox`, `input`, …
|
||||
//! `default-features = false` plus the features you want for a lean build;
|
||||
//! `full` (the default) for everything. Features gate whole modules and are
|
||||
//! additive only; each component adds its `#[cfg(feature = "…")] pub mod`
|
||||
//! declaration together with its file. Shared helpers promoted per the
|
||||
//! third-use rule live in the ungated `common` module once one exists.
|
||||
|
||||
pub mod common;
|
||||
|
||||
#[cfg(feature = "button")]
|
||||
pub mod button;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyWindowHandle, AppContext as _, Context, IntoElement, KeyDownEvent, KeyUpEvent, Keystroke,
|
||||
Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window,
|
||||
div, point, px,
|
||||
};
|
||||
use gpui_native_core::button::Button;
|
||||
use gpui_native_theme::Theme;
|
||||
|
||||
struct Harness {
|
||||
clicks: Rc<RefCell<usize>>,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Render for Harness {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let clicks = self.clicks.clone();
|
||||
div().size_full().flex().flex_col().child(
|
||||
Button::new("test-button")
|
||||
.label("Save")
|
||||
.disabled(self.disabled)
|
||||
.on_click(move |_, _, _| *clicks.borrow_mut() += 1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A maximized window whose button stretches across the top 28px, so
|
||||
/// `point(px(60.), px(14.))` always lands on it.
|
||||
fn button_window(disabled: bool) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<usize>>) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
let clicks = Rc::new(RefCell::new(0));
|
||||
let harness_clicks = clicks.clone();
|
||||
let window = cx.add_window(move |_, _| Harness {
|
||||
clicks: harness_clicks,
|
||||
disabled,
|
||||
});
|
||||
let window = window.into();
|
||||
cx.update_window(window, |_, window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
})
|
||||
.unwrap();
|
||||
(VisualTestContext::from_window(window, &cx), window, clicks)
|
||||
}
|
||||
|
||||
fn draw(cx: &mut VisualTestContext, window: AnyWindowHandle) {
|
||||
cx.update_window(window, |_, window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_click_fires_on_click() {
|
||||
let (mut cx, _window, clicks) = button_window(false);
|
||||
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
||||
assert_eq!(*clicks.borrow(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_button_ignores_clicks() {
|
||||
let (mut cx, _window, clicks) = button_window(true);
|
||||
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
||||
assert_eq!(*clicks.borrow(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_and_space_fire_on_click_while_focused() {
|
||||
let (mut cx, window, clicks) = button_window(false);
|
||||
cx.simulate_click(point(px(60.), px(14.)), Modifiers::default());
|
||||
assert_eq!(*clicks.borrow(), 1, "the pointer click itself activates");
|
||||
|
||||
// Repaint with the button focused so its Enter/Space handlers register.
|
||||
draw(&mut cx, window);
|
||||
|
||||
for key in ["enter", "space"] {
|
||||
cx.simulate_event(KeyDownEvent {
|
||||
keystroke: Keystroke::parse(key).unwrap(),
|
||||
is_held: false,
|
||||
prefer_character_input: false,
|
||||
});
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).unwrap(),
|
||||
});
|
||||
}
|
||||
|
||||
assert_eq!(*clicks.borrow(), 3, "Enter and Space each fire on_click");
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use crate::ThemeVariant;
|
||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
|
||||
pub struct ThemeDefinition {
|
||||
pub mode: Option<ThemeVariant>,
|
||||
pub control_radius: Option<f32>,
|
||||
pub accent: Option<String>,
|
||||
pub accent_foreground: Option<String>,
|
||||
pub background: Option<String>,
|
||||
|
||||
+26
-1
@@ -20,7 +20,7 @@ pub use ramps::{AccentShades, Oklch};
|
||||
pub use resolve::{Ramps, ResolvedTheme};
|
||||
pub use system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
|
||||
|
||||
use gpui::{App, Global, WindowAppearance};
|
||||
use gpui::{App, Global, Pixels, WindowAppearance, px};
|
||||
|
||||
/// Light or dark, resolved from the system appearance.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
@@ -46,11 +46,35 @@ pub struct Theme {
|
||||
pub variant: ThemeVariant,
|
||||
pub colors: ColorTokens,
|
||||
pub ramps: Ramps,
|
||||
pub radius: Radius,
|
||||
pub accessibility: AccessibilityPreferences,
|
||||
}
|
||||
|
||||
impl Global for Theme {}
|
||||
|
||||
/// Corner radii components paint from; grows one class at a time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Radius {
|
||||
pub control: Pixels,
|
||||
}
|
||||
|
||||
impl Default for Radius {
|
||||
fn default() -> Self {
|
||||
Self { control: px(6.) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the resolved theme global from any app context.
|
||||
pub trait ActiveTheme {
|
||||
fn theme(&self) -> &Theme;
|
||||
}
|
||||
|
||||
impl ActiveTheme for App {
|
||||
fn theme(&self) -> &Theme {
|
||||
Theme::global(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// Reads the theme global. Panics before `init`.
|
||||
pub fn global(cx: &App) -> &Self {
|
||||
@@ -62,6 +86,7 @@ impl Theme {
|
||||
variant: resolved.variant,
|
||||
colors: resolved.colors,
|
||||
ramps: resolved.ramps,
|
||||
radius: resolved.radius,
|
||||
accessibility: resolved.accessibility,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
use crate::definition::ThemeDefinition;
|
||||
use crate::ramps::{AccentShades, Oklch, contrast_foreground, derive_shades, mix};
|
||||
use crate::system::SystemTheme;
|
||||
use crate::{ColorTokens, ThemeVariant};
|
||||
use crate::{ColorTokens, Radius, ThemeVariant};
|
||||
use gpui::px;
|
||||
|
||||
/// Derived state shades for the colors that get pressed and hovered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Ramps {
|
||||
pub primary: AccentShades,
|
||||
pub secondary: AccentShades,
|
||||
pub destructive: AccentShades,
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ pub struct ResolvedTheme {
|
||||
pub variant: ThemeVariant,
|
||||
pub colors: ColorTokens,
|
||||
pub ramps: Ramps,
|
||||
pub radius: Radius,
|
||||
pub accessibility: crate::system::AccessibilityPreferences,
|
||||
}
|
||||
|
||||
@@ -134,10 +137,20 @@ pub(crate) fn resolve(
|
||||
.to_hsla();
|
||||
}
|
||||
|
||||
let mut radius = Radius::default();
|
||||
if let Some(control) = preset.and_then(|def| def.control_radius) {
|
||||
radius.control = px(control);
|
||||
}
|
||||
|
||||
ResolvedTheme {
|
||||
variant,
|
||||
ramps: Ramps {
|
||||
primary: derive_shades(primary, colors.background, variant == ThemeVariant::Dark),
|
||||
secondary: derive_shades(
|
||||
colors.secondary,
|
||||
colors.background,
|
||||
variant == ThemeVariant::Dark,
|
||||
),
|
||||
destructive: derive_shades(
|
||||
destructive,
|
||||
colors.background,
|
||||
@@ -145,6 +158,7 @@ pub(crate) fn resolve(
|
||||
),
|
||||
},
|
||||
colors,
|
||||
radius,
|
||||
accessibility,
|
||||
}
|
||||
}
|
||||
@@ -188,6 +202,8 @@ mod tests {
|
||||
assert_eq!(resolved.colors.background, base.background);
|
||||
assert_eq!(resolved.colors.accent, base.accent);
|
||||
assert_eq!(resolved.ramps.primary.base, base.primary);
|
||||
assert_eq!(resolved.ramps.secondary.base, base.secondary);
|
||||
assert_eq!(resolved.radius.control, px(6.));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +256,7 @@ mod tests {
|
||||
#[test]
|
||||
fn preset_beats_system_and_the_mode_override_beats_everything() {
|
||||
let def = ThemeDefinition::parse_toml(
|
||||
"mode = \"dark\"\nbackground = \"#1a1b26\"\naccent = \"#7aa2f7\"\n",
|
||||
"mode = \"dark\"\nbackground = \"#1a1b26\"\naccent = \"#7aa2f7\"\ncontrol_radius = 12.0\n",
|
||||
)
|
||||
.unwrap();
|
||||
let palette = PartialPalette {
|
||||
@@ -259,6 +275,8 @@ mod tests {
|
||||
assert!((bg.b - 0x26 as f32 / 255.0).abs() < 0.001);
|
||||
let accent = Rgba::from(resolved.colors.accent);
|
||||
assert!((accent.r - 0x7a as f32 / 255.0).abs() < 0.001);
|
||||
// And the metric override beats the radius default.
|
||||
assert_eq!(resolved.radius.control, px(12.));
|
||||
|
||||
let forced = resolve(
|
||||
&system(ThemeVariant::Light, palette, None),
|
||||
|
||||
Reference in New Issue
Block a user