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:
2026-08-09 04:20:48 +08:00
parent ad7c965c5a
commit df7c3d274f
6 changed files with 616 additions and 0 deletions
Generated
+11
View File
@@ -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"
+1
View File
@@ -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/"]
+21
View File
@@ -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"] }
+164
View File
@@ -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)
);
}
}
+21
View File
@@ -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;
+398
View File
@@ -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));
}
}