simple_editor\ui/
settings_ui.rs

1//! Settings window (egui::Window, closable). Tabs:
2//!  * General: theme (system/dark/light), decoder (auto/mf/ffmpeg), ffmpeg folder (text + Browse…, shows
3//!    whether ffmpeg/ffprobe were found), preview max width, snapping, confirm overwrite,
4//!    "Edit with Simple Editor" context menu checkbox (install/uninstall via crate::contextmenu).
5//!  * Performance: GPU preview toggle + the detected OpenGL renderer, preview render quality (%),
6//!    movie mode (pre-rendered playback) and the stock image the effect thumbnails are rendered from.
7//!  * Capture: screen recording (fps, bitrate, microphone, desktop audio, cursor, record-on-blur, output
8//!    folder) and voiceover (input device, channels). Device lists come from `engine::capture`.
9//!  * Export: encoder combo (auto + detected encoders filtered to h264/hevc/vp9/av1 families), CRF, preset.
10//!  * Hotkeys: table of every Action (label, current binding, "Rebind" → waits for the next key press with
11//!    modifiers (Escape cancels, Backspace/Delete unbinds), "Reset"), plus "Reset all". Conflicts are
12//!    resolved by unbinding the other action (Hotkeys::set). Note the fixed mouse modifiers.
13//! Returns true when settings changed (caller saves + re-applies theme/backend/hotkeys).
14
15use crate::hotkeys::{Action, Hotkeys};
16use crate::settings::Settings;
17use crate::theme::Palette;
18use crate::ui::tools::Glyph;
19use crate::ui::{combo, encoder_options, ENCODER_PRESETS};
20use eframe::egui::{self, Event, Key, KeyboardShortcut, Modifiers};
21
22#[derive(Default)]
23pub struct SettingsUi {
24    pub open: bool,
25    pub tab: usize,
26    pub rebinding: Option<Action>,
27    /// Cached ffmpeg / context-menu status (filesystem + registry lookups are not per-frame).
28    status: Option<Status>,
29    /// Last rebind note ("Unbound X") shown under the hotkey table.
30    note: String,
31    /// MCP port while it is being dragged / typed; committed to the settings when the gesture ends
32    /// (the app restarts the server on every value it sees, and a busy one in between switches it off).
33    port_edit: Option<u16>,
34}
35
36struct Status {
37    /// Inputs the status was computed from; recomputed at the start of a frame when they differ
38    /// (the app applies ffmpeg_dir / context_menu changes after `show` returns).
39    ffmpeg_dir: String,
40    ytdlp_dir: String,
41    context_menu: bool,
42    ffmpeg: String,
43    ytdlp: String,
44    ctxmenu: &'static str,
45}
46
47impl Status {
48    fn compute(s: &Settings) -> Self {
49        let exe = |p: Option<std::path::PathBuf>| {
50            p.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "not found".into())
51        };
52        Self {
53            ffmpeg_dir: s.ffmpeg_dir.clone(),
54            ytdlp_dir: s.ytdlp_dir.clone(),
55            context_menu: s.context_menu,
56            ffmpeg: format!(
57                "ffmpeg: {}\nffprobe: {}",
58                exe(crate::media::ffpipe::ffmpeg_exe()),
59                exe(crate::media::ffpipe::ffprobe_exe())
60            ),
61            ytdlp: match crate::media::ytdlp::exe() {
62                Some(p) => format!("yt-dlp: {}", p.display()),
63                None => "yt-dlp: not found — the Library's \"Import URL…\" button is hidden".into(),
64            },
65            ctxmenu: if crate::contextmenu::is_installed() { "(installed)" } else { "(not installed)" },
66        }
67    }
68}
69
70const THEMES: [(&str, &str); 3] = [("system", "System"), ("dark", "Dark"), ("light", "Light")];
71const DECODERS: [(&str, &str); 3] =
72    [("auto", "Auto (Media Foundation, ffmpeg fallback)"), ("mf", "Media Foundation"), ("ffmpeg", "ffmpeg")];
73const PALETTE_MODES: [(&str, &str); 4] =
74    [("system", "Follow Windows"), ("light", "Light"), ("dark", "Dark"), ("custom", "Custom")];
75
76#[allow(clippy::too_many_arguments)]
77pub fn show(
78    ctx: &egui::Context,
79    state: &mut SettingsUi,
80    settings: &mut Settings,
81    hotkeys: &mut Hotkeys,
82    encoders: &[String],
83    _palette: &Palette,
84    mcp_status: &str,
85    // OpenGL renderer string ("no OpenGL context" when eframe runs without one)
86    gpu_name: &str,
87    // dshow audio inputs (`engine::capture::audio_devices`); the bool marks loopback/desktop devices
88    audio_inputs: &[(String, bool)],
89) -> bool {
90    let mut changed = false;
91    let stale = state.status.as_ref().is_none_or(|s| {
92        s.ffmpeg_dir != settings.ffmpeg_dir
93            || s.ytdlp_dir != settings.ytdlp_dir
94            || s.context_menu != settings.context_menu
95    });
96    if stale {
97        state.status = Some(Status::compute(settings));
98    }
99    let mut open = state.open;
100    egui::Window::new("Settings").open(&mut open).default_width(520.0).collapsible(false).show(ctx, |ui| {
101        ui.horizontal(|ui| {
102            ui.selectable_value(&mut state.tab, 0, "General");
103            ui.selectable_value(&mut state.tab, 1, "Performance");
104            ui.selectable_value(&mut state.tab, 2, "Capture");
105            ui.selectable_value(&mut state.tab, 3, "Export");
106            ui.selectable_value(&mut state.tab, 4, "Hotkeys");
107            ui.selectable_value(&mut state.tab, 5, "Appearance");
108        });
109        ui.separator();
110        changed = match state.tab {
111            0 => general(ui, state, settings, mcp_status),
112            1 => performance(ui, settings, gpu_name),
113            2 => capture_tab(ui, settings, audio_inputs),
114            3 => export(ui, settings, encoders),
115            4 => hotkeys_tab(ui, state, hotkeys),
116            _ => appearance(ui, settings),
117        };
118    });
119    state.open = open;
120    if !open {
121        state.rebinding = None;
122        state.status = None;
123        state.note.clear();
124        state.port_edit = None;
125    }
126    changed
127}
128
129fn general(ui: &mut egui::Ui, state: &mut SettingsUi, s: &mut Settings, mcp_status: &str) -> bool {
130    let mut changed = false;
131    egui::Grid::new("general").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
132        ui.label("Theme");
133        changed |= combo(ui, "theme", &mut s.theme, &THEMES, Some(260.0));
134        ui.end_row();
135
136        ui.label("Decoder");
137        changed |= combo(ui, "decoder", &mut s.decoder, &DECODERS, Some(260.0));
138        ui.end_row();
139
140        ui.label("ffmpeg folder");
141        ui.horizontal(|ui| {
142            changed |=
143                ui.add(egui::TextEdit::singleline(&mut s.ffmpeg_dir).desired_width(200.0).hint_text("auto")).changed();
144            if ui.button("Browse…").clicked() {
145                if let Some(p) = rfd::FileDialog::new().pick_folder() {
146                    s.ffmpeg_dir = p.to_string_lossy().into_owned();
147                    changed = true;
148                }
149            }
150        });
151        ui.end_row();
152
153        ui.label("");
154        if let Some(st) = &state.status {
155            ui.weak(&st.ffmpeg);
156        }
157        ui.end_row();
158
159        ui.label("yt-dlp folder");
160        ui.horizontal(|ui| {
161            changed |=
162                ui.add(egui::TextEdit::singleline(&mut s.ytdlp_dir).desired_width(200.0).hint_text("auto")).changed();
163            if ui.button("Browse…").clicked() {
164                if let Some(p) = rfd::FileDialog::new().pick_folder() {
165                    s.ytdlp_dir = p.to_string_lossy().into_owned();
166                    changed = true;
167                }
168            }
169        });
170        ui.end_row();
171
172        ui.label("");
173        if let Some(st) = &state.status {
174            ui.weak(&st.ytdlp);
175        }
176        ui.end_row();
177
178        ui.label("Downloads folder");
179        ui.horizontal(|ui| {
180            changed |= ui
181                .add(egui::TextEdit::singleline(&mut s.download_dir).desired_width(200.0).hint_text("Videos"))
182                .changed();
183            if ui.button("Browse…").clicked() {
184                if let Some(p) = rfd::FileDialog::new().pick_folder() {
185                    s.download_dir = p.to_string_lossy().into_owned();
186                    changed = true;
187                }
188            }
189        });
190        ui.end_row();
191
192        ui.label("Preview max width");
193        ui.horizontal(|ui| {
194            changed |= ui.add(egui::DragValue::new(&mut s.preview_max_width).range(320..=3840).speed(8)).changed();
195            ui.weak("px");
196        });
197        ui.end_row();
198    });
199    ui.add_space(6.0);
200    changed |= ui.checkbox(&mut s.snap, "Snapping in the timeline").changed();
201    changed |= ui.checkbox(&mut s.confirm_overwrite, "Confirm before overwriting files").changed();
202    changed |= ui
203        .checkbox(
204            &mut s.lossless_save,
205            "Save (Ctrl+S) uses the instant lossless cut when the project is a plain cut (cuts snap to keyframes)",
206        )
207        .changed();
208    ui.horizontal(|ui| {
209        changed |= ui
210            .checkbox(&mut s.context_menu, "Add 'Edit with Simple Editor' to the right-click menu of video files")
211            .changed();
212        if let Some(st) = &state.status {
213            ui.weak(st.ctxmenu);
214        }
215    });
216    ui.add_space(6.0);
217    ui.separator();
218    // ---- imported fonts ----
219    ui.horizontal(|ui| {
220        ui.label("Fonts");
221        if ui.button("Import font…").clicked() {
222            if let Some(paths) = rfd::FileDialog::new().add_filter("Fonts", &["ttf", "otf", "ttc"]).pick_files() {
223                for p in paths {
224                    let p = p.to_string_lossy().into_owned();
225                    if !s.user_fonts.iter().any(|f| f.eq_ignore_ascii_case(&p)) {
226                        s.user_fonts.push(p);
227                        changed = true;
228                    }
229                }
230            }
231        }
232        ui.weak("(.ttf / .otf, usable in text clips and subtitles)");
233    });
234    let mut remove = None;
235    for (i, f) in s.user_fonts.iter().enumerate() {
236        ui.horizontal(|ui| {
237            if crate::ui::markers_ui::x_button(ui).on_hover_text("Remove this font").clicked() {
238                remove = Some(i);
239            }
240            let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().into_owned());
241            ui.label(name.unwrap_or_else(|| f.clone())).on_hover_text(f);
242        });
243    }
244    if let Some(i) = remove {
245        s.user_fonts.remove(i);
246        changed = true;
247    }
248    ui.add_space(6.0);
249    ui.separator();
250    // ---- MCP server ----
251    changed |= ui.checkbox(&mut s.mcp_enabled, "MCP server (AI co-editing)").changed();
252    ui.horizontal(|ui| {
253        ui.label("Port");
254        changed |= port_field(ui, state, s);
255        ui.weak(format!("http://127.0.0.1:{}/mcp", s.mcp_port));
256    });
257    ui.horizontal(|ui| {
258        if ui.button("Copy Claude Code command").clicked() {
259            ui.ctx().copy_text(crate::mcp::claude_code_command(s.mcp_port));
260        }
261        ui.weak(mcp_status);
262    });
263    changed
264}
265
266/// MCP port DragValue. The app restarts the server whenever `settings.mcp_port` changes, so the value is
267/// held in `state.port_edit` while the user drags or types and only written when the gesture ends —
268/// otherwise a drag from 7337 to 7400 walks through ~60 ports and one busy port in between turns the
269/// server off. Returns true when the setting changed.
270fn port_field(ui: &mut egui::Ui, state: &mut SettingsUi, s: &mut Settings) -> bool {
271    let mut port = state.port_edit.unwrap_or(s.mcp_port);
272    let r = ui.add(egui::DragValue::new(&mut port).range(1024..=65535));
273    if r.dragged() || r.has_focus() {
274        state.port_edit = Some(port);
275        return false;
276    }
277    state.port_edit = None;
278    if port != s.mcp_port {
279        s.mcp_port = port;
280        return true;
281    }
282    false
283}
284
285fn performance(ui: &mut egui::Ui, s: &mut Settings, gpu_name: &str) -> bool {
286    let mut changed = false;
287    egui::Grid::new("perf").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
288        ui.label("GPU preview");
289        ui.vertical(|ui| {
290            changed |= ui.checkbox(&mut s.gpu, "Render the preview with OpenGL shaders").changed();
291            ui.weak(format!("Renderer: {gpu_name}"));
292            ui.weak("Off (or when the driver refuses the shaders) the CPU compositor is used.");
293        });
294        ui.end_row();
295
296        ui.label("Preview quality");
297        ui.horizontal(|ui| {
298            let mut q = s.preview_quality.clamp(25, 100);
299            changed |= ui.add(egui::DragValue::new(&mut q).range(25..=100).suffix(" %").speed(1)).changed();
300            s.preview_quality = q;
301            ui.weak("of the preview size — lower is faster, the export is unaffected");
302        });
303        ui.end_row();
304
305        ui.label("Proxy media");
306        ui.vertical(|ui| {
307            changed |= ui.checkbox(&mut s.use_proxies, "Play low-res all-intra proxies in the preview").changed();
308            ui.horizontal(|ui| {
309                let mut h = s.proxy_height.clamp(120, 2160);
310                changed |= ui.add(egui::DragValue::new(&mut h).range(120..=2160).suffix(" px").speed(10)).changed();
311                s.proxy_height = h;
312                ui.weak("proxy height — built in the background, exports always use the originals");
313            });
314        });
315        ui.end_row();
316
317        ui.label("Movie mode");
318        ui.vertical(|ui| {
319            changed |= ui.checkbox(&mut s.movie_mode, "Play back pre-rendered frames").changed();
320            ui.weak("Renders the in/out range (or the whole timeline) at full quality in the background.");
321        });
322        ui.end_row();
323
324        ui.label("Effect thumbnails");
325        ui.horizontal(|ui| {
326            changed |= ui
327                .add(
328                    egui::TextEdit::singleline(&mut s.effect_thumb_image)
329                        .desired_width(200.0)
330                        .hint_text("built-in image"),
331                )
332                .changed();
333            if ui.button("Browse…").clicked() {
334                if let Some(p) =
335                    rfd::FileDialog::new().add_filter("Images", &["png", "jpg", "jpeg", "bmp", "webp"]).pick_file()
336                {
337                    s.effect_thumb_image = p.to_string_lossy().into_owned();
338                    changed = true;
339                }
340            }
341            if ui
342                .add_enabled(!s.effect_thumb_image.is_empty(), egui::Button::new("Reset to default"))
343                .on_hover_text("Render the catalogue over the image built into the app")
344                .clicked()
345            {
346                s.effect_thumb_image.clear();
347                changed = true;
348            }
349        });
350        ui.end_row();
351    });
352    changed
353}
354
355fn capture_tab(ui: &mut egui::Ui, s: &mut Settings, audio_inputs: &[(String, bool)]) -> bool {
356    let mut changed = false;
357    let devices = |ui: &mut egui::Ui, id: &str, value: &mut String, want_loopback: bool| -> bool {
358        let mut opts: Vec<(&str, &str)> = vec![("", "(none)")];
359        opts.extend(audio_inputs.iter().filter(|(_, lb)| !want_loopback || *lb).map(|(n, _)| (n.as_str(), n.as_str())));
360        combo(ui, id, value, &opts, Some(260.0))
361    };
362    ui.strong("Screen recording");
363    egui::Grid::new("capture").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
364        ui.label("Frame rate");
365        ui.horizontal(|ui| {
366            changed |= ui.add(egui::DragValue::new(&mut s.capture_fps).range(5..=120)).changed();
367            ui.weak("fps");
368        });
369        ui.end_row();
370
371        ui.label("Bitrate");
372        ui.horizontal(|ui| {
373            changed |=
374                ui.add(egui::DragValue::new(&mut s.capture_bitrate_kbps).range(0..=100_000).speed(100)).changed();
375            ui.weak("kbit/s (0 = use the export quality / CRF)");
376        });
377        ui.end_row();
378
379        ui.label("Microphone");
380        changed |= devices(ui, "cap_mic", &mut s.capture_mic, false);
381        ui.end_row();
382
383        ui.label("Output folder");
384        ui.horizontal(|ui| {
385            changed |= ui
386                .add(egui::TextEdit::singleline(&mut s.capture_dir).desired_width(200.0).hint_text("temp folder"))
387                .changed();
388            if ui.button("Browse…").clicked() {
389                if let Some(p) = rfd::FileDialog::new().pick_folder() {
390                    s.capture_dir = p.to_string_lossy().into_owned();
391                    changed = true;
392                }
393            }
394        });
395        ui.end_row();
396    });
397    changed |= ui.checkbox(&mut s.capture_desktop_audio, "Record desktop audio (needs a loopback device)").changed();
398    changed |= ui.checkbox(&mut s.capture_cursor, "Record the mouse cursor").changed();
399    changed |= ui
400        .checkbox(&mut s.capture_on_blur, "Record while the editor is in the background (starts when it loses focus)")
401        .changed();
402    if audio_inputs.is_empty() {
403        ui.weak("No audio input devices found (ffmpeg -list_devices).");
404    }
405    ui.add_space(6.0);
406    ui.separator();
407    ui.strong("Voiceover");
408    egui::Grid::new("voice").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
409        ui.label("Input device");
410        changed |= devices(ui, "voice_dev", &mut s.voice_device, false);
411        ui.end_row();
412        ui.label("Channels");
413        ui.horizontal(|ui| {
414            changed |= ui.add(egui::DragValue::new(&mut s.voice_channels).range(1..=2)).changed();
415            ui.weak("1 = mono, 2 = stereo");
416        });
417        ui.end_row();
418    });
419    changed
420}
421
422fn export(ui: &mut egui::Ui, s: &mut Settings, encoders: &[String]) -> bool {
423    let mut changed = false;
424    egui::Grid::new("export").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
425        ui.label("Encoder");
426        ui.horizontal(|ui| {
427            let opts: Vec<(&str, &str)> = std::iter::once(("auto", "auto"))
428                .chain(encoder_options(encoders).into_iter().map(|e| (e, e)))
429                .collect();
430            changed |= combo(ui, "encoder", &mut s.encoder, &opts, Some(260.0));
431            if encoders.is_empty() {
432                ui.weak("(ffmpeg not found)");
433            }
434        });
435        ui.end_row();
436
437        ui.label("Quality (CRF)");
438        ui.horizontal(|ui| {
439            changed |= ui.add(egui::DragValue::new(&mut s.crf).range(0..=51)).changed();
440            ui.weak("18 ≈ visually lossless, 23 default; lower = better / larger");
441        });
442        ui.end_row();
443
444        ui.label("Preset");
445        let opts: Vec<(&str, &str)> = ENCODER_PRESETS.iter().map(|p| (*p, *p)).collect();
446        changed |= combo(ui, "preset", &mut s.preset, &opts, Some(260.0));
447        ui.end_row();
448    });
449    changed
450}
451
452fn hotkeys_tab(ui: &mut egui::Ui, state: &mut SettingsUi, hotkeys: &mut Hotkeys) -> bool {
453    let mut changed = false;
454    if let Some(a) = state.rebinding {
455        changed |= capture(ui.ctx(), state, a, hotkeys);
456    }
457    if ui.button("Reset all").clicked() {
458        hotkeys.reset_all();
459        state.rebinding = None;
460        state.note.clear();
461        changed = true;
462    }
463    ui.add_space(4.0);
464    egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
465        egui::Grid::new("hotkeys").num_columns(4).striped(true).spacing([12.0, 4.0]).show(ui, |ui| {
466            for &a in Action::ALL {
467                ui.label(a.label());
468                let text = hotkeys.text(a);
469                if state.rebinding == Some(a) {
470                    ui.strong("…");
471                } else if text.is_empty() {
472                    ui.weak("—");
473                } else {
474                    ui.label(text);
475                }
476                if ui.small_button("Rebind").clicked() {
477                    state.rebinding = Some(a);
478                    state.note.clear();
479                }
480                if ui.small_button("Reset").clicked() {
481                    let before = hotkeys.get(a);
482                    hotkeys.reset(a);
483                    changed |= hotkeys.get(a) != before;
484                }
485                ui.end_row();
486            }
487        });
488    });
489    ui.add_space(4.0);
490    if let Some(a) = state.rebinding {
491        let r = ui.strong(format!("Press keys for \"{}\"… (Esc cancels, Backspace/Delete unbinds)", a.label()));
492        // Hold keyboard focus so the app's hotkey polling (which skips while a widget has focus) stays quiet.
493        r.request_focus();
494        ui.memory_mut(|m| {
495            m.set_focus_lock_filter(
496                r.id,
497                egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true },
498            )
499        });
500    } else if !state.note.is_empty() {
501        ui.label(&state.note);
502    }
503    ui.weak("Mouse: Ctrl+Scroll zoom, Shift+Scroll pan, Alt+Scroll track height (fixed).");
504    changed
505}
506
507/// While rebinding: take the first key press this frame, bind/unbind/cancel, and swallow all key/text
508/// events so nothing else reacts to them. Returns true if a binding changed.
509fn capture(ctx: &egui::Context, state: &mut SettingsUi, a: Action, hotkeys: &mut Hotkeys) -> bool {
510    let mut changed = false;
511    ctx.input_mut(|i| {
512        let pressed = i.events.iter().find_map(|e| match e {
513            Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
514            _ => None,
515        });
516        if let Some((key, m)) = pressed {
517            state.rebinding = None;
518            match key {
519                Key::Escape => {}
520                Key::Backspace | Key::Delete => {
521                    changed = hotkeys.get(a).is_some();
522                    hotkeys.set(a, None);
523                }
524                key => {
525                    let ks = KeyboardShortcut::new(
526                        Modifiers {
527                            alt: m.alt,
528                            ctrl: m.ctrl || m.command,
529                            shift: m.shift,
530                            mac_cmd: false,
531                            command: false,
532                        },
533                        key,
534                    );
535                    if let Some(other) = hotkeys.conflict(ks).filter(|&o| o != a) {
536                        state.note =
537                            format!("{} was using {} and is now unbound.", other.label(), Hotkeys::format(&ks));
538                    }
539                    hotkeys.set(a, Some(ks));
540                    changed = true;
541                }
542            }
543        }
544        i.events.retain(|e| !matches!(e, Event::Key { .. } | Event::Text(_)));
545    });
546    changed
547}
548
549/// Appearance: mode (Follow Windows / Light / Dark / Custom) + a colour override per `Palette` field
550/// the app actually paints with. Mutates `s.palette` directly, so `theme::palette_with` (recomputed
551/// every frame in `App::update`) picks it up immediately — no restart, no extra plumbing.
552fn appearance(ui: &mut egui::Ui, s: &mut Settings) -> bool {
553    let mut changed = false;
554    ui.horizontal(|ui| {
555        ui.label("Mode");
556        changed |= combo(ui, "palette_mode", &mut s.palette.mode, &PALETTE_MODES, Some(200.0));
557        if ui.button("Reset to system").clicked() {
558            s.palette = crate::theme::PaletteOverride::default();
559            changed = true;
560        }
561    });
562    ui.weak("Custom colours only take effect once the mode above is Light, Dark or Custom.");
563    ui.add_space(6.0);
564
565    let base = crate::theme::palette_with(ui.ctx(), &s.palette);
566    ui.horizontal(|ui| {
567        ui.label("Preview");
568        for c in [
569            base.bg,
570            base.panel,
571            base.header,
572            base.border,
573            base.text,
574            base.text_dim,
575            base.accent,
576            base.selection,
577            base.keyframe,
578            base.waveform,
579        ] {
580            let (rect, _) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::hover());
581            ui.painter().rect_filled(rect, 2.0, c);
582            ui.painter().rect_stroke(rect, 2.0, (1.0, base.border), egui::StrokeKind::Outside);
583        }
584    });
585    ui.add_space(6.0);
586
587    egui::Grid::new("appearance").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
588        changed |= color_row(ui, "Background", &mut s.palette.background, base.bg);
589        changed |= color_row(ui, "Panel", &mut s.palette.panel, base.panel);
590        changed |= color_row(ui, "Header", &mut s.palette.header, base.header);
591        changed |= color_row(ui, "Border", &mut s.palette.border, base.border);
592        changed |= color_row(ui, "Text", &mut s.palette.text, base.text);
593        changed |= color_row(ui, "Text (dim)", &mut s.palette.text_dim, base.text_dim);
594        changed |= color_row(ui, "Accent", &mut s.palette.accent, base.accent);
595        changed |= color_row(ui, "Selection", &mut s.palette.selection, base.selection);
596        changed |= color_row(ui, "Keyframe", &mut s.palette.keyframe, base.keyframe);
597        changed |= color_row(ui, "Waveform", &mut s.palette.waveform, base.waveform);
598    });
599    ui.add_space(8.0);
600    ui.collapsing("Icons", |ui| {
601        ui.weak("Pick the glyph shown for each pane and menu action ('None' removes it, 'Default' restores).");
602        egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
603            egui::Grid::new("icons_panes").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
604                for p in crate::ui::layout::Pane::ALL {
605                    changed |= icon_row(ui, s, format!("pane.{}", p.title()), p.title(), Some(p.glyph()));
606                }
607                for a in crate::hotkeys::Action::ALL {
608                    changed |=
609                        icon_row(ui, s, format!("action.{}", a.id()), a.label(), crate::ui::tools::action_glyph(*a));
610                }
611            });
612        });
613    });
614    changed
615}
616
617/// One icon assignment: current glyph (or blank), and a picker with every glyph, None and Default.
618fn icon_row(ui: &mut egui::Ui, s: &mut Settings, key: String, label: &str, default: Option<Glyph>) -> bool {
619    use crate::ui::tools::{glyph_label, glyph_text_button};
620    let mut changed = false;
621    ui.label(label);
622    let cur = match s.icon_overrides.get(&key) {
623        Some(n) if n == "none" => None,
624        Some(n) => Glyph::from_name(n).or(default),
625        None => default,
626    };
627    ui.horizontal(|ui| {
628        match cur {
629            Some(g) => {
630                glyph_label(ui, g, ui.visuals().text_color());
631            }
632            None => ui.add_space(18.0),
633        }
634        ui.menu_button("Change", |ui| {
635            ui.set_max_width(260.0);
636            ui.horizontal_wrapped(|ui| {
637                for g in Glyph::ALL {
638                    if glyph_text_button(ui, *g, "").on_hover_text(g.name()).clicked() {
639                        s.icon_overrides.insert(key.clone(), g.name().to_string());
640                        changed = true;
641                        ui.close();
642                    }
643                }
644            });
645            ui.separator();
646            ui.horizontal(|ui| {
647                if ui.button("None").clicked() {
648                    s.icon_overrides.insert(key.clone(), "none".into());
649                    changed = true;
650                    ui.close();
651                }
652                if ui.button("Default").clicked() {
653                    s.icon_overrides.remove(&key);
654                    changed = true;
655                    ui.close();
656                }
657            });
658        });
659    });
660    ui.end_row();
661    changed
662}
663
664/// One overridable colour: checkbox turns the override on/off, the button edits it while on (shown
665/// at `fallback` — today's derived colour — while off, so turning it on starts from what's in effect).
666fn color_row(ui: &mut egui::Ui, label: &str, ov: &mut Option<[u8; 3]>, fallback: egui::Color32) -> bool {
667    let mut changed = false;
668    ui.label(label);
669    let mut on = ov.is_some();
670    let mut rgb = ov.unwrap_or([fallback.r(), fallback.g(), fallback.b()]);
671    ui.horizontal(|ui| {
672        changed |= ui.checkbox(&mut on, "").changed();
673        ui.add_enabled_ui(on, |ui| {
674            changed |= egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed();
675        });
676    });
677    ui.end_row();
678    *ov = on.then_some(rgb);
679    changed
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn encoder_filter() {
688        let all: Vec<String> =
689            ["libx264", "h264_nvenc", "hevc_qsv", "libvpx-vp9", "libaom-av1", "h264_amf", "mpeg4", "gif", "aac"]
690                .iter()
691                .map(|s| s.to_string())
692                .collect();
693        assert_eq!(
694            encoder_options(&all),
695            vec!["libx264", "h264_nvenc", "hevc_qsv", "libvpx-vp9", "libaom-av1", "h264_amf"]
696        );
697        assert!(encoder_options(&[]).is_empty());
698    }
699
700    #[test]
701    fn capture_binds_and_swallows() {
702        let ctx = egui::Context::default();
703        let mut hk = Hotkeys::defaults();
704        let mut st = SettingsUi { rebinding: Some(Action::Split), ..Default::default() };
705        let mut input = egui::RawInput::default();
706        input.events.push(Event::Key {
707            key: Key::B,
708            physical_key: None,
709            pressed: true,
710            repeat: false,
711            modifiers: Modifiers::CTRL | Modifiers::SHIFT,
712        });
713        input.events.push(Event::Text("B".into()));
714        ctx.begin_pass(input);
715        // Ctrl+Shift+B is free: binds Split, swallows the events, stops rebinding.
716        assert!(capture(&ctx, &mut st, Action::Split, &mut hk));
717        assert_eq!(hk.text(Action::Split), "Ctrl+Shift+B");
718        assert!(st.rebinding.is_none());
719        assert!(ctx.input(|i| i.events.is_empty()));
720        let _ = ctx.end_pass();
721
722        // Ctrl+Z conflicts with Undo: Undo gets unbound and a note is written.
723        let mut input = egui::RawInput::default();
724        input.events.push(Event::Key {
725            key: Key::Z,
726            physical_key: None,
727            pressed: true,
728            repeat: false,
729            modifiers: Modifiers::COMMAND,
730        });
731        ctx.begin_pass(input);
732        st.rebinding = Some(Action::Split);
733        assert!(capture(&ctx, &mut st, Action::Split, &mut hk));
734        assert_eq!(hk.text(Action::Split), "Ctrl+Z");
735        assert_eq!(hk.text(Action::Undo), "");
736        assert!(st.note.contains("Undo"));
737        let _ = ctx.end_pass();
738
739        // Escape cancels without changes; Delete unbinds.
740        for (key, expect_changed, expect_text) in [(Key::Escape, false, "Ctrl+Z"), (Key::Delete, true, "")] {
741            let mut input = egui::RawInput::default();
742            input.events.push(Event::Key {
743                key,
744                physical_key: None,
745                pressed: true,
746                repeat: false,
747                modifiers: Modifiers::NONE,
748            });
749            ctx.begin_pass(input);
750            st.rebinding = Some(Action::Split);
751            assert_eq!(capture(&ctx, &mut st, Action::Split, &mut hk), expect_changed);
752            assert_eq!(hk.text(Action::Split), expect_text);
753            assert!(st.rebinding.is_none());
754            let _ = ctx.end_pass();
755        }
756    }
757
758    /// Headless: every tab lays out without panicking and reports no change without input.
759    #[test]
760    fn show_headless_no_change() {
761        let ctx = egui::Context::default();
762        let mut settings = Settings::default();
763        let mut hk = Hotkeys::defaults();
764        let encoders = vec!["libx264".to_string(), "aac".to_string()];
765        let palette = Palette::new(false, egui::Color32::BLACK);
766        let mut st = SettingsUi { open: true, ..Default::default() };
767        let inputs = vec![("Microphone (USB)".to_string(), false), ("Stereo Mix".to_string(), true)];
768        for tab in [0, 1, 2, 3, 4, 5] {
769            st.tab = tab;
770            for _ in 0..2 {
771                let _ = ctx.run(egui::RawInput::default(), |ctx| {
772                    assert!(!show(
773                        ctx,
774                        &mut st,
775                        &mut settings,
776                        &mut hk,
777                        &encoders,
778                        &palette,
779                        "stopped",
780                        "Test GL",
781                        &inputs
782                    ));
783                });
784            }
785            assert!(st.open && st.status.is_some());
786        }
787        // the new tabs left the settings alone
788        assert_eq!(settings.preview_quality, Settings::default().preview_quality);
789        assert_eq!(settings.capture_fps, Settings::default().capture_fps);
790        assert_eq!(settings.palette, crate::theme::PaletteOverride::default());
791    }
792
793    /// The Appearance tab's per-colour toggle: off stays unset, and an already-on value round-trips
794    /// through a redraw unchanged (no spurious `changed` from just laying the row out).
795    #[test]
796    fn color_row_toggle_persists() {
797        let ctx = egui::Context::default();
798        let mut ov: Option<[u8; 3]> = None;
799        let _ = ctx.run(egui::RawInput::default(), |ctx| {
800            egui::CentralPanel::default().show(ctx, |ui| {
801                assert!(!color_row(ui, "Test", &mut ov, egui::Color32::from_rgb(1, 2, 3)));
802            });
803        });
804        assert!(ov.is_none(), "left alone: stays unset");
805
806        ov = Some([1, 2, 3]);
807        let _ = ctx.run(egui::RawInput::default(), |ctx| {
808            egui::CentralPanel::default().show(ctx, |ui| {
809                assert!(!color_row(ui, "Test", &mut ov, egui::Color32::from_rgb(9, 9, 9)));
810            });
811        });
812        assert_eq!(ov, Some([1, 2, 3]));
813    }
814}