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:
+29
-7
@@ -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);
|
||||
|
||||
@@ -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", "示波器"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user