Skip to main content

upgate_presentation/tui/selection/
mod.rs

1mod input;
2mod render;
3mod screen;
4
5use std::io;
6use std::sync::mpsc::{Receiver, TryRecvError};
7use std::time::Duration;
8
9use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture};
10use crossterm::execute;
11use crossterm::terminal::{
12    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
13};
14use ratatui::Terminal;
15use ratatui::backend::CrosstermBackend;
16use ratatui::layout::Rect;
17use upgate_domain::{ManagerId, SelectedItem, UpdateSelectionPolicy, VersionPolicy};
18
19use self::input::handle_selection_ready_events;
20use self::render::draw_selection;
21use self::screen::InteractiveSelectionScreen;
22use crate::SelectionView;
23
24const MAX_DRAINED_INPUT_EVENTS: usize = 256;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct InteractiveManagerSelectionDraft {
28    pub manager_id: ManagerId,
29    pub selected_items: Vec<SelectedItem>,
30    pub selection_policy: UpdateSelectionPolicy,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum InteractiveSelectionOutcome {
35    Confirmed(Vec<InteractiveManagerSelectionDraft>),
36    Cancelled,
37    Interrupted,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SelectionInput {
42    Up,
43    Down,
44    NextTab,
45    PreviousTab,
46    ToggleCurrent,
47    SelectVisible,
48    SelectNoneVisible,
49    ToggleViewAll,
50    OpenTargetPicker,
51    PickerUp,
52    PickerDown,
53    PickerPreviousRow,
54    PickerNextRow,
55    PickerConfirm,
56    PickerCancel,
57    RecommendedTarget,
58    Confirm,
59    Cancel,
60    Interrupt,
61    Ignore,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum SelectionControl {
66    Continue,
67    Confirm,
68    Cancel,
69    Interrupt,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum InteractiveSelectionPlanningEvent {
74    ManagerStarted {
75        manager_id: ManagerId,
76    },
77    CommandStarted {
78        command: String,
79    },
80    ManagerReady {
81        view: SelectionView,
82        selection_policy: UpdateSelectionPolicy,
83        version_policy: VersionPolicy,
84    },
85    ManagerError {
86        manager_id: ManagerId,
87        detail: String,
88    },
89    PlanningFailed {
90        detail: String,
91    },
92    Finished,
93}
94
95/// Runs the terminal selection UI from manager ids while planning events arrive externally.
96///
97/// # Errors
98///
99/// Returns an I/O error for terminal setup, rendering, event reading, or typed selection
100/// validation failures surfaced by the event loop.
101#[expect(clippy::needless_pass_by_value)]
102pub fn run_interactive_selection_with_planning_events(
103    manager_ids: Vec<ManagerId>,
104    planning_events: Receiver<InteractiveSelectionPlanningEvent>,
105    trace_commands: bool,
106) -> io::Result<InteractiveSelectionOutcome> {
107    run_interactive_selection_screen(
108        InteractiveSelectionScreen::from_manager_ids(manager_ids).trace_commands(trace_commands),
109        &planning_events,
110    )
111}
112
113fn run_interactive_selection_screen(
114    mut screen: InteractiveSelectionScreen,
115    planning_events: &Receiver<InteractiveSelectionPlanningEvent>,
116) -> io::Result<InteractiveSelectionOutcome> {
117    let mut stdout = io::stdout();
118    enable_raw_mode()?;
119    if let Err(err) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) {
120        let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
121        let _ = disable_raw_mode();
122        return Err(err);
123    }
124    let backend = CrosstermBackend::new(stdout);
125    let mut terminal = match Terminal::new(backend) {
126        Ok(terminal) => terminal,
127        Err(err) => {
128            let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen);
129            let _ = disable_raw_mode();
130            return Err(err);
131        }
132    };
133
134    let result = run_selection_loop(&mut terminal, &mut screen, planning_events);
135
136    let cleanup = cleanup_selection_terminal(&mut terminal);
137    match (result, cleanup) {
138        (Ok(outcome), Ok(())) => Ok(outcome),
139        (Err(err), Ok(()) | Err(_)) | (Ok(_), Err(err)) => Err(err),
140    }
141}
142
143fn cleanup_selection_terminal(
144    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
145) -> io::Result<()> {
146    let raw_mode = disable_raw_mode();
147    let screen = execute!(
148        terminal.backend_mut(),
149        DisableMouseCapture,
150        LeaveAlternateScreen
151    );
152    let cursor = terminal.show_cursor();
153    raw_mode?;
154    screen?;
155    cursor
156}
157
158fn run_selection_loop(
159    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
160    screen: &mut InteractiveSelectionScreen,
161    planning_events: &Receiver<InteractiveSelectionPlanningEvent>,
162) -> io::Result<InteractiveSelectionOutcome> {
163    loop {
164        drain_planning_events(screen, planning_events);
165        terminal.draw(|frame| draw_selection(frame, screen))?;
166        if !event::poll(Duration::from_millis(100))? {
167            screen.tick();
168            continue;
169        }
170        drain_planning_events(screen, planning_events);
171        let size = terminal.size()?;
172        let area = Rect::new(0, 0, size.width, size.height);
173        let control = handle_selection_ready_events(screen, area)?.map_err(io::Error::other)?;
174        match control {
175            SelectionControl::Continue => {}
176            SelectionControl::Confirm => {
177                return Ok(InteractiveSelectionOutcome::Confirmed(
178                    screen.selection_drafts(),
179                ));
180            }
181            SelectionControl::Cancel => return Ok(InteractiveSelectionOutcome::Cancelled),
182            SelectionControl::Interrupt => return Ok(InteractiveSelectionOutcome::Interrupted),
183        }
184    }
185}
186
187fn drain_planning_events(
188    screen: &mut InteractiveSelectionScreen,
189    planning_events: &Receiver<InteractiveSelectionPlanningEvent>,
190) {
191    loop {
192        match planning_events.try_recv() {
193            Ok(event) => screen.apply_planning_event(event),
194            Err(TryRecvError::Empty) => break,
195            Err(TryRecvError::Disconnected) => {
196                screen.planning_events_disconnected();
197                break;
198            }
199        }
200    }
201}