use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use crate::calc::{self, CalcError};
use crate::config::CalculatorConfig;
use crate::frame::{Binding, FRAME_HEIGHT, FRAME_WIDTH};
use crate::glyphs::{self, BigText};
use crate::grid::display_width;
use crate::panel::{KeyOutcome, Panel, RenderContext};
const BINDINGS: &[Binding] = &[
Binding::primary("0-9 + - * /", "type"),
Binding::primary("Enter", "work it out"),
Binding::primary("y", "copy"),
Binding::extra("( )", "group"),
Binding::extra("Backspace", "rub out"),
Binding::extra("Esc", "clear"),
Binding::extra("↑ / ↓", "scroll the tape"),
Binding::extra("x", "multiply"),
];
const MAX_SCALE: u16 = 1;
const MAX_TAPE: usize = 200;
const USEFUL_WIDTH: u16 = 44;
const EXPRESSION_AND_TAPE: u16 = 2;
#[derive(Debug, Clone, PartialEq)]
struct Entry {
expression: String,
result: f64,
}
#[derive(Debug, Clone, PartialEq)]
enum Shown {
Nothing,
Answer(f64),
Failed(CalcError),
}
pub struct CalculatorPanel {
#[allow(dead_code)]
config: CalculatorConfig,
typing: String,
shown: Shown,
tape: Vec<Entry>,
scroll: ListState,
drawn: usize,
action: Option<String>,
}
impl std::fmt::Debug for CalculatorPanel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CalculatorPanel")
.field("typing", &self.typing)
.field("shown", &self.shown)
.field("tape", &self.tape.len())
.finish_non_exhaustive()
}
}
impl CalculatorPanel {
pub fn new(config: CalculatorConfig) -> Self {
Self {
config,
typing: String::new(),
shown: Shown::Nothing,
tape: Vec::new(),
scroll: ListState::default(),
drawn: 0,
action: None,
}
}
fn answer(&self) -> Option<f64> {
match self.shown {
Shown::Answer(value) => Some(value),
_ => None,
}
}
fn push(&mut self, c: char) {
if self.typing.is_empty()
&& let Some(value) = self.answer()
&& matches!(c, '+' | '-' | '*' | '/' | 'x' | '\u{00D7}' | '\u{00F7}')
{
self.typing = calc::format_result(value);
}
if self.typing.chars().count() >= calc::MAX_LEN {
self.shown = Shown::Failed(CalcError::TooLong);
return;
}
self.typing.push(c);
}
fn evaluate(&mut self) {
match calc::evaluate(&self.typing) {
Ok(value) => {
let expression = self.typing.trim().to_string();
self.tape.insert(
0,
Entry {
expression,
result: value,
},
);
self.tape.truncate(MAX_TAPE);
self.shown = Shown::Answer(value);
self.typing.clear();
self.scroll.select(None);
}
Err(error) => self.shown = Shown::Failed(error),
}
}
fn expression_line(&self, theme: &crate::theme::Theme) -> (String, Color) {
if let Some(action) = &self.action {
return (action.clone(), theme.label);
}
if !self.typing.is_empty() {
return (self.typing.clone(), theme.text);
}
match &self.shown {
Shown::Nothing => ("type a sum".to_string(), theme.muted),
Shown::Failed(error) => (error.message(), theme.error),
Shown::Answer(_) => (
self.tape
.first()
.map_or_else(String::new, |entry| entry.expression.clone()),
theme.muted,
),
}
}
fn display(&self, width: u16) -> (String, bool) {
match &self.shown {
Shown::Answer(value) => (calc::fit_result(*value, usize::from(width)), true),
Shown::Failed(error) => (error.message(), false),
Shown::Nothing => ("\u{2013}".to_string(), false),
}
}
}
impl Panel for CalculatorPanel {
fn title(&self) -> String {
"Calculator".to_string()
}
fn counter(&self) -> Option<String> {
None
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn max_width(&self) -> Option<u16> {
Some(USEFUL_WIDTH + FRAME_WIDTH)
}
fn max_height(&self) -> Option<u16> {
None
}
fn tick(&mut self) -> bool {
false
}
fn captures_input(&self) -> bool {
false
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
return KeyOutcome::Ignored;
}
let had_action = self.action.take().is_some();
match key.code {
KeyCode::Char(
c @ ('0'..='9'
| '.'
| '+'
| '-'
| '*'
| '/'
| '('
| ')'
| 'x'
| '\u{00D7}'
| '\u{00F7}'),
) => self.push(c),
KeyCode::Char('=') | KeyCode::Enter => self.evaluate(),
KeyCode::Backspace => {
if self.typing.pop().is_none() {
self.shown = Shown::Nothing;
}
}
KeyCode::Esc => {
if !self.typing.is_empty() {
self.typing.clear();
} else if self.shown != Shown::Nothing {
self.shown = Shown::Nothing;
} else if !had_action {
return KeyOutcome::Ignored;
}
}
KeyCode::Char('y') => {
let Some(value) = self.answer() else {
return KeyOutcome::Consumed;
};
let text = calc::format_result(value);
self.action = Some(match crate::clipboard::copy(&text) {
Ok(()) => format!("sent {text} to the clipboard"),
Err(e) => format!("clipboard failed: {e}"),
});
}
KeyCode::Down | KeyCode::Char('j') => {
crate::selection::down(&mut self.scroll, 1, self.drawn);
}
KeyCode::Up | KeyCode::Char('k') => {
crate::selection::up(&mut self.scroll, 1, self.drawn);
}
_ => {
if had_action {
return KeyOutcome::Consumed;
}
return KeyOutcome::Ignored;
}
}
KeyOutcome::Consumed
}
fn handle_mouse(&mut self, event: MouseEvent, _area: Rect) -> KeyOutcome {
match event.kind {
MouseEventKind::ScrollDown => crate::selection::down(&mut self.scroll, 1, self.drawn),
MouseEventKind::ScrollUp => crate::selection::up(&mut self.scroll, 1, self.drawn),
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
if area.width == 0 || area.height == 0 {
self.drawn = 0;
return;
}
let theme = ctx.theme;
let (text, is_number) = self.display(area.width);
let colour = match self.shown {
Shown::Failed(_) => theme.error,
_ => theme.accent,
};
let scale = is_number
.then(|| {
glyphs::fitting_scale(
&text,
area.width,
area.height.saturating_sub(EXPRESSION_AND_TAPE).max(1),
MAX_SCALE,
)
})
.flatten();
let numeral_rows = scale.map_or(1, |s| BigText::new(&text, s).height);
let bottom = area.y + area.height;
let mut cursor = area.y;
if cursor < bottom {
let (line, line_colour) = self.expression_line(theme);
frame.render_widget(
Paragraph::new(Span::styled(
crate::grid::truncate(&line, usize::from(area.width)),
Style::default().fg(line_colour),
)),
Rect::new(area.x, cursor, area.width, 1),
);
cursor += 1;
}
if let Some(scale) = scale {
let big = BigText::new(&text, scale);
let x = area.x + area.width.saturating_sub(big.width) / 2;
for (index, row) in big.rows.iter().enumerate() {
let y = cursor + u16::try_from(index).unwrap_or(0);
if y >= bottom {
break;
}
frame.render_widget(
Paragraph::new(Span::styled(row.clone(), Style::default().fg(colour))),
Rect::new(x, y, big.width.min(area.width), 1),
);
}
cursor += numeral_rows;
} else if cursor < bottom {
let rows = crate::grid::wrap(&text, usize::from(area.width));
let style = Style::default().fg(colour).add_modifier(Modifier::BOLD);
for row in rows {
if cursor >= bottom {
break;
}
let width = u16::try_from(display_width(&row)).unwrap_or(area.width);
let x = area.x + area.width.saturating_sub(width) / 2;
frame.render_widget(
Paragraph::new(Span::styled(row, style)),
Rect::new(x, cursor, width.min(area.width), 1),
);
cursor += 1;
}
}
self.draw_tape(frame, area, cursor, bottom, theme);
}
}
impl CalculatorPanel {
fn draw_tape(
&mut self,
frame: &mut Frame,
area: Rect,
cursor: u16,
bottom: u16,
theme: &crate::theme::Theme,
) {
let room = bottom.saturating_sub(cursor);
self.drawn = usize::from(room).min(self.tape.len());
if room == 0 || self.tape.is_empty() {
if room > 0 {
self.drawn = 0;
}
return;
}
let width = usize::from(area.width);
let items: Vec<ListItem> = self
.tape
.iter()
.take(self.drawn)
.map(|entry| {
const SEP: &str = " = ";
let sep_width = display_width(SEP);
let result = calc::fit_result(entry.result, width);
let used = display_width(&result);
let line = if used + sep_width < width {
let expression =
crate::grid::truncate(&entry.expression, width - used - sep_width);
if expression.is_empty() {
result
} else {
format!("{expression}{SEP}{result}")
}
} else {
result
};
ListItem::new(Span::styled(line, Style::default().fg(theme.muted)))
})
.collect();
frame.render_stateful_widget(
List::new(items).highlight_style(Style::default().fg(theme.text)),
Rect::new(area.x, cursor, area.width, room),
&mut self.scroll,
);
}
}
#[allow(dead_code)]
const _: u16 = FRAME_HEIGHT;
#[allow(clippy::float_cmp)]
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn new_panel() -> CalculatorPanel {
CalculatorPanel::new(CalculatorConfig::default())
}
fn press(panel: &mut CalculatorPanel, c: char) -> KeyOutcome {
panel.handle_key(KeyEvent::from(KeyCode::Char(c)))
}
fn type_in(panel: &mut CalculatorPanel, text: &str) {
for c in text.chars() {
press(panel, c);
}
}
fn draw(panel: &mut CalculatorPanel, width: u16, height: u16) -> String {
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn digits_are_taken_and_the_shell_keys_are_left_alone() {
let mut panel = new_panel();
for c in "0123456789.+-*/()x".chars() {
assert_eq!(
press(&mut panel, c),
KeyOutcome::Consumed,
"{c:?} has to reach the calculator"
);
}
let mut panel = new_panel();
for code in [
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Char('q'),
KeyCode::Char('?'),
KeyCode::Char('w'),
KeyCode::Char('t'),
KeyCode::Char('m'),
] {
assert_eq!(
panel.handle_key(KeyEvent::from(code)),
KeyOutcome::Ignored,
"{code:?} belongs to the shell; consuming it locks the user in"
);
}
assert!(
!panel.captures_input(),
"capturing input would veto every global key, which is the trap this design avoids"
);
}
#[test]
fn typing_a_sum_and_pressing_enter_works_it_out() {
let mut panel = new_panel();
type_in(&mut panel, "2+3*4");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(panel.answer(), Some(14.0));
assert_eq!(panel.tape.len(), 1);
assert_eq!(panel.tape[0].expression, "2+3*4");
assert!(panel.typing.is_empty(), "the line clears for the next sum");
}
#[test]
fn an_operator_after_an_answer_carries_it_forward() {
let mut panel = new_panel();
type_in(&mut panel, "50+7");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
press(&mut panel, '+');
assert_eq!(panel.typing, "57+");
type_in(&mut panel, "10");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(panel.answer(), Some(67.0));
}
#[test]
fn a_digit_after_an_answer_starts_over() {
let mut panel = new_panel();
type_in(&mut panel, "50+7");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
press(&mut panel, '9');
assert_eq!(
panel.typing, "9",
"a digit begins a new sum, not a longer one"
);
}
#[test]
fn a_failed_sum_says_why_and_keeps_what_was_typed() {
let mut panel = new_panel();
type_in(&mut panel, "1/0");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert!(matches!(
panel.shown,
Shown::Failed(CalcError::DivideByZero)
));
assert_eq!(
panel.typing, "1/0",
"a rejected sum must stay on the line so it can be corrected"
);
assert!(
panel.tape.is_empty(),
"nothing that failed reaches the tape"
);
}
#[test]
fn esc_clears_what_is_typed_then_the_answer_then_gives_up_the_key() {
let mut panel = new_panel();
type_in(&mut panel, "12+3");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
type_in(&mut panel, "99");
assert_eq!(
panel.handle_key(KeyEvent::from(KeyCode::Esc)),
KeyOutcome::Consumed
);
assert!(panel.typing.is_empty());
assert_eq!(
panel.handle_key(KeyEvent::from(KeyCode::Esc)),
KeyOutcome::Consumed
);
assert_eq!(panel.shown, Shown::Nothing);
assert_eq!(
panel.handle_key(KeyEvent::from(KeyCode::Esc)),
KeyOutcome::Ignored
);
}
#[test]
fn the_tape_is_bounded() {
let mut panel = new_panel();
for n in 0..MAX_TAPE + 50 {
panel.typing = format!("{n}+1");
panel.evaluate();
}
assert_eq!(panel.tape.len(), MAX_TAPE);
assert_eq!(
panel.tape[0].expression,
format!("{}+1", MAX_TAPE + 49),
"newest first"
);
}
#[test]
fn an_over_long_expression_is_refused_rather_than_swallowed() {
let mut panel = new_panel();
for _ in 0..calc::MAX_LEN + 20 {
press(&mut panel, '1');
}
assert_eq!(panel.typing.chars().count(), calc::MAX_LEN);
assert!(
matches!(panel.shown, Shown::Failed(CalcError::TooLong)),
"a key that does nothing with no explanation reads as a stuck terminal"
);
}
#[test]
fn a_result_too_wide_for_the_panel_is_never_shown_truncated() {
let mut panel = new_panel();
panel.typing = "123456789 * 1000".to_string();
panel.evaluate();
assert_eq!(calc::format_result(panel.answer().unwrap()), "123456789000");
for row in draw(&mut panel, 8, 6).lines() {
let stripped: String = row.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
!stripped.contains("123456789") || stripped.contains('e'),
"a prefix of the answer reached the screen: {row:?}"
);
}
assert!(
draw(&mut panel, 8, 6).contains('e'),
"the answer should have become scientific"
);
}
#[test]
fn no_tape_row_is_ever_wider_than_the_panel() {
let mut panel = new_panel();
for expression in [
"123456789 * 1000",
"1/3",
"2+2",
"999999 * 999999 * 999999",
"0.1+0.2",
] {
panel.typing = expression.to_string();
panel.evaluate();
}
for width in 1u16..=44 {
for row in draw(&mut panel, width, 10).lines() {
assert!(
crate::grid::display_width(row) <= usize::from(width),
"a {}-cell row was drawn into a {width}-cell panel: {row:?}",
crate::grid::display_width(row)
);
}
}
}
#[test]
fn the_panel_draws_at_any_size_without_panicking() {
let mut panel = new_panel();
type_in(&mut panel, "12345+6789");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
for width in [1u16, 2, 3, 7, 12, 20, 44, 120] {
for height in [1u16, 2, 3, 5, 9, 30] {
let _ = draw(&mut panel, width, height);
}
}
}
#[test]
fn a_failed_sum_says_why_on_screen() {
let mut panel = new_panel();
type_in(&mut panel, "1/0");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
let screen = draw(&mut panel, 30, 9);
assert!(
screen.contains("cannot divide by zero"),
"the reason has to reach the screen, not just the state:\n{screen}"
);
assert!(
screen.contains("1/0"),
"and what was typed has to stay there to be corrected:\n{screen}"
);
}
#[test]
fn the_resting_display_is_visible_rather_than_blank() {
let mut panel = new_panel();
let screen = draw(&mut panel, 30, 9);
assert!(
screen.contains('\u{2013}'),
"an idle calculator draws a dash, not five blank rows — `glyphs` has \
no numeral for it and renders unknown characters as empty cells:\n{screen}"
);
}
#[test]
fn an_untouched_calculator_says_what_to_do() {
let mut panel = new_panel();
let screen = draw(&mut panel, 30, 8);
assert!(
screen.contains("type a sum"),
"an empty calculator has to invite the first keystroke:\n{screen}"
);
}
#[test]
fn only_the_tape_rows_that_fit_are_built() {
let mut panel = new_panel();
for n in 0..MAX_TAPE {
panel.typing = format!("{n}+1");
panel.evaluate();
}
draw(&mut panel, 30, 9);
assert!(
panel.drawn <= 9,
"{} rows were built for a nine-row panel",
panel.drawn
);
assert!(panel.drawn > 0, "a tall panel should show some of the tape");
}
#[test]
fn the_cursor_cannot_leave_the_visible_tape() {
let mut panel = new_panel();
for n in 0..40 {
panel.typing = format!("{n}+1");
panel.evaluate();
}
draw(&mut panel, 30, 12);
for _ in 0..100 {
panel.handle_key(KeyEvent::from(KeyCode::Down));
}
assert!(panel.scroll.selected().is_some_and(|i| i < panel.drawn));
}
}