use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
};
use crate::app::{App, QuestFocus, QuestViewState, TestResult};
pub fn render(frame: &mut Frame, app: &App, qv: &QuestViewState, area: Rect) {
if matches!(qv.test_result, Some(TestResult::Pass)) {
render_victory_screen(frame, app, qv, area);
return;
}
let quest = match app.get_quest(qv.quest_id) {
Some(q) => q,
None => return,
};
let done_badge = if app.completed.contains(&qv.quest_id) {
" ✓"
} else {
""
};
let title = format!(
" Quest {}/{}: {}{} ",
qv.quest_id,
app.quest_count(),
quest.title,
done_badge
);
let outer = Block::bordered()
.title(title.as_str())
.border_style(Style::new().fg(Color::Cyan));
let inner = outer.inner(area);
frame.render_widget(outer, area);
let cols = Layout::horizontal([
Constraint::Percentage(qv.left_column_width),
Constraint::Percentage(100_u16.saturating_sub(qv.left_column_width)),
])
.split(inner);
let left_col = cols[0];
let right_col = cols[1];
let inner_left_w = left_col.width.saturating_sub(2);
let mut sol_height = 3;
if qv.solutions_expanded {
for (i, sol) in quest.solutions.iter().enumerate() {
sol_height += 1;
if qv.revealed_solutions.contains(&i) {
sol_height += count_wrapped_lines(sol, inner_left_w);
}
}
}
let max_sol_height = inner.height.saturating_sub(5); sol_height = sol_height.min(max_sol_height).max(3);
let left_chunks =
Layout::vertical([Constraint::Fill(1), Constraint::Length(sol_height)]).split(left_col);
render_instructions(frame, qv, &quest.instructions, left_chunks[0]);
render_solutions(frame, qv, &quest.solutions, left_chunks[1]);
let mut right_constraints = vec![Constraint::Fill(1)]; if qv.has_answer_input {
right_constraints.push(Constraint::Length(3)); }
right_constraints.push(Constraint::Length(3)); right_constraints.push(Constraint::Length(3));
let right_chunks = Layout::vertical(right_constraints).split(right_col);
let mut idx = 0;
render_terminal(frame, qv, right_chunks[idx]);
idx += 1;
if qv.has_answer_input {
let prompt = quest.submit_prompt.as_deref().unwrap_or("Your answer");
render_answer_input(frame, qv, prompt, right_chunks[idx]);
idx += 1;
}
render_result(frame, qv, right_chunks[idx]);
idx += 1;
render_buttons(frame, qv, right_chunks[idx]);
}
fn count_wrapped_lines(text: &str, width: u16) -> u16 {
let mut count = 0;
let w = width.max(1) as usize;
for line in text.lines() {
let len = line.chars().count();
if len == 0 {
count += 1;
} else {
count += ((len - 1) / w) as u16 + 1;
}
}
count
}
fn render_instructions(frame: &mut Frame, qv: &QuestViewState, instructions: &str, area: Rect) {
let focused = qv.focus == QuestFocus::Instructions;
let border_color = if focused {
Color::Yellow
} else {
Color::DarkGray
};
let title = if focused {
" Instructions (↑/↓ scroll ←/→ resize Tab next) "
} else {
" Instructions "
};
let block = Block::bordered()
.title(title)
.border_style(Style::new().fg(border_color));
let text = instructions.to_string();
let inner = block.inner(area);
let total_lines = count_wrapped_lines(&text, inner.width);
let max_scroll = total_lines.saturating_sub(inner.height);
qv.max_instructions_scroll.set(max_scroll as usize);
let scroll = qv.instructions_scroll_offset.min(max_scroll as usize) as u16;
frame.render_widget(
Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((scroll, 0)),
area,
);
if max_scroll > 0 {
let mut scrollbar_state = ScrollbarState::default()
.content_length(max_scroll as usize)
.position(scroll as usize);
frame.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
area,
&mut scrollbar_state,
);
}
}
fn render_solutions(frame: &mut Frame, qv: &QuestViewState, solutions: &[String], area: Rect) {
let focused = qv.focus == QuestFocus::Solutions;
let border_color = if focused {
Color::Yellow
} else {
Color::DarkGray
};
let title = if focused {
" Solutions (Enter to toggle, Arrows/Tab to navigate) "
} else {
" Solutions "
};
let block = Block::bordered()
.title(title)
.border_style(Style::new().fg(border_color));
let mut lines = Vec::new();
if qv.solutions_expanded {
let mut tabs = vec![Span::raw(" ")];
let num_sols = solutions.len();
for (i, _) in solutions.iter().enumerate() {
let is_selected = focused && qv.selected_solution_idx == i;
let is_active = qv.revealed_solutions.contains(&i);
let style = if is_selected {
Style::new().bg(Color::White).fg(Color::Black).bold()
} else if is_active {
Style::new().fg(Color::Green).bold()
} else {
Style::new().fg(Color::Cyan)
};
tabs.push(Span::styled(format!(" [ {} ] ", i + 1), style));
tabs.push(Span::raw(" "));
}
let close_selected = focused && qv.selected_solution_idx == num_sols;
let close_style = if close_selected {
Style::new().bg(Color::White).fg(Color::Black).bold()
} else {
Style::new().fg(Color::Red)
};
tabs.push(Span::styled(" [ X ] ", close_style));
lines.push(Line::from(tabs));
lines.push(Line::from(Span::styled(
"─".repeat(area.width.saturating_sub(2) as usize),
Style::new().fg(Color::DarkGray),
)));
let mut found = false;
for (i, sol) in solutions.iter().enumerate() {
if qv.revealed_solutions.contains(&i) {
found = true;
for line in sol.lines() {
lines.push(Line::from(Span::styled(
format!(" {}", line),
Style::new().fg(Color::Gray),
)));
}
}
}
if !found {
lines.push(Line::from(Span::styled(
" Select a part to view the solution",
Style::new().fg(Color::DarkGray).italic(),
)));
}
} else {
let is_selected = focused && qv.selected_solution_idx == 0;
let style = if is_selected {
Style::new().bg(Color::White).fg(Color::Black).bold()
} else {
Style::new().fg(Color::Cyan)
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("▶ Show Solutions", style),
]));
}
frame.render_widget(
Paragraph::new(lines).block(block).wrap(Wrap { trim: false }),
area,
);
}
fn render_terminal(frame: &mut Frame, qv: &QuestViewState, area: Rect) {
let focused = qv.focus == QuestFocus::Terminal;
let border_color = if focused {
Color::Yellow
} else {
Color::DarkGray
};
let title = if focused {
" Terminal (Enter to run Tab navigate Shift+↑↓↔ scroll Ctrl+V paste Ctrl+C copy output) "
} else {
" Terminal "
};
let block = Block::bordered()
.title(title)
.border_style(Style::new().fg(border_color));
let inner = block.inner(area);
let mut lines: Vec<Line> = Vec::new();
for entry in &qv.history {
let mut first = true;
for line in entry.command.lines() {
let prompt = if first { "$ " } else { "> " };
first = false;
lines.push(Line::from(vec![
Span::styled(prompt, Style::new().fg(Color::Green).bold()),
Span::styled(line.to_string(), Style::new().fg(Color::White)),
]));
}
for output_line in entry.output.lines() {
lines.push(Line::from(Span::styled(
format!(" {}", output_line),
Style::new().fg(Color::Gray),
)));
}
lines.push(Line::default());
}
let mut spans = vec![Span::styled("$ ", Style::new().fg(Color::Green).bold())];
for (i, c) in qv.input.char_indices() {
let is_cursor = i == qv.cursor && focused;
let style = if is_cursor {
Style::new().fg(Color::Black).bg(Color::White)
} else {
Style::new()
};
if c == '\n' {
if is_cursor {
spans.push(Span::styled(
"█",
Style::new().fg(Color::Black).bg(Color::White),
));
}
lines.push(Line::from(spans));
spans = vec![Span::styled("> ", Style::new().fg(Color::Green).bold())];
} else {
spans.push(Span::styled(c.to_string(), style));
}
}
if qv.cursor == qv.input.len() {
spans.push(Span::styled(
if focused { "█" } else { "" },
Style::new().bg(Color::White).fg(Color::Black),
));
}
lines.push(Line::from(spans));
let inner_w = inner.width.max(1) as usize;
let total_lines: u16 = lines
.iter()
.map(|line| {
let len = line.width();
if len == 0 {
1
} else {
((len.saturating_sub(1) / inner_w) + 1) as u16
}
})
.sum();
let visible = inner.height;
let max_scroll = total_lines.saturating_sub(visible);
qv.max_terminal_scroll.set(max_scroll as usize);
let effective_scroll_offset = qv.scroll_offset.min(max_scroll as usize) as u16;
let scroll = max_scroll.saturating_sub(effective_scroll_offset);
frame.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((scroll, 0)),
area,
);
if max_scroll > 0 {
let mut scrollbar_state = ScrollbarState::default()
.content_length(max_scroll as usize)
.position(scroll as usize);
frame.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
area,
&mut scrollbar_state,
);
}
}
fn render_answer_input(frame: &mut Frame, qv: &QuestViewState, prompt: &str, area: Rect) {
let focused = qv.focus == QuestFocus::Answer;
let border_color = if focused {
Color::Yellow
} else {
Color::DarkGray
};
let title = format!(" {} ", prompt);
let block = Block::bordered()
.title(title)
.border_style(Style::new().fg(border_color));
let before = &qv.answer[..qv.answer_cursor];
let after = &qv.answer[qv.answer_cursor..];
let line = Line::from(vec![
Span::styled("> ", Style::new().fg(Color::Cyan).bold()),
Span::raw(before.to_string()),
Span::styled(
if focused { "█" } else { "" },
Style::new().fg(Color::Black).bg(Color::White),
),
Span::raw(after.to_string()),
]);
frame.render_widget(Paragraph::new(line).block(block), area);
}
fn render_result(frame: &mut Frame, qv: &QuestViewState, area: Rect) {
let (text, color) = match &qv.test_result {
Some(TestResult::Pass) => (" ✓ Quest complete! Well done.", Color::Green),
Some(TestResult::Fail(msg)) => (msg.as_str(), Color::Red),
None => (
" Press Tab → [Submit] to verify your work.",
Color::DarkGray,
),
};
let focused = qv.focus == QuestFocus::Submit;
let border_color = if focused { Color::Yellow } else { color };
let block = Block::bordered().border_style(Style::new().fg(border_color));
frame.render_widget(
Paragraph::new(text)
.style(Style::new().fg(color).bold())
.block(block),
area,
);
}
fn render_buttons(frame: &mut Frame, qv: &QuestViewState, area: Rect) {
let sel = Style::new().bg(Color::Yellow).fg(Color::Black).bold();
let normal = Style::new().fg(Color::White);
let sub_s = if qv.focus == QuestFocus::Submit {
sel
} else {
normal
};
let back_s = if qv.focus == QuestFocus::Back {
sel
} else {
normal
};
let line = Line::from(vec![
Span::raw(" "),
Span::styled("[ Submit ]", sub_s),
Span::raw(" "),
Span::styled("[ Back ]", back_s),
Span::raw(" "),
Span::styled("Tab/←→ switch Esc back", Style::new().fg(Color::DarkGray)),
]);
let block = Block::bordered().border_style(Style::new().fg(Color::DarkGray));
frame.render_widget(Paragraph::new(line).block(block), area);
}
fn render_victory_screen(frame: &mut Frame, app: &App, _qv: &QuestViewState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(15),
Constraint::Fill(1),
])
.split(area);
let inner_chunks = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(60),
Constraint::Fill(1),
])
.split(chunks[1]);
let victory_area = inner_chunks[1];
let tick = app.tick;
let frame_idx = (tick / 2) % 10;
let firework = match frame_idx {
0 => vec![
" ",
" | ",
" | ",
" ",
" ",
],
1 => vec![
" ",
" ^ ",
" /|\\ ",
" | ",
" ",
],
2 => vec![
" . ",
" \\|/ ",
" -- * -- ",
" /|\\ ",
" ' ",
],
3 => vec![
" \\ | / ",
" . \\ | / . ",
" --- *** --- ",
" . / | \\ . ",
" / | \\ ",
],
4 => vec![
" * \\ | / * ",
" * *** * ",
" -- ***** -- ",
" * *** * ",
" * / | \\ * ",
],
5 => vec![
" . * * . ",
" \\ | | / ",
" * -- * -- * ",
" / | | \\ ",
" . * * . ",
],
6 => vec![
" . . . ",
" . * * . ",
" . . ",
" . * * . ",
" . . . ",
],
7 => vec![
" . . ",
" . . ",
" ",
" . . ",
" . . ",
],
_ => vec![
" ",
" ",
" ",
" ",
" ",
],
};
let block = Block::bordered()
.border_style(Style::new().fg(Color::Green))
.style(Style::new().bg(Color::Reset));
let mut text = vec![Line::from("")];
let fw_color = match frame_idx {
0..=2 => Color::White,
3..=5 => Color::Yellow,
6..=7 => Color::DarkGray,
_ => Color::Reset,
};
for line in firework {
text.push(
Line::from(Span::styled(line, Style::new().fg(fw_color).bold()))
.alignment(Alignment::Center),
);
}
text.extend(vec![
Line::from(""),
Line::from(Span::styled(
"COMPLETED QUEST",
Style::new().fg(Color::Green).bold(),
))
.alignment(Alignment::Center),
Line::from(""),
Line::from(vec![Span::raw(
"Congratulations! You have completed this quest.",
)])
.alignment(Alignment::Center),
Line::from(""),
Line::from(vec![Span::styled(
" [ Go Back ] ",
Style::new().bg(Color::Yellow).fg(Color::Black).bold(),
)])
.alignment(Alignment::Center),
Line::from(vec![Span::styled(
"(press enter to go back to quests page)",
Style::new().fg(Color::DarkGray),
)])
.alignment(Alignment::Center),
]);
frame.render_widget(Paragraph::new(text).block(block), victory_area);
}