use crate::tui;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Tabs},
};
use std::collections::{BTreeSet, HashMap};
use wcore::protocol::message::AskQuestion;
pub enum AskAction {
Noop,
Cancelled,
Submitted(HashMap<String, String>),
}
pub struct AskState {
questions: Vec<QuestionState>,
focused: usize,
mode: InputMode,
input_buf: String,
input_cursor: usize,
}
struct QuestionState {
question: AskQuestion,
selected: BTreeSet<usize>,
cursor: usize,
other_text: Option<String>,
}
#[derive(PartialEq)]
enum InputMode {
Normal,
TextInput,
}
impl AskState {
pub fn new(questions: &[AskQuestion]) -> Self {
Self {
questions: questions.iter().map(QuestionState::new).collect(),
focused: 0,
mode: InputMode::Normal,
input_buf: String::new(),
input_cursor: 0,
}
}
fn tab_count(&self) -> usize {
self.questions.len() + 1
}
fn on_submit_tab(&self) -> bool {
self.focused == self.questions.len()
}
fn focused_q(&self) -> &QuestionState {
&self.questions[self.focused]
}
fn focused_q_mut(&mut self) -> &mut QuestionState {
&mut self.questions[self.focused]
}
fn item_count(&self) -> usize {
self.focused_q().question.options.len() + 1
}
fn other_idx(&self) -> usize {
self.focused_q().question.options.len()
}
fn commit_input(&mut self) {
let text = std::mem::take(&mut self.input_buf);
self.input_cursor = 0;
self.focused_q_mut().other_text = Some(text);
self.mode = InputMode::Normal;
}
fn advance(&mut self) {
let count = self.tab_count();
self.focused = (self.focused + 1).min(count - 1);
}
fn auto_select(&mut self) {
let qs = &mut self.questions[self.focused];
if qs.question.multi_select {
return;
}
let cursor = qs.cursor;
let other = qs.question.options.len();
qs.selected.clear();
if cursor < other {
qs.selected.insert(cursor);
self.mode = InputMode::Normal;
} else {
let existing = qs.other_text.clone().unwrap_or_default();
self.input_buf = existing;
self.input_cursor = self.input_buf.chars().count();
self.mode = InputMode::TextInput;
}
}
fn answers(&self) -> HashMap<String, String> {
let mut map = HashMap::new();
for qs in &self.questions {
let key = qs.question.question.clone();
if let Some(ref text) = qs.other_text
&& (qs.question.multi_select || qs.selected.is_empty())
{
map.insert(key, text.clone());
} else if qs.question.multi_select {
let labels: Vec<&str> = qs
.selected
.iter()
.filter_map(|&i| qs.question.options.get(i).map(|o| o.label.as_str()))
.collect();
map.insert(key, labels.join(", "));
} else if let Some(&i) = qs.selected.iter().next()
&& let Some(opt) = qs.question.options.get(i)
{
map.insert(key, opt.label.clone());
}
}
map
}
pub fn handle_key(&mut self, key: KeyEvent) -> AskAction {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return AskAction::Cancelled;
}
if self.mode == InputMode::TextInput {
match key.code {
KeyCode::Enter | KeyCode::Tab => {
self.commit_input();
self.advance();
}
KeyCode::Esc => {
self.input_buf.clear();
self.input_cursor = 0;
self.mode = InputMode::Normal;
}
KeyCode::Up => {
self.commit_input();
let q = self.focused_q_mut();
q.cursor = q.cursor.saturating_sub(1);
self.auto_select();
}
KeyCode::Down => {
self.commit_input();
let max = self.item_count().saturating_sub(1);
let q = self.focused_q_mut();
q.cursor = (q.cursor + 1).min(max);
self.auto_select();
}
code => {
tui::handle_text_input(code, &mut self.input_buf, &mut self.input_cursor);
}
}
return AskAction::Noop;
}
if self.on_submit_tab() {
match key.code {
KeyCode::Esc => return AskAction::Cancelled,
KeyCode::Enter => return AskAction::Submitted(self.answers()),
KeyCode::Tab => self.focused = 0,
KeyCode::BackTab => {
self.focused = self.questions.len().saturating_sub(1);
}
_ => {}
}
return AskAction::Noop;
}
match key.code {
KeyCode::Esc => return AskAction::Cancelled,
KeyCode::Enter | KeyCode::Tab => self.advance(),
KeyCode::Up => {
let q = self.focused_q_mut();
q.cursor = q.cursor.saturating_sub(1);
self.auto_select();
}
KeyCode::Down => {
let max = self.item_count().saturating_sub(1);
let q = self.focused_q_mut();
q.cursor = (q.cursor + 1).min(max);
self.auto_select();
}
KeyCode::BackTab => {
let count = self.tab_count();
self.focused = (self.focused + count - 1) % count;
}
KeyCode::Char(' ') => {
let other = self.other_idx();
let cursor = self.focused_q().cursor;
if cursor == other {
let existing = self.focused_q().other_text.clone().unwrap_or_default();
self.input_buf = existing;
self.input_cursor = self.input_buf.chars().count();
self.mode = InputMode::TextInput;
} else if self.focused_q().question.multi_select {
let q = self.focused_q_mut();
q.other_text = None;
if q.selected.contains(&cursor) {
q.selected.remove(&cursor);
} else {
q.selected.insert(cursor);
}
}
}
_ => {}
}
AskAction::Noop
}
pub fn draw(&self, frame: &mut ratatui::Frame) {
let area = frame.area();
let max_opts = self
.questions
.iter()
.map(|q| q.question.options.len())
.max()
.unwrap_or(0);
let content_height = (1 + 1 + 1 + 1 + max_opts + 1 + 1 + 1) as u16;
let modal_height = content_height.min(area.height.saturating_sub(4));
let modal_width = (area.width * 7 / 10)
.max(40)
.min(area.width.saturating_sub(4));
let x = (area.width.saturating_sub(modal_width)) / 2;
let y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let chunks = Layout::default()
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(modal_area);
let mut titles: Vec<Line> = self
.questions
.iter()
.map(|q| Line::from(q.question.header.clone()))
.collect();
titles.push(Line::from("Submit"));
let tabs = Tabs::new(titles)
.select(self.focused)
.highlight_style(
Style::default()
.fg(Color::Rgb(215, 119, 87))
.add_modifier(Modifier::BOLD),
)
.style(Style::default().fg(Color::DarkGray))
.divider(" | ");
frame.render_widget(tabs, chunks[0]);
if self.on_submit_tab() {
draw_submit(frame, self, chunks[1]);
} else {
draw_content(frame, self, chunks[1]);
}
draw_status(frame, self, chunks[2]);
}
}
impl QuestionState {
fn new(q: &AskQuestion) -> Self {
let mut question = q.clone();
question
.options
.retain(|o| !o.label.eq_ignore_ascii_case("other"));
Self {
question,
selected: BTreeSet::new(),
cursor: 0,
other_text: None,
}
}
}
fn draw_submit(frame: &mut ratatui::Frame, state: &AskState, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(136, 136, 136)));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let mut lines = vec![
Line::from(Span::styled(
"Review your answers:",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
for qs in &state.questions {
let answer = if let Some(ref text) = qs.other_text
&& (qs.question.multi_select || qs.selected.is_empty())
{
format!("\"{}\"", text)
} else if qs.selected.is_empty() {
"(no selection)".to_string()
} else {
qs.selected
.iter()
.filter_map(|&i| qs.question.options.get(i).map(|o| o.label.as_str()))
.collect::<Vec<_>>()
.join(", ")
};
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", qs.question.header),
Style::default().fg(Color::Rgb(215, 119, 87)),
),
Span::styled(answer, Style::default().fg(Color::Cyan)),
]));
}
frame.render_widget(Paragraph::new(lines), inner);
}
fn draw_content(frame: &mut ratatui::Frame, state: &AskState, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(136, 136, 136)));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 {
return;
}
let qs = state.focused_q();
let multi = qs.question.multi_select;
let mut lines = vec![
Line::from(Span::styled(
&qs.question.question,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
for (i, opt) in qs.question.options.iter().enumerate() {
let is_cursor = qs.cursor == i;
let is_selected = qs.selected.contains(&i);
let prefix = option_prefix(multi, is_cursor, is_selected);
let label = if opt.description.is_empty() {
opt.label.clone()
} else {
format!("{} — {}", opt.label, opt.description)
};
let style = if is_cursor && !multi {
Style::default()
.fg(Color::Rgb(215, 119, 87))
.add_modifier(Modifier::BOLD)
} else if is_cursor {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(format!("{prefix}{label}"), style)));
}
let other_idx = qs.question.options.len();
let is_cursor = qs.cursor == other_idx;
let has_other = qs.other_text.is_some() && (multi || qs.selected.is_empty());
let prefix = option_prefix(multi, is_cursor, has_other);
let other_text = if state.mode == InputMode::TextInput && is_cursor {
&state.input_buf
} else {
match &qs.other_text {
Some(t) => t.as_str(),
None => "",
}
};
let other_label = format!("{prefix}Other: {other_text}");
let style = if is_cursor {
Style::default()
.fg(Color::Rgb(215, 119, 87))
.add_modifier(Modifier::BOLD)
} else if has_other {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(other_label, style)));
frame.render_widget(Paragraph::new(lines), inner);
}
fn option_prefix(multi: bool, is_cursor: bool, is_selected: bool) -> &'static str {
match (multi, is_cursor, is_selected) {
(true, true, true) => " [x] ",
(true, true, false) => " [ ] ",
(true, false, true) => " [x] ",
(true, false, false) => " [ ] ",
(false, true, true) => " > ",
(false, true, false) => " > ",
(false, false, true) => " * ",
(false, false, false) => " ",
}
}
fn draw_status(frame: &mut ratatui::Frame, state: &AskState, area: Rect) {
let key = Style::default().fg(Color::Rgb(177, 185, 249));
let spans = if state.mode == InputMode::TextInput {
vec![
Span::styled(" Type your answer ", Style::default().fg(Color::White)),
Span::styled("Enter ", key),
Span::raw("Next "),
Span::styled("Esc ", key),
Span::raw("Cancel"),
]
} else if state.on_submit_tab() {
vec![
Span::styled(" Enter ", key),
Span::raw("Submit "),
Span::styled("Tab ", key),
Span::raw("Back "),
Span::styled("Esc ", key),
Span::raw("Cancel"),
]
} else {
vec![
Span::styled(" ↑↓ ", key),
Span::raw("Select "),
Span::styled("Enter ", key),
Span::raw("Next "),
Span::styled("Tab ", key),
Span::raw("Next "),
Span::styled("Space ", key),
Span::raw("Toggle "),
Span::styled("Esc ", key),
Span::raw("Cancel"),
]
};
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}