Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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"] }
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 ¶ms.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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
>;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms);
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user