1use std::{
2 io,
3 time::{Duration, Instant},
4};
5
6use crossterm::{
7 event::{
8 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode as CrosstermKeyCode,
9 KeyEvent as CrosstermKeyEvent, KeyEventKind, KeyModifiers as CrosstermKeyModifiers,
10 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
11 },
12 execute, terminal as crossterm_terminal,
13};
14use mural::{
15 Color, Hr, KeyOutcome, Line, ListItem, Padding, Render, Size, Span, Spinner, StdoutBackend,
16 Style, Terminal, Text, TextError, Textarea,
17};
18
19const FPS: u64 = 15;
20const FRAME_DELAY: Duration = Duration::from_millis(1_000 / FPS);
21const ID_INPUT: &str = "input";
22const ID_TYPING: &str = "typing";
23
24fn main() -> Result<(), Box<dyn std::error::Error>> {
25 let mut session = InteractiveSession::start()?;
32
33 setup_ui(session.terminal_mut())?;
34 run_event_loop(&mut session)?;
35 session.finish()?;
36
37 println!("finished: submitted messages remain above.");
38 Ok(())
39}
40
41fn setup_ui(
42 terminal: &mut Terminal<StdoutBackend<io::Stdout>>,
43) -> Result<(), Box<dyn std::error::Error>> {
44 terminal.push_pinned(Text::from_plain("")?)?;
45 terminal.insert_pinned(ID_TYPING, pinned(TypingIndicator::new()?))?;
46 terminal.push_pinned(separator())?;
47 terminal.insert_pinned(
48 ID_INPUT,
49 pinned(
50 Textarea::new()
51 .placeholder("type something…")?
52 .placeholder_style(Style::new().dim().italic())
53 .prompt_style(Style::new().fg(Color::Cyan))
54 .max_height(5),
55 ),
56 )?;
57 terminal.push_pinned(separator())?;
58 terminal.push_pinned(pinned(help_text()?))?;
59 terminal.render()?;
60 Ok(())
61}
62
63fn run_event_loop(session: &mut InteractiveSession) -> Result<(), Box<dyn std::error::Error>> {
64 let mut dirty = false;
65 let mut animate = false;
66 let mut last_render = Instant::now();
67
68 loop {
69 let should_render = dirty || animate;
70 if should_render && last_render.elapsed() >= FRAME_DELAY {
71 session.terminal_mut().render()?;
72 last_render = Instant::now();
73 dirty = false;
74 }
75
76 let poll_timeout = if should_render {
77 FRAME_DELAY.saturating_sub(last_render.elapsed())
78 } else {
79 FRAME_DELAY
80 };
81
82 if !event::poll(poll_timeout)? {
83 continue;
84 }
85
86 match event::read()? {
87 Event::Key(key) => {
88 if should_quit(key) {
89 break;
90 }
91 let (changed, typing) = handle_input_key(session.terminal_mut(), key)?;
92 dirty |= changed;
93 if let Some(typing) = typing {
94 animate = typing;
95 }
96 }
97 Event::Resize(width, height) => {
98 session.terminal_mut().resize(Size::new(width, height))?;
99 dirty = true;
100 }
101 Event::Paste(text) => {
102 animate = handle_paste(session.terminal_mut(), text)?;
103 dirty = true;
104 }
105 Event::FocusGained | Event::FocusLost | Event::Mouse(_) => {}
106 }
107 }
108
109 if dirty {
110 session.terminal_mut().render()?;
111 }
112
113 Ok(())
114}
115
116fn handle_input_key(
117 terminal: &mut Terminal<StdoutBackend<io::Stdout>>,
118 key: CrosstermKeyEvent,
119) -> Result<(bool, Option<bool>), Box<dyn std::error::Error>> {
120 let outcome = terminal
121 .pinned_block_mut::<Padding<Textarea>>(ID_INPUT)?
122 .content_mut()
123 .handle_key_event(key);
124
125 let typing = match outcome {
126 KeyOutcome::Submit => Some(submit_input(terminal)?),
127 KeyOutcome::Changed | KeyOutcome::Unchanged => Some(update_typing_indicator(terminal)?),
128 KeyOutcome::Ignored => None,
129 };
130
131 Ok((!matches!(outcome, KeyOutcome::Ignored), typing))
132}
133
134fn handle_paste(
135 terminal: &mut Terminal<StdoutBackend<io::Stdout>>,
136 text: String,
137) -> Result<bool, Box<dyn std::error::Error>> {
138 terminal
139 .pinned_block_mut::<Padding<Textarea>>(ID_INPUT)?
140 .content_mut()
141 .insert_str(text);
142 update_typing_indicator(terminal)
143}
144
145fn submit_input(
146 terminal: &mut Terminal<StdoutBackend<io::Stdout>>,
147) -> Result<bool, Box<dyn std::error::Error>> {
148 let submitted = terminal
149 .pinned_block_mut::<Padding<Textarea>>(ID_INPUT)?
150 .content_mut()
151 .take();
152 if !submitted.is_empty() {
153 terminal.push_live(submitted_message(&submitted)?)?;
154 }
155
156 update_typing_indicator(terminal)
157}
158
159fn update_typing_indicator(
160 terminal: &mut Terminal<StdoutBackend<io::Stdout>>,
161) -> Result<bool, Box<dyn std::error::Error>> {
162 let is_typing = !terminal
163 .pinned_block_mut::<Padding<Textarea>>(ID_INPUT)?
164 .content_mut()
165 .value()
166 .is_empty();
167 terminal
168 .pinned_block_mut::<Padding<TypingIndicator>>(ID_TYPING)?
169 .content_mut()
170 .set_visible(is_typing);
171 Ok(is_typing)
172}
173
174fn should_quit(key: CrosstermKeyEvent) -> bool {
175 if key.kind == KeyEventKind::Release {
176 return false;
177 }
178
179 matches!(key.code, CrosstermKeyCode::Esc)
180 || matches!(key.code, CrosstermKeyCode::Char('c' | 'C') if key.modifiers.contains(CrosstermKeyModifiers::CONTROL))
181}
182
183fn submitted_message(content: &str) -> Result<Padding<ListItem<Text>>, TextError> {
184 Ok(Padding::new(
185 ListItem::new(Text::from_raw_lossy(content)?)
186 .bullet("›")?
187 .bullet_style(Style::new().fg(Color::BrightCyan).bold())
188 .gap(1),
189 )
190 .top(1)
191 .left(1))
192}
193
194fn pinned<T>(block: T) -> Padding<T> {
195 Padding::new(block).left(1)
196}
197
198fn separator() -> Hr {
199 Hr::new().style(Style::new().fg(Color::Cyan))
200}
201
202fn help_text() -> Result<Text, TextError> {
203 styled_text(
204 "Enter submit · Shift/Alt+Enter newline · Esc/Ctrl+C quit",
205 Style::new().dim(),
206 )
207}
208
209fn styled_text(content: &str, style: Style) -> Result<Text, TextError> {
210 Ok(Text::from_lines(vec![Line::from_spans(vec![Span::new(
211 content, style,
212 )?])]))
213}
214
215struct TypingIndicator {
216 spinner: Spinner,
217 visible: bool,
218}
219
220impl TypingIndicator {
221 fn new() -> Result<Self, TextError> {
222 Ok(Self {
223 spinner: Spinner::new(styled_text("Typing...", Style::new().dim().italic())?)
224 .spinner_style(Style::new().fg(Color::BrightYellow)),
225 visible: false,
226 })
227 }
228
229 fn set_visible(&mut self, visible: bool) {
230 if visible && !self.visible {
231 self.spinner.reset();
232 }
233 self.visible = visible;
234 }
235}
236
237impl Render for TypingIndicator {
238 fn render(&self, width: u16) -> Text {
239 if self.visible {
240 self.spinner.render(width)
241 } else {
242 Text::empty()
243 }
244 }
245
246 fn render_every_frame(&self) -> bool {
247 self.visible && self.spinner.render_every_frame()
248 }
249}
250
251struct InteractiveSession {
252 terminal: Terminal<StdoutBackend<io::Stdout>>,
253 raw_mode: bool,
254 bracketed_paste: bool,
255 keyboard_enhancement: bool,
256}
257
258impl InteractiveSession {
259 fn start() -> io::Result<Self> {
260 crossterm_terminal::enable_raw_mode()?;
261
262 let mut stdout = io::stdout();
263 if let Err(error) = execute!(stdout, EnableBracketedPaste) {
264 let _ = crossterm_terminal::disable_raw_mode();
265 return Err(error);
266 }
267
268 let keyboard_enhancement = execute!(
269 stdout,
270 PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES),
271 )
272 .is_ok();
273
274 match Terminal::stdout() {
275 Ok(terminal) => Ok(Self {
276 terminal,
277 raw_mode: true,
278 bracketed_paste: true,
279 keyboard_enhancement,
280 }),
281 Err(error) => {
282 if keyboard_enhancement {
283 let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
284 }
285 let _ = execute!(io::stdout(), DisableBracketedPaste);
286 let _ = crossterm_terminal::disable_raw_mode();
287 Err(error)
288 }
289 }
290 }
291
292 fn terminal_mut(&mut self) -> &mut Terminal<StdoutBackend<io::Stdout>> {
293 &mut self.terminal
294 }
295
296 fn finish(&mut self) -> io::Result<()> {
297 self.terminal.finish()?;
298 if self.keyboard_enhancement {
299 execute!(io::stdout(), PopKeyboardEnhancementFlags)?;
300 self.keyboard_enhancement = false;
301 }
302 if self.bracketed_paste {
303 execute!(io::stdout(), DisableBracketedPaste)?;
304 self.bracketed_paste = false;
305 }
306 if self.raw_mode {
307 crossterm_terminal::disable_raw_mode()?;
308 self.raw_mode = false;
309 }
310 Ok(())
311 }
312}
313
314impl Drop for InteractiveSession {
315 fn drop(&mut self) {
316 let _ = self.terminal.finish();
317 if self.keyboard_enhancement {
318 let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags);
319 self.keyboard_enhancement = false;
320 }
321 if self.bracketed_paste {
322 let _ = execute!(io::stdout(), DisableBracketedPaste);
323 self.bracketed_paste = false;
324 }
325 if self.raw_mode {
326 let _ = crossterm_terminal::disable_raw_mode();
327 self.raw_mode = false;
328 }
329 }
330}