slt/widgets/feedback.rs
1/// State for the rich log viewer widget.
2#[derive(Debug, Clone)]
3pub struct RichLogState {
4 /// Log entries to display, ordered from oldest to newest.
5 entries: std::collections::VecDeque<RichLogEntry>,
6 /// Scroll offset (0 = top).
7 pub(crate) scroll_offset: usize,
8 /// Whether to auto-scroll to bottom when new entries are added.
9 pub auto_scroll: bool,
10 /// Maximum number of entries to keep (None = unlimited).
11 pub max_entries: Option<usize>,
12}
13
14/// A single entry in a RichLog.
15#[derive(Debug, Clone)]
16pub struct RichLogEntry {
17 /// Styled text segments for this entry.
18 pub segments: Vec<(String, Style)>,
19}
20
21impl RichLogState {
22 /// Default maximum entry cap used by [`RichLogState::new`].
23 ///
24 /// Long-running apps that push log entries continuously would otherwise
25 /// accumulate state without bound. Use [`RichLogState::new_unbounded`] to
26 /// opt out explicitly.
27 pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
28
29 /// Create an empty rich log state with the default entry cap
30 /// ([`Self::DEFAULT_MAX_ENTRIES`]).
31 pub fn new() -> Self {
32 Self {
33 max_entries: Some(Self::DEFAULT_MAX_ENTRIES),
34 ..Self::new_unbounded()
35 }
36 }
37
38 /// Create an empty rich log state without an entry cap.
39 ///
40 /// Prefer [`RichLogState::new`] in long-running apps. Use this constructor
41 /// only when the host explicitly bounds growth elsewhere.
42 pub fn new_unbounded() -> Self {
43 Self {
44 entries: std::collections::VecDeque::new(),
45 scroll_offset: 0,
46 auto_scroll: true,
47 max_entries: None,
48 }
49 }
50
51 /// Add a single-style entry to the log.
52 pub fn push(&mut self, text: impl Into<String>, style: Style) {
53 self.push_segments(vec![(text.into(), style)]);
54 }
55
56 /// Add a plain text entry using default style.
57 pub fn push_plain(&mut self, text: impl Into<String>) {
58 self.push(text, Style::new());
59 }
60
61 /// Add a multi-segment styled entry to the log.
62 pub fn push_segments(&mut self, segments: Vec<(String, Style)>) {
63 self.push_entry(RichLogEntry { segments });
64 }
65
66 /// Add a pre-built entry to the log and enforce the configured cap.
67 ///
68 /// This replaces direct `entries.push(...)` access from earlier releases.
69 pub fn push_entry(&mut self, entry: RichLogEntry) {
70 self.entries.push_back(entry);
71
72 if let Some(max_entries) = self.max_entries
73 && self.entries.len() > max_entries
74 {
75 let remove_count = self.entries.len() - max_entries;
76 for _ in 0..remove_count {
77 let _ = self.entries.pop_front();
78 }
79 self.scroll_offset = self.scroll_offset.saturating_sub(remove_count);
80 }
81
82 if self.auto_scroll {
83 self.scroll_offset = usize::MAX;
84 }
85 }
86
87 /// Clear all entries and reset scroll position.
88 pub fn clear(&mut self) {
89 self.entries.clear();
90 self.scroll_offset = 0;
91 }
92
93 /// Return number of entries in the log.
94 pub fn len(&self) -> usize {
95 self.entries.len()
96 }
97
98 /// Return true when no entries are present.
99 pub fn is_empty(&self) -> bool {
100 self.entries.is_empty()
101 }
102
103 /// Iterate over entries from oldest to newest.
104 ///
105 /// This replaces `entries.iter()` from earlier releases. Use [`entry`](Self::entry)
106 /// for indexed access.
107 pub fn entries(
108 &self,
109 ) -> impl DoubleEndedIterator<Item = &RichLogEntry> + ExactSizeIterator + '_ {
110 self.entries.iter()
111 }
112
113 /// Mutably iterate over entries from oldest to newest.
114 ///
115 /// Entry contents may be changed, but insertion and removal remain behind
116 /// [`push_entry`](Self::push_entry) and [`clear`](Self::clear) so retention
117 /// invariants cannot be bypassed.
118 pub fn entries_mut(
119 &mut self,
120 ) -> impl DoubleEndedIterator<Item = &mut RichLogEntry> + ExactSizeIterator + '_ {
121 self.entries.iter_mut()
122 }
123
124 /// Return an entry by its zero-based position in oldest-to-newest order.
125 ///
126 /// This replaces `entries[index]` from earlier releases without exposing
127 /// the retention storage type.
128 pub fn entry(&self, index: usize) -> Option<&RichLogEntry> {
129 self.entries.get(index)
130 }
131
132 /// Return a mutable entry by its zero-based position in retention order.
133 pub fn entry_mut(&mut self, index: usize) -> Option<&mut RichLogEntry> {
134 self.entries.get_mut(index)
135 }
136}
137
138impl Default for RichLogState {
139 fn default() -> Self {
140 Self::new()
141 }
142}
143
144#[cfg(test)]
145mod rich_log_tests {
146 use super::*;
147
148 fn texts(state: &RichLogState) -> Vec<&str> {
149 state
150 .entries()
151 .map(|entry| entry.segments[0].0.as_str())
152 .collect()
153 }
154
155 #[test]
156 fn cap_zero_discards_every_push() {
157 let mut state = RichLogState::new();
158 state.auto_scroll = false;
159 state.max_entries = Some(0);
160
161 state.push_plain("discarded");
162
163 assert!(state.is_empty());
164 assert_eq!(state.scroll_offset, 0);
165 }
166
167 #[test]
168 fn cap_one_retains_only_the_newest_entry() {
169 let mut state = RichLogState::new();
170 state.max_entries = Some(1);
171
172 state.push_plain("first");
173 state.push_plain("second");
174
175 assert_eq!(texts(&state), ["second"]);
176 }
177
178 #[test]
179 fn default_cap_retains_order_without_front_shifts() {
180 let mut state = RichLogState::new();
181 for index in 0..=RichLogState::DEFAULT_MAX_ENTRIES {
182 state.push_plain(index.to_string());
183 }
184
185 assert_eq!(state.len(), RichLogState::DEFAULT_MAX_ENTRIES);
186 assert_eq!(state.entry(0).unwrap().segments[0].0, "1");
187 assert_eq!(
188 state.entry(state.len() - 1).unwrap().segments[0].0,
189 RichLogState::DEFAULT_MAX_ENTRIES.to_string()
190 );
191 }
192
193 #[test]
194 fn unbounded_state_retains_more_than_the_default_cap() {
195 let mut state = RichLogState::new_unbounded();
196 for index in 0..=RichLogState::DEFAULT_MAX_ENTRIES {
197 state.push_plain(index.to_string());
198 }
199
200 assert_eq!(state.len(), RichLogState::DEFAULT_MAX_ENTRIES + 1);
201 assert_eq!(state.entry(0).unwrap().segments[0].0, "0");
202 }
203
204 #[test]
205 fn lowering_cap_evicts_in_bulk_on_the_next_push_and_adjusts_scroll() {
206 let mut state = RichLogState::new_unbounded();
207 state.auto_scroll = false;
208 for index in 0..5 {
209 state.push_plain(index.to_string());
210 }
211 state.scroll_offset = 4;
212 state.max_entries = Some(2);
213
214 state.push_plain("5");
215
216 assert_eq!(texts(&state), ["4", "5"]);
217 assert_eq!(state.scroll_offset, 0);
218 }
219
220 #[test]
221 fn raising_and_disabling_cap_preserves_existing_order() {
222 let mut state = RichLogState::new();
223 state.max_entries = Some(1);
224 state.push_plain("first");
225 state.max_entries = Some(3);
226 state.push_plain("second");
227 state.push_plain("third");
228 state.max_entries = None;
229 state.push_plain("fourth");
230
231 assert_eq!(texts(&state), ["first", "second", "third", "fourth"]);
232 }
233
234 #[test]
235 fn entry_mutation_and_clear_preserve_state_contract() {
236 let mut state = RichLogState::new();
237 state.push_plain("before");
238 state.entry_mut(0).unwrap().segments[0].0 = "after".to_owned();
239
240 assert_eq!(texts(&state), ["after"]);
241
242 state.clear();
243 assert!(state.is_empty());
244 assert_eq!(state.scroll_offset, 0);
245 }
246}
247
248/// An absolute calendar date `(year, month 1–12, day 1–31)`.
249///
250/// Used by [`CalendarState`] to represent range endpoints that can span
251/// month and year boundaries (a `selected_day` alone is scoped to the
252/// currently displayed month). Available since `0.21.0`.
253///
254/// # Example
255///
256/// ```no_run
257/// use slt::CalDate;
258///
259/// let d = CalDate { year: 2024, month: 12, day: 31 };
260/// assert_eq!((d.year, d.month, d.day), (2024, 12, 31));
261/// ```
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub struct CalDate {
264 /// Calendar year.
265 pub year: i32,
266 /// Month of year, `1`–`12`.
267 pub month: u32,
268 /// Day of month, `1`–`31`.
269 pub day: u32,
270}
271
272impl CalDate {
273 /// Sort key ordering this date against another by year, then month, then day.
274 fn key(&self) -> (i32, u32, u32) {
275 (self.year, self.month, self.day)
276 }
277}
278
279/// Selection behavior for [`CalendarState`].
280///
281/// Defaults to [`Single`](CalendarSelect::Single), preserving the original
282/// single-date pick. Switch to [`Range`](CalendarSelect::Range) via
283/// [`CalendarState::with_range`] for start/end range selection. Available
284/// since `0.21.0`.
285///
286/// # Example
287///
288/// ```no_run
289/// use slt::{CalendarSelect, CalendarState};
290///
291/// let mut cal = CalendarState::from_ym(2024, 3);
292/// assert_eq!(cal.mode(), CalendarSelect::Single);
293/// cal.with_range();
294/// assert_eq!(cal.mode(), CalendarSelect::Range);
295/// ```
296#[non_exhaustive]
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
298pub enum CalendarSelect {
299 /// Pick exactly one date (default).
300 #[default]
301 Single,
302 /// Pick a start/end date range via Shift-extend.
303 Range,
304}
305
306/// State for the calendar date picker widget.
307#[derive(Debug, Clone)]
308pub struct CalendarState {
309 /// Current display year.
310 pub year: i32,
311 /// Current display month (1–12).
312 pub month: u32,
313 /// Currently selected day, if any (single-date mode).
314 pub selected_day: Option<u32>,
315 pub(crate) cursor_day: u32,
316 pub(crate) mode: CalendarSelect,
317 pub(crate) anchor: Option<CalDate>,
318 pub(crate) extent: Option<CalDate>,
319 pub(crate) time_enabled: bool,
320 pub(crate) hour: u8,
321 pub(crate) minute: u8,
322}
323
324impl CalendarState {
325 /// Create a new `CalendarState` initialized to the current month.
326 pub fn new() -> Self {
327 let (year, month) = Self::current_year_month();
328 Self::from_ym(year, month)
329 }
330
331 /// Create a `CalendarState` for a specific year and month.
332 pub fn from_ym(year: i32, month: u32) -> Self {
333 let month = month.clamp(1, 12);
334 Self {
335 year,
336 month,
337 selected_day: None,
338 cursor_day: 1,
339 mode: CalendarSelect::Single,
340 anchor: None,
341 extent: None,
342 time_enabled: false,
343 hour: 0,
344 minute: 0,
345 }
346 }
347
348 /// Enable date-range selection (start/end via Shift-extend).
349 ///
350 /// Single-date mode remains the default; call this to opt in. Returns
351 /// `&mut Self` for chaining. Available since `0.21.0`.
352 ///
353 /// # Example
354 ///
355 /// ```no_run
356 /// use slt::CalendarState;
357 ///
358 /// let mut cal = CalendarState::from_ym(2024, 3);
359 /// cal.with_range();
360 /// ```
361 pub fn with_range(&mut self) -> &mut Self {
362 self.mode = CalendarSelect::Range;
363 self
364 }
365
366 /// Enable hour/minute selection, rendered as `HH:MM` below the grid.
367 ///
368 /// Off by default — no time row is rendered unless enabled. Returns
369 /// `&mut Self` for chaining. Available since `0.21.0`.
370 ///
371 /// # Example
372 ///
373 /// ```no_run
374 /// use slt::CalendarState;
375 ///
376 /// let mut cal = CalendarState::from_ym(2024, 3);
377 /// cal.with_time();
378 /// ```
379 pub fn with_time(&mut self) -> &mut Self {
380 self.time_enabled = true;
381 self
382 }
383
384 /// The active selection mode (`Single` by default).
385 ///
386 /// Available since `0.21.0`.
387 ///
388 /// # Example
389 ///
390 /// ```no_run
391 /// use slt::{CalendarSelect, CalendarState};
392 ///
393 /// let cal = CalendarState::from_ym(2024, 3);
394 /// assert_eq!(cal.mode(), CalendarSelect::Single);
395 /// ```
396 pub fn mode(&self) -> CalendarSelect {
397 self.mode
398 }
399
400 /// Returns the selected date as `(year, month, day)`, if any.
401 pub fn selected_date(&self) -> Option<(i32, u32, u32)> {
402 self.selected_day.map(|day| (self.year, self.month, day))
403 }
404
405 /// The normalized selected range as `(start, end)` with `start <= end`.
406 ///
407 /// Returns `None` in single-date mode or until an anchor has been set in
408 /// range mode. Endpoints are absolute [`CalDate`]s, so a range may span
409 /// month or year boundaries. Available since `0.21.0`.
410 ///
411 /// # Example
412 ///
413 /// ```no_run
414 /// use slt::CalendarState;
415 ///
416 /// let mut cal = CalendarState::from_ym(2024, 3);
417 /// cal.with_range();
418 /// assert!(cal.selected_range().is_none());
419 /// ```
420 pub fn selected_range(&self) -> Option<(CalDate, CalDate)> {
421 if self.mode != CalendarSelect::Range {
422 return None;
423 }
424 let anchor = self.anchor?;
425 let extent = self.extent.unwrap_or(anchor);
426 if anchor.key() <= extent.key() {
427 Some((anchor, extent))
428 } else {
429 Some((extent, anchor))
430 }
431 }
432
433 /// The selected `(hour, minute)` when time is enabled, else `None`.
434 ///
435 /// Available since `0.21.0`.
436 ///
437 /// # Example
438 ///
439 /// ```no_run
440 /// use slt::CalendarState;
441 ///
442 /// let mut cal = CalendarState::from_ym(2024, 3);
443 /// assert!(cal.selected_time().is_none());
444 /// cal.with_time();
445 /// assert_eq!(cal.selected_time(), Some((0, 0)));
446 /// ```
447 pub fn selected_time(&self) -> Option<(u8, u8)> {
448 self.time_enabled.then_some((self.hour, self.minute))
449 }
450
451 /// The cursor day as an absolute [`CalDate`] in the displayed month.
452 pub(crate) fn cursor_date(&self) -> CalDate {
453 CalDate {
454 year: self.year,
455 month: self.month,
456 day: self.cursor_day,
457 }
458 }
459
460 /// Set the range anchor to the cursor, clearing any prior extent.
461 pub(crate) fn set_anchor_to_cursor(&mut self) {
462 let cur = self.cursor_date();
463 self.anchor = Some(cur);
464 self.extent = None;
465 }
466
467 /// Set the range extent endpoint to the cursor.
468 ///
469 /// If no anchor exists yet, the cursor becomes the anchor.
470 pub(crate) fn extend_to_cursor(&mut self) {
471 let cur = self.cursor_date();
472 if self.anchor.is_none() {
473 self.anchor = Some(cur);
474 }
475 self.extent = Some(cur);
476 }
477
478 /// Whether the given absolute date falls inside the selected range
479 /// (inclusive of both endpoints).
480 pub(crate) fn in_range(&self, d: CalDate) -> bool {
481 match self.selected_range() {
482 Some((start, end)) => start.key() <= d.key() && d.key() <= end.key(),
483 None => false,
484 }
485 }
486
487 /// Whether the given absolute date is one of the range endpoints.
488 pub(crate) fn is_range_endpoint(&self, d: CalDate) -> bool {
489 match self.selected_range() {
490 Some((start, end)) => d == start || d == end,
491 None => false,
492 }
493 }
494
495 /// Navigate to the previous month.
496 pub fn prev_month(&mut self) {
497 if self.month == 1 {
498 self.month = 12;
499 self.year -= 1;
500 } else {
501 self.month -= 1;
502 }
503 self.clamp_days();
504 }
505
506 /// Navigate to the next month.
507 pub fn next_month(&mut self) {
508 if self.month == 12 {
509 self.month = 1;
510 self.year += 1;
511 } else {
512 self.month += 1;
513 }
514 self.clamp_days();
515 }
516
517 pub(crate) fn days_in_month(year: i32, month: u32) -> u32 {
518 match month {
519 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
520 4 | 6 | 9 | 11 => 30,
521 2 => {
522 if Self::is_leap_year(year) {
523 29
524 } else {
525 28
526 }
527 }
528 _ => 30,
529 }
530 }
531
532 pub(crate) fn first_weekday(year: i32, month: u32) -> u32 {
533 let month = month.clamp(1, 12);
534 let offsets = [0_i32, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
535 let mut y = year;
536 if month < 3 {
537 y -= 1;
538 }
539 let sunday_based = (y + y / 4 - y / 100 + y / 400 + offsets[(month - 1) as usize] + 1) % 7;
540 ((sunday_based + 6) % 7) as u32
541 }
542
543 fn clamp_days(&mut self) {
544 let max_day = Self::days_in_month(self.year, self.month);
545 self.cursor_day = self.cursor_day.clamp(1, max_day);
546 if let Some(day) = self.selected_day {
547 self.selected_day = Some(day.min(max_day));
548 }
549 }
550
551 fn is_leap_year(year: i32) -> bool {
552 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
553 }
554
555 fn current_year_month() -> (i32, u32) {
556 let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else {
557 return (1970, 1);
558 };
559 let days_since_epoch = (duration.as_secs() / 86_400) as i64;
560 let (year, month, _) = Self::civil_from_days(days_since_epoch);
561 (year, month)
562 }
563
564 fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
565 let z = days_since_epoch + 719_468;
566 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
567 let doe = z - era * 146_097;
568 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
569 let mut year = (yoe as i32) + (era as i32) * 400;
570 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
571 let mp = (5 * doy + 2) / 153;
572 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
573 let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
574 if month <= 2 {
575 year += 1;
576 }
577 (year, month, day)
578 }
579}
580
581impl Default for CalendarState {
582 fn default() -> Self {
583 Self::new()
584 }
585}
586
587/// Visual variant for buttons.
588///
589/// Controls the color scheme used when rendering a button. Pass to
590/// [`crate::Context::button_with`] to create styled button variants.
591///
592/// - `Default` — theme text color, primary when focused (same as `button()`)
593/// - `Primary` — primary color background with contrasting text
594/// - `Danger` — error/red color for destructive actions
595/// - `Outline` — bordered appearance without fill
596#[non_exhaustive]
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
598pub enum ButtonVariant {
599 /// Standard button style.
600 #[default]
601 Default,
602 /// Filled button with primary background color.
603 Primary,
604 /// Filled button with error/danger background color.
605 Danger,
606 /// Bordered button without background fill.
607 Outline,
608}
609
610/// Direction indicator for stat widgets.
611#[non_exhaustive]
612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
613pub enum Trend {
614 /// Positive movement.
615 Up,
616 /// Negative movement.
617 Down,
618}
619
620// ── Frame-clock scheduler (issue #248) ────────────────────────────────
621
622/// The kind of timer a [`SchedulerSlot`] holds.
623///
624/// Sampled once per frame against the frame's wall-clock
625/// [`std::time::Instant`]. Intentionally **not** keyed on the frame tick:
626/// `run_frame_kernel` does not advance `diagnostics.tick`, so a tick-based
627/// deadline would never elapse under `TestBackend`. See issue #248.
628pub(crate) enum SchedKind {
629 /// One-shot timer that fires exactly once after `dur` has elapsed from the
630 /// slot's `started` instant.
631 Once {
632 dur: std::time::Duration,
633 fired: bool,
634 },
635 /// Recurring timer that reports whole `interval`s elapsed since `last`.
636 Every {
637 interval: std::time::Duration,
638 last: std::time::Instant,
639 },
640 /// Debounce timer: rearmed to `quiet_started` on every dirty frame, fires
641 /// once when the quiet window `dur` elapses.
642 Debounce {
643 dur: std::time::Duration,
644 quiet_started: std::time::Instant,
645 fired: bool,
646 },
647}
648
649/// A single live timer in the [`SchedulerState`] table.
650pub(crate) struct SchedulerSlot {
651 /// Wall-clock instant the slot was first created. Backs [`Context::elapsed`].
652 pub(crate) started: std::time::Instant,
653 /// The timer behavior for this slot.
654 pub(crate) kind: SchedKind,
655 /// GC flag: set true every frame the slot is sampled; slots left `false`
656 /// at frame end are dropped so abandoned timers do not leak.
657 pub(crate) touched_this_frame: bool,
658}
659
660/// Persistent timer table backing the frame-clock scheduler (issue #248).
661///
662/// Round-tripped through the per-frame state exactly like the named-state
663/// map: moved into [`Context`](crate::Context) at frame start and moved back
664/// at frame end, where untouched slots are garbage-collected. Drives
665/// [`Context::schedule`](crate::Context::schedule),
666/// [`every`](crate::Context::every), [`debounce`](crate::Context::debounce),
667/// [`exclusive`](crate::Context::exclusive), [`cancel`](crate::Context::cancel),
668/// and [`elapsed`](crate::Context::elapsed).
669///
670/// This type is public so it appears in `cargo doc`, but all fields are
671/// `pub(crate)`: you never construct or inspect it directly — the `Context`
672/// timer methods are the entire API surface.
673///
674/// # Example
675///
676/// ```no_run
677/// use std::time::Duration;
678///
679/// slt::run(|ui: &mut slt::Context| {
680/// // The scheduler state is managed for you behind the timer methods.
681/// if ui.schedule("greet", Duration::from_millis(500)) {
682/// ui.text("Half a second has passed.");
683/// }
684/// })?;
685/// # Ok::<_, std::io::Error>(())
686/// ```
687#[derive(Default)]
688pub struct SchedulerState {
689 /// `&'static str`-keyed slots (mirrors `named_states`).
690 pub(crate) named: std::collections::HashMap<&'static str, SchedulerSlot>,
691 /// Runtime-`String`-keyed slots for dynamic ids (mirrors `keyed_states`).
692 pub(crate) keyed: std::collections::HashMap<String, SchedulerSlot>,
693 /// Exclusive-group claim table: `group -> claim state` (issue #248).
694 pub(crate) exclusive: std::collections::HashMap<String, ExclusiveGroup>,
695}
696
697/// Maximum stale claim ids retained per exclusive group. This bounds scheduler
698/// memory while covering substantially more superseded in-flight work than a
699/// UI should launch concurrently.
700pub(crate) const EXCLUSIVE_RETIRED_LIMIT: usize = 256;
701
702/// Per-group claim state for [`Context::exclusive`](crate::Context::exclusive)
703/// (issue #248). Tracks the current winning id plus ids that were superseded
704/// recently enough that in-flight stale work may still re-poll them.
705#[derive(Default)]
706pub(crate) struct ExclusiveGroup {
707 /// The most-recently-claimed id; the only id that returns `true`.
708 pub(crate) winner: String,
709 /// Recently superseded ids. The bounded window prevents ordinary stale
710 /// in-flight work from reclaiming the group without retaining every id for
711 /// the lifetime of the application.
712 pub(crate) retired: std::collections::HashSet<String>,
713 /// FIFO order for bounded eviction from `retired`.
714 retired_order: std::collections::VecDeque<String>,
715 /// GC flag matching scheduler timer slots.
716 touched_this_frame: bool,
717}
718
719impl ExclusiveGroup {
720 pub(crate) fn claim(&mut self, id: &str) -> bool {
721 self.touched_this_frame = true;
722 if self.winner == id {
723 return true;
724 }
725 if self.retired.contains(id) {
726 return false;
727 }
728
729 if !self.winner.is_empty() {
730 let old = std::mem::take(&mut self.winner);
731 if self.retired.insert(old.clone()) {
732 self.retired_order.push_back(old);
733 }
734 while self.retired_order.len() > EXCLUSIVE_RETIRED_LIMIT {
735 if let Some(expired) = self.retired_order.pop_front() {
736 self.retired.remove(&expired);
737 }
738 }
739 }
740 self.winner = id.to_owned();
741 true
742 }
743}
744
745/// Pure interval-counting kernel for [`Context::every`](crate::Context::every)
746/// (issue #248). Returns how many whole `interval`s fit into `elapsed`,
747/// saturating at [`u32::MAX`]. Extracted so the no-drop / no-double-count
748/// invariant can be proptested deterministically without real sleeps.
749pub(crate) fn intervals_elapsed(
750 elapsed: std::time::Duration,
751 interval: std::time::Duration,
752) -> u32 {
753 let nanos = interval.as_nanos().max(1);
754 let count = elapsed.as_nanos() / nanos;
755 count.min(u32::MAX as u128) as u32
756}
757
758impl SchedulerState {
759 /// Drop every slot that was not sampled this frame, then reset the
760 /// per-frame `touched` flag on the survivors. Called at frame end from
761 /// `run_frame_kernel`, mirroring the `named_states` writeback lifecycle.
762 pub(crate) fn gc_untouched(&mut self) {
763 self.named.retain(|_, slot| slot.touched_this_frame);
764 self.keyed.retain(|_, slot| slot.touched_this_frame);
765 self.exclusive.retain(|_, group| group.touched_this_frame);
766 for slot in self.named.values_mut() {
767 slot.touched_this_frame = false;
768 }
769 for slot in self.keyed.values_mut() {
770 slot.touched_this_frame = false;
771 }
772 for group in self.exclusive.values_mut() {
773 group.touched_this_frame = false;
774 }
775 }
776
777 /// Total number of live timer slots (named + keyed). Test-only accessor
778 /// used to assert GC of abandoned timers (issue #248).
779 #[cfg(test)]
780 pub(crate) fn slot_count(&self) -> usize {
781 self.named.len() + self.keyed.len()
782 }
783}
784
785#[cfg(test)]
786mod exclusive_group_tests {
787 use super::*;
788
789 #[test]
790 fn exclusive_history_stays_bounded_under_thousands_of_claims() {
791 let mut group = ExclusiveGroup::default();
792 for id in 0..10_000 {
793 assert!(group.claim(&format!("claim-{id}")));
794 assert!(group.retired.len() <= EXCLUSIVE_RETIRED_LIMIT);
795 assert!(group.retired_order.len() <= EXCLUSIVE_RETIRED_LIMIT);
796 }
797
798 assert_eq!(group.winner, "claim-9999");
799 assert!(group.claim("claim-9999"));
800 assert!(!group.claim("claim-9998"));
801 }
802
803 #[test]
804 fn untouched_exclusive_groups_are_garbage_collected() {
805 let mut scheduler = SchedulerState::default();
806 scheduler
807 .exclusive
808 .entry("search".to_owned())
809 .or_default()
810 .claim("first");
811
812 scheduler.gc_untouched();
813 assert_eq!(scheduler.exclusive.len(), 1);
814 scheduler.gc_untouched();
815 assert!(scheduler.exclusive.is_empty());
816 }
817}
818
819// ── Select / Dropdown ─────────────────────────────────────────────────