Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs

649 lines
24 KiB
Rust

//! Preferences dialog UI
//!
//! Provides a user interface for configuring application preferences,
//! including a Keyboard Shortcuts tab with click-to-rebind support.
use std::collections::HashMap;
use eframe::egui;
use crate::config::AppConfig;
use crate::keymap::{self, AppAction, KeymapManager};
use crate::menu::{MenuSystem, Shortcut, ShortcutKey};
use crate::theme::{Theme, ThemeMode};
/// Which tab is selected in the preferences dialog
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PreferencesTab {
General,
Shortcuts,
}
/// Preferences dialog state
pub struct PreferencesDialog {
/// Is the dialog open?
pub open: bool,
/// Currently selected tab
tab: PreferencesTab,
/// Working copy of preferences (allows cancel to discard changes)
working_prefs: PreferencesState,
/// Original audio buffer size (to detect changes that need restart)
original_buffer_size: u32,
/// Error message (if validation fails)
error_message: Option<String>,
// --- Shortcuts tab state ---
/// Working copy of keybindings (for live editing before save)
working_keybindings: HashMap<AppAction, Option<Shortcut>>,
/// Which action is currently being rebound (waiting for key press)
rebinding: Option<AppAction>,
/// Search/filter text for shortcuts list
shortcut_filter: String,
}
/// Editable preferences state (working copy)
#[derive(Debug, Clone)]
struct PreferencesState {
bpm: u32,
framerate: u32,
file_width: u32,
file_height: u32,
scroll_speed: f64,
audio_buffer_size: u32,
reopen_last_session: bool,
restore_layout_from_file: bool,
debug: bool,
waveform_stereo: bool,
theme_mode: ThemeMode,
}
impl From<(&AppConfig, &Theme)> for PreferencesState {
fn from((config, theme): (&AppConfig, &Theme)) -> Self {
Self {
bpm: config.bpm,
framerate: config.framerate,
file_width: config.file_width,
file_height: config.file_height,
scroll_speed: config.scroll_speed,
audio_buffer_size: config.audio_buffer_size,
reopen_last_session: config.reopen_last_session,
restore_layout_from_file: config.restore_layout_from_file,
debug: config.debug,
waveform_stereo: config.waveform_stereo,
theme_mode: theme.mode(),
}
}
}
impl Default for PreferencesState {
fn default() -> Self {
Self {
bpm: 120,
framerate: 24,
file_width: 800,
file_height: 600,
scroll_speed: 1.0,
audio_buffer_size: 256,
reopen_last_session: false,
restore_layout_from_file: true,
debug: false,
waveform_stereo: false,
theme_mode: ThemeMode::System,
}
}
}
/// Result returned when preferences are saved
pub struct PreferencesSaveResult {
/// Whether audio buffer size changed (requires restart)
pub buffer_size_changed: bool,
/// New keymap manager if keybindings changed (caller must replace their keymap and call apply_keybindings)
pub new_keymap: Option<KeymapManager>,
}
impl Default for PreferencesDialog {
fn default() -> Self {
Self {
open: false,
tab: PreferencesTab::General,
working_prefs: PreferencesState::default(),
original_buffer_size: 256,
error_message: None,
working_keybindings: HashMap::new(),
rebinding: None,
shortcut_filter: String::new(),
}
}
}
impl PreferencesDialog {
/// Open the dialog with current config and theme
pub fn open(&mut self, config: &AppConfig, theme: &Theme) {
self.open = true;
self.working_prefs = PreferencesState::from((config, theme));
self.original_buffer_size = config.audio_buffer_size;
self.error_message = None;
self.working_keybindings = config.keybindings.effective_bindings();
self.rebinding = None;
self.shortcut_filter.clear();
}
/// Close the dialog
pub fn close(&mut self) {
self.open = false;
self.error_message = None;
self.rebinding = None;
}
/// Render the preferences dialog
///
/// Returns Some(PreferencesSaveResult) if user clicked Save, None otherwise.
pub fn render(
&mut self,
ctx: &egui::Context,
config: &mut AppConfig,
theme: &mut Theme,
) -> Option<PreferencesSaveResult> {
if !self.open {
return None;
}
let mut should_save = false;
let mut should_cancel = false;
let mut open = self.open;
egui::Window::new("Preferences")
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.show(ctx, |ui| {
ui.set_width(550.0);
// Error message
if let Some(error) = &self.error_message {
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error);
ui.add_space(8.0);
}
// Tab bar
ui.horizontal(|ui| {
ui.selectable_value(&mut self.tab, PreferencesTab::General, "General");
ui.selectable_value(&mut self.tab, PreferencesTab::Shortcuts, "Keyboard Shortcuts");
});
ui.separator();
// Tab content
match self.tab {
PreferencesTab::General => {
egui::ScrollArea::vertical()
.max_height(400.0)
.show(ui, |ui| {
self.render_general_section(ui);
ui.add_space(8.0);
self.render_audio_section(ui);
ui.add_space(8.0);
self.render_appearance_section(ui);
ui.add_space(8.0);
self.render_startup_section(ui);
ui.add_space(8.0);
self.render_advanced_section(ui);
});
}
PreferencesTab::Shortcuts => {
self.render_shortcuts_tab(ui);
}
}
ui.add_space(16.0);
// Buttons
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
should_cancel = true;
}
if ui.button("Reset to Defaults").clicked() {
self.reset_to_defaults();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Save").clicked() {
should_save = true;
}
});
});
});
// Update open state
self.open = open;
if should_cancel {
self.close();
return None;
}
if should_save {
return self.handle_save(config, theme);
}
None
}
fn render_shortcuts_tab(&mut self, ui: &mut egui::Ui) {
// Capture key events for rebinding BEFORE rendering the rest
if let Some(rebind_action) = self.rebinding {
// Intercept key presses for rebinding
let captured = ui.input(|i| {
for event in &i.events {
if let egui::Event::Key { key, pressed: true, modifiers, .. } = event {
// Escape clears the binding
if *key == egui::Key::Escape && !modifiers.ctrl && !modifiers.shift && !modifiers.alt {
return Some(None); // Clear binding
}
// Any other key: set as new binding
if let Some(shortcut_key) = ShortcutKey::from_egui_key(*key) {
return Some(Some(Shortcut::new(
shortcut_key,
modifiers.ctrl || modifiers.command,
modifiers.shift,
modifiers.alt,
)));
}
}
}
None
});
if let Some(new_binding) = captured {
self.working_keybindings.insert(rebind_action, new_binding);
self.rebinding = None;
}
}
// Search/filter
ui.horizontal(|ui| {
ui.label("Filter:");
ui.text_edit_singleline(&mut self.shortcut_filter);
});
ui.add_space(4.0);
// Conflict detection
let conflicts = self.detect_conflicts();
if !conflicts.is_empty() {
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(255, 180, 50),
format!("{} conflict(s) detected", conflicts.len()));
});
ui.add_space(4.0);
}
// Scrollable list of actions grouped by category
egui::ScrollArea::vertical()
.max_height(350.0)
.show(ui, |ui| {
let filter_lower = self.shortcut_filter.to_lowercase();
// Collect categories in display order
let category_order = [
"File", "Edit", "Modify", "Layer", "Timeline", "View",
"Help", "Window", "Tools", "Global", "Pane",
];
for category in &category_order {
let actions_in_category: Vec<AppAction> = AppAction::all().iter()
.filter(|a| a.category() == *category)
.filter(|a| {
if filter_lower.is_empty() {
true
} else {
a.display_name().to_lowercase().contains(&filter_lower)
|| a.category().to_lowercase().contains(&filter_lower)
}
})
.copied()
.collect();
if actions_in_category.is_empty() {
continue;
}
egui::CollapsingHeader::new(*category)
.default_open(!filter_lower.is_empty() || *category == "Tools" || *category == "Global")
.show(ui, |ui| {
for action in &actions_in_category {
self.render_shortcut_row(ui, *action, &conflicts);
}
});
}
});
}
fn render_shortcut_row(
&mut self,
ui: &mut egui::Ui,
action: AppAction,
conflicts: &HashMap<Shortcut, Vec<AppAction>>,
) {
let binding = self.working_keybindings.get(&action).copied().flatten();
let is_rebinding = self.rebinding == Some(action);
let has_conflict = binding
.as_ref()
.and_then(|s| conflicts.get(s))
.map(|actions| actions.len() > 1)
.unwrap_or(false);
ui.horizontal(|ui| {
// Action name (fixed width)
ui.add_sized([200.0, 20.0], egui::Label::new(action.display_name()));
// Binding button (click to rebind)
let button_text = if is_rebinding {
"Press a key...".to_string()
} else if let Some(s) = &binding {
MenuSystem::format_shortcut(s)
} else {
"None".to_string()
};
let button_color = if is_rebinding {
egui::Color32::from_rgb(100, 150, 255)
} else if has_conflict {
egui::Color32::from_rgb(255, 180, 50)
} else {
ui.visuals().widgets.inactive.text_color()
};
let response = ui.add_sized(
[140.0, 20.0],
egui::Button::new(egui::RichText::new(&button_text).color(button_color)),
);
if response.clicked() && !is_rebinding {
self.rebinding = Some(action);
}
// Show conflict tooltip
if has_conflict {
if let Some(s) = &binding {
if let Some(conflicting) = conflicts.get(s) {
let others: Vec<&str> = conflicting.iter()
.filter(|a| **a != action)
.map(|a| a.display_name())
.collect();
response.on_hover_text(format!("Conflicts with: {}", others.join(", ")));
}
}
}
// Clear button
if ui.small_button("x").clicked() {
self.working_keybindings.insert(action, None);
if self.rebinding == Some(action) {
self.rebinding = None;
}
}
});
}
/// Detect all shortcut conflicts (shortcut -> list of actions sharing it).
/// Only actions within the same conflict scope can conflict — pane-local actions
/// are isolated to their pane and never conflict with each other or global actions.
fn detect_conflicts(&self) -> HashMap<Shortcut, Vec<AppAction>> {
// Group by (shortcut, conflict_scope)
let mut by_scope: HashMap<(&str, Shortcut), Vec<AppAction>> = HashMap::new();
for (&action, &shortcut) in &self.working_keybindings {
if let Some(s) = shortcut {
by_scope.entry((action.conflict_scope(), s)).or_default().push(action);
}
}
// Flatten into shortcut -> conflicting actions (only where there are actual conflicts)
let mut result: HashMap<Shortcut, Vec<AppAction>> = HashMap::new();
for ((_, shortcut), actions) in by_scope {
if actions.len() > 1 {
result.entry(shortcut).or_default().extend(actions);
}
}
result
}
fn render_general_section(&mut self, ui: &mut egui::Ui) {
egui::CollapsingHeader::new("General")
.default_open(true)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Default BPM:");
ui.add(
egui::DragValue::new(&mut self.working_prefs.bpm)
.range(20..=300)
.speed(1.0),
);
});
ui.horizontal(|ui| {
ui.label("Default Framerate:");
ui.add(
egui::DragValue::new(&mut self.working_prefs.framerate)
.range(1..=120)
.speed(1.0)
.suffix(" fps"),
);
});
ui.horizontal(|ui| {
ui.label("Default File Width:");
ui.add(
egui::DragValue::new(&mut self.working_prefs.file_width)
.range(100..=10000)
.speed(10.0)
.suffix(" px"),
);
});
ui.horizontal(|ui| {
ui.label("Default File Height:");
ui.add(
egui::DragValue::new(&mut self.working_prefs.file_height)
.range(100..=10000)
.speed(10.0)
.suffix(" px"),
);
});
ui.horizontal(|ui| {
ui.label("Scroll Speed:");
ui.add(
egui::DragValue::new(&mut self.working_prefs.scroll_speed)
.range(0.1..=10.0)
.speed(0.1),
);
});
});
}
fn render_audio_section(&mut self, ui: &mut egui::Ui) {
egui::CollapsingHeader::new("Audio")
.default_open(true)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Audio Buffer Size:");
egui::ComboBox::from_id_salt("audio_buffer_size")
.selected_text(format!("{} samples", self.working_prefs.audio_buffer_size))
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.working_prefs.audio_buffer_size,
128,
"128 samples (~3ms - Low latency)",
);
ui.selectable_value(
&mut self.working_prefs.audio_buffer_size,
256,
"256 samples (~6ms - Balanced)",
);
ui.selectable_value(
&mut self.working_prefs.audio_buffer_size,
512,
"512 samples (~12ms - Stable)",
);
ui.selectable_value(
&mut self.working_prefs.audio_buffer_size,
1024,
"1024 samples (~23ms - Very stable)",
);
ui.selectable_value(
&mut self.working_prefs.audio_buffer_size,
2048,
"2048 samples (~46ms - Low-end systems)",
);
ui.selectable_value(
&mut self.working_prefs.audio_buffer_size,
4096,
"4096 samples (~93ms - Very low-end systems)",
);
});
});
ui.label("Requires app restart to take effect");
});
}
fn render_appearance_section(&mut self, ui: &mut egui::Ui) {
egui::CollapsingHeader::new("Appearance")
.default_open(true)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Theme:");
egui::ComboBox::from_id_salt("theme_mode")
.selected_text(format!("{:?}", self.working_prefs.theme_mode))
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.working_prefs.theme_mode,
ThemeMode::Light,
"Light",
);
ui.selectable_value(
&mut self.working_prefs.theme_mode,
ThemeMode::Dark,
"Dark",
);
ui.selectable_value(
&mut self.working_prefs.theme_mode,
ThemeMode::System,
"System",
);
});
});
});
}
fn render_startup_section(&mut self, ui: &mut egui::Ui) {
egui::CollapsingHeader::new("Startup")
.default_open(false)
.show(ui, |ui| {
ui.checkbox(
&mut self.working_prefs.reopen_last_session,
"Reopen last session on startup",
);
ui.checkbox(
&mut self.working_prefs.restore_layout_from_file,
"Restore layout when opening files",
);
});
}
fn render_advanced_section(&mut self, ui: &mut egui::Ui) {
egui::CollapsingHeader::new("Advanced")
.default_open(false)
.show(ui, |ui| {
ui.checkbox(&mut self.working_prefs.debug, "Enable debug mode");
ui.checkbox(
&mut self.working_prefs.waveform_stereo,
"Show waveforms as stacked stereo",
);
});
}
fn reset_to_defaults(&mut self) {
self.working_prefs = PreferencesState::default();
self.working_keybindings = keymap::all_defaults();
self.rebinding = None;
self.error_message = None;
}
fn handle_save(
&mut self,
config: &mut AppConfig,
theme: &mut Theme,
) -> Option<PreferencesSaveResult> {
// Create temp config for validation
let mut temp_config = config.clone();
temp_config.bpm = self.working_prefs.bpm;
temp_config.framerate = self.working_prefs.framerate;
temp_config.file_width = self.working_prefs.file_width;
temp_config.file_height = self.working_prefs.file_height;
temp_config.scroll_speed = self.working_prefs.scroll_speed;
temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size;
temp_config.reopen_last_session = self.working_prefs.reopen_last_session;
temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
temp_config.debug = self.working_prefs.debug;
temp_config.waveform_stereo = self.working_prefs.waveform_stereo;
temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
// Validate
if let Err(err) = temp_config.validate() {
self.error_message = Some(err);
return None;
}
// Check if buffer size changed
let buffer_size_changed = self.working_prefs.audio_buffer_size != self.original_buffer_size;
// Build new keymap from working keybindings to compute sparse overrides
let defaults = keymap::all_defaults();
let mut overrides = HashMap::new();
for (&action, &shortcut) in &self.working_keybindings {
let default = defaults.get(&action).copied().flatten();
if shortcut != default {
overrides.insert(action, shortcut);
}
}
let keybinding_config = keymap::KeybindingConfig { overrides };
let new_keymap = KeymapManager::new(&keybinding_config);
// Apply changes to config
config.bpm = self.working_prefs.bpm;
config.framerate = self.working_prefs.framerate;
config.file_width = self.working_prefs.file_width;
config.file_height = self.working_prefs.file_height;
config.scroll_speed = self.working_prefs.scroll_speed;
config.audio_buffer_size = self.working_prefs.audio_buffer_size;
config.reopen_last_session = self.working_prefs.reopen_last_session;
config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
config.debug = self.working_prefs.debug;
config.waveform_stereo = self.working_prefs.waveform_stereo;
config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
config.keybindings = keybinding_config;
// Apply theme immediately
theme.set_mode(self.working_prefs.theme_mode);
// Save to disk
config.save();
// Close dialog
self.close();
Some(PreferencesSaveResult {
buffer_size_changed,
new_keymap: Some(new_keymap),
})
}
}