Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user