Files
Ely-GPUI-Components/src/chat/actions.rs
T

395 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, Focusable, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant, CopyButton, IconButton, ToggleButton, ToggleItem},
forms::{Input, Pick, Run, Submit, TextInput},
primitives::IconName,
theme::{ActiveTheme, ControlSize, TextSize},
typography::{Ellipsis, tabular},
};
type OnRate = Rc<dyn Fn(Option<bool>, &mut Window, &mut App)>;
/// Asks for another answer to the same message.
pub fn regenerate_button(id: impl Into<ElementId>) -> Button {
Button::new(id, "Regenerate")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.icon(IconName::RefreshCw)
}
/// Stops an answer while it streams.
pub fn stop_button(id: impl Into<ElementId>) -> Button {
Button::new(id, "Stop")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.icon(IconName::CircleStop)
}
/// Picks an answer up where it was cut off.
pub fn continue_button(id: impl Into<ElementId>) -> Button {
Button::new(id, "Continue")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.icon(IconName::StepForward)
}
/// A message quoted in a reply: a rule, who wrote it and the start of what they said; in the composer, a way to take it off.
#[derive(IntoElement)]
pub struct QuoteReply {
id: ElementId,
author: SharedString,
text: SharedString,
on_remove: Option<Run>,
}
impl QuoteReply {
pub fn new(
id: impl Into<ElementId>,
author: impl Into<SharedString>,
text: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
author: author.into(),
text: text.into(),
on_remove: None,
}
}
pub fn on_remove(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for QuoteReply {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let line = self.text.lines().next().unwrap_or_default().to_string();
div()
.flex()
.items_center()
.gap_2()
.pl_3()
.py_1()
.border_l_2()
.border_color(colors.border_strong)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.child(self.author),
)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(Ellipsis::new(line)),
),
)
.children(self.on_remove.map(|remove| {
IconButton::new((self.id.clone(), "remove"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove quote")
.on_click(move |_, window, cx| remove(window, cx))
}))
}
}
/// What can be done with a message, in a quiet row: copy, edit, regenerate, and a good or bad mark; each shows once its handler is set, copy always.
#[derive(IntoElement)]
pub struct MessageActions {
id: ElementId,
text: SharedString,
rating: Option<bool>,
on_edit: Option<Run>,
on_regenerate: Option<Run>,
on_rate: Option<OnRate>,
}
impl MessageActions {
/// `text` is what copy takes.
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
rating: None,
on_edit: None,
on_regenerate: None,
on_rate: None,
}
}
pub fn on_edit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_edit = Some(Rc::new(handler));
self
}
pub fn on_regenerate(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_regenerate = Some(Rc::new(handler));
self
}
/// The mark given, good or bad, and what a press asks: the other mark, or none to take it back.
pub fn rating(
mut self,
rating: Option<bool>,
on_rate: impl Fn(Option<bool>, &mut Window, &mut App) + 'static,
) -> Self {
self.rating = rating;
self.on_rate = Some(Rc::new(on_rate));
self
}
}
impl RenderOnce for MessageActions {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let button = |key: &str, icon: IconName, tip: &'static str, run: Option<Run>| {
run.map(|run| {
IconButton::new((self.id.clone(), key.to_string()), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.on_click(move |_, window, cx| run(window, cx))
})
};
let mark = |good: bool| {
self.on_rate.clone().map(|rate| {
let on = self.rating == Some(good);
let (key, icon, tip) = if good {
("good", IconName::ThumbsUp, "Good answer")
} else {
("bad", IconName::ThumbsDown, "Bad answer")
};
ToggleButton::new(
(self.id.clone(), key),
ToggleItem::new(key).icon(icon).tooltip(tip),
on,
)
.size(ControlSize::Sm)
.on_toggle(move |now, window, cx| {
let next = now.then_some(good);
log::info!("message actions: rated {next:?}");
rate(next, window, cx)
})
})
};
div()
.flex()
.items_center()
.gap_0p5()
.child(CopyButton::new(
(self.id.clone(), "copy"),
self.text.clone(),
))
.children(button(
"edit",
IconName::Pencil,
"Edit",
self.on_edit.clone(),
))
.children(button(
"regenerate",
IconName::RefreshCw,
"Regenerate",
self.on_regenerate.clone(),
))
.children(mark(true))
.children(mark(false))
}
}
/// A sent message opened to change: the field, focused as it opens, then cancel and save. Escape cancels; the submit key saves once the words changed.
#[derive(IntoElement)]
pub struct MessageEditor {
id: ElementId,
field: Entity<TextInput>,
original: SharedString,
on_save: Run,
on_cancel: Run,
}
impl MessageEditor {
/// `field` holds the words being changed, `original` what was sent.
pub fn new(
id: impl Into<ElementId>,
field: &Entity<TextInput>,
original: impl Into<SharedString>,
on_save: impl Fn(&mut Window, &mut App) + 'static,
on_cancel: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
field: field.clone(),
original: original.into(),
on_save: Rc::new(on_save),
on_cancel: Rc::new(on_cancel),
}
}
}
impl RenderOnce for MessageEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let opened = window.use_keyed_state((self.id.clone(), "opened"), cx, |_, _| false);
if !*opened.read(cx) {
log::info!("message editor: takes focus");
window.focus(&self.field.focus_handle(cx), cx);
opened.update(cx, |opened, _| *opened = true);
}
let text = self.field.read(cx).text().trim().to_string();
let changed = !text.is_empty() && text != self.original.trim();
let (save, submit, cancel, escape) = (
self.on_save.clone(),
self.on_save,
self.on_cancel.clone(),
self.on_cancel,
);
div()
.flex()
.flex_col()
.gap_2()
.capture_action(move |_: &Submit, window, cx| {
cx.stop_propagation();
if changed {
log::info!("message editor: saved");
submit(window, cx);
}
})
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
log::info!("message editor: cancelled");
escape(window, cx);
}
})
.child(Input::new(&self.field))
.child(
div()
.flex()
.justify_end()
.gap_2()
.child(
Button::new((self.id.clone(), "cancel"), "Cancel")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| cancel(window, cx)),
)
.child(
Button::new((self.id.clone(), "save"), "Save")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.shortcut("secondary-enter")
.disabled(!changed)
.on_click(move |_, window, cx| {
log::info!("message editor: saved");
save(window, cx)
}),
),
)
}
}
/// Which version of a message shows, among all it has: back, where it stands, forward.
#[derive(IntoElement)]
pub struct BranchNavigator {
id: ElementId,
current: Option<usize>,
total: usize,
on_change: Pick,
}
impl BranchNavigator {
pub fn new(
id: impl Into<ElementId>,
current: usize,
total: usize,
on_change: impl Fn(usize, &mut Window, &mut App) + 'static,
) -> Self {
if current >= total {
log::error!("branch navigator: version {current} of {total}; none shown");
}
Self {
id: id.into(),
current: (current < total).then_some(current),
total,
on_change: Rc::new(on_change),
}
}
}
impl RenderOnce for BranchNavigator {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (current, total) = (self.current, self.total);
let step = |key: &str, icon: IconName, tip: &'static str, to: Option<usize>| {
let change = self.on_change.clone();
IconButton::new((self.id.clone(), key.to_string()), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.disabled(to.is_none())
.when_some(to, |button, to| {
button.on_click(move |_, window, cx| {
log::info!("branch navigator: version {to}");
change(to, window, cx)
})
})
};
div()
.flex()
.items_center()
.child(step(
"back",
IconName::ChevronLeft,
"Previous version",
current.and_then(|current| current.checked_sub(1)),
))
.child(
tabular(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_muted),
)
.child(match current {
Some(current) => format!("{} / {total}", current + 1),
None => format!("– / {total}"),
}),
)
.child(step(
"forward",
IconName::ChevronRight,
"Next version",
current.and_then(|current| (current + 1 < total).then_some(current + 1)),
))
}
}
#[cfg(test)]
mod tests {
use super::BranchNavigator;
#[test]
fn a_version_past_the_branches_shows_none() {
let navigator =
|current, total| BranchNavigator::new("branch", current, total, |_, _, _| {});
assert_eq!(navigator(1, 3).current, Some(1));
assert_eq!(navigator(3, 3).current, None);
assert_eq!(navigator(0, 0).current, None);
}
}