Misc · Numbers: a calculator and a unit converter
misc::Calculator works a typed sum in forms::evaluate's precedence, from a pad or the keyboard; Enter is equals while the calculator itself has focus, and Escape clears or passes on. misc::UnitConverter reads an amount in another unit across seven kinds, with a swap. Both show numbers through format::significant, which rounds through Rust's exponent form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
87b0effafb
commit
fc10b64303
13 files changed
+1023
-7
No files matched your search
@@ -85,6 +85,7 @@ pub(crate) use mention::{
|
||||
Suggestions, active_trigger, handles_matching, one_word, replace_trigger,
|
||||
};
|
||||
pub use multi::MultiSelect;
|
||||
pub(crate) use number::parse as parse_number;
|
||||
pub use number::{NumberInput, ScrubInput};
|
||||
pub use options::Choice;
|
||||
pub(crate) use options::{
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
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] = ['+', '-', '*', '/'];
|
||||
|
||||
/// The entry as typed, in ASCII; the sum that gave it while it is a result; and why that sum failed.
|
||||
#[derive(Default)]
|
||||
struct Tape {
|
||||
entry: String,
|
||||
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) {
|
||||
let failed = self.problem.take().is_some();
|
||||
let result = self.worked.take().is_some() && !failed;
|
||||
if failed || (result && 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 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() => {}
|
||||
Key::Equals => match evaluate(&self.entry, &[]) {
|
||||
Ok(value) => self.worked = Some(std::mem::replace(&mut self.entry, plain(value))),
|
||||
Err(problem) => {
|
||||
self.worked = Some(self.entry.clone());
|
||||
self.problem = Some(problem);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A result as the entry types it: twelve significant digits, no groups, an ASCII minus.
|
||||
fn plain(value: f64) -> String {
|
||||
significant(value, 12, Separators::EN)
|
||||
.chars()
|
||||
.filter(|ch| *ch != ',')
|
||||
.map(|ch| if ch == MINUS { '-' } else { ch })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The entry as it reads: digits grouped, and ×, ÷ and a true minus between spaced terms.
|
||||
fn shown(entry: &str) -> 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.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
|
||||
}
|
||||
|
||||
fn hit(tape: &Entity<Tape>, key: Key, cx: &mut App) {
|
||||
tape.update(cx, |tape, cx| {
|
||||
tape.press(key);
|
||||
log::info!("calculator: {key:?}, entry {:?}", tape.entry);
|
||||
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. 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),
|
||||
};
|
||||
let above = tape
|
||||
.worked
|
||||
.as_deref()
|
||||
.map(|sum| format!("{} =", shown(sum)));
|
||||
(above, 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, _| window.focus(&focus)
|
||||
})
|
||||
.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, plain, 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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_works_in_precedence_and_carries_the_result_on() {
|
||||
let tape = typed(&sum("12+3*4="));
|
||||
assert_eq!(
|
||||
(tape.entry.as_str(), tape.worked.as_deref()),
|
||||
("24", Some("12+3*4"))
|
||||
);
|
||||
assert_eq!(
|
||||
typed(&sum("12+3*4=-4=")).entry,
|
||||
"20",
|
||||
"an operator goes on from the result"
|
||||
);
|
||||
assert_eq!(typed(&sum("12+3*4=7")).entry, "7", "a digit starts afresh");
|
||||
assert_eq!(
|
||||
typed(&sum(".1+.2=")).entry,
|
||||
"0.3",
|
||||
"twelve digits hide binary noise"
|
||||
);
|
||||
assert_eq!(typed(&sum("2/3=")).entry, "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+*")).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, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_sum_says_why_and_the_next_key_starts_afresh() {
|
||||
let tape = typed(&sum("5/0="));
|
||||
assert_eq!(tape.problem.as_deref(), Some("division by zero"));
|
||||
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"), "−1,234.5 × −6 ÷ 7 − 8");
|
||||
assert_eq!(shown("0."), "0.");
|
||||
assert_eq!(plain(-1_234_567.0), "-1234567");
|
||||
assert_eq!(plain(1e-15), "0.000000000000001");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
mod calculator;
|
||||
mod clock;
|
||||
mod stopwatch;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod units;
|
||||
|
||||
pub use calculator::Calculator;
|
||||
pub use clock::{Clock, WorldClock};
|
||||
pub use stopwatch::Stopwatch;
|
||||
pub use units::UnitConverter;
|
||||
@@ -8,10 +8,13 @@ use jiff::{Timestamp, tz::TimeZone};
|
||||
|
||||
use super::{Clock, Stopwatch, WorldClock};
|
||||
use crate::{
|
||||
forms,
|
||||
primitives::{FocusNext, FocusPrev, FocusScope},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
mod numbers;
|
||||
|
||||
struct Stage {
|
||||
root: FocusHandle,
|
||||
part: fn() -> AnyElement,
|
||||
@@ -35,12 +38,13 @@ fn settle(cx: &mut VisualTestContext) {
|
||||
}
|
||||
|
||||
/// Stages `part`, counting the times the stage is told to redraw.
|
||||
fn stage(
|
||||
pub(super) fn stage(
|
||||
part: fn() -> AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Rc<Cell<usize>>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
forms::bind_keys(cx);
|
||||
cx.bind_keys([
|
||||
gpui::KeyBinding::new("tab", FocusNext, None),
|
||||
gpui::KeyBinding::new("shift-tab", FocusPrev, None),
|
||||
@@ -62,7 +66,7 @@ fn stage(
|
||||
(wakes, cx)
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
pub(super) fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
@@ -76,7 +80,7 @@ fn wait(ms: u64, cx: &mut VisualTestContext) {
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn shown(selector: &'static str, cx: &mut VisualTestContext) -> bool {
|
||||
pub(super) fn shown(selector: &'static str, cx: &mut VisualTestContext) -> bool {
|
||||
cx.debug_bounds(selector).is_some()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::cell::Cell;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, InteractiveElement, IntoElement, Modifiers, ParentElement, TestAppContext, div,
|
||||
};
|
||||
|
||||
use super::{press, shown, stage};
|
||||
use crate::misc::{Calculator, UnitConverter};
|
||||
|
||||
thread_local! {
|
||||
static ESCAPES: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
fn calculator() -> AnyElement {
|
||||
div()
|
||||
.on_key_down(|event, _, _| {
|
||||
if event.keystroke.key == "escape" {
|
||||
ESCAPES.with(|escapes| escapes.set(escapes.get() + 1));
|
||||
}
|
||||
})
|
||||
.child(Calculator::new("calculator"))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Typed keys work the sum in precedence and Enter gives it, but on a focused key Enter presses that key.
|
||||
#[gpui::test]
|
||||
fn a_calculator_takes_typed_keys(cx: &mut TestAppContext) {
|
||||
let (_, cx) = stage(calculator, cx);
|
||||
press("tab", cx);
|
||||
for key in ["1", "2", "+", "3", "*", "4", "cmd-backspace", "5"] {
|
||||
press(key, cx);
|
||||
}
|
||||
assert!(
|
||||
shown("calculator-12 + 3 × 45", cx),
|
||||
"Command left the delete to the app"
|
||||
);
|
||||
press("backspace", cx);
|
||||
for _ in 0..5 {
|
||||
press("tab", cx);
|
||||
}
|
||||
press("enter", cx);
|
||||
assert!(
|
||||
shown("calculator-12 + 3 × 47", cx),
|
||||
"Enter on the focused 7 typed it"
|
||||
);
|
||||
for _ in 0..5 {
|
||||
press("shift-tab", cx);
|
||||
}
|
||||
press("enter", cx);
|
||||
assert!(
|
||||
shown("calculator-153", cx),
|
||||
"Enter on the calculator is equals"
|
||||
);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
press("+", cx);
|
||||
press("5", cx);
|
||||
assert!(
|
||||
shown("calculator-5", cx) && !shown("calculator-153 + 5", cx),
|
||||
"Enter on the focused AC cleared the result"
|
||||
);
|
||||
}
|
||||
|
||||
/// A press on a key hands the keyboard to the calculator, though the key itself takes no focus.
|
||||
#[gpui::test]
|
||||
fn a_pressed_key_hands_the_keyboard_to_the_calculator(cx: &mut TestAppContext) {
|
||||
let (_, cx) = stage(calculator, cx);
|
||||
let seven = cx.debug_bounds("key-7").expect("the 7 key").center();
|
||||
cx.simulate_click(seven, Modifiers::none());
|
||||
press("+", cx);
|
||||
press("1", cx);
|
||||
assert!(shown("calculator-7 + 1", cx));
|
||||
}
|
||||
|
||||
/// Escape clears what there is and stops there; on a blank calculator it passes on, as to a dialog.
|
||||
#[gpui::test]
|
||||
fn escape_clears_or_passes_on(cx: &mut TestAppContext) {
|
||||
let (_, cx) = stage(calculator, cx);
|
||||
press("tab", cx);
|
||||
press("escape", cx);
|
||||
assert_eq!(ESCAPES.with(Cell::get), 1, "blank, it passed Escape on");
|
||||
press("5", cx);
|
||||
press("escape", cx);
|
||||
press("3", cx);
|
||||
assert!(shown("calculator-3", cx) && !shown("calculator-53", cx));
|
||||
assert_eq!(ESCAPES.with(Cell::get), 1, "clearing kept it");
|
||||
}
|
||||
|
||||
fn converter() -> AnyElement {
|
||||
UnitConverter::new("converter").into_any_element()
|
||||
}
|
||||
|
||||
/// A meter reads in feet and, swapped, a foot in meters; another kind starts on its own pair; a typed amount converts, and text that is no number reads as none.
|
||||
#[gpui::test]
|
||||
fn a_converter_reads_an_amount_in_another_unit(cx: &mut TestAppContext) {
|
||||
let (_, cx) = stage(converter, cx);
|
||||
assert!(shown("converted-3.28084 ft", cx));
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert!(shown("converted-0.3048 m", cx), "swapped");
|
||||
press("shift-tab", cx);
|
||||
for key in ["down", "down", "down", "down", "down", "enter"] {
|
||||
press(key, cx);
|
||||
}
|
||||
assert!(
|
||||
shown("converted-33.8 °F", cx),
|
||||
"Temperature starts on Celsius to Fahrenheit"
|
||||
);
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("cmd-a", cx);
|
||||
cx.simulate_input("-40");
|
||||
assert!(shown("converted-−40 °F", cx));
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
for key in ["down", "down", "enter"] {
|
||||
press(key, cx);
|
||||
}
|
||||
assert!(shown("converted-233.15 K", cx), "picked Kelvin");
|
||||
press("shift-tab", cx);
|
||||
press("shift-tab", cx);
|
||||
press("cmd-a", cx);
|
||||
cx.simulate_input("-");
|
||||
assert!(shown("converted-—", cx));
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use gpui::{
|
||||
App, AppContext as _, ElementId, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::IconButton,
|
||||
forms::{Choice, Input, Select, TextInput, parse_number},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, Radius},
|
||||
typography::{
|
||||
Ellipsis,
|
||||
format::{MINUS, Separators, significant},
|
||||
tabular,
|
||||
},
|
||||
};
|
||||
|
||||
/// A unit as its kind's base times `scale`, plus `offset`: kelvin from celsius is 1 and 273.15.
|
||||
pub(crate) struct Unit {
|
||||
name: &'static str,
|
||||
symbol: &'static str,
|
||||
scale: f64,
|
||||
offset: f64,
|
||||
}
|
||||
|
||||
const fn unit(name: &'static str, symbol: &'static str, scale: f64) -> Unit {
|
||||
Unit {
|
||||
name,
|
||||
symbol,
|
||||
scale,
|
||||
offset: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind of measure: its name, its units, and the two it starts on.
|
||||
pub(crate) struct Kind {
|
||||
name: &'static str,
|
||||
units: &'static [Unit],
|
||||
pair: (usize, usize),
|
||||
}
|
||||
|
||||
/// Lengths in meters, areas in square meters, volumes in liters, masses in kilograms, temperatures in kelvin, speeds in meters a second, data in bytes.
|
||||
pub(crate) static KINDS: [Kind; 7] = [
|
||||
Kind {
|
||||
name: "Length",
|
||||
units: &[
|
||||
unit("Millimeter", "mm", 0.001),
|
||||
unit("Centimeter", "cm", 0.01),
|
||||
unit("Meter", "m", 1.0),
|
||||
unit("Kilometer", "km", 1000.0),
|
||||
unit("Inch", "in", 0.0254),
|
||||
unit("Foot", "ft", 0.3048),
|
||||
unit("Yard", "yd", 0.9144),
|
||||
unit("Mile", "mi", 1609.344),
|
||||
],
|
||||
pair: (2, 5),
|
||||
},
|
||||
Kind {
|
||||
name: "Area",
|
||||
units: &[
|
||||
unit("Square meter", "m²", 1.0),
|
||||
unit("Hectare", "ha", 1e4),
|
||||
unit("Square kilometer", "km²", 1e6),
|
||||
unit("Square foot", "ft²", 0.092_903_04),
|
||||
unit("Acre", "ac", 4_046.856_422_4),
|
||||
unit("Square mile", "mi²", 2_589_988.110_336),
|
||||
],
|
||||
pair: (0, 3),
|
||||
},
|
||||
Kind {
|
||||
name: "Volume",
|
||||
units: &[
|
||||
unit("Milliliter", "mL", 0.001),
|
||||
unit("Liter", "L", 1.0),
|
||||
unit("Cubic meter", "m³", 1000.0),
|
||||
unit("US fluid ounce", "fl oz", 0.029_573_529_562_5),
|
||||
unit("US cup", "cup", 0.236_588_236_5),
|
||||
unit("US pint", "pt", 0.473_176_473),
|
||||
unit("US gallon", "gal", 3.785_411_784),
|
||||
],
|
||||
pair: (1, 6),
|
||||
},
|
||||
Kind {
|
||||
name: "Mass",
|
||||
units: &[
|
||||
unit("Milligram", "mg", 1e-6),
|
||||
unit("Gram", "g", 0.001),
|
||||
unit("Kilogram", "kg", 1.0),
|
||||
unit("Tonne", "t", 1000.0),
|
||||
unit("Ounce", "oz", 0.028_349_523_125),
|
||||
unit("Pound", "lb", 0.453_592_37),
|
||||
unit("Stone", "st", 6.350_293_18),
|
||||
],
|
||||
pair: (2, 5),
|
||||
},
|
||||
Kind {
|
||||
name: "Temperature",
|
||||
units: &[
|
||||
Unit {
|
||||
name: "Celsius",
|
||||
symbol: "°C",
|
||||
scale: 1.0,
|
||||
offset: 273.15,
|
||||
},
|
||||
Unit {
|
||||
name: "Fahrenheit",
|
||||
symbol: "°F",
|
||||
scale: 5.0 / 9.0,
|
||||
offset: 273.15 - 32.0 * 5.0 / 9.0,
|
||||
},
|
||||
unit("Kelvin", "K", 1.0),
|
||||
],
|
||||
pair: (0, 1),
|
||||
},
|
||||
Kind {
|
||||
name: "Speed",
|
||||
units: &[
|
||||
unit("Meter per second", "m/s", 1.0),
|
||||
unit("Kilometer per hour", "km/h", 1.0 / 3.6),
|
||||
unit("Mile per hour", "mph", 0.447_04),
|
||||
unit("Knot", "kn", 1852.0 / 3600.0),
|
||||
],
|
||||
pair: (1, 2),
|
||||
},
|
||||
Kind {
|
||||
name: "Data",
|
||||
units: &[
|
||||
unit("Bit", "b", 0.125),
|
||||
unit("Byte", "B", 1.0),
|
||||
unit("Kilobyte", "kB", 1e3),
|
||||
unit("Megabyte", "MB", 1e6),
|
||||
unit("Gigabyte", "GB", 1e9),
|
||||
unit("Terabyte", "TB", 1e12),
|
||||
unit("Kibibyte", "KiB", 1024.0),
|
||||
unit("Mebibyte", "MiB", 1_048_576.0),
|
||||
unit("Gibibyte", "GiB", 1_073_741_824.0),
|
||||
],
|
||||
pair: (4, 8),
|
||||
},
|
||||
];
|
||||
|
||||
/// `amount` in `from` as `to`, through their kind's base.
|
||||
pub(crate) fn convert(amount: f64, from: &Unit, to: &Unit) -> f64 {
|
||||
(amount * from.scale + from.offset - to.offset) / to.scale
|
||||
}
|
||||
|
||||
/// The converter's own field, kind and units.
|
||||
struct Desk {
|
||||
input: Entity<TextInput>,
|
||||
kind: usize,
|
||||
from: usize,
|
||||
to: usize,
|
||||
}
|
||||
|
||||
/// Where a unit named `name` stands among its kind's.
|
||||
fn place(units: &[Unit], name: &str) -> usize {
|
||||
units
|
||||
.iter()
|
||||
.position(|unit| unit.name == name)
|
||||
.unwrap_or_else(|| panic!("unit converter: no unit named {name}"))
|
||||
}
|
||||
|
||||
/// An amount in one unit read in another: length, area, volume, mass, temperature, speed or data, with a swap of the two units. It shows six significant digits.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UnitConverter {
|
||||
id: ElementId,
|
||||
}
|
||||
|
||||
impl UnitConverter {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self { id: id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for UnitConverter {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let desk = window.use_keyed_state((id.clone(), "desk"), cx, |window, cx| Desk {
|
||||
input: cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx)
|
||||
.filter(|ch| ch.is_ascii_digit() || matches!(ch, '.' | ',' | '-' | MINUS));
|
||||
input.set_text("1", cx);
|
||||
input
|
||||
}),
|
||||
kind: 0,
|
||||
from: KINDS[0].pair.0,
|
||||
to: KINDS[0].pair.1,
|
||||
});
|
||||
let (input, kind, from, to) = {
|
||||
let desk = desk.read(cx);
|
||||
(desk.input.clone(), &KINDS[desk.kind], desk.from, desk.to)
|
||||
};
|
||||
let text = input.read(cx).text().to_string();
|
||||
let amount = parse_number(&text);
|
||||
let result = match amount {
|
||||
Some(amount) => format!(
|
||||
"{} {}",
|
||||
significant(
|
||||
convert(amount, &kind.units[from], &kind.units[to]),
|
||||
6,
|
||||
Separators::EN
|
||||
),
|
||||
kind.units[to].symbol
|
||||
),
|
||||
None => "—".to_string(),
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let choices = || {
|
||||
kind.units
|
||||
.iter()
|
||||
.map(|unit| Choice::new(unit.name, unit.name))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let pick = |edit: fn(&mut Desk, usize)| {
|
||||
let desk = desk.clone();
|
||||
move |name: &SharedString, _: &mut Window, cx: &mut App| {
|
||||
desk.update(cx, |desk, cx| {
|
||||
edit(desk, place(KINDS[desk.kind].units, name));
|
||||
log::info!("unit converter: {name}");
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let (kinds, swap) = (desk.clone(), desk.clone());
|
||||
let half = || div().flex_1().min_w_0();
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
half().child(
|
||||
Select::new(
|
||||
(id.clone(), "kind"),
|
||||
KINDS.iter().map(|kind| Choice::new(kind.name, kind.name)),
|
||||
)
|
||||
.selected(kind.name)
|
||||
.on_change(move |name, _, cx| {
|
||||
kinds.update(cx, |desk, cx| {
|
||||
desk.kind = KINDS
|
||||
.iter()
|
||||
.position(|kind| kind.name == name.as_ref())
|
||||
.unwrap_or_else(|| {
|
||||
panic!("unit converter: no kind {name}")
|
||||
});
|
||||
(desk.from, desk.to) = KINDS[desk.kind].pair;
|
||||
log::info!("unit converter: {name}");
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
IconButton::new((id.clone(), "swap"), IconName::ArrowDownUp)
|
||||
.tooltip("Swap units")
|
||||
.on_click(move |_, _, cx| {
|
||||
swap.update(cx, |desk, cx| {
|
||||
(desk.from, desk.to) = (desk.to, desk.from);
|
||||
log::info!("unit converter: swapped");
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(half().child(Input::new(&input).invalid(amount.is_none())))
|
||||
.child(
|
||||
half().child(
|
||||
Select::new((id.clone(), "from"), choices())
|
||||
.selected(kind.units[from].name)
|
||||
.on_change(pick(|desk, at| desk.from = at)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
half().child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(theme.control_height(ControlSize::Md))
|
||||
.px(theme.control_padding(ControlSize::Md))
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.sunken)
|
||||
.text_color(theme.colors.fg)
|
||||
.child(
|
||||
tabular(div())
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.debug_selector(|| format!("converted-{result}"))
|
||||
.child(Ellipsis::new(result.clone())),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
half().child(
|
||||
Select::new((id, "to"), choices())
|
||||
.selected(kind.units[to].name)
|
||||
.on_change(pick(|desk, at| desk.to = at)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{KINDS, Kind, convert, place};
|
||||
|
||||
fn between(kind: &Kind, amount: f64, from: &str, to: &str) -> f64 {
|
||||
convert(
|
||||
amount,
|
||||
&kind.units[place(kind.units, from)],
|
||||
&kind.units[place(kind.units, to)],
|
||||
)
|
||||
}
|
||||
|
||||
fn near(value: f64, expected: f64) -> bool {
|
||||
(value - expected).abs() <= 1e-9 * expected.abs().max(1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn units_convert_through_their_base() {
|
||||
let [length, area, volume, mass, temperature, speed, data] = &KINDS;
|
||||
assert!(near(between(length, 1.0, "Mile", "Kilometer"), 1.609_344));
|
||||
assert!(near(between(length, 1.0, "Foot", "Inch"), 12.0));
|
||||
assert!(near(between(area, 1.0, "Square mile", "Acre"), 640.0));
|
||||
assert!(near(between(volume, 1.0, "US gallon", "US cup"), 16.0));
|
||||
assert!(near(between(mass, 1.0, "Stone", "Pound"), 14.0));
|
||||
assert!(near(
|
||||
between(speed, 1.0, "Knot", "Kilometer per hour"),
|
||||
1.852
|
||||
));
|
||||
assert!(near(
|
||||
between(data, 1.0, "Gibibyte", "Megabyte"),
|
||||
1_073.741_824
|
||||
));
|
||||
assert!(near(
|
||||
between(temperature, 100.0, "Celsius", "Fahrenheit"),
|
||||
212.0
|
||||
));
|
||||
assert!(near(
|
||||
between(temperature, -40.0, "Fahrenheit", "Celsius"),
|
||||
-40.0
|
||||
));
|
||||
assert!(near(
|
||||
between(temperature, 0.0, "Kelvin", "Celsius"),
|
||||
-273.15
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_kind_starts_on_two_units_of_its_own() {
|
||||
for kind in &KINDS {
|
||||
let (from, to) = kind.pair;
|
||||
assert!(from != to && to < kind.units.len(), "{}", kind.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ icons! {
|
||||
AppWindow => "app-window",
|
||||
Archive => "archive",
|
||||
ArrowDownLeft => "arrow-down-left",
|
||||
ArrowDownUp => "arrow-down-up",
|
||||
ArrowDownToDot => "arrow-down-to-dot",
|
||||
ArrowDownToLine => "arrow-down-to-line",
|
||||
ArrowDownWideNarrow => "arrow-down-wide-narrow",
|
||||
@@ -131,6 +132,7 @@ icons! {
|
||||
Crop => "crop",
|
||||
Crosshair => "crosshair",
|
||||
Database => "database",
|
||||
Delete => "delete",
|
||||
Dices => "dices",
|
||||
DollarSign => "dollar-sign",
|
||||
Dot => "dot",
|
||||
|
||||
@@ -50,6 +50,40 @@ pub fn group_digits(digits: &str, group: char) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// `value` rounded to `digits` significant digits and written out in full, grouped, without trailing zeros. Non-finite values print as Rust prints them.
|
||||
pub fn significant(value: f64, digits: usize, separators: Separators) -> String {
|
||||
assert!(digits > 0, "a number keeps at least one significant digit");
|
||||
if !value.is_finite() {
|
||||
return value.to_string();
|
||||
}
|
||||
let rounded = format!("{:.*e}", digits - 1, value.abs());
|
||||
let (mantissa, exponent) = rounded.split_once('e').expect("Rust writes an exponent");
|
||||
let point = exponent.parse::<i32>().expect("a whole exponent") + 1;
|
||||
let figures: String = mantissa.chars().filter(char::is_ascii_digit).collect();
|
||||
let (whole, part) = match usize::try_from(point) {
|
||||
Err(_) | Ok(0) => (
|
||||
"0".to_string(),
|
||||
"0".repeat(point.unsigned_abs() as usize) + &figures,
|
||||
),
|
||||
Ok(point) if point >= figures.len() => (
|
||||
figures.clone() + &"0".repeat(point - figures.len()),
|
||||
String::new(),
|
||||
),
|
||||
Ok(point) => (figures[..point].to_string(), figures[point..].to_string()),
|
||||
};
|
||||
let mut out = String::new();
|
||||
if value < 0.0 && figures.chars().any(|ch| ch != '0') {
|
||||
out.push(MINUS);
|
||||
}
|
||||
out.push_str(&group_digits(&whole, separators.group));
|
||||
let part = part.trim_end_matches('0');
|
||||
if !part.is_empty() {
|
||||
out.push(separators.decimal);
|
||||
out.push_str(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// ISO 4217 code to symbol, minor units, and whether a space follows.
|
||||
pub fn currency_parts(code: &str) -> (&str, usize, bool) {
|
||||
let places = iso_currency::Currency::from_code(code)
|
||||
@@ -246,6 +280,31 @@ mod tests {
|
||||
assert_eq!(decimals(1e-11), 11, "a tiny step keeps its places");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn significant_digits_round_at_every_size() {
|
||||
let en = Separators::EN;
|
||||
assert_eq!(
|
||||
significant(0.1 + 0.2, 12, en),
|
||||
"0.3",
|
||||
"binary noise rounds away"
|
||||
);
|
||||
assert_eq!(significant(2.0 / 3.0, 12, en), "0.666666666667");
|
||||
assert_eq!(significant(1234.5678, 6, en), "1,234.57");
|
||||
assert_eq!(
|
||||
significant(9.999_999_6, 6, en),
|
||||
"10",
|
||||
"a carry adds a digit"
|
||||
);
|
||||
assert_eq!(
|
||||
significant(12_345_678_901_234_567_890.0, 12, en),
|
||||
"12,345,678,901,200,000,000"
|
||||
);
|
||||
assert_eq!(significant(-1.0e-15, 12, en), "−0.000000000000001");
|
||||
assert_eq!(significant(0.0, 12, en), "0");
|
||||
assert_eq!(significant(-0.0, 12, en), "0");
|
||||
assert_eq!(significant(f64::INFINITY, 12, en), "inf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_groups_and_rounds() {
|
||||
assert_eq!(number(1234567.891, 2, Separators::EN), "1,234,567.89");
|
||||
|
||||
Reference in new issue
Block a user