use std::sync::Arc;
use parking_lot::RwLock;
use tessera_ui::{
Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, MeasurementError,
Modifier, PressKeyEventType, Px, PxPosition, State,
accesskit::{Action, Role},
layout::{LayoutInput, LayoutOutput, LayoutSpec},
tessera,
};
use crate::{
modifier::ModifierExt as _,
scrollable::{ScrollBarBehavior, ScrollableController},
shape_def::{RoundedCorner, Shape},
surface::{SurfaceArgs, surface},
};
#[derive(Clone, Copy)]
enum ScrollOrientation {
Vertical,
Horizontal,
}
#[derive(Clone, PartialEq)]
struct ScrollBarVLayout {
thumb_offset: Px,
}
impl LayoutSpec for ScrollBarVLayout {
fn measure(
&self,
input: &LayoutInput<'_>,
output: &mut LayoutOutput<'_>,
) -> Result<ComputedData, MeasurementError> {
let track_node_id = input.children_ids()[0];
let size = input.measure_child(track_node_id, &Constraint::NONE)?;
output.place_child(track_node_id, PxPosition::ZERO);
let thumb_node_id = input.children_ids()[1];
input.measure_child(thumb_node_id, &Constraint::NONE)?;
output.place_child(thumb_node_id, PxPosition::new(Px::ZERO, self.thumb_offset));
Ok(size)
}
}
#[derive(Clone, PartialEq)]
struct ScrollBarHLayout {
thumb_offset: Px,
}
impl LayoutSpec for ScrollBarHLayout {
fn measure(
&self,
input: &LayoutInput<'_>,
output: &mut LayoutOutput<'_>,
) -> Result<ComputedData, MeasurementError> {
let track_node_id = input.children_ids()[0];
let size = input.measure_child(track_node_id, &Constraint::NONE)?;
output.place_child(track_node_id, PxPosition::ZERO);
let thumb_node_id = input.children_ids()[1];
input.measure_child(thumb_node_id, &Constraint::NONE)?;
output.place_child(thumb_node_id, PxPosition::new(self.thumb_offset, Px::ZERO));
Ok(size)
}
}
#[derive(Clone)]
pub struct ScrollBarArgs {
pub total: Px,
pub visible: Px,
pub offset: Px,
pub thickness: Dp,
pub state: State<ScrollableController>,
pub scrollbar_behavior: ScrollBarBehavior,
pub track_color: Color,
pub thumb_color: Color,
pub thumb_hover_color: Color,
}
#[derive(Default)]
pub struct ScrollBarStateInner {
pub is_dragging: bool,
pub is_hovered: bool,
pub hover_instant: Option<std::time::Instant>,
pub last_scroll_activity: Option<std::time::Instant>,
pub should_be_visible: bool,
}
#[derive(Clone)]
pub struct ScrollBarState {
inner: Arc<RwLock<ScrollBarStateInner>>,
}
impl ScrollBarState {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(ScrollBarStateInner::default())),
}
}
pub fn read(&self) -> parking_lot::RwLockReadGuard<'_, ScrollBarStateInner> {
self.inner.read()
}
pub fn write(&self) -> parking_lot::RwLockWriteGuard<'_, ScrollBarStateInner> {
self.inner.write()
}
}
impl Default for ScrollBarState {
fn default() -> Self {
Self::new()
}
}
fn calculate_target_pos_v(
cursor_y: Px,
track_height: Px,
thumb_height: Px,
total: Px,
visible: Px,
fallback: PxPosition,
) -> PxPosition {
let thumb_scrollable_range = track_height - thumb_height;
if thumb_scrollable_range <= Px::ZERO {
return fallback;
}
let cursor_y_adjusted = cursor_y - thumb_height / 2;
let progress = (cursor_y_adjusted.to_f32() / thumb_scrollable_range.to_f32()).clamp(0.0, 1.0);
let content_scrollable_range = total - visible;
if content_scrollable_range <= Px::ZERO {
return PxPosition::ZERO;
}
let new_target_y = Px::from_f32(-progress * content_scrollable_range.to_f32());
PxPosition {
x: Px::ZERO, y: new_target_y,
}
}
fn calculate_target_pos_h(
cursor_x: Px,
track_width: Px,
thumb_width: Px,
total: Px,
visible: Px,
fallback: PxPosition,
) -> PxPosition {
let thumb_scrollable_range = track_width - thumb_width;
if thumb_scrollable_range <= Px::ZERO {
return fallback;
}
let cursor_x_adjusted = cursor_x - thumb_width / 2;
let progress = (cursor_x_adjusted.to_f32() / thumb_scrollable_range.to_f32()).clamp(0.0, 1.0);
let content_scrollable_range = total - visible;
if content_scrollable_range <= Px::ZERO {
return PxPosition::ZERO;
}
let new_target_x = Px::from_f32(-progress * content_scrollable_range.to_f32());
PxPosition {
x: new_target_x,
y: Px::ZERO, }
}
fn compute_thumb_color(state_lock: &ScrollBarState, args: &ScrollBarArgs) -> Color {
let state = state_lock.read();
let (from_color, to_color) = if state.is_hovered {
(args.thumb_color, args.thumb_hover_color)
} else {
(args.thumb_hover_color, args.thumb_color)
};
let progress = if let Some(instant) = state.hover_instant {
(instant.elapsed().as_secs_f32() / 0.2).min(1.0)
} else {
0.0
};
from_color.lerp(&to_color, progress)
}
fn render_track_surface_v(width: Px, height: Px, color: Color) {
surface(
SurfaceArgs::default()
.modifier(Modifier::new().constrain(
Some(DimensionValue::Fixed(width)),
Some(DimensionValue::Fixed(height)),
))
.style(color.into())
.shape(Shape::RoundedRectangle {
top_left: RoundedCorner::Capsule,
top_right: RoundedCorner::ZERO,
bottom_left: RoundedCorner::Capsule,
bottom_right: RoundedCorner::ZERO,
}),
|| {},
);
}
fn render_thumb_surface_v(width: Px, height: Px, color: Color) {
surface(
SurfaceArgs::default()
.modifier(Modifier::new().constrain(
Some(DimensionValue::Fixed(width)),
Some(DimensionValue::Fixed(height)),
))
.shape(Shape::RoundedRectangle {
top_left: RoundedCorner::Capsule,
top_right: RoundedCorner::ZERO,
bottom_left: RoundedCorner::Capsule,
bottom_right: RoundedCorner::ZERO,
})
.style(color.into()),
|| {},
);
}
fn render_track_surface_h(width: Px, height: Px, color: Color) {
surface(
SurfaceArgs::default()
.modifier(Modifier::new().constrain(
Some(DimensionValue::Fixed(width)),
Some(DimensionValue::Fixed(height)),
))
.style(color.into())
.shape(Shape::RoundedRectangle {
top_left: RoundedCorner::Capsule,
top_right: RoundedCorner::Capsule,
bottom_left: RoundedCorner::ZERO,
bottom_right: RoundedCorner::ZERO,
}),
|| {},
);
}
fn render_thumb_surface_h(width: Px, height: Px, color: Color) {
surface(
SurfaceArgs::default()
.modifier(Modifier::new().constrain(
Some(DimensionValue::Fixed(width)),
Some(DimensionValue::Fixed(height)),
))
.shape(Shape::RoundedRectangle {
top_left: RoundedCorner::Capsule,
top_right: RoundedCorner::Capsule,
bottom_left: RoundedCorner::ZERO,
bottom_right: RoundedCorner::ZERO,
})
.style(color.into()),
|| {},
);
}
fn should_show_scrollbar(args: &ScrollBarArgs, state: &ScrollBarState) -> bool {
match args.scrollbar_behavior {
ScrollBarBehavior::AlwaysVisible => true,
ScrollBarBehavior::Hidden => false,
ScrollBarBehavior::AutoHide => {
let state_guard = state.read();
state_guard.should_be_visible || state_guard.is_dragging || state_guard.is_hovered
}
}
}
fn handle_autohide_if_needed(args: &ScrollBarArgs, state: &ScrollBarState) {
if matches!(args.scrollbar_behavior, ScrollBarBehavior::AutoHide) {
let mut state_guard = state.write();
if let Some(last_activity) = state_guard.last_scroll_activity {
if last_activity.elapsed().as_secs_f32() > 2.0 {
state_guard.should_be_visible = false;
}
}
}
}
fn mark_scroll_activity(state: &ScrollBarState, behavior: &ScrollBarBehavior) {
if matches!(*behavior, ScrollBarBehavior::AutoHide) {
let mut state_guard = state.write();
state_guard.last_scroll_activity = Some(std::time::Instant::now());
state_guard.should_be_visible = true;
}
}
fn compute_thumb_progress(offset: Px, total: Px) -> f32 {
if total <= Px::ZERO {
0.0
} else {
offset.to_f32().abs() / total.to_f32()
}
}
fn compute_thumb_size(visible: Px, total: Px) -> Px {
if total <= Px::ZERO {
return visible.max(Px::ZERO);
}
let visible_len = visible.to_f32().abs();
let total_len = total.to_f32().abs().max(1.0);
let thumb = (visible_len * visible_len) / total_len;
let min_thumb = (visible_len * 0.05).clamp(8.0, 32.0);
Px::saturating_from_f32(thumb.max(min_thumb))
}
fn cursor_on_thumb_v(cursor_pos: PxPosition, width: Px, thumb_y: f32, thumb_height: Px) -> bool {
cursor_pos.x >= Px::ZERO
&& cursor_pos.x <= width
&& cursor_pos.y >= Px::from_f32(thumb_y)
&& cursor_pos.y <= Px::from_f32(thumb_y + thumb_height.to_f32())
}
fn cursor_on_thumb_h(cursor_pos: PxPosition, height: Px, thumb_x: f32, thumb_width: Px) -> bool {
cursor_pos.y >= Px::ZERO
&& cursor_pos.y <= height
&& cursor_pos.x >= Px::from_f32(thumb_x)
&& cursor_pos.x <= Px::from_f32(thumb_x + thumb_width.to_f32())
}
fn is_on_track_v(cursor_pos: PxPosition, thickness: Px, track_height: Px) -> bool {
cursor_pos.x >= Px::ZERO
&& cursor_pos.x <= thickness
&& cursor_pos.y >= Px::ZERO
&& cursor_pos.y <= track_height
}
fn is_on_track_h(cursor_pos: PxPosition, thickness: Px, track_width: Px) -> bool {
cursor_pos.y >= Px::ZERO
&& cursor_pos.y <= thickness
&& cursor_pos.x >= Px::ZERO
&& cursor_pos.x <= track_width
}
fn check_and_handle_release(input: &tessera_ui::InputHandlerInput, state: &ScrollBarState) -> bool {
if input.cursor_events.iter().any(|event| {
matches!(
event.content,
CursorEventContent::Released(PressKeyEventType::Left)
)
}) {
state.write().is_dragging = false;
true
} else {
false
}
}
fn apply_scrollbar_accessibility(
input: &mut tessera_ui::InputHandlerInput<'_>,
args: &ScrollBarArgs,
state: &ScrollBarState,
orientation: ScrollOrientation,
) {
let mut builder = input.accessibility().role(Role::ScrollBar);
let label = match orientation {
ScrollOrientation::Vertical => "Vertical scrollbar",
ScrollOrientation::Horizontal => "Horizontal scrollbar",
};
builder = builder.label(label.to_string());
let progress = compute_thumb_progress(args.offset, args.total).clamp(0.0, 1.0);
builder = builder
.numeric_value(progress as f64)
.numeric_range(0.0, 1.0)
.focusable()
.action(Action::Increment)
.action(Action::Decrement);
builder.commit();
let args_clone = args.clone();
let state_clone = state.clone();
input.set_accessibility_action_handler(move |action| match action {
Action::Increment => {
scroll_accessibility_step(&args_clone, &state_clone, orientation, true)
}
Action::Decrement => {
scroll_accessibility_step(&args_clone, &state_clone, orientation, false)
}
_ => {}
});
}
fn scroll_accessibility_step(
args: &ScrollBarArgs,
state: &ScrollBarState,
orientation: ScrollOrientation,
increment: bool,
) {
let step_amount = (args.visible.to_f32() * 0.1).max(1.0);
let step_px = Px::from_f32(step_amount);
let delta = match orientation {
ScrollOrientation::Vertical => {
let dy = if increment { -step_px } else { step_px };
PxPosition::new(Px::ZERO, dy)
}
ScrollOrientation::Horizontal => {
let dx = if increment { -step_px } else { step_px };
PxPosition::new(dx, Px::ZERO)
}
};
let new_target = args
.state
.with(|c| c.target_position().saturating_offset(delta.x, delta.y));
args.state.with_mut(|c| c.set_target_position(new_target));
if matches!(args.scrollbar_behavior, ScrollBarBehavior::AutoHide) {
let mut scroll_state = state.write();
scroll_state.last_scroll_activity = Some(std::time::Instant::now());
scroll_state.should_be_visible = true;
}
}
fn is_pressed_left(input: &tessera_ui::InputHandlerInput) -> bool {
input.cursor_events.iter().any(|event| {
matches!(
event.content,
CursorEventContent::Pressed(PressKeyEventType::Left)
)
})
}
fn update_drag_vertical(
input: &tessera_ui::InputHandlerInput,
calculate_target: &dyn Fn(Px) -> PxPosition,
args: &ScrollBarArgs,
state: &ScrollBarState,
) {
if let Some(cursor_pos) = input.cursor_position_rel {
let new_target_pos = calculate_target(cursor_pos.y);
args.state
.with_mut(|c| c.set_target_position(new_target_pos));
mark_scroll_activity(state, &args.scrollbar_behavior);
} else {
state.write().is_dragging = false;
}
}
fn update_hover_state(is_on_thumb: bool, state: &ScrollBarState) {
if is_on_thumb && !state.read().is_hovered {
let mut state_guard = state.write();
state_guard.is_hovered = true;
state_guard.hover_instant = Some(std::time::Instant::now());
} else if !is_on_thumb && state.read().is_hovered {
let mut state_guard = state.write();
state_guard.is_hovered = false;
state_guard.hover_instant = Some(std::time::Instant::now());
}
}
fn handle_state_v(
args: &ScrollBarArgs,
state: &ScrollBarState,
track_height: Px,
thumb_height: Px,
input: &mut tessera_ui::InputHandlerInput<'_>,
) {
handle_autohide_if_needed(args, state);
let fallback_pos = args.state.with(|c| c.target_position());
let calculate_target_pos = |cursor_y: Px| -> PxPosition {
calculate_target_pos_v(
cursor_y,
track_height,
thumb_height,
args.total,
args.visible,
fallback_pos,
)
};
if state.read().is_dragging {
if check_and_handle_release(input, state) {
return;
}
update_drag_vertical(input, &calculate_target_pos, args, state);
} else {
let Some(cursor_pos) = input.cursor_position_rel else {
state.write().is_hovered = false; return; };
let is_on_thumb = cursor_on_thumb_v(
cursor_pos,
args.thickness.to_px(),
args.visible.to_f32() * (args.offset.to_f32().abs() / args.total.to_f32()),
thumb_height,
);
update_hover_state(is_on_thumb, state);
if !is_pressed_left(input) {
return; }
if is_on_thumb {
state.write().is_dragging = true;
return;
}
if is_on_track_v(cursor_pos, args.thickness.to_px(), track_height) {
let new_target_pos = calculate_target_pos(cursor_pos.y);
args.state
.with_mut(|c| c.set_target_position(new_target_pos));
}
}
}
fn update_drag_horizontal(
input: &tessera_ui::InputHandlerInput,
calculate_target: &dyn Fn(Px) -> PxPosition,
args: &ScrollBarArgs,
state: &ScrollBarState,
) {
if let Some(cursor_pos) = input.cursor_position_rel {
let new_target_pos = calculate_target(cursor_pos.x);
args.state
.with_mut(|c| c.set_target_position(new_target_pos));
mark_scroll_activity(state, &args.scrollbar_behavior);
} else {
state.write().is_dragging = false;
}
}
fn handle_state_h(
args: &ScrollBarArgs,
state: &ScrollBarState,
track_width: Px,
thumb_width: Px,
input: &mut tessera_ui::InputHandlerInput<'_>,
) {
handle_autohide_if_needed(args, state);
let fallback_pos = args.state.with(|c| c.target_position());
let calculate_target_pos = |cursor_x: Px| -> PxPosition {
calculate_target_pos_h(
cursor_x,
track_width,
thumb_width,
args.total,
args.visible,
fallback_pos,
)
};
if state.read().is_dragging {
if check_and_handle_release(input, state) {
return;
}
update_drag_horizontal(input, &calculate_target_pos, args, state);
} else {
let Some(cursor_pos) = input.cursor_position_rel else {
state.write().is_hovered = false; return; };
let is_on_thumb = cursor_on_thumb_h(
cursor_pos,
args.thickness.to_px(),
args.visible.to_f32() * (args.offset.to_f32().abs() / args.total.to_f32()),
thumb_width,
);
update_hover_state(is_on_thumb, state);
if !is_pressed_left(input) {
return;
}
if is_on_thumb {
state.write().is_dragging = true;
return;
}
if is_on_track_h(cursor_pos, args.thickness.to_px(), track_width) {
let new_target_pos = calculate_target_pos(cursor_pos.x);
args.state
.with_mut(|c| c.set_target_position(new_target_pos));
}
}
}
#[tessera]
pub fn scrollbar_v(args: impl Into<ScrollBarArgs>, state: ScrollBarState) {
let args: ScrollBarArgs = args.into();
let should_show = should_show_scrollbar(&args, &state);
if !should_show {
return;
}
if args.visible <= Px::ZERO || args.total <= Px::ZERO || args.thickness <= Dp::ZERO {
return;
}
let width = args.thickness.to_px();
let track_height = args.visible;
let thumb_height = compute_thumb_size(args.visible, args.total);
let has_vertical_overflow = args.total > args.visible;
let track_color = if has_vertical_overflow {
args.track_color
} else {
args.track_color.with_alpha(0.0)
};
render_track_surface_v(width, track_height, track_color);
let thumb_color = if has_vertical_overflow {
compute_thumb_color(&state, &args)
} else {
args.thumb_color.with_alpha(0.0)
};
render_thumb_surface_v(width, thumb_height, thumb_color);
let progress = compute_thumb_progress(args.offset, args.total);
let thumb_y = args.visible.to_f32() * progress;
layout(ScrollBarVLayout {
thumb_offset: Px::from_f32(thumb_y),
});
let args_for_handler = args.clone();
let state_for_handler = state.clone();
input_handler(move |mut input| {
handle_state_v(
&args_for_handler,
&state_for_handler,
track_height,
thumb_height,
&mut input,
);
apply_scrollbar_accessibility(
&mut input,
&args_for_handler,
&state_for_handler,
ScrollOrientation::Vertical,
);
});
}
#[tessera]
pub fn scrollbar_h(args: impl Into<ScrollBarArgs>, state: ScrollBarState) {
let args: ScrollBarArgs = args.into();
let should_show = should_show_scrollbar(&args, &state);
if !should_show {
return;
}
if args.visible <= Px::ZERO || args.total <= Px::ZERO || args.thickness <= Dp::ZERO {
return;
}
let height = args.thickness.to_px();
let track_width = args.visible;
let thumb_width = compute_thumb_size(args.visible, args.total);
let has_horizontal_overflow = args.total > args.visible;
let track_color = if has_horizontal_overflow {
args.track_color
} else {
args.track_color.with_alpha(0.0)
};
render_track_surface_h(track_width, height, track_color);
let thumb_color = if has_horizontal_overflow {
compute_thumb_color(&state, &args)
} else {
args.thumb_color.with_alpha(0.0)
};
render_thumb_surface_h(thumb_width, height, thumb_color);
let progress = compute_thumb_progress(args.offset, args.total);
let thumb_x = args.visible.to_f32() * progress;
layout(ScrollBarHLayout {
thumb_offset: Px::from_f32(thumb_x),
});
let args_for_handler = args.clone();
let state_for_handler = state.clone();
input_handler(move |mut input| {
handle_state_h(
&args_for_handler,
&state_for_handler,
track_width,
thumb_width,
&mut input,
);
apply_scrollbar_accessibility(
&mut input,
&args_for_handler,
&state_for_handler,
ScrollOrientation::Horizontal,
);
});
}