Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
@@ -0,0 +1,452 @@
use pathfinder_geometry::vector::vec2f;
use std::borrow::Cow;
use crate::elements::{
Align, ChildAnchor, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Shrinkable, Stack,
};
use crate::geometry::vector::Vector2F;
use crate::platform::Cursor;
use crate::{
elements::{
Border, ConstrainedBox, Container, Element, Empty, Hoverable, Icon, MouseState,
MouseStateHandle,
},
ui_components::{
components::{UiComponent, UiComponentStyles},
text::Span,
},
};
/// Enum specifying relative alignment of the text and icon within
/// a button. "First" is used instead of left/right to make this
/// robust to RTL languages.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TextAndIconAlignment {
/// Render the icon before the text.
IconFirst,
/// Render the text before the icon.
TextFirst,
}
/// Configuration data for a button containing both a text and an icon.
#[derive(Clone)]
pub struct TextAndIcon {
alignment: TextAndIconAlignment,
/// The amount of space the `Flex` row should consume along the main axis.
flex_size: MainAxisSize,
/// The alignment strategy for rendering the `Flex`.
flex_spacing: MainAxisAlignment,
text: Cow<'static, str>,
icon: Icon,
/// Padding between the text and the icon.
padding: f32,
icon_size: Vector2F,
}
impl TextAndIcon {
pub fn new(
alignment: TextAndIconAlignment,
text: impl Into<Cow<'static, str>>,
icon: Icon,
flex_size: MainAxisSize,
flex_spacing: MainAxisAlignment,
icon_size: Vector2F,
) -> Self {
Self {
alignment,
flex_size,
flex_spacing,
text: text.into(),
icon,
padding: 0.,
icon_size,
}
}
pub fn with_inner_padding(mut self, padding: f32) -> Self {
self.padding = padding;
self
}
}
enum ButtonLabel {
None,
/// A start-aligned text label.
Text(String),
/// A center-aligned text label.
CenteredText(String),
Icon(Icon),
TextAndIcon(TextAndIcon),
Custom(Box<dyn Element>),
}
pub struct Button {
label: ButtonLabel,
/// Should the button be clickable?
disabled: bool,
/// Was the button clicked and its state is active?
active: bool,
styles: UiComponentStyles,
/// Used when the button is hovered, if None - falls back to `styles`
hovered_styles: Option<UiComponentStyles>,
/// Used when the button is clicked, if None - falls back to `styles`
clicked_styles: Option<UiComponentStyles>,
/// Used when the button is disabled, if None - falls back to `styles`
disabled_styles: Option<UiComponentStyles>,
/// Used when the button is active, if None - falls back to `clicked_styles` when available,
/// or `styles` otherwise
active_styles: Option<UiComponentStyles>,
render_tooltip_fn: Option<Box<dyn FnOnce() -> Box<dyn Element>>>,
tooltip_position: ButtonTooltipPosition,
hover_state: MouseStateHandle,
cursor: Option<Cursor>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ButtonVariant {
Basic,
Secondary,
Accent,
Outlined,
Warn,
Error,
Text,
Link,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ButtonTooltipPosition {
/// Position the tooltip above the button (center-aligned).
Above,
/// Position the tooltip below the button (center-aligned).
#[default]
Below,
/// Position the tooltip above the button (left-aligned).
AboveLeft,
/// Position the tooltip above the button (right-aligned).
AboveRight,
/// Position the tooltip below the button (left-aligned).
BelowLeft,
/// Position the tooltip below the button (right-aligned).
BelowRight,
}
impl UiComponent for Button {
type ElementType = Hoverable;
fn build(self) -> Hoverable {
let disabled = self.disabled;
let cursor = self.cursor;
let mut hoverable = Hoverable::new(self.hover_state.clone(), |state| {
self.render_inner_button(state)
});
if let Some(cursor) = cursor {
hoverable = hoverable.with_cursor(cursor);
}
if disabled {
return hoverable.disable();
}
hoverable
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Button {
styles: self.styles.merge(styles),
hovered_styles: Some(self.hovered_styles.unwrap_or(self.styles).merge(styles)),
clicked_styles: Some(self.clicked_styles.unwrap_or(self.styles).merge(styles)),
disabled_styles: Some(self.disabled_styles.unwrap_or(self.styles).merge(styles)),
active_styles: Some(self.active_styles.unwrap_or(self.styles).merge(styles)),
..self
}
}
}
impl Button {
pub fn new(
mouse_state: MouseStateHandle,
default_styles: UiComponentStyles,
hovered_styles: Option<UiComponentStyles>,
clicked_styles: Option<UiComponentStyles>,
disabled_styles: Option<UiComponentStyles>,
) -> Self {
Button {
label: ButtonLabel::None,
disabled: false,
styles: default_styles,
hovered_styles,
clicked_styles,
disabled_styles,
active_styles: None,
active: false,
hover_state: mouse_state,
render_tooltip_fn: None,
tooltip_position: Default::default(),
cursor: Some(Cursor::PointingHand),
}
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
pub fn active(mut self) -> Self {
self.active = true;
self
}
pub fn with_text_label(mut self, label: String) -> Self {
self.label = ButtonLabel::Text(label);
self
}
pub fn with_centered_text_label(mut self, label: String) -> Self {
self.label = ButtonLabel::CenteredText(label);
self
}
pub fn with_icon_label(mut self, icon: Icon) -> Self {
self.label = ButtonLabel::Icon(icon);
self
}
pub fn with_cursor(mut self, cursor: Option<Cursor>) -> Self {
self.cursor = cursor;
self
}
pub fn with_active_styles(mut self, styles: UiComponentStyles) -> Self {
self.active_styles = Some(self.styles.merge(styles));
self
}
pub fn with_hovered_styles(mut self, styles: UiComponentStyles) -> Self {
self.hovered_styles = Some(self.styles.merge(styles));
self
}
pub fn hovered_styles(&self) -> &UiComponentStyles {
self.hovered_styles.as_ref().unwrap_or(&self.styles)
}
pub fn with_disabled_styles(mut self, styles: UiComponentStyles) -> Self {
self.disabled_styles = Some(self.styles.merge(styles));
self
}
pub fn with_clicked_styles(mut self, styles: UiComponentStyles) -> Self {
self.clicked_styles = Some(self.styles.merge(styles));
self
}
/// Renders text followed by an icon within the Button.
pub fn with_text_and_icon_label(mut self, text_and_icon: TextAndIcon) -> Self {
self.label = ButtonLabel::TextAndIcon(text_and_icon);
self
}
pub fn with_custom_label(mut self, label: Box<dyn Element>) -> Self {
self.label = ButtonLabel::Custom(label);
self
}
pub fn with_tooltip<F>(mut self, render_tooltip_fn: F) -> Self
where
F: 'static + FnOnce() -> Box<dyn Element>,
{
self.render_tooltip_fn = Some(Box::new(render_tooltip_fn));
self
}
/// Sets how the tooltip is positioned relative to the button itself. This only has an effect
/// if a tooltip is set with [`Self::with_tooltip`].
pub fn with_tooltip_position(mut self, position: ButtonTooltipPosition) -> Self {
self.tooltip_position = position;
self
}
fn styles(&self, state: &MouseState) -> UiComponentStyles {
// disabled button ignores click/hover events
if self.disabled {
return self.disabled_styles.unwrap_or(self.styles);
}
if self.active {
return self
.active_styles
.unwrap_or_else(|| self.clicked_styles.unwrap_or(self.styles));
}
// For hover styles, we want to show the correct style based on
// where the mouse _currently_ is, rather than whether the element
// is considered hovered, because the latter takes into account delays.
if state.is_mouse_over_element() {
if state.is_clicked() {
return self.clicked_styles.unwrap_or(self.styles);
}
return self.hovered_styles.unwrap_or(self.styles);
}
self.styles
}
fn render_inner_button(mut self, state: &MouseState) -> Box<dyn Element> {
let styles = self.styles(state);
// Text & font / Icon
let label = match self.label {
ButtonLabel::Text(text) => Span::new(text, styles).build().finish(),
ButtonLabel::CenteredText(text) => {
Align::new(Span::new(text, styles).build().finish()).finish()
}
ButtonLabel::Icon(icon) => {
if let Some(color) = styles.font_color {
icon.with_color(color).finish()
} else {
icon.finish()
}
}
ButtonLabel::TextAndIcon(text_and_icon) => {
let text = Shrinkable::new(
1.,
Container::new(Span::new(text_and_icon.text, styles).build().finish()).finish(),
)
.finish();
let icon = if let Some(color) = styles.font_color {
text_and_icon.icon.with_color(color).finish()
} else {
text_and_icon.icon.finish()
};
let icon = ConstrainedBox::new(icon)
.with_width(text_and_icon.icon_size.x())
.with_height(text_and_icon.icon_size.y())
.finish();
let (first, second) = if text_and_icon.alignment == TextAndIconAlignment::TextFirst
{
(text, icon)
} else {
(icon, text)
};
Flex::row()
.with_children([
first,
Container::new(second)
.with_padding_left(text_and_icon.padding)
.finish(),
])
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(text_and_icon.flex_spacing)
.with_main_axis_size(text_and_icon.flex_size)
.finish()
}
ButtonLabel::Custom(element) => element,
ButtonLabel::None => Empty::new().finish(),
};
let mut container = Container::new(label);
// Setting up the border
if let Some(corner) = styles.border_radius {
container = container.with_corner_radius(corner);
}
// TODO border width separate for top/left/right/bottom
let mut border = Border::all(styles.border_width.unwrap_or_default());
if let Some(border_color) = styles.border_color {
border = border.with_border_fill(border_color);
}
container = container.with_border(border);
// Position-related settings
if let Some(padding) = styles.padding {
container = container
.with_padding_left(padding.left)
.with_padding_top(padding.top)
.with_padding_right(padding.right)
.with_padding_bottom(padding.bottom);
}
if let Some(margin) = styles.margin {
container = container
.with_margin_left(margin.left)
.with_margin_top(margin.top)
.with_margin_right(margin.right)
.with_margin_bottom(margin.bottom);
}
if let Some(background) = styles.background {
container = container.with_background(background);
}
let container = match (styles.height, styles.width) {
(None, None) => container.finish(),
(_, _) => {
let mut constrained_box = ConstrainedBox::new(container.finish());
if let Some(height) = styles.height {
constrained_box = constrained_box.with_height(height);
}
if let Some(width) = styles.width {
constrained_box = constrained_box.with_width(width);
}
constrained_box.finish()
}
};
// The tooltip should only be shown if the element
// is considered hovered (accounting for delays).
if state.is_hovered() {
if let Some(render_tooltip_fn) = self.render_tooltip_fn.take() {
// Keep stack within this rather than using a stack for all cases to allow multiple stack overlays to work
let mut stack = Stack::new();
stack.add_child(container);
let tooltip = render_tooltip_fn();
let tooltip_offset = match self.tooltip_position {
ButtonTooltipPosition::Above => OffsetPositioning::offset_from_parent(
vec2f(0., -8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopMiddle,
ChildAnchor::BottomMiddle,
),
ButtonTooltipPosition::Below => OffsetPositioning::offset_from_parent(
vec2f(0., 8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
ButtonTooltipPosition::AboveLeft => OffsetPositioning::offset_from_parent(
vec2f(0., -8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::BottomLeft,
),
ButtonTooltipPosition::BelowLeft => OffsetPositioning::offset_from_parent(
vec2f(0., 8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
ButtonTooltipPosition::AboveRight => OffsetPositioning::offset_from_parent(
vec2f(0., -8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
),
ButtonTooltipPosition::BelowRight => OffsetPositioning::offset_from_parent(
vec2f(0., 8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomRight,
ChildAnchor::TopRight,
),
};
stack.add_positioned_overlay_child(tooltip, tooltip_offset);
return stack.finish();
}
}
container
}
pub fn set_clicked_styles(mut self, styles: Option<UiComponentStyles>) -> Self {
self.clicked_styles = styles;
self
}
}
@@ -0,0 +1,233 @@
use crate::color::ColorU;
use crate::elements::{ChildAnchor, ParentAnchor, ParentOffsetBounds};
use crate::geometry::vector::Vector2F;
use crate::prelude::{Coords, Fill};
use crate::{
elements::{
Align, Border, ConstrainedBox, Container, Element, Flex, Hoverable, Icon, MouseState,
MouseStateHandle, OffsetPositioning, ParentElement, Rect, Stack,
},
ui_components::components::{UiComponent, UiComponentStyles},
ui_components::text::Span,
};
use lazy_static::lazy_static;
const CHECK_SVG_PATH: &str = "bundled/svg/check-thick.svg";
/// Number of pixels that should be between the checkmark and checkbox.
const CHECKMARK_LENGTH_ADJUSTMENT: f32 = 3.;
const LABEL_LEFT_MARGIN: f32 = 4.;
lazy_static! {
pub static ref HOVER_BACKGROUND_COLOR: ColorU = ColorU::new(170, 170, 170, 50);
}
pub struct Checkbox {
default_styles: UiComponentStyles,
hovered_styles: Option<UiComponentStyles>,
checked_styles: Option<UiComponentStyles>,
disabled_styles: Option<UiComponentStyles>,
hover_state: MouseStateHandle,
disabled: bool,
checked: bool,
/// Optional, clickable text rendered to the right of the checkbox
label: Option<Span>,
}
impl UiComponent for Checkbox {
type ElementType = Hoverable;
fn build(self) -> Hoverable {
let hoverable = Hoverable::new(self.hover_state.clone(), |state| {
let checkbox = self.render_checkbox(state);
if let Some(label) = self.label.clone() {
Flex::row()
.with_cross_axis_alignment(crate::elements::CrossAxisAlignment::Center)
.with_child(checkbox)
.with_child(
Container::new(label.with_style(self.styles(state)).build().finish())
.with_margin_left(LABEL_LEFT_MARGIN)
.finish(),
)
.finish()
} else {
checkbox
}
});
if self.disabled {
return hoverable.disable();
}
hoverable
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
default_styles: self.default_styles.merge(styles),
hovered_styles: Some(
self.hovered_styles
.unwrap_or(self.default_styles)
.merge(styles),
),
checked_styles: Some(
self.checked_styles
.unwrap_or(self.default_styles)
.merge(styles),
),
disabled_styles: Some(
self.disabled_styles
.unwrap_or(self.default_styles)
.merge(styles),
),
..self
}
}
}
impl Checkbox {
pub fn new(
mouse_state: MouseStateHandle,
default_styles: UiComponentStyles,
hovered_styles: Option<UiComponentStyles>,
checked_styles: Option<UiComponentStyles>,
disabled_styles: Option<UiComponentStyles>,
) -> Self {
Self {
default_styles,
hovered_styles,
checked_styles,
disabled_styles,
hover_state: mouse_state,
disabled: false,
checked: false,
label: None,
}
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
pub fn check(mut self, check: bool) -> Self {
self.checked = check;
self
}
pub fn with_label(mut self, label: Span) -> Self {
self.label = Some(label);
self
}
fn styles(&self, state: &MouseState) -> UiComponentStyles {
let styles = if self.disabled {
self.disabled_styles
} else if self.checked || state.is_clicked() {
self.checked_styles
} else {
None
};
styles.unwrap_or(self.default_styles)
}
// If checked, use the icon with the appropriate color. Otherwise, use an empty box.
fn render_checkmark(&self, checked: bool, icon_color: ColorU) -> Box<dyn Element> {
if checked {
Icon::new(CHECK_SVG_PATH, icon_color).finish()
} else {
Rect::new().finish()
}
}
fn render_checkbox(&self, state: &MouseState) -> Box<dyn Element> {
let styles = self.styles(state);
let border_width = styles.border_width;
// The full length of the checkbox will be the font size, but we need
// to account for the border on each side of the length.
let checkbox_length = styles.font_size.unwrap_or_default();
let checkbox_length_without_border = checkbox_length - 2. * border_width.unwrap_or(0.);
// Use font_color for the checkmark when checked, otherwise use default foreground
let icon_color = styles.font_color.unwrap_or_default();
let checkmark = self.render_checkmark(self.checked, icon_color);
let checkmark_length = checkbox_length_without_border - CHECKMARK_LENGTH_ADJUSTMENT;
let mut checkbox = Container::new(
ConstrainedBox::new(
Align::new(
ConstrainedBox::new(checkmark)
.with_height(checkmark_length)
.with_width(checkmark_length)
.finish(),
)
.finish(),
)
.with_width(checkbox_length_without_border)
.with_height(checkbox_length_without_border)
.finish(),
)
.with_corner_radius(styles.border_radius.unwrap_or_default());
if !state.is_mouse_over_element() {
if let Some(background) = styles.background {
checkbox = checkbox.with_background(background);
}
}
if let Some(border_width) = border_width {
checkbox = checkbox.with_border(
Border::all(border_width).with_border_fill(styles.border_color.unwrap_or_default()),
);
}
let mut stack = Stack::new();
if state.is_mouse_over_element() {
let hover = Container::new(
ConstrainedBox::new(
Rect::new()
.with_background(
self.hovered_styles
.and_then(|styles| styles.background)
.unwrap_or(Fill::Solid(*HOVER_BACKGROUND_COLOR)),
)
.with_corner_radius(styles.border_radius.unwrap_or_default())
.finish(),
)
.with_width(checkbox_length)
.with_height(checkbox_length)
.finish(),
)
.finish();
// Position the hover so that it's centered behind the checkbox.
stack.add_positioned_child(
hover,
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::Unbounded,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
// Add the checkbox itself
stack.add_child(checkbox.finish());
let margin = styles
.margin
.unwrap_or(Coords::uniform(checkbox_length / 2.));
Container::new(stack.finish())
.with_margin_left(margin.left)
.with_margin_right(margin.right)
.with_margin_top(margin.top)
.with_margin_bottom(margin.bottom)
.finish()
}
}
@@ -0,0 +1,111 @@
use crate::{
elements::{
ConstrainedBox, Container, CrossAxisAlignment, Flex, Icon, MainAxisAlignment, MainAxisSize,
ParentElement,
},
scene::Border,
Element,
};
use super::{
components::{UiComponent, UiComponentStyles},
text::Span,
};
pub struct Chip {
label: String,
styles: UiComponentStyles,
icon: Option<Icon>,
close_button: Option<Box<dyn Element>>,
}
impl Chip {
pub fn new(label: String, styles: UiComponentStyles) -> Self {
Self {
label,
styles,
icon: Default::default(),
close_button: Default::default(),
}
}
pub fn with_icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn with_close_button(mut self, close_button: Box<dyn Element>) -> Self {
self.close_button = Some(close_button);
self
}
fn styles(&self) -> UiComponentStyles {
self.styles
}
}
impl UiComponent for Chip {
type ElementType = Container;
fn build(self) -> Container {
let styles = self.styles();
let mut label_and_button = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
if let Some(icon) = self.icon {
label_and_button.add_child(
ConstrainedBox::new(icon.finish())
.with_width(styles.font_size.unwrap_or_default())
.with_height(styles.font_size.unwrap_or_default())
.finish(),
);
}
label_and_button.add_child(
Container::new(
ConstrainedBox::new(Span::new(self.label, styles).build().finish())
.with_max_width(240.)
.finish(),
)
.with_margin_left(5.)
.finish(),
);
if let Some(close_button) = self.close_button {
label_and_button.add_child(Container::new(close_button).with_margin_left(10.).finish());
}
let mut container = Container::new(label_and_button.finish())
.with_horizontal_padding(4.)
.with_vertical_padding(2.)
.with_border(
Border::all(styles.border_width.unwrap_or_default())
.with_border_fill(styles.border_color.unwrap_or_default()),
)
.with_corner_radius(styles.border_radius.unwrap_or_default());
if let Some(background) = styles.background {
container = container.with_background(background);
}
if let Some(margin) = styles.margin {
container = container
.with_margin_left(margin.left)
.with_margin_top(margin.top)
.with_margin_right(margin.right)
.with_margin_bottom(margin.bottom);
}
container
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
styles: self.styles.merge(styles),
..self
}
}
}
@@ -0,0 +1,171 @@
use crate::{
color::ColorU,
elements::{Element, Fill},
fonts::{FamilyId, Properties, Weight},
scene::CornerRadius,
};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum BorderStyle {
#[default]
None,
Solid,
Double,
Dotted,
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct Coords {
pub top: f32,
pub bottom: f32,
pub left: f32,
pub right: f32,
}
impl Coords {
pub fn uniform(val: f32) -> Self {
Coords {
top: val,
bottom: val,
left: val,
right: val,
}
}
pub fn top(mut self, top: f32) -> Self {
self.top = top;
self
}
pub fn bottom(mut self, bottom: f32) -> Self {
self.bottom = bottom;
self
}
pub fn left(mut self, left: f32) -> Self {
self.left = left;
self
}
pub fn right(mut self, right: f32) -> Self {
self.right = right;
self
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct UiComponentStyles {
pub width: Option<f32>, // TODO should be possible to spec units/equations (eg. 100% - 5px)
pub height: Option<f32>,
pub position: Option<Coords>,
pub background: Option<Fill>,
pub foreground: Option<Fill>,
pub border_color: Option<Fill>,
pub border_width: Option<f32>,
pub border_style: Option<BorderStyle>,
pub border_radius: Option<CornerRadius>,
pub font_family_id: Option<FamilyId>,
pub font_size: Option<f32>,
pub font_color: Option<ColorU>,
pub font_weight: Option<Weight>,
// TODO add text_decorations (underline, etc.)
pub padding: Option<Coords>,
pub margin: Option<Coords>,
}
impl UiComponentStyles {
/// `merge` combines 2 styles together. Self (usually a default) is overwritten by the styles
/// from `style` element (in other words: `style` values has higher precedence than `self`).
pub fn merge(&self, style: UiComponentStyles) -> Self {
UiComponentStyles {
width: style.width.or(self.width),
height: style.height.or(self.height),
position: style.position.or(self.position),
background: style.background.or(self.background),
foreground: style.foreground.or(self.foreground),
border_color: style.border_color.or(self.border_color),
border_width: style.border_width.or(self.border_width),
border_style: style.border_style.or(self.border_style),
border_radius: style.border_radius.or(self.border_radius),
font_family_id: style.font_family_id.or(self.font_family_id),
font_size: style.font_size.or(self.font_size),
font_color: style.font_color.or(self.font_color),
font_weight: style.font_weight.or(self.font_weight),
padding: style.padding.or(self.padding),
margin: style.margin.or(self.margin),
}
}
pub fn set_width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
pub fn set_height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
pub fn set_position(mut self, position: Coords) -> Self {
self.position = Some(position);
self
}
pub fn set_background(mut self, background: Fill) -> Self {
self.background = Some(background);
self
}
pub fn set_border_color(mut self, border_color: Fill) -> Self {
self.border_color = Some(border_color);
self
}
pub fn set_border_width(mut self, border_width: f32) -> Self {
self.border_width = Some(border_width);
self
}
pub fn set_border_style(mut self, border_style: BorderStyle) -> Self {
self.border_style = Some(border_style);
self
}
pub fn set_border_radius(mut self, border_radius: CornerRadius) -> Self {
self.border_radius = Some(border_radius);
self
}
pub fn set_font_family_id(mut self, font_family_id: FamilyId) -> Self {
self.font_family_id = Some(font_family_id);
self
}
pub fn set_font_size(mut self, font_size: f32) -> Self {
self.font_size = Some(font_size);
self
}
pub fn set_font_color(mut self, font_color: ColorU) -> Self {
self.font_color = Some(font_color);
self
}
pub fn set_font_weight(mut self, font_weight: Weight) -> Self {
self.font_weight = Some(font_weight);
self
}
pub fn set_padding(mut self, padding: Coords) -> Self {
self.padding = Some(padding);
self
}
pub fn set_margin(mut self, margin: Coords) -> Self {
self.margin = Some(margin);
self
}
pub fn font_properties(&self) -> Properties {
self.font_weight.map_or_else(Properties::default, |weight| {
Properties::default().weight(weight)
})
}
}
pub trait UiComponent {
type ElementType: Element;
fn build(self) -> Self::ElementType;
fn with_style(self, style: UiComponentStyles) -> Self;
}
#[cfg(test)]
#[path = "components_test.rs"]
mod tests;
@@ -0,0 +1,26 @@
use super::*;
#[test]
fn ui_element_style_merge_test() {
let style1 = UiComponentStyles {
width: Some(24.),
..Default::default()
};
let style2 = UiComponentStyles {
width: Some(25.),
font_size: Some(14.),
..Default::default()
};
assert_eq!(style2, style1.merge(style2));
let style3 = UiComponentStyles {
font_size: Some(14.),
..Default::default()
};
let style4 = UiComponentStyles {
width: Some(24.),
font_size: Some(14.),
..Default::default()
};
assert_eq!(style4, style1.merge(style3));
}
@@ -0,0 +1,347 @@
use std::borrow::Cow;
use std::sync::Arc;
use itertools::Itertools;
use crate::elements::{Icon, DEFAULT_UI_LINE_HEIGHT_RATIO};
use crate::{
elements::{
Align, ConstrainedBox, Container, CrossAxisAlignment, Element, Flex, MinSize, ParentElement,
},
keymap::Keystroke,
platform::OperatingSystem,
scene::Border,
};
use super::{
components::{UiComponent, UiComponentStyles},
text::Span,
};
type IconForKeystrokeFn = Arc<dyn Fn(&str) -> Option<Icon>>;
/// UI Component representing a keyboard shortcut, can be styled using `UiComponent::with_style`
#[derive(Clone)]
pub struct KeyboardShortcut {
keys: Vec<Key>,
style: UiComponentStyles,
is_lowercase_modifier: bool,
is_text_only: bool,
space_between_keys: f32,
line_height_ratio: f32,
icon_for_keystroke: IconForKeystrokeFn,
}
impl KeyboardShortcut {
pub fn new(keystroke: &Keystroke, style: UiComponentStyles) -> Self {
Self {
keys: keystroke_to_keys(keystroke),
style,
is_lowercase_modifier: false,
is_text_only: false,
space_between_keys: 3.,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
icon_for_keystroke: Arc::new(|_| None),
}
}
pub fn lowercase_modifier(mut self) -> Self {
self.is_lowercase_modifier = true;
self
}
pub fn text_only(mut self) -> Self {
self.is_text_only = true;
self
}
pub fn with_space_between_keys(mut self, spacing: f32) -> Self {
self.space_between_keys = spacing;
self
}
pub fn with_line_height_ratio(mut self, line_height_ratio: f32) -> Self {
self.line_height_ratio = line_height_ratio;
self
}
pub fn with_icon_for_keystroke(
mut self,
icon_for_keystroke: impl Fn(&str) -> Option<Icon> + 'static,
) -> Self {
self.icon_for_keystroke = Arc::new(icon_for_keystroke);
self
}
}
impl UiComponent for KeyboardShortcut {
type ElementType = Container;
fn build(self) -> Container {
let keys = if self.is_text_only {
// On Mac, we use symbols for modifiers so we don't need a separator.
// On other OS, we spell out modifiers so they need to be separated by space
let sep = if OperatingSystem::get().is_mac() {
""
} else {
" "
};
let combined_text = self
.keys
.iter()
.map(|key| key.text(self.is_lowercase_modifier))
.join(sep);
let text_element = Align::new(
Span::new(
combined_text,
// Removing any margin from the style passed to Span, since we process it below
self.style,
)
.with_line_height_ratio(self.line_height_ratio)
.with_selectable(false)
.build()
.finish(),
)
.finish();
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(text_element)
} else {
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children(self.keys.iter().enumerate().map(|(i, key)| {
if i == 0 {
key.render(
self.style,
self.is_lowercase_modifier,
self.line_height_ratio,
self.icon_for_keystroke.as_ref(),
)
} else {
Container::new(key.render(
self.style,
self.is_lowercase_modifier,
self.line_height_ratio,
self.icon_for_keystroke.as_ref(),
))
.with_margin_left(self.space_between_keys)
.finish()
}
}))
};
let mut keys = Container::new(keys.finish());
if let Some(margin) = self.style.margin {
keys = keys
.with_margin_top(margin.top)
.with_margin_right(margin.right)
.with_margin_bottom(margin.bottom)
.with_margin_left(margin.left);
}
keys
}
fn with_style(mut self, style: UiComponentStyles) -> Self {
self.style = self.style.merge(style);
self
}
}
pub 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.clone()));
keys
}
#[derive(Clone)]
pub enum Key {
Command,
Option,
Control,
Shift,
Meta,
Other(String),
}
impl Key {
pub fn text(&self, is_lowercase_modifier: bool) -> Cow<'static, str> {
let mut text: Cow<'static, str> = match self {
Key::Command => {
if OperatingSystem::get().is_mac() {
"".into()
} else {
"Logo".into()
}
}
Key::Option => {
if OperatingSystem::get().is_mac() {
"".into()
} else {
"Alt".into()
}
}
Key::Control => {
if OperatingSystem::get().is_mac() {
"".into()
} else {
"Ctrl".into()
}
}
Key::Shift => {
if OperatingSystem::get().is_mac() {
"".into()
} else {
"Shift".into()
}
}
Key::Meta => "Meta".into(),
Key::Other(key) => match key.as_str() {
"up" => "".into(),
"down" => "".into(),
"left" => "".into(),
"right" => "".into(),
"\t" => "Tab".into(),
" " => "Space".into(),
"escape" => "ESC".into(),
"enter" => "".into(),
"backspace" => "".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,
style: UiComponentStyles,
is_lowercase_modifier: bool,
line_height_ratio: f32,
icon_for_keystroke: &dyn Fn(&str) -> Option<Icon>,
) -> Box<dyn Element> {
let text = self.text(is_lowercase_modifier);
let (content, is_multi_char_key) = if let Some(mut icon) = icon_for_keystroke(text.as_ref())
{
if let Some(font_color) = style.font_color {
icon = icon.with_color(font_color);
}
let size = style.font_size.unwrap_or_default();
let icon = ConstrainedBox::new(icon.finish())
.with_height(size)
.with_width(size)
.finish();
(icon, false)
} else {
let is_multi_char_key = text.chars().count() > 1;
let content = Span::new(
text,
// Removing any margin from the style passed to Span, since we process it below
UiComponentStyles {
margin: None,
..style
},
)
.with_line_height_ratio(line_height_ratio)
.with_selectable(false)
.build()
.finish();
(content, is_multi_char_key)
};
let mut background = Container::new(MinSize::new(content).finish());
let mut border = Border::all(style.border_width.unwrap_or_default());
if let Some(border_color) = style.border_color {
border = border.with_border_fill(border_color);
}
background = background.with_border(border);
if let Some(padding) = style.padding {
background = background
.with_padding_top(padding.top)
.with_padding_right(padding.right)
.with_padding_bottom(padding.bottom)
.with_padding_left(padding.left);
}
if is_multi_char_key
&& (style
.padding
.is_some_and(|padding| padding.left == 0. && padding.right == 0.)
|| style.padding.is_none())
{
// If this shortcut is for a keystroke represented with multiple chars and there is
// no specified horizontal padding, add a default 4px horizontal padding. Because
// it's multiple chars, itll exceed the given width constraint and leave you with a
// shortcut with no padding.
background = background.with_horizontal_padding(4.);
}
if let Some(radius) = style.border_radius {
background = background.with_corner_radius(radius);
}
if let Some(background_color) = style.background {
background = background.with_background(background_color);
}
let mut sized = ConstrainedBox::new(background.finish());
match (style.width, style.height) {
(Some(width), Some(height)) => {
// If the height is set, use it as a minimum. If the content doesn't fill the
// given height, grow each key to fit. If the content exceeds the given height,
// allow it to grow to fit. This should not result in inconsistent heights since
// each key will require the same amount of extra height (assuming all use the
// same font, font size, and padding).
// Allow the width to grow as needed to fit the content.
sized = sized.with_min_width(width).with_min_height(height);
}
(None, Some(height)) => {
// Make the minimum size a square as suggested by design if no width is given.
sized = sized.with_min_width(height).with_min_height(height);
}
(Some(width), None) => {
// Allow the width to grow as needed to fit the content.
sized = sized.with_min_width(width);
}
(None, None) => (),
}
sized.finish()
}
}
@@ -0,0 +1,145 @@
use crate::{
elements::{Border, Container, Element, Hoverable, MouseState, MouseStateHandle, Text},
fonts::Properties,
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
EventContext,
};
pub type OnClickFn = Box<dyn Fn(&mut EventContext)>;
pub struct Link {
text: String, // TODO figure out how it can be ui element (or icon?)
/// A URL that should be opened in the user's default web browser when clicked.
url: Option<String>,
/// A callback that should be fired when clicked.
/// Commonly dispatches an action from the calling view.
callback: Option<OnClickFn>,
styles: LinkStyles,
hover_state: MouseStateHandle,
}
#[derive(Copy, Clone)]
pub struct LinkStyles {
pub base: UiComponentStyles,
pub hovered: Option<UiComponentStyles>,
pub clicked: Option<UiComponentStyles>,
pub soft_wrap: bool,
}
impl LinkStyles {
fn merge(&self, style: UiComponentStyles) -> Self {
Self {
base: self.base.merge(style),
hovered: Some(self.hovered.unwrap_or(self.base).merge(style)),
clicked: Some(self.clicked.unwrap_or(self.base).merge(style)),
soft_wrap: self.soft_wrap,
}
}
}
impl UiComponent for Link {
type ElementType = Container;
fn build(self) -> Container {
let url = self.url.clone();
Container::new(
Hoverable::new(self.hover_state.clone(), |state| {
let styles = self.styles(state);
let mut text = Text::new(
self.text.clone(),
styles.font_family_id.unwrap(),
styles.font_size.unwrap_or(14.),
)
.soft_wrap(self.styles.soft_wrap);
if let Some(font_color) = styles.font_color {
text = text.with_color(font_color);
}
if let Some(weight) = styles.font_weight {
text = text.with_style(Properties::default().weight(weight));
}
match (styles.border_width, styles.border_color) {
(Some(border_width), Some(border_color)) => Container::new(text.finish())
.with_border(Border::bottom(border_width).with_border_fill(border_color))
// Pull down the element by 1px so that the 1px border doesn't affect the
// vertical positioning of the element. Without this, the text won't be
// vertically aligned with neighboring `Text` elements.
.with_margin_bottom(-border_width)
.finish(),
(_, _) => text.finish(),
}
})
.on_click(move |ctx, app, _| {
if let Some(url) = &url {
app.open_url(url);
}
if let Some(callback) = &self.callback {
callback(ctx);
}
})
.with_cursor(Cursor::PointingHand)
.finish(),
)
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Link {
text: self.text.clone(),
url: self.url,
callback: self.callback,
styles: self.styles.merge(styles),
hover_state: self.hover_state,
}
}
}
impl Link {
pub fn new(
text: String,
url: Option<String>,
callback: Option<OnClickFn>,
mouse_state: MouseStateHandle,
styles: LinkStyles,
) -> Self {
Link {
text,
url,
callback,
styles,
hover_state: mouse_state,
}
}
fn styles(&self, state: &MouseState) -> UiComponentStyles {
if state.is_hovered() {
if state.is_clicked() {
return self.styles.clicked.unwrap_or(self.styles.base);
}
return self.styles.hovered.unwrap_or(self.styles.base);
}
self.styles.base
}
pub fn with_hovered_style(mut self, hover_style: UiComponentStyles) -> Self {
if let Some(style) = &mut self.styles.hovered {
*style = style.merge(hover_style);
}
self
}
pub fn with_clicked_style(mut self, hover_style: UiComponentStyles) -> Self {
if let Some(style) = &mut self.styles.clicked {
*style = style.merge(hover_style);
}
self
}
pub fn soft_wrap(mut self, soft_wrap: bool) -> Self {
self.styles.soft_wrap = soft_wrap;
self
}
}
@@ -0,0 +1,81 @@
use pathfinder_color::ColorU;
use crate::{
elements::{Container, Element, Flex, ParentElement, Text},
ui_components::components::{Coords, UiComponent, UiComponentStyles},
};
const BULLET: &str = "";
pub enum ListStyle {
Numbered,
Bulleted,
}
impl ListStyle {
fn render(&self, idx: usize, text: &str) -> String {
match self {
ListStyle::Numbered => format!("{} {}", idx + 1, text),
ListStyle::Bulleted => format!("{BULLET} {text}"),
}
}
}
pub struct List {
list_style: ListStyle,
styles: UiComponentStyles,
items: Vec<String>,
}
impl UiComponent for List {
type ElementType = Flex;
fn build(self) -> Flex {
Flex::column().with_children(
self.items
.iter()
.enumerate()
.map(|(item_idx, item)| self.render_item(item_idx, item)),
)
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
items: self.items,
list_style: self.list_style,
styles: self.styles.merge(styles),
}
}
}
impl List {
pub fn new(
list_style: ListStyle,
default_styles: UiComponentStyles,
items: Vec<String>,
) -> Self {
Self {
list_style,
styles: default_styles,
items,
}
}
fn render_item(&self, item_idx: usize, item: &str) -> Box<dyn Element> {
let padding = self.styles.padding.unwrap_or_else(|| Coords::uniform(2.));
Container::new(
Text::new(
self.list_style.render(item_idx, item),
self.styles.font_family_id.unwrap(),
self.styles.font_size.unwrap_or(14.),
)
.with_color(self.styles.font_color.unwrap_or_else(ColorU::white))
.finish(),
)
.with_padding_top(padding.top)
.with_padding_bottom(padding.bottom)
.with_padding_left(padding.left)
.with_padding_right(padding.right)
.finish()
}
}
@@ -0,0 +1,17 @@
pub mod button;
pub mod checkbox;
pub mod chip;
pub mod components;
pub mod keyboard_shortcut;
pub mod link;
pub mod list;
pub mod progress_bar;
pub mod radio_buttons;
pub mod segmented_control;
pub mod slider;
pub mod switch;
pub mod text;
pub mod text_input;
pub mod toggle_button;
pub mod toggle_menu;
pub mod tool_tip;
@@ -0,0 +1,55 @@
use crate::elements::{ConstrainedBox, Empty, Flex, ParentElement};
use crate::{
elements::{Container, Element},
ui_components::components::{UiComponent, UiComponentStyles},
};
pub struct ProgressBar {
progress: f32,
styles: UiComponentStyles,
}
impl UiComponent for ProgressBar {
type ElementType = Flex;
fn build(self) -> Flex {
let styles = self.styles;
let progress_width = self.progress * styles.width.unwrap();
Flex::row()
.with_child(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background(styles.foreground.unwrap())
.finish(),
)
.with_width(progress_width)
.with_height(styles.height.unwrap())
.finish(),
)
.with_child(
ConstrainedBox::new(
Container::new(Empty::new().finish())
.with_background(styles.background.unwrap())
.finish(),
)
.with_width(styles.width.unwrap() - progress_width)
.with_height(styles.height.unwrap())
.finish(),
)
}
fn with_style(self, styles: UiComponentStyles) -> Self {
ProgressBar {
styles: styles.merge(styles),
..self
}
}
}
impl ProgressBar {
pub fn new(progress: f32, default_styles: UiComponentStyles) -> Self {
ProgressBar {
progress,
styles: default_styles,
}
}
}
@@ -0,0 +1,449 @@
use std::{borrow::Cow, rc::Rc};
use crate::{elements::FormattedTextElement, platform::Cursor, AppContext, EventContext};
use parking_lot::Mutex;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use crate::{
elements::{
ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Rect,
Stack,
},
scene::{Border, CornerRadius, Radius},
Element,
};
use super::components::{Coords, UiComponent, UiComponentStyles};
use lazy_static::lazy_static;
const LABEL_LEFT_MARGIN: f32 = 8.;
const BORDER_WIDTH: f32 = 1.5;
const DEFAULT_FONT_SIZE: f32 = 14.;
const HOVER_SIZE_MULTIPLE: f32 = 1.75;
const RADIO_BUTTON_DIAMETER: f32 = 20.;
lazy_static! {
pub static ref HOVER_BACKGROUND_COLOR: ColorU = ColorU::new(170, 170, 170, 50);
}
pub enum RadioButtonLayout {
Row,
Column,
}
/// A function from (is_disabled, is_selected, hovered) to a rendered element.
type RichLabelFn<'a> = dyn FnOnce(bool, bool, bool) -> Box<dyn Element> + 'a;
/// A function from (is_disabled, is_selected, hovered) to a rendered element.
type CustomItemFn<'a> = dyn FnOnce(bool, bool, bool) -> Box<dyn Element> + 'a;
pub enum Label<'a> {
Text(Cow<'static, str>),
Rich(Box<RichLabelFn<'a>>),
CustomItem(Box<CustomItemFn<'a>>),
}
pub struct RadioButtonItem<'a> {
is_disabled: bool,
child: Label<'a>,
}
impl<'a> RadioButtonItem<'a> {
fn new(child: Label<'a>) -> Self {
Self {
is_disabled: false,
child,
}
}
pub fn text(label: impl Into<Cow<'static, str>>) -> Self {
Self::new(Label::Text(label.into()))
}
pub fn rich_element(label: Box<RichLabelFn<'a>>) -> Self {
Self::new(Label::Rich(Box::new(label)))
}
pub fn custom_item(label: Box<CustomItemFn<'a>>) -> Self {
Self::new(Label::CustomItem(Box::new(label)))
}
pub fn with_disabled(mut self, is_disabled: bool) -> Self {
self.is_disabled = is_disabled;
self
}
}
#[derive(Clone, Copy, Default)]
struct RadioButtonState {
selected_item: Option<usize>,
default_selected_item: Option<usize>,
}
impl RadioButtonState {
#[allow(dead_code)] // This is a temporary constructor that isn't used right now but will be used as soon as radio buttons are used.
pub fn new(default_selected_item: Option<usize>) -> Self {
RadioButtonState {
selected_item: None,
default_selected_item,
}
}
}
#[derive(Clone, Default)]
pub struct RadioButtonStateHandle {
inner: Rc<Mutex<RadioButtonState>>,
}
// TODO(roland): Remembering the selected option can be unintuitive if the number of options
// changes or options become disabled/enabled. The remembered index can be semantically different
// if the number/content of options change, and we may not want to remember an option chosen only
// because other options were disabled. Consider a refactor.
impl RadioButtonStateHandle {
pub fn get_selected_idx(&self) -> Option<usize> {
let state = self.inner.lock();
match (state.selected_item, state.default_selected_item) {
(Some(selected_idx), _) => Some(selected_idx),
(None, Some(default_idx)) => Some(default_idx),
_ => None,
}
}
fn get_default_idx(&self) -> Option<usize> {
let state = self.inner.lock();
state.default_selected_item
}
fn set(&self, new_state: RadioButtonState) {
let mut guard = self.inner.lock();
*guard = new_state;
}
// Set the active index from outside of the radio button component
pub fn set_selected_idx(&self, new_idx: usize) {
let default_selected_item = self.get_default_idx();
self.set(RadioButtonState {
selected_item: Some(new_idx),
default_selected_item,
});
}
}
struct RadioButtonRenderer {
layout: RadioButtonLayout,
default_styles: UiComponentStyles,
selected_styles: UiComponentStyles,
disabled_styles: UiComponentStyles,
state_handle: RadioButtonStateHandle,
hover_states: Vec<MouseStateHandle>,
/// If None, then center the button relative to its child.
/// Otherwise, insert a margin on the top edge.
button_vertical_offset: Option<f32>,
change_handler: Option<Rc<OnChangeFn>>,
supports_unselected_state: bool,
button_diameter_override: Option<f32>,
}
impl RadioButtonRenderer {
fn render_selection_circle(&self, selected: bool, is_disabled: bool) -> Box<dyn Element> {
let mut stack = Stack::new();
let diameter = self
.button_diameter_override
.unwrap_or(RADIO_BUTTON_DIAMETER);
let mut outer_circle_rect =
Rect::new().with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)));
if let Some(background) = self.default_styles.background {
outer_circle_rect = outer_circle_rect.with_background(background);
}
let border_color = if selected {
self.selected_styles.border_color.unwrap_or_default()
} else if is_disabled {
self.disabled_styles.border_color.unwrap_or_default()
} else {
self.default_styles.border_color.unwrap_or_default()
};
outer_circle_rect =
outer_circle_rect.with_border(Border::all(BORDER_WIDTH).with_border_fill(border_color));
let outer_circle = ConstrainedBox::new(outer_circle_rect.finish())
.with_height(diameter)
.with_width(diameter);
stack.add_child(outer_circle.finish());
if selected {
let inner_circle_rect = Rect::new()
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_background(self.selected_styles.background.unwrap_or_default());
let inner_circle_diameter = diameter / 2.;
let inner_circle = ConstrainedBox::new(inner_circle_rect.finish())
.with_height(inner_circle_diameter)
.with_width(inner_circle_diameter);
// Position the inner circle so that it's centered in the outer circle.
stack.add_positioned_child(
inner_circle.finish(),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::Unbounded,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
stack.finish()
}
fn render_label(&self, label: Cow<'static, str>, is_disabled: bool) -> Box<dyn Element> {
let color = if is_disabled {
self.disabled_styles.font_color
} else {
self.default_styles.font_color
}
.unwrap_or_else(ColorU::white);
FormattedTextElement::from_str(
label,
self.default_styles.font_family_id.expect("No font family"),
self.default_styles.font_size.unwrap_or(DEFAULT_FONT_SIZE),
)
.with_color(color)
.with_weight(self.default_styles.font_weight.unwrap_or_default())
.finish()
}
fn render_item(&self, item_idx: usize, item: RadioButtonItem) -> Box<dyn Element> {
let selected = self
.state_handle
.get_selected_idx()
.map(|selected_idx| selected_idx == item_idx)
.unwrap_or(false);
let padding = self.default_styles.padding.unwrap_or(Coords::uniform(2.));
let (left_padding, top_padding) = match (item_idx, &self.layout) {
(0, RadioButtonLayout::Column) => (padding.left, 0.),
(0, RadioButtonLayout::Row) => (0., padding.top),
_ => (padding.left, padding.top),
};
let mut hoverable = Hoverable::new(self.hover_states[item_idx].clone(), |state| {
if let Label::CustomItem(build_child) = item.child {
return (build_child)(item.is_disabled, selected, state.is_hovered());
}
let mut stack = Stack::new();
let button = self.render_selection_circle(selected, item.is_disabled);
let circle_diameter = self.default_styles.font_size.unwrap_or_default();
let hover_size = circle_diameter * HOVER_SIZE_MULTIPLE;
if !item.is_disabled && state.is_hovered() {
let hover = Container::new(
ConstrainedBox::new(
Rect::new()
.with_background_color(*HOVER_BACKGROUND_COLOR)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_width(hover_size)
.with_height(hover_size)
.finish(),
)
.finish();
// Position the hover so that it's centered behind the circle.
stack.add_positioned_child(
hover,
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::Unbounded,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
stack.add_child(button);
let container = Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(if let Some(offset) = self.button_vertical_offset {
Container::new(stack.finish())
.with_margin_top(offset)
.finish()
} else {
stack.finish()
})
.with_child(match item.child {
Label::Text(label) => {
Container::new(self.render_label(label, item.is_disabled))
.with_margin_left(LABEL_LEFT_MARGIN)
.finish()
}
Label::Rich(build_child) => {
(build_child)(item.is_disabled, selected, state.is_hovered())
}
_ => Flex::row().finish(),
})
.with_cross_axis_alignment(if self.button_vertical_offset.is_some() {
CrossAxisAlignment::Start
} else {
CrossAxisAlignment::Center
})
.finish(),
);
container
.with_padding_top(top_padding)
.with_padding_bottom(padding.bottom)
.with_padding_left(left_padding)
.with_padding_right(padding.right)
.finish()
});
if !item.is_disabled {
let state_handle = self.state_handle.clone();
let old_default = state_handle.get_default_idx();
let change_handler = self.change_handler.clone();
let supports_unselected = self.supports_unselected_state;
hoverable = hoverable
.on_click(move |event_context, app_context, _| {
let selected_item = if supports_unselected && selected {
None
} else {
Some(item_idx)
};
state_handle.set(RadioButtonState {
selected_item,
default_selected_item: old_default,
});
if let Some(change_handler) = &change_handler {
change_handler(event_context, app_context, selected_item);
}
})
.with_cursor(Cursor::PointingHand);
}
let margin = self.default_styles.margin.unwrap_or(Coords::uniform(2.));
let (left_margin, top_margin) = match (item_idx, &self.layout) {
(0, RadioButtonLayout::Column) => (margin.left, 0.),
(0, RadioButtonLayout::Row) => (0., margin.top),
_ => (margin.left, margin.top),
};
let container = Container::new(hoverable.finish());
container
.with_margin_top(top_margin)
.with_margin_bottom(margin.bottom)
.with_margin_left(left_margin)
.with_margin_right(margin.right)
.finish()
}
}
type OnChangeFn = dyn Fn(&mut EventContext, &AppContext, Option<usize>) + 'static;
pub struct RadioButtons<'a> {
items: Vec<RadioButtonItem<'a>>,
renderer: RadioButtonRenderer,
}
impl UiComponent for RadioButtons<'_> {
type ElementType = Flex;
fn build(self) -> Self::ElementType {
let flex = match self.renderer.layout {
RadioButtonLayout::Row => Flex::row(),
RadioButtonLayout::Column => Flex::column(),
};
flex.with_children(
self.items
.into_iter()
.enumerate()
.map(|(idx, item)| self.renderer.render_item(idx, item)),
)
}
fn with_style(self, new_styles: UiComponentStyles) -> Self {
Self {
renderer: RadioButtonRenderer {
default_styles: self.renderer.default_styles.merge(new_styles),
..self.renderer
},
..self
}
}
}
impl<'a> RadioButtons<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
mouse_states: Vec<MouseStateHandle>,
items: Vec<RadioButtonItem<'a>>,
radio_button_state_handle: RadioButtonStateHandle,
default_option: Option<usize>,
default_styles: UiComponentStyles,
selected_styles: UiComponentStyles,
disabled_styles: UiComponentStyles,
layout: RadioButtonLayout,
) -> Self {
let mut selected_idx = radio_button_state_handle.get_selected_idx();
if let Some(id) = selected_idx {
// If the previously selected option is disabled, reset the selected option to the default.
if let Some(item) = items.get(id) {
if item.is_disabled {
selected_idx = None
}
} else {
// Previously selected option is out of range, reset to default.
selected_idx = None
}
}
radio_button_state_handle.set(RadioButtonState {
selected_item: selected_idx,
default_selected_item: default_option,
});
Self {
items,
renderer: RadioButtonRenderer {
layout,
default_styles,
selected_styles,
disabled_styles,
state_handle: radio_button_state_handle,
hover_states: mouse_states,
button_vertical_offset: None,
change_handler: None,
supports_unselected_state: false,
button_diameter_override: None,
},
}
}
/// Set the vertical offset of the radio button relative to the top of the child element.
pub fn with_button_vertical_offset(mut self, offset: f32) -> Self {
self.renderer.button_vertical_offset = Some(offset);
self
}
pub fn on_change(mut self, callback: Rc<OnChangeFn>) -> Self {
self.renderer.change_handler = Some(callback);
self
}
pub fn supports_unselected_state(mut self) -> Self {
self.renderer.supports_unselected_state = true;
self
}
pub fn with_button_diameter(mut self, diameter: f32) -> Self {
self.renderer.button_diameter_override = Some(diameter);
self
}
}
@@ -0,0 +1,397 @@
use itertools::Itertools;
use crate::{
color::ColorU,
elements::{
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex,
Icon, MainAxisAlignment, MouseStateHandle, ParentElement, Radius, Text,
},
fonts::FamilyId,
platform::Cursor,
ui_components::{
button::Button,
components::{Coords, UiComponent, UiComponentStyles},
tool_tip::{Tooltip, TooltipWithSublabel},
},
AppContext, Element, Entity, TypedActionView, View, ViewContext,
};
use core::fmt;
use std::{borrow::Cow, boxed::Box};
use super::button::ButtonTooltipPosition;
const MAX_WIDTH: f32 = 300.0;
/// A segmented control component with multiple selectable options
pub struct SegmentedControl<T> {
options: Vec<T>,
selected_option: T,
build_option_config: BuildRenderableOptionConfig<T>,
mouse_states: Vec<MouseStateHandle>,
styles: UiComponentStyles,
/// If Some, we will set the control to disabled and use the tooltip text provided
disabled_tooltip: Option<Cow<'static, str>>,
}
#[derive(Debug)]
pub enum SegmentedControlAction<T: SegmentedControlOption> {
SelectOption(T),
}
pub enum SegmentedControlEvent<T: SegmentedControlOption> {
OptionSelected(T),
}
pub struct LabelConfig {
pub label: Cow<'static, str>,
pub width_override: Option<f32>,
pub color: ColorU,
}
pub struct TooltipConfig {
pub text: Cow<'static, str>,
pub sub_text: Option<Cow<'static, str>>,
pub text_color: ColorU,
pub background_color: ColorU,
pub border_color: ColorU,
}
/// Config for rendering an option within the control.
pub struct RenderableOptionConfig {
pub icon_path: &'static str,
pub icon_color: ColorU,
pub label: Option<LabelConfig>,
pub tooltip: Option<TooltipConfig>,
pub background: Fill,
}
/// Trait for data types that may be used as options within a segmented control.
///
/// This basically exists to ensure options are `Copy` and support checking for value equality.
pub trait SegmentedControlOption:
fmt::Debug + Copy + Clone + PartialEq + Eq + Send + Sync + 'static
{
}
impl<T> SegmentedControlOption for T where
T: fmt::Debug + Copy + Clone + PartialEq + Eq + Send + Sync + 'static
{
}
/// Type alias for function used to construct a [`RenderableOptionConfig`] to do determine how to
/// render an option within the segmented control, called at render time.
///
/// The first param is the option `T` being rendered, the second param is a boolean indicating
/// whether the option is currently selected.
///
/// If the returned value is [`None`], the option will not be rendered.
pub type BuildRenderableOptionConfig<T> =
Box<dyn Fn(T, bool, &AppContext) -> Option<RenderableOptionConfig>>;
impl<T: SegmentedControlOption> SegmentedControl<T> {
pub fn new<F>(
options: Vec<T>,
build_option_config_fn: F,
mut default_option: T,
styles: UiComponentStyles,
) -> Self
where
F: Fn(T, bool, &AppContext) -> Option<RenderableOptionConfig> + 'static,
{
debug_assert!(
options.contains(&default_option),
"Default option must be one of the provided options"
);
if !options.contains(&default_option) {
default_option = options[0];
}
let mouse_states = options
.iter()
.map(|_| MouseStateHandle::default())
.collect();
Self {
options,
build_option_config: Box::new(build_option_config_fn),
selected_option: default_option,
mouse_states,
styles,
disabled_tooltip: None,
}
}
/// Get the value of the currently selected option
pub fn selected_option(&self) -> T {
self.selected_option
}
/// Set the selected option.
///
/// If `option` is not present in `self.options`, does nothing.
pub fn set_selected_option(&mut self, option: T, ctx: &mut ViewContext<Self>) {
if !self.options.iter().contains(&option) {
return;
}
self.selected_option = option;
ctx.notify();
}
pub fn set_styles(&mut self, styles: UiComponentStyles, ctx: &mut ViewContext<Self>) {
self.styles = styles;
ctx.notify();
}
/// Enable/disable the segmented control (disables click selection but retains hover/tooltip)
pub fn set_disabled_tooltip(
&mut self,
disabled_tooltip: Option<Cow<'static, str>>,
ctx: &mut ViewContext<Self>,
) {
self.disabled_tooltip = disabled_tooltip;
ctx.notify();
}
/// Update the available options in the control.
///
/// If the currently selected option is not present in the new list, selects the first option by default.
pub fn update_options(&mut self, updated_options: Vec<T>, ctx: &mut ViewContext<Self>) {
debug_assert!(
!updated_options.is_empty(),
"Cannot pass empty options to SegmentedControl"
);
if updated_options.is_empty() {
log::error!("Attempted to update SegmentedControl with empty options");
return;
}
let should_update_selected = !updated_options.contains(&self.selected_option);
self.options = updated_options;
self.mouse_states = self
.options
.iter()
.map(|_| MouseStateHandle::default())
.collect();
if should_update_selected {
self.set_selected_option(self.options[0], ctx);
}
ctx.notify();
}
}
impl<T: SegmentedControlOption> Entity for SegmentedControl<T> {
type Event = SegmentedControlEvent<T>;
}
impl<T: SegmentedControlOption> View for SegmentedControl<T> {
fn ui_name() -> &'static str {
"SegmentedControl"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let is_disabled = self.disabled_tooltip.is_some();
let mut options_container = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Start);
for (index, option) in self.options.iter().enumerate() {
let is_selected = *option == self.selected_option;
let Some(mut option_config) = (self.build_option_config)(*option, is_selected, app)
else {
continue;
};
// If globally disabled and an override tooltip is set, replace the tooltip text
if let Some(disabled_text) = self.disabled_tooltip.clone() {
// Override tooltip text with disabled text
if let Some(tooltip_config) = option_config.tooltip.as_mut() {
tooltip_config.text = disabled_text;
// Clear keybinding/subtext when disabled
tooltip_config.sub_text = None;
}
}
let mouse_state = self.mouse_states[index].clone();
let button_styles = UiComponentStyles {
background: Some(option_config.background),
// Slightly tighter padding to keep controls compact in narrow headers.
padding: Some(Coords::uniform(2.0)),
border_width: None,
border_radius: Some(CornerRadius::with_all(Radius::Pixels(3.0))),
margin: None,
..self.styles
};
let mut button = Button::new(
mouse_state,
button_styles,
None, // hover styles
None, // clicked styles
None, // disabled styles
);
if let Some(label_config) = option_config.label.take() {
let font_size = if cfg!(any(windows, target_os = "linux")) {
// Reduce the font size by one to avoid text being cut off on Windows and Linux.
self.styles.font_size.unwrap_or(12.0) - 1.0
} else {
self.styles.font_size.unwrap_or(12.0)
};
let icon_size = font_size * 1.4;
let font_family_id = self.styles.font_family_id.unwrap_or(FamilyId(0));
let mut text = ConstrainedBox::new(
Container::new(
Align::new(
Text::new(label_config.label, font_family_id, font_size)
.with_color(option_config.icon_color)
.finish(),
)
.finish(),
)
// Account for icon margins due to viewbox difference in the SVG
.with_padding_right(icon_size * 0.2)
.finish(),
);
if let Some(width_override) = label_config.width_override {
// Scale label width by the same ratio as font size for proper zoom behavior
let font_size = self.styles.font_size.unwrap_or(12.0);
let base_font_size = 10.0; // Match the base font size used in universal_developer_input.rs
let ui_scalar = font_size / base_font_size;
text = text.with_width(width_override * ui_scalar);
}
let text = text.finish();
if option_config.icon_path.is_empty() {
button = button.with_custom_label(text);
} else {
let icon = ConstrainedBox::new(
Container::new(
Icon::new(option_config.icon_path, option_config.icon_color).finish(),
)
.finish(),
)
.with_width(icon_size)
.with_height(icon_size)
.finish();
let button_label = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(icon)
.with_child(text)
.finish();
button = button.with_custom_label(button_label);
}
} else {
button = button
.with_icon_label(Icon::new(option_config.icon_path, option_config.icon_color));
}
if let Some(tooltip_config) = option_config.tooltip.as_ref() {
let styles = self.styles;
let tooltip = tooltip_config.text.clone();
let subtext = tooltip_config.sub_text.clone();
let text_color = tooltip_config.text_color;
let background_color = tooltip_config.background_color;
let border_color = tooltip_config.border_color;
button = button.with_tooltip(move || {
let styles = UiComponentStyles {
font_color: Some(text_color),
background: Some(Fill::Solid(background_color)),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.0))),
border_width: Some(1.0),
border_color: Some(Fill::Solid(border_color)),
font_family_id: styles.font_family_id,
font_size: styles.font_size.map(|size| size - 2.0),
padding: Some(Coords {
top: 4.,
bottom: 4.,
left: 8.,
right: 8.,
}),
..Default::default()
};
if let Some(subtext) = subtext {
TooltipWithSublabel::new(tooltip.into(), subtext.into(), styles)
.build()
.finish()
} else {
Tooltip::new(tooltip.into(), styles).build().finish()
}
});
}
button = button.with_tooltip_position(ButtonTooltipPosition::AboveLeft);
let option_copy = *option;
let mut hoverable = button.build().with_cursor(if is_disabled {
Cursor::Arrow
} else {
Cursor::PointingHand
});
// Buttons should not be clickable if they are disabled
if !is_disabled {
hoverable = hoverable.on_click({
move |ctx, _, _| {
ctx.dispatch_typed_action(SegmentedControlAction::SelectOption(
option_copy,
));
}
});
}
options_container = options_container.with_child(hoverable.finish());
}
let mut container = Container::new(
ConstrainedBox::new(options_container.finish())
.with_max_width(MAX_WIDTH)
.finish(),
);
// Apply styles from UiComponentStyles
if let Some(background) = self.styles.background {
container = container.with_background(background);
}
if let Some(border_width) = self.styles.border_width {
if let Some(border_color) = self.styles.border_color {
container =
container.with_border(Border::all(border_width).with_border_fill(border_color));
}
}
if let Some(border_radius) = self.styles.border_radius {
container = container.with_corner_radius(border_radius);
}
if let Some(margin) = self.styles.margin {
container = container
.with_margin_left(margin.left)
.with_margin_right(margin.right)
.with_margin_top(margin.top)
.with_margin_bottom(margin.bottom);
}
container.finish()
}
}
impl<T: SegmentedControlOption> TypedActionView for SegmentedControl<T> {
type Action = SegmentedControlAction<T>;
fn handle_action(&mut self, action: &SegmentedControlAction<T>, ctx: &mut ViewContext<Self>) {
match action {
SegmentedControlAction::SelectOption(option) => {
self.set_selected_option(*option, ctx);
ctx.emit(SegmentedControlEvent::OptionSelected(*option));
}
}
}
}
@@ -0,0 +1,561 @@
use std::{ops::Range, sync::Arc};
use crate::platform::Cursor;
use crate::{
elements::{
AnchorPair, ConstrainedBox, Container, CornerRadius, DragAxis, Draggable, DraggableState,
DropShadow, Fill, Hoverable, MouseStateHandle, OffsetPositioning, OffsetType,
ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds, PositioningAxis, Radius,
Rect, SavePosition, Stack, XAxisAnchor, YAxisAnchor,
},
ui_components::components::UiComponentStyles,
AppContext, Element, EventContext,
};
use lazy_static::lazy_static;
use parking_lot::{Mutex, RwLock};
use pathfinder_color::ColorU;
use pathfinder_geometry::{rect::RectF, vector::vec2f};
use super::components::UiComponent;
const DEFAULT_THUMB_SIZE: f32 = 18.;
const DEFAULT_TRACK_HEIGHT: f32 = 4.;
const HOVER_OPACITY: u8 = 100;
const HOVER_BORDER_SIZE: f32 = 10.;
lazy_static! {
pub static ref DEFAULT_TRACK_COLOR: ColorU = ColorU::new(170, 170, 170, 255);
pub static ref DEFAULT_TRACK_FILL: Fill = Fill::Solid(ColorU::new(170, 170, 170, 255));
pub static ref DEFAULT_THUMB_FILL: Fill = Fill::Solid(ColorU::white());
static ref THUMB_DROP_SHADOW: DropShadow = DropShadow {
color: ColorU::black(),
offset: vec2f(-0.5, 2.),
blur_radius: 20.,
spread_radius: 0.,
};
/// A static counter of the number of instantiated sliders, which is used to create a unique
/// SavePosition ID to reference the position of the slider track, which is used to position
/// the slider thumb.
static ref TRACK_POSITION_ID_COUNT: RwLock<usize> = RwLock::new(0);
}
#[derive(Clone, Copy, Default)]
struct SliderState {
// The thumb's current offset from the "beginning" (minimum) x-axis coordinate of the track.
thumb_offset_x: Option<f32>,
}
#[derive(Clone, Default)]
pub struct SliderStateHandle {
thumb_hoverable_state: MouseStateHandle,
thumb_draggable_state: DraggableState,
track_hoverable_state: MouseStateHandle,
inner: Arc<Mutex<SliderState>>,
}
impl SliderStateHandle {
// Returns the thumb's current offset from the "beginning" (minimum) x-axis coordinate of the
// track.
fn thumb_offset_x(&self) -> Option<f32> {
self.inner.lock().thumb_offset_x
}
// Returns the 'value' represented by the slider's current position along the track. The
// returned value is normalized to the given value_range.
fn get_value(&self, draggable_width: f32, value_range: &Range<f32>) -> f32 {
let state = self.inner.lock();
let thumb_offset_x = state.thumb_offset_x.unwrap_or(0.);
let canonical_value = thumb_offset_x / draggable_width;
canonical_value * (value_range.end - value_range.start) + value_range.start
}
/// Sets the inner [`SliderState`] to `new_state`.
fn store(&self, new_state: SliderState) {
let mut guard = self.inner.lock();
*guard = new_state;
}
/// Resets the thumb's offset to `None`, which causes the default value to be
/// used when the slider is next rendered.
pub fn reset_offset(&self) {
self.store(SliderState {
thumb_offset_x: None,
});
}
}
/// Type alias for `on_drag` and `on_change` callbacks, either of which is executed when the slider's
/// value has changed.
type OnValueChangedFn = dyn Fn(&mut EventContext, &AppContext, f32) + 'static;
/// Slider UiComponent for modulating a value between given bounds.
///
/// Builder methods allow the caller to configure the styling of the slider, as well as set a
/// callback to be executed when the slider 'thumb' (handle) is dragged, as well as when the thumb
/// is dropped (marking the end of a 'drag').
pub struct Slider {
state_handle: SliderStateHandle,
track_position_id: String,
on_drag_callback: Option<Box<OnValueChangedFn>>,
on_change_callback: Option<Arc<OnValueChangedFn>>,
thumb_size: f32,
track_height: f32,
track_fill: Fill,
thumb_fill: Fill,
styles: UiComponentStyles,
value_range: Range<f32>,
default_value: Option<f32>,
}
impl Slider {
pub fn new(slider_state_handle: SliderStateHandle) -> Self {
Self {
track_position_id: new_track_position_id(),
state_handle: slider_state_handle,
on_drag_callback: None,
on_change_callback: None,
thumb_size: DEFAULT_THUMB_SIZE,
track_height: DEFAULT_TRACK_HEIGHT,
track_fill: *DEFAULT_TRACK_FILL,
thumb_fill: *DEFAULT_THUMB_FILL,
value_range: 0.0..1.,
default_value: None,
styles: UiComponentStyles {
..Default::default()
},
}
}
pub fn with_thumb_size(mut self, thumb_size: f32) -> Self {
self.thumb_size = thumb_size;
self
}
pub fn with_thumb_fill(mut self, fill: Fill) -> Self {
self.thumb_fill = fill;
self
}
pub fn with_track_fill(mut self, fill: Fill) -> Self {
self.track_fill = fill;
self
}
pub fn with_track_height(mut self, height: f32) -> Self {
self.track_height = height;
self
}
/// Sets the slider's value range. If set, values passed to the `on_change` callback are
/// normalized to the given range.
pub fn with_range(mut self, range: Range<f32>) -> Self {
self.value_range = range;
self
}
pub fn with_default_value(mut self, value: f32) -> Self {
self.default_value = Some(value);
self
}
/// Called when the value represented by the slider changes when the user drags the slider
/// thumb. The emitted value is normalized to the slider's value range, the default for
/// which is [0, 1].
pub fn on_drag<F>(mut self, callback: F) -> Self
where
F: Fn(&mut EventContext, &AppContext, f32) + 'static,
{
self.on_drag_callback = Some(Box::new(callback));
self
}
/// Called when the slider thumb is 'dropped' at the end of a drag. The emitted value is
/// normalized to the slider's value range, the default for which is [0, 1].
pub fn on_change<F>(mut self, callback: F) -> Self
where
F: Fn(&mut EventContext, &AppContext, f32) + 'static,
{
self.on_change_callback = Some(Arc::new(callback));
self
}
/// Registers the 'on_drag_start` callback on the `Draggable` element representing the slider
/// thumb.
///
/// This callback stores the thumb's x-axis offset from the start of the track in the
/// given `SliderStateHandle`.
fn register_on_drag_start_callback(
thumb_draggable: &mut Draggable,
track_position_id: String,
state_handle: SliderStateHandle,
) {
thumb_draggable.set_on_drag_start(move |event_ctx, _app, thumb_position| {
let track_position = event_ctx
.element_position_by_id(track_position_id.as_str())
.expect("Track should be laid out by the time the slider is dragged.");
// Save the position along the x-axis of the thumb when the drag started.
state_handle.store(SliderState {
thumb_offset_x: Some(thumb_position.origin_x() - track_position.origin_x()),
});
});
}
/// Registers the 'on_drag` callback on the `Draggable` element representing the slider thumb.
///
/// The registered callback calls the user's supplied `on_drag` callback if the slider's x-axis
/// position has changed since the last time it was called. The user's callback is called with
/// the slider's current value, which is basically the slider thumb's offset x normalized to
/// the slider's `value_range`. In addition, it updates the `thumb_offset_x` in the slider's
/// state.
fn register_on_drag_callback(
thumb_draggable: &mut Draggable,
track_position_id: String,
thumb_size: f32,
value_range: Range<f32>,
state_handle: SliderStateHandle,
on_drag_callback: Option<Box<OnValueChangedFn>>,
) {
thumb_draggable.set_on_drag(move |event_ctx, app, thumb_position, _| {
let track_position = event_ctx
.element_position_by_id(track_position_id.as_str())
.expect("Track should be laid out by the time the slider is dragged.");
let current_thumb_offset_x = thumb_position.origin_x() - track_position.origin_x();
// The on_drag callback is called even if the draggable element's position
// hasn't changed -- only call the on_change callback if the slider's
// position has changed.
if Some(current_thumb_offset_x) != state_handle.thumb_offset_x() {
state_handle.store(SliderState {
thumb_offset_x: Some(current_thumb_offset_x),
});
if let Some(callback) = &on_drag_callback {
let draggable_width = draggable_width(track_position, thumb_size);
let updated_value = state_handle.get_value(draggable_width, &value_range);
callback(event_ctx, app, updated_value);
}
}
});
}
/// Registers the 'on_change` callback on the `Draggable` element representing the slider thumb.
///
/// The registered callback unconditinoally calls the user's supplied `on_change` callback. The
/// user's callback is called with the slider's current value, which is basically the slider
/// thumb's offset x normalized to the slider's `value_range`. In addition, it updates the
/// `thumb_offset_x` in the slider's state.
fn register_on_drop_callback(
thumb_draggable: &mut Draggable,
track_position_id: String,
thumb_size: f32,
value_range: Range<f32>,
state_handle: SliderStateHandle,
on_change_callback: Option<Arc<OnValueChangedFn>>,
) {
thumb_draggable.set_on_drop(move |event_ctx, app, thumb_position, _| {
let track_position = event_ctx
.element_position_by_id(track_position_id.as_str())
.expect("Track should be laid out by the time the slider is dropped.");
state_handle.store(SliderState {
thumb_offset_x: Some(thumb_position.origin_x() - track_position.origin_x()),
});
if let Some(callback) = &on_change_callback {
let draggable_width = draggable_width(track_position, thumb_size);
let updated_value = state_handle.get_value(draggable_width, &value_range);
callback(event_ctx, app, updated_value);
}
});
}
/// Registers the 'on_change_callback` callback on the `Hoverable` element representing the slider track.
///
/// Whenever the underlying track is clicked, we set the thumb offset to the location of the click,
/// and then call the on_change_callback with the updated value. Basically works as if a user immediately
/// dragged the thumb to that location, without all the intermediate on_drag calls.
fn register_on_click_callback(
track_hoverable: Hoverable,
track_position_id: String,
thumb_size: f32,
value_range: Range<f32>,
state_handle: SliderStateHandle,
on_change_callback: Option<Arc<OnValueChangedFn>>,
) -> Hoverable {
track_hoverable.on_click(move |event_ctx, app, click_position| {
let Some(track_position) = event_ctx.element_position_by_id(track_position_id.as_str())
else {
return;
};
let click_position_x = click_position.x();
let padding = thumb_size / 2.;
let min_x = track_position.min_x() + padding;
let max_x = track_position.max_x() - padding;
// If the user clicks outside of the actual visible portion of the track,
// we do not proceed.
if min_x > click_position_x || max_x < click_position_x {
return;
}
state_handle.store(SliderState {
thumb_offset_x: Some(click_position_x - min_x),
});
if let Some(callback) = &on_change_callback {
let draggable_width = draggable_width(track_position, thumb_size);
let updated_value = state_handle.get_value(draggable_width, &value_range);
callback(event_ctx, app, updated_value);
}
})
}
}
impl UiComponent for Slider {
type ElementType = Container;
fn build(self) -> Self::ElementType {
let Slider {
state_handle,
track_position_id: slider_track_position_id,
on_drag_callback,
on_change_callback,
thumb_size,
track_height,
track_fill,
thumb_fill,
styles,
value_range,
default_value,
} = self;
let track_position_id = slider_track_position_id.clone();
let mut slider_thumb = Draggable::new(
state_handle.thumb_draggable_state.clone(),
render_thumb(
thumb_fill,
thumb_size,
state_handle.thumb_hoverable_state.clone(),
),
)
.with_drag_axis(DragAxis::HorizontalOnly)
.with_drag_bounds_callback(move |position_cache, _| {
position_cache
.get_position(track_position_id.as_str())
.map(|track_position| {
// Set drag bounds so the thumb may only be dragged along the track.
RectF::new(
vec2f(track_position.origin_x(), track_position.origin_y()),
vec2f(track_position.width(), 0.),
)
})
});
Self::register_on_drag_start_callback(
&mut slider_thumb,
slider_track_position_id.clone(),
state_handle.clone(),
);
Self::register_on_drag_callback(
&mut slider_thumb,
slider_track_position_id.clone(),
thumb_size,
value_range.clone(),
state_handle.clone(),
on_drag_callback,
);
Self::register_on_drop_callback(
&mut slider_thumb,
slider_track_position_id.clone(),
thumb_size,
value_range.clone(),
state_handle.clone(),
on_change_callback.clone(),
);
let track = Hoverable::new(state_handle.track_hoverable_state.clone(), |_| {
render_track(thumb_size, styles.width, track_height, track_fill)
});
let track = Self::register_on_click_callback(
track,
slider_track_position_id.clone(),
thumb_size,
value_range.clone(),
state_handle.clone(),
on_change_callback.clone(),
);
let mut slider = Stack::new();
slider.add_child(
SavePosition::new(track.finish(), slider_track_position_id.as_str()).finish(),
);
let offset = match state_handle.thumb_offset_x() {
Some(offset_x) => OffsetType::Pixel(offset_x),
None => OffsetType::Percentage(
default_value
.map(|value| {
((value - value_range.start) / (value_range.end - value_range.start))
.clamp(0., 1.)
})
.unwrap_or(0.),
),
};
slider.add_positioned_child(
slider_thumb.finish(),
OffsetPositioning::from_axes(
PositioningAxis::relative_to_stack_child(
&slider_track_position_id,
PositionedElementOffsetBounds::AnchoredElement,
// Set the position of the thumb based on the slider's current value.
offset,
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_stack_child(
&slider_track_position_id,
PositionedElementOffsetBounds::Unbounded,
OffsetType::Pixel(0.),
AnchorPair::new(YAxisAnchor::Middle, YAxisAnchor::Middle),
),
),
);
Container::new(slider.finish())
.with_margin_top(styles.margin.map(|margin| margin.top).unwrap_or(0.))
.with_margin_bottom(styles.margin.map(|margin| margin.bottom).unwrap_or(0.))
.with_margin_left(styles.margin.map(|margin| margin.left).unwrap_or(0.))
.with_margin_right(styles.margin.map(|margin| margin.right).unwrap_or(0.))
.with_padding_top(styles.padding.map(|padding| padding.top).unwrap_or(0.))
.with_padding_bottom(styles.padding.map(|padding| padding.bottom).unwrap_or(0.))
.with_padding_left(styles.padding.map(|padding| padding.left).unwrap_or(0.))
.with_padding_right(styles.padding.map(|padding| padding.right).unwrap_or(0.))
}
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
state_handle: self.state_handle,
track_position_id: self.track_position_id,
on_drag_callback: self.on_drag_callback,
on_change_callback: self.on_change_callback,
thumb_size: self.thumb_size,
track_height: self.track_height,
track_fill: self.track_fill,
thumb_fill: self.thumb_fill,
value_range: self.value_range,
default_value: self.default_value,
styles: self.styles.merge(styles),
}
}
}
/// Renders the slider 'track', along which the thumb can be dragged.
fn render_track(thumb_size: f32, width: Option<f32>, height: f32, fill: Fill) -> Box<dyn Element> {
let mut track = ConstrainedBox::new(
Container::new(
Rect::new()
.with_background(fill)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_padding_left(thumb_size / 2.)
.with_padding_right(thumb_size / 2.)
.finish(),
)
.with_height(height);
if let Some(width) = width {
track = track.with_width(width);
}
// We add a container with extra padding to make the track
// as tall (invisibly) as the thumb. This way, we can detect
// clicks that are slightly above or below the track bar itself.
let vertical_padding = ((thumb_size - height) / 2.).max(0.);
Container::new(track.finish())
.with_padding_top(vertical_padding)
.with_padding_bottom(vertical_padding)
.finish()
}
/// Renders the 'thumb' (handle) for the slider.
///
/// The thumb is a circle with diameter set to `size`.
fn render_thumb(fill: Fill, size: f32, state_handle: MouseStateHandle) -> Box<dyn Element> {
Hoverable::new(state_handle, move |hover_state| {
let thumb = Container::new(
ConstrainedBox::new(
Rect::new()
.with_background(fill)
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_drop_shadow(*THUMB_DROP_SHADOW)
.finish(),
)
.with_width(size)
.with_height(size)
.finish(),
)
.finish();
let mut stack = Stack::new();
if hover_state.is_hovered() {
let hover_size = size + HOVER_BORDER_SIZE;
let mut hover_background = *DEFAULT_TRACK_COLOR;
hover_background.a = HOVER_OPACITY;
let thumb_hover = Container::new(
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(),
)
.finish();
// Position the hover so that it's centered around the thumb. Since the hover
// is guaranteed to be larger than the thumb, we position the hover at the top
// left corner of the thumb and then translate it to the left and up so that it
// is centered.
stack.add_positioned_child(
thumb_hover,
OffsetPositioning::from_axes(
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
OffsetType::Pixel(-((hover_size - size) / 2.)),
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
OffsetType::Pixel(-((hover_size - size) / 2.)),
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
),
),
);
}
stack.add_child(thumb);
stack.finish()
})
.with_cursor(Cursor::PointingHand)
.finish()
}
/// Returns a unique position ID for the slider track.
fn new_track_position_id() -> String {
let current_count = *TRACK_POSITION_ID_COUNT.read();
let position_id = format!("SliderTrack{current_count}");
*TRACK_POSITION_ID_COUNT.write() = current_count + 1;
position_id
}
/// Returns total width of the draggable area on the 'track'.
fn draggable_width(track_position: RectF, thumb_size: f32) -> f32 {
track_position.max_x() - track_position.min_x() - thumb_size
}
@@ -0,0 +1,340 @@
use crate::color::ColorU;
use crate::elements::{
AnchorPair, ChildAnchor, Empty, Fill, OffsetPositioning, OffsetType, ParentAnchor,
ParentOffsetBounds, PositioningAxis, Stack, XAxisAnchor, YAxisAnchor,
};
use crate::geometry::vector::vec2f;
use crate::platform::Cursor;
use crate::scene::{DropShadow, Radius};
use crate::{
elements::{
ConstrainedBox, Container, CornerRadius, Element, Flex, Hoverable, MouseState,
MouseStateHandle, ParentElement, Rect,
},
ui_components::components::{UiComponent, UiComponentStyles},
ui_components::text::Span,
ui_components::tool_tip::Tooltip,
};
use lazy_static::lazy_static;
const DEFAULT_THUMB_HEIGHT: f32 = 18.;
lazy_static! {
// Hardcode for now, but can be made configurable if necessary.
pub static ref TRACK_COLOR: ColorU = ColorU::new(170, 170, 170, 255);
static ref DROP_SHADOW: DropShadow = DropShadow {
color: ColorU::black(),
offset: vec2f(-0.5, 2.),
blur_radius: 20.,
spread_radius: 0.,
};
}
/// A config to provide both the text and the styles for a tooltip.
/// Bundling these together prevents any callers from passing in just one
/// without the other (and this ui element is not capable of coming up with sensible, themed defaults for the tooltip styles).
#[derive(Clone)]
pub struct TooltipConfig {
pub text: String,
pub styles: UiComponentStyles,
}
/// A switch element used to toggle the on/off state of a single value. A switch consists of two
/// distinct pieces: the "thumb" which is the piece that is clickable and is rendered on the left if
/// unchecked and on the right if checked, and the "track", the background that the thumb moves
/// along. The switch optionally includes a label that can also be clicked to active the element.
/// Note the switch does not contain any state, it's up to the caller to rebuild the switch with the
/// correct value for "checked" when the switch is clicked.
pub struct Switch {
checked: bool,
disabled: bool,
label: Option<Span>, // optional label for the Switch, also clickable
styles: UiComponentStyles,
hovered_styles: Option<UiComponentStyles>,
checked_styles: Option<UiComponentStyles>,
disabled_styles: Option<UiComponentStyles>,
hover_border_size: Option<f32>,
mouse_state: SwitchStateHandle,
tooltip: Option<TooltipConfig>,
}
/// State handles necessary for the Switch component. Two mouse state handles are needed to handle
/// clicks on the entire component while having a hover on only the thumb.
#[derive(Default, Clone)]
pub struct SwitchStateHandle {
component_mouse_state: MouseStateHandle,
thumb_mouse_state: MouseStateHandle,
}
impl UiComponent for Switch {
type ElementType = Hoverable;
fn build(self) -> Hoverable {
let tooltip = self.tooltip.clone();
let hoverable = Hoverable::new(self.mouse_state.component_mouse_state.clone(), |state| {
let styles = self.styles(state);
let thumb_height = styles.height.unwrap_or(DEFAULT_THUMB_HEIGHT);
let switch_element = self.render_switch(styles);
let switch_element = if let Some(label) = self.label.clone() {
let label = label.with_style(self.styles).build();
let font_size = self.styles.font_size.unwrap_or_default();
// If the thumb is larger than the label font, apply padding so the switch is
// centered with the label.
let padding_top = if thumb_height > font_size {
(thumb_height - font_size) / 2.
} else {
0.
};
Flex::row()
.with_child(label.finish())
.with_child(
Container::new(switch_element)
.with_padding_top(padding_top)
.finish(),
)
.finish()
} else {
switch_element
};
// If a tooltip is configured and we're hovered, show it above the switch
if let Some(TooltipConfig { text, styles }) = &tooltip {
if state.is_hovered() {
let tooltip_element = Tooltip::new(text.clone(), *styles).build().finish();
return Stack::new()
.with_child(switch_element)
.with_positioned_child(
tooltip_element,
OffsetPositioning::offset_from_parent(
vec2f(0., -3.),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopRight,
ChildAnchor::BottomRight,
),
)
.finish();
}
}
switch_element
});
if !self.disabled {
hoverable.with_cursor(Cursor::PointingHand)
} else {
hoverable
}
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
checked: self.checked,
disabled: self.disabled,
label: self.label,
styles: self.styles.merge(styles),
hovered_styles: Some(self.hovered_styles.unwrap_or(self.styles).merge(styles)),
checked_styles: Some(self.checked_styles.unwrap_or(self.styles).merge(styles)),
disabled_styles: Some(self.disabled_styles.unwrap_or(self.styles).merge(styles)),
mouse_state: self.mouse_state,
hover_border_size: self.hover_border_size,
tooltip: self.tooltip,
}
}
}
impl Switch {
pub fn new(
mouse_state: SwitchStateHandle,
default_styles: UiComponentStyles,
hovered_styles: Option<UiComponentStyles>,
checked_styles: Option<UiComponentStyles>,
disabled_styles: Option<UiComponentStyles>,
) -> Self {
Self {
checked: false,
disabled: false,
label: None,
styles: default_styles,
hovered_styles,
checked_styles,
disabled_styles,
mouse_state,
hover_border_size: None,
tooltip: None,
}
}
/// Sets the a circular hover border on the thumb of size `border_size`.
pub fn with_thumb_hover_border(mut self, border_size: f32) -> Self {
self.hover_border_size = Some(border_size);
self
}
pub fn with_disabled_styles(mut self, styles: UiComponentStyles) -> Self {
self.disabled_styles = Some(self.disabled_styles.unwrap_or_default().merge(styles));
self
}
pub fn check(mut self, check: bool) -> Self {
self.checked = check;
self
}
pub fn disable(mut self) -> Self {
self.disabled = true;
self
}
pub fn with_disabled(mut self, is_disabled: bool) -> Self {
self.disabled = is_disabled;
self
}
pub fn label(mut self, label: Span) -> Self {
self.label = Some(label);
self
}
/// Adds a tooltip that appears above the switch on hover.
pub fn with_tooltip(mut self, config: TooltipConfig) -> Self {
self.tooltip = Some(config);
self
}
fn styles(&self, state: &MouseState) -> UiComponentStyles {
if self.disabled {
return self.disabled_styles.unwrap_or(self.styles);
}
if self.checked {
return self.checked_styles.unwrap_or(self.styles);
}
if state.is_mouse_over_element() {
return self.hovered_styles.unwrap_or(self.styles);
}
self.styles
}
// Renders the thumb. The thumb needs its own hoverable to render a border around itself when
// hovered.
fn render_thumb(&self, styles: UiComponentStyles, thumb_height: f32) -> Box<dyn Element> {
let is_disabled = self.disabled;
let thumb_color = styles.foreground.unwrap_or(Fill::Solid(ColorU::white()));
Hoverable::new(self.mouse_state.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) = self.hover_border_size {
if !is_disabled && state.is_mouse_over_element() {
let mut hover_background = *TRACK_COLOR;
hover_background.a = 100;
let hover_size = thumb_height + border_size;
let thumb_hover = Container::new(
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(),
)
.finish();
// Position the hover so that it's centered around the thumb. Since the hover
// is guaranteed to be larger than the thumb, we position the hover at the top
// left corner of the thumb and then translate it to the left and up so that it
// is centered.
stack.add_positioned_child(
thumb_hover,
OffsetPositioning::from_axes(
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
OffsetType::Pixel(-((hover_size - thumb_height) / 2.)),
AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left),
),
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
OffsetType::Pixel(-((hover_size - thumb_height) / 2.)),
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
),
),
);
}
}
stack.add_child(thumb);
stack.finish()
})
.finish()
}
fn render_switch(&self, styles: UiComponentStyles) -> Box<dyn Element> {
let thumb_height = styles.height.unwrap_or(DEFAULT_THUMB_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 = styles.background.unwrap_or(Fill::Solid(*TRACK_COLOR));
let mut stack = Stack::new();
stack.add_child(track.with_background(background_color).finish());
let thumb = self.render_thumb(styles, thumb_height);
// If checked, render the thumb's right corner on the right corner of the track. If
// unchecked, render the thumb's left corner on the left corner of the track.
let positioning = if self.checked {
OffsetPositioning::from_axes(
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
OffsetType::Pixel(0.),
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Right),
),
PositioningAxis::relative_to_parent(
ParentOffsetBounds::Unbounded,
OffsetType::Pixel(0.),
AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top),
),
)
} else {
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::Unbounded,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
)
};
stack.add_positioned_child(thumb, positioning);
stack.finish()
}
}
@@ -0,0 +1,196 @@
use std::borrow::Cow;
use crate::elements::{Highlight, HighlightedRange, DEFAULT_UI_LINE_HEIGHT_RATIO};
use crate::{
elements::{Container, Element, Text},
fonts::Properties,
ui_components::components::{UiComponent, UiComponentStyles},
};
use itertools::Itertools;
#[derive(Debug, Clone, Default)]
pub struct WrappableText {
text: Cow<'static, str>,
styles: UiComponentStyles,
wrap: bool,
line_height_ratio: f32,
highlights: Vec<HighlightedRange>,
/// Whether the text is selectable when rendered as a descendant of a [`SelectableArea`].
is_selectable: bool,
}
impl WrappableText {
pub fn new(text: Cow<'static, str>, soft_wrap: bool, styles: UiComponentStyles) -> Self {
WrappableText {
text,
styles,
wrap: soft_wrap,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
highlights: vec![],
is_selectable: true,
}
}
pub fn with_highlights(mut self, highlight_indices: Vec<usize>, highlight: Highlight) -> Self {
if highlight_indices.is_empty() {
return self;
}
self.highlights = vec![HighlightedRange {
highlight,
highlight_indices,
}];
self
}
pub fn with_line_height_ratio(mut self, line_height_ratio: f32) -> Self {
self.line_height_ratio = line_height_ratio;
self
}
pub fn with_selectable(mut self, is_selectable: bool) -> Self {
self.is_selectable = is_selectable;
self
}
}
impl UiComponent for WrappableText {
type ElementType = Container;
fn build(self) -> Container {
let styles = self.styles;
let mut text = Text::new(
self.text,
styles.font_family_id.unwrap(),
styles.font_size.unwrap_or_default(),
)
.soft_wrap(self.wrap)
.with_line_height_ratio(self.line_height_ratio)
.with_selectable(self.is_selectable);
if let Some(color) = styles.font_color {
text = text.with_color(color);
}
if let Some(weight) = styles.font_weight {
text = text.with_style(Properties::default().weight(weight))
}
// The text element assumes that highlights are sorted by character index.
text = text.with_highlights(
self.highlights
.iter()
.sorted_by_key(|highlighted_range| highlighted_range.highlight_indices.first())
.cloned(),
);
let mut container = Container::new(text.finish());
if let Some(margin) = styles.margin {
container = container
.with_margin_left(margin.left)
.with_margin_top(margin.top)
.with_margin_right(margin.right)
.with_margin_bottom(margin.bottom);
}
container
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
text: self.text,
styles: self.styles.merge(styles),
..self
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Span {
text: WrappableText,
}
impl Span {
pub fn new(text: impl Into<Cow<'static, str>>, styles: UiComponentStyles) -> Self {
Span {
text: WrappableText::new(text.into(), false, styles),
}
}
pub fn with_highlights(mut self, highlight_indices: Vec<usize>, highlight: Highlight) -> Self {
self.text = self.text.with_highlights(highlight_indices, highlight);
self
}
pub fn with_soft_wrap(mut self) -> Self {
self.text.wrap = true;
self
}
pub fn with_line_height_ratio(mut self, line_height_ratio: f32) -> Self {
self.text.line_height_ratio = line_height_ratio;
self
}
pub fn with_selectable(mut self, is_selectable: bool) -> Self {
self.text.is_selectable = is_selectable;
self
}
}
impl UiComponent for Span {
type ElementType = Container;
fn build(self) -> Container {
self.text.build()
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
text: self.text.with_style(styles),
}
}
}
// Main difference between Span vs Paragraph is that Paragraph wraps the text
// and it's intention is to be used for longer text blocks whereas Span is good
// for short labels etc.
#[derive(Debug, Clone, Default)]
pub struct Paragraph {
text: WrappableText,
}
impl Paragraph {
pub fn new(text: impl Into<Cow<'static, str>>, styles: UiComponentStyles) -> Self {
Paragraph {
text: WrappableText::new(text.into(), true, styles),
}
}
pub fn with_highlights(mut self, highlight_indices: Vec<usize>, highlight: Highlight) -> Self {
self.text = self.text.with_highlights(highlight_indices, highlight);
self
}
// TODO(alokedesai): Make it clear throughout the text rendering code that highlights are
// indexed by _character_, not byte.
pub fn add_highlight(&mut self, highlight_indices: Vec<usize>, highlight: Highlight) {
if highlight_indices.is_empty() {
return;
}
self.text.highlights.push(HighlightedRange {
highlight,
highlight_indices,
});
}
}
impl UiComponent for Paragraph {
type ElementType = Container;
fn build(self) -> Container {
self.text.build()
}
/// Overwrites _some_ styles passed in `style` parameter
fn with_style(self, styles: UiComponentStyles) -> Self {
Self {
text: self.text.with_style(styles),
}
}
}
@@ -0,0 +1,85 @@
use crate::elements::{ChildView, Clipped};
use crate::{
elements::{Border, ConstrainedBox, Container, Element},
ui_components::components::{UiComponent, UiComponentStyles},
View, ViewHandle,
};
pub struct TextInput<T: View> {
editor: ViewHandle<T>,
styles: UiComponentStyles,
}
impl<T: View> UiComponent for TextInput<T> {
type ElementType = ConstrainedBox;
fn build(self) -> ConstrainedBox {
self.render_text_input()
}
fn with_style(self, styles: UiComponentStyles) -> Self {
TextInput {
editor: self.editor,
styles: self.styles.merge(styles),
}
}
}
impl<T: View> TextInput<T> {
pub fn new(editor: ViewHandle<T>, default_styles: UiComponentStyles) -> Self {
TextInput {
editor,
styles: default_styles,
}
}
fn render_text_input(&self) -> ConstrainedBox {
let styles = self.styles;
let mut container =
Container::new(Clipped::new(ChildView::new(&self.editor).finish()).finish());
// Setting up the border
if let Some(corner) = styles.border_radius {
container = container.with_corner_radius(corner);
}
let mut border = Border::all(styles.border_width.unwrap_or_default());
if let Some(border_color) = styles.border_color {
border = border.with_border_fill(border_color);
}
container = container.with_border(border);
// Position-related settings
if let Some(padding) = styles.padding {
container = container
.with_padding_left(padding.left)
.with_padding_top(padding.top)
.with_padding_right(padding.right)
.with_padding_bottom(padding.bottom);
}
if let Some(margin) = styles.margin {
container = container
.with_margin_left(margin.left)
.with_margin_top(margin.top)
.with_margin_right(margin.right)
.with_margin_bottom(margin.bottom);
}
if let Some(background) = styles.background {
container = container.with_background(background);
}
match (styles.height, styles.width) {
(None, None) => ConstrainedBox::new(container.finish()),
(_, _) => {
let mut constrained_box = ConstrainedBox::new(container.finish());
if let Some(height) = styles.height {
constrained_box = constrained_box.with_height(height);
}
if let Some(width) = styles.width {
constrained_box = constrained_box.with_width(width);
}
constrained_box
}
}
}
}
@@ -0,0 +1,169 @@
use pathfinder_geometry::vector::vec2f;
use crate::{
elements::{
ChildAnchor, ConstrainedBox, Container, Empty, Hoverable, MouseState, MouseStateHandle,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
},
scene::Border,
Element,
};
use super::{
components::{UiComponent, UiComponentStyles},
text::Span,
};
/// A button element used to toggle a single value on or off.
pub struct ToggleButton {
label: ToggleButtonLabel,
tooltip: Option<Box<dyn Element>>,
toggled_on: bool,
mouse_state: MouseStateHandle,
styles: UiComponentStyles,
hovered_styles: Option<UiComponentStyles>,
toggled_on_styles: Option<UiComponentStyles>,
}
pub enum ToggleButtonLabel {
None,
Text(String),
}
impl<S> From<S> for ToggleButtonLabel
where
S: Into<String>,
{
fn from(label: S) -> Self {
Self::Text(label.into())
}
}
impl ToggleButton {
pub fn new(mouse_state: MouseStateHandle, styles: UiComponentStyles) -> Self {
Self {
label: ToggleButtonLabel::None,
toggled_on: false,
tooltip: None,
mouse_state,
styles,
hovered_styles: None,
toggled_on_styles: None,
}
}
pub fn with_label(mut self, label: impl Into<ToggleButtonLabel>) -> Self {
self.label = label.into();
self
}
pub fn with_tooltip(mut self, tooltip: Box<dyn Element>) -> Self {
self.tooltip = Some(tooltip);
self
}
pub fn with_toggled_on(mut self, toggled_on: bool) -> Self {
self.toggled_on = toggled_on;
self
}
pub fn with_hovered_styles(mut self, styles: UiComponentStyles) -> Self {
self.hovered_styles = Some(styles);
self
}
pub fn with_toggled_on_styles(mut self, styles: UiComponentStyles) -> Self {
self.toggled_on_styles = Some(styles);
self
}
fn styles(&self, state: &MouseState) -> UiComponentStyles {
let mut styles = self.styles;
if self.toggled_on {
if let Some(overlay) = self.toggled_on_styles {
styles = styles.merge(overlay);
}
}
if state.is_mouse_over_element() {
if let Some(overlay) = self.hovered_styles {
styles = styles.merge(overlay);
}
}
styles
}
fn render_button(&self, styles: &UiComponentStyles) -> Box<dyn Element> {
let label = match &self.label {
ToggleButtonLabel::Text(text) => Span::new(text.clone(), *styles).build().finish(),
ToggleButtonLabel::None => Empty::new().finish(),
};
let mut constrained_box = ConstrainedBox::new(label);
if let Some(width) = styles.width {
constrained_box = constrained_box.with_width(width);
}
if let Some(height) = styles.height {
constrained_box = constrained_box.with_height(height);
};
let mut button = Container::new(constrained_box.finish());
if let Some(background) = styles.background {
button = button.with_background(background);
}
if let Some(corner_radius) = styles.border_radius {
button = button.with_corner_radius(corner_radius);
}
if let Some(padding) = styles.padding {
button = button
.with_padding_top(padding.top)
.with_padding_bottom(padding.bottom)
.with_padding_left(padding.left)
.with_padding_right(padding.right);
}
let mut border = Border::all(styles.border_width.unwrap_or_default());
if let Some(border_fill) = styles.border_color {
border = border.with_border_fill(border_fill);
}
button = button.with_border(border);
button.finish()
}
}
impl UiComponent for ToggleButton {
type ElementType = Hoverable;
fn build(mut self) -> Hoverable {
Hoverable::new(self.mouse_state.clone(), |state| {
let styles = self.styles(state);
let button = self.render_button(&styles);
let mut stack = Stack::new().with_child(button);
if state.is_hovered() {
if let Some(tooltip) = self.tooltip.take() {
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., 10.),
ParentOffsetBounds::Unbounded,
ParentAnchor::BottomRight,
ChildAnchor::TopRight,
),
)
}
}
stack.finish()
})
}
fn with_style(mut self, style: UiComponentStyles) -> Self {
self.styles = style;
self
}
}
@@ -0,0 +1,301 @@
use std::{borrow::Cow, rc::Rc, sync::Arc};
use crate::{
elements::{
Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize, MouseStateHandle,
ParentElement, Shrinkable,
},
platform::Cursor,
scene::{CornerRadius, Radius},
AppContext, Element, EventContext,
};
use parking_lot::Mutex;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::Vector2F;
use super::{
components::{UiComponent, UiComponentStyles},
text::Span,
};
use lazy_static::lazy_static;
const BORDER_RADIUS: f32 = 4.;
const BUTTON_VERTICAL_PADDING: f32 = 2.;
const BUTTON_MARGIN: f32 = 4.;
lazy_static! {
pub static ref FALLBACK_SELECTED_COLOR: ColorU = ColorU::new(64, 64, 64, 100);
pub static ref FALLBACK_BACKGROUND_COLOR: ColorU = ColorU::new(25, 25, 25, 100);
}
pub struct ToggleMenuItem {
label: Cow<'static, str>,
}
impl ToggleMenuItem {
pub fn new(label: impl Into<Cow<'static, str>>) -> Self {
Self {
label: label.into(),
}
}
}
#[derive(Clone, Copy, Default)]
struct ToggleMenuState {
selected_item: Option<usize>,
default_selected_item: Option<usize>,
}
#[derive(Clone, Default)]
pub struct ToggleMenuStateHandle {
inner: Arc<Mutex<ToggleMenuState>>,
}
impl ToggleMenuStateHandle {
pub fn get_selected_idx(&self) -> Option<usize> {
let state = self.inner.lock();
match (state.selected_item, state.default_selected_item) {
(Some(selected_idx), _) => Some(selected_idx),
(None, Some(default_idx)) => Some(default_idx),
_ => None,
}
}
fn get_default_idx(&self) -> Option<usize> {
let state = self.inner.lock();
state.default_selected_item
}
fn set(&self, new_state: ToggleMenuState) {
let mut guard = self.inner.lock();
*guard = new_state;
}
// Set the active index from outside of the toggle menu component
pub fn set_selected_idx(&self, new_idx: usize) {
let default_selected_item = self.get_default_idx();
self.set(ToggleMenuState {
selected_item: Some(new_idx),
default_selected_item,
});
}
}
struct ToggleMenuRenderer {
default_styles: UiComponentStyles,
selected_styles: UiComponentStyles,
hovered_styles: UiComponentStyles,
state_handle: ToggleMenuStateHandle,
hover_states: Vec<MouseStateHandle>,
is_disabled: bool,
}
impl ToggleMenuRenderer {
fn render_label(&self, label: Cow<'static, str>) -> Box<dyn Element> {
let font_styles = UiComponentStyles {
font_family_id: self.default_styles.font_family_id,
font_size: self.default_styles.font_size,
font_color: self
.default_styles
.font_color
.unwrap_or_else(ColorU::white)
.into(),
font_weight: self.default_styles.font_weight,
..Default::default()
};
Span::new(label, font_styles)
.with_soft_wrap()
.build()
.finish()
}
fn render_item(
&self,
item_idx: usize,
item: ToggleMenuItem,
on_toggle_change: Rc<ToggleMenuCallback>,
) -> Box<dyn Element> {
let selected = self
.state_handle
.get_selected_idx()
.map(|selected_idx| selected_idx == item_idx)
.unwrap_or(false);
let mut hoverable = Hoverable::new(self.hover_states[item_idx].clone(), |state| {
let ToggleMenuItem { label } = item;
let flex_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Shrinkable::new(1., Empty::new().finish()).finish())
.with_child(self.render_label(label))
.with_child(Shrinkable::new(1., Empty::new().finish()).finish())
.finish();
let mut container = Container::new(flex_row);
if let Some(padding) = self.default_styles.padding {
container = container
.with_padding_bottom(padding.bottom)
.with_padding_left(padding.left)
.with_padding_right(padding.right)
.with_padding_top(padding.top);
} else {
container = container.with_vertical_padding(BUTTON_VERTICAL_PADDING)
}
if let Some(margin) = self.default_styles.margin {
container = container
.with_margin_bottom(margin.bottom)
.with_margin_left(margin.left)
.with_margin_right(margin.right)
.with_margin_top(margin.top);
} else if item_idx == 0 {
container = container.with_uniform_margin(BUTTON_MARGIN);
} else {
container = container
.with_margin_right(BUTTON_MARGIN)
.with_vertical_margin(BUTTON_MARGIN);
}
if let Some(radius) = self.default_styles.border_radius {
container = container.with_corner_radius(radius);
} else {
container = container
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(BORDER_RADIUS)));
}
if selected {
container = container.with_background(
self.selected_styles
.background
.unwrap_or((*FALLBACK_SELECTED_COLOR).into()),
);
} else if !self.is_disabled && state.is_hovered() {
container = container.with_background(
self.hovered_styles
.background
.unwrap_or((*FALLBACK_SELECTED_COLOR).into()),
);
}
container.finish()
});
let state_handle = self.state_handle.clone();
let old_default = state_handle.get_default_idx();
if !self.is_disabled {
hoverable = hoverable
.on_click(move |event_ctx, app, v2f| {
// Trigger the callback if a new item is selected
if state_handle.get_selected_idx() != Some(item_idx) {
on_toggle_change(event_ctx, app, v2f);
state_handle.set(ToggleMenuState {
selected_item: Some(item_idx),
default_selected_item: old_default,
});
}
})
.with_cursor(Cursor::PointingHand);
}
hoverable.finish()
}
}
pub type ToggleMenuCallback = dyn Fn(&mut EventContext, &AppContext, Vector2F) + 'static;
pub struct ToggleMenu {
items: Vec<ToggleMenuItem>,
renderer: ToggleMenuRenderer,
/// Callback function to be run when the toggle state is changed.
on_toggle_change: Rc<ToggleMenuCallback>,
}
impl UiComponent for ToggleMenu {
type ElementType = Container;
fn build(self) -> Self::ElementType {
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children(self.items.into_iter().enumerate().map(|(idx, item)| {
Shrinkable::new(
1.,
Container::new(self.renderer.render_item(
idx,
item,
self.on_toggle_change.clone(),
))
.finish(),
)
.finish()
}))
.with_main_axis_size(MainAxisSize::Max)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_background(
self.renderer
.default_styles
.background
.unwrap_or((*FALLBACK_BACKGROUND_COLOR).into()),
)
}
fn with_style(self, new_styles: UiComponentStyles) -> Self {
Self {
renderer: ToggleMenuRenderer {
default_styles: new_styles.merge(self.renderer.default_styles),
..self.renderer
},
..self
}
}
}
impl ToggleMenu {
#[allow(clippy::too_many_arguments)]
pub fn new(
mouse_states: Vec<MouseStateHandle>,
items: Vec<ToggleMenuItem>,
toggle_menu_state_handle: ToggleMenuStateHandle,
default_option: Option<usize>,
default_styles: UiComponentStyles,
selected_styles: UiComponentStyles,
hovered_styles: UiComponentStyles,
on_toggle_change: Rc<ToggleMenuCallback>,
) -> Self {
let mut selected_idx = toggle_menu_state_handle.get_selected_idx();
if let Some(id) = selected_idx {
if items.get(id).is_none() {
// Previously selected option is out of range, reset to default.
selected_idx = None
}
}
toggle_menu_state_handle.set(ToggleMenuState {
selected_item: selected_idx,
default_selected_item: default_option,
});
Self {
items,
renderer: ToggleMenuRenderer {
default_styles,
selected_styles,
hovered_styles,
state_handle: toggle_menu_state_handle,
hover_states: mouse_states,
is_disabled: false,
},
on_toggle_change,
}
}
pub fn with_disabled(mut self, is_disabled: bool) -> Self {
self.renderer.is_disabled = is_disabled;
self
}
}
@@ -0,0 +1,170 @@
use crate::{
elements::{Border, Container, Element, Flex, ParentElement, Text},
ui_components::components::{UiComponent, UiComponentStyles},
};
use pathfinder_color::ColorU;
pub struct Tooltip {
label: String,
styles: UiComponentStyles,
}
const FORTY_PERCENT_OPACITY: u8 = (255. * 0.4) as u8;
impl UiComponent for Tooltip {
type ElementType = Container;
fn build(self) -> Container {
let styles = self.styles;
let mut container = Container::new(
Text::new(
self.label,
styles.font_family_id.unwrap(),
styles.font_size.unwrap_or_default(),
)
.with_color(styles.font_color.unwrap_or_default())
.finish(),
);
if let Some(corner) = styles.border_radius {
container = container.with_corner_radius(corner);
}
let mut border = Border::all(styles.border_width.unwrap_or_default());
if let Some(border_color) = styles.border_color {
border = border.with_border_fill(border_color);
}
container = container.with_border(border);
if let Some(padding) = styles.padding {
container = container
.with_padding_left(padding.left)
.with_padding_top(padding.top)
.with_padding_right(padding.right)
.with_padding_bottom(padding.bottom);
}
if let Some(margin) = styles.margin {
container = container
.with_margin_left(margin.left)
.with_margin_top(margin.top)
.with_margin_right(margin.right)
.with_margin_bottom(margin.bottom);
}
if let Some(background) = styles.background {
container = container.with_background(background);
}
container
}
fn with_style(self, styles: UiComponentStyles) -> Self {
Tooltip {
styles: self.styles.merge(styles),
..self
}
}
}
impl Tooltip {
pub fn new(label: String, styles: UiComponentStyles) -> Self {
Tooltip { label, styles }
}
}
pub struct TooltipWithSublabel {
label: String,
sublabel: String,
styles: UiComponentStyles,
}
impl UiComponent for TooltipWithSublabel {
type ElementType = Container;
fn build(self) -> Container {
let styles = self.styles;
let label_text = Container::new(
Text::new_inline(
self.label,
styles.font_family_id.unwrap(),
styles.font_size.unwrap_or_default(),
)
.with_color(styles.font_color.unwrap_or_default())
.finish(),
)
.with_margin_right(4.)
.finish();
let label_font_color = styles.font_color.unwrap_or_default();
let sublabel_font_color = ColorU::new(
label_font_color.r,
label_font_color.g,
label_font_color.b,
FORTY_PERCENT_OPACITY,
);
let sublabel_text = Container::new(
Text::new_inline(
self.sublabel,
styles.font_family_id.unwrap(),
styles.font_size.unwrap_or_default(),
)
.with_color(sublabel_font_color)
.finish(),
)
.with_margin_left(4.)
.finish();
let mut container = Container::new(
Flex::row()
.with_children([label_text, sublabel_text])
.finish(),
);
if let Some(corner) = styles.border_radius {
container = container.with_corner_radius(corner);
}
let mut border = Border::all(styles.border_width.unwrap_or_default());
if let Some(border_color) = styles.border_color {
border = border.with_border_fill(border_color);
}
container = container.with_border(border);
if let Some(padding) = styles.padding {
container = container
.with_padding_left(padding.left)
.with_padding_top(padding.top)
.with_padding_right(padding.right)
.with_padding_bottom(padding.bottom);
}
if let Some(margin) = styles.margin {
container = container
.with_margin_left(margin.left)
.with_margin_top(margin.top)
.with_margin_right(margin.right)
.with_margin_bottom(margin.bottom);
}
if let Some(background) = styles.background {
container = container.with_background(background);
}
container
}
fn with_style(self, styles: UiComponentStyles) -> Self {
TooltipWithSublabel {
styles: self.styles.merge(styles),
..self
}
}
}
impl TooltipWithSublabel {
pub fn new(label: String, sublabel: String, styles: UiComponentStyles) -> Self {
TooltipWithSublabel {
label,
sublabel,
styles,
}
}
}