1use std::sync::Arc;
2use wgpu::BindGroupLayout;
3
4const MAX_SCROLLBAR_MARKS: usize = 256;
7
8pub struct ScrollbarUpdateParams<'a> {
10 pub scroll_offset: usize,
11 pub visible_lines: usize,
12 pub total_lines: usize,
13 pub window_width: u32,
14 pub window_height: u32,
15 pub content_offset_y: f32,
16 pub content_inset_bottom: f32,
17 pub content_inset_right: f32,
18 pub marks: &'a [par_term_config::ScrollbackMark],
19}
20
21struct PrepareMarksLayout {
23 total_lines: usize,
24 window_width: u32,
25 window_height: u32,
26 content_offset_y: f32,
27 content_inset_bottom: f32,
28 content_inset_right: f32,
29 record_hit_state: bool,
31}
32
33const MIN_SCROLLBAR_THUMB_HEIGHT_PX: f32 = 20.0;
36
37const SCROLLBAR_MARK_HEIGHT_PX: f32 = 4.0;
39use wgpu::util::DeviceExt;
40use wgpu::{
41 BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor,
42 BindGroupLayoutEntry, BindingType, Buffer, BufferBindingType, BufferUsages, ColorTargetState,
43 ColorWrites, Device, FragmentState, MultisampleState, PipelineLayoutDescriptor, PrimitiveState,
44 PrimitiveTopology, Queue, RenderPass, RenderPipeline, RenderPipelineDescriptor,
45 ShaderModuleDescriptor, ShaderSource, ShaderStages, TextureFormat, VertexState,
46};
47
48use par_term_config::{ScrollbackMark, color_tuple_to_f32_a};
49
50struct ScrollbarSlot {
58 visible: bool,
59 thumb_uniform_buffer: Buffer,
60 thumb_bind_group: BindGroup,
61 track_uniform_buffer: Buffer,
62 track_bind_group: BindGroup,
63 mark_uniform_buffers: Vec<Buffer>,
65 mark_bind_groups: Vec<BindGroup>,
67 mark_count: usize,
69}
70
71pub struct Scrollbar {
73 device: Arc<Device>,
74 pipeline: RenderPipeline,
75 bind_group_layout: BindGroupLayout,
76 slots: Vec<ScrollbarSlot>,
79 width: f32,
80 position_right: bool, thumb_color: [f32; 4],
82 track_color: [f32; 4],
83
84 hit_visible: bool,
92 scrollbar_x: f32, scrollbar_y: f32, scrollbar_height: f32, window_width: u32,
96 window_height: u32,
97 track_top: f32,
99 track_pixel_height: f32,
101
102 scroll_offset: usize,
104 visible_lines: usize,
105 total_lines: usize,
106
107 mark_hit_info: Vec<MarkHitInfo>,
109
110 max_marks: usize,
112}
113
114#[repr(C)]
115#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
116struct ScrollbarUniforms {
117 position: [f32; 2], size: [f32; 2], color: [f32; 4],
122}
123
124#[derive(Clone)]
126struct MarkHitInfo {
127 y_pixel: f32,
129 mark: ScrollbackMark,
131}
132
133impl Scrollbar {
134 pub fn new(
144 device: std::sync::Arc<Device>,
145 format: TextureFormat,
146 width: f32,
147 position: &str,
148 thumb_color: [f32; 4],
149 track_color: [f32; 4],
150 ) -> Self {
151 let shader = device.create_shader_module(ShaderModuleDescriptor {
153 label: Some("Scrollbar Shader"),
154 source: ShaderSource::Wgsl(include_str!("shaders/scrollbar.wgsl").into()),
155 });
156
157 let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
159 label: Some("Scrollbar Bind Group Layout"),
160 entries: &[BindGroupLayoutEntry {
161 binding: 0,
162 visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
163 ty: BindingType::Buffer {
164 ty: BufferBindingType::Uniform,
165 has_dynamic_offset: false,
166 min_binding_size: None,
167 },
168 count: None,
169 }],
170 });
171
172 let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
174 label: Some("Scrollbar Pipeline Layout"),
175 bind_group_layouts: &[Some(&bind_group_layout)],
176 immediate_size: 0,
177 });
178
179 let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
181 label: Some("Scrollbar Pipeline"),
182 layout: Some(&pipeline_layout),
183 vertex: VertexState {
184 module: &shader,
185 entry_point: Some("vs_main"),
186 buffers: &[],
187 compilation_options: Default::default(),
188 },
189 fragment: Some(FragmentState {
190 module: &shader,
191 entry_point: Some("fs_main"),
192 targets: &[Some(ColorTargetState {
193 format,
194 blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
196 write_mask: ColorWrites::ALL,
197 })],
198 compilation_options: Default::default(),
199 }),
200 primitive: PrimitiveState {
201 topology: PrimitiveTopology::TriangleStrip,
202 ..Default::default()
203 },
204 depth_stencil: None,
205 multisample: MultisampleState::default(),
206 cache: None,
207 multiview_mask: None,
208 });
209
210 let position_right = position.eq_ignore_ascii_case("right");
215
216 Self {
217 device,
218 pipeline,
219 bind_group_layout,
220 slots: Vec::new(),
221 width,
222 position_right,
223 thumb_color,
224 track_color,
225 hit_visible: false,
226 scrollbar_x: 0.0,
227 scrollbar_y: 0.0,
228 scrollbar_height: 0.0,
229 window_width: 0,
230 window_height: 0,
231 track_top: 0.0,
232 track_pixel_height: 0.0,
233 scroll_offset: 0,
234 visible_lines: 0,
235 total_lines: 0,
236 mark_hit_info: Vec::new(),
237 max_marks: MAX_SCROLLBAR_MARKS,
238 }
239 }
240
241 fn ensure_slot(&mut self, index: usize) {
246 while self.slots.len() <= index {
247 let thumb_uniform_buffer =
248 self.device
249 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
250 label: Some("Scrollbar Thumb Uniform Buffer"),
251 contents: bytemuck::cast_slice(&[ScrollbarUniforms {
252 position: [0.0, 0.0],
253 size: [1.0, 1.0],
254 color: self.thumb_color,
255 }]),
256 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
257 });
258 let track_uniform_buffer =
259 self.device
260 .create_buffer_init(&wgpu::util::BufferInitDescriptor {
261 label: Some("Scrollbar Track Uniform Buffer"),
262 contents: bytemuck::cast_slice(&[ScrollbarUniforms {
263 position: [0.0, 0.0],
264 size: [1.0, 1.0],
265 color: self.track_color,
266 }]),
267 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
268 });
269 let thumb_bind_group = self.device.create_bind_group(&BindGroupDescriptor {
270 label: Some("Scrollbar Thumb Bind Group"),
271 layout: &self.bind_group_layout,
272 entries: &[BindGroupEntry {
273 binding: 0,
274 resource: thumb_uniform_buffer.as_entire_binding(),
275 }],
276 });
277 let track_bind_group = self.device.create_bind_group(&BindGroupDescriptor {
278 label: Some("Scrollbar Track Bind Group"),
279 layout: &self.bind_group_layout,
280 entries: &[BindGroupEntry {
281 binding: 0,
282 resource: track_uniform_buffer.as_entire_binding(),
283 }],
284 });
285 self.slots.push(ScrollbarSlot {
286 visible: false,
287 thumb_uniform_buffer,
288 thumb_bind_group,
289 track_uniform_buffer,
290 track_bind_group,
291 mark_uniform_buffers: Vec::new(),
292 mark_bind_groups: Vec::new(),
293 mark_count: 0,
294 });
295 }
296 }
297
298 pub fn update(&mut self, queue: &Queue, params: ScrollbarUpdateParams<'_>) {
302 self.update_slot(queue, 0, params, true);
303 }
304
305 pub fn clear_hit_state(&mut self) {
310 self.hit_visible = false;
311 self.mark_hit_info.clear();
312 }
313
314 pub fn update_slot(
319 &mut self,
320 queue: &Queue,
321 slot: usize,
322 params: ScrollbarUpdateParams<'_>,
323 record_hit_state: bool,
324 ) {
325 let ScrollbarUpdateParams {
326 scroll_offset,
327 visible_lines,
328 total_lines,
329 window_width,
330 window_height,
331 content_offset_y,
332 content_inset_bottom,
333 content_inset_right,
334 marks,
335 } = params;
336
337 self.ensure_slot(slot);
338
339 let visible = total_lines > visible_lines || !marks.is_empty();
341 self.slots[slot].visible = visible;
342 if record_hit_state {
343 self.hit_visible = visible;
344 }
345
346 if !visible {
347 self.slots[slot].mark_count = 0;
349 if record_hit_state {
350 self.mark_hit_info.clear();
351 }
352 return;
353 }
354
355 if record_hit_state {
357 self.scroll_offset = scroll_offset;
358 self.visible_lines = visible_lines;
359 self.total_lines = total_lines;
360 self.window_width = window_width;
361 self.window_height = window_height;
362 }
363
364 let track_pixel_height =
366 (window_height as f32 - content_offset_y - content_inset_bottom).max(1.0);
367 if record_hit_state {
368 self.track_top = content_offset_y;
369 self.track_pixel_height = track_pixel_height;
370 }
371
372 let total = total_lines.max(1);
374 let viewport_ratio = visible_lines.min(total) as f32 / total as f32;
375 let scrollbar_height =
376 (viewport_ratio * track_pixel_height).max(MIN_SCROLLBAR_THUMB_HEIGHT_PX);
377
378 let max_scroll = total.saturating_sub(visible_lines);
382
383 let clamped_offset = scroll_offset.min(max_scroll);
385
386 let scroll_ratio = if max_scroll > 0 {
387 (clamped_offset as f32 / max_scroll as f32).clamp(0.0, 1.0)
388 } else {
389 0.0
390 };
391
392 let scrollbar_y = content_offset_y
394 + ((1.0 - scroll_ratio) * (track_pixel_height - scrollbar_height))
395 .clamp(0.0, track_pixel_height - scrollbar_height);
396
397 if record_hit_state {
400 self.scrollbar_x = if self.position_right {
401 window_width as f32 - self.width - content_inset_right
402 } else {
403 0.0
404 };
405 self.scrollbar_y = scrollbar_y;
406 self.scrollbar_height = scrollbar_height;
407 }
408
409 let ww = window_width as f32;
411 let wh = window_height as f32;
412 let ndc_width = 2.0 * self.width / ww;
413 let ndc_x = if self.position_right {
414 let right_inset_ndc = 2.0 * content_inset_right / ww;
416 1.0 - ndc_width - right_inset_ndc
417 } else {
418 -1.0 };
420
421 let track_bottom_pixel = wh - content_offset_y - track_pixel_height;
423 let track_ndc_y = -1.0 + (2.0 * track_bottom_pixel / wh);
424 let track_ndc_height = 2.0 * track_pixel_height / wh;
425 let track_uniforms = ScrollbarUniforms {
426 position: [ndc_x, track_ndc_y],
427 size: [ndc_width, track_ndc_height],
428 color: self.track_color,
429 };
430 queue.write_buffer(
431 &self.slots[slot].track_uniform_buffer,
432 0,
433 bytemuck::cast_slice(&[track_uniforms]),
434 );
435
436 let thumb_bottom = wh - (scrollbar_y + scrollbar_height);
438 let thumb_ndc_y = -1.0 + (2.0 * thumb_bottom / wh);
439 let thumb_ndc_height = 2.0 * scrollbar_height / wh;
440 let thumb_uniforms = ScrollbarUniforms {
441 position: [ndc_x, thumb_ndc_y],
442 size: [ndc_width, thumb_ndc_height],
443 color: self.thumb_color,
444 };
445 queue.write_buffer(
446 &self.slots[slot].thumb_uniform_buffer,
447 0,
448 bytemuck::cast_slice(&[thumb_uniforms]),
449 );
450
451 self.prepare_marks(
453 queue,
454 slot,
455 marks,
456 PrepareMarksLayout {
457 total_lines,
458 window_width,
459 window_height,
460 content_offset_y,
461 content_inset_bottom,
462 content_inset_right,
463 record_hit_state,
464 },
465 );
466 }
467
468 pub fn render<'a>(&'a self, render_pass: &mut RenderPass<'a>, slot: usize) {
473 let Some(slot) = self.slots.get(slot) else {
474 return;
475 };
476 if !slot.visible {
477 return;
478 }
479
480 render_pass.set_pipeline(&self.pipeline);
481
482 render_pass.set_bind_group(0, &slot.track_bind_group, &[]);
484 render_pass.draw(0..4, 0..1);
485
486 render_pass.set_bind_group(0, &slot.thumb_bind_group, &[]);
488 render_pass.draw(0..4, 0..1);
489
490 for bind_group in slot.mark_bind_groups.iter().take(slot.mark_count) {
492 render_pass.set_bind_group(0, bind_group, &[]);
493 render_pass.draw(0..4, 0..1);
494 }
495 }
496
497 fn prepare_marks(
498 &mut self,
499 queue: &Queue,
500 slot: usize,
501 marks: &[par_term_config::ScrollbackMark],
502 layout: PrepareMarksLayout,
503 ) {
504 let PrepareMarksLayout {
505 total_lines,
506 window_width,
507 window_height,
508 content_offset_y,
509 content_inset_bottom,
510 content_inset_right,
511 record_hit_state,
512 } = layout;
513 self.slots[slot].mark_count = 0;
514 if record_hit_state {
515 self.mark_hit_info.clear();
516 }
517
518 if total_lines == 0 || marks.is_empty() {
519 return;
520 }
521
522 let num_marks = marks.len().min(self.max_marks);
523 let ww = window_width as f32;
524 let wh = window_height as f32;
525 let track_pixel_height = (wh - content_offset_y - content_inset_bottom).max(1.0);
526 let mark_height_ndc = (2.0 * SCROLLBAR_MARK_HEIGHT_PX) / wh;
527 let ndc_width = 2.0 * self.width / ww;
528 let ndc_x = if self.position_right {
529 let right_inset_ndc = 2.0 * content_inset_right / ww;
530 1.0 - ndc_width - right_inset_ndc
531 } else {
532 -1.0
533 };
534
535 while self.slots[slot].mark_uniform_buffers.len() < num_marks {
537 let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
539 label: Some("Scrollbar Mark Uniform Buffer"),
540 size: std::mem::size_of::<ScrollbarUniforms>() as u64,
541 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
542 mapped_at_creation: false,
543 });
544
545 let bind_group = self.device.create_bind_group(&BindGroupDescriptor {
547 label: Some("Scrollbar Mark Bind Group"),
548 layout: &self.bind_group_layout,
549 entries: &[BindGroupEntry {
550 binding: 0,
551 resource: buffer.as_entire_binding(),
552 }],
553 });
554
555 self.slots[slot].mark_uniform_buffers.push(buffer);
556 self.slots[slot].mark_bind_groups.push(bind_group);
557 }
558
559 let mut mark_index = 0;
561 for mark in marks.iter().take(num_marks) {
562 if mark.line >= total_lines {
563 continue;
564 }
565 let ratio = mark.line as f32 / (total_lines as f32 - 1.0).max(1.0);
566 let y_pixel = content_offset_y + ratio * track_pixel_height;
568 let ndc_y = 1.0 - 2.0 * y_pixel / wh;
569
570 if record_hit_state {
572 self.mark_hit_info.push(MarkHitInfo {
573 y_pixel,
574 mark: mark.clone(),
575 });
576 }
577
578 let color = if let Some((r, g, b)) = mark.color {
579 color_tuple_to_f32_a(r, g, b, 1.0)
580 } else {
581 match mark.exit_code {
582 Some(0) => [0.2, 0.8, 0.4, 1.0],
583 Some(_) => [0.9, 0.25, 0.2, 1.0],
584 None => [0.6, 0.6, 0.6, 0.9],
585 }
586 };
587
588 let mark_uniforms = ScrollbarUniforms {
589 position: [ndc_x, ndc_y - mark_height_ndc / 2.0],
590 size: [ndc_width, mark_height_ndc],
591 color,
592 };
593
594 queue.write_buffer(
596 &self.slots[slot].mark_uniform_buffers[mark_index],
597 0,
598 bytemuck::cast_slice(&[mark_uniforms]),
599 );
600
601 mark_index += 1;
602 }
603
604 self.slots[slot].mark_count = mark_index;
606 }
607
608 pub fn update_appearance(&mut self, width: f32, thumb_color: [f32; 4], track_color: [f32; 4]) {
610 self.width = width;
611 self.thumb_color = thumb_color;
612 self.track_color = track_color;
613 }
615
616 pub fn update_position(&mut self, position: &str) {
618 self.position_right = !position.eq_ignore_ascii_case("left");
619 }
620
621 pub fn width(&self) -> f32 {
622 self.width
623 }
624
625 pub fn thumb_color(&self) -> [f32; 4] {
626 self.thumb_color
627 }
628
629 pub fn track_color(&self) -> [f32; 4] {
630 self.track_color
631 }
632
633 pub fn position_right(&self) -> bool {
634 self.position_right
635 }
636
637 pub fn contains_point(&self, x: f32, y: f32) -> bool {
643 if !self.hit_visible {
644 return false;
645 }
646
647 x >= self.scrollbar_x
648 && x <= self.scrollbar_x + self.width
649 && y >= self.scrollbar_y
650 && y <= self.scrollbar_y + self.scrollbar_height
651 }
652
653 pub fn track_contains_x(&self, x: f32) -> bool {
655 if !self.hit_visible {
656 return false;
657 }
658
659 x >= self.scrollbar_x && x <= self.scrollbar_x + self.width
660 }
661
662 pub fn thumb_bounds(&self) -> Option<(f32, f32)> {
664 if !self.hit_visible {
665 return None;
666 }
667
668 Some((self.scrollbar_y, self.scrollbar_height))
669 }
670
671 pub fn mouse_y_to_scroll_offset(&self, mouse_y: f32) -> Option<usize> {
679 if !self.hit_visible {
680 return None;
681 }
682
683 let max_scroll = self.total_lines.saturating_sub(self.visible_lines);
684 if max_scroll == 0 {
685 return Some(0);
686 }
687
688 let track_height = (self.track_pixel_height - self.scrollbar_height).max(1.0);
690
691 let relative_y = mouse_y - self.track_top;
693 let clamped_y = relative_y.clamp(0.0, track_height);
694
695 let scroll_ratio = 1.0 - (clamped_y / track_height);
697
698 let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
700
701 Some(scroll_offset.min(max_scroll))
702 }
703
704 pub fn is_visible(&self) -> bool {
706 self.hit_visible
707 }
708
709 pub fn mark_at_position(
713 &self,
714 mouse_x: f32,
715 mouse_y: f32,
716 tolerance: f32,
717 ) -> Option<&ScrollbackMark> {
718 if !self.hit_visible || !self.track_contains_x(mouse_x) {
719 return None;
720 }
721
722 let mut closest: Option<(f32, &MarkHitInfo)> = None;
724 for hit_info in &self.mark_hit_info {
725 let distance = (hit_info.y_pixel - mouse_y).abs();
726 if distance <= tolerance {
727 match closest {
728 Some((best_dist, _)) if distance < best_dist => {
729 closest = Some((distance, hit_info));
730 }
731 None => {
732 closest = Some((distance, hit_info));
733 }
734 _ => {}
735 }
736 }
737 }
738
739 closest.map(|(_, hit_info)| &hit_info.mark)
740 }
741}