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