1use 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
43pub const COMMAND_LINK_BUTTON_ICON_SIZE: f32 = 28.0;
46pub const COMMAND_LINK_BUTTON_ICON_TEXT_GAP: f32 = 14.0;
47
48pub 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
55pub 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
62pub 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
69pub 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
76pub fn command_link_button_min_height(tokens: &InputTokens) -> f32 {
79 dp(COMMAND_LINK_BUTTON_MIN_HEIGHT, TargetRole::Target, tokens)
80}
81
82pub struct CommandLinkButton {
84 title: LocalizedString,
85 description: Option<LocalizedString>,
86 icon: Option<IconWidget>,
87 enabled: Prop<bool>,
90 action: Option<Box<dyn Fn(&mut EventContext)>>,
91 title_style: Option<teksilo_core::color_prop::TextStyleProp>,
93 description_style: Option<teksilo_core::color_prop::TextStyleProp>,
95 title_color: Option<teksilo_core::color_prop::ColorProp>,
97 description_color: Option<teksilo_core::color_prop::ColorProp>,
99 interaction: Signal<InteractionState>,
100 root_child_id: Option<WidgetId>,
101 tooltip_text: Option<LocalizedString>,
105 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
107 composite_tooltip_content: Option<Box<dyn Widget>>,
109}
110
111impl CommandLinkButton {
112 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 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 pub fn icon(mut self, icon: IconWidget) -> Self {
144 self.icon = Some(icon);
145 self
146 }
147
148 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
151 self.enabled = enabled.into();
152 self
153 }
154
155 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 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 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 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 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 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 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 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 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 ctx.enabled_when(self_id, self.enabled.clone());
265
266 let interaction = ctx.signal(InteractionState::Idle);
267 self.interaction = interaction.clone();
268
269 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 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 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 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 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 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 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}