Skip to main content

ravenrs_widgets/
conversation.rs

1//! Pieces of a conversation: files on their way, a recording to hear, and what to do with
2//! a selection.
3
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Color, ImageData, Insets, Key, Quad, Rect, Role, Scene};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
13use crate::files::size_text;
14use crate::{
15    Button, ButtonSize, ButtonStyle, DropdownMenu, Icon, IconButton, IconName, Image, MenuItem, SoundClip,
16    Text, Toolbar, ToolbarAction, View, ViewExt,
17};
18
19/// The name of a file with its size: what a row of a file says first.
20pub(crate) fn file_name_and_size(name: &str, size: Option<u64>) -> String {
21    size.map_or_else(|| name.to_owned(), |size| format!("{name}, {}", size_text(size)))
22}
23
24/// How a file can be sent, for an [`Attachment`].
25struct SendOptions {
26    options: Vec<String>,
27    chosen: usize,
28    on_choose: Rc<dyn Fn(usize)>,
29}
30
31/// A file in an [`AttachmentStrip`].
32#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
33pub struct Attachment {
34    name: String,
35    size: Option<u64>,
36    icon: IconName,
37    thumbnail: Option<ImageData>,
38    send_as: Option<SendOptions>,
39}
40
41impl fmt::Debug for Attachment {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.debug_struct("Attachment")
44            .field("name", &self.name)
45            .field("size", &self.size)
46            .finish_non_exhaustive()
47    }
48}
49
50#[cfg_attr(feature = "live", ravenrs_live::expose)]
51impl Attachment {
52    /// A file called `name`.
53    pub fn new(name: impl Into<String>) -> Self {
54        Self { name: name.into(), size: None, icon: IconName::File, thumbnail: None, send_as: None }
55    }
56
57    /// Its size in bytes, which is shown in the units people read.
58    #[must_use]
59    pub fn size(mut self, bytes: u64) -> Self {
60        self.size = Some(bytes);
61        self
62    }
63
64    /// The icon before its name ([`IconName::File`] by default).
65    #[must_use]
66    pub fn icon(mut self, icon: IconName) -> Self {
67        self.icon = icon;
68        self
69    }
70
71    /// A small picture in place of the icon.
72    #[must_use]
73    pub fn thumbnail(mut self, image: ImageData) -> Self {
74        self.thumbnail = Some(image);
75        self
76    }
77
78    /// The ways this file can be sent, such as a photo or a document, with `chosen` selected.
79    /// `on_choose` gets the index of another one. With fewer than two there is nothing to
80    /// ask, and the row does not ask.
81    #[must_use]
82    pub fn send_as(
83        mut self,
84        options: impl IntoIterator<Item = impl Into<String>>,
85        chosen: usize,
86        on_choose: impl Fn(usize) + 'static,
87    ) -> Self {
88        self.send_as = Some(SendOptions {
89            options: options.into_iter().map(Into::into).collect(),
90            chosen,
91            on_choose: Rc::new(on_choose),
92        });
93        self
94    }
95}
96
97/// The files about to go with a message: icon or thumbnail, name, size, how to send each one
98/// and a button to take it out. It keeps nothing: the app holds the list and changes it when
99/// told.
100///
101/// ```
102/// use ravenrs_reactive::State;
103/// use ravenrs_widgets::{Attachment, AttachmentStrip, Ui};
104///
105/// let removed = State::new(None::<usize>);
106/// let told = removed.clone();
107/// let strip = AttachmentStrip::new([
108///     Attachment::new("report.pdf").size(120_000),
109///     Attachment::new("photo.jpg").size(2_400_000).send_as(["photo", "document"], 0, |_| {}),
110/// ])
111/// .on_remove(move |index| told.set(Some(index)));
112/// let mut ui = Ui::new(strip, ravenrs_style::Theme::raven_dark());
113/// ui.update();
114/// ```
115#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
116pub struct AttachmentStrip {
117    attachments: Vec<Attachment>,
118    on_remove: Option<Rc<dyn Fn(usize)>>,
119}
120
121impl fmt::Debug for AttachmentStrip {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.debug_struct("AttachmentStrip").field("attachments", &self.attachments).finish_non_exhaustive()
124    }
125}
126
127#[cfg_attr(feature = "live", ravenrs_live::expose)]
128impl AttachmentStrip {
129    /// A strip with these files.
130    pub fn new(attachments: impl IntoIterator<Item = Attachment>) -> Self {
131        Self { attachments: attachments.into_iter().collect(), on_remove: None }
132    }
133
134    /// Adds a button to each row, which calls `handler` with its index.
135    #[must_use]
136    pub fn on_remove(mut self, handler: impl Fn(usize) + 'static) -> Self {
137        self.on_remove = Some(Rc::new(handler));
138        self
139    }
140}
141
142/// One row of an [`AttachmentStrip`], with an identity of its own for its menu and button.
143struct AttachmentRow<'a> {
144    attachment: &'a Attachment,
145    index: usize,
146    on_remove: Option<Rc<dyn Fn(usize)>>,
147}
148
149impl View for AttachmentRow<'_> {
150    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
151        let theme = *cx.theme();
152        let labels = cx.labels();
153        let file = self.attachment;
154        let side = theme.text(TextStyle::Body).line_height;
155        let leading = match &file.thumbnail {
156            Some(image) => {
157                cx.child(&Image::new(image.clone()).size(side, side).corner_radius(theme.radii.sm))
158            }
159            None => cx.child(&Icon::new(file.icon).size(16.0).color(theme.color(Semantic::InkMuted))),
160        };
161        // The same room for either, so that the names line up.
162        let leading = cx.container(
163            LayoutStyle {
164                width: Some(side),
165                align: Align::Center,
166                justify: Justify::Center,
167                ..LayoutStyle::stack(Axis::Vertical)
168            },
169            &[leading],
170            Paint::None,
171        );
172        let mut children = vec![leading];
173        children.push(cx.child(&Text::new(file.name.clone()).style(TextStyle::Callout).truncate().grow(1.0)));
174        if let Some(size) = file.size {
175            // It is the name that gives way: half a size says nothing.
176            let (size, attrs) = (size_text(size), crate::util::attrs(&theme, TextStyle::Caption, None));
177            let width = cx.measure_text(&size, &attrs).width;
178            let style = LayoutStyle { min_width: Some(width + 1.0), ..LayoutStyle::leaf() };
179            children.push(cx.text(&size, &attrs, theme.color(Semantic::InkMuted), style));
180        }
181        if let Some(send) = file.send_as.as_ref().filter(|send| send.options.len() > 1) {
182            let chosen = send.options.get(send.chosen).or(send.options.first());
183            let chosen = chosen.map_or("", String::as_str);
184            let mut menu = DropdownMenu::new(labels.attachment_send_as.replace("{what}", chosen))
185                .size(ButtonSize::Mini)
186                .style(ButtonStyle::Secondary);
187            for (i, option) in send.options.iter().enumerate() {
188                let on_choose = send.on_choose.clone();
189                menu = menu.item(
190                    MenuItem::new(option.clone()).checked(i == send.chosen).on_select(move || on_choose(i)),
191                );
192            }
193            children.push(cx.child(&menu));
194        }
195        if let Some(on_remove) = self.on_remove.clone() {
196            let index = self.index;
197            children.push(
198                cx.child(
199                    &IconButton::new(IconName::X)
200                        .label(format!("{} {}", labels.attachment_remove, file.name))
201                        .style(ButtonStyle::Ghost)
202                        .size(ButtonSize::Mini)
203                        .on_click(move || on_remove(index)),
204                ),
205            );
206        }
207        let node = cx.container(
208            LayoutStyle {
209                spacing: theme.spacing.s2,
210                align: Align::Center,
211                padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s2),
212                ..LayoutStyle::stack(Axis::Horizontal)
213            },
214            &children,
215            Paint::None,
216        );
217        cx.semantics(node, Semantics::new(Role::ListItem).name(file_name_and_size(&file.name, file.size)));
218        node
219    }
220}
221
222impl View for AttachmentStrip {
223    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
224        let rows: Vec<NodeId> = self
225            .attachments
226            .iter()
227            .enumerate()
228            .map(|(index, attachment)| {
229                cx.keyed_child(
230                    index as u64,
231                    &AttachmentRow { attachment, index, on_remove: self.on_remove.clone() },
232                )
233            })
234            .collect();
235        let node = cx.container(
236            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
237            &rows,
238            Paint::None,
239        );
240        cx.semantics(node, Semantics::new(Role::List).name(cx.labels().attachments.clone()));
241        node
242    }
243}
244
245/// A bar for what to do with several things chosen at once: how many, a way out and the
246/// actions, which are disabled while nothing is chosen. Escape also leaves. It goes where the
247/// field to write in was, or over a list; the app decides when it shows.
248///
249/// ```
250/// use ravenrs_widgets::{IconName, SelectionBar, ToolbarAction, Ui};
251///
252/// let bar = SelectionBar::new(3)
253///     .on_exit(|| {})
254///     .action(ToolbarAction::new("Copy").icon(IconName::Copy).icon_only().on_click(|| {}));
255/// let mut ui = Ui::new(bar, ravenrs_style::Theme::raven_dark());
256/// ui.update();
257/// ```
258#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
259pub struct SelectionBar {
260    count: usize,
261    hint: Option<String>,
262    on_exit: Option<Rc<dyn Fn()>>,
263    actions: Toolbar,
264}
265
266impl fmt::Debug for SelectionBar {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        f.debug_struct("SelectionBar").field("count", &self.count).finish_non_exhaustive()
269    }
270}
271
272#[cfg_attr(feature = "live", ravenrs_live::expose)]
273impl SelectionBar {
274    /// A bar over `count` things chosen.
275    pub fn new(count: usize) -> Self {
276        Self { count, hint: None, on_exit: None, actions: Toolbar::new().aligned_end() }
277    }
278
279    /// What it says while nothing is chosen, such as how to choose. By default, that nothing
280    /// is.
281    #[must_use]
282    pub fn hint(mut self, hint: impl Into<String>) -> Self {
283        self.hint = Some(hint.into());
284        self
285    }
286
287    /// Adds a button to leave the selection, and makes Escape do the same.
288    #[must_use]
289    pub fn on_exit(mut self, handler: impl Fn() + 'static) -> Self {
290        self.on_exit = Some(Rc::new(handler));
291        self
292    }
293
294    /// Adds an action. Those that do not fit go to a menu, as in any [`Toolbar`].
295    #[must_use]
296    pub fn action(mut self, action: ToolbarAction) -> Self {
297        let action = if self.count == 0 { action.disabled(true) } else { action };
298        self.actions = self.actions.action(action);
299        self
300    }
301}
302
303impl View for SelectionBar {
304    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
305        let theme = *cx.theme();
306        let labels = cx.labels();
307        let mut children = Vec::new();
308        if let Some(on_exit) = &self.on_exit {
309            cx.shortcut(Key::Escape, false, on_exit.clone());
310            let on_exit = on_exit.clone();
311            children.push(
312                cx.child(
313                    &IconButton::new(IconName::X)
314                        .label(labels.selection_exit.clone())
315                        .style(ButtonStyle::Ghost)
316                        .on_click(move || on_exit()),
317                ),
318            );
319        }
320        let says = match self.count {
321            0 => self.hint.clone().unwrap_or_else(|| labels.selection_none.clone()),
322            1 => labels.selection_one.clone(),
323            n => labels.selection_many.replace("{count}", &n.to_string()),
324        };
325        let count = cx.child(&Text::new(says.clone()).style(TextStyle::Callout));
326        cx.semantics(count, Semantics::new(Role::Status).name(says));
327        children.push(count);
328        children.push(cx.child(&self.actions));
329        let node = cx.container(
330            LayoutStyle {
331                spacing: theme.spacing.s2,
332                align: Align::Center,
333                padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s2),
334                ..LayoutStyle::stack(Axis::Horizontal)
335            },
336            &children,
337            Paint::None,
338        );
339        cx.semantics(node, Semantics::new(Role::Group).name(labels.selection_bar.clone()));
340        node
341    }
342}
343
344/// `0:07` or `12:34`.
345fn clock_text(seconds: f32) -> String {
346    let whole = seconds.max(0.0) as u32;
347    format!("{}:{:02}", whole / 60, whole % 60)
348}
349
350fn draw_progress(frame: Rect, fraction: f32, track: Color, fill: Color, scene: &mut Scene) {
351    let radius = frame.height() / 2.0;
352    scene.push_quad(Quad::new(frame, track).radius(radius));
353    if fraction > 0.0 {
354        let done = Rect::new(frame.x(), frame.y(), frame.width() * fraction.min(1.0), frame.height());
355        scene.push_quad(Quad::new(done, fill).radius(radius));
356    }
357}
358
359/// A button that plays a recording and stops it, with its progress and, when there is one, a
360/// transcript to open. Space and Enter work on the button, as on any.
361///
362/// The app delivers the audio as a [`SoundClip`]. It can do it later: with
363/// [`Self::on_request`], the first press asks for it and playing starts when it arrives.
364///
365/// It plays through [`Sound::speak_clip`](crate::Sound::speak_clip), so a new recording or
366/// anything else said by the [`Sound`](crate::Sound) takes its place, and this button goes
367/// back to rest. Progress is by the clock, as the service does not report where it is. The
368/// sound does not stop by itself if the view leaves the screen while playing.
369///
370/// ```
371/// use ravenrs_widgets::{AudioPlayerButton, SoundClip, Ui};
372///
373/// let audio = AudioPlayerButton::new()
374///     .clip(SoundClip::new(vec![0.0; 44_100], 44_100))
375///     .transcript("Your order is ready.");
376/// let mut ui = Ui::new(audio, ravenrs_style::Theme::raven_dark());
377/// ui.update();
378/// ```
379#[derive(Clone, Default)]
380#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
381pub struct AudioPlayerButton {
382    clip: Option<SoundClip>,
383    seconds: Option<f32>,
384    loading: bool,
385    on_request: Option<Rc<dyn Fn()>>,
386    transcript: Option<String>,
387    transcribing: bool,
388    on_transcribe: Option<Rc<dyn Fn()>>,
389    transcript_open: Option<Binding<bool>>,
390}
391
392impl fmt::Debug for AudioPlayerButton {
393    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
394        f.debug_struct("AudioPlayerButton")
395            .field("seconds", &self.length())
396            .field("loading", &self.loading)
397            .finish_non_exhaustive()
398    }
399}
400
401#[cfg_attr(feature = "live", ravenrs_live::expose)]
402impl AudioPlayerButton {
403    /// A player with nothing to play yet.
404    pub fn new() -> Self {
405        Self::default()
406    }
407
408    /// The recording.
409    #[must_use]
410    pub fn clip(mut self, clip: SoundClip) -> Self {
411        self.clip = Some(clip);
412        self
413    }
414
415    /// How long it lasts, to show before the recording arrives.
416    #[must_use]
417    pub fn seconds(mut self, seconds: f32) -> Self {
418        self.seconds = Some(seconds);
419        self
420    }
421
422    /// Whether the recording is on its way, which holds the button until it arrives.
423    #[must_use]
424    pub fn loading(mut self, loading: bool) -> Self {
425        self.loading = loading;
426        self
427    }
428
429    /// Called when someone presses play with no recording here: the app brings it, and it
430    /// plays when it arrives through [`Self::clip`]. Without this or a clip, the button stays
431    /// disabled.
432    #[must_use]
433    pub fn on_request(mut self, handler: impl Fn() + 'static) -> Self {
434        self.on_request = Some(Rc::new(handler));
435        self
436    }
437
438    /// What was said, under a button that shows and hides it. Blank text says there is no
439    /// speech in the recording.
440    #[must_use]
441    pub fn transcript(mut self, text: impl Into<String>) -> Self {
442        self.transcript = Some(text.into());
443        self
444    }
445
446    /// With no transcript, offers a button that asks for one.
447    #[must_use]
448    pub fn on_transcribe(mut self, handler: impl Fn() + 'static) -> Self {
449        self.on_transcribe = Some(Rc::new(handler));
450        self
451    }
452
453    /// Whether a transcript is on its way, which holds that button.
454    #[must_use]
455    pub fn transcribing(mut self, transcribing: bool) -> Self {
456        self.transcribing = transcribing;
457        self
458    }
459
460    /// Keeps whether the transcript is open in the app's hands. By default it starts closed
461    /// and is kept here.
462    #[must_use]
463    pub fn transcript_open(mut self, open: Binding<bool>) -> Self {
464        self.transcript_open = Some(open);
465        self
466    }
467
468    fn length(&self) -> Option<f32> {
469        self.clip.as_ref().map(SoundClip::seconds).or(self.seconds)
470    }
471
472    /// The button to read what was said, or to ask for it.
473    fn transcript_part(&self, cx: &mut BuildCx<'_>) -> Vec<NodeId> {
474        let labels = cx.labels();
475        let Some(transcript) = &self.transcript else {
476            let Some(on_transcribe) = self.on_transcribe.clone() else { return Vec::new() };
477            let says = if self.transcribing {
478                labels.audio_transcribing.clone()
479            } else {
480                labels.audio_transcribe.clone()
481            };
482            return vec![
483                cx.child(
484                    &Button::new(says)
485                        .style(ButtonStyle::Ghost)
486                        .size(ButtonSize::Mini)
487                        .disabled(self.transcribing)
488                        .on_click(move || on_transcribe()),
489                ),
490            ];
491        };
492        let local: State<bool> = cx.use_state(|| false);
493        let open = self.transcript_open.clone().unwrap_or_else(|| local.binding());
494        let is_open = open.get();
495        let toggle = cx.child(
496            &Button::new(labels.audio_transcript.clone())
497                .icon(if is_open { IconName::ChevronDown } else { IconName::ChevronRight })
498                .style(ButtonStyle::Ghost)
499                .size(ButtonSize::Mini)
500                .on_click(move || open.set(!open.get())),
501        );
502        cx.update_semantics(toggle, |s| s.expanded = Some(is_open));
503        let mut parts = vec![toggle];
504        if is_open {
505            let theme = *cx.theme();
506            let said = if transcript.trim().is_empty() {
507                labels.audio_no_speech.clone()
508            } else {
509                transcript.clone()
510            };
511            let bar = cx.container(
512                LayoutStyle { width: Some(2.0), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
513                &[],
514                Paint::Quad(QuadPaint {
515                    corner_radius: 1.0,
516                    ..QuadPaint::new(theme.color(Semantic::Border))
517                }),
518            );
519            let said = cx.child(&Text::new(said).style(TextStyle::Callout).foreground(Semantic::InkMuted));
520            parts.push(cx.container(
521                LayoutStyle {
522                    spacing: theme.spacing.s2,
523                    min_width: Some(0.0),
524                    align_self: Some(Align::Stretch),
525                    ..LayoutStyle::stack(Axis::Horizontal)
526                },
527                &[bar, said],
528                Paint::None,
529            ));
530        }
531        parts
532    }
533}
534
535impl View for AudioPlayerButton {
536    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
537        let theme = *cx.theme();
538        let labels = cx.labels();
539        let sound = cx.sound();
540        let now = cx.now();
541        let wanted: State<bool> = cx.use_state(|| false);
542        let playing: State<Option<(u64, f64)>> = cx.use_state(|| None);
543        let length = self.length().unwrap_or(0.0);
544
545        // A press knows no clock, so it only asks, and playing starts on the build it causes.
546        if wanted.get()
547            && let Some(clip) = &self.clip
548        {
549            wanted.set(false);
550            sound.speak_clip(clip);
551            playing.set(Some((sound.turn(), now)));
552        }
553        let start = playing
554            .get()
555            .filter(|(turn, start)| *turn == sound.turn() && now < start + f64::from(length))
556            .map(|(_, start)| start);
557        if start.is_none() && playing.get().is_some() {
558            playing.set(None);
559        }
560        let waiting = wanted.get() || self.loading;
561
562        let (icon, label) = if start.is_some() {
563            (IconName::Square, labels.audio_stop.clone())
564        } else {
565            (IconName::Play, labels.audio_play.clone())
566        };
567        let request = self.on_request.clone();
568        let has_clip = self.clip.is_some();
569        let (stopper, wish, stopped) = (sound.clone(), wanted.clone(), playing.clone());
570        let is_playing = start.is_some();
571        let button = cx.child(
572            &IconButton::new(icon)
573                .label(label)
574                .style(if is_playing { ButtonStyle::Primary } else { ButtonStyle::Secondary })
575                .disabled(waiting || !(has_clip || request.is_some()))
576                .on_click(move || {
577                    if is_playing {
578                        stopper.stop_speaking();
579                        stopped.set(None);
580                    } else {
581                        wish.set(true);
582                        if !has_clip && let Some(request) = &request {
583                            request();
584                        }
585                    }
586                }),
587        );
588
589        let elapsed = start.map_or(0.0, |start| (now - start) as f32);
590        let (track, fill) = (theme.color(Semantic::Border), theme.color(Semantic::Accent));
591        let paint = match start {
592            // Drawn without rebuilding; reduced motion stops the clock this reads.
593            Some(start) if !cx.reduced_motion() => Paint::Live(Rc::new(move |frame, time, scene| {
594                let fraction = ((time - start) as f32 / length).clamp(0.0, 1.0);
595                draw_progress(frame, fraction, track, fill, scene);
596            })),
597            _ => {
598                let fraction = if length > 0.0 { elapsed / length } else { 0.0 };
599                Paint::Custom(Rc::new(move |frame, scene| draw_progress(frame, fraction, track, fill, scene)))
600            }
601        };
602        let bar = cx.container(
603            LayoutStyle {
604                height: Some(4.0),
605                grow: 1.0,
606                min_width: Some(theme.spacing.s6),
607                align_self: Some(Align::Center),
608                ..LayoutStyle::leaf()
609            },
610            &[],
611            paint,
612        );
613        cx.semantics(
614            bar,
615            Semantics::new(Role::ProgressIndicator).name(labels.audio_player.clone()).numeric(
616                f64::from(elapsed),
617                0.0,
618                f64::from(length),
619                None,
620            ),
621        );
622        if let Some(start) = start {
623            // The time on the label moves by the second, and the end stops it.
624            let next_second = start + f64::from(elapsed.floor() + 1.0);
625            cx.wake_at(next_second.min(start + f64::from(length)));
626        }
627        let says = if waiting {
628            labels.audio_loading.clone()
629        } else if start.is_some() {
630            format!("{} / {}", clock_text(elapsed), clock_text(length))
631        } else {
632            self.length().map_or_else(String::new, clock_text)
633        };
634        let time = cx.child(&Text::new(says).style(TextStyle::Caption).foreground(Semantic::InkMuted));
635        let player = cx.container(
636            LayoutStyle {
637                spacing: theme.spacing.s2,
638                align: Align::Center,
639                align_self: Some(Align::Stretch),
640                ..LayoutStyle::stack(Axis::Horizontal)
641            },
642            &[button, bar, time],
643            Paint::None,
644        );
645        let mut children = vec![player];
646        children.extend(self.transcript_part(cx));
647        cx.container(
648            // Stretching only what asks: a button keeps the size of its words.
649            LayoutStyle {
650                spacing: theme.spacing.s1,
651                align: Align::Start,
652                min_width: Some(0.0),
653                ..LayoutStyle::stack(Axis::Vertical)
654            },
655            &children,
656            Paint::None,
657        )
658    }
659}