feat(gpui_widgets): scaffold form-controls crate with value core and keying diamond
New workspace crate implementing W1 of the oak task list. This first slice lands the pure numeric core (ValueKind / SliderValue / RationalValue / ValueFormatter with round-trip tests) and the keying diamond button (KeyingState / KeyingRequest) that keyable controls embed. 12 unit tests pass.
This commit is contained in:
Generated
+11
@@ -2757,6 +2757,17 @@ dependencies = [
|
||||
"zed-font-kit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui_widgets"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
"gpui_elements",
|
||||
"gpui_platform",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpui_windows"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -19,6 +19,7 @@ members = [
|
||||
"./crates/gpui_media/",
|
||||
"./crates/gpui_zed_util/",
|
||||
"./crates/gpui_ce_util/",
|
||||
"./crates/gpui_widgets/",
|
||||
"./tooling/perf/",
|
||||
]
|
||||
default-members = ["./crates/gpui/"]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "gpui_widgets"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors = ["Oak <oak@users.noreply.github.com>"]
|
||||
description = "Form controls, menus, dialogs and NLE widgets built on gpui"
|
||||
publish = true
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui_elements = { path = "../gpui_elements" }
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] }
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Keying support for keyable form controls.
|
||||
//!
|
||||
//! Every keyable control (sliders, color swatches, curve editor points) shows
|
||||
//! a small diamond button on its right edge. Clicking it emits a
|
||||
//! [`KeyingRequest`] so the host can add/remove/inspect the keyframe for that
|
||||
//! control; the widget itself never touches the engine.
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Div, ElementId, Hsla, Pixels, Stateful, Window, canvas, colors::DefaultColors,
|
||||
div, fill, point, prelude::*, px, size,
|
||||
};
|
||||
|
||||
/// The keying state of a control at the current playhead frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyingState {
|
||||
/// The control has no keyframe.
|
||||
NoKey,
|
||||
/// The control has a keyframe somewhere on the timeline.
|
||||
HasKey,
|
||||
/// The control has a keyframe exactly at the current frame.
|
||||
AtCurrentFrame,
|
||||
}
|
||||
|
||||
/// A request emitted when a keying diamond is clicked.
|
||||
///
|
||||
/// Widgets embed this in their own event enums, e.g.
|
||||
/// `SliderEvent::Keying(KeyingRequest)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct KeyingRequest {
|
||||
/// The stable id of the control that owns the diamond.
|
||||
pub control: usize,
|
||||
/// The keying state the control reported when the diamond was painted.
|
||||
pub state: KeyingState,
|
||||
}
|
||||
|
||||
impl KeyingRequest {
|
||||
/// Create a keying request for `control`.
|
||||
pub fn new(control: usize, state: KeyingState) -> Self {
|
||||
Self { control, state }
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent used for "has a key" diamonds.
|
||||
const KEY_COLOR: Hsla = Hsla {
|
||||
h: 0.09,
|
||||
s: 0.75,
|
||||
l: 0.55,
|
||||
a: 1.0,
|
||||
};
|
||||
/// Brighter accent for "key at current frame".
|
||||
const KEY_ACTIVE_COLOR: Hsla = Hsla {
|
||||
h: 0.09,
|
||||
s: 0.9,
|
||||
l: 0.68,
|
||||
a: 1.0,
|
||||
};
|
||||
/// Base opacity of an empty diamond, so missing keys read dimmer.
|
||||
const EMPTY_OPACITY: f32 = 0.45;
|
||||
/// The painted size of the diamond glyph itself.
|
||||
const DIAMOND_SIZE: f32 = 14.0;
|
||||
|
||||
/// Build the keying diamond element for `control`.
|
||||
///
|
||||
/// The returned element is interactive: chain `.on_click(...)` (or
|
||||
/// `cx.listener(...)`) to receive clicks, then emit a [`KeyingRequest`].
|
||||
pub fn keying_diamond(control: usize, state: KeyingState) -> Stateful<Div> {
|
||||
div()
|
||||
.size(px(20.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.opacity(if state == KeyingState::NoKey {
|
||||
EMPTY_OPACITY
|
||||
} else {
|
||||
1.0
|
||||
})
|
||||
.hover(|style| style.opacity(1.0))
|
||||
.child(canvas(
|
||||
move |_bounds, _window, _cx| (),
|
||||
move |bounds, (), window, cx| paint_diamond(bounds, state, window, cx),
|
||||
))
|
||||
.id(ElementId::named_usize("gpui-widgets-keying", control))
|
||||
}
|
||||
|
||||
/// A ready-made [`KeyingRequest`] for this diamond's state.
|
||||
pub fn keying_request(control: usize, state: KeyingState) -> KeyingRequest {
|
||||
KeyingRequest::new(control, state)
|
||||
}
|
||||
|
||||
fn paint_diamond(bounds: Bounds<Pixels>, state: KeyingState, window: &mut Window, cx: &mut App) {
|
||||
use gpui::PathBuilder;
|
||||
let colors = cx.default_colors().clone();
|
||||
let center = bounds.center();
|
||||
let half = px(DIAMOND_SIZE / 2.0 - 2.0);
|
||||
let (fill_color, border_color) = match state {
|
||||
KeyingState::NoKey => (
|
||||
Hsla::from(colors.disabled),
|
||||
Hsla::from(colors.disabled),
|
||||
),
|
||||
KeyingState::HasKey => (KEY_COLOR, KEY_COLOR),
|
||||
KeyingState::AtCurrentFrame => (KEY_ACTIVE_COLOR, KEY_ACTIVE_COLOR),
|
||||
};
|
||||
|
||||
// The diamond body is filled when a key exists, hollow otherwise.
|
||||
if state != KeyingState::NoKey {
|
||||
let mut body = PathBuilder::fill();
|
||||
body.move_to(point(center.x, center.y - half));
|
||||
body.line_to(point(center.x + half, center.y));
|
||||
body.line_to(point(center.x, center.y + half));
|
||||
body.line_to(point(center.x - half, center.y));
|
||||
body.close();
|
||||
if let Ok(path) = body.build() {
|
||||
window.paint_path(path, fill_color);
|
||||
}
|
||||
}
|
||||
|
||||
// The outline is always painted so the diamond is visible when empty.
|
||||
let mut outline = PathBuilder::stroke(px(1.0));
|
||||
outline.move_to(point(center.x, center.y - half));
|
||||
outline.line_to(point(center.x + half, center.y));
|
||||
outline.line_to(point(center.x, center.y + half));
|
||||
outline.line_to(point(center.x - half, center.y));
|
||||
outline.close();
|
||||
if let Ok(path) = outline.build() {
|
||||
window.paint_path(path, border_color);
|
||||
}
|
||||
|
||||
// A center dot marks a key exactly at the current frame.
|
||||
if state == KeyingState::AtCurrentFrame {
|
||||
let origin = point(center.x - px(2.0), center.y - px(2.0));
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(origin, size(px(4.0), px(4.0))),
|
||||
Hsla::from(colors.background),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_round_trips() {
|
||||
let request = KeyingRequest::new(7, KeyingState::AtCurrentFrame);
|
||||
assert_eq!(request.control, 7);
|
||||
assert_eq!(request.state, KeyingState::AtCurrentFrame);
|
||||
assert_ne!(request, KeyingRequest::new(7, KeyingState::HasKey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_are_distinct() {
|
||||
assert_ne!(KeyingState::NoKey, KeyingState::HasKey);
|
||||
assert_ne!(KeyingState::HasKey, KeyingState::AtCurrentFrame);
|
||||
assert_ne!(KeyingState::NoKey, KeyingState::AtCurrentFrame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keying_request_helper_matches() {
|
||||
assert_eq!(
|
||||
keying_request(3, KeyingState::NoKey),
|
||||
KeyingRequest::new(3, KeyingState::NoKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Widget library built on gpui for the Oak video editor: form controls,
|
||||
//! menus and dialogs, and NLE-specific components (viewer, scopes, project
|
||||
//! explorer).
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! Widgets follow the same contract as the `gpui::timeline` /
|
||||
//! `gpui::node_graph` / `gpui::effect_stack` / `gpui::dock` modules:
|
||||
//!
|
||||
//! * **Data-agnostic** — a widget owns no model. The host implements a
|
||||
//! data-source trait over its own state and hands the widget an
|
||||
//! [`Entity`](gpui::Entity) of that implementation.
|
||||
//! * **Requests only** — every edit is emitted as a request event through
|
||||
//! `cx.emit`; the host applies it through its engine (the single source of
|
||||
//! truth) and then notifies the data entity.
|
||||
//! * **Testable core** — pure value/geometry/state-machine logic lives in
|
||||
//! files with no gpui coupling (e.g. [`value`], [`slider::model`]) and is
|
||||
//! covered by plain unit tests.
|
||||
|
||||
pub mod keyable;
|
||||
pub mod value;
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Pure numeric value types shared by the form controls.
|
||||
//!
|
||||
//! These types carry no gpui dependency: they are the unit-testable core of
|
||||
//! the slider / spinbox / color widgets. The key invariant is that a
|
||||
//! [`SliderValue`] round-trips through [`SliderValue::to_f64`] and
|
||||
//! [`SliderValue::from_f64`] losslessly for the kinds where exactness matters
|
||||
//! (integers and rationals), so repeated edits cannot accumulate drift.
|
||||
|
||||
use gpui::SharedString;
|
||||
|
||||
/// The family of values a control can edit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ValueKind {
|
||||
/// A real number.
|
||||
Float,
|
||||
/// A whole number.
|
||||
Integer,
|
||||
/// An exact fraction `num / den`.
|
||||
Rational,
|
||||
/// An angle in degrees.
|
||||
Angle,
|
||||
}
|
||||
|
||||
/// The value edited by a form control.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SliderValue {
|
||||
/// A real number.
|
||||
Float(f64),
|
||||
/// A whole number.
|
||||
Integer(i64),
|
||||
/// An exact fraction.
|
||||
Rational(RationalValue),
|
||||
/// An angle in degrees.
|
||||
Angle(f64),
|
||||
}
|
||||
|
||||
impl SliderValue {
|
||||
/// The [`ValueKind`] of this value.
|
||||
pub fn kind(self) -> ValueKind {
|
||||
match self {
|
||||
SliderValue::Float(_) => ValueKind::Float,
|
||||
SliderValue::Integer(_) => ValueKind::Integer,
|
||||
SliderValue::Rational(_) => ValueKind::Rational,
|
||||
SliderValue::Angle(_) => ValueKind::Angle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The numeric projection used for painting and interpolation.
|
||||
///
|
||||
/// For [`SliderValue::Rational`] this is `num / den` as `f64`; the exact
|
||||
/// pair is preserved by [`SliderValue::Rational`] itself.
|
||||
pub fn to_f64(self) -> f64 {
|
||||
match self {
|
||||
SliderValue::Float(v) => v,
|
||||
SliderValue::Integer(v) => v as f64,
|
||||
SliderValue::Rational(v) => v.to_f64(),
|
||||
SliderValue::Angle(v) => v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a value from its numeric projection.
|
||||
///
|
||||
/// [`SliderValue::Integer`] truncates toward zero and
|
||||
/// [`SliderValue::Rational`] reconstructs the closest fraction with a
|
||||
/// bounded denominator, so this is only lossless when the source value
|
||||
/// was exact to begin with.
|
||||
pub fn from_f64(kind: ValueKind, value: f64) -> Self {
|
||||
match kind {
|
||||
ValueKind::Float => SliderValue::Float(value),
|
||||
ValueKind::Integer => SliderValue::Integer(value.trunc() as i64),
|
||||
ValueKind::Rational => SliderValue::Rational(RationalValue::from_f64(value, 1_000_000)),
|
||||
ValueKind::Angle => SliderValue::Angle(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An exact rational number, kept normalized (`den > 0`, `gcd(|num|, den) == 1`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct RationalValue {
|
||||
num: i64,
|
||||
den: i64,
|
||||
}
|
||||
|
||||
/// Error returned when a rational cannot be constructed from raw parts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum RationalError {
|
||||
/// The denominator must not be zero.
|
||||
#[error("rational denominator must not be zero")]
|
||||
ZeroDenominator,
|
||||
}
|
||||
|
||||
impl RationalValue {
|
||||
/// The zero rational `0/1`.
|
||||
pub const ZERO: Self = Self { num: 0, den: 1 };
|
||||
/// The one rational `1/1`.
|
||||
pub const ONE: Self = Self { num: 1, den: 1 };
|
||||
|
||||
/// Construct a normalized rational, rejecting a zero denominator.
|
||||
pub fn new(num: i64, den: i64) -> Result<Self, RationalError> {
|
||||
if den == 0 {
|
||||
return Err(RationalError::ZeroDenominator);
|
||||
}
|
||||
Ok(Self::new_unchecked(num, den))
|
||||
}
|
||||
|
||||
/// Construct a normalized rational, clamping a zero denominator to one.
|
||||
pub fn new_or_one(num: i64, den: i64) -> Self {
|
||||
if den == 0 {
|
||||
return Self { num, den: 1 };
|
||||
}
|
||||
Self::new_unchecked(num, den)
|
||||
}
|
||||
|
||||
fn new_unchecked(num: i64, den: i64) -> Self {
|
||||
let (num, den) = if den < 0 { (-num, -den) } else { (num, den) };
|
||||
let gcd = num.unsigned_abs().gcd(den as u64) as i64;
|
||||
Self {
|
||||
num: num / gcd,
|
||||
den: den / gcd,
|
||||
}
|
||||
}
|
||||
|
||||
/// The numerator of the normalized fraction.
|
||||
pub fn num(self) -> i64 {
|
||||
self.num
|
||||
}
|
||||
|
||||
/// The denominator of the normalized fraction (always positive).
|
||||
pub fn den(self) -> i64 {
|
||||
self.den
|
||||
}
|
||||
|
||||
/// The value as `f64`.
|
||||
pub fn to_f64(self) -> f64 {
|
||||
self.num as f64 / self.den as f64
|
||||
}
|
||||
|
||||
/// The closest rational with `1 <= den <= max_den`, via continued
|
||||
/// fractions. `NaN` and infinities map to the zero rational; negative
|
||||
/// values keep their sign.
|
||||
pub fn from_f64(value: f64, max_den: i64) -> Self {
|
||||
if !value.is_finite() {
|
||||
return Self::ZERO;
|
||||
}
|
||||
let max_den = max_den.max(1);
|
||||
let negative = value < 0.0;
|
||||
let value = value.abs();
|
||||
// Continued fraction convergents with bounded denominator.
|
||||
let mut n0 = 0i64;
|
||||
let mut d0 = 1i64;
|
||||
let mut n1 = 1i64;
|
||||
let mut d1 = 0i64;
|
||||
let mut remainder = value;
|
||||
for _ in 0..64 {
|
||||
let a = remainder.floor() as i64;
|
||||
let n2 = a.saturating_mul(n1).saturating_add(n0);
|
||||
let d2 = a.saturating_mul(d1).saturating_add(d0);
|
||||
if d2 > max_den {
|
||||
break;
|
||||
}
|
||||
n0 = n1;
|
||||
d0 = d1;
|
||||
n1 = n2;
|
||||
d1 = d2;
|
||||
let frac = remainder - remainder.floor();
|
||||
if frac.abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
remainder = 1.0 / frac;
|
||||
}
|
||||
let (num, den) = if negative { (-n1, d1) } else { (n1, d1) };
|
||||
Self::new_unchecked(num, den)
|
||||
}
|
||||
}
|
||||
|
||||
/// A failure to parse a value from text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ParseValueError {
|
||||
/// The text did not match the expected format for this value kind.
|
||||
#[error("could not parse {0:?} as {1:?}")]
|
||||
InvalidFormat(SharedString, ValueKind),
|
||||
}
|
||||
|
||||
/// Formats and parses a [`SliderValue`] for display and direct entry.
|
||||
///
|
||||
/// Implementations are injected into controls so hosts can localize or
|
||||
/// specialize the representation (e.g. timecode instead of frames, or
|
||||
/// fractions with a fixed denominator).
|
||||
pub trait ValueFormatter: 'static + Clone {
|
||||
/// Render a value as text.
|
||||
fn format(&self, value: SliderValue) -> SharedString;
|
||||
/// Parse text into a value. Must reject malformed input with
|
||||
/// [`ParseValueError`]; controls refuse to apply rejected input.
|
||||
fn parse(&self, text: &str) -> Result<SliderValue, ParseValueError>;
|
||||
}
|
||||
|
||||
/// The default formatter:
|
||||
///
|
||||
/// * `Float` — up to three decimal digits, trailing zeros trimmed.
|
||||
/// * `Integer` — plain decimal.
|
||||
/// * `Rational` — `num/den`.
|
||||
/// * `Angle` — degrees with a trailing `°`.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct DefaultFormatter;
|
||||
|
||||
impl ValueFormatter for DefaultFormatter {
|
||||
fn format(&self, value: SliderValue) -> SharedString {
|
||||
match value {
|
||||
SliderValue::Float(v) => {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let rounded = (v * 1000.0).round() / 1000.0;
|
||||
let mut text = format!("{rounded}");
|
||||
if text.contains('.') {
|
||||
while text.ends_with('0') {
|
||||
text.pop();
|
||||
}
|
||||
if text.ends_with('.') {
|
||||
text.pop();
|
||||
}
|
||||
}
|
||||
text.into()
|
||||
}
|
||||
SliderValue::Integer(v) => format!("{v}").into(),
|
||||
SliderValue::Rational(v) => format!("{}/{}", v.num(), v.den()).into(),
|
||||
SliderValue::Angle(v) => {
|
||||
let rounded = (v * 1000.0).round() / 1000.0;
|
||||
format!("{rounded}°").into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(&self, text: &str) -> Result<SliderValue, ParseValueError> {
|
||||
let text = text.trim();
|
||||
let kind = |reason: SharedString| ParseValueError::InvalidFormat(reason, ValueKind::Float);
|
||||
if let Some((num, den)) = text.split_once('/') {
|
||||
let num: i64 = num
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational))?;
|
||||
let den: i64 = den
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational))?;
|
||||
return RationalValue::new(num, den)
|
||||
.map(SliderValue::Rational)
|
||||
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Rational));
|
||||
}
|
||||
let (body, is_angle) = text
|
||||
.strip_suffix('°')
|
||||
.map(|body| (body, true))
|
||||
.or_else(|| {
|
||||
text.strip_suffix("deg")
|
||||
.or_else(|| text.strip_suffix("DEG"))
|
||||
.map(|body| (body, true))
|
||||
})
|
||||
.unwrap_or((text, false));
|
||||
if is_angle {
|
||||
let value: f64 = body
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| ParseValueError::InvalidFormat(text.into(), ValueKind::Angle))?;
|
||||
return Ok(SliderValue::Angle(value));
|
||||
}
|
||||
// An exact decimal with no fractional part parses as an integer when
|
||||
// there are no digits after the point; otherwise as a float.
|
||||
let value: f64 = body
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| kind(text.into()))?;
|
||||
if value.fract() == 0.0 && value.abs() < i64::MAX as f64 {
|
||||
Ok(SliderValue::Integer(value as i64))
|
||||
} else {
|
||||
Ok(SliderValue::Float(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension: `u64::gcd` used by [`RationalValue`].
|
||||
trait Gcd {
|
||||
fn gcd(self, other: Self) -> Self;
|
||||
}
|
||||
|
||||
impl Gcd for u64 {
|
||||
fn gcd(mut self, mut other: Self) -> Self {
|
||||
while other != 0 {
|
||||
(self, other) = (other, self % other);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rational_normalizes() {
|
||||
assert_eq!(
|
||||
RationalValue::new(4, 6).unwrap(),
|
||||
RationalValue::new(2, 3).unwrap()
|
||||
);
|
||||
assert_eq!(RationalValue::new(2, 3).unwrap().num(), 2);
|
||||
assert_eq!(RationalValue::new(2, 3).unwrap().den(), 3);
|
||||
assert_eq!(RationalValue::new(-2, 4).unwrap(), RationalValue::new(-1, 2).unwrap());
|
||||
assert_eq!(RationalValue::new(1, -2).unwrap(), RationalValue::new(-1, 2).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_rejects_zero_denominator() {
|
||||
assert_eq!(
|
||||
RationalValue::new(1, 0),
|
||||
Err(RationalError::ZeroDenominator)
|
||||
);
|
||||
assert_eq!(RationalValue::new_or_one(1, 0), RationalValue::new(1, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_from_f64_exact() {
|
||||
assert_eq!(RationalValue::from_f64(0.5, 1000), RationalValue::new(1, 2).unwrap());
|
||||
assert_eq!(RationalValue::from_f64(0.25, 1000), RationalValue::new(1, 4).unwrap());
|
||||
assert_eq!(RationalValue::from_f64(2.0, 1000), RationalValue::new(2, 1).unwrap());
|
||||
assert_eq!(RationalValue::from_f64(-1.5, 1000), RationalValue::new(-3, 2).unwrap());
|
||||
assert_eq!(RationalValue::from_f64(0.3333333333, 1000), RationalValue::new(1, 3).unwrap());
|
||||
assert_eq!(RationalValue::from_f64(f64::NAN, 1000), RationalValue::ZERO);
|
||||
assert_eq!(RationalValue::from_f64(f64::INFINITY, 1000), RationalValue::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_from_f64_bounded_denominator() {
|
||||
// 1/999983 needs a huge denominator; the bounded approximation may be
|
||||
// 0 but must never exceed the bound or produce a negative denominator.
|
||||
let value = RationalValue::from_f64(1.0 / 999983.0, 1000);
|
||||
assert!(value.den() <= 1000);
|
||||
assert!(value.den() > 0);
|
||||
// A friendlier irrational keeps a nonzero approximation.
|
||||
let approx = RationalValue::from_f64(std::f64::consts::FRAC_1_PI, 1000);
|
||||
assert!(approx.num() > 0);
|
||||
assert!(approx.den() <= 1000);
|
||||
assert!((approx.to_f64() - std::f64::consts::FRAC_1_PI).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_kind_projection() {
|
||||
assert_eq!(SliderValue::Integer(-3).to_f64(), -3.0);
|
||||
assert_eq!(SliderValue::Rational(RationalValue::new(1, 3).unwrap()).to_f64(), 1.0 / 3.0);
|
||||
assert_eq!(SliderValue::from_f64(ValueKind::Integer, 3.9), SliderValue::Integer(3));
|
||||
assert_eq!(
|
||||
SliderValue::from_f64(ValueKind::Rational, 0.5),
|
||||
SliderValue::Rational(RationalValue::new(1, 2).unwrap())
|
||||
);
|
||||
assert_eq!(SliderValue::from_f64(ValueKind::Angle, 90.0), SliderValue::Angle(90.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_formatter_round_trips() {
|
||||
let formatter = DefaultFormatter;
|
||||
for value in [
|
||||
SliderValue::Float(1.5),
|
||||
SliderValue::Integer(-42),
|
||||
SliderValue::Rational(RationalValue::new(1, 3).unwrap()),
|
||||
SliderValue::Angle(90.0),
|
||||
] {
|
||||
let text = formatter.format(value);
|
||||
let parsed = formatter.parse(text.as_ref()).unwrap();
|
||||
assert_eq!(parsed.kind(), value.kind());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_formatter_trims_and_truncates() {
|
||||
let formatter = DefaultFormatter;
|
||||
assert_eq!(formatter.format(SliderValue::Float(1.5)), "1.5");
|
||||
assert_eq!(formatter.format(SliderValue::Float(0.123456)), "0.123");
|
||||
assert_eq!(formatter.format(SliderValue::Float(2.0)), "2");
|
||||
assert_eq!(formatter.format(SliderValue::Angle(45.5)), "45.5°");
|
||||
assert_eq!(formatter.format(SliderValue::Integer(7)), "7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_formatter_rejects_invalid_input() {
|
||||
let formatter = DefaultFormatter;
|
||||
assert!(formatter.parse("abc").is_err());
|
||||
assert!(formatter.parse("1/0").is_err());
|
||||
assert!(formatter.parse("12/ab").is_err());
|
||||
assert!(formatter.parse("").is_err());
|
||||
assert!(formatter.parse("90°abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_formatter_parses_angle_suffix() {
|
||||
let formatter = DefaultFormatter;
|
||||
assert_eq!(formatter.parse("90°").unwrap(), SliderValue::Angle(90.0));
|
||||
assert_eq!(formatter.parse("45 deg").unwrap(), SliderValue::Angle(45.0));
|
||||
assert_eq!(formatter.parse("180 DEG").unwrap(), SliderValue::Angle(180.0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user