diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml index 649f7ae191..743ee95c38 100644 --- a/crates/gpui_widgets/Cargo.toml +++ b/crates/gpui_widgets/Cargo.toml @@ -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" diff --git a/crates/gpui_widgets/examples/controls.rs b/crates/gpui_widgets/examples/controls.rs new file mode 100644 index 0000000000..a43a3d3ac2 --- /dev/null +++ b/crates/gpui_widgets/examples/controls.rs @@ -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, + frame_rate: Entity, + shutter_angle: Entity, + iso: Entity, + format: Entity, + muted: Entity, + solo: Entity, + playback_mode: Entity, + color: Entity, + remap: Entity, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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) -> 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, 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(); + }); +} diff --git a/docs/zh/oak-app-rewrite.md b/docs/zh/oak-app-rewrite.md new file mode 100644 index 0000000000..b97622840e --- /dev/null +++ b/docs/zh/oak-app-rewrite.md @@ -0,0 +1,102 @@ +# Oak app/ 层 GPUI 重写计划(oak-gpui 侧工作项) + +> 面向实现者(DeepSeek)的任务书。主仓库:/Users/sunyu/Projects/oak +> (引擎 RIIR 进行中,模块边界是 `include//*.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)。