Skip to main content

ravenrs_widgets/
chat.rs

1//! A conversation with a field to write in, and a panel that floats over an app.
2
3use 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
26/// The widest a message gets, in logical pixels.
27const MESSAGE_WIDTH: f32 = 320.0;
28/// The narrowest a recording gets in a message.
29const MEDIA_AUDIO_WIDTH: f32 = 240.0;
30
31/// What a [`ChatMessage`](crate::ChatMessage) carries in its bubble, above its text: a photo,
32/// a document or a recording. The file stays with the app, which asks to open it from
33/// [`Self::on_open`].
34///
35/// ```
36/// use ravenrs_reactive::State;
37/// use ravenrs_widgets::{Chat, ChatMedia, ChatMessage, Ui};
38///
39/// let messages = State::new(vec![
40///     ChatMessage::person("The *signed* contract")
41///         .formatted()
42///         .media(ChatMedia::document("contract.pdf").size(240_000).on_open(|| {})),
43/// ]);
44/// let mut ui = Ui::new(Chat::new(messages.binding()), ravenrs_style::Theme::raven_dark());
45/// ui.update();
46/// ```
47#[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    /// A photo. While it is `None` it holds a space of the same size, so that what is above
75    /// does not jump when it arrives.
76    pub fn photo(image: Option<ImageData>) -> Self {
77        Self { kind: MediaKind::Photo { image, side: 240.0, alt: None }, on_open: None }
78    }
79
80    /// A file shown as a card, with its name.
81    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    /// A recording. The player is built each time the message is shown, so that the clip
89    /// or the transcript that arrives later is there.
90    pub fn audio(player: impl Fn() -> AudioPlayerButton + 'static) -> Self {
91        Self { kind: MediaKind::Audio(Rc::new(player)), on_open: None }
92    }
93
94    /// The side of a photo, which is shown square (240 logical pixels by default).
95    #[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    /// What a photo shows, for screen readers.
104    #[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    /// The size of a document in bytes.
113    #[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    /// The icon of a document ([`IconName::FileText`] by default).
122    #[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    /// Makes a photo or a document open with a click, Enter or Space: for instance in an
131    /// [`ImageLightbox`](crate::ImageLightbox), or through the system.
132    #[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                // A bubble fits what is inside, and a progress bar has no width of its own.
147                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/// Who wrote a [`ChatMessage`].
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252#[non_exhaustive]
253#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
254pub enum ChatAuthor {
255    /// The person at the screen.
256    Person,
257    /// Whatever answers them.
258    Assistant,
259}
260
261/// One message of a [`Chat`].
262#[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    /// What the person wrote.
284    pub fn person(text: impl Into<String>) -> Self {
285        Self::of(ChatAuthor::Person, Arc::new(Mutex::new(text.into())))
286    }
287
288    /// What the assistant answered.
289    pub fn assistant(text: impl Into<String>) -> Self {
290        Self::of(ChatAuthor::Assistant, Arc::new(Mutex::new(text.into())))
291    }
292
293    /// What the assistant is still answering. Whoever answers writes to `text`, from
294    /// any thread, and asks for a rebuild with a [`UiWaker`](crate::UiWaker). While it
295    /// is empty, the message shows that it is on its way.
296    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    /// A photo, a document or a recording in the bubble, above the text, which may be empty.
305    #[must_use]
306    pub fn media(mut self, media: ChatMedia) -> Self {
307        self.media = Some(media);
308        self
309    }
310
311    /// Reads its text as a [`RichText`], with marks and links.
312    #[must_use]
313    pub fn formatted(mut self) -> Self {
314        self.formatted = true;
315        self
316    }
317
318    /// Something to act on under the text, such as buttons. It is built each time
319    /// the message is shown.
320    #[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    /// Who wrote it.
327    pub fn author(&self) -> ChatAuthor {
328        self.author
329    }
330
331    /// What it says.
332    pub fn text(&self) -> String {
333        self.text.lock().map(|text| text.clone()).unwrap_or_default()
334    }
335}
336
337/// A conversation over a field to write in. It fills what its parent gives it and
338/// follows the newest message.
339///
340/// It does not know who answers: `on_send` gets what the person wrote, already in
341/// the list, and whoever answers adds to `messages`, now or later.
342///
343/// ```
344/// use ravenrs_reactive::State;
345/// use ravenrs_widgets::{Chat, ChatMessage};
346///
347/// let messages = State::new(Vec::<ChatMessage>::new());
348/// let answers = messages.clone();
349/// let chat = Chat::new(messages.binding())
350///     .placeholder("Ask something")
351///     .examples(["How do I start?"])
352///     .on_send(move |_text| answers.update(|m| m.push(ChatMessage::assistant("Hello!"))));
353/// ```
354#[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    /// A conversation over `messages`.
375    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    /// Muted text inside the empty field.
389    #[must_use]
390    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
391        self.placeholder = text.into();
392        self
393    }
394
395    /// What it says before the first message.
396    #[must_use]
397    pub fn intro(mut self, text: impl Into<String>) -> Self {
398        self.intro = Some(text.into());
399        self
400    }
401
402    /// Things to ask, offered as buttons before the first message.
403    #[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    /// Whether an answer is on its way: nothing is sent until it arrives, and what is
410    /// being written stays in the field.
411    #[must_use]
412    pub fn busy(mut self, busy: bool) -> Self {
413        self.busy = busy;
414        self
415    }
416
417    /// Puts a button to hear each answer, which `handler` gets as text.
418    #[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    /// Called with the address of a link in a [`ChatMessage::formatted`] message.
425    #[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    /// Called with what the person sent, after it joined the messages.
432    #[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/// Adds what was written to the messages and passes it on.
440#[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
463/// The messages, scrolled to the newest when one arrives.
464struct 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                // The right side leaves room for the scrollbar.
493                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            // Clamped to the content, which is not laid out yet.
507            cx.set_scroll_offset(Point::new(0.0, f32::MAX));
508        }
509        node
510    }
511}
512
513/// One message.
514struct 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            // What someone asked is set apart; the answer is the page itself.
553            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        // A card takes the whole width, outside the bubble: it has its own surface.
563        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            // Announced as it appears.
588            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/// A corner of the window, where a [`FloatingPanel`] rests.
665#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
666#[non_exhaustive]
667#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
668pub enum FloatingCorner {
669    /// Top left.
670    TopLeft,
671    /// Top right.
672    TopRight,
673    /// Bottom left.
674    BottomLeft,
675    /// Bottom right.
676    #[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    /// The corner nearest to a point of a window of this size.
700    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    /// The corner an arrow key leads to, if it leads anywhere.
705    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/// A round button in the corner of the window that opens a panel over the app.
719/// The app stays usable behind it, and Escape or the sphere closes it. Dragging it
720/// or pressing an arrow key sends it to another corner, and it stays there while
721/// the app runs.
722///
723/// It moves while the pointer or the focus is on it. The rest of the time it holds
724/// still and asks for no frames, like the rest of the UI.
725///
726/// Whatever must outlive closing it, such as a [`Chat`]'s messages, belongs outside.
727///
728/// ```
729/// use ravenrs_reactive::State;
730/// use ravenrs_widgets::{FloatingPanel, Text};
731///
732/// let open = State::new(false);
733/// let panel = FloatingPanel::new(open.binding(), "Assistant", || {
734///     Box::new(Text::new("Hello"))
735/// });
736/// ```
737#[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    /// A closed panel called `title`, the name screen readers give its sphere.
759    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    /// Its size in logical pixels (400 by 520 by default). A smaller window gets
778    /// less height.
779    #[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    /// Keeps it this far, in logical pixels, from the bottom of the window, to clear
787    /// something fixed there, such as a [`StatusBar`](crate::StatusBar).
788    #[must_use]
789    pub fn bottom_inset(mut self, inset: f32) -> Self {
790        self.bottom_inset = inset;
791        self
792    }
793
794    /// Lets it go anywhere in the window, dragged by its header or moved with the arrow
795    /// keys on it, without the sphere: `open` is what opens it. `place` holds its top-left
796    /// corner, so the app can remember it, and is written when a drag ends; `None` puts it
797    /// in the corner at the bottom right. It stays inside a window that gets smaller.
798    #[must_use]
799    pub fn movable(mut self, place: Binding<Option<Point>>) -> Self {
800        self.place = Some(place);
801        self
802    }
803
804    /// Adds a button that reduces it to its header and brings it back, kept in `minimized`.
805    #[must_use]
806    pub fn minimized(mut self, minimized: Binding<bool>) -> Self {
807        self.minimized = Some(minimized);
808        self
809    }
810
811    /// What the header shows while it is reduced, such as how things stand inside.
812    #[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    /// The height of the header alone, the height of a reduced panel.
823    fn header_height(cx: &BuildCx<'_>) -> f32 {
824        ButtonSize::Small.height() + cx.theme().spacing.s4 * 2.0
825    }
826
827    /// The panel's surface, with its header and, unless reduced, its content. The header
828    /// comes back too, for whoever makes it the place to drag from.
829    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
931/// Keeps the reduce button's state while the header changes around it.
932const PANEL_REDUCE_KEY: u64 = 2;
933/// Keeps the state of what a reduced panel shows.
934const PANEL_SUMMARY_KEY: u64 = 3;
935/// How far from the window's edges a layer stays, as every overlay does.
936pub(crate) const PANEL_MARGIN: f32 = 4.0;
937
938/// `p` kept inside the window, given the largest place a panel can have in it.
939pub(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
943/// How far an arrow key moves a panel, in logical pixels.
944const PANEL_STEP: f32 = 16.0;
945
946/// Makes `handle` move something placed anywhere, by dragging and with the arrow keys. While
947/// it is dragged the place stays in `dragging`, and goes to `place` only at the end, since the
948/// app may write it to a file.
949pub(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
994/// A panel placed anywhere, dragged by its header.
995struct FreeLayer<'a> {
996    panel: &'a FloatingPanel,
997    height: f32,
998    /// Where it is now, and the limits of where it can go.
999    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
1020/// The room the sphere and its glow take, in logical pixels.
1021const ORB_BOX: f32 = 60.0;
1022/// The sphere itself.
1023const ORB_SPHERE: f32 = 42.0;
1024/// What surrounds the sphere inside its box: the panel lines up with the sphere, not the box.
1025const ORB_MARGIN: f32 = (ORB_BOX - ORB_SPHERE) / 2.0;
1026/// How far the sphere's box keeps from the edges of the window.
1027const ORB_EDGE: f32 = 15.0;
1028/// How far the pointer has to go for a press to become a drag, in logical pixels.
1029const ORB_DRAG_FROM: f32 = 5.0;
1030/// The moment of its motion it holds when still.
1031const ORB_REST: f64 = 0.8;
1032
1033/// The theme's colors, for painting the sphere.
1034#[derive(Clone, Copy)]
1035struct OrbColors {
1036    surface: Color,
1037    border: Color,
1038    accent: Color,
1039}
1040
1041/// Paints the sphere: a calm disc over the page, leaving the accent to the icon and
1042/// to the screen's one main action. All of it is circles, so it needs nothing from the
1043/// renderer and looks the same in the browser.
1044fn 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    // A soft shadow under it, to lift it from whatever is on the page.
1050    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    // A ring going out, only while the pointer or the focus is on it.
1057    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
1071/// The sphere: a button that can also be dragged to another corner.
1072struct 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        // A drag starts with the press, and the sphere is still under the pointer at
1111        // the release: only moving away from where it was pressed makes it a drag.
1112        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
1162/// The panel and its sphere.
1163struct 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            // The panel opens toward the middle of the window.
1188            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
1206/// Keeps the sphere's state while the panel comes and goes around it.
1207const 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}