1use 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
35pub 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
44pub struct CommandLinkButton {
46 title: LocalizedString,
47 description: Option<LocalizedString>,
48 icon: Option<IconWidget>,
49 enabled: Prop<bool>,
52 action: Option<Box<dyn Fn(&mut EventContext)>>,
53 title_style: Option<teksilo_core::color_prop::TextStyleProp>,
55 description_style: Option<teksilo_core::color_prop::TextStyleProp>,
57 title_color: Option<teksilo_core::color_prop::ColorProp>,
59 description_color: Option<teksilo_core::color_prop::ColorProp>,
61 interaction: Signal<InteractionState>,
62 root_child_id: Option<WidgetId>,
63 tooltip_text: Option<LocalizedString>,
67 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
69 composite_tooltip_content: Option<Box<dyn Widget>>,
71}
72
73impl CommandLinkButton {
74 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 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 pub fn icon(mut self, icon: IconWidget) -> Self {
106 self.icon = Some(icon);
107 self
108 }
109
110 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
113 self.enabled = enabled.into();
114 self
115 }
116
117 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 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 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 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 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 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 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 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 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 ctx.enabled_when(self_id, self.enabled.clone());
227
228 let interaction = ctx.signal(InteractionState::Idle);
229 self.interaction = interaction.clone();
230
231 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 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 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 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 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 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 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}