par_term/settings_ui.rs
1use crate::config::{BackgroundImageMode, Config, CursorStyle, VsyncMode};
2use crate::themes::Theme;
3use arboard::Clipboard;
4use egui::{Color32, Context, Frame, Window, epaint::Shadow};
5use rfd::FileDialog;
6
7/// Result of shader editor actions
8#[derive(Debug, Clone)]
9pub struct ShaderEditorResult {
10 /// New shader source code to compile and apply
11 pub source: String,
12}
13
14/// Settings UI manager using egui
15pub struct SettingsUI {
16 /// Whether the settings window is currently visible
17 pub visible: bool,
18
19 /// Working copy of config being edited
20 config: Config,
21
22 /// Last opacity value that was forwarded for live updates
23 last_live_opacity: f32,
24
25 /// Whether config has unsaved changes
26 has_changes: bool,
27
28 /// Temp strings for optional fields (for UI editing)
29 temp_font_bold: String,
30 temp_font_italic: String,
31 temp_font_bold_italic: String,
32 temp_font_family: String,
33 temp_font_size: f32,
34 temp_line_spacing: f32,
35 temp_char_spacing: f32,
36 temp_enable_text_shaping: bool,
37 temp_enable_ligatures: bool,
38 temp_enable_kerning: bool,
39 font_pending_changes: bool,
40 temp_custom_shell: String,
41 temp_shell_args: String,
42 temp_working_directory: String,
43 temp_background_image: String,
44 temp_custom_shader: String,
45
46 /// Search query used to filter settings sections
47 search_query: String,
48
49 // Shader editor state
50 /// Whether the shader editor window is visible
51 shader_editor_visible: bool,
52 /// The shader source code being edited
53 shader_editor_source: String,
54 /// Shader compilation error message (if any)
55 shader_editor_error: Option<String>,
56 /// Original source when editor was opened (for cancel)
57 shader_editor_original: String,
58
59 // Shader management state
60 /// List of available shader files in the shaders folder
61 available_shaders: Vec<String>,
62 /// Name for new shader (in create dialog)
63 new_shader_name: String,
64 /// Whether to show the create shader dialog
65 show_create_shader_dialog: bool,
66 /// Whether to show the delete confirmation dialog
67 show_delete_shader_dialog: bool,
68
69 // Shader editor search state
70 /// Search query for shader editor
71 shader_search_query: String,
72 /// Byte positions of search matches
73 shader_search_matches: Vec<usize>,
74 /// Current match index (0-based)
75 shader_search_current: usize,
76 /// Whether search bar is visible
77 shader_search_visible: bool,
78}
79
80impl SettingsUI {
81 /// Create a new settings UI
82 pub fn new(config: Config) -> Self {
83 Self {
84 visible: false,
85 temp_font_bold: config.font_family_bold.clone().unwrap_or_default(),
86 temp_font_italic: config.font_family_italic.clone().unwrap_or_default(),
87 temp_font_bold_italic: config.font_family_bold_italic.clone().unwrap_or_default(),
88 temp_font_family: config.font_family.clone(),
89 temp_font_size: config.font_size,
90 temp_line_spacing: config.line_spacing,
91 temp_char_spacing: config.char_spacing,
92 temp_enable_text_shaping: config.enable_text_shaping,
93 temp_enable_ligatures: config.enable_ligatures,
94 temp_enable_kerning: config.enable_kerning,
95 font_pending_changes: false,
96 temp_custom_shell: config.custom_shell.clone().unwrap_or_default(),
97 temp_shell_args: config
98 .shell_args
99 .as_ref()
100 .map(|args| args.join(" "))
101 .unwrap_or_default(),
102 temp_working_directory: config.working_directory.clone().unwrap_or_default(),
103 temp_background_image: config.background_image.clone().unwrap_or_default(),
104 temp_custom_shader: config.custom_shader.clone().unwrap_or_default(),
105 last_live_opacity: config.window_opacity,
106 config,
107 has_changes: false,
108 search_query: String::new(),
109 shader_editor_visible: false,
110 shader_editor_source: String::new(),
111 shader_editor_error: None,
112 shader_editor_original: String::new(),
113 available_shaders: Self::scan_shaders_folder(),
114 new_shader_name: String::new(),
115 show_create_shader_dialog: false,
116 show_delete_shader_dialog: false,
117 shader_search_query: String::new(),
118 shader_search_matches: Vec::new(),
119 shader_search_current: 0,
120 shader_search_visible: false,
121 }
122 }
123
124 /// Scan the shaders folder and return a list of shader filenames
125 fn scan_shaders_folder() -> Vec<String> {
126 let shaders_dir = crate::config::Config::shaders_dir();
127 let mut shaders = Vec::new();
128
129 // Create the shaders directory if it doesn't exist
130 if !shaders_dir.exists()
131 && let Err(e) = std::fs::create_dir_all(&shaders_dir)
132 {
133 log::warn!("Failed to create shaders directory: {}", e);
134 return shaders;
135 }
136
137 // Read all .glsl files from the shaders directory
138 if let Ok(entries) = std::fs::read_dir(&shaders_dir) {
139 for entry in entries.flatten() {
140 let path = entry.path();
141 if path.is_file()
142 && let Some(ext) = path.extension()
143 && (ext == "glsl" || ext == "frag" || ext == "shader")
144 && let Some(name) = path.file_name()
145 {
146 shaders.push(name.to_string_lossy().to_string());
147 }
148 }
149 }
150
151 shaders.sort();
152 shaders
153 }
154
155 /// Refresh the list of available shaders
156 pub fn refresh_shaders(&mut self) {
157 self.available_shaders = Self::scan_shaders_folder();
158 }
159
160 /// Set shader compilation error (called from app when shader fails to compile)
161 pub fn set_shader_error(&mut self, error: Option<String>) {
162 self.shader_editor_error = error;
163 }
164
165 /// Clear shader error
166 pub fn clear_shader_error(&mut self) {
167 self.shader_editor_error = None;
168 }
169
170 /// Open the shader editor directly (without opening settings)
171 ///
172 /// Returns true if the editor was opened, false if no shader path is configured
173 pub fn open_shader_editor(&mut self) -> bool {
174 if self.temp_custom_shader.is_empty() {
175 log::warn!("Cannot open shader editor: no shader path configured");
176 return false;
177 }
178
179 // Load shader source from file
180 let shader_path = crate::config::Config::shader_path(&self.temp_custom_shader);
181 match std::fs::read_to_string(&shader_path) {
182 Ok(source) => {
183 self.shader_editor_source = source.clone();
184 self.shader_editor_original = source;
185 self.shader_editor_error = None;
186 self.shader_editor_visible = true;
187 log::info!("Shader editor opened for: {}", shader_path.display());
188 true
189 }
190 Err(e) => {
191 self.shader_editor_error = Some(format!(
192 "Failed to read shader file '{}': {}",
193 shader_path.display(),
194 e
195 ));
196 self.shader_editor_visible = true; // Show editor with error
197 log::error!("Failed to load shader: {}", e);
198 true
199 }
200 }
201 }
202
203 /// Update search matches based on current query
204 fn update_shader_search_matches(&mut self) {
205 self.shader_search_matches.clear();
206 self.shader_search_current = 0;
207
208 if self.shader_search_query.is_empty() {
209 return;
210 }
211
212 let query_lower = self.shader_search_query.to_lowercase();
213 let source_lower = self.shader_editor_source.to_lowercase();
214
215 let mut start = 0;
216 while let Some(pos) = source_lower[start..].find(&query_lower) {
217 self.shader_search_matches.push(start + pos);
218 start += pos + query_lower.len();
219 }
220 }
221
222 /// Move to next search match
223 fn shader_search_next(&mut self) {
224 if !self.shader_search_matches.is_empty() {
225 self.shader_search_current =
226 (self.shader_search_current + 1) % self.shader_search_matches.len();
227 }
228 }
229
230 /// Move to previous search match
231 fn shader_search_previous(&mut self) {
232 if !self.shader_search_matches.is_empty() {
233 if self.shader_search_current == 0 {
234 self.shader_search_current = self.shader_search_matches.len() - 1;
235 } else {
236 self.shader_search_current -= 1;
237 }
238 }
239 }
240
241 /// Get the current match position (byte offset) if any
242 fn shader_search_current_pos(&self) -> Option<usize> {
243 if self.shader_search_matches.is_empty() {
244 None
245 } else {
246 Some(self.shader_search_matches[self.shader_search_current])
247 }
248 }
249
250 /// Check if shader editor is visible
251 pub fn is_shader_editor_visible(&self) -> bool {
252 self.shader_editor_visible
253 }
254
255 fn pick_file_path(&self, title: &str) -> Option<String> {
256 FileDialog::new()
257 .set_title(title)
258 .pick_file()
259 .map(|p| p.display().to_string())
260 }
261
262 fn pick_folder_path(&self, title: &str) -> Option<String> {
263 FileDialog::new()
264 .set_title(title)
265 .pick_folder()
266 .map(|p| p.display().to_string())
267 }
268
269 /// Update the config copy (e.g., when config is reloaded)
270 pub fn update_config(&mut self, config: Config) {
271 if !self.has_changes {
272 self.config = config;
273 self.last_live_opacity = self.config.window_opacity;
274
275 // Refresh staged font values only if there aren't unsaved font edits
276 if !self.font_pending_changes {
277 self.sync_font_temps_from_config();
278 }
279 }
280 }
281
282 fn sync_font_temps_from_config(&mut self) {
283 self.temp_font_family = self.config.font_family.clone();
284 self.temp_font_size = self.config.font_size;
285 self.temp_line_spacing = self.config.line_spacing;
286 self.temp_char_spacing = self.config.char_spacing;
287 self.temp_enable_text_shaping = self.config.enable_text_shaping;
288 self.temp_enable_ligatures = self.config.enable_ligatures;
289 self.temp_enable_kerning = self.config.enable_kerning;
290 self.temp_font_bold = self.config.font_family_bold.clone().unwrap_or_default();
291 self.temp_font_italic = self.config.font_family_italic.clone().unwrap_or_default();
292 self.temp_font_bold_italic = self
293 .config
294 .font_family_bold_italic
295 .clone()
296 .unwrap_or_default();
297 self.font_pending_changes = false;
298 }
299
300 fn apply_font_changes(&mut self) {
301 self.config.font_family = self.temp_font_family.clone();
302 self.config.font_size = self.temp_font_size;
303 self.config.line_spacing = self.temp_line_spacing;
304 self.config.char_spacing = self.temp_char_spacing;
305 self.config.enable_text_shaping = self.temp_enable_text_shaping;
306 self.config.enable_ligatures = self.temp_enable_ligatures;
307 self.config.enable_kerning = self.temp_enable_kerning;
308 self.config.font_family_bold = if self.temp_font_bold.is_empty() {
309 None
310 } else {
311 Some(self.temp_font_bold.clone())
312 };
313 self.config.font_family_italic = if self.temp_font_italic.is_empty() {
314 None
315 } else {
316 Some(self.temp_font_italic.clone())
317 };
318 self.config.font_family_bold_italic = if self.temp_font_bold_italic.is_empty() {
319 None
320 } else {
321 Some(self.temp_font_bold_italic.clone())
322 };
323 self.font_pending_changes = false;
324 }
325
326 /// Toggle settings window visibility
327 pub fn toggle(&mut self) {
328 self.visible = !self.visible;
329 }
330
331 /// Get a reference to the working config (for live sync)
332 pub fn current_config(&self) -> &Config {
333 &self.config
334 }
335
336 /// Show the settings window and return (Option<config_to_save>, Option<config_for_live_update>, Option<ShaderEditorResult>)
337 /// - First Option: Some(config) if save was clicked (persist to disk)
338 /// - Second Option: Some(config) if any changes were made (apply immediately)
339 /// - Third Option: Some(ShaderEditorResult) if shader Apply was clicked
340 pub fn show(
341 &mut self,
342 ctx: &Context,
343 ) -> (Option<Config>, Option<Config>, Option<ShaderEditorResult>) {
344 if !self.visible && !self.shader_editor_visible {
345 return (None, None, None);
346 }
347
348 log::info!("SettingsUI.show() called - visible: true");
349
350 // Handle Escape key to close settings window
351 if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
352 if self.shader_editor_visible {
353 // Close shader editor first if open
354 self.shader_editor_visible = false;
355 self.shader_editor_error = None;
356 } else if self.visible {
357 // Close settings window
358 self.visible = false;
359 return (None, None, None);
360 }
361 }
362
363 // Ensure settings panel is fully opaque regardless of terminal opacity
364 let mut style = (*ctx.style()).clone();
365 let solid_bg = Color32::from_rgba_unmultiplied(24, 24, 24, 255);
366 style.visuals.window_fill = solid_bg;
367 style.visuals.panel_fill = solid_bg;
368 style.visuals.widgets.noninteractive.bg_fill = solid_bg;
369 ctx.set_style(style);
370
371 let mut save_requested = false;
372 let mut discard_requested = false;
373 let mut close_requested = false;
374 let mut open = true;
375 let mut changes_this_frame = false;
376 let mut shader_apply_result: Option<ShaderEditorResult> = None;
377
378 // Only show the main settings window if visible
379 if self.visible {
380 let settings_viewport = ctx.input(|i| i.viewport_rect());
381 Window::new("Settings")
382 .resizable(true)
383 .default_width(650.0)
384 .default_height(700.0)
385 .default_pos(settings_viewport.center())
386 .pivot(egui::Align2::CENTER_CENTER)
387 .open(&mut open)
388 .frame(
389 Frame::window(&ctx.style())
390 .fill(solid_bg)
391 .stroke(egui::Stroke::NONE)
392 .shadow(Shadow {
393 offset: [0, 0],
394 blur: 0,
395 spread: 0,
396 color: Color32::TRANSPARENT,
397 }),
398 )
399 .show(ctx, |ui| {
400 // Reserve space for fixed footer buttons
401 let available_height = ui.available_height();
402 let footer_height = 45.0;
403
404 // Scrollable content area (takes remaining space above footer)
405 egui::ScrollArea::vertical()
406 .max_height(available_height - footer_height)
407 .show(ui, |ui| {
408 ui.heading("Terminal Settings");
409 ui.horizontal(|ui| {
410 ui.label("Quick search:");
411 ui.add(
412 egui::TextEdit::singleline(&mut self.search_query)
413 .hint_text("Type to filter settings"),
414 );
415 });
416 ui.separator();
417
418 let query = self.search_query.trim().to_lowercase();
419 let mut matches_found = false;
420 let mut section_shown = false;
421 let insert_section_separator = |ui: &mut egui::Ui, shown: &mut bool| {
422 if *shown {
423 ui.separator();
424 } else {
425 *shown = true;
426 }
427 };
428 let section_matches = |title: &str, fields: &[&str]| -> bool {
429 if query.is_empty() {
430 return true;
431 }
432
433 let q = query.as_str();
434 title.to_lowercase().contains(q)
435 || fields.iter().any(|f| f.to_lowercase().contains(q))
436 };
437
438 // Window & Display
439 if section_matches(
440 "Window & Display",
441 &[
442 "Title",
443 "Width",
444 "Height",
445 "Padding",
446 "Opacity",
447 "Decorations",
448 "Always on top",
449 "Max FPS",
450 "VSync",
451 ],
452 ) {
453 insert_section_separator(ui, &mut section_shown);
454 matches_found = true;
455
456 ui.collapsing("Window & Display", |ui| {
457 ui.horizontal(|ui| {
458 ui.label("Title:");
459 if ui
460 .text_edit_singleline(&mut self.config.window_title)
461 .changed()
462 {
463 self.has_changes = true;
464 changes_this_frame = true;
465 }
466 });
467
468 ui.horizontal(|ui| {
469 ui.label("Width:");
470 if ui
471 .add(egui::Slider::new(
472 &mut self.config.window_width,
473 400..=3840,
474 ))
475 .changed()
476 {
477 self.has_changes = true;
478 changes_this_frame = true;
479 }
480 });
481
482 ui.horizontal(|ui| {
483 ui.label("Height:");
484 if ui
485 .add(egui::Slider::new(
486 &mut self.config.window_height,
487 300..=2160,
488 ))
489 .changed()
490 {
491 self.has_changes = true;
492 changes_this_frame = true;
493 }
494 });
495
496 ui.horizontal(|ui| {
497 ui.label("Padding:");
498 if ui
499 .add(egui::Slider::new(
500 &mut self.config.window_padding,
501 0.0..=50.0,
502 ))
503 .changed()
504 {
505 self.has_changes = true;
506 changes_this_frame = true;
507 }
508 });
509
510 ui.horizontal(|ui| {
511 ui.label("Opacity:");
512 let response = ui.add(egui::Slider::new(
513 &mut self.config.window_opacity,
514 0.1..=1.0,
515 ));
516 if response.changed() {
517 log::info!(
518 "Opacity slider changed to: {}",
519 self.config.window_opacity
520 );
521 self.has_changes = true;
522 changes_this_frame = true;
523 }
524 });
525
526 if ui
527 .checkbox(&mut self.config.window_decorations, "Window decorations")
528 .changed()
529 {
530 self.has_changes = true;
531 changes_this_frame = true;
532 }
533
534 if ui
535 .checkbox(&mut self.config.window_always_on_top, "Always on top")
536 .changed()
537 {
538 self.has_changes = true;
539 changes_this_frame = true;
540 }
541
542 ui.horizontal(|ui| {
543 ui.label("Max FPS:");
544 if ui
545 .add(egui::Slider::new(&mut self.config.max_fps, 1..=240))
546 .changed()
547 {
548 self.has_changes = true;
549 changes_this_frame = true;
550 }
551 });
552
553 ui.horizontal(|ui| {
554 ui.label("VSync Mode:");
555 let current = match self.config.vsync_mode {
556 VsyncMode::Immediate => 0,
557 VsyncMode::Mailbox => 1,
558 VsyncMode::Fifo => 2,
559 };
560 let mut selected = current;
561 egui::ComboBox::from_id_salt("vsync_mode")
562 .selected_text(match current {
563 0 => "Immediate (No VSync)",
564 1 => "Mailbox (Balanced)",
565 2 => "FIFO (VSync)",
566 _ => "Unknown",
567 })
568 .show_ui(ui, |ui| {
569 ui.selectable_value(
570 &mut selected,
571 0,
572 "Immediate (No VSync)",
573 );
574 ui.selectable_value(&mut selected, 1, "Mailbox (Balanced)");
575 ui.selectable_value(&mut selected, 2, "FIFO (VSync)");
576 });
577 if selected != current {
578 self.config.vsync_mode = match selected {
579 0 => VsyncMode::Immediate,
580 1 => VsyncMode::Mailbox,
581 2 => VsyncMode::Fifo,
582 _ => VsyncMode::Immediate,
583 };
584 self.has_changes = true;
585 }
586 });
587 });
588 }
589
590 // Terminal
591 if section_matches(
592 "Terminal",
593 &["Columns", "Rows", "Scrollback", "Exit when shell exits"],
594 ) {
595 insert_section_separator(ui, &mut section_shown);
596 matches_found = true;
597
598 ui.collapsing("Terminal", |ui| {
599 ui.horizontal(|ui| {
600 ui.label("Columns:");
601 if ui
602 .add(egui::Slider::new(&mut self.config.cols, 40..=300))
603 .changed()
604 {
605 self.has_changes = true;
606 changes_this_frame = true;
607 }
608 });
609
610 ui.horizontal(|ui| {
611 ui.label("Rows:");
612 if ui
613 .add(egui::Slider::new(&mut self.config.rows, 10..=100))
614 .changed()
615 {
616 self.has_changes = true;
617 changes_this_frame = true;
618 }
619 });
620
621 ui.horizontal(|ui| {
622 ui.label("Scrollback lines:");
623 if ui
624 .add(egui::Slider::new(
625 &mut self.config.scrollback_lines,
626 1000..=100000,
627 ))
628 .changed()
629 {
630 self.has_changes = true;
631 changes_this_frame = true;
632 }
633 });
634
635 if ui
636 .checkbox(
637 &mut self.config.exit_on_shell_exit,
638 "Exit when shell exits",
639 )
640 .changed()
641 {
642 self.has_changes = true;
643 changes_this_frame = true;
644 }
645 });
646 }
647
648 // Font Settings
649 if section_matches(
650 "Font",
651 &[
652 "Family",
653 "Bold",
654 "Italic",
655 "Size",
656 "Line spacing",
657 "Char spacing",
658 "Text shaping",
659 "Ligatures",
660 "Kerning",
661 ],
662 ) {
663 insert_section_separator(ui, &mut section_shown);
664 matches_found = true;
665
666 ui.collapsing("Font", |ui| {
667 ui.horizontal(|ui| {
668 ui.label("Family (regular):");
669 if ui
670 .text_edit_singleline(&mut self.temp_font_family)
671 .changed()
672 {
673 self.font_pending_changes = true;
674 }
675 });
676
677 ui.horizontal(|ui| {
678 ui.label("Bold font (optional):");
679 if ui.text_edit_singleline(&mut self.temp_font_bold).changed() {
680 self.font_pending_changes = true;
681 }
682 });
683
684 ui.horizontal(|ui| {
685 ui.label("Italic font (optional):");
686 if ui
687 .text_edit_singleline(&mut self.temp_font_italic)
688 .changed()
689 {
690 self.font_pending_changes = true;
691 }
692 });
693
694 ui.horizontal(|ui| {
695 ui.label("Bold-Italic font (optional):");
696 if ui
697 .text_edit_singleline(&mut self.temp_font_bold_italic)
698 .changed()
699 {
700 self.font_pending_changes = true;
701 }
702 });
703
704 ui.horizontal(|ui| {
705 ui.label("Size:");
706 if ui
707 .add(egui::Slider::new(&mut self.temp_font_size, 6.0..=48.0))
708 .changed()
709 {
710 self.font_pending_changes = true;
711 }
712 });
713
714 ui.horizontal(|ui| {
715 ui.label("Line spacing:");
716 if ui
717 .add(egui::Slider::new(&mut self.temp_line_spacing, 0.8..=2.0))
718 .changed()
719 {
720 self.font_pending_changes = true;
721 }
722 });
723
724 ui.horizontal(|ui| {
725 ui.label("Char spacing:");
726 if ui
727 .add(egui::Slider::new(&mut self.temp_char_spacing, 0.5..=1.0))
728 .changed()
729 {
730 self.font_pending_changes = true;
731 }
732 });
733
734 if ui
735 .checkbox(&mut self.temp_enable_text_shaping, "Enable text shaping")
736 .changed()
737 {
738 self.font_pending_changes = true;
739 }
740
741 if ui
742 .checkbox(&mut self.temp_enable_ligatures, "Enable ligatures")
743 .changed()
744 {
745 self.font_pending_changes = true;
746 }
747
748 if ui
749 .checkbox(&mut self.temp_enable_kerning, "Enable kerning")
750 .changed()
751 {
752 self.font_pending_changes = true;
753 }
754
755 ui.horizontal(|ui| {
756 if ui.button("Apply font changes").clicked() {
757 self.apply_font_changes();
758 self.has_changes = true;
759 changes_this_frame = true;
760 }
761 if self.font_pending_changes {
762 ui.colored_label(egui::Color32::YELLOW, "(pending)");
763 }
764 });
765 });
766 }
767
768 // Theme & Colors
769 if section_matches("Theme & Colors", &["Theme"]) {
770 insert_section_separator(ui, &mut section_shown);
771 matches_found = true;
772
773 ui.collapsing("Theme & Colors", |ui| {
774 let available = Theme::available_themes();
775 let mut selected = self.config.theme.clone();
776
777 ui.horizontal(|ui| {
778 ui.label("Theme:");
779 egui::ComboBox::from_id_salt("theme_select")
780 .width(220.0)
781 .selected_text(selected.clone())
782 .show_ui(ui, |ui| {
783 for theme in &available {
784 ui.selectable_value(
785 &mut selected,
786 theme.to_string(),
787 *theme,
788 );
789 }
790 });
791 });
792
793 if selected != self.config.theme {
794 self.config.theme = selected;
795 self.has_changes = true;
796 changes_this_frame = true;
797 }
798 });
799 }
800
801 // Background & Effects
802 if section_matches(
803 "Background & Effects",
804 &[
805 "Background image",
806 "Enable background image",
807 "Shader",
808 "Enable shader",
809 "Opacity",
810 "Animation",
811 "Mode",
812 "Text opacity",
813 ],
814 ) {
815 insert_section_separator(ui, &mut section_shown);
816 matches_found = true;
817
818 ui.collapsing("Background & Effects", |ui| {
819 ui.horizontal(|ui| {
820 ui.label("Background image path:");
821 if ui
822 .text_edit_singleline(&mut self.temp_background_image)
823 .changed()
824 {
825 self.config.background_image =
826 if self.temp_background_image.is_empty() {
827 None
828 } else {
829 Some(self.temp_background_image.clone())
830 };
831 self.has_changes = true;
832 }
833
834 if ui.button("Browse…").clicked()
835 && let Some(path) =
836 self.pick_file_path("Select background image")
837 {
838 self.temp_background_image = path.clone();
839 self.config.background_image = Some(path);
840 self.has_changes = true;
841 }
842 });
843
844 if ui
845 .checkbox(
846 &mut self.config.background_image_enabled,
847 "Enable background image",
848 )
849 .changed()
850 {
851 self.has_changes = true;
852 changes_this_frame = true;
853 }
854
855 ui.horizontal(|ui| {
856 ui.label("Background image mode:");
857 let current = match self.config.background_image_mode {
858 BackgroundImageMode::Fit => 0,
859 BackgroundImageMode::Fill => 1,
860 BackgroundImageMode::Stretch => 2,
861 BackgroundImageMode::Tile => 3,
862 BackgroundImageMode::Center => 4,
863 };
864 let mut selected = current;
865 egui::ComboBox::from_id_salt("bg_mode")
866 .selected_text(match current {
867 0 => "Fit",
868 1 => "Fill",
869 2 => "Stretch",
870 3 => "Tile",
871 4 => "Center",
872 _ => "Unknown",
873 })
874 .show_ui(ui, |ui| {
875 ui.selectable_value(&mut selected, 0, "Fit");
876 ui.selectable_value(&mut selected, 1, "Fill");
877 ui.selectable_value(&mut selected, 2, "Stretch");
878 ui.selectable_value(&mut selected, 3, "Tile");
879 ui.selectable_value(&mut selected, 4, "Center");
880 });
881 if selected != current {
882 self.config.background_image_mode = match selected {
883 0 => BackgroundImageMode::Fit,
884 1 => BackgroundImageMode::Fill,
885 2 => BackgroundImageMode::Stretch,
886 3 => BackgroundImageMode::Tile,
887 4 => BackgroundImageMode::Center,
888 _ => BackgroundImageMode::Stretch,
889 };
890 self.has_changes = true;
891 }
892 });
893
894 ui.horizontal(|ui| {
895 ui.label("Background image opacity:");
896 if ui
897 .add(egui::Slider::new(
898 &mut self.config.background_image_opacity,
899 0.0..=1.0,
900 ))
901 .changed()
902 {
903 self.has_changes = true;
904 changes_this_frame = true;
905 }
906 });
907
908 // Shader selection dropdown
909 ui.horizontal(|ui| {
910 ui.label("Shader:");
911 let selected_text = if self.temp_custom_shader.is_empty() {
912 "(none)".to_string()
913 } else {
914 self.temp_custom_shader.clone()
915 };
916
917 let mut shader_changed = false;
918 egui::ComboBox::from_id_salt("shader_select")
919 .selected_text(&selected_text)
920 .width(200.0)
921 .show_ui(ui, |ui| {
922 // Option to select none
923 if ui.selectable_label(self.temp_custom_shader.is_empty(), "(none)").clicked() {
924 self.temp_custom_shader.clear();
925 self.config.custom_shader = None;
926 shader_changed = true;
927 }
928
929 // List available shaders
930 for shader in &self.available_shaders.clone() {
931 let is_selected = self.temp_custom_shader == *shader;
932 if ui.selectable_label(is_selected, shader).clicked() {
933 self.temp_custom_shader = shader.clone();
934 self.config.custom_shader = Some(shader.clone());
935 shader_changed = true;
936 }
937 }
938 });
939
940 if shader_changed {
941 self.has_changes = true;
942 changes_this_frame = true;
943 }
944
945 // Refresh button
946 if ui.button("↻").on_hover_text("Refresh shader list").clicked() {
947 self.refresh_shaders();
948 }
949 });
950
951 // Create and Delete buttons
952 ui.horizontal(|ui| {
953 if ui.button("Create New...").clicked() {
954 self.new_shader_name.clear();
955 self.show_create_shader_dialog = true;
956 }
957
958 let has_shader = !self.temp_custom_shader.is_empty();
959 if ui.add_enabled(has_shader, egui::Button::new("Delete")).clicked() {
960 self.show_delete_shader_dialog = true;
961 }
962
963 if ui.button("Browse...").on_hover_text("Browse for external shader file").clicked()
964 && let Some(path) = self.pick_file_path("Select shader file")
965 {
966 self.temp_custom_shader = path.clone();
967 self.config.custom_shader = Some(path);
968 self.has_changes = true;
969 changes_this_frame = true;
970 }
971 });
972
973 // Show shader compilation error if any
974 if let Some(error) = &self.shader_editor_error {
975 let shader_path = crate::config::Config::shader_path(&self.temp_custom_shader);
976 let full_error = format!("File: {}\n\n{}", shader_path.display(), error);
977 let error_display = error.clone();
978
979 ui.add_space(4.0);
980 egui::Frame::default()
981 .fill(Color32::from_rgb(80, 20, 20))
982 .inner_margin(8.0)
983 .outer_margin(0.0)
984 .corner_radius(4.0)
985 .show(ui, |ui| {
986 ui.horizontal(|ui| {
987 ui.colored_label(Color32::from_rgb(255, 100, 100), "⚠ Shader Error");
988 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
989 if ui.small_button("Copy").clicked()
990 && let Ok(mut clipboard) = Clipboard::new()
991 {
992 let _ = clipboard.set_text(full_error.clone());
993 }
994 });
995 });
996 // Show shader path on its own line
997 ui.label(format!("File: {}", shader_path.display()));
998 ui.separator();
999 // Show error details with word wrap
1000 ui.add(
1001 egui::TextEdit::multiline(&mut error_display.as_str())
1002 .font(egui::TextStyle::Monospace)
1003 .desired_width(f32::INFINITY)
1004 .desired_rows(3)
1005 .interactive(false)
1006 );
1007 });
1008 ui.add_space(4.0);
1009 }
1010
1011 if ui
1012 .checkbox(
1013 &mut self.config.custom_shader_enabled,
1014 "Enable custom shader",
1015 )
1016 .changed()
1017 {
1018 self.has_changes = true;
1019 changes_this_frame = true;
1020 }
1021
1022 if ui
1023 .checkbox(
1024 &mut self.config.custom_shader_animation,
1025 "Enable shader animation",
1026 )
1027 .changed()
1028 {
1029 self.has_changes = true;
1030 changes_this_frame = true;
1031 }
1032
1033 ui.horizontal(|ui| {
1034 ui.label("Animation speed:");
1035 if ui
1036 .add(egui::Slider::new(
1037 &mut self.config.custom_shader_animation_speed,
1038 0.0..=5.0,
1039 ))
1040 .changed()
1041 {
1042 self.has_changes = true;
1043 changes_this_frame = true;
1044 }
1045 });
1046
1047 ui.horizontal(|ui| {
1048 ui.label("Shader text opacity:");
1049 if ui
1050 .add(egui::Slider::new(
1051 &mut self.config.custom_shader_text_opacity,
1052 0.0..=1.0,
1053 ))
1054 .changed()
1055 {
1056 self.has_changes = true;
1057 changes_this_frame = true;
1058 }
1059 });
1060
1061 if ui
1062 .checkbox(
1063 &mut self.config.custom_shader_full_content,
1064 "Full content mode",
1065 )
1066 .on_hover_text("When enabled, shader receives and can manipulate the full terminal content (text + background). When disabled, shader only provides background and text is composited cleanly on top.")
1067 .changed()
1068 {
1069 self.has_changes = true;
1070 changes_this_frame = true;
1071 }
1072
1073 ui.separator();
1074
1075 // Edit Shader button - only enabled when a shader path is set
1076 let has_shader_path = !self.temp_custom_shader.is_empty();
1077 ui.horizontal(|ui| {
1078 let edit_button = ui.add_enabled(
1079 has_shader_path,
1080 egui::Button::new("Edit Shader..."),
1081 );
1082 if edit_button.clicked() {
1083 // Load shader source from file
1084 let shader_path = crate::config::Config::shader_path(&self.temp_custom_shader);
1085 match std::fs::read_to_string(&shader_path) {
1086 Ok(source) => {
1087 self.shader_editor_source = source.clone();
1088 self.shader_editor_original = source;
1089 self.shader_editor_error = None;
1090 self.shader_editor_visible = true;
1091 }
1092 Err(e) => {
1093 self.shader_editor_error = Some(format!(
1094 "Failed to read shader file '{}': {}",
1095 shader_path.display(),
1096 e
1097 ));
1098 }
1099 }
1100 }
1101 if !has_shader_path {
1102 ui.label("(set shader path first)");
1103 }
1104 });
1105 });
1106 }
1107
1108 // Cursor
1109 if section_matches("Cursor", &["Style", "Blink", "Blink interval"]) {
1110 insert_section_separator(ui, &mut section_shown);
1111 matches_found = true;
1112
1113 ui.collapsing("Cursor", |ui| {
1114 ui.horizontal(|ui| {
1115 ui.label("Style:");
1116 let current = match self.config.cursor_style {
1117 CursorStyle::Block => 0,
1118 CursorStyle::Beam => 1,
1119 CursorStyle::Underline => 2,
1120 };
1121 let mut selected = current;
1122 egui::ComboBox::from_id_salt("cursor_style")
1123 .selected_text(match current {
1124 0 => "Block",
1125 1 => "Beam",
1126 2 => "Underline",
1127 _ => "Unknown",
1128 })
1129 .show_ui(ui, |ui| {
1130 ui.selectable_value(&mut selected, 0, "Block");
1131 ui.selectable_value(&mut selected, 1, "Beam");
1132 ui.selectable_value(&mut selected, 2, "Underline");
1133 });
1134 if selected != current {
1135 self.config.cursor_style = match selected {
1136 0 => CursorStyle::Block,
1137 1 => CursorStyle::Beam,
1138 2 => CursorStyle::Underline,
1139 _ => CursorStyle::Block,
1140 };
1141 self.has_changes = true;
1142 }
1143 });
1144
1145 if ui
1146 .checkbox(&mut self.config.cursor_blink, "Cursor blink")
1147 .changed()
1148 {
1149 self.has_changes = true;
1150 changes_this_frame = true;
1151 }
1152
1153 ui.horizontal(|ui| {
1154 ui.label("Blink interval (ms):");
1155 if ui
1156 .add(egui::Slider::new(
1157 &mut self.config.cursor_blink_interval,
1158 100..=2000,
1159 ))
1160 .changed()
1161 {
1162 self.has_changes = true;
1163 changes_this_frame = true;
1164 }
1165 });
1166 });
1167 }
1168
1169 // Selection & Clipboard
1170 if section_matches(
1171 "Selection & Clipboard",
1172 &[
1173 "Auto-copy",
1174 "Trailing newline",
1175 "Middle-click",
1176 "Max clipboard",
1177 ],
1178 ) {
1179 insert_section_separator(ui, &mut section_shown);
1180 matches_found = true;
1181
1182 ui.collapsing("Selection & Clipboard", |ui| {
1183 if ui
1184 .checkbox(
1185 &mut self.config.auto_copy_selection,
1186 "Auto-copy selection",
1187 )
1188 .changed()
1189 {
1190 self.has_changes = true;
1191 changes_this_frame = true;
1192 }
1193
1194 if ui
1195 .checkbox(
1196 &mut self.config.copy_trailing_newline,
1197 "Include trailing newline when copying",
1198 )
1199 .changed()
1200 {
1201 self.has_changes = true;
1202 changes_this_frame = true;
1203 }
1204
1205 if ui
1206 .checkbox(&mut self.config.middle_click_paste, "Middle-click paste")
1207 .changed()
1208 {
1209 self.has_changes = true;
1210 changes_this_frame = true;
1211 }
1212
1213 ui.horizontal(|ui| {
1214 ui.label("Max clipboard sync events:");
1215 if ui
1216 .add(egui::Slider::new(
1217 &mut self.config.clipboard_max_sync_events,
1218 8..=256,
1219 ))
1220 .changed()
1221 {
1222 self.has_changes = true;
1223 changes_this_frame = true;
1224 }
1225 });
1226
1227 ui.horizontal(|ui| {
1228 ui.label("Max clipboard event bytes:");
1229 if ui
1230 .add(egui::Slider::new(
1231 &mut self.config.clipboard_max_event_bytes,
1232 512..=16384,
1233 ))
1234 .changed()
1235 {
1236 self.has_changes = true;
1237 changes_this_frame = true;
1238 }
1239 });
1240 });
1241 }
1242
1243 // Mouse Behavior
1244 if section_matches(
1245 "Mouse Behavior",
1246 &["Scroll speed", "Double-click", "Triple-click"],
1247 ) {
1248 insert_section_separator(ui, &mut section_shown);
1249 matches_found = true;
1250
1251 ui.collapsing("Mouse Behavior", |ui| {
1252 ui.horizontal(|ui| {
1253 ui.label("Scroll speed:");
1254 if ui
1255 .add(egui::Slider::new(
1256 &mut self.config.mouse_scroll_speed,
1257 0.1..=10.0,
1258 ))
1259 .changed()
1260 {
1261 self.has_changes = true;
1262 changes_this_frame = true;
1263 }
1264 });
1265
1266 ui.horizontal(|ui| {
1267 ui.label("Double-click threshold (ms):");
1268 if ui
1269 .add(egui::Slider::new(
1270 &mut self.config.mouse_double_click_threshold,
1271 100..=1000,
1272 ))
1273 .changed()
1274 {
1275 self.has_changes = true;
1276 changes_this_frame = true;
1277 }
1278 });
1279
1280 ui.horizontal(|ui| {
1281 ui.label("Triple-click threshold (ms):");
1282 if ui
1283 .add(egui::Slider::new(
1284 &mut self.config.mouse_triple_click_threshold,
1285 100..=1000,
1286 ))
1287 .changed()
1288 {
1289 self.has_changes = true;
1290 changes_this_frame = true;
1291 }
1292 });
1293 });
1294 }
1295
1296 // Scrollbar
1297 if section_matches(
1298 "Scrollbar",
1299 &[
1300 "Width",
1301 "Autohide",
1302 "Position",
1303 "Thumb color",
1304 "Track color",
1305 ],
1306 ) {
1307 insert_section_separator(ui, &mut section_shown);
1308 matches_found = true;
1309
1310 ui.collapsing("Scrollbar", |ui| {
1311 ui.horizontal(|ui| {
1312 ui.label("Width:");
1313 if ui
1314 .add(egui::Slider::new(
1315 &mut self.config.scrollbar_width,
1316 4.0..=50.0,
1317 ))
1318 .changed()
1319 {
1320 self.has_changes = true;
1321 changes_this_frame = true;
1322 }
1323 });
1324
1325 ui.horizontal(|ui| {
1326 ui.label("Autohide delay (ms, 0=never):");
1327 if ui
1328 .add(egui::Slider::new(
1329 &mut self.config.scrollbar_autohide_delay,
1330 0..=5000,
1331 ))
1332 .changed()
1333 {
1334 self.has_changes = true;
1335 changes_this_frame = true;
1336 }
1337 });
1338
1339 ui.horizontal(|ui| {
1340 ui.label("Position:");
1341 ui.label("Right (only)");
1342 });
1343
1344 ui.horizontal(|ui| {
1345 ui.label("Thumb color:");
1346 let mut thumb = egui::Color32::from_rgba_unmultiplied(
1347 (self.config.scrollbar_thumb_color[0] * 255.0) as u8,
1348 (self.config.scrollbar_thumb_color[1] * 255.0) as u8,
1349 (self.config.scrollbar_thumb_color[2] * 255.0) as u8,
1350 (self.config.scrollbar_thumb_color[3] * 255.0) as u8,
1351 );
1352 if egui::color_picker::color_edit_button_srgba(
1353 ui,
1354 &mut thumb,
1355 egui::color_picker::Alpha::Opaque,
1356 )
1357 .changed()
1358 {
1359 self.config.scrollbar_thumb_color = [
1360 thumb.r() as f32 / 255.0,
1361 thumb.g() as f32 / 255.0,
1362 thumb.b() as f32 / 255.0,
1363 thumb.a() as f32 / 255.0,
1364 ];
1365 self.has_changes = true;
1366 changes_this_frame = true;
1367 }
1368 });
1369
1370 ui.horizontal(|ui| {
1371 ui.label("Track color:");
1372 let mut track = egui::Color32::from_rgba_unmultiplied(
1373 (self.config.scrollbar_track_color[0] * 255.0) as u8,
1374 (self.config.scrollbar_track_color[1] * 255.0) as u8,
1375 (self.config.scrollbar_track_color[2] * 255.0) as u8,
1376 (self.config.scrollbar_track_color[3] * 255.0) as u8,
1377 );
1378 if egui::color_picker::color_edit_button_srgba(
1379 ui,
1380 &mut track,
1381 egui::color_picker::Alpha::Opaque,
1382 )
1383 .changed()
1384 {
1385 self.config.scrollbar_track_color = [
1386 track.r() as f32 / 255.0,
1387 track.g() as f32 / 255.0,
1388 track.b() as f32 / 255.0,
1389 track.a() as f32 / 255.0,
1390 ];
1391 self.has_changes = true;
1392 changes_this_frame = true;
1393 }
1394 });
1395 });
1396 }
1397
1398 // Bell & Notifications
1399 if section_matches(
1400 "Bell & Notifications",
1401 &[
1402 "Visual bell",
1403 "Audio bell",
1404 "Desktop notifications",
1405 "Activity",
1406 "Silence",
1407 "Notification buffer",
1408 ],
1409 ) {
1410 insert_section_separator(ui, &mut section_shown);
1411 matches_found = true;
1412
1413 ui.collapsing("Bell & Notifications", |ui| {
1414 ui.label("Bell Settings:");
1415 if ui
1416 .checkbox(&mut self.config.notification_bell_visual, "Visual bell")
1417 .changed()
1418 {
1419 self.has_changes = true;
1420 changes_this_frame = true;
1421 }
1422
1423 ui.horizontal(|ui| {
1424 ui.label("Audio bell volume (0=off):");
1425 if ui
1426 .add(egui::Slider::new(
1427 &mut self.config.notification_bell_sound,
1428 0..=100,
1429 ))
1430 .changed()
1431 {
1432 self.has_changes = true;
1433 changes_this_frame = true;
1434 }
1435 });
1436
1437 if ui
1438 .checkbox(
1439 &mut self.config.notification_bell_desktop,
1440 "Desktop notifications for bell",
1441 )
1442 .changed()
1443 {
1444 self.has_changes = true;
1445 changes_this_frame = true;
1446 }
1447
1448 ui.separator();
1449 ui.label("Activity Notifications:");
1450 if ui
1451 .checkbox(
1452 &mut self.config.notification_activity_enabled,
1453 "Notify on activity after inactivity",
1454 )
1455 .changed()
1456 {
1457 self.has_changes = true;
1458 changes_this_frame = true;
1459 }
1460
1461 ui.horizontal(|ui| {
1462 ui.label("Activity threshold (seconds):");
1463 if ui
1464 .add(egui::Slider::new(
1465 &mut self.config.notification_activity_threshold,
1466 1..=300,
1467 ))
1468 .changed()
1469 {
1470 self.has_changes = true;
1471 changes_this_frame = true;
1472 }
1473 });
1474
1475 ui.separator();
1476 ui.label("Silence Notifications:");
1477 if ui
1478 .checkbox(
1479 &mut self.config.notification_silence_enabled,
1480 "Notify after prolonged silence",
1481 )
1482 .changed()
1483 {
1484 self.has_changes = true;
1485 changes_this_frame = true;
1486 }
1487
1488 ui.horizontal(|ui| {
1489 ui.label("Silence threshold (seconds):");
1490 if ui
1491 .add(egui::Slider::new(
1492 &mut self.config.notification_silence_threshold,
1493 10..=600,
1494 ))
1495 .changed()
1496 {
1497 self.has_changes = true;
1498 changes_this_frame = true;
1499 }
1500 });
1501
1502 ui.separator();
1503 ui.horizontal(|ui| {
1504 ui.label("Max notification buffer:");
1505 if ui
1506 .add(egui::Slider::new(
1507 &mut self.config.notification_max_buffer,
1508 10..=1000,
1509 ))
1510 .changed()
1511 {
1512 self.has_changes = true;
1513 changes_this_frame = true;
1514 }
1515 });
1516 });
1517 }
1518
1519 // Shell Configuration
1520 if section_matches(
1521 "Shell Configuration",
1522 &["Custom shell", "Shell args", "Working directory"],
1523 ) {
1524 insert_section_separator(ui, &mut section_shown);
1525 matches_found = true;
1526
1527 ui.collapsing("Shell Configuration", |ui| {
1528 ui.horizontal(|ui| {
1529 ui.label("Custom shell (optional):");
1530 if ui
1531 .text_edit_singleline(&mut self.temp_custom_shell)
1532 .changed()
1533 {
1534 self.config.custom_shell = if self.temp_custom_shell.is_empty()
1535 {
1536 None
1537 } else {
1538 Some(self.temp_custom_shell.clone())
1539 };
1540 self.has_changes = true;
1541 }
1542
1543 if ui.button("Browse…").clicked()
1544 && let Some(path) = self.pick_file_path("Select shell binary")
1545 {
1546 self.temp_custom_shell = path.clone();
1547 self.config.custom_shell = Some(path);
1548 self.has_changes = true;
1549 }
1550 });
1551
1552 ui.horizontal(|ui| {
1553 ui.label("Shell args (space-separated):");
1554 if ui.text_edit_singleline(&mut self.temp_shell_args).changed() {
1555 self.config.shell_args = if self.temp_shell_args.is_empty() {
1556 None
1557 } else {
1558 Some(
1559 self.temp_shell_args
1560 .split_whitespace()
1561 .map(String::from)
1562 .collect(),
1563 )
1564 };
1565 self.has_changes = true;
1566 }
1567 });
1568
1569 ui.horizontal(|ui| {
1570 ui.label("Working directory (optional):");
1571 if ui
1572 .text_edit_singleline(&mut self.temp_working_directory)
1573 .changed()
1574 {
1575 self.config.working_directory =
1576 if self.temp_working_directory.is_empty() {
1577 None
1578 } else {
1579 Some(self.temp_working_directory.clone())
1580 };
1581 self.has_changes = true;
1582 }
1583
1584 if ui.button("Browse…").clicked()
1585 && let Some(path) =
1586 self.pick_folder_path("Select working directory")
1587 {
1588 self.temp_working_directory = path.clone();
1589 self.config.working_directory = Some(path);
1590 self.has_changes = true;
1591 }
1592 });
1593 });
1594 }
1595
1596 // Screenshot
1597 if section_matches("Screenshot", &["Format", "png", "jpeg", "svg", "html"]) {
1598 insert_section_separator(ui, &mut section_shown);
1599 matches_found = true;
1600
1601 ui.collapsing("Screenshot", |ui| {
1602 ui.horizontal(|ui| {
1603 ui.label("Format:");
1604
1605 let options = ["png", "jpeg", "svg", "html"];
1606 let mut selected = self.config.screenshot_format.clone();
1607
1608 egui::ComboBox::from_id_salt("screenshot_format")
1609 .width(140.0)
1610 .selected_text(selected.as_str())
1611 .show_ui(ui, |ui| {
1612 for opt in options {
1613 ui.selectable_value(
1614 &mut selected,
1615 opt.to_string(),
1616 opt,
1617 );
1618 }
1619 });
1620
1621 if selected != self.config.screenshot_format {
1622 self.config.screenshot_format = selected;
1623 self.has_changes = true;
1624 changes_this_frame = true;
1625 }
1626 });
1627 ui.label("Supported: png, jpeg, svg, html");
1628 });
1629 }
1630
1631 if !matches_found && !query.is_empty() {
1632 ui.label(format!("No settings match \"{}\"", self.search_query));
1633 }
1634 });
1635
1636 // Fixed footer with action buttons (outside ScrollArea)
1637 ui.separator();
1638 ui.horizontal(|ui| {
1639 if ui.button("Save").clicked() {
1640 save_requested = true;
1641 }
1642
1643 if ui.button("Discard").clicked() {
1644 discard_requested = true;
1645 }
1646
1647 if ui.button("Close").clicked() {
1648 close_requested = true;
1649 }
1650
1651 if self.has_changes {
1652 ui.colored_label(egui::Color32::YELLOW, "* Unsaved changes");
1653 }
1654 });
1655 });
1656 }
1657
1658 // Show shader editor window if visible
1659 if self.shader_editor_visible {
1660 let mut shader_editor_open = true;
1661 let mut apply_clicked = false;
1662 let mut cancel_clicked = false;
1663 let mut save_to_file_clicked = false;
1664
1665 // Calculate 90% of viewport height
1666 let viewport = ctx.input(|i| i.viewport_rect());
1667 let window_height = viewport.height() * 0.9;
1668
1669 Window::new("Shader Editor")
1670 .resizable(true)
1671 .default_width(900.0)
1672 .default_height(window_height)
1673 .default_pos(viewport.center())
1674 .pivot(egui::Align2::CENTER_CENTER)
1675 .open(&mut shader_editor_open)
1676 .frame(
1677 Frame::window(&ctx.style())
1678 .fill(Color32::from_rgba_unmultiplied(20, 20, 20, 255))
1679 .stroke(egui::Stroke::new(1.0, Color32::from_rgb(60, 60, 60)))
1680 .shadow(Shadow {
1681 offset: [2, 2],
1682 blur: 8,
1683 spread: 0,
1684 color: Color32::from_black_alpha(128),
1685 }),
1686 )
1687 .show(ctx, |ui| {
1688 ui.heading("GLSL Shader Editor (F11 to toggle)");
1689 ui.horizontal(|ui| {
1690 ui.label("Edit your custom shader below. Click Apply to test changes.");
1691 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
1692 ui.small("Ctrl+F to search");
1693 });
1694 });
1695 ui.separator();
1696
1697 // Search bar (Ctrl+F to toggle)
1698 let ctrl_f = ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F));
1699 let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
1700
1701 if ctrl_f {
1702 self.shader_search_visible = !self.shader_search_visible;
1703 if self.shader_search_visible {
1704 // Focus will be requested below
1705 }
1706 }
1707 if escape && self.shader_search_visible {
1708 self.shader_search_visible = false;
1709 }
1710
1711 if self.shader_search_visible {
1712 ui.horizontal(|ui| {
1713 ui.label("Find:");
1714 let search_field = ui.add(
1715 egui::TextEdit::singleline(&mut self.shader_search_query)
1716 .desired_width(200.0)
1717 .hint_text("Search..."),
1718 );
1719
1720 // Focus search field when first shown
1721 if ctrl_f {
1722 search_field.request_focus();
1723 }
1724
1725 // Update matches when query changes
1726 if search_field.changed() {
1727 self.update_shader_search_matches();
1728 }
1729
1730 // Handle Enter for next match, Shift+Enter for previous
1731 let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter));
1732 let shift_held = ui.input(|i| i.modifiers.shift);
1733
1734 // Previous/Next buttons
1735 let has_matches = !self.shader_search_matches.is_empty();
1736 if ui
1737 .add_enabled(has_matches, egui::Button::new("◀"))
1738 .on_hover_text("Previous (Shift+Enter)")
1739 .clicked()
1740 || (enter_pressed && shift_held && has_matches)
1741 {
1742 self.shader_search_previous();
1743 }
1744 if ui
1745 .add_enabled(has_matches, egui::Button::new("▶"))
1746 .on_hover_text("Next (Enter)")
1747 .clicked()
1748 || (enter_pressed && !shift_held && has_matches)
1749 {
1750 self.shader_search_next();
1751 }
1752
1753 // Match count
1754 if self.shader_search_query.is_empty() {
1755 ui.label("");
1756 } else if self.shader_search_matches.is_empty() {
1757 ui.colored_label(Color32::from_rgb(255, 100, 100), "No matches");
1758 } else {
1759 ui.label(format!(
1760 "{} / {}",
1761 self.shader_search_current + 1,
1762 self.shader_search_matches.len()
1763 ));
1764 }
1765
1766 // Close button
1767 if ui.button("✕").on_hover_text("Close (Esc)").clicked() {
1768 self.shader_search_visible = false;
1769 }
1770 });
1771 ui.separator();
1772 }
1773
1774 // Show error dialog if there's an error
1775 let mut dismiss_error = false;
1776 if let Some(error) = &self.shader_editor_error {
1777 let error_text = error.clone();
1778 let shader_path =
1779 crate::config::Config::shader_path(&self.temp_custom_shader);
1780 let full_error =
1781 format!("File: {}\n\n{}", shader_path.display(), error_text);
1782
1783 ui.group(|ui| {
1784 ui.horizontal(|ui| {
1785 ui.colored_label(
1786 Color32::from_rgb(255, 100, 100),
1787 "⚠ Shader Compilation Error",
1788 );
1789 ui.with_layout(
1790 egui::Layout::right_to_left(egui::Align::Center),
1791 |ui| {
1792 if ui.button("Dismiss").clicked() {
1793 dismiss_error = true;
1794 }
1795 if ui.button("Copy").clicked()
1796 && let Ok(mut clipboard) = Clipboard::new()
1797 {
1798 let _ = clipboard.set_text(full_error.clone());
1799 }
1800 },
1801 );
1802 });
1803 ui.label(format!("File: {}", shader_path.display()));
1804 ui.separator();
1805 // Multiline selectable text for copying
1806 egui::ScrollArea::vertical()
1807 .max_height(120.0)
1808 .show(ui, |ui| {
1809 ui.add(
1810 egui::TextEdit::multiline(&mut error_text.as_str())
1811 .font(egui::TextStyle::Monospace)
1812 .desired_width(f32::INFINITY)
1813 .interactive(true),
1814 );
1815 });
1816 });
1817 ui.separator();
1818 }
1819 if dismiss_error {
1820 self.shader_editor_error = None;
1821 }
1822
1823 // Shader source editor
1824 // Note: code_editor() provides a dark theme optimized for code
1825 let available_height = ui.available_height() - 60.0; // Reserve space for buttons
1826
1827 // Get current search match position before rendering
1828 let search_selection = self.shader_search_current_pos().map(|pos| {
1829 let end = pos + self.shader_search_query.len();
1830 (pos, end)
1831 });
1832
1833 let editor_id = egui::Id::new("shader_editor_textedit");
1834
1835 egui::ScrollArea::both()
1836 .auto_shrink([false, false])
1837 .max_height(available_height)
1838 .show(ui, |ui| {
1839 let response = ui.add(
1840 egui::TextEdit::multiline(&mut self.shader_editor_source)
1841 .id(editor_id)
1842 .font(egui::TextStyle::Monospace)
1843 .code_editor()
1844 .desired_width(f32::INFINITY)
1845 .min_size(egui::vec2(
1846 ui.available_width(),
1847 available_height - 20.0,
1848 )),
1849 );
1850
1851 // If we have a search match, select it and scroll to it
1852 if let Some((start, end)) = search_selection
1853 && let Some(mut state) =
1854 egui::TextEdit::load_state(ui.ctx(), editor_id)
1855 {
1856 // Create a cursor range that selects the match
1857 let ccursor_range = egui::text::CCursorRange::two(
1858 egui::text::CCursor::new(start),
1859 egui::text::CCursor::new(end),
1860 );
1861 state.cursor.set_char_range(Some(ccursor_range));
1862 state.store(ui.ctx(), editor_id);
1863
1864 // Request scroll to cursor
1865 ui.scroll_to_rect(response.rect, Some(egui::Align::Center));
1866 }
1867 });
1868
1869 ui.separator();
1870
1871 // Action buttons
1872 ui.horizontal(|ui| {
1873 if ui.button("Apply").clicked() {
1874 apply_clicked = true;
1875 }
1876 ui.label("|");
1877 if ui.button("Save to File").clicked() {
1878 save_to_file_clicked = true;
1879 }
1880 ui.label("|");
1881 if ui.button("Find").on_hover_text("Ctrl+F").clicked() {
1882 self.shader_search_visible = !self.shader_search_visible;
1883 }
1884 ui.label("|");
1885 if ui.button("Revert").clicked() {
1886 self.shader_editor_source = self.shader_editor_original.clone();
1887 self.shader_editor_error = None;
1888 }
1889 ui.label("|");
1890 if ui.button("Close").clicked() {
1891 cancel_clicked = true;
1892 }
1893 });
1894 });
1895
1896 // Handle shader editor actions
1897 if apply_clicked {
1898 shader_apply_result = Some(ShaderEditorResult {
1899 source: self.shader_editor_source.clone(),
1900 });
1901 // Don't close editor - let user see if it worked or get error
1902 }
1903
1904 if save_to_file_clicked {
1905 // Save current source to the shader file
1906 let shader_path = crate::config::Config::shader_path(&self.temp_custom_shader);
1907 match std::fs::write(&shader_path, &self.shader_editor_source) {
1908 Ok(()) => {
1909 self.shader_editor_original = self.shader_editor_source.clone();
1910 log::info!("Shader saved to {}", shader_path.display());
1911 }
1912 Err(e) => {
1913 self.shader_editor_error = Some(format!(
1914 "Failed to save shader file '{}': {}",
1915 shader_path.display(),
1916 e
1917 ));
1918 }
1919 }
1920 }
1921
1922 if cancel_clicked || !shader_editor_open {
1923 self.shader_editor_visible = false;
1924 self.shader_editor_source.clear();
1925 self.shader_editor_original.clear();
1926 self.shader_editor_error = None;
1927 // Clear search state
1928 self.shader_search_query.clear();
1929 self.shader_search_matches.clear();
1930 self.shader_search_current = 0;
1931 self.shader_search_visible = false;
1932 }
1933 }
1934
1935 // Create Shader Dialog
1936 if self.show_create_shader_dialog {
1937 let mut close_dialog = false;
1938 let mut create_shader = false;
1939
1940 Window::new("Create New Shader")
1941 .collapsible(false)
1942 .resizable(false)
1943 .default_width(400.0)
1944 .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
1945 .show(ctx, |ui| {
1946 ui.label("Enter a name for the new shader file:");
1947 ui.label("(will be saved as .glsl in the shaders folder)");
1948 ui.add_space(8.0);
1949
1950 ui.horizontal(|ui| {
1951 ui.label("Name:");
1952 let response = ui.text_edit_singleline(&mut self.new_shader_name);
1953 if response.lost_focus()
1954 && ui.input(|i| i.key_pressed(egui::Key::Enter))
1955 && !self.new_shader_name.is_empty()
1956 {
1957 create_shader = true;
1958 }
1959 });
1960
1961 ui.add_space(8.0);
1962 ui.horizontal(|ui| {
1963 if ui.button("Create").clicked() && !self.new_shader_name.is_empty() {
1964 create_shader = true;
1965 }
1966 if ui.button("Cancel").clicked() {
1967 close_dialog = true;
1968 }
1969 });
1970 });
1971
1972 if create_shader {
1973 // Ensure filename ends with .glsl
1974 let mut filename = self.new_shader_name.clone();
1975 if !filename.ends_with(".glsl")
1976 && !filename.ends_with(".frag")
1977 && !filename.ends_with(".shader")
1978 {
1979 filename.push_str(".glsl");
1980 }
1981
1982 let shader_path = crate::config::Config::shaders_dir().join(&filename);
1983
1984 // Check if file already exists
1985 if shader_path.exists() {
1986 self.shader_editor_error =
1987 Some(format!("Shader '{}' already exists!", filename));
1988 } else {
1989 // Create the shader with a basic template
1990 let template = r#"// Custom shader for par-term
1991// Available uniforms:
1992// iTime - Time in seconds (when animation enabled)
1993// iResolution - Viewport resolution (vec2)
1994// iChannel0 - Terminal content texture (sampler2D)
1995// iOpacity - Window opacity (float)
1996// iTextOpacity - Text opacity (float)
1997
1998void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1999 vec2 uv = fragCoord / iResolution.xy;
2000
2001 // Sample terminal content
2002 vec4 terminal = texture(iChannel0, uv);
2003
2004 // Example: simple color tint based on position
2005 vec3 tint = vec3(0.8, 0.9, 1.0);
2006
2007 // Mix terminal content with effect
2008 vec3 color = terminal.rgb * tint;
2009
2010 fragColor = vec4(color, terminal.a);
2011}
2012"#;
2013
2014 match std::fs::write(&shader_path, template) {
2015 Ok(()) => {
2016 log::info!("Created new shader: {}", shader_path.display());
2017 // Update the shader list
2018 self.refresh_shaders();
2019 // Select the new shader
2020 self.temp_custom_shader = filename.clone();
2021 self.config.custom_shader = Some(filename);
2022 self.has_changes = true;
2023 // Open the shader editor with the new shader
2024 self.shader_editor_source = template.to_string();
2025 self.shader_editor_original = template.to_string();
2026 self.shader_editor_error = None;
2027 self.shader_editor_visible = true;
2028 close_dialog = true;
2029 }
2030 Err(e) => {
2031 self.shader_editor_error =
2032 Some(format!("Failed to create shader: {}", e));
2033 }
2034 }
2035 }
2036 }
2037
2038 if close_dialog {
2039 self.show_create_shader_dialog = false;
2040 self.new_shader_name.clear();
2041 }
2042 }
2043
2044 // Delete Shader Confirmation Dialog
2045 if self.show_delete_shader_dialog {
2046 let mut close_dialog = false;
2047 let mut delete_shader = false;
2048 let shader_name = self.temp_custom_shader.clone();
2049
2050 Window::new("Delete Shader")
2051 .collapsible(false)
2052 .resizable(false)
2053 .default_width(350.0)
2054 .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
2055 .show(ctx, |ui| {
2056 ui.label(format!(
2057 "Are you sure you want to delete '{}'?",
2058 shader_name
2059 ));
2060 ui.label("This action cannot be undone.");
2061 ui.add_space(12.0);
2062 ui.horizontal(|ui| {
2063 if ui.button("Delete").clicked() {
2064 delete_shader = true;
2065 }
2066 if ui.button("Cancel").clicked() {
2067 close_dialog = true;
2068 }
2069 });
2070 });
2071
2072 if delete_shader {
2073 let shader_path = crate::config::Config::shader_path(&shader_name);
2074 match std::fs::remove_file(&shader_path) {
2075 Ok(()) => {
2076 log::info!("Deleted shader: {}", shader_path.display());
2077 // Clear the selection
2078 self.temp_custom_shader.clear();
2079 self.config.custom_shader = None;
2080 self.has_changes = true;
2081 // Refresh the shader list
2082 self.refresh_shaders();
2083 close_dialog = true;
2084 }
2085 Err(e) => {
2086 self.shader_editor_error = Some(format!("Failed to delete shader: {}", e));
2087 close_dialog = true;
2088 }
2089 }
2090 }
2091
2092 if close_dialog {
2093 self.show_delete_shader_dialog = false;
2094 }
2095 }
2096
2097 // Update visibility based on window state (only if settings window is being shown)
2098 if self.visible && (!open || close_requested) {
2099 self.visible = false;
2100 }
2101
2102 // Handle save request
2103 let config_to_save = if save_requested {
2104 if self.font_pending_changes {
2105 self.apply_font_changes();
2106 }
2107 self.has_changes = false;
2108 Some(self.config.clone())
2109 } else {
2110 None
2111 };
2112
2113 // Handle discard request
2114 if discard_requested {
2115 self.has_changes = false;
2116 self.sync_font_temps_from_config();
2117 // No-op for live updates when discarded
2118 }
2119
2120 // Push live config while the settings window is open to guarantee real-time updates.
2121 let config_for_live_update = if self.visible {
2122 // Only log when the value actually changes to avoid spam
2123 if (self.config.window_opacity - self.last_live_opacity).abs() > f32::EPSILON {
2124 log::info!(
2125 "SettingsUI: live opacity {:.3} (last {:.3})",
2126 self.config.window_opacity,
2127 self.last_live_opacity
2128 );
2129 self.last_live_opacity = self.config.window_opacity;
2130 }
2131 Some(self.config.clone())
2132 } else {
2133 None
2134 };
2135
2136 (config_to_save, config_for_live_update, shader_apply_result)
2137 }
2138}
2139
2140// Note: Syntax highlighting for shader editor could be added via egui_extras::syntax_highlighting
2141// when the API stabilizes. The code_editor() mode provides a dark theme suitable for code editing.