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");
|
||||
}
|
||||
Reference in New Issue
Block a user