Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contributors/people.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,7 @@ sync_github_name = false

[people.webdevtodayjason]
display_name = "Jason Brashear"

[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
1 change: 1 addition & 0 deletions crates/engine/src/clip_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,7 @@ fn close_project(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into()));
}
s.project = std::sync::Arc::new(Project::new("Untitled"));
s.project_generation += 1;
s.history = Default::default();
s.history.limit = 200;
s.state = Session::default().state;
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,7 @@ fn build() -> Vec<CommandSpec> {
cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| {
let name = str_p(p, "name").unwrap_or("Untitled").to_string();
s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name));
s.project_generation += 1;
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
Expand All @@ -1153,6 +1154,7 @@ fn build() -> Vec<CommandSpec> {
cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| {
let (p, seq) = crate::demo::demo_project(&s.media);
s.project = std::sync::Arc::new(p);
s.project_generation += 1;
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
Expand Down Expand Up @@ -4298,6 +4300,7 @@ fn schema_backup_path(path: &str, schema: u32) -> String {

/// Make `proj` the session's project (fresh history, media pool and editor state).
fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Option<String>, clean: bool) {
s.project_generation += 1;
let proxies = s.media.use_proxies();
s.media = std::sync::Arc::new(crate::MediaPool::default());
s.media.set_use_proxies(proxies);
Expand Down
5 changes: 5 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,10 @@ pub struct Session {
pub history: History,
pub revision: u64,
pub saved_revision: u64,
/// Counts the projects this session has held: it changes when another project replaces this
/// one (open, new, close), never with an edit. Work that finishes later (a file dialog's
/// answer) checks it is still for the same project.
pub project_generation: u64,
pub path: Option<String>,
pub state: EditorState,
pub media: Arc<MediaPool>,
Expand Down Expand Up @@ -499,6 +503,7 @@ impl Session {
history: History { limit: 200, ..Default::default() },
revision: 1,
saved_revision: 1,
project_generation: 0,
path: None,
state: EditorState {
snapping: true,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/project_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
proj.name = name.clone();
proj.root.name = name;
s.project = std::sync::Arc::new(proj);
s.project_generation += 1;
s.history = Default::default();
s.history.limit = 200;
s.state = Session::default().state;
Expand Down
4 changes: 4 additions & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,10 @@ impl FilmcraftApp {
}
"file.open" => self.pick(FileDialog::OpenProject, |app, paths| {
let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };
// unsaved changes: Save / Don't Save / Cancel first
if panels::clip_dialogs::intercept_open(app, &path) {
return Ok(json!({"dialog": "openProject"}));
}
app.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string())
}),
"graphics.newFromFile" => {
Expand Down
4 changes: 4 additions & 0 deletions crates/ui-egui/src/menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
// File ▸ Open Recent
if let Some(i) = id.strip_prefix("file.openRecent.") {
let path = i.parse::<usize>().ok().and_then(|i| app.session.prefs.general.recent_projects.get(i).cloned()).ok_or("no such recent project")?;
// unsaved changes: Save / Don't Save / Cancel first
if crate::panels::clip_dialogs::intercept_open(app, &path) {
return Ok(json!({"dialog": "openProject"}));
}
return invoke(app, ctx, "file.open", json!({"path": path}));
}
if id == "file.clearRecent" {
Expand Down
96 changes: 90 additions & 6 deletions crates/ui-egui/src/panels/clip_dialogs.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Edit / Clip / File menu dialogs (M3.10): Paste Attributes, Remove Attributes, Offline File,
//! Make Subclip, Edit Subclip, Modify ▸ Audio Channels, Modify ▸ Timecode, Frame Hold Options,
//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project and
//! Quit "save changes?" prompts.
//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project,
//! Open Project and Quit "save changes?" prompts.
//!
//! The open dialog lives in `UiState::clip_dialog` (serde): the engine command it runs and the
//! parameters being edited — the same JSON the command takes — so agents can open a dialog from
Expand All @@ -27,6 +27,8 @@
//! - Clip Speed / Duration `speedDuration.*`: `speed`, `reverse`, `ripple`,
//! `interpolation.<frameSampling|frameBlending|opticalFlow>`;
//! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`;
//! - Open Project (asked once the project to open is chosen, see [`intercept_open`])
//! `openProject.save`, `openProject.dontSave`, `openProject.cancel`;
//! - Quit (asked when the window is closed with unsaved changes, see [`intercept_quit`])
//! `quit.save`, `quit.dontSave`, `quit.cancel`.

Expand Down Expand Up @@ -59,6 +61,7 @@ fn meta(command: &str) -> Option<(&'static str, &'static str)> {
"clip.fieldOptions" => (tl!("Field Options"), "fieldOptions"),
"clip.speedDuration" => (tl!("Clip Speed / Duration"), "speedDuration"),
"file.closeProject" => (tl!("Save Project"), "closeProject"),
"file.open" => (tl!("Save Project"), "openProject"),
"app.quit" => (tl!("Save Project"), "quit"),
_ => return None,
})
Expand Down Expand Up @@ -93,7 +96,8 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<
if id == "file.saveAll" && app.session.path.is_none() {
return Some(app.file_dialog("file.save", params));
}
if (id == "file.closeProject" && !app.session.is_dirty()) || id == "app.quit" {
// Open Project asks once the project to open is chosen (`intercept_open`)
if (id == "file.closeProject" && !app.session.is_dirty()) || id == "app.quit" || id == "file.open" {
return None;
}
meta(id)?;
Expand All @@ -114,11 +118,23 @@ pub fn intercept_quit(app: &mut FilmcraftApp) -> bool {
return false;
}
if app.ui.clip_dialog.as_ref().is_none_or(|d| d.command != "app.quit") {
app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: Value::Null, error: String::new() });
app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: asked_over(&app.session), error: String::new() });
}
true
}

/// File ▸ Open Project once the project at `path` is chosen, File ▸ Open Recent and Home's Recent
/// list: with unsaved changes Save / Don't Save / Cancel is asked first, as Close Project asks, and
/// `path` opens after Save or Don't Save. Returns whether it asked.
pub fn intercept_open(app: &mut FilmcraftApp, path: &str) -> bool {
if !app.session.is_dirty() {
return false;
}
app.ui.clip_dialog =
Some(ClipDialogDraft { command: "file.open".into(), params: json!({"path": path}), info: asked_over(&app.session), error: String::new() });
true
}

/// Initial parameters (and display info) of a command's dialog.
fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) {
let s = &app.session;
Expand Down Expand Up @@ -228,10 +244,17 @@ fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) {
json!({"duration": it.as_ref().map_or(0, |i| i.duration.0), "speed": speed.abs(), "fps": fps}),
)
}
"file.closeProject" => (json!({}), asked_over(s)),
_ => (json!({}), Value::Null),
}
}

/// The info of the Close Project, Open Project and Quit prompts: the project they are asked over
/// (`Session::project_generation`). When another project replaces it, the prompt closes.
fn asked_over(s: &filmcraft_engine::Session) -> Value {
json!({"project": s.project_generation})
}

fn format_name(f: AudioChannels) -> &'static str {
match f {
AudioChannels::Mono => "mono",
Expand Down Expand Up @@ -305,11 +328,17 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
app.ui.clip_dialog = None;
return;
};
// a save prompt asked over a project that another one has replaced since (a script opened,
// created or closed to it) closes without acting on the new project
if d.info["project"].as_u64().is_some_and(|p| p != app.session.project_generation) {
app.ui.clip_dialog = None;
return;
}
let mut elems: Elems = Vec::new();
let mut action: Option<&'static str> = None;
let project_name = app.session.project.name.clone();
// a never-saved project is saved through the Save dialog
let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some();
let can_save = app.session.path.is_some() || crate::native_dialogs::FileDialog::SaveProject { name: String::new() }.available(&app.hooks);
// the title is translated; the window keeps one id whatever the interface language
let id = egui::Id::new(("clip-dialog", pre));
egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).centered(ctx).show(ctx, |ui| {
Expand Down Expand Up @@ -539,7 +568,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
}
}
}
"file.closeProject" | "app.quit" => {
"file.closeProject" | "file.open" | "app.quit" => {
ui.label(tlf!("Save changes to “{project_name}” before closing?", project_name));
ui.add_space(8.0);
ui.horizontal(|ui| {
Expand Down Expand Up @@ -605,6 +634,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
}
}
Some("dontSave") | Some("save") => {
// Open Project ▸ Save on a never-saved project: the chosen project opens once the Save
// dialog's file is saved, which can be a later frame (Linux)
if action == Some("save") && d.command == "file.open" && app.session.path.is_none() {
app.ui.clip_dialog = None;
if let Err(e) = save_then_open(app, d.clone()) {
app.ui.status = e;
app.ui.clip_dialog = Some(d);
}
return;
}
if action == Some("save") {
let saved = if app.session.path.is_some() {
app.session.execute("file.save", json!({})).map(|_| ()).map_err(|e| e.to_string())
Expand All @@ -622,6 +661,15 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
return;
}
}
// Open Project opens the chosen project; when that fails, this project stays open with
// its unsaved changes
if d.command == "file.open" {
if let Err(e) = app.session.execute("file.open", d.params.clone()) {
app.ui.status = e.to_string();
}
app.ui.clip_dialog = None;
return;
}
// Quit ▸ Don't Save discards the changes: closing the project also clears their recovery
// snapshot, so the next launch doesn't offer them back
let close = d.command != "app.quit" || app.session.is_dirty();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand All @@ -640,6 +688,42 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
app.ui.clip_dialog = Some(d);
}

/// Open Project ▸ Save on a never-saved project: the Save dialog, then `prompt`'s project opens
/// once the file is saved. The dialog's answer runs when it arrives (the host may answer in a later
/// frame). A canceled dialog or a failed save shows the prompt again. An answer that arrives after
/// another project was opened, created or closed to does nothing, and one that arrives while another
/// prompt is shown saves but leaves the opening to that prompt. Returns an error when no dialog can
/// be shown.
fn save_then_open(app: &mut FilmcraftApp, prompt: ClipDialogDraft) -> Result<Value, String> {
let name = format!("{}.fcproj", app.session.project.name);
// the project the dialog saves (not one opened, created or closed to meanwhile)
let project = app.session.project_generation;
app.pick(crate::native_dialogs::FileDialog::SaveProject { name }, move |app, paths| {
if app.session.project_generation != project {
return Ok(Value::Null);
}
let Some(path) = paths.into_iter().next() else {
if app.ui.clip_dialog.is_none() {
app.ui.clip_dialog = Some(prompt);
}
return Ok(Value::Null);
};
if let Err(e) = app.session.execute("file.saveAs", json!({"path": path})) {
app.ui.status = e.to_string();
if app.ui.clip_dialog.is_none() {
app.ui.clip_dialog = Some(prompt);
}
return Ok(Value::Null);
}
if app.ui.clip_dialog.is_none()
&& let Err(e) = app.session.execute("file.open", prompt.params)
{
app.ui.status = e.to_string();
}
Ok(Value::Null)
})
}

/// The parameters sent on OK (dialog-only fields dropped or converted).
fn finish(d: &ClipDialogDraft) -> Value {
let mut p = d.params.clone();
Expand Down
3 changes: 2 additions & 1 deletion crates/ui-egui/src/panels/import_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32
}
if let Some(path) = open {
let ctx = ui.ctx().clone();
if crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() {
// unsaved changes: Save / Don't Save / Cancel first, asked over the project they were made in
if crate::panels::clip_dialogs::intercept_open(app, &path) || crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() {
app.ui.mode = crate::state::Mode::Edit;
}
}
Expand Down
Loading
Loading