1use std::fmt;
2use std::rc::Rc;
3
4use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Quad, Rect, Role, Scene};
5use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
6use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
7
8use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
9use crate::{IconName, Motion, View, util};
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13#[non_exhaustive]
14#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
15pub enum ButtonStyle {
16 #[default]
18 Primary,
19 Secondary,
21 Ghost,
23 Danger,
25}
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29#[non_exhaustive]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
31pub enum ButtonSize {
32 Small,
34 #[default]
36 Medium,
37 Large,
39 Mini,
41}
42
43#[cfg_attr(feature = "live", ravenrs_live::expose)]
44impl ButtonSize {
45 pub(crate) fn height(self) -> f32 {
46 match self {
47 Self::Mini => 22.0,
48 Self::Small => 32.0,
49 Self::Medium => 40.0,
50 Self::Large => 48.0,
51 }
52 }
53
54 pub(crate) fn text_style(self) -> TextStyle {
56 match self {
57 Self::Mini => TextStyle::Caption,
58 Self::Small => TextStyle::Callout,
59 _ => TextStyle::Body,
60 }
61 }
62
63 fn padding_x(self) -> f32 {
64 match self {
65 Self::Mini => 8.0,
66 Self::Small => 12.0,
67 Self::Medium => 18.0,
68 Self::Large => 24.0,
69 }
70 }
71}
72
73pub(crate) fn control_colors(
75 theme: &Theme,
76 style: ButtonStyle,
77 hovered: bool,
78 pressed: bool,
79 disabled: bool,
80) -> (Color, Color, Option<Border>) {
81 let ink = theme.color(Semantic::Ink);
82 let (fill, label, border) = match style {
83 ButtonStyle::Primary => {
84 let fill = if disabled {
85 Semantic::Accent
86 } else if pressed {
87 Semantic::AccentPressed
88 } else if hovered {
89 Semantic::AccentHover
90 } else {
91 Semantic::Accent
92 };
93 (theme.color(fill), theme.color(Semantic::OnAccent), None)
94 }
95 ButtonStyle::Secondary => {
96 let base = theme.color(Semantic::Control);
97 let fill = if disabled {
98 base
99 } else if pressed {
100 base.lerp(ink, 0.12)
101 } else if hovered {
102 base.lerp(ink, 0.06)
103 } else {
104 base
105 };
106 (fill, ink, Some(Border { width: 1.0, color: theme.color(Semantic::Border) }))
107 }
108 ButtonStyle::Ghost => {
109 let fill = if disabled {
110 Color::TRANSPARENT
111 } else if pressed {
112 ink.with_alpha(0.12)
113 } else if hovered {
114 ink.with_alpha(0.07)
115 } else {
116 Color::TRANSPARENT
117 };
118 (fill, ink, None)
119 }
120 ButtonStyle::Danger => {
121 let base = theme.color(Semantic::Danger);
122 let fill = if disabled {
123 base
124 } else if pressed {
125 base.lerp(Color::BLACK, 0.18)
126 } else if hovered {
127 base.lerp(Color::WHITE, 0.08)
128 } else {
129 base
130 };
131 (fill, Color::WHITE, None)
132 }
133 };
134 if disabled {
135 (crate::util::faded(fill), crate::util::faded(label), border)
136 } else {
137 (fill, label, border)
138 }
139}
140
141fn selected_colors(
143 theme: &Theme,
144 style: ButtonStyle,
145 hovered: bool,
146 disabled: bool,
147) -> (Color, Color, Border) {
148 let (ink, accent) = (theme.color(Semantic::Ink), theme.color(Semantic::Accent));
149 let (fill, label, border) = match style {
150 ButtonStyle::Primary => {
151 let on_accent = theme.color(Semantic::OnAccent);
152 (theme.color(Semantic::AccentPressed), on_accent, on_accent.with_alpha(0.6))
153 }
154 ButtonStyle::Danger => {
155 let fill = theme.color(Semantic::Danger).lerp(Color::BLACK, 0.18);
156 (fill, Color::WHITE, Color::WHITE.with_alpha(0.6))
157 }
158 _ => (accent.with_alpha(0.18), accent.lerp(ink, 0.3), accent.with_alpha(0.55)),
160 };
161 let fill = if hovered && !disabled { fill.lerp(ink, 0.06) } else { fill };
162 if disabled {
163 (util::faded(fill), util::faded(label), Border { width: 1.0, color: util::faded(border) })
164 } else {
165 (fill, label, Border { width: 1.0, color: border })
166 }
167}
168
169#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
186pub struct Button {
187 label: String,
188 on_click: Option<Rc<dyn Fn()>>,
189 style: ButtonStyle,
190 size: ButtonSize,
191 icon: Option<IconName>,
192 trailing_icon: Option<IconName>,
193 disabled: bool,
194 full_width: bool,
195 accessible_name: Option<String>,
196 accessible_description: Option<String>,
197 loading: bool,
198 selected: Option<bool>,
199 shortcut: Option<String>,
200 join: Option<Join>,
201}
202
203#[derive(Clone, Copy, Debug)]
205struct Join {
206 first: bool,
207 last: bool,
208}
209
210impl fmt::Debug for Button {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 f.debug_struct("Button").field("label", &self.label).field("style", &self.style).finish()
213 }
214}
215
216#[cfg_attr(feature = "live", ravenrs_live::expose)]
217impl Button {
218 pub fn new(label: impl Into<String>) -> Self {
220 Self {
221 label: label.into(),
222 on_click: None,
223 style: ButtonStyle::Primary,
224 size: ButtonSize::Medium,
225 icon: None,
226 trailing_icon: None,
227 disabled: false,
228 full_width: false,
229 accessible_name: None,
230 accessible_description: None,
231 loading: false,
232 selected: None,
233 shortcut: None,
234 join: None,
235 }
236 }
237
238 pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
241 self.accessible_name = Some(name.into());
242 self
243 }
244
245 pub fn accessible_description(mut self, text: impl Into<String>) -> Self {
248 self.accessible_description = Some(text.into());
249 self
250 }
251
252 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
254 self.on_click = Some(Rc::new(handler));
255 self
256 }
257
258 pub fn style(mut self, style: ButtonStyle) -> Self {
260 self.style = style;
261 self
262 }
263
264 pub fn size(mut self, size: ButtonSize) -> Self {
266 self.size = size;
267 self
268 }
269
270 pub fn icon(mut self, icon: IconName) -> Self {
272 self.icon = Some(icon);
273 self
274 }
275
276 pub fn trailing_icon(mut self, icon: IconName) -> Self {
278 self.trailing_icon = Some(icon);
279 self
280 }
281
282 pub fn disabled(mut self, disabled: bool) -> Self {
284 self.disabled = disabled;
285 self
286 }
287
288 pub fn full_width(mut self) -> Self {
290 self.full_width = true;
291 self
292 }
293
294 pub fn loading(mut self, loading: bool) -> Self {
298 self.loading = loading;
299 self
300 }
301
302 pub fn selected(mut self, selected: bool) -> Self {
305 self.selected = Some(selected);
306 self
307 }
308
309 pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
312 self.shortcut = Some(keys.into());
313 self
314 }
315}
316
317impl View for Button {
318 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
319 let theme = *cx.theme();
320 let inert = self.disabled || self.loading;
321 let pressed = cx.is_pressed() && !inert;
322 let hovered = cx.is_hovered() && !inert;
323 let (fill, label_color, border) = if self.selected == Some(true) {
324 let (fill, label, border) = selected_colors(&theme, self.style, hovered, self.disabled);
325 (fill, label, Some(border))
326 } else {
327 control_colors(&theme, self.style, hovered, pressed, self.disabled)
328 };
329 let spinner_color = label_color;
331 let label_color = if self.loading { label_color.with_alpha(0.0) } else { label_color };
332 let icon_size = match self.size {
333 ButtonSize::Mini => 12.0,
334 ButtonSize::Small => 14.0,
335 _ => 16.0,
336 };
337 let mut children = Vec::new();
338 if let Some(icon) = self.icon {
339 children.push(util::icon(cx, icon, icon_size, label_color));
340 }
341 if !self.label.is_empty() {
342 let style = self.size.text_style();
343 children.push(util::label(cx, &self.label, style, Some(Weight::SEMIBOLD), label_color));
344 }
345 if let Some(icon) = self.trailing_icon {
346 children.push(util::icon(cx, icon, icon_size, label_color));
347 }
348 if let Some(keys) = &self.shortcut {
349 let cap = crate::Kbd::new(keys.clone()).inherit_color();
350 let cap = cx.with_foreground(label_color, |cx| cx.child(&cap));
351 cx.semantics(cap, Semantics::hidden());
353 children.push(cap);
354 }
355 let height = self.size.height();
356 let icon_only = self.label.is_empty();
357 let spacing = if self.size == ButtonSize::Mini { 4.0 } else { 8.0 };
358 if self.loading {
359 let content = cx.container(
360 LayoutStyle {
361 spacing,
362 align: Align::Center,
363 justify: Justify::Center,
364 ..LayoutStyle::stack(Axis::Horizontal)
365 },
366 &children,
367 crate::extras::spinner_paint(icon_size, spinner_color),
368 );
369 children = vec![content];
370 }
371 let style = LayoutStyle {
372 padding: Insets::symmetric(0.0, if icon_only { 0.0 } else { self.size.padding_x() }),
373 width: icon_only.then_some(height),
374 height: Some(height),
375 spacing,
376 align: Align::Center,
377 justify: Justify::Center,
378 align_self: Some(if self.full_width { Align::Stretch } else { Align::Fit }),
379 ..LayoutStyle::stack(Axis::Horizontal)
380 };
381 let radius = theme.radii.md;
382 let node = if let Some(join) = self.join {
383 cx.container(style, &children, joined_paint(join, fill, radius, border))
385 } else {
386 let paint =
387 Paint::Quad(QuadPaint { fill: fill.into(), corner_radius: radius, border, shadow: None });
388 let node = cx.container(style, &children, paint);
389 let squeeze = if pressed { Effect::scaled(0.97) } else { Effect::IDENTITY };
390 cx.motion(node, Motion::new().paint(theme.motion.fast).effect(squeeze, theme.motion.snappy));
391 node
392 };
393 let mut semantics = Semantics::new(Role::Button).disabled(inert);
394 if let Some(selected) = self.selected {
395 semantics = semantics.toggled(selected);
396 }
397 let busy = self.loading.then(|| cx.labels().loading.clone());
399 let parts: Vec<&str> =
400 [self.accessible_description.as_deref(), busy.as_deref(), self.shortcut.as_deref()]
401 .into_iter()
402 .flatten()
403 .collect();
404 let description = (!parts.is_empty()).then(|| parts.join(". "));
405 if let Some(description) = description {
406 semantics = semantics.description(description);
407 }
408 if let Some(name) = &self.accessible_name {
409 semantics = semantics.name(name.clone());
410 } else if let (true, Some(icon)) = (icon_only, self.icon) {
411 semantics = semantics.name(icon.name().replace('-', " "));
413 semantics.fallback_name = true;
414 }
415 cx.semantics(node, semantics);
416 if !self.disabled {
417 if let (Some(handler), false) = (&self.on_click, self.loading) {
418 cx.on_click(node, handler.clone());
419 }
420 cx.focusable(node, radius);
421 if !self.loading {
422 cx.cursor(node, CursorIcon::Pointer);
423 }
424 }
425 node
426 }
427}
428
429fn joined_paint(join: Join, fill: Color, radius: f32, border: Option<Border>) -> Paint {
432 let (left, right) = (if join.first { radius } else { 0.0 }, if join.last { radius } else { 0.0 });
433 let overlap = if join.first || border.is_none() { 0.0 } else { border.map_or(0.0, |b| b.width) };
434 Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
435 let rect = Rect::new(frame.x() - overlap, frame.y(), frame.width() + overlap, frame.height());
436 let mut quad = Quad::new(rect, fill);
437 quad.corner_radii = [left, right, right, left];
438 quad.border = border;
439 scene.push_quad(quad);
440 }))
441}
442
443#[derive(Debug)]
457#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
458pub struct ButtonGroup {
459 buttons: Vec<Button>,
460 label: Option<String>,
461}
462
463#[cfg_attr(feature = "live", ravenrs_live::expose)]
464impl ButtonGroup {
465 pub fn new(buttons: impl IntoIterator<Item = Button>) -> Self {
467 let mut buttons: Vec<Button> = buttons.into_iter().collect();
468 let last = buttons.len().saturating_sub(1);
469 for (index, button) in buttons.iter_mut().enumerate() {
470 button.join = Some(Join { first: index == 0, last: index == last });
471 }
472 Self { buttons, label: None }
473 }
474
475 pub fn label(mut self, label: impl Into<String>) -> Self {
477 self.label = Some(label.into());
478 self
479 }
480}
481
482impl View for ButtonGroup {
483 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
484 let buttons: Vec<NodeId> = self.buttons.iter().map(|button| cx.child(button)).collect();
485 let node = cx.container(
486 LayoutStyle {
487 align: Align::Center,
488 align_self: Some(Align::Fit),
489 ..LayoutStyle::stack(Axis::Horizontal)
490 },
491 &buttons,
492 Paint::None,
493 );
494 if let Some(label) = &self.label {
495 cx.semantics(node, Semantics::new(Role::Group).name(label.clone()));
496 }
497 node
498 }
499}
500
501#[derive(Debug)]
503#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
504pub struct IconButton {
505 button: Button,
506}
507
508#[cfg_attr(feature = "live", ravenrs_live::expose)]
509impl IconButton {
510 pub fn new(icon: IconName) -> Self {
512 Self { button: Button::new("").icon(icon).style(ButtonStyle::Ghost) }
513 }
514
515 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
517 self.button = self.button.on_click(handler);
518 self
519 }
520
521 pub fn disabled(mut self, disabled: bool) -> Self {
523 self.button = self.button.disabled(disabled);
524 self
525 }
526
527 pub fn label(mut self, name: impl Into<String>) -> Self {
530 self.button = self.button.accessible_name(name);
531 self
532 }
533
534 pub fn description(mut self, text: impl Into<String>) -> Self {
536 self.button = self.button.accessible_description(text);
537 self
538 }
539
540 pub fn style(mut self, style: ButtonStyle) -> Self {
542 self.button = self.button.style(style);
543 self
544 }
545
546 pub fn size(mut self, size: ButtonSize) -> Self {
548 self.button = self.button.size(size);
549 self
550 }
551
552 pub fn selected(mut self, selected: bool) -> Self {
554 self.button = self.button.selected(selected);
555 self
556 }
557}
558
559impl View for IconButton {
560 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
561 self.button.build(cx)
562 }
563}