Improve focus (#14)

- Add `FocusableCycle` trait to help impl cycle focus.
- Add `outline` style to StyledExt.
This commit is contained in:
Jason Lee 2024-07-04 14:05:14 +08:00 committed by GitHub
parent 24348ec874
commit 3ccadf6cc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 226 additions and 86 deletions

View file

@ -23,6 +23,7 @@ pub fn init(_app_state: Arc<AppState>, cx: &mut AppContext) {
Theme::init(cx);
ui::init(cx);
story::init(cx);
}
pub struct StoryWorkspace {

View file

@ -34,19 +34,19 @@ impl Render for ButtonStory {
.child(
section("Normal Button", cx)
.child(
Button::new("button-1")
Button::new("button-1", cx)
.label("Primary Button")
.style(ButtonStyle::Primary)
.on_click(Self::on_click),
)
.child(
Button::new("button-2")
Button::new("button-2", cx)
.label("Secondary Button")
.style(ButtonStyle::Secondary)
.on_click(Self::on_click),
)
.child(
Button::new("button-4")
Button::new("button-4", cx)
.label("Danger Button")
.style(ButtonStyle::Danger)
.on_click(Self::on_click),
@ -55,21 +55,21 @@ impl Render for ButtonStory {
.child(
section("Button with Icon", cx)
.child(
Button::new("button-icon-1")
Button::new("button-icon-1", cx)
.label("Confirm")
.icon(IconName::Check)
.style(ButtonStyle::Primary)
.on_click(Self::on_click),
)
.child(
Button::new("button-icon-2")
Button::new("button-icon-2", cx)
.label("Abort")
.icon(IconName::Close)
.style(ButtonStyle::Secondary)
.on_click(Self::on_click),
)
.child(
Button::new("button-icon-3")
Button::new("button-icon-3", cx)
.label("Maximize")
.icon(Icon::new(IconName::Maximize))
.style(ButtonStyle::Secondary)
@ -83,21 +83,21 @@ impl Render for ButtonStory {
.child(
section("Small Size", cx)
.child(
Button::new("button-6")
Button::new("button-6", cx)
.label("Primary Button")
.style(ButtonStyle::Primary)
.size(ButtonSize::Small)
.on_click(Self::on_click),
)
.child(
Button::new("button-7")
Button::new("button-7", cx)
.label("Secondary Button")
.style(ButtonStyle::Secondary)
.size(ButtonSize::Small)
.on_click(Self::on_click),
)
.child(
Button::new("button-8")
Button::new("button-8", cx)
.label("Danger Button")
.style(ButtonStyle::Danger)
.size(ButtonSize::Small)
@ -107,21 +107,21 @@ impl Render for ButtonStory {
.child(
section("XSmall Size", cx)
.child(
Button::new("button-xs-1")
Button::new("button-xs-1", cx)
.label("Primary Button")
.style(ButtonStyle::Primary)
.size(ButtonSize::XSmall)
.on_click(Self::on_click),
)
.child(
Button::new("button-xs-2")
Button::new("button-xs-2", cx)
.label("Secondary Button")
.style(ButtonStyle::Secondary)
.size(ButtonSize::XSmall)
.on_click(Self::on_click),
)
.child(
Button::new("button-xs-3")
Button::new("button-xs-3", cx)
.label("Danger Button")
.style(ButtonStyle::Danger)
.size(ButtonSize::XSmall)
@ -135,21 +135,21 @@ impl Render for ButtonStory {
.child(
section("Disabled Button", cx)
.child(
Button::new("button-disabled1")
Button::new("button-disabled1", cx)
.label("Disabled Button")
.style(ButtonStyle::Primary)
.on_click(Self::on_click)
.disabled(true),
)
.child(
Button::new("button-disabled1")
Button::new("button-disabled1", cx)
.label("Disabled Button")
.style(ButtonStyle::Secondary)
.on_click(Self::on_click)
.disabled(true),
)
.child(
Button::new("button-disabled1")
Button::new("button-disabled1", cx)
.label("Disabled Button")
.style(ButtonStyle::Danger)
.on_click(Self::on_click)
@ -159,19 +159,19 @@ impl Render for ButtonStory {
.child(
section("Selected Style", cx)
.child(
Button::new("button-selected-1")
Button::new("button-selected-1", cx)
.label("Selected Button")
.style(ButtonStyle::Primary)
.selected(true),
)
.child(
Button::new("button-selected-2")
Button::new("button-selected-2", cx)
.label("Selected Button")
.style(ButtonStyle::Secondary)
.selected(true),
)
.child(
Button::new("button-selected-3")
Button::new("button-selected-3", cx)
.label("Selected Button")
.style(ButtonStyle::Danger)
.selected(true),
@ -181,46 +181,46 @@ impl Render for ButtonStory {
.child(
section("Icon Button", cx)
.child(
Button::new("icon-button-0")
Button::new("icon-button-0", cx)
.icon(IconName::Search)
.style(ButtonStyle::Primary),
)
.child(Button::new("icon-button-1").icon(IconName::Info))
.child(Button::new("icon-button-1", cx).icon(IconName::Info))
.child(
Button::new("icon-button-2")
Button::new("icon-button-2", cx)
.icon(IconName::Close)
.style(ButtonStyle::Danger),
)
.child(
Button::new("icon-button-3")
Button::new("icon-button-3", cx)
.icon(IconName::Search)
.size(ButtonSize::Small)
.style(ButtonStyle::Primary),
)
.child(
Button::new("icon-button-4")
Button::new("icon-button-4", cx)
.icon(IconName::Info)
.size(ButtonSize::Small),
)
.child(
Button::new("icon-button-5")
Button::new("icon-button-5", cx)
.icon(IconName::Close)
.size(ButtonSize::Small)
.style(ButtonStyle::Danger),
)
.child(
Button::new("icon-button-6")
Button::new("icon-button-6", cx)
.icon(IconName::Search)
.size(ButtonSize::XSmall)
.style(ButtonStyle::Primary),
)
.child(
Button::new("icon-button-7")
Button::new("icon-button-7", cx)
.icon(IconName::Info)
.size(ButtonSize::XSmall),
)
.child(
Button::new("icon-button-8")
Button::new("icon-button-8", cx)
.icon(IconName::Close)
.size(ButtonSize::XSmall)
.style(ButtonStyle::Danger),

View file

@ -1,13 +1,24 @@
use gpui::{
ClickEvent, IntoElement, ParentElement as _, Render, Styled as _, View, ViewContext,
VisualContext, WindowContext,
actions, AppContext, ClickEvent, FocusHandle, FocusableView, InteractiveElement, IntoElement,
KeyBinding, ParentElement as _, Render, Styled, View, ViewContext, VisualContext,
WindowContext,
};
use ui::{input::TextInput, v_flex, IconName};
use ui::{button::Button, h_flex, input::TextInput, v_flex, FocusableCycle, IconName};
use crate::section;
actions!(input_story, [Tab, TabPrev]);
pub fn init(cx: &mut AppContext) {
cx.bind_keys([
KeyBinding::new("shift-tab", TabPrev, Some("InputStory")),
KeyBinding::new("tab", Tab, Some("InputStory")),
])
}
pub struct InputStory {
focus_handle: FocusHandle,
input1: View<TextInput>,
input2: View<TextInput>,
mash_input: View<TextInput>,
@ -54,6 +65,7 @@ impl InputStory {
});
Self {
focus_handle: cx.focus_handle(),
input1,
input2: cx.new_view(|cx| {
let mut input = TextInput::new(cx);
@ -77,11 +89,46 @@ impl InputStory {
fn on_change(ev: &ClickEvent, cx: &mut WindowContext) {
println!("Input changed: {:?}", ev);
}
fn tab(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
self.cycle_focus(true, cx);
}
fn tab_prev(&mut self, _: &TabPrev, cx: &mut ViewContext<Self>) {
self.cycle_focus(false, cx);
}
}
impl FocusableCycle for InputStory {
fn cycle_focus_handles(&self, cx: &mut ViewContext<Self>) -> Vec<FocusHandle> {
[
&self.input1,
&self.input2,
&self.disabled_input,
&self.mash_input,
&self.prefix_input1,
&self.both_input1,
&self.suffix_input1,
]
.iter()
.map(|v| v.focus_handle(cx))
.collect()
}
}
impl FocusableView for InputStory {
fn focus_handle(&self, cx: &gpui::AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InputStory {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_flex()
.key_context("InputStory")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::tab))
.on_action(cx.listener(Self::tab_prev))
.size_full()
.p_4()
.justify_start()
@ -102,5 +149,23 @@ impl Render for InputStory {
.child(self.both_input1.clone())
.child(self.suffix_input1.clone()),
)
.child(
h_flex()
.items_center()
.w_full()
.gap_3()
.child(
Button::new("btn-submit", cx)
.w_full()
.style(ui::button::ButtonStyle::Primary)
.label("Submit"),
)
.child(
Button::new("btn-cancel", cx)
.w_full()
.label("Cancel")
.into_element(),
),
)
}
}

View file

@ -21,10 +21,10 @@ pub use switch_story::SwitchStory;
pub use tooltip_story::TooltipStory;
use gpui::{
div, prelude::FluentBuilder as _, px, AnyElement, AnyView, AppContext, Div, Element,
EventEmitter, FocusableView, InteractiveElement, IntoElement, ParentElement, Pixels, Render,
ScrollHandle, SharedString, StatefulInteractiveElement, Styled as _, Task, View, ViewContext,
VisualContext, WindowContext,
div, prelude::FluentBuilder as _, px, AnyElement, AnyView, AppContext, Div, EventEmitter,
FocusableView, InteractiveElement, IntoElement, ParentElement, Pixels, Render, ScrollHandle,
SharedString, StatefulInteractiveElement, Styled as _, Task, View, ViewContext, VisualContext,
WindowContext,
};
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
@ -33,7 +33,11 @@ use workspace::{
};
use anyhow::Result;
use ui::{divider::Divider, h_flex, label::Label, v_flex, StyledExt};
use ui::{divider::Divider, h_flex, label::Label, v_flex};
pub fn init(cx: &mut AppContext) {
input_story::init(cx);
}
pub fn story_case(
name: &'static str,
@ -286,7 +290,6 @@ impl Panel for MyPanel {
}
fn set_size(&mut self, size: Option<gpui::Pixels>, cx: &mut ViewContext<Self>) {
dbg!("================ set_size", size);
if let Some(size) = size {
self.update_size(size)
}

View file

@ -2,9 +2,9 @@ use core::time;
use fake::Fake;
use gpui::{
actions, div, prelude::FluentBuilder as _, px, ElementId, FocusHandle, FocusableView,
InteractiveElement, IntoElement, ParentElement, Render, RenderOnce, Styled, Timer, View,
ViewContext, VisualContext, WindowContext,
actions, div, px, ElementId, FocusHandle, FocusableView, InteractiveElement, IntoElement,
ParentElement, Render, RenderOnce, Styled, Timer, View, ViewContext, VisualContext,
WindowContext,
};
use ui::{

View file

@ -197,7 +197,7 @@ impl Render for PickerStory {
.gap_6()
.child(
v_flex().items_start().child(
Button::new("show-picker")
Button::new("show-picker", cx)
.label("Show Picker...")
.icon(IconName::Search)
.style(ButtonStyle::Primary)

View file

@ -40,7 +40,7 @@ impl Render for Form {
.child("This is a form container.")
.child(self.input1.clone())
.child(
Button::primary("submit", "Submit")
Button::primary("submit", "Submit", cx)
.on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
)
}
@ -62,7 +62,7 @@ impl PopoverStory {
}
impl Render for PopoverStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let form = self.form.clone();
v_flex()
@ -76,15 +76,15 @@ impl Render for PopoverStory {
.child(
v_flex().gap_4().child(
Popover::new("info-top-left")
.trigger(Button::new("info-top-left").label("Top Left"))
.trigger(Button::new("info-top-left", cx).label("Top Left"))
.content(|cx| {
PopoverContent::new(cx, |_| {
PopoverContent::new(cx, |cx| {
v_flex()
.gap_4()
.child("Hello, this is a Popover.")
.child(Divider::horizontal())
.child(
Button::new("info1")
Button::new("info1", cx)
.label("Yes")
.width(px(80.))
.size(ButtonSize::Small),
@ -97,15 +97,15 @@ impl Render for PopoverStory {
.child(
Popover::new("info-top-right")
.anchor(AnchorCorner::TopRight)
.trigger(Button::new("info-top-right").label("Top Right"))
.trigger(Button::new("info-top-right", cx).label("Top Right"))
.content(|cx| {
PopoverContent::new(cx, |_| {
PopoverContent::new(cx, |cx| {
v_flex()
.gap_4()
.child("Hello, this is a Popover on the Top Right.")
.child(Divider::horizontal())
.child(
Button::new("info1")
Button::new("info1", cx)
.label("Yes")
.width(px(80.))
.size(ButtonSize::Small),
@ -124,7 +124,9 @@ impl Render for PopoverStory {
Popover::new("info-bottom-left")
.anchor(AnchorCorner::BottomLeft)
.trigger(
Button::new("pop").label("Popup with Form").width(px(300.)),
Button::new("pop", cx)
.label("Popup with Form")
.width(px(300.)),
)
.content(move |_| form.clone()),
)
@ -133,18 +135,18 @@ impl Render for PopoverStory {
.anchor(AnchorCorner::BottomRight)
.mouse_button(MouseButton::Right)
.trigger(
Button::new("pop")
Button::new("pop", cx)
.label("Mouse Right Click")
.width(px(300.)),
)
.content(|cx| {
PopoverContent::new(cx, |_| {
PopoverContent::new(cx, |cx| {
v_flex()
.gap_4()
.child("Hello, this is a Popover on the Bottom Right.")
.child(Divider::horizontal())
.child(
Button::new("info1")
Button::new("info1", cx)
.label("Yes")
.width(px(80.))
.size(ButtonSize::Small),

View file

@ -1,5 +1,5 @@
use gpui::{
div, px, CursorStyle, InteractiveElement, ParentElement, Render, StatefulInteractiveElement,
div, CursorStyle, InteractiveElement, ParentElement, Render, StatefulInteractiveElement,
Styled, View, VisualContext as _, WindowContext,
};
@ -25,7 +25,7 @@ impl TooltipStory {
}
impl Render for TooltipStory {
fn render(&mut self, _cx: &mut gpui::ViewContext<Self>) -> impl gpui::IntoElement {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl gpui::IntoElement {
v_flex()
.p_4()
.gap_5()
@ -33,7 +33,7 @@ impl Render for TooltipStory {
div()
.cursor(CursorStyle::PointingHand)
.child(
Button::new("button")
Button::new("button", cx)
.label("Hover me")
.style(ButtonStyle::Primary),
)

View file

@ -1,12 +1,13 @@
use crate::{
h_flex,
theme::{ActiveTheme, Colorize as _, ThemeMode},
Clickable, Disableable, Icon, Selectable,
Clickable, Disableable, Icon, Selectable, StyledExt,
};
use gpui::{
div, prelude::FluentBuilder as _, px, ClickEvent, DefiniteLength, Div, ElementId, Hsla,
InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement as _, Styled, WindowContext,
div, prelude::FluentBuilder as _, px, ClickEvent, DefiniteLength, Div, ElementId, FocusHandle,
Focusable, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, View, ViewContext,
VisualContext, WindowContext,
};
pub enum ButtonRounded {
@ -34,6 +35,7 @@ pub enum ButtonStyle {
pub struct Button {
pub base: Div,
id: ElementId,
focus_handle: FocusHandle,
icon: Option<Icon>,
label: Option<SharedString>,
disabled: bool,
@ -48,9 +50,10 @@ pub struct Button {
}
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
pub fn new(id: impl Into<ElementId>, cx: &mut WindowContext) -> Self {
Self {
base: div(),
focus_handle: cx.focus_handle(),
id: id.into(),
icon: None,
label: None,
@ -66,16 +69,28 @@ impl Button {
}
}
pub fn primary(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self::new(id).label(label).style(ButtonStyle::Primary)
pub fn primary(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
cx: &mut WindowContext,
) -> Self {
Self::new(id, cx).label(label).style(ButtonStyle::Primary)
}
pub fn danger(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self::new(id).label(label).style(ButtonStyle::Danger)
pub fn danger(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
cx: &mut WindowContext,
) -> Self {
Self::new(id, cx).label(label).style(ButtonStyle::Danger)
}
pub fn small(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self::new(id).label(label).size(ButtonSize::Small)
pub fn small(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
cx: &mut WindowContext,
) -> Self {
Self::new(id, cx).label(label).size(ButtonSize::Small)
}
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
@ -141,14 +156,22 @@ impl Clickable for Button {
}
}
impl Styled for Button {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Button {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let theme = cx.theme();
let style: ButtonStyle = self.style;
let normal_style = style.normal(cx);
let focused = self.focus_handle.is_focused(cx);
self.base
.id(self.id)
.track_focus(&self.focus_handle)
.flex()
.items_center()
.justify_center()
@ -192,6 +215,9 @@ impl RenderOnce for Button {
.border_color(normal_style.border)
.bg(normal_style.bg)
})
.when(focused, |this| {
this.border_color(cx.theme().ring).debug_blue()
})
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| {

View file

@ -23,7 +23,7 @@ use crate::{
list::ListItem,
picker::{self, Picker, PickerDelegate},
theme::ActiveTheme,
Icon, IconName,
Icon, IconName, StyledExt,
};
/// A trait for items that can be displayed in a dropdown.
@ -221,13 +221,12 @@ where
D: DropdownDelegate + 'static,
{
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let group_id = format!("dropdown-group:{}", self.id);
let title = self.title.clone().unwrap_or_else(|| "Select...".into());
let focused = self.focus_handle.is_focused(cx);
div()
.key_context("Dropdown")
.group(group_id.clone())
.group(format!("dropdown-group:{}", self.id))
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::up))
.on_action(cx.listener(Self::down))
@ -248,9 +247,9 @@ where
.border_color(cx.theme().input)
.rounded(px(cx.theme().radius))
.shadow_sm()
.when(focused, |this| this.outline(cx))
.px_3()
.py_2()
.when(focused, |this| this.border_color(cx.theme().ring))
.on_click(cx.listener(|this, _, cx| {
this.open = !this.open;
cx.notify();
@ -280,20 +279,12 @@ struct DropdownMenuElement<D: DropdownDelegate + 'static> {
dropdown: View<Dropdown<D>>,
}
#[derive(Default)]
struct DropdownMenuElementState {
menu_element: Option<AnyElement>,
layout_id: Option<LayoutId>,
}
impl Default for DropdownMenuElementState {
fn default() -> Self {
Self {
menu_element: None,
layout_id: None,
}
}
}
impl<D> IntoElement for DropdownMenuElement<D>
where
D: DropdownDelegate + 'static,

View file

@ -0,0 +1,40 @@
use gpui::{FocusHandle, ViewContext};
/// A trait for views that can cycle focus between its children.
///
/// This will provide a default implementation for the `cycle_focus` method that will cycle focus.
///
/// You should implement the `cycle_focus_handles` method to return a list of focus handles that
/// should be cycled, and the cycle will follow the order of the list.
pub trait FocusableCycle {
/// Returns a list of focus handles that should be cycled.
fn cycle_focus_handles(&self, cx: &mut ViewContext<Self>) -> Vec<FocusHandle>
where
Self: Sized;
/// Cycles focus between the focus handles returned by `cycle_focus_handles`.
/// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev.
fn cycle_focus(&self, is_next: bool, cx: &mut ViewContext<Self>)
where
Self: Sized,
{
let focused_handle = cx.focused();
let handles = self.cycle_focus_handles(cx);
let handles = if is_next {
handles
} else {
handles.into_iter().rev().collect()
};
let fallback_handle = handles[0].clone();
let target_focus_handle = handles
.into_iter()
.skip_while(|handle| Some(handle) != focused_handle.as_ref())
.skip(1)
.next()
.unwrap_or(fallback_handle);
target_focus_handle.focus(cx);
cx.stop_propagation();
}
}

View file

@ -2,6 +2,7 @@ use std::ops::Range;
use crate::event::InterativeElementExt as _;
use crate::theme::ActiveTheme;
use crate::StyledExt as _;
use blink_cursor::BlinkCursor;
use gpui::*;
use prelude::FluentBuilder as _;
@ -679,14 +680,11 @@ impl Render for TextInput {
.h_10()
.when(self.appearance, |this| {
this.bg(cx.theme().input)
.border_color(if focused {
cx.theme().ring
} else {
cx.theme().input
})
.border_color(cx.theme().input)
.border_1()
.rounded(px(cx.theme().radius))
.shadow_sm()
.when(focused, |this| this.outline(cx))
.px_3()
.bg(if self.disabled {
cx.theme().muted

View file

@ -1,6 +1,7 @@
mod clickable;
mod disableable;
mod event;
mod focusable;
mod icon;
mod scrollbar;
mod selectable;
@ -28,6 +29,7 @@ pub mod tooltip;
pub use clickable::Clickable;
pub use disableable::Disableable;
pub use event::InterativeElementExt;
pub use focusable::FocusableCycle;
pub use selectable::{Selectable, Selection};
pub use icon::*;

View file

@ -1,5 +1,5 @@
use crate::theme::{hsl, ActiveTheme};
use gpui::{hsla, point, px, BoxShadow, Styled, WindowContext};
use gpui::{div, hsla, point, px, BoxShadow, Styled, WindowContext};
use smallvec::{smallvec, SmallVec};
pub enum ElevationIndex {
@ -96,6 +96,18 @@ pub trait StyledExt: Styled + Sized {
fn debug_pink(self) -> Self {
self.border_1().border_color(hsl(300., 100., 47.))
}
/// Render a border with a width of 1px, color ring color
///
/// Please ensure this after the shadow
fn outline(self, cx: &WindowContext) -> Self {
self.shadow(smallvec![BoxShadow {
color: cx.theme().ring,
offset: point(px(0.), px(0.)),
blur_radius: px(0.1),
spread_radius: px(1.),
}])
}
}
impl<E: Styled> StyledExt for E {}

View file

@ -167,7 +167,7 @@ impl Colors {
accent_foreground: hsl(240.0, 5.9, 10.0),
border: hsl(240.0, 5.9, 90.0),
input: hsl(240.0, 5.9, 90.0),
ring: hsl(240.0, 5.9, 10.0),
ring: hsl(240.0, 5.9, 65.0),
selection: hsl(211.0, 97.0, 85.0),
scrollbar: Hsla::transparent_black(),
scrollbar_thumb: hsl(240.0, 5.9, 90.0).opacity(0.7),