par_term/
scrollbar.rs

1use wgpu::util::DeviceExt;
2use wgpu::{
3    BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor,
4    BindGroupLayoutEntry, BindingType, Buffer, BufferBindingType, BufferUsages, ColorTargetState,
5    ColorWrites, Device, FragmentState, MultisampleState, PipelineLayoutDescriptor, PrimitiveState,
6    PrimitiveTopology, Queue, RenderPass, RenderPipeline, RenderPipelineDescriptor,
7    ShaderModuleDescriptor, ShaderSource, ShaderStages, TextureFormat, VertexState,
8};
9
10/// Scrollbar renderer using wgpu
11pub struct Scrollbar {
12    pipeline: RenderPipeline,
13    uniform_buffer: Buffer,
14    bind_group: BindGroup,
15    track_bind_group: BindGroup,
16    track_uniform_buffer: Buffer,
17    width: f32,
18    visible: bool,
19    position_right: bool, // true = right side, false = left side
20    thumb_color: [f32; 4],
21    track_color: [f32; 4],
22
23    // Cached state for hit testing and interaction
24    scrollbar_x: f32,      // Pixel position X
25    scrollbar_y: f32,      // Pixel position Y
26    scrollbar_height: f32, // Pixel height (thumb)
27    window_width: u32,
28    window_height: u32,
29
30    // Scroll state
31    scroll_offset: usize,
32    visible_lines: usize,
33    total_lines: usize,
34}
35
36#[repr(C)]
37#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
38struct ScrollbarUniforms {
39    // Position and size (normalized device coordinates: -1 to 1)
40    position: [f32; 2], // x, y
41    size: [f32; 2],     // width, height
42    // Color (RGBA)
43    color: [f32; 4],
44}
45
46impl Scrollbar {
47    /// Create a new scrollbar renderer
48    ///
49    /// # Arguments
50    /// * `device` - WGPU device
51    /// * `format` - Texture format
52    /// * `width` - Scrollbar width in pixels
53    /// * `position` - Scrollbar position ("left" or "right")
54    /// * `thumb_color` - RGBA color for thumb [r, g, b, a]
55    /// * `track_color` - RGBA color for track [r, g, b, a]
56    pub fn new(
57        device: &Device,
58        format: TextureFormat,
59        width: f32,
60        position: &str,
61        thumb_color: [f32; 4],
62        track_color: [f32; 4],
63    ) -> Self {
64        // Create shader module
65        let shader = device.create_shader_module(ShaderModuleDescriptor {
66            label: Some("Scrollbar Shader"),
67            source: ShaderSource::Wgsl(include_str!("shaders/scrollbar.wgsl").into()),
68        });
69
70        // Create bind group layout
71        let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
72            label: Some("Scrollbar Bind Group Layout"),
73            entries: &[BindGroupLayoutEntry {
74                binding: 0,
75                visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
76                ty: BindingType::Buffer {
77                    ty: BufferBindingType::Uniform,
78                    has_dynamic_offset: false,
79                    min_binding_size: None,
80                },
81                count: None,
82            }],
83        });
84
85        // Create pipeline layout
86        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
87            label: Some("Scrollbar Pipeline Layout"),
88            bind_group_layouts: &[&bind_group_layout],
89            push_constant_ranges: &[],
90        });
91
92        // Create render pipeline
93        let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
94            label: Some("Scrollbar Pipeline"),
95            layout: Some(&pipeline_layout),
96            vertex: VertexState {
97                module: &shader,
98                entry_point: Some("vs_main"),
99                buffers: &[],
100                compilation_options: Default::default(),
101            },
102            fragment: Some(FragmentState {
103                module: &shader,
104                entry_point: Some("fs_main"),
105                targets: &[Some(ColorTargetState {
106                    format,
107                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
108                    write_mask: ColorWrites::ALL,
109                })],
110                compilation_options: Default::default(),
111            }),
112            primitive: PrimitiveState {
113                topology: PrimitiveTopology::TriangleStrip,
114                ..Default::default()
115            },
116            depth_stencil: None,
117            multisample: MultisampleState::default(),
118            multiview: None,
119            cache: None,
120        });
121
122        // Create uniform buffers for thumb and track
123        // Note: We don't need a vertex buffer because vertices are generated
124        // procedurally in the shader using builtin(vertex_index)
125
126        // Thumb uniform buffer
127        let thumb_uniforms = ScrollbarUniforms {
128            position: [0.0, 0.0],
129            size: [1.0, 1.0],
130            color: thumb_color,
131        };
132
133        let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
134            label: Some("Scrollbar Thumb Uniform Buffer"),
135            contents: bytemuck::cast_slice(&[thumb_uniforms]),
136            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
137        });
138
139        // Track uniform buffer
140        let track_uniforms = ScrollbarUniforms {
141            position: [0.0, 0.0],
142            size: [1.0, 1.0],
143            color: track_color,
144        };
145
146        let track_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
147            label: Some("Scrollbar Track Uniform Buffer"),
148            contents: bytemuck::cast_slice(&[track_uniforms]),
149            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
150        });
151
152        // Create bind groups
153        let bind_group = device.create_bind_group(&BindGroupDescriptor {
154            label: Some("Scrollbar Thumb Bind Group"),
155            layout: &bind_group_layout,
156            entries: &[BindGroupEntry {
157                binding: 0,
158                resource: uniform_buffer.as_entire_binding(),
159            }],
160        });
161
162        let track_bind_group = device.create_bind_group(&BindGroupDescriptor {
163            label: Some("Scrollbar Track Bind Group"),
164            layout: &bind_group_layout,
165            entries: &[BindGroupEntry {
166                binding: 0,
167                resource: track_uniform_buffer.as_entire_binding(),
168            }],
169        });
170
171        let position_right = position.eq_ignore_ascii_case("right");
172
173        Self {
174            pipeline,
175            uniform_buffer,
176            bind_group,
177            track_bind_group,
178            track_uniform_buffer,
179            width,
180            visible: false,
181            position_right,
182            thumb_color,
183            track_color,
184            scrollbar_x: 0.0,
185            scrollbar_y: 0.0,
186            scrollbar_height: 0.0,
187            window_width: 0,
188            window_height: 0,
189            scroll_offset: 0,
190            visible_lines: 0,
191            total_lines: 0,
192        }
193    }
194
195    /// Update scrollbar position and visibility
196    ///
197    /// # Arguments
198    /// * `scroll_offset` - Current scroll offset (0 = at bottom)
199    /// * `visible_lines` - Number of lines visible on screen
200    /// * `total_lines` - Total number of lines including scrollback
201    /// * `window_width` - Window width in pixels
202    /// * `window_height` - Window height in pixels
203    pub fn update(
204        &mut self,
205        queue: &Queue,
206        scroll_offset: usize,
207        visible_lines: usize,
208        total_lines: usize,
209        window_width: u32,
210        window_height: u32,
211    ) {
212        // Store parameters for hit testing
213        self.scroll_offset = scroll_offset;
214        self.visible_lines = visible_lines;
215        self.total_lines = total_lines;
216        self.window_width = window_width;
217        self.window_height = window_height;
218
219        // Only show scrollbar if there's scrollback content
220        self.visible = total_lines > visible_lines;
221
222        if !self.visible {
223            return;
224        }
225
226        // Calculate scrollbar dimensions
227        let viewport_ratio = visible_lines as f32 / total_lines as f32;
228        let scrollbar_height = (viewport_ratio * window_height as f32).max(20.0);
229
230        // Calculate scrollbar position
231        // When scroll_offset is 0, we're at the bottom
232        // When scroll_offset is max, we're at the top
233        let max_scroll = total_lines.saturating_sub(visible_lines);
234
235        // Clamp scroll_offset to valid range
236        let clamped_offset = scroll_offset.min(max_scroll);
237
238        let scroll_ratio = if max_scroll > 0 {
239            (clamped_offset as f32 / max_scroll as f32).clamp(0.0, 1.0)
240        } else {
241            0.0
242        };
243
244        // Position from bottom (invert scroll ratio since 0 = bottom)
245        let scrollbar_y = ((1.0 - scroll_ratio) * (window_height as f32 - scrollbar_height))
246            .clamp(0.0, window_height as f32 - scrollbar_height);
247
248        // Store pixel coordinates for hit testing
249        // Position on right or left based on config
250        self.scrollbar_x = if self.position_right {
251            window_width as f32 - self.width
252        } else {
253            0.0
254        };
255        self.scrollbar_y = scrollbar_y;
256        self.scrollbar_height = scrollbar_height;
257
258        // Convert to normalized device coordinates (-1 to 1)
259        let ndc_width = 2.0 * self.width / window_width as f32;
260        let ndc_x = if self.position_right {
261            1.0 - ndc_width // align right edge at +1
262        } else {
263            -1.0 // left edge at -1
264        };
265
266        // Update track uniforms (full height background)
267        let track_ndc_y = -1.0; // Full height from bottom to top
268        let track_ndc_height = 2.0; // Full NDC range
269        let track_uniforms = ScrollbarUniforms {
270            position: [ndc_x, track_ndc_y],
271            size: [ndc_width, track_ndc_height],
272            color: self.track_color,
273        };
274        queue.write_buffer(
275            &self.track_uniform_buffer,
276            0,
277            bytemuck::cast_slice(&[track_uniforms]),
278        );
279
280        // Update thumb uniforms (scrollable part)
281        let thumb_bottom = window_height as f32 - (scrollbar_y + scrollbar_height);
282        let thumb_ndc_y = -1.0 + (2.0 * thumb_bottom / window_height as f32);
283        let thumb_ndc_height = 2.0 * scrollbar_height / window_height as f32;
284        let thumb_uniforms = ScrollbarUniforms {
285            position: [ndc_x, thumb_ndc_y],
286            size: [ndc_width, thumb_ndc_height],
287            color: self.thumb_color,
288        };
289        queue.write_buffer(
290            &self.uniform_buffer,
291            0,
292            bytemuck::cast_slice(&[thumb_uniforms]),
293        );
294    }
295
296    /// Render the scrollbar (track + thumb)
297    pub fn render<'a>(&'a self, render_pass: &mut RenderPass<'a>) {
298        if !self.visible {
299            return;
300        }
301
302        render_pass.set_pipeline(&self.pipeline);
303
304        // Render track (background) first
305        render_pass.set_bind_group(0, &self.track_bind_group, &[]);
306        render_pass.draw(0..4, 0..1);
307
308        // Render thumb on top
309        render_pass.set_bind_group(0, &self.bind_group, &[]);
310        render_pass.draw(0..4, 0..1);
311    }
312
313    /// Update scrollbar appearance (width and colors) in real-time
314    pub fn update_appearance(&mut self, width: f32, thumb_color: [f32; 4], track_color: [f32; 4]) {
315        self.width = width;
316        self.thumb_color = thumb_color;
317        self.track_color = track_color;
318        // Note: Visual changes will be reflected on next frame when uniforms are updated
319    }
320
321    /// Update scrollbar position side (left/right)
322    #[allow(dead_code)]
323    pub fn update_position(&mut self, position: &str) {
324        self.position_right = !position.eq_ignore_ascii_case("left");
325    }
326
327    #[allow(dead_code)]
328    pub fn width(&self) -> f32 {
329        self.width
330    }
331
332    #[allow(dead_code)]
333    pub fn position_right(&self) -> bool {
334        self.position_right
335    }
336
337    /// Check if a point (in pixel coordinates) is within the scrollbar bounds
338    ///
339    /// # Arguments
340    /// * `x` - X coordinate in pixels (from left edge)
341    /// * `y` - Y coordinate in pixels (from top edge)
342    pub fn contains_point(&self, x: f32, y: f32) -> bool {
343        if !self.visible {
344            return false;
345        }
346
347        x >= self.scrollbar_x
348            && x <= self.scrollbar_x + self.width
349            && y >= self.scrollbar_y
350            && y <= self.scrollbar_y + self.scrollbar_height
351    }
352
353    /// Check if a point is within the scrollbar track (any Y position)
354    pub fn track_contains_x(&self, x: f32) -> bool {
355        if !self.visible {
356            return false;
357        }
358
359        x >= self.scrollbar_x && x <= self.scrollbar_x + self.width
360    }
361
362    /// Get the current thumb bounds (top Y in pixels, height in pixels)
363    pub fn thumb_bounds(&self) -> Option<(f32, f32)> {
364        if !self.visible {
365            return None;
366        }
367
368        Some((self.scrollbar_y, self.scrollbar_height))
369    }
370
371    /// Convert a mouse Y position to a scroll offset
372    ///
373    /// # Arguments
374    /// * `mouse_y` - Desired thumb top Y coordinate in pixels (from top edge)
375    ///
376    /// # Returns
377    /// The scroll offset corresponding to the mouse position, or None if scrollbar is not visible
378    pub fn mouse_y_to_scroll_offset(&self, mouse_y: f32) -> Option<usize> {
379        if !self.visible {
380            return None;
381        }
382
383        let max_scroll = self.total_lines.saturating_sub(self.visible_lines);
384        if max_scroll == 0 {
385            return Some(0);
386        }
387
388        // Calculate the scrollable track area (space the thumb can move)
389        let track_height = (self.window_height as f32 - self.scrollbar_height).max(1.0);
390
391        // Clamp mouse position (thumb top) to valid range
392        let clamped_y = mouse_y.clamp(0.0, track_height);
393
394        // Calculate scroll ratio (inverted because 0 = bottom)
395        let scroll_ratio = 1.0 - (clamped_y / track_height);
396
397        // Convert to scroll offset
398        let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
399
400        Some(scroll_offset.min(max_scroll))
401    }
402
403    /// Whether the scrollbar is currently visible
404    #[allow(dead_code)]
405    pub fn is_visible(&self) -> bool {
406        self.visible
407    }
408}