Skip to main content

taba_cli/tui/app/
mod.rs

1//! The interactive loop (doc 34, section 4): prepares the terminal (and gives it back on exit and on panic), turns
2//! keys, the clock and the workers' replies into `Msg`s, runs `update`, carries out its effects and draws.
3
4use std::io;
5use std::sync::mpsc;
6use std::sync::Arc;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use ratatui::crossterm::event::{
10    self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
11    EnableFocusChange, EnableMouseCapture, Event, KeyEventKind, MouseEventKind,
12};
13use ratatui::crossterm::execute;
14
15use crate::profile::{ConfigFile, Profile};
16use crate::tui::effect::Effect;
17use crate::tui::i18n::Lang;
18use crate::tui::model::{Model, Options};
19use crate::tui::msg::Msg;
20use crate::tui::update::update;
21use crate::tui::view::view;
22use crate::tui::worker::{Backend, SwitchBackend, Workers, THREADS};
23
24/// The clock tick of the interface.
25const TICK: Duration = Duration::from_millis(250);
26/// How long to wait for a key before looking at the workers again.
27const POLL: Duration = Duration::from_millis(40);
28
29/// What the loop asks of the world besides the network.
30pub trait Host {
31    fn save_language(&mut self, lang: Lang) -> Result<(), String>;
32    /// Writes a new file (never over an existing one).
33    fn write_file(&mut self, path: &str, contents: &str) -> Result<(), String>;
34    /// Writes a new file that only its owner can read (a token secret); never over an existing one.
35    fn write_secret(&mut self, path: &str, contents: &str) -> Result<(), String>;
36    /// Writes the default profile and the profiles, keeping the rest of the file as it is on disk.
37    fn save_profiles(&mut self, file: &ConfigFile) -> Result<(), String>;
38    /// Tries the connection of a profile that may not be saved.
39    fn check_profile(&mut self, profile: &Profile) -> crate::tui::msg::CheckResult;
40    /// What the calls of a profile of the file go through, and the address it ends up using.
41    fn connect(&mut self, name: &str) -> Result<Connection, String>;
42}
43
44/// A connection to a server, made with a profile.
45pub struct Connection {
46    pub backend: Arc<dyn Backend>,
47    pub url: String,
48}
49
50/// Gives the terminal back as it was, also when the loop ends by an error or a panic unwinds.
51struct TerminalGuard {
52    mouse: bool,
53}
54
55impl TerminalGuard {
56    fn enter(mouse: bool) -> TerminalGuard {
57        let mut out = io::stdout();
58        if mouse {
59            let _ = execute!(out, EnableMouseCapture);
60        }
61        let _ = execute!(out, EnableBracketedPaste, EnableFocusChange);
62        TerminalGuard { mouse }
63    }
64}
65
66impl Drop for TerminalGuard {
67    fn drop(&mut self) {
68        let mut out = io::stdout();
69        if self.mouse {
70            let _ = execute!(out, DisableMouseCapture);
71        }
72        let _ = execute!(out, DisableBracketedPaste, DisableFocusChange);
73    }
74}
75
76fn wall_ms() -> i64 {
77    SystemTime::now()
78        .duration_since(UNIX_EPOCH)
79        .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(0))
80}
81
82/// Carries out the effects of an `update`. Returns `false` when the app must quit.
83fn carry_out(
84    effects: Vec<Effect>,
85    model: &mut Model,
86    workers: &Workers,
87    switch: &SwitchBackend,
88    host: &mut dyn Host,
89) -> bool {
90    let mut keep_going = true;
91    for effect in effects {
92        match effect {
93            Effect::Call {
94                id,
95                generation,
96                call,
97            } => workers.submit(id, generation, call),
98            Effect::Quit => keep_going = false,
99            Effect::WriteFile { path, contents } => {
100                let result = host.write_file(&path, &contents);
101                let more = update(model, Msg::FileWritten { path, result });
102                keep_going &= carry_out(more, model, workers, switch, host);
103            }
104            Effect::SaveLanguage(lang) => {
105                let result = host.save_language(lang);
106                let more = update(model, Msg::LanguageSaved(result));
107                keep_going &= carry_out(more, model, workers, switch, host);
108            }
109            Effect::WriteSecret { path, contents } => {
110                let result = host.write_secret(&path, &contents);
111                let more = update(model, Msg::FileWritten { path, result });
112                keep_going &= carry_out(more, model, workers, switch, host);
113            }
114            Effect::Copy(text) => copy_to_clipboard(&text),
115            Effect::SaveProfiles { file, select } => {
116                let result = host.save_profiles(&file);
117                let more = update(
118                    model,
119                    Msg::ProfilesSaved {
120                        file,
121                        select,
122                        result,
123                    },
124                );
125                keep_going &= carry_out(more, model, workers, switch, host);
126            }
127            Effect::CheckProfile(profile) => {
128                let result = host.check_profile(&profile);
129                let more = update(model, Msg::ProfileChecked(result));
130                keep_going &= carry_out(more, model, workers, switch, host);
131            }
132            Effect::Connect(name) => {
133                let (url, result) = match host.connect(&name) {
134                    Ok(connection) => {
135                        switch.switch(connection.backend);
136                        (connection.url, Ok(()))
137                    }
138                    Err(e) => (String::new(), Err(e)),
139                };
140                let more = update(
141                    model,
142                    Msg::Connected {
143                        profile: name,
144                        url,
145                        result,
146                    },
147                );
148                keep_going &= carry_out(more, model, workers, switch, host);
149            }
150        }
151    }
152    keep_going
153}
154
155/// Puts text on the clipboard with the OSC 52 sequence; a terminal that does not support it ignores it.
156fn copy_to_clipboard(text: &str) {
157    use std::io::Write;
158    let mut out = io::stdout();
159    let _ = write!(
160        out,
161        "]52;c;{}",
162        taba_client::base64::encode(text.as_bytes())
163    );
164    let _ = out.flush();
165}
166
167/// Runs the interface until the person quits.
168pub fn run(
169    backend: Arc<dyn Backend>,
170    options: Options,
171    mouse: bool,
172    host: &mut dyn Host,
173) -> io::Result<()> {
174    let mut terminal = ratatui::init();
175    let _guard = TerminalGuard::enter(mouse);
176    let result = event_loop(&mut terminal, backend, options, host);
177    ratatui::restore();
178    result
179}
180
181fn event_loop(
182    terminal: &mut ratatui::DefaultTerminal,
183    backend: Arc<dyn Backend>,
184    options: Options,
185    host: &mut dyn Host,
186) -> io::Result<()> {
187    let (results, replies) = mpsc::channel::<Msg>();
188    let switch = Arc::new(SwitchBackend::new(backend));
189    let workers = Workers::start(Arc::clone(&switch) as Arc<dyn Backend>, results, THREADS);
190    let mut model = Model::new(options);
191    let size = terminal.size()?;
192    model.size = (size.width, size.height);
193    let started = Instant::now();
194    let mut last_tick: Option<Instant> = None;
195    let mut dirty = true;
196    loop {
197        let mut messages: Vec<Msg> = Vec::new();
198        if event::poll(POLL)? {
199            match event::read()? {
200                Event::Key(key) if key.kind != KeyEventKind::Release => {
201                    messages.push(Msg::Key(key))
202                }
203                Event::Resize(width, height) => messages.push(Msg::Resize(width, height)),
204                Event::Paste(text) => messages.push(Msg::Paste(text)),
205                Event::FocusGained => messages.push(Msg::Focus(true)),
206                Event::FocusLost => messages.push(Msg::Focus(false)),
207                Event::Mouse(mouse) => match mouse.kind {
208                    MouseEventKind::ScrollUp => messages.push(Msg::Scroll(-3)),
209                    MouseEventKind::ScrollDown => messages.push(Msg::Scroll(3)),
210                    _ => {}
211                },
212                _ => {}
213            }
214        }
215        while let Ok(reply) = replies.try_recv() {
216            messages.push(reply);
217        }
218        if last_tick.is_none_or(|t| t.elapsed() >= TICK) {
219            last_tick = Some(Instant::now());
220            messages.push(Msg::Tick {
221                now: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
222                wall_ms: wall_ms(),
223            });
224        }
225        dirty |= !messages.is_empty();
226        for message in messages {
227            let effects = update(&mut model, message);
228            if !carry_out(effects, &mut model, &workers, &switch, host) {
229                return Ok(());
230            }
231        }
232        if dirty {
233            terminal.draw(|frame| view(frame, &model))?;
234            dirty = false;
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests;