use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
use super::app::{App, QAState, SPINNER_FRAMES};
use super::qa_protocol::{AnswerValue, PreviewBlock, Question, QuestionType};
use std::collections::HashMap;
pub fn draw_question_panel(frame: &mut Frame, app: &App, area: Rect) {
if let Some(ref qout) = app.qa_state.current_qout {
let current_preview = if !app.qa_state.show_review {
qout.questions
.get(app.qa_state.selected_tab)
.and_then(|q| q.preview.as_ref())
} else {
None
};
let has_preview = current_preview.is_some();
let constraints = if has_preview {
vec![
Constraint::Length(6), Constraint::Length(2), Constraint::Min(5), Constraint::Min(2), ]
} else {
vec![
Constraint::Length(2), Constraint::Min(5), Constraint::Min(2), ]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let (preview_area, tab_area, widget_area, hint_area) = if has_preview {
(Some(chunks[0]), chunks[1], chunks[2], chunks[3])
} else {
(None, chunks[0], chunks[1], chunks[2])
};
if let (Some(preview_area), Some(preview)) = (preview_area, current_preview) {
draw_preview(frame, preview, preview_area);
}
draw_question_tabs(
frame,
&qout.questions,
app.qa_state.selected_tab,
app.qa_state.show_review,
&app.qa_state.pending_answers,
tab_area,
);
if app.qa_state.show_review {
draw_review_panel(
frame,
&qout.questions,
&app.qa_state.pending_answers,
widget_area,
);
} else if let Some(question) = qout.questions.get(app.qa_state.selected_tab) {
draw_question_widget(frame, question, &app.qa_state, widget_area);
}
draw_qa_hints(frame, &app.qa_state, hint_area);
} else if app.qa_state.waiting {
draw_waiting_spinner(frame, app.spinner_frame, area);
} else {
let block = Block::default()
.title(" Q&A ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
frame.render_widget(block, area);
}
}
fn draw_preview(frame: &mut Frame, preview: &PreviewBlock, area: Rect) {
let block = Block::default()
.title(" Preview ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let paragraph = Paragraph::new(preview.content.as_str())
.block(block)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn draw_question_tabs(
frame: &mut Frame,
questions: &[Question],
selected_tab: usize,
show_review: bool,
answers: &HashMap<String, AnswerValue>,
area: Rect,
) {
let mut spans = Vec::new();
spans.push(Span::styled(" ", Style::default()));
for (i, question) in questions.iter().enumerate() {
let is_selected = !show_review && i == selected_tab;
let is_answered = answers.contains_key(&question.id);
let label = format!(" {} ", i + 1);
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if is_answered {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(label, style));
spans.push(Span::raw(" "));
}
let review_style = if show_review {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(" Review ", review_style));
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
fn draw_question_widget(frame: &mut Frame, question: &Question, qa_state: &QAState, area: Rect) {
let block = Block::default()
.title(format!(" {} ", question.prompt))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
match question.question_type {
QuestionType::ChooseOne => {
draw_choose_one(frame, question, qa_state, inner);
}
QuestionType::ChooseMany => {
draw_choose_many(frame, question, qa_state, inner);
}
QuestionType::FreeWrite => {
draw_free_write(frame, qa_state, inner);
}
QuestionType::YesNo => {
draw_yes_no(frame, qa_state, inner);
}
}
}
fn draw_choose_one(frame: &mut Frame, question: &Question, qa_state: &QAState, area: Rect) {
let options = match &question.options {
Some(opts) => opts,
None => return,
};
let current_answer = qa_state.pending_answers.get(&question.id);
let items: Vec<ListItem> = options
.iter()
.enumerate()
.map(|(i, opt)| {
let is_cursor = i == qa_state.widget_cursor;
let is_answered = match current_answer {
Some(AnswerValue::Text(t)) => t == opt,
_ => false,
};
let prefix = if is_cursor { "> " } else { " " };
let suffix = if is_answered { " [selected]" } else { "" };
let style = if is_cursor {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else if is_answered {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Gray)
};
ListItem::new(Line::from(Span::styled(
format!("{}{}{}", prefix, opt, suffix),
style,
)))
})
.collect();
let list = List::new(items);
frame.render_widget(list, area);
}
fn draw_choose_many(frame: &mut Frame, question: &Question, qa_state: &QAState, area: Rect) {
let options = match &question.options {
Some(opts) => opts,
None => return,
};
let items: Vec<ListItem> = options
.iter()
.enumerate()
.map(|(i, opt)| {
let is_cursor = i == qa_state.widget_cursor;
let is_toggled = qa_state.toggled.contains(&i);
let checkbox = if is_toggled { "[x]" } else { "[ ]" };
let prefix = if is_cursor { "> " } else { " " };
let style = if is_cursor {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else if is_toggled {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Gray)
};
ListItem::new(Line::from(Span::styled(
format!("{}{} {}", prefix, checkbox, opt),
style,
)))
})
.collect();
let list = List::new(items);
frame.render_widget(list, area);
}
fn draw_free_write(frame: &mut Frame, qa_state: &QAState, area: Rect) {
if qa_state.text_buffer.is_empty() {
let paragraph = Paragraph::new(Span::styled(
"> Type your answer...",
Style::default().fg(Color::DarkGray),
))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
return;
}
let chars: Vec<char> = qa_state.text_buffer.chars().collect();
let cursor = qa_state.text_cursor.min(chars.len());
let before: String = chars[..cursor].iter().collect();
let after: String = chars[cursor..].iter().collect();
let display = format!("> {}|{}", before, after);
let paragraph = Paragraph::new(display)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn draw_yes_no(frame: &mut Frame, qa_state: &QAState, area: Rect) {
let yes_style = if qa_state.widget_cursor == 0 {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let no_style = if qa_state.widget_cursor == 1 {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let yes_prefix = if qa_state.widget_cursor == 0 {
"> "
} else {
" "
};
let no_prefix = if qa_state.widget_cursor == 1 {
"> "
} else {
" "
};
let items = vec![
ListItem::new(Line::from(Span::styled(
format!("{}[Y] Yes", yes_prefix),
yes_style,
))),
ListItem::new(Line::from(Span::styled(
format!("{}[N] No", no_prefix),
no_style,
))),
];
let list = List::new(items);
frame.render_widget(list, area);
}
fn draw_review_panel(
frame: &mut Frame,
questions: &[Question],
answers: &HashMap<String, AnswerValue>,
area: Rect,
) {
let block = Block::default()
.title(" Review Answers ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines = Vec::new();
for (i, question) in questions.iter().enumerate() {
let prompt_line = Line::from(vec![
Span::styled(
format!("{}. ", i + 1),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(&question.prompt, Style::default().fg(Color::White)),
]);
lines.push(prompt_line);
let answer_text = match answers.get(&question.id) {
Some(AnswerValue::Text(t)) => format!(" -> {}", t),
Some(AnswerValue::Boolean(b)) => {
format!(" -> {}", if *b { "Yes" } else { "No" })
}
Some(AnswerValue::MultiSelect(items)) => {
format!(" -> {}", items.join(", "))
}
None => " -> (unanswered)".to_string(),
};
let answer_style = if answers.contains_key(&question.id) {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
};
lines.push(Line::from(Span::styled(answer_text, answer_style)));
lines.push(Line::from(""));
}
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(paragraph, inner);
}
fn draw_waiting_spinner(frame: &mut Frame, spinner_frame: usize, area: Rect) {
let block = Block::default()
.title(" Q&A ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
frame.render_widget(block, area);
let spinner = SPINNER_FRAMES[spinner_frame % SPINNER_FRAMES.len()];
let line = Line::from(vec![
Span::styled(
format!(" {} ", spinner),
Style::default().fg(Color::Yellow),
),
Span::styled(
"Waiting for questions...",
Style::default().fg(Color::DarkGray),
),
]);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, inner);
}
fn draw_qa_hints(frame: &mut Frame, qa_state: &QAState, area: Rect) {
let hints = if qa_state.show_review {
vec![
("Enter", "Submit"),
("e/Backspace", "Back to edit"),
("Left/Right", "Navigate"),
("Esc", "Close"),
]
} else {
let qtype = qa_state
.current_qout
.as_ref()
.and_then(|q| q.questions.get(qa_state.selected_tab))
.map(|q| &q.question_type);
match qtype {
Some(QuestionType::ChooseOne) => {
vec![
("Up/Down", "Navigate"),
("Enter", "Select"),
("Left/Right", "Tab"),
("Esc", "Close"),
]
}
Some(QuestionType::ChooseMany) => {
vec![
("Up/Down", "Navigate"),
("Space", "Toggle"),
("Enter", "Confirm"),
("Left/Right", "Tab"),
("Esc", "Close"),
]
}
Some(QuestionType::FreeWrite) => {
vec![
("^←/^→", "Word"),
("S+Enter", "Newline"),
("Enter", "Confirm"),
("Tab/S+Tab", "Switch tab"),
("Esc", "Close"),
]
}
Some(QuestionType::YesNo) => {
vec![
("Y/N", "Select"),
("Enter", "Confirm"),
("Left/Right", "Tab"),
("Esc", "Close"),
]
}
None => vec![("Esc", "Close")],
}
};
let footer = Paragraph::new(render_hint_line(&hints)).wrap(Wrap { trim: false });
frame.render_widget(footer, area);
}
fn render_hint_line(hints: &[(&str, &str)]) -> Line<'static> {
let mut spans = Vec::new();
for (idx, (key, label)) in hints.iter().enumerate() {
if idx > 0 {
spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
}
spans.push(Span::styled(
key.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(": "));
spans.push(Span::styled(
label.to_string(),
Style::default().fg(Color::Gray),
));
}
Line::from(spans)
}