1use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6use std::sync::{Arc, Mutex};
7
8use ravenrs_core::{
9 Border, Color, CursorIcon, Effect, ImageData, Insets, Key, Point, Quad, Rect, Role, Scene, Size,
10};
11use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
12use ravenrs_reactive::{Binding, State};
13use ravenrs_style::{Semantic, TextStyle, Weight};
14
15use crate::context::{BuildCx, Drag, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
16use crate::conversation::file_name_and_size;
17use crate::files::size_text;
18use crate::modifiers::ViewExt;
19use crate::motion::Motion;
20use crate::view::{AnyView, View};
21use crate::{
22 AudioPlayerButton, Button, ButtonSize, ButtonStyle, HStack, Icon, IconButton, IconName, Image, RichText,
23 Spinner, Text, TextField, VStack, util,
24};
25
26const MESSAGE_WIDTH: f32 = 320.0;
28const MEDIA_AUDIO_WIDTH: f32 = 240.0;
30
31#[derive(Clone)]
48#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
49pub struct ChatMedia {
50 kind: MediaKind,
51 on_open: Option<Rc<dyn Fn()>>,
52}
53
54#[derive(Clone)]
55enum MediaKind {
56 Photo { image: Option<ImageData>, side: f32, alt: Option<String> },
57 Document { name: String, size: Option<u64>, icon: IconName },
58 Audio(Rc<dyn Fn() -> AudioPlayerButton>),
59}
60
61impl fmt::Debug for ChatMedia {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 let kind = match &self.kind {
64 MediaKind::Photo { .. } => "photo",
65 MediaKind::Document { .. } => "document",
66 MediaKind::Audio(_) => "audio",
67 };
68 f.debug_struct("ChatMedia").field("kind", &kind).finish_non_exhaustive()
69 }
70}
71
72#[cfg_attr(feature = "live", ravenrs_live::expose)]
73impl ChatMedia {
74 pub fn photo(image: Option<ImageData>) -> Self {
77 Self { kind: MediaKind::Photo { image, side: 240.0, alt: None }, on_open: None }
78 }
79
80 pub fn document(name: impl Into<String>) -> Self {
82 Self {
83 kind: MediaKind::Document { name: name.into(), size: None, icon: IconName::FileText },
84 on_open: None,
85 }
86 }
87
88 pub fn audio(player: impl Fn() -> AudioPlayerButton + 'static) -> Self {
91 Self { kind: MediaKind::Audio(Rc::new(player)), on_open: None }
92 }
93
94 #[must_use]
96 pub fn side(mut self, side: f32) -> Self {
97 if let MediaKind::Photo { side: current, .. } = &mut self.kind {
98 *current = side;
99 }
100 self
101 }
102
103 #[must_use]
105 pub fn alt(mut self, text: impl Into<String>) -> Self {
106 if let MediaKind::Photo { alt, .. } = &mut self.kind {
107 *alt = Some(text.into());
108 }
109 self
110 }
111
112 #[must_use]
114 pub fn size(mut self, bytes: u64) -> Self {
115 if let MediaKind::Document { size, .. } = &mut self.kind {
116 *size = Some(bytes);
117 }
118 self
119 }
120
121 #[must_use]
123 pub fn icon(mut self, icon: IconName) -> Self {
124 if let MediaKind::Document { icon: current, .. } = &mut self.kind {
125 *current = icon;
126 }
127 self
128 }
129
130 #[must_use]
133 pub fn on_open(mut self, handler: impl Fn() + 'static) -> Self {
134 self.on_open = Some(Rc::new(handler));
135 self
136 }
137}
138
139impl View for ChatMedia {
140 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
141 let theme = *cx.theme();
142 let labels = cx.labels();
143 let (node, name) = match &self.kind {
144 MediaKind::Audio(player) => {
145 let player = cx.child(&player());
146 return cx.container(
148 LayoutStyle {
149 min_width: Some(MEDIA_AUDIO_WIDTH),
150 align: Align::Stretch,
151 ..LayoutStyle::stack(Axis::Vertical)
152 },
153 &[player],
154 Paint::None,
155 );
156 }
157 MediaKind::Photo { image, side, alt } => {
158 let name = alt.clone().unwrap_or_else(|| labels.chat_photo.clone());
159 let radius = theme.radii.md;
160 let content = match image {
161 Some(image) => {
162 cx.child(&Image::new(image.clone()).size(*side, *side).corner_radius(radius))
163 }
164 None => cx.child(
165 &Text::new(labels.chat_photo_loading.clone())
166 .style(TextStyle::Caption)
167 .foreground(Semantic::InkMuted),
168 ),
169 };
170 let node = cx.container(
171 LayoutStyle {
172 width: Some(*side),
173 height: Some(*side),
174 align: Align::Center,
175 justify: Justify::Center,
176 align_self: Some(Align::Start),
177 ..LayoutStyle::stack(Axis::Vertical)
178 },
179 &[content],
180 if image.is_some() {
181 Paint::None
182 } else {
183 Paint::Quad(QuadPaint {
184 corner_radius: radius,
185 ..QuadPaint::new(theme.color(Semantic::Surface))
186 })
187 },
188 );
189 if self.on_open.is_none() {
190 cx.semantics(node, Semantics::new(Role::Image).name(name.clone()));
191 }
192 (node, name)
193 }
194 MediaKind::Document { name, size, icon } => {
195 let icon = cx.child(&Icon::new(*icon).size(20.0).color(theme.color(Semantic::InkMuted)));
196 let title = cx.child(&Text::new(name.clone()).style(TextStyle::Callout).truncate());
197 let mut lines = vec![title];
198 if let Some(size) = size {
199 lines.push(cx.child(
200 &Text::new(size_text(*size)).style(TextStyle::Caption).foreground(Semantic::InkMuted),
201 ));
202 }
203 let lines = cx.container(
204 LayoutStyle {
205 grow: 1.0,
206 min_width: Some(0.0),
207 align: Align::Stretch,
208 ..LayoutStyle::stack(Axis::Vertical)
209 },
210 &lines,
211 Paint::None,
212 );
213 let surface = if cx.is_hovered() && self.on_open.is_some() {
214 Semantic::SurfaceElevated
215 } else {
216 Semantic::Surface
217 };
218 let node = cx.container(
219 LayoutStyle {
220 spacing: theme.spacing.s2,
221 align: Align::Center,
222 align_self: Some(Align::Stretch),
223 min_width: Some(0.0),
224 padding: Insets::symmetric(theme.spacing.s2, theme.spacing.s3),
225 ..LayoutStyle::stack(Axis::Horizontal)
226 },
227 &[icon, lines],
228 Paint::Quad(QuadPaint {
229 corner_radius: theme.radii.md,
230 ..QuadPaint::new(theme.color(surface))
231 }),
232 );
233 let named = file_name_and_size(name, *size);
234 if self.on_open.is_none() {
235 cx.semantics(node, Semantics::new(Role::Group).name(named.clone()));
236 }
237 (node, named)
238 }
239 };
240 if let Some(on_open) = &self.on_open {
241 cx.on_click(node, on_open.clone());
242 cx.focusable(node, theme.radii.md);
243 cx.cursor(node, CursorIcon::Pointer);
244 cx.semantics(node, Semantics::new(Role::Button).name(labels.chat_open.replace("{what}", &name)));
245 }
246 node
247 }
248}
249
250#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252#[non_exhaustive]
253#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
254pub enum ChatAuthor {
255 Person,
257 Assistant,
259}
260
261#[derive(Clone)]
263#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
264pub struct ChatMessage {
265 author: ChatAuthor,
266 text: Arc<Mutex<String>>,
267 card: Option<Rc<dyn Fn() -> AnyView>>,
268 media: Option<ChatMedia>,
269 formatted: bool,
270}
271
272impl fmt::Debug for ChatMessage {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 f.debug_struct("ChatMessage")
275 .field("author", &self.author)
276 .field("text", &self.text())
277 .finish_non_exhaustive()
278 }
279}
280
281#[cfg_attr(feature = "live", ravenrs_live::expose)]
282impl ChatMessage {
283 pub fn person(text: impl Into<String>) -> Self {
285 Self::of(ChatAuthor::Person, Arc::new(Mutex::new(text.into())))
286 }
287
288 pub fn assistant(text: impl Into<String>) -> Self {
290 Self::of(ChatAuthor::Assistant, Arc::new(Mutex::new(text.into())))
291 }
292
293 pub fn assistant_writing(text: Arc<Mutex<String>>) -> Self {
297 Self::of(ChatAuthor::Assistant, text)
298 }
299
300 fn of(author: ChatAuthor, text: Arc<Mutex<String>>) -> Self {
301 Self { author, text, card: None, media: None, formatted: false }
302 }
303
304 #[must_use]
306 pub fn media(mut self, media: ChatMedia) -> Self {
307 self.media = Some(media);
308 self
309 }
310
311 #[must_use]
313 pub fn formatted(mut self) -> Self {
314 self.formatted = true;
315 self
316 }
317
318 #[must_use]
321 pub fn card(mut self, card: impl Fn() -> AnyView + 'static) -> Self {
322 self.card = Some(Rc::new(card));
323 self
324 }
325
326 pub fn author(&self) -> ChatAuthor {
328 self.author
329 }
330
331 pub fn text(&self) -> String {
333 self.text.lock().map(|text| text.clone()).unwrap_or_default()
334 }
335}
336
337#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
355pub struct Chat {
356 messages: Binding<Vec<ChatMessage>>,
357 placeholder: String,
358 intro: Option<String>,
359 examples: Vec<String>,
360 on_send: Option<Rc<dyn Fn(String)>>,
361 on_listen: Option<Rc<dyn Fn(String)>>,
362 on_link: Option<Rc<dyn Fn(String)>>,
363 busy: bool,
364}
365
366impl fmt::Debug for Chat {
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 f.debug_struct("Chat").field("placeholder", &self.placeholder).finish_non_exhaustive()
369 }
370}
371
372#[cfg_attr(feature = "live", ravenrs_live::expose)]
373impl Chat {
374 pub fn new(messages: Binding<Vec<ChatMessage>>) -> Self {
376 Self {
377 messages,
378 placeholder: String::new(),
379 intro: None,
380 examples: Vec::new(),
381 on_send: None,
382 on_listen: None,
383 on_link: None,
384 busy: false,
385 }
386 }
387
388 #[must_use]
390 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
391 self.placeholder = text.into();
392 self
393 }
394
395 #[must_use]
397 pub fn intro(mut self, text: impl Into<String>) -> Self {
398 self.intro = Some(text.into());
399 self
400 }
401
402 #[must_use]
404 pub fn examples(mut self, examples: impl IntoIterator<Item = impl Into<String>>) -> Self {
405 self.examples = examples.into_iter().map(Into::into).collect();
406 self
407 }
408
409 #[must_use]
412 pub fn busy(mut self, busy: bool) -> Self {
413 self.busy = busy;
414 self
415 }
416
417 #[must_use]
419 pub fn on_listen(mut self, handler: impl Fn(String) + 'static) -> Self {
420 self.on_listen = Some(Rc::new(handler));
421 self
422 }
423
424 #[must_use]
426 pub fn on_link(mut self, handler: impl Fn(String) + 'static) -> Self {
427 self.on_link = Some(Rc::new(handler));
428 self
429 }
430
431 #[must_use]
433 pub fn on_send(mut self, handler: impl Fn(String) + 'static) -> Self {
434 self.on_send = Some(Rc::new(handler));
435 self
436 }
437}
438
439#[derive(Clone)]
441struct Send {
442 messages: Binding<Vec<ChatMessage>>,
443 draft: State<String>,
444 on_send: Option<Rc<dyn Fn(String)>>,
445 busy: bool,
446}
447
448#[cfg_attr(feature = "live", ravenrs_live::expose)]
449impl Send {
450 fn text(&self, text: &str) {
451 let text = text.trim();
452 if text.is_empty() || self.busy {
453 return;
454 }
455 self.draft.set(String::new());
456 self.messages.update(|messages| messages.push(ChatMessage::person(text)));
457 if let Some(on_send) = &self.on_send {
458 on_send(text.to_owned());
459 }
460 }
461}
462
463struct Conversation {
465 messages: Vec<ChatMessage>,
466 empty: Option<AnyView>,
467 on_listen: Option<Rc<dyn Fn(String)>>,
468 on_link: Option<Rc<dyn Fn(String)>>,
469}
470
471impl View for Conversation {
472 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
473 let theme = *cx.theme();
474 let seen = cx.use_state(|| Rc::new(Cell::new(0_usize))).get();
475 let mut children = Vec::new();
476 if let Some(empty) = &self.empty {
477 children.push(cx.child(empty.as_ref()));
478 }
479 for (i, message) in self.messages.iter().enumerate() {
480 children.push(cx.keyed_child(
481 i as u64,
482 &Bubble(message.clone(), self.on_listen.clone(), self.on_link.clone()),
483 ));
484 }
485 let node = cx.container(
486 LayoutStyle {
487 spacing: theme.spacing.s3,
488 grow: 1.0,
489 align: Align::Stretch,
490 overflow_hidden: true,
491 min_height: Some(0.0),
492 padding: Insets {
494 top: theme.spacing.s2,
495 bottom: theme.spacing.s2,
496 left: 0.0,
497 right: theme.spacing.s3,
498 },
499 ..LayoutStyle::stack(Axis::Vertical)
500 },
501 &children,
502 Paint::None,
503 );
504 cx.scrollable(node);
505 if seen.replace(self.messages.len()) != self.messages.len() {
506 cx.set_scroll_offset(Point::new(0.0, f32::MAX));
508 }
509 node
510 }
511}
512
513struct Bubble(ChatMessage, Option<Rc<dyn Fn(String)>>, Option<Rc<dyn Fn(String)>>);
515
516impl View for Bubble {
517 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
518 let theme = *cx.theme();
519 let person = self.0.author == ChatAuthor::Person;
520 let words = self.0.text();
521 let media = self.0.media.as_ref().map(|media| cx.child(media));
522 let text = if !words.is_empty() {
523 Some(if self.0.formatted {
524 let rich = RichText::new(words.clone()).foreground(Semantic::Ink);
525 cx.child(&match self.2.clone() {
526 Some(on_link) => rich.on_link(move |address| on_link(address)),
527 None => rich,
528 })
529 } else {
530 cx.child(&Text::new(words.clone()).foreground(Semantic::Ink))
531 })
532 } else if media.is_none() {
533 Some(cx.child(&Spinner::new().size(16.0)))
534 } else {
535 None
536 };
537 let inside: Vec<NodeId> = media.into_iter().chain(text).collect();
538 let bubble = cx.container(
539 LayoutStyle {
540 padding: if person {
541 Insets::symmetric(theme.spacing.s2, theme.spacing.s3)
542 } else {
543 Insets::symmetric(theme.spacing.s1, 0.0)
544 },
545 max_width: Some(MESSAGE_WIDTH),
546 spacing: if inside.len() > 1 { theme.spacing.s2 } else { 0.0 },
547 align: Align::Stretch,
548 align_self: Some(if person { Align::End } else { Align::Start }),
549 ..LayoutStyle::stack(Axis::Vertical)
550 },
551 &inside,
552 if person {
554 Paint::Quad(QuadPaint {
555 corner_radius: theme.radii.lg,
556 ..QuadPaint::new(theme.color(Semantic::Control))
557 })
558 } else {
559 Paint::None
560 },
561 );
562 let mut children = vec![bubble];
564 if let Some(on_listen) = self.1.clone().filter(|_| !person && !words.is_empty()) {
565 let listen = IconButton::new(IconName::Volume2)
566 .label(cx.labels().listen.clone())
567 .style(ButtonStyle::Ghost)
568 .size(ButtonSize::Small)
569 .on_click(move || on_listen(words.clone()));
570 let listen = HStack::new((listen,));
571 children.push(cx.child(&listen));
572 }
573 if let Some(card) = &self.0.card {
574 let card = card();
575 children.push(cx.child(card.as_ref()));
576 }
577 let node = cx.container(
578 LayoutStyle {
579 spacing: theme.spacing.s2,
580 align: Align::Stretch,
581 ..LayoutStyle::stack(Axis::Vertical)
582 },
583 &children,
584 Paint::None,
585 );
586 if !person {
587 cx.semantics(node, Semantics::new(Role::Status));
589 }
590 node
591 }
592}
593
594impl View for Chat {
595 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
596 let theme = *cx.theme();
597 let draft: State<String> = cx.use_state(String::new);
598 let send = Send {
599 messages: self.messages.clone(),
600 draft: draft.clone(),
601 on_send: self.on_send.clone(),
602 busy: self.busy,
603 };
604 let messages = self.messages.get();
605 let empty = messages.is_empty().then(|| -> AnyView {
606 let examples: Vec<Button> = self
607 .examples
608 .iter()
609 .map(|example| {
610 let (send, example) = (send.clone(), example.clone());
611 Button::new(example.clone())
612 .style(ButtonStyle::Secondary)
613 .size(ButtonSize::Small)
614 .on_click(move || send.text(&example))
615 })
616 .collect();
617 Box::new(
618 VStack::new((
619 self.intro.clone().map(|intro| {
620 Text::new(intro).style(TextStyle::Callout).foreground(Semantic::InkMuted)
621 }),
622 HStack::new(examples).spacing(theme.spacing.s2).wrap(),
623 ))
624 .spacing(theme.spacing.s3)
625 .align(Align::Stretch),
626 )
627 });
628 let conversation = cx.child(&Conversation {
629 messages,
630 empty,
631 on_listen: self.on_listen.clone(),
632 on_link: self.on_link.clone(),
633 });
634
635 let (on_field, on_button, written) = (send.clone(), send, draft.clone());
636 let writing = HStack::new((
637 TextField::new(draft.binding())
638 .placeholder(self.placeholder.clone())
639 .on_submit(move |text| on_field.text(&text))
640 .grow(1.0),
641 IconButton::new(IconName::Send)
642 .label(cx.labels().send.clone())
643 .style(ButtonStyle::Primary)
644 .disabled(self.busy)
645 .on_click(move || on_button.text(&written.get())),
646 ))
647 .spacing(theme.spacing.s2)
648 .align(Align::Center);
649 let writing = cx.child(&writing);
650 cx.container(
651 LayoutStyle {
652 spacing: theme.spacing.s3,
653 grow: 1.0,
654 align: Align::Stretch,
655 min_height: Some(0.0),
656 ..LayoutStyle::stack(Axis::Vertical)
657 },
658 &[conversation, writing],
659 Paint::None,
660 )
661 }
662}
663
664#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
666#[non_exhaustive]
667#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
668pub enum FloatingCorner {
669 TopLeft,
671 TopRight,
673 BottomLeft,
675 #[default]
677 BottomRight,
678}
679
680#[cfg_attr(feature = "live", ravenrs_live::expose)]
681impl FloatingCorner {
682 fn is_right(self) -> bool {
683 matches!(self, Self::TopRight | Self::BottomRight)
684 }
685
686 fn is_bottom(self) -> bool {
687 matches!(self, Self::BottomLeft | Self::BottomRight)
688 }
689
690 fn at(right: bool, bottom: bool) -> Self {
691 match (right, bottom) {
692 (false, false) => Self::TopLeft,
693 (true, false) => Self::TopRight,
694 (false, true) => Self::BottomLeft,
695 (true, true) => Self::BottomRight,
696 }
697 }
698
699 fn nearest(point: Point, window: Size) -> Self {
701 Self::at(point.x > window.width / 2.0, point.y > window.height / 2.0)
702 }
703
704 fn moved(self, key: &Key) -> Option<Self> {
706 let (right, bottom) = (self.is_right(), self.is_bottom());
707 let next = match key {
708 Key::ArrowLeft => Self::at(false, bottom),
709 Key::ArrowRight => Self::at(true, bottom),
710 Key::ArrowUp => Self::at(right, false),
711 Key::ArrowDown => Self::at(right, true),
712 _ => return None,
713 };
714 Some(next)
715 }
716}
717
718#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
738pub struct FloatingPanel {
739 open: Binding<bool>,
740 title: String,
741 content: Rc<dyn Fn() -> AnyView>,
742 width: f32,
743 height: f32,
744 bottom_inset: f32,
745 place: Option<Binding<Option<Point>>>,
746 minimized: Option<Binding<bool>>,
747 summary: Option<Rc<dyn Fn() -> AnyView>>,
748}
749
750impl fmt::Debug for FloatingPanel {
751 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
752 f.debug_struct("FloatingPanel").field("title", &self.title).finish_non_exhaustive()
753 }
754}
755
756#[cfg_attr(feature = "live", ravenrs_live::expose)]
757impl FloatingPanel {
758 pub fn new(
760 open: Binding<bool>,
761 title: impl Into<String>,
762 content: impl Fn() -> AnyView + 'static,
763 ) -> Self {
764 Self {
765 open,
766 title: title.into(),
767 content: Rc::new(content),
768 width: 400.0,
769 height: 520.0,
770 bottom_inset: 0.0,
771 place: None,
772 minimized: None,
773 summary: None,
774 }
775 }
776
777 #[must_use]
780 pub fn size(mut self, width: f32, height: f32) -> Self {
781 self.width = width;
782 self.height = height;
783 self
784 }
785
786 #[must_use]
789 pub fn bottom_inset(mut self, inset: f32) -> Self {
790 self.bottom_inset = inset;
791 self
792 }
793
794 #[must_use]
799 pub fn movable(mut self, place: Binding<Option<Point>>) -> Self {
800 self.place = Some(place);
801 self
802 }
803
804 #[must_use]
806 pub fn minimized(mut self, minimized: Binding<bool>) -> Self {
807 self.minimized = Some(minimized);
808 self
809 }
810
811 #[must_use]
813 pub fn summary(mut self, summary: impl Fn() -> AnyView + 'static) -> Self {
814 self.summary = Some(Rc::new(summary));
815 self
816 }
817
818 fn is_minimized(&self) -> bool {
819 self.minimized.as_ref().is_some_and(Binding::get)
820 }
821
822 fn header_height(cx: &BuildCx<'_>) -> f32 {
824 ButtonSize::Small.height() + cx.theme().spacing.s4 * 2.0
825 }
826
827 fn surface(&self, cx: &mut BuildCx<'_>, height: f32) -> (NodeId, NodeId) {
830 let theme = *cx.theme();
831 let close = self.open.clone();
832 let minimized = self.is_minimized();
833 let title = util::label(
834 cx,
835 &self.title,
836 TextStyle::Headline,
837 Some(Weight::SEMIBOLD),
838 theme.color(Semantic::Ink),
839 );
840 let x = cx.child(
841 &IconButton::new(IconName::X)
842 .label(cx.labels().close.clone())
843 .size(ButtonSize::Small)
844 .on_click(move || close.set(false)),
845 );
846 let mut header = vec![title];
847 let mut buttons = vec![x];
848 if let Some(reduce) = &self.minimized {
849 let (labels, reduce) = (cx.labels(), reduce.clone());
850 let (icon, label) = if minimized {
851 (IconName::Maximize2, labels.panel_restore.clone())
852 } else {
853 (IconName::Minus, labels.panel_minimize.clone())
854 };
855 buttons.insert(
856 0,
857 cx.keyed_child(
858 PANEL_REDUCE_KEY,
859 &IconButton::new(icon)
860 .label(label)
861 .size(ButtonSize::Small)
862 .on_click(move || reduce.set(!reduce.get())),
863 ),
864 );
865 if let Some(summary) = self.summary.as_ref().filter(|_| minimized) {
866 let summary = summary();
867 let summary = cx.keyed_child(PANEL_SUMMARY_KEY, summary.as_ref());
868 header.push(summary);
869 }
870 let title_side = cx.container(
871 LayoutStyle {
872 spacing: theme.spacing.s2,
873 align: Align::Center,
874 ..LayoutStyle::stack(Axis::Horizontal)
875 },
876 &header,
877 Paint::None,
878 );
879 header = vec![title_side];
880 let buttons_side = cx.container(
881 LayoutStyle { spacing: theme.spacing.s1, ..LayoutStyle::stack(Axis::Horizontal) },
882 &buttons,
883 Paint::None,
884 );
885 buttons = vec![buttons_side];
886 }
887 header.append(&mut buttons);
888 let header = cx.container(
889 LayoutStyle {
890 justify: Justify::SpaceBetween,
891 align: Align::Center,
892 ..LayoutStyle::stack(Axis::Horizontal)
893 },
894 &header,
895 Paint::None,
896 );
897 let mut children = vec![header];
898 if !minimized {
899 let content = (self.content)();
900 children.push(cx.child(content.as_ref()));
901 }
902 let node = cx.container(
903 LayoutStyle {
904 width: Some(self.width),
905 height: Some(if minimized { Self::header_height(cx) } else { height }),
906 spacing: theme.spacing.s2,
907 padding: Insets::uniform(theme.spacing.s4),
908 align: Align::Stretch,
909 overflow_hidden: true,
910 ..LayoutStyle::stack(Axis::Vertical)
911 },
912 &children,
913 Paint::Quad(QuadPaint {
914 corner_radius: theme.radii.lg,
915 border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
916 shadow: Some(theme.elevation.raised),
917 ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
918 }),
919 );
920 cx.semantics(node, Semantics::new(Role::Group).name(self.title.clone()));
921 cx.motion(
922 node,
923 Motion::new()
924 .enter(Effect::moved(0.0, 12.0), theme.motion.gentle)
925 .exit(Effect::moved(0.0, 12.0), theme.motion.fast),
926 );
927 (node, header)
928 }
929}
930
931const PANEL_REDUCE_KEY: u64 = 2;
933const PANEL_SUMMARY_KEY: u64 = 3;
935pub(crate) const PANEL_MARGIN: f32 = 4.0;
937
938pub(crate) fn panel_inside(p: Point, largest: Point) -> Point {
940 Point::new(p.x.clamp(PANEL_MARGIN, largest.x), p.y.clamp(PANEL_MARGIN, largest.y))
941}
942
943const PANEL_STEP: f32 = 16.0;
945
946pub(crate) fn move_by_handle(
950 cx: &mut BuildCx<'_>,
951 handle: NodeId,
952 at: Point,
953 largest: Point,
954 place: &Binding<Option<Point>>,
955 dragging: &State<Option<Point>>,
956) {
957 let keep_in = move |p: Point| panel_inside(p, largest);
958 let grab = cx.use_state(|| Rc::new(Cell::new(Point::ZERO))).get();
959 let (dropped, dragging) = (place.clone(), dragging.clone());
960 cx.on_drag(
961 handle,
962 Rc::new(move |drag: Drag| match drag.phase {
963 DragPhase::Start => grab.set(Point::new(drag.position.x - at.x, drag.position.y - at.y)),
964 DragPhase::Move => {
965 let from = grab.get();
966 dragging.set(Some(keep_in(Point::new(drag.position.x - from.x, drag.position.y - from.y))));
967 }
968 DragPhase::End => {
969 if let Some(last) = *dragging.peek() {
970 dropped.set(Some(last));
971 }
972 dragging.set(None);
973 }
974 }),
975 );
976 let place = place.clone();
977 cx.on_key(
978 handle,
979 Rc::new(move |key, modifiers| {
980 let far = if modifiers.shift { PANEL_STEP * 4.0 } else { PANEL_STEP };
981 let (dx, dy) = match key {
982 Key::ArrowLeft => (-far, 0.0),
983 Key::ArrowRight => (far, 0.0),
984 Key::ArrowUp => (0.0, -far),
985 Key::ArrowDown => (0.0, far),
986 _ => return false,
987 };
988 place.set(Some(keep_in(Point::new(at.x + dx, at.y + dy))));
989 true
990 }),
991 );
992}
993
994struct FreeLayer<'a> {
996 panel: &'a FloatingPanel,
997 height: f32,
998 at: Point,
1000 largest: Point,
1001 place: Binding<Option<Point>>,
1002 dragging: State<Option<Point>>,
1003}
1004
1005impl View for FreeLayer<'_> {
1006 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1007 let (node, header) = self.panel.surface(cx, self.height);
1008 move_by_handle(cx, header, self.at, self.largest, &self.place, &self.dragging);
1009 let labels = cx.labels();
1010 cx.semantics(
1011 header,
1012 Semantics::new(Role::Button).name(format!("{}: {}", labels.panel_move, self.panel.title)),
1013 );
1014 cx.focusable(header, cx.theme().radii.sm);
1015 cx.cursor(header, CursorIcon::Pointer);
1016 node
1017 }
1018}
1019
1020const ORB_BOX: f32 = 60.0;
1022const ORB_SPHERE: f32 = 42.0;
1024const ORB_MARGIN: f32 = (ORB_BOX - ORB_SPHERE) / 2.0;
1026const ORB_EDGE: f32 = 15.0;
1028const ORB_DRAG_FROM: f32 = 5.0;
1030const ORB_REST: f64 = 0.8;
1032
1033#[derive(Clone, Copy)]
1035struct OrbColors {
1036 surface: Color,
1037 border: Color,
1038 accent: Color,
1039}
1040
1041fn paint_orb(scene: &mut Scene, frame: Rect, time: f64, colors: OrbColors, moving: bool) {
1045 let center = frame.center();
1046 let radius = ORB_SPHERE / 2.0;
1047 let circle = |x: f32, y: f32, r: f32| Rect::new(x - r, y - r, r * 2.0, r * 2.0);
1048
1049 for ring in 0..4 {
1051 let ring = ring as f32;
1052 let r = radius + 1.0 + ring * 1.4;
1053 let shadow = Color::BLACK.with_alpha(0.07 - ring * 0.015);
1054 scene.push_quad(Quad::new(circle(center.x, center.y + 2.0, r), shadow).radius(r));
1055 }
1056 if moving {
1058 let progress = (time as f32 / 1.8).fract();
1059 let r = radius + 1.0 + progress * 7.0;
1060 scene.push_quad(Quad {
1061 border: Some(Border { width: 1.5, color: colors.accent.with_alpha((1.0 - progress) * 0.5) }),
1062 ..Quad::new(circle(center.x, center.y, r), Color::TRANSPARENT).radius(r)
1063 });
1064 }
1065 scene.push_quad(Quad {
1066 border: Some(Border { width: 1.0, color: colors.border }),
1067 ..Quad::new(circle(center.x, center.y, radius), colors.surface).radius(radius)
1068 });
1069}
1070
1071struct Orb {
1073 title: String,
1074 open: Binding<bool>,
1075 corner: State<FloatingCorner>,
1076 dragging: State<Option<Point>>,
1077}
1078
1079impl View for Orb {
1080 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1081 let theme = *cx.theme();
1082 let colors = OrbColors {
1083 surface: theme.color(Semantic::SurfaceElevated),
1084 border: theme.color(Semantic::Border),
1085 accent: theme.color(Semantic::Accent),
1086 };
1087 let moving = cx.is_hovered() || cx.is_focus_visible() || self.dragging.peek().is_some();
1088 let paint = if moving {
1089 Paint::Live(Rc::new(move |frame: Rect, time, scene: &mut Scene| {
1090 paint_orb(scene, frame, time, colors, true);
1091 }))
1092 } else {
1093 Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
1094 paint_orb(scene, frame, ORB_REST, colors, false);
1095 }))
1096 };
1097 let icon = cx.child(&crate::Icon::new(IconName::Sparkles).color(Semantic::Accent));
1098 let node = cx.container(
1099 LayoutStyle {
1100 width: Some(ORB_BOX),
1101 height: Some(ORB_BOX),
1102 align: Align::Center,
1103 justify: Justify::Center,
1104 ..LayoutStyle::stack(Axis::Vertical)
1105 },
1106 &[icon],
1107 paint,
1108 );
1109 cx.semantics(node, Semantics::new(Role::Button).name(self.title.clone()).expanded(self.open.get()));
1110 let pressed = cx.use_state(|| Rc::new(Cell::new(None::<Point>))).get();
1113 let dragged = cx.use_state(|| Rc::new(Cell::new(false))).get();
1114 let (toggle, was_dragged) = (self.open.clone(), dragged.clone());
1115 cx.on_click(
1116 node,
1117 Rc::new(move || {
1118 if !was_dragged.replace(false) {
1119 toggle.update(|open| *open = !*open);
1120 }
1121 }),
1122 );
1123 cx.focusable(node, ORB_BOX / 2.0);
1124 cx.cursor(node, CursorIcon::Pointer);
1125 let (corner, dragging, window) = (self.corner.clone(), self.dragging.clone(), cx.viewport());
1126 cx.on_drag(
1127 node,
1128 Rc::new(move |drag: Drag| match drag.phase {
1129 DragPhase::Start => {
1130 pressed.set(Some(drag.position));
1131 dragged.set(false);
1132 }
1133 DragPhase::Move => {
1134 let far = pressed.get().is_some_and(|from| {
1135 (drag.position.x - from.x).hypot(drag.position.y - from.y) > ORB_DRAG_FROM
1136 });
1137 if far || dragged.get() {
1138 dragged.set(true);
1139 dragging.set(Some(drag.position));
1140 }
1141 }
1142 DragPhase::End => {
1143 if dragged.get() {
1144 corner.set(FloatingCorner::nearest(drag.position, window));
1145 dragging.set(None);
1146 }
1147 }
1148 }),
1149 );
1150 let corner = self.corner.clone();
1151 cx.on_key(
1152 node,
1153 Rc::new(move |key, _| {
1154 let next = corner.peek().moved(key);
1155 next.map(|next| corner.set(next)).is_some()
1156 }),
1157 );
1158 node
1159 }
1160}
1161
1162struct Layer<'a> {
1164 panel: &'a FloatingPanel,
1165 corner: FloatingCorner,
1166 height: f32,
1167 orb: Orb,
1168}
1169
1170impl View for Layer<'_> {
1171 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1172 let panel = self.panel;
1173 let orb = cx.keyed_child(ORB_KEY, &self.orb);
1174 let showing = panel.open.get() && self.orb.dragging.peek().is_none();
1175 let mut children = vec![orb];
1176 if showing {
1177 let (node, _) = panel.surface(cx, self.height);
1178 let (left, right) = if self.corner.is_right() { (0.0, ORB_MARGIN) } else { (ORB_MARGIN, 0.0) };
1179 let node = cx.container(
1180 LayoutStyle {
1181 padding: Insets { top: 0.0, bottom: 0.0, left, right },
1182 ..LayoutStyle::stack(Axis::Vertical)
1183 },
1184 &[node],
1185 Paint::None,
1186 );
1187 if self.corner.is_bottom() {
1189 children.insert(0, node);
1190 } else {
1191 children.push(node);
1192 }
1193 }
1194 cx.container(
1195 LayoutStyle {
1196 spacing: PANEL_GAP,
1197 align: if self.corner.is_right() { Align::End } else { Align::Start },
1198 ..LayoutStyle::stack(Axis::Vertical)
1199 },
1200 &children,
1201 Paint::None,
1202 )
1203 }
1204}
1205
1206const ORB_KEY: u64 = 1;
1208const PANEL_GAP: f32 = 4.0;
1209
1210impl View for FloatingPanel {
1211 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
1212 let anchor = cx.empty();
1213 let corner = cx.use_state(FloatingCorner::default);
1214 let dragging: State<Option<Point>> = cx.use_state(|| None);
1215 let open = self.open.get();
1216 if open {
1217 let open = self.open.clone();
1218 cx.shortcut(Key::Escape, false, Rc::new(move || open.set(false)));
1219 }
1220 let window = cx.viewport();
1221 if let Some(place) = &self.place {
1222 if open {
1223 let free = (window.height - self.bottom_inset - ORB_EDGE * 2.0).max(0.0);
1224 let height = self.height.min(free);
1225 let tall = if self.is_minimized() { Self::header_height(cx) } else { height };
1226 let largest = Point::new(
1227 (window.width - self.width - PANEL_MARGIN).max(PANEL_MARGIN),
1228 (window.height - self.bottom_inset - tall - PANEL_MARGIN).max(PANEL_MARGIN),
1229 );
1230 let corner = Point::new(
1231 (largest.x - ORB_EDGE).max(PANEL_MARGIN),
1232 (largest.y - ORB_EDGE).max(PANEL_MARGIN),
1233 );
1234 let wanted = dragging.get().or_else(|| place.get()).unwrap_or(corner);
1235 let at = panel_inside(wanted, largest);
1236 let layer = FreeLayer { panel: self, height, at, largest, place: place.clone(), dragging };
1237 cx.floating_overlay(anchor, Placement::At(at), &layer);
1238 }
1239 return anchor;
1240 }
1241 let here = corner.get();
1242 let held = dragging.get();
1243 let free = window.height - self.bottom_inset - ORB_BOX - PANEL_GAP - ORB_EDGE * 2.0;
1244 let height = self.height.min(free.max(0.0));
1245 let body = if self.is_minimized() { Self::header_height(cx) } else { height };
1246 let tall = if open && held.is_none() { body + PANEL_GAP + ORB_BOX } else { ORB_BOX };
1247 let wide = if open && held.is_none() { (self.width + ORB_MARGIN).max(ORB_BOX) } else { ORB_BOX };
1248 let place = held.map_or_else(
1249 || {
1250 Point::new(
1251 if here.is_right() { window.width - wide - ORB_EDGE } else { ORB_EDGE },
1252 if here.is_bottom() {
1253 window.height - self.bottom_inset - tall - ORB_EDGE
1254 } else {
1255 ORB_EDGE
1256 },
1257 )
1258 },
1259 |pointer| Point::new(pointer.x - ORB_BOX / 2.0, pointer.y - ORB_BOX / 2.0),
1260 );
1261 let layer = Layer {
1262 panel: self,
1263 corner: here,
1264 height,
1265 orb: Orb { title: self.title.clone(), open: self.open.clone(), corner, dragging },
1266 };
1267 cx.floating_overlay(anchor, Placement::At(place), &layer);
1268 anchor
1269 }
1270}