From c4b7cf38039e9521542e12aa8a6c797a07e543e4 Mon Sep 17 00:00:00 2001 From: Alvin Date: Thu, 22 Jan 2026 12:43:33 +0800 Subject: [PATCH 1/2] add Link component - Implement MpLink widget with href and click handling - Add hover/pressed states with animations - Add Link demo section to component-zoo - Update CLAUDE.md to mark Link as implemented --- CLAUDE.md | 2 +- crates/component-zoo/src/app.rs | 63 ++++++++++++ crates/ui/src/widgets/link.rs | 176 ++++++++++++++++++++++++++++++++ crates/ui/src/widgets/mod.rs | 3 + 4 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 crates/ui/src/widgets/link.rs diff --git a/CLAUDE.md b/CLAUDE.md index 0ca7c8d..9783a56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,7 @@ The skills are located in `.claude/skills/` directory and contain Makepad-specif | Text | text/ | widgets/text.rs | | Tooltip | tooltip.rs | widgets/tooltip.rs | | Label | label.rs | widgets/label.rs | +| Link | link.rs | widgets/link.rs | ### 待实现组件 📋 @@ -69,7 +70,6 @@ The skills are located in `.claude/skills/` directory and contain Makepad-specif | Highlighter | highlighter/ | 低 | 代码高亮 | | Icon | icon.rs | 高 | 图标组件 | | Kbd | kbd.rs | 低 | 键盘快捷键显示 | -| Link | link.rs | 中 | 链接组件 | | Menu | menu/ | 高 | 菜单组件 | | Pagination | pagination.rs | 中 | 分页组件 | | Plot | plot/ | 低 | 绑图组件 | diff --git a/crates/component-zoo/src/app.rs b/crates/component-zoo/src/app.rs index 18a26b9..d4a2106 100644 --- a/crates/component-zoo/src/app.rs +++ b/crates/component-zoo/src/app.rs @@ -37,6 +37,7 @@ live_design! { use makepad_component::widgets::popover::*; use makepad_component::widgets::label::*; use makepad_component::widgets::text::*; + use makepad_component::widgets::link::*; // ============================================================ // Section Header Component @@ -983,6 +984,68 @@ live_design! { {} + // ===== Link Section ===== + { + width: Fill, height: Fit, + flow: Down, + spacing: 16, + + { text: "Link" } + + // Basic links + { + width: Fit, height: Fit, + flow: Down, + spacing: 8, + + { text: "Basic Links" } + + { + width: Fit, height: Fit, + flow: Right, + spacing: 16, + + { + text: "Visit Makepad" + href: "https://makepad.nl" + } + { + text: "GitHub Repository" + href: "https://github.com/makepad/makepad" + } + { + text: "Documentation" + href: "https://makepad.dev" + } + } + } + + // Inline with text + { + width: Fit, height: Fit, + flow: Down, + spacing: 8, + + { text: "Inline with Text" } + + { + width: Fit, height: Fit, + flow: Right, + spacing: 4, + align: { y: 0.5 } + + { text: "Check out" } + { + text: "our website" + href: "https://makepad.nl" + } + { text: "for more information." } + } + } + } + + {} + // ===== Badge Section ===== { width: Fill, height: Fit, diff --git a/crates/ui/src/widgets/link.rs b/crates/ui/src/widgets/link.rs new file mode 100644 index 0000000..8ac3f2a --- /dev/null +++ b/crates/ui/src/widgets/link.rs @@ -0,0 +1,176 @@ +use makepad_widgets::*; + +live_design! { + use link::theme::*; + use link::shaders::*; + use link::widgets::*; + + use crate::theme::colors::*; + + pub MpLink = {{MpLink}} { + width: Fit, + height: Fit, + + draw_text: { + text_style: { font_size: 14.0 } + color: (PRIMARY) + + fn get_color(self) -> vec4 { + return mix( + mix(self.color, self.color * 0.8, self.hover), + self.color * 0.6, + self.pressed + ); + } + } + + cursor: Hand + + animator: { + hover = { + default: off + off = { + from: { all: Forward { duration: 0.15 } } + apply: { draw_text: { hover: 0.0 } } + } + on = { + from: { all: Forward { duration: 0.15 } } + apply: { draw_text: { hover: 1.0 } } + } + } + pressed = { + default: off + off = { + from: { all: Forward { duration: 0.1 } } + apply: { draw_text: { pressed: 0.0 } } + } + on = { + from: { all: Forward { duration: 0.1 } } + apply: { draw_text: { pressed: 1.0 } } + } + } + } + + text: "" + } +} + +#[derive(Live, LiveHook, Widget)] +pub struct MpLink { + #[redraw] + #[live] + draw_text: DrawText, + + #[walk] + walk: Walk, + + #[layout] + layout: Layout, + + #[live] + text: ArcStringMut, + + #[live] + href: ArcStringMut, + + #[live(false)] + disabled: bool, + + #[animator] + animator: Animator, + + #[rust] + area: Area, +} + +#[derive(Clone, Debug, DefaultNone)] +pub enum MpLinkAction { + Clicked, + None, +} + +impl Widget for MpLink { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let uid = self.widget_uid(); + + if self.animator_handle_event(cx, event).must_redraw() { + self.redraw(cx); + } + + if self.disabled { + return; + } + + match event.hits(cx, self.area) { + Hit::FingerHoverIn(_) => { + cx.set_cursor(MouseCursor::Hand); + self.animator_play(cx, ids!(hover.on)); + } + Hit::FingerHoverOut(_) => { + cx.set_cursor(MouseCursor::Default); + self.animator_play(cx, ids!(hover.off)); + } + Hit::FingerDown(_) => { + self.animator_play(cx, ids!(pressed.on)); + } + Hit::FingerUp(fe) => { + self.animator_play(cx, ids!(pressed.off)); + if fe.is_over { + let href = self.href.as_ref(); + if !href.is_empty() { + cx.open_url(href, OpenUrlInPlace::No); + } + cx.widget_action(uid, &scope.path, MpLinkAction::Clicked); + } + } + _ => {} + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { + cx.begin_turtle(walk, self.layout); + self.draw_text.draw_walk(cx, Walk::fit(), Align::default(), self.text.as_ref()); + cx.end_turtle_with_area(&mut self.area); + DrawStep::done() + } +} + +impl MpLink { + pub fn clicked(&self, actions: &Actions) -> bool { + if let Some(action) = actions.find_widget_action(self.widget_uid()) { + matches!(action.cast::(), MpLinkAction::Clicked) + } else { + false + } + } + + pub fn set_text(&mut self, text: &str) { + self.text.as_mut_empty().push_str(text); + } + + pub fn set_href(&mut self, href: &str) { + self.href.as_mut_empty().push_str(href); + } +} + +impl MpLinkRef { + pub fn clicked(&self, actions: &Actions) -> bool { + if let Some(inner) = self.borrow() { + inner.clicked(actions) + } else { + false + } + } + + pub fn set_text(&self, text: &str) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_text(text); + } + } + + pub fn set_href(&self, href: &str) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_href(href); + } + } +} diff --git a/crates/ui/src/widgets/mod.rs b/crates/ui/src/widgets/mod.rs index 2e3bf9d..1bd9ff4 100644 --- a/crates/ui/src/widgets/mod.rs +++ b/crates/ui/src/widgets/mod.rs @@ -21,6 +21,7 @@ pub mod modal; pub mod popover; pub mod label; pub mod text; +pub mod link; pub use button::*; pub use checkbox::*; @@ -37,6 +38,7 @@ pub use tab::*; pub use accordion::*; pub use label::*; pub use text::*; +pub use link::*; // dropdown, card, avatar, skeleton 只定义 live_design 样式,不导出 Rust 类型 use makepad_widgets::Cx; @@ -65,4 +67,5 @@ pub fn live_design(cx: &mut Cx) { crate::widgets::popover::live_design(cx); crate::widgets::label::live_design(cx); crate::widgets::text::live_design(cx); + crate::widgets::link::live_design(cx); } From e77294eb0c032499264b934005cb341589d5b40e Mon Sep 17 00:00:00 2001 From: Alvin Date: Thu, 22 Jan 2026 12:47:08 +0800 Subject: [PATCH 2/2] improve Link component with makepad-skills guidance - Add underline decoration using draw_underline shader - Declare instance variables (hover, pressed) in shaders - Add keyboard support (Enter/Space keys) - Extract activate_link helper method - Follow makepad event handling patterns --- crates/ui/src/widgets/link.rs | 72 +++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/crates/ui/src/widgets/link.rs b/crates/ui/src/widgets/link.rs index 8ac3f2a..8063752 100644 --- a/crates/ui/src/widgets/link.rs +++ b/crates/ui/src/widgets/link.rs @@ -15,6 +15,9 @@ live_design! { text_style: { font_size: 14.0 } color: (PRIMARY) + instance hover: 0.0 + instance pressed: 0.0 + fn get_color(self) -> vec4 { return mix( mix(self.color, self.color * 0.8, self.hover), @@ -24,6 +27,27 @@ live_design! { } } + draw_underline: { + instance color: (PRIMARY) + instance hover: 0.0 + instance pressed: 0.0 + + fn pixel(self) -> vec4 { + let sdf = Sdf2d::viewport(self.pos * self.rect_size); + let y = self.rect_size.y - 1.0; + sdf.move_to(0.0, y); + sdf.line_to(self.rect_size.x, y); + + let line_color = mix( + mix(self.color, self.color * 0.8, self.hover), + self.color * 0.6, + self.pressed + ); + + return sdf.stroke(line_color, 1.0); + } + } + cursor: Hand animator: { @@ -31,22 +55,34 @@ live_design! { default: off off = { from: { all: Forward { duration: 0.15 } } - apply: { draw_text: { hover: 0.0 } } + apply: { + draw_text: { hover: 0.0 } + draw_underline: { hover: 0.0 } + } } on = { from: { all: Forward { duration: 0.15 } } - apply: { draw_text: { hover: 1.0 } } + apply: { + draw_text: { hover: 1.0 } + draw_underline: { hover: 1.0 } + } } } pressed = { default: off off = { from: { all: Forward { duration: 0.1 } } - apply: { draw_text: { pressed: 0.0 } } + apply: { + draw_text: { pressed: 0.0 } + draw_underline: { pressed: 0.0 } + } } on = { from: { all: Forward { duration: 0.1 } } - apply: { draw_text: { pressed: 1.0 } } + apply: { + draw_text: { pressed: 1.0 } + draw_underline: { pressed: 1.0 } + } } } } @@ -61,6 +97,9 @@ pub struct MpLink { #[live] draw_text: DrawText, + #[live] + draw_underline: DrawQuad, + #[walk] walk: Walk, @@ -116,11 +155,13 @@ impl Widget for MpLink { Hit::FingerUp(fe) => { self.animator_play(cx, ids!(pressed.off)); if fe.is_over { - let href = self.href.as_ref(); - if !href.is_empty() { - cx.open_url(href, OpenUrlInPlace::No); - } - cx.widget_action(uid, &scope.path, MpLinkAction::Clicked); + self.activate_link(cx, uid, scope); + } + } + Hit::KeyDown(ke) => { + if ke.key_code == KeyCode::ReturnKey || ke.key_code == KeyCode::Space { + self.animator_play(cx, ids!(pressed.on)); + self.activate_link(cx, uid, scope); } } _ => {} @@ -128,14 +169,23 @@ impl Widget for MpLink { } fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { - cx.begin_turtle(walk, self.layout); + self.draw_underline.begin(cx, walk, self.layout); self.draw_text.draw_walk(cx, Walk::fit(), Align::default(), self.text.as_ref()); - cx.end_turtle_with_area(&mut self.area); + self.draw_underline.end(cx); + self.area = self.draw_underline.area(); DrawStep::done() } } impl MpLink { + fn activate_link(&mut self, cx: &mut Cx, uid: WidgetUid, scope: &Scope) { + let href = self.href.as_ref(); + if !href.is_empty() { + cx.open_url(href, OpenUrlInPlace::No); + } + cx.widget_action(uid, &scope.path, MpLinkAction::Clicked); + } + pub fn clicked(&self, actions: &Actions) -> bool { if let Some(action) = actions.find_widget_action(self.widget_uid()) { matches!(action.cast::(), MpLinkAction::Clicked)