use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use crate::app::App;
pub fn draw(f: &mut Frame, app: &App) {
let p = &app.palette;
let area = wizard_rect(f.area());
let block = Block::default()
.title(" Welcome to octopeek ")
.borders(Borders::ALL)
.border_style(Style::default().fg(p.border_focused))
.style(Style::default().bg(p.help_bg));
f.render_widget(Clear, area);
f.render_widget(block, area);
let inner = inner_area(area);
if app.first_run_suggestions.is_empty() {
render_empty(f, app, inner);
} else {
render_suggestions(f, app, inner);
}
}
fn render_empty(f: &mut Frame, app: &App, area: Rect) {
let p = &app.palette;
let text =
"No repositories to suggest yet. Press `a` to add one manually, or `Esc` to continue.";
let paragraph =
Paragraph::new(Span::styled(text, Style::default().fg(p.dim))).wrap(Wrap { trim: false });
f.render_widget(paragraph, area);
}
fn render_suggestions(f: &mut Frame, app: &App, area: Rect) {
const BLURB_ROWS: u16 = 2;
const SEPARATOR: u16 = 1;
const FOOTER_ROWS: u16 = 1;
let p = &app.palette;
let fixed = BLURB_ROWS + SEPARATOR + SEPARATOR + FOOTER_ROWS;
let list_height = area.height.saturating_sub(fixed);
let [blurb_area, _sep1, list_area, _sep2, footer_area] = Layout::vertical([
Constraint::Length(BLURB_ROWS),
Constraint::Length(SEPARATOR),
Constraint::Length(list_height),
Constraint::Length(SEPARATOR),
Constraint::Length(FOOTER_ROWS),
])
.areas(area);
let blurb_lines: Vec<Line> = vec![
Line::from(Span::styled(
"We found these repositories you're active in.",
Style::default().fg(p.dim),
)),
Line::from(Span::styled("Pick the ones you want to track.", Style::default().fg(p.dim))),
];
let blurb = Paragraph::new(blurb_lines);
f.render_widget(blurb, blurb_area);
let visible = list_height as usize;
let total = app.first_run_suggestions.len();
let cursor = app.first_run_cursor.min(total.saturating_sub(1));
let scroll = if visible == 0 {
0
} else {
cursor.saturating_sub(visible - 1).min(total.saturating_sub(visible))
};
let list_lines: Vec<Line> = app
.first_run_suggestions
.iter()
.enumerate()
.skip(scroll)
.take(visible)
.map(|(idx, s)| {
let checkbox = if s.selected { "[x]" } else { "[ ]" };
let text = format!(
" {checkbox} {} ({} open item{})",
s.repo,
s.count,
if s.count == 1 { "" } else { "s" }
);
if idx == cursor {
Line::from(Span::styled(
text,
Style::default()
.fg(p.selection_fg)
.bg(p.selection_bg)
.add_modifier(Modifier::BOLD),
))
} else {
Line::from(Span::styled(text, Style::default().fg(p.foreground)))
}
})
.collect();
let list = Paragraph::new(list_lines);
f.render_widget(list, list_area);
let footer = Paragraph::new(Span::styled(
"Space toggle Enter confirm a add custom Esc skip",
Style::default().fg(p.dim),
));
f.render_widget(footer, footer_area);
}
fn wizard_rect(area: Rect) -> Rect {
let width = 70u16.min(area.width);
let height = 24u16.min(area.height.saturating_sub(4)).max(8);
let [_, center_v, _] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(height), Constraint::Fill(1)])
.flex(Flex::Center)
.areas(area);
let [_, center_h, _] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(width), Constraint::Fill(1)])
.flex(Flex::Center)
.areas(center_v);
center_h
}
fn inner_area(area: Rect) -> Rect {
Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use crate::app::{FirstRunSuggestion, Focus};
fn make_app_with_suggestions(suggestions: Vec<FirstRunSuggestion>) -> App {
let config = crate::config::Config::default();
let session = crate::state::AppSession::default();
let mut app = App::new(config, session);
app.focus = Focus::FirstRun;
app.first_run_suggestions = suggestions;
app
}
#[test]
fn draw_with_suggestions_shows_content() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
let suggestions = vec![
FirstRunSuggestion { repo: "alice/foo".to_owned(), count: 3, selected: true },
FirstRunSuggestion { repo: "bob/bar".to_owned(), count: 1, selected: false },
];
let app = make_app_with_suggestions(suggestions);
terminal.draw(|f| draw(f, &app)).expect("draw");
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(rendered.contains("Welcome to octopeek"), "title must render");
assert!(rendered.contains("alice/foo"), "suggestion repo must render");
});
}
#[test]
fn draw_empty_suggestions_shows_fallback() {
let tmp = tempfile::tempdir().expect("tempdir");
crate::config::with_config_dir_override(tmp.path(), || {
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
let app = make_app_with_suggestions(vec![]);
terminal.draw(|f| draw(f, &app)).expect("draw");
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(
rendered.contains("No repositories to suggest"),
"fallback message must render; got: {rendered}"
);
});
}
}