app: project properties dialog (File > Project Properties…)

The menu item was a placeholder print; it now opens a real dialog (the
C++ ProjectPropertiesDialog):

- Per-project OCIO config override with a 浏览… picker: validated on OK
  (an invalid config keeps the dialog open with the error shown, like
  the C++ accept()), persisted in the project settings, applied to the
  display color pipeline on accept and on project open, and reverted to
  the app default when the project closes. oak-render gains
  set_up_default_config_from for the explicit-path load.
- Disk-cache location (default / alongside the project / custom path):
  persisted through the OVE serializer (cachesetting/customcachepath
  round-trip the settings map, clamped on load) and honored by the
  thumbnail writer — the first live consumer of Project::cache_path.
- PathField gains an enabled state (the custom path field follows the
  combo selection).

The C++ color tab's Default Input Color Space and Reference Space
combos are intentionally absent: the Rust render pipeline has no
consumer for them today (decode performs no input transfer conversion),
so showing them would be dead settings.

Tests: dialog opens, OK applies the cache location, an invalid OCIO
config keeps the dialog open with the error row. i18n keys for all
eight packs.
This commit is contained in:
2026-08-25 00:45:04 +08:00
parent f7b5996032
commit 476114cec0
15 changed files with 809 additions and 18 deletions
+9
View File
@@ -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"
+9
View File
@@ -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"
+9
View File
@@ -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"
+9
View File
@@ -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"
+9
View File
@@ -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": "環境設定"
+9
View File
@@ -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"
+9
View File
@@ -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": "Настройки"
+9
View File
@@ -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": "偏好设置"
+227 -1
View File
@@ -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<E: AppEngine> {
modal: Entity<Modal>,
content: Entity<crate::dialogs::ActionSearchContent>,
},
/// The project properties dialog (File > Project Properties…; the
/// per-project OCIO config override and the disk-cache location).
ProjectProperties {
modal: Entity<Modal>,
content: Entity<crate::dialogs::ProjectPropertiesContent<E>>,
},
}
/// A running export: the session the tick loop drains for progress.
@@ -197,7 +205,8 @@ impl<E: AppEngine> ModalState<E> {
| 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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
});
}
/// 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<Self>) {
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<Self>) {
if self.engine.read(cx).current_sequence().is_none() {
@@ -2194,6 +2244,25 @@ impl<E: AppEngine> OakApp<E> {
}
}
}
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<E: AppEngine> OakApp<E> {
// 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::<MockEngine>::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)
// -------------------------------------------------------------------
+271 -5
View File
@@ -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<EditableTextState>,
/// 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<Self>) {
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<Self>) -> 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<E: crate::oakui::engine::AppEngine> ProxyDialogContent<E> {
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<E: crate::oakui::engine::AppEngine> Render for ProxyDialogContent<E> {
}
}
// ---------------------------------------------------------------------------
// 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<E: crate::oakui::engine::AppEngine> {
engine: Entity<E>,
ocio_config: Entity<PathField>,
cache_location: Entity<ComboBox>,
custom_cache_path: Entity<PathField>,
/// 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<String>,
}
impl<E: crate::oakui::engine::AppEngine> ProjectPropertiesContent<E> {
/// Builds the content seeded from the engine's current project state.
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> 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<SharedString>, cx: &mut Context<Self>) {
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<SharedString>, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) -> 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<String>, cx: &mut Context<Self>) {
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<E: crate::oakui::engine::AppEngine> Render for ProjectPropertiesContent<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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)
// ---------------------------------------------------------------------------
+33
View File
@@ -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<Self>) -> 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<Self>) {
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<ProxyFootageRow> {
+39
View File
@@ -539,6 +539,10 @@ pub struct MockEngine {
proxy_custom: HashMap<u64, crate::oakui::engine::ProxyParamsUi>,
/// 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<Self>) -> 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<Self>) {
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<crate::oakui::engine::ProxyFootageRow> {
// Every explorer footage entry gets a row; audio-only entries keep
// `can_generate` off (the proxy pipeline is video-only).
+122 -8
View File
@@ -444,6 +444,10 @@ struct ThumbnailState {
pending: HashSet<u64>,
}
/// 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<PathBuf> {
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<Self>) -> 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<Self>) {
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<super::engine::ProxyFootageRow> {
let Some(project) = self.project.as_ref() else {
return Vec::new();
+33
View File
@@ -307,8 +307,27 @@ pub fn save(project: &Project) -> crate::error::Result<String> {
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::<i32>().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);
+12 -4
View File
@@ -412,10 +412,18 @@ pub fn default_config() -> Option<std::sync::Arc<SafeConfig>> {
/// 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}")))?,
};