Files
Ely-GPUI-Components/src/misc/calculator.rs
T
ZacharyZhang-NY 4c3ed1bbb9 Port · Build: Zed's gpui at 1a28cff
gpui and gpui_platform now come from Zed's repo by git at 1a28cff,
the code gpui-pre 0.3.7 republishes, so the gallery can later build
for the browser. gpui takes stacker, as 0.2.2 always did, and
gpui_platform takes font-kit, without which macOS draws no text;
runtime_shaders moves to gpui_platform.

The API moved: focus, blur and Tab steps take cx, Corner is Anchor,
shaped lines paint with an alignment, defer_draw takes a content
mask, the inspector registers a factory, async updates return their
value, and wrapped lines are shared, not cloned. Updates through a
window context go through the window, so a closed window still ends
them as it did.

Where the new gpui behaves differently, Ely keeps its old intent.
Holds and reveals under a resting pointer survive typing through
gpui's input-modality-independent hover; previews the keyboard also
moves keep the default, and the calendar's leave ignores a key press.
Enter reaches a button inside the code editor, since gpui now arms a
key click on the press. The event logger skips a lone modifier. A
video and an image grid keep their shape in a flex column under
taffy 0.13, as a picture does.

AGENTS.md records the new stack and the rules this port changes; the
rest of the old gpui and taffy rules are re-tested in T44c.
2026-09-28 20:30:17 -04:00

414 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use gpui::{
App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, KeyDownEvent, MouseButton,
ParentElement, RenderOnce, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::evaluate,
primitives::{FocusRing, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{
format::{MINUS, Separators, group_digits, significant},
tabular,
},
};
/// A key on the pad or the keyboard.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Key {
Digit(char),
Point,
Op(char),
Sign,
Back,
Clear,
Equals,
}
/// The pad, four keys a row, each with what it shows; zero takes two.
const PAD: [(Key, &str); 19] = [
(Key::Clear, "AC"),
(Key::Sign, "±"),
(Key::Back, "Delete"),
(Key::Op('/'), "÷"),
(Key::Digit('7'), "7"),
(Key::Digit('8'), "8"),
(Key::Digit('9'), "9"),
(Key::Op('*'), "×"),
(Key::Digit('4'), "4"),
(Key::Digit('5'), "5"),
(Key::Digit('6'), "6"),
(Key::Op('-'), "−"),
(Key::Digit('1'), "1"),
(Key::Digit('2'), "2"),
(Key::Digit('3'), "3"),
(Key::Op('+'), "+"),
(Key::Digit('0'), "0"),
(Key::Point, "."),
(Key::Equals, "="),
];
const OPS: [char; 4] = ['+', '-', '*', '/'];
/// Stands for the last result in the entry, so a sum goes on with its whole value.
const ANSWER: char = 'a';
/// The entry as typed, in ASCII, where `a` is the last result, kept whole in `answer`; the sum that gave it, as shown; and why a sum failed.
#[derive(Default)]
struct Tape {
entry: String,
answer: f64,
worked: Option<String>,
problem: Option<String>,
}
impl Tape {
/// Where the number being typed starts, its sign included.
fn number_start(&self) -> usize {
let bytes = self.entry.as_bytes();
let start = self.entry.rfind(OPS).map_or(0, |at| at + 1);
let signed = start > 0
&& bytes[start - 1] == b'-'
&& (start == 1 || OPS.contains(&(bytes[start - 2] as char)));
if signed { start - 1 } else { start }
}
fn blank(&self) -> bool {
self.entry.is_empty() && self.worked.is_none() && self.problem.is_none()
}
fn press(&mut self, key: Key) {
if self.problem.take().is_some() {
self.entry.clear();
}
self.worked = None;
let answered = self.entry.ends_with(ANSWER);
if answered && matches!(key, Key::Digit(_) | Key::Point) {
self.entry.clear();
}
let start = self.number_start();
let number = &self.entry[start..];
match key {
Key::Digit(digit) => {
if number.trim_start_matches('-') == "0" {
self.entry.pop();
}
self.entry.push(digit);
}
Key::Point if number.contains('.') => {}
Key::Point => {
if number.trim_start_matches('-').is_empty() {
self.entry.push('0');
}
self.entry.push('.');
}
Key::Op(op) => {
let kept = self.entry.trim_end_matches(OPS).len();
match kept {
0 if self.entry.is_empty() && op == '-' => self.entry.push('-'),
0 => {}
_ => {
self.entry.truncate(kept);
self.entry.push(op);
}
}
}
Key::Sign if answered => self.answer = -self.answer,
Key::Sign if number.starts_with('-') => {
self.entry.remove(start);
}
Key::Sign if !number.is_empty() => self.entry.insert(start, '-'),
Key::Sign => {}
Key::Back => {
self.entry.pop();
}
Key::Clear => self.entry.clear(),
Key::Equals if self.entry.is_empty() || self.entry.ends_with(OPS) => {}
Key::Equals => {
self.worked = Some(format!("{} =", shown(&self.entry, self.answer)));
match evaluate(&self.entry, &[(ANSWER.to_string().into(), self.answer)]) {
Ok(value) => (self.entry, self.answer) = (ANSWER.to_string(), value),
Err(problem) => self.problem = Some(problem),
}
}
}
}
}
/// The entry as it reads: digits grouped, the last result to twelve significant digits, and ×, ÷ and a true minus between spaced terms.
fn shown(entry: &str, answer: f64) -> String {
let (mut out, mut number, mut after_op) = (String::new(), String::new(), true);
let flush = |out: &mut String, number: &mut String| {
let (whole, part) = number.split_once('.').unwrap_or((number, ""));
out.push_str(&group_digits(whole, ','));
if number.contains('.') {
out.push('.');
out.push_str(part);
}
number.clear();
};
for ch in entry.chars() {
if ch == ANSWER {
out.push_str(&significant(answer, 12, Separators::EN));
after_op = false;
continue;
}
if ch.is_ascii_digit() || ch == '.' {
number.push(ch);
after_op = false;
continue;
}
flush(&mut out, &mut number);
match (after_op, ch) {
(true, _) => out.push(MINUS),
(_, '+') => out.push_str(" + "),
(_, '-') => out.push_str(" − "),
(_, '*') => out.push_str(" × "),
_ => out.push_str(" ÷ "),
}
after_op = true;
}
flush(&mut out, &mut number);
out.trim_end().to_string()
}
fn hit(tape: &Entity<Tape>, key: Key, cx: &mut App) {
tape.update(cx, |tape, cx| {
tape.press(key);
log::info!(
"calculator: {key:?}, entry {:?}, answer {}",
tape.entry,
tape.answer
);
cx.notify();
});
}
/// A key typed on the keyboard; Enter is equals only while the calculator itself has focus, so a focused key presses on its own.
fn typed(event: &KeyDownEvent, own: bool) -> Option<Key> {
let stroke = &event.keystroke;
if stroke.modifiers.platform || stroke.modifiers.control {
return None;
}
match (stroke.key.as_str(), stroke.key_char.as_deref()) {
("enter", _) if own => Some(Key::Equals),
("backspace", _) => Some(Key::Back),
("escape", _) => Some(Key::Clear),
(_, Some("=")) => Some(Key::Equals),
(_, Some(".")) => Some(Key::Point),
(_, Some(op @ ("+" | "-" | "*" | "/"))) => op.chars().next().map(Key::Op),
(_, Some(digit)) if digit.len() == 1 && digit.as_bytes()[0].is_ascii_digit() => {
digit.chars().next().map(Key::Digit)
}
_ => None,
}
}
/// A calculator: the sum as you type it over its result, and a pad of digits, the four operations, sign, delete and clear. Keys type too: digits, `.`, `+ - * /`, `=` or Enter, Backspace and Escape; Equals waits while the sum ends in an operator. It works in precedence, × and ÷ before + and −, and shows twelve significant digits.
#[derive(IntoElement)]
pub struct Calculator {
id: ElementId,
}
impl Calculator {
pub fn new(id: impl Into<ElementId>) -> Self {
Self { id: id.into() }
}
}
impl RenderOnce for Calculator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let tape = window.use_keyed_state((id.clone(), "tape"), cx, |_, _| Tape::default());
let focus = tab_stop((id.clone(), "focus").into(), true, window, cx);
let theme = cx.theme();
let colors = &theme.colors;
let (above, main, failed) = {
let tape = tape.read(cx);
let main = match (&tape.problem, tape.entry.is_empty()) {
(Some(problem), _) => problem.clone(),
(None, true) => "0".to_string(),
(None, false) => shown(&tape.entry, tape.answer),
};
(tape.worked.clone(), main, tape.problem.is_some())
};
let keys = PAD.iter().map(|&(key, label)| {
let button = match key {
Key::Back => Button::new((id.clone(), "back"), "").icon(IconName::Delete),
_ => Button::new((id.clone(), label), label),
};
let tape = tape.clone();
div()
.debug_selector(|| format!("key-{label}"))
.when(key == Key::Digit('0'), |cell| cell.col_span(2))
.child(
button
.size(ControlSize::Lg)
.variant(match key {
Key::Equals => ButtonVariant::Primary,
Key::Digit(_) | Key::Point => ButtonVariant::Secondary,
Key::Op(_) => ButtonVariant::Outline,
_ => ButtonVariant::Ghost,
})
.full_width()
.on_click(move |_: &ClickEvent, _, cx| hit(&tape, key, cx)),
)
});
div()
.id(id.clone())
.track_focus(&focus)
.p_3()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, {
let focus = focus.clone();
move |_, window, cx| window.focus(&focus, cx)
})
.on_key_down({
let tape = tape.clone();
move |event, window, cx| {
let Some(key) = typed(event, focus.is_focused(window)) else {
return;
};
if key == Key::Clear && tape.read(cx).blank() {
return;
}
cx.stop_propagation();
hit(&tape, key, cx);
}
})
.child(
div()
.pb_3()
.text_right()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(above.unwrap_or_else(|| "\u{a0}".into())),
)
.child(
tabular(div())
.debug_selector(|| format!("calculator-{main}"))
.text_size(theme.text_size(TextSize::Xxl))
.text_color(if failed { colors.danger } else { colors.fg })
.child(main.clone()),
),
)
.child(div().grid().grid_cols(4).gap_2().children(keys))
}
}
#[cfg(test)]
mod tests {
use super::{Key, Tape, shown};
fn typed(keys: &[Key]) -> Tape {
let mut tape = Tape::default();
keys.iter().for_each(|key| tape.press(*key));
tape
}
fn sum(text: &str) -> Vec<Key> {
text.chars()
.map(|ch| match ch {
'0'..='9' => Key::Digit(ch),
'.' => Key::Point,
'=' => Key::Equals,
'~' => Key::Sign,
'<' => Key::Back,
'C' => Key::Clear,
op => Key::Op(op),
})
.collect()
}
/// What the calculator's main line reads after `keys`.
fn reads(keys: &str) -> String {
let tape = typed(&sum(keys));
match tape.problem {
Some(problem) => problem,
None if tape.entry.is_empty() => "0".into(),
None => shown(&tape.entry, tape.answer),
}
}
#[test]
fn it_works_in_precedence_and_goes_on_with_the_whole_result() {
assert_eq!(
typed(&sum("12+3*4=")).worked.as_deref(),
Some("12 + 3 × 4 =")
);
assert_eq!(reads("12+3*4="), "24");
assert_eq!(
reads("12+3*4=-4="),
"20",
"an operator goes on from the result"
);
assert_eq!(
reads("1/3=*3="),
"1",
"the whole result, not its twelve digits"
);
assert_eq!(reads("12+3*4=7"), "7", "a digit starts afresh");
assert_eq!(reads("12+3*4=.5"), "0.5", "so does a point");
assert_eq!(reads("12+3*4=~+4="), "−20", "sign turns the result");
assert_eq!(reads("12+3*4=<"), "0", "delete takes the whole result");
assert_eq!(reads(".1+.2="), "0.3", "twelve digits hide binary noise");
assert_eq!(reads("2/3="), "0.666666666667");
}
#[test]
fn keys_keep_the_entry_a_sum() {
assert_eq!(typed(&sum("007")).entry, "7", "no leading zeros");
assert_eq!(typed(&sum("1..5.")).entry, "1.5", "one point a number");
assert_eq!(
typed(&sum(".5+.5")).entry,
"0.5+0.5",
"a point starts with a zero"
);
assert_eq!(
typed(&sum("5+*")).entry,
"5*",
"an operator replaces the last"
);
assert_eq!(typed(&sum("+-")).entry, "-", "only minus starts, as a sign");
assert_eq!(
typed(&sum("5*3~")).entry,
"5*-3",
"sign turns the number typed"
);
assert_eq!(typed(&sum("5*3~~")).entry, "5*3");
assert_eq!(
typed(&sum("5*3~<+")).entry,
"5+",
"a dangling sign goes with the operator"
);
assert_eq!(typed(&sum("12+3C")).entry, "");
assert_eq!(
reads("1234+5*="),
"1,234 + 5 ×",
"Equals waits on a dangling operator"
);
}
#[test]
fn a_failed_sum_says_why_and_the_next_key_starts_afresh() {
assert_eq!(reads("5/0="), "division by zero");
assert_eq!(typed(&sum("5/0=")).worked.as_deref(), Some("5 ÷ 0 ="));
assert_eq!(typed(&sum("5/0=8")).entry, "8");
assert_eq!(typed(&sum("5/0=+")).entry, "", "no result to go on from");
}
#[test]
fn the_entry_reads_grouped_with_true_signs() {
assert_eq!(shown("-1234.5*-6/7-8", 0.0), "−1,234.5 × −6 ÷ 7 − 8");
assert_eq!(shown("0.", 0.0), "0.");
assert_eq!(shown("a*2", -1_234.5), "−1,234.5 × 2");
}
}