feat(app): UI walkthrough pass — faithful screenshots, dock labels, timeline clips, status bar, meter

- screenshot example inits i18n and captures both zh-CN and en-US
  (docs/screenshot-window{,-en}.png)
- dock tabs size to content (no more 64px truncation); viewer/panel
  titles follow the design (素材查看器·name etc.)
- mock project carries V1/V2 video + A1/A2 audio clips rendered as
  rounded green bars; timeline clip geometry/rounded corners match the
  design; status bar visible with full content; audio meter strip
  docked at the program viewer's right edge; project explorer rows
  have icons
- tests/waveform_e2e.rs: waveform cache extracts real peaks and hits
  cache on re-query (P4 acceptance)
This commit is contained in:
2026-08-13 23:37:57 +08:00
parent c5287ff234
commit f0517fe6af
16 changed files with 506 additions and 94 deletions
+29 -7
View File
@@ -46,7 +46,7 @@ use gpui::{
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, PathPromptOptions,
Render, Window, WindowBounds, WindowOptions,
};
use gpui_widgets::audio_meter::AudioLevelMeter;
use gpui_widgets::audio_meter::{AudioLevelMeter, MeterOrientation};
use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent};
use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions};
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
@@ -204,8 +204,10 @@ impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
)),
"program-viewer" => Some(PanelHandle::new(
cx.new(|cx| {
let meter =
cx.new(|cx| AudioLevelMeter::new(30, self.engine.clone(), window, cx));
let meter = cx.new(|cx| {
AudioLevelMeter::new(30, self.engine.clone(), window, cx)
.with_orientation(MeterOrientation::Vertical)
});
ProgramViewerPanel::new(
self.engine.clone(),
self.program_clock.clone(),
@@ -279,7 +281,10 @@ impl<E: AppEngine> OakApp<E> {
));
timeline.update(cx, |view, _| view.set_clip_decorator(decorator));
}
let meter = cx.new(|cx| AudioLevelMeter::new(3, engine.clone(), window, cx));
let meter = cx.new(|cx| {
AudioLevelMeter::new(3, engine.clone(), window, cx)
.with_orientation(MeterOrientation::Vertical)
});
// --- menu bar ------------------------------------------------------
let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(true), window, cx));
@@ -375,10 +380,27 @@ impl<E: AppEngine> OakApp<E> {
);
});
// Tune the default split ratios: top 70%, project bin 17% of the row.
// Tune the default split ratios: viewers 60% / timeline 40%, project
// bin 17% of the row. The timeline share leaves room for all four
// tracks (V2/V1 video + A1/A2 audio) plus the ruler and toolbar at
// 1600×900; the viewers keep the remaining ~60%. The program viewer
// (with its audio level strip) is the active tab of its group, so the
// shell opens on the design's visible 素材查看器 | 序列查看器 row rather
// than on the node editor.
let mut layout: DockLayout = dock.read(cx).layout().clone();
layout.resize_split(&NodePath(vec![]), 0.70);
layout.resize_split(&NodePath(vec![]), 0.60);
layout.resize_split(&NodePath(vec![0]), 0.17);
// The program viewer's transport row (six transport buttons, the
// timecode, the 安全框/缩放 toggles) plus its 26px meter strip needs
// ~430px at 1600×900 — more than an equal share of the row gives it,
// and the design makes the program monitor the prominent viewer. Tilt
// the source/program and program/inspector boundaries accordingly so
// the transport's trailing toggles are not clipped.
layout.resize_split_child(&NodePath(vec![0]), 1, 0.52);
layout.resize_split_child(&NodePath(vec![0]), 2, 0.62);
if let Some(path) = layout.find_panel(PROGRAM_VIEWER) {
layout.set_tabs_active(&path, PROGRAM_VIEWER);
}
dock.update(cx, |dock, cx| dock.set_layout(layout, cx));
// --- status bar ----------------------------------------------------
@@ -962,7 +984,7 @@ impl<E: AppEngine> Render for OakApp<E> {
.flex()
.flex_col()
.child(self.menu_bar.clone())
.child(div().flex_1().child(self.dock.clone()))
.child(div().flex_1().min_h_0().child(self.dock.clone()))
.child(self.status_bar.clone());
if let Some(modal) = self.modal.modal_entity() {
root = root.child(modal);
-6
View File
@@ -268,9 +268,6 @@ const EN: &[(&str, &str)] = &[
("history.set_in_point", "Set In Point"),
// --- node editor ---
("node.fit", "Fit"),
// --- viewer header chips ---
("viewer.source", "Source Viewer · Source"),
("viewer.program", "Program Viewer · Program"),
// --- program viewer tabs and scope labels ---
("viewer.picture", "Picture"),
("viewer.scopes", "Scopes"),
@@ -418,9 +415,6 @@ const ZH: &[(&str, &str)] = &[
("history.set_in_point", "设置入点"),
// --- node editor ---
("node.fit", "适配"),
// --- viewer header chips ---
("viewer.source", "素材查看器 · 源"),
("viewer.program", "序列查看器 · 节目"),
// --- program viewer tabs and scope labels ---
("viewer.picture", "画面"),
("viewer.scopes", "示波器"),
+7
View File
@@ -117,6 +117,13 @@ pub trait EngineGateway: Sized {
/// The current sequence of the open project, if any.
fn current_sequence(&self) -> Option<&Sequence>;
/// The display name of the source media shown in the source viewer, used
/// in the viewer header and dock tab. Empty when the engine has no source
/// media loaded.
fn source_media_name(&self) -> String {
String::new()
}
/// Open a project file. The backend loads it and becomes the source of
/// truth for [`project`](EngineGateway::project) /
/// [`current_sequence`](EngineGateway::current_sequence).
+21 -9
View File
@@ -445,16 +445,20 @@ impl MockEngine {
label: label.into(),
color,
};
let video = |h: f32| Hsla {
h,
s: 0.55,
l: 0.45,
// Clip colors follow the design's timeline: video and audio clips are
// green bars (#48a26d), slightly lighter for audio. The `h` argument
// is kept so the call sites read like before; every demo clip shares
// the design accent.
let video = |_h: f32| Hsla {
h: 0.402,
s: 0.385,
l: 0.459,
a: 1.0,
};
let audio = |h: f32| Hsla {
h,
s: 0.45,
l: 0.55,
let audio = |_h: f32| Hsla {
h: 0.402,
s: 0.32,
l: 0.54,
a: 1.0,
};
let node_color = |h: f32| Hsla {
@@ -948,7 +952,9 @@ impl MockEngine {
/// The demo audio levels: animated while the program monitor plays.
fn meter_levels(&self) -> Vec<f32> {
if !self.program_playing {
return vec![0.03, 0.03];
// Idle rest level: low but visible, so the program viewer's level
// strip reads as alive (the design shows a lit green strip).
return vec![0.42, 0.35];
}
let t = (self.meter_phase % 120) as f32 / 120.0 * std::f32::consts::TAU;
let level = 0.25 + 0.55 * (t.sin() * 0.6 + (2.0 * t).sin() * 0.4).abs();
@@ -969,6 +975,12 @@ impl EngineGateway for MockEngine {
Some(&self.sequence)
}
fn source_media_name(&self) -> String {
// The demo "source" media shown in the source viewer: the first
// footage item of the mock project.
"第一稿.mp4".into()
}
fn open_project(&mut self, path: PathBuf, cx: &mut Context<Self>) {
self.project.path = path;
if let Some(name) = self.project.path.file_stem() {
+16 -3
View File
@@ -63,13 +63,26 @@ impl Render for HistoryPanel {
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.px_3()
.py_1()
.text_color(colors.text)
.child(div().child(label))
.child(div().text_color(colors.disabled).child(*timestamp)),
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(label),
)
.child(
div()
.flex_shrink_0()
.whitespace_nowrap()
.text_color(colors.disabled)
.child(*timestamp),
),
);
}
div().size_full().flex().flex_col().child(list)
+14
View File
@@ -68,3 +68,17 @@ pub(crate) fn chip(colors: &gpui::colors::Colors, label: impl gpui::IntoElement)
.text_color(colors.text)
.child(label)
}
/// The viewer panel title, per the design: `<面板>·<素材/序列名>` in zh-CN,
/// `<Panel> · <name>` in en-US — no redundant "Source"/"Program" placeholder
/// suffix. The panel key is the localized panel name; `name` is the media or
/// sequence name (data, not translated).
pub(crate) fn viewer_title(panel_key: &'static str, name: &str) -> String {
if name.is_empty() {
return crate::i18n::tr(panel_key).to_owned();
}
match crate::i18n::language() {
crate::i18n::Language::ZhCN => format!("{}·{}", crate::i18n::tr(panel_key), name),
crate::i18n::Language::EnUs => format!("{} · {}", crate::i18n::tr(panel_key), name),
}
}
+37 -7
View File
@@ -32,8 +32,8 @@ use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
use crate::oakui::timecode::{format_fps, format_resolution};
use crate::oakui::{AppEngine, Monitor};
use crate::panels::chip;
use crate::panels::ids::PROGRAM_VIEWER;
use crate::panels::{chip, viewer_title};
/// Width of the audio level strip, per the design (26px).
const METER_WIDTH: f32 = 26.0;
@@ -199,15 +199,32 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
.current_sequence()
.map(|sequence| sequence.format)
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
let sequence_name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
let title = viewer_title("panel.program_viewer", &sequence_name);
let body = match self.tab {
ProgramViewTab::Picture => div()
.flex_1()
.flex()
.child(div().flex_1().child(self.viewer.clone()))
.min_h_0()
.min_w_0()
.child(
div()
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.child(self.viewer.clone()),
)
.child(
div()
.w(px(METER_WIDTH))
.flex_shrink_0()
.border_l_1()
.border_color(colors.border)
.child(self.meter.clone()),
@@ -252,6 +269,7 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
.size_full()
.flex()
.flex_col()
.overflow_hidden()
.child(
div()
.flex()
@@ -261,7 +279,7 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
.py_1()
.border_b_1()
.border_color(colors.border)
.child(chip(&colors, crate::i18n::tr("viewer.program")))
.child(chip(&colors, title))
.child(chip(
&colors,
format_resolution(format.width, format.height),
@@ -293,13 +311,25 @@ impl<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
PROGRAM_VIEWER
}
fn title(&self, _cx: &App) -> SharedString {
crate::i18n::tr("panel.program_viewer").into()
fn title(&self, cx: &App) -> SharedString {
let name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
viewer_title("panel.program_viewer", &name).into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
fn tab_content(&self, cx: &App) -> AnyElement {
let name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
div()
.child(crate::i18n::tr("panel.program_viewer"))
.child(viewer_title("panel.program_viewer", &name))
.into_any_element()
}
}
+20 -7
View File
@@ -26,7 +26,7 @@ use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
use crate::oakui::timecode::{format_fps, format_resolution};
use crate::oakui::{AppEngine, Monitor};
use crate::panels::chip;
use crate::panels::{chip, viewer_title};
use crate::panels::ids::SOURCE_VIEWER;
/// The source viewer panel.
@@ -94,11 +94,14 @@ impl<E: AppEngine> Render for SourceViewerPanel<E> {
.current_sequence()
.map(|sequence| sequence.format)
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
let media = self.engine.read(cx).source_media_name();
let title = viewer_title("panel.source_viewer", &media);
div()
.size_full()
.flex()
.flex_col()
.overflow_hidden()
.child(
div()
.flex()
@@ -108,14 +111,21 @@ impl<E: AppEngine> Render for SourceViewerPanel<E> {
.py_1()
.border_b_1()
.border_color(colors.border)
.child(chip(&colors, crate::i18n::tr("viewer.source")))
.child(chip(&colors, title))
.child(chip(
&colors,
format_resolution(format.width, format.height),
))
.child(chip(&colors, format_fps(format.rate))),
)
.child(div().flex_1().child(self.viewer.clone()))
.child(
div()
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.child(self.viewer.clone()),
)
}
}
@@ -126,13 +136,16 @@ impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {
SOURCE_VIEWER
}
fn title(&self, _cx: &App) -> SharedString {
crate::i18n::tr("panel.source_viewer").into()
fn title(&self, cx: &App) -> SharedString {
viewer_title("panel.source_viewer", &self.engine.read(cx).source_media_name()).into()
}
fn tab_content(&self, _cx: &App) -> AnyElement {
fn tab_content(&self, cx: &App) -> AnyElement {
div()
.child(crate::i18n::tr("panel.source_viewer"))
.child(viewer_title(
"panel.source_viewer",
&self.engine.read(cx).source_media_name(),
))
.into_any_element()
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ impl<E: AppEngine> Render for StatusBar<E> {
))
.child(div().px_2().text_color(colors.disabled).child(project))
.child(div().px_2().text_color(colors.disabled).child(format!(
"{} · {}",
"{} {}",
crate::i18n::tr("status.backend"),
self.engine.read(cx).backend_name(),
)))
+2 -18
View File
@@ -37,9 +37,7 @@
//! narrow widths the sliders squeezed into the ruler's right side). They
//! now live in a fixed-width trailing slot beside the timeline body, and the
//! timeline wrapper is `min_w_0` so the ruler always keeps the remaining
//! space — no overlap at 1600×900 or down to ~1100px wide. On hidpi (2x)
//! displays the render compensates for a gpui view-positioning quirk with a
//! top padding on the timeline canvas (see the note in [`Render::render`]).
//! space — no overlap at 1600×900 or down to ~1100px wide.
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
@@ -161,22 +159,9 @@ impl<E: AppEngine> TimelinePanel<E> {
}
impl<E: AppEngine> Render for TimelinePanel<E> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
// The gpui view-positioning code prepaints a view's root at the panel
// content origin rather than at its flex wrapper's position, which is
// only noticeable on hidpi (2x) displays where the fixed 31px toolbar
// row and the timeline view's own 32px ruler row would otherwise
// overlap. The canvas wrapper carries a compensating top padding on
// hidpi so the ruler lands just below the toolbar; at 1x the layout
// is already correct and no padding is applied.
let view_offset = if window.scale_factor() > 1.5 {
TOOLBAR_HEIGHT + 2.0
} else {
0.0
};
// --- toolbar row (fixed 31px, above the ruler) --------------------
let mut toolbar = div()
.debug_selector(|| "timeline-toolbar".into())
@@ -345,7 +330,6 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
.debug_selector(|| "timeline-canvas".into())
.flex_1()
.min_w_0()
.pt(px(view_offset))
.child(self.timeline.clone()),
)
.child(right_controls),