From 5ec23c1243d128e096ea84bfef6d1846379a6886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ki=C3=ABd=20Llaentenn?= Date: Sun, 25 May 2025 18:30:27 -0400 Subject: [PATCH 1/6] Add `delay` suppport for `tooltip` widget --- examples/tooltip/src/main.rs | 24 +++++++- widget/src/tooltip.rs | 108 ++++++++++++++++++++++++++--------- 2 files changed, 102 insertions(+), 30 deletions(-) diff --git a/examples/tooltip/src/main.rs b/examples/tooltip/src/main.rs index bf927271..86c75eb6 100644 --- a/examples/tooltip/src/main.rs +++ b/examples/tooltip/src/main.rs @@ -1,6 +1,7 @@ use iced::Element; use iced::widget::tooltip::Position; -use iced::widget::{button, center, container, tooltip}; +use iced::widget::{button, center, checkbox, column, container, tooltip}; +use iced::{alignment, time::Duration}; pub fn main() -> iced::Result { iced::run(Tooltip::update, Tooltip::view) @@ -9,11 +10,13 @@ pub fn main() -> iced::Result { #[derive(Default)] struct Tooltip { position: Position, + is_immediate: bool, } #[derive(Debug, Clone)] enum Message { ChangePosition, + SetImmediate(bool), } impl Tooltip { @@ -30,10 +33,17 @@ impl Tooltip { self.position = position; } + + Message::SetImmediate(is_immediate) => { + self.is_immediate = is_immediate; + } } } fn view(&self) -> Element<'_, Message> { + let delay = + Duration::from_millis(if self.is_immediate { 0 } else { 2000 }); + let tooltip = tooltip( button("Press to change position") .on_press(Message::ChangePosition), @@ -41,9 +51,19 @@ impl Tooltip { self.position, ) .gap(10) + .delay(delay) .style(container::rounded_box); - center(tooltip).into() + let checkbox = checkbox(self.is_immediate) + .label("Show immediately") + .on_toggle(Message::SetImmediate); + + center( + column![tooltip, checkbox] + .align_x(alignment::Horizontal::Center) + .spacing(7), + ) + .into() } } diff --git a/widget/src/tooltip.rs b/widget/src/tooltip.rs index 8518b07c..631c8b6c 100644 --- a/widget/src/tooltip.rs +++ b/widget/src/tooltip.rs @@ -1,5 +1,8 @@ //! Tooltips display a hint of information over some element when hovered. //! +//! By default, the tooltip is only displayed after a short duration. (This +//! delay can be customized.) +//! //! # Example //! ```no_run //! # mod iced { pub mod widget { pub use iced_widget::*; } } @@ -27,6 +30,7 @@ use crate::core::mouse; use crate::core::overlay; use crate::core::renderer; use crate::core::text; +use crate::core::time::{Duration, Instant}; use crate::core::widget::{self, Widget}; use crate::core::window; use crate::core::{ @@ -34,6 +38,9 @@ use crate::core::{ Shell, Size, Vector, }; +// Default tooltip delay, 2 seconds. Chosen because it's the default on macOS. +const DEFAULT_TOOLTIP_DELAY: Duration = Duration::from_millis(2000); + /// An element to display a widget over another. /// /// # Example @@ -72,6 +79,7 @@ pub struct Tooltip< gap: f32, padding: f32, snap_within_viewport: bool, + delay: Duration, class: Theme::Class<'a>, } @@ -98,6 +106,7 @@ where gap: 0.0, padding: Self::DEFAULT_PADDING, snap_within_viewport: true, + delay: DEFAULT_TOOLTIP_DELAY, class: Theme::default(), } } @@ -114,6 +123,13 @@ where self } + /// Sets the delay before the [`Tooltip`] is shown. Set to 0 milliseconds + /// to be shown immediately. + pub fn delay(mut self, delay: Duration) -> Self { + self.delay = delay; + self + } + /// Sets whether the [`Tooltip`] is snapped within the viewport. pub fn snap_within_viewport(mut self, snap: bool) -> Self { self.snap_within_viewport = snap; @@ -206,23 +222,54 @@ where | Event::Window(window::Event::RedrawRequested(_)) = event { let state = tree.state.downcast_mut::(); - let previous_state = *state; - let was_idle = *state == State::Idle; + let now = Instant::now(); + let cursor_position = cursor.position_over(layout.bounds()); - *state = cursor - .position_over(layout.bounds()) - .map(|cursor_position| State::Hovered { cursor_position }) - .unwrap_or_default(); + match (&state, cursor_position) { + // Tooltip was idle, but is now hovered. + (State::Idle, Some(cursor_position)) => { + shell.invalidate_layout(); + shell.request_redraw_at(now + self.delay); + *state = State::Hovered { + cursor_position, + at: now, + }; + } - let is_idle = *state == State::Idle; + // Tooltip was active and isn't hovered anymore. + (State::Hovered { .. }, None) => { + shell.invalidate_layout(); + shell.request_redraw(); + *state = State::Idle; + } - if was_idle != is_idle { - shell.invalidate_layout(); - shell.request_redraw(); - } else if self.position == Position::FollowCursor - && *state != previous_state - { - shell.request_redraw(); + // Tooltip is active, but not for long enough. + (State::Hovered { at, .. }, Some(cursor_position)) + if at.elapsed() < self.delay => + { + let when = now + self.delay - at.elapsed(); + shell.request_redraw_at(when); + *state = State::Hovered { + at: *at, + cursor_position, + }; + } + + // Tooltip has been active long enough, and is following the cursor + // (thus requiring a redraw) + (State::Hovered { at, .. }, Some(cursor_position)) + if self.position == Position::FollowCursor => + { + shell.request_redraw(); + *state = State::Hovered { + at: *at, + cursor_position, + }; + } + + // No change in state. + (State::Hovered { .. }, Some(_)) => (), + (State::Idle, None) => (), } } @@ -296,21 +343,25 @@ where translation, ); - let tooltip = if let State::Hovered { cursor_position } = *state { - Some(overlay::Element::new(Box::new(Overlay { - position: layout.position() + translation, - tooltip: &mut self.tooltip, - tree: children.next().unwrap(), + let tooltip = match *state { + State::Hovered { cursor_position, - content_bounds: layout.bounds(), - snap_within_viewport: self.snap_within_viewport, - positioning: self.position, - gap: self.gap, - padding: self.padding, - class: &self.class, - }))) - } else { - None + at, + } if at.elapsed() > self.delay => { + Some(overlay::Element::new(Box::new(Overlay { + position: layout.position() + translation, + tooltip: &mut self.tooltip, + tree: children.next().unwrap(), + cursor_position, + content_bounds: layout.bounds(), + snap_within_viewport: self.snap_within_viewport, + positioning: self.position, + gap: self.gap, + padding: self.padding, + class: &self.class, + }))) + } + _ => None, }; if content.is_some() || tooltip.is_some() { @@ -362,6 +413,7 @@ enum State { Idle, Hovered { cursor_position: Point, + at: Instant, }, } From 77708f057430050046ab2b5ce0ee2eec344980f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 27 Nov 2025 04:12:07 +0100 Subject: [PATCH 2/6] Avoid constant redraws when `tooltip` follows cursor --- examples/tooltip/src/main.rs | 9 ++++---- widget/src/tooltip.rs | 42 +++++++++++++++++------------------- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/examples/tooltip/src/main.rs b/examples/tooltip/src/main.rs index 86c75eb6..dbe9380b 100644 --- a/examples/tooltip/src/main.rs +++ b/examples/tooltip/src/main.rs @@ -16,7 +16,7 @@ struct Tooltip { #[derive(Debug, Clone)] enum Message { ChangePosition, - SetImmediate(bool), + ToggleImmediate(bool), } impl Tooltip { @@ -33,8 +33,7 @@ impl Tooltip { self.position = position; } - - Message::SetImmediate(is_immediate) => { + Message::ToggleImmediate(is_immediate) => { self.is_immediate = is_immediate; } } @@ -56,12 +55,12 @@ impl Tooltip { let checkbox = checkbox(self.is_immediate) .label("Show immediately") - .on_toggle(Message::SetImmediate); + .on_toggle(Message::ToggleImmediate); center( column![tooltip, checkbox] .align_x(alignment::Horizontal::Center) - .spacing(7), + .spacing(10), ) .into() } diff --git a/widget/src/tooltip.rs b/widget/src/tooltip.rs index 631c8b6c..db335bbb 100644 --- a/widget/src/tooltip.rs +++ b/widget/src/tooltip.rs @@ -225,49 +225,47 @@ where let now = Instant::now(); let cursor_position = cursor.position_over(layout.bounds()); - match (&state, cursor_position) { - // Tooltip was idle, but is now hovered. + match (*state, cursor_position) { (State::Idle, Some(cursor_position)) => { - shell.invalidate_layout(); - shell.request_redraw_at(now + self.delay); *state = State::Hovered { cursor_position, at: now, }; - } - // Tooltip was active and isn't hovered anymore. - (State::Hovered { .. }, None) => { shell.invalidate_layout(); - shell.request_redraw(); - *state = State::Idle; + shell.request_redraw_at(now + self.delay); } + (State::Hovered { .. }, None) => { + *state = State::Idle; - // Tooltip is active, but not for long enough. + shell.invalidate_layout(); + } (State::Hovered { at, .. }, Some(cursor_position)) if at.elapsed() < self.delay => { - let when = now + self.delay - at.elapsed(); - shell.request_redraw_at(when); *state = State::Hovered { - at: *at, + at, cursor_position, }; - } - // Tooltip has been active long enough, and is following the cursor - // (thus requiring a redraw) - (State::Hovered { at, .. }, Some(cursor_position)) - if self.position == Position::FollowCursor => + shell.request_redraw_at(now + self.delay - at.elapsed()); + } + ( + State::Hovered { + at, + cursor_position: last_position, + }, + Some(cursor_position), + ) if self.position == Position::FollowCursor + && last_position != cursor_position => { - shell.request_redraw(); *state = State::Hovered { - at: *at, + at, cursor_position, }; - } - // No change in state. + shell.request_redraw(); + } (State::Hovered { .. }, Some(_)) => (), (State::Idle, None) => (), } From cbc311ed9e33db9eb65f3b91fe53eaca94b13d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 27 Nov 2025 04:13:51 +0100 Subject: [PATCH 3/6] Fine-tune `tooltip` documentation --- widget/src/tooltip.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/widget/src/tooltip.rs b/widget/src/tooltip.rs index db335bbb..b3873e36 100644 --- a/widget/src/tooltip.rs +++ b/widget/src/tooltip.rs @@ -1,7 +1,7 @@ //! Tooltips display a hint of information over some element when hovered. //! -//! By default, the tooltip is only displayed after a short duration. (This -//! delay can be customized.) +//! By default, the tooltip is only displayed when hovered for 2 seconds. +//! This delay can be adjusted with [`Tooltip::delay`]. //! //! # Example //! ```no_run @@ -123,8 +123,9 @@ where self } - /// Sets the delay before the [`Tooltip`] is shown. Set to 0 milliseconds - /// to be shown immediately. + /// Sets the delay before the [`Tooltip`] is shown. + /// + /// Set to [`Duration::ZERO`] to be shown immediately. pub fn delay(mut self, delay: Duration) -> Self { self.delay = delay; self From defd59584fc49ac91fb61d73e7b909859647b15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 27 Nov 2025 04:15:34 +0100 Subject: [PATCH 4/6] Use `seconds` helper in `tooltip` example --- examples/tooltip/src/main.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/tooltip/src/main.rs b/examples/tooltip/src/main.rs index dbe9380b..feef27f4 100644 --- a/examples/tooltip/src/main.rs +++ b/examples/tooltip/src/main.rs @@ -1,7 +1,8 @@ use iced::Element; +use iced::alignment; +use iced::time::seconds; use iced::widget::tooltip::Position; use iced::widget::{button, center, checkbox, column, container, tooltip}; -use iced::{alignment, time::Duration}; pub fn main() -> iced::Result { iced::run(Tooltip::update, Tooltip::view) @@ -40,9 +41,6 @@ impl Tooltip { } fn view(&self) -> Element<'_, Message> { - let delay = - Duration::from_millis(if self.is_immediate { 0 } else { 2000 }); - let tooltip = tooltip( button("Press to change position") .on_press(Message::ChangePosition), @@ -50,7 +48,7 @@ impl Tooltip { self.position, ) .gap(10) - .delay(delay) + .delay(seconds(if self.is_immediate { 0 } else { 2 })) .style(container::rounded_box); let checkbox = checkbox(self.is_immediate) From 63a95942ba9d11469813c0e78882cd91cdc3f66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 27 Nov 2025 04:17:10 +0100 Subject: [PATCH 5/6] Move delay constant to `Tooltip` struct --- widget/src/tooltip.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/widget/src/tooltip.rs b/widget/src/tooltip.rs index b3873e36..1502b94a 100644 --- a/widget/src/tooltip.rs +++ b/widget/src/tooltip.rs @@ -38,9 +38,6 @@ use crate::core::{ Shell, Size, Vector, }; -// Default tooltip delay, 2 seconds. Chosen because it's the default on macOS. -const DEFAULT_TOOLTIP_DELAY: Duration = Duration::from_millis(2000); - /// An element to display a widget over another. /// /// # Example @@ -91,6 +88,9 @@ where /// The default padding of a [`Tooltip`] drawn by this renderer. const DEFAULT_PADDING: f32 = 5.0; + /// The default delay before a [`Tooltip`] is shown. + const DEFAULT_DELAY: Duration = Duration::from_secs(2); + /// Creates a new [`Tooltip`]. /// /// [`Tooltip`]: struct.Tooltip.html @@ -106,7 +106,7 @@ where gap: 0.0, padding: Self::DEFAULT_PADDING, snap_within_viewport: true, - delay: DEFAULT_TOOLTIP_DELAY, + delay: Self::DEFAULT_DELAY, class: Theme::default(), } } From f493065376f74228edbe97143cef075f915d98f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Thu, 27 Nov 2025 04:29:24 +0100 Subject: [PATCH 6/6] Add explicit `Open` state to `tooltip` widget This allows us to properly invalidate layout. --- widget/src/tooltip.rs | 78 +++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/widget/src/tooltip.rs b/widget/src/tooltip.rs index 1502b94a..a5207ed1 100644 --- a/widget/src/tooltip.rs +++ b/widget/src/tooltip.rs @@ -228,47 +228,41 @@ where match (*state, cursor_position) { (State::Idle, Some(cursor_position)) => { - *state = State::Hovered { - cursor_position, - at: now, - }; + if self.delay == Duration::ZERO { + *state = State::Open { cursor_position }; + shell.invalidate_layout(); + } else { + *state = State::Hovered { at: now }; + } - shell.invalidate_layout(); shell.request_redraw_at(now + self.delay); } (State::Hovered { .. }, None) => { *state = State::Idle; - - shell.invalidate_layout(); } - (State::Hovered { at, .. }, Some(cursor_position)) - if at.elapsed() < self.delay => - { - *state = State::Hovered { - at, - cursor_position, - }; - + (State::Hovered { at, .. }, _) if at.elapsed() < self.delay => { shell.request_redraw_at(now + self.delay - at.elapsed()); } + (State::Hovered { .. }, Some(cursor_position)) => { + *state = State::Open { cursor_position }; + shell.invalidate_layout(); + } ( - State::Hovered { - at, + State::Open { cursor_position: last_position, }, Some(cursor_position), ) if self.position == Position::FollowCursor && last_position != cursor_position => { - *state = State::Hovered { - at, - cursor_position, - }; - + *state = State::Open { cursor_position }; shell.request_redraw(); } - (State::Hovered { .. }, Some(_)) => (), - (State::Idle, None) => (), + (State::Open { .. }, None) => { + *state = State::Idle; + shell.invalidate_layout(); + } + (State::Open { .. }, Some(_)) | (State::Idle, None) => (), } } @@ -342,25 +336,21 @@ where translation, ); - let tooltip = match *state { - State::Hovered { + let tooltip = if let State::Open { cursor_position } = *state { + Some(overlay::Element::new(Box::new(Overlay { + position: layout.position() + translation, + tooltip: &mut self.tooltip, + tree: children.next().unwrap(), cursor_position, - at, - } if at.elapsed() > self.delay => { - Some(overlay::Element::new(Box::new(Overlay { - position: layout.position() + translation, - tooltip: &mut self.tooltip, - tree: children.next().unwrap(), - cursor_position, - content_bounds: layout.bounds(), - snap_within_viewport: self.snap_within_viewport, - positioning: self.position, - gap: self.gap, - padding: self.padding, - class: &self.class, - }))) - } - _ => None, + content_bounds: layout.bounds(), + snap_within_viewport: self.snap_within_viewport, + positioning: self.position, + gap: self.gap, + padding: self.padding, + class: &self.class, + }))) + } else { + None }; if content.is_some() || tooltip.is_some() { @@ -411,9 +401,11 @@ enum State { #[default] Idle, Hovered { - cursor_position: Point, at: Instant, }, + Open { + cursor_position: Point, + }, } struct Overlay<'a, 'b, Message, Theme, Renderer>