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".
This commit is contained in:
kuddev committed 2026-07-11 18:34:12 +08:00
1 parent 6b92a7a927
commit 510010fb3f
5 files changed
+138 -48

No files matched your search

+17 -6
View File
@@ -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);
}
+5
View File
@@ -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);
+76 -41
View File
@@ -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,
+9 -1
View File
@@ -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;
+31
View File
@@ -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::<f32>() * 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::<f32>() * 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,
};