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