feat: add resizable component
This commit is contained in:
@@ -17,9 +17,10 @@ gpui = { workspace = true, features = ["test-support"] }
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["full"]
|
default = ["full"]
|
||||||
full = ["dock", "resize_handle", "shell", "window_controls"]
|
full = ["dock", "resize_handle", "resizable", "shell", "window_controls"]
|
||||||
dock = [
|
dock = [
|
||||||
"resize_handle",
|
"resize_handle",
|
||||||
|
"resizable",
|
||||||
"dep:gpui-native-core",
|
"dep:gpui-native-core",
|
||||||
"dep:gpui-native-data",
|
"dep:gpui-native-data",
|
||||||
"dep:gpui-native-icons",
|
"dep:gpui-native-icons",
|
||||||
@@ -28,6 +29,7 @@ dock = [
|
|||||||
"window_controls",
|
"window_controls",
|
||||||
]
|
]
|
||||||
resize_handle = []
|
resize_handle = []
|
||||||
|
resizable = ["resize_handle"]
|
||||||
shell = [
|
shell = [
|
||||||
"resize_handle",
|
"resize_handle",
|
||||||
"window_controls",
|
"window_controls",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ pub mod dock;
|
|||||||
#[cfg(feature = "resize_handle")]
|
#[cfg(feature = "resize_handle")]
|
||||||
pub mod resize_handle;
|
pub mod resize_handle;
|
||||||
|
|
||||||
|
#[cfg(feature = "resizable")]
|
||||||
|
pub mod resizable;
|
||||||
|
|
||||||
#[cfg(feature = "shell")]
|
#[cfg(feature = "shell")]
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
//! The resizable group: stacked panes with drag dividers, outside the dock.
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext as _, Axis, Bounds, Context, ElementId, Entity,
|
||||||
|
InteractiveElement as _, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString,
|
||||||
|
StyleRefinement, Styled, Window, canvas, div, px,
|
||||||
|
};
|
||||||
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
|
use gpui_native_utils::StyledExt as _;
|
||||||
|
|
||||||
|
use crate::resize_handle::ResizeHandle;
|
||||||
|
|
||||||
|
/// The size a pane starts at before the group has measured its extent.
|
||||||
|
const DEFAULT_PANE_SIZE: f32 = 200.0;
|
||||||
|
/// The floor a pane keeps while dragging unless the caller lowers it.
|
||||||
|
const DEFAULT_PANE_MIN: f32 = 48.0;
|
||||||
|
|
||||||
|
type DebugSelector = std::rc::Rc<dyn Fn() -> String>;
|
||||||
|
|
||||||
|
/// The retained pane sizes of one resizable group.
|
||||||
|
pub struct ResizableState {
|
||||||
|
/// Sizes of every pane except the remainder pane, which fills what's left.
|
||||||
|
sizes: Vec<Pixels>,
|
||||||
|
/// The group's main-axis extent, captured at paint for drag clamping.
|
||||||
|
measured: Option<Pixels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResizableState {
|
||||||
|
/// Creates state whose panes start at the default size.
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
cx.new(|_| Self {
|
||||||
|
sizes: Vec::new(),
|
||||||
|
measured: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored size of each fixed pane; the remainder pane isn't listed.
|
||||||
|
pub fn sizes(&self) -> &[Pixels] {
|
||||||
|
&self.sizes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores pane sizes, e.g. from a persisted layout.
|
||||||
|
pub fn set_sizes<I>(&mut self, sizes: I, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = Pixels>,
|
||||||
|
{
|
||||||
|
self.sizes = sizes.into_iter().collect();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the group's measured extent, notifying only when it changes.
|
||||||
|
fn set_measured(&mut self, extent: Pixels, cx: &mut Context<Self>) {
|
||||||
|
if self.measured != Some(extent) {
|
||||||
|
self.measured = Some(extent);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The equal split of the measured extent.
|
||||||
|
fn equal_split(&self, pane_count: usize, thickness: Pixels) -> Pixels {
|
||||||
|
match self.measured {
|
||||||
|
Some(extent) => (extent - thickness * (pane_count as f32 - 1.0)) / pane_count as f32,
|
||||||
|
None => px(DEFAULT_PANE_SIZE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size pane `ix` renders at: its stored size, or the equal split.
|
||||||
|
fn pane_size(&self, ix: usize, pane_count: usize, thickness: Pixels) -> Pixels {
|
||||||
|
self.sizes
|
||||||
|
.get(ix)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_else(|| self.equal_split(pane_count, thickness))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a drag at divider `ix` (between pane ix and ix+1).
|
||||||
|
fn resize(
|
||||||
|
&mut self,
|
||||||
|
ix: usize,
|
||||||
|
delta: Pixels,
|
||||||
|
mins: &[Pixels],
|
||||||
|
thickness: Pixels,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Some(&floor) = mins.get(ix) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pane_count = mins.len();
|
||||||
|
|
||||||
|
let mut size = self.pane_size(ix, pane_count, thickness) + delta;
|
||||||
|
size = size.max(floor);
|
||||||
|
|
||||||
|
if let Some(extent) = self.measured {
|
||||||
|
let floors = mins
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(j, _)| *j != ix)
|
||||||
|
.fold(px(0.), |sum, (_, min)| sum + *min);
|
||||||
|
let ceiling = extent - thickness * (pane_count as f32 - 1.0) - floors;
|
||||||
|
size = size.min(ceiling);
|
||||||
|
}
|
||||||
|
|
||||||
|
while self.sizes.len() <= ix {
|
||||||
|
self.sizes.push(px(DEFAULT_PANE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sizes[ix] = size;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores pane `ix` to the equal split, as a double-click does.
|
||||||
|
fn reset_pane(
|
||||||
|
&mut self,
|
||||||
|
ix: usize,
|
||||||
|
pane_count: usize,
|
||||||
|
thickness: Pixels,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let split = self.equal_split(pane_count, thickness);
|
||||||
|
while self.sizes.len() <= ix {
|
||||||
|
self.sizes.push(px(DEFAULT_PANE_SIZE));
|
||||||
|
}
|
||||||
|
self.sizes[ix] = split;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pane of a resizable group: its content and its drag floor.
|
||||||
|
pub struct ResizablePane {
|
||||||
|
content: AnyElement,
|
||||||
|
min: Pixels,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResizablePane {
|
||||||
|
/// The floor a pane keeps while dragging unless the caller lowers it.
|
||||||
|
pub const DEFAULT_MIN: Pixels = px(DEFAULT_PANE_MIN);
|
||||||
|
|
||||||
|
/// Wraps this element as a pane.
|
||||||
|
pub fn new<E>(content: E) -> Self
|
||||||
|
where
|
||||||
|
E: IntoElement,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
content: content.into_any_element(),
|
||||||
|
min: Self::DEFAULT_MIN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the floor this pane keeps while dragging.
|
||||||
|
pub fn min(mut self, min: Pixels) -> Self {
|
||||||
|
self.min = min;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group of panes stacked along one axis, separated by drag dividers.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct Resizable {
|
||||||
|
id: ElementId,
|
||||||
|
state: Entity<ResizableState>,
|
||||||
|
axis: Axis,
|
||||||
|
panes: Vec<ResizablePane>,
|
||||||
|
debug_selector: Option<DebugSelector>,
|
||||||
|
style: StyleRefinement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Resizable {
|
||||||
|
/// Creates a group stacking its panes vertically, bound to `state`.
|
||||||
|
pub fn new<I>(id: I, state: Entity<ResizableState>) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
state,
|
||||||
|
axis: Axis::Vertical,
|
||||||
|
panes: Vec::new(),
|
||||||
|
debug_selector: None,
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the axis the panes stack along.
|
||||||
|
pub fn axis(mut self, axis: Axis) -> Self {
|
||||||
|
self.axis = axis;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a pane with the default drag floor.
|
||||||
|
pub fn pane<E>(mut self, content: E) -> Self
|
||||||
|
where
|
||||||
|
E: IntoElement,
|
||||||
|
{
|
||||||
|
self.panes.push(ResizablePane::new(content));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a pane that can't be dragged below `min`.
|
||||||
|
pub fn pane_with_min<E>(mut self, content: E, min: Pixels) -> Self
|
||||||
|
where
|
||||||
|
E: IntoElement,
|
||||||
|
{
|
||||||
|
self.panes.push(ResizablePane::new(content).min(min));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the debug-bounds selector of each pane, suffixed with its index.
|
||||||
|
pub fn debug_selector<F>(mut self, selector: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn() -> String + 'static,
|
||||||
|
{
|
||||||
|
self.debug_selector = Some(std::rc::Rc::new(selector));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for Resizable {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Resizable {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let thickness = cx.theme().sizes.resolve(Size::default()).dense_padding_x;
|
||||||
|
let axis = self.axis;
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
let pane_count = self.panes.len();
|
||||||
|
let mins: Vec<Pixels> = self.panes.iter().map(|pane| pane.min).collect();
|
||||||
|
let debug_selector = self.debug_selector;
|
||||||
|
|
||||||
|
let sizes: Vec<Pixels> = (0..pane_count)
|
||||||
|
.map(|ix| state.read(cx).pane_size(ix, pane_count, thickness))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut group = div()
|
||||||
|
.flex()
|
||||||
|
.overflow_hidden()
|
||||||
|
.when(axis == Axis::Vertical, |group| group.flex_col())
|
||||||
|
.when(axis == Axis::Horizontal, |group| group.flex_row())
|
||||||
|
.refine_style(&self.style);
|
||||||
|
|
||||||
|
for (ix, pane) in self.panes.into_iter().enumerate() {
|
||||||
|
let remainder = ix + 1 == pane_count;
|
||||||
|
let content = pane.content;
|
||||||
|
let mut pane_element = if remainder {
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.overflow_hidden()
|
||||||
|
.when(axis == Axis::Vertical, |p| p.min_h(pane.min))
|
||||||
|
.when(axis == Axis::Horizontal, |p| p.min_w(pane.min))
|
||||||
|
} else {
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.overflow_hidden()
|
||||||
|
.when(axis == Axis::Vertical, |p| p.h(sizes[ix]).w_full())
|
||||||
|
.when(axis == Axis::Horizontal, |p| p.w(sizes[ix]).h_full())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(selector) = &debug_selector {
|
||||||
|
let prefix = selector();
|
||||||
|
pane_element = pane_element.debug_selector(move || format!("{prefix}-{ix}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
group = group.child(pane_element.child(content));
|
||||||
|
|
||||||
|
if !remainder {
|
||||||
|
let divider =
|
||||||
|
ResizeHandle::new(SharedString::from(format!("{}-divider-{ix}", self.id)))
|
||||||
|
.axis(axis)
|
||||||
|
.on_resize({
|
||||||
|
let state = state.clone();
|
||||||
|
let mins = mins.clone();
|
||||||
|
move |delta, _, cx| {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.resize(ix, delta, &mins, thickness, cx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_reset({
|
||||||
|
let state = state.clone();
|
||||||
|
let mins = mins.clone();
|
||||||
|
move |_, cx| {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.reset_pane(ix, mins.len(), thickness, cx)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group = group.child(divider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pane_count > 0 {
|
||||||
|
group = group.child(
|
||||||
|
canvas(
|
||||||
|
{
|
||||||
|
let state = state.clone();
|
||||||
|
move |bounds: Bounds<Pixels>, _, cx: &mut App| {
|
||||||
|
let extent = match axis {
|
||||||
|
Axis::Vertical => bounds.size.height,
|
||||||
|
Axis::Horizontal => bounds.size.width,
|
||||||
|
};
|
||||||
|
state.update(cx, |state, cx| state.set_measured(extent, cx));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|_, _, _, _| {},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use gpui::{
|
||||||
|
AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent,
|
||||||
|
MouseMoveEvent, ParentElement as _, Pixels, Render, Styled as _, Subscription, TestAppContext,
|
||||||
|
VisualTestContext, Window, div, point, px,
|
||||||
|
};
|
||||||
|
use gpui_native_layout::resizable::{Resizable, ResizablePane, ResizableState};
|
||||||
|
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||||
|
|
||||||
|
const GROUP_WIDTH: f32 = 200.0;
|
||||||
|
const GROUP_HEIGHT: f32 = 300.0;
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
state: Entity<ResizableState>,
|
||||||
|
_observation: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Harness {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().w(px(GROUP_WIDTH)).h(px(GROUP_HEIGHT)).child(
|
||||||
|
Resizable::new("test-split", self.state.clone())
|
||||||
|
.debug_selector(|| "test-pane".to_string())
|
||||||
|
.size_full()
|
||||||
|
.pane(div())
|
||||||
|
.pane(div()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_window(sizes: [Pixels; 1]) -> (TestAppContext, Entity<ResizableState>, VisualTestContext) {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let state = cx.update(ResizableState::new);
|
||||||
|
state.update(&mut cx, |state, cx| state.set_sizes(sizes, cx));
|
||||||
|
let window_state = state.clone();
|
||||||
|
let window = cx.add_window(move |_, cx: &mut Context<Harness>| {
|
||||||
|
let observation = cx.observe(&window_state, |_, _, cx| cx.notify());
|
||||||
|
Harness {
|
||||||
|
state: window_state,
|
||||||
|
_observation: observation,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let window = window.into();
|
||||||
|
cx.update_window(window, |_, window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let visual = VisualTestContext::from_window(window, &cx);
|
||||||
|
(cx, state, visual)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn divider_center(visual: &mut VisualTestContext) -> gpui::Point<gpui::Pixels> {
|
||||||
|
let bounds = visual
|
||||||
|
.debug_bounds("resize-handle")
|
||||||
|
.expect("divider rendered");
|
||||||
|
point(bounds.center().x, bounds.center().y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drag_divider(visual: &mut VisualTestContext, delta: gpui::Pixels) {
|
||||||
|
let start = divider_center(visual);
|
||||||
|
visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(start.x, start.y + delta),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drag_moves_the_pane_and_its_rendered_height() {
|
||||||
|
let (mut cx, state, mut visual) = split_window([px(100.)]);
|
||||||
|
drag_divider(&mut visual, px(60.));
|
||||||
|
|
||||||
|
state.update(&mut cx, |state, _| assert_eq!(state.sizes(), &[px(160.)]));
|
||||||
|
|
||||||
|
// The pane re-renders at the new size after the state notifies the owner.
|
||||||
|
visual.update(|window, cx| window.draw(cx).clear(cx));
|
||||||
|
let bounds = visual
|
||||||
|
.debug_bounds("test-pane-0")
|
||||||
|
.expect("first pane rendered");
|
||||||
|
assert_eq!(bounds.size.height, px(160.));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drag_stops_where_the_remainder_keeps_its_floor() {
|
||||||
|
let (mut cx, state, mut visual) = split_window([px(100.)]);
|
||||||
|
let thickness = cx.update(|cx| cx.theme().sizes.resolve(Size::default()).dense_padding_x);
|
||||||
|
let floor = ResizablePane::DEFAULT_MIN;
|
||||||
|
|
||||||
|
drag_divider(&mut visual, px(1000.));
|
||||||
|
|
||||||
|
state.update(&mut cx, |state, _| {
|
||||||
|
assert_eq!(state.sizes(), &[px(GROUP_HEIGHT) - thickness - floor]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_double_click_resets_the_pane_to_the_equal_split() {
|
||||||
|
let (mut cx, state, mut visual) = split_window([px(240.)]);
|
||||||
|
let thickness = cx.update(|cx| cx.theme().sizes.resolve(Size::default()).dense_padding_x);
|
||||||
|
let start = divider_center(&mut visual);
|
||||||
|
|
||||||
|
visual.simulate_event(MouseDownEvent {
|
||||||
|
position: start,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
button: MouseButton::Left,
|
||||||
|
click_count: 2,
|
||||||
|
first_mouse: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.update(&mut cx, |state, _| {
|
||||||
|
assert_eq!(
|
||||||
|
state.sizes(),
|
||||||
|
&[px((GROUP_HEIGHT - thickness.as_f32()) / 2.)]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_keys_resize_the_focused_pane() {
|
||||||
|
let (mut cx, state, mut visual) = split_window([px(100.)]);
|
||||||
|
let start = divider_center(&mut visual);
|
||||||
|
|
||||||
|
// A single click focuses the handle without resetting anything.
|
||||||
|
visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_keystrokes("down");
|
||||||
|
visual.simulate_keystrokes("up");
|
||||||
|
|
||||||
|
state.update(&mut cx, |state, _| assert_eq!(state.sizes(), &[px(100.)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unfocused_handle_ignores_arrow_keys() {
|
||||||
|
let (mut cx, state, mut visual) = split_window([px(100.)]);
|
||||||
|
let start = divider_center(&mut visual);
|
||||||
|
let _ = start;
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("down");
|
||||||
|
|
||||||
|
state.update(&mut cx, |state, _| assert_eq!(state.sizes(), &[px(100.)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_panes_and_the_divider_fill_the_group_exactly() {
|
||||||
|
let (_cx, _state, mut visual) = split_window([px(100.)]);
|
||||||
|
visual.update(|window, cx| window.draw(cx).clear(cx));
|
||||||
|
let first = visual
|
||||||
|
.debug_bounds("test-pane-0")
|
||||||
|
.expect("first pane rendered");
|
||||||
|
let second = visual
|
||||||
|
.debug_bounds("test-pane-1")
|
||||||
|
.expect("remainder pane rendered");
|
||||||
|
let divider = visual
|
||||||
|
.debug_bounds("resize-handle")
|
||||||
|
.expect("divider rendered");
|
||||||
|
assert_eq!(
|
||||||
|
first.size.height + divider.size.height + second.size.height,
|
||||||
|
px(GROUP_HEIGHT)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
second.size.height,
|
||||||
|
px(GROUP_HEIGHT) - first.size.height - divider.size.height
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -19,8 +19,6 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
|||||||
- [ ] **Editable code field** — syntax-highlighted editable multiline field,
|
- [ ] **Editable code field** — syntax-highlighted editable multiline field,
|
||||||
the code view's highlighter over the input's editing model; signed's
|
the code view's highlighter over the input's editing model; signed's
|
||||||
repo file browser.
|
repo file browser.
|
||||||
- [ ] **Resizable group** — stacked arbitrary panes with drag dividers
|
|
||||||
outside the dock; signed's commit-diff split.
|
|
||||||
- [ ] **Searchable list filter** — searchable collection behind combobox and
|
- [ ] **Searchable list filter** — searchable collection behind combobox and
|
||||||
list option sources; the combobox's pluggable filter covers part of it
|
list option sources; the combobox's pluggable filter covers part of it
|
||||||
(signed's `SearchableVec`).
|
(signed's `SearchableVec`).
|
||||||
@@ -112,6 +110,8 @@ Gaps surfaced by the signed comparison; ordered by signed's usage.
|
|||||||
- [x] Titlebar + window controls — app-owned window dragging, per-platform
|
- [x] Titlebar + window controls — app-owned window dragging, per-platform
|
||||||
buttons, `Theme::titlebar_height`
|
buttons, `Theme::titlebar_height`
|
||||||
- [x] Sidebar auto-open — `PanelDescriptor::right_sidebar` + `DockSidebar`
|
- [x] Sidebar auto-open — `PanelDescriptor::right_sidebar` + `DockSidebar`
|
||||||
|
- [x] Resizable group — stacked panes with drag dividers outside the dock,
|
||||||
|
per-pane floors, double-click reset, keyboard steps
|
||||||
|
|
||||||
### gallery (`examples/gallery`)
|
### gallery (`examples/gallery`)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, Context, Entity, FontWeight, InteractiveElement as _,
|
App, AppContext as _, Axis, Bounds, Context, Entity, FontWeight, InteractiveElement as _,
|
||||||
IntoElement, KeyBinding, ListAlignment, ListState, ParentElement as _, Pixels, Render,
|
IntoElement, KeyBinding, ListAlignment, ListState, ParentElement as _, Pixels, Render,
|
||||||
SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds,
|
SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds,
|
||||||
WindowOptions, actions, div, list, px, size, uniform_list,
|
WindowOptions, actions, div, list, px, size, uniform_list,
|
||||||
@@ -48,6 +48,7 @@ use gpui_native_editor::highlight::Language;
|
|||||||
use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState};
|
use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState};
|
||||||
use gpui_native_icons::{IconAssets, IconRole, icon};
|
use gpui_native_icons::{IconAssets, IconRole, icon};
|
||||||
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection};
|
||||||
|
use gpui_native_layout::resizable::{Resizable, ResizableState};
|
||||||
use gpui_native_layout::resize_handle::ResizeHandle;
|
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||||
use gpui_native_layout::shell::{LayoutState, Shell};
|
use gpui_native_layout::shell::{LayoutState, Shell};
|
||||||
use gpui_native_theme::{
|
use gpui_native_theme::{
|
||||||
@@ -119,6 +120,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("welcome", "Welcome"),
|
("welcome", "Welcome"),
|
||||||
("sidebar", "Sidebar"),
|
("sidebar", "Sidebar"),
|
||||||
("resize-handle", "Resize handle"),
|
("resize-handle", "Resize handle"),
|
||||||
|
("resizable", "Resizable"),
|
||||||
("separator", "Separator"),
|
("separator", "Separator"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -675,6 +677,12 @@ impl Gallery {
|
|||||||
}),
|
}),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("resizable", "Resizable", |_, cx| {
|
||||||
|
cx.new(ResizableDemo::new).into()
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("separator", "Separator", |_, cx| {
|
PanelDescriptor::new("separator", "Separator", |_, cx| {
|
||||||
cx.new(|_| SeparatorDemo).into()
|
cx.new(|_| SeparatorDemo).into()
|
||||||
@@ -1706,6 +1714,68 @@ impl Render for SeparatorDemo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ResizableDemo {
|
||||||
|
vertical: Entity<ResizableState>,
|
||||||
|
horizontal: Entity<ResizableState>,
|
||||||
|
_observations: (gpui::Subscription, gpui::Subscription),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResizableDemo {
|
||||||
|
fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
// The panel is a cached dock view: it re-renders only when the size
|
||||||
|
// entities it reads are observed and notified. Each group owns one.
|
||||||
|
let vertical = ResizableState::new(cx);
|
||||||
|
let horizontal = ResizableState::new(cx);
|
||||||
|
let observations = (
|
||||||
|
cx.observe(&vertical, |_, _, cx| cx.notify()),
|
||||||
|
cx.observe(&horizontal, |_, _, cx| cx.notify()),
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
vertical,
|
||||||
|
horizontal,
|
||||||
|
_observations: observations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ResizableDemo {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let colors = cx.theme().colors;
|
||||||
|
let pane = |text: &'static str| {
|
||||||
|
div().size_full().bg(colors.muted).p_2().child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(text),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
ScrollArea::new("resizable-demo")
|
||||||
|
.size_full()
|
||||||
|
.p_4()
|
||||||
|
.child(demo_section(
|
||||||
|
"vertical",
|
||||||
|
colors,
|
||||||
|
Resizable::new("resizable-demo-vertical", self.vertical.clone())
|
||||||
|
.size_full()
|
||||||
|
.h(px(320.))
|
||||||
|
.pane_with_min(pane("Tree"), px(96.))
|
||||||
|
.pane(pane("Editor"))
|
||||||
|
.pane(pane("Terminal")),
|
||||||
|
))
|
||||||
|
.child(demo_section(
|
||||||
|
"horizontal",
|
||||||
|
colors,
|
||||||
|
Resizable::new("resizable-demo-horizontal", self.horizontal.clone())
|
||||||
|
.axis(Axis::Horizontal)
|
||||||
|
.size_full()
|
||||||
|
.h(px(120.))
|
||||||
|
.pane(pane("Primary"))
|
||||||
|
.pane(pane("Inspector")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct BadgeDemo;
|
struct BadgeDemo;
|
||||||
|
|
||||||
impl Render for BadgeDemo {
|
impl Render for BadgeDemo {
|
||||||
|
|||||||
Reference in New Issue
Block a user