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 if self.entries.len() > max_entries {
67 let remove_count = self.entries.len() - max_entries;
68 self.entries.drain(0..remove_count);
69 self.scroll_offset = self.scroll_offset.saturating_sub(remove_count);
70 }
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 ─────────────────────────────────────────────────