1use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Border, Color, CursorIcon, Insets, Quad, Rect, Role};
9use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
12
13use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
14use crate::overlay::{Entry, MenuList, panel};
15use crate::{IconName, MenuItem, TextField, Tone, Tooltip, View, util};
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19#[non_exhaustive]
20#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
21pub enum RibbonSize {
22 #[default]
24 Large,
25 Small,
27 IconOnly,
29}
30
31#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
33pub struct RibbonItem {
34 label: String,
35 icon: Option<IconName>,
36 size: RibbonSize,
37 action: Option<Rc<dyn Fn()>>,
38 toggle: Option<Binding<bool>>,
39 menu: Vec<Entry>,
40 enabled: bool,
41 tooltip: Option<String>,
42}
43
44impl fmt::Debug for RibbonItem {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.debug_struct("RibbonItem")
47 .field("label", &self.label)
48 .field("size", &self.size)
49 .finish_non_exhaustive()
50 }
51}
52
53#[cfg_attr(feature = "live", ravenrs_live::expose)]
54impl RibbonItem {
55 pub fn button(label: impl Into<String>, icon: IconName) -> Self {
57 Self {
58 label: label.into(),
59 icon: Some(icon),
60 size: RibbonSize::Large,
61 action: None,
62 toggle: None,
63 menu: Vec::new(),
64 enabled: true,
65 tooltip: None,
66 }
67 }
68
69 pub fn toggle(label: impl Into<String>, icon: IconName, value: Binding<bool>) -> Self {
71 Self { toggle: Some(value), size: RibbonSize::Small, ..Self::button(label, icon) }
72 }
73
74 pub fn large(mut self) -> Self {
76 self.size = RibbonSize::Large;
77 self
78 }
79
80 pub fn small(mut self) -> Self {
82 self.size = RibbonSize::Small;
83 self
84 }
85
86 pub fn icon_only(mut self) -> Self {
88 self.size = RibbonSize::IconOnly;
89 self
90 }
91
92 pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
94 self.action = Some(Rc::new(handler));
95 self
96 }
97
98 pub fn menu_item(mut self, item: MenuItem) -> Self {
100 self.menu.push(Entry::Item(item));
101 self
102 }
103
104 pub fn menu_separator(mut self) -> Self {
106 self.menu.push(Entry::Separator);
107 self
108 }
109
110 pub fn enabled(mut self, enabled: bool) -> Self {
112 self.enabled = enabled;
113 self
114 }
115
116 pub fn tooltip(mut self, text: impl Into<String>) -> Self {
118 self.tooltip = Some(text.into());
119 self
120 }
121}
122
123#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
125pub struct RibbonGroup {
126 title: String,
127 items: Vec<RibbonItem>,
128 launcher: Option<Rc<dyn Fn()>>,
129}
130
131impl fmt::Debug for RibbonGroup {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.debug_struct("RibbonGroup")
134 .field("title", &self.title)
135 .field("items", &self.items)
136 .finish_non_exhaustive()
137 }
138}
139
140#[cfg_attr(feature = "live", ravenrs_live::expose)]
141impl RibbonGroup {
142 pub fn new(title: impl Into<String>) -> Self {
144 Self { title: title.into(), items: Vec::new(), launcher: None }
145 }
146
147 pub fn item(mut self, item: RibbonItem) -> Self {
149 self.items.push(item);
150 self
151 }
152
153 pub fn dialog_launcher(mut self, handler: impl Fn() + 'static) -> Self {
155 self.launcher = Some(Rc::new(handler));
156 self
157 }
158}
159
160#[derive(Debug)]
162#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
163pub struct RibbonPage {
164 title: String,
165 groups: Vec<RibbonGroup>,
166 category: Option<(String, Tone)>,
168 visible: bool,
169}
170
171#[cfg_attr(feature = "live", ravenrs_live::expose)]
172impl RibbonPage {
173 pub fn new(title: impl Into<String>) -> Self {
175 Self { title: title.into(), groups: Vec::new(), category: None, visible: true }
176 }
177
178 pub fn group(mut self, group: RibbonGroup) -> Self {
180 self.groups.push(group);
181 self
182 }
183
184 pub fn contextual(mut self, category: impl Into<String>, tone: Tone, visible: bool) -> Self {
186 self.category = Some((category.into(), tone));
187 self.visible = visible;
188 self
189 }
190}
191
192#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
214pub struct Ribbon {
215 pages: Vec<RibbonPage>,
216 app_menu: Option<(String, Vec<Entry>)>,
217 quick_access: Vec<RibbonItem>,
218 search: Option<Binding<String>>,
219 selected: Option<Binding<usize>>,
220}
221
222impl fmt::Debug for Ribbon {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 f.debug_struct("Ribbon").field("pages", &self.pages).finish_non_exhaustive()
225 }
226}
227
228impl Default for Ribbon {
229 fn default() -> Self {
230 Self::new()
231 }
232}
233
234#[cfg_attr(feature = "live", ravenrs_live::expose)]
235impl Ribbon {
236 pub fn new() -> Self {
238 Self { pages: Vec::new(), app_menu: None, quick_access: Vec::new(), search: None, selected: None }
239 }
240
241 pub fn page(mut self, page: RibbonPage) -> Self {
243 self.pages.push(page);
244 self
245 }
246
247 pub fn app_menu(mut self, label: impl Into<String>, items: Vec<MenuItem>) -> Self {
249 self.app_menu = Some((label.into(), items.into_iter().map(Entry::Item).collect()));
250 self
251 }
252
253 pub fn quick_access(mut self, item: RibbonItem) -> Self {
255 self.quick_access.push(item.icon_only());
256 self
257 }
258
259 pub fn search(mut self, query: Binding<String>) -> Self {
261 self.search = Some(query);
262 self
263 }
264
265 pub fn selected_page(mut self, page: Binding<usize>) -> Self {
267 self.selected = Some(page);
268 self
269 }
270}
271
272const BODY_HEIGHT: f32 = 92.0;
274const CAPTION_HEIGHT: f32 = 22.0;
275
276fn tone_color(theme: &Theme, tone: Tone) -> Color {
277 match tone {
278 Tone::Neutral => theme.color(Semantic::InkMuted),
279 Tone::Accent => theme.color(Semantic::Accent),
280 Tone::Success => theme.color(Semantic::Success),
281 Tone::Warning => theme.color(Semantic::Warning),
282 Tone::Danger => theme.color(Semantic::Danger),
283 }
284}
285
286struct ItemView<'a>(&'a RibbonItem);
287
288impl View for ItemView<'_> {
289 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
290 let theme = *cx.theme();
291 let item = self.0;
292 let open: State<bool> = cx.use_state(|| false);
293 let on = item.toggle.as_ref().is_some_and(Binding::get);
294 let hovered = cx.is_hovered() && item.enabled;
295 let pressed = cx.is_pressed() && item.enabled;
296 let ink = theme.color(Semantic::Ink);
297 let color = if item.enabled { ink } else { theme.color(Semantic::InkMuted).with_alpha(0.5) };
298 let icon_color = if !item.enabled {
299 color
300 } else if on {
301 theme.color(Semantic::Accent).lerp(ink, 0.2)
302 } else {
303 theme.color(Semantic::Accent).lerp(ink, 0.45)
304 };
305 let has_menu = !item.menu.is_empty();
306 let mut children = Vec::new();
307 let node_style = match item.size {
308 RibbonSize::Large => {
309 if let Some(i) = item.icon {
310 children.push(util::icon(cx, i, 28.0, icon_color));
311 }
312 let mut a = util::attrs(&theme, TextStyle::Caption, None);
313 a.size = 12.0;
314 a.line_height = 15.0;
315 a.letter_spacing = 0.0;
316 let label = cx.text(
317 &item.label,
318 &a,
319 color,
320 LayoutStyle { max_width: Some(72.0), ..LayoutStyle::leaf() },
321 );
322 children.push(label);
323 if has_menu {
324 children.push(util::icon(
325 cx,
326 IconName::ChevronDown,
327 12.0,
328 theme.color(Semantic::InkMuted),
329 ));
330 }
331 LayoutStyle {
332 height: Some(BODY_HEIGHT - 6.0),
333 min_width: Some(52.0),
334 padding: Insets { top: 8.0, bottom: 4.0, left: 6.0, right: 6.0 },
335 spacing: 4.0,
336 align: Align::Center,
337 ..LayoutStyle::stack(Axis::Vertical)
338 }
339 }
340 RibbonSize::Small | RibbonSize::IconOnly => {
341 if let Some(i) = item.icon {
342 children.push(util::icon(cx, i, 16.0, icon_color));
343 }
344 if item.size == RibbonSize::Small {
345 let mut a = util::attrs(&theme, TextStyle::Callout, None);
346 a.size = 13.0;
347 children.push(cx.text(&item.label, &a, color, LayoutStyle::leaf()));
348 }
349 if has_menu {
350 children.push(util::icon(
351 cx,
352 IconName::ChevronDown,
353 12.0,
354 theme.color(Semantic::InkMuted),
355 ));
356 }
357 let icon_only = item.size == RibbonSize::IconOnly;
358 LayoutStyle {
359 height: Some(26.0),
360 width: (icon_only && !has_menu).then_some(28.0),
361 padding: Insets::symmetric(0.0, 6.0),
362 spacing: 6.0,
363 align: Align::Center,
364 justify: if icon_only { Justify::Center } else { Justify::Start },
365 ..LayoutStyle::stack(Axis::Horizontal)
366 }
367 }
368 };
369 let fill = if on || open.get() {
370 theme.color(Semantic::Accent).with_alpha(if pressed { 0.28 } else { 0.18 })
371 } else if pressed {
372 ink.with_alpha(0.12)
373 } else if hovered {
374 ink.with_alpha(0.07)
375 } else {
376 Color::TRANSPARENT
377 };
378 let border = (on || open.get())
379 .then(|| Border { width: 1.0, color: theme.color(Semantic::Accent).with_alpha(0.45) });
380 let node = cx.container(
381 node_style,
382 &children,
383 Paint::Quad(QuadPaint { corner_radius: 6.0, border, ..QuadPaint::new(fill) }),
384 );
385 crate::motion::control(cx, node);
386 let mut semantics = Semantics::new(Role::Button).name(item.label.clone()).disabled(!item.enabled);
387 if item.toggle.is_some() {
388 semantics = semantics.toggled(on);
389 }
390 if has_menu {
391 semantics = semantics.expanded(open.get());
392 }
393 cx.semantics(node, semantics);
394 if item.enabled {
395 let (action, toggle, menu_open) = (item.action.clone(), item.toggle.clone(), open.clone());
396 cx.on_click(
397 node,
398 Rc::new(move || {
399 if has_menu {
400 menu_open.update(|o| *o = !*o);
401 return;
402 }
403 if let Some(t) = &toggle {
404 t.update(|v| *v = !*v);
405 }
406 if let Some(a) = &action {
407 a();
408 }
409 }),
410 );
411 cx.focusable(node, 6.0);
412 cx.cursor(node, CursorIcon::Pointer);
413 }
414 if open.get() {
415 let close: Rc<dyn Fn()> = {
416 let open = open.clone();
417 Rc::new(move || open.set(false))
418 };
419 cx.overlay(
420 node,
421 Placement::Below,
422 &MenuList { entries: &item.menu, close: close.clone(), on_side: None },
423 Some(close),
424 );
425 }
426 node
427 }
428}
429
430struct Command<'a>(&'a RibbonItem);
432
433impl View for Command<'_> {
434 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
435 let tip = self
436 .0
437 .tooltip
438 .clone()
439 .or_else(|| (self.0.size == RibbonSize::IconOnly).then(|| self.0.label.clone()));
440 match tip {
441 Some(t) if !t.is_empty() => cx.child(&Tooltip::new(ItemView(self.0), t)),
442 _ => cx.child(&ItemView(self.0)),
443 }
444 }
445}
446
447struct GroupView<'a>(&'a RibbonGroup);
448
449impl View for GroupView<'_> {
450 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
451 let theme = *cx.theme();
452 let g = self.0;
453 let mut columns = Vec::new();
455 let mut stack: Vec<NodeId> = Vec::new();
456 let flush = |cx: &mut BuildCx<'_>, stack: &mut Vec<NodeId>, columns: &mut Vec<NodeId>| {
457 if !stack.is_empty() {
458 columns.push(cx.container(
459 LayoutStyle {
460 spacing: 2.0,
461 justify: Justify::Start,
462 ..LayoutStyle::stack(Axis::Vertical)
463 },
464 stack,
465 Paint::None,
466 ));
467 stack.clear();
468 }
469 };
470 for (i, item) in g.items.iter().enumerate() {
471 let node = cx.keyed_child(i as u64, &Command(item));
472 if item.size == RibbonSize::Large {
473 flush(cx, &mut stack, &mut columns);
474 columns.push(node);
475 } else {
476 stack.push(node);
477 if stack.len() == 3 {
478 flush(cx, &mut stack, &mut columns);
479 }
480 }
481 }
482 flush(cx, &mut stack, &mut columns);
483 let body = cx.container(
484 LayoutStyle {
485 height: Some(BODY_HEIGHT),
486 padding: Insets::symmetric(3.0, 4.0),
487 spacing: 2.0,
488 align: Align::Start,
489 ..LayoutStyle::stack(Axis::Horizontal)
490 },
491 &columns,
492 Paint::None,
493 );
494 let muted = theme.color(Semantic::InkMuted);
495 let title = util::label(cx, &g.title, TextStyle::Caption, None, muted);
496 cx.semantics(title, Semantics::hidden());
498 let mut caption = vec![title];
499 if let Some(l) = &g.launcher {
500 let arrow = util::icon(cx, IconName::ArrowDown, 11.0, muted);
501 let b = cx.container(
502 LayoutStyle {
503 width: Some(18.0),
504 height: Some(18.0),
505 align: Align::Center,
506 justify: Justify::Center,
507 ..LayoutStyle::stack(Axis::Horizontal)
508 },
509 &[arrow],
510 Paint::None,
511 );
512 cx.semantics(b, Semantics::new(Role::Button).name(format!("{} options", g.title)));
513 cx.on_click(b, l.clone());
514 cx.cursor(b, CursorIcon::Pointer);
515 caption.push(b);
516 }
517 let caption = cx.container(
518 LayoutStyle {
519 height: Some(CAPTION_HEIGHT),
520 spacing: 4.0,
521 align: Align::Center,
522 justify: Justify::Center,
523 align_self: Some(Align::Stretch),
524 ..LayoutStyle::stack(Axis::Horizontal)
525 },
526 &caption,
527 Paint::None,
528 );
529 let line = theme.color(Semantic::Border);
530 let node = cx.container(
531 LayoutStyle {
532 padding: Insets { right: 6.0, left: 2.0, ..Insets::ZERO },
533 align: Align::Center,
534 ..LayoutStyle::stack(Axis::Vertical)
535 },
536 &[body, caption],
537 Paint::Custom(Rc::new(move |f: Rect, scene| {
538 scene.push_quad(Quad::new(
539 Rect::new(f.max_x() - 1.0, f.y() + 8.0, 1.0, f.height() - 16.0),
540 line,
541 ));
542 })),
543 );
544 cx.semantics(node, Semantics::new(Role::Toolbar).name(g.title.clone()));
545 node
546 }
547}
548
549struct PageBody<'a>(&'a RibbonPage);
550
551impl View for PageBody<'_> {
552 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
553 let groups: Vec<NodeId> =
554 self.0.groups.iter().enumerate().map(|(i, g)| cx.keyed_child(i as u64, &GroupView(g))).collect();
555 let node = cx.container(
556 LayoutStyle {
557 padding: Insets::symmetric(4.0, 8.0),
558 spacing: 6.0,
559 align_self: Some(Align::Stretch),
560 overflow_hidden: true,
561 ..LayoutStyle::stack(Axis::Horizontal)
562 },
563 &groups,
564 Paint::None,
565 );
566 cx.clip(node);
567 node
568 }
569}
570
571struct BodyPopup<'a>(&'a RibbonPage, f32);
573
574impl View for BodyPopup<'_> {
575 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
576 let body = cx.child(&PageBody(self.0));
577 panel(cx, &[body], 0.0, self.1)
578 }
579}
580
581struct Tab<'a> {
582 page: &'a RibbonPage,
583 index: usize,
584 selected: Binding<usize>,
585 collapsed: State<bool>,
586 popup: State<bool>,
587}
588
589impl View for Tab<'_> {
590 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
591 let theme = *cx.theme();
592 let on = self.selected.get() == self.index;
593 let hovered = cx.is_hovered();
594 let ink = theme.color(Semantic::Ink);
595 let tone = self.page.category.as_ref().map(|(_, t)| tone_color(&theme, *t));
596 let color = match (on, tone) {
597 (true, Some(t)) => t.lerp(ink, 0.25),
598 (true, None) => ink,
599 _ if hovered => ink,
600 _ => theme.color(Semantic::InkMuted),
601 };
602 let label = util::label(cx, &self.page.title, TextStyle::Callout, Some(Weight::SEMIBOLD), color);
603 crate::motion::control(cx, label);
604 let accent = tone.unwrap_or(theme.color(Semantic::Accent));
605 let category_fill = tone.map(|t| t.with_alpha(0.10));
606 let node = cx.container(
607 LayoutStyle {
608 height: Some(34.0),
609 padding: Insets::symmetric(0.0, 12.0),
610 align: Align::Center,
611 ..LayoutStyle::stack(Axis::Horizontal)
612 },
613 &[label],
614 Paint::Custom(Rc::new(move |f: Rect, scene| {
615 if let Some(fill) = category_fill {
616 scene.push_quad(Quad::new(f, fill));
617 }
618 if on {
619 scene.push_quad(
620 Quad::new(Rect::new(f.x() + 8.0, f.max_y() - 2.0, f.width() - 16.0, 2.0), accent)
621 .radius(1.0),
622 );
623 }
624 })),
625 );
626 cx.semantics(node, Semantics::new(Role::Tab).selected(on));
627 let (sel, i, collapsed, popup) =
628 (self.selected.clone(), self.index, self.collapsed.clone(), self.popup.clone());
629 cx.on_click(
630 node,
631 Rc::new(move || {
632 let same = sel.get() == i;
633 sel.set(i);
634 if collapsed.get() {
635 popup.set(!(same && popup.get()));
636 }
637 }),
638 );
639 let (collapsed, popup) = (self.collapsed.clone(), self.popup.clone());
640 cx.on_double_click(
641 node,
642 Rc::new(move || {
643 popup.set(false);
644 collapsed.update(|c| *c = !*c);
645 }),
646 );
647 cx.focusable(node, 6.0);
648 cx.cursor(node, CursorIcon::Pointer);
649 node
650 }
651}
652
653impl View for Ribbon {
654 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
655 let theme = *cx.theme();
656 let own: State<usize> = cx.use_state(|| 0);
657 let selected = self.selected.clone().unwrap_or_else(|| own.binding());
658 let collapsed: State<bool> = cx.use_state(|| false);
659 let popup: State<bool> = cx.use_state(|| false);
660 let app_open: State<bool> = cx.use_state(|| false);
661 let visible: Vec<usize> = (0..self.pages.len()).filter(|&i| self.pages[i].visible).collect();
662 let current = {
663 let s = selected.get();
664 if visible.contains(&s) { s } else { visible.first().copied().unwrap_or(0) }
665 };
666
667 let mut row = Vec::new();
669 if let Some((label, entries)) = &self.app_menu {
670 let ink = theme.color(Semantic::Ink);
671 let text = util::label(cx, label, TextStyle::Callout, Some(Weight::SEMIBOLD), ink);
672 let hovered = cx.is_hovered();
673 let control = theme.color(Semantic::Control);
674 let fill = if hovered { control.lerp(ink, 0.08) } else { control };
675 let app = cx.container(
676 LayoutStyle {
677 height: Some(32.0),
678 padding: Insets::symmetric(0.0, theme.spacing.s3),
679 align: Align::Center,
680 ..LayoutStyle::stack(Axis::Horizontal)
681 },
682 &[text],
683 Paint::Quad(QuadPaint {
684 corner_radius: theme.radii.md,
685 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
686 ..QuadPaint::new(fill)
687 }),
688 );
689 cx.semantics(app, Semantics::new(Role::Button).expanded(app_open.get()));
690 let o = app_open.clone();
691 cx.on_click(app, Rc::new(move || o.update(|v| *v = !*v)));
692 cx.focusable(app, theme.radii.md);
693 cx.cursor(app, CursorIcon::Pointer);
694 if app_open.get() {
695 let close: Rc<dyn Fn()> = {
696 let o = app_open.clone();
697 Rc::new(move || o.set(false))
698 };
699 cx.overlay(
700 app,
701 Placement::Below,
702 &MenuList { entries, close: close.clone(), on_side: None },
703 Some(close),
704 );
705 }
706 row.push(app);
707 }
708 if !self.quick_access.is_empty() {
709 let items: Vec<NodeId> = self
710 .quick_access
711 .iter()
712 .enumerate()
713 .map(|(i, q)| cx.keyed_child(500 + i as u64, &Command(q)))
714 .collect();
715 row.push(cx.container(
716 LayoutStyle {
717 spacing: 1.0,
718 padding: Insets::symmetric(0.0, 6.0),
719 align: Align::Center,
720 ..LayoutStyle::stack(Axis::Horizontal)
721 },
722 &items,
723 Paint::None,
724 ));
725 }
726 let mut tabs = Vec::new();
728 for &i in &visible {
729 let page = &self.pages[i];
730 let tab = cx.keyed_child(
731 1000 + i as u64,
732 &Tab {
733 page,
734 index: i,
735 selected: selected.clone(),
736 collapsed: collapsed.clone(),
737 popup: popup.clone(),
738 },
739 );
740 if let Some((name, tone)) = &page.category {
741 let color = tone_color(&theme, *tone);
742 let cap = util::label(cx, &name.to_uppercase(), TextStyle::Caption, None, color);
743 tabs.push(cx.container(
744 LayoutStyle { spacing: 0.0, align: Align::Center, ..LayoutStyle::stack(Axis::Vertical) },
745 &[cap, tab],
746 Paint::None,
747 ));
748 } else {
749 tabs.push(tab);
750 }
751 }
752 let tab_list = cx.container(
753 LayoutStyle {
754 spacing: 2.0,
755 align: Align::End,
756 grow: 1.0,
757 min_width: Some(0.0),
758 ..LayoutStyle::stack(Axis::Horizontal)
759 },
760 &tabs,
761 Paint::None,
762 );
763 cx.semantics(tab_list, Semantics::new(Role::TabList).name(cx.labels().ribbon.clone()));
764 row.push(tab_list);
765 if let Some(q) = &self.search {
766 row.push(
767 cx.child(
768 &TextField::new(q.clone())
769 .placeholder(cx.labels().ribbon_search.clone())
770 .icon(IconName::Search)
771 .compact()
772 .width(240.0),
773 ),
774 );
775 }
776 let is_collapsed = collapsed.get();
777 let (c, p) = (collapsed.clone(), popup.clone());
778 row.push(
779 cx.child(&Tooltip::new(
780 crate::IconButton::new(if is_collapsed {
781 IconName::ChevronDown
782 } else {
783 IconName::ChevronUp
784 })
785 .size(crate::ButtonSize::Small)
786 .on_click(move || {
787 p.set(false);
788 c.update(|v| *v = !*v);
789 }),
790 if is_collapsed {
791 cx.labels().ribbon_pin.clone()
792 } else {
793 cx.labels().ribbon_collapse.clone()
794 },
795 )),
796 );
797 let title_row = cx.container(
798 LayoutStyle {
799 padding: Insets { top: 6.0, bottom: 0.0, left: 8.0, right: 8.0 },
800 spacing: 8.0,
801 align: Align::End,
802 align_self: Some(Align::Stretch),
803 ..LayoutStyle::stack(Axis::Horizontal)
804 },
805 &row,
806 Paint::None,
807 );
808 let line = theme.color(Semantic::Border);
809 let mut sections = vec![title_row];
810 if let Some(page) = self.pages.get(current) {
811 if is_collapsed {
812 if popup.get() {
813 let close: Rc<dyn Fn()> = {
814 let p = popup.clone();
815 Rc::new(move || p.set(false))
816 };
817 let width = cx.viewport().width - 32.0;
818 cx.overlay(title_row, Placement::Below, &BodyPopup(page, width.min(1100.0)), Some(close));
819 }
820 } else {
821 sections.push(cx.keyed_child(2000 + current as u64, &PageBody(page)));
822 }
823 }
824 let surface = theme.color(Semantic::Surface);
825 cx.container(
826 LayoutStyle {
827 align: Align::Stretch,
828 align_self: Some(Align::Stretch),
829 ..LayoutStyle::stack(Axis::Vertical)
830 },
831 §ions,
832 Paint::Custom(Rc::new(move |f: Rect, scene| {
833 scene.push_quad(Quad::new(f, surface));
834 scene.push_quad(Quad::new(Rect::new(f.x(), f.max_y() - 1.0, f.width(), 1.0), line));
835 scene.push_quad(Quad::new(Rect::new(f.x(), f.y() + 40.0, f.width(), 1.0), line.faded(0.6)));
837 })),
838 )
839 }
840}