Skip to main content

par_term_render/
scrollbar.rs

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
14/// Scrollbar renderer using wgpu
15pub 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, // true = right side, false = left side
26    thumb_color: [f32; 4],
27    track_color: [f32; 4],
28
29    // Cached state for hit testing and interaction
30    scrollbar_x: f32,      // Pixel position X
31    scrollbar_y: f32,      // Pixel position Y
32    scrollbar_height: f32, // Pixel height (thumb)
33    window_width: u32,
34    window_height: u32,
35    /// Top of the scrollbar track in pixels (accounts for tab bar, etc.)
36    track_top: f32,
37    /// Height of the scrollbar track in pixels (excludes insets)
38    track_pixel_height: f32,
39
40    // Scroll state
41    scroll_offset: usize,
42    visible_lines: usize,
43    total_lines: usize,
44
45    // Mark overlays (prompt/command indicators)
46    marks: Vec<ScrollbarMarkInstance>,
47    /// Mark hit-test data for tooltip display
48    mark_hit_info: Vec<MarkHitInfo>,
49}
50
51#[repr(C)]
52#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
53struct ScrollbarUniforms {
54    // Position and size (normalized device coordinates: -1 to 1)
55    position: [f32; 2], // x, y
56    size: [f32; 2],     // width, height
57    // Color (RGBA)
58    color: [f32; 4],
59}
60
61struct ScrollbarMarkInstance {
62    bind_group: BindGroup,
63    #[allow(dead_code)]
64    buffer: Buffer,
65}
66
67/// Data for hit-testing marks on the scrollbar
68#[derive(Clone)]
69struct MarkHitInfo {
70    /// Y position in pixels (from top)
71    y_pixel: f32,
72    /// Original mark data for tooltip display
73    mark: ScrollbackMark,
74}
75
76impl Scrollbar {
77    /// Create a new scrollbar renderer
78    ///
79    /// # Arguments
80    /// * `device` - WGPU device
81    /// * `format` - Texture format
82    /// * `width` - Scrollbar width in pixels
83    /// * `position` - Scrollbar position ("left" or "right")
84    /// * `thumb_color` - RGBA color for thumb [r, g, b, a]
85    /// * `track_color` - RGBA color for track [r, g, b, a]
86    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        // Create shader module
95        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        // Create bind group layout
101        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        // Create pipeline layout
116        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        // Create render pipeline
123        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                    // Use premultiplied alpha blending since shader outputs premultiplied colors
138                    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        // Create uniform buffers for thumb and track
154        // Note: We don't need a vertex buffer because vertices are generated
155        // procedurally in the shader using builtin(vertex_index)
156
157        // Thumb uniform buffer
158        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        // Track uniform buffer
171        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        // Create bind groups
184        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    /// Update scrollbar position and visibility
235    ///
236    /// # Arguments
237    /// * `scroll_offset` - Current scroll offset (0 = at bottom)
238    /// * `visible_lines` - Number of lines visible on screen
239    /// * `total_lines` - Total number of lines including scrollback
240    /// * `window_width` - Window width in pixels
241    /// * `window_height` - Window height in pixels
242    /// * `content_offset_y` - Top inset in pixels (e.g., tab bar at top)
243    /// * `content_inset_bottom` - Bottom inset in pixels (e.g., status bar)
244    /// * `content_inset_right` - Right inset in pixels (e.g., AI Inspector panel)
245    #[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        // Store parameters for hit testing
260        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        // Show scrollbar when either scrollback exists or mark indicators are available
267        self.visible = total_lines > visible_lines || !marks.is_empty();
268
269        if !self.visible {
270            return;
271        }
272
273        // The visible track area excludes top and bottom insets (tab bar, status bar, etc.)
274        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        // Calculate scrollbar dimensions (guard against zero)
280        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        // Calculate scrollbar position
285        // When scroll_offset is 0, we're at the bottom
286        // When scroll_offset is max, we're at the top
287        let max_scroll = total.saturating_sub(visible_lines);
288
289        // Clamp scroll_offset to valid range
290        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        // Position from bottom within the visible track area (offset by content_offset_y)
299        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        // Store pixel coordinates for hit testing
304        // Position on right or left based on config, accounting for right inset (panel)
305        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        // Convert to normalized device coordinates (-1 to 1)
314        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            // Offset from right edge by right inset (panel width)
319            let right_inset_ndc = 2.0 * content_inset_right / ww;
320            1.0 - ndc_width - right_inset_ndc
321        } else {
322            -1.0 // left edge at -1
323        };
324
325        // Track spans only the visible area (between top inset and bottom inset)
326        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        // Update thumb uniforms (scrollable part)
341        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        // Prepare and upload mark uniforms (draw later)
356        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    /// Render the scrollbar (track + thumb)
367    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 track (background) first
375        render_pass.set_bind_group(0, &self.track_bind_group, &[]);
376        render_pass.draw(0..4, 0..1);
377
378        // Render thumb on top
379        render_pass.set_bind_group(0, &self.bind_group, &[]);
380        render_pass.draw(0..4, 0..1);
381
382        // Render marks on top of track/thumb
383        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; // 4px height
409        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            // Position within the constrained track area
423            let y_pixel = content_offset_y + ratio * track_pixel_height;
424            let ndc_y = 1.0 - 2.0 * y_pixel / wh;
425
426            // Store pixel position for hit testing (y from top)
427            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    /// Update scrollbar appearance (width and colors) in real-time
471    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        // Note: Visual changes will be reflected on next frame when uniforms are updated
476    }
477
478    /// Update scrollbar position side (left/right)
479    #[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    /// Check if a point (in pixel coordinates) is within the scrollbar bounds
495    ///
496    /// # Arguments
497    /// * `x` - X coordinate in pixels (from left edge)
498    /// * `y` - Y coordinate in pixels (from top edge)
499    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    /// Check if a point is within the scrollbar track (any Y position)
511    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    /// Get the current thumb bounds (top Y in pixels, height in pixels)
520    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    /// Convert a mouse Y position to a scroll offset
529    ///
530    /// # Arguments
531    /// * `mouse_y` - Desired thumb top Y coordinate in pixels (from top edge)
532    ///
533    /// # Returns
534    /// The scroll offset corresponding to the mouse position, or None if scrollbar is not visible
535    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        // Calculate the scrollable track area (space the thumb can move within the track)
546        let track_height = (self.track_pixel_height - self.scrollbar_height).max(1.0);
547
548        // Clamp mouse position relative to the track top
549        let relative_y = mouse_y - self.track_top;
550        let clamped_y = relative_y.clamp(0.0, track_height);
551
552        // Calculate scroll ratio (inverted because 0 = bottom)
553        let scroll_ratio = 1.0 - (clamped_y / track_height);
554
555        // Convert to scroll offset
556        let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
557
558        Some(scroll_offset.min(max_scroll))
559    }
560
561    /// Whether the scrollbar is currently visible
562    #[allow(dead_code)]
563    pub fn is_visible(&self) -> bool {
564        self.visible
565    }
566
567    /// Find a mark at the given mouse position (in pixels from top-left).
568    /// Returns the mark if the mouse is within `tolerance` pixels of a mark's Y position
569    /// and within the scrollbar's X bounds.
570    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        // Find the closest mark within tolerance
581        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}