1use 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
19pub(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
24struct SendOptions {
26 options: Vec<String>,
27 chosen: usize,
28 on_choose: Rc<dyn Fn(usize)>,
29}
30
31#[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 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 #[must_use]
59 pub fn size(mut self, bytes: u64) -> Self {
60 self.size = Some(bytes);
61 self
62 }
63
64 #[must_use]
66 pub fn icon(mut self, icon: IconName) -> Self {
67 self.icon = icon;
68 self
69 }
70
71 #[must_use]
73 pub fn thumbnail(mut self, image: ImageData) -> Self {
74 self.thumbnail = Some(image);
75 self
76 }
77
78 #[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#[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 pub fn new(attachments: impl IntoIterator<Item = Attachment>) -> Self {
131 Self { attachments: attachments.into_iter().collect(), on_remove: None }
132 }
133
134 #[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
142struct 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 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 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#[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 pub fn new(count: usize) -> Self {
276 Self { count, hint: None, on_exit: None, actions: Toolbar::new().aligned_end() }
277 }
278
279 #[must_use]
282 pub fn hint(mut self, hint: impl Into<String>) -> Self {
283 self.hint = Some(hint.into());
284 self
285 }
286
287 #[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 #[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
344fn 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#[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 pub fn new() -> Self {
405 Self::default()
406 }
407
408 #[must_use]
410 pub fn clip(mut self, clip: SoundClip) -> Self {
411 self.clip = Some(clip);
412 self
413 }
414
415 #[must_use]
417 pub fn seconds(mut self, seconds: f32) -> Self {
418 self.seconds = Some(seconds);
419 self
420 }
421
422 #[must_use]
424 pub fn loading(mut self, loading: bool) -> Self {
425 self.loading = loading;
426 self
427 }
428
429 #[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 #[must_use]
441 pub fn transcript(mut self, text: impl Into<String>) -> Self {
442 self.transcript = Some(text.into());
443 self
444 }
445
446 #[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 #[must_use]
455 pub fn transcribing(mut self, transcribing: bool) -> Self {
456 self.transcribing = transcribing;
457 self
458 }
459
460 #[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 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 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 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 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 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}