Skip to main content

slt/widgets/
commanding.rs

1/// Default tick budget (~1s at 60Hz) after which a partially-typed chord
2/// is abandoned. Matches the tick clock used by notifications/animation.
3///
4/// Override per call site with
5/// [`Context::key_chord_timeout`](crate::Context::key_chord_timeout).
6pub const DEFAULT_CHORD_TIMEOUT_TICKS: u64 = 60;
7
8/// Cross-frame partial-sequence buffer for
9/// [`Context::key_chord`](crate::Context::key_chord).
10///
11/// Persisted in `FrameState` across frames (same out/in policy as
12/// `keyed_states`). Holds at most one in-flight chord prefix; a mismatching
13/// key or a timeout clears it. You never construct this directly — SLT owns a
14/// single instance per [`Context`](crate::Context) and threads it through the
15/// frame loop for you.
16///
17/// # Example
18///
19/// ```no_run
20/// slt::run(|ui: &mut slt::Context| {
21///     // The buffer is managed internally; just call `key_chord`.
22///     if ui.key_chord("gg") {
23///         // jump to top
24///     }
25/// });
26/// ```
27#[derive(Debug, Default, Clone)]
28pub struct ChordState {
29    /// Characters accumulated so far toward some registered chord.
30    pub(crate) pending: String,
31    /// Tick of the most recent accepted key; used for timeout expiry.
32    pub(crate) last_tick: u64,
33}
34
35/// State for a command palette overlay.
36///
37/// Renders as a modal with a search input and filtered command list.
38#[derive(Debug, Clone)]
39pub struct CommandPaletteState {
40    /// Available commands.
41    pub commands: Vec<PaletteCommand>,
42    /// Current search query.
43    pub input: String,
44    /// Cursor index within `input`.
45    pub cursor: usize,
46    /// Whether the palette modal is open.
47    pub open: bool,
48    /// The last selected command index, set when the user confirms a selection.
49    /// Check this after `response.changed` is true.
50    pub last_selected: Option<usize>,
51    selected: usize,
52    /// Cached filtered indices for the last `input` value. Avoids running
53    /// `fuzzy_score` twice per frame (clamp + render).
54    filter_cache: Option<(String, Vec<usize>)>,
55}
56
57impl CommandPaletteState {
58    /// Create command palette state from a command list.
59    pub fn new(commands: Vec<PaletteCommand>) -> Self {
60        Self {
61            commands,
62            input: String::new(),
63            cursor: 0,
64            open: false,
65            last_selected: None,
66            selected: 0,
67            filter_cache: None,
68        }
69    }
70
71    /// Toggle open/closed state and reset input when opening.
72    pub fn toggle(&mut self) {
73        self.open = !self.open;
74        if self.open {
75            self.input.clear();
76            self.cursor = 0;
77            self.selected = 0;
78            self.filter_cache = None;
79        }
80    }
81
82    pub(crate) fn fuzzy_score(pattern: &str, text: &str) -> Option<i32> {
83        let pattern = pattern.trim();
84        if pattern.is_empty() {
85            return Some(0);
86        }
87
88        let text_chars: Vec<char> = text.chars().collect();
89        let mut score = 0;
90        let mut search_start = 0usize;
91        let mut prev_match: Option<usize> = None;
92
93        for p in pattern.chars() {
94            let mut found = None;
95            for (idx, ch) in text_chars.iter().enumerate().skip(search_start) {
96                if ch.eq_ignore_ascii_case(&p) {
97                    found = Some(idx);
98                    break;
99                }
100            }
101
102            let idx = found?;
103            if prev_match.is_some_and(|prev| idx == prev + 1) {
104                score += 3;
105            } else {
106                score += 1;
107            }
108
109            if idx == 0 {
110                score += 2;
111            } else {
112                let prev = text_chars[idx - 1];
113                let curr = text_chars[idx];
114                if matches!(prev, ' ' | '_' | '-') || prev.is_uppercase() || curr.is_uppercase() {
115                    score += 2;
116                }
117            }
118
119            prev_match = Some(idx);
120            search_start = idx + 1;
121        }
122
123        Some(score)
124    }
125
126    /// Cached variant of [`Self::filtered_indices`].
127    ///
128    /// Reuses the previous result when `self.input` has not changed since the
129    /// last call. `command_palette()` invokes this twice per frame (before key
130    /// handling, to clamp the selection index, and again for render); on idle
131    /// frames the second call is served from cache instead of re-running
132    /// `fuzzy_score` over the full command list.
133    pub(crate) fn filtered_indices_cached(&mut self) -> &[usize] {
134        let needs_recompute = match &self.filter_cache {
135            Some((cached_input, _)) => *cached_input != self.input,
136            None => true,
137        };
138        if needs_recompute {
139            let indices = self.filtered_indices();
140            self.filter_cache = Some((self.input.clone(), indices));
141        }
142        &self
143            .filter_cache
144            .as_ref()
145            .expect("filter_cache populated above")
146            .1
147    }
148
149    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
150        let query = self.input.trim();
151        if query.is_empty() {
152            return (0..self.commands.len()).collect();
153        }
154
155        let mut scored: Vec<(usize, i32)> = self
156            .commands
157            .iter()
158            .enumerate()
159            .filter_map(|(i, cmd)| {
160                let mut haystack =
161                    String::with_capacity(cmd.label.len() + cmd.description.len() + 1);
162                haystack.push_str(&cmd.label);
163                haystack.push(' ');
164                haystack.push_str(&cmd.description);
165                Self::fuzzy_score(query, &haystack).map(|score| (i, score))
166            })
167            .collect();
168
169        if scored.is_empty() {
170            let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
171            return self
172                .commands
173                .iter()
174                .enumerate()
175                .filter(|(_, cmd)| {
176                    let label = cmd.label.to_lowercase();
177                    let desc = cmd.description.to_lowercase();
178                    tokens.iter().all(|token| {
179                        label.contains(token.as_str()) || desc.contains(token.as_str())
180                    })
181                })
182                .map(|(i, _)| i)
183                .collect();
184        }
185
186        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
187        scored.into_iter().map(|(idx, _)| idx).collect()
188    }
189
190    pub(crate) fn selected(&self) -> usize {
191        self.selected
192    }
193
194    pub(crate) fn set_selected(&mut self, s: usize) {
195        self.selected = s;
196    }
197}
198
199/// State for a streaming text display.
200///
201/// Accumulates text chunks as they arrive from an LLM stream.
202/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
203#[derive(Debug, Clone)]
204pub struct StreamingTextState {
205    /// The accumulated text content.
206    pub content: String,
207    /// Whether the stream is still receiving data.
208    pub streaming: bool,
209    /// Cursor blink state (for the typing indicator).
210    pub(crate) cursor_visible: bool,
211    pub(crate) cursor_tick: u64,
212    /// Monotonic content version, bumped on every content mutation
213    /// (`push` / `start` / `clear`). See [`StreamingTextState::version`].
214    pub(crate) version: u64,
215}
216
217impl StreamingTextState {
218    /// Create a new empty streaming text state.
219    pub fn new() -> Self {
220        Self {
221            content: String::new(),
222            streaming: false,
223            cursor_visible: true,
224            cursor_tick: 0,
225            version: 0,
226        }
227    }
228
229    /// Append a chunk of text (e.g., from an LLM stream delta).
230    pub fn push(&mut self, chunk: &str) {
231        self.content.push_str(chunk);
232        self.version = self.version.wrapping_add(1);
233    }
234
235    /// Mark the stream as complete (hides the typing cursor).
236    pub fn finish(&mut self) {
237        self.streaming = false;
238    }
239
240    /// Start a new streaming session, clearing previous content.
241    pub fn start(&mut self) {
242        self.content.clear();
243        self.streaming = true;
244        self.cursor_visible = true;
245        self.cursor_tick = 0;
246        self.version = self.version.wrapping_add(1);
247    }
248
249    /// Clear all content and reset state.
250    pub fn clear(&mut self) {
251        self.content.clear();
252        self.streaming = false;
253        self.cursor_visible = true;
254        self.cursor_tick = 0;
255        self.version = self.version.wrapping_add(1);
256    }
257
258    /// Monotonic version counter, bumped on every content mutation
259    /// (`push` / `start` / `clear`).
260    ///
261    /// The stream itself changes every token, so this value is **not** a
262    /// useful cache key for the streaming region. Its purpose is the
263    /// inverse: it lets you detect when the stream *did* change so you can
264    /// decide whether the *surrounding static chrome* is stable. Combine a
265    /// hash of your non-streaming inputs into a key for
266    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) and wrap
267    /// the chrome — not the stream — in it.
268    ///
269    /// Since 0.21.0.
270    ///
271    /// # Example
272    /// ```no_run
273    /// # slt::run(|ui: &mut slt::Context| {
274    /// let mut stream = slt::StreamingTextState::new();
275    /// stream.push("hello");
276    /// assert_eq!(stream.version(), 1);
277    /// stream.push(" world");
278    /// assert_eq!(stream.version(), 2);
279    /// # });
280    /// ```
281    pub fn version(&self) -> u64 {
282        self.version
283    }
284}
285
286impl Default for StreamingTextState {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292/// State for a streaming markdown display.
293///
294/// Accumulates markdown chunks as they arrive from an LLM stream.
295/// Pass to [`Context::streaming_markdown`](crate::Context::streaming_markdown) each frame.
296#[derive(Debug, Clone)]
297pub struct StreamingMarkdownState {
298    /// The accumulated markdown content.
299    pub content: String,
300    /// Whether the stream is still receiving data.
301    pub streaming: bool,
302    /// Cursor blink state (for the typing indicator).
303    pub cursor_visible: bool,
304    /// Cursor animation tick counter.
305    pub cursor_tick: u64,
306    /// Whether the parser is currently inside a fenced code block.
307    pub in_code_block: bool,
308    /// Language label of the active fenced code block.
309    pub code_block_lang: String,
310    /// Monotonic content version, bumped on every content mutation
311    /// (`push` / `start` / `clear`). See [`StreamingMarkdownState::version`].
312    pub(crate) version: u64,
313}
314
315impl StreamingMarkdownState {
316    /// Create a new empty streaming markdown state.
317    pub fn new() -> Self {
318        Self {
319            content: String::new(),
320            streaming: false,
321            cursor_visible: true,
322            cursor_tick: 0,
323            in_code_block: false,
324            code_block_lang: String::new(),
325            version: 0,
326        }
327    }
328
329    /// Append a markdown chunk (e.g., from an LLM stream delta).
330    pub fn push(&mut self, chunk: &str) {
331        self.content.push_str(chunk);
332        self.version = self.version.wrapping_add(1);
333    }
334
335    /// Start a new streaming session, clearing previous content.
336    pub fn start(&mut self) {
337        self.content.clear();
338        self.streaming = true;
339        self.cursor_visible = true;
340        self.cursor_tick = 0;
341        self.in_code_block = false;
342        self.code_block_lang.clear();
343        self.version = self.version.wrapping_add(1);
344    }
345
346    /// Mark the stream as complete (hides the typing cursor).
347    pub fn finish(&mut self) {
348        self.streaming = false;
349    }
350
351    /// Clear all content and reset state.
352    pub fn clear(&mut self) {
353        self.content.clear();
354        self.streaming = false;
355        self.cursor_visible = true;
356        self.cursor_tick = 0;
357        self.in_code_block = false;
358        self.code_block_lang.clear();
359        self.version = self.version.wrapping_add(1);
360    }
361
362    /// Monotonic version counter, bumped on every content mutation
363    /// (`push` / `start` / `clear`).
364    ///
365    /// As with [`StreamingTextState::version`], use this to detect stream
366    /// deltas and key the *surrounding static chrome* into
367    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) — not to
368    /// cache the stream region itself.
369    ///
370    /// Since 0.21.0.
371    ///
372    /// # Example
373    /// ```no_run
374    /// # slt::run(|ui: &mut slt::Context| {
375    /// let mut md = slt::StreamingMarkdownState::new();
376    /// md.push("# Title");
377    /// assert_eq!(md.version(), 1);
378    /// # });
379    /// ```
380    pub fn version(&self) -> u64 {
381        self.version
382    }
383}
384
385impl Default for StreamingMarkdownState {
386    fn default() -> Self {
387        Self::new()
388    }
389}
390
391/// Navigation stack state for multi-screen apps.
392///
393/// Tracks screen names in a push/pop stack while preserving the root screen.
394/// Each screen gets isolated focus and hook state when used with
395/// [`crate::Context::screen`].
396///
397/// # Example
398///
399/// ```no_run
400/// let mut screens = slt::ScreenState::new("main");
401///
402/// slt::run(|ui| {
403///     let current = screens.current().to_string();
404///     if current == "main" {
405///         if ui.button("Settings").clicked { screens.push("settings"); }
406///     }
407///     if current == "settings" {
408///         if ui.button("Back").clicked { screens.pop(); }
409///     }
410/// });
411/// ```
412#[derive(Debug, Clone)]
413pub struct ScreenState {
414    stack: Vec<String>,
415    focus_state: std::collections::HashMap<String, (usize, usize)>,
416}
417
418impl ScreenState {
419    /// Create a screen stack with an initial root screen.
420    pub fn new(initial: impl Into<String>) -> Self {
421        Self {
422            stack: vec![initial.into()],
423            focus_state: std::collections::HashMap::new(),
424        }
425    }
426
427    /// Return the current screen name (top of the stack).
428    pub fn current(&self) -> &str {
429        self.stack
430            .last()
431            .expect("ScreenState always contains at least one screen")
432            .as_str()
433    }
434
435    /// Push a new screen onto the stack.
436    pub fn push(&mut self, name: impl Into<String>) {
437        self.stack.push(name.into());
438    }
439
440    /// Pop the current screen, preserving the root screen.
441    pub fn pop(&mut self) {
442        if self.can_pop() {
443            self.stack.pop();
444        }
445    }
446
447    /// Return current stack depth.
448    pub fn depth(&self) -> usize {
449        self.stack.len()
450    }
451
452    /// Return `true` if popping is allowed.
453    pub fn can_pop(&self) -> bool {
454        self.stack.len() > 1
455    }
456
457    /// Reset to only the root screen.
458    pub fn reset(&mut self) {
459        self.stack.truncate(1);
460    }
461
462    /// Apply a deferred navigation request recorded inside a
463    /// [`crate::Context::screen`] closure (issue #279).
464    pub(crate) fn apply_nav(&mut self, nav: ScreenNav) {
465        match nav {
466            ScreenNav::Push(name) => self.push(name),
467            ScreenNav::Pop => self.pop(),
468            ScreenNav::Reset => self.reset(),
469        }
470    }
471
472    pub(crate) fn save_focus(&mut self, name: &str, focus_index: usize, focus_count: usize) {
473        self.focus_state
474            .insert(name.to_string(), (focus_index, focus_count));
475    }
476
477    pub(crate) fn restore_focus(&self, name: &str) -> (usize, usize) {
478        self.focus_state.get(name).copied().unwrap_or((0, 0))
479    }
480}
481
482/// A deferred screen-navigation request recorded inside a
483/// [`crate::Context::screen`] closure and applied to the active
484/// [`ScreenState`] after the closure returns (issue #279).
485#[derive(Debug, Clone)]
486pub(crate) enum ScreenNav {
487    /// Push a new screen onto the stack.
488    Push(String),
489    /// Pop the current screen, preserving the root.
490    Pop,
491    /// Reset to only the root screen.
492    Reset,
493}
494
495/// Named mode system with independent screen stacks.
496///
497/// Each mode contains its own [`ScreenState`]. Switching modes preserves
498/// the previous mode's screen stack, focus, and hook state.
499///
500/// # Example
501///
502/// ```no_run
503/// let mut modes = slt::ModeState::new("app", "home");
504/// modes.add_mode("settings", "general");
505///
506/// slt::run(|ui| {
507///     if ui.key('1') { modes.switch_mode("app"); }
508///     if ui.key('2') { modes.switch_mode("settings"); }
509///     let mode = modes.active_mode().to_string();
510///     ui.text(format!("Mode: {}", mode));
511/// });
512/// ```
513#[derive(Debug, Clone)]
514pub struct ModeState {
515    modes: std::collections::HashMap<String, ScreenState>,
516    active: String,
517}
518
519impl ModeState {
520    /// Create a mode system with an initial mode and screen.
521    pub fn new(mode: impl Into<String>, screen: impl Into<String>) -> Self {
522        let mode = mode.into();
523        let mut modes = std::collections::HashMap::new();
524        modes.insert(mode.clone(), ScreenState::new(screen));
525        Self {
526            modes,
527            active: mode,
528        }
529    }
530
531    /// Add a new mode with an initial screen.
532    pub fn add_mode(&mut self, mode: impl Into<String>, screen: impl Into<String>) {
533        let mode = mode.into();
534        self.modes
535            .entry(mode)
536            .or_insert_with(|| ScreenState::new(screen));
537    }
538
539    /// Switch to a different mode. The mode must have been added with [`Self::add_mode`].
540    ///
541    /// Panics if the mode does not exist. For a non-panicking variant that
542    /// reports success, use [`Self::try_switch_mode`].
543    pub fn switch_mode(&mut self, mode: impl Into<String>) {
544        let mode = mode.into();
545        assert!(
546            self.modes.contains_key(&mode),
547            "mode '{}' not found",
548            mode
549        );
550        self.active = mode;
551    }
552
553    /// Switch modes, returning `true` when the mode exists and the switch
554    /// happened, or `false` when the mode has not been registered via
555    /// [`Self::add_mode`].
556    ///
557    /// Prefer this over [`Self::switch_mode`] when the mode name comes from
558    /// user input, key bindings, or anywhere the value could be unexpected
559    /// at runtime — an unknown mode should not crash the host application.
560    pub fn try_switch_mode(&mut self, mode: impl Into<String>) -> bool {
561        let mode = mode.into();
562        if !self.modes.contains_key(&mode) {
563            return false;
564        }
565        self.active = mode;
566        true
567    }
568
569    /// Return the active mode name.
570    pub fn active_mode(&self) -> &str {
571        &self.active
572    }
573
574    /// Get a reference to the active mode's screen state.
575    pub fn screens(&self) -> &ScreenState {
576        self.modes
577            .get(&self.active)
578            .expect("active mode must exist")
579    }
580
581    /// Get a mutable reference to the active mode's screen state.
582    pub fn screens_mut(&mut self) -> &mut ScreenState {
583        self.modes
584            .get_mut(&self.active)
585            .expect("active mode must exist")
586    }
587}
588
589#[cfg(test)]
590mod mode_state_tests {
591    use super::ModeState;
592
593    #[test]
594    fn try_switch_mode_returns_false_for_unknown_mode() {
595        let mut modes = ModeState::new("app", "home");
596        modes.add_mode("settings", "general");
597        assert!(modes.try_switch_mode("settings"));
598        assert_eq!(modes.active_mode(), "settings");
599        assert!(!modes.try_switch_mode("nonexistent"));
600        // Active mode must not change when the switch is rejected.
601        assert_eq!(modes.active_mode(), "settings");
602    }
603}
604
605#[cfg(test)]
606mod streaming_version_tests {
607    //! Issue #273 — the monotonic `version()` counter on streaming states.
608    use super::{StreamingMarkdownState, StreamingTextState};
609
610    #[test]
611    fn text_version_starts_at_zero_and_bumps_on_mutation() {
612        let mut s = StreamingTextState::new();
613        assert_eq!(s.version(), 0, "fresh state has version 0");
614        s.push("a");
615        assert_eq!(s.version(), 1);
616        s.push("b");
617        assert_eq!(s.version(), 2);
618        s.start();
619        assert_eq!(s.version(), 3, "start() is a mutation");
620        s.clear();
621        assert_eq!(s.version(), 4, "clear() is a mutation");
622    }
623
624    #[test]
625    fn text_finish_does_not_bump_version() {
626        let mut s = StreamingTextState::new();
627        s.push("x");
628        let v = s.version();
629        s.finish();
630        assert_eq!(s.version(), v, "finish() only toggles the streaming flag");
631    }
632
633    #[test]
634    fn markdown_version_bumps_on_mutation() {
635        let mut s = StreamingMarkdownState::new();
636        assert_eq!(s.version(), 0);
637        s.push("# h");
638        assert_eq!(s.version(), 1);
639        s.start();
640        assert_eq!(s.version(), 2);
641        s.clear();
642        assert_eq!(s.version(), 3);
643        let v = s.version();
644        s.finish();
645        assert_eq!(s.version(), v, "finish() does not bump");
646    }
647}
648
649/// Approval state for a tool call.
650#[non_exhaustive]
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub enum ApprovalAction {
653    /// No action taken yet.
654    Pending,
655    /// User approved the tool call.
656    Approved,
657    /// User rejected the tool call.
658    Rejected,
659}
660
661/// State for a tool approval widget.
662///
663/// Displays a tool call with approve/reject buttons for human-in-the-loop
664/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
665/// each frame.
666#[derive(Debug, Clone)]
667pub struct ToolApprovalState {
668    /// The name of the tool being invoked.
669    pub tool_name: String,
670    /// A human-readable description of what the tool will do.
671    pub description: String,
672    /// The current approval status.
673    pub action: ApprovalAction,
674}
675
676impl ToolApprovalState {
677    /// Create a new tool approval prompt.
678    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
679        Self {
680            tool_name: tool_name.into(),
681            description: description.into(),
682            action: ApprovalAction::Pending,
683        }
684    }
685
686    /// Reset to pending state.
687    pub fn reset(&mut self) {
688        self.action = ApprovalAction::Pending;
689    }
690}
691
692/// Item in a context bar showing active context sources.
693#[derive(Debug, Clone)]
694pub struct ContextItem {
695    /// Display label for this context source.
696    pub label: String,
697    /// Token count or size indicator.
698    pub tokens: usize,
699}
700
701impl ContextItem {
702    /// Create a new context item with a label and token count.
703    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
704        Self {
705            label: label.into(),
706            tokens,
707        }
708    }
709}