use std::collections::HashMap;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::utils::ui::hints::{Hint, render_hints};
use crate::utils::ui::tui_ui::{Answer, OutputLevel, OutputLine, Prompt, PullState};
#[derive(Debug)]
enum PromptState {
Select {
message: String,
items: Vec<String>,
cursor: usize,
list_state: ListState,
},
MultiSelect {
message: String,
items: Vec<String>,
checked: Vec<bool>,
cursor: usize,
list_state: ListState,
},
Confirm {
message: String,
yes: bool,
},
Text {
message: String,
buf: String,
allow_empty: bool,
password: bool,
},
}
impl PromptState {
fn from_prompt(prompt: Prompt) -> Self {
match prompt {
Prompt::Select {
message,
items,
default,
} => {
let cursor = default.min(items.len().saturating_sub(1));
let mut list_state = ListState::default();
list_state.select(Some(cursor));
Self::Select {
message,
cursor,
items,
list_state,
}
}
Prompt::MultiSelect {
message,
items,
defaults,
} => {
let len = items.len();
let checked = if defaults.len() == len {
defaults
} else {
vec![false; len]
};
let mut list_state = ListState::default();
list_state.select(Some(0));
Self::MultiSelect {
message,
items,
checked,
cursor: 0,
list_state,
}
}
Prompt::Confirm { message, default } => Self::Confirm {
message,
yes: default,
},
Prompt::Text {
message,
default,
allow_empty,
password,
} => Self::Text {
message,
buf: default,
allow_empty,
password,
},
}
}
fn hint(&self) -> String {
match self {
PromptState::Select { .. } => render_hints(&[Hint::Move, Hint::Confirm, Hint::Cancel]),
PromptState::MultiSelect { .. } => {
render_hints(&[Hint::Move, Hint::Toggle, Hint::Confirm, Hint::Cancel])
}
PromptState::Confirm { .. } => {
render_hints(&[Hint::ConfirmYN, Hint::Confirm, Hint::Cancel])
}
PromptState::Text { .. } => {
render_hints(&[Hint::Typing("Edit"), Hint::Confirm, Hint::Cancel])
}
}
}
}
pub struct SetupPane {
pub title: String,
pub output: std::sync::Arc<std::sync::Mutex<Vec<OutputLine>>>,
pulls: std::sync::Arc<std::sync::Mutex<HashMap<u64, PullState>>>,
output_scroll: usize,
prompt_rx: std::sync::mpsc::Receiver<Prompt>,
answer_tx: std::sync::mpsc::SyncSender<Answer>,
active: Option<PromptState>,
pub finished: bool,
}
impl SetupPane {
pub fn new(
title: String,
output: std::sync::Arc<std::sync::Mutex<Vec<OutputLine>>>,
pulls: std::sync::Arc<std::sync::Mutex<HashMap<u64, PullState>>>,
prompt_rx: std::sync::mpsc::Receiver<Prompt>,
answer_tx: std::sync::mpsc::SyncSender<Answer>,
) -> Self {
Self {
title,
output,
pulls,
output_scroll: 0,
prompt_rx,
answer_tx,
active: None,
finished: false,
}
}
pub fn poll(&mut self) -> bool {
match self.prompt_rx.try_recv() {
Ok(prompt) => {
self.active = Some(PromptState::from_prompt(prompt));
true
}
Err(std::sync::mpsc::TryRecvError::Empty) => false,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.finished = true;
self.active = None;
true
}
}
}
pub fn hint(&self) -> String {
match &self.active {
Some(p) => p.hint(),
None if self.finished => render_hints(&[Hint::Close]),
None => render_hints(&[Hint::ScrollOutput, Hint::Cancel]),
}
}
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
use crossterm::event::KeyCode;
if key.code == KeyCode::Esc {
self.finished = true;
self.active = None;
return true;
}
match &mut self.active {
None => {
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
self.output_scroll += 1;
true
}
KeyCode::Up | KeyCode::Char('k') => {
self.output_scroll = self.output_scroll.saturating_sub(1);
true
}
_ => false,
}
}
Some(PromptState::Select {
items,
cursor,
list_state,
..
}) => {
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
*cursor = (*cursor + 1).min(items.len().saturating_sub(1));
list_state.select(Some(*cursor));
}
KeyCode::Up | KeyCode::Char('k') => {
*cursor = cursor.saturating_sub(1);
list_state.select(Some(*cursor));
}
KeyCode::Enter => {
let idx = *cursor;
self.active = None;
let _ = self.answer_tx.send(Answer::Index(idx));
}
_ => return false,
}
true
}
Some(PromptState::MultiSelect {
items,
checked,
cursor,
list_state,
..
}) => {
match key.code {
KeyCode::Down | KeyCode::Char('j') => {
*cursor = (*cursor + 1).min(items.len().saturating_sub(1));
list_state.select(Some(*cursor));
}
KeyCode::Up | KeyCode::Char('k') => {
*cursor = cursor.saturating_sub(1);
list_state.select(Some(*cursor));
}
KeyCode::Char(' ') => {
if let Some(c) = checked.get_mut(*cursor) {
*c = !*c;
}
}
KeyCode::Enter => {
let indices: Vec<usize> = checked
.iter()
.enumerate()
.filter_map(|(i, &c)| c.then_some(i))
.collect();
self.active = None;
let _ = self.answer_tx.send(Answer::Indices(indices));
}
_ => return false,
}
true
}
Some(PromptState::Confirm { yes, .. }) => {
match key.code {
KeyCode::Left | KeyCode::Char('h') | KeyCode::Right | KeyCode::Char('l') => {
*yes = !*yes;
}
KeyCode::Char('y') | KeyCode::Char('Y') => {
*yes = true;
}
KeyCode::Char('n') | KeyCode::Char('N') => {
*yes = false;
}
KeyCode::Enter => {
let answer = *yes;
self.active = None;
let _ = self.answer_tx.send(Answer::Bool(answer));
}
_ => return false,
}
true
}
Some(PromptState::Text {
buf, allow_empty, ..
}) => {
match key.code {
KeyCode::Char(c) => {
buf.push(c);
}
KeyCode::Backspace => {
buf.pop();
}
KeyCode::Enter => {
if buf.is_empty() && !*allow_empty {
return true;
}
let text = buf.clone();
self.active = None;
let _ = self.answer_tx.send(Answer::Text(text));
}
_ => return false,
}
true
}
}
}
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
let active_pulls: Vec<PullState> = self
.pulls
.lock()
.unwrap()
.values()
.filter(|p| !p.done)
.cloned()
.collect();
let pull_height = if active_pulls.is_empty() {
0u16
} else {
active_pulls.len() as u16 + 2
};
let prompt_h = prompt_height(&self.active);
let constraints: Vec<Constraint> = {
let mut c = vec![Constraint::Min(3)];
if pull_height > 0 {
c.push(Constraint::Length(pull_height));
}
if prompt_h > 0 {
c.push(Constraint::Length(prompt_h));
}
c
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut idx = 0;
self.render_output_log(frame, chunks[idx]);
idx += 1;
if pull_height > 0 {
render_pull_bars(frame, chunks[idx], &active_pulls);
idx += 1;
}
if prompt_h > 0 {
self.render_prompt(frame, chunks[idx]);
}
}
fn render_output_log(&mut self, frame: &mut Frame, area: Rect) {
let lines: Vec<Line> = {
let guard = self.output.lock().unwrap();
guard
.iter()
.map(|ol| {
let style = match ol.level {
OutputLevel::Info => Style::default(),
OutputLevel::Warn => Style::default().fg(Color::Yellow),
OutputLevel::Error => Style::default().fg(Color::Red),
};
Line::from(Span::styled(ol.text.clone(), style))
})
.collect()
};
let total = lines.len();
let visible = area.height.saturating_sub(2) as usize; if self.active.is_none() && !self.finished {
self.output_scroll = total.saturating_sub(visible);
}
let scroll = self.output_scroll.min(total.saturating_sub(visible));
let status = if self.finished {
" — done"
} else {
" — running…"
};
let title = format!(" {} {status} ", self.title);
let para = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(title))
.wrap(Wrap { trim: false })
.scroll((scroll as u16, 0));
frame.render_widget(para, area);
}
fn render_prompt(&mut self, frame: &mut Frame, area: Rect) {
match &mut self.active {
None => {}
Some(PromptState::Select {
message,
items,
cursor,
list_state,
}) => {
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let prefix = if i == *cursor { "▶ " } else { " " };
let style = if i == *cursor {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(format!("{prefix}{item}"), style)))
})
.collect();
let list = List::new(list_items).block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {message} ")),
);
frame.render_stateful_widget(list, area, list_state);
}
Some(PromptState::MultiSelect {
message,
items,
checked,
cursor,
list_state,
}) => {
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let check = if checked[i] { "[x]" } else { "[ ]" };
let prefix = if i == *cursor { "▶ " } else { " " };
let style = if i == *cursor {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(
format!("{prefix}{check} {item}"),
style,
)))
})
.collect();
let list = List::new(list_items).block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {message} ")),
);
frame.render_stateful_widget(list, area, list_state);
}
Some(PromptState::Confirm { message, yes }) => {
let yes_style = if *yes {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let no_style = if !*yes {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let line = Line::from(vec![
Span::styled(" Yes ", yes_style),
Span::raw(" "),
Span::styled(" No ", no_style),
]);
let para = Paragraph::new(line).block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {message} ")),
);
frame.render_widget(para, area);
}
Some(PromptState::Text {
message,
buf,
password,
..
}) => {
let display = if *password {
"*".repeat(buf.len())
} else {
format!("{buf}█") };
let para = Paragraph::new(display).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(format!(" {message} ")),
);
frame.render_widget(para, area);
}
}
}
}
fn prompt_height(active: &Option<PromptState>) -> u16 {
match active {
None => 0,
Some(PromptState::Select { items, .. }) => (items.len() as u16 + 2).min(14),
Some(PromptState::MultiSelect { items, .. }) => (items.len() as u16 + 2).min(14),
Some(PromptState::Confirm { .. }) => 3,
Some(PromptState::Text { .. }) => 3,
}
}
fn render_pull_bars(frame: &mut Frame, area: Rect, pulls: &[PullState]) {
let inner_width = area.width.saturating_sub(2) as usize;
let lines: Vec<Line> = pulls
.iter()
.map(|p| {
match p.total {
Some(total) if total > 0 => {
let pct = (p.downloaded as f64 / total as f64).clamp(0.0, 1.0);
let label_max = 20usize;
let label = if p.label.len() > label_max {
format!("{}…", &p.label[..label_max - 1])
} else {
p.label.clone()
};
let pct_text = format!(" {:3.0}%", pct * 100.0);
let bar_width = inner_width.saturating_sub(label.len() + pct_text.len() + 3); let filled = ((bar_width as f64) * pct) as usize;
let empty = bar_width.saturating_sub(filled);
Line::from(vec![
Span::styled(format!("{label} ["), Style::default().fg(Color::DarkGray)),
Span::styled("█".repeat(filled), Style::default().fg(Color::Cyan)),
Span::styled("░".repeat(empty), Style::default().fg(Color::DarkGray)),
Span::styled(
format!("]{} {}", pct_text, format_bytes(p.downloaded)),
Style::default().fg(Color::DarkGray),
),
])
}
_ => {
let spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let spin = spinner_chars[(p.downloaded as usize / 65536) % spinner_chars.len()];
Line::from(vec![
Span::styled(
format!("{} {} ", p.label, spin),
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("{} downloaded", format_bytes(p.downloaded)),
Style::default().fg(Color::DarkGray),
),
])
}
}
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" Downloading ");
let para = Paragraph::new(lines).block(block);
frame.render_widget(para, area);
}
fn format_bytes(b: u64) -> String {
if b >= 1_073_741_824 {
format!("{:.1} GB", b as f64 / 1_073_741_824.0)
} else if b >= 1_048_576 {
format!("{:.1} MB", b as f64 / 1_048_576.0)
} else if b >= 1_024 {
format!("{:.1} KB", b as f64 / 1_024.0)
} else {
format!("{b} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::mpsc::sync_channel;
fn make_pane() -> (SetupPane, std::sync::mpsc::SyncSender<Prompt>) {
let (prompt_tx, prompt_rx) = sync_channel(4);
let (answer_tx, _answer_rx) = sync_channel(4);
let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let pulls = std::sync::Arc::new(std::sync::Mutex::new(HashMap::new()));
let pane = SetupPane::new("T".to_string(), output, pulls, prompt_rx, answer_tx);
(pane, prompt_tx)
}
#[test]
fn prompt_state_hint_select() {
let (mut pane, tx) = make_pane();
tx.send(Prompt::Select {
message: "Pick".to_string(),
items: vec!["a".to_string()],
default: 0,
})
.unwrap();
pane.poll();
assert_eq!(pane.hint(), "[↑↓/jk] Move [Enter] Confirm [Esc] Cancel");
}
#[test]
fn prompt_state_hint_multi_select() {
let (mut pane, tx) = make_pane();
tx.send(Prompt::MultiSelect {
message: "Pick many".to_string(),
items: vec!["a".to_string()],
defaults: vec![false],
})
.unwrap();
pane.poll();
assert_eq!(
pane.hint(),
"[↑↓/jk] Move [Space] Toggle [Enter] Confirm [Esc] Cancel"
);
}
#[test]
fn prompt_state_hint_confirm() {
let (mut pane, tx) = make_pane();
tx.send(Prompt::Confirm {
message: "Sure?".to_string(),
default: false,
})
.unwrap();
pane.poll();
assert_eq!(
pane.hint(),
"[y/n/←→/hl] Select [Enter] Confirm [Esc] Cancel"
);
}
#[test]
fn prompt_state_hint_text() {
let (mut pane, tx) = make_pane();
tx.send(Prompt::Text {
message: "Name?".to_string(),
default: String::new(),
allow_empty: false,
password: false,
})
.unwrap();
pane.poll();
assert_eq!(pane.hint(), "[typing] Edit [Enter] Confirm [Esc] Cancel");
}
#[test]
fn setup_pane_hint_waiting_for_prompt() {
let (pane, _tx) = make_pane();
assert_eq!(pane.hint(), "[↑↓/jk] Scroll output [Esc] Cancel");
}
#[test]
fn setup_pane_hint_finished() {
let (mut pane, tx) = make_pane();
drop(tx); pane.poll();
assert_eq!(pane.hint(), "[Enter/Esc] Close");
}
#[test]
fn confirm_requires_enter_after_y_or_n() {
let (prompt_tx, prompt_rx) = sync_channel(1);
let (answer_tx, answer_rx) = sync_channel(1);
let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let pulls = std::sync::Arc::new(std::sync::Mutex::new(HashMap::new()));
let mut pane = SetupPane::new("Test Pane".to_string(), output, pulls, prompt_rx, answer_tx);
prompt_tx
.send(Prompt::Confirm {
message: "Confirm action?".to_string(),
default: false,
})
.unwrap();
assert!(pane.poll());
let event_y = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
assert!(pane.handle_key(event_y));
assert!(answer_rx.try_recv().is_err());
match pane.active {
Some(PromptState::Confirm { yes, .. }) => assert!(yes),
_ => panic!("Expected active Confirm prompt"),
}
let event_n = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE);
assert!(pane.handle_key(event_n));
assert!(answer_rx.try_recv().is_err());
match pane.active {
Some(PromptState::Confirm { yes, .. }) => assert!(!yes),
_ => panic!("Expected active Confirm prompt"),
}
let event_y = KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE);
assert!(pane.handle_key(event_y));
assert!(answer_rx.try_recv().is_err());
let event_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert!(pane.handle_key(event_enter));
assert_eq!(answer_rx.try_recv().unwrap(), Answer::Bool(true));
assert!(pane.active.is_none());
}
}