1use anyhow::{Context, Result};
18use par_term_emu_core_rust::cursor::CursorStyle;
19use std::collections::BTreeMap;
20use std::path::Path;
21use std::time::Instant;
22use wgpu::util::DeviceExt;
23use wgpu::*;
24
25mod builtin_textures;
26mod cubemap;
27mod cursor;
28mod hot_reload;
29pub mod pipeline;
30mod state;
31pub mod textures;
32pub mod transpiler;
33pub mod types;
34mod uniforms;
35
36use cubemap::CubemapTexture;
37use pipeline::{
38 BindGroupInputs, create_bind_group, create_bind_group_layout, create_render_pipeline,
39};
40use textures::{ChannelTexture, load_channel_textures};
41use transpiler::transpile_glsl_to_wgsl;
42
43#[cfg_attr(not(debug_assertions), allow(dead_code))]
50fn debug_shader_wgsl_filename(shader_name: &str) -> String {
51 crate::shader_debug::transpiled_wgsl_path(shader_name)
52 .to_string_lossy()
53 .into_owned()
54}
55
56fn write_debug_shader_wgsl(shader_name: &str, wgsl_source: &str) {
66 #[cfg(debug_assertions)]
67 {
68 let debug_filename = debug_shader_wgsl_filename(shader_name);
69
70 let _ = std::fs::remove_file(&debug_filename);
74
75 let mut opts = std::fs::OpenOptions::new();
76 opts.write(true).create_new(true);
77 #[cfg(unix)]
78 {
79 use std::os::unix::fs::OpenOptionsExt;
80 opts.mode(0o600);
81 }
82
83 let result = opts
84 .open(&debug_filename)
85 .and_then(|mut f| std::io::Write::write_all(&mut f, wgsl_source.as_bytes()));
86
87 match result {
88 Ok(()) => log::info!("Wrote debug shader to {}", debug_filename),
89 Err(e) => log::warn!("Failed to write debug shader: {}", e),
90 }
91 }
92 #[cfg(not(debug_assertions))]
93 {
94 let _ = (shader_name, wgsl_source);
95 }
96}
97
98fn animation_start_after_enabled_update(
99 currently_enabled: bool,
100 enabled: bool,
101 current_start: Instant,
102 now: Instant,
103) -> Instant {
104 if enabled && !currently_enabled {
105 now
106 } else {
107 current_start
108 }
109}
110
111pub struct CustomShaderRenderer {
113 pub(crate) pipeline: RenderPipeline,
115 pub(crate) bind_group: BindGroup,
117 pub(crate) uniform_buffer: Buffer,
119 pub(crate) custom_uniform_buffer: Buffer,
121 pub(crate) intermediate_texture: Texture,
123 pub(crate) intermediate_texture_view: TextureView,
125 pub(crate) start_time: Instant,
127 pub(crate) animation_enabled: bool,
129 pub(crate) animation_speed: f32,
131 pub(crate) texture_width: u32,
133 pub(crate) texture_height: u32,
134 pub(crate) surface_format: TextureFormat,
136 pub(crate) bind_group_layout: BindGroupLayout,
138 pub(crate) sampler: Sampler,
140 pub(crate) scale_factor: f32,
142 pub(crate) window_opacity: f32,
144 pub(crate) keep_text_opaque: bool,
146 pub(crate) full_content_mode: bool,
148 pub(crate) brightness: f32,
150 pub(crate) auto_dim_under_text: bool,
152 pub(crate) auto_dim_strength: f32,
154 pub(crate) frame_count: u32,
156 pub(crate) last_frame_time: Instant,
158 pub(crate) mouse_position: [f32; 2],
160 pub(crate) mouse_click_position: [f32; 2],
162 pub(crate) mouse_button_down: bool,
164 pub(crate) frame_time_accumulator: f32,
166 pub(crate) frames_in_second: u32,
168 pub(crate) current_frame_rate: f32,
170
171 pub(crate) current_cursor_pos: (usize, usize),
174 pub(crate) previous_cursor_pos: (usize, usize),
176 pub(crate) current_cursor_color: [f32; 4],
178 pub(crate) previous_cursor_color: [f32; 4],
180 pub(crate) current_cursor_opacity: f32,
182 pub(crate) previous_cursor_opacity: f32,
184 pub(crate) cursor_change_time: f32,
186 pub(crate) current_cursor_style: CursorStyle,
188 pub(crate) previous_cursor_style: CursorStyle,
190 pub(crate) cursor_cell_width: f32,
192 pub(crate) cursor_cell_height: f32,
194 pub(crate) cursor_window_padding: f32,
196 pub(crate) cursor_content_offset_y: f32,
198 pub(crate) cursor_content_offset_x: f32,
200
201 pub(crate) cursor_shader_color: [f32; 4],
204 pub(crate) cursor_trail_duration: f32,
206 pub(crate) cursor_glow_radius: f32,
208 pub(crate) cursor_glow_intensity: f32,
210
211 pub(crate) key_press_time: f32,
214
215 pub(crate) channel_textures: [ChannelTexture; 4],
218
219 pub(crate) cubemap: CubemapTexture,
222
223 pub(crate) use_background_as_channel0: bool,
226 pub(crate) background_channel_texture: Option<ChannelTexture>,
229 pub(crate) background_channel0_blend_mode: par_term_config::ShaderBackgroundBlendMode,
231
232 pub(crate) background_color: [f32; 4],
237
238 pub(crate) progress_data: [f32; 4],
241
242 pub(crate) command_data: [f32; 4],
245
246 pub(crate) focused_pane: [f32; 4],
249
250 pub(crate) scroll_data: [f32; 4],
253
254 pub(crate) content_inset_right: f32,
258
259 pub(crate) custom_controls: Vec<par_term_config::ShaderControl>,
262 pub(crate) custom_uniform_values: BTreeMap<String, par_term_config::ShaderUniformValue>,
264}
265
266pub struct CustomShaderRendererConfig<'a> {
268 pub surface_format: TextureFormat,
269 pub shader_path: &'a Path,
270 pub width: u32,
271 pub height: u32,
272 pub animation_enabled: bool,
273 pub animation_speed: f32,
274 pub window_opacity: f32,
275 pub full_content_mode: bool,
276 pub channel_paths: &'a [Option<std::path::PathBuf>; 4],
277 pub cubemap_path: Option<&'a Path>,
278 pub custom_uniforms: &'a BTreeMap<String, par_term_config::ShaderUniformValue>,
279 pub background_channel0_blend_mode: par_term_config::ShaderBackgroundBlendMode,
280}
281
282impl CustomShaderRenderer {
283 pub fn new(
285 device: &Device,
286 queue: &Queue,
287 config: CustomShaderRendererConfig<'_>,
288 ) -> Result<Self> {
289 let CustomShaderRendererConfig {
290 surface_format,
291 shader_path,
292 width,
293 height,
294 animation_enabled,
295 animation_speed,
296 window_opacity,
297 full_content_mode,
298 channel_paths,
299 cubemap_path,
300 custom_uniforms,
301 background_channel0_blend_mode,
302 } = config;
303 let glsl_source = std::fs::read_to_string(shader_path)
305 .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
306
307 let control_parse = par_term_config::parse_shader_controls(&glsl_source);
308 for warning in &control_parse.warnings {
309 log::warn!(
310 "Shader control warning line {}: {}",
311 warning.line,
312 warning.message
313 );
314 }
315 let custom_controls = control_parse.controls;
316 let custom_uniform_values = custom_uniforms.clone();
317
318 let wgsl_source = transpile_glsl_to_wgsl(&glsl_source, shader_path)?;
320
321 log::info!(
322 "Loaded custom shader from {} ({} bytes GLSL -> {} bytes WGSL)",
323 shader_path.display(),
324 glsl_source.len(),
325 wgsl_source.len()
326 );
327 log::debug!("Generated WGSL:\n{}", wgsl_source);
328
329 let shader_name = shader_path
331 .file_stem()
332 .and_then(|s| s.to_str())
333 .unwrap_or("unknown");
334 write_debug_shader_wgsl(shader_name, &wgsl_source);
335
336 let module = naga::front::wgsl::parse_str(&wgsl_source)
338 .context("Custom shader WGSL parse failed")?;
339 let _info = naga::valid::Validator::new(
340 naga::valid::ValidationFlags::all(),
341 naga::valid::Capabilities::empty(),
342 )
343 .validate(&module)
344 .context("Custom shader WGSL validation failed")?;
345
346 let shader_module = device.create_shader_module(ShaderModuleDescriptor {
347 label: Some("Custom Shader Module"),
348 source: ShaderSource::Wgsl(wgsl_source.clone().into()),
349 });
350
351 let (intermediate_texture, intermediate_texture_view) =
353 Self::create_intermediate_texture(device, surface_format, width, height);
354
355 let sampler = device.create_sampler(&SamplerDescriptor {
358 label: Some("Custom Shader Sampler"),
359 address_mode_u: AddressMode::ClampToEdge,
360 address_mode_v: AddressMode::ClampToEdge,
361 address_mode_w: AddressMode::ClampToEdge,
362 mag_filter: FilterMode::Nearest,
363 min_filter: FilterMode::Nearest,
364 mipmap_filter: MipmapFilterMode::Nearest,
365 ..Default::default()
366 });
367
368 let channel_textures = load_channel_textures(device, queue, channel_paths);
370
371 let cubemap = match cubemap_path {
373 Some(path) => match CubemapTexture::from_prefix(device, queue, path) {
374 Ok(cm) => cm,
375 Err(e) => {
376 log::error!("Failed to load cubemap '{}': {}", path.display(), e);
377 CubemapTexture::placeholder(device, queue)
378 }
379 },
380 None => CubemapTexture::placeholder(device, queue),
381 };
382
383 let uniform_buffer = Self::create_uniform_buffer(device);
385 let custom_uniform_data =
386 crate::custom_shader_renderer::types::CustomShaderControlUniforms::from_controls(
387 &custom_controls,
388 &custom_uniform_values,
389 );
390 let custom_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
391 label: Some("Custom Shader Control Uniform Buffer"),
392 contents: bytemuck::cast_slice(&[custom_uniform_data]),
393 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
394 });
395
396 let bind_group_layout = create_bind_group_layout(device);
398 let bind_group = create_bind_group(
399 device,
400 BindGroupInputs {
401 layout: &bind_group_layout,
402 uniform_buffer: &uniform_buffer,
403 intermediate_texture_view: &intermediate_texture_view,
404 custom_uniform_buffer: &custom_uniform_buffer,
405 sampler: &sampler,
406 channel_textures: &channel_textures,
407 cubemap: &cubemap,
408 },
409 );
410
411 let pipeline = create_render_pipeline(
413 device,
414 &shader_module,
415 &bind_group_layout,
416 surface_format,
417 Some("Custom Shader Pipeline"),
418 );
419
420 let now = Instant::now();
421 Ok(Self {
422 pipeline,
423 bind_group,
424 uniform_buffer,
425 custom_uniform_buffer,
426 intermediate_texture,
427 intermediate_texture_view,
428 start_time: now,
429 animation_enabled,
430 animation_speed,
431 texture_width: width,
432 texture_height: height,
433 surface_format,
434 bind_group_layout,
435 sampler,
436 window_opacity,
437 keep_text_opaque: false,
438 scale_factor: 1.0,
439 full_content_mode,
440 brightness: 1.0,
441 auto_dim_under_text: false,
442 auto_dim_strength: 0.35,
443 frame_count: 0,
444 last_frame_time: now,
445 mouse_position: [0.0, 0.0],
446 mouse_click_position: [0.0, 0.0],
447 mouse_button_down: false,
448 frame_time_accumulator: 0.0,
449 frames_in_second: 0,
450 current_frame_rate: 60.0,
451 current_cursor_pos: (0, 0),
452 previous_cursor_pos: (0, 0),
453 current_cursor_color: [1.0, 1.0, 1.0, 1.0],
454 previous_cursor_color: [1.0, 1.0, 1.0, 1.0],
455 current_cursor_opacity: 1.0,
456 previous_cursor_opacity: 1.0,
457 cursor_change_time: 0.0,
458 current_cursor_style: CursorStyle::SteadyBlock,
459 previous_cursor_style: CursorStyle::SteadyBlock,
460 cursor_cell_width: 10.0,
461 cursor_cell_height: 20.0,
462 cursor_window_padding: 0.0,
463 cursor_content_offset_y: 0.0,
464 cursor_content_offset_x: 0.0,
465 cursor_shader_color: [1.0, 1.0, 1.0, 1.0],
466 cursor_trail_duration: 0.5,
467 cursor_glow_radius: 80.0,
468 cursor_glow_intensity: 0.3,
469 key_press_time: 0.0,
470 channel_textures,
471 cubemap,
472 use_background_as_channel0: false,
473 background_channel_texture: None,
474 background_channel0_blend_mode,
475 background_color: [0.0, 0.0, 0.0, 0.0], progress_data: [0.0, 0.0, 0.0, 0.0],
477 command_data: [0.0, 0.0, 0.0, 0.0],
478 focused_pane: [0.0, 0.0, width as f32, height as f32],
479 scroll_data: [0.0, 0.0, 0.0, 0.0],
480 content_inset_right: 0.0,
481 custom_controls,
482 custom_uniform_values,
483 })
484 }
485
486 pub fn intermediate_texture_view(&self) -> &TextureView {
488 &self.intermediate_texture_view
489 }
490
491 pub fn render(
501 &mut self,
502 device: &Device,
503 queue: &Queue,
504 output_view: &TextureView,
505 apply_opacity: bool,
506 ) -> Result<()> {
507 self.render_with_clear_color(
508 device,
509 queue,
510 output_view,
511 apply_opacity,
512 Color::TRANSPARENT,
513 )
514 }
515
516 pub fn render_with_clear_color(
519 &mut self,
520 device: &Device,
521 queue: &Queue,
522 output_view: &TextureView,
523 apply_opacity: bool,
524 clear_color: Color,
525 ) -> Result<()> {
526 let now = Instant::now();
527
528 let time = if self.animation_enabled {
530 self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
531 } else {
532 0.0
533 };
534
535 let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
537 self.last_frame_time = now;
538
539 self.frame_time_accumulator += time_delta;
541 self.frames_in_second += 1;
542 if self.frame_time_accumulator >= 1.0 {
543 self.current_frame_rate = self.frames_in_second as f32 / self.frame_time_accumulator;
544 self.frame_time_accumulator = 0.0;
545 self.frames_in_second = 0;
546 }
547
548 self.frame_count = self.frame_count.wrapping_add(1);
549
550 let uniforms = self.build_uniforms(time, time_delta, apply_opacity);
552 queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
553 let custom_uniforms =
554 crate::custom_shader_renderer::types::CustomShaderControlUniforms::from_controls(
555 &self.custom_controls,
556 &self.custom_uniform_values,
557 );
558 queue.write_buffer(
559 &self.custom_uniform_buffer,
560 0,
561 bytemuck::cast_slice(&[custom_uniforms]),
562 );
563
564 let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
566 label: Some("Custom Shader Encoder"),
567 });
568
569 {
570 let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
571 label: Some("Custom Shader Render Pass"),
572 color_attachments: &[Some(RenderPassColorAttachment {
573 view: output_view,
574 resolve_target: None,
575 ops: Operations {
576 load: LoadOp::Clear(clear_color),
577 store: StoreOp::Store,
578 },
579 depth_slice: None,
580 })],
581 depth_stencil_attachment: None,
582 timestamp_writes: None,
583 occlusion_query_set: None,
584 multiview_mask: None,
585 });
586
587 render_pass.set_pipeline(&self.pipeline);
593 render_pass.set_bind_group(0, &self.bind_group, &[]);
594 render_pass.draw(0..4, 0..1);
595 }
596
597 queue.submit(std::iter::once(encoder.finish()));
598 Ok(())
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use std::time::Duration;
606
607 #[test]
608 fn enabling_animation_when_already_enabled_preserves_start_time() {
609 let start_time = Instant::now() - Duration::from_secs(5);
610 let now = Instant::now();
611
612 assert_eq!(
613 animation_start_after_enabled_update(true, true, start_time, now),
614 start_time
615 );
616 }
617
618 #[test]
619 fn enabling_animation_from_disabled_starts_at_now() {
620 let start_time = Instant::now() - Duration::from_secs(5);
621 let now = Instant::now();
622
623 assert_eq!(
624 animation_start_after_enabled_update(false, true, start_time, now),
625 now
626 );
627 }
628
629 #[test]
630 fn debug_shader_wgsl_filename_matches_new_renderer_output_path() {
631 let path = debug_shader_wgsl_filename("matrix");
632 assert_eq!(
633 std::path::Path::new(&path),
634 crate::shader_debug::debug_dump_dir().join("par_term_matrix_shader.wgsl")
635 );
636 }
637
638 #[cfg(debug_assertions)]
641 #[test]
642 fn write_debug_shader_wgsl_refreshes_existing_output() {
643 let shader_name = format!("par_term_test_{}", std::process::id());
644 let path = debug_shader_wgsl_filename(&shader_name);
645 let _ = std::fs::remove_file(&path);
646
647 write_debug_shader_wgsl(&shader_name, "first");
648 write_debug_shader_wgsl(&shader_name, "second");
649
650 assert_eq!(
651 std::fs::read_to_string(&path).expect("read debug wgsl"),
652 "second"
653 );
654 std::fs::remove_file(&path).expect("remove debug wgsl");
655 }
656
657 #[cfg(all(debug_assertions, unix))]
660 #[test]
661 fn write_debug_shader_wgsl_creates_owner_only_file() {
662 use std::os::unix::fs::PermissionsExt;
663
664 let shader_name = format!("par_term_mode_{}", std::process::id());
665 let path = debug_shader_wgsl_filename(&shader_name);
666 let _ = std::fs::remove_file(&path);
667
668 write_debug_shader_wgsl(&shader_name, "secret");
669
670 let mode = std::fs::metadata(&path)
671 .expect("dump exists")
672 .permissions()
673 .mode();
674 assert_eq!(mode & 0o777, 0o600, "dump must not be group/world readable");
675 std::fs::remove_file(&path).expect("remove debug wgsl");
676 }
677
678 #[cfg(all(debug_assertions, unix))]
680 #[test]
681 fn write_debug_shader_wgsl_does_not_follow_a_planted_symlink() {
682 let shader_name = format!("par_term_link_{}", std::process::id());
683 let path = debug_shader_wgsl_filename(&shader_name);
684 let target = crate::shader_debug::debug_dump_dir()
685 .join(format!("par_term_link_target_{}", std::process::id()));
686
687 std::fs::write(&target, "original").expect("seed symlink target");
688 let _ = std::fs::remove_file(&path);
689 std::os::unix::fs::symlink(&target, &path).expect("plant symlink");
690
691 write_debug_shader_wgsl(&shader_name, "attacker-visible");
692
693 assert_eq!(
694 std::fs::read_to_string(&target).expect("read symlink target"),
695 "original",
696 "the dump must not be written through the planted symlink"
697 );
698 let _ = std::fs::remove_file(&path);
699 std::fs::remove_file(&target).expect("remove symlink target");
700 }
701}