Skip to main content

teksilo_widgets/
command_link_button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! CommandLinkButton — large two-line button with icon, title, and
5//! subtitle. Used for wizard landing screens, onboarding choices, and
6//! any "card-shaped CTA" pattern.
7//!
8//! Modeled on Qt's `QCommandLinkButton`. Distinct from a regular
9//! [`Button`](crate::button::Button) by its layout (`HStack(icon +
10//! VStack(title + subtitle))`) and default visual variant (`Flat` —
11//! Int UI convention — with an interactive surface tint on hover).
12//!
13//! ```ignore
14//! CommandLinkButton::new(tr!(create_new_project()))
15//!     .description(tr!(create_new_project_subtitle()))
16//!     .icon(IconWidget::from_svg(NEW_PROJECT_ICON))
17//!     .on_activate_fn(|ctx| ctx.send_intent(AppIntent::NewProject))
18//! ```
19
20use teksilo_canvas::{Rect, SizeProposal};
21use teksilo_core::accessibility::AccessNodeBuilder;
22use teksilo_core::build_context::BuildContext;
23use teksilo_core::signal::{Prop, Signal};
24use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
25use teksilo_core::widget_id::WidgetId;
26use teksilo_tokens::{
27    BorderRole, CornerRadius, HAlignment, SurfaceRole, TextRole, TextStyleRole, VAlignment,
28};
29
30use crate::button::InteractionState;
31use crate::primitives::icon_widget::IconWidget;
32use crate::primitives::{HStack, Padding, RectWidget, TextWidget, VStack, ZStack};
33use teksilo_i18n::LocalizedString;
34
35/// CommandLinkButton design tokens. The widget is a group-4 composite
36/// with no dedicated recipe module.
37pub const COMMAND_LINK_BUTTON_ICON_SIZE: f32 = 28.0;
38pub const COMMAND_LINK_BUTTON_ICON_TEXT_GAP: f32 = 14.0;
39pub const COMMAND_LINK_BUTTON_TITLE_DESCRIPTION_GAP: f32 = 4.0;
40pub const COMMAND_LINK_BUTTON_PADDING_HORIZONTAL: f32 = 16.0;
41pub const COMMAND_LINK_BUTTON_PADDING_VERTICAL: f32 = 14.0;
42pub const COMMAND_LINK_BUTTON_MIN_HEIGHT: f32 = 64.0;
43
44/// A large two-line CTA button: icon + title + subtitle.
45pub struct CommandLinkButton {
46    title: LocalizedString,
47    description: Option<LocalizedString>,
48    icon: Option<IconWidget>,
49    /// Enabled state, static or reactive; forwarded to the arena at
50    /// build time.
51    enabled: Prop<bool>,
52    action: Option<Box<dyn Fn(&mut EventContext)>>,
53    /// Per-call title text-style override. `None` ⇒ `TextStyleRole::BodyBold`.
54    title_style: Option<teksilo_core::color_prop::TextStyleProp>,
55    /// Per-call description text-style override. `None` ⇒ `TextStyleRole::Body`.
56    description_style: Option<teksilo_core::color_prop::TextStyleProp>,
57    /// Per-call title text-color override. `None` ⇒ `TextRole::Primary`.
58    title_color: Option<teksilo_core::color_prop::ColorProp>,
59    /// Per-call description text-color override. `None` ⇒ `TextRole::Secondary`.
60    description_color: Option<teksilo_core::color_prop::ColorProp>,
61    interaction: Signal<InteractionState>,
62    root_child_id: Option<WidgetId>,
63    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
64    /// with the rich / composite slots — every setter clears the other two so
65    /// the last call wins.
66    tooltip_text: Option<LocalizedString>,
67    /// Optional rich tooltip source (registry key or inline content).
68    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
69    /// Optional composite tooltip body (arbitrary widget tree).
70    composite_tooltip_content: Option<Box<dyn Widget>>,
71}
72
73impl CommandLinkButton {
74    /// Create a `CommandLinkButton` with the given title text.
75    /// Chain `.description(...)` and `.icon(...)` to complete the card layout.
76    pub fn new(title: impl Into<LocalizedString>) -> Self {
77        let ls: LocalizedString = title.into();
78        Self {
79            title: ls,
80            description: None,
81            icon: None,
82            enabled: Prop::Static(true),
83            action: None,
84            title_style: None,
85            description_style: None,
86            title_color: None,
87            description_color: None,
88            interaction: Signal::new(InteractionState::Idle),
89            root_child_id: None,
90            tooltip_text: None,
91            rich_tooltip_source: None,
92            composite_tooltip_content: None,
93        }
94    }
95
96    /// Optional descriptive subtitle rendered below the title.
97    pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
98        let ls: LocalizedString = text.into();
99        self.description = Some(ls);
100        self
101    }
102
103    /// Leading icon — large enough to anchor the card visually
104    /// (rendered at 28 dp).
105    pub fn icon(mut self, icon: IconWidget) -> Self {
106        self.icon = Some(icon);
107        self
108    }
109
110    /// Set the enabled state, statically or reactively. Forwarded to
111    /// the arena at build time.
112    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
113        self.enabled = enabled.into();
114        self
115    }
116
117    /// Closure invoked on activation. Use `ctx.send_intent(...)` to
118    /// route through the Action / Intent system.
119    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
120        self.action = Some(Box::new(f));
121        self
122    }
123
124    /// Override the title's text style (font, size, weight). Accepts a
125    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default
126    /// (unset) is `TextStyleRole::BodyBold`.
127    pub fn title_style(
128        mut self,
129        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
130    ) -> Self {
131        self.title_style = Some(style.into());
132        self
133    }
134
135    /// Override the description's text style. Default is `TextStyleRole::Body`.
136    pub fn description_style(
137        mut self,
138        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
139    ) -> Self {
140        self.description_style = Some(style.into());
141        self
142    }
143
144    /// Override the title's text color. Accepts `Color`, a role, or a
145    /// `Signal` of either. Default (unset) is `TextRole::Primary`.
146    pub fn title_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
147        self.title_color = Some(color.into());
148        self
149    }
150
151    /// Override the description's text color. Default is `TextRole::Secondary`.
152    pub fn description_color(
153        mut self,
154        color: impl Into<teksilo_core::color_prop::ColorProp>,
155    ) -> Self {
156        self.description_color = Some(color.into());
157        self
158    }
159
160    /// Attach a plain single-line tooltip shown after a hover delay.
161    /// Clears any previously set rich or composite tooltip.
162    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
163        self.tooltip_text = Some(text.into());
164        self.rich_tooltip_source = None;
165        self.composite_tooltip_content = None;
166        self
167    }
168
169    /// Attach a rich tooltip looked up by registry key.
170    /// Clears any previously set plain or composite tooltip.
171    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
172        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
173        self.tooltip_text = None;
174        self.composite_tooltip_content = None;
175        self
176    }
177
178    /// Attach a rich tooltip with inline content (no registry lookup).
179    /// Clears any previously set plain or composite tooltip.
180    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
181        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
182        self.tooltip_text = None;
183        self.composite_tooltip_content = None;
184        self
185    }
186
187    /// Attach a composite tooltip hosting an arbitrary widget tree body.
188    /// Clears any previously set plain or rich tooltip.
189    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
190        self.composite_tooltip_content = Some(Box::new(content));
191        self.tooltip_text = None;
192        self.rich_tooltip_source = None;
193        self
194    }
195}
196
197impl std::fmt::Debug for CommandLinkButton {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.debug_struct("CommandLinkButton")
200            .field("title", &self.title)
201            .field("description", &self.description)
202            .field("enabled", &self.enabled.get())
203            .finish()
204    }
205}
206
207fn resolve_bg_role(state: InteractionState) -> SurfaceRole {
208    match state {
209        InteractionState::Pressed => SurfaceRole::Pressed,
210        InteractionState::Hovered => SurfaceRole::Hover,
211        _ => SurfaceRole::Transparent,
212    }
213}
214
215fn resolve_border_role(state: InteractionState) -> BorderRole {
216    match state {
217        InteractionState::Focused => BorderRole::Focused,
218        _ => BorderRole::Transparent,
219    }
220}
221
222impl Widget for CommandLinkButton {
223    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
224        let self_id = ctx.self_id();
225        // Forward the enabled state to the arena; see IconButton.
226        ctx.enabled_when(self_id, self.enabled.clone());
227
228        let interaction = ctx.signal(InteractionState::Idle);
229        self.interaction = interaction.clone();
230
231        // The leaves' `ColorProp::resolve(theme, ctx.effective_enabled)`
232        // substitutes `TextRole::Disabled` automatically when the arena
233        // says we're disabled; we no longer need to fold the Disabled
234        // state into these role-derivations.
235        let bg_role = interaction.map(|s| resolve_bg_role(*s));
236        let border_role = interaction.map(|s| resolve_border_role(*s));
237        let title_role = interaction.map(|_s| TextRole::Primary);
238        let desc_role = interaction.map(|_s| TextRole::Secondary);
239        let icon_role = title_role.clone();
240
241        let normal_bw = crate::styles::recipe_button_style::BUTTON_BORDER_WIDTH;
242        let focus_bw = ctx.theme().shape.focus_ring_width;
243        let border_width = interaction.map(move |s| match s {
244            InteractionState::Focused => focus_bw,
245            _ => normal_bw,
246        });
247        let corner_radius = crate::styles::recipe_button_style::BUTTON_CORNER_RADIUS;
248
249        // Title + optional description column.
250        let title_color: teksilo_core::color_prop::ColorProp = self
251            .title_color
252            .clone()
253            .unwrap_or_else(|| title_role.into());
254        let title_style: teksilo_core::color_prop::TextStyleProp = self
255            .title_style
256            .clone()
257            .unwrap_or_else(|| TextStyleRole::BodyBold.into());
258        let title_widget = TextWidget::new(self.title.clone())
259            .style(title_style)
260            .color(title_color)
261            .single_line()
262            .a11y_hidden();
263        let title_id = ctx.add(title_widget);
264
265        let mut text_column = VStack::new()
266            .spacing(COMMAND_LINK_BUTTON_TITLE_DESCRIPTION_GAP)
267            .alignment(HAlignment::Leading)
268            .add_child(title_id);
269        if let Some(description) = &self.description {
270            let desc_color: teksilo_core::color_prop::ColorProp = self
271                .description_color
272                .clone()
273                .unwrap_or_else(|| desc_role.into());
274            let desc_style: teksilo_core::color_prop::TextStyleProp = self
275                .description_style
276                .clone()
277                .unwrap_or_else(|| TextStyleRole::Body.into());
278            let desc = ctx.add(
279                TextWidget::new(description.clone())
280                    .style(desc_style)
281                    .color(desc_color)
282                    .a11y_hidden(),
283            );
284            text_column = text_column.add_child(desc);
285        }
286        let text_column_id = ctx.add(text_column);
287
288        // Optional leading icon.
289        let mut row = HStack::new()
290            .spacing(COMMAND_LINK_BUTTON_ICON_TEXT_GAP)
291            .alignment(VAlignment::Center);
292        if let Some(icon) = self.icon.take() {
293            let icon_id = ctx.add(
294                icon.icon_size(COMMAND_LINK_BUTTON_ICON_SIZE)
295                    .color(icon_role),
296            );
297            row = row.add_child(icon_id);
298        }
299        row = row.add_child(text_column_id);
300        let row_id = ctx.add(row);
301
302        // Padding inside the surface.
303        let padded = ctx.add(
304            Padding::symmetric(
305                COMMAND_LINK_BUTTON_PADDING_VERTICAL,
306                COMMAND_LINK_BUTTON_PADDING_HORIZONTAL,
307            )
308            .child_id(row_id),
309        );
310
311        // Surface (background + border, drives hover / press / focus).
312        let rect = ctx.add(
313            RectWidget::new()
314                .background(bg_role)
315                .border_color(border_role)
316                .border_width(border_width)
317                .corner_radius(CornerRadius::uniform(corner_radius)),
318        );
319
320        let zstack = ctx.add(ZStack::new().add_child(rect).add_child(padded));
321        let root = ctx.add(
322            crate::primitives::MinSize::new(0.0, COMMAND_LINK_BUTTON_MIN_HEIGHT).child_id(zstack),
323        );
324
325        // Attached handlers via the shared button-family helper
326        // (`build_interaction_handlers`) — same interaction/keyboard/AT
327        // contract as Button, including the lone-KeyUp guard. No
328        // shortcut / tooltip / has_popup machinery here.
329        let action: std::rc::Rc<Option<Box<dyn Fn(&mut EventContext)>>> =
330            std::rc::Rc::new(self.action.take());
331        let on_activate: std::rc::Rc<dyn Fn(&mut EventContext)> =
332            std::rc::Rc::new(move |ctx: &mut EventContext| {
333                if let Some(ref a) = *action {
334                    a(ctx);
335                }
336            });
337        let handlers = crate::button::build_interaction_handlers(interaction, on_activate, true);
338        ctx.apply_self_handlers(handlers);
339
340        self.root_child_id = Some(root);
341
342        if let Some(content) = self.composite_tooltip_content.take() {
343            let delay = ctx.theme().motion.tooltip_delay_heavy;
344            crate::tooltip::attach_composite_tooltip_boxed(ctx, root, content, delay);
345        } else if let Some(source) = self.rich_tooltip_source.clone() {
346            let delay = ctx.theme().motion.tooltip_delay;
347            crate::tooltip::attach_rich_tooltip_source(ctx, root, source, delay);
348        } else if let Some(text) = self.tooltip_text.clone() {
349            let delay = ctx.theme().motion.tooltip_delay;
350            crate::tooltip::attach_plain_tooltip(ctx, root, text, delay);
351        }
352
353        vec![root]
354    }
355
356    fn layout_response(
357        &self,
358        proposal: SizeProposal,
359        ctx: &LayoutContext,
360    ) -> teksilo_core::widget::LayoutResponse {
361        let _ = ctx;
362        self.root_child_id
363            .and_then(|id| ctx.child_size(id, proposal))
364            .unwrap_or_else(|| proposal.resolve(0.0, COMMAND_LINK_BUTTON_MIN_HEIGHT))
365            .into()
366    }
367
368    fn place_children(
369        &self,
370        bounds: Rect,
371        _proposal: SizeProposal,
372        children: &mut [WidgetPlacement],
373        _ctx: &LayoutContext,
374    ) {
375        for child in children.iter_mut() {
376            child.origin = bounds.origin();
377            child.size = bounds.size();
378        }
379    }
380
381    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
382        builder.set_role(teksilo_core::accesskit::Role::Button);
383        // Compose the AT name as "title — description" so screen reader
384        // users hear both lines without having to drill into children.
385        let name = match &self.description {
386            Some(desc) => format!("{} — {}", self.title.resolve_now(), desc.resolve_now()),
387            None => self.title.resolve_now(),
388        };
389        builder.set_name(name);
390    }
391
392    fn children(&self) -> Vec<WidgetId> {
393        self.root_child_id.into_iter().collect()
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use teksilo_core::event::WidgetEvent;
401    use teksilo_core::widget_tree::WidgetTree;
402    use teksilo_i18n::lit;
403
404    #[test]
405    fn builds_with_title_and_description() {
406        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
407        let id = tree.add(
408            CommandLinkButton::new(lit!("Create new project"))
409                .description(lit!("Start with a blank workspace.")),
410        );
411        tree.layout(SizeProposal {
412            width: Some(420.0),
413            height: None,
414        });
415        let b = tree.bounds(id);
416        assert!(b.width > 0.0);
417        let _ = teksilo_core::presets::intui::light();
418        assert!(b.height >= COMMAND_LINK_BUTTON_MIN_HEIGHT);
419    }
420
421    #[test]
422    fn a11y_role_is_button_with_combined_name() {
423        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
424        let id = tree.add(
425            CommandLinkButton::new(lit!("Create new project")).description(lit!("Start blank.")),
426        );
427        tree.layout(SizeProposal::exact(400.0, 100.0));
428        let info = tree.accessibility_node(id);
429        assert_eq!(info.role(), teksilo_core::accesskit::Role::Button);
430        assert_eq!(info.name(), Some("Create new project — Start blank."));
431    }
432
433    #[test]
434    fn click_via_access_action_invokes_callback() {
435        use std::cell::Cell;
436        use std::rc::Rc;
437        let fired = Rc::new(Cell::new(0usize));
438        let fired_clone = fired.clone();
439        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
440        let id = tree.add(
441            CommandLinkButton::new(lit!("Open existing project"))
442                .on_activate_fn(move |_| fired_clone.set(fired_clone.get() + 1)),
443        );
444        tree.layout(SizeProposal::exact(400.0, 100.0));
445        tree.dispatch_event(WidgetEvent::AccessAction {
446            action: teksilo_core::accesskit::Action::Click,
447            target: Some(id),
448            target_node: teksilo_core::accessibility::root_node_id(),
449            data: None,
450        });
451        assert_eq!(fired.get(), 1);
452    }
453
454    #[test]
455    fn tooltip_appears_on_hover() {
456        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
457        let id = tree.add(CommandLinkButton::new(lit!("New Project")).tooltip(lit!("Tip")));
458        tree.layout(SizeProposal::exact(300.0, 200.0));
459        tree.pointer_move(tree.bounds(id).center());
460        tree.advance_time(std::time::Duration::from_secs(1));
461        assert_eq!(
462            tree.active_overlays().len(),
463            1,
464            "tooltip should appear on hover"
465        );
466        assert!(tree.find_by_label("Tip").is_some());
467    }
468}