Ferret v1.2.3 - A modern, accuracy-first video player for Linux, written in Rust.
This commit is contained in:
parent
1b8c38f17b
commit
6e71d72d1f
File diff suppressed because it is too large
Load Diff
|
|
@ -8,7 +8,7 @@ members = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.0.0"
|
version = "1.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "GPL-2.0-or-later"
|
license = "GPL-2.0-or-later"
|
||||||
|
|
@ -51,6 +51,9 @@ serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
# Random / shuffle
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ pub fn key_to_cmd(key: &Key, state: &PlaybackState) -> Option<Cmd> {
|
||||||
fn is_n(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "n" || s == "N") }
|
fn is_n(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "n" || s == "N") }
|
||||||
fn is_p(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "p" || s == "P") }
|
fn is_p(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "p" || s == "P") }
|
||||||
fn is_v(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "v" || s == "V") }
|
fn is_v(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "v" || s == "V") }
|
||||||
|
fn is_r(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "r" || s == "R") }
|
||||||
|
fn is_s(k: &Key) -> bool { matches!(k, Key::Character(s) if s == "s" || s == "S") }
|
||||||
|
|
||||||
// ---- Factories: produce the Cmd. State-dependent ones read `state`. ----
|
// ---- Factories: produce the Cmd. State-dependent ones read `state`. ----
|
||||||
fn play_pause(_: &PlaybackState) -> Cmd { Cmd::PlayPause }
|
fn play_pause(_: &PlaybackState) -> Cmd { Cmd::PlayPause }
|
||||||
|
|
@ -71,6 +73,10 @@ pub fn key_to_cmd(key: &Key, state: &PlaybackState) -> Option<Cmd> {
|
||||||
fn next_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistNext }
|
fn next_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistNext }
|
||||||
fn prev_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistPrev }
|
fn prev_track(_: &PlaybackState) -> Cmd { Cmd::PlaylistPrev }
|
||||||
fn toggle_subs(_: &PlaybackState) -> Cmd { Cmd::ToggleSubVisibility }
|
fn toggle_subs(_: &PlaybackState) -> Cmd { Cmd::ToggleSubVisibility }
|
||||||
|
fn random_next(_: &PlaybackState) -> Cmd { Cmd::RandomNext }
|
||||||
|
fn cycle_random(state: &PlaybackState) -> Cmd {
|
||||||
|
Cmd::SetRandomMode(state.random_mode.cycle())
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Lookup table. Order matters only for `q`/`f` which are
|
// ---- Lookup table. Order matters only for `q`/`f` which are
|
||||||
// intercepted by the caller; all other keys are mutually exclusive. ----
|
// intercepted by the caller; all other keys are mutually exclusive. ----
|
||||||
|
|
@ -92,6 +98,8 @@ pub fn key_to_cmd(key: &Key, state: &PlaybackState) -> Option<Cmd> {
|
||||||
(is_n, next_track),
|
(is_n, next_track),
|
||||||
(is_p, prev_track),
|
(is_p, prev_track),
|
||||||
(is_v, toggle_subs),
|
(is_v, toggle_subs),
|
||||||
|
(is_r, random_next),
|
||||||
|
(is_s, cycle_random),
|
||||||
];
|
];
|
||||||
TABLE
|
TABLE
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@ struct FerretApp {
|
||||||
/// know what Cmd to emit once we have the path.
|
/// know what Cmd to emit once we have the path.
|
||||||
dialog_result_rx: crossbeam_channel::Receiver<(DialogKind, DialogResult)>,
|
dialog_result_rx: crossbeam_channel::Receiver<(DialogKind, DialogResult)>,
|
||||||
dialog_result_tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
|
dialog_result_tx: crossbeam_channel::Sender<(DialogKind, DialogResult)>,
|
||||||
|
/// Whether either of our windows currently has keyboard focus.
|
||||||
|
/// When false, the overlay drops from AlwaysOnTop so it doesn't block
|
||||||
|
/// other applications.
|
||||||
|
has_focus: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FerretApp {
|
impl FerretApp {
|
||||||
|
|
@ -133,6 +137,7 @@ impl FerretApp {
|
||||||
cmd_rx,
|
cmd_rx,
|
||||||
dialog_result_rx,
|
dialog_result_rx,
|
||||||
dialog_result_tx,
|
dialog_result_tx,
|
||||||
|
has_focus: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,6 +202,16 @@ impl FerretApp {
|
||||||
self.windows.video = Some(video_window_arc);
|
self.windows.video = Some(video_window_arc);
|
||||||
self.windows.overlay = Some(overlay_window_arc);
|
self.windows.overlay = Some(overlay_window_arc);
|
||||||
|
|
||||||
|
// 8. Tell X11 the overlay is a helper window for the video window.
|
||||||
|
// This makes the WM:
|
||||||
|
// - Skip the overlay in the taskbar / alt-tab list
|
||||||
|
// - Keep the overlay visually grouped with the video window
|
||||||
|
// - Un-fullscreen both when focus leaves
|
||||||
|
set_x11_overlay_hints(
|
||||||
|
self.windows.video.as_ref().unwrap(),
|
||||||
|
self.windows.overlay.as_ref().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
self.request_redraw_both();
|
self.request_redraw_both();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -339,14 +354,35 @@ impl FerretApp {
|
||||||
self.fullscreen = !self.fullscreen;
|
self.fullscreen = !self.fullscreen;
|
||||||
if self.fullscreen {
|
if self.fullscreen {
|
||||||
video.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
|
video.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
|
||||||
|
// Also fullscreen the overlay so it covers the entire screen.
|
||||||
|
// Without this, the overlay stays at the old windowed size while
|
||||||
|
// the video fills the screen — controls would be cut off.
|
||||||
|
if let Some(overlay_win) = self.windows.overlay.as_ref() {
|
||||||
|
overlay_win.set_fullscreen(Some(winit::window::Fullscreen::Borderless(None)));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
video.set_fullscreen(None);
|
video.set_fullscreen(None);
|
||||||
|
if let Some(overlay_win) = self.windows.overlay.as_ref() {
|
||||||
|
overlay_win.set_fullscreen(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Sync state to overlay so the fullscreen button reflects it.
|
// Sync state to overlay so the fullscreen button reflects it.
|
||||||
if let Some(overlay) = self.overlay.as_mut() {
|
if let Some(overlay) = self.overlay.as_mut() {
|
||||||
overlay.app.set_fullscreen(self.fullscreen);
|
overlay.app.set_fullscreen(self.fullscreen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the overlay window level based on focus state.
|
||||||
|
/// When we have focus: overlay is AlwaysOnTop (controls visible above video).
|
||||||
|
/// When we lose focus: overlay drops to Normal so it doesn't block other apps.
|
||||||
|
fn update_overlay_focus(&mut self) {
|
||||||
|
let Some(overlay_win) = self.windows.overlay.as_ref() else { return; };
|
||||||
|
if self.has_focus {
|
||||||
|
overlay_win.set_window_level(winit::window::WindowLevel::AlwaysOnTop);
|
||||||
|
} else {
|
||||||
|
overlay_win.set_window_level(winit::window::WindowLevel::Normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApplicationHandler for FerretApp {
|
impl ApplicationHandler for FerretApp {
|
||||||
|
|
@ -369,6 +405,10 @@ impl ApplicationHandler for FerretApp {
|
||||||
|
|
||||||
match kind {
|
match kind {
|
||||||
WindowKind::Video => match event {
|
WindowKind::Video => match event {
|
||||||
|
WindowEvent::Focused(gained) => {
|
||||||
|
self.has_focus = gained;
|
||||||
|
self.update_overlay_focus();
|
||||||
|
}
|
||||||
WindowEvent::KeyboardInput {
|
WindowEvent::KeyboardInput {
|
||||||
event:
|
event:
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
|
|
@ -419,6 +459,10 @@ impl ApplicationHandler for FerretApp {
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
WindowKind::Overlay => match event {
|
WindowKind::Overlay => match event {
|
||||||
|
WindowEvent::Focused(gained) => {
|
||||||
|
self.has_focus = gained;
|
||||||
|
self.update_overlay_focus();
|
||||||
|
}
|
||||||
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.
|
||||||
|
|
@ -619,6 +663,146 @@ fn set_x11_window_background(window: &Arc<winit::window::Window>, pixel: u64) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set X11 hints on the overlay window so the window manager treats it as a
|
||||||
|
/// helper/child of the video window rather than a standalone top-level window.
|
||||||
|
///
|
||||||
|
/// Three hints are set:
|
||||||
|
///
|
||||||
|
/// 1. **`_NET_WM_WINDOW_TYPE = DIALOG`** — tells EWMH-compliant WMs that the
|
||||||
|
/// overlay is a dialog/helper window, not a normal application window. Most
|
||||||
|
/// WMs respond by:
|
||||||
|
/// - Skipping it in the taskbar and alt-tab list
|
||||||
|
/// - Grouping it with its parent window
|
||||||
|
/// - Not giving it its own virtual desktop entry
|
||||||
|
///
|
||||||
|
/// 2. **`XSetTransientForHint`** — X11 ICCCM hint that marks the overlay as a
|
||||||
|
/// "transient" (short-lived) window belonging to the video window. WMs use
|
||||||
|
/// this to:
|
||||||
|
/// - Center the dialog over its parent
|
||||||
|
/// - Keep it on the same screen/monitor
|
||||||
|
/// - Un-map it when the parent is withdrawn or iconified
|
||||||
|
///
|
||||||
|
/// 3. **`_NET_WM_STATE: _NET_WM_STATE_SKIP_TASKBAR`** — explicit hint for WMs
|
||||||
|
/// that don't respect _NET_WM_WINDOW_TYPE=DIALOG for taskbar suppression.
|
||||||
|
///
|
||||||
|
/// Together, these hints ensure the overlay appears as a single integrated UI
|
||||||
|
/// with the video window, not as a separate top-level window that clutters
|
||||||
|
/// the taskbar and blocks other apps when fullscreen.
|
||||||
|
fn set_x11_overlay_hints(
|
||||||
|
video: &Arc<winit::window::Window>,
|
||||||
|
overlay: &Arc<winit::window::Window>,
|
||||||
|
) {
|
||||||
|
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||||
|
|
||||||
|
let Ok(video_handle) = video.window_handle() else { return; };
|
||||||
|
let Ok(overlay_handle) = overlay.window_handle() else { return; };
|
||||||
|
let Ok(disp_handle) = overlay.display_handle() else { return; };
|
||||||
|
|
||||||
|
let video_raw = video_handle.as_raw();
|
||||||
|
let overlay_raw = overlay_handle.as_raw();
|
||||||
|
let disp_raw = disp_handle.as_raw();
|
||||||
|
|
||||||
|
match (
|
||||||
|
video_raw,
|
||||||
|
overlay_raw,
|
||||||
|
disp_raw,
|
||||||
|
) {
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Xlib(video_x),
|
||||||
|
raw_window_handle::RawWindowHandle::Xlib(overlay_x),
|
||||||
|
raw_window_handle::RawDisplayHandle::Xlib(d),
|
||||||
|
) => {
|
||||||
|
#[link(name = "X11")]
|
||||||
|
extern "C" {
|
||||||
|
fn XInternAtom(
|
||||||
|
display: *mut std::os::raw::c_void,
|
||||||
|
name: *const std::os::raw::c_char,
|
||||||
|
only_if_exists: std::os::raw::c_int,
|
||||||
|
) -> std::os::raw::c_ulong;
|
||||||
|
fn XSetTransientForHint(
|
||||||
|
display: *mut std::os::raw::c_void,
|
||||||
|
w: std::os::raw::c_ulong,
|
||||||
|
prop_window: std::os::raw::c_ulong,
|
||||||
|
) -> std::os::raw::c_int;
|
||||||
|
fn XChangeProperty(
|
||||||
|
display: *mut std::os::raw::c_void,
|
||||||
|
w: std::os::raw::c_ulong,
|
||||||
|
property: std::os::raw::c_ulong,
|
||||||
|
atype: std::os::raw::c_ulong,
|
||||||
|
format: std::os::raw::c_int,
|
||||||
|
mode: std::os::raw::c_int,
|
||||||
|
data: *const std::os::raw::c_uchar,
|
||||||
|
nelements: std::os::raw::c_int,
|
||||||
|
) -> std::os::raw::c_int;
|
||||||
|
fn XFlush(display: *mut std::os::raw::c_void) -> std::os::raw::c_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(display_ptr) = d.display else {
|
||||||
|
warn!("XlibDisplayHandle.display is None; cannot set overlay hints");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let display = display_ptr.as_ptr();
|
||||||
|
unsafe {
|
||||||
|
let video_xid = video_x.window as std::os::raw::c_ulong;
|
||||||
|
let overlay_xid = overlay_x.window as std::os::raw::c_ulong;
|
||||||
|
|
||||||
|
// 1. Set _NET_WM_WINDOW_TYPE = DIALOG on the overlay.
|
||||||
|
let atom_window_type =
|
||||||
|
XInternAtom(display, b"_NET_WM_WINDOW_TYPE\0".as_ptr() as *const _, 0);
|
||||||
|
let atom_dialog =
|
||||||
|
XInternAtom(display, b"_NET_WM_WINDOW_TYPE_DIALOG\0".as_ptr() as *const _, 0);
|
||||||
|
let xa_atom = XInternAtom(display, b"ATOM\0".as_ptr() as *const _, 0);
|
||||||
|
|
||||||
|
XChangeProperty(
|
||||||
|
display,
|
||||||
|
overlay_xid,
|
||||||
|
atom_window_type,
|
||||||
|
xa_atom,
|
||||||
|
32,
|
||||||
|
0, // PropModeReplace
|
||||||
|
&atom_dialog as *const _ as *const std::os::raw::c_uchar,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Set _NET_WM_STATE = _NET_WM_STATE_SKIP_TASKBAR.
|
||||||
|
let atom_wm_state =
|
||||||
|
XInternAtom(display, b"_NET_WM_STATE\0".as_ptr() as *const _, 0);
|
||||||
|
let atom_skip_taskbar =
|
||||||
|
XInternAtom(display, b"_NET_WM_STATE_SKIP_TASKBAR\0".as_ptr() as *const _, 0);
|
||||||
|
XChangeProperty(
|
||||||
|
display,
|
||||||
|
overlay_xid,
|
||||||
|
atom_wm_state,
|
||||||
|
xa_atom,
|
||||||
|
32,
|
||||||
|
0,
|
||||||
|
&atom_skip_taskbar as *const _ as *const std::os::raw::c_uchar,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Set transient-for hint: overlay belongs to video window.
|
||||||
|
XSetTransientForHint(display, overlay_xid, video_xid);
|
||||||
|
|
||||||
|
XFlush(display);
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"set overlay X11 hints: DIALOG type + SKIP_TASKBAR + transient-for video window"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
raw_window_handle::RawWindowHandle::Xcb(_),
|
||||||
|
raw_window_handle::RawWindowHandle::Xcb(_),
|
||||||
|
raw_window_handle::RawDisplayHandle::Xcb(_),
|
||||||
|
) => {
|
||||||
|
warn!("XCB backend detected; overlay WM hints not set (Xlib path only)");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Not X11 — no WM hints needed (Wayland compositors handle this
|
||||||
|
// via their own protocol; macOS/iOS use NSPanel/etc.).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,4 @@ tracing = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
libc = { workspace = true }
|
libc = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,82 @@
|
||||||
//! Commands sent from the UI thread to the engine thread.
|
//! Commands sent from the UI thread to the engine thread.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::error::CoreResult;
|
use crate::error::CoreResult;
|
||||||
use mpv_bindings::command::{LoadMode, SeekFlags, SeekMode};
|
use mpv_bindings::command::{LoadMode, SeekFlags, SeekMode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ---- Random / shuffle mode ----------------------------------------------
|
||||||
|
|
||||||
|
/// Describes how the random/shuffle feature selects the next file.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum RandomMode {
|
||||||
|
/// Shuffle is disabled; normal sequential playback.
|
||||||
|
Off,
|
||||||
|
/// Randomly pick from media files in the **same directory** as the
|
||||||
|
/// currently playing file.
|
||||||
|
SameFolder,
|
||||||
|
/// Randomly pick from media files in the **entire folder tree** rooted at
|
||||||
|
/// the current file's parent directory (recursive descent).
|
||||||
|
WholeTree,
|
||||||
|
/// Two-level random: first uniformly pick a subdirectory that contains
|
||||||
|
/// media files, then uniformly pick a file within it. This avoids the
|
||||||
|
/// VLC bug where selecting a subfolder always plays the same first file.
|
||||||
|
StepAware,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RandomMode {
|
||||||
|
/// Cycle through modes: Off → SameFolder → WholeTree → StepAware → Off.
|
||||||
|
pub fn cycle(self) -> Self {
|
||||||
|
const ORDER: [RandomMode; 4] = [
|
||||||
|
RandomMode::Off,
|
||||||
|
RandomMode::SameFolder,
|
||||||
|
RandomMode::WholeTree,
|
||||||
|
RandomMode::StepAware,
|
||||||
|
];
|
||||||
|
let idx = ORDER
|
||||||
|
.iter()
|
||||||
|
.position(|m| *m == self)
|
||||||
|
.expect("RandomMode is exhaustive over ORDER");
|
||||||
|
ORDER[(idx + 1) % ORDER.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label for menus.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
const TABLE: [(RandomMode, &str); 4] = [
|
||||||
|
(RandomMode::Off, "Random: Off"),
|
||||||
|
(RandomMode::SameFolder, "Random: Same Folder"),
|
||||||
|
(RandomMode::WholeTree, "Random: Whole Tree"),
|
||||||
|
(RandomMode::StepAware, "Random: Step-Aware"),
|
||||||
|
];
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(m, _)| *m == self)
|
||||||
|
.map(|(_, l)| l)
|
||||||
|
.expect("RandomMode is exhaustive over TABLE")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short label for the status bar.
|
||||||
|
pub fn short_label(self) -> &'static str {
|
||||||
|
const TABLE: [(RandomMode, &str); 4] = [
|
||||||
|
(RandomMode::Off, "rand:off"),
|
||||||
|
(RandomMode::SameFolder, "rand:folder"),
|
||||||
|
(RandomMode::WholeTree, "rand:tree"),
|
||||||
|
(RandomMode::StepAware, "rand:step"),
|
||||||
|
];
|
||||||
|
TABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|(m, _)| *m == self)
|
||||||
|
.map(|(_, l)| l)
|
||||||
|
.expect("RandomMode is exhaustive over TABLE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RandomMode {
|
||||||
|
fn default() -> Self {
|
||||||
|
RandomMode::Off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Magic dialog-request strings --------------------------------------------
|
// ---- Magic dialog-request strings --------------------------------------------
|
||||||
//
|
//
|
||||||
|
|
@ -191,6 +264,15 @@ pub enum Cmd {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Random / shuffle ------------------------------------------------
|
||||||
|
|
||||||
|
/// Set the random/shuffle mode. Off disables it; other modes control how
|
||||||
|
/// the next random file is selected (same folder, whole tree, step-aware).
|
||||||
|
SetRandomMode(RandomMode),
|
||||||
|
|
||||||
|
/// Pick a random file according to the current `random_mode` and load it.
|
||||||
|
RandomNext,
|
||||||
|
|
||||||
// ---- Lifecycle -----------------------------------------------------
|
// ---- Lifecycle -----------------------------------------------------
|
||||||
|
|
||||||
/// Shutdown libmpv and exit the engine thread.
|
/// Shutdown libmpv and exit the engine thread.
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crossbeam_channel::{bounded, Receiver, Sender};
|
use crossbeam_channel::{bounded, Receiver, Sender};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use mpv_bindings::event::{Event as MpvEvent, EventId, LogLevel};
|
use mpv_bindings::event::{Event as MpvEvent, EventId, LogLevel};
|
||||||
|
|
@ -28,7 +30,7 @@ use mpv_bindings::handle::Builder as MpvBuilder;
|
||||||
use mpv_bindings::property::{Format, Property};
|
use mpv_bindings::property::{Format, Property};
|
||||||
use mpv_bindings::MpvHandle;
|
use mpv_bindings::MpvHandle;
|
||||||
|
|
||||||
use crate::cmd::{build_loadfile, build_seek, Cmd, LoopMode, MarkerExportFormat};
|
use crate::cmd::{build_loadfile, build_seek, Cmd, LoopMode, MarkerExportFormat, RandomMode};
|
||||||
use crate::error::{CoreError, CoreResult};
|
use crate::error::{CoreError, CoreResult};
|
||||||
use crate::event::{EngineEvent, EngineEventBus, EngineEventSender, EndReason};
|
use crate::event::{EngineEvent, EngineEventBus, EngineEventSender, EndReason};
|
||||||
use crate::options::EngineOptions;
|
use crate::options::EngineOptions;
|
||||||
|
|
@ -304,6 +306,111 @@ fn engine_main(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Random / shuffle file-scanning helpers --------------------------------
|
||||||
|
|
||||||
|
const MEDIA_EXTENSIONS: &[&str] = &[
|
||||||
|
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "mp3", "flac",
|
||||||
|
"wav", "ogg", "opus", "m4a", "aac", "wma", "m2ts", "ts", "3gp",
|
||||||
|
"f4v", "ogv", "weba",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn is_media_file(path: &Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| MEDIA_EXTENSIONS.contains(&e.to_lowercase().as_str()))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect media files directly in `dir` (non-recursive).
|
||||||
|
fn collect_files_in_dir(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && is_media_file(&path) {
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collect media files under `dir`.
|
||||||
|
fn collect_files_recursive(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && is_media_file(&path) {
|
||||||
|
files.push(path);
|
||||||
|
} else if path.is_dir() {
|
||||||
|
files.extend(collect_files_recursive(&path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step-aware collection: uniformly pick a subdirectory containing media,
|
||||||
|
/// then return only the media files within that subdirectory.
|
||||||
|
fn collect_files_step_aware(dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut subdirs_with_media: Vec<PathBuf> = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if !collect_files_in_dir(&path).is_empty() {
|
||||||
|
subdirs_with_media.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include files directly in the root as another candidate group.
|
||||||
|
let root_files = collect_files_in_dir(dir);
|
||||||
|
if !root_files.is_empty() {
|
||||||
|
subdirs_with_media.push(dir.to_path_buf());
|
||||||
|
}
|
||||||
|
|
||||||
|
if subdirs_with_media.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
if let Some(chosen_dir) = subdirs_with_media.choose(&mut rng) {
|
||||||
|
return collect_files_in_dir(chosen_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified entry point: returns the candidate list depending on mode.
|
||||||
|
fn collect_random_candidates(current_path: &Path, mode: RandomMode) -> Vec<PathBuf> {
|
||||||
|
// Determine the base directory to scan:
|
||||||
|
// - If current_path is a directory, scan it directly (user loaded a
|
||||||
|
// folder, media files are inside it).
|
||||||
|
// - If current_path is a file, scan its parent directory (user loaded
|
||||||
|
// a single file, siblings are in the same folder).
|
||||||
|
let dir = if current_path.is_dir() {
|
||||||
|
current_path
|
||||||
|
} else {
|
||||||
|
// Rust doesn't count guard conditions toward exhaustiveness, so we
|
||||||
|
// must cover all Option variants explicitly with Some(_) | None.
|
||||||
|
match current_path.parent() {
|
||||||
|
Some(d) if !d.as_os_str().is_empty() => d,
|
||||||
|
Some(_) | None => return Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
RandomMode::Off => Vec::new(),
|
||||||
|
RandomMode::SameFolder => collect_files_in_dir(dir),
|
||||||
|
RandomMode::WholeTree => collect_files_recursive(dir),
|
||||||
|
RandomMode::StepAware => collect_files_step_aware(dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()> {
|
fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()> {
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::LoadFile { path, options } => {
|
Cmd::LoadFile { path, options } => {
|
||||||
|
|
@ -637,6 +744,46 @@ fn apply_cmd(mpv: &MpvHandle, bus: &EngineEventBus, cmd: &Cmd) -> CoreResult<()>
|
||||||
warn!("ExportABLoopVideo reached engine — should be intercepted by main app");
|
warn!("ExportABLoopVideo reached engine — should be intercepted by main app");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Random / shuffle -------------------------------------------
|
||||||
|
|
||||||
|
Cmd::SetRandomMode(mode) => {
|
||||||
|
bus.update_state(|s| s.random_mode = *mode);
|
||||||
|
bus.send(EngineEvent::StateChanged);
|
||||||
|
info!("random mode set to {}", mode.label());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::RandomNext => {
|
||||||
|
let path_snapshot = bus.snapshot().path.clone();
|
||||||
|
let mode = bus.snapshot().random_mode;
|
||||||
|
if let Some(ref current) = path_snapshot {
|
||||||
|
let current_path = Path::new(current);
|
||||||
|
let candidates = collect_random_candidates(current_path, mode);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
let msg = format!("no media files found for random mode ({})", mode.label());
|
||||||
|
warn!("{msg}");
|
||||||
|
bus.send(EngineEvent::Error { message: msg });
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let pick = candidates.choose(&mut rng).unwrap();
|
||||||
|
info!("random next: {}", pick.display());
|
||||||
|
let path_str = pick.to_string_lossy().into_owned();
|
||||||
|
let mpv_cmd = mpv_bindings::command::Command::loadfile(
|
||||||
|
&path_str,
|
||||||
|
mpv_bindings::command::LoadMode::Replace,
|
||||||
|
)?;
|
||||||
|
mpv.command(&mpv_cmd)?;
|
||||||
|
// Unpause so the new file starts playing.
|
||||||
|
mpv.set_property(&Property::flag("pause", false))?;
|
||||||
|
} else {
|
||||||
|
bus.send(EngineEvent::Error {
|
||||||
|
message: "Random Next: no file currently loaded".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
Cmd::Shutdown => unreachable!("handled by caller"),
|
Cmd::Shutdown => unreachable!("handled by caller"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ pub mod error;
|
||||||
pub mod options;
|
pub mod options;
|
||||||
|
|
||||||
pub use cmd::{
|
pub use cmd::{
|
||||||
Cmd, LoadOptions, LoopMode, MarkerExportFormat,
|
Cmd, LoadOptions, LoopMode, MarkerExportFormat, RandomMode,
|
||||||
MAGIC_FILE_DIALOG, MAGIC_FOLDER_DIALOG, MAGIC_PLAYLIST_DIALOG,
|
MAGIC_FILE_DIALOG, MAGIC_FOLDER_DIALOG, MAGIC_PLAYLIST_DIALOG,
|
||||||
MAGIC_SAVE_DIALOG_TXT, MAGIC_SAVE_DIALOG_JSON,
|
MAGIC_SAVE_DIALOG_TXT, MAGIC_SAVE_DIALOG_JSON,
|
||||||
MAGIC_SUBTITLE_DIALOG, MAGIC_IMPORT_MARKERS_DIALOG,
|
MAGIC_SUBTITLE_DIALOG, MAGIC_IMPORT_MARKERS_DIALOG,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use serde::{Serialize, Serializer};
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
use crate::cmd::LoopMode;
|
use crate::cmd::{LoopMode, RandomMode};
|
||||||
|
|
||||||
/// One mpv track-list entry. Used for both audio and subtitle tracks — they
|
/// One mpv track-list entry. Used for both audio and subtitle tracks — they
|
||||||
/// have the same shape, just different `type` values ("audio" vs "sub").
|
/// have the same shape, just different `type` values ("audio" vs "sub").
|
||||||
|
|
@ -101,6 +101,9 @@ pub struct PlaybackState {
|
||||||
pub marker_b: Option<f64>,
|
pub marker_b: Option<f64>,
|
||||||
/// Is A→B loop currently enabled?
|
/// Is A→B loop currently enabled?
|
||||||
pub marker_loop_enabled: bool,
|
pub marker_loop_enabled: bool,
|
||||||
|
|
||||||
|
/// Current random/shuffle mode. Mirrors the engine's last `SetRandomMode` cmd.
|
||||||
|
pub random_mode: RandomMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ use egui::{Color32, Context, Layout, Ui, Vec2};
|
||||||
|
|
||||||
use player_core::event::EngineEvent;
|
use player_core::event::EngineEvent;
|
||||||
use player_core::state::PlaybackState;
|
use player_core::state::PlaybackState;
|
||||||
use player_core::{Cmd, LoopMode};
|
use player_core::{Cmd, LoopMode, RandomMode};
|
||||||
|
|
||||||
use crate::icons;
|
use crate::icons;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
|
|
@ -570,6 +570,36 @@ impl OverlayApp {
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Random / Shuffle")
|
||||||
|
.color(fg_dim).size(10.0).strong(),
|
||||||
|
);
|
||||||
|
ui.add_space(2.0);
|
||||||
|
let random_modes = [
|
||||||
|
("Off", RandomMode::Off),
|
||||||
|
("Same Folder", RandomMode::SameFolder),
|
||||||
|
("Whole Tree", RandomMode::WholeTree),
|
||||||
|
("Step-Aware", RandomMode::StepAware),
|
||||||
|
];
|
||||||
|
let random_mode = self.state.random_mode;
|
||||||
|
for (label, mode) in random_modes {
|
||||||
|
let checked = random_mode == mode;
|
||||||
|
if ui.selectable_label(checked, label).clicked() {
|
||||||
|
pending_cmds.push(Cmd::SetRandomMode(mode));
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("Random Next (R)").clicked() {
|
||||||
|
pending_cmds.push(Cmd::RandomNext);
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Cycle Random Mode (S)").clicked() {
|
||||||
|
pending_cmds.push(Cmd::SetRandomMode(random_mode.cycle()));
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("Speed")
|
egui::RichText::new("Speed")
|
||||||
.color(fg_dim).size(10.0).strong(),
|
.color(fg_dim).size(10.0).strong(),
|
||||||
|
|
@ -719,7 +749,7 @@ impl OverlayApp {
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("ferret 1.0.0")
|
egui::RichText::new("ferret 1.2.0")
|
||||||
.color(fg_dim).size(10.0),
|
.color(fg_dim).size(10.0),
|
||||||
);
|
);
|
||||||
ui.label(
|
ui.label(
|
||||||
|
|
@ -742,8 +772,14 @@ impl OverlayApp {
|
||||||
LoopMode::File => " loop:file".into(),
|
LoopMode::File => " loop:file".into(),
|
||||||
LoopMode::Playlist => " loop:list".into(),
|
LoopMode::Playlist => " loop:list".into(),
|
||||||
};
|
};
|
||||||
|
let random_mode = self.state.random_mode;
|
||||||
|
let rand_str = if random_mode == RandomMode::Off {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {}", random_mode.short_label())
|
||||||
|
};
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!("{status}{speed_str}{loop_str}"))
|
egui::RichText::new(format!("{status}{speed_str}{loop_str}{rand_str}"))
|
||||||
.color(fg_dim).size(10.0),
|
.color(fg_dim).size(10.0),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -923,7 +959,7 @@ impl OverlayApp {
|
||||||
.strong(),
|
.strong(),
|
||||||
);
|
);
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("1.0.0")
|
egui::RichText::new("1.2.0")
|
||||||
.color(fg_dim)
|
.color(fg_dim)
|
||||||
.size(13.0),
|
.size(13.0),
|
||||||
);
|
);
|
||||||
|
|
@ -1094,6 +1130,16 @@ impl OverlayApp {
|
||||||
let ff_resp = self.icon_button(ui, "btn_frame_fwd", btn_size, |p, r, c| icons::frame_forward(p, r, c));
|
let ff_resp = self.icon_button(ui, "btn_frame_fwd", btn_size, |p, r, c| icons::frame_forward(p, r, c));
|
||||||
if ff_resp.clicked() { self.send(Cmd::FrameStep); }
|
if ff_resp.clicked() { self.send(Cmd::FrameStep); }
|
||||||
|
|
||||||
|
ui.add_space(6.0);
|
||||||
|
|
||||||
|
// Shuffle button — cycles through random modes (Off → Folder → Tree → Step → Off).
|
||||||
|
let rand_active = self.state.random_mode != RandomMode::Off;
|
||||||
|
let rand_resp = self.icon_button_toggled(ui, "btn_random", btn_size, rand_active, |p, r, c| {
|
||||||
|
icons::shuffle(p, r, c, rand_active)
|
||||||
|
});
|
||||||
|
if rand_resp.clicked() { self.send(Cmd::SetRandomMode(self.state.random_mode.cycle())); }
|
||||||
|
rand_resp.on_hover_text(format!("Cycle Random Mode — {}", self.state.random_mode.label()));
|
||||||
|
|
||||||
// Center: time display
|
// Center: time display
|
||||||
ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
|
ui.with_layout(Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
|
||||||
let time_str = self.state.time_str();
|
let time_str = self.state.time_str();
|
||||||
|
|
|
||||||
|
|
@ -221,13 +221,16 @@ impl FileDialog {
|
||||||
/// needs to select something first.
|
/// needs to select something first.
|
||||||
fn confirm(&self) -> Option<FileDialogResult> {
|
fn confirm(&self) -> Option<FileDialogResult> {
|
||||||
if self.kind.is_folder() {
|
if self.kind.is_folder() {
|
||||||
// Folder select: return the selected directory (or current dir).
|
// Folder select: return the selected directory, or fall back to
|
||||||
|
// the current directory so the user can open the folder they're
|
||||||
|
// already browsing without navigating up first.
|
||||||
if let Some(sel) = &self.selected {
|
if let Some(sel) = &self.selected {
|
||||||
if sel.is_dir() {
|
if sel.is_dir() {
|
||||||
return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned()));
|
return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return None;
|
// No subfolder selected — use the current directory itself.
|
||||||
|
return Some(FileDialogResult::Path(self.current_dir.to_string_lossy().into_owned()));
|
||||||
}
|
}
|
||||||
if self.kind.is_save() {
|
if self.kind.is_save() {
|
||||||
// Save: need a filename.
|
// Save: need a filename.
|
||||||
|
|
|
||||||
|
|
@ -405,3 +405,72 @@ pub fn speed_gauge(painter: &Painter, rect: Rect, color: Color32, speed: f32) {
|
||||||
Stroke::new(thick * 1.2, Color32::from_rgb(255, 168, 40)),
|
Stroke::new(thick * 1.2, Color32::from_rgb(255, 168, 40)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shuffle icon — two crossed arrows with arrowheads. When `active` is true
|
||||||
|
/// the icon is rendered at full opacity; when false it is drawn dimmed to
|
||||||
|
/// indicate the feature is disengaged.
|
||||||
|
pub fn shuffle(painter: &Painter, rect: Rect, color: Color32, active: bool) {
|
||||||
|
let size = rect.height().min(rect.width()) * 0.55;
|
||||||
|
let cx = rect.center().x;
|
||||||
|
let cy = rect.center().y;
|
||||||
|
let thick = size * 0.10;
|
||||||
|
let alpha = if active { 1.0 } else { 0.3 };
|
||||||
|
let col = Color32::from_rgba_unmultiplied(
|
||||||
|
(color.r() as f32 * alpha) as u8,
|
||||||
|
(color.g() as f32 * alpha) as u8,
|
||||||
|
(color.b() as f32 * alpha) as u8,
|
||||||
|
(color.a() as f32 * alpha) as u8,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two lines crossing in an X pattern.
|
||||||
|
let half_w = size * 0.45;
|
||||||
|
let half_h = size * 0.28;
|
||||||
|
|
||||||
|
// Top-left → bottom-right arrow.
|
||||||
|
let tl = Pos2::new(cx - half_w, cy - half_h);
|
||||||
|
let br = Pos2::new(cx + half_w, cy + half_h);
|
||||||
|
painter.line_segment([tl, br], Stroke::new(thick, col));
|
||||||
|
|
||||||
|
// Bottom-left → top-right arrow.
|
||||||
|
let bl = Pos2::new(cx - half_w, cy + half_h);
|
||||||
|
let tr = Pos2::new(cx + half_w, cy - half_h);
|
||||||
|
painter.line_segment([bl, tr], Stroke::new(thick, col));
|
||||||
|
|
||||||
|
// Arrowheads.
|
||||||
|
let arrow = size * 0.16;
|
||||||
|
let angle = 0.4;
|
||||||
|
|
||||||
|
// Arrowhead at top-right (on bl→tr).
|
||||||
|
let dir_x = -(tr.x - bl.x);
|
||||||
|
let dir_y = -(tr.y - bl.y);
|
||||||
|
let len = (dir_x * dir_x + dir_y * dir_y).sqrt();
|
||||||
|
let dx = dir_x / len;
|
||||||
|
let dy = dir_y / len;
|
||||||
|
let px = -dy;
|
||||||
|
let py = dx;
|
||||||
|
painter.line_segment(
|
||||||
|
[tr, Pos2::new(tr.x + (dx + px) * arrow * angle, tr.y + (dy + py) * arrow * angle)],
|
||||||
|
Stroke::new(thick, col),
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[tr, Pos2::new(tr.x + (dx - px) * arrow * angle, tr.y + (dy - py) * arrow * angle)],
|
||||||
|
Stroke::new(thick, col),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Arrowhead at bottom-right (on tl→br).
|
||||||
|
let dir_x2 = -(br.x - tl.x);
|
||||||
|
let dir_y2 = -(br.y - tl.y);
|
||||||
|
let len2 = (dir_x2 * dir_x2 + dir_y2 * dir_y2).sqrt();
|
||||||
|
let dx2 = dir_x2 / len2;
|
||||||
|
let dy2 = dir_y2 / len2;
|
||||||
|
let px2 = -dy2;
|
||||||
|
let py2 = dx2;
|
||||||
|
painter.line_segment(
|
||||||
|
[br, Pos2::new(br.x + (dx2 + px2) * arrow * angle, br.y + (dy2 + py2) * arrow * angle)],
|
||||||
|
Stroke::new(thick, col),
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[br, Pos2::new(br.x + (dx2 - px2) * arrow * angle, br.y + (dy2 - py2) * arrow * angle)],
|
||||||
|
Stroke::new(thick, col),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue