#![deny(clippy::arithmetic_side_effects)]
use crate::api::model::ReviewEvent;
use crate::theme::{Bg, DiffBg, Fg, Theme};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use ratatui_textarea::TextArea;
use crate::ui::{Ctx, PAD_Y, centered, chips, cols, fill, fit_spans, idx, key_hints, padded, text};
const SHEET: Bg = Bg::ContainerHigh;
const INPUT_ROWS: u16 = 8;
pub struct ComposeSheet<'a> {
pub ctx: Ctx<'a>,
pub title: &'a str,
pub input: &'a TextArea<'static>,
pub preview: Option<(u32, &'a [String], &'a [String])>,
pub note: Option<&'a str>,
pub error: Option<&'a str>,
pub sending: bool,
pub save: &'a str,
pub completions: Option<(&'a [String], usize)>,
}
fn preview_rows(lines: Option<usize>) -> u16 {
lines.map_or(0, |n| cols(n).saturating_add(2)).min(12)
}
pub fn compose_rows(preview: Option<usize>, note: bool) -> u16 {
(2 * PAD_Y + 2 + INPUT_ROWS + 2)
.saturating_add(preview_rows(preview))
.saturating_add(u16::from(note))
}
impl Widget for ComposeSheet<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let theme = self.ctx.theme;
let preview_rows = preview_rows(
self.preview
.map(|(_, o, s)| o.len().saturating_add(s.len())),
);
fill(buf, area, theme, SHEET);
let inner = padded(area);
let mut y = inner.y;
let row = |y: u16| Rect {
y,
height: 1,
..inner
};
let title = text::truncate(self.title, usize::from(inner.width));
Span::styled(title, theme.title(SHEET)).render(row(y), buf);
y = y.saturating_add(1);
if let Some(note) = self.note {
Span::styled(
text::truncate(note, usize::from(inner.width)),
theme.meta(SHEET),
)
.render(row(y), buf);
y = y.saturating_add(1);
}
y = y.saturating_add(1);
let input_rows = INPUT_ROWS.min(
inner
.bottom()
.saturating_sub(y)
.saturating_sub(preview_rows.saturating_add(2)),
);
let input_area = Rect {
y,
height: input_rows,
..inner
};
self.input.render(input_area, buf);
let completions = self.completions.zip(cursor_in(theme, input_area, buf));
y = y.saturating_add(input_rows).saturating_add(1);
if let Some((start, original, suggested)) = self.preview {
Span::styled("Preview of the suggestion", theme.meta(SHEET)).render(row(y), buf);
y = y.saturating_add(1);
let removed = (DiffBg::Removed, "-", Fg::DiffRemovedSign);
let added = (DiffBg::Added, "+", Fg::DiffAddedSign);
let lines = original.iter().enumerate().map(|(i, l)| (removed, i, l));
let lines = lines.chain(suggested.iter().enumerate().map(|(i, l)| (added, i, l)));
for ((diff_bg, sign, fg), i, line) in lines {
if y.saturating_add(1) >= inner.bottom() {
break;
}
let n = start.saturating_add(idx(i));
let bg = Bg::Diff(diff_bg);
let r = row(y);
fill(buf, r, theme, bg);
Line::from(vec![
Span::styled(format!("{n:>4} "), theme.meta(bg)),
Span::styled(sign, theme.style(fg, bg).add_modifier(Modifier::BOLD)),
Span::styled(" ", theme.body(bg)),
Span::styled(
text::truncate(
&text::expand_tabs(line.as_str()),
usize::from(r.width).saturating_sub(8),
),
theme.body(bg),
),
])
.render(r, buf);
y = y.saturating_add(1);
}
y = y.saturating_add(1);
}
footer(
theme,
row(inner.bottom().saturating_sub(1).max(y)),
buf,
self.sending.then_some("Posting…"),
self.error,
&[
("ctrl-s", self.save),
("ctrl-e", "open in $EDITOR"),
("esc", "cancel"),
("ctrl-g", "saved replies"),
],
);
if let Some(((choices, selected), at)) = completions {
completion_list(theme, choices, selected, at, buf);
}
}
}
fn cursor_in(theme: &Theme, area: Rect, buf: &Buffer) -> Option<(u16, u16)> {
let cursor = theme.fill(Bg::Primary).bg;
area.rows()
.flat_map(Rect::columns)
.find(|p| buf.cell(*p).is_some_and(|c| Some(c.bg) == cursor))
.map(|p| (p.x, p.y))
}
fn completion_list(
theme: &Theme,
choices: &[String],
selected: usize,
(x, y): (u16, u16),
buf: &mut Buffer,
) {
const LIST: Bg = Bg::ContainerHighest;
let screen = buf.area;
let widest = choices.iter().map(|c| text::width(c)).max().unwrap_or(0);
let width = cols(widest.saturating_add(2)).min(56).min(screen.width);
let height = cols(choices.len());
let below = y.saturating_add(1);
let top = if below.saturating_add(height) <= screen.bottom() {
below
} else {
y.saturating_sub(height)
};
let left = x.min(screen.right().saturating_sub(width));
for (i, choice) in choices.iter().enumerate() {
let bg = if i == selected {
Bg::SecondaryContainer
} else {
LIST
};
let row = Rect {
x: left,
y: top.saturating_add(cols(i)),
width,
height: 1,
};
fill(buf, row, theme, bg);
let shown = text::truncate(choice, usize::from(width.saturating_sub(2)));
Span::styled(format!(" {shown}"), theme.body(bg)).render(row, buf);
}
}
fn footer(
theme: &Theme,
area: Rect,
buf: &mut Buffer,
busy: Option<&str>,
error: Option<&str>,
hints: &[(&str, &str)],
) {
let spans = match (busy, error) {
(Some(busy), _) => vec![Span::styled(busy.to_owned(), theme.meta(SHEET))],
(None, Some(error)) => vec![Span::styled(error.to_owned(), theme.error(SHEET))],
(None, None) => key_hints(theme, SHEET, hints),
};
Line::from(fit_spans(&spans, area.width)).render(area, buf);
}
pub struct SubmitSheet<'a> {
pub ctx: Ctx<'a>,
pub event: ReviewEvent,
pub input: &'a TextArea<'static>,
pub pending: usize,
pub on_github: Option<u64>,
pub rejected: usize,
pub error: Option<&'a str>,
pub sending: bool,
pub quick: bool,
}
impl Widget for SubmitSheet<'_> {
fn render(self, screen: Rect, buf: &mut Buffer) {
let ctx = self.ctx;
let theme = ctx.theme;
let height = 2 * PAD_Y + 10;
let top = screen.height.saturating_sub(height) / 2;
let area = centered(screen, 76.min(screen.width.saturating_sub(4)), height, top);
fill(buf, area, theme, SHEET);
let inner = padded(area);
let row = |y: u16| Rect {
y,
height: 1,
..inner
};
let mut y = inner.y;
Span::styled("Submit review", theme.title(SHEET)).render(row(y), buf);
y = y.saturating_add(1);
let s = if self.pending == 1 { "" } else { "s" };
let pending = match (self.pending, self.rejected) {
(0, _) => "No pending comments here.".to_owned(),
(n, 0) => format!("{n} pending comment{s} will be added."),
(n, r) => format!(
"{n} pending comment{s}, {r} previously rejected (fix or delete those first)."
),
};
let pending = text::truncate(&pending, usize::from(inner.width));
Span::styled(pending, theme.meta(SHEET)).render(row(y), buf);
let on_github = match self.on_github {
None => Span::styled(
"Checking GitHub for your pending review…",
theme.meta(SHEET),
),
Some(0) => Span::raw(""),
Some(n) => Span::styled(
if n == 1 {
"Your pending review on GitHub holds 1 comment: it's published too.".to_owned()
} else {
format!(
"Your pending review on GitHub holds {n} comments: they're published too."
)
},
theme.error(SHEET),
),
};
Line::from(fit_spans(&[on_github], inner.width)).render(row(y.saturating_add(1)), buf);
y = y.saturating_add(2);
let mut buttons: Vec<Span<'static>> = Vec::new();
for (event, label) in [
(ReviewEvent::Comment, "Comment"),
(ReviewEvent::Approve, "Approve"),
(ReviewEvent::RequestChanges, "Request changes"),
] {
let style = if event == self.event {
theme.fill(Bg::Primary).add_modifier(Modifier::BOLD)
} else {
theme.fill(Bg::SecondaryContainer)
};
buttons.extend(chips::chip(ctx, format!(" {label} "), style, SHEET));
buttons.push(Span::styled(" ", theme.body(SHEET)));
}
Line::from(buttons).render(row(y), buf);
y = y.saturating_add(2);
let input_area = Rect {
y,
height: 3.min(inner.bottom().saturating_sub(y).saturating_sub(2)),
..inner
};
self.input.render(input_area, buf);
footer(
theme,
row(inner.bottom().saturating_sub(1)),
buf,
self.sending.then_some("Submitting…"),
self.error,
if self.quick {
&[
("↵", "submit"),
("⇥", "choose"),
("ctrl-e", "$EDITOR"),
("esc", "cancel"),
]
} else {
&[
("⇥", "choose"),
("ctrl-s", "submit"),
("ctrl-e", "$EDITOR"),
("esc", "cancel"),
]
},
);
}
}
pub struct ConfirmSheet<'a> {
pub ctx: Ctx<'a>,
pub title: &'a str,
pub facts: &'a [(String, bool)],
pub choices: &'a [String],
pub selected: usize,
pub error: Option<&'a str>,
pub sending: Option<&'a str>,
}
impl Widget for ConfirmSheet<'_> {
fn render(self, screen: Rect, buf: &mut Buffer) {
let ctx = self.ctx;
let theme = ctx.theme;
let width = 76.min(screen.width.saturating_sub(4));
let error_rows: u16 = if self.error.is_some() { 3 } else { 0 };
let height = (2 * PAD_Y + 6)
.saturating_add(cols(self.facts.len()))
.saturating_add(error_rows);
let top = screen.height.saturating_sub(height) / 2;
let area = centered(screen, width, height, top);
fill(buf, area, theme, SHEET);
let inner = padded(area);
let row = |y: u16| Rect {
y,
height: 1,
..inner
};
let mut y = inner.y;
let title = text::truncate(self.title, usize::from(inner.width));
Span::styled(title, theme.title(SHEET)).render(row(y), buf);
y = y.saturating_add(2);
for (fact, problem) in self.facts {
let style = if *problem {
theme.error(SHEET)
} else {
theme.meta(SHEET)
};
let fact = text::truncate(fact, usize::from(inner.width));
Span::styled(fact, style).render(row(y), buf);
y = y.saturating_add(1);
}
if !self.facts.is_empty() {
y = y.saturating_add(1);
}
let mut buttons: Vec<Span<'static>> = Vec::new();
for (i, label) in self.choices.iter().enumerate() {
let style = if i == self.selected {
theme.fill(Bg::Primary).add_modifier(Modifier::BOLD)
} else {
theme.fill(Bg::SecondaryContainer)
};
buttons.extend(chips::chip(ctx, format!(" {label} "), style, SHEET));
buttons.push(Span::styled(" ", theme.body(SHEET)));
}
Line::from(buttons).render(row(y), buf);
if let Some(error) = self.error {
let area = Rect {
y: y.saturating_add(2),
height: error_rows,
..inner
};
ratatui::widgets::Paragraph::new(error.to_owned())
.style(theme.error(SHEET))
.wrap(ratatui::widgets::Wrap { trim: true })
.render(area, buf);
}
let hints: &[(&str, &str)] = if self.choices.len() > 1 {
&[("⇥", "choose"), ("↵", "do it"), ("esc", "back")]
} else {
&[("↵", "do it"), ("esc", "back")]
};
footer(
theme,
row(inner.bottom().saturating_sub(1)),
buf,
self.sending,
None,
hints,
);
}
}