ferret/crates/player-ui/src/file_dialog.rs

489 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! In-UI file browser — replaces external zenity/kdialog/rfd dialogs.
//!
//! The overlay is `WindowLevel::AlwaysOnTop`, which means external file
//! dialogs (separate OS windows) pop *under* the overlay — invisible to
//! the user. Drawing the file browser inside the egui overlay eliminates
//! this z-order conflict entirely: the browser is part of the overlay,
//! so it's always visible and always on top of the video window.
//!
//! Supported kinds:
//! - LoadFile (single select, media extensions)
//! - LoadFolder (directory select)
//! - LoadPlaylist (multi select, media + playlist extensions)
//! - SaveMarkers (save with filename, txt/json)
//! - LoadSubtitle (single select, subtitle extensions)
//! - ImportMarkers (single select, txt/json)
//! - ExportVideo (save with filename, mp4/mkv/webm)
use std::path::PathBuf;
use std::time::Instant;
use egui::{Color32, Context, Layout, Vec2};
use player_core::cmd::MarkerExportFormat;
use crate::theme::Theme;
/// Media file extensions for LoadFile / LoadFolder / LoadPlaylist.
const MEDIA_EXTENSIONS: &[&str] = &[
"mp4", "mkv", "webm", "avi", "mov", "flv",
"mp3", "ogg", "wav", "flac", "aac", "m4a",
"ts", "m2ts", "vob", "wmv", "3gp",
];
/// Playlist file extensions for LoadPlaylist.
const PLAYLIST_EXTENSIONS: &[&str] = &["m3u", "m3u8", "pls"];
/// Subtitle file extensions for LoadSubtitle.
const SUBTITLE_EXTENSIONS: &[&str] = &[
"srt", "ass", "ssa", "sub", "idx", "sup", "vtt", "smi", "lrc",
];
/// Marker file extensions for ImportMarkers.
const MARKER_EXTENSIONS: &[&str] = &["txt", "json"];
/// What kind of dialog to show. Determines title, filter, multi-select,
/// and what Cmd gets sent on confirm.
#[derive(Clone, Debug)]
pub enum FileDialogKind {
LoadFile,
LoadFolder,
LoadPlaylist,
SaveMarkers(MarkerExportFormat),
LoadSubtitle,
ImportMarkers,
ExportVideo,
}
impl FileDialogKind {
fn title(&self) -> &'static str {
match self {
FileDialogKind::LoadFile => "Open File",
FileDialogKind::LoadFolder => "Open Folder",
FileDialogKind::LoadPlaylist => "Open Playlist (select multiple files)",
FileDialogKind::SaveMarkers(_) => "Export Markers",
FileDialogKind::LoadSubtitle => "Open Subtitle File",
FileDialogKind::ImportMarkers => "Import Markers",
FileDialogKind::ExportVideo => "Export A-B Loop Video",
}
}
fn is_save(&self) -> bool {
matches!(self, FileDialogKind::SaveMarkers(_) | FileDialogKind::ExportVideo)
}
fn is_multi(&self) -> bool {
matches!(self, FileDialogKind::LoadPlaylist)
}
fn is_folder(&self) -> bool {
matches!(self, FileDialogKind::LoadFolder)
}
fn extensions(&self) -> &[&str] {
match self {
FileDialogKind::LoadFile => MEDIA_EXTENSIONS,
FileDialogKind::LoadFolder => &[],
FileDialogKind::LoadPlaylist => &[], // we filter in-code (media OR playlist)
FileDialogKind::SaveMarkers(fmt) => match fmt {
MarkerExportFormat::Text => &["txt"],
MarkerExportFormat::Json => &["json"],
},
FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS,
FileDialogKind::ImportMarkers => MARKER_EXTENSIONS,
FileDialogKind::ExportVideo => &["mp4", "mkv", "webm"],
}
}
/// For LoadPlaylist, accept either media or playlist extensions.
fn accepts(&self, ext: &str) -> bool {
match self {
FileDialogKind::LoadFile => MEDIA_EXTENSIONS.contains(&ext),
FileDialogKind::LoadFolder => false, // folders handled separately
FileDialogKind::LoadPlaylist => {
MEDIA_EXTENSIONS.contains(&ext) || PLAYLIST_EXTENSIONS.contains(&ext)
}
FileDialogKind::SaveMarkers(_) => true, // save accepts any extension
FileDialogKind::LoadSubtitle => SUBTITLE_EXTENSIONS.contains(&ext),
FileDialogKind::ImportMarkers => MARKER_EXTENSIONS.contains(&ext),
FileDialogKind::ExportVideo => true, // save accepts any extension
}
}
}
/// One directory entry, cached for rendering.
#[derive(Clone, Debug)]
struct Entry {
name: String,
path: PathBuf,
is_dir: bool,
}
/// The result of a completed dialog.
#[derive(Clone, Debug)]
pub enum FileDialogResult {
/// User cancelled.
Cancel,
/// Single file or folder selected.
Path(String),
/// Multiple files selected (LoadPlaylist only).
Paths(Vec<String>),
}
pub struct FileDialog {
pub kind: FileDialogKind,
current_dir: PathBuf,
entries: Vec<Entry>,
selected: Option<PathBuf>,
/// Multi-select (LoadPlaylist). Stored as a set of paths.
selected_multi: Vec<PathBuf>,
/// Filename text input for save dialogs.
filename: String,
/// Error message (permission denied, etc.).
error: Option<String>,
/// When the dialog was opened — used for focus management.
opened_at: Instant,
}
impl FileDialog {
pub fn open(kind: FileDialogKind) -> Self {
let start_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let mut dlg = Self {
kind,
current_dir: start_dir,
entries: Vec::new(),
selected: None,
selected_multi: Vec::new(),
filename: String::new(),
error: None,
opened_at: Instant::now(),
};
dlg.refresh();
dlg
}
/// Re-read the current directory. Sorts: directories first (alpha), then
/// files (alpha). Clears on error and sets `error`.
fn refresh(&mut self) {
self.entries.clear();
self.error = None;
match std::fs::read_dir(&self.current_dir) {
Ok(rd) => {
let mut dirs: Vec<Entry> = Vec::new();
let mut files: Vec<Entry> = Vec::new();
for entry in rd.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let path = entry.path();
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let e = Entry { name, path, is_dir };
if is_dir {
dirs.push(e);
} else {
files.push(e);
}
}
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.entries = dirs;
self.entries.extend(files);
}
Err(e) => {
self.error = Some(format!("Cannot read directory: {e}"));
}
}
}
/// Navigate to a directory. Falls back to the parent if it fails.
fn navigate_to(&mut self, path: PathBuf) {
if path.is_dir() {
self.current_dir = path;
self.selected = None;
self.selected_multi.clear();
self.refresh();
}
}
/// Go up one level.
fn go_up(&mut self) {
if let Some(parent) = self.current_dir.parent() {
self.navigate_to(parent.to_path_buf());
}
}
/// Go to the user's home directory.
fn go_home(&mut self) {
if let Some(home) = std::env::var_os("HOME") {
self.navigate_to(PathBuf::from(home));
}
}
/// Confirm the selection. Returns the result if valid, None if the user
/// needs to select something first.
fn confirm(&self) -> Option<FileDialogResult> {
if self.kind.is_folder() {
// Folder select: return the selected directory (or current dir).
if let Some(sel) = &self.selected {
if sel.is_dir() {
return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned()));
}
}
return None;
}
if self.kind.is_save() {
// Save: need a filename.
if self.filename.trim().is_empty() {
return None;
}
let path = self.current_dir.join(&self.filename);
return Some(FileDialogResult::Path(path.to_string_lossy().into_owned()));
}
if self.kind.is_multi() {
// Multi-select: return all selected files.
if self.selected_multi.is_empty() {
return None;
}
let paths: Vec<String> = self
.selected_multi
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
return Some(FileDialogResult::Paths(paths));
}
// Single file select.
if let Some(sel) = &self.selected {
return Some(FileDialogResult::Path(sel.to_string_lossy().into_owned()));
}
None
}
/// Draw the dialog as a modal overlay. Returns `Some(result)` when the
/// user confirms or cancels, `None` while the dialog is still open.
pub fn draw(&mut self, ctx: &Context, theme: &Theme) -> Option<FileDialogResult> {
let mut result: Option<FileDialogResult> = None;
let win_size = ctx.screen_rect().size();
let dlg_w = 700.0_f32.min(win_size.x - 40.0).max(400.0);
let dlg_h = 500.0_f32.min(win_size.y - 40.0).max(300.0);
let dlg_x = (win_size.x - dlg_w) / 2.0;
let dlg_y = (win_size.y - dlg_h) / 2.0;
// Dim the background behind the dialog.
let dim_rect = ctx.screen_rect();
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Background,
egui::Id::new("ferret_file_dialog_dim"),
));
painter.rect_filled(
dim_rect,
0.0,
Color32::from_rgba_unmultiplied(0, 0, 0, 160),
);
let fg = theme.fg_color32();
let fg_dim = theme.fg_dim_color32();
let bg = theme.bg_color32();
let accent = theme.accent_color32();
egui::Area::new(egui::Id::new("ferret_file_dialog"))
.fixed_pos(egui::pos2(dlg_x, dlg_y))
.order(egui::Order::Foreground)
.show(ctx, |ui| {
egui::Frame::none()
.fill(bg)
.rounding(8.0)
.stroke(egui::Stroke::new(1.0_f32, accent))
.inner_margin(egui::Margin::symmetric(0.0, 0.0))
.show(ui, |ui| {
ui.set_min_size(Vec2::new(dlg_w, dlg_h));
ui.set_max_size(Vec2::new(dlg_w, dlg_h));
ui.vertical(|ui| {
// ---- Title bar ----
ui.horizontal(|ui| {
ui.add_space(12.0);
ui.label(
egui::RichText::new(self.kind.title())
.color(fg)
.size(14.0)
.strong(),
);
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("×").clicked() {
result = Some(FileDialogResult::Cancel);
}
});
});
ui.separator();
// ---- Path bar ----
ui.horizontal(|ui| {
ui.add_space(8.0);
if ui.button("↑ Up").clicked() {
self.go_up();
}
if ui.button("Home").clicked() {
self.go_home();
}
ui.label(
egui::RichText::new("📁")
.color(accent)
.size(13.0),
);
ui.label(
egui::RichText::new(
self.current_dir.to_string_lossy().into_owned(),
)
.color(fg_dim)
.size(12.0)
.monospace(),
);
});
ui.separator();
// ---- File list ----
egui::ScrollArea::vertical()
.max_height(dlg_h - 140.0)
.show(ui, |ui| {
if let Some(err) = &self.error {
ui.add_space(8.0);
ui.label(
egui::RichText::new(err)
.color(Color32::from_rgb(220, 80, 80))
.size(12.0),
);
}
if self.entries.is_empty() && self.error.is_none() {
ui.add_space(8.0);
ui.label(
egui::RichText::new("(empty directory)")
.color(fg_dim)
.size(12.0),
);
}
// Collect navigation requests — can't call
// self.navigate_to() inside the for loop
// because &self.entries borrows self immutably.
let mut navigate_to: Option<PathBuf> = None;
for entry in &self.entries {
let is_selected = if self.kind.is_multi() {
self.selected_multi.contains(&entry.path)
} else {
self.selected.as_ref() == Some(&entry.path)
};
let icon = if entry.is_dir { "📁" } else { "📄" };
let label = format!("{icon} {}", entry.name);
// For non-folder dialogs, skip files that
// don't match the extension filter.
if !entry.is_dir && !self.kind.is_folder() {
let ext = entry
.path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
if !self.kind.accepts(&ext) {
continue;
}
}
let row_resp = ui.add_sized(
Vec2::new(ui.available_width(), 22.0),
egui::SelectableLabel::new(is_selected, label),
);
if row_resp.clicked() {
if entry.is_dir {
// Single-click selects, double-click navigates.
if self.kind.is_folder() {
self.selected = Some(entry.path.clone());
}
if row_resp.double_clicked() {
navigate_to = Some(entry.path.clone());
}
} else {
if self.kind.is_multi() {
// Toggle selection.
if let Some(idx) =
self.selected_multi.iter().position(|p| p == &entry.path)
{
self.selected_multi.remove(idx);
} else {
self.selected_multi.push(entry.path.clone());
}
} else {
self.selected = Some(entry.path.clone());
// For save dialogs, prefill the filename.
if self.kind.is_save() {
self.filename = entry.name.clone();
}
}
}
}
}
// Apply navigation after the borrow ends.
if let Some(path) = navigate_to {
self.navigate_to(path);
}
});
ui.separator();
// ---- Filename input (save dialogs only) ----
if self.kind.is_save() {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(
egui::RichText::new("Filename:")
.color(fg_dim)
.size(12.0),
);
let resp = ui.add_sized(
Vec2::new(dlg_w - 120.0, 20.0),
egui::TextEdit::singleline(&mut self.filename),
);
// Focus the filename field on open.
if self.opened_at.elapsed().as_millis() < 200 {
resp.request_focus();
}
});
ui.add_space(4.0);
}
// ---- Bottom buttons ----
ui.horizontal(|ui| {
ui.add_space(8.0);
let confirm_label = if self.kind.is_save() { "Save" } else { "Open" };
let can_confirm = self.confirm().is_some();
ui.add_enabled_ui(can_confirm, |ui| {
if ui.button(confirm_label).clicked() {
if let Some(r) = self.confirm() {
result = Some(r);
}
}
});
if ui.button("Cancel").clicked() {
result = Some(FileDialogResult::Cancel);
}
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
let count = if self.kind.is_multi() {
self.selected_multi.len()
} else if self.selected.is_some() {
1
} else {
0
};
ui.label(
egui::RichText::new(format!("{count} selected"))
.color(fg_dim)
.size(11.0),
);
ui.add_space(8.0);
});
});
});
});
});
result
}
}