Skip to content

Add widget timers to Masonry and use them for delayed UI behavior. - #1779

Draft
waywardmonkeys wants to merge 5 commits into
linebender:mainfrom
waywardmonkeys:integrate-understory-timing
Draft

Add widget timers to Masonry and use them for delayed UI behavior.#1779
waywardmonkeys wants to merge 5 commits into
linebender:mainfrom
waywardmonkeys:integrate-understory-timing

Conversation

@waywardmonkeys

Copy link
Copy Markdown
Contributor
  • Add understory_timing as a masonry_core dependency.
  • Store pending widget timers in RenderRoot.
  • Add TimerToken and deliver expired timers through Update::Timer.
  • Add context APIs:
    • request_timer(delay) -> TimerToken
    • cancel_timer(token)
  • Add backend hooks for timer driving:
    • RenderRoot::set_timer_time
    • RenderRoot::next_timer_deadline
    • RenderRoot::handle_timers
  • Update masonry_winit to drive timer time and platform wakeups.
  • Convert TextArea cursor blinking to one-shot timers.
  • Convert the layers tooltip-delay example to one-shot timers.
  • Document when to use timers versus animation frames.

Rationale

Masonry has several UI behaviors that are delayed but not frame-cadenced. Examples include cursor blinking, tooltip
delays, debounce, and long press recognition. These fit a timer model better than animation frames.

This PR adds a widget-local timer lifecycle: widgets request a timer, keep the returned TimerToken, and receive
Update::Timer when it expires. Widgets can cancel pending timers when the relevant state changes.

Animation frames remain the right tool for continuous visual updates, such as interpolation or motion that should advance with frame cadence.

@waywardmonkeys
waywardmonkeys marked this pull request as draft April 30, 2026 05:49
@waywardmonkeys

Copy link
Copy Markdown
Contributor Author

This was done with LLM assistance to avoid typing out the boring stuff.

Draft since I will publish understory_timing soon rather than a git dep.

@waywardmonkeys
waywardmonkeys force-pushed the integrate-understory-timing branch from 8e792bf to cfeb24c Compare April 30, 2026 06:12

@xStrom xStrom left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timers are such a great feature to have, thanks for moving this forward.

The TextInput cursor is also much less buggy with this implementation compared to the old one. For example, losing focus when the cursor is not shown doesn't cause it disappear for good anymore. 😅

Comment on lines -443 to -444
// TODO: These should be reading from the system settings, but we currently
// aren't aware of a robust way to read that cross-platform.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's preserve this comment near the top area where these consts now live.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored.

Comment thread masonry/src/widgets/text_area.rs Outdated
Comment on lines +287 to +288
let should_show = self.anim_elapsed >= CURSOR_BLINK_TIMEOUT
|| self.anim_prev_interval < CURSOR_BLINK_INTERVAL;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The self.anim_prev_interval < CURSOR_BLINK_INTERVAL check seems needlessly misleading, the only scenario that satisfies this is self.anim_prev_interval == 0.

Comment thread masonry/examples/layers.rs Outdated
Comment on lines +80 to +83
if let Some(timer) = self.hover_timer.take() {
ctx.cancel_timer(timer);
}
self.hover_timer = Some(ctx.request_timer(Duration::from_millis(300)));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this replicated the old logic of showing the layer after the mouse stopped moving for 300ms, but I think this is a good opportunity to improve things.

Canceling and requesting a new timer for every mouse move position is kind of wasteful, including understory_timing always looping over the whole set of timers for the cancel. Also, usually tooltips are shown after hovering for a certain amount of time, not after stopping movement. This is certainly how Windows tooltips work.

Thus I think we should update the code. Perhaps just having Update::HoveredChanged(true) request the timer and HoveredChanged(false) cancel it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps just having Update::HoveredChanged(true) request the timer and HoveredChanged(false) cancel it.

I'm all for finding a better solution, but I'll note that this does not match how tooltips work in most frameworks.

@PoignardAzur PoignardAzur left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timers are long-overdue in Masonry. Glad to finally have an implementation!

I have doubts about the API, but the internals seem sound.

Comment thread masonry/src/tests/update.rs Outdated
Comment thread masonry/src/tests/update.rs Outdated
Comment thread masonry_core/src/app/render_root.rs Outdated
Comment on lines +983 to +985
pub fn set_timer_time(&mut self, time: Duration) {
self.global_state.timer_now = duration_to_timer_ticks(time);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a benefit to splitting this from handle_timers? As far as I can tell, all call sites call them both at once.

This seems like a surprising pattern, we don't do this anywhere else (we don't call one method to set the mouse position and another to trigger all mouse events).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote up a whole thing ... and thought it was still complicated ... so going to revisit this code soon even further. I think there's a better / simpler solution (on the surface, it might need some other changes internally, we'll see).

Comment on lines +987 to +988
/// Returns the next timer deadline, measured from the host's timer origin.
pub fn next_timer_deadline(&self) -> Option<Duration> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So as far as I can tell, the reason to use Duration instead of Instant is that Instant can't be mocked in tests? If so, this should probably be documented in either a doc comment or a code comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was the reason and there's a comment there. But I may revisit this in a moment due to some other simplifications.

Comment thread masonry_core/src/app/render_root.rs Outdated
fired.push((timer.into_target(), token));
}

let mut delivered = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think keeping track of delivered is very useful. It's used to short-circuit rewrite passes in RenderRoot and signal handling in EventLoop, but both of those already have short-circuits built in. I'd recommend dropping it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, I think.

Comment thread masonry_core/src/core/events.rs Outdated
Comment thread masonry_core/src/passes/update.rs
Comment thread masonry_testing/src/harness.rs Outdated
Comment on lines +859 to +862
/// Sets the simulated timer time.
pub fn set_timer_time(&mut self, time: Duration) {
self.render_root.set_timer_time(time);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, I think this should be merged with handle_timers.

In fact, I'm thinking maybe this should be merged with the animate_ms method above into a move_time_forward method, because a situation where animations progress but timers don't is unrealistic.

@waywardmonkeys

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback. I'm heading out for the evening shortly but will respond with updates within the next day or two.

I didn't know about assert_all!

Add `understory_timing` as a `masonry_core` dependency and keep the timer queue inside `RenderRoot` so widget timer requests synchronously return the queue-assigned identity.

Expose timer time/deadline/drain hooks for backends, and update `masonry_winit` to own only the host clock origin and platform wakeup scheduling. Add focused tests for targeted timer delivery and cancellation.
Replace `TextArea`'s animation-frame polling for caret blinking with one-shot widget timers. Focus and text activity reset the blink state and schedule a half-cycle timer; timer updates toggle the caret and reschedule while the text area remains focused.

Update the text input cursor blink snapshot test to advance the simulated timer clock instead of sending an animation frame.
Replace the layers example's animation-frame polling loop with a one-shot hover timer. Pointer movement cancels and reschedules the delay, hover exit cancels it, and the timer update creates the tooltip layer once the cursor has settled.
Clarify that `request_anim_frame` and `Widget::on_anim_frame` are for frame-cadenced visual updates, while delayed one-shot UI behavior should use widget timers.

Update the pass-system and widget-implementation docs to describe timer delivery through `Update::Timer`, the non-bubbling delivery model, and the usual `Option<TimerToken>` request/cancel pattern.
@waywardmonkeys
waywardmonkeys force-pushed the integrate-understory-timing branch from cfeb24c to e55ee2d Compare May 17, 2026 16:04
@waywardmonkeys

Copy link
Copy Markdown
Contributor Author

This is now using the published version 0.1.2 of understory_timing.

Comment on lines +30 to 31
use winit::event::StartCause;
use winit::event::{DeviceEvent as WinitDeviceEvent, DeviceId, WindowEvent as WinitWindowEvent};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
use winit::event::StartCause;
use winit::event::{DeviceEvent as WinitDeviceEvent, DeviceId, WindowEvent as WinitWindowEvent};
use winit::event::{
DeviceEvent as WinitDeviceEvent, DeviceId, StartCause, WindowEvent as WinitWindowEvent,
};

@PoignardAzur PoignardAzur left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I have nitpicks about the API, but I'm fine merging now and fixing them later.

LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants