diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index a58ddee63..f35652195 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "Ungültige Hexadezimalfarbe" "dialog.cancel": "Abbrechen" "dialog.close": "Schließen" +"dialog.ok": "OK" +"projprops.title": "Projekteigenschaften" +"projprops.name": "Projektname" +"projprops.ocio_config": "OpenColorIO-Konfiguration" +"projprops.browse": "Durchsuchen…" +"projprops.cache.location": "Speicherort für den Festplatten-Cache" +"projprops.cache.default": "Standardort verwenden" +"projprops.cache.alongside": "Zusammen mit dem Projekt speichern" +"projprops.cache.custom": "Benutzerdefinierten Speicherort verwenden:" "file.open.title": "Projekt öffnen" "file.import_footage.title": "Material importieren" "preferences.title": "Einstellungen" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index fc80f04b2..ced3c9a96 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "Invalid hex color" "dialog.cancel": "Cancel" "dialog.close": "Close" +"dialog.ok": "OK" +"projprops.title": "Project Properties" +"projprops.name": "Project Name" +"projprops.ocio_config": "OpenColorIO Configuration" +"projprops.browse": "Browse…" +"projprops.cache.location": "Disk Cache Location" +"projprops.cache.default": "Use Default Location" +"projprops.cache.alongside": "Store Alongside Project" +"projprops.cache.custom": "Use Custom Location:" "file.open.title": "Open Project" "file.import_footage.title": "Import Footage" "preferences.title": "Preferences" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 0339c31b7..33985ffb6 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "Color hexadecimal no válido" "dialog.cancel": "Cancelar" "dialog.close": "Cerrar" +"dialog.ok": "Aceptar" +"projprops.title": "Propiedades del proyecto" +"projprops.name": "Nombre del proyecto" +"projprops.ocio_config": "Configuración de OpenColorIO" +"projprops.browse": "Examinar…" +"projprops.cache.location": "Ubicación de la caché de disco" +"projprops.cache.default": "Usar ubicación predeterminada" +"projprops.cache.alongside": "Almacenar junto al proyecto" +"projprops.cache.custom": "Usar ubicación personalizada:" "file.open.title": "Abrir proyecto" "file.import_footage.title": "Importar material" "preferences.title": "Preferencias" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index df46cd72b..ab98b0b6e 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "Couleur hexadécimale invalide" "dialog.cancel": "Annuler" "dialog.close": "Fermer" +"dialog.ok": "OK" +"projprops.title": "Propriétés du projet" +"projprops.name": "Nom du projet" +"projprops.ocio_config": "Configuration OpenColorIO" +"projprops.browse": "Parcourir…" +"projprops.cache.location": "Emplacement du cache disque" +"projprops.cache.default": "Utiliser l'emplacement par défaut" +"projprops.cache.alongside": "Stocker à côté du projet" +"projprops.cache.custom": "Utiliser un emplacement personnalisé :" "file.open.title": "Ouvrir le projet" "file.import_footage.title": "Importer des médias" "preferences.title": "Préférences" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index 93311052e..562dfe015 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "無効な16進カラーです" "dialog.cancel": "キャンセル" "dialog.close": "閉じる" +"dialog.ok": "OK" +"projprops.title": "プロジェクト設定" +"projprops.name": "プロジェクト名" +"projprops.ocio_config": "OpenColorIO設定" +"projprops.browse": "参照…" +"projprops.cache.location": "ディスクキャッシュの場所" +"projprops.cache.default": "デフォルトの場所を使用" +"projprops.cache.alongside": "プロジェクトと同じ場所に保存" +"projprops.cache.custom": "カスタムの場所を使用:" "file.open.title": "プロジェクトを開く" "file.import_footage.title": "素材を読み込み" "preferences.title": "環境設定" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index e2907b91b..31d6eb51c 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "Cor hexadecimal inválida" "dialog.cancel": "Cancelar" "dialog.close": "Fechar" +"dialog.ok": "OK" +"projprops.title": "Propriedades do projeto" +"projprops.name": "Nome do projeto" +"projprops.ocio_config": "Configuração do OpenColorIO" +"projprops.browse": "Procurar…" +"projprops.cache.location": "Localização do cache de disco" +"projprops.cache.default": "Usar localização padrão" +"projprops.cache.alongside": "Armazenar junto ao projeto" +"projprops.cache.custom": "Usar localização personalizada:" "file.open.title": "Abrir projeto" "file.import_footage.title": "Importar mídia" "preferences.title": "Preferências" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 91c294844..f3b6c8580 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "Недопустимый шестнадцатеричный цвет" "dialog.cancel": "Отмена" "dialog.close": "Закрыть" +"dialog.ok": "ОК" +"projprops.title": "Свойства проекта" +"projprops.name": "Имя проекта" +"projprops.ocio_config": "Конфигурация OpenColorIO" +"projprops.browse": "Обзор…" +"projprops.cache.location": "Расположение дискового кэша" +"projprops.cache.default": "Использовать расположение по умолчанию" +"projprops.cache.alongside": "Хранить рядом с проектом" +"projprops.cache.custom": "Использовать пользовательское расположение:" "file.open.title": "Открыть проект" "file.import_footage.title": "Импорт медиафайлов" "preferences.title": "Настройки" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index 36508038f..5e780816b 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -238,6 +238,15 @@ "ofx.color.invalid": "无效的十六进制颜色" "dialog.cancel": "取消" "dialog.close": "关闭" +"dialog.ok": "确定" +"projprops.title": "项目属性" +"projprops.name": "项目名称" +"projprops.ocio_config": "OpenColorIO 配置" +"projprops.browse": "浏览…" +"projprops.cache.location": "磁盘缓存位置" +"projprops.cache.default": "使用默认位置" +"projprops.cache.alongside": "随工程文件存储" +"projprops.cache.custom": "使用自定义位置:" "file.open.title": "打开项目" "file.import_footage.title": "导入素材" "preferences.title": "偏好设置" diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index a66f0194b..153e7ba8d 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -123,6 +123,8 @@ mod modal_ids { pub const PLUGIN_PROGRESS: usize = 10; /// The action search dialog (Help > Search Actions…, the `/` key). pub const ACTION_SEARCH: usize = 11; + /// The project properties dialog (File > Project Properties…). + pub const PROJECT_PROPERTIES: usize = 12; } /// What a picked platform-dialog path should do. @@ -178,6 +180,12 @@ enum ModalState { modal: Entity, content: Entity, }, + /// The project properties dialog (File > Project Properties…; the + /// per-project OCIO config override and the disk-cache location). + ProjectProperties { + modal: Entity, + content: Entity>, + }, } /// A running export: the session the tick loop drains for progress. @@ -197,7 +205,8 @@ impl ModalState { | ModalState::ManagerRename { modal, .. } | ModalState::ManagerDelete { modal, .. } | ModalState::Proxy { modal, .. } - | ModalState::ActionSearch { modal, .. } => Some(modal.clone()), + | ModalState::ActionSearch { modal, .. } + | ModalState::ProjectProperties { modal, .. } => Some(modal.clone()), } } } @@ -1062,6 +1071,7 @@ impl OakApp { self.rebuild_menu_bar(cx); } A::ProxySettings => self.open_proxy_dialog(cx), + A::ProjectProperties => self.open_project_properties(cx), // The multicam source-switch hotkeys are scoped to the Multicam // panel (the focused-panel route handles them there); a fall-through // from any other focused panel is a silent no-op. @@ -2000,6 +2010,46 @@ impl OakApp { }); } + /// Opens the project properties dialog (File > Project Properties…; the + /// C++ `ProjectPropertiesDialog`): the read-only project name, the + /// per-project OCIO config override and the disk-cache location. The OK + /// button applies through the content's `commit` (an invalid OCIO config + /// keeps the dialog open), Escape / Cancel discard without applying. + fn open_project_properties(&mut self, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let engine = self.engine.clone(); + let project_name = self + .engine + .read(cx) + .project() + .map(|p| p.name.clone()) + .unwrap_or_default(); + self.spawn_modal(cx, move |window, app| { + let content = + app.new(|cx| crate::dialogs::ProjectPropertiesContent::new(engine, window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::PROJECT_PROPERTIES, + ModalOptions::new( + format!("{} — {project_name}", crate::i18n::tr("projprops.title")), + px(560.0), + ) + .with_button(DialogButton::primary(crate::i18n::tr("dialog.ok"))) + .with_button(DialogButton::new( + crate::i18n::tr("dialog.cancel"), + gpui_widgets::dialog::DialogButtonRole::Secondary, + )), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::ProjectProperties { modal, content } + }); + } + /// Opens the export dialog. fn open_export_dialog(&mut self, cx: &mut Context) { if self.engine.read(cx).current_sequence().is_none() { @@ -2194,6 +2244,25 @@ impl OakApp { } } } + modal_ids::PROJECT_PROPERTIES => { + if let ModalState::ProjectProperties { content, .. } = &self.modal { + let content = content.clone(); + if *button == 0 { + // OK: apply the settings; an invalid OCIO config + // keeps the dialog open with the error shown. + match content.update(cx, |dialog, cx| dialog.commit(cx)) { + Ok(()) => self.close_modal(cx), + Err(err) => { + content.update(cx, |dialog, cx| { + dialog.set_error(Some(err), cx) + }); + } + } + } else { + self.close_modal(cx); + } + } + } _ => {} }, ModalEvent::Dismissed { control } => match *control { @@ -2213,6 +2282,9 @@ impl OakApp { // Escape closes the proxy dialog without applying (the // Close button is the apply path, like the C++ accept()). modal_ids::PROXY => self.close_modal(cx), + // Escape / backdrop close the project properties dialog + // without applying (only OK commits). + modal_ids::PROJECT_PROPERTIES => self.close_modal(cx), _ => self.close_modal(cx), }, } @@ -3064,6 +3136,160 @@ mod tests { ); } + /// Opening File → Project Properties… must not crash: the content is + /// built with the engine and the modal is layered onto the shell (the + /// same deferred-build path as Preferences). + #[gpui::test] + async fn project_properties_dialog_opens_without_crashing(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + crate::i18n::set_language_code("en-US"); + + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| { + OakApp::::new(window, None, cx) + }); + cx.run_until_parked(); + let root = window.root(cx).expect("app root"); + + cx.update(|app| { + root.update( + app, + |app, cx| app.on_menu(ActionId::ProjectProperties.menu_id(), cx), + ) + }); + cx.run_until_parked(); + // Force a draw so render-time panics in the dialog content surface. + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + + let has_modal = + cx.read(|app| matches!(root.read(app).modal, ModalState::ProjectProperties { .. })); + assert!( + has_modal, + "project properties modal should be shown after the menu action" + ); + } + + /// The dialog's OK button applies the chosen cache location to the + /// engine: choosing 自定义位置 + a path, then clicking OK, lands in + /// `project_cache_location()` as `(2, path)` and closes the dialog. + #[gpui::test] + async fn project_properties_commit_applies_cache_location(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let (window, root) = mock_shell(cx); + + cx.update(|app| { + root.update( + app, + |app, cx| app.on_menu(ActionId::ProjectProperties.menu_id(), cx), + ) + }); + cx.run_until_parked(); + let content = cx.read(|app| match &root.read(app).modal { + ModalState::ProjectProperties { content, .. } => content.clone(), + _ => panic!("project properties modal should be open"), + }); + + // Choose 自定义位置 and type a path. + cx.update(|app| { + content.update(app, |dialog, cx| { + dialog.select_cache_setting(2, cx); + dialog.set_custom_cache_path("/tmp/oak-cache", cx); + }) + }); + cx.run_until_parked(); + + // Click the OK button (the modal's primary button). + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + cx.run_until_parked(); + let mut vcx = VisualTestContext::from_window(window.into(), cx); + let ok = vcx.debug_bounds("dialog-button-0").expect("OK button rendered"); + vcx.simulate_click(ok.center(), gpui::Modifiers::none()); + drop(vcx); + cx.run_until_parked(); + + assert!( + cx.read(|app| matches!(root.read(app).modal, ModalState::None)), + "OK closes the dialog" + ); + let location = cx.read(|app| root.read(app).engine.read(app).project_cache_location()); + assert_eq!(location, (2, "/tmp/oak-cache".to_string())); + } + + /// A bogus OCIO config keeps the dialog open: clicking OK runs the + /// engine's validation, which rejects the path, shows the error label + /// under the OCIO row and leaves the modal on screen. + #[gpui::test] + async fn project_properties_invalid_ocio_keeps_the_dialog_open(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let (window, root) = mock_shell(cx); + + cx.update(|app| { + root.update( + app, + |app, cx| app.on_menu(ActionId::ProjectProperties.menu_id(), cx), + ) + }); + cx.run_until_parked(); + let content = cx.read(|app| match &root.read(app).modal { + ModalState::ProjectProperties { content, .. } => content.clone(), + _ => panic!("project properties modal should be open"), + }); + + // Type a path that cannot load as an OCIO config. + cx.update(|app| { + content.update(app, |dialog, cx| { + dialog.set_ocio_config_path("/nonexistent/ocio/config.ocio", cx); + }) + }); + cx.run_until_parked(); + + // Click OK: the commit fails, the dialog stays open, the error shows. + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + cx.run_until_parked(); + let mut vcx = VisualTestContext::from_window(window.into(), cx); + let ok = vcx.debug_bounds("dialog-button-0").expect("OK button rendered"); + vcx.simulate_click(ok.center(), gpui::Modifiers::none()); + drop(vcx); + cx.run_until_parked(); + + let still_open = + cx.read(|app| matches!(root.read(app).modal, ModalState::ProjectProperties { .. })); + assert!(still_open, "an invalid OCIO config keeps the dialog open"); + let error = cx.read(|app| match &root.read(app).modal { + ModalState::ProjectProperties { content, .. } => content.read(app).error().cloned(), + _ => None, + }); + let error = error.expect("the commit error is recorded"); + assert!( + error.to_lowercase().contains("ocio"), + "the error mentions the config: {error}" + ); + + // The error label renders under the OCIO row. + cx.update_window(window.into(), |_root, window, cx| { + window.draw(cx).clear(); + }) + .expect("window is still open"); + let mut vcx = VisualTestContext::from_window(window.into(), cx); + assert!( + vcx.debug_bounds("projprops-error").is_some(), + "the OCIO error label renders" + ); + drop(vcx); + } + // ------------------------------------------------------------------- // Keyboard shortcuts (M12 P5c) // ------------------------------------------------------------------- diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index c077515db..de55a186a 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -204,7 +204,10 @@ impl PreferencesContent { // --- 缓存 Cache: the disk cache directory -------------------------- let cache_dir = cx.new(|cx| { let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); - PathField { editor } + PathField { + editor, + enabled: true, + } }); let configured_cache = config_get_string(CONFIG_KEY_DISK_CACHE_PATH); cache_dir.update(cx, |field, cx| field.set_path(configured_cache, cx)); @@ -319,7 +322,10 @@ impl PreferencesContent { .detach(); let display_icc_path = cx.new(|cx| { let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); - PathField { editor } + PathField { + editor, + enabled: true, + } }); let configured_icc = config_get_string(CONFIG_KEY_CUSTOM_ICC); display_icc_path.update(cx, |field, cx| field.set_path(configured_icc, cx)); @@ -719,6 +725,9 @@ impl Render for PreferencesContent { /// A text field with the same shape as the file dialog's path field. pub struct PathField { editor: Entity, + /// Whether the field accepts input (disabled fields dim and drop the + /// text-input handler, like the checkbox disabled state). + enabled: bool, } impl PathField { @@ -735,12 +744,29 @@ impl PathField { }); cx.notify(); } + + /// Whether the field accepts input (disabled fields are read-only and + /// render dimmed). + pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context) { + if self.enabled == enabled { + return; + } + self.enabled = enabled; + cx.notify(); + } + + /// The input state the field starts in (chainable after construction). + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } } impl Render for PathField { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let weak = self.editor.downgrade(); + let enabled = self.enabled; div() .rounded_md() .border_1() @@ -748,10 +774,11 @@ impl Render for PathField { .bg(colors.background) .px_2() .py_1() + .opacity(if enabled { 1.0 } else { 0.45 }) .child( text_input("gpui-widgets-export-path", cx) .state(weak) - .accepts_input(true), + .accepts_input(enabled), ) } } @@ -790,7 +817,10 @@ impl ExportDialogContent { let path = cx.new(|cx| { let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); - PathField { editor } + PathField { + editor, + enabled: true, + } }); Self { @@ -986,7 +1016,10 @@ impl ProxyDialogContent { let ffmpeg_path = cx.new(|cx| { let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); - PathField { editor } + PathField { + editor, + enabled: true, + } }); ffmpeg_path.update(cx, |field, cx| { field.set_path(config_get_string(CONFIG_KEY_FFMPEG_PATH), cx) @@ -1273,6 +1306,239 @@ impl Render for ProxyDialogContent { } } +// --------------------------------------------------------------------------- +// Project properties (the C++ File > Project Properties dialog) +// --------------------------------------------------------------------------- + +/// The project-properties dialog content (the C++ `ProjectPropertiesDialog`): +/// the read-only project name, the per-project OCIO config override and the +/// disk-cache location. Apply happens through [`Self::commit`], which the +/// host runs on the OK button — an invalid OCIO config keeps the dialog open +/// with the error shown under the OCIO row. +pub struct ProjectPropertiesContent { + engine: Entity, + ocio_config: Entity, + cache_location: Entity, + custom_cache_path: Entity, + /// The cache location selected in the combo (0 = default location, + /// 1 = alongside the project, 2 = custom path; see + /// [`crate::oakui::engine::AppEngine::project_cache_location`]). + cache_setting: i32, + /// The commit error shown under the OCIO row (an invalid config keeps + /// the dialog open, like the C++ accept()). + error: Option, +} + +impl ProjectPropertiesContent { + /// Builds the content seeded from the engine's current project state. + pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { + let ocio_config = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + PathField { + editor, + enabled: true, + } + }); + let configured_ocio = engine.read(cx).project_ocio_config(); + ocio_config.update(cx, |field, cx| field.set_path(configured_ocio, cx)); + + let cache_options = vec![ + ComboBoxOption::new(0, i18n::tr("projprops.cache.default")), + ComboBoxOption::new(1, i18n::tr("projprops.cache.alongside")), + ComboBoxOption::new(2, i18n::tr("projprops.cache.custom")), + ]; + let cache_location = cx.new(|cx| ComboBox::new(30, cache_options, window, cx)); + let (cache_setting, custom_path) = engine.read(cx).project_cache_location(); + cache_location.update(cx, |combo, cx| { + combo.set_selected(Some(cache_setting as usize), cx) + }); + // The custom-path field follows the combo selection live. + cx.subscribe(&cache_location, |this, _combo, event: &ComboBoxEvent, cx| { + let ComboBoxEvent::Selected { value } = event; + let setting = *value as i32; + this.cache_setting = setting; + this.custom_cache_path.update(cx, |field, cx| { + field.set_enabled(setting == 2, cx) + }); + cx.notify(); + }) + .detach(); + + let custom_cache_path = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + PathField { editor, enabled: cache_setting == 2 } + }); + custom_cache_path.update(cx, |field, cx| field.set_path(custom_path, cx)); + + Self { + engine, + ocio_config, + cache_location, + custom_cache_path, + cache_setting, + error: None, + } + } + + /// The OCIO config path currently entered. + pub fn ocio_config_path(&self, cx: &App) -> SharedString { + self.ocio_config.read(cx).path(cx) + } + + /// Replaces the OCIO config path (the 浏览… picker and tests). + pub fn set_ocio_config_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.ocio_config + .update(cx, |field, cx| field.set_path(path, cx)); + cx.notify(); + } + + /// The custom disk-cache path currently entered. + pub fn custom_cache_path(&self, cx: &App) -> SharedString { + self.custom_cache_path.read(cx).path(cx) + } + + /// Replaces the custom disk-cache path. + pub fn set_custom_cache_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.custom_cache_path + .update(cx, |field, cx| field.set_path(path, cx)); + cx.notify(); + } + + /// Selects the cache location option (0 = default, 1 = alongside, + /// 2 = custom) and enables the custom-path field accordingly — the + /// combo's own event path, exposed for tests. + pub fn select_cache_setting(&mut self, setting: i32, cx: &mut Context) { + let setting = setting.clamp(0, 2); + self.cache_setting = setting; + self.cache_location + .update(cx, |combo, cx| combo.set_selected(Some(setting as usize), cx)); + self.custom_cache_path.update(cx, |field, cx| { + field.set_enabled(setting == 2, cx) + }); + cx.notify(); + } + + /// Applies the edited settings (the C++ `accept()`): validates and + /// applies the OCIO config override first — an invalid config keeps the + /// dialog open — then the disk-cache location. Ok clears the error row. + pub fn commit(&mut self, cx: &mut Context) -> Result<(), String> { + let ocio = self.ocio_config_path(cx).to_string(); + self.engine + .update(cx, |engine, cx| engine.set_project_ocio_config(ocio, cx))?; + let custom = self.custom_cache_path(cx).to_string(); + let setting = self.cache_setting; + let path = if setting == 2 { custom } else { String::new() }; + self.engine + .update(cx, |engine, cx| engine.set_project_cache_location(setting, path, cx)); + self.set_error(None, cx); + Ok(()) + } + + /// The error shown under the OCIO row after a rejected commit. + pub fn set_error(&mut self, msg: Option, cx: &mut Context) { + self.error = msg; + cx.notify(); + } + + /// The commit error currently shown (`None` while the last commit + /// applied cleanly). + pub fn error(&self) -> Option<&String> { + self.error.as_ref() + } +} + +impl Render for ProjectPropertiesContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let project_name = self + .engine + .read(cx) + .project() + .map(|p| p.name.clone()) + .unwrap_or_default(); + + // The 浏览… button picks an OCIO config through the platform file + // dialog and fills the path field (the resolve is async, so the + // picker's receiver is drained in a spawned task). + let ocio_field = self.ocio_config.clone(); + let browse = div() + .id("projprops-browse") + .px_3() + .py_1() + .rounded_md() + .bg(colors.background) + .border_1() + .border_color(colors.border) + .text_color(colors.text) + .cursor_pointer() + .on_click(cx.listener(move |_this, _event: &gpui::ClickEvent, _window, cx| { + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = receiver.await { + if let Some(path) = paths.first() { + let path = path.to_string_lossy().into_owned(); + let _ = this.update(cx, |this, cx| this.set_ocio_config_path(path, cx)); + } + } + }) + .detach(); + })) + .child(i18n::tr("projprops.browse")); + + let ocio_row = div() + .flex() + .gap_2() + .child(ocio_field) + .child(browse); + + let custom_row = form_row( + &colors, + i18n::tr("projprops.cache.custom").into(), + self.custom_cache_path.clone(), + ); + + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("projprops.name").into(), + div().text_color(colors.text).child(project_name), + )) + .child( + form_row( + &colors, + i18n::tr("projprops.ocio_config").into(), + ocio_row, + ) + .child(if let Some(error) = &self.error { + div() + .debug_selector(|| "projprops-error".into()) + .text_color(gpui::rgb(0xe5484d)) + .text_xs() + .child(error.clone()) + } else { + div() + }), + ) + .child(form_row( + &colors, + i18n::tr("projprops.cache.location").into(), + self.cache_location.clone(), + )) + .child(custom_row) + } +} + // --------------------------------------------------------------------------- // Preferences: the tabbed host (General + Keyboard) // --------------------------------------------------------------------------- diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index 88d1fe0b7..e87bfd330 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -795,6 +795,39 @@ pub trait AppEngine: let _ = cx; } + // ------------------------------------------------------------------- + // Project properties (the C++ File > Project Properties dialog): + // the per-project OCIO config override and the disk-cache location. + // Defaults cover engines without a project surface. + // ------------------------------------------------------------------- + + /// The project's OCIO config override ("" = the app default config). + fn project_ocio_config(&self) -> String { + String::new() + } + + /// Validates and applies the OCIO config override: the file must load + /// as an OCIO config (Err keeps the dialog open, like the C++ accept()), + /// then the display color pipeline reloads and every rendered frame is + /// invalidated. Empty path restores the app default. + fn set_project_ocio_config(&mut self, path: String, cx: &mut Context) -> Result<(), String> { + let _ = (path, cx); + Ok(()) + } + + /// The project's disk-cache location: `(setting, custom_path)` with + /// setting 0 = default location, 1 = alongside the project file, + /// 2 = custom path (the C++ `Project::CacheSetting`). + fn project_cache_location(&self) -> (i32, String) { + (0, String::new()) + } + + /// Sets the disk-cache location; later cache writes (thumbnails and + /// any future per-project caches) land in the new location. + fn set_project_cache_location(&mut self, setting: i32, custom_path: String, cx: &mut Context) { + let _ = (setting, custom_path, cx); + } + /// The footage rows the proxy dialog's footage mode lists (every /// footage node in the open project). fn proxy_rows(&self) -> Vec { diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 881886f81..5b8ba06c3 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -539,6 +539,10 @@ pub struct MockEngine { proxy_custom: HashMap, /// The demo's global "Use Proxy Media" switch. use_proxy: bool, + /// The demo project's OCIO config override (the 项目属性 color tab). + ocio_config: String, + /// The demo project's disk-cache location (setting, custom path). + cache_location: (i32, String), /// The demo multicam graph: a real oaknode project whose source /// sequence's video tracks are the angles. Created lazily so the demo /// panel shows a genuine graph behind its synthetic frames — and the @@ -806,6 +810,8 @@ impl MockEngine { proxy_enabled: HashMap::new(), proxy_custom: HashMap::new(), use_proxy: true, + ocio_config: String::new(), + cache_location: (0, String::new()), multicam_graph: Mutex::new(None), multicam_frames: Mutex::new(HashMap::new()), }; @@ -1903,6 +1909,39 @@ impl AppEngine for MockEngine { cx.notify(); } + fn project_ocio_config(&self) -> String { + self.ocio_config.clone() + } + + fn set_project_ocio_config(&mut self, path: String, cx: &mut Context) -> Result<(), String> { + let trimmed = path.trim().to_string(); + // Validate like the real engine (a bogus path keeps the dialog open). + if !trimmed.is_empty() { + oak_render::color::set_up_default_config_from(Some(&trimmed)) + .map_err(|e| e.to_string())?; + } + self.ocio_config = trimmed; + cx.notify(); + Ok(()) + } + + fn project_cache_location(&self) -> (i32, String) { + self.cache_location.clone() + } + + fn set_project_cache_location(&mut self, setting: i32, custom_path: String, cx: &mut Context) { + let setting = setting.clamp(0, 2); + self.cache_location = ( + setting, + if setting == 2 { + custom_path.trim().to_string() + } else { + String::new() + }, + ); + cx.notify(); + } + fn proxy_rows(&self) -> Vec { // Every explorer footage entry gets a row; audio-only entries keep // `can_generate` off (the proxy pipeline is video-only). diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 9b2f3b374..5da50a1cb 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -444,6 +444,10 @@ struct ThumbnailState { pending: HashSet, } +/// The project settings key of the OCIO config override (the 项目属性 +/// color tab). +pub const PROJECT_SETTING_OCIO_CONFIG: &str = "ocioconfig"; + /// The shared directory holding generated footage thumbnails. fn thumbnail_dir() -> PathBuf { std::env::temp_dir().join("oak-thumbnails") @@ -451,13 +455,18 @@ fn thumbnail_dir() -> PathBuf { /// The PNG path of a footage's thumbnail: an FNV-1a hash of the media /// filename, so the same file always hits the same cached PNG. -fn thumbnail_path(filename: &str) -> PathBuf { +/// `cache_dir` is the project's cache-location override (the 项目属性 +/// disk-cache setting); `None` uses the shared default directory. +fn thumbnail_path(cache_dir: Option<&str>, filename: &str) -> PathBuf { let mut h: u64 = 0xcbf29ce484222325; for b in filename.as_bytes() { h ^= u64::from(*b); h = h.wrapping_mul(0x100000001b3); } - thumbnail_dir().join(format!("{h:016x}.png")) + let dir = cache_dir + .map(|d| PathBuf::from(d).join("thumbnails")) + .unwrap_or_else(thumbnail_dir); + dir.join(format!("{h:016x}.png")) } // --------------------------------------------------------------------------- @@ -1920,18 +1929,22 @@ impl RealEngine { }); } - /// Renders `identity`'s footage first frame into the shared thumbnail + /// Renders `identity`'s footage first frame into the thumbnail /// directory and returns the PNG path (`None` when the entry is not a - /// renderable footage or the render fails). + /// renderable footage or the render fails). The directory follows the + /// project's disk-cache location when it overrides the default (the + /// 项目属性 disk-cache setting's live consumer). fn render_thumbnail(project: &ProjectRef, identity: u64) -> Option { let node = graphops::id_of(identity)?; - let filename = { + let (filename, cache_dir) = { let guard = graphops::lock(project); - graphops::footage_behavior(&guard.graph, node) + let filename = graphops::footage_behavior(&guard.graph, node) .map(|f| f.filename.clone()) - .filter(|f| !f.is_empty())? + .filter(|f| !f.is_empty())?; + let dir = (guard.cache_location_setting != 0).then(|| guard.cache_path()); + (filename, dir) }; - let path = thumbnail_path(&filename); + let path = thumbnail_path(cache_dir.as_deref(), &filename); if path.exists() { return Some(path); } @@ -2531,6 +2544,10 @@ impl RealEngine { self.refresh_sequence_info(); self.rebuild_timeline(); + // The project's stored OCIO override (if any) drives the display + // color pipeline from here on. + Self::apply_project_color_config(Some(&project)); + cx.notify(); } @@ -2573,6 +2590,31 @@ impl RealEngine { name: UNTITLED.into(), path: PathBuf::new(), }; + // The dropped project's OCIO override leaves with it: the display + // color pipeline returns to the app default config. + Self::apply_project_color_config(None); + } + + /// Applies a project's stored OCIO override (`Some(project)`) or + /// restores the app default config (`None`, or a project without an + /// override). A config that fails to load falls back to the app default + /// (the file may have moved since the project was saved). + fn apply_project_color_config(project: Option<&ProjectRef>) { + let stored = project.and_then(|p| { + graphops::lock(p) + .settings + .get(PROJECT_SETTING_OCIO_CONFIG) + .cloned() + }); + let applied = match stored.as_deref() { + None => oak_render::color::set_up_default_config(), + Some(path) => oak_render::color::set_up_default_config_from(Some(path)), + }; + if let Err(e) = applied { + println!("[real engine] project OCIO config apply failed: {e}"); + let _ = oak_render::color::set_up_default_config(); + } + super::displaycolor::invalidate(); } /// Refreshes the cached `Sequence` (name / format / length) from the @@ -4345,6 +4387,78 @@ impl AppEngine for RealEngine { self.invalidate_preview_frames(cx); } + /// The project's OCIO config override (the 项目属性 color tab; "" = the + /// app default config). + fn project_ocio_config(&self) -> String { + self.project_ref() + .map(|p| { + graphops::lock(p) + .settings + .get(PROJECT_SETTING_OCIO_CONFIG) + .cloned() + .unwrap_or_default() + }) + .unwrap_or_default() + } + + fn set_project_ocio_config(&mut self, path: String, cx: &mut Context) -> Result<(), String> { + let Some(project) = self.project.clone() else { + return Err("no project open".to_string()); + }; + let trimmed = path.trim().to_string(); + // Validate first — the dialog stays open on Err (the C++ accept() + // refuses an invalid config the same way). Applying is the + // process-wide color config reload plus a full frame invalidation. + if trimmed.is_empty() { + oak_render::color::set_up_default_config().map_err(|e| e.to_string())?; + } else { + oak_render::color::set_up_default_config_from(Some(&trimmed)) + .map_err(|e| e.to_string())?; + } + { + let mut guard = graphops::lock(&project); + if trimmed.is_empty() { + guard.settings.remove(PROJECT_SETTING_OCIO_CONFIG); + } else { + guard + .settings + .insert(PROJECT_SETTING_OCIO_CONFIG.to_string(), trimmed); + } + guard.modified = true; + } + super::displaycolor::invalidate(); + self.invalidate_rendered_frames(); + cx.notify(); + Ok(()) + } + + fn project_cache_location(&self) -> (i32, String) { + self.project_ref() + .map(|p| { + let g = graphops::lock(p); + // Clamp: the dialog's combo indexes by this value. + (g.cache_location_setting.clamp(0, 2), g.custom_cache_path.clone()) + }) + .unwrap_or((0, String::new())) + } + + fn set_project_cache_location(&mut self, setting: i32, custom_path: String, cx: &mut Context) { + let Some(project) = self.project.clone() else { + return; + }; + { + let mut guard = graphops::lock(&project); + guard.cache_location_setting = setting.clamp(0, 2); + guard.custom_cache_path = if guard.cache_location_setting == 2 { + custom_path.trim().to_string() + } else { + String::new() + }; + guard.modified = true; + } + cx.notify(); + } + fn proxy_rows(&self) -> Vec { let Some(project) = self.project.as_ref() else { return Vec::new(); diff --git a/crates/oak-node/src/serializer.rs b/crates/oak-node/src/serializer.rs index 9af5ee9d5..2c5c93f8c 100644 --- a/crates/oak-node/src/serializer.rs +++ b/crates/oak-node/src/serializer.rs @@ -307,8 +307,27 @@ pub fn save(project: &Project) -> crate::error::Result { let mut keys: Vec<&String> = project.settings.keys().collect(); keys.sort(); for key in keys { + // The cache location lives on the struct fields; any settings-map + // copies (from a load) are skipped — the fields win on save. + if key == crate::project::SETTING_CACHE_LOCATION + || key == crate::project::SETTING_CACHE_PATH + { + continue; + } writer.text_element(key, project.settings.get(key).unwrap_or(&String::new())); } + // Persist the cache location as settings entries (the C++ + // k_cache_location_setting_key / k_cache_path_key); defaults add no + // noise to the file. + if project.cache_location_setting != 0 { + writer.text_element( + crate::project::SETTING_CACHE_LOCATION, + &project.cache_location_setting.to_string(), + ); + } + if !project.custom_cache_path.is_empty() { + writer.text_element(crate::project::SETTING_CACHE_PATH, &project.custom_cache_path); + } writer.end_element(); // settings writer.end_element(); // project @@ -604,6 +623,20 @@ fn load_project_body(reader: &mut dyn XmlRead, project: &mut Project) -> crate:: } } + // The cache location arrives as settings entries (see the save side). + if let Some(setting) = project + .settings + .get(crate::project::SETTING_CACHE_LOCATION) + .and_then(|s| s.parse::().ok()) + { + // Clamp to the known CacheSetting range: a hand-edited file must not + // poison the dialog's combo selection. + project.cache_location_setting = setting.clamp(0, 2); + } + if let Some(path) = project.settings.get(crate::project::SETTING_CACHE_PATH) { + project.custom_cache_path = path.clone(); + } + // Rebuild the timeline structure: the custom segments carry packed // references that only resolve now that every node is live. resolve_timeline_refs(&mut project.graph, &id_map); diff --git a/crates/oak-render/src/color.rs b/crates/oak-render/src/color.rs index 8a8b03500..e03464f87 100644 --- a/crates/oak-render/src/color.rs +++ b/crates/oak-render/src/color.rs @@ -412,10 +412,18 @@ pub fn default_config() -> Option> { /// Load the process-wide default config from $OCIO or the bundled config /// (C++ `ColorManager::SetUpDefaultConfig`). pub fn set_up_default_config() -> Result<()> { - let config = match std::env::var("OCIO") { - Ok(path) if !path.is_empty() => ocio_rs::Config::from_file(&path) - .map_err(|e| Error::Failed(format!("load $OCIO config: {e}")))?, - _ => ocio_rs::Config::create_from_builtin_config("default") + let env = std::env::var("OCIO").ok().filter(|p| !p.is_empty()); + set_up_default_config_from(env.as_deref()) +} + +/// Load the process-wide default config from an explicit path (`None` = +/// the bundled default config). The project-properties OCIO override and +/// the `$OCIO` startup path both land here. +pub fn set_up_default_config_from(path: Option<&str>) -> Result<()> { + let config = match path { + Some(path) => ocio_rs::Config::from_file(path) + .map_err(|e| Error::Failed(format!("load OCIO config \"{path}\": {e}")))?, + None => ocio_rs::Config::create_from_builtin_config("default") .or_else(|_| ocio_rs::Config::create_from_builtin_config("ocio-2.2-default")) .map_err(|e| Error::Failed(format!("load bundled config: {e}")))?, };