From 510010fb3fb94dbb8626bc2806df0b8676e222e8 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sat, 11 Jul 2026 18:34:12 +0800 Subject: [PATCH] Merge top bar + sidebar into one connected chrome shell (L-frame) The top bar and left sidebar were two separate rounded panels with a 12px gap between them; the user wants them to read as one continuous shell with a larger outer radius. - Add per-corner radii to the UI quad pipeline (UiQuad::with_corners, a new vertex attribute at location 6, and a per-quadrant radius select in the fragment shader's sdRoundedBox). Existing call sites default to the uniform radius, so nothing else changes. - Close the 12px gap: the sidebar now abuts the top bar's bottom edge and runs to the window's bottom margin, sharing the left edge. The join corners are squared while the outer corners take a new, larger UI_SHELL_RADIUS_LOGICAL (14) so the whole chrome reads as one card. - Smoothly close the join corner as the sidebar slides in, instead of popping from round to square when the panel becomes visible. - Seal the seam: the sidebar (same color, drawn on top) extends up a couple physical px under the top bar so the two translucent edges' antialiasing can't leak the dark background as a hairline "black edge". --- nebula_app/res/ui.f.glsl | 23 ++++-- nebula_app/res/ui.v.glsl | 5 ++ nebula_app/src/display/chrome.rs | 117 ++++++++++++++++++++----------- nebula_app/src/display/mod.rs | 10 ++- nebula_app/src/renderer/ui.rs | 31 ++++++++ 5 files changed, 138 insertions(+), 48 deletions(-) diff --git a/nebula_app/res/ui.f.glsl b/nebula_app/res/ui.f.glsl index d61fc69..f8fa63d 100644 --- a/nebula_app/res/ui.f.glsl +++ b/nebula_app/res/ui.f.glsl @@ -13,6 +13,7 @@ varying mediump vec2 quadSize; varying mediump float quadRadius; varying mediump float quadFeather; varying mediump vec2 grad; +varying mediump vec4 corners; varying mediump vec4 color0; varying mediump vec4 color1; #else @@ -26,14 +27,21 @@ in vec2 quadSize; in float quadRadius; in float quadFeather; in vec2 grad; +in vec4 corners; in vec4 color0; in vec4 color1; #endif -// Signed distance to a rounded box centered at the origin. -float_t sdRoundedBox(vec2 p, vec2 halfSize, float_t radius) { - vec2 q = abs(p) - halfSize + radius; - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +// Signed distance to a rounded box centered at the origin, with per-corner +// radii. `radii` = [top-left, top-right, bottom-right, bottom-left]; the corner +// facing `p`'s quadrant is selected so join corners can stay square while the +// outer ones curve (the connected top-bar/sidebar L-frame). +float_t sdRoundedBox(vec2 p, vec2 halfSize, vec4 radii) { + // p.x > 0 -> right half (top-right / bottom-right), else left half. + float_t r = (p.x > 0.0) ? ((p.y > 0.0) ? radii.z : radii.y) + : ((p.y > 0.0) ? radii.w : radii.x); + vec2 q = abs(p) - halfSize + r; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; } void main() { @@ -54,8 +62,11 @@ void main() { coverage = 1.0; } else { // Crisp rounded box with 1px antialiasing from the signed distance. - float_t radius = min(quadRadius, min(halfSize.x, halfSize.y)); - float_t d = sdRoundedBox(p, halfSize, radius); + // Each corner is clamped independently so a large outer radius and a + // square (0) join corner can coexist on the same quad. + float_t lim = min(halfSize.x, halfSize.y); + vec4 radii = min(corners, vec4(lim)); + float_t d = sdRoundedBox(p, halfSize, radii); coverage = clamp(0.5 - d, 0.0, 1.0); } diff --git a/nebula_app/res/ui.v.glsl b/nebula_app/res/ui.v.glsl index 3b033c5..7ed5c21 100644 --- a/nebula_app/res/ui.v.glsl +++ b/nebula_app/res/ui.v.glsl @@ -6,6 +6,7 @@ attribute vec2 aPos; attribute vec2 aUv; attribute vec4 aSizeRadius; attribute vec2 aGrad; +attribute vec4 aCorners; attribute vec4 aColor0; attribute vec4 aColor1; @@ -14,6 +15,7 @@ varying mediump vec2 quadSize; varying mediump float quadRadius; varying mediump float quadFeather; varying mediump vec2 grad; +varying mediump vec4 corners; varying mediump vec4 color0; varying mediump vec4 color1; #else @@ -21,6 +23,7 @@ layout(location = 0) in vec2 aPos; layout(location = 1) in vec2 aUv; layout(location = 2) in vec4 aSizeRadius; layout(location = 3) in vec2 aGrad; +layout(location = 6) in vec4 aCorners; layout(location = 4) in vec4 aColor0; layout(location = 5) in vec4 aColor1; @@ -29,6 +32,7 @@ out vec2 quadSize; out float quadRadius; out float quadFeather; out vec2 grad; +out vec4 corners; out vec4 color0; out vec4 color1; #endif @@ -39,6 +43,7 @@ void main() { quadRadius = aSizeRadius.z; quadFeather = aSizeRadius.w; grad = aGrad; + corners = aCorners; color0 = aColor0; color1 = aColor1; gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0); diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index fd643b0..83466a5 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -213,16 +213,20 @@ pub(super) fn chrome_tab_layout( }; } - // Expanded panel: fills the left gutter between the top and bottom bars, - // leaving a small breathing gap before the terminal grid on its right. + // Expanded panel: the left leg of the connected chrome L-frame. It shares + // the top bar's left edge (`margin`) and abuts its bottom edge with NO gap + // (`panel_top = top + bar_h`) so the two read as one panel; the join corners + // are squared off in `draw_chrome`. It runs down to the window's bottom + // margin (symmetric with the top bar's top at `margin`), leaving only a + // breathing gap before the terminal grid on its right. let sw = SIDEBAR_W_LOGICAL * scale_factor; let t = expand.clamp(0.0, 1.0); let eased = 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t); let slide = (1.0 - eased) * sw; let panel_x = margin - slide; let panel_w = (sw - margin - s(12.0)).max(s(120.0)); - let panel_top = top + bar_h + s(12.0); - let panel_bottom = h - margin - s(12.0); + let panel_top = top + bar_h; + let panel_bottom = h - margin; let panel_h = (panel_bottom - panel_top).max(0.0); let panel = (panel_x, panel_top, panel_w, panel_h); @@ -234,7 +238,10 @@ pub(super) fn chrome_tab_layout( let tab_w = panel_w - 2.0 * tab_pad; let row_h = s(34.0); let gap = s(8.0); - let header = s(42.0); // room for a "TABS" caption at the panel top + // Header band below the top-bar join: the panel now abuts the top bar with + // no gap, so this carries the seam clearance (the old +12 panel gap) plus + // room for the "TABS" caption and the "+" square. + let header = s(54.0); // "+" square, vertically centred in the header band, pinned to the right. let plus_sz = s(20.0); @@ -399,6 +406,9 @@ pub(super) fn draw_chrome(d: &mut Display) { let bar_h = s(40.0); let inner_pad = s(6.0); let radius = s(UI_CORNER_RADIUS_LOGICAL); + // Larger outer radius for the connected chrome shell (top bar + sidebar + // L-frame); pills/controls keep the smaller `radius`. + let shell_r = s(UI_SHELL_RADIUS_LOGICAL); let pill_h = bar_h - 2.0 * inner_pad; let pill_r = s(UI_CORNER_RADIUS_LOGICAL); let hairline_w = s(UI_HAIRLINE_LOGICAL).max(1.0); @@ -430,28 +440,26 @@ pub(super) fn draw_chrome(d: &mut Display) { )); } - // ---- Window border: same as the window background ---- - // The border is painted in the actual background color (custom override - // or the scheme background) so the window edge reads as one cohesive - // surface — no bright accent outline floating around the chrome. - let bg = d.nebula_background.unwrap_or_else(|| d.colors[NamedColor::Background]); - let border = Rgba::opaque(bg); - let glow_b = s(8.0); - quads.push(UiQuad::solid(0.0, 0.0, w, glow_b, 0.0, border)); - quads.push(UiQuad::solid(0.0, h - glow_b, w, glow_b, 0.0, border)); - quads.push(UiQuad::solid(0.0, 0.0, glow_b, h, 0.0, border)); - quads.push(UiQuad::solid(w - glow_b, 0.0, glow_b, h, 0.0, border)); - let t_b = s(1.5); - quads.push(UiQuad::solid(0.0, 0.0, w, t_b, 0.0, border)); - quads.push(UiQuad::solid(0.0, h - t_b, w, t_b, 0.0, border)); - quads.push(UiQuad::solid(0.0, 0.0, t_b, h, 0.0, border)); - quads.push(UiQuad::solid(w - t_b, 0.0, t_b, h, 0.0, border)); - // ---- Top title / tab bar ---- - let top_y = margin; - quads.push(UiQuad::solid(margin, top_y, w - 2.0 * margin, bar_h, radius, palette.panel)); - + // The top bar and the left sidebar form one connected chrome shell (an + // L-frame): the top bar's bottom-left corner is squared so the sidebar + // abuts it seamlessly, while the three outer corners keep the larger shell + // radius. Collapsed (no sidebar) the bar is a standalone card, all four + // corners shell-rounded. + let shell_r = s(UI_SHELL_RADIUS_LOGICAL); let sidebar_expand = d.left_sidebar_progress(); + let top_y = margin; + // The bottom-left corner closes toward square as the sidebar slides in, so + // the join seals smoothly instead of the corner popping from round to square + // the instant the panel becomes visible. `expand` is 0 collapsed → 1 open. + let join_r = shell_r * (1.0 - sidebar_expand.clamp(0.0, 1.0)); + // [top-left, top-right, bottom-right, bottom-left] + let top_bar_corners = [shell_r, shell_r, shell_r, join_r]; + quads.push( + UiQuad::solid(margin, top_y, w - 2.0 * margin, bar_h, radius, palette.panel) + .with_corners(top_bar_corners), + ); + let tab_layout = chrome_tab_layout( &size, scale, @@ -499,10 +507,27 @@ pub(super) fn draw_chrome(d: &mut Display) { quads.push(UiQuad::solid(set_x, set_y, set_w, set_h, pill_r, HOVER_FILL_STRONG)); } - // Sidebar panel background (draws through slide-out so folding is visible). + // Sidebar panel background — the left leg of the chrome shell. Its top + // corners are squared so it abuts the top bar's squared bottom-left into + // one continuous L; only the two outer bottom corners keep the shell + // radius. Draws through slide-out so folding stays visible. + // + // Seam seal: the sidebar is painted AFTER the top bar and in the same + // panel color, so we extend its top edge UP by a couple of physical pixels + // to tuck under the top bar. Without this, the top bar's bottom edge and + // the sidebar's top edge each carry a 1px antialiasing falloff; where two + // translucent edges merely abut, both are under-covered and the dark + // background bleeds through as a hairline "black edge". Overlapping turns + // that transition into panel-over-panel (≈1.5% delta, invisible) and the + // gap is gone. if d.left_sidebar_visible() { let (pnl_x, pnl_y, pnl_w, pnl_h) = tab_layout.panel; - quads.push(UiQuad::solid(pnl_x, pnl_y, pnl_w, pnl_h, radius, palette.panel)); + let seam = s(2.0).max(1.0); + quads.push( + UiQuad::solid(pnl_x, pnl_y - seam, pnl_w, pnl_h + seam, radius, palette.panel) + // [top-left, top-right, bottom-right, bottom-left] + .with_corners([0.0, 0.0, shell_r, shell_r]), + ); } // Dock preview: while a dragged tab hovers the terminal area, glow the @@ -576,17 +601,24 @@ pub(super) fn draw_chrome(d: &mut Display) { Rgba::new(accent.r, accent.g, accent.b, 40), )); quads.push(UiQuad::solid(tab_draw_x, tab_draw_y, tab_w, tab_h, pill_r, palette.tab_bg_l)); - quads.push(UiQuad::solid( - tab_draw_x, - tab_draw_y, - tab_w, - tab_h, - pill_r, - Rgba::new(accent.r, accent.g, accent.b, 26), - )); - } else { - quads.push(UiQuad::solid(tab_draw_x, tab_draw_y, tab_w, tab_h, pill_r, palette.pill)); + // The accent wash on top is a DARK-theme depth cue: the white pill + // (`tab_bg_l`) is the state on the light themes, so tinting it just + // grays the white the user asked to keep pure. Only the dark themes + // layer the wash over their (dark) active pill. + if !palette.is_light { + quads.push(UiQuad::solid( + tab_draw_x, + tab_draw_y, + tab_w, + tab_h, + pill_r, + Rgba::new(accent.r, accent.g, accent.b, 26), + )); + } } + // Inactive tabs carry no standalone fill — they sit flush on the + // sidebar surface and only light up on hover (below). State is the + // white active pill, not a per-row background. if tab_hovered { quads.push(UiQuad::solid(tab_draw_x, tab_draw_y, tab_w, tab_h, pill_r, HOVER_FILL)); @@ -670,9 +702,10 @@ pub(super) fn draw_chrome(d: &mut Display) { } // "New tab" pill (a wide row when expanded, a square beside the toggle - // when collapsed — both come straight from the layout). + // when collapsed — both come straight from the layout). No resting fill: + // the "+" is just an icon until the pointer arrives, then a hover pill + // lifts under it (matches the top-bar window controls). let (plus_x, plus_y, plus_w, plus_h) = tab_layout.plus; - quads.push(UiQuad::solid(plus_x, plus_y, plus_w, plus_h, pill_r, palette.pill)); if d.nebula_chrome_hover == ChromeHit::NewTab { quads.push(UiQuad::solid(plus_x, plus_y, plus_w, plus_h, pill_r, HOVER_FILL_STRONG)); } @@ -863,12 +896,14 @@ pub(super) fn draw_chrome(d: &mut Display) { // Sidebar text (caption, labels, × buttons) also stays under the // settings glass — skip it entirely while the modal is up. if d.left_sidebar_visible() && tab_layout.panel.2 > 0.0 && !d.nebula_settings_open { - // "TABS" caption at the panel head. + // "TABS" caption at the panel head. The panel now abuts the top bar + // with no gap, so the caption is pushed down inside the header band to + // keep clearance from the (invisible) join seam. let (pnl_x, pnl_y, _, _) = tab_layout.panel; d.renderer.draw_chrome_text( &size, pnl_x + s(16.0), - pnl_y + s(11.0), + pnl_y + s(18.0), TXT_DIM, "TABS", &mut d.glyph_cache, diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index eb991ff..87f15e4 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -152,9 +152,17 @@ pub(crate) const NEBULA_UNFOCUSED_SPLIT_DIM: f32 = 0.30; /// bar while the symmetric `SizeInfo` padding model is still in use. pub const CHROME_BAR_LOGICAL: f32 = 56.0; -/// Shared chrome/control corner radius. +/// Shared chrome/control corner radius. Used for the small in-shell affordances +/// (window-control hover pills, tab pills, the "+" square) — kept modest so the +/// controls stay crisp. pub(super) const UI_CORNER_RADIUS_LOGICAL: f32 = 8.0; +/// Outer radius of the connected chrome shell (the L-frame formed by the top +/// bar + left sidebar). Larger than the control radius so the whole window +/// chrome reads as one soft-cornered card while the affordances inside keep +/// their tighter [`UI_CORNER_RADIUS_LOGICAL`] curve. +pub(super) const UI_SHELL_RADIUS_LOGICAL: f32 = 14.0; + /// Shared quiet outline thickness. pub(super) const UI_HAIRLINE_LOGICAL: f32 = 1.0; diff --git a/nebula_app/src/renderer/ui.rs b/nebula_app/src/renderer/ui.rs index a0404e9..38621e4 100644 --- a/nebula_app/src/renderer/ui.rs +++ b/nebula_app/src/renderer/ui.rs @@ -80,6 +80,12 @@ pub struct UiQuad { pub width: f32, pub height: f32, pub radius: f32, + /// Per-corner radii in pixels `[top-left, top-right, bottom-right, + /// bottom-left]`. Defaults to `[radius; 4]`; set via [`UiQuad::with_corners`] + /// to round only some corners (e.g. the connected top-bar/sidebar L-frame, + /// where the join corners are square and only the outer corners curve). + /// `radius` still drives the flat/glow shader flags, so keep it ≥ 0 here. + pub corner_radii: [f32; 4], /// Soft glow falloff in pixels; `0.0` means a crisp rounded rectangle. pub feather: f32, /// Explicit corner positions in pixels `[top-left, bottom-left, top-right, @@ -107,6 +113,7 @@ impl UiQuad { width, height, radius, + corner_radii: [radius; 4], feather: 0.0, corners: None, v_range: [0.0, 1.0], @@ -134,6 +141,7 @@ impl UiQuad { width, height, radius, + corner_radii: [radius; 4], feather: 0.0, corners: None, v_range: [0.0, 1.0], @@ -152,6 +160,7 @@ impl UiQuad { width, height, radius: 0.0, + corner_radii: [0.0; 4], feather: 1.0, corners: None, v_range: [0.0, 1.0], @@ -176,6 +185,7 @@ impl UiQuad { width: 0.0, height: 0.0, radius: -1.0, + corner_radii: [0.0; 4], feather: 0.0, corners: Some(corners), v_range: [0.0, 1.0], @@ -185,6 +195,19 @@ impl UiQuad { } } + /// Rounded rectangle with independent per-corner radii, ordered + /// `[top-left, top-right, bottom-right, bottom-left]`. Used for the + /// connected top-bar / sidebar L-frame, where the two join corners stay + /// square (radius 0) and only the outer corners curve. `radius` is kept as + /// the max of the four so the shader's flat/glow branch and the geometry's + /// AA padding still behave; the fragment shader picks the right corner. + #[inline] + pub fn with_corners(mut self, corner_radii: [f32; 4]) -> Self { + self.corner_radii = corner_radii; + self.radius = corner_radii.iter().copied().fold(0.0_f32, f32::max); + self + } + /// Clip the quad to the vertical band `top..bot` (screen px). The visible /// part renders pixel-identical to the unclipped quad — rounded corners, /// gradients and glows are cut mid-shape rather than re-rounded — because @@ -226,6 +249,8 @@ struct UiVertex { // Gradient axis in uv space. gx: f32, gy: f32, + // Per-corner radii in pixels: [top-left, top-right, bottom-right, bottom-left]. + corners: [f32; 4], // Endpoint colors. c0: [u8; 4], c1: [u8; 4], @@ -276,6 +301,11 @@ impl UiRenderer { gl::EnableVertexAttribArray(3); offset += (mem::size_of::() * 2) as i32; + // aCorners (location 6): vec4 per-corner radii (TL, TR, BR, BL). + gl::VertexAttribPointer(6, 4, gl::FLOAT, gl::FALSE, stride, offset as *const _); + gl::EnableVertexAttribArray(6); + offset += (mem::size_of::() * 4) as i32; + // aColor0 (location 4): normalized ubyte4. gl::VertexAttribPointer(4, 4, gl::UNSIGNED_BYTE, gl::TRUE, stride, offset as *const _); gl::EnableVertexAttribArray(4); @@ -353,6 +383,7 @@ impl UiRenderer { feather: quad.feather, gx, gy, + corners: quad.corner_radii, c0, c1, };