Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "ui_components"
edition = "2024"
authors.workspace = true
publish.workspace = true
license.workspace = true
[dependencies]
pathfinder_color.workspace = true
pathfinder_geometry.workspace = true
warpui.workspace = true
warp_core.workspace = true
[dev-dependencies]
anyhow.workspace = true
asset_cache.workspace = true
rust-embed.workspace = true
rustls.workspace = true
warp_core = { workspace = true, features = ["test-util"] }
+11
View File
@@ -0,0 +1,11 @@
# Owners for the ui_components crate.
#
# We're defining explicit approvers for this crate while we're still working
# on solidifying patterns and best practices. This set of approvers should
# grow over time as more people contribute to the crate.
vorporeal
zachbai
alokedesai
bnavetta
acarl005
+749
View File
@@ -0,0 +1,749 @@
use std::{borrow::Cow, sync::Arc, time::Duration};
use anyhow::{Result, anyhow};
use pathfinder_geometry::vector::Vector2F;
use rust_embed::RustEmbed;
use ui_components::{
Component as _, Options, button, dialog,
lightbox::{self, LightboxImage, LightboxImageSource, NavigationDirection},
switch, tooltip,
};
use warp_core::ui::{Icon, appearance::Appearance, theme::color::internal_colors};
use warpui::{
AssetProvider, SingletonEntity, Tracked,
assets::asset_cache::{AssetCache, AssetSource, AssetState},
r#async::Timer,
elements::Stack,
image_cache::ImageType,
keymap::FixedBinding,
platform,
prelude::*,
};
#[derive(Clone, Copy, RustEmbed)]
#[folder = "../../app/assets"]
#[include = "bundled/**"] // Should be kept in sync with BUNDLED_ASSETS_DIR.
#[include = "async/**"] // Should be kept in sync with ASYNC_ASSETS_DIR.
#[cfg_attr(target_family = "wasm", exclude = "async/**")] // Excludes take precedence.
pub struct Assets;
pub static ASSETS: Assets = Assets;
impl AssetProvider for Assets {
fn get(&self, path: &str) -> Result<Cow<'_, [u8]>> {
<Assets as RustEmbed>::get(path)
.map(|f| f.data)
.ok_or_else(|| anyhow!("no asset exists at path {}", path))
}
}
fn main() -> warpui::platform::app::TerminationResult {
// Initialize the TLS provider so reqwest can make HTTPS requests.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("must be able to initialize crypto provider for TLS support");
let app_builder =
platform::AppBuilder::new(platform::AppCallbacks::default(), Box::new(ASSETS), None);
app_builder.run(move |ctx| {
let font_name = if cfg!(target_os = "macos") {
".AppleSystemUIFont".to_string()
} else if cfg!(target_os = "windows") {
"Segoe UI".to_string()
} else {
"Noto Sans".to_string()
};
let font_family = warpui::fonts::Cache::handle(ctx).update(ctx, |cache, _ctx| {
cache.load_system_font(&font_name).unwrap()
});
ctx.add_singleton_model(|ctx| {
let mut appearance = Appearance::mock();
appearance.set_ui_font_family(font_family, ctx);
appearance
});
{
use warpui::keymap::macros::*;
let lightbox_open = id!("RootView") & id!("RootView_LightboxOpen");
ctx.register_fixed_bindings([
FixedBinding::new(
"escape",
Action::CloseDialog,
id!("RootView") & id!("RootView_DialogOpen"),
),
FixedBinding::new("escape", Action::CloseLightbox, lightbox_open.clone()),
FixedBinding::new(
"left",
Action::LightboxNavigatePrevious,
lightbox_open.clone(),
),
FixedBinding::new("right", Action::LightboxNavigateNext, lightbox_open),
]);
}
ctx.add_window(warpui::AddWindowOptions::default(), RootView::new);
})
}
pub struct RootView {
// Switch.
switch: switch::Switch,
switch_checked: Tracked<bool>,
// Buttons.
default_button_row: ButtonRow,
small_button_row: ButtonRow,
// Dialog.
dialog: dialog::Dialog,
dialog_open: Tracked<bool>,
open_dialog_button: button::Button,
// Lightbox.
lightbox: lightbox::Lightbox,
lightbox_open: Tracked<bool>,
lightbox_images: Vec<LightboxImage>,
lightbox_current_index: usize,
open_lightbox_button: button::Button,
// Async lightbox.
async_lightbox: lightbox::Lightbox,
async_lightbox_open: Tracked<bool>,
async_lightbox_images: Vec<LightboxImage>,
async_lightbox_current_index: usize,
open_async_lightbox_button: button::Button,
}
impl RootView {
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
Self {
// Switch.
switch: Default::default(),
switch_checked: Tracked::new(false),
// Buttons.
default_button_row: ButtonRow::default(),
small_button_row: ButtonRow::default(),
// Dialog.
dialog: Default::default(),
dialog_open: Tracked::new(false),
open_dialog_button: Default::default(),
// Lightbox.
lightbox: Default::default(),
lightbox_open: Tracked::new(false),
lightbox_images: vec![
LightboxImage {
source: LightboxImageSource::Resolved {
asset_source: AssetSource::Bundled {
path: "bundled/png/dev.png",
},
},
description: Some("First image (dev.png)".to_string()),
},
LightboxImage {
source: LightboxImageSource::Resolved {
asset_source: AssetSource::Bundled {
path: "bundled/png/dev.png",
},
},
description: Some("Second image (also dev.png)".to_string()),
},
],
lightbox_current_index: 0,
open_lightbox_button: Default::default(),
// Async lightbox.
async_lightbox: Default::default(),
async_lightbox_open: Tracked::new(false),
async_lightbox_images: Vec::new(),
async_lightbox_current_index: 0,
open_async_lightbox_button: Default::default(),
}
}
}
impl Entity for RootView {
type Event = ();
}
impl View for RootView {
fn ui_name() -> &'static str {
"RootView"
}
fn keymap_context(&self, _: &AppContext) -> warpui::keymap::Context {
let mut context = Self::default_keymap_context();
if *self.dialog_open {
context.set.insert("RootView_DialogOpen");
}
if *self.lightbox_open {
context.set.insert("RootView_LightboxOpen");
}
context
}
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(ctx);
let mut column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_spacing(8.);
for render_fn in &[
Self::render_switch,
Self::render_tooltip,
Self::render_buttons,
Self::render_dialog_button,
Self::render_lightbox_buttons,
] {
column.add_child(example_row(render_fn(self, appearance), appearance));
}
let content = Container::new(Align::new(column.finish()).finish())
.with_background_color(ColorU::new(68, 68, 68, 255))
.finish();
if *self.dialog_open {
Stack::new()
.with_child(content)
.with_child(Align::new(self.render_dialog(appearance)).finish())
.finish()
} else if *self.lightbox_open {
Stack::new()
.with_child(content)
.with_child(self.render_lightbox(appearance, ctx))
.finish()
} else if *self.async_lightbox_open {
Stack::new()
.with_child(content)
.with_child(self.render_async_lightbox(appearance, ctx))
.finish()
} else {
content
}
}
}
impl RootView {
fn render_switch(&self, appearance: &Appearance) -> Box<dyn Element> {
self.switch.render(
appearance,
switch::Params {
checked: *self.switch_checked,
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(Action::SwitchToggled);
})),
options: switch::Options {
hover_border_size: Some(10.),
label: Some(Box::new(move |appearance: &Appearance| {
warpui::elements::Text::new(
"Switch",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(ColorU::white())
.finish()
})),
..Options::default(appearance)
},
},
)
}
fn render_tooltip(&self, appearance: &Appearance) -> Box<dyn Element> {
tooltip::Tooltip.render(
appearance,
tooltip::Params {
label: "Tooltip label".into(),
options: tooltip::Options {
keyboard_shortcut: Some(warpui::keymap::Keystroke {
ctrl: true,
key: "k".to_string(),
..Default::default()
}),
},
},
)
}
fn render_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
let default_size_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_spacing(8.)
.with_children([
self.default_button_row.default.render(
appearance,
button::Params {
content: button::Content::Label("Primary".into()),
theme: &button::themes::Primary,
options: button::Options {
keystroke: Some(warpui::keymap::Keystroke {
ctrl: true,
key: "k".to_string(),
..Default::default()
}),
tooltip: Some(button::Tooltip {
params: tooltip::Params {
label: "Primary / Default".into(),
options: Options::default(appearance),
},
alignment: Default::default(),
}),
..Options::default(appearance)
},
},
),
self.default_button_row.secondary.render(
appearance,
button::Params {
content: button::Content::Label("Secondary".into()),
theme: &button::themes::Secondary,
options: button::Options {
keystroke: Some(warpui::keymap::Keystroke {
cmd: true,
key: "enter".to_string(),
..Default::default()
}),
tooltip: Some(button::Tooltip {
params: tooltip::Params {
label: "Secondary / Default".into(),
options: Options::default(appearance),
},
alignment: Default::default(),
}),
..Options::default(appearance)
},
},
),
self.default_button_row.disabled.render(
appearance,
button::Params {
content: button::Content::Label("Disabled".into()),
theme: &button::themes::Primary,
options: button::Options {
disabled: true,
keystroke: Some(warpui::keymap::Keystroke {
shift: true,
key: "d".to_string(),
..Default::default()
}),
tooltip: Some(button::Tooltip {
params: tooltip::Params {
label: "Disabled / Default".into(),
options: Options::default(appearance),
},
alignment: Default::default(),
}),
..Options::default(appearance)
},
},
),
self.default_button_row.icon.render(
appearance,
button::Params {
content: button::Content::Icon(Icon::X),
theme: &button::themes::Primary,
options: button::Options {
..Options::default(appearance)
},
},
),
])
.finish();
let small_size_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_spacing(8.)
.with_children([
self.small_button_row.default.render(
appearance,
button::Params {
content: button::Content::Label("Primary".into()),
theme: &button::themes::Primary,
options: button::Options {
size: button::Size::Small,
keystroke: Some(warpui::keymap::Keystroke {
ctrl: true,
key: "k".to_string(),
..Default::default()
}),
tooltip: Some(button::Tooltip {
params: tooltip::Params {
label: "Primary / Small".into(),
options: Options::default(appearance),
},
alignment: Default::default(),
}),
..Options::default(appearance)
},
},
),
self.small_button_row.secondary.render(
appearance,
button::Params {
content: button::Content::Label("Secondary".into()),
theme: &button::themes::Secondary,
options: button::Options {
size: button::Size::Small,
keystroke: Some(warpui::keymap::Keystroke {
cmd: true,
key: "enter".to_string(),
..Default::default()
}),
tooltip: Some(button::Tooltip {
params: tooltip::Params {
label: "Secondary / Small".into(),
options: Options::default(appearance),
},
alignment: Default::default(),
}),
..Options::default(appearance)
},
},
),
self.small_button_row.disabled.render(
appearance,
button::Params {
content: button::Content::Label("Disabled".into()),
theme: &button::themes::Primary,
options: button::Options {
disabled: true,
size: button::Size::Small,
keystroke: Some(warpui::keymap::Keystroke {
shift: true,
key: "d".to_string(),
..Default::default()
}),
tooltip: Some(button::Tooltip {
params: tooltip::Params {
label: "Disabled / Small".into(),
options: Options::default(appearance),
},
alignment: Default::default(),
}),
..Options::default(appearance)
},
},
),
self.small_button_row.icon.render(
appearance,
button::Params {
content: button::Content::Icon(Icon::X),
theme: &button::themes::Secondary,
options: button::Options {
size: button::Size::Small,
keystroke: Some(warpui::keymap::Keystroke {
key: "escape".to_string(),
..Default::default()
}),
..Options::default(appearance)
},
},
),
])
.finish();
let small_size_row = Container::new(small_size_row).with_margin_top(8.).finish();
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_children([default_size_row, small_size_row])
.finish()
}
fn render_dialog_button(&self, appearance: &Appearance) -> Box<dyn Element> {
self.open_dialog_button.render(
appearance,
button::Params {
content: button::Content::Label("Open Dialog".into()),
theme: &button::themes::Primary,
options: button::Options {
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(Action::OpenDialog);
})),
..Options::default(appearance)
},
},
)
}
fn render_dialog(&self, appearance: &Appearance) -> Box<dyn Element> {
self.dialog.render(
appearance,
dialog::Params {
title: "Dialog Title".into(),
content: Box::new(|appearance: &Appearance| {
Container::new(
Text::new(
"This is a dialog.",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(ColorU::white())
.finish(),
)
.with_horizontal_padding(dialog::HORIZONTAL_PADDING)
.with_padding_bottom(dialog::BASE_PADDING)
.finish()
}),
options: dialog::Options {
width: Some(500.),
on_dismiss: Some(Arc::new(|ctx, _app| {
ctx.dispatch_typed_action(Action::CloseDialog);
})),
dismiss_keystroke: Some(warpui::keymap::Keystroke {
key: "escape".to_string(),
..Default::default()
}),
footer: Some(Box::new(|appearance: &Appearance| {
Text::new(
"This is the footer",
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(ColorU::white())
.finish()
})),
},
},
)
}
fn render_lightbox_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
Flex::row()
.with_spacing(8.)
.with_child(self.open_lightbox_button.render(
appearance,
button::Params {
content: button::Content::Label("Open Lightbox".into()),
theme: &button::themes::Primary,
options: button::Options {
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(Action::OpenLightbox);
})),
..Options::default(appearance)
},
},
))
.with_child(self.open_async_lightbox_button.render(
appearance,
button::Params {
content: button::Content::Label("Open Lightbox (Async)".into()),
theme: &button::themes::Secondary,
options: button::Options {
on_click: Some(Box::new(|ctx, _app, _pos| {
ctx.dispatch_typed_action(Action::OpenAsyncLightbox);
})),
..Options::default(appearance)
},
},
))
.finish()
}
fn render_lightbox(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let current_image_native_size = self
.lightbox_images
.get(self.lightbox_current_index)
.and_then(|img| native_size_for_image(img, app));
self.lightbox.render(
appearance,
lightbox::Params {
images: &self.lightbox_images,
current_index: self.lightbox_current_index,
on_dismiss: Arc::new(|ctx, _app| {
ctx.dispatch_typed_action(Action::CloseLightbox);
}),
current_image_native_size,
options: lightbox::Options {
dismiss_keystroke: Some(warpui::keymap::Keystroke {
key: "escape".to_string(),
..Default::default()
}),
on_navigate: Some(Arc::new(|direction, ctx, _app| match direction {
NavigationDirection::Previous => {
ctx.dispatch_typed_action(Action::LightboxNavigatePrevious);
}
NavigationDirection::Next => {
ctx.dispatch_typed_action(Action::LightboxNavigateNext);
}
})),
},
},
)
}
fn render_async_lightbox(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
let current_image_native_size = self
.async_lightbox_images
.get(self.async_lightbox_current_index)
.and_then(|img| native_size_for_image(img, app));
self.async_lightbox.render(
appearance,
lightbox::Params {
images: &self.async_lightbox_images,
current_index: self.async_lightbox_current_index,
on_dismiss: Arc::new(|ctx, _app| {
ctx.dispatch_typed_action(Action::CloseLightbox);
}),
current_image_native_size,
options: lightbox::Options {
dismiss_keystroke: Some(warpui::keymap::Keystroke {
key: "escape".to_string(),
..Default::default()
}),
on_navigate: Some(Arc::new(|direction, ctx, _app| match direction {
NavigationDirection::Previous => {
ctx.dispatch_typed_action(Action::LightboxNavigatePrevious);
}
NavigationDirection::Next => {
ctx.dispatch_typed_action(Action::LightboxNavigateNext);
}
})),
},
},
)
}
}
#[derive(Debug)]
pub enum Action {
SwitchToggled,
OpenDialog,
CloseDialog,
OpenLightbox,
OpenAsyncLightbox,
CloseLightbox,
LightboxNavigatePrevious,
LightboxNavigateNext,
}
impl TypedActionView for RootView {
type Action = Action;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
Action::SwitchToggled => {
*self.switch_checked = !*self.switch_checked;
}
Action::OpenDialog => {
*self.dialog_open = true;
}
Action::CloseDialog => {
*self.dialog_open = false;
}
Action::OpenLightbox => {
self.lightbox_current_index = 0;
*self.lightbox_open = true;
}
Action::OpenAsyncLightbox => {
// Start with 3 images in Loading state.
self.async_lightbox_images = vec![
LightboxImage {
source: LightboxImageSource::Loading,
description: Some("Image 1".to_string()),
},
LightboxImage {
source: LightboxImageSource::Loading,
description: Some("Image 2".to_string()),
},
LightboxImage {
source: LightboxImageSource::Loading,
description: Some("Image 3".to_string()),
},
];
self.async_lightbox_current_index = 0;
*self.async_lightbox_open = true;
// Simulate async loading: each image "loads" after a staggered delay.
for i in 0..3usize {
let delay = Duration::from_secs((i as u64 + 1) * 2);
ctx.spawn(
async move {
Timer::after(delay).await;
i
},
|view, index, ctx| {
if let Some(image) = view.async_lightbox_images.get_mut(index) {
image.source = LightboxImageSource::Resolved {
asset_source: ::asset_cache::url_source(
"https://cdn.terminaltrove.com/m/b1c31938-6e80-4f28-a2cd-d2047eddcdb2.png",
),
};
image.description =
Some(format!("Image {} \u{2014} loaded!", index + 1));
}
ctx.notify();
},
);
}
}
Action::CloseLightbox => {
*self.lightbox_open = false;
*self.async_lightbox_open = false;
}
Action::LightboxNavigatePrevious => {
if *self.lightbox_open && self.lightbox_current_index > 0 {
self.lightbox_current_index -= 1;
}
if *self.async_lightbox_open && self.async_lightbox_current_index > 0 {
self.async_lightbox_current_index -= 1;
}
}
Action::LightboxNavigateNext => {
if *self.lightbox_open
&& self.lightbox_current_index + 1 < self.lightbox_images.len()
{
self.lightbox_current_index += 1;
}
if *self.async_lightbox_open
&& self.async_lightbox_current_index + 1 < self.async_lightbox_images.len()
{
self.async_lightbox_current_index += 1;
}
}
}
}
}
#[derive(Default)]
struct ButtonRow {
default: button::Button,
secondary: button::Button,
disabled: button::Button,
icon: button::Button,
}
/// Queries the `AssetCache` for the native pixel dimensions of a lightbox image.
/// Returns `Some` when the image bytes have been fully loaded and decoded.
fn native_size_for_image(image: &LightboxImage, app: &AppContext) -> Option<Vector2F> {
match &image.source {
LightboxImageSource::Resolved { asset_source } => {
let asset_cache = AssetCache::as_ref(app);
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loaded { data } => data
.image_size()
.map(|size| Vector2F::new(size.x() as f32, size.y() as f32)),
_ => None,
}
}
LightboxImageSource::Loading => None,
}
}
fn example_row(contents: Box<dyn Element>, appearance: &Appearance) -> Box<dyn Element> {
Container::new(contents)
.with_uniform_padding(16.)
.with_border(internal_colors::neutral_4(appearance.theme()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
}
+184
View File
@@ -0,0 +1,184 @@
mod params;
pub mod themes;
use warp_core::ui::{
appearance::Appearance,
color::{ContrastingColor as _, contrast::MinimumAllowedContrast},
};
use warpui::{
elements::{MouseState, MouseStateHandle},
prelude::*,
};
pub use params::*;
pub use themes::Theme;
use crate::{keyboard_shortcut, tooltip};
#[derive(Default)]
pub struct Button {
mouse_state: MouseStateHandle,
tooltip: tooltip::Tooltip,
}
impl crate::Component for Button {
type Params<'a> = Params<'a>;
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
let theme: &dyn Theme = if params.options.disabled {
&themes::Disabled
} else {
params.theme
};
let mut hoverable = Hoverable::new(self.mouse_state.clone(), |mouse_state| {
let size = params.options.size;
let is_icon_button = matches!(params.content, Content::Icon(_));
let background = theme.background(mouse_state.into(), appearance);
let mut text_color = theme.text_color(background, appearance);
// Ensures that the action button text is always rendered with sufficient contrast.
// For hovered states that use a semi-transparent background, we apply the contrast adjustment using the base background.
if let Some(base_bg) = theme.background(State::Default, appearance) {
text_color =
text_color.on_background(base_bg.into_solid(), MinimumAllowedContrast::Text);
}
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_spacing(size.inner_spacing());
// Add icon, if any.
match &params.content {
Content::Icon(icon) | Content::IconAndLabel(icon, _) => {
let icon_size = size.icon_size();
row.add_child(
ConstrainedBox::new(icon.to_warpui_icon(text_color.into()).finish())
.with_width(icon_size)
.with_height(icon_size)
.finish(),
);
}
Content::Label(_) => {}
}
// Add label, if any.
match params.content {
Content::Label(label) | Content::IconAndLabel(_, label) => {
let font_size = size.font_size();
let font_properties = size.font_properties();
row.add_child(
Text::new_inline(label, appearance.ui_font_family(), font_size)
.with_color(text_color)
.with_style(font_properties)
.with_selectable(false)
.finish(),
);
}
Content::Icon(_) => {}
}
// Add keystroke, if any.
if let Some(keystroke) = params.options.keystroke {
let sizing = size.keyboard_shortcut_sizing();
row.add_child(
Container::new(
keyboard_shortcut::KeyboardShortcut.render(
appearance,
keyboard_shortcut::Params {
keystroke,
options: keyboard_shortcut::Options {
font_color: Some(text_color),
background: theme
.keyboard_shortcut_background(appearance)
.map(Into::into),
border_fill: theme
.keyboard_shortcut_border(text_color, appearance)
.map(Into::into),
sizing,
},
},
),
)
.with_margin_left(2.)
.finish(),
);
}
let mut button = Container::new(row.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if let Some(background) = background {
button = button.with_background(background);
}
if let Some(border) = theme.border(appearance) {
button = button.with_border(Border::all(1.).with_border_color(border));
}
if is_icon_button {
// Make the button evenly padded when there is no label.
let vertical_padding = (size.height() - size.icon_size()) / 2.;
button = button.with_horizontal_padding(vertical_padding);
} else {
button = button.with_horizontal_padding(size.horizontal_padding());
}
// Constrain the button sizing.
//
// It is important that this is done after styling changes are applied above,
// otherwise the presence of a border or padding will affect the final size.
let height = size.height();
let mut button = ConstrainedBox::new(button.finish()).with_height(height);
// If the content is an icon, make it square.
if is_icon_button {
button = button.with_width(height);
}
let mut stack = stack::Stack::new().with_child(button.finish());
if mouse_state.is_hovered()
&& let Some(tooltip) = params.options.tooltip
{
stack.add_positioned_overlay_child(
self.tooltip.render(appearance, tooltip.params),
stack::OffsetPositioning::offset_from_parent(
vec2f(0., -4.),
stack::ParentOffsetBounds::WindowByPosition,
tooltip.alignment.parent_anchor(),
tooltip.alignment.child_anchor(),
),
);
}
stack.finish()
});
if !params.options.disabled
&& let Some(on_click) = params.options.on_click
{
hoverable = hoverable
.with_cursor(Cursor::PointingHand)
.on_click(on_click);
}
hoverable.finish()
}
}
/// The current state of the button.
pub enum State {
Default,
Hovered,
Pressed,
}
impl From<&MouseState> for State {
fn from(mouse_state: &MouseState) -> Self {
if mouse_state.is_clicked() {
return Self::Pressed;
}
if mouse_state.is_hovered() {
return Self::Hovered;
}
Self::Default
}
}
+190
View File
@@ -0,0 +1,190 @@
use std::borrow::Cow;
use warp_core::ui::Icon;
use warp_core::ui::appearance::Appearance;
use warpui::{fonts, keymap::Keystroke, prelude::stack};
use crate::{keyboard_shortcut, tooltip};
use super::Theme;
/// The parameters for rendering a button.
pub struct Params<'a> {
/// The content of the button.
pub content: Content,
/// The theme to use for the button.
pub theme: &'a dyn Theme,
/// The options for the button.
pub options: Options,
}
impl<'a> crate::Params for Params<'a> {
type Options<'b> = Options;
}
pub struct Options {
/// Whether or not the button is disabled.
///
/// Disabled buttons are rendered with a different theme and do not respond
/// to mouse events.
pub disabled: bool,
/// The size of the button.
pub size: Size,
/// The tooltip to show on hover, if any.
pub tooltip: Option<Tooltip>,
/// The keystroke to display, if any.
pub keystroke: Option<Keystroke>,
/// The callback to invoke when the button is clicked, if any.
pub on_click: Option<crate::MouseEventHandler>,
}
impl crate::Options for Options {
fn default(appearance: &Appearance) -> Self {
Self {
disabled: false,
size: Size::default(appearance),
tooltip: None,
keystroke: None,
on_click: None,
}
}
}
/// The content of the button.
pub enum Content {
/// A label-only button.
Label(Cow<'static, str>),
/// An icon-only button.
Icon(Icon),
/// A button with both an icon and a label.
IconAndLabel(Icon, Cow<'static, str>),
}
/// The size of the button.
pub enum Size {
Default,
Small,
Custom(Sizing),
}
impl crate::Options for Size {
fn default(_: &Appearance) -> Self {
Self::Default
}
}
impl Size {
pub(super) fn height(&self) -> f32 {
self.sizing().height
}
pub(super) fn icon_size(&self) -> f32 {
self.sizing().icon_size
}
pub(super) fn font_size(&self) -> f32 {
self.sizing().font_size
}
pub(super) fn font_properties(&self) -> fonts::Properties {
self.sizing().font_properties
}
pub(super) fn horizontal_padding(&self) -> f32 {
self.sizing().horizontal_padding
}
pub(super) fn inner_spacing(&self) -> f32 {
self.sizing().inner_spacing
}
pub(super) fn keyboard_shortcut_sizing(&self) -> keyboard_shortcut::Sizing {
self.sizing().keyboard_shortcut_sizing
}
fn sizing(&self) -> &Sizing {
match self {
Size::Default => &DEFAULT_SIZE,
Size::Small => &SMALL_SIZE,
Size::Custom(custom) => custom,
}
}
}
/// The set of properties that vary with button size.
pub struct Sizing {
pub height: f32,
pub font_size: f32,
pub icon_size: f32,
pub font_properties: fonts::Properties,
pub horizontal_padding: f32,
pub inner_spacing: f32,
pub keyboard_shortcut_sizing: keyboard_shortcut::Sizing,
}
/// Sizing for a default-sized button.
const DEFAULT_SIZE: Sizing = Sizing {
height: 32.,
font_size: 14.,
icon_size: 16.,
font_properties: fonts::Properties {
weight: fonts::Weight::Semibold,
style: fonts::Style::Normal,
},
horizontal_padding: 12.,
inner_spacing: 4.,
keyboard_shortcut_sizing: keyboard_shortcut::Sizing {
font_size: 12.,
padding: 2.,
},
};
/// Sizing for a small-sized button.
const SMALL_SIZE: Sizing = Sizing {
height: 24.,
font_size: 12.,
icon_size: 14.,
font_properties: fonts::Properties {
weight: fonts::Weight::Semibold,
style: fonts::Style::Normal,
},
horizontal_padding: 8.,
inner_spacing: 2.,
keyboard_shortcut_sizing: keyboard_shortcut::Sizing {
font_size: 10.,
padding: 2.,
},
};
/// The tooltip to show on hover, if any.
pub struct Tooltip {
pub params: tooltip::Params,
pub alignment: TooltipAlignment,
}
/// Alignment options for button tooltips.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TooltipAlignment {
Left,
Center,
#[default]
Right,
}
impl TooltipAlignment {
pub fn parent_anchor(&self) -> stack::ParentAnchor {
match self {
Self::Left => stack::ParentAnchor::TopLeft,
Self::Center => stack::ParentAnchor::TopMiddle,
Self::Right => stack::ParentAnchor::TopRight,
}
}
pub fn child_anchor(&self) -> stack::ChildAnchor {
match self {
Self::Left => stack::ChildAnchor::BottomLeft,
Self::Center => stack::ChildAnchor::BottomMiddle,
Self::Right => stack::ChildAnchor::BottomRight,
}
}
}
+122
View File
@@ -0,0 +1,122 @@
use pathfinder_color::ColorU;
use warp_core::ui::{
appearance::Appearance, color::coloru_with_opacity, theme::Fill, theme::color::internal_colors,
};
/// Theming delegate for a button.
pub trait Theme {
/// The background fill for the button.
fn background(&self, button_state: super::State, appearance: &Appearance) -> Option<Fill>;
/// The color to use for text and icons, given the current background color.
fn text_color(&self, background: Option<Fill>, appearance: &Appearance) -> ColorU;
/// The border color for the button, if any.
fn border(&self, _: &Appearance) -> Option<ColorU> {
None
}
/// The border color for the keyboard shortcut, if any.
fn keyboard_shortcut_border(&self, _text_color: ColorU, _: &Appearance) -> Option<ColorU> {
None
}
/// The background color for the keyboard shortcut, if any.
fn keyboard_shortcut_background(&self, _: &Appearance) -> Option<ColorU> {
None
}
}
/// "Primary" buttons have a colorful fill.
///
/// [Figma spec](https://www.figma.com/design/chk9pwt35jTJhf9KnHmZyE/Components?node-id=3628-14344&t=GRYXipD0INVmDupA-0)
pub struct Primary;
impl Theme for Primary {
fn background(&self, button_state: super::State, appearance: &Appearance) -> Option<Fill> {
match button_state {
super::State::Default => Some(appearance.theme().accent()),
super::State::Hovered => Some(internal_colors::accent_overlay_4(appearance.theme())),
super::State::Pressed => Some(internal_colors::accent_overlay_3(appearance.theme())),
}
}
fn text_color(&self, background: Option<Fill>, appearance: &Appearance) -> ColorU {
let theme = appearance.theme();
let bg = background.unwrap_or_else(|| theme.accent()).into_solid();
theme.font_color(bg).into_solid()
}
fn keyboard_shortcut_border(&self, text_color: ColorU, _: &Appearance) -> Option<ColorU> {
Some(coloru_with_opacity(text_color, 60))
}
}
/// "Secondary" buttons have no fill and a border.
///
/// [Figma spec](https://www.figma.com/design/chk9pwt35jTJhf9KnHmZyE/Components?node-id=3628-14344&t=L1sS5Nxu1zzpWPYp-0)
pub struct Secondary;
impl Theme for Secondary {
fn background(&self, button_state: super::State, appearance: &Appearance) -> Option<Fill> {
match button_state {
super::State::Default => None,
super::State::Hovered => Some(internal_colors::fg_overlay_2(appearance.theme())),
super::State::Pressed => Some(internal_colors::fg_overlay_3(appearance.theme())),
}
}
fn text_color(&self, _background: Option<Fill>, appearance: &Appearance) -> ColorU {
appearance.theme().foreground().into()
}
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
Some(internal_colors::neutral_4(appearance.theme()))
}
fn keyboard_shortcut_background(&self, appearance: &Appearance) -> Option<ColorU> {
Some(internal_colors::neutral_3(appearance.theme()))
}
}
/// "Disabled" buttons have a disabled fill and text color.
///
/// [Figma spec](https://www.figma.com/design/chk9pwt35jTJhf9KnHmZyE/Components?node-id=3628-14344&t=c27DwGHWevMlisVN-0)
pub struct Disabled;
impl Theme for Disabled {
fn background(&self, _button_state: super::State, _: &Appearance) -> Option<Fill> {
None
}
fn text_color(&self, _background: Option<Fill>, appearance: &Appearance) -> ColorU {
internal_colors::neutral_5(appearance.theme())
}
fn keyboard_shortcut_background(&self, appearance: &Appearance) -> Option<ColorU> {
Some(internal_colors::neutral_3(appearance.theme()))
}
}
/// "Naked" buttons have no fill or border by default, only their contents.
///
/// This is typically used for link-like or text-style actions.
pub struct Naked;
impl Theme for Naked {
fn background(&self, button_state: super::State, appearance: &Appearance) -> Option<Fill> {
match button_state {
super::State::Default => None,
super::State::Hovered => Some(internal_colors::fg_overlay_2(appearance.theme())),
super::State::Pressed => Some(internal_colors::fg_overlay_3(appearance.theme())),
}
}
fn text_color(&self, _background: Option<Fill>, appearance: &Appearance) -> ColorU {
appearance.theme().foreground().into_solid()
}
fn keyboard_shortcut_background(&self, appearance: &Appearance) -> Option<ColorU> {
Some(internal_colors::neutral_3(appearance.theme()))
}
}
+166
View File
@@ -0,0 +1,166 @@
use std::{borrow::Cow, sync::Arc};
use warp_core::ui::{Icon, appearance::Appearance, theme::color::internal_colors};
use warpui::{
AppContext, EventContext,
elements::{
Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Element,
Flex, ParentElement, Radius, Shrinkable,
},
fonts::{Properties, Weight},
keymap::Keystroke,
prelude::{MainAxisAlignment, MainAxisSize, Text},
};
use crate::{Component, Options as _, Renderable, button};
const CORNER_RADIUS: Radius = Radius::Pixels(8.);
const BORDER_WIDTH: f32 = 1.;
/// Base unit of dialog padding. The dialog component applies this to the header, but consumers are responsible
/// for adding their own content padding. This allows for full-width contents, such as dividers, which should
/// not have any padding.
pub const BASE_PADDING: f32 = 12.;
/// Horizontal padding that consumers should apply to their contents.
pub const HORIZONTAL_PADDING: f32 = 2. * BASE_PADDING;
/// A reusable dialog component that renders content in a styled container.
#[derive(Default)]
pub struct Dialog {
close_button: button::Button,
}
pub struct Params<'a> {
/// Dialog title.
pub title: Cow<'static, str>,
/// The content to display inside the dialog.
pub content: Box<dyn Renderable<'a>>,
/// Optional configuration for the dialog.
pub options: Options<'a>,
}
impl<'a> crate::Params for Params<'a> {
type Options<'o> = Options<'o>;
}
/// A function that handles dismiss events.
pub type DismissHandler = Arc<dyn Fn(&mut EventContext, &AppContext)>;
pub struct Options<'a> {
/// Optional fixed width for the dialog. If not set, the dialog will size to its content.
pub width: Option<f32>,
/// Handler to invoke when the dialog is dismissed.
/// If `None`, the dialog is not dismissible.
pub on_dismiss: Option<DismissHandler>,
/// Optional keystroke associated with the dismiss action. This will be rendered alongside
/// the dismiss button in the dialog, but the caller is responsible for adding a keybinding.
pub dismiss_keystroke: Option<Keystroke>,
/// Optional footer to display at the bottom of the dialog.
pub footer: Option<Box<dyn Renderable<'a>>>,
}
impl<'a> crate::Options for Options<'a> {
fn default(_appearance: &Appearance) -> Self {
Self {
width: None,
on_dismiss: None,
dismiss_keystroke: None,
footer: None,
}
}
}
impl Component for Dialog {
type Params<'a> = Params<'a>;
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
let theme = appearance.theme();
let options = params.options;
let background = theme.surface_1();
let text_color = theme.main_text_color(background).into_solid();
let border_color = internal_colors::neutral_4(theme);
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
header_row.add_child(
Shrinkable::new(
1.,
Text::new_inline(
params.title,
appearance.ui_font_family(),
appearance.header_font_size(),
)
.with_color(text_color)
.with_style(Properties::default().weight(Weight::Semibold))
.finish(),
)
.finish(),
);
if let Some(on_dismiss) = options.on_dismiss.clone() {
let close_button = self.close_button.render(
appearance,
button::Params {
content: button::Content::Icon(Icon::X),
theme: &button::themes::Naked,
options: button::Options {
keystroke: options.dismiss_keystroke.clone(),
on_click: Some(Box::new(move |ctx, app, _| on_dismiss(ctx, app))),
..button::Options::default(appearance)
},
},
);
header_row.add_child(close_button);
}
let header = Container::new(header_row.finish())
.with_horizontal_padding(HORIZONTAL_PADDING)
.with_padding_top(2. * BASE_PADDING)
.with_padding_bottom(BASE_PADDING)
.finish();
let footer = options.footer.map(|footer| {
Container::new(footer.render(appearance))
.with_vertical_padding(BASE_PADDING)
.with_horizontal_padding(HORIZONTAL_PADDING)
.with_border(Border::top(BORDER_WIDTH).with_border_color(border_color))
.finish()
});
let body = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_child(header)
.with_child(params.content.render(appearance))
.with_children(footer)
.finish();
let container = Container::new(body)
.with_background(background)
.with_corner_radius(CornerRadius::with_all(CORNER_RADIUS))
.with_border(Border::all(BORDER_WIDTH).with_border_color(border_color))
.finish();
let sized_container = if let Some(width) = options.width {
ConstrainedBox::new(container).with_width(width).finish()
} else {
container
};
if let Some(on_dismiss) = options.on_dismiss {
Dismiss::new(sized_container)
.prevent_interaction_with_other_elements()
.on_dismiss(move |ctx, app| on_dismiss(ctx, app))
.finish()
} else {
sized_container
}
}
}
@@ -0,0 +1,202 @@
use std::borrow::Cow;
use warp_core::ui::{appearance::Appearance, icons::Icon};
use warpui::{keymap::Keystroke, platform::OperatingSystem, prelude::*};
use crate::Component;
#[derive(Default)]
pub struct KeyboardShortcut;
pub struct Params {
pub keystroke: Keystroke,
pub options: Options,
}
impl crate::Params for Params {
type Options<'a> = Options;
}
pub struct Options {
pub font_color: Option<ColorU>,
pub background: Option<Fill>,
pub border_fill: Option<Fill>,
pub sizing: Sizing,
}
impl crate::Options for Options {
fn default(appearance: &Appearance) -> Self {
Self {
font_color: None,
background: None,
border_fill: None,
sizing: Sizing::default(appearance),
}
}
}
#[derive(Copy, Clone)]
pub struct Sizing {
pub font_size: f32,
pub padding: f32,
}
impl crate::Options for Sizing {
fn default(appearance: &Appearance) -> Self {
Self {
font_size: appearance.ui_font_size() - 1.,
padding: 4.,
}
}
}
impl Component for KeyboardShortcut {
type Params<'a> = Params;
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
Flex::row()
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children(
keystroke_to_keys(params.keystroke)
.into_iter()
.map(|key| key.render(&params.options, appearance)),
)
.finish()
}
}
fn keystroke_to_keys(keystroke: Keystroke) -> Vec<Key> {
let mut keys = Vec::new();
// Note: The order of the modifiers is intentional, to match the VS Code command palette
if keystroke.ctrl {
keys.push(Key::Control);
}
if keystroke.shift {
keys.push(Key::Shift);
}
if keystroke.meta {
keys.push(Key::Meta);
}
if keystroke.alt {
keys.push(Key::Option);
}
if keystroke.cmd {
keys.push(Key::Command);
}
keys.push(Key::Other(keystroke.key.into()));
keys
}
#[derive(Clone)]
enum Key {
Command,
Option,
Control,
Shift,
Meta,
Other(Cow<'static, str>),
}
impl Key {
fn text(&self, is_lowercase_modifier: bool) -> Cow<'static, str> {
let is_mac = OperatingSystem::get().is_mac();
let mut text: Cow<'static, str> = match self {
Key::Command => if is_mac { "" } else { "Logo" }.into(),
Key::Option => if is_mac { "" } else { "Alt" }.into(),
Key::Control => if is_mac { "" } else { "Ctrl" }.into(),
Key::Shift => if is_mac { "" } else { "Shift" }.into(),
Key::Meta => "Meta".into(),
Key::Other(key) => match key.as_ref() {
"up" => "".into(),
"down" => "".into(),
"left" => "".into(),
"right" => "".into(),
"\t" => "Tab".into(),
" " => "Space".into(),
"escape" => "ESC".into(),
"enter" => "".into(),
"delete" => "".into(),
_ => {
// Capitalize the first letter of the key name
key.chars()
.next()
.map(|c| c.to_ascii_uppercase())
.into_iter()
.chain(key.chars().skip(1))
.collect()
}
},
};
// Single character keys should still be uppercase.
if text.len() > 1 && is_lowercase_modifier {
text = text.to_lowercase().into();
}
text
}
fn render(&self, options: &Options, appearance: &Appearance) -> Box<dyn Element> {
// TODO(vorporeal): consider supporting lowercase-only text
let text = self.text(false);
let font_size = options.sizing.font_size;
let color = options
.font_color
.unwrap_or_else(|| appearance.theme().foreground().into());
let content = if let Some(mut icon) = Icon::icon_for_key(text.as_ref()) {
icon = icon.with_color(color);
ConstrainedBox::new(icon.finish())
.with_height(font_size)
.with_width(font_size)
.finish()
} else {
Text::new(text, appearance.ui_font_family(), font_size)
.with_color(color)
.with_line_height_ratio(1.)
.with_selectable(false)
.finish()
};
let is_naked = options.background.is_none() && options.border_fill.is_none();
if is_naked {
content
} else {
let border_width = 1.;
let mut container = Container::new(
// Ensure that the center alignment is applied properly if the content does not
// meet the min width or height.
MinSize::new(
Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(content)
.finish(),
)
.finish(),
)
.with_padding(Padding::uniform(options.sizing.padding))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)));
if let Some(border_fill) = options.border_fill {
container =
container.with_border(Border::all(border_width).with_border_fill(border_fill));
}
if let Some(background) = options.background {
container = container.with_background(background);
}
// If there's some visual border (either due to a background or explicit border),
// prevent a "tall rectangle" aspect ratio.
let min_size = font_size + 2. * (options.sizing.padding + border_width);
ConstrainedBox::new(container.finish())
.with_min_height(min_size)
.with_min_width(min_size)
.finish()
}
}
}
+290
View File
@@ -0,0 +1,290 @@
pub mod button;
pub mod dialog;
pub mod keyboard_shortcut;
pub mod lightbox;
pub mod switch;
pub mod tooltip;
pub use keyboard_shortcut::KeyboardShortcut;
use warp_core::ui::appearance::Appearance;
use warpui::Element;
/// A reusable UI component that can be rendered with configurable parameters.
///
/// Components are designed to be long-lived and stored as fields in views rather than
/// created on every render. This is critical for components that maintain internal state
/// (such as mouse hover state via `MouseStateHandle`) - creating them fresh each render
/// will cause intra-frame state to be incorrect.
///
/// # Design Pattern
///
/// The component pattern separates:
/// - **Component struct**: Holds persistent state (mouse handles, tooltips, etc.)
/// - **Params struct**: Contains both required and optional rendering parameters
/// - **Options struct**: Contains only optional parameters with appearance-based defaults
///
/// This separation allows users to specify only what's necessary while getting sensible
/// defaults for everything else.
///
/// # Example
///
/// ```rust
/// use ui_components::{Component, Options, button};
/// use warp_core::ui::appearance::Appearance;
/// use warpui::prelude::*;
///
/// // Store component as a field in your view.
/// struct MyView {
/// my_button: button::Button,
/// }
///
/// impl MyView {
/// fn render_button(&self, appearance: &Appearance) -> Box<dyn warpui::Element> {
/// self.my_button.render(
/// appearance,
/// button::Params {
/// // Required: specify what the button displays.
/// content: button::Content::Label("Click me".into()),
/// theme: &button::themes::Primary,
/// // Optional: use defaults and override as needed.
/// options: button::Options {
/// disabled: false,
/// ..Options::default(appearance)
/// },
/// },
/// )
/// }
/// }
/// ```
///
/// # Implementing a New Component
///
/// ```rust
/// use ui_components::Component;
/// use warp_core::ui::appearance::Appearance;
/// use warpui::prelude::*;
///
/// // 1. Define the component struct with any persistent state.
/// #[derive(Default)]
/// pub struct MyComponent {
/// mouse_state: MouseStateHandle,
/// }
///
/// // 2. Define the params struct with required fields.
/// pub struct Params {
/// pub content: String, // Required parameter.
/// pub options: Options, // Optional parameters.
/// }
///
/// // 3. Define the options struct with optional fields.
/// pub struct Options {
/// pub disabled: bool,
/// pub size: f32,
/// }
///
/// // 4. Implement the traits.
/// impl ui_components::Params for Params {
/// type Options<'a> = Options;
/// }
///
/// impl ui_components::Options for Options {
/// fn default(appearance: &Appearance) -> Self {
/// Self {
/// disabled: false,
/// size: appearance.ui_font_size(),
/// }
/// }
/// }
///
/// impl Component for MyComponent {
/// type Params<'a> = Params;
///
/// fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
/// // Render implementation.
/// # Empty::new().finish()
/// }
/// }
/// ```
pub trait Component: Default {
/// The set of parameters that control rendering.
///
/// This type should include both required parameters (fields that must always be
/// specified, like button content) and an `options` field of type `Self::Params::Options`
/// containing optional parameters.
type Params<'a>: Params;
/// Renders the component given the current application appearance and rendering parameters.
///
/// This method is called during the render phase to produce the element tree for this
/// component. The component can use its internal state (mouse handles, etc.) along with
/// the provided parameters to determine how to render.
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element>;
}
/// The set of parameters that control rendering of a component.
///
/// This trait links a params struct to its corresponding options struct. The params struct
/// should contain:
/// - Required parameters as direct fields (e.g., button content, switch state)
/// - An `options: Self::Options` field for optional parameters
///
/// The lifetime parameter `'a` allows params and options to contain borrowed data, though
/// specific implementations may not use it.
///
/// # Example
///
/// ```rust
/// use ui_components::{Params, Options};
/// use warp_core::ui::appearance::Appearance;
///
/// pub struct MyParams {
/// pub content: String, // Required.
/// pub options: MyOptions, // Optional.
/// }
///
/// pub struct MyOptions;
///
/// impl Options for MyOptions {
/// fn default(_: &Appearance) -> Self { Self }
/// }
///
/// impl Params for MyParams {
/// type Options<'a> = MyOptions;
/// }
/// ```
pub trait Params {
/// The optional subset of parameters for this component.
///
/// This type should contain only optional configuration that has sensible defaults.
type Options<'a>: Options;
}
/// The optional subset of parameters that control rendering of a component.
///
/// Options provide appearance-based defaults for optional configuration, allowing users to
/// override only what they need. This trait requires implementing a `default` method that
/// computes appropriate defaults based on the current appearance (theme, font sizes, etc.).
///
/// # Design Philosophy
///
/// The distinction between required params and optional options allows for:
/// - **Compile-time safety**: Required parameters must be provided.
/// - **Convenience**: Optional parameters have sensible defaults.
/// - **Flexibility**: Defaults adapt to the current appearance/theme.
///
/// # Example
///
/// ```rust
/// use ui_components::{Options, MouseEventHandler};
/// use warp_core::ui::appearance::Appearance;
///
/// pub struct MyOptions {
/// pub disabled: bool,
/// pub font_size: f32,
/// pub on_click: Option<MouseEventHandler>,
/// }
///
/// impl Options for MyOptions {
/// fn default(appearance: &Appearance) -> Self {
/// Self {
/// disabled: false,
/// font_size: appearance.ui_font_size(),
/// on_click: None,
/// }
/// }
/// }
///
/// // Users can then use the struct update syntax to override specific options.
/// # fn example(appearance: &Appearance) {
/// let options = MyOptions {
/// disabled: true,
/// ..Options::default(&appearance)
/// };
/// # }
/// ```
pub trait Options {
/// Computes default values for optional parameters based on the current appearance.
///
/// This method should return sensible defaults that work well with the current theme,
/// font sizes, and other appearance settings. The appearance parameter allows defaults
/// to adapt to different visual contexts (light/dark theme, different font scales, etc.).
fn default(appearance: &Appearance) -> Self;
}
/// A trait representing anything that can be rendered to an element tree given an appearance.
///
/// This trait provides a common interface for both UI components and custom rendering closures.
/// It's primarily used to allow components to accept flexible rendering parameters - either
/// sub-components or inline rendering logic.
///
/// # Implementations
///
/// There are two main implementations:
///
/// 1. **Component tuples**: `(&'a T, T::Params<'a>)` where `T: Component`
/// - Allows passing a component reference with its params.
///
/// 2. **Closures**: Any `FnOnce(&Appearance) -> Box<dyn Element>`
/// - Allows inline rendering logic.
///
/// # Example
///
/// ```rust
/// use ui_components::{Renderable, Options};
/// use warp_core::ui::appearance::Appearance;
/// use warpui::prelude::*;
///
/// pub struct SwitchOptions<'a> {
/// pub disabled: bool,
/// // Accept either a component or a rendering closure for the label.
/// pub label: Option<Box<dyn Renderable<'a>>>,
/// }
///
/// impl ui_components::Options for SwitchOptions<'_> {
/// fn default(_: &Appearance) -> Self {
/// Self { disabled: false, label: None }
/// }
/// }
///
/// // Usage with a closure.
/// # fn example(appearance: &Appearance) {
/// let options = SwitchOptions {
/// label: Some(Box::new(|appearance: &Appearance| {
/// Text::new("My Label", appearance.ui_font_family(), appearance.ui_font_size())
/// .finish()
/// })),
/// ..Options::default(&appearance)
/// };
/// # }
/// ```
pub trait Renderable<'a> {
/// Renders this object into an element tree.
fn render(self: Box<Self>, appearance: &Appearance) -> Box<dyn Element>;
}
/// An implementation of [`Renderable`] for any [`UiComponent`] and its parameters.
impl<'a, T: Component> Renderable<'a> for (&'a T, T::Params<'a>) {
fn render(self: Box<Self>, appearance: &Appearance) -> Box<dyn Element> {
self.0.render(appearance, self.1)
}
}
/// An implementation of [`Renderable`] for any [`FnOnce`] that returns a [`Box<dyn Element>`].
impl<'a, T> Renderable<'a> for T
where
T: FnOnce(&Appearance) -> Box<dyn Element>,
{
fn render(self: Box<Self>, appearance: &Appearance) -> Box<dyn Element> {
self(appearance)
}
}
/// A function that handles mouse events.
pub type MouseEventHandler = Box<
dyn FnMut(
&mut warpui::EventContext,
&warpui::AppContext,
pathfinder_geometry::vector::Vector2F,
),
>;
+296
View File
@@ -0,0 +1,296 @@
use std::sync::Arc;
use pathfinder_geometry::vector::{Vector2F, vec2f};
use warp_core::ui::{Icon, appearance::Appearance};
use warpui::{
assets::asset_cache::AssetSource,
elements::{CacheOption, Dismiss, DispatchEventResult, EventHandler, Image, Shrinkable},
keymap::Keystroke,
prelude::{stack::*, *},
};
use crate::{Component, Options as _, button};
/// Padding between the scrim edge and the image.
const SCRIM_PADDING: f32 = 48.;
/// Spacing between the image/loading area and the description text.
const DESCRIPTION_SPACING: f32 = 12.;
const LIGHTBOX_TEXT_SIZE_DELTA: f32 = 4.;
/// Semi-transparent black background color for the scrim.
fn scrim_color() -> ColorU {
ColorU::new(0, 0, 0, 230)
}
/// The loading state of a lightbox image.
#[derive(Clone, Debug)]
pub enum LightboxImageSource {
/// The image metadata is still being fetched.
Loading,
/// The image source has been resolved.
/// Note: the actual image bytes may still be loading via the `AssetCache`.
Resolved { asset_source: AssetSource },
}
/// A single image entry in the lightbox.
#[derive(Clone, Debug)]
pub struct LightboxImage {
/// The loading/loaded state of this image.
pub source: LightboxImageSource,
/// Optional description displayed below the image.
pub description: Option<String>,
}
/// Direction for navigating between images.
#[derive(Clone, Copy, Debug)]
pub enum NavigationDirection {
Previous,
Next,
}
/// A handler invoked when the user navigates between images.
pub type NavigateHandler = Arc<dyn Fn(NavigationDirection, &mut EventContext, &AppContext)>;
/// A lightbox component for displaying images in a full-window overlay.
///
/// The lightbox displays one or more images centered on screen with a semi-transparent scrim
/// background. It supports navigating between images via arrow buttons and can be dismissed by
/// clicking outside the image, clicking the close button, or pressing Escape.
#[derive(Default)]
pub struct Lightbox {
close_button: button::Button,
prev_button: button::Button,
next_button: button::Button,
}
pub struct Params<'a> {
/// The list of images to display.
pub images: &'a [LightboxImage],
/// The index of the currently displayed image.
pub current_index: usize,
/// Handler to invoke when the lightbox is dismissed.
pub on_dismiss: DismissHandler,
/// The native pixel dimensions of the currently displayed image, if known.
/// When `Some`, the image is fully loaded and the lightbox renders it with a
/// `ConstrainedBox` plus description. When `None`, the lightbox shows a loading
/// indicator instead.
pub current_image_native_size: Option<Vector2F>,
/// Optional configuration for the lightbox.
pub options: Options,
}
impl crate::Params for Params<'_> {
type Options<'a> = Options;
}
/// A function that handles dismiss events.
pub type DismissHandler = Arc<dyn Fn(&mut EventContext, &AppContext)>;
pub struct Options {
/// Optional keystroke associated with the dismiss action. This will be rendered alongside
/// the dismiss button in the dialog, but the caller is responsible for adding a keybinding.
pub dismiss_keystroke: Option<Keystroke>,
/// Handler to invoke when the user navigates between images.
/// If `None`, navigation buttons are not shown.
pub on_navigate: Option<NavigateHandler>,
}
impl crate::Options for Options {
fn default(_appearance: &Appearance) -> Self {
Self {
dismiss_keystroke: None,
on_navigate: None,
}
}
}
impl Component for Lightbox {
type Params<'a> = Params<'a>;
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
let on_dismiss_for_button = params.on_dismiss.clone();
let on_dismiss = params.on_dismiss;
let image_count = params.images.len();
let current_index = params.current_index;
// Extract current image data via direct indexing.
let current_image = params.images.get(current_index);
let current_source = current_image.map(|img| &img.source);
let current_description = current_image.and_then(|img| img.description.clone());
let text_size = lightbox_text_size(appearance);
// Close button in the top-right corner.
let close_button = self.close_button.render(
appearance,
button::Params {
content: button::Content::Icon(Icon::X),
theme: &button::themes::Secondary,
options: button::Options {
size: button::Size::Small,
on_click: Some(Box::new(move |ctx, app, _| {
on_dismiss_for_button(ctx, app);
})),
keystroke: params.options.dismiss_keystroke,
..button::Options::default(appearance)
},
},
);
// Build the central content based on the image source and whether the
// native size is known (i.e. the image data has been loaded).
let central_content: Box<dyn Element> =
match (current_source, params.current_image_native_size) {
// Image source resolved AND native size known → render the image.
(Some(LightboxImageSource::Resolved { asset_source }), Some(native_size)) => {
let image = ConstrainedBox::new(
Image::new(asset_source.clone(), CacheOption::Original)
.contain()
.before_load(Align::new(loading_element(appearance)).finish())
.finish(),
)
.with_max_width(native_size.x())
.with_max_height(native_size.y())
.finish();
EventHandler::new(image)
.on_left_mouse_down(|_, _, _| DispatchEventResult::StopPropagation)
.finish()
}
// No images provided at all.
_ if image_count == 0 => {
Text::new("No images", appearance.ui_font_family(), text_size)
.with_color(ColorU::white())
.finish()
}
// Still loading (either metadata or image bytes).
_ => loading_element(appearance),
};
// Show the description only when the image is fully loaded (native size known).
let content_with_description = if let (Some(description), Some(_)) =
(current_description, params.current_image_native_size)
{
let description_text = Text::new(description, appearance.ui_font_family(), text_size)
.with_color(ColorU::white())
.finish();
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(DESCRIPTION_SPACING)
.with_child(Shrinkable::new(1.0, central_content).finish())
.with_child(description_text)
.finish()
} else {
central_content
};
let centered_content = Align::new(content_with_description).finish();
let scrim = Container::new(
Dismiss::new(centered_content)
.prevent_interaction_with_other_elements()
.on_dismiss(move |ctx, app| on_dismiss(ctx, app))
.finish(),
)
.with_background_color(scrim_color())
.with_uniform_padding(SCRIM_PADDING)
.finish();
// Stack the scrim, close button, and optional navigation arrows.
let mut content = Stack::new().with_child(scrim);
content.add_positioned_child(
close_button,
OffsetPositioning::offset_from_parent(
vec2f(-12., 12.),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
// Navigation arrows (only shown when there are multiple images).
if image_count > 1
&& let Some(on_navigate) = params.options.on_navigate
{
// Previous button (hidden on first image).
if current_index > 0 {
let on_nav = on_navigate.clone();
let prev_button = self.prev_button.render(
appearance,
button::Params {
content: button::Content::Icon(Icon::ChevronLeft),
theme: &button::themes::Secondary,
options: button::Options {
size: button::Size::Small,
on_click: Some(Box::new(move |ctx, app, _| {
on_nav(NavigationDirection::Previous, ctx, app);
})),
..button::Options::default(appearance)
},
},
);
content.add_positioned_child(
prev_button,
OffsetPositioning::offset_from_parent(
vec2f(12., 0.),
ParentOffsetBounds::Unbounded,
ParentAnchor::MiddleLeft,
ChildAnchor::MiddleLeft,
),
);
}
// Next button (hidden on last image).
if current_index < image_count - 1 {
let on_nav = on_navigate;
let next_button = self.next_button.render(
appearance,
button::Params {
content: button::Content::Icon(Icon::ChevronRight),
theme: &button::themes::Secondary,
options: button::Options {
size: button::Size::Small,
on_click: Some(Box::new(move |ctx, app, _| {
on_nav(NavigationDirection::Next, ctx, app);
})),
..button::Options::default(appearance)
},
},
);
content.add_positioned_child(
next_button,
OffsetPositioning::offset_from_parent(
vec2f(-12., 0.),
ParentOffsetBounds::Unbounded,
ParentAnchor::MiddleRight,
ChildAnchor::MiddleRight,
),
);
}
}
content.finish()
}
}
/// Builds the shared "Loading..." text element used in both the `Loading` state
/// and as the `before_load` fallback while the `AssetCache` fetches image bytes.
fn loading_element(appearance: &Appearance) -> Box<dyn Element> {
Text::new(
"Loading...",
appearance.ui_font_family(),
lightbox_text_size(appearance),
)
.with_color(ColorU::white())
.finish()
}
fn lightbox_text_size(appearance: &Appearance) -> f32 {
appearance.ui_font_size() + LIGHTBOX_TEXT_SIZE_DELTA
}
+211
View File
@@ -0,0 +1,211 @@
use std::sync::LazyLock;
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::{MouseStateHandle, Rect},
prelude::{stack::*, *},
};
use crate::{Component, MouseEventHandler, Renderable};
/// The color of the switch's track when it is unchecked.
static TRACK_COLOR: LazyLock<ColorU> = LazyLock::new(|| ColorU::new(170, 170, 170, 255));
/// The drop shadow to apply to the switch's thumb when it is hovered.
static DROP_SHADOW: LazyLock<DropShadow> = LazyLock::new(|| DropShadow {
color: ColorU::black(),
offset: vec2f(-0.5, 2.),
blur_radius: 20.,
spread_radius: 0.,
});
#[derive(Default)]
pub struct Switch {
component_mouse_state: MouseStateHandle,
thumb_mouse_state: MouseStateHandle,
}
pub struct Params<'a> {
pub checked: bool,
pub on_click: Option<MouseEventHandler>,
pub options: Options<'a>,
}
impl<'a> crate::Params for Params<'a> {
type Options<'o> = Options<'a>;
}
#[derive(Default)]
pub struct Options<'a> {
pub disabled: bool,
pub height: f32,
/// Optional label for the switch that is rendered within the switch's click target.
pub label: Option<Box<dyn Renderable<'a>>>,
pub hover_border_size: Option<f32>,
}
impl crate::Options for Options<'_> {
fn default(_appearance: &Appearance) -> Self {
const DEFAULT_THUMB_HEIGHT: f32 = 18.;
Self {
disabled: false,
height: DEFAULT_THUMB_HEIGHT,
label: None,
hover_border_size: None,
}
}
}
impl Component for Switch {
type Params<'a> = Params<'a>;
fn render<'a>(&self, appearance: &Appearance, params: Self::Params<'a>) -> Box<dyn Element> {
let disabled = params.options.disabled;
let switch = self.render_switch(appearance, &params);
let mut hoverable = Hoverable::new(self.component_mouse_state.clone(), |_state| {
if let Some(label) = params.options.label {
let label = label.render(appearance);
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(label)
.with_child(Container::new(switch).with_margin_left(8.).finish())
.finish()
} else {
switch
}
});
if !disabled && let Some(mut on_click) = params.on_click {
hoverable = hoverable.on_click(move |ctx, app, pos| {
on_click(ctx, app, pos);
});
}
if !disabled {
hoverable = hoverable.with_cursor(Cursor::PointingHand);
}
hoverable.finish()
}
}
impl Switch {
fn render_switch(&self, appearance: &Appearance, params: &Params) -> Box<dyn Element> {
let thumb_height = params.options.height;
let track = Container::new(
ConstrainedBox::new(Empty::new().finish())
.with_width(thumb_height * 2.)
.with_height(thumb_height)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)));
let background_color = if params.checked {
appearance.theme().accent().into()
} else {
Fill::Solid(*TRACK_COLOR)
};
Stack::new()
.with_child(track.with_background(background_color).finish())
.with_positioned_child(
self.render_thumb(params),
Self::thumb_positioning(params.checked),
)
.finish()
}
fn thumb_positioning(checked: bool) -> OffsetPositioning {
// If checked, right-align the thumb. If unchecked, left-align the thumb.
let (parent_anchor, child_anchor) = if checked {
(ParentAnchor::TopRight, ChildAnchor::TopRight)
} else {
(ParentAnchor::TopLeft, ChildAnchor::TopLeft)
};
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::Unbounded,
parent_anchor,
child_anchor,
)
}
// Renders the thumb. The thumb needs its own hoverable to render a border around itself when
// hovered.
fn render_thumb(&self, params: &Params<'_>) -> Box<dyn Element> {
let thumb_height = params.options.height;
let is_disabled = params.options.disabled;
let thumb_color = Fill::Solid(ColorU::white());
Hoverable::new(self.thumb_mouse_state.clone(), |state| {
let thumb = Container::new(
ConstrainedBox::new(
Rect::new()
.with_background(thumb_color)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_drop_shadow(*DROP_SHADOW)
.finish(),
)
.with_width(thumb_height)
.with_height(thumb_height)
.finish(),
)
.finish();
let mut stack = Stack::new();
// If a border is specified and the mouse is over the element,
// render a circle behind the thumb with the border color.
if let Some(border_size) = params.options.hover_border_size
&& !is_disabled
&& state.is_mouse_over_element()
{
Self::add_thumb_hover(&mut stack, thumb_height, border_size);
}
stack.add_child(thumb);
stack.finish()
})
.finish()
}
/// Adds the hovered thumb border to the given stack.
fn add_thumb_hover(stack: &mut Stack, thumb_height: f32, border_size: f32) {
let mut hover_background = *TRACK_COLOR;
hover_background.a = 100;
let hover_size = thumb_height + border_size;
let thumb_hover = ConstrainedBox::new(
Rect::new()
.with_background_color(hover_background)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_width(hover_size)
.with_height(hover_size)
.finish();
// Compute the difference in radii between the hover and the thumb.
let radius_diff = (hover_size - thumb_height) / 2.;
// Offset the hover so that it's centered around the thumb.
let offset = OffsetType::Pixel(-radius_diff);
stack.add_positioned_child(
thumb_hover,
OffsetPositioning::from_axes(
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
offset,
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
offset,
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
use std::borrow::Cow;
use warp_core::ui::{appearance::Appearance, theme::color::internal_colors};
use warpui::{keymap::Keystroke, prelude::*};
use crate::{Component, keyboard_shortcut};
/// Use a smaller-than-normal font size for the tooltip text to make it more compact.
const UI_FONT_SIZE_ADJUSTMENT: f32 = -2.;
#[derive(Default)]
pub struct Tooltip;
pub struct Params {
pub label: Cow<'static, str>,
pub options: Options,
}
impl crate::Params for Params {
type Options<'a> = Options;
}
pub struct Options {
pub keyboard_shortcut: Option<Keystroke>,
}
impl crate::Options for Options {
fn default(_: &Appearance) -> Self {
Self {
keyboard_shortcut: None,
}
}
}
impl Component for Tooltip {
type Params<'a> = Params;
fn render<'a>(
&self,
appearance: &Appearance,
params: Self::Params<'a>,
) -> Box<dyn warpui::Element> {
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size() + UI_FONT_SIZE_ADJUSTMENT;
let mut content = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(10.)
.with_child(
Text::new(params.label, font_family, font_size)
.soft_wrap(false)
.with_color(appearance.theme().background().into_solid())
.finish(),
);
if let Some(keystroke) = params.options.keyboard_shortcut {
content.add_child(keyboard_shortcut::KeyboardShortcut.render(
appearance,
keyboard_shortcut::Params {
keystroke,
options: keyboard_shortcut::Options {
font_color: Some(internal_colors::semantic_text_disabled(
appearance.theme(),
)),
sizing: keyboard_shortcut::Sizing {
font_size,
..crate::Options::default(appearance)
},
..crate::Options::default(appearance)
},
},
));
}
Container::new(content.finish())
.with_horizontal_padding(7.)
.with_vertical_padding(3.)
.with_background(appearance.theme().tooltip_background())
.with_border(Border::all(1.).with_border_fill(appearance.theme().surface_2()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish()
}
}