1use std::sync::Arc;
2use wgpu::BindGroupLayout;
3use wgpu::util::DeviceExt;
4use wgpu::{
5 BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor,
6 BindGroupLayoutEntry, BindingType, Buffer, BufferBindingType, BufferUsages, ColorTargetState,
7 ColorWrites, Device, FragmentState, MultisampleState, PipelineLayoutDescriptor, PrimitiveState,
8 PrimitiveTopology, Queue, RenderPass, RenderPipeline, RenderPipelineDescriptor,
9 ShaderModuleDescriptor, ShaderSource, ShaderStages, TextureFormat, VertexState,
10};
11
12use par_term_config::ScrollbackMark;
13
14pub struct Scrollbar {
16 device: Arc<Device>,
17 pipeline: RenderPipeline,
18 uniform_buffer: Buffer,
19 bind_group: BindGroup,
20 track_bind_group: BindGroup,
21 track_uniform_buffer: Buffer,
22 mark_bind_group_layout: BindGroupLayout,
23 width: f32,
24 visible: bool,
25 position_right: bool, thumb_color: [f32; 4],
27 track_color: [f32; 4],
28
29 scrollbar_x: f32, scrollbar_y: f32, scrollbar_height: f32, window_width: u32,
34 window_height: u32,
35 track_top: f32,
37 track_pixel_height: f32,
39
40 scroll_offset: usize,
42 visible_lines: usize,
43 total_lines: usize,
44
45 marks: Vec<ScrollbarMarkInstance>,
47 mark_hit_info: Vec<MarkHitInfo>,
49}
50
51#[repr(C)]
52#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
53struct ScrollbarUniforms {
54 position: [f32; 2], size: [f32; 2], color: [f32; 4],
59}
60
61struct ScrollbarMarkInstance {
62 bind_group: BindGroup,
63 #[allow(dead_code)]
64 buffer: Buffer,
65}
66
67#[derive(Clone)]
69struct MarkHitInfo {
70 y_pixel: f32,
72 mark: ScrollbackMark,
74}
75
76impl Scrollbar {
77 pub fn new(
87 device: std::sync::Arc<Device>,
88 format: TextureFormat,
89 width: f32,
90 position: &str,
91 thumb_color: [f32; 4],
92 track_color: [f32; 4],
93 ) -> Self {
94 let shader = device.create_shader_module(ShaderModuleDescriptor {
96 label: Some("Scrollbar Shader"),
97 source: ShaderSource::Wgsl(include_str!("shaders/scrollbar.wgsl").into()),
98 });
99
100 let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
102 label: Some("Scrollbar Bind Group Layout"),
103 entries: &[BindGroupLayoutEntry {
104 binding: 0,
105 visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
106 ty: BindingType::Buffer {
107 ty: BufferBindingType::Uniform,
108 has_dynamic_offset: false,
109 min_binding_size: None,
110 },
111 count: None,
112 }],
113 });
114
115 let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
117 label: Some("Scrollbar Pipeline Layout"),
118 bind_group_layouts: &[&bind_group_layout],
119 push_constant_ranges: &[],
120 });
121
122 let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
124 label: Some("Scrollbar Pipeline"),
125 layout: Some(&pipeline_layout),
126 vertex: VertexState {
127 module: &shader,
128 entry_point: Some("vs_main"),
129 buffers: &[],
130 compilation_options: Default::default(),
131 },
132 fragment: Some(FragmentState {
133 module: &shader,
134 entry_point: Some("fs_main"),
135 targets: &[Some(ColorTargetState {
136 format,
137 blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
139 write_mask: ColorWrites::ALL,
140 })],
141 compilation_options: Default::default(),
142 }),
143 primitive: PrimitiveState {
144 topology: PrimitiveTopology::TriangleStrip,
145 ..Default::default()
146 },
147 depth_stencil: None,
148 multisample: MultisampleState::default(),
149 multiview: None,
150 cache: None,
151 });
152
153 let thumb_uniforms = ScrollbarUniforms {
159 position: [0.0, 0.0],
160 size: [1.0, 1.0],
161 color: thumb_color,
162 };
163
164 let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
165 label: Some("Scrollbar Thumb Uniform Buffer"),
166 contents: bytemuck::cast_slice(&[thumb_uniforms]),
167 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
168 });
169
170 let track_uniforms = ScrollbarUniforms {
172 position: [0.0, 0.0],
173 size: [1.0, 1.0],
174 color: track_color,
175 };
176
177 let track_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
178 label: Some("Scrollbar Track Uniform Buffer"),
179 contents: bytemuck::cast_slice(&[track_uniforms]),
180 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
181 });
182
183 let bind_group = device.create_bind_group(&BindGroupDescriptor {
185 label: Some("Scrollbar Thumb Bind Group"),
186 layout: &bind_group_layout,
187 entries: &[BindGroupEntry {
188 binding: 0,
189 resource: uniform_buffer.as_entire_binding(),
190 }],
191 });
192
193 let track_bind_group = device.create_bind_group(&BindGroupDescriptor {
194 label: Some("Scrollbar Track Bind Group"),
195 layout: &bind_group_layout,
196 entries: &[BindGroupEntry {
197 binding: 0,
198 resource: track_uniform_buffer.as_entire_binding(),
199 }],
200 });
201
202 let mark_bind_group_layout = bind_group_layout.clone();
203
204 let position_right = position.eq_ignore_ascii_case("right");
205
206 Self {
207 device,
208 pipeline,
209 uniform_buffer,
210 bind_group,
211 track_bind_group,
212 track_uniform_buffer,
213 mark_bind_group_layout,
214 width,
215 visible: false,
216 position_right,
217 thumb_color,
218 track_color,
219 scrollbar_x: 0.0,
220 scrollbar_y: 0.0,
221 scrollbar_height: 0.0,
222 window_width: 0,
223 window_height: 0,
224 track_top: 0.0,
225 track_pixel_height: 0.0,
226 scroll_offset: 0,
227 visible_lines: 0,
228 total_lines: 0,
229 marks: Vec::new(),
230 mark_hit_info: Vec::new(),
231 }
232 }
233
234 #[allow(clippy::too_many_arguments)]
246 pub fn update(
247 &mut self,
248 queue: &Queue,
249 scroll_offset: usize,
250 visible_lines: usize,
251 total_lines: usize,
252 window_width: u32,
253 window_height: u32,
254 content_offset_y: f32,
255 content_inset_bottom: f32,
256 content_inset_right: f32,
257 marks: &[par_term_config::ScrollbackMark],
258 ) {
259 self.scroll_offset = scroll_offset;
261 self.visible_lines = visible_lines;
262 self.total_lines = total_lines;
263 self.window_width = window_width;
264 self.window_height = window_height;
265
266 self.visible = total_lines > visible_lines || !marks.is_empty();
268
269 if !self.visible {
270 return;
271 }
272
273 let track_pixel_height =
275 (window_height as f32 - content_offset_y - content_inset_bottom).max(1.0);
276 self.track_top = content_offset_y;
277 self.track_pixel_height = track_pixel_height;
278
279 let total = total_lines.max(1);
281 let viewport_ratio = visible_lines.min(total) as f32 / total as f32;
282 let scrollbar_height = (viewport_ratio * track_pixel_height).max(20.0);
283
284 let max_scroll = total.saturating_sub(visible_lines);
288
289 let clamped_offset = scroll_offset.min(max_scroll);
291
292 let scroll_ratio = if max_scroll > 0 {
293 (clamped_offset as f32 / max_scroll as f32).clamp(0.0, 1.0)
294 } else {
295 0.0
296 };
297
298 let scrollbar_y = content_offset_y
300 + ((1.0 - scroll_ratio) * (track_pixel_height - scrollbar_height))
301 .clamp(0.0, track_pixel_height - scrollbar_height);
302
303 self.scrollbar_x = if self.position_right {
306 window_width as f32 - self.width - content_inset_right
307 } else {
308 0.0
309 };
310 self.scrollbar_y = scrollbar_y;
311 self.scrollbar_height = scrollbar_height;
312
313 let ww = window_width as f32;
315 let wh = window_height as f32;
316 let ndc_width = 2.0 * self.width / ww;
317 let ndc_x = if self.position_right {
318 let right_inset_ndc = 2.0 * content_inset_right / ww;
320 1.0 - ndc_width - right_inset_ndc
321 } else {
322 -1.0 };
324
325 let track_bottom_pixel = wh - content_offset_y - track_pixel_height;
327 let track_ndc_y = -1.0 + (2.0 * track_bottom_pixel / wh);
328 let track_ndc_height = 2.0 * track_pixel_height / wh;
329 let track_uniforms = ScrollbarUniforms {
330 position: [ndc_x, track_ndc_y],
331 size: [ndc_width, track_ndc_height],
332 color: self.track_color,
333 };
334 queue.write_buffer(
335 &self.track_uniform_buffer,
336 0,
337 bytemuck::cast_slice(&[track_uniforms]),
338 );
339
340 let thumb_bottom = wh - (scrollbar_y + scrollbar_height);
342 let thumb_ndc_y = -1.0 + (2.0 * thumb_bottom / wh);
343 let thumb_ndc_height = 2.0 * scrollbar_height / wh;
344 let thumb_uniforms = ScrollbarUniforms {
345 position: [ndc_x, thumb_ndc_y],
346 size: [ndc_width, thumb_ndc_height],
347 color: self.thumb_color,
348 };
349 queue.write_buffer(
350 &self.uniform_buffer,
351 0,
352 bytemuck::cast_slice(&[thumb_uniforms]),
353 );
354
355 self.prepare_marks(
357 marks,
358 total_lines,
359 window_height,
360 content_offset_y,
361 content_inset_bottom,
362 content_inset_right,
363 );
364 }
365
366 pub fn render<'a>(&'a self, render_pass: &mut RenderPass<'a>) {
368 if !self.visible {
369 return;
370 }
371
372 render_pass.set_pipeline(&self.pipeline);
373
374 render_pass.set_bind_group(0, &self.track_bind_group, &[]);
376 render_pass.draw(0..4, 0..1);
377
378 render_pass.set_bind_group(0, &self.bind_group, &[]);
380 render_pass.draw(0..4, 0..1);
381
382 for mark in &self.marks {
384 render_pass.set_bind_group(0, &mark.bind_group, &[]);
385 render_pass.draw(0..4, 0..1);
386 }
387 }
388
389 fn prepare_marks(
390 &mut self,
391 marks: &[par_term_config::ScrollbackMark],
392 total_lines: usize,
393 window_height: u32,
394 content_offset_y: f32,
395 content_inset_bottom: f32,
396 content_inset_right: f32,
397 ) {
398 self.marks.clear();
399 self.mark_hit_info.clear();
400
401 if total_lines == 0 || marks.is_empty() {
402 return;
403 }
404
405 let ww = self.window_width as f32;
406 let wh = window_height as f32;
407 let track_pixel_height = (wh - content_offset_y - content_inset_bottom).max(1.0);
408 let mark_height_ndc = (2.0 * 4.0) / wh; let ndc_width = 2.0 * self.width / ww;
410 let ndc_x = if self.position_right {
411 let right_inset_ndc = 2.0 * content_inset_right / ww;
412 1.0 - ndc_width - right_inset_ndc
413 } else {
414 -1.0
415 };
416
417 for mark in marks {
418 if mark.line >= total_lines {
419 continue;
420 }
421 let ratio = mark.line as f32 / (total_lines as f32 - 1.0).max(1.0);
422 let y_pixel = content_offset_y + ratio * track_pixel_height;
424 let ndc_y = 1.0 - 2.0 * y_pixel / wh;
425
426 self.mark_hit_info.push(MarkHitInfo {
428 y_pixel,
429 mark: mark.clone(),
430 });
431
432 let color = if let Some((r, g, b)) = mark.color {
433 [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
434 } else {
435 match mark.exit_code {
436 Some(0) => [0.2, 0.8, 0.4, 1.0],
437 Some(_) => [0.9, 0.25, 0.2, 1.0],
438 None => [0.6, 0.6, 0.6, 0.9],
439 }
440 };
441
442 let mark_uniforms = ScrollbarUniforms {
443 position: [ndc_x, ndc_y - mark_height_ndc / 2.0],
444 size: [ndc_width, mark_height_ndc],
445 color,
446 };
447
448 let buffer = self
449 .device
450 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
451 label: Some("Scrollbar Mark Buffer"),
452 contents: bytemuck::cast_slice(&[mark_uniforms]),
453 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
454 });
455
456 let bind_group = self.device.create_bind_group(&BindGroupDescriptor {
457 label: Some("Scrollbar Mark Bind Group"),
458 layout: &self.mark_bind_group_layout,
459 entries: &[BindGroupEntry {
460 binding: 0,
461 resource: buffer.as_entire_binding(),
462 }],
463 });
464
465 self.marks
466 .push(ScrollbarMarkInstance { bind_group, buffer });
467 }
468 }
469
470 pub fn update_appearance(&mut self, width: f32, thumb_color: [f32; 4], track_color: [f32; 4]) {
472 self.width = width;
473 self.thumb_color = thumb_color;
474 self.track_color = track_color;
475 }
477
478 #[allow(dead_code)]
480 pub fn update_position(&mut self, position: &str) {
481 self.position_right = !position.eq_ignore_ascii_case("left");
482 }
483
484 #[allow(dead_code)]
485 pub fn width(&self) -> f32 {
486 self.width
487 }
488
489 #[allow(dead_code)]
490 pub fn position_right(&self) -> bool {
491 self.position_right
492 }
493
494 pub fn contains_point(&self, x: f32, y: f32) -> bool {
500 if !self.visible {
501 return false;
502 }
503
504 x >= self.scrollbar_x
505 && x <= self.scrollbar_x + self.width
506 && y >= self.scrollbar_y
507 && y <= self.scrollbar_y + self.scrollbar_height
508 }
509
510 pub fn track_contains_x(&self, x: f32) -> bool {
512 if !self.visible {
513 return false;
514 }
515
516 x >= self.scrollbar_x && x <= self.scrollbar_x + self.width
517 }
518
519 pub fn thumb_bounds(&self) -> Option<(f32, f32)> {
521 if !self.visible {
522 return None;
523 }
524
525 Some((self.scrollbar_y, self.scrollbar_height))
526 }
527
528 pub fn mouse_y_to_scroll_offset(&self, mouse_y: f32) -> Option<usize> {
536 if !self.visible {
537 return None;
538 }
539
540 let max_scroll = self.total_lines.saturating_sub(self.visible_lines);
541 if max_scroll == 0 {
542 return Some(0);
543 }
544
545 let track_height = (self.track_pixel_height - self.scrollbar_height).max(1.0);
547
548 let relative_y = mouse_y - self.track_top;
550 let clamped_y = relative_y.clamp(0.0, track_height);
551
552 let scroll_ratio = 1.0 - (clamped_y / track_height);
554
555 let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
557
558 Some(scroll_offset.min(max_scroll))
559 }
560
561 #[allow(dead_code)]
563 pub fn is_visible(&self) -> bool {
564 self.visible
565 }
566
567 pub fn mark_at_position(
571 &self,
572 mouse_x: f32,
573 mouse_y: f32,
574 tolerance: f32,
575 ) -> Option<&ScrollbackMark> {
576 if !self.visible || !self.track_contains_x(mouse_x) {
577 return None;
578 }
579
580 let mut closest: Option<(f32, &MarkHitInfo)> = None;
582 for hit_info in &self.mark_hit_info {
583 let distance = (hit_info.y_pixel - mouse_y).abs();
584 if distance <= tolerance {
585 match closest {
586 Some((best_dist, _)) if distance < best_dist => {
587 closest = Some((distance, hit_info));
588 }
589 None => {
590 closest = Some((distance, hit_info));
591 }
592 _ => {}
593 }
594 }
595 }
596
597 closest.map(|(_, hit_info)| &hit_info.mark)
598 }
599}