rename app layout to shell and add window frame

This commit is contained in:
2026-11-02 09:28:47 +07:00
parent aa40003a37
commit 0f535413f2
8 changed files with 205 additions and 19 deletions
+2 -2
View File
@@ -17,9 +17,9 @@ gpui = { workspace = true, features = ["test-support"] }
[features]
default = ["full"]
full = ["resize_handle", "app_layout"]
full = ["resize_handle", "shell"]
resize_handle = []
app_layout = [
shell = [
"resize_handle",
"dep:gpui-native-core",
"dep:gpui-native-data",
+4 -2
View File
@@ -3,5 +3,7 @@
#[cfg(feature = "resize_handle")]
pub mod resize_handle;
#[cfg(feature = "app_layout")]
pub mod app_layout;
#[cfg(feature = "shell")]
pub mod shell;
mod window_frame;
@@ -13,6 +13,7 @@ use gpui_native_icons::IconRole;
use gpui_native_theme::{ActiveTheme as _, Size};
use crate::resize_handle::ResizeHandle;
use crate::window_frame::frame;
/// The width a sidebar opens at.
const DEFAULT_SIDEBAR_WIDTH: f32 = 240.0;
@@ -123,7 +124,7 @@ impl Side {
/// The application shell.
#[derive(IntoElement)]
pub struct AppLayout {
pub struct Shell {
id: ElementId,
state: Entity<LayoutState>,
left_sidebar: Option<Sidebar>,
@@ -131,7 +132,7 @@ pub struct AppLayout {
center: Option<AnyElement>,
}
impl AppLayout {
impl Shell {
/// Creates the shell bound to `state`.
pub fn new<I>(id: I, state: Entity<LayoutState>) -> Self
where
@@ -171,8 +172,8 @@ impl AppLayout {
}
}
impl RenderOnce for AppLayout {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
impl RenderOnce for Shell {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let metrics = theme.sizes.resolve(Size::default());
@@ -186,7 +187,7 @@ impl RenderOnce for AppLayout {
let left_visible = state.read(cx).is_left_sidebar_visible();
let right_visible = state.read(cx).is_right_sidebar_visible();
div()
let layout = div()
.flex()
.flex_row()
.w_full()
@@ -237,7 +238,10 @@ impl RenderOnce for AppLayout {
metrics.strip_height,
metrics.dense_padding_x,
))
})
});
// Wraps the layout in a window frame.
frame(layout, window, cx)
}
}
+179
View File
@@ -0,0 +1,179 @@
//! The client-side window frame: the border, corners, shadow, and resize
//! edges of a window the system does not decorate.
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Bounds, BoxShadow, CursorStyle, Decorations, Div, HitboxBehavior, InteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, Pixels, Point, ResizeEdge, Size, Styled as _,
Tiling, Window, canvas, div, point, px, size,
};
use gpui_native_theme::ActiveTheme as _;
/// The width of the band inside the window's edge that resizes it.
const RESIZE_INSET: f32 = 4.0;
/// The hairline around the content.
const BORDER_WIDTH: f32 = 1.0;
/// How much of the shadow's strength survives losing window focus.
const INACTIVE_SHADOW_OPACITY: f32 = 0.7;
/// Wraps a window's root in the frame it owes.
pub fn frame(child: impl IntoElement, window: &mut Window, cx: &App) -> Div {
let Decorations::Client { tiling } = window.window_decorations() else {
return div().size_full().child(child);
};
let tiling = if window.is_maximized() {
Tiling::tiled()
} else {
tiling
};
let inset = px(RESIZE_INSET);
let border = cx.theme().colors.border;
let radius = cx.theme().radius.overlay;
let shadow = frame_shadow(cx, window.is_window_active());
window.set_client_inset(inset);
div()
.size_full()
.child(
canvas(
move |_, window, _| {
let size = window.window_bounds().get_bounds().size;
bands(size, inset)
.into_iter()
.filter(|(edge, _)| live(*edge, tiling))
.map(|(edge, band)| {
(edge, window.insert_hitbox(band, HitboxBehavior::Normal))
})
.collect::<Vec<_>>()
},
|_, bands, window, _| {
for (edge, band) in bands {
window.set_cursor_style(cursor(edge), &band);
}
},
)
.absolute()
.size_full(),
)
.when(!tiling.top, |frame| frame.pt(inset))
.when(!tiling.bottom, |frame| frame.pb(inset))
.when(!tiling.left, |frame| frame.pl(inset))
.when(!tiling.right, |frame| frame.pr(inset))
.on_mouse_down(MouseButton::Left, move |event, window, _| {
let size = window.window_bounds().get_bounds().size;
if let Some(edge) =
resize_edge(event.position, inset, size).filter(|edge| live(*edge, tiling))
{
window.start_window_resize(edge);
}
})
.child(
div()
.size_full()
.overflow_hidden()
.border_color(border)
.when(!tiling.top, |surface| surface.border_t(px(BORDER_WIDTH)))
.when(!tiling.bottom, |surface| surface.border_b(px(BORDER_WIDTH)))
.when(!tiling.left, |surface| surface.border_l(px(BORDER_WIDTH)))
.when(!tiling.right, |surface| surface.border_r(px(BORDER_WIDTH)))
.when(!(tiling.top || tiling.left), |surface| {
surface.rounded_tl(radius)
})
.when(!(tiling.top || tiling.right), |surface| {
surface.rounded_tr(radius)
})
.when(!(tiling.bottom || tiling.left), |surface| {
surface.rounded_bl(radius)
})
.when(!(tiling.bottom || tiling.right), |surface| {
surface.rounded_br(radius)
})
.when(!tiling.is_tiled(), |surface| surface.shadow(shadow))
.child(child),
)
}
/// The dialog elevation's shadow, dimmed while the window is inactive.
fn frame_shadow(cx: &App, active: bool) -> Vec<BoxShadow> {
let opacity = if active { 1.0 } else { INACTIVE_SHADOW_OPACITY };
cx.theme()
.elevations
.dialog
.shadows()
.iter()
.map(|shadow| {
let mut dimmed = shadow.clone();
dimmed.color.a *= opacity;
dimmed
})
.collect()
}
/// Which edge a press at `pos` resizes, or `None` for a press on the content.
fn resize_edge(pos: Point<Pixels>, inset: Pixels, size: Size<Pixels>) -> Option<ResizeEdge> {
let (top, bottom) = (pos.y < inset, pos.y > size.height - inset);
let (left, right) = (pos.x < inset, pos.x > size.width - inset);
Some(match (top, bottom, left, right) {
(true, _, true, _) => ResizeEdge::TopLeft,
(true, _, _, true) => ResizeEdge::TopRight,
(_, true, true, _) => ResizeEdge::BottomLeft,
(_, true, _, true) => ResizeEdge::BottomRight,
(true, ..) => ResizeEdge::Top,
(_, true, ..) => ResizeEdge::Bottom,
(_, _, true, _) => ResizeEdge::Left,
(_, _, _, true) => ResizeEdge::Right,
_ => return None,
})
}
/// The eight bands, as the rects [`resize_edge`] classifies to.
fn bands(window: Size<Pixels>, inset: Pixels) -> [(ResizeEdge, Bounds<Pixels>); 8] {
let band = |x: Pixels, y: Pixels, width: Pixels, height: Pixels| Bounds {
origin: point(x, y),
size: size(width, height),
};
let (far_x, far_y) = (window.width - inset, window.height - inset);
let (span_x, span_y) = (window.width - inset - inset, window.height - inset - inset);
let zero = px(0.0);
[
(ResizeEdge::TopLeft, band(zero, zero, inset, inset)),
(ResizeEdge::TopRight, band(far_x, zero, inset, inset)),
(ResizeEdge::BottomLeft, band(zero, far_y, inset, inset)),
(ResizeEdge::BottomRight, band(far_x, far_y, inset, inset)),
(ResizeEdge::Top, band(inset, zero, span_x, inset)),
(ResizeEdge::Bottom, band(inset, far_y, span_x, inset)),
(ResizeEdge::Left, band(zero, inset, inset, span_y)),
(ResizeEdge::Right, band(far_x, inset, inset, span_y)),
]
}
/// Whether `edge` resizes: none of the sides it touches is flush.
fn live(edge: ResizeEdge, tiling: Tiling) -> bool {
let (top, bottom, left, right) = match edge {
ResizeEdge::Top => (true, false, false, false),
ResizeEdge::Bottom => (false, true, false, false),
ResizeEdge::Left => (false, false, true, false),
ResizeEdge::Right => (false, false, false, true),
ResizeEdge::TopLeft => (true, false, true, false),
ResizeEdge::TopRight => (true, false, false, true),
ResizeEdge::BottomLeft => (false, true, true, false),
ResizeEdge::BottomRight => (false, true, false, true),
};
!(top && tiling.top || bottom && tiling.bottom || left && tiling.left || right && tiling.right)
}
/// The pointer an edge shows.
fn cursor(edge: ResizeEdge) -> CursorStyle {
match edge {
ResizeEdge::Top | ResizeEdge::Bottom => CursorStyle::ResizeUpDown,
ResizeEdge::Left | ResizeEdge::Right => CursorStyle::ResizeLeftRight,
ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorStyle::ResizeUpLeftDownRight,
ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorStyle::ResizeUpRightDownLeft,
}
}
@@ -4,7 +4,7 @@ use gpui::{
VisualTestContext, Window, div, point, px,
};
use gpui_native_data::sidebar::Sidebar;
use gpui_native_layout::app_layout::{AppLayout, LayoutState};
use gpui_native_layout::shell::{LayoutState, Shell};
use gpui_native_theme::{ActiveTheme as _, Size, Theme};
struct Harness {
@@ -18,7 +18,7 @@ impl Render for Harness {
_cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
div().size_full().child(
AppLayout::new("test-layout", self.state.clone())
Shell::new("test-layout", self.state.clone())
.left_sidebar(Sidebar::new("test-left"))
.center(
div()