From dea33c713f3eeaade8adf3a435d1aeae68cb394e Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 20 Aug 2026 17:43:44 +0800 Subject: [PATCH] style: professional dark theme refresh aligned with the design spec - oak theme palette to a denser professional dark (near-black base, container/window surfaces, subtler secondary text). - Dock tabs: selected tab solid accent with white text, hairline separators between panels. - Timeline: green clip blocks, blue playhead, neutral track headers, brighter ruler labels, deep-green waveforms. - Viewer: green timecode readout, solid-accent play button, chip-style fit/safe-margin buttons. - Effect stack cards and node cards get rounded borders; spinbox values in amber per the design. --- crates/gpui/src/dock/split_handle.rs | 33 ++++++++---- crates/gpui/src/dock/tab_bar.rs | 26 +++++++++- crates/gpui/src/effect_stack/stack_view.rs | 33 ++++++++---- crates/gpui/src/node_graph/node_element.rs | 3 +- crates/gpui/src/timeline/ruler.rs | 8 +-- crates/gpui/src/timeline/timeline_view.rs | 11 ++-- crates/gpui/src/timeline/track_header.rs | 40 ++++++--------- crates/gpui_widgets/src/spinbox.rs | 3 ++ crates/gpui_widgets/src/theme.rs | 16 +++--- crates/gpui_widgets/src/viewer/mod.rs | 58 ++++++++++++++++++++-- 10 files changed, 167 insertions(+), 64 deletions(-) diff --git a/crates/gpui/src/dock/split_handle.rs b/crates/gpui/src/dock/split_handle.rs index 5eac0c3d4d..a6454ca926 100644 --- a/crates/gpui/src/dock/split_handle.rs +++ b/crates/gpui/src/dock/split_handle.rs @@ -9,7 +9,8 @@ use crate::colors::DefaultColors; use crate::{ App, AppContext, Axis, ClickEvent, Context, ElementId, EventEmitter, InteractiveElement, - IntoElement, Pixels, Point, Render, StatefulInteractiveElement, Styled, Window, div, px, + IntoElement, ParentElement, Pixels, Point, Render, StatefulInteractiveElement, Styled, Window, + div, px, }; use super::{NodePath, path_key}; @@ -194,13 +195,21 @@ impl Render for SplitHandle { cx.new(|_cx| SplitDragGhost { direction }) }; + // The hitbox stays HITBOX-wide for grabbability, but the visible + // divider is a centered 1px hairline — the design separates panels + // with thin lines, not thick bars. + let line = div() + .flex_none() + .bg(colors.separator); let mut root = div() .id(ElementId::named_usize( "dock-split-handle", path_key(&self.path), )) .flex_none() - .bg(colors.separator) + .flex() + .items_center() + .justify_center() .on_click(cx.listener(move |this, event: &ClickEvent, _window, cx| { if event.click_count() >= 2 { this.reset(cx); @@ -209,14 +218,18 @@ impl Render for SplitHandle { // A horizontal split stacks children side by side, so its divider is // a vertical bar and vice versa. - match direction { - Axis::Horizontal => { - root = root.w(px(Self::HITBOX.0)).h_full().cursor_col_resize(); - } - Axis::Vertical => { - root = root.w_full().h(px(Self::HITBOX.0)).cursor_row_resize(); - } - } + root = match direction { + Axis::Horizontal => root + .w(px(Self::HITBOX.0)) + .h_full() + .cursor_col_resize() + .child(line.w(px(1.0)).h_full()), + Axis::Vertical => root + .w_full() + .h(px(Self::HITBOX.0)) + .cursor_row_resize() + .child(line.w_full().h(px(1.0))), + }; root.on_drag( SplitHandleDrag { diff --git a/crates/gpui/src/dock/tab_bar.rs b/crates/gpui/src/dock/tab_bar.rs index bf92b6f164..7c1ed30064 100644 --- a/crates/gpui/src/dock/tab_bar.rs +++ b/crates/gpui/src/dock/tab_bar.rs @@ -244,6 +244,11 @@ impl Render for TabBar { .h(px(26.0)) .w_full() .overflow_hidden() + // The strip reads as raised chrome above the panel content, closed + // off by a hairline border (per the design's dense panel headers). + .bg(colors.container) + .border_b_1() + .border_color(colors.border) .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _window, cx| { let delta = match event.delta { ScrollDelta::Pixels(delta) => delta.x.0, @@ -278,6 +283,9 @@ impl Render for TabBar { for (index, &panel) in self.tabs.iter().enumerate() { let active = index == self.active; + // The hover fill for inactive tabs, hoisted out of the closure so + // the shared `colors` isn't moved (it is behind an `Arc`). + let inactive_hover = colors.background; let title = self .titles .get(index) @@ -310,12 +318,26 @@ impl Render for TabBar { .whitespace_nowrap() .cursor_pointer() .text_sm() + // The active tab is the accent-filled chip of the design; + // inactive tabs sit flat on the strip with dimmed labels and + // surface on hover. .bg(if active { colors.selected } else { - colors.background + colors.container + }) + .text_color(if active { + colors.selected_text + } else { + colors.disabled + }) + .hover(move |style| { + if active { + style + } else { + style.bg(inactive_hover) + } }) - .text_color(if active { colors.text } else { colors.disabled }) .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { this.activate(index, window, cx); })) diff --git a/crates/gpui/src/effect_stack/stack_view.rs b/crates/gpui/src/effect_stack/stack_view.rs index 9e3f18e5ab..a421a17757 100644 --- a/crates/gpui/src/effect_stack/stack_view.rs +++ b/crates/gpui/src/effect_stack/stack_view.rs @@ -439,7 +439,15 @@ impl Render for EffectStackView { .child(label), ); - let mut column = div().id("effect-stack-cards").flex().flex_col().w_full(); + // Cards breathe as separate rounded panels (the design's stack), not + // as a contiguous list. + let mut column = div() + .id("effect-stack-cards") + .flex() + .flex_col() + .w_full() + .gap_2() + .p_2(); for (index, effect) in effects.iter().enumerate() { let id = effect.id(); @@ -458,7 +466,9 @@ impl Render for EffectStackView { } // Header row: drag handle, enable toggle, the card itself - // (flexing to fill), and the remove button. + // (flexing to fill), and the remove button. Effect cards get the + // design's two-tone treatment: a raised header strip over the + // darker card body. let mut header_row = div() .id(ElementId::named_usize("effect-header", id.0 as usize)) .flex() @@ -471,6 +481,9 @@ impl Render for EffectStackView { cx.stop_propagation(); this.context_menu(id, event.position(), cx); })); + if !fixed { + header_row = header_row.bg(colors.container); + } if !fixed { header_row = header_row.cursor_pointer().on_click(cx.listener( @@ -545,15 +558,17 @@ impl Render for EffectStackView { .border_1() .border_color(if selected_effect == Some(id) { colors.selected - } else if fixed { + } else { colors.border - } else { - colors.separator }) - .bg(if fixed { - colors.container - } else { - colors.background + // The source card is the design's accent-tinted media bar; the + // output card and effect bodies stay neutral. + .bg(match effect.kind() { + EffectCardKind::Source => { + crate::Background::from(crate::Hsla::from(colors.selected).opacity(0.3)) + } + EffectCardKind::Output => crate::Background::from(colors.container), + EffectCardKind::Effect => crate::Background::from(colors.background), }) .overflow_hidden(); wrapper = wrapper.child(header_row); diff --git a/crates/gpui/src/node_graph/node_element.rs b/crates/gpui/src/node_graph/node_element.rs index 37f49f8a75..8473694cc4 100644 --- a/crates/gpui/src/node_graph/node_element.rs +++ b/crates/gpui/src/node_graph/node_element.rs @@ -256,9 +256,10 @@ impl NodeElement { window.paint_quad(fill(bounds, colors.background)); // Border quad: transparent fill, themed border (accent when selected). + // The 6px corner matches the design's softly rounded node cards. window.paint_quad(PaintQuad { bounds, - corner_radii: Corners::all(px(4.0) * zoom), + corner_radii: Corners::all(px(6.0) * zoom), background: hsla(0.0, 0.0, 0.0, 0.0).into(), border_widths: Edges::all(if self.visual.selected { px(1.5) diff --git a/crates/gpui/src/timeline/ruler.rs b/crates/gpui/src/timeline/ruler.rs index c8e94cd02a..9c1fb0f70b 100644 --- a/crates/gpui/src/timeline/ruler.rs +++ b/crates/gpui/src/timeline/ruler.rs @@ -240,9 +240,11 @@ impl RenderOnce for TimelineRuler { }, move |bounds, content, window, cx| { let baseline_color = hsla(0.0, 0.0, 0.5, 0.5); - let major_color = hsla(0.0, 0.0, 0.6, 0.9); - let minor_color = hsla(0.0, 0.0, 0.6, 0.45); - let text_color = hsla(0.0, 0.0, 0.5, 1.0); + let major_color = hsla(0.0, 0.0, 0.55, 0.9); + let minor_color = hsla(0.0, 0.0, 0.55, 0.4); + // Ruler labels read as the design's muted light gray against + // the near-black ruler strip. + let text_color = hsla(0.0, 0.0, 0.62, 1.0); let band_color = hsla(0.63, 0.55, 0.55, 0.10); let bottom = bounds.bottom(); diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index 4691216335..67c4d87c39 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -1655,9 +1655,9 @@ fn drag_ghost( cx.new(|_cx| DragPreview) } -/// The playhead line color. +/// The playhead line color: the design's accent-blue vertical line. fn playhead_color() -> Hsla { - hsla(0.0, 0.0, 0.9, 0.9) + hsla(0.60, 0.90, 0.60, 1.0) } /// Reshapes the work-area band for a ruler drag move. @@ -1930,10 +1930,13 @@ mod tests { } /// The default body color for clips on a track of `kind`. +/// +/// Per the design both media kinds read as green bars (the audio waveform +/// supplies the contrast); subtitles keep the amber family. fn kind_color(kind: TrackKind) -> Hsla { match kind { - TrackKind::Video => hsla(0.58, 0.45, 0.35, 1.0), - TrackKind::Audio => hsla(0.35, 0.45, 0.35, 1.0), + TrackKind::Video => hsla(0.40, 0.44, 0.43, 1.0), + TrackKind::Audio => hsla(0.38, 0.45, 0.36, 1.0), TrackKind::Subtitle => hsla(0.10, 0.45, 0.35, 1.0), } } diff --git a/crates/gpui/src/timeline/track_header.rs b/crates/gpui/src/timeline/track_header.rs index 510dc65360..427701b145 100644 --- a/crates/gpui/src/timeline/track_header.rs +++ b/crates/gpui/src/timeline/track_header.rs @@ -21,7 +21,10 @@ //! `TimelineEvent::TrackToggleRequested`); without a handler they render as //! inert status glyphs. -use crate::{App, ClickEvent, ElementId, Hsla, SharedString, Window, div, hsla, prelude::*, px}; +use crate::{ + App, ClickEvent, ElementId, SharedString, Window, colors::DefaultColors, div, hsla, prelude::*, + px, +}; use super::data::TrackKind; @@ -123,24 +126,6 @@ impl TrackHeader { self.index } - /// The background tint for a track of `kind`. - fn kind_background(kind: TrackKind) -> Hsla { - match kind { - TrackKind::Video => hsla(0.58, 0.45, 0.32, 0.18), - TrackKind::Audio => hsla(0.35, 0.45, 0.32, 0.18), - TrackKind::Subtitle => hsla(0.10, 0.45, 0.32, 0.18), - } - } - - /// The label color for a track of `kind`. - fn kind_text(kind: TrackKind) -> Hsla { - match kind { - TrackKind::Video => hsla(0.58, 0.35, 0.85, 1.0), - TrackKind::Audio => hsla(0.35, 0.35, 0.85, 1.0), - TrackKind::Subtitle => hsla(0.10, 0.35, 0.85, 1.0), - } - } - /// A small toggle glyph (one or two letters) reflecting `active`. When an /// [`Self::on_toggle`] handler is installed the glyph is a click target /// emitting `event`; the click stops propagating so it never toggles the @@ -205,14 +190,16 @@ impl TrackHeader { } impl RenderOnce for TrackHeader { - fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let background = Self::kind_background(self.kind); - let text = Self::kind_text(self.kind); + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + // Neutral chrome per the design: the header cell sits on the panel + // container color with primary-text labels; the track kind is conveyed + // by the toggle glyphs, not by a tinted background. + let colors = cx.default_colors().clone(); let separator_height = px(TrackHeader::SEPARATOR_HEIGHT); div() .size_full() - .bg(background) + .bg(colors.container) .flex() .flex_col() .child( @@ -223,7 +210,12 @@ impl RenderOnce for TrackHeader { .items_center() .gap(px(6.)) .px_2() - .child(div().text_sm().text_color(text).child(self.name.clone())) + .child( + div() + .text_sm() + .text_color(colors.text) + .child(self.name.clone()), + ) .child(div().flex_1()) .child(self.toggle_row()), ) diff --git a/crates/gpui_widgets/src/spinbox.rs b/crates/gpui_widgets/src/spinbox.rs index 91cbe5bf7b..e0526b3cc9 100644 --- a/crates/gpui_widgets/src/spinbox.rs +++ b/crates/gpui_widgets/src/spinbox.rs @@ -173,6 +173,9 @@ impl Render for SpinBox { .border_1() .border_color(colors.border) .bg(colors.background) + // Editable values render in the theme's amber (the + // design's gold numerals), distinct from labels. + .text_color(crate::theme::current_theme(cx).link) .px_1() .flex() .items_center() diff --git a/crates/gpui_widgets/src/theme.rs b/crates/gpui_widgets/src/theme.rs index 8de2bafd10..49edd370ca 100644 --- a/crates/gpui_widgets/src/theme.rs +++ b/crates/gpui_widgets/src/theme.rs @@ -45,18 +45,22 @@ fn rgba(hex: u32) -> Rgba { impl OakTheme { /// The olive-dark palette (oak's default). + /// + /// Tuned to the design's professional dark scheme: a near-black content + /// base, slightly raised panel containers, a low-contrast separator and a + /// dimmed secondary text — the blue accent stays the single saturated hue. pub fn olive_dark() -> Self { Self { name: "Olive Dark".into(), - window: rgba(0x353535), - base: rgba(0x191919), - alternate_base: rgba(0x353535), - text: rgb(0xffffff), + window: rgba(0x23272D), + base: rgba(0x16181D), + alternate_base: rgba(0x2E333B), + text: rgb(0xE8EAED), accent: rgba(0x2A82DA), accent_text: rgb(0xffffff), link: rgba(0xE0B040), - disabled_text: rgba(0xA0A0A0), - disabled_button_text: rgba(0x808080), + disabled_text: rgba(0x7D838C), + disabled_button_text: rgba(0x565C66), } } diff --git a/crates/gpui_widgets/src/viewer/mod.rs b/crates/gpui_widgets/src/viewer/mod.rs index eaff0511d5..8fd0279b32 100644 --- a/crates/gpui_widgets/src/viewer/mod.rs +++ b/crates/gpui_widgets/src/viewer/mod.rs @@ -414,11 +414,15 @@ impl Render for ViewerWidget { .px_2() .py_0p5() .bg(colors.container) + .border_t_1() + .border_color(colors.border) .child(transport_button( "gpui-widgets-viewer-in", in_icon, "⏮", crate::i18n::tr("viewer.in_point", "入点"), + false, + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { this.emit( ViewerEvent::InPointRequested { @@ -433,6 +437,8 @@ impl Render for ViewerWidget { step_back_icon, "⏪", crate::i18n::tr("viewer.step_back", "上一帧"), + false, + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { this.emit( ViewerEvent::StepRequested { @@ -452,6 +458,8 @@ impl Render for ViewerWidget { } else { crate::i18n::tr("viewer.play", "播放") }, + true, + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { let event = if this.transport.playing { ViewerEvent::PauseRequested { @@ -470,6 +478,8 @@ impl Render for ViewerWidget { step_forward_icon, "⏩", crate::i18n::tr("viewer.step_forward", "下一帧"), + false, + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { this.emit( ViewerEvent::StepRequested { @@ -485,6 +495,8 @@ impl Render for ViewerWidget { out_icon, "⏭", crate::i18n::tr("viewer.out_point", "出点"), + false, + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { this.emit( ViewerEvent::OutPointRequested { @@ -499,6 +511,8 @@ impl Render for ViewerWidget { None, x_glyph(colors.text), crate::i18n::tr("viewer.clear_range", "清除入出点"), + false, + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { this.emit( ViewerEvent::ClearRangeRequested { @@ -508,11 +522,19 @@ impl Render for ViewerWidget { ); }), )) - .child(div().px_2().text_xs().text_color(colors.text).child(timecode)) + // The current timecode reads in the design's bright green. + .child( + div() + .px_2() + .text_xs() + .text_color(gpui::hsla(0.33, 0.75, 0.62, 1.0)) + .child(timecode), + ) .child(div().flex_1()) .child(button( "gpui-widgets-viewer-safe", crate::i18n::tr("viewer.safe_frames", "安全框"), + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { this.show_safe_frames = !this.show_safe_frames; this.emit( @@ -526,6 +548,7 @@ impl Render for ViewerWidget { .child(button( "gpui-widgets-viewer-zoom", crate::i18n::tr("viewer.zoom", "缩放"), + &colors, cx.listener(|this, _event: &ClickEvent, _window, cx| { this.zoom = !this.zoom; this.emit( @@ -550,14 +573,20 @@ impl Render for ViewerWidget { /// A transport icon button: a 16px icon on a 24px hit target with a /// localized tooltip. Falls back to the `fallback` glyph when `icon` is -/// `None` (no resolver registered, or no file for the name). +/// `None` (no resolver registered, or no file for the name). `primary` marks +/// the design's accent-filled play/pause button; the rest are flat buttons +/// that surface on hover. fn transport_button( id: &'static str, icon: Option, fallback: impl IntoElement, tooltip: SharedString, + primary: bool, + colors: &gpui::colors::Colors, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> impl IntoElement { + let background = colors.selected; + let hover = colors.container; let mut el = div() .id(id) .debug_selector(move || id.into()) @@ -568,7 +597,19 @@ fn transport_button( .justify_center() .rounded_md() .cursor_pointer() - .hover(|style| style.bg(gpui::colors::Colors::dark().selected)) + .text_color(if primary { + colors.selected_text + } else { + colors.text + }) + .when(primary, |style| style.bg(background)) + .hover(move |style| { + if primary { + style.bg(background) + } else { + style.bg(hover) + } + }) .tooltip(move |window, cx| tooltip_view(tooltip.clone(), window, cx)) .on_click(on_click); if let Some(path) = icon { @@ -609,20 +650,27 @@ fn x_glyph(color: gpui::Rgba) -> impl IntoElement { ) } -/// A small labeled button. +/// A small labeled button, styled as the design's bordered chip (the "适合 / +/// 安全框" controls at the transport bar's right end). fn button( id: &'static str, label: impl IntoElement, + colors: &gpui::colors::Colors, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> impl IntoElement { + let border = colors.border; + let hover = colors.separator; div() .id(id) .debug_selector(move || id.into()) .px_2() .py_0p5() .rounded_md() + .border_1() + .border_color(border) + .text_color(colors.text) .cursor_pointer() - .hover(|style| style.bg(gpui::colors::Colors::dark().selected)) + .hover(move |style| style.bg(hover)) .on_click(on_click) .child(label) }