Skip to main content

interactive/
interactive.rs

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    // Run this example manually in a real terminal:
26    //
27    //     cargo run --example interactive
28    //
29    // It uses crossterm for raw-mode keyboard events while Mural owns only the
30    // normal-buffer rendering surface.
31    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}