feat(gpui_widgets): add controls demo example and mark W1 complete

Adds a parameter-panel example (cargo run -p gpui_widgets --example controls)
that demonstrates every form control: float/rational/angle sliders with
keying diamonds, a spinbox, a combo box, checkboxes, a radio group, a color
picker and a curve editor, printing each edit as a request event. Marks all
W1 checklist items done in docs/zh/oak-app-rewrite.md. 65 tests pass.
This commit is contained in:
2026-08-09 05:24:10 +08:00
parent b2ed7a66e4
commit 4a92ab13d8
3 changed files with 387 additions and 0 deletions
+4
View File
@@ -19,3 +19,7 @@ thiserror.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] }
[[example]]
name = "controls"
path = "examples/controls.rs"
+281
View File
@@ -0,0 +1,281 @@
//! A parameter-panel demo of every form control in `gpui_widgets`:
//! sliders (float / rational / angle with keying diamonds), a spinbox, a
//! combo box, checkboxes, a radio group, a color picker and a curve editor.
//!
//! Every edit is printed to stdout as a *request* event - the host would
//! apply it through its engine instead.
use gpui::{
App, Bounds, Context, Entity, Render, Window, WindowBounds, WindowOptions,
colors::DefaultColors, div, prelude::*, px, size,
};
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
use gpui_widgets::color::{ColorPicker, ColorPickerEvent};
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
use gpui_widgets::curve_editor::{CurveEditor, CurveEditorEvent, CurvePoint, CurveVec2};
use gpui_widgets::keyable::KeyingState;
use gpui_widgets::radio_group::{RadioGroup, RadioGroupEvent, RadioOption};
use gpui_widgets::slider::{Slider, SliderEvent, SliderModel};
use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent};
use gpui_widgets::value::ValueKind;
struct Example {
exposure: Entity<Slider>,
frame_rate: Entity<Slider>,
shutter_angle: Entity<Slider>,
iso: Entity<SpinBox>,
format: Entity<ComboBox>,
muted: Entity<CheckBox>,
solo: Entity<CheckBox>,
playback_mode: Entity<RadioGroup>,
color: Entity<ColorPicker>,
remap: Entity<CurveEditor>,
}
impl Example {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
// Float slider with a key at the current frame.
let exposure = cx.new(|cx| {
Slider::new(
1,
SliderModel::new(ValueKind::Float, -5.0, 5.0, 0.1, 0.0),
window,
cx,
)
.with_keying(KeyingState::AtCurrentFrame)
});
cx.subscribe(
&exposure,
|_this: &mut Self, _s: Entity<Slider>, event: &SliderEvent, _cx| {
println!("exposure request: {event:?}");
},
)
.detach();
// Rational slider: 1/24 .. 24/24 in numerator steps.
let frame_rate = cx.new(|cx| {
Slider::new(
2,
SliderModel::new(ValueKind::Rational, 1.0, 24.0, 1.0, 24.0)
.with_rational_den(24),
window,
cx,
)
.with_keying(KeyingState::HasKey)
});
cx.subscribe(
&frame_rate,
|_this: &mut Self, _s: Entity<Slider>, event: &SliderEvent, _cx| {
println!("frame rate request: {event:?}");
},
)
.detach();
// Angle slider (degrees).
let shutter_angle = cx.new(|cx| {
Slider::new(
3,
SliderModel::new(ValueKind::Angle, 0.0, 360.0, 1.0, 180.0),
window,
cx,
)
.with_keying(KeyingState::NoKey)
});
cx.subscribe(
&shutter_angle,
|_this: &mut Self, _s: Entity<Slider>, event: &SliderEvent, _cx| {
println!("shutter request: {event:?}");
},
)
.detach();
let iso = cx.new(|cx| {
SpinBox::new(
4,
SliderModel::new(ValueKind::Integer, 100.0, 12800.0, 100.0, 800.0),
window,
cx,
)
});
cx.subscribe(
&iso,
|_this: &mut Self, _s: Entity<SpinBox>, event: &SpinBoxEvent, _cx| {
println!("iso request: {event:?}");
},
)
.detach();
let format = cx.new(|cx| {
ComboBox::new(
5,
vec![
ComboBoxOption::new(1, "Frame"),
ComboBoxOption::new(2, "Timecode"),
ComboBoxOption::new(3, "Frames"),
],
window,
cx,
)
.with_placeholder("Choose…")
});
cx.subscribe(
&format,
|_this: &mut Self, _s: Entity<ComboBox>, event: &ComboBoxEvent, _cx| {
println!("format request: {event:?}");
},
)
.detach();
let muted = cx.new(|cx| {
CheckBox::new(6, CheckState::Unchecked, window, cx).with_label("Mute")
});
cx.subscribe(
&muted,
|_this: &mut Self, _s: Entity<CheckBox>, event: &CheckBoxEvent, _cx| {
println!("mute request: {event:?}");
},
)
.detach();
let solo = cx.new(|cx| {
CheckBox::new(7, CheckState::Indeterminate, window, cx)
.with_label("Solo")
.with_tri_state(true)
});
cx.subscribe(
&solo,
|_this: &mut Self, _s: Entity<CheckBox>, event: &CheckBoxEvent, _cx| {
println!("solo request: {event:?}");
},
)
.detach();
let playback_mode = cx.new(|cx| {
RadioGroup::new(
8,
vec![
RadioOption::new(1, "Loop"),
RadioOption::new(2, "Ping-pong"),
RadioOption::new(3, "Once"),
],
window,
cx,
)
});
cx.subscribe(
&playback_mode,
|_this: &mut Self, _s: Entity<RadioGroup>, event: &RadioGroupEvent, _cx| {
println!("playback mode request: {event:?}");
},
)
.detach();
let color = cx.new(|cx| {
ColorPicker::new(9, gpui::Rgba { r: 1.0, g: 0.4, b: 0.1, a: 1.0 }, window, cx)
});
cx.subscribe(
&color,
|_this: &mut Self, _s: Entity<ColorPicker>, event: &ColorPickerEvent, _cx| {
println!("color request: {event:?}");
},
)
.detach();
let remap = cx.new(|cx| {
CurveEditor::new(
10,
vec![
CurvePoint::with_handles(0.0, 0.0, CurveVec2::new(0.0, 0.5)),
CurvePoint::with_handles(1.0, 1.0, CurveVec2::new(0.0, -0.5)),
],
window,
cx,
)
});
cx.subscribe(
&remap,
|_this: &mut Self, _s: Entity<CurveEditor>, event: &CurveEditorEvent, _cx| {
println!("remap request: {event:?}");
},
)
.detach();
Self {
exposure,
frame_rate,
shutter_angle,
iso,
format,
muted,
solo,
playback_mode,
color,
remap,
}
}
}
impl Render for Example {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div()
.size_full()
.bg(colors.background)
.flex()
.flex_col()
.p_4()
.gap_3()
.id("example-scroll")
.overflow_y_scroll()
.child(section("Exposure (float)", self.exposure.clone()))
.child(section("Frame rate (rational)", self.frame_rate.clone()))
.child(section("Shutter (angle)", self.shutter_angle.clone()))
.child(section("ISO (spinbox)", self.iso.clone()))
.child(section("Time format (combo)", self.format.clone()))
.child(section("Audio", self.muted.clone()))
.child(section("Track", self.solo.clone()))
.child(section("Playback mode", self.playback_mode.clone()))
.child(section("Accent color", self.color.clone()))
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(div().text_color(colors.text).child("Time remap (curve)"))
.child(self.remap.clone()),
)
}
}
/// A labeled row used by the demo panel.
fn section(label: impl Into<gpui::SharedString>, widget: impl IntoElement) -> impl IntoElement {
div()
.flex()
.items_center()
.gap_3()
.child(div().w(px(130.0)).child(label.into()))
.child(widget)
}
fn main() {
gpui_platform::application().run(|cx: &mut App| {
cx.init_colors();
let bounds = Bounds::centered(None, size(px(620.0), px(760.0)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| cx.new(|cx| Example::new(window, cx)),
)
.expect("Failed to open window");
cx.activate(true);
cx.on_window_closed(|cx, _| {
if cx.windows().is_empty() {
cx.quit();
}
})
.detach();
});
}
+102
View File
@@ -0,0 +1,102 @@
# Oak app/ 层 GPUI 重写计划(oak-gpui 侧工作项)
> 面向实现者(DeepSeek)的任务书。主仓库:/Users/sunyu/Projects/oak
> (引擎 RIIR 进行中,模块边界是 `include/<mod>/*.h` 纯 C ABI +
> 引用计数句柄)。本文件列出 oak-gpui 仓库里需要完成的工作项。
>
> 已实现(本仓库,2026-08-09 提交):`gpui::timeline` /
> `gpui::node_graph` / `gpui::effect_stack` / `gpui::dock` 四个
> NLE widget。
>
> 原则:widget 不直接改引擎状态,只发请求事件;引擎(oak C ABI)
> 是唯一事实源。crates.io 有成熟库就不自造。测试驱动:每个 widget
> 的状态机/几何计算必须有单测(参照 timeline/time.rs 的做法)。
## W1. 表单控件库(咽喉项,最先做)
位置:`crates/gpui_widgets/`(新 crate)。这是参数面板和全部对话框
的前置依赖。
对标 oak C++ 侧的 `app/widget/slider/``app/widget/nodeparamview/`
- [x] `Slider` 族:float / integer / rational(分式)/ 角度。
拖动改值(上下拖 + 微调修饰键)、双击直接输入、滚轮步进、
中键复位默认值。数值格式化与解析必须可注入。
- [x] `SpinBox`(数字输入 + 上下按钮)。
- [x] `ComboBox`(下拉选择;纯 gpui 弹层实现)。
- [x] `CheckBox` / `RadioGroup`
- [x] `ColorSwatchButton`(色块按钮 + 点击弹取色器);
`ColorPicker`HSV 轮 + RGBA 输入 + 吸管占位)。
- [x] `CurveEditor`(关键帧曲线编辑,供时间重映射等;canvas 绘制,
贝塞尔控制点拖拽)。
- [x] 键控支持:每个可键控控件右侧的关键帧菱形按钮(状态:
无键/有键/在当前帧),点击发请求事件。
- [x] 全部控件的状态逻辑(值域、步进、钳制、非法输入拒绝)有
单测;绘制走 gpui canvas/quad,不碰平台 API。
## W2. 菜单与对话框框架
位置:`crates/gpui_widgets/`(或独立 `gpui_dialogs`)。
- [ ] `ContextMenu`/`MenuBar` 窗口内菜单组件(Zed 的菜单在 zed app
crate 而非 gpui,需要自带):弹层定位、键盘导航、子菜单、勾选/
禁用态、快捷键展示。
- [ ] `Modal` 对话框框架:模态遮罩、标题栏、按钮行(确定/取消/
应用)、Esc/Enter 默认键、尺寸约束。
- [ ] 常用对话框原语:消息框(info/warning/error 三档)、文件选择
(包 `prompt_for_paths`/`prompt_for_new_path` 平台 API)、进度条
对话框(可取消)。
- [ ] 单测:菜单模型(勾选/禁用/级联)、对话框结果路由。
## W3. macOS 视频帧桥接(关键路径)
目标:引擎渲染结果零拷贝上屏。
- [ ] 引擎侧输出是 wgpu 纹理(Metal 后端)。在 `gpui_media` 或新
`oak_bridge` crate 里做 wgpu Metal 纹理 → IOSurface →
CVPixelBuffer 的包装(`CVMetalTextureCache` helper 已在
`gpui_media/src/media.rs`),输出给 `window.paint_surface`
- [ ] 保留 CPU 回读兜底路径(任何后端可用),但默认不走。
- [ ] 验收:1080p/4K F32 帧连续上屏无掉帧(写一个 demo example
循环显示测试图序列,测 FPS);CI 无 GPU 环境跳过。
- [ ] Windows/Linux 路径用 gpui_wgpu 的 `paint_surface(wgpu::Texture)`
直连,同 demo 验证。
## W4. 播放同步与检视器 glue
- [ ] `ViewerWidget`(新,放 `crates/gpui_widgets/` 或 oak 侧):
画面区(W3 的 surface)+ 走带控制(播放/暂停/逐帧/入点出点)+
时间码显示 + 安全框/缩放开关。播放驱动:oak audio 引擎时钟经
C ABI 查询,`cx.spawn` + timer 刷新播放头。
- [ ] 单测:时间码换算(复用 oakcore-rs Rational)、走带状态机。
## W5. 时间线工具模式层
- [ ] 在 oak 侧(不在本仓库)实现 14 个工具模式(ripple/roll/slip/
slide/razor/ 等)为 `gpui::timeline``TimelineEvent` 消费者 +
引擎命令映射。**本仓库侧配套**:`TimelineEvent` 覆盖不全的手势
(如 transition 拖拽、轨道选择)按需补事件。
- [ ] 素材箱(ProjectExplorer):树 + 图标双视图,文件拖入经
`FileDropEvent`,缩略图经 sprite atlas。
## W6. 示波器与音频表
- [ ] `Histogram` / `Vectorscope` / `Waveform` 检视组件(canvas
绘制,数据来自 oak render C ABI 的帧采样)。
- [ ] `AudioLevelMeter` 表头(数据来自 oak audio C ABI)。
## W7. 主题系统
- [ ] 设计系统:把 oak 的 olive-dark/olive-light QSS 翻译成 gpui
`Colors`/样式结构,支持运行期切换。
## 顺序与验收
1. W1 → W2(解锁参数面板与对话框)
2. W3(检视器能上屏)→ W4
3. W5/W6 并行
4. W7 随时
每个 W 的完成标准:cargo test 绿
+ 一个可运行的 example 演示。
widget 与 oak 引擎的联调在 oak 仓库侧做(本仓库只交付 widget)。