use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use super::MIN_TERM_WIDTH;
use super::flock::MIN_HEIGHT;
use super::overlay;
use super::status::key_hint::modal_keys;
use crate::lookout::app::{App, Modal};
use crate::output::width::{char_columns, text_columns};
use crate::terminal_safe::sanitise_multiline;
const INTERIOR: u16 = 72;
const PAD: u16 = 1;
struct Parts {
boxed: bool,
title: Option<String>,
body: Vec<String>,
keys: Vec<String>,
rows: usize,
}
impl Parts {
fn reach(&self) -> usize {
self.body.len().saturating_sub(self.rows)
}
}
fn text_of(modal: &Modal) -> String {
match modal {
Modal::Grant { prompt, .. } => {
let listing = sanitise_multiline(prompt.listing()).0;
let question = sanitise_multiline(prompt.question()).0;
format!("{listing}\n{question}")
}
Modal::Output { text, .. } => text.as_str().to_owned(),
Modal::Confirm { question, .. } => sanitise_multiline(question).0,
}
}
fn chrome(boxed: bool, titled: bool, keys: usize) -> usize {
let border = if boxed { 2 } else { 0 };
let blanks = if boxed { 1 + usize::from(titled) } else { 0 };
border + usize::from(titled) + blanks + keys
}
fn measure(app: &App, area: Rect) -> Option<Parts> {
let modal = app.modal()?;
let title = match modal {
Modal::Output { title, .. } => Some(title.as_str().to_owned()),
Modal::Grant { .. } | Modal::Confirm { .. } => None,
};
let titled = title.is_some();
let height = usize::from(area.height);
let boxed = overlay::is_boxed(area.width, INTERIOR) && chrome(true, titled, 1) < height;
let width = if boxed { INTERIOR } else { area.width };
let inner = usize::from(width.saturating_sub(2 * PAD)).max(1);
let body = wrap_text(&text_of(modal), inner);
let rows_for = |keys: usize| {
body.len()
.min(height.saturating_sub(chrome(boxed, titled, keys)).max(1))
};
let resting = wrap_keys(modal_keys(modal, false), inner);
let scrolls = body.len() > rows_for(resting.len());
let keys = wrap_keys(modal_keys(modal, scrolls), inner);
let rows = rows_for(keys.len());
Some(Parts {
boxed,
title,
body,
keys,
rows,
})
}
pub(in crate::lookout) fn scroll_reach(app: &App, area: Rect) -> Option<usize> {
if area.width < MIN_TERM_WIDTH || area.height < MIN_HEIGHT {
return None;
}
measure(app, area).map(|parts| parts.reach())
}
pub(super) fn draw(app: &App, area: Rect, buffer: &mut Buffer) {
let Some(parts) = measure(app, area) else {
return;
};
let palette = app.palette();
overlay::mute(buffer, area, palette);
let pad = " ".repeat(usize::from(PAD));
let blank = || Line::default();
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(title) = &parts.title {
lines.push(Line::styled(format!("{pad}{title}"), palette.attention()));
if parts.boxed {
lines.push(blank());
}
}
let first = app.modal_scroll().min(parts.reach());
lines.extend(
parts
.body
.iter()
.skip(first)
.take(parts.rows)
.map(|text| Line::from(format!("{pad}{text}"))),
);
if parts.boxed {
lines.push(blank());
}
lines.extend(
parts
.keys
.iter()
.map(|text| Line::styled(format!("{pad}{text}"), palette.attention())),
);
if parts.boxed {
overlay::draw_boxed(&lines, INTERIOR, palette, palette.ground(), area, buffer);
return;
}
let shown = lines.len().min(usize::from(area.height));
let top = area.y + area.height - u16::try_from(shown).unwrap_or(area.height);
let row = overlay::blank_of(area.width);
for (offset, line) in lines.iter().take(shown).enumerate() {
let y = top + u16::try_from(offset).unwrap_or(0);
overlay::blank_row(
buffer,
area.x,
y,
&row,
Style::reset().patch(palette.ground()),
);
buffer.set_line(area.x, y, line, area.width);
}
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut out = Vec::new();
for raw in text.split('\n') {
let indent = raw
.chars()
.take_while(|ch| *ch == ' ')
.count()
.min(width / 2);
let lead = " ".repeat(indent);
let mut row = lead.clone();
let mut has_word = false;
for word in raw.split_whitespace() {
let columns = text_columns(word);
if has_word && text_columns(&row) + 1 + columns <= width {
row.push(' ');
row.push_str(word);
continue;
}
if has_word {
out.push(core::mem::replace(&mut row, lead.clone()));
}
if text_columns(&row) + columns <= width {
row.push_str(word);
} else {
push_split(word, width, &lead, &mut row, &mut out);
}
has_word = true;
}
out.push(if has_word { row } else { String::new() });
}
out
}
fn wrap_keys(keys: &str, width: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for clause in keys.split(" ") {
match out.last_mut() {
Some(row) if text_columns(row) + 3 + text_columns(clause) <= width => {
row.push_str(" ");
row.push_str(clause);
}
_ => out.extend(wrap_text(clause, width)),
}
}
out
}
fn push_split(word: &str, width: usize, lead: &str, row: &mut String, out: &mut Vec<String>) {
for ch in word.chars() {
let columns = char_columns(ch);
if text_columns(row) + columns > width && !row.trim().is_empty() {
out.push(core::mem::replace(row, lead.to_owned()));
}
row.push(ch);
}
}
#[cfg(test)]
mod tests;