use ratada::theme::Skin;
use ratada::{chrome, style};
use ratatui::Frame;
use ratatui::layout::{Margin, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use crate::tui::text_edit::{self, SpanContext};
use crate::tui::views::calc::{CalcView, Mode};
pub(super) fn input_wrap_width(total_width: u16) -> usize {
(total_width as usize).saturating_sub(4).max(1)
}
pub(super) 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
}
pub(super) 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)
}
pub(super) 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
}
}
pub(super) 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 super::*;
use ratada::theme::{
ColorOverrides, GlyphVariant, Glyphs, Palette, ThemeRegistry,
};
use crate::tui::views::calc::testing::{contrast, luminance, skin};
#[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));
}
}