Skip to main content

modalkit_ratatui/
lib.rs

1//! # modalkit-ratatui
2//!
3//! ## Overview
4//!
5//! This crate contains widgets that can be used to build modal editing TUI applications using
6//! the [ratatui] and [modalkit] crates.
7//!
8//! ## Example
9//!
10//! The following example shows a program that opens a single textbox where the user can enter text
11//! using Vim keybindings.
12//!
13//! For a more complete example that includes a command bar and window splitting, see
14//! `examples/editor.rs` in the source repository.
15//!
16//! ```no_run
17//! use modalkit::{
18//!     actions::{Action, Editable, Jumpable, Scrollable},
19//!     editing::{context::Resolve, key::KeyManager, store::Store},
20//!     editing::application::EmptyInfo,
21//!     errors::UIResult,
22//!     env::vim::keybindings::default_vim_keys,
23//!     key::TerminalKey,
24//!     keybindings::BindingMachine,
25//! };
26//! use modalkit_ratatui::textbox::{TextBoxState, TextBox};
27//! use modalkit_ratatui::TerminalExtOps;
28//!
29//! use modalkit::crossterm::event::{read, Event};
30//! use modalkit::crossterm::terminal::EnterAlternateScreen;
31//! use ratatui::{backend::CrosstermBackend, Terminal};
32//! use std::io::stdout;
33//!
34//! fn main() -> UIResult<(), EmptyInfo> {
35//!     let mut stdout = stdout();
36//!
37//!     crossterm::terminal::enable_raw_mode()?;
38//!     crossterm::execute!(stdout, EnterAlternateScreen)?;
39//!
40//!     let backend = CrosstermBackend::new(stdout);
41//!     let mut terminal = Terminal::new(backend)?;
42//!     let mut store = Store::default();
43//!     let mut bindings = KeyManager::new(default_vim_keys::<EmptyInfo>());
44//!     let mut tbox = TextBoxState::new(store.load_buffer(String::from("*scratch*")));
45//!
46//!     terminal.clear()?;
47//!
48//!     loop {
49//!         terminal.draw(|f| f.render_stateful_widget(TextBox::new(), f.size(), &mut tbox))?;
50//!
51//!         if let Event::Key(key) = read()? {
52//!             bindings.input_key(key.into());
53//!         } else {
54//!             continue;
55//!         };
56//!
57//!         while let Some((act, ctx)) = bindings.pop() {
58//!             let store = &mut store;
59//!
60//!             let _ = match act {
61//!                 Action::Editor(act) => tbox.editor_command(&act, &ctx, store)?,
62//!                 Action::Macro(act) => bindings.macro_command(&act, &ctx, store)?,
63//!                 Action::Scroll(style) => tbox.scroll(&style, &ctx, store)?,
64//!                 Action::Repeat(rt) => {
65//!                     bindings.repeat(rt, Some(ctx));
66//!                     None
67//!                 },
68//!                 Action::Jump(l, dir, count) => {
69//!                     let _ = tbox.jump(l, dir, ctx.resolve(&count), &ctx)?;
70//!                     None
71//!                 },
72//!                 Action::Suspend => terminal.program_suspend()?,
73//!                 Action::NoOp => None,
74//!                 _ => continue,
75//!             };
76//!         }
77//!     }
78//! }
79//! ```
80
81// Require docs for public APIs, and disable the more annoying clippy lints.
82#![deny(missing_docs)]
83#![allow(clippy::bool_to_int_with_if)]
84#![allow(clippy::field_reassign_with_default)]
85#![allow(clippy::len_without_is_empty)]
86#![allow(clippy::manual_range_contains)]
87#![allow(clippy::match_like_matches_macro)]
88#![allow(clippy::needless_return)]
89#![allow(clippy::too_many_arguments)]
90#![allow(clippy::type_complexity)]
91use std::io::{stdout, Stdout};
92use std::process;
93
94use ratatui::{
95    backend::CrosstermBackend,
96    buffer::Buffer,
97    layout::Rect,
98    style::{Color, Style},
99    text::{Line, Span},
100    widgets::Paragraph,
101    Frame,
102    Terminal,
103};
104
105use crossterm::{
106    execute,
107    terminal::{EnterAlternateScreen, LeaveAlternateScreen},
108};
109
110use modalkit::actions::Action;
111use modalkit::editing::{application::ApplicationInfo, completion::CompletionList, store::Store};
112use modalkit::errors::{EditResult, UIResult};
113use modalkit::prelude::*;
114
115pub mod cmdbar;
116pub mod list;
117pub mod screen;
118pub mod textbox;
119pub mod windows;
120
121mod util;
122
123/// An offset from the upper-left corner of the terminal.
124pub type TermOffset = (u16, u16);
125
126/// A widget that the user's cursor can be placed into.
127pub trait TerminalCursor {
128    /// Returns the current offset of the cursor, relative to the upper left corner of the
129    /// terminal.
130    fn get_term_cursor(&self) -> Option<TermOffset>;
131}
132
133/// A widget whose content can be scrolled in multiple ways.
134pub trait ScrollActions<C, S, I>
135where
136    I: ApplicationInfo,
137{
138    /// Pan the viewport.
139    fn dirscroll(
140        &mut self,
141        dir: MoveDir2D,
142        size: ScrollSize,
143        count: &Count,
144        ctx: &C,
145        store: &mut S,
146    ) -> EditResult<EditInfo, I>;
147
148    /// Scroll so that the cursor is placed along a viewport boundary.
149    fn cursorpos(
150        &mut self,
151        pos: MovePosition,
152        axis: Axis,
153        ctx: &C,
154        store: &mut S,
155    ) -> EditResult<EditInfo, I>;
156
157    /// Scroll so that a specific line is placed at a given place in the viewport.
158    fn linepos(
159        &mut self,
160        pos: MovePosition,
161        count: &Count,
162        ctx: &C,
163        store: &mut S,
164    ) -> EditResult<EditInfo, I>;
165}
166
167/// A widget that contains content that can be converted into an action when the user is done
168/// entering text.
169pub trait PromptActions<C, S, I>
170where
171    I: ApplicationInfo,
172{
173    /// Submit the currently entered text.
174    fn submit(&mut self, ctx: &C, store: &mut S) -> EditResult<Vec<(Action<I>, C)>, I>;
175
176    /// Abort command entry and reset the current contents.
177    ///
178    /// If `empty` is true, and there is currently entered text, do nothing.
179    fn abort(&mut self, empty: bool, ctx: &C, store: &mut S) -> EditResult<Vec<(Action<I>, C)>, I>;
180
181    /// Recall previously entered text.
182    fn recall(
183        &mut self,
184        filter: &RecallFilter,
185        dir: &MoveDir1D,
186        count: &Count,
187        ctx: &C,
188        store: &mut S,
189    ) -> EditResult<Vec<(Action<I>, C)>, I>;
190}
191
192/// Trait to allow widgets to control how they get drawn onto the screen when they are either
193/// focused or unfocused.
194pub trait WindowOps<I: ApplicationInfo>: TerminalCursor {
195    /// Create a copy of this window during a window split.
196    fn dup(&self, store: &mut Store<I>) -> Self;
197
198    /// Perform any necessary cleanup for this window and close it.
199    ///
200    /// If this function returns false, it's because the window cannot be closed, at least not with
201    /// the provided set of flags.
202    fn close(&mut self, flags: CloseFlags, store: &mut Store<I>) -> bool;
203
204    /// Draw this window into the buffer for the prescribed area.
205    fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut Store<I>);
206
207    /// Get completion candidates to show the user.
208    fn get_completions(&self) -> Option<CompletionList>;
209
210    /// Returns the word following the current cursor position in this window.
211    fn get_cursor_word(&self, style: &WordStyle) -> Option<String>;
212
213    /// Returns the currently selected text in this window.
214    fn get_selected_word(&self) -> Option<String>;
215
216    /// Write the contents of the window.
217    fn write(
218        &mut self,
219        path: Option<&str>,
220        flags: WriteFlags,
221        store: &mut Store<I>,
222    ) -> UIResult<EditInfo, I>;
223}
224
225/// A widget that the user can open and close on the screen.
226pub trait Window<I: ApplicationInfo>: WindowOps<I> + Sized {
227    /// Get the identifier for this window.
228    fn id(&self) -> I::WindowId;
229
230    /// Get the title to show in the window layout.
231    fn get_win_title(&self, store: &mut Store<I>) -> Line;
232
233    /// Get the title to show in the tab list when this is the currently focused window.
234    ///
235    /// The default implementation will use the same title as shown in the window.
236    fn get_tab_title(&self, store: &mut Store<I>) -> Line {
237        self.get_win_title(store)
238    }
239
240    /// Open a window that displays the content referenced by `id`.
241    fn open(id: I::WindowId, store: &mut Store<I>) -> UIResult<Self, I>;
242
243    /// Open a window given a name to lookup.
244    fn find(name: String, store: &mut Store<I>) -> UIResult<Self, I>;
245
246    /// Open a globally indexed window given a position.
247    fn posn(index: usize, store: &mut Store<I>) -> UIResult<Self, I>;
248
249    /// Open a default window when no target has been specified.
250    fn unnamed(store: &mut Store<I>) -> UIResult<Self, I>;
251}
252
253/// Position and draw a terminal cursor.
254pub fn render_cursor<T: TerminalCursor>(f: &mut Frame, widget: &T, cursor: Option<char>) {
255    if let Some((cx, cy)) = widget.get_term_cursor() {
256        if let Some(c) = cursor {
257            let style = Style::default().fg(Color::Green);
258            let span = Span::styled(c.to_string(), style);
259            let para = Paragraph::new(span);
260            let inner = Rect::new(cx, cy, 1, 1);
261            f.render_widget(para, inner)
262        }
263        f.set_cursor_position((cx, cy));
264    }
265}
266
267/// Extended operations for [Terminal].
268pub trait TerminalExtOps {
269    /// Result type for terminal operations.
270    type Result;
271
272    /// Suspend the process.
273    fn program_suspend(&mut self) -> Self::Result;
274}
275
276impl TerminalExtOps for Terminal<CrosstermBackend<Stdout>> {
277    type Result = Result<EditInfo, std::io::Error>;
278
279    fn program_suspend(&mut self) -> Self::Result {
280        let mut stdout = stdout();
281
282        // Restore old terminal state.
283        crossterm::terminal::disable_raw_mode()?;
284        execute!(self.backend_mut(), LeaveAlternateScreen)?;
285        self.show_cursor()?;
286
287        // Send SIGTSTP to process.
288        let pid = process::id();
289
290        #[cfg(unix)]
291        unsafe {
292            libc::kill(pid as i32, libc::SIGTSTP);
293        }
294
295        // Restore application terminal state.
296        crossterm::terminal::enable_raw_mode()?;
297        crossterm::execute!(stdout, EnterAlternateScreen)?;
298        self.clear()?;
299
300        Ok(None)
301    }
302}