use std::cell::Cell;
use crate::anim::{Easing, Transition};
use crate::core::{EventCtx, Widget};
use crate::event::{Event, Key, PointerKind};
use crate::geometry::{Color, Point, Rect, Size};
use crate::render::image::VisualState;
use crate::render::{Canvas, Paint};
use crate::signal::Signal;
use crate::spec::Align;
use crate::style::Style;
use crate::text::TextEngine;
use crate::ui::ImageContent;
const SCROLLBAR_HIT_W: i32 = 10;
const SCROLLBAR_MIN_THUMB: f32 = 24.0;
const TAB_ICON_GAP: i32 = 6;
pub struct VScrollbar {
pub dragging: bool,
start_y: i32,
start_scroll: i32,
drag_bar_h: f32,
drag_content_h: i32,
drag_view_h: i32,
}
impl Default for VScrollbar {
fn default() -> Self {
Self::new()
}
}
impl VScrollbar {
pub const TRACK_W: f32 = 5.0;
pub const MARGIN: f32 = 3.0;
pub const MIN_THUMB: f32 = 16.0;
pub const HIT_W: i32 = 12;
pub fn new() -> Self {
Self {
dragging: false,
start_y: 0,
start_scroll: 0,
drag_bar_h: 0.0,
drag_content_h: 0,
drag_view_h: 0,
}
}
fn bar_h(bounds: Rect) -> f32 {
(bounds.h as f32 - 2.0 * Self::MARGIN).max(0.0)
}
fn thumb_h(bar_h: f32, content_h: i32, view_h: i32) -> f32 {
let ratio = (view_h as f32 / content_h as f32).min(1.0);
(bar_h * ratio).max(Self::MIN_THUMB)
}
fn max_scroll(content_h: i32, view_h: i32) -> i32 {
(content_h - view_h).max(0)
}
pub fn has_overflow(content_h: i32, view_h: i32) -> bool {
content_h > view_h
}
pub fn hit_test(&self, pos: Point, bounds: Rect, content_h: i32, view_h: i32) -> bool {
Self::has_overflow(content_h, view_h)
&& pos.x >= bounds.right() - Self::HIT_W
&& pos.y >= bounds.y
&& pos.y < bounds.y + bounds.h
}
pub fn paint(
&self,
canvas: &mut dyn Canvas,
bounds: Rect,
scroll_y: i32,
content_h: i32,
view_h: i32,
) {
if !Self::has_overflow(content_h, view_h) {
return;
}
let bx = bounds.x as f32 + bounds.w as f32 - Self::TRACK_W - Self::MARGIN;
let by = bounds.y as f32;
let bh = Self::bar_h(bounds);
let th = Self::thumb_h(bh, content_h, view_h);
let max = Self::max_scroll(content_h, view_h).max(1) as f32;
let travel = (bh - th).max(1.0);
let ty = by + Self::MARGIN + travel * (scroll_y as f32 / max);
let r = Self::TRACK_W / 2.0;
canvas.fill_round_rect(
bx,
by + Self::MARGIN,
Self::TRACK_W,
bh,
r,
&Paint::fill(Color::rgba(0, 0, 0, 0x14)),
);
let alpha = if self.dragging { 0x78u8 } else { 0x52u8 };
canvas.fill_round_rect(
bx,
ty,
Self::TRACK_W,
th,
r,
&Paint::fill(Color::rgba(0, 0, 0, alpha)),
);
}
pub fn on_down(
&mut self,
pos: Point,
bounds: Rect,
scroll_y: i32,
content_h: i32,
view_h: i32,
ctx: &mut EventCtx,
) -> bool {
if !self.hit_test(pos, bounds, content_h, view_h) {
return false;
}
self.dragging = true;
self.start_y = pos.y;
self.start_scroll = scroll_y;
self.drag_bar_h = Self::bar_h(bounds);
self.drag_content_h = content_h;
self.drag_view_h = view_h;
ctx.capture();
true
}
pub fn on_move(&self, pos: Point) -> Option<i32> {
if !self.dragging {
return None;
}
let th = Self::thumb_h(self.drag_bar_h, self.drag_content_h, self.drag_view_h);
let travel = (self.drag_bar_h - th).max(1.0);
let max = Self::max_scroll(self.drag_content_h, self.drag_view_h);
let dy = pos.y - self.start_y;
let delta = (dy as f32 * max as f32 / travel) as i32;
Some((self.start_scroll + delta).clamp(0, max))
}
pub fn on_up(&mut self, ctx: &mut EventCtx) -> bool {
if self.dragging {
self.dragging = false;
ctx.release_capture();
true
} else {
false
}
}
}
#[derive(Default)]
pub struct ScrollWidget {
dragging: bool,
start_y: i32,
start_scroll: i32,
}
impl Widget for ScrollWidget {
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
let Event::Pointer(p) = ev else { return false };
match p.kind {
PointerKind::Wheel(delta) => {
let (scroll_y, content_h, view_h) = ctx.scroll_metrics();
let max_scroll = (content_h - view_h).max(0);
if max_scroll == 0 {
return false;
}
let dy = -delta * 48 / 120;
let at_boundary = (dy < 0 && scroll_y <= 0) || (dy > 0 && scroll_y >= max_scroll);
if at_boundary {
return false;
}
ctx.scroll_by(dy);
true
}
PointerKind::Down => {
let b = ctx.bounds();
let (scroll_y, content_h, view_h) = ctx.scroll_metrics();
if content_h > view_h && p.pos.x >= b.right() - SCROLLBAR_HIT_W {
self.dragging = true;
self.start_y = p.pos.y;
self.start_scroll = scroll_y;
ctx.capture();
true
} else {
false
}
}
PointerKind::Move if self.dragging => {
let (_, content_h, view_h) = ctx.scroll_metrics();
if view_h > 0 && content_h > view_h {
let max_scroll = content_h - view_h;
let thumb_h =
(view_h as f32 * view_h as f32 / content_h as f32).max(SCROLLBAR_MIN_THUMB);
let travel = (view_h as f32 - thumb_h).max(1.0);
let dy = p.pos.y - self.start_y;
let delta = (dy as f32 * max_scroll as f32 / travel) as i32;
ctx.set_scroll((self.start_scroll + delta).clamp(0, max_scroll));
}
true
}
PointerKind::Up if self.dragging => {
self.dragging = false;
ctx.release_capture();
true
}
_ => false,
}
}
}
pub struct ModalScrim;
impl Widget for ModalScrim {
fn on_event(&mut self, _ctx: &mut EventCtx, ev: &Event) -> bool {
matches!(ev, Event::Pointer(_))
}
}
pub struct TabButton {
label: String,
icon: Option<ImageContent>,
group: Signal<usize>,
index: usize,
hover: bool,
color_anim: Cell<Transition<Color>>,
primed: Cell<bool>,
ind: Cell<Transition<f32>>,
}
impl TabButton {
pub fn new(label: String, group: Signal<usize>, index: usize) -> Self {
let ind = if group.get() == index { 1.0 } else { 0.0 };
Self {
label,
icon: None,
group,
index,
hover: false,
color_anim: Cell::new(Transition::new(Color::rgba(0, 0, 0, 0))),
primed: Cell::new(false),
ind: Cell::new(Transition::new(ind)),
}
}
pub fn with_icon(mut self, icon: ImageContent) -> Self {
self.icon = Some(icon);
self
}
fn selected(&self) -> bool {
self.group.get() == self.index
}
fn visual_state(&self) -> VisualState {
if self.selected() {
VisualState::Selected
} else if self.hover {
VisualState::Hover
} else {
VisualState::Normal
}
}
}
impl Widget for TabButton {
fn measure(&self, _avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let t = text.measure(
&self.label,
style.font_family.as_deref(),
style.font_size,
None,
);
let icon_extra = if self.icon.is_some() {
t.h + TAB_ICON_GAP
} else {
0
};
Size::new(t.w + 24 + icon_extra, t.h + 16)
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
let (pal, tab) = (&th.palette, &th.tab);
let sel = self.selected();
let target_color = if !enabled {
pal.text_disabled
} else if sel {
tab.accent(pal)
} else if self.hover {
tab.hover(pal)
} else {
tab.inactive(pal)
};
let mut canim = self.color_anim.get();
if !self.primed.get() {
canim = Transition::new(target_color);
self.primed.set(true);
} else if canim.target() != target_color {
canim.retarget(target_color, th.anim.fast(), Easing::EaseOut);
}
let color = canim.animate();
self.color_anim.set(canim);
let vstate = if !enabled {
VisualState::Disabled
} else {
self.visual_state()
};
if let Some(icon) = &self.icon {
let ts =
canvas.measure_text(&self.label, style.font_family.as_deref(), style.font_size);
let ih = ts.h;
let total_w = ih + TAB_ICON_GAP + ts.w;
let sx = bounds.x + ((bounds.w - total_w) / 2).max(0);
let iy = bounds.y + ((bounds.h - ih) / 2).max(0);
let istyle = Style {
corner_radius: 0.0,
..style.clone()
};
icon.paint_into(Rect::new(sx, iy, ih, ih), canvas, &istyle, vstate);
let tr = Rect::new(sx + ih + TAB_ICON_GAP, bounds.y, ts.w + 2, bounds.h);
canvas.draw_text(
&self.label,
tr,
color,
Align::Start,
style.font_family.as_deref(),
style.font_size,
);
} else {
canvas.draw_text(
&self.label,
bounds,
color,
Align::Center,
style.font_family.as_deref(),
style.font_size,
);
}
let mut ind = self.ind.get();
let ind_target = if sel && enabled { 1.0 } else { 0.0 };
if ind.target() != ind_target {
ind.retarget(ind_target, th.anim.normal(), Easing::EaseOut);
}
let amount = ind.animate();
self.ind.set(ind);
if amount > 0.0 {
let ts =
canvas.measure_text(&self.label, style.font_family.as_deref(), style.font_size);
let content_w = if self.icon.is_some() {
ts.h + TAB_ICON_GAP + ts.w
} else {
ts.w
};
let full = ((content_w + 22) as f32)
.min(bounds.w as f32 - 4.0)
.max(0.0);
let bw = full * amount;
let cx = bounds.x as f32 + bounds.w as f32 / 2.0;
canvas.fill_round_rect(
cx - bw / 2.0,
(bounds.y + bounds.h - 3) as f32,
bw,
3.0,
1.5,
&Paint::fill(tab.accent(pal).scale_alpha(amount)),
);
}
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) => match p.kind {
PointerKind::Enter => {
self.hover = true;
ctx.mark_dirty();
true
}
PointerKind::Leave => {
self.hover = false;
ctx.mark_dirty();
true
}
PointerKind::Down => {
ctx.request_focus();
true
}
PointerKind::Up => {
if ctx.bounds().contains(p.pos) {
self.group.set(self.index);
ctx.mark_layout_dirty();
}
true
}
_ => false,
},
Event::Key(k) if k.pressed && (k.key == Key::Enter || k.key == Key::Space) => {
self.group.set(self.index);
ctx.mark_layout_dirty();
true
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::image::{Fit, Image};
use crate::signal::signal;
use crate::text::NullTextEngine;
#[test]
fn tab_icon_widens_measure() {
let g = signal(0);
let style = Style::default();
let mut te = NullTextEngine;
let w0 = TabButton::new("Home".into(), g, 0)
.measure(Size::ZERO, &style, &mut te)
.w;
let red = Image::from_rgba(4, 4, &[255u8, 0, 0, 255].repeat(4 * 4)).unwrap();
let iconed = TabButton::new("Home".into(), g, 0)
.with_icon(ImageContent::new(Some(red)).fit(Fit::Fill));
let w1 = iconed.measure(Size::ZERO, &style, &mut te).w;
assert!(w1 > w0, "带图标标签应更宽:w0={w0}, w1={w1}");
}
#[test]
fn tab_visual_state_tracks_selection() {
let g = signal(2);
assert_eq!(
TabButton::new("A".into(), g, 2).visual_state(),
VisualState::Selected
);
assert_eq!(
TabButton::new("B".into(), g, 0).visual_state(),
VisualState::Normal
);
}
}