feat(app): P5 — async full-res render, full preferences, shortcut map
- viewers show the 480px proxy immediately and a background thread fills the sequence-resolution frame (per-monitor in-flight job, generation-based staleness, playback skips full-res) - preferences dialog complete: cache dir (now consumed by default_disk_cache_path), proxy policy/divider, snapshot interval (write-through era autosave), default transition length, audio in/out devices (new facade device-enumeration exports; audio init from config — playback was never creating the audio instance), language/theme/renderer backend, all persisted via config - shortcut map (src/shortcuts.rs): space/J/K/L, I/O, S split, A/^A, ⌘Z/⌘⇧Z, ⌘N/⌘O/⌘S/⌘E/⌘Q, frame step, Home, track zoom; dispatch shares the menu action path and stays silent over modals - screenshots: preferences dialog zh/en captured and reviewed
This commit is contained in:
+403
-14
@@ -14,44 +14,98 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The content views of the app's modal dialogs: preferences (renderer
|
||||
//! backend + language) and export (format + output path).
|
||||
//! The content views of the app's modal dialogs: preferences (the settings
|
||||
//! panel mirroring the C++ tabbed preferences: general / rendering / cache
|
||||
//! / proxy / project / audio) and export (format + output path).
|
||||
//!
|
||||
//! Each view owns its widgets and emits nothing itself — the host
|
||||
//! (`crate::app::OakApp`) reads the state (format / path) when a dialog
|
||||
//! button is clicked, and the preferences view writes its choices straight
|
||||
//! through the config C ABI on selection.
|
||||
//! through the config C ABI on selection. Theme/language changes
|
||||
//! additionally emit a [`PreferencesEvent`] so the host can re-apply the
|
||||
//! shell chrome immediately.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{div, App, Context, Entity, Render, SharedString, Window};
|
||||
use gpui::{div, px, App, Context, Entity, Render, SharedString, Window};
|
||||
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState};
|
||||
use gpui_widgets::combo_box::{ComboBox, ComboBoxEvent, ComboBoxOption};
|
||||
use gpui_widgets::slider::SliderModel;
|
||||
use gpui_widgets::spinbox::{SpinBox, SpinBoxEvent};
|
||||
use gpui_widgets::value::{SliderValue, ValueKind};
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::real::{
|
||||
config_get_string, config_set_string, encoding_formats, renderer_backends,
|
||||
CONFIG_KEY_RENDERER_BACKEND, EXPORT_FORMAT_MP4,
|
||||
audio_input_device, audio_input_devices, audio_output_device, audio_output_devices,
|
||||
config_get_bool, config_get_int, config_get_string, config_set_bool, config_set_int,
|
||||
config_set_string, encoding_formats, proxy_dividers, renderer_backends,
|
||||
set_audio_input_device, set_audio_output_device, set_theme_dark, theme_is_dark,
|
||||
CONFIG_KEY_DEFAULT_TRANSITION_SEC, CONFIG_KEY_DISK_CACHE_PATH, CONFIG_KEY_PROXY_DIVIDER,
|
||||
CONFIG_KEY_RENDERER_BACKEND, CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, CONFIG_KEY_USE_PROXY,
|
||||
DEFAULT_SNAPSHOT_INTERVAL_SEC, DEFAULT_TRANSITION_SEC, EXPORT_FORMAT_MP4,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preferences
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The preferences dialog content: the renderer backend and the language
|
||||
/// dropdowns. Both write through the config C ABI on selection, so the
|
||||
/// choices survive restarts.
|
||||
/// A request the preferences dialog emits for the host shell (the settings
|
||||
/// themselves are written through the config C ABI directly; these need
|
||||
/// shell chrome — the menu bar / theme — to re-render).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PreferencesEvent {
|
||||
/// The theme dropdown changed (the payload is the new dark flag).
|
||||
ThemeChanged(bool),
|
||||
/// The language dropdown changed (already applied to the i18n global).
|
||||
LanguageChanged,
|
||||
}
|
||||
|
||||
impl gpui::EventEmitter<PreferencesEvent> for PreferencesContent {}
|
||||
|
||||
/// The preferences dialog content, mirroring the C++ tabbed preferences as
|
||||
/// one grouped panel:
|
||||
///
|
||||
/// * **常规 General** — language, theme.
|
||||
/// * **渲染 Rendering** — the renderer backend.
|
||||
/// * **缓存 Cache** — the disk cache directory (`DiskCachePath`).
|
||||
/// * **代理 Proxy** — use proxy media (`UseProxyMedia`), proxy resolution
|
||||
/// divider (`ProxyDivider`).
|
||||
/// * **项目 Project** — the snapshot interval (`Storage/SnapshotIntervalSec`,
|
||||
/// the write-through era's auto-save interval) and the default transition
|
||||
/// length (`DefaultTransitionLength`).
|
||||
/// * **音频 Audio** — the output / input devices (`AudioOutput` /
|
||||
/// `AudioInput`, applied live through the audio facade).
|
||||
///
|
||||
/// Every row writes through the config C ABI on selection, so the choices
|
||||
/// survive restarts (the app loads the config at startup and saves it on
|
||||
/// exit).
|
||||
pub struct PreferencesContent {
|
||||
backend: Entity<ComboBox>,
|
||||
language: Entity<ComboBox>,
|
||||
theme: Entity<ComboBox>,
|
||||
cache_dir: Entity<PathField>,
|
||||
use_proxy: Entity<CheckBox>,
|
||||
proxy_divider: Entity<ComboBox>,
|
||||
snapshot_interval: Entity<SpinBox>,
|
||||
transition_length: Entity<SpinBox>,
|
||||
audio_output: Entity<ComboBox>,
|
||||
audio_input: Entity<ComboBox>,
|
||||
/// The backend options, in display order.
|
||||
backends: Vec<&'static str>,
|
||||
/// The proxy divider options, in display order (1 = full resolution).
|
||||
dividers: Vec<i64>,
|
||||
/// The output device names (dropdown order; index 0 is system default).
|
||||
output_devices: Vec<String>,
|
||||
/// The input device names (dropdown order; index 0 is system default).
|
||||
input_devices: Vec<String>,
|
||||
}
|
||||
|
||||
impl PreferencesContent {
|
||||
/// Builds the content: reads the current config values and seeds the
|
||||
/// dropdowns.
|
||||
/// Builds the content: reads the current config values and seeds every
|
||||
/// widget.
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// --- 渲染 Rendering: the renderer backend --------------------------
|
||||
let backends = renderer_backends();
|
||||
let current_backend = config_get_string(CONFIG_KEY_RENDERER_BACKEND);
|
||||
let backend_selected = backends
|
||||
@@ -81,6 +135,7 @@ impl PreferencesContent {
|
||||
combo.set_selected(Some(backend_selected), cx)
|
||||
});
|
||||
|
||||
// --- 常规 General: language + theme --------------------------------
|
||||
let language_options = vec![
|
||||
ComboBoxOption::new(0, "English (en-US)"),
|
||||
ComboBoxOption::new(1, "简体中文 (zh-CN)"),
|
||||
@@ -100,20 +155,272 @@ impl PreferencesContent {
|
||||
_ => crate::i18n::Language::EnUs,
|
||||
};
|
||||
crate::i18n::set_language(language);
|
||||
cx.emit(PreferencesEvent::LanguageChanged);
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
language.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(language_selected), cx)
|
||||
});
|
||||
|
||||
let theme_options = vec![
|
||||
ComboBoxOption::new(0, i18n::tr("preferences.theme.dark")),
|
||||
ComboBoxOption::new(1, i18n::tr("preferences.theme.light")),
|
||||
];
|
||||
let theme = cx.new(|cx| ComboBox::new(3, theme_options, window, cx));
|
||||
cx.subscribe(&theme, |_this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
let dark = *value == 0;
|
||||
set_theme_dark(dark);
|
||||
cx.emit(PreferencesEvent::ThemeChanged(dark));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
theme.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(if theme_is_dark() { 0 } else { 1 }), cx)
|
||||
});
|
||||
|
||||
// --- 缓存 Cache: the disk cache directory --------------------------
|
||||
let cache_dir = cx.new(|cx| {
|
||||
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
|
||||
PathField { editor }
|
||||
});
|
||||
let configured_cache = config_get_string(CONFIG_KEY_DISK_CACHE_PATH);
|
||||
cache_dir.update(cx, |field, cx| field.set_path(configured_cache, cx));
|
||||
|
||||
// --- 代理 Proxy -----------------------------------------------------
|
||||
let use_proxy = cx.new(|cx| {
|
||||
CheckBox::new(
|
||||
7,
|
||||
if config_get_bool(CONFIG_KEY_USE_PROXY, true) {
|
||||
CheckState::Checked
|
||||
} else {
|
||||
CheckState::Unchecked
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.with_label(i18n::tr("preferences.proxy.enable"))
|
||||
});
|
||||
cx.subscribe(&use_proxy, |_this, check, event: &CheckBoxEvent, cx| {
|
||||
if let CheckBoxEvent::Toggled { state, .. } = event {
|
||||
let enabled = *state == CheckState::Checked;
|
||||
config_set_bool(CONFIG_KEY_USE_PROXY, enabled);
|
||||
check.update(cx, |check, cx| check.set_state(*state, cx));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
let dividers = proxy_dividers();
|
||||
let divider_options = dividers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
if *d <= 1 {
|
||||
ComboBoxOption::new(i, i18n::tr("preferences.proxy.full"))
|
||||
} else {
|
||||
ComboBoxOption::new(i, format!("1/{d}"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let proxy_divider = cx.new(|cx| ComboBox::new(4, divider_options, window, cx));
|
||||
cx.subscribe(&proxy_divider, |this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
if let Some(divider) = this.dividers.get(*value) {
|
||||
config_set_int(CONFIG_KEY_PROXY_DIVIDER, *divider);
|
||||
}
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
let current_divider = config_get_int(CONFIG_KEY_PROXY_DIVIDER, 1);
|
||||
let divider_selected = dividers
|
||||
.iter()
|
||||
.position(|d| *d == current_divider)
|
||||
.unwrap_or(0);
|
||||
proxy_divider.update(cx, |combo, cx| {
|
||||
combo.set_selected(Some(divider_selected), cx)
|
||||
});
|
||||
|
||||
// --- 项目 Project: snapshot interval + default transition ----------
|
||||
let snapshot_interval = cx.new(|cx| {
|
||||
let current =
|
||||
config_get_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, DEFAULT_SNAPSHOT_INTERVAL_SEC);
|
||||
SpinBox::new(
|
||||
8,
|
||||
SliderModel::new(ValueKind::Integer, 0.0, 86400.0, 10.0, current as f64),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.subscribe(
|
||||
&snapshot_interval,
|
||||
|_this, _spin, event: &SpinBoxEvent, cx| {
|
||||
let value = match event {
|
||||
SpinBoxEvent::ValueChanged { value, .. }
|
||||
| SpinBoxEvent::EditCommitted { value, .. } => value.to_f64() as i64,
|
||||
_ => return,
|
||||
};
|
||||
config_set_int(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, value);
|
||||
let _ = cx;
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
let transition_length = cx.new(|cx| {
|
||||
let current = config_get_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC)
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| *v >= 0.0)
|
||||
.unwrap_or_else(|| DEFAULT_TRANSITION_SEC.parse().unwrap());
|
||||
SpinBox::new(
|
||||
9,
|
||||
SliderModel::new(ValueKind::Float, 0.0, 60.0, 0.5, current),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.subscribe(
|
||||
&transition_length,
|
||||
|_this, _spin, event: &SpinBoxEvent, cx| {
|
||||
let value = match event {
|
||||
SpinBoxEvent::ValueChanged { value, .. }
|
||||
| SpinBoxEvent::EditCommitted { value, .. } => value.to_f64(),
|
||||
_ => return,
|
||||
};
|
||||
config_set_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC, &format!("{value}"));
|
||||
let _ = cx;
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
// --- 音频 Audio: output / input devices -----------------------------
|
||||
// The enumeration goes through the facade even on the mock engine;
|
||||
// the config choice applies the moment the device dropdown changes.
|
||||
let (audio_output, output_devices) =
|
||||
device_combo(5, true, window, cx);
|
||||
let (audio_input, input_devices) =
|
||||
device_combo(6, false, window, cx);
|
||||
cx.subscribe(&audio_output, |this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
// Option 0 is the system default; the devices start at 1.
|
||||
let name = value
|
||||
.checked_sub(1)
|
||||
.and_then(|i| this.output_devices.get(i))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
set_audio_output_device(&name);
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(&audio_input, |this, _combo, event: &ComboBoxEvent, cx| {
|
||||
if let ComboBoxEvent::Selected { value, .. } = event {
|
||||
let name = value
|
||||
.checked_sub(1)
|
||||
.and_then(|i| this.input_devices.get(i))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
set_audio_input_device(&name);
|
||||
}
|
||||
let _ = cx;
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
backend,
|
||||
language,
|
||||
theme,
|
||||
cache_dir,
|
||||
use_proxy,
|
||||
proxy_divider,
|
||||
snapshot_interval,
|
||||
transition_length,
|
||||
audio_output,
|
||||
audio_input,
|
||||
backends,
|
||||
dividers,
|
||||
output_devices,
|
||||
input_devices,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cache directory currently entered.
|
||||
pub fn cache_dir(&self, cx: &App) -> SharedString {
|
||||
self.cache_dir.read(cx).path(cx)
|
||||
}
|
||||
|
||||
/// Commits the cache directory field to the config (called by the host
|
||||
/// when the dialog closes, so a typed-but-unbrowsed path still lands).
|
||||
pub fn commit_cache_dir(&self, cx: &App) {
|
||||
let path = self.cache_dir(cx).trim().to_string();
|
||||
config_set_string(CONFIG_KEY_DISK_CACHE_PATH, &path);
|
||||
}
|
||||
|
||||
/// Opens the platform directory picker and lands the choice in the cache
|
||||
/// directory field (committed with the field, on dialog close).
|
||||
fn browse_cache_dir(&mut self, cx: &mut Context<Self>) {
|
||||
let receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some(i18n::tr("preferences.cache.browse").into()),
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let Ok(Ok(Some(paths))) = receiver.await else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = paths.first() else {
|
||||
return;
|
||||
};
|
||||
this.update(cx, |this, cx| {
|
||||
this.cache_dir.update(cx, |field, cx| {
|
||||
field.set_path(path.to_string_lossy().into_owned(), cx)
|
||||
});
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a device dropdown for the output (`output = true`) or input side:
|
||||
/// option 0 is the system default, the rest are the enumerated devices, and
|
||||
/// the current config value (validated against the enumeration) is
|
||||
/// preselected. Returns the combo and the option→device-name list.
|
||||
fn device_combo(
|
||||
control: usize,
|
||||
output: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<PreferencesContent>,
|
||||
) -> (Entity<ComboBox>, Vec<String>) {
|
||||
let devices = if output {
|
||||
audio_output_devices()
|
||||
} else {
|
||||
audio_input_devices()
|
||||
};
|
||||
let current = if output {
|
||||
audio_output_device()
|
||||
} else {
|
||||
audio_input_device()
|
||||
};
|
||||
let mut options = vec![ComboBoxOption::new(0, i18n::tr("preferences.audio.default"))];
|
||||
for (i, name) in devices.iter().enumerate() {
|
||||
options.push(ComboBoxOption::new(i + 1, name.clone()));
|
||||
}
|
||||
let selected = devices
|
||||
.iter()
|
||||
.position(|n| *n == current)
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0);
|
||||
let placeholder = if output {
|
||||
i18n::tr("preferences.audio.output.placeholder")
|
||||
} else {
|
||||
i18n::tr("preferences.audio.input.placeholder")
|
||||
};
|
||||
let combo = cx.new(|cx| ComboBox::new(control, options, window, cx).with_placeholder(placeholder));
|
||||
combo.update(cx, |combo, cx| combo.set_selected(Some(selected), cx));
|
||||
(combo, devices)
|
||||
}
|
||||
|
||||
/// A display label for a renderer backend id.
|
||||
@@ -142,23 +449,104 @@ fn form_row(
|
||||
.child(widget)
|
||||
}
|
||||
|
||||
/// A group header separating the preference sections.
|
||||
fn section_header(colors: &gpui::colors::Colors, label: SharedString) -> gpui::Div {
|
||||
div()
|
||||
.pt_2()
|
||||
.text_color(colors.disabled)
|
||||
.text_xs()
|
||||
.child(label)
|
||||
}
|
||||
|
||||
impl Render for PreferencesContent {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
// The full settings list is taller than a 900px window at the design
|
||||
// density, so the content scrolls inside the modal card.
|
||||
div()
|
||||
.id("preferences-content")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.max_h(px(720.0))
|
||||
.overflow_y_scroll()
|
||||
// 常规 General
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.general").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.language").into(),
|
||||
self.language.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.theme").into(),
|
||||
self.theme.clone(),
|
||||
))
|
||||
// 渲染 Rendering
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.render").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.backend").into(),
|
||||
self.backend.clone(),
|
||||
))
|
||||
// 缓存 Cache
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.cache").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.language").into(),
|
||||
self.language.clone(),
|
||||
i18n::tr("preferences.cache.dir").into(),
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(div().flex_1().child(self.cache_dir.clone()))
|
||||
.child(
|
||||
div()
|
||||
.id("preferences-cache-browse")
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded_md()
|
||||
.bg(colors.background)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.text)
|
||||
.cursor_pointer()
|
||||
.child(i18n::tr("preferences.cache.browse"))
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.browse_cache_dir(cx);
|
||||
})),
|
||||
),
|
||||
))
|
||||
// 代理 Proxy
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.proxy").into()))
|
||||
.child(self.use_proxy.clone())
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.proxy.resolution").into(),
|
||||
self.proxy_divider.clone(),
|
||||
))
|
||||
// 项目 Project
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.project").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.snapshot.interval").into(),
|
||||
self.snapshot_interval.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.transition.default").into(),
|
||||
self.transition_length.clone(),
|
||||
))
|
||||
// 音频 Audio
|
||||
.child(section_header(&colors, i18n::tr("preferences.section.audio").into()))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.audio.output").into(),
|
||||
self.audio_output.clone(),
|
||||
))
|
||||
.child(form_row(
|
||||
&colors,
|
||||
i18n::tr("preferences.audio.input").into(),
|
||||
self.audio_input.clone(),
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
@@ -169,6 +557,7 @@ impl Render for PreferencesContent {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user