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.
This commit is contained in:
1 parent
d1345cd14e
commit
73f3399cea
13 files changed
+1023
-7
No files matched your search
@@ -198,6 +198,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- gpui 0.2.2's Metal renderer starts its instance buffer at 2 MB; a frame that outgrows it logs "failed to render … retrying with larger instance buffer size" and the buffer doubles, up to 256 MB. A world map's first frame grows it to 16 MB, then draws: the log is gpui's recovery, not a failure.
|
||||
- gpui 0.2.2 packs pictures in its atlas without padding and samples them with a linear filter, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`).
|
||||
- A wall clock reads `Timestamp::now` and wakes its view at each whole second through a keyed task, only while the owner passes no moment; a stopwatch reads the executor's clock, so tests advance it (`misc::Clock`, `misc::Stopwatch`).
|
||||
- A number shown to so many significant digits goes through `typography::format::significant`, which rounds through Rust's exponent form, so no binary noise shows at any size (`misc::Calculator`, `misc::UnitConverter`).
|
||||
- A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. Its tiles stay in gpui's asset cache, as every `img()` picture does. Its popup is an overlay that is not modal: it takes focus when it shows, and the frame it stops showing, by any way out, hands focus back. Its layers, lines and areas unwrap across the date line and repeat on every world in view. Clustered pins gather by squares of the world, not of the view, so a pan never regroups them; a zoom may, so it hands a focused pin's or cluster's focus to the map.
|
||||
|
||||
## Decisions
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-down-up"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m3 16 4 4 4-4" />
|
||||
<path d="M7 20V4" />
|
||||
<path d="m21 8-4-4-4 4" />
|
||||
<path d="M17 4v16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-delete"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z" />
|
||||
<path d="m12 9 6 6" />
|
||||
<path d="m18 9-6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
@@ -1,4 +1,4 @@
|
||||
use ely_gpui_component::misc::{Clock, Stopwatch, WorldClock};
|
||||
use ely_gpui_component::misc::{Calculator, Clock, Stopwatch, UnitConverter, WorldClock};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
use jiff::tz::TimeZone;
|
||||
|
||||
@@ -11,7 +11,7 @@ pub const PAGE: Page = Page {
|
||||
number: 42,
|
||||
slug: "misc",
|
||||
title: "Misc",
|
||||
summary: "Small tools that stand alone: clocks, a stopwatch.",
|
||||
summary: "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter.",
|
||||
render,
|
||||
script: &[
|
||||
Step::DownAt("stopwatch-start", 30.0, 62.0),
|
||||
@@ -21,6 +21,12 @@ pub const PAGE: Page = Page {
|
||||
Step::UpAt("stopwatch-start", 30.0, 62.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("stopwatch"),
|
||||
Step::DownAt("calculator", 24.0, 24.0),
|
||||
Step::UpAt("calculator", 24.0, 24.0),
|
||||
Step::Type("1234+5*6"),
|
||||
Step::Shot("calculator-typed"),
|
||||
Step::Key("enter"),
|
||||
Step::Shot("calculator-result"),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -32,6 +38,8 @@ fn render(_: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(clocks(cx))
|
||||
.child(stopwatch(cx))
|
||||
.child(calculator(cx))
|
||||
.child(converter(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -69,3 +77,24 @@ fn stopwatch(cx: &App) -> impl IntoElement + use<> {
|
||||
)
|
||||
.child(probe("stopwatch-start", div().w(px(320.0)).child(Stopwatch::new("stopwatch"))))
|
||||
}
|
||||
|
||||
fn calculator(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"Calculator",
|
||||
"Digits, the four operations, sign, delete and clear, worked in precedence; keys type too, and Enter gives the result.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"calculator",
|
||||
div().w(px(280.0)).child(Calculator::new("calculator")),
|
||||
))
|
||||
}
|
||||
|
||||
fn converter(cx: &App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"UnitConverter",
|
||||
"An amount read in another unit: length, area, volume, mass, temperature, speed and data, with a swap.",
|
||||
cx,
|
||||
)
|
||||
.child(div().w(px(280.0)).child(UnitConverter::new("unit-converter")))
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
+2
-2
@@ -315,8 +315,8 @@
|
||||
|
||||
## T42b Misc · Numbers
|
||||
|
||||
- [ ] Calculator
|
||||
- [ ] UnitConverter
|
||||
- [x] Calculator → `misc::Calculator`: digits, the four operations in precedence through `forms::evaluate`, sign, delete and clear, twelve significant digits; keys type too, and Enter gives the result while the calculator itself has focus
|
||||
- [x] UnitConverter → `misc::UnitConverter`: length, area, volume, mass, temperature, speed and data, each on a starting pair, with a swap; six significant digits
|
||||
|
||||
## T42c Misc · Codes and checks
|
||||
|
||||
|
||||
Reference in new issue
Block a user