add layout crate with resize handle
This commit is contained in:
@@ -26,9 +26,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cargo check -p gpui-native-core --no-default-features
|
cargo check -p gpui-native-core --no-default-features
|
||||||
cargo check -p gpui-native-core --all-features
|
cargo check -p gpui-native-core --all-features
|
||||||
for f in button checkbox switch radio toggle link slider progress avatar input popover tooltip menu dialog sheet toast; do
|
for f in button checkbox switch radio toggle link slider progress avatar input popover tooltip menu dialog toast; do
|
||||||
cargo check -p gpui-native-core --no-default-features --features "$f"
|
cargo check -p gpui-native-core --no-default-features --features "$f"
|
||||||
done
|
done
|
||||||
|
- name: Feature checks (layout)
|
||||||
|
run: |
|
||||||
|
cargo check -p gpui-native-layout --no-default-features
|
||||||
|
cargo check -p gpui-native-layout --all-features
|
||||||
|
cargo check -p gpui-native-layout --no-default-features --features resize_handle
|
||||||
- name: Feature checks (facade)
|
- name: Feature checks (facade)
|
||||||
run: |
|
run: |
|
||||||
cargo check -p gpui-native-kit --no-default-features
|
cargo check -p gpui-native-kit --no-default-features
|
||||||
|
|||||||
Generated
+11
@@ -1515,6 +1515,7 @@ dependencies = [
|
|||||||
"gpui-native-core",
|
"gpui-native-core",
|
||||||
"gpui-native-data",
|
"gpui-native-data",
|
||||||
"gpui-native-icons",
|
"gpui-native-icons",
|
||||||
|
"gpui-native-layout",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
"gpui_platform",
|
"gpui_platform",
|
||||||
]
|
]
|
||||||
@@ -1733,9 +1734,19 @@ dependencies = [
|
|||||||
"gpui-native-core",
|
"gpui-native-core",
|
||||||
"gpui-native-data",
|
"gpui-native-data",
|
||||||
"gpui-native-icons",
|
"gpui-native-icons",
|
||||||
|
"gpui-native-layout",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gpui-native-layout"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"gpui",
|
||||||
|
"gpui-native-theme",
|
||||||
|
"gpui-native-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-native-theme"
|
name = "gpui-native-theme"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/theme", "crates/utils", "crates/icons", "crates/core", "crates/data", "examples/gallery"]
|
members = ["crates/theme", "crates/utils", "crates/icons", "crates/core", "crates/data", "crates/layout", "examples/gallery"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "gpui-native-kit"
|
name = "gpui-native-kit"
|
||||||
@@ -11,11 +11,13 @@ description = "GPUI components that feel native to the operating system"
|
|||||||
default = ["core", "data"]
|
default = ["core", "data"]
|
||||||
core = ["dep:gpui-native-core"]
|
core = ["dep:gpui-native-core"]
|
||||||
data = ["dep:gpui-native-data"]
|
data = ["dep:gpui-native-data"]
|
||||||
|
layout = ["dep:gpui-native-layout"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui-native-core = { path = "crates/core", optional = true }
|
gpui-native-core = { path = "crates/core", optional = true }
|
||||||
gpui-native-data = { path = "crates/data", optional = true }
|
gpui-native-data = { path = "crates/data", optional = true }
|
||||||
|
gpui-native-layout = { path = "crates/layout", optional = true }
|
||||||
gpui-native-icons = { path = "crates/icons" }
|
gpui-native-icons = { path = "crates/icons" }
|
||||||
gpui-native-theme = { path = "crates/theme" }
|
gpui-native-theme = { path = "crates/theme" }
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d
|
|||||||
] }
|
] }
|
||||||
gpui-native-core = { path = "crates/core" }
|
gpui-native-core = { path = "crates/core" }
|
||||||
gpui-native-data = { path = "crates/data" }
|
gpui-native-data = { path = "crates/data" }
|
||||||
|
gpui-native-layout = { path = "crates/layout" }
|
||||||
gpui-native-icons = { path = "crates/icons" }
|
gpui-native-icons = { path = "crates/icons" }
|
||||||
gpui-native-theme = { path = "crates/theme" }
|
gpui-native-theme = { path = "crates/theme" }
|
||||||
gpui-native-utils = { path = "crates/utils" }
|
gpui-native-utils = { path = "crates/utils" }
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "gpui-native-layout"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Application layout bootstrap: sidebars and a center dock, one cargo feature each"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
gpui = { workspace = true }
|
||||||
|
gpui-native-theme = { workspace = true }
|
||||||
|
gpui-native-utils = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["full"]
|
||||||
|
full = ["resize_handle"]
|
||||||
|
resize_handle = []
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//! Application layout: sidebars and a center dock bootstrapped for the app (plan §6.4).
|
||||||
|
|
||||||
|
#[cfg(feature = "resize_handle")]
|
||||||
|
pub mod resize_handle;
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! The drag divider: a thin handle that resizes the adjacent region.
|
||||||
|
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
App, Axis, Bounds, CursorStyle, DispatchPhase, ElementId, HitboxBehavior,
|
||||||
|
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||||
|
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, RenderOnce, Role,
|
||||||
|
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, fill, point,
|
||||||
|
px,
|
||||||
|
};
|
||||||
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
|
use gpui_native_utils::{focus_handle, focus_ring};
|
||||||
|
|
||||||
|
/// How far one keyboard arrow step moves the seam.
|
||||||
|
const KEYBOARD_STEP: f32 = 32.0;
|
||||||
|
|
||||||
|
type ResizeHandler = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
|
||||||
|
type ResetHandler = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// The pointer drag state, keyed to the handle's element id.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DragState {
|
||||||
|
dragging: bool,
|
||||||
|
last: Point<Pixels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A thin drag divider that resizes the adjacent region.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct ResizeHandle {
|
||||||
|
id: ElementId,
|
||||||
|
/// The axis the drag moves along: a vertical seam drags horizontally.
|
||||||
|
axis: Axis,
|
||||||
|
disabled: bool,
|
||||||
|
a11y_label: Option<SharedString>,
|
||||||
|
on_resize: Option<ResizeHandler>,
|
||||||
|
on_reset: Option<ResetHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResizeHandle {
|
||||||
|
/// Creates a horizontal resize handle (a vertical seam).
|
||||||
|
pub fn new<I>(id: I) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
axis: Axis::Horizontal,
|
||||||
|
disabled: false,
|
||||||
|
a11y_label: None,
|
||||||
|
on_resize: None,
|
||||||
|
on_reset: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the axis the drag moves along.
|
||||||
|
pub fn axis(mut self, axis: Axis) -> Self {
|
||||||
|
self.axis = axis;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the handle inert and dimmed.
|
||||||
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||||
|
self.disabled = disabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the accessibility label, which defaults to "Resize".
|
||||||
|
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.a11y_label = Some(label.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs for every pointer movement while the seam is dragged.
|
||||||
|
pub fn on_resize<F>(mut self, handler: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(Pixels, &mut Window, &mut App) + 'static,
|
||||||
|
{
|
||||||
|
self.on_resize = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs when the seam is double-clicked, to restore the default size.
|
||||||
|
pub fn on_reset<F>(mut self, handler: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&mut Window, &mut App) + 'static,
|
||||||
|
{
|
||||||
|
self.on_reset = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for ResizeHandle {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let border = theme.colors.border;
|
||||||
|
let ring = theme.colors.ring;
|
||||||
|
let thickness = theme.sizes.resolve(Size::default()).dense_padding_x;
|
||||||
|
|
||||||
|
let label = self
|
||||||
|
.a11y_label
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| SharedString::from("Resize"));
|
||||||
|
|
||||||
|
let drag = window.with_element_namespace(self.id.clone(), |window| {
|
||||||
|
window
|
||||||
|
.use_keyed_state("resize-drag", cx, |_, _| DragState {
|
||||||
|
dragging: false,
|
||||||
|
last: point(px(0.), px(0.)),
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
});
|
||||||
|
let focus = focus_handle(&self.id, window, cx);
|
||||||
|
let dragging = drag.read(cx).dragging;
|
||||||
|
let axis = self.axis;
|
||||||
|
let disabled = self.disabled;
|
||||||
|
|
||||||
|
let cursor = match axis {
|
||||||
|
Axis::Horizontal => CursorStyle::ResizeLeftRight,
|
||||||
|
Axis::Vertical => CursorStyle::ResizeUpDown,
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_reset = self.on_reset;
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(self.id.clone())
|
||||||
|
.debug_selector(|| "resize-handle".to_string())
|
||||||
|
.role(Role::Splitter)
|
||||||
|
.aria_label(label)
|
||||||
|
.flex_none()
|
||||||
|
.when(axis == Axis::Horizontal, |handle| {
|
||||||
|
handle.w(thickness).h_full()
|
||||||
|
})
|
||||||
|
.when(axis == Axis::Vertical, |handle| {
|
||||||
|
handle.w_full().h(thickness)
|
||||||
|
})
|
||||||
|
.when(self.disabled, |handle| handle.opacity(0.5))
|
||||||
|
.when(!self.disabled, |handle| {
|
||||||
|
handle
|
||||||
|
.track_focus(&focus)
|
||||||
|
.focusable()
|
||||||
|
.tab_stop(true)
|
||||||
|
.focus_visible(|style| style.shadow(focus_ring(ring)))
|
||||||
|
.on_mouse_down(MouseButton::Left, {
|
||||||
|
let drag = drag.clone();
|
||||||
|
let focus = focus.clone();
|
||||||
|
move |event: &MouseDownEvent, window, cx| {
|
||||||
|
window.focus(&focus, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
if event.click_count >= 2 {
|
||||||
|
drag.update(cx, |drag, cx| {
|
||||||
|
drag.dragging = false;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
if let Some(on_reset) = on_reset.clone() {
|
||||||
|
on_reset(window, cx);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drag.update(cx, |drag, cx| {
|
||||||
|
drag.dragging = true;
|
||||||
|
drag.last = event.position;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_key_down({
|
||||||
|
let on_resize = self.on_resize.clone();
|
||||||
|
move |event: &KeyDownEvent, window, cx| {
|
||||||
|
let delta = match (axis, event.keystroke.key.as_str()) {
|
||||||
|
(Axis::Horizontal, "left") => Some(px(-KEYBOARD_STEP)),
|
||||||
|
(Axis::Horizontal, "right") => Some(px(KEYBOARD_STEP)),
|
||||||
|
(Axis::Vertical, "up") => Some(px(-KEYBOARD_STEP)),
|
||||||
|
(Axis::Vertical, "down") => Some(px(KEYBOARD_STEP)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(delta) = delta {
|
||||||
|
cx.stop_propagation();
|
||||||
|
if let Some(on_resize) = on_resize.clone() {
|
||||||
|
on_resize(delta, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
canvas(
|
||||||
|
{
|
||||||
|
move |bounds, window, _| {
|
||||||
|
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||||
|
(hitbox, bounds)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let drag = drag.clone();
|
||||||
|
let on_resize = self.on_resize.clone();
|
||||||
|
move |_, (hitbox, bounds), window, _cx| {
|
||||||
|
let hovered = hitbox.is_hovered(window);
|
||||||
|
if !disabled {
|
||||||
|
window.set_cursor_style(cursor, &hitbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hairline highlights while hovered or dragged.
|
||||||
|
let line_color = if !disabled && (dragging || hovered) {
|
||||||
|
ring
|
||||||
|
} else {
|
||||||
|
border
|
||||||
|
};
|
||||||
|
let line = match axis {
|
||||||
|
Axis::Horizontal => {
|
||||||
|
let x = bounds.center().x - px(0.5);
|
||||||
|
Bounds::from_corners(
|
||||||
|
point(x, bounds.top()),
|
||||||
|
point(x + px(1.), bounds.bottom()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Axis::Vertical => {
|
||||||
|
let y = bounds.center().y - px(0.5);
|
||||||
|
Bounds::from_corners(
|
||||||
|
point(bounds.left(), y),
|
||||||
|
point(bounds.right(), y + px(1.)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.paint_quad(fill(line, line_color));
|
||||||
|
|
||||||
|
window.on_mouse_event({
|
||||||
|
let drag = drag.clone();
|
||||||
|
let on_resize = on_resize.clone();
|
||||||
|
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||||
|
if phase != DispatchPhase::Bubble {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (dragging, last) = {
|
||||||
|
let drag = drag.read(cx);
|
||||||
|
(drag.dragging, drag.last)
|
||||||
|
};
|
||||||
|
if !dragging {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta = match axis {
|
||||||
|
Axis::Horizontal => event.position.x - last.x,
|
||||||
|
Axis::Vertical => event.position.y - last.y,
|
||||||
|
};
|
||||||
|
drag.update(cx, |drag, _| drag.last = event.position);
|
||||||
|
if let Some(on_resize) = on_resize.clone() {
|
||||||
|
on_resize(delta, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.on_mouse_event({
|
||||||
|
let drag = drag.clone();
|
||||||
|
move |event: &MouseUpEvent, phase, _, cx| {
|
||||||
|
if phase != DispatchPhase::Bubble
|
||||||
|
|| event.button != MouseButton::Left
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if drag.read(cx).dragging {
|
||||||
|
drag.update(cx, |drag, cx| {
|
||||||
|
drag.dragging = false;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
Axis, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
||||||
|
Pixels, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
|
};
|
||||||
|
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||||
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
|
type DeltaSink = Rc<RefCell<Vec<Pixels>>>;
|
||||||
|
type ResetSink = Rc<RefCell<usize>>;
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
deltas: Rc<RefCell<Vec<Pixels>>>,
|
||||||
|
resets: Rc<RefCell<usize>>,
|
||||||
|
axis: Axis,
|
||||||
|
disabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Harness {
|
||||||
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut gpui::Context<Self>,
|
||||||
|
) -> impl gpui::IntoElement {
|
||||||
|
let deltas = self.deltas.clone();
|
||||||
|
let resets = self.resets.clone();
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.flex()
|
||||||
|
.flex_row()
|
||||||
|
.child(div().w(px(120.)).h_full())
|
||||||
|
.child(
|
||||||
|
ResizeHandle::new("test-handle")
|
||||||
|
.axis(self.axis)
|
||||||
|
.disabled(self.disabled)
|
||||||
|
.on_resize(move |delta, _, _| deltas.borrow_mut().push(delta))
|
||||||
|
.on_reset(move |_, _| *resets.borrow_mut() += 1),
|
||||||
|
)
|
||||||
|
.child(div().flex_1().h_full())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_window(
|
||||||
|
axis: Axis,
|
||||||
|
disabled: bool,
|
||||||
|
) -> (TestAppContext, DeltaSink, ResetSink, VisualTestContext) {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let deltas = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let resets = Rc::new(RefCell::new(0usize));
|
||||||
|
let deltas_for_window = deltas.clone();
|
||||||
|
let resets_for_window = resets.clone();
|
||||||
|
let window = cx.add_window(move |_, _| Harness {
|
||||||
|
deltas: deltas_for_window,
|
||||||
|
resets: resets_for_window,
|
||||||
|
axis,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
(cx, deltas, resets, visual)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_center(visual: &mut VisualTestContext) -> gpui::Point<gpui::Pixels> {
|
||||||
|
let bounds = visual
|
||||||
|
.debug_bounds("resize-handle")
|
||||||
|
.expect("resize handle missing");
|
||||||
|
point(bounds.center().x, bounds.center().y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||||
|
visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragging_reports_signed_deltas() {
|
||||||
|
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||||
|
let start = handle_center(&mut visual);
|
||||||
|
|
||||||
|
visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(start.x + px(30.), start.y),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(start.x + px(10.), start.y),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default());
|
||||||
|
|
||||||
|
assert_eq!(*deltas.borrow(), vec![px(30.), px(-20.)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertical_seams_report_y_deltas() {
|
||||||
|
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Vertical, false);
|
||||||
|
let start = handle_center(&mut visual);
|
||||||
|
|
||||||
|
visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(start.x, start.y + px(40.)),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default());
|
||||||
|
|
||||||
|
assert_eq!(*deltas.borrow(), vec![px(40.)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_without_a_drag_report_nothing() {
|
||||||
|
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||||
|
let start = handle_center(&mut visual);
|
||||||
|
|
||||||
|
// Hovering and moving across the seam without pressing does nothing.
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(start.x + px(30.), start.y),
|
||||||
|
pressed_button: None,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
// A press-and-move that started elsewhere must not reach the handle either.
|
||||||
|
visual.simulate_mouse_down(
|
||||||
|
point(px(20.), px(20.)),
|
||||||
|
MouseButton::Left,
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(px(50.), px(20.)),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(deltas.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_keys_move_the_seam_when_focused() {
|
||||||
|
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||||
|
let center = handle_center(&mut visual);
|
||||||
|
click(&mut visual, center);
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("left");
|
||||||
|
visual.simulate_keystrokes("right");
|
||||||
|
assert_eq!(*deltas.borrow(), vec![px(-32.), px(32.)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_click_resets_and_starts_no_drag() {
|
||||||
|
let (_cx, deltas, resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||||
|
let center = handle_center(&mut visual);
|
||||||
|
|
||||||
|
for click_count in [1usize, 2] {
|
||||||
|
visual.simulate_event(MouseDownEvent {
|
||||||
|
position: center,
|
||||||
|
click_count,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
button: MouseButton::Left,
|
||||||
|
first_mouse: false,
|
||||||
|
});
|
||||||
|
visual.simulate_event(MouseUpEvent {
|
||||||
|
position: center,
|
||||||
|
click_count,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
button: MouseButton::Left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(*resets.borrow(), 1);
|
||||||
|
|
||||||
|
// The double click must not leave a drag behind.
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(center.x + px(20.), center.y),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
assert!(deltas.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_handle_ignores_drag_and_keys() {
|
||||||
|
let (_cx, deltas, resets, mut visual) = handle_window(Axis::Horizontal, true);
|
||||||
|
let center = handle_center(&mut visual);
|
||||||
|
|
||||||
|
visual.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_event(MouseMoveEvent {
|
||||||
|
position: point(center.x + px(30.), center.y),
|
||||||
|
pressed_button: Some(MouseButton::Left),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
});
|
||||||
|
visual.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_keystrokes("left");
|
||||||
|
|
||||||
|
assert!(deltas.borrow().is_empty());
|
||||||
|
assert_eq!(*resets.borrow(), 0);
|
||||||
|
}
|
||||||
+2
-2
@@ -284,7 +284,7 @@ Units, each through the review gate:
|
|||||||
|
|
||||||
| Unit | Crate | Component | Notes |
|
| Unit | Crate | Component | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| L1 | layout | ResizeHandle | The drag divider primitive: a thin themed handle that resizes the adjacent region (sidebar edge, split seam). Pointer drag, double-click resets to the default fraction, splitter a11y role. Lives in `layout` — promotes to `core` if a second consumer appears (D16). |
|
| L1 | layout | ResizeHandle ✅ Landed (2026-11-01) | The drag divider primitive: a thin themed handle that resizes the adjacent region (sidebar edge, split seam). Pointer drag, double-click resets to the default fraction, splitter a11y role. Lives in `layout` — promotes to `core` if a second consumer appears (D16). Shipped as a delta reporter: `ResizeHandle::new(id).axis(Axis).on_resize(delta)` hands the owner the signed seam movement per event — the owner stores a pixel size and clamps, the `InputState::scroll_by` pattern — with `.on_reset()` for double-click; no geometry parameters, so it works at any edge or seam. The hairline is `border`, highlights to `ring` on hover/drag; hit slop = `dense_padding_x` (component-own decoration, D25), hairline 1 px like the input's paint hairlines; `Role::Splitter` + focus ring, arrow keys move the seam (`KEYBOARD_STEP` 32 px, input-style const). One gpui dispatch lesson locked in tests: bubble runs user mouse-down listeners before the div's own focus transfer, so a listener that stops propagation must focus explicitly first — the keyboard test caught it. Feature `resize_handle`; 6 headless tests in `crates/layout/tests/resize_handle.rs`; gallery section shows a draggable split with live width |
|
||||||
| L2 | layout | AppLayout | The bootstrap shell: left sidebar region (on by default) + center + right sidebar region (off by default). Sidebar toggle in each sidebar's header, drag-resize widths through `ResizeHandle`; visibility and widths live on an application-owned `LayoutState` (the `InputState` pattern: `set_left_sidebar_visible` / `set_right_sidebar_visible` / `is_*` readers). The center region hosts the `Dock`. |
|
| L2 | layout | AppLayout | The bootstrap shell: left sidebar region (on by default) + center + right sidebar region (off by default). Sidebar toggle in each sidebar's header, drag-resize widths through `ResizeHandle`; visibility and widths live on an application-owned `LayoutState` (the `InputState` pattern: `set_left_sidebar_visible` / `set_right_sidebar_visible` / `is_*` readers). The center region hosts the `Dock`. |
|
||||||
| L3 | layout | Dock | The center pane tree, controlled: `DockState` = `Pane { tabs, active }` or `Split { axis, ratio, a, b }`; panels are registered descriptors (`id` + view builder); add / close / activate are plain state ops; a pane emptied by closing its last tab collapses its split. Pane headers show titles, the active tab's close button, and a context menu (via core `ContextMenu`). |
|
| L3 | layout | Dock | The center pane tree, controlled: `DockState` = `Pane { tabs, active }` or `Split { axis, ratio, a, b }`; panels are registered descriptors (`id` + view builder); add / close / activate are plain state ops; a pane emptied by closing its last tab collapses its split. Pane headers show titles, the active tab's close button, and a context menu (via core `ContextMenu`). |
|
||||||
| L4 | layout | Dock drag-to-split | Dragging a tab: drop on a pane's edge splits a new pane there; drop on a pane's center moves the tab into that pane; a pane emptied by the move collapses. Edge drop zones paint from theme tokens while a drag is live. Split/merge go through the same `DockState` ops L3 exposed — the drag is interaction, not a second API. |
|
| L4 | layout | Dock drag-to-split | Dragging a tab: drop on a pane's edge splits a new pane there; drop on a pane's center moves the tab into that pane; a pane emptied by the move collapses. Edge drop zones paint from theme tokens while a drag is live. Split/merge go through the same `DockState` ops L3 exposed — the drag is interaction, not a second API. |
|
||||||
@@ -374,6 +374,6 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in
|
|||||||
6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list).
|
6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list).
|
||||||
7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct), plus the `StyledExt` trait (`refine_style` — adapted from gpui-kit `base`, D15 — replacing the per-component `style().refine` pattern; 5 sites) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. **Windows source + icon resolvers landed (2026-10-30)**: `SourceKind::Windows` (WinRT `UISettings` + `Personalize` fallback + `ColorValuesChanged`/`HighContrastChanged`, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real `windows` 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: **side-by-side gallery screenshots per platform** — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput stays deferred until needed; multiline landed as §6.3 M7 (2026-11-01).
|
7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct), plus the `StyledExt` trait (`refine_style` — adapted from gpui-kit `base`, D15 — replacing the per-component `style().refine` pattern; 5 sites) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. **Windows source + icon resolvers landed (2026-10-30)**: `SourceKind::Windows` (WinRT `UISettings` + `Personalize` fallback + `ColorValuesChanged`/`HighContrastChanged`, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real `windows` 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: **side-by-side gallery screenshots per platform** — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput stays deferred until needed; multiline landed as §6.3 M7 (2026-11-01).
|
||||||
8. Then — Phase G is the migration additions (§6.3): Badge ✅, Select ✅, Segmented control ✅, Context menu ✅, Empty state ✅, password input ✅, multiline textarea ✅, then Avatar image (M8) — each unblocks a concrete signed screen. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. `crates/editor` starts when file viewing (M15) is reached.
|
8. Then — Phase G is the migration additions (§6.3): Badge ✅, Select ✅, Segmented control ✅, Context menu ✅, Empty state ✅, password input ✅, multiline textarea ✅, then Avatar image (M8) — each unblocks a concrete signed screen. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. `crates/editor` starts when file viewing (M15) is reached.
|
||||||
9. Then — the layout crate (§6.4): ResizeHandle, the AppLayout bootstrap, the center Dock with drag-to-split, and panel-declared sidebar auto-open — each through the review gate, with signed's `crates/dock` as the usage reference and the gallery workspace page as the example. Order against M9+ is decided when the crate starts.
|
9. Then — the layout crate (§6.4): ResizeHandle ✅, the AppLayout bootstrap, the center Dock with drag-to-split, and panel-declared sidebar auto-open — each through the review gate, with signed's `crates/dock` as the usage reference and the gallery workspace page as the example. Order against M9+ is decided when the crate starts.
|
||||||
|
|
||||||
Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.
|
Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ gpui = { workspace = true }
|
|||||||
gpui_platform = { workspace = true }
|
gpui_platform = { workspace = true }
|
||||||
gpui-native-core = { workspace = true }
|
gpui-native-core = { workspace = true }
|
||||||
gpui-native-data = { workspace = true }
|
gpui-native-data = { workspace = true }
|
||||||
|
gpui-native-layout = { workspace = true }
|
||||||
gpui-native-icons = { workspace = true }
|
gpui-native-icons = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::time::Duration;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _,
|
App, AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _,
|
||||||
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
|
IntoElement, ParentElement as _, Pixels, Render, SharedString, StatefulInteractiveElement as _,
|
||||||
Styled as _, Window, WindowBounds, WindowOptions, div, px, size,
|
Styled as _, Window, WindowBounds, WindowOptions, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView};
|
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView};
|
||||||
@@ -34,6 +34,7 @@ use gpui_native_data::sidebar::{Sidebar, SidebarEntry};
|
|||||||
use gpui_native_data::tabs::{Tab, Tabs};
|
use gpui_native_data::tabs::{Tab, Tabs};
|
||||||
use gpui_native_data::tree::{Tree, TreeItem};
|
use gpui_native_data::tree::{Tree, TreeItem};
|
||||||
use gpui_native_icons::{IconAssets, IconRole, icon};
|
use gpui_native_icons::{IconAssets, IconRole, icon};
|
||||||
|
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
|
||||||
use gpui_platform::application;
|
use gpui_platform::application;
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ struct Gallery {
|
|||||||
input_state: Entity<InputState>,
|
input_state: Entity<InputState>,
|
||||||
password_state: Entity<InputState>,
|
password_state: Entity<InputState>,
|
||||||
notes_state: Entity<InputState>,
|
notes_state: Entity<InputState>,
|
||||||
|
split_width: Pixels,
|
||||||
popover_open: bool,
|
popover_open: bool,
|
||||||
menu_open: bool,
|
menu_open: bool,
|
||||||
strategy: Option<Strategy>,
|
strategy: Option<Strategy>,
|
||||||
@@ -378,6 +380,74 @@ impl Render for Gallery {
|
|||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(section_label(
|
||||||
|
"layout · resize handle \u{2014} NSSplitView divider, WinUI GridSplitter, GtkPaned handle",
|
||||||
|
colors,
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.h(px(112.))
|
||||||
|
.rounded(radius.control)
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(self.split_width)
|
||||||
|
.h_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.bg(colors.muted)
|
||||||
|
.child("Sidebar"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
ResizeHandle::new("gallery-resize")
|
||||||
|
.on_resize({
|
||||||
|
let gallery = gallery.clone();
|
||||||
|
move |delta, _, cx| {
|
||||||
|
gallery.update(cx, |gallery, cx| {
|
||||||
|
gallery.split_width = (gallery.split_width
|
||||||
|
+ delta)
|
||||||
|
.clamp(px(96.), px(320.));
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_reset({
|
||||||
|
let gallery = gallery.clone();
|
||||||
|
move |_, cx| {
|
||||||
|
gallery.update(cx, |gallery, cx| {
|
||||||
|
gallery.split_width = px(200.);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.h_full()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.bg(colors.input)
|
||||||
|
.child("Editor"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(format!(
|
||||||
|
"width: {} px \u{2014} drag the seam, double-click resets",
|
||||||
|
f32::from(self.split_width)
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -1506,6 +1576,7 @@ fn main() {
|
|||||||
input_state,
|
input_state,
|
||||||
password_state,
|
password_state,
|
||||||
notes_state,
|
notes_state,
|
||||||
|
split_width: px(200.),
|
||||||
popover_open: false,
|
popover_open: false,
|
||||||
menu_open: false,
|
menu_open: false,
|
||||||
strategy: Some(Strategy::Curated),
|
strategy: Some(Strategy::Curated),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ pub use gpui_native_core::*;
|
|||||||
#[cfg(feature = "data")]
|
#[cfg(feature = "data")]
|
||||||
pub use gpui_native_data as data;
|
pub use gpui_native_data as data;
|
||||||
|
|
||||||
|
#[cfg(feature = "layout")]
|
||||||
|
pub use gpui_native_layout as layout;
|
||||||
|
|
||||||
use gpui::App;
|
use gpui::App;
|
||||||
|
|
||||||
/// Initializes the framework.
|
/// Initializes the framework.
|
||||||
|
|||||||
Reference in New Issue
Block a user