From aa996a090bd2a74180b4acd138b23cfcc98d8dc1 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 13 Dec 2023 16:30:34 -0800 Subject: [PATCH] Tooltips Closes #37 --- examples/login.rs | 48 ++++++------ src/widget.rs | 6 ++ src/widgets/container.rs | 14 ++++ src/widgets/layers.rs | 155 +++++++++++++++++++++++++++++++++------ 4 files changed, 179 insertions(+), 44 deletions(-) diff --git a/examples/login.rs b/examples/login.rs index c9cdba3..19462b6 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -3,29 +3,17 @@ use std::process::exit; use gooey::value::{Dynamic, Validations}; use gooey::widget::MakeWidget; use gooey::widgets::input::{InputValue, MaskedString}; +use gooey::widgets::layers::OverlayLayer; use gooey::widgets::Expand; use gooey::Run; use kludgine::figures::units::Lp; fn main() -> gooey::Result { + let tooltips = OverlayLayer::default(); let username = Dynamic::default(); let password = Dynamic::default(); let validations = Validations::default(); - let username_valid = validations.validate(&username, |u: &String| { - if u.is_empty() { - Err("usernames must contain at least one character") - } else { - Ok(()) - } - }); - - let password_valid = validations.validate(&password, |u: &MaskedString| match u.len() { - 0..=7 => Err("passwords must be at least 8 characters long"), - _ => Ok(()), - }); - - // TODO this should be a grid layout to ensure proper visual alignment. let username_field = "Username" .align_left() .and( @@ -33,8 +21,18 @@ fn main() -> gooey::Result { .clone() .into_input() .placeholder("Username") - .validation(username_valid) - .hint("* required"), + .validation(validations.validate(&username, |u: &String| { + if u.is_empty() { + Err("usernames must contain at least one character") + } else { + Ok(()) + } + })) + .hint("* required") + .tooltip( + &tooltips, + "If you can't remember your username, that's because this is a demo.", + ), ) .into_rows(); @@ -45,8 +43,14 @@ fn main() -> gooey::Result { .clone() .into_input() .placeholder("Password") - .validation(password_valid) - .hint("* required, 8 characters min"), + .validation( + validations.validate(&password, |u: &MaskedString| match u.len() { + 0..=7 => Err("passwords must be at least 8 characters long"), + _ => Ok(()), + }), + ) + .hint("* required, 8 characters min") + .tooltip(&tooltips, "Passwords are always at least 8 bytes long."), ) .into_rows(); @@ -57,6 +61,7 @@ fn main() -> gooey::Result { exit(0) }) .into_escape() + .tooltip(&tooltips, "This button quits the program") .and(Expand::empty()) .and( "Log In" @@ -69,7 +74,7 @@ fn main() -> gooey::Result { ) .into_columns(); - username_field + let ui = username_field .and(password_field) .and(buttons) .into_rows() @@ -77,6 +82,7 @@ fn main() -> gooey::Result { .width(Lp::inches(3)..Lp::inches(6)) .pad() .scroll() - .centered() - .run() + .centered(); + + ui.and(tooltips).into_layers().run() } diff --git a/src/widget.rs b/src/widget.rs index 5aa0b08..638234d 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -39,6 +39,7 @@ use crate::tree::Tree; use crate::utils::IgnorePoison; use crate::value::{Dynamic, IntoDynamic, IntoValue, Validation, Value}; use crate::widgets::checkbox::{Checkable, CheckboxState}; +use crate::widgets::layers::{OverlayLayer, Tooltipped}; use crate::widgets::{ Align, Button, Checkbox, Collapse, Container, Expand, Layers, Resize, Scroll, Space, Stack, Style, Themed, ThemedMode, Validated, @@ -1077,6 +1078,11 @@ pub trait MakeWidget: Sized { fn validation(self, validation: impl IntoDynamic) -> Validated { Validated::new(validation, self) } + + /// Returns a widget that shows `tip` on `layer` when `self` is hovered. + fn tooltip(self, layer: &OverlayLayer, tip: impl MakeWidget) -> Tooltipped { + layer.new_tooltip(tip, self) + } } /// A type that can create a [`WidgetInstance`] with a preallocated diff --git a/src/widgets/container.rs b/src/widgets/container.rs index 33eb3ec..edaf71d 100644 --- a/src/widgets/container.rs +++ b/src/widgets/container.rs @@ -1,5 +1,7 @@ //! A visual container widget. +use std::ops::Div; + use kludgine::figures::units::{Lp, Px, UPx}; use kludgine::figures::{ Abs, Angle, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, Zero, @@ -719,6 +721,18 @@ impl ContainerShadow { } } + /// Returns a drop shadow placed `distance` below with a combined + /// blur/spread radius of `blur`. + pub fn drop(distance: Unit, blur: Unit) -> Self + where + Unit: Zero + Div + Default + Copy, + { + let half_blur = blur / 2; + Self::new(Point::new(Unit::ZERO, distance)) + .blur_radius(half_blur) + .spread(half_blur) + } + /// Sets the shadow color and returns self. #[must_use] pub fn color(mut self, color: Color) -> Self { diff --git a/src/widgets/layers.rs b/src/widgets/layers.rs index 7468a2c..3a87cd8 100644 --- a/src/widgets/layers.rs +++ b/src/widgets/layers.rs @@ -1,21 +1,24 @@ //! Widgets that stack in the Z-direction. -use std::fmt; +use std::fmt::{self, Debug}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use alot::{LotId, OrderedLots}; use gooey::widget::{RootBehavior, WidgetInstance}; use intentional::Assert; -use kludgine::figures::units::{Px, UPx}; +use kludgine::figures::units::{Lp, Px, UPx}; use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, Size, Zero}; use crate::animation::easings::EaseOutQuadradic; -use crate::animation::{AnimationTarget, Spawn, ZeroToOne}; +use crate::animation::{AnimationHandle, AnimationTarget, IntoAnimate, Spawn, ZeroToOne}; use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext}; +use crate::utils::IgnorePoison; use crate::value::{Dynamic, DynamicGuard, Generation, IntoValue, Value}; use crate::widget::{ - Children, MakeWidget, ManagedWidget, OnceCallback, Widget, WidgetId, WidgetRef, + Callback, Children, MakeWidget, ManagedWidget, Widget, WidgetId, WidgetRef, WrapperWidget, }; +use crate::widgets::container::ContainerShadow; use crate::ConstraintLimit; /// A Z-direction stack of widgets. @@ -193,6 +196,20 @@ impl OverlayLayer { }, } } + + /// Returns a new wudget that shows a `tooltip` when `content` is hovered. + pub fn new_tooltip(&self, tooltip: impl MakeWidget, content: impl MakeWidget) -> Tooltipped { + Tooltipped { + child: WidgetRef::new(content), + data: TooltipData { + target_layer: self.clone(), + tooltip: tooltip.make_widget(), + direction: Direction::Down, + shown_tooltip: Dynamic::default(), + }, + show_animation: None, + } + } } impl Widget for OverlayLayer { @@ -562,6 +579,7 @@ impl OverlayState { } /// A builder for overlaying a widget on an [`OverlayLayer`]. +#[derive(Debug, Clone)] pub struct OverlayBuilder<'a> { overlay: &'a OverlayLayer, layout: OverlayLayout, @@ -578,40 +596,40 @@ impl OverlayBuilder<'_> { /// Show this overlay to the left of the specified widget. #[must_use] - pub fn left_of(mut self, id: WidgetId) -> Self { - self.layout.relative_to = Some(id); - self.layout.direction = Direction::Left; - self + pub fn left_of(self, id: WidgetId) -> Self { + self.near(id, Direction::Left) } /// Show this overlay to the right of the specified widget. #[must_use] - pub fn right_of(mut self, id: WidgetId) -> Self { - self.layout.relative_to = Some(id); - self.layout.direction = Direction::Right; - self + pub fn right_of(self, id: WidgetId) -> Self { + self.near(id, Direction::Right) } /// Show this overlay to show below the specified widget. #[must_use] - pub fn below(mut self, id: WidgetId) -> Self { - self.layout.relative_to = Some(id); - self.layout.direction = Direction::Down; - self + pub fn below(self, id: WidgetId) -> Self { + self.near(id, Direction::Down) } /// Show this overlay to show above the specified widget. #[must_use] - pub fn above(mut self, id: WidgetId) -> Self { + pub fn above(self, id: WidgetId) -> Self { + self.near(id, Direction::Up) + } + + /// Shows this overlay near `id` off to the `direction` side. + #[must_use] + pub fn near(mut self, id: WidgetId, direction: Direction) -> Self { self.layout.relative_to = Some(id); - self.layout.direction = Direction::Up; + self.layout.direction = direction; self } /// Sets `callback` to be invoked once this overlay is dismissed. #[must_use] - pub fn on_dismiss(mut self, callback: OnceCallback) -> Self { - self.layout.on_dismiss = Some(callback); + pub fn on_dismiss(mut self, callback: Callback) -> Self { + self.layout.on_dismiss = Some(Arc::new(Mutex::new(callback))); self } @@ -639,7 +657,7 @@ impl OverlayBuilder<'_> { } } -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Clone)] struct OverlayLayout { widget: WidgetRef, opacity: Dynamic, @@ -647,17 +665,36 @@ struct OverlayLayout { direction: Direction, requires_hover: bool, layout: Option>, - on_dismiss: Option, + on_dismiss: Option>>, } impl Drop for OverlayLayout { fn drop(&mut self) { - if let Some(on_dismiss) = self.on_dismiss.take() { + if let Some(on_dismiss) = &self.on_dismiss { + let mut on_dismiss = on_dismiss.lock().ignore_poison(); on_dismiss.invoke(()); } } } +impl Eq for OverlayLayout {} + +impl PartialEq for OverlayLayout { + fn eq(&self, other: &Self) -> bool { + self.widget == other.widget + && self.opacity == other.opacity + && self.relative_to == other.relative_to + && self.direction == other.direction + && self.requires_hover == other.requires_hover + && self.layout == other.layout + && match (&self.on_dismiss, &other.on_dismiss) { + (Some(this), Some(other)) => Arc::ptr_eq(this, other), + (None, None) => true, + _ => false, + } + } +} + /// A relative direction. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum Direction { @@ -685,6 +722,7 @@ impl Direction { } /// A handle to an overlay that was shown in an [`OverlayLayer`]. +#[derive(PartialEq, Eq)] pub struct OverlayHandle { state: Dynamic, id: LotId, @@ -719,3 +757,74 @@ impl Drop for OverlayHandle { } } } + +impl Debug for OverlayHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OverlayHandle") + .field("id", &self.id) + .field("dismiss_on_drop", &self.dismiss_on_drop) + .finish_non_exhaustive() + } +} + +/// A widget that shows a tooltip when hovered. +#[derive(Debug)] +pub struct Tooltipped { + child: WidgetRef, + show_animation: Option, + data: TooltipData, +} + +#[derive(Debug, Clone)] +struct TooltipData { + target_layer: OverlayLayer, + tooltip: WidgetInstance, + direction: Direction, + shown_tooltip: Dynamic>, +} + +impl WrapperWidget for Tooltipped { + fn child_mut(&mut self) -> &mut WidgetRef { + &mut self.child + } + + fn hover( + &mut self, + _location: Point, + context: &mut EventContext<'_, '_>, + ) -> Option { + let background_color = context.theme().surface.highest_container; + + let data = self.data.clone(); + let my_id = self.child.widget().id(); + + self.show_animation = Some( + Duration::from_millis(500) + .on_complete(move || { + let mut shown_tooltip = data.shown_tooltip.lock(); + if shown_tooltip.is_none() { + *shown_tooltip = Some( + data.target_layer + .build_overlay( + data.tooltip + .clone() + .contain() + .background_color(background_color) + .shadow(ContainerShadow::drop(Lp::mm(1), Lp::mm(2))), + ) + .hide_on_unhover() + .near(my_id, data.direction) + .show(), + ); + } + }) + .spawn(), + ); + None + } + + fn unhover(&mut self, _context: &mut EventContext<'_, '_>) { + self.show_animation = None; + self.data.shown_tooltip.set(None); + } +}