Skip to main content

par_term_render/
scrollbar.rs

1use std::sync::Arc;
2use wgpu::BindGroupLayout;
3
4/// Maximum number of scrollback marks that can be rendered simultaneously.
5/// Pre-allocating this many GPU buffers avoids per-frame allocation churn.
6const MAX_SCROLLBAR_MARKS: usize = 256;
7
8/// Parameters for updating the scrollbar position and mark overlays each frame.
9pub 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
21/// Geometry layout passed to [`Scrollbar::prepare_marks`].
22struct 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
30/// Minimum scrollbar thumb height in pixels.
31/// Prevents the thumb from becoming too small to click when scrollback is very long.
32const MIN_SCROLLBAR_THUMB_HEIGHT_PX: f32 = 20.0;
33
34/// Height of each scrollback mark indicator in pixels.
35const 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
47/// Scrollbar renderer using wgpu
48pub 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, // true = right side, false = left side
59    thumb_color: [f32; 4],
60    track_color: [f32; 4],
61
62    // Cached state for hit testing and interaction
63    scrollbar_x: f32,      // Pixel position X
64    scrollbar_y: f32,      // Pixel position Y
65    scrollbar_height: f32, // Pixel height (thumb)
66    window_width: u32,
67    window_height: u32,
68    /// Top of the scrollbar track in pixels (accounts for tab bar, etc.)
69    track_top: f32,
70    /// Height of the scrollbar track in pixels (excludes insets)
71    track_pixel_height: f32,
72
73    // Scroll state
74    scroll_offset: usize,
75    visible_lines: usize,
76    total_lines: usize,
77
78    // Mark overlays (prompt/command indicators)
79    marks: Vec<ScrollbarMarkInstance>,
80    /// Mark hit-test data for tooltip display
81    mark_hit_info: Vec<MarkHitInfo>,
82
83    // Pre-allocated GPU resources for marks to avoid per-frame allocation churn
84    /// Maximum number of marks we can render (pre-allocated)
85    max_marks: usize,
86    /// Pre-allocated uniform buffers for each mark slot
87    mark_uniform_buffers: Vec<Buffer>,
88    /// Bind groups for each mark slot (re-created when buffers are allocated)
89    mark_bind_groups: Vec<BindGroup>,
90}
91
92#[repr(C)]
93#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
94struct ScrollbarUniforms {
95    // Position and size (normalized device coordinates: -1 to 1)
96    position: [f32; 2], // x, y
97    size: [f32; 2],     // width, height
98    // Color (RGBA)
99    color: [f32; 4],
100}
101
102struct ScrollbarMarkInstance {
103    bind_group: BindGroup,
104}
105
106/// Data for hit-testing marks on the scrollbar
107#[derive(Clone)]
108struct MarkHitInfo {
109    /// Y position in pixels (from top)
110    y_pixel: f32,
111    /// Original mark data for tooltip display
112    mark: ScrollbackMark,
113}
114
115impl Scrollbar {
116    /// Create a new scrollbar renderer
117    ///
118    /// # Arguments
119    /// * `device` - WGPU device
120    /// * `format` - Texture format
121    /// * `width` - Scrollbar width in pixels
122    /// * `position` - Scrollbar position ("left" or "right")
123    /// * `thumb_color` - RGBA color for thumb [r, g, b, a]
124    /// * `track_color` - RGBA color for track [r, g, b, a]
125    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        // Create shader module
134        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        // Create bind group layout
140        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        // Create pipeline layout
155        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        // Create render pipeline
162        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                    // Use premultiplied alpha blending since shader outputs premultiplied colors
177                    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        // Create uniform buffers for thumb and track
193        // Note: We don't need a vertex buffer because vertices are generated
194        // procedurally in the shader using builtin(vertex_index)
195
196        // Thumb uniform buffer
197        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        // Track uniform buffer
210        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        // Create bind groups
223        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    /// Update scrollbar position and visibility.
277    ///
278    /// All geometry and scroll-state parameters are passed via [`ScrollbarUpdateParams`].
279    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        // Store parameters for hit testing
293        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        // Show scrollbar when either scrollback exists or mark indicators are available
300        self.visible = total_lines > visible_lines || !marks.is_empty();
301
302        if !self.visible {
303            return;
304        }
305
306        // The visible track area excludes top and bottom insets (tab bar, status bar, etc.)
307        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        // Calculate scrollbar dimensions (guard against zero)
313        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        // Calculate scrollbar position
319        // When scroll_offset is 0, we're at the bottom
320        // When scroll_offset is max, we're at the top
321        let max_scroll = total.saturating_sub(visible_lines);
322
323        // Clamp scroll_offset to valid range
324        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        // Position from bottom within the visible track area (offset by content_offset_y)
333        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        // Store pixel coordinates for hit testing
338        // Position on right or left based on config, accounting for right inset (panel)
339        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        // Convert to normalized device coordinates (-1 to 1)
348        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            // Offset from right edge by right inset (panel width)
353            let right_inset_ndc = 2.0 * content_inset_right / ww;
354            1.0 - ndc_width - right_inset_ndc
355        } else {
356            -1.0 // left edge at -1
357        };
358
359        // Track spans only the visible area (between top inset and bottom inset)
360        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        // Update thumb uniforms (scrollable part)
375        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        // Prepare and upload mark uniforms (draw later)
390        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    /// Render the scrollbar (track + thumb)
404    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 track (background) first
412        render_pass.set_bind_group(0, &self.track_bind_group, &[]);
413        render_pass.draw(0..4, 0..1);
414
415        // Render thumb on top
416        render_pass.set_bind_group(0, &self.bind_group, &[]);
417        render_pass.draw(0..4, 0..1);
418
419        // Render marks on top of track/thumb
420        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        // Ensure we have enough pre-allocated buffers and bind groups
460        if self.mark_uniform_buffers.len() < num_marks {
461            let additional = num_marks - self.mark_uniform_buffers.len();
462            for _ in 0..additional {
463                // Create pre-allocated uniform buffer for a mark
464                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                // Create bind group for this buffer
472                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        // Process each mark and update the pre-allocated buffers
487        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            // Position within the constrained track area
494            let y_pixel = content_offset_y + ratio * track_pixel_height;
495            let ndc_y = 1.0 - 2.0 * y_pixel / wh;
496
497            // Store pixel position for hit testing (y from top)
498            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            // Update the pre-allocated buffer using queue.write_buffer (no new allocation)
520            queue.write_buffer(
521                &self.mark_uniform_buffers[mark_index],
522                0,
523                bytemuck::cast_slice(&[mark_uniforms]),
524            );
525
526            // Create mark instance reference to the pre-allocated bind group
527            self.marks.push(ScrollbarMarkInstance {
528                bind_group: self.mark_bind_groups[mark_index].clone(),
529            });
530
531            mark_index += 1;
532        }
533    }
534
535    /// Update scrollbar appearance (width and colors) in real-time
536    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        // Note: Visual changes will be reflected on next frame when uniforms are updated
541    }
542
543    /// Update scrollbar position side (left/right)
544    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    /// Check if a point (in pixel coordinates) is within the scrollbar bounds
557    ///
558    /// # Arguments
559    /// * `x` - X coordinate in pixels (from left edge)
560    /// * `y` - Y coordinate in pixels (from top edge)
561    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    /// Check if a point is within the scrollbar track (any Y position)
573    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    /// Get the current thumb bounds (top Y in pixels, height in pixels)
582    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    /// Convert a mouse Y position to a scroll offset
591    ///
592    /// # Arguments
593    /// * `mouse_y` - Desired thumb top Y coordinate in pixels (from top edge)
594    ///
595    /// # Returns
596    /// The scroll offset corresponding to the mouse position, or None if scrollbar is not visible
597    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        // Calculate the scrollable track area (space the thumb can move within the track)
608        let track_height = (self.track_pixel_height - self.scrollbar_height).max(1.0);
609
610        // Clamp mouse position relative to the track top
611        let relative_y = mouse_y - self.track_top;
612        let clamped_y = relative_y.clamp(0.0, track_height);
613
614        // Calculate scroll ratio (inverted because 0 = bottom)
615        let scroll_ratio = 1.0 - (clamped_y / track_height);
616
617        // Convert to scroll offset
618        let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
619
620        Some(scroll_offset.min(max_scroll))
621    }
622
623    /// Whether the scrollbar is currently visible
624    pub fn is_visible(&self) -> bool {
625        self.visible
626    }
627
628    /// Find a mark at the given mouse position (in pixels from top-left).
629    /// Returns the mark if the mouse is within `tolerance` pixels of a mark's Y position
630    /// and within the scrollbar's X bounds.
631    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        // Find the closest mark within tolerance
642        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}