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}