use rio_backend::sugarloaf::Sugarloaf;
use std::time::Instant;
const SCROLLBAR_WIDTH: f32 = 6.0;
const SCROLLBAR_MARGIN: f32 = 2.0;
const SCROLLBAR_MIN_THUMB_HEIGHT: f32 = 20.0;
const SCROLLBAR_HIT_WIDTH: f32 = 14.0;
const FADE_OUT_DELAY_MS: u128 = 2000;
const FADE_OUT_DURATION_MS: u128 = 300;
const SCROLLBAR_COLOR: [f32; 4] = [0.6, 0.6, 0.6, 0.5];
const SCROLLBAR_DRAG_COLOR: [f32; 4] = [0.7, 0.7, 0.7, 0.7];
const DEPTH: f32 = -0.05;
const ORDER: u8 = 5;
pub struct ThumbGeometry {
bar_x: f32,
bar_y: f32,
track_height: f32,
thumb_y: f32,
thumb_height: f32,
}
#[derive(Clone, Copy)]
pub struct ScrollbarDragState {
pub rich_text_id: usize,
grab_offset_y: f32,
bar_y: f32,
track_height: f32,
thumb_height: f32,
history_size: usize,
}
#[derive(Clone, Copy)]
pub struct PanelScrollState {
pub rich_text_id: usize,
pub panel_rect: [f32; 4],
pub display_offset: usize,
pub history_size: usize,
pub screen_lines: usize,
}
pub struct Scrollbar {
enabled: bool,
last_scroll_times: Vec<(usize, Instant)>,
pub drag_state: Option<ScrollbarDragState>,
panel_states: Vec<PanelScrollState>,
}
impl Scrollbar {
pub fn new(enabled: bool) -> Self {
Scrollbar {
enabled,
last_scroll_times: Vec::new(),
drag_state: None,
panel_states: Vec::new(),
}
}
pub fn clear_panel_states(&mut self) {
self.panel_states.clear();
}
pub fn push_panel_state(&mut self, state: PanelScrollState) {
self.panel_states.push(state);
}
pub fn panel_states(&self) -> &[PanelScrollState] {
&self.panel_states
}
#[inline]
pub fn notify_scroll(&mut self, rich_text_id: usize) {
if !self.enabled {
return;
}
let now = Instant::now();
if let Some(entry) = self
.last_scroll_times
.iter_mut()
.find(|(id, _)| *id == rich_text_id)
{
entry.1 = now;
} else {
self.last_scroll_times.push((rich_text_id, now));
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn is_dragging(&self) -> bool {
self.drag_state.is_some()
}
fn opacity_for(&self, rich_text_id: usize) -> f32 {
if self
.drag_state
.is_some_and(|d| d.rich_text_id == rich_text_id)
{
return 1.0;
}
let entry = self
.last_scroll_times
.iter()
.find(|(id, _)| *id == rich_text_id);
let last_scroll = match entry {
Some((_, t)) => t,
None => return 0.0,
};
let elapsed = last_scroll.elapsed().as_millis();
if elapsed < FADE_OUT_DELAY_MS {
1.0
} else {
let fade_elapsed = elapsed - FADE_OUT_DELAY_MS;
if fade_elapsed >= FADE_OUT_DURATION_MS {
0.0
} else {
1.0 - (fade_elapsed as f32 / FADE_OUT_DURATION_MS as f32)
}
}
}
fn compute_thumb(
panel_rect: [f32; 4],
scale_factor: f32,
display_offset: usize,
history_size: usize,
screen_lines: usize,
grid_margin: (f32, f32),
) -> ThumbGeometry {
let total_lines = history_size + screen_lines;
let panel_x = (panel_rect[0] + grid_margin.0) / scale_factor;
let panel_y = (panel_rect[1] + grid_margin.1) / scale_factor;
let panel_width = panel_rect[2] / scale_factor;
let panel_height = panel_rect[3] / scale_factor;
let bar_x = panel_x + panel_width - SCROLLBAR_WIDTH - SCROLLBAR_MARGIN;
let bar_y = panel_y + SCROLLBAR_MARGIN;
let track_height = panel_height - SCROLLBAR_MARGIN * 2.0;
let thumb_ratio = screen_lines as f32 / total_lines as f32;
let thumb_height = (track_height * thumb_ratio).max(SCROLLBAR_MIN_THUMB_HEIGHT);
let scroll_ratio = if history_size > 0 {
display_offset as f32 / history_size as f32
} else {
0.0
};
let thumb_y = bar_y + (1.0 - scroll_ratio) * (track_height - thumb_height);
ThumbGeometry {
bar_x,
bar_y,
track_height,
thumb_y,
thumb_height,
}
}
#[allow(clippy::too_many_arguments)]
pub fn hit_test(
&self,
mouse_x: f32,
mouse_y: f32,
panel_rect: [f32; 4],
scale_factor: f32,
display_offset: usize,
history_size: usize,
screen_lines: usize,
grid_margin: (f32, f32),
) -> Option<(Option<f32>, ThumbGeometry)> {
if !self.enabled || history_size == 0 {
return None;
}
let geom = Self::compute_thumb(
panel_rect,
scale_factor,
display_offset,
history_size,
screen_lines,
grid_margin,
);
let hit_x = geom.bar_x - (SCROLLBAR_HIT_WIDTH - SCROLLBAR_WIDTH) / 2.0;
let hit_width = SCROLLBAR_HIT_WIDTH;
if mouse_x < hit_x || mouse_x > hit_x + hit_width {
return None;
}
if mouse_y < geom.bar_y || mouse_y > geom.bar_y + geom.track_height {
return None;
}
if mouse_y >= geom.thumb_y && mouse_y <= geom.thumb_y + geom.thumb_height {
let grab_offset = mouse_y - geom.thumb_y;
Some((Some(grab_offset), geom))
} else {
Some((None, geom))
}
}
pub fn start_drag(
&mut self,
rich_text_id: usize,
grab_offset_y: Option<f32>,
geom: &ThumbGeometry,
history_size: usize,
) {
let grab_offset = grab_offset_y.unwrap_or(geom.thumb_height / 2.0);
self.drag_state = Some(ScrollbarDragState {
rich_text_id,
grab_offset_y: grab_offset,
bar_y: geom.bar_y,
track_height: geom.track_height,
thumb_height: geom.thumb_height,
history_size,
});
self.notify_scroll(rich_text_id);
}
pub fn drag_update(&mut self, mouse_y: f32) -> Option<usize> {
let state = self.drag_state?;
let thumb_top = mouse_y - state.grab_offset_y;
let available = state.track_height - state.thumb_height;
if available <= 0.0 {
return Some(0);
}
let clamped = (thumb_top - state.bar_y).clamp(0.0, available);
let scroll_ratio = 1.0 - (clamped / available);
let display_offset = (scroll_ratio * state.history_size as f32).round() as usize;
let display_offset = display_offset.min(state.history_size);
self.notify_scroll(state.rich_text_id);
Some(display_offset)
}
pub fn end_drag(&mut self) {
if let Some(state) = self.drag_state.take() {
self.notify_scroll(state.rich_text_id);
}
}
#[allow(clippy::too_many_arguments)]
pub fn render(
&self,
sugarloaf: &mut Sugarloaf,
panel_rect: [f32; 4],
scale_factor: f32,
display_offset: usize,
history_size: usize,
screen_lines: usize,
rich_text_id: usize,
grid_margin: (f32, f32),
) {
if !self.enabled || history_size == 0 {
return;
}
let opacity = self.opacity_for(rich_text_id);
if opacity <= 0.0 {
return;
}
let geom = Self::compute_thumb(
panel_rect,
scale_factor,
display_offset,
history_size,
screen_lines,
grid_margin,
);
let is_dragging = self
.drag_state
.is_some_and(|d| d.rich_text_id == rich_text_id);
let base_color = if is_dragging {
SCROLLBAR_DRAG_COLOR
} else {
SCROLLBAR_COLOR
};
let color = [
base_color[0],
base_color[1],
base_color[2],
base_color[3] * opacity,
];
sugarloaf.rect(
None,
geom.bar_x,
geom.thumb_y,
SCROLLBAR_WIDTH,
geom.thumb_height,
color,
DEPTH,
ORDER,
);
}
pub fn needs_redraw(&mut self) -> bool {
if !self.enabled {
return false;
}
if self.drag_state.is_some() {
return true;
}
let deadline = FADE_OUT_DELAY_MS + FADE_OUT_DURATION_MS;
self.last_scroll_times
.retain(|(_, t)| t.elapsed().as_millis() < deadline);
!self.last_scroll_times.is_empty()
}
}