use ratada::nav::ScrollView;
use ratada::theme::Skin;
use ratada::{chrome, scroll, style};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph};
use unicode_width::UnicodeWidthStr;
use crate::domain::history::HistoryEntry;
use crate::tui::colors::{CaretColors, to_ratatui};
use crate::tui::text_edit::{self, SpanContext, TextCursor};
const MIN_ENTRY_LINES: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Input,
History,
Edit(usize),
}
impl Mode {
pub fn is_text_editing(self) -> bool {
matches!(self, Mode::Input | Mode::Edit(_))
}
}
pub struct Row<'a> {
pub entry: &'a HistoryEntry,
pub result: String,
}
pub struct HistoryStyle {
pub spacing: usize,
pub separator: Option<Color>,
}
pub struct CalcView<'a> {
pub rows: &'a [Row<'a>],
pub selected: Option<usize>,
pub mode: Mode,
pub input: &'a str,
pub cursor: TextCursor,
pub input_styles: &'a [Style],
pub row_styles: &'a [Vec<Style>],
pub completion: Vec<Line<'static>>,
pub caret: CaretColors,
pub style: HistoryStyle,
pub accent_color: Color,
pub error_color: Color,
pub warn: &'a str,
pub feedback: Option<Line<'static>>,
pub input_max_lines: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Metrics {
pub input_width: usize,
pub history_width: usize,
pub view_height: usize,
}
pub fn render(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: &CalcView<'_>,
) -> Metrics {
let input_height = input_box_height(view, area.width);
let rows = Layout::vertical([
Constraint::Min(1),
Constraint::Length(input_height),
])
.split(area);
let mut metrics = render_history(frame, rows[0], skin, view);
metrics.input_width = render_input(frame, rows[1], skin, view);
render_completion(frame, rows[0], skin, &view.completion);
metrics
}
fn render_completion(
frame: &mut Frame,
area: Rect,
skin: &Skin,
lines: &[Line<'static>],
) {
if lines.is_empty() {
return;
}
let content_width =
lines.iter().map(|line| line.width()).max().unwrap_or(0) as u16;
let width = (content_width + 4).min(area.width).max(3);
let wanted = lines.len() as u16 + 2;
let height = wanted.min(area.height);
if height < 3 {
return;
}
let box_area = Rect {
x: area.x,
y: area.y + area.height - height,
width,
height,
};
let visible = height as usize - 2;
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(style::border_focus(&skin.palette))
.style(style::bg(skin.palette.input_bg))
.padding(Padding::horizontal(1));
frame.render_widget(Clear, box_area);
frame.render_widget(
Paragraph::new(lines[..visible.min(lines.len())].to_vec()).block(block),
box_area,
);
}
fn render_history(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: &CalcView<'_>,
) -> Metrics {
let height = area.height as usize;
let full_width = area.width as usize;
let mut metrics = Metrics {
history_width: full_width.max(1),
view_height: 1,
..Metrics::default()
};
if height == 0 || full_width == 0 {
return metrics;
}
if view.rows.is_empty() {
let hint = Line::from(Span::styled(
"type an expression and press Enter",
style::secondary(&skin.palette),
));
frame.render_widget(Paragraph::new(hint), area);
return metrics;
}
let probe = entry_starts(view, full_width);
let total = *probe.last().expect("entry_starts appends a sentinel");
let overflow = total > height;
let width = if overflow {
full_width.saturating_sub(2).max(1)
} else {
full_width
};
metrics.history_width = width;
let starts = if overflow {
entry_starts(view, width)
} else {
probe
};
let total_lines = *starts.last().expect("entry_starts appends a sentinel");
let start_line = window_start(total_lines, height, view.selected, &starts);
metrics.view_height =
(height / (MIN_ENTRY_LINES + view.style.spacing)).max(1);
let window_end = start_line + height;
let mut lines: Vec<Line> = Vec::with_capacity(height);
for (index, row) in view.rows.iter().enumerate() {
if starts[index + 1] <= start_line {
continue;
}
if starts[index] >= window_end {
break;
}
for (offset, line) in entry_block(view, skin, row, index, width)
.into_iter()
.enumerate()
{
let global = starts[index] + offset;
if global >= start_line && global < window_end {
lines.push(line);
}
}
}
let content = Rect {
width: width as u16,
..area
};
frame.render_widget(Paragraph::new(lines), content);
scroll::render_scrollbar(
frame,
area,
skin,
ScrollView {
total: total_lines,
offset: start_line,
viewport: height,
},
);
metrics
}
fn entry_starts(view: &CalcView<'_>, width: usize) -> Vec<usize> {
let mut starts = Vec::with_capacity(view.rows.len() + 1);
let mut acc = 0;
for (index, row) in view.rows.iter().enumerate() {
starts.push(acc);
acc += entry_height(view, row, index, width);
}
starts.push(acc);
starts
}
fn entry_height(
view: &CalcView<'_>,
row: &Row<'_>,
index: usize,
width: usize,
) -> usize {
let input_lines =
text_edit::wrap_offsets(entry_input(view, row, index), width).len();
let (result_text, _) = result_span(view, row);
let result_lines = if result_text.is_empty() {
0
} else {
text_edit::wrap_offsets(&result_text, width).len()
};
input_lines + result_lines + view.style.spacing
}
fn window_start(
total_lines: usize,
height: usize,
selected: Option<usize>,
starts: &[usize],
) -> usize {
let mut start = total_lines.saturating_sub(height);
let Some(index) = selected else {
return start;
};
let top = starts[index];
let bottom = starts[index + 1];
if top < start {
start = top;
}
if bottom > start + height {
start = bottom - height;
}
if bottom - top > height {
start = top;
}
start
}
fn entry_input<'a>(
view: &'a CalcView<'a>,
row: &'a Row<'a>,
index: usize,
) -> &'a str {
if view.mode == Mode::Edit(index) {
view.input
} else {
&row.entry.input
}
}
fn entry_block(
view: &CalcView<'_>,
skin: &Skin,
row: &Row<'_>,
index: usize,
width: usize,
) -> Vec<Line<'static>> {
let bg = row_bg(view, skin, index);
let mut lines = input_lines(view, row, index, width, bg);
lines.extend(result_lines(view, row, width, bg));
push_gap(&mut lines, view.style.spacing, view.style.separator, width);
lines
}
fn input_lines(
view: &CalcView<'_>,
row: &Row<'_>,
index: usize,
width: usize,
bg: Option<Color>,
) -> Vec<Line<'static>> {
let text = entry_input(view, row, index);
let editing = view.mode == Mode::Edit(index);
let styles: &[Style] = if editing {
view.input_styles
} else {
view.row_styles.get(index).map_or(&[], Vec::as_slice)
};
let ctx = SpanContext {
width,
styles,
caret: view.caret,
};
let wrapped = if editing {
text_edit::multiline_spans_styled(text, view.cursor, &ctx)
} else {
text_edit::wrapped_spans(text, &ctx)
};
wrapped
.into_iter()
.map(|line| fill_row(line.spans, width, bg))
.collect()
}
fn result_lines(
view: &CalcView<'_>,
row: &Row<'_>,
width: usize,
bg: Option<Color>,
) -> Vec<Line<'static>> {
let (text, style) = result_span(view, row);
if text.is_empty() {
return Vec::new();
}
text_edit::wrap_offsets(&text, width)
.into_iter()
.map(|(segment, _)| {
let padding = width.saturating_sub(segment.width());
let spans = vec![
Span::raw(" ".repeat(padding)),
Span::styled(segment, style),
];
styled_row(Line::from(spans), bg)
})
.collect()
}
fn fill_row(
mut spans: Vec<Span<'static>>,
width: usize,
bg: Option<Color>,
) -> Line<'static> {
let used: usize = spans.iter().map(|span| span.content.width()).sum();
spans.push(Span::raw(" ".repeat(width.saturating_sub(used))));
styled_row(Line::from(spans), bg)
}
fn row_bg(view: &CalcView<'_>, skin: &Skin, index: usize) -> Option<Color> {
if view.mode == Mode::Edit(index) {
return Some(to_ratatui(skin.palette.input_bg_active));
}
if view.selected == Some(index) {
return Some(to_ratatui(skin.palette.selection));
}
None
}
fn push_gap(
lines: &mut Vec<Line<'static>>,
spacing: usize,
separator: Option<Color>,
width: usize,
) {
for index in 0..spacing {
if index + 1 == spacing
&& let Some(color) = separator
{
let rule = "\u{2500}".repeat(width);
lines.push(Line::from(Span::styled(
rule,
Style::default().fg(color),
)));
} else {
lines.push(Line::default());
}
}
}
fn styled_row(line: Line<'static>, bg: Option<Color>) -> Line<'static> {
match bg {
Some(color) => line.style(Style::default().bg(color)),
None => line,
}
}
fn result_span(view: &CalcView<'_>, row: &Row<'_>) -> (String, Style) {
if let Some(error) = &row.entry.error {
let text = format!("{} {}", view.warn, error);
return (text, Style::default().fg(view.error_color));
}
if row.result.is_empty() {
return (String::new(), Style::default());
}
let style = Style::default()
.fg(view.accent_color)
.add_modifier(Modifier::BOLD);
(format!("= {}", row.result), style)
}
fn input_wrap_width(total_width: u16) -> usize {
(total_width as usize).saturating_sub(4).max(1)
}
fn input_box_height(view: &CalcView<'_>, total_width: u16) -> u16 {
let content = if matches!(view.mode, Mode::Input) {
text_edit::wrap_offsets(view.input, input_wrap_width(total_width)).len()
} else {
1
};
content.clamp(1, view.input_max_lines.max(1)) as u16 + 2
}
fn render_input(
frame: &mut Frame,
area: Rect,
skin: &Skin,
view: &CalcView<'_>,
) -> usize {
let framed = focus_skin(skin, view.mode);
let title = chrome::border_title(
&framed,
"input",
style::accent(&framed.palette).add_modifier(Modifier::BOLD),
);
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(style::border(&framed.palette))
.style(style::bg(input_fill(skin, view.mode)))
.title(title);
if let Some(feedback) = view.feedback.clone() {
block = block.title_bottom(feedback.right_aligned());
}
let inner = area.inner(Margin::new(1, 1));
frame.render_widget(block, area);
let width = inner.width.saturating_sub(2) as usize;
let lines = match view.mode {
Mode::Input => input_editor_lines(view, skin, inner, width),
Mode::History => vec![Line::from(Span::styled(
"browsing history - \u{2191}\u{2193} select, Enter edit, Esc back",
style::secondary(&skin.palette),
))],
Mode::Edit(_) => vec![Line::from(Span::styled(
"editing line - Enter apply, Esc cancel",
style::secondary(&skin.palette),
))],
};
frame.render_widget(Paragraph::new(lines), inner);
width.max(1)
}
fn input_fill(skin: &Skin, mode: Mode) -> ratada::theme::Color {
if matches!(mode, Mode::Input) {
skin.palette.input_bg_active
} else {
skin.palette.input_bg
}
}
fn focus_skin(skin: &Skin, mode: Mode) -> Skin {
if !matches!(mode, Mode::Input) {
return *skin;
}
let mut focused = *skin;
focused.palette.border = skin.palette.border_focus;
focused
}
fn input_editor_lines(
view: &CalcView<'_>,
skin: &Skin,
inner: Rect,
width: usize,
) -> Vec<Line<'static>> {
let ctx = SpanContext {
width: width.max(1),
styles: view.input_styles,
caret: view.caret,
};
let display =
text_edit::multiline_spans_styled(view.input, view.cursor, &ctx);
let height = (inner.height as usize).max(1);
let offsets = text_edit::wrap_offsets(view.input, ctx.width);
let total = view.input.chars().count();
let (cursor_line, _) =
text_edit::cursor_to_display(&offsets, total, view.cursor.pos);
let start = window_start_line(display.len(), height, cursor_line);
let prompt_style =
style::fg(skin.palette.accent).add_modifier(Modifier::BOLD);
display
.into_iter()
.enumerate()
.skip(start)
.take(height)
.map(|(index, line)| {
let prefix = if index == 0 {
Span::styled("> ", prompt_style)
} else {
Span::raw(" ")
};
let mut spans = line.spans;
spans.insert(0, prefix);
Line::from(spans)
})
.collect()
}
fn window_start_line(total: usize, height: usize, cursor_line: usize) -> usize {
if total <= height {
return 0;
}
if cursor_line < height {
0
} else {
(cursor_line + 1 - height).min(total - height)
}
}
#[cfg(test)]
mod tests {
use ratada::theme::{
ColorOverrides, GlyphVariant, Glyphs, Palette, ThemeRegistry,
};
use super::*;
use crate::config::Config;
fn row_view() -> CalcView<'static> {
CalcView {
rows: &[],
selected: None,
mode: Mode::Input,
input: "",
cursor: TextCursor::at(0),
input_styles: &[],
row_styles: &[],
completion: Vec::new(),
caret: CaretColors {
cursor: ratatui::style::Color::Reset,
selection: ratatui::style::Color::Reset,
},
style: HistoryStyle {
spacing: 1,
separator: None,
},
accent_color: ratatui::style::Color::Reset,
error_color: ratatui::style::Color::Reset,
warn: "!",
feedback: None,
input_max_lines: 5,
}
}
fn skin() -> Skin {
Skin::new(
Config::default().palette(),
Glyphs::new(GlyphVariant::Unicode),
)
}
fn luminance(color: ratada::theme::Color) -> f32 {
color.luminance()
}
fn contrast(mode: Mode) -> f32 {
let skin = skin();
let border = focus_skin(&skin, mode).palette.border;
(luminance(border) - luminance(input_fill(&skin, mode))).abs()
}
#[test]
fn a_history_row_is_tinted_only_while_selected_or_edited() {
let skin = skin();
let mut view = row_view();
assert_eq!(row_bg(&view, &skin, 0), None, "at rest nothing is tinted");
assert_eq!(row_bg(&view, &skin, 1), None);
view.selected = Some(1);
assert_eq!(row_bg(&view, &skin, 0), None, "only the selected row");
assert_eq!(
row_bg(&view, &skin, 1),
Some(to_ratatui(skin.palette.selection)),
);
let mut view = row_view();
view.mode = Mode::Edit(1);
assert_eq!(
row_bg(&view, &skin, 1),
Some(to_ratatui(skin.palette.input_bg_active)),
"the row being edited wears the focused input fill",
);
}
#[test]
fn the_focused_frame_is_drawn_from_the_palette_focus_border() {
let skin = skin();
let resting = focus_skin(&skin, Mode::History).palette.border;
let focused = focus_skin(&skin, Mode::Input).palette.border;
assert_eq!(resting, skin.palette.border, "unfocused stays as themed");
assert_eq!(
focused, skin.palette.border_focus,
"the focused frame is configurable, not hard-derived",
);
assert!(luminance(focused) > luminance(resting), "focused is lifted");
}
#[test]
fn a_configured_focus_border_reaches_the_frame() {
let mut skin = skin();
skin.palette.border_focus = ratada::theme::Color::Rgb(1, 2, 3);
let focused = focus_skin(&skin, Mode::Input).palette.border;
assert_eq!(focused, ratada::theme::Color::Rgb(1, 2, 3));
}
#[test]
fn an_in_place_edit_leaves_the_border_at_rest() {
let skin = skin();
let editing = focus_skin(&skin, Mode::Edit(0)).palette.border;
assert_eq!(editing, skin.palette.border);
}
#[test]
fn the_border_keeps_its_contrast_against_the_brighter_focused_fill() {
assert!(
contrast(Mode::Input) >= contrast(Mode::History),
"focused contrast {} fell below resting {}",
contrast(Mode::Input),
contrast(Mode::History),
);
}
#[test]
fn the_lifted_border_never_outshines_the_foreground() {
let skin = skin();
let focused = focus_skin(&skin, Mode::Input).palette.border;
assert!(
luminance(focused) < luminance(skin.palette.foreground),
"the frame must not read as text",
);
}
#[test]
fn a_custom_theme_border_is_lifted_from_its_own_value() {
let base = ThemeRegistry::builtin().resolve("monochrome");
let palette = Palette::resolve(base, &ColorOverrides::default());
let skin = Skin::new(palette, Glyphs::new(GlyphVariant::Unicode));
let focused = focus_skin(&skin, Mode::Input).palette.border;
assert!(luminance(focused) > luminance(skin.palette.border));
}
}