viewer: zoom levels, safe margins, fps overlay, waveform-mode state

Real state behind the context menu: percentage zoom levels (Fit plus
10..800%), parameterised safe margins replacing the hardcoded 560x315
overlay, an EMA fps counter drawn in the corner, and the waveform-mode
enum mirrored to the app config.
This commit is contained in:
2026-08-27 17:39:03 +08:00
parent 50f78b57c8
commit fb8d726fa3
+313 -27
View File
@@ -107,6 +107,146 @@ pub enum InteractPointerKind {
Up,
}
/// The zoom state of the viewer picture.
///
/// [`ViewerZoom::Fit`] fits the whole frame inside the picture area (like the
/// old contain mode); [`ViewerZoom::Level`] renders the frame at a fixed
/// multiple of its native resolution, clipped to the picture area.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewerZoom {
/// Fit the entire frame (contain).
Fit,
/// A fixed multiple of the native resolution (index into
/// [`VIEWER_ZOOM_LEVELS`]).
Level(usize),
}
impl ViewerZoom {
/// The next zoom in the cycle used by the toolbar zoom button: Fit → 100%
/// → 200% → Fit; any other level steps up by one.
pub fn next(self) -> Self {
match self {
ViewerZoom::Fit => ViewerZoom::Level(4),
ViewerZoom::Level(i) if i + 1 >= VIEWER_ZOOM_LEVELS.len() => ViewerZoom::Fit,
ViewerZoom::Level(i) if i == 3 => ViewerZoom::Level(7),
ViewerZoom::Level(i) => ViewerZoom::Level(i + 1),
}
}
}
/// The safe-frame margin overlay.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SafeMargins {
/// No overlay.
Off,
/// The standard 90% × 80% action-safe margins.
On,
/// Custom horizontal/vertical fractions of the frame (e.g. 0.9 × 0.8).
Custom(f32, f32),
}
impl SafeMargins {
/// The next state in the toolbar cycle: Off → On → Off; custom behaves
/// like On (cycles back to Off).
pub fn next(self) -> Self {
match self {
SafeMargins::Off => SafeMargins::On,
SafeMargins::On | SafeMargins::Custom(_, _) => SafeMargins::Off,
}
}
/// The frame fractions the overlay covers, or `None` when disabled.
pub fn ratio(self) -> Option<(f32, f32)> {
match self {
SafeMargins::Off => None,
SafeMargins::On => Some((0.9, 0.8)),
SafeMargins::Custom(w, h) => Some((w, h)),
}
}
}
/// How the audio waveform overlay behaves.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WaveformMode {
/// Show the waveform only while playing.
Automatic = 0,
/// Show the waveform always (never hide it).
Only = 1,
/// Show both the waveform and the picture.
Both = 2,
}
impl WaveformMode {
/// The persisted config value (`0`/`1`/`2`).
pub fn config_value(self) -> i32 {
self as i32
}
/// Reconstruct from a persisted config value, clamping out-of-range
/// values back to [`WaveformMode::Automatic`].
pub fn from_config_value(value: i32) -> Self {
match value.clamp(0, 2) {
1 => WaveformMode::Only,
2 => WaveformMode::Both,
_ => WaveformMode::Automatic,
}
}
}
/// The zoom levels offered by the context menu, as multiples of the native
/// resolution. Index 4 is 100% and index 7 is 200% — those are the stops the
/// toolbar zoom button cycles through.
pub const VIEWER_ZOOM_LEVELS: [f32; 10] = [
0.10, 0.25, 0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 4.00, 8.00,
];
/// A smoothed per-second frame-rate meter for the `Show FPS` overlay.
#[derive(Debug, Clone, Copy)]
pub struct FpsCounter {
last: Option<std::time::Instant>,
smoothed: f32,
samples: u32,
}
impl FpsCounter {
/// A fresh counter with no samples.
pub fn new() -> Self {
Self {
last: None,
smoothed: 0.0,
samples: 0,
}
}
/// Record a new frame at `now` and update the smoothed rate.
pub fn record(&mut self, now: std::time::Instant) {
if let Some(last) = self.last {
let dt = now.duration_since(last).as_secs_f32();
if dt > 0.0 {
let instant = 1.0 / dt;
self.smoothed = if self.samples == 0 {
instant
} else {
self.smoothed * 0.9 + instant * 0.1
};
self.samples += 1;
}
}
self.last = Some(now);
}
/// The smoothed rate, or `0.0` before the second frame.
pub fn value(&self) -> f32 {
self.smoothed
}
}
impl Default for FpsCounter {
fn default() -> Self {
Self::new()
}
}
/// The picture source of a [`ViewerWidget`].
///
/// On macOS the fast path is a CoreVideo [`SurfaceSource`]; on platforms
@@ -131,8 +271,10 @@ pub struct ViewerWidget<C: PlaybackClock> {
transport: TransportState,
frame_source: Option<ViewerFrameSource>,
focus_handle: FocusHandle,
show_safe_frames: bool,
zoom: bool,
safe_margins: SafeMargins,
zoom: ViewerZoom,
show_fps: bool,
fps: FpsCounter,
/// The picture area's bounds in window pixels, captured each render
/// (the interact pointer forwarding maps window events to the picture's
/// local pixels from these).
@@ -173,8 +315,10 @@ impl<C: PlaybackClock> ViewerWidget<C> {
transport: TransportState::new(),
frame_source: None,
focus_handle: cx.focus_handle(),
show_safe_frames: false,
zoom: false,
safe_margins: SafeMargins::Off,
zoom: ViewerZoom::Fit,
show_fps: false,
fps: FpsCounter::new(),
picture_bounds: Rc::new(Cell::new(None)),
}
}
@@ -210,6 +354,52 @@ impl<C: PlaybackClock> ViewerWidget<C> {
self.picture_bounds.get()
}
/// The current safe-margin overlay state.
pub fn safe_margins(&self) -> SafeMargins {
self.safe_margins
}
/// Set the safe-margin overlay state and repaint.
pub fn set_safe_margins(&mut self, margins: SafeMargins, cx: &mut Context<Self>) {
self.safe_margins = margins;
cx.notify();
}
/// The current zoom state.
pub fn zoom(&self) -> ViewerZoom {
self.zoom
}
/// Set the zoom state and repaint.
pub fn set_zoom(&mut self, zoom: ViewerZoom, cx: &mut Context<Self>) {
self.zoom = zoom;
cx.notify();
}
/// Whether the frame-rate overlay is shown.
pub fn show_fps(&self) -> bool {
self.show_fps
}
/// Toggle the frame-rate overlay and repaint.
pub fn set_show_fps(&mut self, show: bool, cx: &mut Context<Self>) {
self.show_fps = show;
cx.notify();
}
/// The frame's native size in pixels when the source carries one (CPU
/// frames), `None` for platform surfaces. Used to size the safe-frame
/// overlay relative to the actual picture.
fn frame_pixel_size(&self) -> Option<(f32, f32)> {
match &self.frame_source {
Some(ViewerFrameSource::CpuFrame(image)) => {
let size = image.size(0);
Some((size.width.0 as f32, size.height.0 as f32))
}
_ => None,
}
}
fn emit_interact_pointer(
&mut self,
kind: InteractPointerKind,
@@ -231,6 +421,7 @@ impl<C: PlaybackClock> ViewerWidget<C> {
let frame = clock.current_frame();
let playing = clock.is_playing();
if frame != self.transport.frame || playing != self.transport.playing {
self.fps.record(std::time::Instant::now());
self.transport.frame = frame;
self.transport.playing = playing;
cx.notify();
@@ -328,22 +519,50 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
}));
if let Some(source) = &self.frame_source {
let fit = if self.zoom {
ObjectFit::Cover
} else {
ObjectFit::Contain
};
let picture_element: AnyElement = match source {
ViewerFrameSource::Surface(surface_source) => surface(surface_source.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(),
let picture_element: AnyElement = match self.zoom {
// Fit renders the whole frame, contained inside the area.
ViewerZoom::Fit => match source {
ViewerFrameSource::Surface(surface_source) => surface(surface_source.clone())
.size_full()
.object_fit(ObjectFit::Contain)
.into_any(),
ViewerFrameSource::CpuFrame(image) => img(image.clone())
.size_full()
.min_w_0()
.min_h_0()
.object_fit(ObjectFit::Contain)
.into_any(),
},
// A zoom level renders at a fixed multiple of the native
// resolution. Only a CPU frame carries its pixel size; a
// platform surface has no resolution here, so it falls back
// to fit.
ViewerZoom::Level(i) => match source {
ViewerFrameSource::Surface(surface_source) => surface(surface_source.clone())
.size_full()
.object_fit(ObjectFit::Contain)
.into_any(),
ViewerFrameSource::CpuFrame(image) => {
let scale = VIEWER_ZOOM_LEVELS[i];
let size = image.size(0);
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.flex()
.items_center()
.justify_center()
.child(
img(image.clone())
.w(px(size.width.0 as f32 * scale))
.h(px(size.height.0 as f32 * scale))
.object_fit(ObjectFit::Fill),
)
.into_any()
}
},
};
picture = picture.child(picture_element);
} else {
@@ -358,7 +577,9 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
);
}
if self.show_safe_frames {
if let (Some((frame_w, frame_h)), Some((margin_w, margin_h))) =
(self.frame_pixel_size(), self.safe_margins.ratio())
{
picture = picture.child(
div()
.absolute()
@@ -371,14 +592,30 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
.justify_center()
.child(
div()
.w(px(560.0))
.h(px(315.0))
.w(px(frame_w * margin_w))
.h(px(frame_h * margin_h))
.border_1()
.border_color(colors.selected),
),
);
}
if self.show_fps {
picture = picture.child(
div()
.absolute()
.right_1()
.top_1()
.px_1()
.py_0p5()
.rounded_md()
.bg(colors.container)
.text_xs()
.text_color(colors.text)
.child(format!("{:.1} fps", self.fps.value())),
);
}
// Capture the picture area's bounds each frame so the pointer
// handlers above can map window coordinates to picture-local
// pixels. The canvas paints nothing; it only records bounds.
@@ -536,7 +773,7 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
crate::i18n::tr("viewer.safe_frames", "安全框"),
&colors,
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.show_safe_frames = !this.show_safe_frames;
this.safe_margins = this.safe_margins.next();
this.emit(
ViewerEvent::ToggleSafeFramesRequested {
control: this.control,
@@ -550,7 +787,7 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
crate::i18n::tr("viewer.zoom", "缩放"),
&colors,
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.zoom = !this.zoom;
this.zoom = this.zoom.next();
this.emit(
ViewerEvent::ToggleZoomRequested {
control: this.control,
@@ -845,10 +1082,59 @@ mod tests {
host.events
.iter()
.any(|e| matches!(e, ViewerEvent::ToggleSafeFramesRequested { .. })),
host.viewer.read(app).show_safe_frames,
host.viewer.read(app).safe_margins,
)
});
assert!(requested, "expected a ToggleSafeFramesRequested event");
assert!(shown, "safe frames should now be shown locally");
assert_eq!(shown, SafeMargins::On, "safe margins should now be shown");
}
#[test]
fn zoom_cycles_fit_through_levels() {
// The toolbar zoom button cycles Fit → 100% → 200% → Fit.
assert_eq!(ViewerZoom::Fit.next(), ViewerZoom::Level(4));
assert_eq!(ViewerZoom::Level(4).next(), ViewerZoom::Level(5));
assert_eq!(ViewerZoom::Level(3).next(), ViewerZoom::Level(7));
assert_eq!(ViewerZoom::Level(7).next(), ViewerZoom::Level(8));
assert_eq!(
ViewerZoom::Level(VIEWER_ZOOM_LEVELS.len() - 1).next(),
ViewerZoom::Fit
);
}
#[test]
fn safe_margins_cycle_and_ratio() {
assert_eq!(SafeMargins::Off.next(), SafeMargins::On);
assert_eq!(SafeMargins::On.next(), SafeMargins::Off);
assert_eq!(SafeMargins::Custom(0.5, 0.5).next(), SafeMargins::Off);
assert_eq!(SafeMargins::Off.ratio(), None);
assert_eq!(SafeMargins::On.ratio(), Some((0.9, 0.8)));
assert_eq!(SafeMargins::Custom(0.7, 0.6).ratio(), Some((0.7, 0.6)));
}
#[test]
fn waveform_mode_config_round_trips() {
assert_eq!(WaveformMode::Automatic.config_value(), 0);
assert_eq!(WaveformMode::Only.config_value(), 1);
assert_eq!(WaveformMode::Both.config_value(), 2);
assert_eq!(WaveformMode::from_config_value(0), WaveformMode::Automatic);
assert_eq!(WaveformMode::from_config_value(1), WaveformMode::Only);
assert_eq!(WaveformMode::from_config_value(2), WaveformMode::Both);
// Out-of-range values clamp to the nearest valid mode.
assert_eq!(WaveformMode::from_config_value(-5), WaveformMode::Automatic);
assert_eq!(WaveformMode::from_config_value(9), WaveformMode::Both);
}
#[test]
fn fps_counter_smooths_measured_rate() {
let mut counter = FpsCounter::new();
let t0 = std::time::Instant::now();
counter.record(t0);
assert_eq!(counter.value(), 0.0, "no rate before a second sample");
// 100ms apart => 10 fps instant.
counter.record(t0 + std::time::Duration::from_millis(100));
assert!((counter.value() - 10.0).abs() < 1.0);
counter.record(t0 + std::time::Duration::from_millis(200));
assert!((counter.value() - 10.0).abs() < 1.5);
}
}