filmcraft/crates/ui-egui/src/panels/dialogs.rs

486 lines
23 KiB
Rust

//! Modal dialogs (About; Keyboard Shortcuts in `shortcuts_dialog`; Settings in `settings`;
//! Recovery/Revert in `file_dialogs`).
use crate::{Dialog, FilmcraftApp};
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::media_dialogs::show(app, ctx);
crate::panels::color_dialogs::show(app, ctx);
crate::panels::clip_dialogs::show(app, ctx);
crate::panels::menu_dialogs::show(app, ctx);
crate::panels::graphics_templates::show(app, ctx);
crate::panels::presets::save_dialog(app, ctx);
crate::panels::audio_fx_editor::show(app, ctx);
crate::panels::multicam::show_dialog(app, ctx);
crate::panels::multicam::show_edit_cameras(app, ctx);
crate::panels::monitor_view::dialogs(app, ctx);
crate::panels::graphics::dialogs(app, ctx);
crate::panels::workspaces::dialogs(app, ctx);
crate::panels::voiceover::show(app, ctx);
crate::panels::remix::show(app, ctx);
crate::panels::interchange_export::show(app, ctx);
crate::panels::frame_export::show(app, ctx);
crate::panels::project_dialogs::show(app, ctx);
crate::panels::media_browser::dialogs(app, ctx);
let Some(d) = app.dialog else { return };
if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) {
if !still_open && app.dialog == Some(d) {
app.dialog = None;
}
return;
}
let mut open = true;
match d {
Dialog::About => {
egui::Window::new(tl!("About FilmCraft"))
.id(egui::Id::new("About FilmCraft"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.show(ctx, |ui| about(app, ui));
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
open = false;
}
}
Dialog::Shortcuts => {
if !crate::panels::shortcuts_dialog::show(app, ctx) && app.dialog == Some(d) {
app.dialog = None;
}
return;
}
Dialog::AudioGain => {
if !audio_gain(app, ctx) {
app.dialog = None;
}
return;
}
Dialog::DeleteTracks => {
if !delete_tracks(app, ctx) {
app.dialog = None;
}
return;
}
Dialog::AddTracks => {
if !add_tracks(app, ctx) {
app.dialog = None;
}
return;
}
Dialog::TransitionDuration => {
if !crate::panels::transition_controls::duration_dialog(app, ctx) && app.dialog == Some(d) {
app.dialog = None;
}
return;
}
Dialog::SequenceSettings => {
if !crate::panels::sequence_settings::show(app, ctx) && app.dialog == Some(d) {
app.dialog = None;
}
return;
}
Dialog::NewSequence | Dialog::Preferences | Dialog::Recovery | Dialog::RevertConfirm => {}
}
if !open {
app.dialog = None;
}
}
/// Clip ▸ Audio Gain…: Set Gain to / Adjust Gain by / Normalize Max Peak to / Normalize All Peaks to,
/// with the selection's peak amplitude. Returns whether the dialog stays open.
///
/// Automation ids: `audioGain.<set|adjust|normalizeMax|normalizeAll>` (radio buttons),
/// `audioGain.<mode>.value` (dB fields), `audioGain.peak`, `audioGain.ok`, `audioGain.cancel`.
fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
let peak_id = egui::Id::new("audio-gain-peak");
let rev = app.session.revision;
let cached: Option<(u64, Option<f64>)> = ctx.data(|d| d.get_temp(peak_id));
let peak = match cached {
Some((r, p)) if r == rev => p,
_ => {
let p = app.session.execute("clip.audioPeak", serde_json::json!({})).ok().and_then(|v| v["peakDb"].as_f64());
ctx.data_mut(|d| d.insert_temp(peak_id, (rev, p)));
p
}
};
let mut draft = app.ui.audio_gain.clone();
let mut keep = true;
let mut apply = false;
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
egui::Window::new(tl!("Audio Gain"))
.id(egui::Id::new("Audio Gain"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.add_space(6.0);
egui::Grid::new("audio-gain").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| {
let rows: [(&str, &str, &mut f64); 4] = [
("set", tl!("Set Gain to:"), &mut draft.set_db),
("adjust", tl!("Adjust Gain by:"), &mut draft.adjust_db),
("normalizeMax", tl!("Normalize Max Peak to:"), &mut draft.max_peak_db),
("normalizeAll", tl!("Normalize All Peaks to:"), &mut draft.all_peaks_db),
];
for (id, label, v) in rows {
let r = ui.radio(draft.mode == id, label);
elems.push((format!("audioGain.{id}"), r.rect, label.to_string()));
if r.clicked() {
draft.mode = id.to_string();
}
let f = ui.add_enabled(draft.mode == id, egui::DragValue::new(v).speed(0.1).range(-96.0..=96.0).suffix(" dB"));
elems.push((format!("audioGain.{id}.value"), f.rect, format!("{v:.1} dB")));
ui.end_row();
}
});
ui.add_space(8.0);
let pk_text = tlf!("Peak Amplitude: {peak}", peak = peak.map(|p| format!("{p:.1} dB")).unwrap_or_else(|| "—".into()));
let pk = ui.label(&pk_text);
elems.push(("audioGain.peak".into(), pk.rect, pk_text));
ui.add_space(8.0);
ui.horizontal(|ui| {
let c = ui.button(tl!("Cancel"));
elems.push(("audioGain.cancel".into(), c.rect, "Cancel".into()));
if c.clicked() {
keep = false;
}
let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent));
elems.push(("audioGain.ok".into(), o.rect, "OK".into()));
if o.clicked() {
apply = true;
}
});
});
for (id, r, l) in elems {
app.auto.add(&id, r, &l);
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
keep = false;
}
if apply {
let db = match draft.mode.as_str() {
"set" => draft.set_db,
"normalizeMax" => draft.max_peak_db,
"normalizeAll" => draft.all_peaks_db,
_ => draft.adjust_db,
};
if let Err(e) = app.session.execute("clip.audioGain", serde_json::json!({"mode": draft.mode, "db": db})) {
app.ui.status = e.to_string();
}
keep = false;
}
app.ui.audio_gain = draft;
keep
}
/// Sequence ▸ Add Tracks…, laid out as Premiere Pro's dialog: "Add video tracks" (Amount,
/// Placement), "Add audio tracks" (Amount, Placement, Track type: Standard, 5.1, Adaptive, Mono)
/// and "Add audio submix tracks" (Amount, Placement, Track type: Stereo, 5.1, Adaptive, Mono).
/// Placement is "Before First Track" or after one of the tracks; the submix placement is off
/// while the sequence has no submix track. Returns whether the dialog stays open.
///
/// Automation ids, with `<kind>` = `video`, `audio` or `submix`: `addTracks.<kind>.amount`,
/// `addTracks.<kind>.placement` and, while its list is open, `addTracks.<kind>.placement.option.<n>`
/// (n tracks before the new ones; 0 = Before First Track); `addTracks.<audio|submix>.type` and
/// `addTracks.<audio|submix>.type.option.<standard|stereo|5.1|adaptive|mono>`; `addTracks.ok`,
/// `addTracks.cancel`.
fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
let Some(seq) = app.session.active_sequence() else { return false };
let names = |tracks: &[filmcraft_engine::project::Track]| tracks.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
let (vnames, anames, snames) = (names(&seq.video_tracks), names(&seq.audio_tracks), names(&seq.submix_tracks));
let mut d = app.ui.add_tracks.clone();
let mut keep = true;
let mut apply = false;
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
let audio_types: [(&str, &str); 4] = [("standard", tl!("Standard")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))];
let submix_types: [(&str, &str); 4] = [("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))];
egui::Window::new(tl!("Add Tracks"))
.id(egui::Id::new("Add Tracks"))
.collapsible(false)
.resizable(false)
.default_width(320.0)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
// everything spans the width the window has: no empty band beside the fields
ui.set_width(ui.available_width());
// the label column is as wide as its widest label; the lists take the rest of the row,
// up to the group's right edge (as in Premiere's dialog)
let font = egui::TextStyle::Body.resolve(ui.style());
let label_w = [tl!("Amount"), tl!("Placement"), tl!("Track type")]
.iter()
.map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), egui::Color32::WHITE).size().x)
.fold(0.0, f32::max);
let row_h = ui.spacing().interact_size.y;
let label = |ui: &mut egui::Ui, text: &str| {
ui.allocate_ui_with_layout(egui::vec2(label_w, row_h), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text));
};
let groups: [(&str, &str, &mut u32, &mut usize, &[String], Option<(&mut String, &[(&str, &str); 4])>); 3] = [
("video", tl!("Add video tracks"), &mut d.video, &mut d.video_after, &vnames, None),
("audio", tl!("Add audio tracks"), &mut d.audio, &mut d.audio_after, &anames, Some((&mut d.audio_type, &audio_types))),
("submix", tl!("Add audio submix tracks"), &mut d.submix, &mut d.submix_after, &snames, Some((&mut d.submix_type, &submix_types))),
];
for (kind, title, amount, after, tracks, track_type) in groups {
ui.add_space(4.0);
ui.label(egui::RichText::new(title).strong());
ui.group(|ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
label(ui, tl!("Amount"));
let r = ui.add(egui::DragValue::new(amount).range(0..=99).speed(0.1));
elems.push((format!("addTracks.{kind}.amount"), r.rect, amount.to_string()));
});
ui.horizontal(|ui| {
label(ui, tl!("Placement"));
*after = (*after).min(tracks.len());
let place = |n: usize| match n.checked_sub(1).and_then(|i| tracks.get(i)) {
Some(name) => tlf!("After {name}", name),
None => tl!("Before First Track").to_string(),
};
let shown = place(*after);
let list_w = ui.available_width();
// nowhere to choose from while the sequence has no track of the kind
ui.add_enabled_ui(!tracks.is_empty(), |ui| {
let r = egui::ComboBox::from_id_salt(("add-tracks-placement", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| {
for n in 0..=tracks.len() {
let label = place(n);
let o = ui.selectable_value(after, n, &label);
elems.push((format!("addTracks.{kind}.placement.option.{n}"), o.rect, label));
}
});
elems.push((format!("addTracks.{kind}.placement"), r.response.rect, shown.clone()));
});
});
if let Some((chosen, types)) = track_type {
ui.horizontal(|ui| {
label(ui, tl!("Track type"));
let shown = types.iter().find(|(id, _)| *id == chosen.as_str()).map_or(types[0].1, |(_, label)| label).to_string();
let list_w = ui.available_width();
let r = egui::ComboBox::from_id_salt(("add-tracks-type", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| {
for (id, label) in types {
let o = ui.selectable_value(chosen, id.to_string(), *label);
elems.push((format!("addTracks.{kind}.type.option.{id}"), o.rect, label.to_string()));
}
});
elems.push((format!("addTracks.{kind}.type"), r.response.rect, shown));
});
}
});
}
ui.add_space(10.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let o = ui.add(egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent));
elems.push(("addTracks.ok".into(), o.rect, "OK".into()));
if o.clicked() {
apply = true;
}
let c = ui.button(tl!("Cancel"));
elems.push(("addTracks.cancel".into(), c.rect, "Cancel".into()));
if c.clicked() {
keep = false;
}
});
});
for (id, r, l) in elems {
app.auto.add(&id, r, &l);
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
keep = false;
}
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input() {
apply = true;
}
if apply {
// nothing asked for: OK closes the dialog and changes nothing
if d.video > 0 || d.audio > 0 || d.submix > 0 {
let params = serde_json::json!({
"video": d.video, "videoAfter": d.video_after,
"audio": d.audio, "audioAfter": d.audio_after, "audioType": d.audio_type,
"submix": d.submix, "submixAfter": d.submix_after, "submixType": d.submix_type,
});
if let Err(e) = app.session.execute("sequence.addTracks", params) {
app.ui.status = e.to_string();
}
}
keep = false;
}
app.ui.add_tracks = d;
keep
}
/// Sequence ▸ Delete Tracks…: "Delete Video Tracks" / "Delete Audio Tracks" checkboxes, each with a
/// track choice (All Empty Tracks or one track). Returns whether the dialog stays open.
///
/// Automation ids: `deleteTracks.video`, `deleteTracks.audio` (checkboxes),
/// `deleteTracks.video.target`, `deleteTracks.audio.target` (track menus),
/// `deleteTracks.<kind>.option.<empty|V1|A2…>` (menu entries while open), `deleteTracks.ok`,
/// `deleteTracks.cancel`.
fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
let Some(seq) = app.session.active_sequence() else { return false };
let names = |n: usize, p: &str| (1..=n).map(|i| format!("{p}{i}")).collect::<Vec<_>>();
let vnames = names(seq.video_tracks.len(), "V");
let anames = names(seq.audio_tracks.len(), "A");
let mut draft = app.ui.delete_tracks.clone();
let mut keep = true;
let mut apply = false;
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
egui::Window::new(tl!("Delete Tracks"))
.id(egui::Id::new("Delete Tracks"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.set_min_width(300.0);
for (kind, title, delete, on, target, list) in [
("video", tl!("Video Tracks"), tl!("Delete Video Tracks"), &mut draft.video, &mut draft.video_target, &vnames),
("audio", tl!("Audio Tracks"), tl!("Delete Audio Tracks"), &mut draft.audio, &mut draft.audio_target, &anames),
] {
ui.add_space(4.0);
ui.label(egui::RichText::new(title).strong());
let label = delete.to_string();
let c = ui.checkbox(on, &label);
elems.push((format!("deleteTracks.{kind}"), c.rect, label));
ui.horizontal(|ui| {
ui.add_space(22.0);
let shown = if target == "empty" { tl!("All Empty Tracks").to_string() } else { target.clone() };
ui.add_enabled_ui(*on, |ui| {
let r = egui::ComboBox::from_id_salt(("delete-tracks", kind)).selected_text(&shown).width(170.0).show_ui(ui, |ui| {
let e = ui.selectable_value(target, "empty".to_string(), tl!("All Empty Tracks"));
elems.push((format!("deleteTracks.{kind}.option.empty"), e.rect, "All Empty Tracks".into()));
for n in list {
let r = ui.selectable_value(target, n.clone(), n);
elems.push((format!("deleteTracks.{kind}.option.{n}"), r.rect, n.clone()));
}
});
elems.push((format!("deleteTracks.{kind}.target"), r.response.rect, shown));
});
});
}
ui.add_space(10.0);
ui.horizontal(|ui| {
let c = ui.button(tl!("Cancel"));
elems.push(("deleteTracks.cancel".into(), c.rect, "Cancel".into()));
if c.clicked() {
keep = false;
}
let o = ui.add_enabled(
draft.video || draft.audio,
egui::Button::new(egui::RichText::new(tl!("OK")).color(egui::Color32::WHITE)).fill(app.tokens.accent),
);
elems.push(("deleteTracks.ok".into(), o.rect, "OK".into()));
if o.clicked() {
apply = true;
}
});
});
for (id, r, l) in elems {
app.auto.add(&id, r, &l);
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
keep = false;
}
if apply {
let mut p = serde_json::Map::new();
if draft.video {
p.insert("video".into(), serde_json::json!(draft.video_target));
}
if draft.audio {
p.insert("audio".into(), serde_json::json!(draft.audio_target));
}
if let Err(e) = app.session.execute("sequence.deleteTracks", serde_json::Value::Object(p)) {
app.ui.status = e.to_string();
}
keep = false;
}
app.ui.delete_tracks = draft;
keep
}
/// Help ▸ About FilmCraft, in three tabs: About (version and the ArtCraft community links),
/// Contributors and Models (the credits compiled in from `contributors/contributors.json`, see
/// `crate::credits` and docs/contributors.md).
///
/// Automation ids: `about.tab.about`, `about.tab.contributors`, `about.tab.models`; the About tab's
/// links and the credits controls are listed on [`about_tab`] and [`crate::credits::contributors_ui`].
fn about(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
let tab_id = egui::Id::new("about_tab");
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
for (i, (id, label)) in [("about", tl!("About")), ("contributors", tl!("Contributors")), ("models", tl!("Models"))].into_iter().enumerate() {
let i = i as u8;
let r = ui.selectable_label(tab == i, label);
app.auto.add(&format!("about.tab.{id}"), r.rect, label);
if r.clicked() {
tab = i;
}
}
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.separator();
let t = app.tokens;
match tab {
1 => {
ui.set_width(680.0);
crate::credits::contributors_ui(ui, &t, &mut app.auto);
}
2 => {
ui.set_width(680.0);
crate::credits::models_ui(ui);
}
_ => about_tab(app, ui),
}
}
/// About ▸ About: version, licence and the ArtCraft community links. Joining the Discord is the
/// first, accented button.
///
/// Automation ids: `about.discord`, `about.website`, `about.appPage`, `about.github`,
/// `about.reportIssue`.
fn about_tab(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
use crate::icons::{self, Icon};
use crate::links;
let t = app.tokens;
ui.set_width(380.0);
// ArtCraft wordmark (first-party trademark, docs/brand/).
let (r, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 30.0), egui::Sense::hover());
crate::brand::paint_wordmark(ui, egui::pos2(r.min.x, r.center().y), 20.0, app.ui.dark);
ui.add_space(6.0);
ui.heading("FilmCraft");
ui.label(tlf!("Version {version}", version = env!("CARGO_PKG_VERSION")));
ui.label(tl!("A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family."));
ui.add_space(10.0);
let mut link = |ui: &mut egui::Ui, id: &str, icon: Icon, label: &str, url: &str, primary: bool| {
let size = egui::vec2(ui.available_width(), if primary { 36.0 } else { 28.0 });
let (r, resp) = ui.allocate_exact_size(size, egui::Sense::click());
let resp = resp.on_hover_text(url);
app.auto.add(&format!("about.{id}"), r, label);
let bg = if primary {
if resp.hovered() { t.accent_hover } else { t.accent }
} else if resp.hovered() {
t.hover
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect_filled(r, 6.0, bg);
let fg = if primary { egui::Color32::WHITE } else { t.text };
let ir = egui::Rect::from_center_size(egui::pos2(r.min.x + 20.0, r.center().y), egui::vec2(18.0, 18.0));
icons::paint(ui.painter(), ir, icon, fg);
ui.painter().text(
egui::pos2(r.min.x + 40.0, r.center().y),
egui::Align2::LEFT_CENTER,
label,
crate::theme::Tokens::ui(if primary { 14.0 } else { 13.0 }),
fg,
);
if resp.clicked() {
links::open(ui.ctx(), url);
}
};
link(ui, "discord", Icon::Chat, tl!("Join the ArtCraft Discord"), links::DISCORD, true);
ui.add_space(6.0);
link(ui, "website", Icon::Globe, "getartcraft.com", links::WEBSITE, false);
link(ui, "appPage", Icon::Globe, tl!("FilmCraft on getartcraft.com"), links::APP_PAGE, false);
link(ui, "github", Icon::Code, tl!("Source code on GitHub"), links::GITHUB, false);
link(ui, "reportIssue", Icon::Code, tl!("Report an issue"), links::ISSUES, false);
ui.add_space(10.0);
ui.label(egui::RichText::new(tl!("MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1).")).small().color(t.text_dim));
}