Files
pebrel/nebula_app/src/gpui_shell/workspace/window_titlebar.rs
T
GeekMr ff03f76655 fix(ui): apply compact density to title bar and tabs (#194)
Apply compact density consistently to the title bar, tabs, sidebar scrolling and drag geometry. Keep native title-bar dimensions and align the command overlay with the effective title-bar height.

Includes layout, hit-target and density-transition regressions.

Fixes #327.
2026-09-29 21:00:35 +08:00

194 lines
8.1 KiB
Rust

use super::*;
fn title_bar_height(density: nebula_settings::DensityName) -> f32 {
match density {
nebula_settings::DensityName::Standard => 48.0,
nebula_settings::DensityName::Compact => 40.0,
}
}
pub(super) fn effective_title_bar_height(
density: nebula_settings::DensityName,
native_layout: Option<(f32, f32)>,
) -> f32 {
native_layout.map_or_else(|| title_bar_height(density), |(height, _)| height)
}
/// Prepaint records the actual pane column before any titlebar paint runs. Keep
/// this cell for the workspace lifetime, so resizing and panel animations do not
/// allocate a new shared slot or duplicate the body's layout calculations.
#[derive(Clone, Default)]
pub(super) struct TitleBarBackground(Rc<std::cell::Cell<Option<Bounds<Pixels>>>>);
impl TitleBarBackground {
pub(super) fn record_pane(&self, bounds: Bounds<Pixels>) {
self.0.set(Some(bounds));
}
fn element(&self) -> gpui::Canvas<()> {
let pane_bounds = self.0.clone();
canvas(
|_, _, _| (),
move |bounds, _, window, cx| {
let card = crate::gpui_shell::theme::PaneCardStyle::current(cx);
let shell = cx.theme().background;
let Some(pane) = pane_bounds.get().filter(|_| card.radius == 0.0) else {
window.paint_quad(fill(bounds, shell));
return;
};
let title_left = f32::from(bounds.origin.x);
let title_right = title_left + f32::from(bounds.size.width);
let pane_left = f32::from(pane.origin.x).clamp(title_left, title_right);
let pane_right = f32::from(pane.right()).clamp(pane_left, title_right);
let content = crate::gpui_shell::theme::card_content_bg(cx);
// Adjacent bands receive alpha once. The actual pane bounds also
// account for a hidden sidebar and an open file tree.
for (left, right, color) in [
(title_left, pane_left, shell),
(pane_left, pane_right, content),
(pane_right, title_right, shell),
] {
if right > left {
window.paint_quad(fill(
Bounds::new(
gpui::point(px(left), bounds.origin.y),
size(px(right - left), bounds.size.height),
),
color,
));
}
}
},
)
.absolute()
.inset_0()
}
}
/// Paint tab and file-tree seams after the terminal, including the titlebar span.
/// Both use the same pixel snapping and theme color; the right seam stays inside
/// the terminal edge so the subsequently painted drawer cannot cover it.
pub(super) fn paint_pane_dividers(
bounds: Bounds<Pixels>,
file_tree: bool,
window: &mut Window,
cx: &App,
) {
let card = crate::gpui_shell::theme::PaneCardStyle::current(cx);
let color = crate::gpui_shell::theme::card_divider_color(cx);
if let Some(line) = pane_card_divider_bounds(bounds, card.divider, window.scale_factor()) {
window.paint_quad(fill(line, color));
}
if file_tree {
let mut edge = bounds;
edge.origin.x += edge.size.width;
if let Some(mut line) = pane_card_divider_bounds(edge, card.divider, window.scale_factor())
{
line.origin.x -= line.size.width;
window.paint_quad(fill(line, color));
}
}
}
fn title_bar_frame() -> gpui::Div {
div().relative().flex_shrink_0().on_mouse_down(MouseButton::Left, |_, _, cx| {
// Window dragging owns this press; it must not anchor the document selection.
gpui_component::global_state::GlobalState::suppress_text_selection(cx);
})
}
impl NebulaWorkspace {
pub(super) fn render_window_title_bar(
&self,
settings_active: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Div {
let top_tabs = self.tabs_position == nebula_settings::TabsPositionName::Top;
let native_layout = crate::platform::window_chrome::layout(window);
let title_bar_height = effective_title_bar_height(self.density, native_layout);
let bar = TitleBar::new()
.h(px(title_bar_height))
.when(!settings_active, |bar| bar.bg(gpui::transparent_black()).border_b_0())
.when(settings_active, |bar| {
bar.border_b_1().border_color(crate::gpui_shell::theme::settings_hairline(cx))
})
.when(top_tabs && native_layout.is_none(), |bar| {
bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET))
})
.when(top_tabs, |bar| bar.child(self.render_top_title_bar(settings_active, window, cx)))
.when(!top_tabs, |bar| bar.child(self.render_sidebar_title_bar(settings_active, cx)));
// AppKit owns both the control group and its geometry. Read the live
// frames so system layout, resize and full-screen transitions agree.
let bar = if let Some((_, inset)) = native_layout { bar.pl(px(inset)) } else { bar };
title_bar_frame()
.debug_selector(|| "workspace-titlebar".to_owned())
.when(!settings_active, |title| title.child(self.titlebar_background.element()))
.child(bar)
}
}
#[cfg(all(test, feature = "gpui-test-support"))]
mod tests {
use super::*;
use gpui::{Modifiers, TestAppContext, point};
use gpui_component::{
Root, WindowExt,
text::{TextView, TextViewState},
};
struct DocumentWindow(Entity<TextViewState>);
impl Render for DocumentWindow {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
v_flex().size_full().child(title_bar_frame().child(TitleBar::new().h(px(48.0)))).child(
div()
.debug_selector(|| "titlebar-selection-document".to_owned())
.child(TextView::new(&self.0).selectable(true)),
)
}
}
#[gpui::test]
fn title_bar_drag_does_not_anchor_document_selection(cx: &mut TestAppContext) {
cx.update(gpui_component::init);
let (_, cx) = cx.add_window_view(|window, cx| {
let text = cx.new(|cx| TextViewState::markdown("Document text remains selectable", cx));
let view = cx.new(|_| DocumentWindow(text));
Root::new(view, window, cx)
});
let text = cx.debug_bounds("titlebar-selection-document").unwrap();
cx.simulate_mouse_down(point(px(140.0), px(24.0)), MouseButton::Left, Modifiers::default());
cx.simulate_mouse_move(text.center(), Some(MouseButton::Left), Modifiers::default());
cx.simulate_mouse_move(text.center(), None, Modifiers::default());
assert!(cx.update(|window, cx| window.selected_text(cx).is_empty()));
// The same reader must still support ordinary text dragging afterwards.
let start = point(text.left() + px(1.0), text.top() + px(10.0));
let end = point(start.x + px(80.0), start.y);
cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
assert!(!cx.update(|window, cx| window.selected_text(cx).is_empty()));
}
}
#[cfg(test)]
mod density_tests {
use super::{effective_title_bar_height, title_bar_height};
use nebula_settings::DensityName;
#[test]
fn compact_title_bar_retains_space_around_controls() {
assert_eq!(title_bar_height(DensityName::Standard), 48.0);
assert_eq!(title_bar_height(DensityName::Compact), 40.0);
assert_eq!(effective_title_bar_height(DensityName::Standard, None), 48.0);
assert_eq!(effective_title_bar_height(DensityName::Compact, None), 40.0);
assert_eq!(effective_title_bar_height(DensityName::Compact, Some((34.0, 7.0))), 34.0);
assert!(title_bar_height(DensityName::Compact) >= 32.0 + 2.0 * 4.0);
}
}