Skip to content

Commit a5f431f

Browse files
committed
feat(promkit): add multiline text editor preset
1 parent d6f02fd commit a5f431f

8 files changed

Lines changed: 688 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
1313
## [Unreleased]
1414

15+
### Added
16+
17+
- Added multiline editing support to the text editor widget and a multiline `TextEditor` prompt preset
18+
1519
## [0.13.0] - 2026-07-24
1620

1721
### Added

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ promkit = "0.14.0"
2828
- A Derive macro that simplifies interactive form input
2929
- Rich preset components
3030
- [Readline](#readline) - Text input with auto-completion
31+
- [TextEditor](#texteditor) - Multiline text editing
3132
- [Confirm](#confirm) - Yes/no confirmation prompt
3233
- [Password](#password) - Password input with masking and validation
3334
- [Form](#form) - Manage multiple text input fields
@@ -73,6 +74,19 @@ cargo run --bin readline
7374

7475
<img src="https://github.com/ynqa/ynqa/blob/master/demo/promkit/readline.gif" width="50%" height="auto">
7576

77+
### TextEditor
78+
79+
<details>
80+
<summary>Command</summary>
81+
82+
```bash
83+
cargo run --bin text_editor
84+
```
85+
86+
</details>
87+
88+
[Code](./examples/text_editor/src/text_editor.rs)
89+
7690
### Confirm
7791

7892
<details>

examples/text_editor/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "text-editor"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[dependencies]
8+
anyhow = { workspace = true }
9+
promkit = { path = "../../promkit", features = ["text-editor"] }
10+
tokio = { workspace = true }
11+
12+
[[bin]]
13+
name = "text_editor"
14+
path = "src/text_editor.rs"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
use promkit::{preset::text_editor::TextEditor, Prompt};
2+
3+
#[tokio::main]
4+
async fn main() -> anyhow::Result<()> {
5+
let text = TextEditor::default()
6+
.title("Enter text (Ctrl+D to submit)")
7+
.lines(8)
8+
.run()
9+
.await?;
10+
11+
println!("result:\n{text}");
12+
Ok(())
13+
}

promkit/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ all = [
2626
"readline",
2727
"spinner",
2828
"text",
29+
"text-editor",
2930
"tree",
3031
]
3132
checkbox = ["promkit-widgets/checkbox", "promkit-widgets/text"]
@@ -47,6 +48,7 @@ readline = [
4748
]
4849
spinner = ["promkit-widgets/spinner"]
4950
text = ["promkit-widgets/text"]
51+
text-editor = ["promkit-widgets/text", "promkit-widgets/texteditor"]
5052
tree = ["promkit-widgets/text", "promkit-widgets/tree"]
5153

5254
[dependencies]

promkit/src/preset.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ pub mod checkbox;
66
#[cfg_attr(docsrs, doc(cfg(feature = "readline")))]
77
pub mod readline;
88

9+
#[cfg(feature = "text-editor")]
10+
#[cfg_attr(docsrs, doc(cfg(feature = "text-editor")))]
11+
pub mod text_editor;
12+
913
#[cfg(feature = "confirm")]
1014
#[cfg_attr(docsrs, doc(cfg(feature = "confirm")))]
1115
pub mod confirm;

promkit/src/preset/text_editor.rs

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
//! Offers functionality for editing multiline text.
2+
3+
use std::collections::HashSet;
4+
5+
use crate::{
6+
core::{
7+
crossterm::{
8+
event::Event,
9+
style::{Attribute, Attributes, Color, ContentStyle},
10+
},
11+
render::{Renderer, SharedRenderer},
12+
Widget,
13+
},
14+
preset::Evaluator,
15+
validate::{ErrorMessageGenerator, Validator, ValidatorManager},
16+
widgets::{
17+
text::{self, Text},
18+
text_editor::{self as text_editor_widget},
19+
},
20+
Signal,
21+
};
22+
23+
pub mod evaluate;
24+
25+
/// Represents the indices of the multiline text editor components.
26+
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27+
pub enum Index {
28+
Title = 0,
29+
Editor = 1,
30+
ErrorMessage = 2,
31+
}
32+
33+
/// A prompt for editing newline-delimited text.
34+
///
35+
/// The default evaluator inserts a newline with <kbd>Enter</kbd> and submits
36+
/// the complete buffer with <kbd>Ctrl+D</kbd>.
37+
pub struct TextEditor {
38+
/// Shared renderer for the prompt.
39+
pub renderer: Option<SharedRenderer<Index>>,
40+
/// Function used to evaluate terminal events.
41+
pub evaluator: Evaluator<Self>,
42+
/// Title displayed above the editor.
43+
pub title: text::State,
44+
/// Multiline text editor state.
45+
pub editor: text_editor_widget::State,
46+
/// Cursor style restored whenever the prompt is reused.
47+
pub active_char_style: ContentStyle,
48+
/// Optional validator applied when the buffer is submitted.
49+
pub validator: Option<ValidatorManager<str>>,
50+
/// Validation error displayed below the editor.
51+
pub error_message: text::State,
52+
}
53+
54+
impl Default for TextEditor {
55+
fn default() -> Self {
56+
let active_char_style = ContentStyle {
57+
background_color: Some(Color::DarkCyan),
58+
..Default::default()
59+
};
60+
61+
Self {
62+
renderer: None,
63+
evaluator: |event, ctx| Box::pin(evaluate::default(event, ctx)),
64+
title: text::State {
65+
config: text::config::Config {
66+
style: Some(ContentStyle {
67+
attributes: Attributes::from(Attribute::Bold),
68+
..Default::default()
69+
}),
70+
..Default::default()
71+
},
72+
..Default::default()
73+
},
74+
editor: text_editor_widget::State {
75+
texteditor: Default::default(),
76+
history: Default::default(),
77+
config: text_editor_widget::config::Config {
78+
prefix: String::from("❯❯ "),
79+
prefix_style: ContentStyle {
80+
foreground_color: Some(Color::DarkGreen),
81+
..Default::default()
82+
},
83+
active_char_style,
84+
inactive_char_style: ContentStyle::default(),
85+
edit_mode: Default::default(),
86+
word_break_chars: HashSet::from([' ', '\n']),
87+
lines: Some(5),
88+
..Default::default()
89+
},
90+
},
91+
active_char_style,
92+
validator: None,
93+
error_message: text::State {
94+
text: Default::default(),
95+
config: text::config::Config {
96+
style: Some(ContentStyle {
97+
foreground_color: Some(Color::DarkRed),
98+
attributes: Attributes::from(Attribute::Bold),
99+
..Default::default()
100+
}),
101+
lines: None,
102+
},
103+
},
104+
}
105+
}
106+
}
107+
108+
#[async_trait::async_trait]
109+
impl crate::Prompt for TextEditor {
110+
async fn initialize(&mut self) -> anyhow::Result<()> {
111+
self.renderer = Some(SharedRenderer::new(
112+
Renderer::try_new_with_graphemes(
113+
[
114+
(Index::Title, self.title.create_graphemes()),
115+
(Index::Editor, self.editor.create_graphemes()),
116+
(Index::ErrorMessage, self.error_message.create_graphemes()),
117+
],
118+
true,
119+
)
120+
.await?,
121+
));
122+
Ok(())
123+
}
124+
125+
async fn evaluate(&mut self, event: &Event) -> anyhow::Result<Signal> {
126+
let signal = (self.evaluator)(event, self).await;
127+
self.render().await?;
128+
signal
129+
}
130+
131+
type Return = String;
132+
133+
fn finalize(&mut self) -> anyhow::Result<Self::Return> {
134+
let text = self.editor.texteditor.text_without_cursor().to_string();
135+
self.editor.texteditor.erase_all();
136+
self.editor.config.active_char_style = self.active_char_style;
137+
Ok(text)
138+
}
139+
}
140+
141+
impl TextEditor {
142+
/// Sets the title displayed above the editor.
143+
pub fn title<T: AsRef<str>>(mut self, text: T) -> Self {
144+
self.title.text = Text::from(text);
145+
self
146+
}
147+
148+
/// Sets the title style.
149+
pub fn title_style(mut self, style: ContentStyle) -> Self {
150+
self.title.config.style = Some(style);
151+
self
152+
}
153+
154+
/// Sets the prefix displayed before the first logical row.
155+
pub fn prefix<T: AsRef<str>>(mut self, prefix: T) -> Self {
156+
self.editor.config.prefix = prefix.as_ref().to_string();
157+
self
158+
}
159+
160+
/// Sets the prefix style.
161+
pub fn prefix_style(mut self, style: ContentStyle) -> Self {
162+
self.editor.config.prefix_style = style;
163+
self
164+
}
165+
166+
/// Sets the style of the grapheme at the cursor.
167+
pub fn active_char_style(mut self, style: ContentStyle) -> Self {
168+
self.editor.config.active_char_style = style;
169+
self.active_char_style = style;
170+
self
171+
}
172+
173+
/// Sets the style of text outside the cursor.
174+
pub fn inactive_char_style(mut self, style: ContentStyle) -> Self {
175+
self.editor.config.inactive_char_style = style;
176+
self
177+
}
178+
179+
/// Sets insert or overwrite editing mode.
180+
pub fn edit_mode(mut self, mode: text_editor_widget::Mode) -> Self {
181+
self.editor.config.edit_mode = mode;
182+
self
183+
}
184+
185+
/// Sets characters used as word movement and deletion boundaries.
186+
pub fn word_break_chars(mut self, characters: HashSet<char>) -> Self {
187+
self.editor.config.word_break_chars = characters;
188+
self
189+
}
190+
191+
/// Sets the maximum number of visible editor rows.
192+
pub fn lines(mut self, lines: usize) -> Self {
193+
self.editor.config.lines = Some(lines);
194+
self
195+
}
196+
197+
/// Replaces the default event evaluator.
198+
pub fn evaluator(mut self, evaluator: Evaluator<Self>) -> Self {
199+
self.evaluator = evaluator;
200+
self
201+
}
202+
203+
/// Configures validation performed when the buffer is submitted.
204+
pub fn validator(
205+
mut self,
206+
validator: Validator<str>,
207+
error_message_generator: ErrorMessageGenerator<str>,
208+
) -> Self {
209+
self.validator = Some(ValidatorManager::new(validator, error_message_generator));
210+
self
211+
}
212+
213+
async fn render(&mut self) -> anyhow::Result<()> {
214+
match self.renderer.as_ref() {
215+
Some(renderer) => {
216+
renderer
217+
.update([
218+
(Index::Title, self.title.create_graphemes()),
219+
(Index::Editor, self.editor.create_graphemes()),
220+
(Index::ErrorMessage, self.error_message.create_graphemes()),
221+
])
222+
.render()
223+
.await
224+
}
225+
None => Err(anyhow::anyhow!("Renderer not initialized")),
226+
}
227+
}
228+
}
229+
230+
#[cfg(test)]
231+
mod tests {
232+
use super::TextEditor;
233+
use crate::Prompt;
234+
235+
#[test]
236+
fn builder_configures_the_editor() {
237+
let editor = TextEditor::default().prefix("lua> ").lines(8);
238+
239+
assert_eq!(editor.editor.config.prefix, "lua> ");
240+
assert_eq!(editor.editor.config.lines, Some(8));
241+
}
242+
243+
#[test]
244+
fn finalize_returns_and_clears_the_buffer() {
245+
let mut editor = TextEditor::default();
246+
let active_char_style = editor.active_char_style;
247+
editor.editor.texteditor.replace("first\nsecond");
248+
editor.editor.config.active_char_style = Default::default();
249+
250+
assert_eq!(editor.finalize().unwrap(), "first\nsecond");
251+
assert_eq!(
252+
editor.editor.texteditor.text_without_cursor().to_string(),
253+
""
254+
);
255+
assert_eq!(editor.editor.config.active_char_style, active_char_style);
256+
}
257+
}

0 commit comments

Comments
 (0)