1use 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 status: Option<Status>,
29 note: String,
31 port_edit: Option<u16>,
34}
35
36struct Status {
37 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 gpu_name: &str,
87 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 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 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
266fn 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 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
507fn 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
549fn 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
617fn 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
664fn 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 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 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 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 #[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 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 #[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}