Skip to content

Commit ba0c04a

Browse files
committed
feat: support multiline cell editing
1 parent 1bbfafa commit ba0c04a

2 files changed

Lines changed: 251 additions & 30 deletions

File tree

src/main.rs

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,92 @@ mod screen;
1010
mod theme;
1111
mod widget;
1212

13+
fn keyboard_enhancement_flags() -> crossterm::event::KeyboardEnhancementFlags {
14+
crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
15+
}
16+
17+
fn enable_keyboard_enhancement() -> bool {
18+
crossterm::execute!(
19+
std::io::stdout(),
20+
crossterm::event::PushKeyboardEnhancementFlags(keyboard_enhancement_flags())
21+
)
22+
.is_ok()
23+
}
24+
25+
struct TerminalInputGuard {
26+
keyboard_enhancement_enabled: bool,
27+
mouse_capture_enabled: bool,
28+
}
29+
30+
impl TerminalInputGuard {
31+
fn enter() -> std::io::Result<Self> {
32+
let mut guard = Self {
33+
keyboard_enhancement_enabled: enable_keyboard_enhancement(),
34+
mouse_capture_enabled: false,
35+
};
36+
37+
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
38+
guard.mouse_capture_enabled = true;
39+
40+
Ok(guard)
41+
}
42+
43+
fn leave(mut self) -> std::io::Result<()> {
44+
let mouse_cleanup = self.disable_mouse_capture();
45+
let keyboard_cleanup = self.disable_keyboard_enhancement();
46+
47+
mouse_cleanup.and(keyboard_cleanup)
48+
}
49+
50+
fn disable_mouse_capture(&mut self) -> std::io::Result<()> {
51+
if !self.mouse_capture_enabled {
52+
return Ok(());
53+
}
54+
55+
self.mouse_capture_enabled = false;
56+
crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)
57+
}
58+
59+
fn disable_keyboard_enhancement(&mut self) -> std::io::Result<()> {
60+
if !self.keyboard_enhancement_enabled {
61+
return Ok(());
62+
}
63+
64+
self.keyboard_enhancement_enabled = false;
65+
crossterm::execute!(
66+
std::io::stdout(),
67+
crossterm::event::PopKeyboardEnhancementFlags
68+
)
69+
}
70+
}
71+
72+
impl Drop for TerminalInputGuard {
73+
fn drop(&mut self) {
74+
let _ = self.disable_mouse_capture();
75+
let _ = self.disable_keyboard_enhancement();
76+
}
77+
}
78+
1379
fn main() -> color_eyre::Result<()> {
1480
color_eyre::install()?;
1581
let initial_file = std::env::args().nth(1);
1682
let mut app = app::App::new(initial_file);
1783
ratatui::run(|terminal| {
18-
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
84+
let terminal_input = TerminalInputGuard::enter()?;
1985
let result = app.run(terminal);
20-
crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)?;
21-
result
86+
87+
result.and(terminal_input.leave())
2288
})?;
2389
Ok(())
2490
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
#[test]
95+
fn keyboard_enhancement_disambiguates_modified_enter() {
96+
assert!(
97+
super::keyboard_enhancement_flags()
98+
.contains(crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
99+
);
100+
}
101+
}

src/screen/editor/edit.rs

Lines changed: 171 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
use crossterm::event::{KeyCode, KeyEvent};
1+
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
22
use ratatui::{
33
Frame,
44
layout::{Constraint, Layout, Rect},
55
style::Style,
6-
text::Line,
6+
text::{Line, Span},
77
widgets::Paragraph,
88
};
99

@@ -19,6 +19,10 @@ pub struct EditMode {
1919
buffer: String,
2020
}
2121

22+
const MIN_EDIT_AREA_HEIGHT: u16 = 1;
23+
const EDIT_LABEL: &str = "编辑: ";
24+
const EDIT_CONTINUATION_PREFIX: &str = " ";
25+
2226
impl EditMode {
2327
pub fn new(initial: String, initial_char: Option<char>) -> Self {
2428
let mut buffer = initial;
@@ -36,6 +40,10 @@ impl Mode for EditMode {
3640

3741
fn handle_key(&mut self, _view: EditorView<'_>, key: KeyEvent) -> ModeResult {
3842
match key.code {
43+
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => {
44+
self.buffer.push('\n');
45+
EventResult::Handled
46+
}
3947
KeyCode::Enter => EventResult::Command(EditorIntent::CommitEdit(self.buffer.clone())),
4048
KeyCode::Esc => {
4149
EventResult::Command(EditorIntent::SwitchMode(Box::new(NavigationMode)))
@@ -53,33 +61,12 @@ impl Mode for EditMode {
5361
}
5462

5563
fn render(&self, frame: &mut Frame, area: Rect, read: EditorReadModel<'_>) -> Rect {
56-
use ratatui::text::Span;
57-
64+
let edit_area_height = self.edit_area_height(area.height);
5865
let [table_area, edit_area] =
59-
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
66+
Layout::vertical([Constraint::Fill(1), Constraint::Length(edit_area_height)])
67+
.areas(area);
6068

61-
let mut spans = vec![Span::styled(
62-
"编辑: ",
63-
Style::default().fg(read.theme.accent),
64-
)];
65-
if self.buffer.is_empty() {
66-
spans.push(Span::styled(
67-
"(空)",
68-
Style::default().fg(read.theme.text_dim),
69-
));
70-
} else {
71-
spans.push(Span::styled(
72-
self.buffer.as_str(),
73-
Style::default().fg(read.theme.text),
74-
));
75-
let cursor_char = if read.blink_visible { "█" } else { " " };
76-
spans.push(Span::styled(
77-
cursor_char,
78-
Style::default().fg(read.theme.text),
79-
));
80-
}
81-
82-
frame.render_widget(Paragraph::new(Line::from(spans)), edit_area);
69+
frame.render_widget(Paragraph::new(self.render_lines(read)), edit_area);
8370
table_area
8471
}
8572

@@ -94,6 +81,9 @@ impl Mode for EditMode {
9481
Span::styled("Enter", Style::default().fg(read.theme.accent)),
9582
Span::styled(" 确认", Style::default().fg(read.theme.text_dim)),
9683
Span::styled(" ", Style::default().fg(read.theme.text_dim)),
84+
Span::styled("Shift+Enter", Style::default().fg(read.theme.accent)),
85+
Span::styled(" 换行", Style::default().fg(read.theme.text_dim)),
86+
Span::styled(" ", Style::default().fg(read.theme.text_dim)),
9787
Span::styled("Esc", Style::default().fg(read.theme.accent)),
9888
Span::styled(" 取消", Style::default().fg(read.theme.text_dim)),
9989
])),
@@ -109,3 +99,157 @@ impl Mode for EditMode {
10999
}
110100
}
111101
}
102+
103+
impl EditMode {
104+
fn edit_area_height(&self, available_height: u16) -> u16 {
105+
let needed_height = self
106+
.buffer
107+
.split('\n')
108+
.count()
109+
.max(MIN_EDIT_AREA_HEIGHT as usize) as u16;
110+
needed_height.min(available_height)
111+
}
112+
113+
fn render_lines(&self, read: EditorReadModel<'_>) -> Vec<Line<'static>> {
114+
if self.buffer.is_empty() {
115+
return vec![Line::from(vec![
116+
Span::styled(EDIT_LABEL, Style::default().fg(read.theme.accent)),
117+
Span::styled("(空)", Style::default().fg(read.theme.text_dim)),
118+
])];
119+
}
120+
121+
let cursor_char = if read.blink_visible { "█" } else { " " };
122+
let mut lines = Vec::new();
123+
let parts: Vec<&str> = self.buffer.split('\n').collect();
124+
for (index, part) in parts.iter().enumerate() {
125+
let is_first = index == 0;
126+
let is_last = index + 1 == parts.len();
127+
let mut spans = Vec::new();
128+
if is_first {
129+
spans.push(Span::styled(
130+
EDIT_LABEL,
131+
Style::default().fg(read.theme.accent),
132+
));
133+
} else {
134+
spans.push(Span::raw(EDIT_CONTINUATION_PREFIX));
135+
}
136+
137+
spans.push(Span::styled(
138+
(*part).to_string(),
139+
Style::default().fg(read.theme.text),
140+
));
141+
if is_last {
142+
spans.push(Span::styled(
143+
cursor_char,
144+
Style::default().fg(read.theme.text),
145+
));
146+
}
147+
148+
lines.push(Line::from(spans));
149+
}
150+
151+
lines
152+
}
153+
}
154+
155+
#[cfg(test)]
156+
mod tests {
157+
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
158+
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
159+
160+
use super::{EditMode, MIN_EDIT_AREA_HEIGHT};
161+
use crate::{
162+
screen::EventResult,
163+
screen::editor::mode::{EditorIntent, EditorView},
164+
screen::editor::mode::{EditorReadModel, Mode},
165+
theme::Theme,
166+
};
167+
168+
#[test]
169+
fn edit_mode_reserves_a_single_line_edit_area_for_single_line_input() {
170+
let backend = TestBackend::new(40, 10);
171+
let mut terminal = Terminal::new(backend).unwrap();
172+
let mode = EditMode::new(String::new(), None);
173+
let theme = Theme::dark();
174+
let viewport = super::super::viewport::Viewport::new();
175+
176+
terminal
177+
.draw(|frame| {
178+
let table_area = mode.render(
179+
frame,
180+
frame.area(),
181+
EditorReadModel {
182+
theme,
183+
viewport: &viewport,
184+
blink_visible: true,
185+
selection_stats: None,
186+
},
187+
);
188+
189+
assert_eq!(table_area, Rect::new(0, 0, 40, 10 - MIN_EDIT_AREA_HEIGHT));
190+
})
191+
.unwrap();
192+
}
193+
194+
#[test]
195+
fn edit_mode_grows_for_multiline_input_and_renders_real_lines() {
196+
let backend = TestBackend::new(40, 10);
197+
let mut terminal = Terminal::new(backend).unwrap();
198+
let mode = EditMode::new("a\nb".to_string(), None);
199+
let theme = Theme::dark();
200+
let viewport = super::super::viewport::Viewport::new();
201+
202+
let frame = terminal
203+
.draw(|frame| {
204+
let table_area = mode.render(
205+
frame,
206+
frame.area(),
207+
EditorReadModel {
208+
theme,
209+
viewport: &viewport,
210+
blink_visible: true,
211+
selection_stats: None,
212+
},
213+
);
214+
215+
assert_eq!(table_area, Rect::new(0, 0, 40, 8));
216+
})
217+
.unwrap();
218+
219+
assert!(
220+
frame
221+
.buffer
222+
.content()
223+
.iter()
224+
.all(|cell| cell.symbol() != "↵")
225+
);
226+
assert_eq!(frame.buffer[(6, 9)].symbol(), "b");
227+
assert_eq!(frame.buffer[(7, 9)].symbol(), "█");
228+
}
229+
230+
#[test]
231+
fn shift_enter_inserts_newline_without_committing() {
232+
let mut mode = EditMode::new("a".to_string(), None);
233+
let result = mode.handle_key(
234+
EditorView::new(None),
235+
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
236+
);
237+
238+
assert!(matches!(result, EventResult::Handled));
239+
assert_eq!(mode.edit_buffer(), Some("a\n"));
240+
}
241+
242+
#[test]
243+
fn enter_commits_multiline_buffer() {
244+
let mut mode = EditMode::new("a\nb".to_string(), None);
245+
let result = mode.handle_key(
246+
EditorView::new(None),
247+
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
248+
);
249+
250+
assert!(matches!(
251+
result,
252+
EventResult::Command(EditorIntent::CommitEdit(raw)) if raw == "a\nb"
253+
));
254+
}
255+
}

0 commit comments

Comments
 (0)