use crate::theme::Bg;
use crate::ui::bars::{Banner, StatusBar};
use crate::ui::chrome::{Header, KeyPanel, SearchPanel, TabBar, TitleBar, header_layout};
use crate::ui::overlays::Palette;
use crate::ui::page::PageView;
use crate::ui::{Ctx, PAD_X, PAD_Y, fill};
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use crate::ui::diff_view::{DiffView, Keys};
use crate::ui::file_tree::{FileTree, TREE_BG};
use crate::ui::review_sheets::{ComposeSheet, ConfirmSheet, SubmitSheet};
use crate::diff_screen::{self, DiffScreen, Pane};
use crate::keymap::Action;
use crate::state::{Overlay, Screen, State};
const PANE: Bg = Bg::ContainerLow;
pub fn view(state: &State, frame: &mut Frame, now: u64) {
let area = frame.area();
let buf = frame.buffer_mut();
let ctx = state.ctx(now);
if area.height < 3 || area.width < 10 {
fill(buf, area, ctx.theme, Bg::Surface);
return;
}
let chrome = state.chrome();
let lay = state.layout_of(&chrome);
let status = lay.status;
let crumbs: Vec<_> = chrome.crumbs.iter().map(|(c, _)| c.clone()).collect();
let right: Vec<_> = chrome.right.iter().map(|(t, _)| t.clone()).collect();
let search_input = match &state.overlay {
Some(Overlay::Search(sb)) => Some(&sb.input),
_ => None,
};
let placeholder = format!("Type {} to search", state.first_key(Action::Search));
Header {
ctx,
crumbs: &crumbs,
right: &right,
input: search_input,
placeholder: &placeholder,
}
.render(lay.header, buf);
if let (Some(rect), Some(pr)) = (lay.title, &chrome.title) {
TitleBar {
ctx,
line: pr_title(state, pr),
}
.render(rect, buf);
}
if let Some(rect) = lay.tabs {
let tabs: Vec<_> = chrome.tabs.iter().map(|(t, _)| t.clone()).collect();
TabBar {
ctx,
tabs: &tabs,
active: chrome.active,
}
.render(rect, buf);
}
let content = lay.content;
match state.screen() {
Screen::Page(p) => {
fill(buf, content, ctx.theme, PANE);
let hints = match &state.overlay {
Some(Overlay::Hints(h)) => h.shown(),
_ => Vec::new(),
};
PageView {
ctx,
page: p.page(),
scroll: p.scroll,
selected: p.selected,
current: p.current_match(),
hints: &hints,
}
.render(state.page_area(), buf);
}
Screen::Diff(screen) => render_diff(state, ctx, content, buf, screen),
}
separate_collapsed_tones(ctx, content, buf);
if let (Some(rect), Some(problem)) = (lay.problem, state.problems.values().next()) {
Banner {
ctx,
text: problem,
hint: None,
error: true,
}
.render(rect, buf);
}
let busy = state.busy();
let rate_limit = state
.rate_limits
.tightest()
.map(|(_, b)| ("API", b.remaining, b.limit));
StatusBar {
ctx,
busy: busy.as_deref(),
spinner: crate::state::SPINNER
.get(state.spinner % crate::state::SPINNER.len())
.unwrap_or(&""),
notice: state.notices.shown(),
rate_limit,
hints: &state.key_hints(),
}
.render(status, buf);
match &state.overlay {
Some(Overlay::Picker(p)) => {
let items: Vec<_> = state
.picker_rows(p)
.into_iter()
.map(|(item, _)| item)
.collect();
Palette {
ctx,
prompt: p.title(),
input: &p.input,
items: &items,
selected: p.selected,
}
.render(area, buf);
}
Some(Overlay::Compose(compose)) => {
let title = compose.title();
let note = match &compose.target {
crate::review::ComposeTarget::File {
reason: Some(reason),
..
} => Some(reason.as_str()),
_ => None,
};
ComposeSheet {
ctx,
title: &title,
input: &compose.input,
preview: compose
.preview
.as_ref()
.map(|p| (p.start_line, p.original.as_slice(), p.suggested.as_slice())),
note,
error: compose.error.as_deref(),
sending: compose.sending,
save: match compose.target {
crate::review::ComposeTarget::Reply { .. } => "post reply",
crate::review::ComposeTarget::Conversation { .. } => "post comment",
_ => "add to review",
},
}
.render(
Rect {
height: area.height.saturating_sub(1),
..area
},
buf,
);
}
Some(Overlay::Submit(dialog)) => {
let pending = dialog.drafts(state).unwrap_or_default();
let rejected = pending.iter().filter(|p| p.error.is_some()).count();
SubmitSheet {
ctx,
event: dialog.event,
input: &dialog.input,
pending: pending.len(),
on_github: dialog.on_github,
rejected,
error: dialog.error.as_deref(),
sending: dialog.sending,
quick: dialog.quick,
}
.render(area, buf);
}
Some(Overlay::Confirm(confirm)) => {
let choices: Vec<String> = confirm.choices.iter().map(|(l, _)| l.clone()).collect();
let busy = confirm.busy();
ConfirmSheet {
ctx,
title: &confirm.title,
facts: &confirm.facts,
choices: &choices,
selected: confirm.selected,
error: confirm.error.as_deref(),
sending: busy.as_deref(),
}
.render(area, buf);
}
Some(Overlay::Menu(menu)) => {
let (rows, selected) = state.menu_rows(menu);
let title = match &menu.filter {
Some(query) => format!("Here you can: /{query}▏"),
None => "Here you can".to_owned(),
};
KeyPanel {
ctx,
title: &title,
rows: &rows,
selected,
position: (menu.selected + 1, menu.shown().len()),
}
.render(area, buf);
}
Some(Overlay::Search(sb)) => {
let rows: Vec<_> = state.suggestions(sb).into_iter().map(|(r, _)| r).collect();
let field = header_layout(lay.header, &crumbs, &right).search;
SearchPanel {
ctx,
rows: &rows,
selected: sb.selected,
field,
}
.render(area, buf);
}
Some(Overlay::DiffSearch(input)) => {
fill(buf, status, ctx.theme, Bg::Container);
let row = Rect {
x: status.x + PAD_X,
width: status.width.saturating_sub(2 * PAD_X),
..status
};
Span::styled("/", ctx.theme.accent(Bg::Container)).render(row, buf);
input.render(
Rect {
x: row.x + 1,
width: row.width.saturating_sub(1),
..row
},
buf,
);
}
Some(Overlay::Hints(_)) | None => {}
}
}
fn render_diff(state: &State, ctx: Ctx<'_>, content: Rect, buf: &mut Buffer, screen: &DiffScreen) {
let theme = ctx.theme;
let lay = diff_screen::layout(content, screen.prefs.tree_visible);
fill(buf, content, theme, Bg::Surface);
let Some(diff) = state.diffs.get(&screen.of) else {
return;
};
let mut area = lay.diff;
if let Some(err) = &diff.error {
let text = format!("Couldn't load the diff: {err}");
let retry = format!("{} retry", state.first_key(Action::Refresh));
let banner = Banner {
ctx,
text: &text,
hint: Some(&retry),
error: true,
};
let height = banner.height(area.width, area.height / 3);
banner.render(Rect { height, ..area }, buf);
area.y += height;
area.height = area.height.saturating_sub(height);
}
let message_area = Rect {
x: area.x + PAD_X,
y: area.y + PAD_Y.min(area.height),
width: area.width.saturating_sub(2 * PAD_X),
height: 1,
};
if !diff.listed() {
if let Some(progress) = &diff.progress {
Span::styled(
crate::ui::text::truncate(progress, usize::from(message_area.width)),
theme.meta(Bg::Surface),
)
.render(message_area, buf);
}
return;
}
if diff.doc.is_empty() {
Span::styled("No changed files.", theme.meta(Bg::Surface)).render(message_area, buf);
return;
}
let key = |a| state.first_key(a);
let jump = key(Action::JumpMove);
let (show, expand, viewed, reply, resolve, delete, file_comment) = (
key(Action::Open),
key(Action::ExpandContext),
key(Action::ToggleViewed),
key(Action::Comment),
key(Action::ResolveThread),
key(Action::Delete),
key(Action::FileComment),
);
DiffView {
ctx,
doc: &diff.doc,
cursor: screen.cursor,
half: screen.half,
top: screen.top,
keys: Keys {
show: &show,
jump: &jump,
expand: &expand,
viewed: &viewed,
reply: &reply,
resolve: &resolve,
delete: &delete,
file_comment: &file_comment,
},
selection: screen.selection.map(|anchor| (anchor, screen.cursor)),
}
.render(area, buf);
if let Some(tree) = lay.tree {
FileTree {
ctx,
doc: &diff.doc,
rows: &diff.tree,
selected: screen.tree_selected,
scroll: screen.tree_scroll,
focused: screen.focus == Pane::Tree,
}
.render(tree, buf);
if theme.tones_collapse(TREE_BG, Bg::Surface) {
let x = tree.right().saturating_sub(1);
for y in tree.top()..tree.bottom() {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol("│").set_style(theme.separator(TREE_BG));
}
}
}
}
}
fn separate_collapsed_tones(ctx: Ctx<'_>, content: Rect, buf: &mut Buffer) {
if !ctx.theme.tones_collapse(Bg::Container, PANE) || content.height < 2 {
return;
}
let style = ctx.theme.separator(PANE);
for y in [content.y, content.bottom() - 1] {
let row_is_pane = buf
.cell((content.x, y))
.is_some_and(|c| c.bg == ctx.theme.bg_color(PANE) && c.symbol() == " ");
if !row_is_pane {
continue;
}
for x in content.left()..content.right() {
if let Some(cell) = buf.cell_mut((x, y))
&& cell.symbol() == " "
{
cell.set_symbol("─").set_style(style);
}
}
}
}
fn pr_title<'a>(state: &'a State, pr: &crate::api::model::PrRef) -> Line<'a> {
let theme = &state.theme;
let bar = Bg::Container;
let Some(d) = state.prs.get(pr).and_then(|r| r.data.as_ref()) else {
return Line::from(Span::styled(pr.to_string(), theme.title(bar)));
};
let (text, bg) = crate::ui::pages::state_label(crate::api::browse::Status::Pr(d.state));
Line::from(vec![
Span::styled(
format!(" {} {text} ", state.icons.pr_state(d.state)),
theme.fill(bg),
),
Span::styled(" ", theme.body(bar)),
Span::styled(d.title.clone(), theme.title(bar)),
Span::styled(format!(" #{}", pr.number), theme.meta(bar)),
])
}