feat(gpui): content-width dock tabs, rounded timeline clips
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -71,6 +71,7 @@ impl RenderOnce for PlayheadElement {
|
||||
window.paint_path(path, color);
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ impl RenderOnce for TimelineRuler {
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user