Ferret: feature updates

This commit is contained in:
Jeremy Anderson 2026-09-27 01:52:10 -04:00
parent 6e71d72d1f
commit e64adaed14
15 changed files with 2891 additions and 207 deletions

View File

@ -42,6 +42,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
winit = { version = "0.30", features = ["x11", "wayland", "rwh_06"] } winit = { version = "0.30", features = ["x11", "wayland", "rwh_06"] }
raw-window-handle = "0.6" raw-window-handle = "0.6"
egui = "0.29" egui = "0.29"
x11rb = "0.13" # X11 SHAPE extension (bounding shape), same version winit locks
egui-wgpu = "0.29" egui-wgpu = "0.29"
wgpu = "22" # pinned to match egui-wgpu 0.29 wgpu = "22" # pinned to match egui-wgpu 0.29
pollster = "0.4" # lightweight blocking executor for wgpu init pollster = "0.4" # lightweight blocking executor for wgpu init

View File

@ -6,10 +6,11 @@
# memory bloat in hot-path enums like Cmd and EngineEvent. # memory bloat in hot-path enums like Cmd and EngineEvent.
enum-variant-size-threshold = 256 enum-variant-size-threshold = 256
# Flag types with too many fields — a code smell for "split this struct". # NOTE: `struct-field-size-threshold` was never a recognized clippy.toml
# PlaybackState has many fields by design (it's a snapshot), so it carries # key on current toolchains and hard-fails config parsing (the related
# an explicit #[allow] where needed. # struct-bloat lints are field-count based, not size based). Removed so
struct-field-size-threshold = 64 # `cargo clippy` runs at all; see PlaybackState's #[allow]s for the
# "too many fields" side of the original intent.
# Single-use bindings are often a sign of "extract this to a named variable # Single-use bindings are often a sign of "extract this to a named variable
# for clarity" — but sometimes they're just noise. Keep the lint at warn # for clarity" — but sometimes they're just noise. Keep the lint at warn

View File

@ -20,6 +20,7 @@ egui = { workspace = true }
egui-wgpu = { workspace = true } egui-wgpu = { workspace = true }
wgpu = { workspace = true } wgpu = { workspace = true }
raw-window-handle = { workspace = true } raw-window-handle = { workspace = true }
x11rb = { workspace = true }
pollster = { workspace = true } pollster = { workspace = true }
crossbeam-channel = { workspace = true } crossbeam-channel = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }

View File

@ -18,7 +18,7 @@
//! worker thread because `rfd` blocks while the dialog is open. The worker //! worker thread because `rfd` blocks while the dialog is open. The worker
//! sends the result back via a channel polled from `about_to_wait`. //! sends the result back via a channel polled from `about_to_wait`.
use std::sync::Arc; use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
@ -27,8 +27,8 @@ use tracing::{error, info, warn};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent}; use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::Key; use winit::keyboard::{Key, ModifiersState, NamedKey};
use winit::window::WindowId; use winit::window::WindowId;
use player_core::cmd::LoadModeKind; use player_core::cmd::LoadModeKind;
@ -36,6 +36,8 @@ use player_core::options::EngineOptions;
use player_core::{Cmd, PlayerEngine}; use player_core::{Cmd, PlayerEngine};
use player_ui::OverlayRenderer; use player_ui::OverlayRenderer;
use x11rb::protocol::xproto;
mod keymap; mod keymap;
mod windows; mod windows;
@ -43,20 +45,19 @@ use windows::{WindowKind, WindowManager};
/// Result from a background ffmpeg export worker. /// Result from a background ffmpeg export worker.
enum DialogResult { enum DialogResult {
/// Export succeeded; the file was written to this path. /// Export succeeded; the file was written to this path. `cropped` says
File(String), /// whether the current zoom/pan focus area was baked in as a crop.
File { path: String, cropped: bool },
/// Export failed with this error message. /// Export failed with this error message.
Error(String), Error(String),
} }
/// Only one background operation remains: ffmpeg video export. All file /// Only one background operation remains: ffmpeg video export. All file
/// selection is now in-UI (see `player_ui::file_dialog`). /// selection is now in-UI (see `player_ui::file_dialog`). The kind tags the
/// dialog-result channel so future worker kinds can be distinguished.
enum DialogKind { enum DialogKind {
ExportVideo { /// Result of an ffmpeg A-B loop export worker.
input: String, ExportVideo,
start: f64,
end: f64,
},
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -118,6 +119,20 @@ struct FerretApp {
/// When false, the overlay drops from AlwaysOnTop so it doesn't block /// When false, the overlay drops from AlwaysOnTop so it doesn't block
/// other applications. /// other applications.
has_focus: bool, has_focus: bool,
/// Latest keyboard modifier state (ctrl/alt/shift/super), tracked via
/// WindowEvent::ModifiersChanged. winit 0.30 KeyEvents do not carry
/// modifiers, so we keep the state here for key→egui translation.
keyboard_modifiers: ModifiersState,
/// The overlay bounding shape last applied to the X server. Kept so we
/// only issue an XShape request when egui's painted rects actually
/// changed (show/hide of bars, opening a dialog, tooltips, ...).
last_shape_rects: Vec<egui::Rect>,
/// Last pointer position while a pan drag is in progress on the video
/// area (the "movable object on a canvas" gesture). None = not
/// dragging. Pointer events only reach the video window through the
/// holes in the overlay's X11 shape, so a drag that starts here is by
/// construction over the video — never over the bars or sidebar.
video_pan_drag: Option<(f64, f64)>,
} }
impl FerretApp { impl FerretApp {
@ -138,6 +153,9 @@ impl FerretApp {
dialog_result_rx, dialog_result_rx,
dialog_result_tx, dialog_result_tx,
has_focus: true, has_focus: true,
keyboard_modifiers: ModifiersState::empty(),
last_shape_rects: Vec::new(),
video_pan_drag: None,
} }
} }
@ -223,32 +241,43 @@ impl FerretApp {
let mut infos: Vec<String> = Vec::new(); let mut infos: Vec<String> = Vec::new();
while let Ok((kind, result)) = self.dialog_result_rx.try_recv() { while let Ok((kind, result)) = self.dialog_result_rx.try_recv() {
match (kind, result) { match (kind, result) {
(DialogKind::ExportVideo { .. }, DialogResult::File(path)) => { (DialogKind::ExportVideo, DialogResult::File { path, cropped }) => {
infos.push(format!("A-B loop exported to {path}")); infos.push(if cropped {
format!("A-B loop exported (with zoom/pan focus area) to {path}")
} else {
format!("A-B loop exported to {path}")
});
} }
(DialogKind::ExportVideo { .. }, DialogResult::Error(msg)) => { (DialogKind::ExportVideo, DialogResult::Error(msg)) => {
infos.push(format!("Export failed: {msg}")); infos.push(format!("Export failed: {msg}"));
} }
(_, DialogResult::Error(msg)) => {
infos.push(msg);
}
_ => {}
} }
} }
infos infos
} }
fn render_overlay(&mut self) { fn render_overlay(&mut self, event_loop: &ActiveEventLoop) {
let Some(engine) = self.engine.as_ref() else { return; }; let Some(engine) = self.engine.as_ref() else { return; };
// Forward commands from the overlay UI to the engine. Two commands // Forward commands from the overlay UI to the engine. Three
// are intercepted here (not sent to the engine): // commands are intercepted here (not sent to the engine):
// - ToggleFullscreen: main-app window concern // - ToggleFullscreen: main-app window concern
// - ExportABLoopVideo: main-app runs ffmpeg (engine no-ops it) // - ExportABLoopVideo: main-app runs ffmpeg (engine no-ops it)
// - Shutdown: menu Quit must exit the whole event loop — the
// engine alone only stops the playback thread and the app
// window would hang around forever.
let mut pending_fullscreen_toggle = false; let mut pending_fullscreen_toggle = false;
let mut pending_quit = false;
while let Ok(cmd) = self.cmd_rx.try_recv() { while let Ok(cmd) = self.cmd_rx.try_recv() {
match &cmd { match &cmd {
Cmd::Shutdown => {
// File → Quit. Exit the winit event loop; FerretApp (and
// with it the engine + windows) is dropped on return,
// which cleanly shuts the engine thread down.
pending_quit = true;
}
Cmd::ToggleFullscreen => { Cmd::ToggleFullscreen => {
// Window-level concern — handled after the render below.
pending_fullscreen_toggle = true; pending_fullscreen_toggle = true;
} }
Cmd::ExportABLoopVideo { path } => { Cmd::ExportABLoopVideo { path } => {
@ -259,6 +288,27 @@ impl FerretApp {
let a = st.marker_a; let a = st.marker_a;
let b = st.marker_b; let b = st.marker_b;
let input = st.path.clone(); let input = st.path.clone();
// Bake the current zoom/pan focus area into the clip:
// map the visible window region back to source pixels
// and crop. Only when the view is actually realigned,
// and only without rotation — the export doesn't remap
// rotated coordinates (it never did).
let crop = if st.video_rotate == 0 {
self.windows.video.as_ref().and_then(|w| {
let size = w.inner_size();
player_core::crop::visible_crop(
st.video_width,
st.video_height,
size.width,
size.height,
st.video_zoom,
st.video_pan_x,
st.video_pan_y,
)
})
} else {
None
};
drop(st); drop(st);
match (a, b, input) { match (a, b, input) {
(Some(start), Some(end), Some(inp)) if end > start => { (Some(start), Some(end), Some(inp)) if end > start => {
@ -267,16 +317,13 @@ impl FerretApp {
start, start,
end, end,
path.clone(), path.clone(),
crop,
self.dialog_result_tx.clone(), self.dialog_result_tx.clone(),
); );
} }
_ => { _ => {
let _ = self.dialog_result_tx.send(( let _ = self.dialog_result_tx.send((
DialogKind::ExportVideo { DialogKind::ExportVideo,
input: String::new(),
start: 0.0,
end: 0.0,
},
DialogResult::Error( DialogResult::Error(
"Set both A and B markers before exporting".into(), "Set both A and B markers before exporting".into(),
), ),
@ -307,16 +354,79 @@ impl FerretApp {
} }
self.last_overlay_render = Instant::now(); self.last_overlay_render = Instant::now();
// Mirror this frame's painted rects onto the overlay window as its
// X11 bounding shape. Everything egui did NOT paint becomes a hole
// in the window — the libmpv video window underneath shows through
// unconditionally (no compositor / alpha-mode / visual involved).
// This is the structural fix for the recurring "no video output"
// class of bugs: the overlay can no longer blanket the video with a
// possibly-opaque surface.
let painted = overlay.painted_rects.clone();
let overlay_window = self.windows.overlay.clone();
if let Some(w) = overlay_window {
if painted != self.last_shape_rects {
apply_overlay_shape(&w, &painted);
self.last_shape_rects = painted;
}
}
if pending_quit {
info!("quit requested via menu — exiting event loop");
event_loop.exit();
return;
}
if pending_fullscreen_toggle { if pending_fullscreen_toggle {
self.toggle_fullscreen(); self.toggle_fullscreen();
} }
} }
/// Forward a pointer position received on the VIDEO window into the
/// egui overlay (the overlay's bounding shape has holes over the video,
/// so those events arrive here instead). Same coordinate space.
fn forward_video_pointer(&mut self, position: winit::dpi::PhysicalPosition<f64>) {
let pos = egui::pos2(position.x as f32, position.y as f32);
self.overlay_mouse_pos = Some(pos);
if let Some(overlay) = self.overlay.as_mut() {
overlay.app.push_event(egui::Event::PointerMoved(pos));
// Moving the mouse over the video is user activity: (re)show
// the menu bar and control bar.
overlay.app.note_user_activity();
}
self.request_redraw_overlay();
}
/// Feed a video-window pointer move into an active pan drag. Deltas are
/// converted to screen fractions and sent to the engine as
/// `Cmd::AdjustVideoPan` — the video follows the pointer like an object
/// being dragged around a canvas, letting the user realign whatever
/// area they want in focus.
fn pan_video_by_drag(&mut self, position: winit::dpi::PhysicalPosition<f64>) {
let Some((last_x, last_y)) = self.video_pan_drag else { return };
let Some(video) = self.windows.video.clone() else { return };
let size = video.inner_size();
if size.width == 0 || size.height == 0 {
return;
}
let dx = (position.x - last_x) as f32 / size.width as f32;
let dy = (position.y - last_y) as f32 / size.height as f32;
if dx == 0.0 && dy == 0.0 {
return;
}
self.video_pan_drag = Some((position.x, position.y));
if let Some(engine) = self.engine.as_ref() {
let _ = engine.send(Cmd::AdjustVideoPan { dx, dy });
}
}
/// Request a redraw on both windows. Single dispatch point so callers /// Request a redraw on both windows. Single dispatch point so callers
/// never have to repeat the `if let Some(w) = ...` dance. /// never have to repeat the `if let Some(w) = ...` dance.
fn request_redraw_both(&self) { fn request_redraw_both(&self) {
self.windows.video.as_ref().map(|w| w.request_redraw()); if let Some(w) = self.windows.video.as_ref() {
self.windows.overlay.as_ref().map(|w| w.request_redraw()); w.request_redraw();
}
if let Some(w) = self.windows.overlay.as_ref() {
w.request_redraw();
}
} }
/// Request a redraw on the overlay window only. /// Request a redraw on the overlay window only.
@ -326,9 +436,35 @@ impl FerretApp {
} }
} }
fn handle_keyboard(&mut self, key: &Key, event_loop: &ActiveEventLoop) { fn handle_keyboard(&mut self, event_loop: &ActiveEventLoop, key_event: &KeyEvent) {
// If the overlay UI has a focused text field (e.g. the save-dialog
// filename input), route the keystroke into egui instead of the
// global hotkeys. Otherwise typing "q" in a filename would quit,
// space would pause, etc.
let ui_wants_keyboard = self
.overlay
.as_ref()
.map(|o| o.app.ui_wants_keyboard)
.unwrap_or(false);
if ui_wants_keyboard {
let events = key_event_to_egui_events(key_event, self.keyboard_modifiers);
if !events.is_empty() {
if let Some(overlay) = self.overlay.as_mut() {
overlay.app.push_events(events);
}
self.request_redraw_overlay();
return;
}
}
// Hotkeys only act on key PRESS; releases are only interesting to
// egui (handled above).
if key_event.state != ElementState::Pressed {
return;
}
// `q` and `f` are window-level concerns; they never reach the engine. // `q` and `f` are window-level concerns; they never reach the engine.
match key { match &key_event.logical_key {
Key::Character(s) if s == "q" || s == "Q" => { Key::Character(s) if s == "q" || s == "Q" => {
event_loop.exit(); event_loop.exit();
return; return;
@ -344,9 +480,14 @@ impl FerretApp {
// UI-derived values (loop mode, speed). Keeps `keymap.rs` decoupled // UI-derived values (loop mode, speed). Keeps `keymap.rs` decoupled
// from `player-ui`. // from `player-ui`.
let state = engine.state(); let state = engine.state();
if let Some(cmd) = keymap::key_to_cmd(key, &state) { if let Some(cmd) = keymap::key_to_cmd(&key_event.logical_key, &state) {
let _ = engine.send(cmd); let _ = engine.send(cmd);
} }
// Reveal the controls briefly (VLC-style) so the effect of the key
// (pause/play toggle, seek jump, ...) is immediately visible.
if let Some(overlay) = self.overlay.as_mut() {
overlay.app.note_user_activity();
}
} }
fn toggle_fullscreen(&mut self) { fn toggle_fullscreen(&mut self) {
@ -409,16 +550,100 @@ impl ApplicationHandler for FerretApp {
self.has_focus = gained; self.has_focus = gained;
self.update_overlay_focus(); self.update_overlay_focus();
} }
WindowEvent::KeyboardInput { WindowEvent::ModifiersChanged(m) => {
event: self.keyboard_modifiers = m.state();
KeyEvent { }
state: ElementState::Pressed, WindowEvent::CursorMoved { position, .. } => {
logical_key, // The X11 bounding shape routes pointer events that fall
.. // in the holes (over the video) to the VIDEO window, not
}, // the overlay. Re-route them into the egui overlay so
.. // hover state, bar auto-show, and widget interaction
} => { // keep working over the whole window. Coordinates are
self.handle_keyboard(&logical_key, event_loop); // identical: the overlay covers the video window's inner
// area exactly and pixels_per_point is 1.0.
self.forward_video_pointer(position);
// An active drag over the video pans it.
self.pan_video_by_drag(position);
}
WindowEvent::CursorLeft { .. } => {
self.overlay_mouse_pos = None;
self.video_pan_drag = None;
if let Some(overlay) = self.overlay.as_mut() {
overlay.app.push_event(egui::Event::PointerGone);
}
self.request_redraw_overlay();
}
WindowEvent::MouseInput { state, button, .. } => {
// Click on the video area (delivered here because the
// overlay's shape has a hole there). Forward as an egui
// event so UI state (e.g. closing dropdowns) stays
// consistent with clicks on the overlay itself.
let egui_button = match button {
MouseButton::Left => egui::PointerButton::Primary,
MouseButton::Right => egui::PointerButton::Secondary,
MouseButton::Middle => egui::PointerButton::Middle,
_ => { return; }
};
let pressed = state == ElementState::Pressed;
if let (Some(overlay), Some(pos)) =
(self.overlay.as_mut(), self.overlay_mouse_pos)
{
overlay.app.push_event(egui::Event::PointerButton {
pos,
button: egui_button,
pressed,
modifiers: egui::Modifiers::default(),
});
// Clicking is user activity: (re)show the bars.
overlay.app.note_user_activity();
}
// Left-drag on the video = pan (movable-object gesture).
// Only meaningful with something on screen.
if matches!(button, MouseButton::Left) {
if pressed {
let has_file = self
.engine
.as_ref()
.map(|e| e.state().path.is_some())
.unwrap_or(false);
if has_file {
if let Some(pos) = self.overlay_mouse_pos {
self.video_pan_drag = Some((pos.x as f64, pos.y as f64));
}
}
} else {
self.video_pan_drag = None;
}
}
self.request_redraw_overlay();
}
WindowEvent::MouseWheel { delta, .. } => {
// Ctrl+wheel over the video zooms (next to the pan
// gesture). Without ctrl the wheel stays unused, as
// before. Wheel-up = zoom in.
if self.keyboard_modifiers.control_key() {
let step = match delta {
winit::event::MouseScrollDelta::LineDelta(_, y) => {
y * 0.1
}
winit::event::MouseScrollDelta::PixelDelta(p) => {
(p.y as f32 / 400.0).clamp(-0.5, 0.5)
}
};
if step != 0.0 {
if let Some(engine) = self.engine.as_ref() {
let _ = engine.send(Cmd::AdjustVideoZoom(step));
}
// Show the bars so the zoom status is visible.
if let Some(overlay) = self.overlay.as_mut() {
overlay.app.note_user_activity();
}
}
}
self.request_redraw_overlay();
}
WindowEvent::KeyboardInput { event: key_event, .. } => {
self.handle_keyboard(event_loop, &key_event);
self.request_redraw_overlay(); self.request_redraw_overlay();
} }
WindowEvent::Resized(_) | WindowEvent::Moved(_) => { WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
@ -453,7 +678,7 @@ impl ApplicationHandler for FerretApp {
// owns it). But a RedrawRequested on the video window // owns it). But a RedrawRequested on the video window
// means the WM wants us to repaint — forward it to the // means the WM wants us to repaint — forward it to the
// overlay so the controls stay in sync. // overlay so the controls stay in sync.
self.render_overlay(); self.render_overlay(event_loop);
self.request_redraw_overlay(); self.request_redraw_overlay();
} }
_ => {} _ => {}
@ -463,6 +688,9 @@ impl ApplicationHandler for FerretApp {
self.has_focus = gained; self.has_focus = gained;
self.update_overlay_focus(); self.update_overlay_focus();
} }
WindowEvent::ModifiersChanged(m) => {
self.keyboard_modifiers = m.state();
}
WindowEvent::CursorMoved { position, .. } => { WindowEvent::CursorMoved { position, .. } => {
// The renderer sets pixels_per_point=1.0, so egui's // The renderer sets pixels_per_point=1.0, so egui's
// coordinate system matches physical pixels directly. // coordinate system matches physical pixels directly.
@ -470,6 +698,9 @@ impl ApplicationHandler for FerretApp {
self.overlay_mouse_pos = Some(pos); self.overlay_mouse_pos = Some(pos);
if let Some(overlay) = self.overlay.as_mut() { if let Some(overlay) = self.overlay.as_mut() {
overlay.app.push_event(egui::Event::PointerMoved(pos)); overlay.app.push_event(egui::Event::PointerMoved(pos));
// Moving the mouse is user activity: (re)show the
// menu bar and control bar.
overlay.app.note_user_activity();
} }
self.request_redraw_overlay(); self.request_redraw_overlay();
} }
@ -495,6 +726,8 @@ impl ApplicationHandler for FerretApp {
pressed, pressed,
modifiers: egui::Modifiers::default(), modifiers: egui::Modifiers::default(),
}); });
// Clicking is user activity: (re)show the bars.
overlay.app.note_user_activity();
} }
// Process the click immediately so dropdown menus open // Process the click immediately so dropdown menus open
// without waiting for the next render cycle. The // without waiting for the next render cycle. The
@ -503,19 +736,12 @@ impl ApplicationHandler for FerretApp {
// timer — so without this eager render, the click sits in // timer — so without this eager render, the click sits in
// pending_events and the dropdown never appears until the // pending_events and the dropdown never appears until the
// mouse moves. // mouse moves.
self.render_overlay(); self.render_overlay(event_loop);
self.request_redraw_overlay(); self.request_redraw_overlay();
} }
WindowEvent::KeyboardInput { WindowEvent::KeyboardInput { event: key_event, .. } => {
event: self.handle_keyboard(event_loop, &key_event);
KeyEvent { self.request_redraw_overlay();
state: ElementState::Pressed,
logical_key,
..
},
..
} => {
self.handle_keyboard(&logical_key, event_loop);
} }
WindowEvent::Resized(_) | WindowEvent::Moved(_) => { WindowEvent::Resized(_) | WindowEvent::Moved(_) => {
// The overlay itself was resized or moved. When the overlay // The overlay itself was resized or moved. When the overlay
@ -536,7 +762,7 @@ impl ApplicationHandler for FerretApp {
// limit caused skipped frames during resize bursts and // limit caused skipped frames during resize bursts and
// delayed dropdown menu opening. wgpu's PresentMode already // delayed dropdown menu opening. wgpu's PresentMode already
// throttles to the display refresh rate. // throttles to the display refresh rate.
self.render_overlay(); self.render_overlay(event_loop);
} }
WindowEvent::CloseRequested => { WindowEvent::CloseRequested => {
event_loop.exit(); event_loop.exit();
@ -547,31 +773,168 @@ impl ApplicationHandler for FerretApp {
} }
} }
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
// Re-render the overlay at ~30fps even when no input arrives, // Self-sustaining ~30fps repaint ticker.
// promptly whenever a dialog result lands, and immediately when //
// there are queued egui events (clicks, key presses) that the // winit's default ControlFlow::Wait parks the event loop once the
// rate-limited RedrawRequested handler might have skipped. // last input event has been processed, and egui's
let has_pending_events = self.overlay // `Context::request_repaint_after` is not wired to winit here. Before
// this ticker existed the overlay only repainted while events kept
// arriving, which caused four visible bugs: a freshly loaded video
// stayed black behind the last (opaque) startup frame until the mouse
// moved, the progress bar / time display froze, the pause button icon
// never flipped after clicking it, and the auto-hide never triggered.
// Scheduling a WaitUntil wakeup at every frame boundary keeps engine
// state flowing into the UI even with zero user input.
if self.overlay.is_none() || self.engine.is_none() {
// Setup hasn't completed — nothing to tick.
event_loop.set_control_flow(ControlFlow::Wait);
return;
}
let has_pending_events = self
.overlay
.as_ref() .as_ref()
.map(|o| !o.app.pending_events.is_empty()) .map(|o| !o.app.pending_events.is_empty())
.unwrap_or(false); .unwrap_or(false);
let need_render = self.last_overlay_render.elapsed() > Duration::from_millis(33) let tick_due = self.last_overlay_render.elapsed() > Duration::from_millis(33);
|| !self.dialog_result_rx.is_empty() if tick_due || !self.dialog_result_rx.is_empty() || has_pending_events {
|| has_pending_events; // Paint via the RedrawRequested path — request_redraw wakes the
if need_render { // loop and the overlay's RedrawRequested handler does the actual
self.render_overlay(); // rendering (single render per tick, throttled by vsync).
self.request_redraw_overlay(); self.request_redraw_overlay();
} }
// Wake up at the next frame boundary even without input events.
let wake_at = (self.last_overlay_render + Duration::from_millis(33)).max(Instant::now());
event_loop.set_control_flow(ControlFlow::WaitUntil(wake_at));
} }
} }
/// Translate a winit `KeyEvent` into egui input events, so the overlay's
/// text fields (save-dialog filename) receive keyboard input. Mirrors the
/// essential parts of egui-winit's translation:
///
/// * printable text (no ctrl held) → `Event::Text`
/// * named keys (Enter, Backspace, arrows, ...) → `Event::Key`, on both
/// press and release so egui's key-down tracking stays consistent
/// * ctrl/alt/meta + character → `Event::Key` with the character mapped to
/// `egui::Key`, so shortcuts like ctrl+A / C / V / X work in text fields
///
/// The modifiers come from the caller's tracked `ModifiersState` (winit
/// delivers modifiers as separate `ModifiersChanged` events).
fn key_event_to_egui_events(
event: &KeyEvent,
mods: ModifiersState,
) -> Vec<egui::Event> {
translate_key(
&event.logical_key,
event.text.as_deref(),
event.state == ElementState::Pressed,
event.repeat,
mods,
)
}
/// The testable core of the key translation: takes the logical key, the
/// text winit produced for it, press state, repeat flag and modifier
/// state. Split out from `key_event_to_egui_events` because winit's
/// `KeyEvent` cannot be constructed outside the crate (it has a
/// `pub(crate)` field), which would make it untestable.
fn translate_key(
logical: &Key,
text: Option<&str>,
pressed: bool,
repeat: bool,
mods: ModifiersState,
) -> Vec<egui::Event> {
let egui_mods = egui::Modifiers {
alt: mods.alt_key(),
ctrl: mods.control_key(),
shift: mods.shift_key(),
mac_cmd: false,
// Linux: ctrl is the "command" key for egui's shortcut matching.
command: mods.control_key(),
};
let mut events: Vec<egui::Event> = Vec::new();
match logical {
Key::Named(named) => {
let key = match named {
NamedKey::Enter => Some(egui::Key::Enter),
NamedKey::Backspace => Some(egui::Key::Backspace),
NamedKey::Escape => Some(egui::Key::Escape),
NamedKey::Tab => Some(egui::Key::Tab),
NamedKey::Space => Some(egui::Key::Space),
NamedKey::ArrowLeft => Some(egui::Key::ArrowLeft),
NamedKey::ArrowRight => Some(egui::Key::ArrowRight),
NamedKey::ArrowUp => Some(egui::Key::ArrowUp),
NamedKey::ArrowDown => Some(egui::Key::ArrowDown),
NamedKey::Delete => Some(egui::Key::Delete),
NamedKey::Home => Some(egui::Key::Home),
NamedKey::End => Some(egui::Key::End),
NamedKey::PageUp => Some(egui::Key::PageUp),
NamedKey::PageDown => Some(egui::Key::PageDown),
NamedKey::Insert => Some(egui::Key::Insert),
_ => None,
};
if let Some(key) = key {
events.push(egui::Event::Key {
key,
physical_key: None,
pressed,
repeat,
modifiers: egui_mods,
});
}
// egui's text insertion only reacts to Event::Text — a bare
// Key::Space event inserts nothing. Emit the text too so typing
// spaces into the save-dialog filename works.
if pressed && matches!(named, NamedKey::Space) {
events.push(egui::Event::Text(" ".into()));
}
}
Key::Character(ch) => {
if mods.control_key() || mods.alt_key() || mods.super_key() {
// Shortcut combo (ctrl+A, ctrl+C, ...). Emit a Key event so
// egui's text editing shortcuts engage. Only single
// characters map cleanly to egui::Key.
if ch.chars().count() == 1 {
if let Some(key) = egui::Key::from_name(&ch.to_lowercase()) {
events.push(egui::Event::Key {
key,
physical_key: None,
pressed,
repeat,
modifiers: egui_mods,
});
}
}
} else if pressed {
// Plain typing — forward the produced text as-is.
let text = match text.filter(|t| !t.is_empty()) {
Some(t) => Some(t),
None => (!ch.is_empty()).then_some(ch.as_str()),
};
if let Some(t) = text {
if !t.chars().any(|c| c.is_control()) {
events.push(egui::Event::Text(t.to_owned()));
}
}
}
}
_ => {}
}
events
}
/// Extract the X11 XID from a winit window. /// Extract the X11 XID from a winit window.
fn extract_x11_xid(window: &Arc<winit::window::Window>) -> Result<u64> { fn extract_x11_xid(window: &Arc<winit::window::Window>) -> Result<u64> {
use raw_window_handle::HasWindowHandle; use raw_window_handle::HasWindowHandle;
let handle = window.window_handle()?.as_raw(); let handle = window.window_handle()?.as_raw();
match handle { match handle {
raw_window_handle::RawWindowHandle::Xlib(x) => Ok(x.window as u64), raw_window_handle::RawWindowHandle::Xlib(x) => Ok(x.window),
raw_window_handle::RawWindowHandle::Xcb(x) => Ok(x.window.get() as u64), raw_window_handle::RawWindowHandle::Xcb(x) => Ok(x.window.get() as u64),
other => Err(anyhow::anyhow!( other => Err(anyhow::anyhow!(
"video window is not on X11 (got {other:?}). Wayland requires libmpv's render-context API, which is on the roadmap." "video window is not on X11 (got {other:?}). Wayland requires libmpv's render-context API, which is on the roadmap."
@ -579,6 +942,125 @@ fn extract_x11_xid(window: &Arc<winit::window::Window>) -> Result<u64> {
} }
} }
// ---------------------------------------------------------------------------
// X11 bounding shape (XShape)
// ---------------------------------------------------------------------------
//
// The overlay window used to cover the video window with a full-screen
// wgpu surface and relied on *transparency* (ARGB visual + compositor +
// surface alpha mode) for the video to show through. That chain broke in
// the field over and over — wgpu's `CompositeAlphaMode::Auto` only ever
// resolves to Opaque/Inherit (never a transparent mode), some drivers
// write opaque alpha, some setups run without a compositor — and each
// break produced the same user-visible bug: "app has no video output"
// while the UI kept working.
//
// The bounding shape removes the dependency on that entire chain. Every
// frame, egui's actually-painted rects (see `renderer::painted_pixel_rects`)
// become the overlay window's X11 *bounding region*. Pixels outside the
// region are a literal hole in the X window: the video window underneath
// shows through because the overlay simply does not exist there, whatever
// the GPU, driver, compositor or alpha mode may say. The input region
// defaults to the bounding region, so pointer events in the holes are
// delivered to the video window — we re-route them into egui (see
// `forward_video_pointer`) to keep hover/auto-show behavior.
/// Dedicated XCB connection for shape requests. Separate from winit's
/// connection so we never interleave requests on its socket.
static SHAPE_CONN: OnceLock<Option<Arc<x11rb::rust_connection::RustConnection>>> = OnceLock::new();
fn shape_conn() -> Option<&'static Arc<x11rb::rust_connection::RustConnection>> {
SHAPE_CONN
.get_or_init(|| {
match x11rb::connect(None) {
Ok((conn, _screen)) => Some(Arc::new(conn)),
Err(e) => {
warn!("X11 shape: cannot open X connection: {e}");
None
}
}
})
.as_ref()
}
/// The overlay window's X11 window ID as the X server knows it.
fn overlay_xid(overlay: &Arc<winit::window::Window>) -> Option<u32> {
use raw_window_handle::HasWindowHandle;
let handle = overlay.window_handle().ok()?.as_raw();
match handle {
raw_window_handle::RawWindowHandle::Xlib(x) => Some(x.window as u32),
raw_window_handle::RawWindowHandle::Xcb(x) => Some(x.window.get()),
_ => None,
}
}
/// Convert an egui rect (window-local pixels, pixels_per_point = 1.0) into
/// an X11 protocol rectangle. Window dims are clamped to 16384 upstream,
/// so the protocol's i16/u16 ranges always hold.
fn to_x_rectangle(r: egui::Rect) -> xproto::Rectangle {
let x = r.min.x.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
let y = r.min.y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
// Width/height are span deltas, always >= 0, clamped to the protocol max.
let w = (r.max.x.round() - r.min.x.round()).clamp(0.0, u16::MAX as f32) as u16;
let h = (r.max.y.round() - r.min.y.round()).clamp(0.0, u16::MAX as f32) as u16;
xproto::Rectangle { x, y, width: w, height: h }
}
/// Set the overlay window's X11 *bounding shape* to exactly `rects`
/// (window-local pixel rects). Pixels outside the union of the rects are a
/// hole: the video window beneath shows through unconditionally, and
/// pointer events in the holes go to the video window. An empty list is a
/// well-defined *empty* region (the server unions zero rectangles): the
/// overlay becomes fully invisible and fully click-through — e.g. while
/// the bars are auto-hidden.
fn apply_overlay_shape(overlay: &Arc<winit::window::Window>, rects: &[egui::Rect]) {
use x11rb::protocol::shape::{self as shape_ext, SK, SO};
use x11rb::protocol::xproto::ClipOrdering;
let Some(xid) = overlay_xid(overlay) else { return; };
let Some(conn) = shape_conn() else { return; };
let xrects: Vec<xproto::Rectangle> = rects.iter().copied().map(to_x_rectangle).collect();
match shape_ext::rectangles(
conn.as_ref(),
SO::SET,
SK::BOUNDING,
ClipOrdering::UNSORTED,
xid,
0,
0,
&xrects,
) {
Ok(cookie) => {
if let Err(e) = cookie.check() {
warn!("X11 shape update error: {e}");
}
}
Err(e) => warn!("X11 shape request failed: {e}"),
}
}
#[cfg(test)]
mod shape_tests {
use super::*;
#[test]
fn x_rectangle_rounding_and_clamping() {
let r = egui::Rect::from_min_max(egui::pos2(10.4, 20.6), egui::pos2(60.2, 70.8));
let x = to_x_rectangle(r);
assert_eq!((x.x, x.y, x.width, x.height), (10, 21, 50, 50));
}
#[test]
fn x_rectangle_saturates_on_origin_clamp() {
// Negative origins (shouldn't occur — egui clips to the window —
// but must not wrap into huge u16s).
let r = egui::Rect::from_min_max(egui::pos2(-5.0, -5.0), egui::pos2(5.0, 5.0));
let x = to_x_rectangle(r);
assert_eq!((x.x, x.y, x.width, x.height), (-5, -5, 10, 10));
}
}
/// Set the X11 background pixel on a winit window. /// Set the X11 background pixel on a winit window.
/// ///
/// winit creates windows with `background_pixel = None`, which means the X /// winit creates windows with `background_pixel = None`, which means the X
@ -806,35 +1288,40 @@ fn set_x11_overlay_hints(
/// Spawn a worker thread to run ffmpeg for A-B loop video export. The /// Spawn a worker thread to run ffmpeg for A-B loop video export. The
/// thread runs the encode and sends the result back on `tx` when done. /// thread runs the encode and sends the result back on `tx` when done.
/// Runs in the background so the UI stays responsive during encoding. /// Runs in the background so the UI stays responsive during encoding.
/// `crop` optionally carries a source-space rectangle (x, y, w, h) to crop
/// the output to — the zoom/pan focus area the user had aligned.
fn spawn_ffmpeg_export( fn spawn_ffmpeg_export(
input: String, input: String,
start: f64, start: f64,
end: f64, end: f64,
output: String, output: String,
crop: Option<player_core::crop::CropRect>,
tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>, tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
) { ) {
let cropped = crop.is_some();
std::thread::Builder::new() std::thread::Builder::new()
.name("ferret-ffmpeg".into()) .name("ferret-ffmpeg".into())
.spawn(move || { .spawn(move || {
let result = export_video_segment(&input, start, end, &output) let result = export_video_segment(&input, start, end, &output, crop)
.map(|_| DialogResult::File(output.clone())) .map(|_| DialogResult::File { path: output.clone(), cropped })
.unwrap_or_else(|e| DialogResult::Error(e.to_string())); .unwrap_or_else(|e| DialogResult::Error(e.to_string()));
let _ = tx.send(( let _ = tx.send((DialogKind::ExportVideo, result));
DialogKind::ExportVideo {
input,
start,
end,
},
result,
));
}) })
.ok(); .ok();
} }
/// Run ffmpeg to extract the video segment [start, end] from `input` into /// Run ffmpeg to extract the video segment [start, end] from `input` into
/// `output`. Re-encodes video (libx264) for frame accuracy and maximum /// `output`. Re-encodes video (libx264) for frame accuracy and maximum
/// compatibility. Audio is re-encoded to AAC. /// compatibility. Audio is re-encoded to AAC. When `crop` is Some, the
fn export_video_segment(input: &str, start: f64, end: f64, output: &str) -> std::io::Result<()> { /// output is cropped to that source-pixel rectangle first — used to bake
/// the zoom/pan focus area into exported A-B clips.
fn export_video_segment(
input: &str,
start: f64,
end: f64,
output: &str,
crop: Option<player_core::crop::CropRect>,
) -> std::io::Result<()> {
let duration = end - start; let duration = end - start;
info!("exporting video segment: {input} [{start:.3}..{end:.3}] → {output}"); info!("exporting video segment: {input} [{start:.3}..{end:.3}] → {output}");
@ -852,27 +1339,122 @@ fn export_video_segment(input: &str, start: f64, end: f64, output: &str) -> std:
// Use -ss before -i for fast seeking. Re-encode video (libx264) for // Use -ss before -i for fast seeking. Re-encode video (libx264) for
// frame accuracy and maximum compatibility. Use -y to overwrite output. // frame accuracy and maximum compatibility. Use -y to overwrite output.
let output = std::process::Command::new("ffmpeg") let mut ffmpeg = std::process::Command::new("ffmpeg");
ffmpeg
.arg("-y") .arg("-y")
.arg("-ss").arg(format!("{start:.3}")) .arg("-ss").arg(format!("{start:.3}"))
.arg("-i").arg(input) .arg("-i").arg(input)
.arg("-t").arg(format!("{duration:.3}")) .arg("-t").arg(format!("{duration:.3}"));
if let Some((cx, cy, cw, ch)) = crop {
ffmpeg.arg("-vf").arg(format!("crop={cw}:{ch}:{cx}:{cy}"));
}
ffmpeg
.arg("-c:v").arg("libx264") .arg("-c:v").arg("libx264")
.arg("-preset").arg("fast") .arg("-preset").arg("fast")
.arg("-crf").arg("18") .arg("-crf").arg("18")
.arg("-c:a").arg("aac") .arg("-c:a").arg("aac")
.arg("-b:a").arg("192k") .arg("-b:a").arg("192k")
.arg(output) .arg(output);
.output()?; let out = ffmpeg.output()?;
if !output.status.success() { if !out.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&out.stderr);
let msg = stderr.lines().last().unwrap_or("unknown ffmpeg error"); let msg = stderr.lines().last().unwrap_or("unknown ffmpeg error");
return Err(std::io::Error::new( return Err(std::io::Error::other(format!("ffmpeg: {msg}")));
std::io::ErrorKind::Other,
format!("ffmpeg: {msg}"),
));
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod key_tests {
use super::*;
use winit::keyboard::ModifiersState;
fn char_key(ch: &str) -> Key {
Key::Character(ch.into())
}
fn is_text(events: &[egui::Event], t: &str) -> bool {
events.iter().any(|e| matches!(e, egui::Event::Text(s) if s == t))
}
fn is_key(events: &[egui::Event], k: egui::Key, pressed: bool) -> bool {
events.iter().any(
|e| matches!(e, egui::Event::Key { key, pressed: p, .. } if *key == k && *p == pressed),
)
}
#[test]
fn plain_characters_become_text() {
// Typing 'q' in the filename field must go to egui — NOT quit.
let ev = translate_key(&char_key("q"), Some("q"), true, false, ModifiersState::empty());
assert!(is_text(&ev, "q"));
assert_eq!(ev.len(), 1);
}
#[test]
fn space_types_a_space() {
let ev = translate_key(
&Key::Named(NamedKey::Space),
Some(" "),
true,
false,
ModifiersState::empty(),
);
assert!(is_text(&ev, " "));
assert!(is_key(&ev, egui::Key::Space, true));
}
#[test]
fn named_keys_map_to_egui_keys() {
let ev = translate_key(
&Key::Named(NamedKey::Backspace),
None,
true,
false,
ModifiersState::empty(),
);
assert!(is_key(&ev, egui::Key::Backspace, true));
// Releases are forwarded too so egui's key-down tracking stays sane.
let ev = translate_key(
&Key::Named(NamedKey::Backspace),
None,
false,
false,
ModifiersState::empty(),
);
assert!(is_key(&ev, egui::Key::Backspace, false));
}
#[test]
fn enter_maps_to_key_not_text() {
// winit gives Enter text "\r" — egui wants Key::Enter, no Text.
let ev = translate_key(
&Key::Named(NamedKey::Enter),
Some("\r"),
true,
false,
ModifiersState::empty(),
);
assert!(is_key(&ev, egui::Key::Enter, true));
assert!(!events_have_text(&ev));
}
#[test]
fn ctrl_char_maps_to_key_event() {
// ctrl+A must reach egui as a Key event for select-all to work.
let ev = translate_key(
&char_key("a"),
None,
true,
false,
ModifiersState::CONTROL,
);
assert!(is_key(&ev, egui::Key::A, true));
assert!(!events_have_text(&ev));
}
fn events_have_text(events: &[egui::Event]) -> bool {
events.iter().any(|e| matches!(e, egui::Event::Text(_)))
}
}

View File

@ -180,6 +180,26 @@ pub enum Cmd {
/// Skip to the previous playlist entry. (mpv `playlist-prev`.) /// Skip to the previous playlist entry. (mpv `playlist-prev`.)
PlaylistPrev, PlaylistPrev,
/// Move the queue entry at index `from` so that it takes the place of the
/// entry currently at index `to` (mpv `playlist-move` semantics: the moved
/// entry is inserted *before* the entry at `to`; `to == playlist.len()`
/// appends to the end; after `playlist-move i j` with `i < j` the entry
/// lands at `j - 1`). Indices are 0-based.
PlaylistMove {
from: usize,
to: usize,
},
/// Remove the queue entry at `index`. (mpv `playlist-remove`.)
PlaylistRemove {
index: usize,
},
/// Jump to playing the queue entry at `index`. (mpv `playlist-play-index`.)
PlaylistPlayIndex {
index: usize,
},
// ---- Speed --------------------------------------------------------- // ---- Speed ---------------------------------------------------------
/// Set playback speed. mpv range is 0.01..=100.0; we expose 0.25..=4.0 /// Set playback speed. mpv range is 0.01..=100.0; we expose 0.25..=4.0
@ -222,6 +242,35 @@ pub enum Cmd {
/// `vflip`. Pass `true` to enable, `false` to disable. /// `vflip`. Pass `true` to enable, `false` to disable.
SetVideoFlipV(bool), SetVideoFlipV(bool),
// ---- Video zoom / pan ----------------------------------------------
/// Set the video zoom directly, in log2 units (mpv `video-zoom`):
/// 0 = fit-to-window, 1.0 = 2×, -1.0 = ½×. Clamped to
/// [`VIDEO_ZOOM_RANGE`].
SetVideoZoom(f32),
/// Adjust zoom by a delta in log2 units (menu steps, Ctrl+wheel).
/// Clamped to [`VIDEO_ZOOM_RANGE`].
AdjustVideoZoom(f32),
/// Set the video pan directly, in screen-fraction units (mpv
/// `video-pan-x` / `video-pan-y`). Positive x = right, positive
/// y = down. Each axis is clamped to ±[`VIDEO_PAN_LIMIT`].
SetVideoPan {
x: f32,
y: f32,
},
/// Pan by deltas in screen-fraction units — the drag-the-video
/// gesture. Positive dx = right, positive dy = down (window coords).
AdjustVideoPan {
dx: f32,
dy: f32,
},
/// Reset zoom and pan to neutral (zoom 0, pan 0,0).
ResetVideoPanZoom,
// ---- A/B markers --------------------------------------------------- // ---- A/B markers ---------------------------------------------------
/// Drop marker A at the current playback position (mpv `time-pos`). /// Drop marker A at the current playback position (mpv `time-pos`).
@ -394,3 +443,91 @@ pub(crate) fn build_loadfile(path: &str, opts: &LoadOptions) -> CoreResult<mpv_b
pub(crate) fn build_seek(target: f64, mode: SeekMode, flags: SeekFlags) -> CoreResult<mpv_bindings::command::Command> { pub(crate) fn build_seek(target: f64, mode: SeekMode, flags: SeekFlags) -> CoreResult<mpv_bindings::command::Command> {
Ok(mpv_bindings::command::Command::seek(target, mode, flags)?) Ok(mpv_bindings::command::Command::seek(target, mode, flags)?)
} }
/// Translate "move the entry at `from` so its **final** index is `final_pos`"
/// into the `(from, to)` pair used by `Cmd::PlaylistMove` (mpv's
/// insert-before semantics). `len` is the current queue length; `to` may come
/// out as `len`, which mpv interprets as "append at the end".
///
/// Used by the queue sidebar's type-a-number reordering.
pub fn playlist_move_args_for_final(from: usize, final_pos: usize, len: usize) -> (usize, usize) {
if final_pos <= from {
(from, final_pos)
} else {
(from, (final_pos + 1).min(len))
}
}
// ---- Video zoom / pan limits -------------------------------------------
/// Allowed zoom range in log2 units: -1.0 = half size, 2.0 = 4×.
/// mpv itself accepts (much) wider values; this keeps the UI from losing
/// the video off-canvas.
pub const VIDEO_ZOOM_RANGE: (f32, f32) = (-1.0, 2.0);
/// Allowed per-axis pan range, in screen fractions. ±1.0 already moves the
/// video a full window across — beyond that there is nothing to look at.
pub const VIDEO_PAN_LIMIT: f32 = 1.0;
/// Clamp a zoom value (log2 units) into [`VIDEO_ZOOM_RANGE`].
pub fn clamp_video_zoom(v: f32) -> f32 {
v.clamp(VIDEO_ZOOM_RANGE.0, VIDEO_ZOOM_RANGE.1)
}
/// Clamp a pan axis value into ±[`VIDEO_PAN_LIMIT`].
pub fn clamp_video_pan(v: f32) -> f32 {
v.clamp(-VIDEO_PAN_LIMIT, VIDEO_PAN_LIMIT)
}
#[cfg(test)]
mod playlist_move_tests {
use super::playlist_move_args_for_final as args;
#[test]
fn moving_up_inserts_before_target() {
// Entry 4 → final index 1: takes the place of the entry at 1.
assert_eq!(args(4, 1, 6), (4, 1));
}
#[test]
fn moving_down_lands_after_target() {
// Entry 1 → final index 3 must insert before the entry currently at 4.
assert_eq!(args(1, 3, 6), (1, 4));
}
#[test]
fn move_to_end_clamps_to_len() {
// Entry 0 → final index 5 of 6 = last slot; insert-before 6 == append.
assert_eq!(args(0, 5, 6), (0, 6));
// Even an out-of-range request clamps instead of overflowing.
assert_eq!(args(0, 99, 6), (0, 6));
}
#[test]
fn same_position_is_noop() {
assert_eq!(args(2, 2, 6), (2, 2));
}
}
#[cfg(test)]
mod zoom_pan_tests {
use super::{clamp_video_pan, clamp_video_zoom, VIDEO_PAN_LIMIT, VIDEO_ZOOM_RANGE};
#[test]
fn zoom_clamps_to_range() {
assert_eq!(clamp_video_zoom(5.0), VIDEO_ZOOM_RANGE.1);
assert_eq!(clamp_video_zoom(-9.0), VIDEO_ZOOM_RANGE.0);
assert_eq!(clamp_video_zoom(0.5), 0.5);
// -1.0 log2 = half size, 2.0 log2 = 4x — both reachable exactly.
assert_eq!(clamp_video_zoom(-1.0), -1.0);
assert_eq!(clamp_video_zoom(2.0), 2.0);
}
#[test]
fn pan_clamps_symmetric() {
assert_eq!(clamp_video_pan(3.0), VIDEO_PAN_LIMIT);
assert_eq!(clamp_video_pan(-3.0), -VIDEO_PAN_LIMIT);
assert_eq!(clamp_video_pan(0.25), 0.25);
assert_eq!(clamp_video_pan(0.0), 0.0);
}
}

View File

@ -0,0 +1,159 @@
//! Map the on-screen zoom/pan view onto a source-space crop rectangle.
//!
//! mpv positions the video in the window with (per axis, see
//! `src_dst_split_scaling` in mpv's `video/out/aspect.c`):
//!
//! ```text
//! scaled_size = aspect_fit_size * 2^zoom
//! dst_start = (window_size - scaled_size) / 2 + pan * scaled_size
//! ```
//!
//! i.e. the video is aspect-fit into the window (letterbox/pillarbox,
//! panscan 0, no margins), zoomed around the window center, and pan is
//! measured in fractions of the *scaled* video size.
//!
//! The functions here reproduce that transform to answer: "which rectangle
//! of source pixels is currently visible in the window?" — used by the
//! A-B clip export so a saved clip contains exactly the focus area the
//! user aligned, instead of the full frame.
/// A crop rectangle in source pixels: (x, y, width, height).
pub type CropRect = (u32, u32, u32, u32);
/// Compute the source-space rectangle visible in a `win_w` × `win_h`
/// window given the current zoom (log2 units) and pan (screen fractions).
///
/// Returns `None` when the whole frame is visible (no zoom in, no pan, or
/// the panned video still covers the window) — then no crop is needed.
/// Also `None` for degenerate inputs (unknown source or window size).
///
/// Dimensions are rounded to even numbers (yuv420p/libx264 friendly) by
/// shrinking, and coordinates are clamped inside the frame.
pub fn visible_crop(
src_w: u32,
src_h: u32,
win_w: u32,
win_h: u32,
zoom: f32,
pan_x: f32,
pan_y: f32,
) -> Option<CropRect> {
if src_w == 0 || src_h == 0 || win_w == 0 || win_h == 0 {
return None;
}
let (sw, sh, ww, wh) = (src_w as f64, src_h as f64, win_w as f64, win_h as f64);
if !zoom.is_finite() || !pan_x.is_finite() || !pan_y.is_finite() {
return None;
}
// Aspect-fit scale (mpv `aspect_calc_panscan`, panscan 0, no margins).
let fit = (ww / sw).min(wh / sh);
let scale = fit * 2.0_f32.powf(zoom) as f64;
// Scaled display size and top-left corner (centered + pan, pan in
// units of the scaled size — exactly mpv's arithmetic).
let dw = sw * scale;
let dh = sh * scale;
let x0 = (ww - dw) / 2.0 + (pan_x as f64) * dw;
let y0 = (wh - dh) / 2.0 + (pan_y as f64) * dh;
// Visible overlap of the video rect with the window, in window px.
let vx0 = x0.max(0.0);
let vx1 = (x0 + dw).min(ww);
let vy0 = y0.max(0.0);
let vy1 = (y0 + dh).min(wh);
// Map the overlap back to source pixels.
let sx = ((vx0 - x0) / dw * sw).floor().clamp(0.0, sw) as u32;
let ex = ((vx1 - x0) / dw * sw).ceil().clamp(0.0, sw) as u32;
let sy = ((vy0 - y0) / dh * sh).floor().clamp(0.0, sh) as u32;
let ey = ((vy1 - y0) / dh * sh).ceil().clamp(0.0, sh) as u32;
// Full frame visible → nothing to crop.
let full = sx == 0 && sy == 0 && ex == src_w && ey == src_h;
if full {
return None;
}
// Even dimensions for yuv420p, kept inside the frame.
let mut w = ex - sx;
let mut h = ey - sy;
w -= w % 2;
h -= h % 2;
if w == 0 || h == 0 {
return None;
}
Some((sx, sy, w, h))
}
#[cfg(test)]
mod tests {
use super::*;
/// src 100×100 in a 100×100 window: fit scale 1, no letterbox.
#[test]
fn no_zoom_no_pan_is_full_frame() {
assert_eq!(visible_crop(100, 100, 100, 100, 0.0, 0.0, 0.0), None);
}
#[test]
fn degenerate_inputs_are_none() {
assert_eq!(visible_crop(0, 100, 100, 100, 1.0, 0.0, 0.0), None);
assert_eq!(visible_crop(100, 100, 0, 100, 0.0, 0.5, 0.0), None);
}
/// 2× zoom, square video, square window: the centered half is visible.
#[test]
fn zoom_2x_shows_center_quarter() {
let (x, y, w, h) = visible_crop(100, 100, 100, 100, 1.0, 0.0, 0.0).unwrap();
assert_eq!((x, y, w, h), (25, 25, 50, 50));
}
/// Pan right by half the scaled size slides the video right, so the
/// window ends up over the video's LEFT half (mpv: pan is relative to
/// the scaled video size, not the window).
#[test]
fn pan_right_shows_left_half_of_source() {
let (x, y, w, h) = visible_crop(100, 100, 100, 100, 0.0, 0.5, 0.0).unwrap();
assert_eq!((x, y, w, h), (0, 0, 50, 100));
}
/// Pan beyond the window keeps the crop clamped to the frame edge: only
/// the first 10 source columns remain visible.
#[test]
fn pan_off_screen_clamps() {
let (x, _y, w, _h) = visible_crop(100, 100, 100, 100, 0.0, 0.9, 0.0).unwrap();
assert_eq!((x, w), (0, 10));
}
/// Zoom *out* (0.5×) always leaves the whole frame visible — the
/// letterbox bars belong to the window, not the source.
#[test]
fn zoom_out_needs_no_crop() {
assert_eq!(visible_crop(100, 100, 100, 100, -1.0, 0.0, 0.0), None);
// Panning a zoomed-out video can still push part of it off-screen.
assert!(visible_crop(100, 100, 100, 100, -1.0, 0.9, 0.0).is_some());
}
/// Letterboxed fit: src 200×100 in a 400×100 window fits by height
/// (scale 1), so 2× zoom makes x exactly fill the window while y
/// overflows — the crop takes only the centered vertical half.
#[test]
fn letterboxed_fit_drives_scale() {
let (x, y, w, h) = visible_crop(200, 100, 400, 100, 1.0, 0.0, 0.0).unwrap();
assert_eq!((x, y, w, h), (0, 25, 200, 50));
}
/// Real-world-ish sizes stay even and in bounds. 2× zoom on 1920×1080
/// shows source x 480..1440; panning right by a quarter of the scaled
/// size slides the visible window back to x 0..960 (clamped at the
/// frame edge), y stays the centered half 270..810.
#[test]
fn odd_sizes_round_to_even() {
let (x, y, w, h) = visible_crop(1920, 1080, 1920, 1080, 1.0, 0.25, 0.0).unwrap();
assert_eq!(w % 2, 0);
assert_eq!(h % 2, 0);
assert!(x + w <= 1920 && y + h <= 1080);
assert_eq!((x, y, w, h), (0, 270, 960, 540));
}
}

View File

@ -53,6 +53,14 @@ const PROP_AB_LOOP_B: EventId = 12;
const PROP_AID: EventId = 13; const PROP_AID: EventId = 13;
const PROP_SID: EventId = 14; const PROP_SID: EventId = 14;
const PROP_SUB_VISIBILITY: EventId = 15; const PROP_SUB_VISIBILITY: EventId = 15;
const PROP_VO_CONFIGURED: EventId = 16;
const PROP_PLAYLIST_COUNT: EventId = 17;
const PROP_PLAYLIST_POS: EventId = 18;
const PROP_VIDEO_ZOOM: EventId = 19;
const PROP_VIDEO_PAN_X: EventId = 20;
const PROP_VIDEO_PAN_Y: EventId = 21;
const PROP_VIDEO_WIDTH: EventId = 22;
const PROP_VIDEO_HEIGHT: EventId = 23;
/// The engine. Construct with `PlayerEngine::new()`, then `start()`, then /// The engine. Construct with `PlayerEngine::new()`, then `start()`, then
/// issue commands via `send()`. Consume events via `take_event_receiver()`. /// issue commands via `send()`. Consume events via `take_event_receiver()`.
@ -251,6 +259,29 @@ fn engine_main(
// Subtitle visibility — when false, subtitles are hidden even if a // Subtitle visibility — when false, subtitles are hidden even if a
// track is selected. (mpv `sub-visibility`.) // track is selected. (mpv `sub-visibility`.)
(PROP_SUB_VISIBILITY, "sub-visibility", Format::Flag), (PROP_SUB_VISIBILITY, "sub-visibility", Format::Flag),
// Video output health. Flips true once mpv has actually initialized
// a VO and presented a frame; if it stays false after a file loads,
// the VO failed (no GL context, bad driver, ...) and the UI can warn
// the user instead of showing a silent black window.
(PROP_VO_CONFIGURED, "vo-configured", Format::Flag),
// Queue/playlist size — fires when entries are added or removed,
// prompting a full re-enumeration of playlist/N/filename (see
// `refresh_playlist`). Reorders don't change the count, so the
// Playlist* commands also refresh explicitly.
(PROP_PLAYLIST_COUNT, "playlist-count", Format::Int64),
// Currently-playing queue index — changes as playback advances
// through the queue, and when the user jumps to another entry.
(PROP_PLAYLIST_POS, "playlist-playing-pos", Format::Int64),
// Video zoom / pan — the movable-object-on-a-canvas controls. Zoom
// is log2 units (1 = 2x); pan is screen fractions. Observed so the
// state mirror stays true even if mpv changes them itself.
(PROP_VIDEO_ZOOM, "video-zoom", Format::Double),
(PROP_VIDEO_PAN_X, "video-pan-x", Format::Double),
(PROP_VIDEO_PAN_Y, "video-pan-y", Format::Double),
// Source video dimensions — needed to map the on-screen zoom/pan
// focus area back to source pixels for the A-B clip export.
(PROP_VIDEO_WIDTH, "width", Format::Int64),
(PROP_VIDEO_HEIGHT, "height", Format::Int64),
]; ];
for (tag, name, fmt) in observed { for (tag, name, fmt) in observed {
if let Err(e) = mpv.observe_property(tag, name, fmt) { if let Err(e) = mpv.observe_property(tag, name, fmt) {
@ -514,6 +545,42 @@ fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()>
mpv.command(&cmd)?; mpv.command(&cmd)?;
Ok(()) Ok(())
} }
Cmd::PlaylistMove { from, to } => {
let count = mpv.get_property_i64("playlist-count").unwrap_or(0).max(0) as usize;
let from = (*from).min(count.saturating_sub(1));
let to = (*to).min(count);
if from != to {
let cmd = mpv_bindings::command::Command::new()
.arg("playlist-move")?
.arg(from.to_string())?
.arg(to.to_string())?;
mpv.command(&cmd)?;
// playlist-count doesn't change on a move, so the observer
// won't fire — refresh the mirror explicitly.
refresh_playlist(mpv, bus);
}
Ok(())
}
Cmd::PlaylistRemove { index } => {
let count = mpv.get_property_i64("playlist-count").unwrap_or(0).max(0) as usize;
if *index < count {
let cmd = mpv_bindings::command::Command::new()
.arg("playlist-remove")?
.arg(index.to_string())?;
mpv.command(&cmd)?;
// The count observer fires on removal too, but refreshing
// here keeps the UI in the same frame as the click.
refresh_playlist(mpv, bus);
}
Ok(())
}
Cmd::PlaylistPlayIndex { index } => {
let cmd = mpv_bindings::command::Command::new()
.arg("playlist-play-index")?
.arg(index.to_string())?;
mpv.command(&cmd)?;
Ok(())
}
// ---- Speed ------------------------------------------------------ // ---- Speed ------------------------------------------------------
@ -600,6 +667,69 @@ fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()>
Ok(()) Ok(())
} }
// ---- Video zoom / pan --------------------------------------------
Cmd::SetVideoZoom(v) => {
let v = crate::cmd::clamp_video_zoom(*v);
mpv.set_property(&Property::double("video-zoom", v as f64))?;
bus.update_state(|s| s.video_zoom = v);
bus.send(EngineEvent::StateChanged);
Ok(())
}
Cmd::AdjustVideoZoom(d) => {
let cur = bus.snapshot().video_zoom;
let v = crate::cmd::clamp_video_zoom(cur + *d);
mpv.set_property(&Property::double("video-zoom", v as f64))?;
bus.update_state(|s| s.video_zoom = v);
bus.send(EngineEvent::StateChanged);
Ok(())
}
Cmd::SetVideoPan { x, y } => {
let x = crate::cmd::clamp_video_pan(*x);
let y = crate::cmd::clamp_video_pan(*y);
mpv.set_property(&Property::double("video-pan-x", x as f64))?;
mpv.set_property(&Property::double("video-pan-y", y as f64))?;
bus.update_state(|s| {
s.video_pan_x = x;
s.video_pan_y = y;
});
bus.send(EngineEvent::StateChanged);
Ok(())
}
Cmd::AdjustVideoPan { dx, dy } => {
let (px, py) = {
let st = bus.snapshot();
(st.video_pan_x, st.video_pan_y)
};
let x = crate::cmd::clamp_video_pan(px + *dx);
let y = crate::cmd::clamp_video_pan(py + *dy);
if x != px {
mpv.set_property(&Property::double("video-pan-x", x as f64))?;
}
if y != py {
mpv.set_property(&Property::double("video-pan-y", y as f64))?;
}
bus.update_state(|s| {
s.video_pan_x = x;
s.video_pan_y = y;
});
bus.send(EngineEvent::StateChanged);
Ok(())
}
Cmd::ResetVideoPanZoom => {
mpv.set_property(&Property::double("video-zoom", 0.0))?;
mpv.set_property(&Property::double("video-pan-x", 0.0))?;
mpv.set_property(&Property::double("video-pan-y", 0.0))?;
bus.update_state(|s| {
s.video_zoom = 0.0;
s.video_pan_x = 0.0;
s.video_pan_y = 0.0;
});
bus.send(EngineEvent::StateChanged);
info!("video zoom/pan reset");
Ok(())
}
// ---- A/B markers ----------------------------------------------- // ---- A/B markers -----------------------------------------------
Cmd::SetMarkerA => { Cmd::SetMarkerA => {
@ -814,6 +944,10 @@ fn handle_mpv_event(event: &MpvEvent, bus: &EngineEventBus, mpv: &MpvHandle) {
}); });
// Refresh audio tracks — track-list/count may not have fired yet. // Refresh audio tracks — track-list/count may not have fired yet.
refresh_audio_tracks(mpv, bus); refresh_audio_tracks(mpv, bus);
// Same for the queue: the first loadfile replaces the (empty)
// playlist, later ones append; either way the mirror should be
// correct the moment the file comes up.
refresh_playlist(mpv, bus);
bus.send(EngineEvent::FileLoaded { path, title }); bus.send(EngineEvent::FileLoaded { path, title });
} }
MpvEvent::EndFile { reason, error } => { MpvEvent::EndFile { reason, error } => {
@ -831,11 +965,14 @@ fn handle_mpv_event(event: &MpvEvent, bus: &EngineEventBus, mpv: &MpvHandle) {
bus.send(EngineEvent::EndReached { reason: r }); bus.send(EngineEvent::EndReached { reason: r });
} }
MpvEvent::PropertyChange { reply_userdata, name, value } => { MpvEvent::PropertyChange { reply_userdata, name, value } => {
let (changed, want_track_refresh) = let (changed, want_track_refresh, want_playlist_refresh) =
apply_property_change(bus, *reply_userdata, name, value); apply_property_change(bus, *reply_userdata, name, value);
if want_track_refresh { if want_track_refresh {
refresh_audio_tracks(mpv, bus); refresh_audio_tracks(mpv, bus);
} }
if want_playlist_refresh {
refresh_playlist(mpv, bus);
}
if changed { if changed {
bus.send(EngineEvent::StateChanged); bus.send(EngineEvent::StateChanged);
} }
@ -874,15 +1011,17 @@ fn apply_property_change(
tag: EventId, tag: EventId,
name: &str, name: &str,
value: &mpv_bindings::event::PropertyValue, value: &mpv_bindings::event::PropertyValue,
) -> (bool, bool) { ) -> (bool, bool, bool) {
use mpv_bindings::event::PropertyValue as V; use mpv_bindings::event::PropertyValue as V;
let mut changed = true; let mut changed = true;
let mut want_track_refresh = false; let mut want_track_refresh = false;
let mut want_playlist_refresh = false;
bus.update_state(|s| { bus.update_state(|s| {
match (tag, value) { match (tag, value) {
(PROP_TIME_POS, V::Double(d)) => s.time_pos = Some(*d), (PROP_TIME_POS, V::Double(d)) => s.time_pos = Some(*d),
(PROP_DURATION, V::Double(d)) => s.duration = Some(*d), (PROP_DURATION, V::Double(d)) => s.duration = Some(*d),
(PROP_PAUSE, V::Flag(b)) => s.paused = *b, (PROP_PAUSE, V::Flag(b)) => s.paused = *b,
(PROP_VO_CONFIGURED, V::Flag(b)) => s.vo_configured = *b,
(PROP_VOLUME, V::Double(d)) => s.volume = (*d as f32 / 100.0).clamp(0.0, 1.0), (PROP_VOLUME, V::Double(d)) => s.volume = (*d as f32 / 100.0).clamp(0.0, 1.0),
(PROP_MUTE, V::Flag(b)) => s.muted = *b, (PROP_MUTE, V::Flag(b)) => s.muted = *b,
(PROP_PATH, V::String(s2)) => s.path = Some(s2.clone()), (PROP_PATH, V::String(s2)) => s.path = Some(s2.clone()),
@ -898,6 +1037,18 @@ fn apply_property_change(
want_track_refresh = true; want_track_refresh = true;
changed = false; changed = false;
} }
// Queue size changed — re-enumerate the playlist mirror.
// refresh_playlist() does the actual state update.
(PROP_PLAYLIST_COUNT, V::Int64(_)) => {
want_playlist_refresh = true;
changed = false;
}
(PROP_PLAYLIST_POS, V::Int64(i)) => s.playlist_pos = *i,
(PROP_VIDEO_ZOOM, V::Double(d)) => s.video_zoom = *d as f32,
(PROP_VIDEO_PAN_X, V::Double(d)) => s.video_pan_x = *d as f32,
(PROP_VIDEO_PAN_Y, V::Double(d)) => s.video_pan_y = *d as f32,
(PROP_VIDEO_WIDTH, V::Int64(w)) => s.video_width = (*w).max(0) as u32,
(PROP_VIDEO_HEIGHT, V::Int64(h)) => s.video_height = (*h).max(0) as u32,
(PROP_AB_LOOP_A, V::Double(d)) => s.marker_a = Some(*d), (PROP_AB_LOOP_A, V::Double(d)) => s.marker_a = Some(*d),
(PROP_AB_LOOP_A, V::String(st)) => { (PROP_AB_LOOP_A, V::String(st)) => {
// mpv returns "no" when ab-loop-a is unset, or a number string. // mpv returns "no" when ab-loop-a is unset, or a number string.
@ -928,6 +1079,9 @@ fn apply_property_change(
PROP_AB_LOOP_B => s.marker_b = None, PROP_AB_LOOP_B => s.marker_b = None,
PROP_AID => s.current_audio_track = None, PROP_AID => s.current_audio_track = None,
PROP_SID => s.current_subtitle_track = None, PROP_SID => s.current_subtitle_track = None,
PROP_PLAYLIST_POS => s.playlist_pos = -1,
PROP_VIDEO_WIDTH => s.video_width = 0,
PROP_VIDEO_HEIGHT => s.video_height = 0,
_ => changed = false, _ => changed = false,
} }
} }
@ -937,7 +1091,7 @@ fn apply_property_change(
} }
}); });
let _ = name; let _ = name;
(changed, want_track_refresh) (changed, want_track_refresh, want_playlist_refresh)
} }
/// Enumerate every track (audio + sub) by walking `track-list/N/*` /// Enumerate every track (audio + sub) by walking `track-list/N/*`
@ -977,6 +1131,33 @@ fn refresh_audio_tracks(mpv: &MpvHandle, bus: &EngineEventBus) {
bus.send(EngineEvent::StateChanged); bus.send(EngineEvent::StateChanged);
} }
/// Mirror mpv's playlist (the queue) into the shared state snapshot.
/// Called whenever the playlist changes shape (count observer, FileLoaded)
/// and explicitly after PlaylistMove/PlaylistRemove — a reorder keeps the
/// count constant, so the observer alone would miss it.
fn refresh_playlist(mpv: &MpvHandle, bus: &EngineEventBus) {
let Some(count) = mpv.get_property_i64("playlist-count").ok() else {
return;
};
let count = count.max(0);
let mut entries = Vec::with_capacity(count as usize);
for i in 0..count {
// Keep index alignment with mpv even if an entry fails to read.
let name = mpv
.get_property_string(&format!("playlist/{i}/filename"))
.ok()
.flatten()
.unwrap_or_default();
entries.push(name);
}
let pos = mpv.get_property_i64("playlist-playing-pos").unwrap_or(-1);
bus.update_state(|s| {
s.playlist = entries;
s.playlist_pos = pos;
});
bus.send(EngineEvent::StateChanged);
}
/// Read one `track-list/N` entry into a `Track`. Returns `None` for video /// Read one `track-list/N` entry into a `Track`. Returns `None` for video
/// tracks and unreadable entries — audio/sub tracks only. /// tracks and unreadable entries — audio/sub tracks only.
fn read_track(mpv: &MpvHandle, i: i64) -> Option<Track> { fn read_track(mpv: &MpvHandle, i: i64) -> Option<Track> {

View File

@ -25,6 +25,7 @@
#![allow(dead_code)] #![allow(dead_code)]
pub mod cmd; pub mod cmd;
pub mod crop;
pub mod engine; pub mod engine;
pub mod event; pub mod event;
pub mod state; pub mod state;
@ -43,3 +44,44 @@ pub use event::{EngineEvent, EngineEventSender};
pub use state::{AudioTrack, PlaybackState, PlayerStatus, Track}; pub use state::{AudioTrack, PlaybackState, PlayerStatus, Track};
pub use options::EngineOptions; pub use options::EngineOptions;
pub use error::{CoreError, CoreResult}; pub use error::{CoreError, CoreResult};
#[cfg(test)]
mod engine_tests {
use crate::options::EngineOptions;
use crate::{Cmd, EngineEvent, PlayerEngine};
use std::time::Duration;
/// The engine must come up with the default options — including the new
/// system-locale-derived `alang` (an invalid option value would abort
/// libmpv init with MPV_ERROR_OPTION_ERROR). Uses vo=null so it runs
/// headless (no GPU / X11 required).
#[test]
fn engine_starts_with_default_options_including_alang() {
let opts = EngineOptions {
vo: "null".into(),
wid: None,
..EngineOptions::default()
};
let mut engine = PlayerEngine::new(opts).expect("engine construct");
engine.start().expect("engine start (libmpv init + alang accepted)");
// The engine publishes Ready on its event channel once running.
let rx = engine.take_event_receiver().expect("event receiver");
let mut ready = false;
for _ in 0..20 {
if let Ok(ev) = rx.recv_timeout(Duration::from_millis(250)) {
if matches!(ev, EngineEvent::Ready) {
ready = true;
break;
}
}
}
assert!(ready, "engine never reported Ready");
// And it accepts commands (this also exercises the drop/shutdown
// path, which joins the engine thread).
engine.send(Cmd::PlayPause).expect("send cmd");
engine.shutdown().expect("shutdown");
}
}

View File

@ -36,13 +36,26 @@ pub struct EngineOptions {
/// MUST be set before `mpv_initialize` (i.e. before `engine.start()`). /// MUST be set before `mpv_initialize` (i.e. before `engine.start()`).
pub wid: Option<String>, pub wid: Option<String>,
/// Video output driver. `"gpu"` for embedded rendering via `wid`. /// Video output driver(s). A comma-separated priority list: mpv tries
/// `"libmpv"` selects the render-context API (target: Wayland support). /// each in order, falling back to the next if one fails to initialize.
/// Default `"gpu,xv,x11"` — `gpu` for the normal GL path, then Xv, and
/// finally the software `x11` driver which works on ANY X server. This
/// means a broken GL stack degrades to slow-but-visible video instead
/// of a silent black window. `"libmpv"` selects the render-context API
/// (target: Wayland support).
pub vo: String, pub vo: String,
/// Initial loop mode. Off by default. Set to File/Playlist at construction /// Initial loop mode. Off by default. Set to File/Playlist at construction
/// if you want looping on startup. Can be changed at runtime via Cmd::SetLoopMode. /// if you want looping on startup. Can be changed at runtime via Cmd::SetLoopMode.
pub loop_mode: LoopMode, pub loop_mode: LoopMode,
/// Preferred audio languages for track auto-selection (mpv `alang`), as a
/// comma-separated list of ISO language codes (e.g. "en", "en,fr").
/// When set, mpv prefers these languages over the container's
/// "default"-flagged track — the default comes from the system locale
/// environment instead of the video's own metadata. `None` leaves mpv's
/// default behavior (container default flag first).
pub alang: Option<String>,
} }
impl Default for EngineOptions { impl Default for EngineOptions {
@ -56,8 +69,14 @@ impl Default for EngineOptions {
volume_max: 1.0, volume_max: 1.0,
log_level: "warn".to_string(), log_level: "warn".to_string(),
wid: None, wid: None,
vo: "gpu".to_string(), // VO fallback chain: GL first, Xv second, software X11 last.
// See the field docs — this is what keeps video visible when
// the GPU/GL path fails on exotic setups.
vo: "gpu,xv,x11".to_string(),
loop_mode: LoopMode::Off, loop_mode: LoopMode::Off,
// Default the audio language to the system environment's locale,
// NOT the video container's "default" flag.
alang: system_locale_alang(),
} }
} }
} }
@ -101,9 +120,81 @@ impl EngineOptions {
.expect("LoopMode is exhaustive over LOOP_TABLE"); .expect("LoopMode is exhaustive over LOOP_TABLE");
v.push(("loop-file", file_v.into())); v.push(("loop-file", file_v.into()));
v.push(("loop-playlist", list_v.into())); v.push(("loop-playlist", list_v.into()));
// Audio language preference: system-locale derived (see
// `system_locale_alang`). mpv falls back to the container default
// when no track matches any listed language.
if let Some(alang) = &self.alang {
v.push(("alang", alang.clone()));
}
if let Some(wid) = &self.wid { if let Some(wid) = &self.wid {
v.push(("wid", wid.clone())); v.push(("wid", wid.clone()));
} }
v v
} }
} }
/// Derive an mpv `alang` value from the system locale environment.
///
/// Consults `LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, and `LANG` (in that order)
/// and extracts the language codes from each entry ("en_US.UTF-8" → "en",
/// "fr_CA" → "fr", "C"/"POSIX" → ignored). `LANGUAGE` is a colon-separated
/// priority list on GNU systems, so every entry is kept in order — the
/// result is a comma-separated preference list for mpv.
///
/// Returns `None` when nothing usable is set (mpv then falls back to its own
/// defaults).
fn system_locale_alang() -> Option<String> {
let mut langs: Vec<String> = Vec::new();
// LANGUAGE is an ordered, colon-separated list (GNU gettext convention).
if let Ok(v) = std::env::var("LANGUAGE") {
for tag in v.split(':') {
push_locale_tag(&mut langs, tag);
}
}
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Ok(v) = std::env::var(var) {
push_locale_tag(&mut langs, &v);
}
}
if langs.is_empty() {
None
} else {
Some(langs.join(","))
}
}
/// Append one locale tag's language code to `langs` (deduped, lowercased).
/// Accepts "en_US.UTF-8", "fr_CA", "de_DE@euro", "en"; rejects "C",
/// "POSIX", and empty tags.
fn push_locale_tag(langs: &mut Vec<String>, tag: &str) {
let tag = tag.trim();
// Strip country / encoding suffixes: "en_US.UTF-8" → "en".
let lang = tag.split(['_', '.', '@']).next().unwrap_or("");
// Accept plausible ISO 639 codes (2-3 letters); reject "C", "POSIX".
let plausible =
(2..=3).contains(&lang.len()) && lang.chars().all(|c| c.is_ascii_alphabetic());
if plausible && !langs.iter().any(|l| l.eq_ignore_ascii_case(lang)) {
langs.push(lang.to_ascii_lowercase());
}
}
#[cfg(test)]
mod tests {
#[test]
fn parses_common_locale_forms() {
fn one(tag: &str) -> Option<String> {
let mut v = Vec::new();
super::push_locale_tag(&mut v, tag);
v.into_iter().next()
}
assert_eq!(one("en_US.UTF-8").as_deref(), Some("en"));
assert_eq!(one("fr_CA").as_deref(), Some("fr"));
assert_eq!(one("de_DE@euro").as_deref(), Some("de"));
assert_eq!(one("C").as_deref(), None);
assert_eq!(one("POSIX").as_deref(), None);
assert_eq!(one("").as_deref(), None);
assert_eq!(one("en").as_deref(), Some("en"));
}
}

View File

@ -62,6 +62,12 @@ pub struct PlaybackState {
pub duration: Option<f64>, pub duration: Option<f64>,
/// Is playback currently paused? /// Is playback currently paused?
pub paused: bool, pub paused: bool,
/// Whether mpv has initialized a video output and presented at least
/// one frame (`vo-configured`). When a file is loaded but this stays
/// false, the VO failed — the UI warns the user instead of showing a
/// silent black window.
pub vo_configured: bool,
/// Volume 0..=1 (clamped). Mapped 1:1 with libmpv's 0..=100. /// Volume 0..=1 (clamped). Mapped 1:1 with libmpv's 0..=100.
pub volume: f32, pub volume: f32,
/// Muted? /// Muted?
@ -94,6 +100,24 @@ pub struct PlaybackState {
/// Is vertical flip (upside-down) enabled? Tracked locally. /// Is vertical flip (upside-down) enabled? Tracked locally.
pub video_flip_v: bool, pub video_flip_v: bool,
/// Video zoom in log2 units (mpv `video-zoom`): 0 = fit-to-window,
/// 1.0 = 2×, -1.0 = ½×. Combined with the pan fields this lets the
/// user treat the video as a movable object on a canvas to realign a
/// focus area.
pub video_zoom: f32,
/// Video pan in screen-fraction units (mpv `video-pan-x`).
/// Positive moves the video right.
pub video_pan_x: f32,
/// Video pan in screen-fraction units (mpv `video-pan-y`).
/// Positive moves the video down.
pub video_pan_y: f32,
/// Source video dimensions in pixels (mpv `width` / `height`).
/// 0 = unknown / no file loaded. Needed to map the on-screen zoom/pan
/// focus area back to source pixels when exporting an A-B clip.
pub video_width: u32,
pub video_height: u32,
/// A/B marker positions in seconds. None = not set. /// A/B marker positions in seconds. None = not set.
/// Mirrored to mpv's `ab-loop-a` / `ab-loop-b` so mpv itself can drive /// Mirrored to mpv's `ab-loop-a` / `ab-loop-b` so mpv itself can drive
/// the looping; we cache them here for UI rendering. /// the looping; we cache them here for UI rendering.
@ -104,6 +128,15 @@ pub struct PlaybackState {
/// Current random/shuffle mode. Mirrors the engine's last `SetRandomMode` cmd. /// Current random/shuffle mode. Mirrors the engine's last `SetRandomMode` cmd.
pub random_mode: RandomMode, pub random_mode: RandomMode,
/// The mpv playlist (the queue), in play order. One entry per queued
/// file — the exact string passed to `loadfile` (an absolute path for
/// local files). Mirrored from mpv's `playlist/N/filename` properties;
/// kept in `PlaybackState` so the UI can render/reorder/save the queue.
pub playlist: Vec<String>,
/// Index into `playlist` of the currently-playing entry, or -1 when
/// nothing is playing. Mirrors mpv's `playlist-playing-pos`.
pub playlist_pos: i64,
} }
// Serialize Track for the JSON marker export. We do it manually so the // Serialize Track for the JSON marker export. We do it manually so the

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,8 @@ pub enum FileDialogKind {
LoadFile, LoadFile,
LoadFolder, LoadFolder,
LoadPlaylist, LoadPlaylist,
/// Save the current queue (in the user-organized order) as an .m3u file.
SavePlaylist,
SaveMarkers(MarkerExportFormat), SaveMarkers(MarkerExportFormat),
LoadSubtitle, LoadSubtitle,
ImportMarkers, ImportMarkers,
@ -61,6 +63,7 @@ impl FileDialogKind {
FileDialogKind::LoadFile => "Open File", FileDialogKind::LoadFile => "Open File",
FileDialogKind::LoadFolder => "Open Folder", FileDialogKind::LoadFolder => "Open Folder",
FileDialogKind::LoadPlaylist => "Open Playlist (select multiple files)", FileDialogKind::LoadPlaylist => "Open Playlist (select multiple files)",
FileDialogKind::SavePlaylist => "Save Playlist As",
FileDialogKind::SaveMarkers(_) => "Export Markers", FileDialogKind::SaveMarkers(_) => "Export Markers",
FileDialogKind::LoadSubtitle => "Open Subtitle File", FileDialogKind::LoadSubtitle => "Open Subtitle File",
FileDialogKind::ImportMarkers => "Import Markers", FileDialogKind::ImportMarkers => "Import Markers",
@ -69,7 +72,10 @@ impl FileDialogKind {
} }
fn is_save(&self) -> bool { fn is_save(&self) -> bool {
matches!(self, FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo) matches!(
self,
FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo | FileDialogKind::SavePlaylist
)
} }
fn is_multi(&self) -> bool { fn is_multi(&self) -> bool {
@ -85,6 +91,7 @@ impl FileDialogKind {
FileDialogKind::LoadFile => MEDIA_EXTENSIONS, FileDialogKind::LoadFile => MEDIA_EXTENSIONS,
FileDialogKind::LoadFolder => &[], FileDialogKind::LoadFolder => &[],
FileDialogKind::LoadPlaylist => &[], // we filter in-code (media OR playlist) FileDialogKind::LoadPlaylist => &[], // we filter in-code (media OR playlist)
FileDialogKind::SavePlaylist => &["m3u", "m3u8"],
FileDialogKind::SaveMarkers(fmt) => match fmt { FileDialogKind::SaveMarkers(fmt) => match fmt {
MarkerExportFormat::Text => &["txt"], MarkerExportFormat::Text => &["txt"],
MarkerExportFormat::Json => &["json"], MarkerExportFormat::Json => &["json"],
@ -104,6 +111,7 @@ impl FileDialogKind {
MEDIA_EXTENSIONS.contains(&ext) || PLAYLIST_EXTENSIONS.contains(&ext) MEDIA_EXTENSIONS.contains(&ext) || PLAYLIST_EXTENSIONS.contains(&ext)
} }
FileDialogKind::SaveMarkers(_) => true, // save accepts any extension FileDialogKind::SaveMarkers(_) => true, // save accepts any extension
FileDialogKind::SavePlaylist => true, // save accepts any extension
FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS.contains(&ext), FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS.contains(&ext),
FileDialogKind::ImportMarkers => MARKER_EXTENSIONS.contains(&ext), FileDialogKind::ImportMarkers => MARKER_EXTENSIONS.contains(&ext),
FileDialogKind::ExportVideo => true, // save accepts any extension FileDialogKind::ExportVideo => true, // save accepts any extension
@ -147,6 +155,13 @@ pub struct FileDialog {
impl FileDialog { impl FileDialog {
pub fn open(kind: FileDialogKind) -> Self { pub fn open(kind: FileDialogKind) -> Self {
Self::open_with_filename(kind, None)
}
/// Open a dialog, optionally pre-filling the filename field (save
/// dialogs). `suggested` gives the user a one-click default instead of
/// forcing them to either type blind or click an existing file.
pub fn open_with_filename(kind: FileDialogKind, suggested: Option<String>) -> Self {
let start_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); let start_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let mut dlg = Self { let mut dlg = Self {
kind, kind,
@ -154,7 +169,7 @@ impl FileDialog {
entries: Vec::new(), entries: Vec::new(),
selected: None, selected: None,
selected_multi: Vec::new(), selected_multi: Vec::new(),
filename: String::new(), filename: suggested.unwrap_or_default(),
error: None, error: None,
opened_at: Instant::now(), opened_at: Instant::now(),
}; };

View File

@ -474,3 +474,28 @@ pub fn shuffle(painter: &Painter, rect: Rect, color: Color32, active: bool) {
Stroke::new(thick, col), Stroke::new(thick, col),
); );
} }
/// Queue/playlist icon — three stacked lines of decreasing length with a
/// leading bullet each (VLC-style playlist glyph). Used by the control-bar
/// button that toggles the queue sidebar.
pub fn queue(painter: &Painter, rect: Rect, color: Color32) {
let size = rect.height().min(rect.width()) * 0.6;
let cx = rect.center().x;
let cy = rect.center().y;
let line_thick = size * 0.12;
let gap = size * 0.28;
let bullet_r = size * 0.09;
for (offset, shrink) in [(-1.0_f32, 0.0_f32), (0.0, 0.15), (1.0, 0.3)] {
let y = cy + offset * gap;
// Leading bullet.
let bx = cx - size * 0.32;
painter.circle_filled(Pos2::new(bx, y), bullet_r, color);
// Line of decreasing length.
let x0 = bx + size * 0.18;
let x1 = cx + size * 0.42 - shrink * size * 0.5;
painter.line_segment(
[Pos2::new(x0, y), Pos2::new(x1, y)],
Stroke::new(line_thick, color),
);
}
}

View File

@ -16,6 +16,17 @@ use crate::app::OverlayApp;
static START_TIME: LazyLock<Instant> = LazyLock::new(Instant::now); static START_TIME: LazyLock<Instant> = LazyLock::new(Instant::now);
/// Padding (pixels) added around every painted rect before it becomes part
/// of the overlay's X11 bounding shape. Covers glyph antialiasing bleed and
/// sub-pixel rounding so no painted pixel falls outside the shape.
pub const SHAPE_PAD_PX: f32 = 2.0;
/// Upper bound on the number of rects sent to XShapeCombineRectangles per
/// frame. Above it we degrade to a single coarse union rect — the X server
/// unions the list anyway, so the only cost of coarseness is a slightly
/// larger see-through-blocking region for one frame.
const MAX_SHAPE_RECTS: usize = 64;
/// Owns the wgpu surface + egui_wgpu renderer for ONE overlay window. /// Owns the wgpu surface + egui_wgpu renderer for ONE overlay window.
pub struct OverlayRenderer { pub struct OverlayRenderer {
pub device: Arc<wgpu::Device>, pub device: Arc<wgpu::Device>,
@ -26,6 +37,16 @@ pub struct OverlayRenderer {
pub egui_ctx: egui::Context, pub egui_ctx: egui::Context,
pub app: OverlayApp, pub app: OverlayApp,
pub viewport_size: [u32; 2], pub viewport_size: [u32; 2],
/// Pixel-space rects egui actually painted last frame (window-local).
/// The main app mirrors these onto the overlay window as an X11
/// *bounding shape* (XShape), so the overlay is visually present ONLY
/// where UI chrome exists. The rest of the window is a literal hole in
/// the X window — the video window underneath shows through with NO
/// dependence on compositors, EGL/Vulkan alpha modes, or window
/// visuals. This is what makes "no video output" structurally
/// impossible: the overlay can no longer blanket the video with a
/// possibly-opaque surface.
pub painted_rects: Vec<egui::Rect>,
/// Timestamp until which the overlay must clear opaque (dark grey) /// Timestamp until which the overlay must clear opaque (dark grey)
/// instead of transparent. Set by `resize()`, `suppress_transparency()`, /// instead of transparent. Set by `resize()`, `suppress_transparency()`,
/// and the surface-error recovery paths. Keeps the desktop from showing /// and the surface-error recovery paths. Keeps the desktop from showing
@ -82,9 +103,6 @@ impl OverlayRenderer {
let caps = surface.get_capabilities(&adapter); let caps = surface.get_capabilities(&adapter);
// Prefer non-sRGB formats — egui warns about sRGB framebuffers // Prefer non-sRGB formats — egui warns about sRGB framebuffers
// ("Detected a linear (sRGBA aware) framebuffer Bgra8UnormSrgb.
// egui prefers Rgba8Unorm or Bgra8Unorm"). Non-sRGB avoids color
// management issues during window operations.
let format = caps let format = caps
.formats .formats
.iter() .iter()
@ -99,10 +117,31 @@ impl OverlayRenderer {
// broken presentation during window moves/resizes. Fifo is the // broken presentation during window moves/resizes. Fifo is the
// most compatible mode and is required by the WebGPU spec. // most compatible mode and is required by the WebGPU spec.
let present_mode = wgpu::PresentMode::Fifo; let present_mode = wgpu::PresentMode::Fifo;
// Use Auto alpha mode — let the surface pick the best-supported // Explicit alpha-mode selection. `CompositeAlphaMode::Auto` in wgpu
// compositing mode. PreMultiplied can cause artifacts on compositors // can only ever resolve to Opaque or Inherit (see wgpu-core
// that don't fully support it (common on Xfwm4). // `device/global.rs`, the `Auto` fallback list) — it will NEVER pick
let alpha_mode = wgpu::CompositeAlphaMode::Auto; // PreMultiplied/PostMultiplied, so on a Vulkan-backed surface (any
// real GPU) an `Auto` overlay presents OPAQUE black over the video:
// the recurring "app has no video output" bug. Prefer an actually
// transparent composite mode when the surface reports one; fall
// back to Opaque (harmless — the X11 bounding shape is what
// guarantees the video is visible, and the bars look fine opaque).
let alpha_mode = caps
.alpha_modes
.iter()
.copied()
.find(|m| {
matches!(
m,
wgpu::CompositeAlphaMode::PreMultiplied
| wgpu::CompositeAlphaMode::PostMultiplied
)
})
.unwrap_or(wgpu::CompositeAlphaMode::Opaque);
info!(
"overlay alpha modes: supported={:?} chosen={:?}",
caps.alpha_modes, alpha_mode
);
let size = window.inner_size(); let size = window.inner_size();
let surface_config = wgpu::SurfaceConfiguration { let surface_config = wgpu::SurfaceConfiguration {
@ -143,6 +182,7 @@ impl OverlayRenderer {
egui_ctx, egui_ctx,
app, app,
viewport_size: [size.width.max(1), size.height.max(1)], viewport_size: [size.width.max(1), size.height.max(1)],
painted_rects: Vec::new(),
force_opaque_until: None, force_opaque_until: None,
}) })
} }
@ -150,6 +190,7 @@ impl OverlayRenderer {
/// Render one frame. /// Render one frame.
pub fn render(&mut self, state: PlaybackState, mouse_pos: Option<egui::Pos2>) -> Result<()> { pub fn render(&mut self, state: PlaybackState, mouse_pos: Option<egui::Pos2>) -> Result<()> {
self.app.update_state(state); self.app.update_state(state);
self.app.pointer_pos = mouse_pos;
self.app.set_mouse_inside(mouse_pos.is_some()); self.app.set_mouse_inside(mouse_pos.is_some());
self.app.compute_visibility(); self.app.compute_visibility();
self.app.poll_events(); self.app.poll_events();
@ -176,6 +217,18 @@ impl OverlayRenderer {
self.app.draw(ctx); self.app.draw(ctx);
}); });
// Remember whether egui now has a focused text field (e.g. the
// save-dialog filename input). The main app consults this flag on
// the next physical keypress to decide whether the keystroke goes
// to the text field (translated to egui events) or to the global
// hotkey map.
self.app.ui_wants_keyboard = self.egui_ctx.wants_keyboard_input();
// Record what was actually painted, in window-local pixel coords.
// The main app applies this as the overlay's X11 bounding shape
// (see `painted_pixel_rects`).
self.painted_rects = painted_pixel_rects(&full_output.shapes, SHAPE_PAD_PX);
// Sync textures (new/updated). // Sync textures (new/updated).
for (id, image_delta) in &full_output.textures_delta.set { for (id, image_delta) in &full_output.textures_delta.set {
self.egui_renderer self.egui_renderer
@ -333,3 +386,172 @@ impl OverlayRenderer {
self.force_opaque_until = Some(Instant::now() + duration); self.force_opaque_until = Some(Instant::now() + duration);
} }
} }
// ---------------------------------------------------------------------------
// Painted-rect extraction (feeds the X11 bounding shape)
// ---------------------------------------------------------------------------
/// Compute the window-local pixel rects egui actually painted this frame.
///
/// For every clipped shape we take the shape's visual bounding box, clip it
/// to the shape's clip rect, pad it by `pad`, and then coalesce the list
/// into a small set of disjoint rects. Empty input (nothing painted —
/// controls auto-hidden, no dialogs) yields an empty Vec, which the caller
/// turns into an empty bounding region: the overlay becomes fully
/// see-through AND click-through, and the video window beneath receives
/// the input events.
pub fn painted_pixel_rects(shapes: &[egui::epaint::ClippedShape], pad: f32) -> Vec<egui::Rect> {
let pad_v = egui::vec2(pad, pad);
let mut rects: Vec<egui::Rect> = shapes
.iter()
.filter_map(|cs| {
let bounds = cs.shape.visual_bounding_rect();
// Rect::NOTHING (and any non-finite garbage) means "paints
// nothing visible".
if bounds.is_negative() || !bounds.min.is_finite() || !bounds.max.is_finite() {
return None;
}
let clipped = bounds.intersect(cs.clip_rect);
if clipped.is_negative() || clipped.width() <= 0.0 || clipped.height() <= 0.0 {
return None;
}
Some(egui::Rect::from_min_max(
clipped.min - pad_v,
clipped.max + pad_v,
))
})
.collect();
coalesce_rects(&mut rects, 4);
if rects.len() > MAX_SHAPE_RECTS {
// Too fragmented — collapse to the overall union. The X server
// unions the rect list anyway, so this is purely a protocol-cost
// guard.
let mut iter = rects.into_iter();
let Some(first) = iter.next() else {
return Vec::new();
};
let union = iter.fold(first, |acc, r| acc.union(r));
return vec![union];
}
rects
}
/// Merge overlapping rects until stable (bounded passes). Adjacent glyph
/// runs and widget clusters collapse into a handful of rects this way, so
/// the X11 shape request stays tiny.
fn coalesce_rects(rects: &mut Vec<egui::Rect>, max_passes: usize) {
for _ in 0..max_passes {
let mut out: Vec<egui::Rect> = Vec::with_capacity(rects.len());
let mut merged_any = false;
for r in rects.iter().copied() {
match out.iter_mut().find(|o| o.intersects(r)) {
Some(o) => {
*o = o.union(r);
merged_any = true;
}
None => out.push(r),
}
}
*rects = out;
if !merged_any {
break;
}
}
}
#[cfg(test)]
mod shape_tests {
use super::*;
use egui::{Color32, Pos2, Rect, Shape, Vec2};
fn clipped(clip: Rect, shape: Shape) -> egui::epaint::ClippedShape {
egui::epaint::ClippedShape { clip_rect: clip, shape }
}
fn full_screen() -> Rect {
Rect::from_min_size(Pos2::ZERO, Vec2::new(1280.0, 720.0))
}
fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
Rect::from_min_size(Pos2::new(x, y), Vec2::new(w, h))
}
#[test]
fn nothing_painted_yields_empty() {
// No shapes at all -> no rects.
assert!(painted_pixel_rects(&[], 2.0).is_empty());
// A Noop shape paints nothing.
let shapes = vec![clipped(full_screen(), Shape::Noop)];
assert!(painted_pixel_rects(&shapes, 2.0).is_empty());
}
#[test]
fn single_painted_rect_is_padded() {
let shapes = vec![clipped(
full_screen(),
Shape::rect_filled(rect(100.0, 200.0, 50.0, 20.0), 0.0, Color32::GRAY),
)];
let out = painted_pixel_rects(&shapes, 2.0);
assert_eq!(out.len(), 1);
assert_eq!(out[0], rect(98.0, 198.0, 54.0, 24.0));
}
#[test]
fn overlapping_rects_coalesce() {
let shapes = vec![
clipped(full_screen(), Shape::rect_filled(rect(0.0, 0.0, 100.0, 30.0), 0.0, Color32::GRAY)),
clipped(full_screen(), Shape::rect_filled(rect(50.0, 10.0, 100.0, 30.0), 0.0, Color32::GRAY)),
];
let out = painted_pixel_rects(&shapes, 0.0);
assert_eq!(out.len(), 1);
assert_eq!(out[0], rect(0.0, 0.0, 150.0, 40.0));
}
#[test]
fn disjoint_rects_stay_disjoint() {
// Top menu strip and bottom control bar must not merge into a
// full-window rect — that would re-create the "overlay covers the
// video" bug the shape exists to prevent.
let shapes = vec![
clipped(full_screen(), Shape::rect_filled(rect(0.0, 0.0, 1280.0, 32.0), 0.0, Color32::GRAY)),
clipped(full_screen(), Shape::rect_filled(rect(0.0, 602.0, 1280.0, 118.0), 0.0, Color32::GRAY)),
];
let out = painted_pixel_rects(&shapes, 2.0);
assert_eq!(out.len(), 2);
assert!((out[0].center().y - 16.0).abs() < 0.5);
assert!((out[1].center().y - 661.0).abs() < 0.5);
}
#[test]
fn clip_rect_trims_shape_bounds() {
// A shape whose bounds exceed its clip rect must be trimmed, so the
// shape region never claims area egui was not allowed to paint.
let shapes = vec![clipped(
rect(0.0, 0.0, 100.0, 100.0),
Shape::rect_filled(rect(0.0, 0.0, 1280.0, 720.0), 0.0, Color32::GRAY),
)];
let out = painted_pixel_rects(&shapes, 0.0);
assert_eq!(out.len(), 1);
assert_eq!(out[0], rect(0.0, 0.0, 100.0, 100.0));
}
#[test]
fn rect_cap_collapses_to_union() {
// >MAX_SHAPE_RECTS disjoint rects (all within the clip window!) ->
// one coarse union rect.
let mut shapes = Vec::new();
for i in 0..(MAX_SHAPE_RECTS + 10) {
let x = (i as f32) * 3.0;
shapes.push(clipped(
full_screen(),
Shape::rect_filled(rect(x, 0.0, 1.0, 10.0), 0.0, Color32::GRAY),
));
}
let out = painted_pixel_rects(&shapes, 0.0);
assert_eq!(out.len(), 1);
// 74 rects (MAX + 10) at 3px pitch: last spans 219..220px.
assert!((out[0].width() - 220.0).abs() < 0.5);
}
}

9
scripts/dev-cargo.sh Normal file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Convenience wrapper: source the rootless libmpv prefix env and run cargo.
# Usage: bash scripts/dev-cargo.sh check|build|test|clippy [args...]
set -euo pipefail
ROOT="/home/z/my-project/ferret"
source "$ROOT/mpv-prefix/env.sh"
source "$HOME/.cargo/env"
cd "$ROOT"
exec cargo "$@"