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_width: u32,
25    window_height: u32,
26    content_offset_y: f32,
27    content_inset_bottom: f32,
28    content_inset_right: f32,
29    /// Whether this slot owns the single-instance mark hit-test list.
30    record_hit_state: bool,
31}
32
33/// Minimum scrollbar thumb height in pixels.
34/// Prevents the thumb from becoming too small to click when scrollback is very long.
35const MIN_SCROLLBAR_THUMB_HEIGHT_PX: f32 = 20.0;
36
37/// Height of each scrollback mark indicator in pixels.
38const 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
50/// One pane's worth of scrollbar GPU state.
51///
52/// ARC-004: the renderer used to own a single thumb/track/mark uniform set that
53/// `update_scrollbar_for_pane` rewrote before each pane's pass. That was only
54/// correct because a `queue.submit` separated each write from the next. Batching
55/// all panes into one encoder makes every write land before any pass executes, so
56/// each pane needs its own uniforms.
57struct 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    /// Pre-allocated mark uniform buffers, grown on demand and reused across frames.
64    mark_uniform_buffers: Vec<Buffer>,
65    /// Bind groups paired 1:1 with `mark_uniform_buffers`.
66    mark_bind_groups: Vec<BindGroup>,
67    /// How many of `mark_bind_groups` hold marks for the current frame.
68    mark_count: usize,
69}
70
71/// Scrollbar renderer using wgpu
72pub struct Scrollbar {
73    device: Arc<Device>,
74    pipeline: RenderPipeline,
75    bind_group_layout: BindGroupLayout,
76    /// Per-pane GPU state, indexed by the pane's position in the frame's pane list.
77    /// Slot 0 doubles as the single-grid path's slot.
78    slots: Vec<ScrollbarSlot>,
79    width: f32,
80    position_right: bool, // true = right side, false = left side
81    thumb_color: [f32; 4],
82    track_color: [f32; 4],
83
84    // Cached state for hit testing and interaction.
85    //
86    // Hit testing is single-instance because the mouse can only be over one
87    // scrollbar: it tracks the *focused* pane. Before per-pane slots existed this
88    // was whichever pane happened to be updated last, which in a split was not
89    // usually the focused one.
90    /// Whether the pane owning the hit-test state currently shows a scrollbar.
91    hit_visible: bool,
92    scrollbar_x: f32,      // Pixel position X
93    scrollbar_y: f32,      // Pixel position Y
94    scrollbar_height: f32, // Pixel height (thumb)
95    window_width: u32,
96    window_height: u32,
97    /// Top of the scrollbar track in pixels (accounts for tab bar, etc.)
98    track_top: f32,
99    /// Height of the scrollbar track in pixels (excludes insets)
100    track_pixel_height: f32,
101
102    // Scroll state
103    scroll_offset: usize,
104    visible_lines: usize,
105    total_lines: usize,
106
107    /// Mark hit-test data for tooltip display
108    mark_hit_info: Vec<MarkHitInfo>,
109
110    /// Maximum number of marks a slot can render.
111    max_marks: usize,
112}
113
114#[repr(C)]
115#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
116struct ScrollbarUniforms {
117    // Position and size (normalized device coordinates: -1 to 1)
118    position: [f32; 2], // x, y
119    size: [f32; 2],     // width, height
120    // Color (RGBA)
121    color: [f32; 4],
122}
123
124/// Data for hit-testing marks on the scrollbar
125#[derive(Clone)]
126struct MarkHitInfo {
127    /// Y position in pixels (from top)
128    y_pixel: f32,
129    /// Original mark data for tooltip display
130    mark: ScrollbackMark,
131}
132
133impl Scrollbar {
134    /// Create a new scrollbar renderer
135    ///
136    /// # Arguments
137    /// * `device` - WGPU device
138    /// * `format` - Texture format
139    /// * `width` - Scrollbar width in pixels
140    /// * `position` - Scrollbar position ("left" or "right")
141    /// * `thumb_color` - RGBA color for thumb [r, g, b, a]
142    /// * `track_color` - RGBA color for track [r, g, b, a]
143    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        // Create shader module
152        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        // Create bind group layout
158        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        // Create pipeline layout
173        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        // Create render pipeline
180        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                    // Use premultiplied alpha blending since shader outputs premultiplied colors
195                    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        // Note: We don't need a vertex buffer because vertices are generated
211        // procedurally in the shader using builtin(vertex_index).
212        // Thumb/track uniform buffers are allocated per slot by `ensure_slot`.
213
214        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    /// Allocate slots up to and including `index`.
242    ///
243    /// Slots are never freed: a session's pane count is small and bounded, and
244    /// reusing them keeps per-frame GPU allocation at zero.
245    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    /// Update slot 0 and adopt it as the hit-test scrollbar.
299    ///
300    /// All geometry and scroll-state parameters are passed via [`ScrollbarUpdateParams`].
301    pub fn update(&mut self, queue: &Queue, params: ScrollbarUpdateParams<'_>) {
302        self.update_slot(queue, 0, params, true);
303    }
304
305    /// Forget the hit-test geometry.
306    ///
307    /// Called when the focused pane has no scrollbar this frame, so that clicks
308    /// are not tested against another pane's (or a previous frame's) bounds.
309    pub fn clear_hit_state(&mut self) {
310        self.hit_visible = false;
311        self.mark_hit_info.clear();
312    }
313
314    /// Update one pane's scrollbar slot.
315    ///
316    /// `record_hit_state` should be true for exactly one slot per frame — the
317    /// focused pane — since hit testing is single-instance.
318    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        // Show scrollbar when either scrollback exists or mark indicators are available
340        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            // Drop any marks the slot carried last frame, or they would draw again.
348            self.slots[slot].mark_count = 0;
349            if record_hit_state {
350                self.mark_hit_info.clear();
351            }
352            return;
353        }
354
355        // Store parameters for hit testing
356        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        // The visible track area excludes top and bottom insets (tab bar, status bar, etc.)
365        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        // Calculate scrollbar dimensions (guard against zero)
373        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        // Calculate scrollbar position
379        // When scroll_offset is 0, we're at the bottom
380        // When scroll_offset is max, we're at the top
381        let max_scroll = total.saturating_sub(visible_lines);
382
383        // Clamp scroll_offset to valid range
384        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        // Position from bottom within the visible track area (offset by content_offset_y)
393        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        // Store pixel coordinates for hit testing
398        // Position on right or left based on config, accounting for right inset (panel)
399        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        // Convert to normalized device coordinates (-1 to 1)
410        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            // Offset from right edge by right inset (panel width)
415            let right_inset_ndc = 2.0 * content_inset_right / ww;
416            1.0 - ndc_width - right_inset_ndc
417        } else {
418            -1.0 // left edge at -1
419        };
420
421        // Track spans only the visible area (between top inset and bottom inset)
422        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        // Update thumb uniforms (scrollable part)
437        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        // Prepare and upload mark uniforms (draw later)
452        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    /// Render one pane's scrollbar (track + thumb + marks).
469    ///
470    /// `slot` is the index passed to [`Scrollbar::update_slot`]; the single-grid
471    /// path uses slot 0.
472    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 track (background) first
483        render_pass.set_bind_group(0, &slot.track_bind_group, &[]);
484        render_pass.draw(0..4, 0..1);
485
486        // Render thumb on top
487        render_pass.set_bind_group(0, &slot.thumb_bind_group, &[]);
488        render_pass.draw(0..4, 0..1);
489
490        // Render marks on top of track/thumb
491        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        // Ensure this slot has enough pre-allocated buffers and bind groups
536        while self.slots[slot].mark_uniform_buffers.len() < num_marks {
537            // Create pre-allocated uniform buffer for a mark
538            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            // Create bind group for this buffer
546            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        // Process each mark and update the pre-allocated buffers
560        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            // Position within the constrained track area
567            let y_pixel = content_offset_y + ratio * track_pixel_height;
568            let ndc_y = 1.0 - 2.0 * y_pixel / wh;
569
570            // Store pixel position for hit testing (y from top)
571            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            // Update the pre-allocated buffer using queue.write_buffer (no new allocation)
595            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        // Marks occupy bind groups [0..mark_index] of this slot, in order.
605        self.slots[slot].mark_count = mark_index;
606    }
607
608    /// Update scrollbar appearance (width and colors) in real-time
609    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        // Note: Visual changes will be reflected on next frame when uniforms are updated
614    }
615
616    /// Update scrollbar position side (left/right)
617    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    /// Check if a point (in pixel coordinates) is within the scrollbar bounds
638    ///
639    /// # Arguments
640    /// * `x` - X coordinate in pixels (from left edge)
641    /// * `y` - Y coordinate in pixels (from top edge)
642    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    /// Check if a point is within the scrollbar track (any Y position)
654    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    /// Get the current thumb bounds (top Y in pixels, height in pixels)
663    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    /// Convert a mouse Y position to a scroll offset
672    ///
673    /// # Arguments
674    /// * `mouse_y` - Desired thumb top Y coordinate in pixels (from top edge)
675    ///
676    /// # Returns
677    /// The scroll offset corresponding to the mouse position, or None if scrollbar is not visible
678    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        // Calculate the scrollable track area (space the thumb can move within the track)
689        let track_height = (self.track_pixel_height - self.scrollbar_height).max(1.0);
690
691        // Clamp mouse position relative to the track top
692        let relative_y = mouse_y - self.track_top;
693        let clamped_y = relative_y.clamp(0.0, track_height);
694
695        // Calculate scroll ratio (inverted because 0 = bottom)
696        let scroll_ratio = 1.0 - (clamped_y / track_height);
697
698        // Convert to scroll offset
699        let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
700
701        Some(scroll_offset.min(max_scroll))
702    }
703
704    /// Whether the scrollbar is currently visible
705    pub fn is_visible(&self) -> bool {
706        self.hit_visible
707    }
708
709    /// Find a mark at the given mouse position (in pixels from top-left).
710    /// Returns the mark if the mouse is within `tolerance` pixels of a mark's Y position
711    /// and within the scrollbar's X bounds.
712    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        // Find the closest mark within tolerance
723        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}