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.
370 lines
12 KiB
Rust
370 lines
12 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|