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.
5    pub entries: Vec<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: Vec::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.entries.push(RichLogEntry { segments });
64
65        if let Some(max_entries) = self.max_entries
66            && self.entries.len() > max_entries
67        {
68            let remove_count = self.entries.len() - max_entries;
69            self.entries.drain(0..remove_count);
70            self.scroll_offset = self.scroll_offset.saturating_sub(remove_count);
71        }
72
73        if self.auto_scroll {
74            self.scroll_offset = usize::MAX;
75        }
76    }
77
78    /// Clear all entries and reset scroll position.
79    pub fn clear(&mut self) {
80        self.entries.clear();
81        self.scroll_offset = 0;
82    }
83
84    /// Return number of entries in the log.
85    pub fn len(&self) -> usize {
86        self.entries.len()
87    }
88
89    /// Return true when no entries are present.
90    pub fn is_empty(&self) -> bool {
91        self.entries.is_empty()
92    }
93}
94
95impl Default for RichLogState {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101/// An absolute calendar date `(year, month 1–12, day 1–31)`.
102///
103/// Used by [`CalendarState`] to represent range endpoints that can span
104/// month and year boundaries (a `selected_day` alone is scoped to the
105/// currently displayed month). Available since `0.21.0`.
106///
107/// # Example
108///
109/// ```no_run
110/// use slt::CalDate;
111///
112/// let d = CalDate { year: 2024, month: 12, day: 31 };
113/// assert_eq!((d.year, d.month, d.day), (2024, 12, 31));
114/// ```
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct CalDate {
117    /// Calendar year.
118    pub year: i32,
119    /// Month of year, `1`–`12`.
120    pub month: u32,
121    /// Day of month, `1`–`31`.
122    pub day: u32,
123}
124
125impl CalDate {
126    /// Sort key ordering this date against another by year, then month, then day.
127    fn key(&self) -> (i32, u32, u32) {
128        (self.year, self.month, self.day)
129    }
130}
131
132/// Selection behavior for [`CalendarState`].
133///
134/// Defaults to [`Single`](CalendarSelect::Single), preserving the original
135/// single-date pick. Switch to [`Range`](CalendarSelect::Range) via
136/// [`CalendarState::with_range`] for start/end range selection. Available
137/// since `0.21.0`.
138///
139/// # Example
140///
141/// ```no_run
142/// use slt::{CalendarSelect, CalendarState};
143///
144/// let mut cal = CalendarState::from_ym(2024, 3);
145/// assert_eq!(cal.mode(), CalendarSelect::Single);
146/// cal.with_range();
147/// assert_eq!(cal.mode(), CalendarSelect::Range);
148/// ```
149#[non_exhaustive]
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum CalendarSelect {
152    /// Pick exactly one date (default).
153    #[default]
154    Single,
155    /// Pick a start/end date range via Shift-extend.
156    Range,
157}
158
159/// State for the calendar date picker widget.
160#[derive(Debug, Clone)]
161pub struct CalendarState {
162    /// Current display year.
163    pub year: i32,
164    /// Current display month (1–12).
165    pub month: u32,
166    /// Currently selected day, if any (single-date mode).
167    pub selected_day: Option<u32>,
168    pub(crate) cursor_day: u32,
169    pub(crate) mode: CalendarSelect,
170    pub(crate) anchor: Option<CalDate>,
171    pub(crate) extent: Option<CalDate>,
172    pub(crate) time_enabled: bool,
173    pub(crate) hour: u8,
174    pub(crate) minute: u8,
175}
176
177impl CalendarState {
178    /// Create a new `CalendarState` initialized to the current month.
179    pub fn new() -> Self {
180        let (year, month) = Self::current_year_month();
181        Self::from_ym(year, month)
182    }
183
184    /// Create a `CalendarState` for a specific year and month.
185    pub fn from_ym(year: i32, month: u32) -> Self {
186        let month = month.clamp(1, 12);
187        Self {
188            year,
189            month,
190            selected_day: None,
191            cursor_day: 1,
192            mode: CalendarSelect::Single,
193            anchor: None,
194            extent: None,
195            time_enabled: false,
196            hour: 0,
197            minute: 0,
198        }
199    }
200
201    /// Enable date-range selection (start/end via Shift-extend).
202    ///
203    /// Single-date mode remains the default; call this to opt in. Returns
204    /// `&mut Self` for chaining. Available since `0.21.0`.
205    ///
206    /// # Example
207    ///
208    /// ```no_run
209    /// use slt::CalendarState;
210    ///
211    /// let mut cal = CalendarState::from_ym(2024, 3);
212    /// cal.with_range();
213    /// ```
214    pub fn with_range(&mut self) -> &mut Self {
215        self.mode = CalendarSelect::Range;
216        self
217    }
218
219    /// Enable hour/minute selection, rendered as `HH:MM` below the grid.
220    ///
221    /// Off by default — no time row is rendered unless enabled. Returns
222    /// `&mut Self` for chaining. Available since `0.21.0`.
223    ///
224    /// # Example
225    ///
226    /// ```no_run
227    /// use slt::CalendarState;
228    ///
229    /// let mut cal = CalendarState::from_ym(2024, 3);
230    /// cal.with_time();
231    /// ```
232    pub fn with_time(&mut self) -> &mut Self {
233        self.time_enabled = true;
234        self
235    }
236
237    /// The active selection mode (`Single` by default).
238    ///
239    /// Available since `0.21.0`.
240    ///
241    /// # Example
242    ///
243    /// ```no_run
244    /// use slt::{CalendarSelect, CalendarState};
245    ///
246    /// let cal = CalendarState::from_ym(2024, 3);
247    /// assert_eq!(cal.mode(), CalendarSelect::Single);
248    /// ```
249    pub fn mode(&self) -> CalendarSelect {
250        self.mode
251    }
252
253    /// Returns the selected date as `(year, month, day)`, if any.
254    pub fn selected_date(&self) -> Option<(i32, u32, u32)> {
255        self.selected_day.map(|day| (self.year, self.month, day))
256    }
257
258    /// The normalized selected range as `(start, end)` with `start <= end`.
259    ///
260    /// Returns `None` in single-date mode or until an anchor has been set in
261    /// range mode. Endpoints are absolute [`CalDate`]s, so a range may span
262    /// month or year boundaries. Available since `0.21.0`.
263    ///
264    /// # Example
265    ///
266    /// ```no_run
267    /// use slt::CalendarState;
268    ///
269    /// let mut cal = CalendarState::from_ym(2024, 3);
270    /// cal.with_range();
271    /// assert!(cal.selected_range().is_none());
272    /// ```
273    pub fn selected_range(&self) -> Option<(CalDate, CalDate)> {
274        if self.mode != CalendarSelect::Range {
275            return None;
276        }
277        let anchor = self.anchor?;
278        let extent = self.extent.unwrap_or(anchor);
279        if anchor.key() <= extent.key() {
280            Some((anchor, extent))
281        } else {
282            Some((extent, anchor))
283        }
284    }
285
286    /// The selected `(hour, minute)` when time is enabled, else `None`.
287    ///
288    /// Available since `0.21.0`.
289    ///
290    /// # Example
291    ///
292    /// ```no_run
293    /// use slt::CalendarState;
294    ///
295    /// let mut cal = CalendarState::from_ym(2024, 3);
296    /// assert!(cal.selected_time().is_none());
297    /// cal.with_time();
298    /// assert_eq!(cal.selected_time(), Some((0, 0)));
299    /// ```
300    pub fn selected_time(&self) -> Option<(u8, u8)> {
301        self.time_enabled.then_some((self.hour, self.minute))
302    }
303
304    /// The cursor day as an absolute [`CalDate`] in the displayed month.
305    pub(crate) fn cursor_date(&self) -> CalDate {
306        CalDate {
307            year: self.year,
308            month: self.month,
309            day: self.cursor_day,
310        }
311    }
312
313    /// Set the range anchor to the cursor, clearing any prior extent.
314    pub(crate) fn set_anchor_to_cursor(&mut self) {
315        let cur = self.cursor_date();
316        self.anchor = Some(cur);
317        self.extent = None;
318    }
319
320    /// Set the range extent endpoint to the cursor.
321    ///
322    /// If no anchor exists yet, the cursor becomes the anchor.
323    pub(crate) fn extend_to_cursor(&mut self) {
324        let cur = self.cursor_date();
325        if self.anchor.is_none() {
326            self.anchor = Some(cur);
327        }
328        self.extent = Some(cur);
329    }
330
331    /// Whether the given absolute date falls inside the selected range
332    /// (inclusive of both endpoints).
333    pub(crate) fn in_range(&self, d: CalDate) -> bool {
334        match self.selected_range() {
335            Some((start, end)) => start.key() <= d.key() && d.key() <= end.key(),
336            None => false,
337        }
338    }
339
340    /// Whether the given absolute date is one of the range endpoints.
341    pub(crate) fn is_range_endpoint(&self, d: CalDate) -> bool {
342        match self.selected_range() {
343            Some((start, end)) => d == start || d == end,
344            None => false,
345        }
346    }
347
348    /// Navigate to the previous month.
349    pub fn prev_month(&mut self) {
350        if self.month == 1 {
351            self.month = 12;
352            self.year -= 1;
353        } else {
354            self.month -= 1;
355        }
356        self.clamp_days();
357    }
358
359    /// Navigate to the next month.
360    pub fn next_month(&mut self) {
361        if self.month == 12 {
362            self.month = 1;
363            self.year += 1;
364        } else {
365            self.month += 1;
366        }
367        self.clamp_days();
368    }
369
370    pub(crate) fn days_in_month(year: i32, month: u32) -> u32 {
371        match month {
372            1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
373            4 | 6 | 9 | 11 => 30,
374            2 => {
375                if Self::is_leap_year(year) {
376                    29
377                } else {
378                    28
379                }
380            }
381            _ => 30,
382        }
383    }
384
385    pub(crate) fn first_weekday(year: i32, month: u32) -> u32 {
386        let month = month.clamp(1, 12);
387        let offsets = [0_i32, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
388        let mut y = year;
389        if month < 3 {
390            y -= 1;
391        }
392        let sunday_based = (y + y / 4 - y / 100 + y / 400 + offsets[(month - 1) as usize] + 1) % 7;
393        ((sunday_based + 6) % 7) as u32
394    }
395
396    fn clamp_days(&mut self) {
397        let max_day = Self::days_in_month(self.year, self.month);
398        self.cursor_day = self.cursor_day.clamp(1, max_day);
399        if let Some(day) = self.selected_day {
400            self.selected_day = Some(day.min(max_day));
401        }
402    }
403
404    fn is_leap_year(year: i32) -> bool {
405        (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
406    }
407
408    fn current_year_month() -> (i32, u32) {
409        let Ok(duration) = SystemTime::now().duration_since(UNIX_EPOCH) else {
410            return (1970, 1);
411        };
412        let days_since_epoch = (duration.as_secs() / 86_400) as i64;
413        let (year, month, _) = Self::civil_from_days(days_since_epoch);
414        (year, month)
415    }
416
417    fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
418        let z = days_since_epoch + 719_468;
419        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
420        let doe = z - era * 146_097;
421        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
422        let mut year = (yoe as i32) + (era as i32) * 400;
423        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
424        let mp = (5 * doy + 2) / 153;
425        let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
426        let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
427        if month <= 2 {
428            year += 1;
429        }
430        (year, month, day)
431    }
432}
433
434impl Default for CalendarState {
435    fn default() -> Self {
436        Self::new()
437    }
438}
439
440/// Visual variant for buttons.
441///
442/// Controls the color scheme used when rendering a button. Pass to
443/// [`crate::Context::button_with`] to create styled button variants.
444///
445/// - `Default` — theme text color, primary when focused (same as `button()`)
446/// - `Primary` — primary color background with contrasting text
447/// - `Danger` — error/red color for destructive actions
448/// - `Outline` — bordered appearance without fill
449#[non_exhaustive]
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
451pub enum ButtonVariant {
452    /// Standard button style.
453    #[default]
454    Default,
455    /// Filled button with primary background color.
456    Primary,
457    /// Filled button with error/danger background color.
458    Danger,
459    /// Bordered button without background fill.
460    Outline,
461}
462
463/// Direction indicator for stat widgets.
464#[non_exhaustive]
465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
466pub enum Trend {
467    /// Positive movement.
468    Up,
469    /// Negative movement.
470    Down,
471}
472
473// ── Frame-clock scheduler (issue #248) ────────────────────────────────
474
475/// The kind of timer a [`SchedulerSlot`] holds.
476///
477/// Sampled once per frame against the frame's wall-clock
478/// [`std::time::Instant`]. Intentionally **not** keyed on the frame tick:
479/// `run_frame_kernel` does not advance `diagnostics.tick`, so a tick-based
480/// deadline would never elapse under `TestBackend`. See issue #248.
481pub(crate) enum SchedKind {
482    /// One-shot timer that fires exactly once at/after `deadline`.
483    Once {
484        deadline: std::time::Instant,
485        fired: bool,
486    },
487    /// Recurring timer that reports whole `interval`s elapsed since `last`.
488    Every {
489        interval: std::time::Duration,
490        last: std::time::Instant,
491    },
492    /// Debounce timer: rearmed to `now + dur` on every dirty frame, fires
493    /// once when the quiet window `dur` elapses.
494    Debounce {
495        dur: std::time::Duration,
496        deadline: std::time::Instant,
497        fired: bool,
498    },
499}
500
501/// A single live timer in the [`SchedulerState`] table.
502pub(crate) struct SchedulerSlot {
503    /// Wall-clock instant the slot was first created. Backs [`Context::elapsed`].
504    pub(crate) started: std::time::Instant,
505    /// The timer behavior for this slot.
506    pub(crate) kind: SchedKind,
507    /// GC flag: set true every frame the slot is sampled; slots left `false`
508    /// at frame end are dropped so abandoned timers do not leak.
509    pub(crate) touched_this_frame: bool,
510}
511
512/// Persistent timer table backing the frame-clock scheduler (issue #248).
513///
514/// Round-tripped through the per-frame state exactly like the named-state
515/// map: moved into [`Context`](crate::Context) at frame start and moved back
516/// at frame end, where untouched slots are garbage-collected. Drives
517/// [`Context::schedule`](crate::Context::schedule),
518/// [`every`](crate::Context::every), [`debounce`](crate::Context::debounce),
519/// [`exclusive`](crate::Context::exclusive), [`cancel`](crate::Context::cancel),
520/// and [`elapsed`](crate::Context::elapsed).
521///
522/// This type is public so it appears in `cargo doc`, but all fields are
523/// `pub(crate)`: you never construct or inspect it directly — the `Context`
524/// timer methods are the entire API surface.
525///
526/// # Example
527///
528/// ```no_run
529/// use std::time::Duration;
530///
531/// slt::run(|ui: &mut slt::Context| {
532///     // The scheduler state is managed for you behind the timer methods.
533///     if ui.schedule("greet", Duration::from_millis(500)) {
534///         ui.text("Half a second has passed.");
535///     }
536/// })?;
537/// # Ok::<_, std::io::Error>(())
538/// ```
539#[derive(Default)]
540pub struct SchedulerState {
541    /// `&'static str`-keyed slots (mirrors `named_states`).
542    pub(crate) named: std::collections::HashMap<&'static str, SchedulerSlot>,
543    /// Runtime-`String`-keyed slots for dynamic ids (mirrors `keyed_states`).
544    pub(crate) keyed: std::collections::HashMap<String, SchedulerSlot>,
545    /// Exclusive-group claim table: `group -> claim state` (issue #248).
546    pub(crate) exclusive: std::collections::HashMap<String, ExclusiveGroup>,
547}
548
549/// Per-group claim state for [`Context::exclusive`](crate::Context::exclusive)
550/// (issue #248). Tracks the current winning id plus ids that were superseded
551/// and must stay stale (`false`) even if re-polled.
552#[derive(Default)]
553pub(crate) struct ExclusiveGroup {
554    /// The most-recently-claimed id; the only id that returns `true`.
555    pub(crate) winner: String,
556    /// Ids that previously won the group and were superseded. They never win
557    /// again, so stale work cancels permanently.
558    pub(crate) retired: std::collections::HashSet<String>,
559}
560
561/// Pure interval-counting kernel for [`Context::every`](crate::Context::every)
562/// (issue #248). Returns how many whole `interval`s fit into `elapsed`,
563/// saturating at [`u32::MAX`]. Extracted so the no-drop / no-double-count
564/// invariant can be proptested deterministically without real sleeps.
565pub(crate) fn intervals_elapsed(
566    elapsed: std::time::Duration,
567    interval: std::time::Duration,
568) -> u32 {
569    let nanos = interval.as_nanos().max(1);
570    let count = elapsed.as_nanos() / nanos;
571    count.min(u32::MAX as u128) as u32
572}
573
574impl SchedulerState {
575    /// Drop every slot that was not sampled this frame, then reset the
576    /// per-frame `touched` flag on the survivors. Called at frame end from
577    /// `run_frame_kernel`, mirroring the `named_states` writeback lifecycle.
578    pub(crate) fn gc_untouched(&mut self) {
579        self.named.retain(|_, slot| slot.touched_this_frame);
580        self.keyed.retain(|_, slot| slot.touched_this_frame);
581        for slot in self.named.values_mut() {
582            slot.touched_this_frame = false;
583        }
584        for slot in self.keyed.values_mut() {
585            slot.touched_this_frame = false;
586        }
587    }
588
589    /// Total number of live timer slots (named + keyed). Test-only accessor
590    /// used to assert GC of abandoned timers (issue #248).
591    #[cfg(test)]
592    pub(crate) fn slot_count(&self) -> usize {
593        self.named.len() + self.keyed.len()
594    }
595}
596
597// ── Select / Dropdown ─────────────────────────────────────────────────