From df7c3d274f16aaaee6603f9d8b17e789dfc36939 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 9 Aug 2026 04:20:48 +0800 Subject: [PATCH] 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. --- Cargo.lock | 11 + Cargo.toml | 1 + crates/gpui_widgets/Cargo.toml | 21 ++ crates/gpui_widgets/src/keyable.rs | 164 ++++++++++++ crates/gpui_widgets/src/lib.rs | 21 ++ crates/gpui_widgets/src/value.rs | 398 +++++++++++++++++++++++++++++ 6 files changed, 616 insertions(+) create mode 100644 crates/gpui_widgets/Cargo.toml create mode 100644 crates/gpui_widgets/src/keyable.rs create mode 100644 crates/gpui_widgets/src/lib.rs create mode 100644 crates/gpui_widgets/src/value.rs diff --git a/Cargo.lock b/Cargo.lock index 93c3893a8e..a18d2ab504 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 0e9ade530a..d5d1596ba3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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/"] diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml new file mode 100644 index 0000000000..649f7ae191 --- /dev/null +++ b/crates/gpui_widgets/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gpui_widgets" +version = "0.1.0" +edition.workspace = true +authors = ["Oak "] +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"] } diff --git a/crates/gpui_widgets/src/keyable.rs b/crates/gpui_widgets/src/keyable.rs new file mode 100644 index 0000000000..6ed55f95a9 --- /dev/null +++ b/crates/gpui_widgets/src/keyable.rs @@ -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() + .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, 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) + ); + } +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs new file mode 100644 index 0000000000..b4aab935a3 --- /dev/null +++ b/crates/gpui_widgets/src/lib.rs @@ -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; diff --git a/crates/gpui_widgets/src/value.rs b/crates/gpui_widgets/src/value.rs new file mode 100644 index 0000000000..8efe84e284 --- /dev/null +++ b/crates/gpui_widgets/src/value.rs @@ -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 { + 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; +} + +/// 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 { + 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)); + } +}