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.
This commit is contained in:
2026-08-20 17:43:44 +08:00
parent 3870d6da06
commit dea33c713f
10 changed files with 167 additions and 64 deletions
+23 -10
View File
@@ -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 {
+24 -2
View File
@@ -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);
}))
+24 -9
View File
@@ -439,7 +439,15 @@ impl<D: EffectStackDataSource> Render for EffectStackView<D> {
.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<D: EffectStackDataSource> Render for EffectStackView<D> {
}
// 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<D: EffectStackDataSource> Render for EffectStackView<D> {
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<D: EffectStackDataSource> Render for EffectStackView<D> {
.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);
+2 -1
View File
@@ -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)
+5 -3
View File
@@ -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();
+7 -4
View File
@@ -1655,9 +1655,9 @@ fn drag_ghost<T>(
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),
}
}
+16 -24
View File
@@ -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()),
)
+3
View File
@@ -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()
+10 -6
View File
@@ -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),
}
}
+53 -5
View File
@@ -414,11 +414,15 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
.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<C: PlaybackClock> Render for ViewerWidget<C> {
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<C: PlaybackClock> Render for ViewerWidget<C> {
} 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<C: PlaybackClock> Render for ViewerWidget<C> {
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<C: PlaybackClock> Render for ViewerWidget<C> {
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<C: PlaybackClock> Render for ViewerWidget<C> {
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<C: PlaybackClock> Render for ViewerWidget<C> {
);
}),
))
.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<C: PlaybackClock> Render for ViewerWidget<C> {
.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<C: PlaybackClock> Render for ViewerWidget<C> {
/// 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<std::path::PathBuf>,
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)
}