Skip to main content

modalkit_ratatui/
cmdbar.rs

1//! # Command bar
2//!
3//! ## Overview
4//!
5//! These components allow creating a bar for entering searches and commands.
6
7//! Typically, this widget is used indirectly by consumers through [Screen], which places this at
8//! the bottom of the terminal window.
9//!
10//! [Screen]: super::screen::Screen
11use std::marker::PhantomData;
12use std::ops::{Deref, DerefMut};
13
14use ratatui::{buffer::Buffer, layout::Rect, style::Style, text::Span, widgets::StatefulWidget};
15
16use modalkit::actions::{Action, CommandBarAction, PromptAction, Promptable};
17use modalkit::editing::{
18    application::ApplicationInfo,
19    completion::CompletionList,
20    context::{EditContext, Resolve},
21    history::ScrollbackState,
22    rope::EditRope,
23    store::Store,
24};
25use modalkit::errors::EditResult;
26use modalkit::prelude::*;
27
28use super::{
29    textbox::{TextBox, TextBoxState},
30    PromptActions,
31    WindowOps,
32};
33
34/// Persistent state for rendering [CommandBar].
35pub struct CommandBarState<I: ApplicationInfo> {
36    scrollback: ScrollbackState,
37    prompt: String,
38    action: Option<(Action<I>, EditContext)>,
39    cmdtype: CommandType,
40    tbox_cmd: TextBoxState<I>,
41    tbox_search: TextBoxState<I>,
42}
43
44impl<I> CommandBarState<I>
45where
46    I: ApplicationInfo,
47{
48    /// Create state for a [CommandBar] widget.
49    pub fn new(store: &mut Store<I>) -> Self {
50        let buffer_cmd = store.load_buffer(I::content_of_command(CommandType::Command));
51        let buffer_search = store.load_buffer(I::content_of_command(CommandType::Search));
52
53        CommandBarState {
54            scrollback: ScrollbackState::Pending,
55            prompt: String::new(),
56            action: None,
57            cmdtype: CommandType::Command,
58            tbox_cmd: TextBoxState::new(buffer_cmd),
59            tbox_search: TextBoxState::new(buffer_search),
60        }
61    }
62
63    /// Get completion candidates from the command bar to show the user.
64    pub fn get_completions(&self) -> Option<CompletionList> {
65        self.deref().get_completions()
66    }
67
68    /// Set the type of command that the bar is being used for.
69    pub fn set_type(&mut self, prompt: &str, ct: CommandType, act: &Action<I>, ctx: &EditContext) {
70        self.prompt = prompt.into();
71        self.action = Some((act.clone(), ctx.clone()));
72        self.cmdtype = ct;
73    }
74
75    /// Reset the contents of the bar, and return the contents as an [EditRope].
76    pub fn reset(&mut self) -> EditRope {
77        self.scrollback = ScrollbackState::Pending;
78
79        self.deref_mut().reset()
80    }
81
82    /// Reset the contents of the bar, and return the contents as a [String].
83    pub fn reset_text(&mut self) -> String {
84        self.reset().to_string()
85    }
86}
87
88impl<I> Deref for CommandBarState<I>
89where
90    I: ApplicationInfo,
91{
92    type Target = TextBoxState<I>;
93
94    fn deref(&self) -> &Self::Target {
95        match self.cmdtype {
96            CommandType::Command => &self.tbox_cmd,
97            CommandType::Search => &self.tbox_search,
98        }
99    }
100}
101
102impl<I> DerefMut for CommandBarState<I>
103where
104    I: ApplicationInfo,
105{
106    fn deref_mut(&mut self) -> &mut Self::Target {
107        match self.cmdtype {
108            CommandType::Command => &mut self.tbox_cmd,
109            CommandType::Search => &mut self.tbox_search,
110        }
111    }
112}
113
114impl<I> PromptActions<EditContext, Store<I>, I> for CommandBarState<I>
115where
116    I: ApplicationInfo,
117{
118    fn submit(
119        &mut self,
120        ctx: &EditContext,
121        store: &mut Store<I>,
122    ) -> EditResult<Vec<(Action<I>, EditContext)>, I> {
123        let rope = self.reset().trim_end_matches(|c| c == '\n');
124        store.registers.set_last_command(self.cmdtype, rope);
125
126        let mut acts = vec![(CommandBarAction::Unfocus.into(), ctx.clone())];
127        acts.extend(self.action.take());
128
129        Ok(acts)
130    }
131
132    fn abort(
133        &mut self,
134        _empty: bool,
135        ctx: &EditContext,
136        store: &mut Store<I>,
137    ) -> EditResult<Vec<(Action<I>, EditContext)>, I> {
138        // We always unfocus currently, regardless of whether _empty=true.
139        let act = Action::CommandBar(CommandBarAction::Unfocus);
140
141        let text = self.reset().trim();
142        store.registers.set_aborted_command(self.cmdtype, text);
143
144        Ok(vec![(act, ctx.clone())])
145    }
146
147    fn recall(
148        &mut self,
149        filter: &RecallFilter,
150        dir: &MoveDir1D,
151        count: &Count,
152        ctx: &EditContext,
153        store: &mut Store<I>,
154    ) -> EditResult<Vec<(Action<I>, EditContext)>, I> {
155        let count = ctx.resolve(count);
156        let rope = self.deref().get();
157
158        let hist = store.registers.get_command_history(self.cmdtype);
159        let text = hist.recall(&rope, &mut self.scrollback, filter, *dir, count);
160
161        if let Some(text) = text {
162            self.set_text(text);
163        }
164
165        Ok(vec![])
166    }
167}
168
169impl<I> Promptable<EditContext, Store<I>, I> for CommandBarState<I>
170where
171    I: ApplicationInfo,
172{
173    fn prompt(
174        &mut self,
175        act: &PromptAction,
176        ctx: &EditContext,
177        store: &mut Store<I>,
178    ) -> EditResult<Vec<(Action<I>, EditContext)>, I> {
179        match act {
180            PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
181            PromptAction::Recall(filter, dir, count) => self.recall(filter, dir, count, ctx, store),
182            PromptAction::Submit => self.submit(ctx, store),
183        }
184    }
185}
186
187/// Widget for rendering a command bar.
188pub struct CommandBar<'a, I: ApplicationInfo> {
189    focused: bool,
190    message: Option<Span<'a>>,
191    style_prompt: Option<Style>,
192    style_text: Style,
193
194    _pc: PhantomData<I>,
195}
196
197impl<'a, I> CommandBar<'a, I>
198where
199    I: ApplicationInfo,
200{
201    /// Create a new widget.
202    pub fn new() -> Self {
203        CommandBar {
204            focused: false,
205            message: None,
206            style_prompt: None,
207            style_text: Style::default(),
208            _pc: PhantomData,
209        }
210    }
211
212    /// Indicate whether the widget is currently focused.
213    pub fn focus(mut self, focused: bool) -> Self {
214        self.focused = focused;
215        self
216    }
217
218    /// Set the style to use for the command bar's prompt.
219    ///
220    /// If one isn't provided, then this is the same style specified with [CommandBar::style].
221    pub fn prompt_style(mut self, style: Style) -> Self {
222        self.style_prompt = Some(style);
223        self
224    }
225
226    /// Set the style to use for the contents of the inner [TextBox].
227    pub fn style(mut self, style: Style) -> Self {
228        self.style_text = style;
229        self
230    }
231
232    /// Set a status string that will be displayed instead of the contents when the widget is not
233    /// currently focused.
234    pub fn status(mut self, msg: Option<Span<'a>>) -> Self {
235        self.message = msg;
236        self
237    }
238}
239
240impl<I> StatefulWidget for CommandBar<'_, I>
241where
242    I: ApplicationInfo,
243{
244    type State = CommandBarState<I>;
245
246    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
247        if self.focused {
248            let prompt_style = self.style_prompt.unwrap_or(self.style_text);
249            let prompt = Span::styled(&state.prompt, prompt_style);
250            let tbox = TextBox::new().prompt(prompt).style(self.style_text).oneline();
251            let tbox_state = match state.cmdtype {
252                CommandType::Command => &mut state.tbox_cmd,
253                CommandType::Search => &mut state.tbox_search,
254            };
255
256            tbox.render(area, buf, tbox_state);
257        } else if let Some(span) = self.message {
258            buf.set_span(area.left(), area.top(), &span, area.width);
259        }
260    }
261}
262
263impl<I> Default for CommandBar<'_, I>
264where
265    I: ApplicationInfo,
266{
267    fn default() -> Self {
268        CommandBar::new()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use modalkit::editing::application::EmptyInfo;
276    use modalkit::editing::context::EditContextBuilder;
277
278    #[test]
279    fn test_set_type_submit() {
280        let mut store = Store::<EmptyInfo>::default();
281        let mut cmdbar = CommandBarState::new(&mut store);
282
283        // Verify that set_type() action and context are returned when the bar is submitted.
284        let act = Action::Suspend;
285        let ctx = EditContextBuilder::default().search_regex_dir(MoveDir1D::Previous).build();
286        cmdbar.set_type(":", CommandType::Command, &act, &ctx);
287
288        let res = cmdbar.submit(&EditContext::default(), &mut store).unwrap();
289        assert_eq!(res.len(), 2);
290        assert_eq!(res[0].0, Action::from(CommandBarAction::Unfocus));
291        assert_eq!(res[0].1, EditContext::default());
292        assert_eq!(res[1].0, act);
293        assert_eq!(res[1].1, ctx);
294
295        // Verify that the most recent set_type() call wins.
296        let act1 = Action::Suspend;
297        let ctx1 = EditContextBuilder::default().search_regex_dir(MoveDir1D::Previous).build();
298        cmdbar.set_type(":", CommandType::Command, &act1, &ctx1);
299        let act2 = Action::KeywordLookup(KeywordTarget::Selection);
300        let ctx2 = EditContextBuilder::default().search_regex_dir(MoveDir1D::Next).build();
301        cmdbar.set_type(":", CommandType::Command, &act2, &ctx2);
302
303        let res = cmdbar.submit(&EditContext::default(), &mut store).unwrap();
304        assert_eq!(res.len(), 2);
305        assert_eq!(res[0].0, Action::from(CommandBarAction::Unfocus));
306        assert_eq!(res[0].1, EditContext::default());
307        assert_eq!(res[1].0, act2);
308        assert_eq!(res[1].1, ctx2);
309    }
310}