use std::sync::Arc;
use wgpu::BindGroupLayout;
const MAX_SCROLLBAR_MARKS: usize = 256;
pub struct ScrollbarUpdateParams<'a> {
pub scroll_offset: usize,
pub visible_lines: usize,
pub total_lines: usize,
pub window_width: u32,
pub window_height: u32,
pub content_offset_y: f32,
pub content_inset_bottom: f32,
pub content_inset_right: f32,
pub marks: &'a [par_term_config::ScrollbackMark],
}
struct PrepareMarksLayout {
total_lines: usize,
window_height: u32,
content_offset_y: f32,
content_inset_bottom: f32,
content_inset_right: f32,
}
const MIN_SCROLLBAR_THUMB_HEIGHT_PX: f32 = 20.0;
const SCROLLBAR_MARK_HEIGHT_PX: f32 = 4.0;
use wgpu::util::DeviceExt;
use wgpu::{
BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor,
BindGroupLayoutEntry, BindingType, Buffer, BufferBindingType, BufferUsages, ColorTargetState,
ColorWrites, Device, FragmentState, MultisampleState, PipelineLayoutDescriptor, PrimitiveState,
PrimitiveTopology, Queue, RenderPass, RenderPipeline, RenderPipelineDescriptor,
ShaderModuleDescriptor, ShaderSource, ShaderStages, TextureFormat, VertexState,
};
use par_term_config::{ScrollbackMark, color_tuple_to_f32_a};
pub struct Scrollbar {
device: Arc<Device>,
pipeline: RenderPipeline,
uniform_buffer: Buffer,
bind_group: BindGroup,
track_bind_group: BindGroup,
track_uniform_buffer: Buffer,
mark_bind_group_layout: BindGroupLayout,
width: f32,
visible: bool,
position_right: bool, thumb_color: [f32; 4],
track_color: [f32; 4],
scrollbar_x: f32, scrollbar_y: f32, scrollbar_height: f32, window_width: u32,
window_height: u32,
track_top: f32,
track_pixel_height: f32,
scroll_offset: usize,
visible_lines: usize,
total_lines: usize,
marks: Vec<ScrollbarMarkInstance>,
mark_hit_info: Vec<MarkHitInfo>,
max_marks: usize,
mark_uniform_buffers: Vec<Buffer>,
mark_bind_groups: Vec<BindGroup>,
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct ScrollbarUniforms {
position: [f32; 2], size: [f32; 2], color: [f32; 4],
}
struct ScrollbarMarkInstance {
bind_group: BindGroup,
}
#[derive(Clone)]
struct MarkHitInfo {
y_pixel: f32,
mark: ScrollbackMark,
}
impl Scrollbar {
pub fn new(
device: std::sync::Arc<Device>,
format: TextureFormat,
width: f32,
position: &str,
thumb_color: [f32; 4],
track_color: [f32; 4],
) -> Self {
let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("Scrollbar Shader"),
source: ShaderSource::Wgsl(include_str!("shaders/scrollbar.wgsl").into()),
});
let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("Scrollbar Bind Group Layout"),
entries: &[BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("Scrollbar Pipeline Layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
label: Some("Scrollbar Pipeline"),
layout: Some(&pipeline_layout),
vertex: VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState {
format,
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: PrimitiveState {
topology: PrimitiveTopology::TriangleStrip,
..Default::default()
},
depth_stencil: None,
multisample: MultisampleState::default(),
cache: None,
multiview_mask: None,
});
let thumb_uniforms = ScrollbarUniforms {
position: [0.0, 0.0],
size: [1.0, 1.0],
color: thumb_color,
};
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Scrollbar Thumb Uniform Buffer"),
contents: bytemuck::cast_slice(&[thumb_uniforms]),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
let track_uniforms = ScrollbarUniforms {
position: [0.0, 0.0],
size: [1.0, 1.0],
color: track_color,
};
let track_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Scrollbar Track Uniform Buffer"),
contents: bytemuck::cast_slice(&[track_uniforms]),
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
});
let bind_group = device.create_bind_group(&BindGroupDescriptor {
label: Some("Scrollbar Thumb Bind Group"),
layout: &bind_group_layout,
entries: &[BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});
let track_bind_group = device.create_bind_group(&BindGroupDescriptor {
label: Some("Scrollbar Track Bind Group"),
layout: &bind_group_layout,
entries: &[BindGroupEntry {
binding: 0,
resource: track_uniform_buffer.as_entire_binding(),
}],
});
let mark_bind_group_layout = bind_group_layout.clone();
let position_right = position.eq_ignore_ascii_case("right");
Self {
device,
pipeline,
uniform_buffer,
bind_group,
track_bind_group,
track_uniform_buffer,
mark_bind_group_layout,
width,
visible: false,
position_right,
thumb_color,
track_color,
scrollbar_x: 0.0,
scrollbar_y: 0.0,
scrollbar_height: 0.0,
window_width: 0,
window_height: 0,
track_top: 0.0,
track_pixel_height: 0.0,
scroll_offset: 0,
visible_lines: 0,
total_lines: 0,
marks: Vec::new(),
mark_hit_info: Vec::new(),
max_marks: MAX_SCROLLBAR_MARKS,
mark_uniform_buffers: Vec::new(),
mark_bind_groups: Vec::new(),
}
}
pub fn update(&mut self, queue: &Queue, params: ScrollbarUpdateParams<'_>) {
let ScrollbarUpdateParams {
scroll_offset,
visible_lines,
total_lines,
window_width,
window_height,
content_offset_y,
content_inset_bottom,
content_inset_right,
marks,
} = params;
self.scroll_offset = scroll_offset;
self.visible_lines = visible_lines;
self.total_lines = total_lines;
self.window_width = window_width;
self.window_height = window_height;
self.visible = total_lines > visible_lines || !marks.is_empty();
if !self.visible {
return;
}
let track_pixel_height =
(window_height as f32 - content_offset_y - content_inset_bottom).max(1.0);
self.track_top = content_offset_y;
self.track_pixel_height = track_pixel_height;
let total = total_lines.max(1);
let viewport_ratio = visible_lines.min(total) as f32 / total as f32;
let scrollbar_height =
(viewport_ratio * track_pixel_height).max(MIN_SCROLLBAR_THUMB_HEIGHT_PX);
let max_scroll = total.saturating_sub(visible_lines);
let clamped_offset = scroll_offset.min(max_scroll);
let scroll_ratio = if max_scroll > 0 {
(clamped_offset as f32 / max_scroll as f32).clamp(0.0, 1.0)
} else {
0.0
};
let scrollbar_y = content_offset_y
+ ((1.0 - scroll_ratio) * (track_pixel_height - scrollbar_height))
.clamp(0.0, track_pixel_height - scrollbar_height);
self.scrollbar_x = if self.position_right {
window_width as f32 - self.width - content_inset_right
} else {
0.0
};
self.scrollbar_y = scrollbar_y;
self.scrollbar_height = scrollbar_height;
let ww = window_width as f32;
let wh = window_height as f32;
let ndc_width = 2.0 * self.width / ww;
let ndc_x = if self.position_right {
let right_inset_ndc = 2.0 * content_inset_right / ww;
1.0 - ndc_width - right_inset_ndc
} else {
-1.0 };
let track_bottom_pixel = wh - content_offset_y - track_pixel_height;
let track_ndc_y = -1.0 + (2.0 * track_bottom_pixel / wh);
let track_ndc_height = 2.0 * track_pixel_height / wh;
let track_uniforms = ScrollbarUniforms {
position: [ndc_x, track_ndc_y],
size: [ndc_width, track_ndc_height],
color: self.track_color,
};
queue.write_buffer(
&self.track_uniform_buffer,
0,
bytemuck::cast_slice(&[track_uniforms]),
);
let thumb_bottom = wh - (scrollbar_y + scrollbar_height);
let thumb_ndc_y = -1.0 + (2.0 * thumb_bottom / wh);
let thumb_ndc_height = 2.0 * scrollbar_height / wh;
let thumb_uniforms = ScrollbarUniforms {
position: [ndc_x, thumb_ndc_y],
size: [ndc_width, thumb_ndc_height],
color: self.thumb_color,
};
queue.write_buffer(
&self.uniform_buffer,
0,
bytemuck::cast_slice(&[thumb_uniforms]),
);
self.prepare_marks(
queue,
marks,
PrepareMarksLayout {
total_lines,
window_height,
content_offset_y,
content_inset_bottom,
content_inset_right,
},
);
}
pub fn render<'a>(&'a self, render_pass: &mut RenderPass<'a>) {
if !self.visible {
return;
}
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &self.track_bind_group, &[]);
render_pass.draw(0..4, 0..1);
render_pass.set_bind_group(0, &self.bind_group, &[]);
render_pass.draw(0..4, 0..1);
for mark in &self.marks {
render_pass.set_bind_group(0, &mark.bind_group, &[]);
render_pass.draw(0..4, 0..1);
}
}
fn prepare_marks(
&mut self,
queue: &Queue,
marks: &[par_term_config::ScrollbackMark],
layout: PrepareMarksLayout,
) {
let PrepareMarksLayout {
total_lines,
window_height,
content_offset_y,
content_inset_bottom,
content_inset_right,
} = layout;
self.marks.clear();
self.mark_hit_info.clear();
if total_lines == 0 || marks.is_empty() {
return;
}
let num_marks = marks.len().min(self.max_marks);
let ww = self.window_width as f32;
let wh = window_height as f32;
let track_pixel_height = (wh - content_offset_y - content_inset_bottom).max(1.0);
let mark_height_ndc = (2.0 * SCROLLBAR_MARK_HEIGHT_PX) / wh;
let ndc_width = 2.0 * self.width / ww;
let ndc_x = if self.position_right {
let right_inset_ndc = 2.0 * content_inset_right / ww;
1.0 - ndc_width - right_inset_ndc
} else {
-1.0
};
if self.mark_uniform_buffers.len() < num_marks {
let additional = num_marks - self.mark_uniform_buffers.len();
for _ in 0..additional {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Scrollbar Mark Uniform Buffer"),
size: std::mem::size_of::<ScrollbarUniforms>() as u64,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = self.device.create_bind_group(&BindGroupDescriptor {
label: Some("Scrollbar Mark Bind Group"),
layout: &self.mark_bind_group_layout,
entries: &[BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
self.mark_uniform_buffers.push(buffer);
self.mark_bind_groups.push(bind_group);
}
}
let mut mark_index = 0;
for mark in marks.iter().take(num_marks) {
if mark.line >= total_lines {
continue;
}
let ratio = mark.line as f32 / (total_lines as f32 - 1.0).max(1.0);
let y_pixel = content_offset_y + ratio * track_pixel_height;
let ndc_y = 1.0 - 2.0 * y_pixel / wh;
self.mark_hit_info.push(MarkHitInfo {
y_pixel,
mark: mark.clone(),
});
let color = if let Some((r, g, b)) = mark.color {
color_tuple_to_f32_a(r, g, b, 1.0)
} else {
match mark.exit_code {
Some(0) => [0.2, 0.8, 0.4, 1.0],
Some(_) => [0.9, 0.25, 0.2, 1.0],
None => [0.6, 0.6, 0.6, 0.9],
}
};
let mark_uniforms = ScrollbarUniforms {
position: [ndc_x, ndc_y - mark_height_ndc / 2.0],
size: [ndc_width, mark_height_ndc],
color,
};
queue.write_buffer(
&self.mark_uniform_buffers[mark_index],
0,
bytemuck::cast_slice(&[mark_uniforms]),
);
self.marks.push(ScrollbarMarkInstance {
bind_group: self.mark_bind_groups[mark_index].clone(),
});
mark_index += 1;
}
}
pub fn update_appearance(&mut self, width: f32, thumb_color: [f32; 4], track_color: [f32; 4]) {
self.width = width;
self.thumb_color = thumb_color;
self.track_color = track_color;
}
pub fn update_position(&mut self, position: &str) {
self.position_right = !position.eq_ignore_ascii_case("left");
}
pub fn width(&self) -> f32 {
self.width
}
pub fn thumb_color(&self) -> [f32; 4] {
self.thumb_color
}
pub fn track_color(&self) -> [f32; 4] {
self.track_color
}
pub fn position_right(&self) -> bool {
self.position_right
}
pub fn contains_point(&self, x: f32, y: f32) -> bool {
if !self.visible {
return false;
}
x >= self.scrollbar_x
&& x <= self.scrollbar_x + self.width
&& y >= self.scrollbar_y
&& y <= self.scrollbar_y + self.scrollbar_height
}
pub fn track_contains_x(&self, x: f32) -> bool {
if !self.visible {
return false;
}
x >= self.scrollbar_x && x <= self.scrollbar_x + self.width
}
pub fn thumb_bounds(&self) -> Option<(f32, f32)> {
if !self.visible {
return None;
}
Some((self.scrollbar_y, self.scrollbar_height))
}
pub fn mouse_y_to_scroll_offset(&self, mouse_y: f32) -> Option<usize> {
if !self.visible {
return None;
}
let max_scroll = self.total_lines.saturating_sub(self.visible_lines);
if max_scroll == 0 {
return Some(0);
}
let track_height = (self.track_pixel_height - self.scrollbar_height).max(1.0);
let relative_y = mouse_y - self.track_top;
let clamped_y = relative_y.clamp(0.0, track_height);
let scroll_ratio = 1.0 - (clamped_y / track_height);
let scroll_offset = (scroll_ratio * max_scroll as f32).round() as usize;
Some(scroll_offset.min(max_scroll))
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn mark_at_position(
&self,
mouse_x: f32,
mouse_y: f32,
tolerance: f32,
) -> Option<&ScrollbackMark> {
if !self.visible || !self.track_contains_x(mouse_x) {
return None;
}
let mut closest: Option<(f32, &MarkHitInfo)> = None;
for hit_info in &self.mark_hit_info {
let distance = (hit_info.y_pixel - mouse_y).abs();
if distance <= tolerance {
match closest {
Some((best_dist, _)) if distance < best_dist => {
closest = Some((distance, hit_info));
}
None => {
closest = Some((distance, hit_info));
}
_ => {}
}
}
}
closest.map(|(_, hit_info)| &hit_info.mark)
}
}