Skip to main content

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 ─────────────────────────────────────────────────