Skip to main content

muster/adapter/tui/
runtime.rs

1use std::{
2    ops::ControlFlow,
3    path::PathBuf,
4    thread,
5    time::{Duration, Instant},
6};
7
8use crossbeam_channel::{Receiver, Sender, after, bounded, never, select, unbounded};
9use crossterm::event;
10use ratatui::layout::{Rect, Size};
11use typed_builder::TypedBuilder;
12
13use super::{TerminalGuard, app::App, event::RuntimeEvent, watch::NotifyConfigWatcher};
14use crate::{
15    application::Workspace,
16    domain::port::{Notifier, PathCompleter, ProcessRunner, ProjectRegistry, SettingsStore},
17    error::Result,
18};
19
20/// The driven adapters the TUI runs on, bundled so [`run`] takes one wiring
21/// object instead of a long argument list. Built at the composition root and
22/// consumed (moved) into the app. No `Getters`: the fields are `Box<dyn _>` and
23/// are moved out once, not borrowed.
24#[derive(TypedBuilder)]
25pub struct Adapters {
26    runner: Box<dyn ProcessRunner>,
27    registry: Box<dyn ProjectRegistry>,
28    completer: Box<dyn PathCompleter + Send>,
29    notifier: Box<dyn Notifier>,
30    settings_store: Box<dyn SettingsStore>,
31}
32
33/// Poll timeout for the input reader thread.
34const INPUT_POLL_INTERVAL: Duration = Duration::from_millis(50);
35/// Bounded output-channel capacity; back-pressures noisy PTYs so memory stays
36/// bounded instead of growing with an ever-larger backlog.
37const OUTPUT_CAPACITY: usize = 1024;
38/// Maximum output events drained per iteration before a single redraw.
39const MAX_BATCH: usize = 512;
40
41/// Runs the TUI. Terminal input flows on its own unbounded channel drained with
42/// priority, so keystrokes are never blocked behind a flood of process output on
43/// the bounded output channel.
44///
45/// # Errors
46/// Returns an error if querying the terminal size or drawing a frame fails.
47pub fn run(
48    guard: &mut TerminalGuard,
49    workspace: Workspace,
50    adapters: Adapters,
51    current_config: PathBuf,
52) -> Result<()> {
53    let Adapters {
54        runner,
55        registry,
56        completer,
57        notifier,
58        settings_store,
59    } = adapters;
60    let (control_tx, control_rx) = unbounded();
61    let (output_tx, output_rx) = bounded(OUTPUT_CAPACITY);
62    let watch_tx = output_tx.clone();
63    spawn_input_thread(control_tx);
64
65    let area = size_to_rect(guard.terminal_mut().size()?);
66    let mut app = App::new(
67        workspace,
68        runner,
69        output_tx,
70        area,
71        completer,
72        registry,
73        current_config,
74    );
75    app.spawn_completion_worker();
76    app.set_config_watcher(Box::new(NotifyConfigWatcher::new(watch_tx)));
77    app.set_notifier(notifier);
78    app.set_settings_store(settings_store);
79    app.start();
80
81    // Children are running now, so shut them down on every return path,
82    // including a draw error, rather than leaking them.
83    let result = run_loop(guard, &mut app, &control_rx, &output_rx);
84    app.shutdown();
85    result
86}
87
88/// Drives the draw/update loop until the user quits or terminal input closes.
89///
90/// # Errors
91/// Returns an error if drawing a frame fails.
92fn run_loop(
93    guard: &mut TerminalGuard,
94    app: &mut App,
95    control_rx: &Receiver<RuntimeEvent>,
96    output_rx: &Receiver<RuntimeEvent>,
97) -> Result<()> {
98    guard.terminal_mut().draw(|frame| app.render(frame))?;
99    while app.is_running() {
100        match drain(app, control_rx, output_rx) {
101            ControlFlow::Break(()) => break,
102            ControlFlow::Continue(redraw) if redraw && app.is_running() => {
103                guard.terminal_mut().draw(|frame| app.render(frame))?;
104            },
105            ControlFlow::Continue(_) => {},
106        }
107    }
108    Ok(())
109}
110
111/// Blocks for the next event or activity deadline, then drains all pending input
112/// (priority) followed by a bounded batch of output. Returns whether to redraw,
113/// or `Break` when the loop should stop.
114fn drain(
115    app: &mut App,
116    control_rx: &Receiver<RuntimeEvent>,
117    output_rx: &Receiver<RuntimeEvent>,
118) -> ControlFlow<(), bool> {
119    let activity_timeout = app
120        .next_activity_deadline()
121        .map(|deadline| after(deadline.saturating_duration_since(Instant::now())))
122        .unwrap_or_else(never);
123    let mut redraw = false;
124    select! {
125        recv(control_rx) -> msg => match msg {
126            Ok(event) => if !apply(app, event) {
127                return ControlFlow::Break(());
128            } else {
129                redraw = true;
130            },
131            Err(_) => return ControlFlow::Break(()),
132        },
133        recv(output_rx) -> msg => if let Ok(event) = msg {
134            if !apply(app, event) {
135                return ControlFlow::Break(());
136            }
137            redraw = true;
138        },
139        recv(activity_timeout) -> now => if let Ok(now) = now {
140            redraw = app.expire_quiet_activity(now);
141        },
142    }
143    while let Ok(event) = control_rx.try_recv() {
144        if !apply(app, event) {
145            return ControlFlow::Break(());
146        }
147        redraw = true;
148    }
149    for _ in 0..MAX_BATCH {
150        match output_rx.try_recv() {
151            Ok(event) => {
152                if !apply(app, event) {
153                    return ControlFlow::Break(());
154                }
155                redraw = true;
156            },
157            Err(_) => break,
158        }
159    }
160    ControlFlow::Continue(redraw)
161}
162
163/// Applies one event to the app; returns `false` when the loop should stop.
164fn apply(app: &mut App, event: RuntimeEvent) -> bool {
165    match event {
166        RuntimeEvent::Input(event) => app.handle_input(event),
167        RuntimeEvent::Output {
168            pane,
169            generation,
170            output,
171        } => app.handle_output(pane, generation, output),
172        RuntimeEvent::Respawn { pane, generation } => app.handle_respawn(pane, generation),
173        RuntimeEvent::ForceStop {
174            pane,
175            spawn_generation,
176            shutdown_generation,
177        } => app.handle_force_stop(pane, spawn_generation, shutdown_generation),
178        RuntimeEvent::Completions {
179            generation,
180            candidates,
181        } => app.handle_completions(generation, candidates),
182        RuntimeEvent::ConfigChanged { path } => app.handle_config_changed(path),
183        RuntimeEvent::InputClosed => return false,
184    }
185    true
186}
187
188/// Spawns a thread forwarding crossterm input onto the control channel, sending
189/// `InputClosed` if the input source errors so the loop never blocks forever.
190fn spawn_input_thread(sender: Sender<RuntimeEvent>) {
191    thread::spawn(move || {
192        loop {
193            match event::poll(INPUT_POLL_INTERVAL) {
194                Ok(true) => match event::read() {
195                    Ok(event) => {
196                        if sender.send(RuntimeEvent::Input(event)).is_err() {
197                            break;
198                        }
199                    },
200                    Err(_) => {
201                        let _ = sender.send(RuntimeEvent::InputClosed);
202                        break;
203                    },
204                },
205                Ok(false) => {},
206                Err(_) => {
207                    let _ = sender.send(RuntimeEvent::InputClosed);
208                    break;
209                },
210            }
211        }
212    });
213}
214
215/// Converts a terminal size into a full-screen rectangle.
216fn size_to_rect(size: Size) -> Rect {
217    Rect::new(0, 0, size.width, size.height)
218}