gpui-component/crates/story/src/lib.rs
Jason Lee 118a0f500e
scrollbar: Introduce overflow_scrollbar to adds Scrollbars to elements. (#1694)
## Description

1. Add `overflow_scrollbar`, `overflow_y_scrollbar`, `overflow_x_scroll`
to GPUI elements to let them has scrollbars. It is almost like the
`overscroll` but adds the Scrollbar.
2. And adjust the display scrollbar of margining 4px of the container.
3. Add `scrollbar`, `vertical_scrollbar`, `horizontal_scrollbar` with
your own scroll handle.

<img width="1181" height="911" alt="image"
src="https://github.com/user-attachments/assets/8b02cd54-d527-4f22-b9be-f762ede22122"
/>

## Break Changes

- There `Scrollable` trait and it `scrollable` method has been removed.

```diff
- div().id("contents").scrollable(Axis::Vertical)
+ div().id("contents").overflow_y_scrollbar()
```
2025-11-27 11:29:50 +00:00

785 lines
22 KiB
Rust

mod accordion_story;
mod alert_story;
mod app_menus;
mod avatar_story;
mod badge_story;
mod button_story;
mod calendar_story;
mod chart_story;
mod checkbox_story;
mod clipboard_story;
mod collapsible_story;
mod color_picker_story;
mod date_picker_story;
mod description_list_story;
mod dialog_story;
mod dropdown_button_story;
mod form_story;
mod group_box_story;
mod icon_story;
mod image_story;
mod input_story;
mod kbd_story;
mod label_story;
mod list_story;
mod menu_story;
mod notification_story;
mod number_input_story;
mod otp_input_story;
mod popover_story;
mod progress_story;
mod radio_story;
mod resizable_story;
mod scrollable_story;
mod select_story;
mod settings_story;
mod sheet_story;
mod sidebar_story;
mod skeleton_story;
mod slider_story;
mod spinner_story;
mod switch_story;
mod table_story;
mod tabs_story;
mod tag_story;
mod textarea_story;
mod themes;
mod title_bar;
mod toggle_story;
mod tooltip_story;
mod tree_story;
mod virtual_list_story;
mod webview_story;
mod welcome_story;
use gpui::{
Action, AnyElement, AnyView, App, AppContext, Bounds, Context, Div, Entity, EventEmitter,
Focusable, Global, Hsla, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels,
Render, RenderOnce, SharedString, Size, StyleRefinement, Styled, Window, WindowBounds,
WindowKind, WindowOptions, actions, div, prelude::FluentBuilder as _, px, rems, size,
};
pub use accordion_story::AccordionStory;
pub use alert_story::AlertStory;
pub use avatar_story::AvatarStory;
pub use badge_story::BadgeStory;
pub use button_story::ButtonStory;
pub use calendar_story::CalendarStory;
pub use chart_story::ChartStory;
pub use checkbox_story::CheckboxStory;
pub use clipboard_story::ClipboardStory;
pub use collapsible_story::CollapsibleStory;
pub use color_picker_story::ColorPickerStory;
pub use date_picker_story::DatePickerStory;
pub use description_list_story::DescriptionListStory;
pub use dialog_story::DialogStory;
pub use dropdown_button_story::DropdownButtonStory;
pub use form_story::FormStory;
pub use group_box_story::GroupBoxStory;
pub use icon_story::IconStory;
pub use image_story::ImageStory;
pub use input_story::InputStory;
pub use kbd_story::KbdStory;
pub use label_story::LabelStory;
pub use list_story::ListStory;
pub use menu_story::MenuStory;
pub use notification_story::NotificationStory;
pub use number_input_story::NumberInputStory;
pub use otp_input_story::OtpInputStory;
pub use popover_story::PopoverStory;
pub use progress_story::ProgressStory;
pub use radio_story::RadioStory;
pub use resizable_story::ResizableStory;
pub use scrollable_story::ScrollableStory;
pub use select_story::SelectStory;
use serde::{Deserialize, Serialize};
pub use settings_story::SettingsStory;
pub use sheet_story::SheetStory;
pub use sidebar_story::SidebarStory;
pub use skeleton_story::SkeletonStory;
pub use slider_story::SliderStory;
pub use spinner_story::SpinnerStory;
pub use switch_story::SwitchStory;
pub use table_story::TableStory;
pub use tabs_story::TabsStory;
pub use tag_story::TagStory;
pub use textarea_story::TextareaStory;
pub use title_bar::AppTitleBar;
pub use toggle_story::ToggleStory;
pub use tooltip_story::TooltipStory;
pub use tree_story::TreeStory;
pub use virtual_list_story::VirtualListStory;
pub use webview_story::WebViewStory;
pub use welcome_story::WelcomeStory;
use gpui_component::{
ActiveTheme, IconName, Root, TitleBar, WindowExt,
button::Button,
dock::{Panel, PanelControl, PanelEvent, PanelInfo, PanelState, TitleStyle, register_panel},
group_box::{GroupBox, GroupBoxVariants as _},
h_flex,
menu::PopupMenu,
notification::Notification,
scroll::{ScrollableElement as _, ScrollbarShow},
v_flex,
};
use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _};
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = story, no_json)]
pub struct SelectScrollbarShow(ScrollbarShow);
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = story, no_json)]
pub struct SelectLocale(SharedString);
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = story, no_json)]
pub struct SelectFont(usize);
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = story, no_json)]
pub struct SelectRadius(usize);
actions!(
story,
[
About,
Open,
Quit,
CloseWindow,
ToggleSearch,
TestAction,
Tab,
TabPrev,
ShowPanelInfo
]
);
const PANEL_NAME: &str = "StoryContainer";
pub struct AppState {
pub invisible_panels: Entity<Vec<SharedString>>,
}
impl AppState {
fn init(cx: &mut App) {
let state = Self {
invisible_panels: cx.new(|_| Vec::new()),
};
cx.set_global::<AppState>(state);
}
pub fn global(cx: &App) -> &Self {
cx.global::<Self>()
}
pub fn global_mut(cx: &mut App) -> &mut Self {
cx.global_mut::<Self>()
}
}
pub fn create_new_window<F, E>(title: &str, crate_view_fn: F, cx: &mut App)
where
E: Into<AnyView>,
F: FnOnce(&mut Window, &mut App) -> E + Send + 'static,
{
create_new_window_with_size(title, None, crate_view_fn, cx);
}
pub fn create_new_window_with_size<F, E>(
title: &str,
window_size: Option<Size<Pixels>>,
crate_view_fn: F,
cx: &mut App,
) where
E: Into<AnyView>,
F: FnOnce(&mut Window, &mut App) -> E + Send + 'static,
{
let mut window_size = window_size.unwrap_or(size(px(1600.0), px(1200.0)));
if let Some(display) = cx.primary_display() {
let display_size = display.bounds().size;
window_size.width = window_size.width.min(display_size.width * 0.85);
window_size.height = window_size.height.min(display_size.height * 0.85);
}
let window_bounds = Bounds::centered(None, window_size, cx);
let title = SharedString::from(title.to_string());
cx.spawn(async move |cx| {
let options = WindowOptions {
window_bounds: Some(WindowBounds::Windowed(window_bounds)),
titlebar: Some(TitleBar::title_bar_options()),
window_min_size: Some(gpui::Size {
width: px(480.),
height: px(320.),
}),
kind: WindowKind::Normal,
#[cfg(target_os = "linux")]
window_background: gpui::WindowBackgroundAppearance::Transparent,
#[cfg(target_os = "linux")]
window_decorations: Some(gpui::WindowDecorations::Client),
..Default::default()
};
let window = cx
.open_window(options, |window, cx| {
let view = crate_view_fn(window, cx);
let root = cx.new(|cx| StoryRoot::new(title.clone(), view, window, cx));
cx.new(|cx| Root::new(root, window, cx))
})
.expect("failed to open window");
window
.update(cx, |_, window, _| {
window.activate_window();
window.set_window_title(&title);
})
.expect("failed to update window");
Ok::<_, anyhow::Error>(())
})
.detach();
}
struct StoryRoot {
title_bar: Entity<AppTitleBar>,
view: AnyView,
}
impl StoryRoot {
pub fn new(
title: impl Into<SharedString>,
view: impl Into<AnyView>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let title_bar = cx.new(|cx| AppTitleBar::new(title, window, cx));
Self {
title_bar,
view: view.into(),
}
}
}
impl Render for StoryRoot {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let sheet_layer = Root::render_sheet_layer(window, cx);
let dialog_layer = Root::render_dialog_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div()
.size_full()
.child(
v_flex()
.size_full()
.child(self.title_bar.clone())
.child(div().flex_1().overflow_hidden().child(self.view.clone())),
)
.children(sheet_layer)
.children(dialog_layer)
.children(notification_layer)
}
}
impl Global for AppState {}
pub fn init(cx: &mut App) {
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("gpui_component=trace".parse().unwrap()),
)
.init();
gpui_component::init(cx);
AppState::init(cx);
themes::init(cx);
input_story::init(cx);
number_input_story::init(cx);
textarea_story::init(cx);
select_story::init(cx);
popover_story::init(cx);
menu_story::init(cx);
webview_story::init(cx);
tooltip_story::init(cx);
otp_input_story::init(cx);
tree_story::init(cx);
let http_client = std::sync::Arc::new(
reqwest_client::ReqwestClient::user_agent("gpui-component/story").unwrap(),
);
cx.set_http_client(http_client);
cx.bind_keys([
KeyBinding::new("/", ToggleSearch, None),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-o", Open, None),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("ctrl-o", Open, None),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-q", Quit, None),
#[cfg(not(target_os = "macos"))]
KeyBinding::new("alt-f4", Quit, None),
]);
cx.on_action(|_: &Quit, cx: &mut App| {
cx.quit();
});
register_panel(cx, PANEL_NAME, |_, _, info, window, cx| {
let story_state = match info {
PanelInfo::Panel(value) => StoryState::from_value(value.clone()),
_ => {
unreachable!("Invalid PanelInfo: {:?}", info)
}
};
let view = cx.new(|cx| {
let (title, description, closable, zoomable, story, on_active) =
story_state.to_story(window, cx);
let mut container = StoryContainer::new(window, cx)
.story(story, story_state.story_klass)
.on_active(on_active);
cx.on_focus_in(
&container.focus_handle,
window,
|this: &mut StoryContainer, _, _| {
println!("StoryContainer focus in: {}", this.name);
},
)
.detach();
container.name = title.into();
container.description = description.into();
container.closable = closable;
container.zoomable = zoomable;
container
});
Box::new(view)
});
cx.activate(true);
}
#[derive(IntoElement)]
struct StorySection {
base: Div,
title: SharedString,
sub_title: Vec<AnyElement>,
children: Vec<AnyElement>,
}
impl StorySection {
pub fn sub_title(mut self, sub_title: impl IntoElement) -> Self {
self.sub_title.push(sub_title.into_any_element());
self
}
#[allow(unused)]
fn max_w_md(mut self) -> Self {
self.base = self.base.max_w(rems(48.));
self
}
#[allow(unused)]
fn max_w_lg(mut self) -> Self {
self.base = self.base.max_w(rems(64.));
self
}
#[allow(unused)]
fn max_w_xl(mut self) -> Self {
self.base = self.base.max_w(rems(80.));
self
}
#[allow(unused)]
fn max_w_2xl(mut self) -> Self {
self.base = self.base.max_w(rems(96.));
self
}
}
impl ParentElement for StorySection {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for StorySection {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for StorySection {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
GroupBox::new()
.id(self.title.clone())
.outline()
.title(
h_flex()
.justify_between()
.w_full()
.gap_4()
.child(self.title)
.children(self.sub_title),
)
.content_style(
StyleRefinement::default()
.rounded(cx.theme().radius_lg)
.overflow_x_hidden()
.items_center()
.justify_center(),
)
.child(self.base.children(self.children))
}
}
pub(crate) fn section(title: impl Into<SharedString>) -> StorySection {
StorySection {
title: title.into(),
sub_title: vec![],
base: h_flex()
.flex_wrap()
.justify_center()
.items_center()
.w_full()
.gap_4(),
children: vec![],
}
}
pub struct StoryContainer {
focus_handle: gpui::FocusHandle,
pub name: SharedString,
pub title_bg: Option<Hsla>,
pub description: SharedString,
width: Option<gpui::Pixels>,
height: Option<gpui::Pixels>,
story: Option<AnyView>,
story_klass: Option<SharedString>,
closable: bool,
zoomable: Option<PanelControl>,
paddings: Pixels,
on_active: Option<fn(AnyView, bool, &mut Window, &mut App)>,
}
#[derive(Debug)]
pub enum ContainerEvent {
Close,
}
pub trait Story: Render + Sized {
fn klass() -> &'static str {
std::any::type_name::<Self>().split("::").last().unwrap()
}
fn title() -> &'static str;
fn description() -> &'static str {
""
}
fn closable() -> bool {
true
}
fn zoomable() -> Option<PanelControl> {
Some(PanelControl::default())
}
fn title_bg() -> Option<Hsla> {
None
}
fn paddings() -> Pixels {
px(16.)
}
fn new_view(window: &mut Window, cx: &mut App) -> Entity<impl Render>;
fn on_active(&mut self, active: bool, window: &mut Window, cx: &mut App) {
let _ = active;
let _ = window;
let _ = cx;
}
fn on_active_any(view: AnyView, active: bool, window: &mut Window, cx: &mut App)
where
Self: 'static,
{
if let Some(story) = view.downcast::<Self>().ok() {
cx.update_entity(&story, |story, cx| {
story.on_active(active, window, cx);
});
}
}
}
impl EventEmitter<ContainerEvent> for StoryContainer {}
impl StoryContainer {
pub fn new(_window: &mut Window, cx: &mut App) -> Self {
let focus_handle = cx.focus_handle();
Self {
focus_handle,
name: "".into(),
title_bg: None,
description: "".into(),
width: None,
height: None,
story: None,
story_klass: None,
closable: true,
zoomable: Some(PanelControl::default()),
paddings: px(16.),
on_active: None,
}
}
pub fn panel<S: Story>(window: &mut Window, cx: &mut App) -> Entity<Self> {
let name = S::title();
let description = S::description();
let story = S::new_view(window, cx);
let story_klass = S::klass();
let view = cx.new(|cx| {
let mut story = Self::new(window, cx)
.story(story.into(), story_klass)
.on_active(S::on_active_any);
story.focus_handle = cx.focus_handle();
story.closable = S::closable();
story.zoomable = S::zoomable();
story.name = name.into();
story.description = description.into();
story.title_bg = S::title_bg();
story.paddings = S::paddings();
story
});
view
}
pub fn width(mut self, width: gpui::Pixels) -> Self {
self.width = Some(width);
self
}
pub fn height(mut self, height: gpui::Pixels) -> Self {
self.height = Some(height);
self
}
pub fn story(mut self, story: AnyView, story_klass: impl Into<SharedString>) -> Self {
self.story = Some(story);
self.story_klass = Some(story_klass.into());
self
}
pub fn on_active(mut self, on_active: fn(AnyView, bool, &mut Window, &mut App)) -> Self {
self.on_active = Some(on_active);
self
}
fn on_action_panel_info(
&mut self,
_: &ShowPanelInfo,
window: &mut Window,
cx: &mut Context<Self>,
) {
struct Info;
let note = Notification::new()
.message(format!("You have clicked panel info on: {}", self.name))
.id::<Info>();
window.push_notification(note, cx);
}
fn on_action_toggle_search(
&mut self,
_: &ToggleSearch,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.propagate();
if window.has_focused_input(cx) {
return;
}
struct Search;
let note = Notification::new()
.message(format!("You have toggled search on: {}", self.name))
.id::<Search>();
window.push_notification(note, cx);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StoryState {
pub story_klass: SharedString,
}
impl StoryState {
fn to_value(&self) -> serde_json::Value {
serde_json::json!({
"story_klass": self.story_klass,
})
}
fn from_value(value: serde_json::Value) -> Self {
serde_json::from_value(value).unwrap()
}
fn to_story(
&self,
window: &mut Window,
cx: &mut App,
) -> (
&'static str,
&'static str,
bool,
Option<PanelControl>,
AnyView,
fn(AnyView, bool, &mut Window, &mut App),
) {
macro_rules! story {
($klass:tt) => {
(
$klass::title(),
$klass::description(),
$klass::closable(),
$klass::zoomable(),
$klass::view(window, cx).into(),
$klass::on_active_any,
)
};
}
match self.story_klass.to_string().as_str() {
"ButtonStory" => story!(ButtonStory),
"CalendarStory" => story!(CalendarStory),
"SelectStory" => story!(SelectStory),
"IconStory" => story!(IconStory),
"ImageStory" => story!(ImageStory),
"InputStory" => story!(InputStory),
"ListStory" => story!(ListStory),
"DialogStory" => story!(DialogStory),
"PopoverStory" => story!(PopoverStory),
"ProgressStory" => story!(ProgressStory),
"ResizableStory" => story!(ResizableStory),
"ScrollableStory" => story!(ScrollableStory),
"SwitchStory" => story!(SwitchStory),
"TableStory" => story!(TableStory),
"LabelStory" => story!(LabelStory),
"TooltipStory" => story!(TooltipStory),
"WebViewStory" => story!(WebViewStory),
"AccordionStory" => story!(AccordionStory),
"SidebarStory" => story!(SidebarStory),
"FormStory" => story!(FormStory),
"NotificationStory" => story!(NotificationStory),
_ => {
unreachable!("Invalid story klass: {}", self.story_klass)
}
}
}
}
impl Panel for StoryContainer {
fn panel_name(&self) -> &'static str {
"StoryContainer"
}
fn title(&self, _window: &Window, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn title_style(&self, cx: &App) -> Option<TitleStyle> {
if let Some(bg) = self.title_bg {
Some(TitleStyle {
background: bg,
foreground: cx.theme().foreground,
})
} else {
None
}
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> Option<PanelControl> {
self.zoomable
}
fn visible(&self, cx: &App) -> bool {
!AppState::global(cx)
.invisible_panels
.read(cx)
.contains(&self.name)
}
fn set_zoomed(&mut self, zoomed: bool, _window: &mut Window, _cx: &mut App) {
println!("panel: {} zoomed: {}", self.name, zoomed);
}
fn set_active(&mut self, active: bool, _window: &mut Window, cx: &mut App) {
println!("panel: {} active: {}", self.name, active);
if let Some(on_active) = self.on_active {
if let Some(story) = self.story.clone() {
on_active(story, active, _window, cx);
}
}
}
fn dropdown_menu(&self, menu: PopupMenu, _window: &Window, _cx: &App) -> PopupMenu {
menu.menu("Info", Box::new(ShowPanelInfo))
}
fn toolbar_buttons(&self, _window: &mut Window, _cx: &mut App) -> Option<Vec<Button>> {
Some(vec![
Button::new("info")
.icon(IconName::Info)
.on_click(|_, window, cx| {
window.push_notification("You have clicked info button", cx);
}),
Button::new("search")
.icon(IconName::Search)
.on_click(|_, window, cx| {
window.push_notification("You have clicked search button", cx);
}),
])
}
fn dump(&self, _cx: &App) -> PanelState {
let mut state = PanelState::new(self);
let story_state = StoryState {
story_klass: self.story_klass.clone().unwrap(),
};
state.info = PanelInfo::panel(story_state.to_value());
state
}
}
impl EventEmitter<PanelEvent> for StoryContainer {}
impl Focusable for StoryContainer {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for StoryContainer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("story-container")
.size_full()
.overflow_y_scrollbar()
.p(self.paddings)
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_action_panel_info))
.on_action(cx.listener(Self::on_action_toggle_search))
.when_some(self.story.clone(), |this, story| this.child(story))
}
}