gpui-kit 可拖拽分栏布局指南:Resizable 面板组与拖拽手柄源码级剖析
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
导读:本指南以 gpui-kit 的
gpui-base原语文档为基础,系统讲解 Resizable 模块——一套用于构建用户可拖拽调整的分栏(split)布局的面板组与拖拽手柄原语。你将掌握h_resizable/v_resizable的声明式组合方式、ResizableState的状态管理模型、拖拽过程中的尺寸约束算法,以及如何将手柄外观接入主题体系。读完即可在你的 GPUI 桌面应用中复刻侧边栏、代码面板、工作区等常见可调布局。
一、Resizable 是什么
Resizable 是gpui-base提供的一组原语,用于构建用户可拖拽调整的分栏布局(user-adjustable split layouts)。与gpui-base的其他原语一致,Resizable只供应行为与语义结构,不施加任何产品视觉语言——面板与手柄的呈现完全交由 GPUI 标准样式(Styled、事件 trait)与消费方自己的设计系统决定。
它的职责边界非常清晰(见 crates/base/src/resizable/mod.rs):
- 面板尺寸存在哪里:所有面板的当前尺寸集中存放在
ResizableState中; - 拖拽由谁驱动:拖拽手柄(resize handle)负责捕获鼠标交互,并将位移换算成相邻面板的尺寸变化;
- 结构由谁提供:
ResizablePanelGroup提供 flex 容器与面板同步逻辑,ResizablePanel提供单个面板的布局约束。
一句话概括其设计哲学:GPUI 的标准样式与事件机制负责"看起来怎样、怎么响应",Resizable 类型负责"交互结构怎样组织"。
二、快速运行示例
原文档给出的运行命令,可以直接从仓库根目录启动原生可运行示例(WASM 预览与它共享同一份实现):
cargo run -p gpui-base-examples -- resizable命令背后的入口是 crates/base/examples/native/src/bin/components.rs:
#[path = "../../../showcase/mod.rs"] mod showcase; use std::sync::Arc; fn main() { let component = std::env::args() .nth(1) .unwrap_or_else(|| "overview".to_string()); let http_client = reqwest_client::ReqwestClient::user_agent("gpui-base/examples").unwrap(); let app = gpui_platform::application().with_http_client(Arc::new(http_client)); showcase::run(app, component); }命令行第一个参数resizable即被传给showcase::run,从共享的 showcase 实现中选出本原语。同一份 showcase 还会编译到 WASM 预览中,因此原生与浏览器两种运行方式看到的是完全相同的代码路径。
三、导入方式
原文档给出的导入路径(经由聚合 crate):
use gpui_kit::base::{ResizablePanel, ResizablePanelGroup, ResizableState, h_resizable, resizable_panel};而示例代码(crates/base/examples/showcase/components/resizable.rs)直接使用gpui_basecrate:
use gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{h_resizable, resizable_panel};两种方式导出的是同一组符号。gpui-base在 crates/base/src/lib.rs 中公开了完整的面板组 API:
#[doc(hidden)] pub use resizable::{PANEL_MIN_SIZE, resize_handle}; pub use resizable::{ ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, ResizeHandleContext, ResizeHandleRenderer, h_resizable, resizable_panel, v_resizable, };其中resize_handle被标记为#[doc(hidden)](内部实现细节,面板组会自动为每个面板装配),ResizeHandleRenderer/ResizeHandleContext则用于自定义手柄外观。
四、Anatomy:三个核心类型的分工
原文档指出示例由三个类型组合而成:ResizablePanel、ResizablePanelGroup、ResizableState。对应源码位于 crates/base/src/resizable/panel.rs 与 crates/base/src/resizable/mod.rs,三者关系如下:
| 类型 | 文件 | 职责 |
|---|---|---|
ResizablePanelGroup | panel.rs | flex 容器:持有面板列表、轴方向、可选外部状态实体、on_resize回调与手柄外观 |
ResizablePanel | panel.rs | 单个面板:初始尺寸、尺寸范围、可见性、子内容与样式覆盖 |
ResizableState | mod.rs | 全部面板尺寸的单一事实来源,执行拖拽时的尺寸重分配算法 |
4.1 组:h_resizable与v_resizable
ResizablePanelGroup通过轴方向(Axis)决定布局方向。原语提供了两个便捷构造函数(mod.rs):
/// Create a [`ResizablePanelGroup`] with horizontal resizing pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup { ResizablePanelGroup::new(id).axis(Axis::Horizontal) } /// Create a [`ResizablePanelGroup`] with vertical resizing pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup { ResizablePanelGroup::new(id).axis(Axis::Vertical) }注意:
id是必需的,面板组用它做稳定元素 ID(内部use_keyed_state依赖它,见下文"状态"一节);- 水平分组(
h_resizable)代表面板左右并排、分隔线可左右拖动;垂直分组(v_resizable)代表面板上下堆叠、分隔线可上下拖动; - 构造器默认为
Axis::Horizontal,也可直接ResizablePanelGroup::new(id).axis(...)自定义。
4.2 面板:resizable_panel()
/// Create a [`ResizablePanel`]. pub fn resizable_panel() -> ResizablePanel { ResizablePanel::new() }面板构造后通常通过.child(...)填充内容(ResizablePanel实现了ParentElement)。它自身实现了Styled,因此调用方可以覆盖面板的渲染样式。
4.3 一个最小结构
一个标准的双面板布局(侧边栏 + 内容区)长这样:
h_resizable("example-resizable") .child( resizable_panel() .size(px(124.)) .size_range(px(116.)..px(210.)) .child(sidebar_content), ) .child( resizable_panel() .child(workspace_content), )五、完整 Rust 示例(来自可运行 showcase)
showcase 中本原语的完整实现(crates/base/examples/showcase/components/resizable.rs)被原文档直接嵌入,它模拟了一个"导航栏 + 工作区"的经典场景,是理解 API 组合方式的最佳范本:
use gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{h_resizable, resizable_panel}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn resizable(&self) -> impl IntoElement { div() .w_72() .h_40() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .child( h_resizable("example-resizable") .child( resizable_panel() .size(px(124.)) .size_range(px(116.)..px(210.)) .child( div() .size_full() .flex() .items_center() .justify_center() .border_r_1() .border_color(super::example_rgb(0x171717)) .p_2() .items_start() .justify_start() .flex_col() .gap_1() .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child("PROJECT"), ) .children(["Overview", "Components", "Settings"].map( |label| { div() .w_full() .h(px(26.)) .px_2() .flex() .items_center() .whitespace_nowrap() .child(label) }, )), ), ) .child( resizable_panel().child( div() .size_full() .flex() .items_center() .justify_center() .bg(super::example_rgb(0xffffff)) .p_2() .items_start() .justify_start() .flex_col() .gap_2() .child(div().child("Workspace")) .child( div() .text_color(super::example_rgb(0x737373)) .child("Drag the divider to resize navigation."), ), ), ), ) } }值得注意的细节:
.size(px(124.)):左侧面板的初始尺寸;.size_range(px(116.)..px(210.)):限定拖拽时该面板可处于的尺寸区间(116px ~ 210px);h_resizable("example-resizable"):组需要一个稳定的元素 ID,用于内部状态键控;- 右侧面板未指定
size,走"自动/灵活"路径,占据剩余空间; div().size_full()保证每个面板的内容填满自身边界,便于观察拖拽效果。
命令cargo run -p gpui-base-examples -- resizable提供应用初始化、窗口创建与共享的BaseShowcase状态,上述代码即是其渲染函数的核心。
六、状态与事件:尺寸的单一事实来源
原文档反复强调一点:面板尺寸存于 resizable 状态中,拖拽手柄会按照面板最小值约束来更新相邻面板。这一点在源码中有完整的落点。
6.1ResizableState的内部结构
#[derive(Debug, Clone)] pub struct ResizableState { axis: Axis, // 与所在组的实际轴同步 panels: Vec<ResizablePanelState>, // 每面板的状态(size/size_range/bounds) sizes: Vec<Pixels>, // 每面板的当前尺寸 resizing_panel_ix: Option<usize>, // 当前正在拖拽的手柄(位于面板 ix 与其右侧之间) bounds: Bounds<Pixels>, // 组的边界(用于计算容器尺寸) }其中每个面板的状态ResizablePanelState还记录了:
size: Option<Pixels>—— 面板偏好尺寸,None表示由 flex 自动布局;size_range: Range<Pixels>—— 尺寸约束区间,默认PANEL_MIN_SIZE..Pixels::MAX;bounds—— 最近一次 prepaint 测量到的边界。
模块级默认最小尺寸(mod.rs):
#[doc(hidden)] pub const PANEL_MIN_SIZE: Pixels = px(100.);全局默认最小面板宽度/高度为 100px,面板未显式指定size_range时即以此为下限。
6.2 两种持有状态的方式
ResizablePanelGroup在RenderOnce::render中解析状态(panel.rs):
let state = self.state.unwrap_or( window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()), );- 内部状态(默认):不传
with_state时,组通过use_keyed_state以组的id为键自建状态,随渲染生命周期自动管理; - 外部受控状态:通过
.with_state(&entity)把调用方持有的Entity<ResizableState>绑定到组上(panel.rs):
/// Bind yourself to a resizable state entity. /// /// If not provided, it will handle its own state internally. pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self { self.state = Some(state.clone()); self }原文档对此给出的实践建议是:把受控状态放在父渲染类型或 GPUI entity 上,在回调中更新它并调用cx.notify();不要在每次渲染时重建持久实体。这正是 dock 等复杂容器采用的做法(ResizableState::adopt_sizes即专供 dock 的 pane 树采纳外部布局决策)。
6.3 状态提供的操作
ResizableState面向调用方公开了一组编程式操作(mod.rs),全部走与拖拽相同的重分配逻辑,因此程序化调整与用户拖拽行为完全一致:
| 方法 | 行为 |
|---|---|
sizes() | 返回各面板当前尺寸快照(&Vec<Pixels>) |
resize_panel(ix, size, ...) | 将面板ix调整为size,超出size_range时自动夹紧;最后一个面板没有自己的手柄,通过调整其前一个兄弟面板间接改变尺寸(L69-L89) |
insert_panel(size, ix, ...) | 在ix处插入面板(缺省追加),并按比例压缩其余面板,保证总和仍等于容器尺寸(L95-L128) |
remove_panel(ix, ...) | 移除面板并重分配剩余空间(L221-L230) |
reset_panel(ix, ...) | 重置面板状态但保留当前尺寸(L233-L239) |
clear() | 清空全部面板状态(L242-L245) |
其中resize_panel的边界情况值得一提:对于最后一个面板,源码通过"先改前一个兄弟、让释放的空间落到最后一个"的方式驱动(L79-L87):
if ix + 1 < self.sizes.len() { self.resize_panel_at_handle(ix, size, window, cx); } else if ix > 0 { // Last panel: drive its size by resizing the previous sibling so // the freed space lands here. let delta = self.sizes[ix] - size; let prev = self.sizes[ix - 1]; self.resize_panel_at_handle(ix - 1, prev + delta, window, cx); }6.4 事件:Resized与on_resize
状态实现EventEmitter<ResizablePanelEvent>(mod.rs),事件定义在 panel.rs:
pub enum ResizablePanelEvent { Resized, }- 拖拽结束(鼠标抬起)时,
done_resizing清除resizing_panel_ix并发出Resized事件(mod.rs),偏好持久化等订阅方可以借此感知"用户刚完成一次拖拽"; - 组层面还提供
on_resize回调(panel.rs),参数为(&Entity<ResizableState>, &mut Window, &mut App),同样在鼠标抬起时触发(见 panel.rs 的MouseUpEvent处理)。
测试 dragging_the_handle_resizes_and_emits_once 验证了"一次完整拖拽只触发一次 resize 回调"这一语义:模拟鼠标按下、移动、抬起后断言resizes.get() == 1。
七、面板组与面板的 API 详解
7.1 组的可配置项
| 方法 | 说明 |
|---|---|
axis(Axis) | 布局轴,默认Horizontal |
child(panel)/children(panels) | 添加一个或多个面板 |
size(px) | 设置组的交叉轴尺寸:水平分组时它是组的高度,垂直分组时它是组的宽度(L97-L104)。组的自身轴尺寸始终为size_full(),由容器决定 |
with_state(&Entity<ResizableState>) | 绑定外部受控状态 |
with_handle_appearance(renderer) | 为组内所有手柄指定绘制器(见第八节) |
on_resize(callback) | 拖拽结束回调 |
测试 a_group_size_binds_the_cross_axis 验证了size()的交叉轴语义:对水平分组调用.size(px(40.))后,面板测量结果为 400px 宽 × 40px 高。
7.2 面板的可配置项
| 方法 | 说明 |
|---|---|
size(px) | 初始尺寸;未指定时面板走 flex 自动布局 |
size_range(range) | 尺寸约束区间,默认px(100.)..Pixels::MAX |
visible(bool) | 面板可见性,默认true;不可见时渲染为空div(L301-L305) |
Styled覆盖 | 面板内部默认flex_grow: 1,调用方可通过.flex_none()等取消,并自由添加 padding/颜色/边框 |
7.3 保留样式:不要从外部调用的 API
面板的尺寸管理依赖一组内部样式,调用方不应覆盖,否则会与面板自身的布局管理冲突(panel.rs 的文档注释明确列出):
.flex_basis(...)—— 由ResizableState驱动,不由调用方决定;.absolute()—— 会把面板从 resizable 的 flex 流中移除;.overflow_hidden()—— 可能裁掉拖拽手柄(手柄以left: -4px绝对定位在每个非首面板的左缘)。
一个常见且推荐的覆盖是.flex_none():面板内部无条件设置flex_grow: 1,因此当兄弟面板收缩时,一个指定了尺寸的面板若想保持自身宽度,就必须通过.flex_none()退出增长。源码文档注释给出了典型的三栏用法(panel.rs):
h_resizable("layout") .child(resizable_panel().size(px(220.)).flex_none().child(sidebar)) .child(resizable_panel().child(content)) // flex .child(resizable_panel().size(px(280.)).flex_none().child(metadata))7.4 面板的尺寸计算顺序
ResizablePanel::render中的样式组装顺序(panel.rs)清晰展示了三种情况:
initial_size为None→ 自动尺寸:flex_grow_1+flex_shrink_1,由 flex 布局分摊空间;initial_size为Some且状态中尺寸为None(首次渲染)→flex_none+flex_basis(initial_size),按初始尺寸呈现;- 状态中已有
Some(size)→flex_basis(size.clamp(range.start, range.end)),完全由状态驱动。
每次 prepaint 时,update_panel_size会把测量到的真实边界与尺寸范围回写进状态(mod.rs),其中有个细节:当某面板尺寸仍等于PANEL_MIN_SIZE(即"尚未被拖过"的新面板)时,会直接采用测量尺寸,避免首帧自行动作。
八、拖拽手柄:命中区、光标与外观
手柄完全由面板组自动装配:每个非首面板的左缘(水平)或上缘(垂直)都会自动挂载一个resize_handle(panel.rs)。
8.1 命中区与光标
手柄的几何参数定义在 resize_handle.rs:
pub(crate) const HANDLE_PADDING: Pixels = px(4.); pub(crate) const HANDLE_SIZE: Pixels = px(1.);- 可见线宽只有1px;
- 但手柄盒在轴线两侧各向外扩展4px的 padding,形成9px 宽的命中区(
left: -4px绝对定位),便于鼠标抓取; - 水平分组使用
cursor_col_resize(左右拉伸光标),垂直分组使用cursor_row_resize(上下拉伸光标); - 手柄实现了
group("handle"),内置线条在 hover 时保持可辨识。
8.2 拖拽的底层算法:resize_panel_at_handle
真正执行尺寸重分配的是 resize_panel_at_handle(鼠标移动事件与编程式resize_panel共用)。核心逻辑:
- 计算期望位移
move_changed = 目标尺寸 - 当前尺寸; - 将目标尺寸按面板
size_range夹紧; - 展开(变大)时:多余空间按顺序从右侧兄弟面板"扣除",每个兄弟最多扣到自身
size_range.start(最小值),扣不完则继续向更右侧面板借; - 收缩(变小)时:被压缩的空间从左侧兄弟面板补还;
- 若总尺寸超出容器,则对主面板继续夹紧,保证任何时刻所有面板总和不超过容器。
鼠标移动事件在ResizePanelGroupElement中注册(panel.rs),水平分组下用鼠标位置.x - 面板左缘计算目标宽度,垂直分组对应使用 y 坐标;鼠标抬起时结束拖拽并触发on_resize。
这正印证了原文档的描述:"dragging handles updates adjacent panels subject to minimums"——拖拽更新相邻面板,且始终受最小值约束。
九、手柄主题化:ResizeHandleRenderer与ResizableTheme
9.1 用with_handle_appearance接管手柄绘制
组的with_handle_appearance(panel.rs)接受一个ResizeHandleRenderer,其签名(resize_handle.rs):
pub type ResizeHandleRenderer = Rc<dyn Fn(&ResizeHandleContext, &mut Window, &mut App) -> Option<AnyElement>>;- 命中区、光标与拖拽行为始终留在手柄本体,渲染器只负责"手柄内部画什么";
- 渲染器返回
None时回落到内置 1px 线条——因此可以只覆盖部分手柄,其余保留默认(源码注释明确:"a renderer that declines — or is absent — leaves the built-in line"); ResizeHandleContext暴露axis()(拖拽轴)与is_active()(当前是否正在被拖拽),供渲染器区分状态。
9.2 内置线条的颜色解析:handle_color
未自定义外观时,内置线条的颜色由handle_color决定(resize_handle.rs):
pub(crate) fn handle_color(theme: &crate::Theme, active: bool) -> gpui::Hsla { if active { theme .resizable .active_handle .unwrap_or(theme.tokens.colors.ring) } else { theme.resizable.handle.unwrap_or(theme.tokens.colors.border) } }即:优先使用主题中投影到resizable上的颜色;未投影时回落到全局语义 token——静止态用border,拖拽激活态用ring。这解决了历史问题:此前未投影时默认是透明色,导致没有样式覆盖的消费方"看不到分隔线"(相关回归测试见 resize_handle.rs 测试,且专门断言了默认色不再透明)。
对应的主题字段定义在 crates/base/src/theme.rs:
#[derive(Clone, Copy, Default)] pub struct ResizableTheme { pub handle: Option<gpui::Hsla>, pub active_handle: Option<gpui::Hsla>, }十、源码测试印证的行为契约
ResizableState的测试模块(mod.rs 测试)是本原语行为契约最权威的说明,可重点参考:
- dragging_the_handle_resizes_and_emits_once:模拟在
400px容器中把 150px/250px 的面板拖到 220px,断言最终尺寸为 220px/180px 且on_resize恰好触发一次; - group_measures_panels_and_programmatic_resize_uses_drag_rules:编程式
resize_panel(0, px(220.))与拖拽走同一套规则,得到相同结果; - dynamic_panel_lifecycle_is_owned_by_resizable_state:验证
insert_panel后 200px/200px 均分、remove_panel后回到 400px、clear后清空; - a_group_size_binds_the_cross_axis:验证组
size()只约束交叉轴; - mixed_sizing_is_stable_between_resize_and_followup_frame与caller_owned_state_settles_on_the_same_frame:验证"固定尺寸面板 + 灵活面板"混排时,容器变化后的比例缩放在下一帧不会再次移动分隔线(内部通过
window.defer延迟通知让落定帧立即调度,见 panel.rs 的注释)。
十一、可访问性与使用注意事项
原文档在 Accessibility 与 Notes 两节给出了几条对消费方设计的硬性要求,结合源码可进一步明确落点:
- 为手柄提供键盘替代方案:手柄本身只有鼠标命中区与光标(
cursor_col_resize/cursor_row_resize),没有内置键盘交互。消费方需要自行提供键盘可达的调节手段——ResizableState::resize_panel正是为此准备的编程式入口,且会照常发出Resized事件,行为与用户拖拽完全一致; - 保留可用的最小面板尺寸:拖拽算法对每个面板按
size_range下限夹紧(默认PANEL_MIN_SIZE = px(100.)),消费方应确保自己设置的下限对内容依然可用; - 使用稳定的元素 ID:
h_resizable/v_resizable的id是内部use_keyed_state的键。保持 ID 稳定可避免状态在重渲染间丢失;在需要跨帧稳定的场景(如 dock)应使用外部with_state实体,而不是在每次渲染中重建; - 在消费方设计系统中核验各状态外观:hover、active(
ResizeHandleContext::is_active)、以及 reduced-motion、high-contrast 等系统偏好下的呈现,属于消费方设计系统的责任,原语不做强制。
十二、总结
gpui-kit 的 Resizable 原语是一套"行为完备、外观可塑"的分栏布局基础设施:
- 声明式组合:
h_resizable/v_resizable+resizable_panel()用几行代码即可搭出可拖拽面板组; - 状态单一来源:
ResizableState集中管理尺寸,拖拽与编程式调整共享同一套resize_panel_at_handle约束算法(最小值夹紧、相邻面板重分配、容器不溢出); - 外观完全可定制:面板样式可经
Styled覆盖,手柄绘制可经ResizeHandleRenderer接管,主题色经ResizableTheme投影并回落border/ringtoken; - 契约有测试背书:从拖拽事件触发次数、混排稳定性到生命周期管理,行为均有
#[gpui::test]佐证,可在 crates/base/src/resizable/mod.rs 与 crates/base/src/resizable/panel.rs 中直接查阅。
无论你要实现的是编辑器侧边栏、属性面板,还是 dock 式多窗格工作区,这套原语都能在提供完整交互语义的同时,把视觉呈现的最终决定权留给你的设计系统。
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考