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 pub(crate) fn save_focus(&mut self, name: &str, focus_index: usize, focus_count: usize) {
463 self.focus_state
464 .insert(name.to_string(), (focus_index, focus_count));
465 }
466
467 pub(crate) fn restore_focus(&self, name: &str) -> (usize, usize) {
468 self.focus_state.get(name).copied().unwrap_or((0, 0))
469 }
470}
471
472/// Named mode system with independent screen stacks.
473///
474/// Each mode contains its own [`ScreenState`]. Switching modes preserves
475/// the previous mode's screen stack, focus, and hook state.
476///
477/// # Example
478///
479/// ```no_run
480/// let mut modes = slt::ModeState::new("app", "home");
481/// modes.add_mode("settings", "general");
482///
483/// slt::run(|ui| {
484/// if ui.key('1') { modes.switch_mode("app"); }
485/// if ui.key('2') { modes.switch_mode("settings"); }
486/// let mode = modes.active_mode().to_string();
487/// ui.text(format!("Mode: {}", mode));
488/// });
489/// ```
490#[derive(Debug, Clone)]
491pub struct ModeState {
492 modes: std::collections::HashMap<String, ScreenState>,
493 active: String,
494}
495
496impl ModeState {
497 /// Create a mode system with an initial mode and screen.
498 pub fn new(mode: impl Into<String>, screen: impl Into<String>) -> Self {
499 let mode = mode.into();
500 let mut modes = std::collections::HashMap::new();
501 modes.insert(mode.clone(), ScreenState::new(screen));
502 Self {
503 modes,
504 active: mode,
505 }
506 }
507
508 /// Add a new mode with an initial screen.
509 pub fn add_mode(&mut self, mode: impl Into<String>, screen: impl Into<String>) {
510 let mode = mode.into();
511 self.modes
512 .entry(mode)
513 .or_insert_with(|| ScreenState::new(screen));
514 }
515
516 /// Switch to a different mode. The mode must have been added with [`Self::add_mode`].
517 ///
518 /// Panics if the mode does not exist. For a non-panicking variant that
519 /// reports success, use [`Self::try_switch_mode`].
520 pub fn switch_mode(&mut self, mode: impl Into<String>) {
521 let mode = mode.into();
522 assert!(
523 self.modes.contains_key(&mode),
524 "mode '{}' not found",
525 mode
526 );
527 self.active = mode;
528 }
529
530 /// Switch modes, returning `true` when the mode exists and the switch
531 /// happened, or `false` when the mode has not been registered via
532 /// [`Self::add_mode`].
533 ///
534 /// Prefer this over [`Self::switch_mode`] when the mode name comes from
535 /// user input, key bindings, or anywhere the value could be unexpected
536 /// at runtime — an unknown mode should not crash the host application.
537 pub fn try_switch_mode(&mut self, mode: impl Into<String>) -> bool {
538 let mode = mode.into();
539 if !self.modes.contains_key(&mode) {
540 return false;
541 }
542 self.active = mode;
543 true
544 }
545
546 /// Return the active mode name.
547 pub fn active_mode(&self) -> &str {
548 &self.active
549 }
550
551 /// Get a reference to the active mode's screen state.
552 pub fn screens(&self) -> &ScreenState {
553 self.modes
554 .get(&self.active)
555 .expect("active mode must exist")
556 }
557
558 /// Get a mutable reference to the active mode's screen state.
559 pub fn screens_mut(&mut self) -> &mut ScreenState {
560 self.modes
561 .get_mut(&self.active)
562 .expect("active mode must exist")
563 }
564}
565
566#[cfg(test)]
567mod mode_state_tests {
568 use super::ModeState;
569
570 #[test]
571 fn try_switch_mode_returns_false_for_unknown_mode() {
572 let mut modes = ModeState::new("app", "home");
573 modes.add_mode("settings", "general");
574 assert!(modes.try_switch_mode("settings"));
575 assert_eq!(modes.active_mode(), "settings");
576 assert!(!modes.try_switch_mode("nonexistent"));
577 // Active mode must not change when the switch is rejected.
578 assert_eq!(modes.active_mode(), "settings");
579 }
580}
581
582#[cfg(test)]
583mod streaming_version_tests {
584 //! Issue #273 — the monotonic `version()` counter on streaming states.
585 use super::{StreamingMarkdownState, StreamingTextState};
586
587 #[test]
588 fn text_version_starts_at_zero_and_bumps_on_mutation() {
589 let mut s = StreamingTextState::new();
590 assert_eq!(s.version(), 0, "fresh state has version 0");
591 s.push("a");
592 assert_eq!(s.version(), 1);
593 s.push("b");
594 assert_eq!(s.version(), 2);
595 s.start();
596 assert_eq!(s.version(), 3, "start() is a mutation");
597 s.clear();
598 assert_eq!(s.version(), 4, "clear() is a mutation");
599 }
600
601 #[test]
602 fn text_finish_does_not_bump_version() {
603 let mut s = StreamingTextState::new();
604 s.push("x");
605 let v = s.version();
606 s.finish();
607 assert_eq!(s.version(), v, "finish() only toggles the streaming flag");
608 }
609
610 #[test]
611 fn markdown_version_bumps_on_mutation() {
612 let mut s = StreamingMarkdownState::new();
613 assert_eq!(s.version(), 0);
614 s.push("# h");
615 assert_eq!(s.version(), 1);
616 s.start();
617 assert_eq!(s.version(), 2);
618 s.clear();
619 assert_eq!(s.version(), 3);
620 let v = s.version();
621 s.finish();
622 assert_eq!(s.version(), v, "finish() does not bump");
623 }
624}
625
626/// Approval state for a tool call.
627#[non_exhaustive]
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629pub enum ApprovalAction {
630 /// No action taken yet.
631 Pending,
632 /// User approved the tool call.
633 Approved,
634 /// User rejected the tool call.
635 Rejected,
636}
637
638/// State for a tool approval widget.
639///
640/// Displays a tool call with approve/reject buttons for human-in-the-loop
641/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
642/// each frame.
643#[derive(Debug, Clone)]
644pub struct ToolApprovalState {
645 /// The name of the tool being invoked.
646 pub tool_name: String,
647 /// A human-readable description of what the tool will do.
648 pub description: String,
649 /// The current approval status.
650 pub action: ApprovalAction,
651}
652
653impl ToolApprovalState {
654 /// Create a new tool approval prompt.
655 pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
656 Self {
657 tool_name: tool_name.into(),
658 description: description.into(),
659 action: ApprovalAction::Pending,
660 }
661 }
662
663 /// Reset to pending state.
664 pub fn reset(&mut self) {
665 self.action = ApprovalAction::Pending;
666 }
667}
668
669/// Item in a context bar showing active context sources.
670#[derive(Debug, Clone)]
671pub struct ContextItem {
672 /// Display label for this context source.
673 pub label: String,
674 /// Token count or size indicator.
675 pub tokens: usize,
676}
677
678impl ContextItem {
679 /// Create a new context item with a label and token count.
680 pub fn new(label: impl Into<String>, tokens: usize) -> Self {
681 Self {
682 label: label.into(),
683 tokens,
684 }
685 }
686}