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_height: u32,
25 content_offset_y: f32,
26 content_inset_bottom: f32,
27 content_inset_right: f32,
28}
29
30const MIN_SCROLLBAR_THUMB_HEIGHT_PX: f32 = 20.0;
33
34const SCROLLBAR_MARK_HEIGHT_PX: f32 = 4.0;
36use wgpu::util::DeviceExt;
37use wgpu::{
38 BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor,
39 BindGroupLayoutEntry, BindingType, Buffer, BufferBindingType, BufferUsages, ColorTargetState,
40 ColorWrites, Device, FragmentState, MultisampleState, PipelineLayoutDescriptor, PrimitiveState,
41 PrimitiveTopology, Queue, RenderPass, RenderPipeline, RenderPipelineDescriptor,
42 ShaderModuleDescriptor, ShaderSource, ShaderStages, TextureFormat, VertexState,
43};
44
45use par_term_config::{ScrollbackMark, color_tuple_to_f32_a};
46
47pub struct Scrollbar {
49 device: Arc<Device>,
50 pipeline: RenderPipeline,
51 uniform_buffer: Buffer,
52 bind_group: BindGroup,
53 track_bind_group: BindGroup,
54 track_uniform_buffer: Buffer,
55 mark_bind_group_layout: BindGroupLayout,
56 width: f32,
57 visible: bool,
58 position_right: bool, thumb_color: [f32; 4],
60 track_color: [f32; 4],
61
62 scrollbar_x: f32, scrollbar_y: f32, scrollbar_height: f32, window_width: u32,
67 window_height: u32,
68 track_top: f32,
70 track_pixel_height: f32,
72
73 scroll_offset: usize,
75 visible_lines: usize,
76 total_lines: usize,
77
78 marks: Vec<ScrollbarMarkInstance>,
80 mark_hit_info: Vec<MarkHitInfo>,
82
83 max_marks: usize,
86 mark_uniform_buffers: Vec<Buffer>,
88 mark_bind_groups: Vec<BindGroup>,
90}
91
92#[repr(C)]
93#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
94struct ScrollbarUniforms {
95 position: [f32; 2], size: [f32; 2], color: [f32; 4],
100}
101
102struct ScrollbarMarkInstance {
103 bind_group: BindGroup,
104}
105
106#[derive(Clone)]
108struct MarkHitInfo {
109 y_pixel: f32,
111 mark: ScrollbackMark,
113}
114
115impl Scrollbar {
116 pub fn new(
126 device: std::sync::Arc<Device>,
127 format: TextureFormat,
128 width: f32,
129 position: &str,
130 thumb_color: [f32; 4],
131 track_color: [f32; 4],
132 ) -> Self {
133 let shader = device.create_shader_module(ShaderModuleDescriptor {
135 label: Some("Scrollbar Shader"),
136 source: ShaderSource::Wgsl(include_str!("shaders/scrollbar.wgsl").into()),
137 });
138
139 let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
141 label: Some("Scrollbar Bind Group Layout"),
142 entries: &[BindGroupLayoutEntry {
143 binding: 0,
144 visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
145 ty: BindingType::Buffer {
146 ty: BufferBindingType::Uniform,
147 has_dynamic_offset: false,
148 min_binding_size: None,
149 },
150 count: None,
151 }],
152 });
153
154 let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
156 label: Some("Scrollbar Pipeline Layout"),
157 bind_group_layouts: &[&bind_group_layout],
158 push_constant_ranges: &[],
159 });
160
161 let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
163 label: Some("Scrollbar Pipeline"),
164 layout: Some(&pipeline_layout),
165 vertex: VertexState {
166 module: &shader,
167 entry_point: Some("vs_main"),
168 buffers: &[],
169 compilation_options: Default::default(),
170 },
171 fragment: Some(FragmentState {
172 module: &shader,
173 entry_point: Some("fs_main"),
174 targets: &[Some(ColorTargetState {
175 format,
176 blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
178 write_mask: ColorWrites::ALL,
179 })],
180 compilation_options: Default::default(),
181 }),
182 primitive: PrimitiveState {
183 topology: PrimitiveTopology::TriangleStrip,
184 ..Default::default()
185 },
186 depth_stencil: None,
187 multisample: MultisampleState::default(),
188 multiview: None,
189 cache: None,
190 });
191
192 let thumb_uniforms = ScrollbarUniforms {
198 position: [0.0, 0.0],
199 size: [1.0, 1.0],
200 color: thumb_color,
201 };
202
203 let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
204 label: Some("Scrollbar Thumb Uniform Buffer"),
205 contents: bytemuck::cast_slice(&[thumb_uniforms]),
206 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
207 });
208
209 let track_uniforms = ScrollbarUniforms {
211 position: [0.0, 0.0],
212 size: [1.0, 1.0],
213 color: track_color,
214 };
215
216 let track_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
217 label: Some("Scrollbar Track Uniform Buffer"),
218 contents: bytemuck::cast_slice(&[track_uniforms]),
219 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
220 });
221
222 let bind_group = device.create_bind_group(&BindGroupDescriptor {
224 label: Some("Scrollbar Thumb Bind Group"),
225 layout: &bind_group_layout,
226 entries: &[BindGroupEntry {
227 binding: 0,
228 resource: uniform_buffer.as_entire_binding(),
229 }],
230 });
231
232 let track_bind_group = device.create_bind_group(&BindGroupDescriptor {
233 label: Some("Scrollbar Track Bind Group"),
234 layout: &bind_group_layout,
235 entries: &[BindGroupEntry {
236 binding: 0,
237 resource: track_uniform_buffer.as_entire_binding(),
238 }],
239 });
240
241 let mark_bind_group_layout = bind_group_layout.clone();
242
243 let position_right = position.eq_ignore_ascii_case("right");
244
245 Self {
246 device,
247 pipeline,
248 uniform_buffer,
249 bind_group,
250 track_bind_group,
251 track_uniform_buffer,
252 mark_bind_group_layout,
253 width,
254 visible: false,
255 position_right,
256 thumb_color,
257 track_color,
258 scrollbar_x: 0.0,
259 scrollbar_y: 0.0,
260 scrollbar_height: 0.0,
261 window_width: 0,
262 window_height: 0,
263 track_top: 0.0,
264 track_pixel_height: 0.0,
265 scroll_offset: 0,
266 visible_lines: 0,
267 total_lines: 0,
268 marks: Vec::new(),
269 mark_hit_info: Vec::new(),
270 max_marks: MAX_SCROLLBAR_MARKS,
271 mark_uniform_buffers: Vec::new(),
272 mark_bind_groups: Vec::new(),
273 }
274 }
275
276 pub fn update(&mut self, queue: &Queue, params: ScrollbarUpdateParams<'_>) {
280 let ScrollbarUpdateParams {
281 scroll_offset,
282 visible_lines,
283 total_lines,
284 window_width,
285 window_height,
286 content_offset_y,
287 content_inset_bottom,
288 content_inset_right,
289 marks,
290 } = params;
291
292 self.scroll_offset = scroll_offset;
294 self.visible_lines = visible_lines;
295 self.total_lines = total_lines;
296 self.window_width = window_width;
297 self.window_height = window_height;
298
299 self.visible = total_lines > visible_lines || !marks.is_empty();
301
302 if !self.visible {
303 return;
304 }
305
306 let track_pixel_height =
308 (window_height as f32 - content_offset_y - content_inset_bottom).max(1.0);
309 self.track_top = content_offset_y;
310 self.track_pixel_height = track_pixel_height;
311
312 let total = total_lines.max(1);
314 let viewport_ratio = visible_lines.min(total) as f32 / total as f32;
315 let scrollbar_height =
316 (viewport_ratio * track_pixel_height).max(MIN_SCROLLBAR_THUMB_HEIGHT_PX);
317
318 let max_scroll = total.saturating_sub(visible_lines);
322
323 let clamped_offset = scroll_offset.min(max_scroll);
325
326 let scroll_ratio = if max_scroll > 0 {
327 (clamped_offset as f32 / max_scroll as f32).clamp(0.0, 1.0)
328 } else {
329 0.0
330 };
331
332 let scrollbar_y = content_offset_y
334 + ((1.0 - scroll_ratio) * (track_pixel_height - scrollbar_height))
335 .clamp(0.0, track_pixel_height - scrollbar_height);
336
337 self.scrollbar_x = if self.position_right {
340 window_width as f32 - self.width - content_inset_right
341 } else {
342 0.0
343 };
344 self.scrollbar_y = scrollbar_y;
345 self.scrollbar_height = scrollbar_height;
346
347 let ww = window_width as f32;
349 let wh = window_height as f32;
350 let ndc_width = 2.0 * self.width / ww;
351 let ndc_x = if self.position_right {
352 let right_inset_ndc = 2.0 * content_inset_right / ww;
354 1.0 - ndc_width - right_inset_ndc
355 } else {
356 -1.0 };
358
359 let track_bottom_pixel = wh - content_offset_y - track_pixel_height;
361 let track_ndc_y = -1.0 + (2.0 * track_bottom_pixel / wh);
362 let track_ndc_height = 2.0 * track_pixel_height / wh;
363 let track_uniforms = ScrollbarUniforms {
364 position: [ndc_x, track_ndc_y],
365 size: [ndc_width, track_ndc_height],
366 color: self.track_color,
367 };
368 queue.write_buffer(
369 &self.track_uniform_buffer,
370 0,
371 bytemuck::cast_slice(&[track_uniforms]),
372 );
373
374 let thumb_bottom = wh - (scrollbar_y + scrollbar_height);
376 let thumb_ndc_y = -1.0 + (2.0 * thumb_bottom / wh);
377 let thumb_ndc_height = 2.0 * scrollbar_height / wh;
378 let thumb_uniforms = ScrollbarUniforms {
379 position: [ndc_x, thumb_ndc_y],
380 size: [ndc_width, thumb_ndc_height],
381 color: self.thumb_color,
382 };
383 queue.write_buffer(
384 &self.uniform_buffer,
385 0,
386 bytemuck::cast_slice(&[thumb_uniforms]),
387 );
388
389 self.prepare_marks(
391 queue,
392 marks,
393 PrepareMarksLayout {
394 total_lines,
395 window_height,
396 content_offset_y,
397 content_inset_bottom,
398 content_inset_right,
399 },
400 );
401 }
402
403 pub fn render<'a>(&'a self, render_pass: &mut RenderPass<'a>) {
405 if !self.visible {
406 return;
407 }
408
409 render_pass.set_pipeline(&self.pipeline);
410
411 render_pass.set_bind_group(0, &self.track_bind_group, &[]);
413 render_pass.draw(0..4, 0..1);
414
415 render_pass.set_bind_group(0, &self.bind_group, &[]);
417 render_pass.draw(0..4, 0..1);
418
419 for mark in &self.marks {
421 render_pass.set_bind_group(0, &mark.bind_group, &[]);
422 render_pass.draw(0..4, 0..1);
423 }
424 }
425
426 fn prepare_marks(
427 &mut self,
428 queue: &Queue,
429 marks: &[par_term_config::ScrollbackMark],
430 layout: PrepareMarksLayout,
431 ) {
432 let PrepareMarksLayout {
433 total_lines,
434 window_height,
435 content_offset_y,
436 content_inset_bottom,
437 content_inset_right,
438 } = layout;
439 self.marks.clear();
440 self.mark_hit_info.clear();
441
442 if total_lines == 0 || marks.is_empty() {
443 return;
444 }
445
446 let num_marks = marks.len().min(self.max_marks);
447 let ww = self.window_width as f32;
448 let wh = window_height as f32;
449 let track_pixel_height = (wh - content_offset_y - content_inset_bottom).max(1.0);
450 let mark_height_ndc = (2.0 * SCROLLBAR_MARK_HEIGHT_PX) / wh;
451 let ndc_width = 2.0 * self.width / ww;
452 let ndc_x = if self.position_right {
453 let right_inset_ndc = 2.0 * content_inset_right / ww;
454 1.0 - ndc_width - right_inset_ndc
455 } else {
456 -1.0
457 };
458
459 if self.mark_uniform_buffers.len() < num_marks {
461 let additional = num_marks - self.mark_uniform_buffers.len();
462 for _ in 0..additional {
463 let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
465 label: Some("Scrollbar Mark Uniform Buffer"),
466 size: std::mem::size_of::<ScrollbarUniforms>() as u64,
467 usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
468 mapped_at_creation: false,
469 });
470
471 let bind_group = self.device.create_bind_group(&BindGroupDescriptor {
473 label: Some("Scrollbar Mark Bind Group"),
474 layout: &self.mark_bind_group_layout,
475 entries: &[BindGroupEntry {
476 binding: 0,
477 resource: buffer.as_entire_binding(),
478 }],
479 });
480
481 self.mark_uniform_buffers.push(buffer);
482 self.mark_bind_groups.push(bind_group);
483 }
484 }
485
486 let mut mark_index = 0;
488 for mark in marks.iter().take(num_marks) {
489 if mark.line >= total_lines {
490 continue;
491 }
492 let ratio = mark.line as f32 / (total_lines as f32 - 1.0).max(1.0);
493 let y_pixel = content_offset_y + ratio * track_pixel_height;
495 let ndc_y = 1.0 - 2.0 * y_pixel / wh;
496
497 self.mark_hit_info.push(MarkHitInfo {
499 y_pixel,
500 mark: mark.clone(),
501 });
502
503 let color = if let Some((r, g, b)) = mark.color {
504 color_tuple_to_f32_a(r, g, b, 1.0)
505 } else {
506 match mark.exit_code {
507 Some(0) => [0.2, 0.8, 0.4, 1.0],
508 Some(_) => [0.9, 0.25, 0.2, 1.0],
509 None => [0.6, 0.6, 0.6, 0.9],
510 }
511 };
512
513 let mark_uniforms = ScrollbarUniforms {
514 position: [ndc_x, ndc_y - mark_height_ndc / 2.0],
515 size: [ndc_width, mark_height_ndc],
516 color,
517 };
518
519 queue.write_buffer(
521 &self.mark_uniform_buffers[mark_index],
522 0,
523 bytemuck::cast_slice(&[mark_uniforms]),
524 );
525
526 self.marks.push(ScrollbarMarkInstance {
528 bind_group: self.mark_bind_groups[mark_index].clone(),
529 });
530
531 mark_index += 1;
532 }
533 }
534
535 pub fn update_appearance(&mut self, width: f32, thumb_color: [f32; 4], track_color: [f32; 4]) {
537 self.width = width;
538 self.thumb_color = thumb_color;
539 self.track_color = track_color;
540 }
542
543 pub fn update_position(&mut self, position: &str) {
545 self.position_right = !position.eq_ignore_ascii_case("left");
546 }
547
548 pub fn width(&self) -> f32 {
549 self.width
550 }
551
552 pub fn position_right(&self) -> bool {
553 self.position_right
554 }
555
556 pub fn contains_point(&self, x: f32, y: f32) -> bool {
562 if !self.visible {
563 return false;
564 }
565
566 x >= self.scrollbar_x
567 && x <= self.scrollbar_x + self.width
568 && y >= self.scrollbar_y
569 && y <= self.scrollbar_y + self.scrollbar_height
570 }
571
572 pub fn track_contains_x(&self, x: f32) -> bool {
574 if !self.visible {
575 return false;
576 }
577
578 x >= self.scrollbar_x && x <= self.scrollbar_x + self.width
579 }
580
581 pub fn thumb_bounds(&self) -> Option<(f32, f32)> {
583 if !self.visible {
584 return None;
585 }
586
587 Some((self.scrollbar_y, self.scrollbar_height))
588 }
589
590 pub fn mouse_y_to_scroll_offset(&self, mouse_y: f32) -> Option<usize> {
598 if !self.visible {
599 return None;
600 }
601
602 let max_scroll = self.total_lines.saturating_sub(self.visible_lines);
603 if max_scroll == 0 {
604 return Some(0);
605 }
606
607 let track_height = (self.track_pixel_height - self.scrollbar_height).max(1.0);
609
610 let relative_y = mouse_y - self.track_top;
612 let clamped_y = relative_y.clamp(0.0, track_height);
613
614 let scroll_ratio = 1.0 - (clamped_y / track_height);
616
617 let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
619
620 Some(scroll_offset.min(max_scroll))
621 }
622
623 pub fn is_visible(&self) -> bool {
625 self.visible
626 }
627
628 pub fn mark_at_position(
632 &self,
633 mouse_x: f32,
634 mouse_y: f32,
635 tolerance: f32,
636 ) -> Option<&ScrollbackMark> {
637 if !self.visible || !self.track_contains_x(mouse_x) {
638 return None;
639 }
640
641 let mut closest: Option<(f32, &MarkHitInfo)> = None;
643 for hit_info in &self.mark_hit_info {
644 let distance = (hit_info.y_pixel - mouse_y).abs();
645 if distance <= tolerance {
646 match closest {
647 Some((best_dist, _)) if distance < best_dist => {
648 closest = Some((distance, hit_info));
649 }
650 None => {
651 closest = Some((distance, hit_info));
652 }
653 _ => {}
654 }
655 }
656 }
657
658 closest.map(|(_, hit_info)| &hit_info.mark)
659 }
660}