use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::io::registry::abbreviate_path;
use crate::tui::app::{App, ProjectPickerState};
use crate::tui::theme::Theme;
use crate::util::unicode;
pub fn render_project_picker(frame: &mut Frame, app: &App, area: Rect) {
let picker = match &app.project_picker {
Some(p) => p,
None => return,
};
render_project_picker_inner(frame, picker, &app.theme, area);
}
pub fn render_project_picker_standalone(
frame: &mut Frame,
picker: &ProjectPickerState,
theme: &Theme,
area: Rect,
) {
render_project_picker_inner(frame, picker, theme, area);
}
fn render_project_picker_inner(
frame: &mut Frame,
picker: &ProjectPickerState,
theme: &Theme,
area: Rect,
) {
let bg = theme.background;
let text_color = theme.text;
let bright = theme.text_bright;
let dim = theme.dim;
let highlight = theme.highlight;
let sel_bg = theme.selection_bg;
let bg_style = Style::default().bg(bg);
let target_w = (area.width as f32 * 0.6) as u16;
let popup_w = target_w.clamp(40, 62).min(area.width.saturating_sub(2));
let inner_w = (popup_w - 2) as usize; let content_w = inner_w.saturating_sub(2);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(Span::styled(" ".repeat(inner_w), bg_style)));
if picker.entries.is_empty() {
let empty_lines = [
" No projects registered.",
"",
" Run `fr init` in a project directory",
" or `fr projects add <path>` to register.",
];
for text in &empty_lines {
let padded = format!(" {}", text);
let mut spans = vec![Span::styled(
padded.clone(),
Style::default().fg(dim).bg(bg),
)];
let used = unicode::display_width(&padded);
if used < inner_w {
spans.push(Span::styled(" ".repeat(inner_w - used), bg_style));
}
lines.push(Line::from(spans));
}
} else {
let max_name = picker
.entries
.iter()
.map(|e| unicode::display_width(&e.name))
.max()
.unwrap_or(0)
.min(content_w / 3);
let name_col = max_name + 2;
for (i, entry) in picker.entries.iter().enumerate() {
let is_selected = i == picker.cursor;
let is_current = picker
.current_project_path
.as_ref()
.is_some_and(|p| *p == entry.path);
let exists = std::path::Path::new(&entry.path).join("frame").exists();
let row_bg = if is_selected { sel_bg } else { bg };
let row_pad = Style::default().bg(row_bg);
let row_style = if is_selected {
Style::default()
.fg(bright)
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else {
row_pad
};
let mut spans: Vec<Span> = Vec::new();
let indicator = if is_selected { " \u{25B6} " } else { " " };
spans.push(Span::styled(indicator, row_style));
let name_display: String = if unicode::display_width(&entry.name) > max_name {
let truncated: String = entry.name.chars().take(max_name - 1).collect();
format!("{}\u{2026}", truncated)
} else {
entry.name.clone()
};
let name_color = if !exists {
dim
} else if is_current {
highlight
} else {
bright
};
let name_style = if is_selected {
Style::default()
.fg(name_color)
.bg(row_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(name_color).bg(row_bg)
};
spans.push(Span::styled(name_display.clone(), name_style));
let name_chars = unicode::display_width(&name_display);
let pad = name_col.saturating_sub(name_chars);
spans.push(Span::styled(" ".repeat(pad), row_pad));
let used_so_far = 3 + name_chars + pad; let path_budget = inner_w.saturating_sub(used_so_far + 1); let path_full = if !exists {
"(not found)".to_string()
} else {
abbreviate_path(&entry.path)
};
let path_display = if unicode::display_width(&path_full) > path_budget {
let truncated: String = path_full
.chars()
.take(path_budget.saturating_sub(1))
.collect();
format!("{}\u{2026}", truncated)
} else {
path_full
};
let path_color = if !exists { dim } else { text_color };
spans.push(Span::styled(
path_display,
Style::default().fg(path_color).bg(row_bg),
));
if picker.confirm_remove == Some(i) {
spans.push(Span::styled(
" remove? X",
Style::default()
.fg(highlight)
.bg(row_bg)
.add_modifier(Modifier::BOLD),
));
}
pad_to_width(&mut spans, inner_w, row_pad);
lines.push(Line::from(spans));
}
}
lines.push(Line::from(Span::styled(" ".repeat(inner_w), bg_style)));
let sort_label = if picker.sort_alpha {
"sorted by: name"
} else {
"sorted by: recent"
};
let sort_style = Style::default().fg(text_color).bg(bg);
let mut sort_spans = vec![
Span::styled(" ", bg_style),
Span::styled(sort_label, sort_style),
];
let sort_used = 1 + unicode::display_width(sort_label);
if sort_used < inner_w {
sort_spans.push(Span::styled(" ".repeat(inner_w - sort_used), bg_style));
}
lines.push(Line::from(sort_spans));
let hint_style = Style::default().fg(dim).bg(bg);
let hint1 = " \u{2191}\u{2193}/jk navigate Enter open s sort";
let hint2 = " X remove Esc close";
let hint1_len = unicode::display_width(hint1);
let hint2_len = unicode::display_width(hint2);
let mut hint1_spans = vec![Span::styled(hint1, hint_style)];
if hint1_len < inner_w {
hint1_spans.push(Span::styled(" ".repeat(inner_w - hint1_len), bg_style));
}
lines.push(Line::from(hint1_spans));
let mut hint2_spans = vec![Span::styled(hint2, hint_style)];
if hint2_len < inner_w {
hint2_spans.push(Span::styled(" ".repeat(inner_w - hint2_len), bg_style));
}
lines.push(Line::from(hint2_spans));
let max_h = ((area.height as f32) * 0.7) as u16;
let content_h = lines.len() as u16;
let popup_h = (content_h + 2)
.min(max_h)
.min(area.height.saturating_sub(2));
let x = area.x + area.width.saturating_sub(popup_w) / 2;
let y = area.y + area.height.saturating_sub(popup_h) / 2;
let popup_area = Rect::new(x, y, popup_w, popup_h);
frame.render_widget(Clear, popup_area);
let title = " Projects ";
let title_style = Style::default()
.fg(text_color)
.bg(bg)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.title(Span::styled(title, title_style))
.borders(Borders::ALL)
.border_style(Style::default().fg(text_color).bg(bg))
.style(Style::default().bg(bg));
let scroll = picker.scroll_offset;
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((scroll as u16, 0))
.style(Style::default().bg(bg));
frame.render_widget(paragraph, popup_area);
}
fn pad_to_width<'a>(spans: &mut Vec<Span<'a>>, target_width: usize, pad_style: Style) {
let total_used: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
if total_used < target_width {
spans.push(Span::styled(
" ".repeat(target_width - total_used),
pad_style,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::registry::ProjectEntry;
use crate::tui::app::ProjectPickerState;
use crate::tui::render::test_helpers::*;
use insta::assert_snapshot;
#[test]
fn picker_with_entries() {
let mut app = app_with_track(SIMPLE_TRACK_MD);
app.project_picker = Some(ProjectPickerState {
entries: vec![
ProjectEntry {
name: "Project Alpha".into(),
path: "/home/user/alpha".into(),
last_accessed_tui: None,
last_accessed_cli: None,
},
ProjectEntry {
name: "Project Beta".into(),
path: "/home/user/beta".into(),
last_accessed_tui: None,
last_accessed_cli: None,
},
],
cursor: 0,
scroll_offset: 0,
sort_alpha: false,
current_project_path: Some("/home/user/alpha".into()),
confirm_remove: None,
});
let output = render_to_string(TERM_W, TERM_H, |frame, area| {
render_project_picker(frame, &app, area);
});
assert_snapshot!(output);
}
}