feat(gpui): content-width dock tabs, rounded timeline clips

This commit is contained in:
2026-08-13 23:37:57 +08:00
parent 888c39bf27
commit fc0420ddd8
10 changed files with 123 additions and 40 deletions
+1 -1
View File
@@ -837,7 +837,7 @@ impl DockArea {
let child = child
.flex_basis(relative(share))
.flex_grow_0()
.flex_shrink_0();
.flex_shrink();
container = container.child(child);
// One handle per boundary, so every pair of panels can be
// resized independently.
+34 -11
View File
@@ -94,6 +94,19 @@ impl TabBar {
/// scrolling instead.
pub(crate) const MIN_TAB_WIDTH: Pixels = Pixels(64.0);
/// Estimated rendered width of a tab for its title: tabs size to their
/// content (the design shows full `<面板>·<名称>` labels), so drag
/// hit-testing estimates each tab's width from the title — CJK glyphs are
/// full-width, ASCII roughly half — plus the horizontal padding. Only the
/// cached drag geometry uses this; the layout itself measures the text.
fn estimated_width(title: &str) -> Pixels {
let units: f32 = title
.chars()
.map(|ch| if ch.is_ascii() { 0.55 } else { 1.0 })
.sum();
Pixels((units * 13.0 + 20.0).max(Self::MIN_TAB_WIDTH.0))
}
/// Creates a strip for the given tabs; `active` is clamped into range.
pub(crate) fn new(tabs: Vec<PanelId>, active: usize) -> Self {
Self {
@@ -201,16 +214,23 @@ impl Render for TabBar {
let colors = cx.default_colors().clone();
// Recompute per-tab geometry from the current order and scroll offset.
self.geometry = self
.tabs
.iter()
.enumerate()
.map(|(index, &panel)| TabGeometry {
panel,
x: Pixels(index as f32 * Self::MIN_TAB_WIDTH.0 - self.scroll_offset.0),
width: Self::MIN_TAB_WIDTH,
})
.collect();
// Tabs are content-sized, so accumulate the estimated widths.
{
let mut x = -self.scroll_offset.0;
self.geometry = self
.tabs
.iter()
.enumerate()
.map(|(index, &panel)| {
let width = Self::estimated_width(
self.titles.get(index).map(|t| t.as_ref()).unwrap_or(""),
);
let tab = TabGeometry { panel, x: Pixels(x), width };
x += width.0;
tab
})
.collect();
}
let mut root = div()
.flex()
@@ -273,9 +293,12 @@ impl Render for TabBar {
let mut tab = div()
.id(ElementId::named_usize("dock-tab", panel.raw() as usize))
.w(px(Self::MIN_TAB_WIDTH.0))
.min_w(px(Self::MIN_TAB_WIDTH.0))
.px_2()
.flex_none()
.h_full()
.overflow_hidden()
.whitespace_nowrap()
.cursor_pointer()
.text_sm()
.bg(if active {
+14 -11
View File
@@ -1011,16 +1011,19 @@ impl<D: NodeGraphDataSource + 'static> Render for NodeGraphView<D> {
.on_pinch(cx.listener(|this, event: &PinchEvent, _window, cx| {
this.on_scroll_or_pinch(event.position, 1.0 + event.delta, cx);
}))
.child(canvas(
move |bounds, _window, cx| {
entity.update(cx, |this, cx| {
this.viewport = bounds;
this.build_draw(cx)
})
},
move |bounds, draw: GraphDraw, window, cx| {
NodeGraphView::<D>::paint_draw(&draw, bounds, window, cx);
},
))
.child(
canvas(
move |bounds, _window, cx| {
entity.update(cx, |this, cx| {
this.viewport = bounds;
this.build_draw(cx)
})
},
move |bounds, draw: GraphDraw, window, cx| {
NodeGraphView::<D>::paint_draw(&draw, bounds, window, cx);
},
)
.size_full(),
)
}
}
+18 -8
View File
@@ -31,6 +31,11 @@ use super::{
/// Screen-space width of each trim-handle hit zone, in pixels.
pub const TRIM_HANDLE_WIDTH: f32 = 6.0;
/// Corner radius of the clip body, per the design's rounded bars. Painted
/// into the body quad itself: gpui's content masks are rectangular only, so
/// a rounded `overflow_hidden` wrapper would not clip the canvas corners.
const CLIP_CORNER_RADIUS: Pixels = px(4.0);
/// Fallback pixels-per-frame used for internal decoration geometry.
///
/// [`ClipElement`] receives no zoom (see the type docs), so transition wedge
@@ -265,7 +270,7 @@ impl RenderOnce for ClipElement {
a: paint.color.a * 0.5,
}
};
window.paint_quad(fill(bounds, body_color));
window.paint_quad(fill(bounds, body_color).corner_radii(CLIP_CORNER_RADIUS));
// Transition wedges: triangles tapering into the clip
// from each edge, capped at 40% of the body width so tiny
@@ -302,10 +307,16 @@ impl RenderOnce for ClipElement {
// State overlays, in back-to-front order.
if !paint.enabled {
window.paint_quad(fill(bounds, hsla(0., 0., 0.05, 0.55)));
window.paint_quad(
fill(bounds, hsla(0., 0., 0.05, 0.55))
.corner_radii(CLIP_CORNER_RADIUS),
);
}
if paint.locked {
window.paint_quad(fill(bounds, hsla(0., 0., 0.1, 0.25)));
window.paint_quad(
fill(bounds, hsla(0., 0., 0.1, 0.25))
.corner_radii(CLIP_CORNER_RADIUS),
);
}
// Rich content via the decorator, scoped to the frames
@@ -330,11 +341,10 @@ impl RenderOnce for ClipElement {
// Selection outline on top of everything.
if paint.selected {
window.paint_quad(outline(
bounds,
hsla(0.6, 0.8, 0.6, 1.),
BorderStyle::Solid,
));
window.paint_quad(
outline(bounds, hsla(0.6, 0.8, 0.6, 1.), BorderStyle::Solid)
.corner_radii(CLIP_CORNER_RADIUS),
);
}
},
)
+1
View File
@@ -71,6 +71,7 @@ impl RenderOnce for PlayheadElement {
window.paint_path(path, color);
},
)
.size_full()
}
}
+1
View File
@@ -282,6 +282,7 @@ impl RenderOnce for TimelineRuler {
}
},
)
.size_full()
}
}
+12 -2
View File
@@ -753,6 +753,7 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
let decorator = self.decorator.clone();
let ruler = div()
.flex()
.flex_row()
.h(px(32.))
.flex_shrink_0()
@@ -781,6 +782,7 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
let headers = div()
.w(px(HEADER_WIDTH))
.flex_shrink_0()
.flex()
.flex_col()
.id("timeline-track-headers")
.on_drag_move(cx.listener(
@@ -863,6 +865,7 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
.id("timeline-clip-area")
.relative()
.overflow_hidden()
.flex()
.flex_col()
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| {
if event.modifiers.control || event.modifiers.platform {
@@ -1068,9 +1071,15 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
div()
.absolute()
.left(px(x0))
.top(px(0.))
// Slight vertical inset: the design's clips read
// as rounded bars with a small gap between rows,
// not full-height slabs. The corners are rounded
// in ClipElement's painted quads (content masks
// are rectangular only).
.top(px(2.))
.w(px(width))
.h(px(clip_height))
.h(px((clip_height - 4.0).max(1.0)))
.overflow_hidden()
.id(ElementId::named_usize("timeline-clip", clip.id.0 as usize))
.on_drag(
Arc::new(RwLock::new(ClipDrag {
@@ -1096,6 +1105,7 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
div().size_full().flex().flex_col().child(ruler).child(
div()
.flex()
.flex_row()
.flex_1()
.relative()
+26 -4
View File
@@ -6,7 +6,7 @@
use gpui::{
App, Bounds, Context, Entity, FocusHandle, Focusable, Hsla, Render, Window, canvas,
colors::DefaultColors, fill, point, prelude::*, px, size,
colors::DefaultColors, fill, hsla, point, prelude::*, px, size,
};
use crate::scopes::{decay_peak, meter_lit_segments};
@@ -16,6 +16,21 @@ const SEGMENTS: usize = 16;
/// Peak decay per frame (fraction of full scale).
const PEAK_DECAY: f32 = 0.01;
/// Segment color by position, per the NLE convention: green for the body,
/// yellow approaching full scale, red at the top (segments count from the
/// meter's zero end).
fn segment_color(segment: usize) -> Hsla {
let yellow_from = SEGMENTS * 5 / 8; // top ~37% caution
let red_from = SEGMENTS * 13 / 16; // top ~19% clip zone
if segment >= red_from {
hsla(0.0, 0.65, 0.5, 1.0)
} else if segment >= yellow_from {
hsla(0.13, 0.75, 0.5, 1.0)
} else {
hsla(0.35, 0.6, 0.45, 1.0)
}
}
/// The orientation of an [`AudioLevelMeter`].
///
/// A horizontal meter stacks channels vertically and lights segments left to
@@ -110,7 +125,6 @@ impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
move |bounds, (), window, _cx| {
let width = f32::from(bounds.size.width);
let height = f32::from(bounds.size.height);
let lit_color = Hsla::from(colors.selected);
let dim_color = Hsla::from(colors.border);
let peak_color = Hsla::from(colors.text);
@@ -134,7 +148,11 @@ impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
);
window.paint_quad(fill(
seg,
if segment < lit { lit_color } else { dim_color },
if segment < lit {
segment_color(segment)
} else {
dim_color
},
));
}
// Peak marker.
@@ -170,7 +188,11 @@ impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
);
window.paint_quad(fill(
seg,
if segment < lit { lit_color } else { dim_color },
if segment < lit {
segment_color(segment)
} else {
dim_color
},
));
}
// Peak marker.
@@ -310,6 +310,11 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
} else {
""
}))
// Small type glyph: a folder box for directories, a
// document for media files. The widget crate ships no
// folder/file SVGs, so the rows use text glyphs (the
// host's icon set is timeline/viewer tools only).
.child(div().w(px(16.0)).child(if entry.is_dir { "" } else { "" }))
.child(div().child(entry.name.clone()));
if !is_selected {
row = row.hover(|style| style.bg(Hsla::from(colors.selected).opacity(0.3)));
+11 -3
View File
@@ -194,6 +194,9 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
let mut picture = div()
.id("gpui-widgets-viewer-picture")
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.relative()
.bg(gpui::Hsla {
h: 0.0,
@@ -213,9 +216,12 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
.size_full()
.object_fit(fit)
.into_any(),
ViewerFrameSource::CpuFrame(image) => {
img(image.clone()).size_full().object_fit(fit).into_any()
}
ViewerFrameSource::CpuFrame(image) => img(image.clone())
.size_full()
.min_w_0()
.min_h_0()
.object_fit(fit)
.into_any(),
};
picture = picture.child(picture_element);
} else {
@@ -395,6 +401,8 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
.size_full()
.flex()
.flex_col()
.min_w_0()
.overflow_hidden()
.child(picture)
.child(transport_bar)
}