use crate::render_backend::{buffer_draw_text, buffer_fill_rect, OptimizedBuffer, Style};
use super::components::{
draw_block, draw_help_bar_ext, draw_text_truncated, BlockLine, HotkeyHint, Rect,
};
use crate::model::{Model, ReviewFilter};
const HEADER_HEIGHT: u32 = 5;
const SEARCH_HEIGHT: u32 = 2;
const ITEM_HEIGHT: u32 = 2;
pub fn view(model: &Model, buffer: &mut OptimizedBuffer) {
let theme = &model.theme;
let area = Rect::from_size(model.width, model.height);
buffer_fill_rect(
buffer,
area.x,
area.y,
area.width,
area.height,
theme.background,
);
let header_text = model.repo_path.as_ref().map_or_else(
|| "Reviews".to_string(),
|path| {
let display_path = std::env::var("HOME")
.ok()
.and_then(|home| path.strip_prefix(&home).map(|rest| format!("~{rest}")))
.unwrap_or_else(|| path.clone());
format!("Reviews for {display_path}")
},
);
draw_block(
buffer,
Rect::new(area.x, area.y, area.width, HEADER_HEIGHT),
theme,
theme.panel_bg,
&[BlockLine::new(
&header_text,
Style::fg(theme.foreground).with_bold(),
)],
);
let search_y = area.y + HEADER_HEIGHT;
draw_search_bar(model, buffer, area.x, search_y, area.width);
let list_y = search_y + SEARCH_HEIGHT;
let list_height = area
.height
.saturating_sub(HEADER_HEIGHT + SEARCH_HEIGHT + 2); let list_area = Rect::new(area.x, list_y, area.width, list_height);
let reviews = model.filtered_reviews();
if reviews.is_empty() {
buffer_draw_text(
buffer,
list_area.x + 4,
list_area.y,
"No reviews found",
theme.style_muted(),
);
render_help_bar(model, buffer, area);
return;
}
let visible_items = (list_height / ITEM_HEIGHT) as usize;
let start = model.list_scroll.min(reviews.len());
let end = (start + visible_items).min(reviews.len());
for (row, review) in reviews[start..end].iter().enumerate() {
let idx = start + row;
let y = list_area.y + (row as u32) * ITEM_HEIGHT;
draw_review_item(model, buffer, list_area, y, review, idx == model.list_index);
}
render_help_bar(model, buffer, area);
}
fn draw_search_bar(model: &Model, buffer: &mut OptimizedBuffer, x: u32, y: u32, width: u32) {
let theme = &model.theme;
buffer_fill_rect(buffer, x, y, width, SEARCH_HEIGHT, theme.background);
let text_x = x + 5;
if model.search_active {
let max_chars = width.saturating_sub(8) as usize; let visible = tail_chars(&model.search_input, max_chars);
let prompt = format!("/ {visible}\u{2588}");
buffer_draw_text(buffer, text_x, y, &prompt, theme.style_foreground());
} else {
buffer_draw_text(buffer, text_x, y, "Press / to search", theme.style_muted());
}
}
fn tail_chars(text: &str, max_chars: usize) -> &str {
if max_chars == 0 {
return "";
}
let total = text.chars().count();
if total <= max_chars {
return text;
}
let skip = total - max_chars;
let start = text.char_indices().nth(skip).map_or(0, |(idx, _)| idx);
&text[start..]
}
fn draw_review_item(
model: &Model,
buffer: &mut OptimizedBuffer,
area: Rect,
y: u32,
review: &crate::db::ReviewSummary,
selected: bool,
) {
let theme = &model.theme;
let bg = if selected {
theme.selection_bg
} else {
theme.background
};
let margin: u32 = 2;
let item_x = area.x + margin;
let item_width = area.width.saturating_sub(margin * 2);
buffer_fill_rect(buffer, item_x, y, item_width, ITEM_HEIGHT, bg);
let left_pad: u32 = 3;
let right_pad: u32 = 2;
let mut x = item_x + left_pad;
let right_edge = item_x + item_width.saturating_sub(right_pad);
let id_style = Style::fg(theme.primary).with_bg(bg);
let id_len = review.review_id.len() as u32;
buffer_draw_text(buffer, x, y, &review.review_id, id_style);
x += id_len + 2;
let thread_text = format_thread_label(review.thread_count, review.open_thread_count);
let thread_len = thread_text.len() as u32;
let thread_x = right_edge.saturating_sub(thread_len);
let thread_color = if selected {
theme.selection_fg
} else if review.open_thread_count > 0 {
theme.warning
} else {
theme.muted
};
buffer_draw_text(
buffer,
thread_x,
y,
&thread_text,
Style::fg(thread_color).with_bg(bg),
);
let title_width = thread_x.saturating_sub(x + 1);
let title_style = if selected {
Style::fg(theme.selection_fg).with_bg(bg)
} else {
Style::fg(theme.foreground).with_bg(bg)
};
draw_text_truncated(buffer, x, y, &review.title, title_width, title_style);
let y2 = y + 1;
let mut x2 = item_x + left_pad;
let badge = format!("[{}]", review.status);
let badge_color = if selected {
theme.selection_fg
} else {
match review.status.as_str() {
"open" | "merged" => theme.success,
"abandoned" => theme.muted,
"approved" => theme.warning,
_ => theme.foreground,
}
};
buffer_draw_text(buffer, x2, y2, &badge, Style::fg(badge_color).with_bg(bg));
x2 += badge.len() as u32 + 2;
let people = if review.reviewers.is_empty() {
format!("@{}", review.author)
} else {
let reviewers: Vec<String> = review.reviewers.iter().map(|r| format!("@{r}")).collect();
format!("@{} -> {}", review.author, reviewers.join(", "))
};
let people_color = if selected {
theme.selection_fg
} else {
theme.muted
};
let people_width = right_edge.saturating_sub(x2);
draw_text_truncated(
buffer,
x2,
y2,
&people,
people_width,
Style::fg(people_color).with_bg(bg),
);
}
fn format_thread_label(total: i64, open: i64) -> String {
if total == 0 {
return String::new();
}
if open > 0 {
format!("{open}/{total} th")
} else {
format!("{total} th")
}
}
fn render_help_bar(model: &Model, buffer: &mut OptimizedBuffer, area: Rect) {
let version = concat!("seal-ui v", env!("CARGO_PKG_VERSION"));
let filter_hint = HotkeyHint::new(
match model.filter {
ReviewFilter::All => "Status (All)",
ReviewFilter::Open => "Status (Open)",
ReviewFilter::Closed => "Status (Closed)",
},
"s",
);
if model.search_active {
let hints = &[
HotkeyHint::new("Commands", "ctrl+p"),
HotkeyHint::new("Select", "Enter"),
filter_hint,
HotkeyHint::new("Clear", "Esc"),
HotkeyHint::new("Quit", "ctrl+c"),
];
draw_help_bar_ext(
buffer,
area,
&model.theme,
hints,
model.theme.background,
version,
);
} else {
let hints = &[
HotkeyHint::new("Commands", "ctrl+p"),
HotkeyHint::new("Select", "Enter"),
filter_hint,
HotkeyHint::new("Quit", "q"),
];
draw_help_bar_ext(
buffer,
area,
&model.theme,
hints,
model.theme.background,
version,
);
}
}