aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Barra de scroll compartida: pulgar arrastrable, clic en la pista, brillo al pasar y
//! marcadores opcionales (diagnósticos, búsqueda, git…) a lo largo del contenido.
//!
//! La usan `Div` con scroll, `list`, `uniform_list` y el editor. Las medidas viven
//! aquí y no repartidas por cada elemento.

use crate::core::{Bounds, Pixels, Point, Rgba, Size};
use crate::view::context::ElementContext;
use crate::view::interactivity::{HandlerEntry, ScrollState, SharedScroll};
use std::rc::Rc;

/// Ancho de la pista (la zona que se puede pulsar).
pub const TRACK_WIDTH: f32 = 12.0;
const THUMB_WIDTH: f32 = 6.0;
const MIN_THUMB: f32 = 24.0;
const THUMB_ALPHA: f32 = 0.35;
const THUMB_ALPHA_ACTIVE: f32 = 0.6;
const TRACK_ALPHA: f32 = 0.07;
const MARKER_HEIGHT: f32 = 2.0;

/// Una marca sobre la pista: posición como fracción del contenido (0 arriba, 1 abajo).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Marker {
    pub fraction: f32,
    pub color: Rgba,
}

/// Pista y pulgar de una barra.
#[derive(Clone, Copy, Debug)]
pub struct Geometry {
    pub track: Bounds,
    pub thumb: Bounds,
    max_offset: f32,
}

impl Geometry {
    /// `None` si no hay contenido oculto que desplazar.
    pub fn new(bounds: Bounds, state: ScrollState) -> Option<Self> {
        let height = bounds.size.height.0;
        if state.max_y <= 0.0 || height <= 0.0 {
            return None;
        }
        let total = state.max_y + state.viewport_y;
        let thumb_height = (height * state.viewport_y / total).clamp(MIN_THUMB.min(height), height);
        let top = bounds.origin.y.0 + (height - thumb_height) * (state.offset_y / state.max_y).clamp(0.0, 1.0);
        let right = bounds.right().0;
        let rect = |x: f32, y: f32, width: f32, height: f32| {
            Bounds::new(Point::new(Pixels(x), Pixels(y)), Size::new(Pixels(width), Pixels(height)))
        };
        Some(Self {
            track: rect(right - TRACK_WIDTH, bounds.origin.y.0, TRACK_WIDTH, height),
            thumb: rect(right - (TRACK_WIDTH + THUMB_WIDTH) / 2.0, top, THUMB_WIDTH, thumb_height),
            max_offset: state.max_y,
        })
    }

    pub fn contains(&self, point: Point) -> bool {
        self.track.contains(point)
    }

    /// Al pulsar en `y`: si cae sobre el pulgar se agarra donde está; si cae en la pista, el pulgar
    /// salta a centrarse ahí. Devuelve la distancia agarrada (desde el borde superior del pulgar).
    pub fn press(&self, y: f32) -> f32 {
        let (top, height) = (self.thumb.origin.y.0, self.thumb.size.height.0);
        if (top..=top + height).contains(&y) { y - top } else { height / 2.0 }
    }

    /// Desplazamiento que deja el pulgar agarrado en `grab` bajo el puntero en `y`.
    pub fn offset_for(&self, y: f32, grab: f32) -> f32 {
        let travel = (self.track.size.height.0 - self.thumb.size.height.0).max(1.0);
        ((y - grab - self.track.origin.y.0) / travel).clamp(0.0, 1.0) * self.max_offset
    }
}

impl ElementContext<'_> {
    /// Pinta la barra de `scroll` sobre `bounds` y atiende pulsar y arrastrar. Con `markers`
    /// dibuja además marcas sobre la pista.
    pub fn scrollbar(&mut self, bounds: Bounds, scroll: &SharedScroll, markers: &[Marker]) {
        let state = scroll.get();
        let Some(geometry) = Geometry::new(bounds, state) else { return };
        self.paint_scrollbar_geometry(&geometry, state.grab.is_some(), markers);
        let hitbox = self.insert_hitbox(geometry.track, false);
        let (on_press, on_drag) = (scroll.clone(), scroll.clone());
        self.register_handlers(HandlerEntry {
            on_mouse_down: Some(Rc::new(move |event, _| {
                let mut state = on_press.get();
                if let Some(geometry) = Geometry::new(bounds, state) {
                    let grab = geometry.press(event.position.y.0);
                    state.offset_y = geometry.offset_for(event.position.y.0, grab);
                    state.grab = Some(grab);
                    on_press.set(state);
                }
            })),
            on_drag: Some(Rc::new(move |event, _| {
                let mut state = on_drag.get();
                if let (Some(grab), Some(geometry)) = (state.grab, Geometry::new(bounds, state)) {
                    state.offset_y = geometry.offset_for(event.position.y.0, grab);
                    on_drag.set(state);
                }
            })),
            ..HandlerEntry::new(hitbox)
        });
    }

    /// Dibuja pista (al pasar o arrastrar), marcas y pulgar con la geometría ya calculada.
    pub fn paint_scrollbar_geometry(&mut self, geometry: &Geometry, dragging: bool, markers: &[Marker]) {
        let hovered = dragging || geometry.contains(self.mouse_position());
        let muted = self.theme.color(crate::view::theme::Token::TextMuted);
        if hovered {
            self.fill_rect(geometry.track, muted.with_alpha(TRACK_ALPHA));
        }
        let track = geometry.track;
        for marker in markers {
            let y = track.origin.y.0 + marker.fraction.clamp(0.0, 1.0) * (track.size.height.0 - MARKER_HEIGHT);
            let rect = Bounds::new(
                Point::new(track.origin.x, Pixels(y)),
                Size::new(Pixels(TRACK_WIDTH), Pixels(MARKER_HEIGHT)),
            );
            self.fill_rect(rect, marker.color);
        }
        let alpha = if hovered { THUMB_ALPHA_ACTIVE } else { THUMB_ALPHA };
        self.paint_rounded_rect(geometry.thumb, muted.with_alpha(alpha), Pixels(THUMB_WIDTH / 2.0));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn bounds() -> Bounds {
        Bounds::new(Point::new(Pixels(100.0), Pixels(10.0)), Size::new(Pixels(300.0), Pixels(200.0)))
    }

    fn state(offset: f32) -> ScrollState {
        ScrollState { offset_y: offset, max_y: 800.0, viewport_y: 200.0, grab: None }
    }

    #[test]
    fn thumb_follows_the_offset_and_maps_back() {
        let top = Geometry::new(bounds(), state(0.0)).expect("hay barra");
        assert_eq!(top.thumb.origin.y.0, 10.0);
        assert_eq!(top.thumb.size.height.0, 40.0, "un quinto del contenido es visible");
        let middle = Geometry::new(bounds(), state(400.0)).expect("hay barra");
        assert_eq!(middle.thumb.origin.y.0, 90.0);
        // Pulgar agarrado por su mitad (20 px) y llevado a 110: recorre la mitad de los 160 px disponibles.
        assert_eq!(top.offset_for(110.0, 20.0), 400.0);
        assert!(Geometry::new(bounds(), ScrollState { max_y: 0.0, ..state(0.0) }).is_none());
    }

    #[test]
    fn pressing_the_track_centers_the_thumb_and_pressing_the_thumb_grabs_it() {
        let geometry = Geometry::new(bounds(), state(0.0)).expect("hay barra");
        assert_eq!(geometry.press(20.0), 10.0, "sobre el pulgar: se agarra donde se pulsó");
        let grab = geometry.press(150.0);
        assert_eq!(grab, 20.0, "en la pista: el pulgar se centra bajo el puntero");
        let offset = geometry.offset_for(150.0, grab);
        assert!((offset - 600.0).abs() < 0.01, "(150 - 20 - 10) / 160 = 75 % de 800 = {offset}");
    }
}