use crate::app::App;
use crate::icons;
use crate::panels::{
context::ContextPanel,
explanation::ExplanationPanel,
help::HelpPanel,
PanelId,
};
use crate::theme::Theme;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
const MIN_WIDTH: u16 = 40;
const MIN_HEIGHT: u16 = 12;
pub fn draw(frame: &mut Frame, app: &mut App) {
let size = frame.size();
if size.width < MIN_WIDTH || size.height < MIN_HEIGHT {
let msg = format!(
"Terminal too small ({} x {})\nMinimum: {} x {}",
size.width, size.height, MIN_WIDTH, MIN_HEIGHT
);
let paragraph = Paragraph::new(msg)
.style(app.theme.style_warning())
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, size);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), ])
.split(size);
draw_header(frame, chunks[0], app);
draw_content(frame, chunks[1], app);
if let Some(ref wizard) = app.onboarding {
wizard.render(frame, &app.theme);
return; }
if app.show_help {
let help_panel = HelpPanel::new();
help_panel.render(frame, &app.theme);
return; }
if let Some(ref panel) = app.settings_panel {
panel.render(frame, &app.theme, &app.config);
}
if let Some(ref mut menu) = app.lesson_menu {
menu.render(
frame,
&app.theme,
&app.completed_lessons,
&app.user_stats,
&app.recommendation_engine,
);
}
if let Some(ref panel) = app.achievements_panel {
panel.render(frame, &app.theme, &app.user_stats.achievements);
}
if let Some(ref panel) = app.progress_panel {
panel.render(frame, &app.theme, &app.user_stats);
}
if let Some(ref panel) = app.challenges_panel {
panel.render(frame, &app.theme, &app.challenge_manager);
}
if let Some(ref notification) = app.showing_notification {
notification.render(frame, &app.theme);
}
}
fn draw_header(frame: &mut Frame, area: Rect, app: &App) {
let width = area.width as usize;
let title = if width >= 60 {
format!(" {}ARC ACADEMY TERMINAL ", icons::lightning().content)
} else if width >= 40 {
format!(" {}ARCT ", icons::lightning().content)
} else {
format!("{}A", icons::lightning().content)
};
let mode_indicator = if app.lesson_mode {
if width >= 50 { format!(" {}LESSON ", icons::lesson().content) } else { String::new() }
} else if app.ai_mode {
if width >= 50 { format!(" {}AI ", icons::ai().content) } else { String::new() }
} else {
String::new()
};
let version = if width >= 80 {
format!("v{} | {} ", env!("CARGO_PKG_VERSION"), app.theme.name)
} else if width >= 50 {
format!("v{} ", env!("CARGO_PKG_VERSION"))
} else {
String::new()
};
let help_text = if width >= 120 {
" [? help] [^L lessons] [^A AI] [^T theme] [q quit] "
} else if width >= 80 {
" [?] [^L] [^A] [^T] [q] "
} else if width >= 50 {
" [? help] "
} else {
""
};
let title_len = title.chars().count();
let version_len = version.chars().count();
let mode_len = mode_indicator.chars().count();
let help_len = help_text.chars().count();
let used_width = title_len + mode_len + version_len + help_len;
let padding = width.saturating_sub(used_width + 2);
let mut spans = vec![
Span::styled(&title, app.theme.style_accent().add_modifier(Modifier::BOLD)),
];
if !mode_indicator.is_empty() {
spans.push(Span::styled(&mode_indicator, app.theme.style_success().add_modifier(Modifier::BOLD)));
}
if !version.is_empty() {
spans.push(Span::styled(&version, app.theme.style_info()));
}
if padding > 0 {
spans.push(Span::raw(" ".repeat(padding)));
}
if !help_text.is_empty() {
spans.push(Span::styled(help_text, app.theme.style_dim()));
}
let header_text = Line::from(spans);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(app.theme.style_border_focused())
.style(app.theme.style_block()), );
frame.render_widget(header, area);
}
fn draw_content(frame: &mut Frame, area: Rect, app: &App) {
let width = area.width;
let main_chunks = if width < 60 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(0), Constraint::Min(0), ])
.split(area)
} else if width < 100 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(20), Constraint::Min(40), ])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), Constraint::Percentage(70), ])
.split(area)
};
if main_chunks[0].width > 0 {
let context_panel = ContextPanel::new();
let analytics_summary = app.analytics.as_ref()
.and_then(|a| a.get_summary().ok());
context_panel.render(
frame,
main_chunks[0],
app.active_panel == PanelId::Context,
&app.context,
&app.theme,
app.config.ai.enabled,
analytics_summary.as_ref(),
app.lesson_mode,
app.virtual_fs.as_ref(),
&app.user_stats,
&app.challenge_manager,
);
}
let height = main_chunks[1].height;
let right_chunks = if height < 15 {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(0), Constraint::Min(0), Constraint::Length(3), ])
.split(main_chunks[1])
} else if height < 25 {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5), Constraint::Percentage(50), Constraint::Length(3), ])
.split(main_chunks[1])
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(35), Constraint::Percentage(40), Constraint::Length(3), ])
.split(main_chunks[1])
};
if right_chunks[0].height > 0 {
if app.lesson_mode {
if let Some(ref lesson_panel) = app.lesson_panel {
lesson_panel.render(
frame,
right_chunks[0],
app.active_panel == PanelId::Explanation,
&app.theme,
);
}
} else {
let explanation_panel = ExplanationPanel::new();
explanation_panel.render(
frame,
right_chunks[0],
app.active_panel == PanelId::Explanation,
app.last_explanation.as_ref(),
&app.theme,
app.ai_mode,
app.ai_response.as_deref(),
);
}
}
draw_output_panel(
frame,
right_chunks[1],
app.active_panel == PanelId::Output,
&app.last_output,
app.output_scroll,
&app.theme,
);
draw_shell_panel(
frame,
right_chunks[2],
app.active_panel == PanelId::Shell,
&app.command_buffer,
&app.completion_suggestions,
&app.theme,
app.ai_mode,
&app.ai_input_buffer,
app.ai_loading,
);
}
fn draw_shell_panel(
frame: &mut Frame,
area: Rect,
focused: bool,
command: &str,
completions: &[String],
theme: &Theme,
ai_mode: bool,
ai_input: &str,
ai_loading: bool,
) {
let border_style = if focused {
theme.style_border_focused()
} else {
theme.style_border()
};
let title = if ai_mode {
if focused {
format!(" {}AI Assistant (Active - Ctrl+A to exit, Enter to ask) ", icons::ai().content)
} else {
format!(" {}AI Assistant ", icons::ai().content)
}
} else if focused {
format!(" {}Shell (Active - Ctrl+A for AI, Tab to complete, ↑↓ for history) ", icons::shell().content)
} else {
format!(" {}Shell ", icons::shell().content)
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.style_block());
let mut lines = Vec::new();
if ai_mode {
let prompt = icons::ai();
let input_text = if ai_loading {
Span::styled(format!("{}Thinking...", icons::loading().content), theme.style_dim())
} else {
Span::styled(ai_input, theme.style_normal())
};
let mut input_line = vec![prompt, input_text];
if focused && !ai_loading {
input_line.push(Span::styled("█", theme.style_accent()));
}
lines.push(Line::from(input_line));
} else {
let prompt = Span::styled("$ ", theme.style_accent());
let command_text = Span::styled(command, theme.style_normal());
let mut command_line = vec![prompt, command_text];
if focused {
command_line.push(Span::styled("█", theme.style_accent()));
}
lines.push(Line::from(command_line));
}
if !ai_mode && !completions.is_empty() {
lines.push(Line::from("")); lines.push(Line::from(vec![
icons::hint(),
Span::styled("Suggestions:", theme.style_dim()),
]));
for completion in completions.iter().take(5) {
lines.push(Line::from(vec![
Span::styled(" • ", theme.style_dim()),
Span::styled(completion, theme.style_success()),
]));
}
if completions.len() > 5 {
lines.push(Line::from(vec![
Span::styled(format!(" ...and {} more", completions.len() - 5), theme.style_dim()),
]));
}
}
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn draw_output_panel(
frame: &mut Frame,
area: Rect,
focused: bool,
output: &str,
scroll_offset: usize,
theme: &Theme,
) {
let border_style = if focused {
theme.style_border_focused()
} else {
theme.style_border()
};
let title = if focused {
format!(" {}Output (↑↓ scroll) ", icons::output().content)
} else {
format!(" {}Output (^↑↓ scroll) ", icons::output().content)
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.style_block());
let inner_height = area.height.saturating_sub(2) as usize;
let text = if output.is_empty() {
vec![
Line::from(""),
Line::from(vec![
Span::styled(" Ready to execute commands!", theme.style_dim()),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Type a command above and press ", theme.style_dim()),
Span::styled("Enter", theme.style_accent()),
]),
]
} else {
let all_lines: Vec<Line> = crate::ansi::parse_ansi(output);
let total_lines = all_lines.len();
let mut visible_lines: Vec<Line> = all_lines
.into_iter()
.skip(scroll_offset)
.take(inner_height)
.collect();
if scroll_offset + inner_height < total_lines {
let remaining = total_lines - (scroll_offset + inner_height);
visible_lines.push(Line::from(vec![
Span::styled(
format!(" ↓ {} more lines (press ↓ or j to scroll)", remaining),
theme.style_dim(),
),
]));
}
if scroll_offset > 0 {
visible_lines.insert(0, Line::from(vec![
Span::styled(
format!(" ↑ {} lines above (press ↑ or k to scroll)", scroll_offset),
theme.style_dim(),
),
]));
}
visible_lines
};
let paragraph = Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}