use std::cell::Cell;
use crate::anim::{Easing, Transition};
use crate::core::{ClickFn, EventCtx, Widget};
use crate::event::{CursorShape, Event, Key, PointerKind};
use crate::geometry::{Color, Rect, Size};
use crate::render::{Canvas, Paint};
use crate::style::Style;
use crate::text::TextEngine;
#[derive(PartialEq, Eq, Clone, Copy)]
enum LinkState {
Normal,
Hover,
Press,
}
pub struct Link {
text: String,
url: Option<String>,
underline: bool,
state: LinkState,
on_click: Option<ClickFn>,
color_anim: Cell<Transition<Color>>,
primed: Cell<bool>,
}
impl Link {
pub fn new(text: String) -> Self {
Self {
text,
url: None,
underline: true,
state: LinkState::Normal,
on_click: None,
color_anim: Cell::new(Transition::new(Color::rgba(0, 0, 0, 0))),
primed: Cell::new(false),
}
}
pub fn set_url(&mut self, url: String) {
self.url = Some(url);
}
pub fn set_underline(&mut self, on: bool) {
self.underline = on;
}
fn activate(&mut self, ctx: &mut EventCtx) {
if let Some(cb) = self.on_click.as_mut() {
cb(ctx);
} else if let Some(url) = &self.url {
ctx.open_url(url);
}
}
}
impl Widget for Link {
fn measure(&self, _avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
text.measure(
&self.text,
style.font_family.as_deref(),
style.font_size,
None,
)
}
fn paint(
&self,
_bounds: Rect,
content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
let (pal, lk) = (&th.palette, &th.link);
let target = if !enabled {
pal.text_disabled
} else {
match self.state {
LinkState::Normal => lk.color(pal),
LinkState::Hover => lk.hover(pal),
LinkState::Press => lk.pressed(pal),
}
};
let mut anim = self.color_anim.get();
if !self.primed.get() {
anim = Transition::new(target);
self.primed.set(true);
} else if anim.target() != target {
anim.retarget(target, th.anim.fast(), Easing::EaseOut);
}
let color = anim.animate();
self.color_anim.set(anim);
canvas.draw_text(
&self.text,
content,
color,
style.text_align,
style.font_family.as_deref(),
style.font_size,
);
if self.underline {
let tw = canvas
.measure_text(&self.text, style.font_family.as_deref(), style.font_size)
.w;
let y = (content.y + content.h - 1) as f32;
let x0 = content.x as f32;
canvas.draw_line(x0, y, x0 + tw as f32, y, 1.0, &Paint::fill(color));
}
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) => match p.kind {
PointerKind::Enter => {
if self.state == LinkState::Normal {
self.state = LinkState::Hover;
ctx.mark_dirty();
}
true
}
PointerKind::Leave => {
if self.state != LinkState::Press {
self.state = LinkState::Normal;
ctx.mark_dirty();
}
true
}
PointerKind::Down => {
self.state = LinkState::Press;
ctx.capture();
ctx.request_focus();
ctx.mark_dirty();
true
}
PointerKind::Up => {
let was_press = self.state == LinkState::Press;
let inside = ctx.bounds().contains(p.pos);
self.state = if inside {
LinkState::Hover
} else {
LinkState::Normal
};
ctx.release_capture();
ctx.mark_dirty();
if was_press && inside {
self.activate(ctx);
}
true
}
_ => false,
},
Event::Key(k) if k.pressed && (k.key == Key::Enter || k.key == Key::Space) => {
self.activate(ctx);
ctx.mark_dirty();
true
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
fn cursor(&self) -> CursorShape {
CursorShape::Hand
}
fn take_click(&mut self, f: ClickFn) {
self.on_click = Some(f);
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Tree;
use crate::event::{KeyEvent, MouseButton, PointerEvent};
use crate::geometry::Point;
use crate::signal::signal;
use crate::ui::Element;
fn link_tree(el: Element) -> (Tree, crate::core::NodeId) {
let mut tree = Tree::new();
let root = el.width(120).height(20).build(&mut tree);
tree.root = Some(root);
tree.layout_root(Size::new(200, 60), &mut crate::text::NullTextEngine);
(tree, root)
}
#[test]
fn click_fires_on_click_callback() {
let hit = signal(0);
let h2 = hit;
let (mut tree, _root) =
link_tree(Element::link("打开").on_click(move |_| h2.set(h2.get() + 1)));
let mut hover = None;
let mut capture = None;
let at = Point::new(10, 10);
tree.dispatch_pointer(
PointerEvent::single(PointerKind::Down, at, MouseButton::Left),
&mut hover,
&mut capture,
);
let res = tree.dispatch_pointer(
PointerEvent::single(PointerKind::Up, at, MouseButton::Left),
&mut hover,
&mut capture,
);
assert_eq!(hit.get(), 1, "左键按下→抬起应触发一次回调");
assert!(res.open_url.is_none(), "设了 on_click 时不应再走 open_url");
}
#[test]
fn click_without_callback_requests_open_url() {
let (mut tree, _root) = link_tree(Element::link("官网").url("https://example.com"));
let mut hover = None;
let mut capture = None;
let at = Point::new(10, 10);
tree.dispatch_pointer(
PointerEvent::single(PointerKind::Down, at, MouseButton::Left),
&mut hover,
&mut capture,
);
let res = tree.dispatch_pointer(
PointerEvent::single(PointerKind::Up, at, MouseButton::Left),
&mut hover,
&mut capture,
);
assert_eq!(
res.open_url.as_deref(),
Some("https://example.com"),
"无回调应请求打开 url"
);
}
#[test]
fn enter_key_activates() {
let (mut tree, root) = link_tree(Element::link("官网").url("https://example.com"));
let res = tree.dispatch_key(
KeyEvent {
key: Key::Enter,
pressed: true,
shift: false,
ctrl: false,
},
Some(root),
);
assert_eq!(
res.open_url.as_deref(),
Some("https://example.com"),
"回车应激活链接"
);
}
#[test]
fn link_reports_hand_cursor() {
let (tree, root) = link_tree(Element::link("x").url("https://example.com"));
assert_eq!(tree.cursor_at(root), CursorShape::Hand);
}
#[test]
fn disabled_link_skips_open_and_cursor() {
let (mut tree, root) = link_tree(
Element::link("官网")
.url("https://example.com")
.disabled(true),
);
let mut hover = None;
let mut capture = None;
let at = Point::new(10, 10);
tree.dispatch_pointer(
PointerEvent::single(PointerKind::Down, at, MouseButton::Left),
&mut hover,
&mut capture,
);
let res = tree.dispatch_pointer(
PointerEvent::single(PointerKind::Up, at, MouseButton::Left),
&mut hover,
&mut capture,
);
assert!(res.open_url.is_none(), "禁用链接点击不应打开");
assert!(
!tree.node_enabled(root),
"禁用态应被核心识别(宿主据此回退箭头光标)"
);
}
}