T23c: AI chat, input
- PromptInput: attachments and context above the field, the owner's tools and send below; Enter sends, Shift-Enter breaks the line; / offers commands and @ offers context over forms::Suggestions; files dropped on it attach behind a drop veil - AttachmentButton, AttachmentChip with thumbnails and uploads, ContextChips - VoiceInputButton and VoiceWaveform with a red dot and the time - ParameterPanel, SystemPromptEditor, TokenCounter, CostEstimator - forms: a Newline action on Shift-Enter keeps one-line fields to one line; replace_trigger moves beside Suggestions; choose and dropped shared - gallery: composer, pickers and tuning
This commit is contained in:
17 files changed
+1562
-38
No files matched your search
@@ -0,0 +1,202 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ObjectFit, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div, relative,
|
||||
};
|
||||
|
||||
use super::media::file_icon;
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
data_display::Tag,
|
||||
documents::source,
|
||||
forms::{Pick, Run},
|
||||
primitives::{Icon, IconName, Image},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, format::file_size},
|
||||
};
|
||||
|
||||
/// Something attached to a message: its key, name and size, a picture's preview, and how far its upload has come, 0 to 1, while it goes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Attachment {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub bytes: u64,
|
||||
pub preview: Option<SharedString>,
|
||||
pub progress: Option<f32>,
|
||||
}
|
||||
|
||||
/// An attachment: a picture's thumbnail or the file's icon, its name, its size or how far it has gone up, and a way to take it off.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AttachmentChip {
|
||||
id: ElementId,
|
||||
attachment: Attachment,
|
||||
on_remove: Option<Run>,
|
||||
}
|
||||
|
||||
impl AttachmentChip {
|
||||
pub fn new(id: impl Into<ElementId>, attachment: Attachment) -> Self {
|
||||
assert!(
|
||||
attachment
|
||||
.progress
|
||||
.is_none_or(|share| (0.0..=1.0).contains(&share)),
|
||||
"an upload of {:?} lies outside 0 to 1",
|
||||
attachment.progress
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
attachment,
|
||||
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 AttachmentChip {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let side = theme.avatar_size(AvatarSize::Md);
|
||||
let item = self.attachment;
|
||||
let face = match &item.preview {
|
||||
Some(preview) => div()
|
||||
.size(side)
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.sunken)
|
||||
.child(
|
||||
Image::new((self.id.clone(), "preview"), source(preview))
|
||||
.fit(ObjectFit::Cover)
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.size_full(),
|
||||
),
|
||||
None => div()
|
||||
.size(side)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.hover)
|
||||
.child(
|
||||
Icon::new(file_icon(&item.name))
|
||||
.size(IconSize::Md)
|
||||
.color(colors.fg_muted),
|
||||
),
|
||||
};
|
||||
let detail = match item.progress {
|
||||
Some(share) => div()
|
||||
.w_full()
|
||||
.h(theme.progress_thickness())
|
||||
.rounded_full()
|
||||
.bg(colors.hover)
|
||||
.child(
|
||||
div()
|
||||
.h_full()
|
||||
.w(relative(share))
|
||||
.rounded_full()
|
||||
.bg(colors.accent),
|
||||
)
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(file_size(item.bytes, false))
|
||||
.into_any_element(),
|
||||
};
|
||||
div()
|
||||
.min_w_0()
|
||||
.max_w(theme.label_width() * 1.4)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.p_1p5()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.child(face)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(Ellipsis::new(item.name.clone())),
|
||||
)
|
||||
.child(detail),
|
||||
)
|
||||
.children(self.on_remove.map(|remove| {
|
||||
IconButton::new((self.id.clone(), "remove"), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Remove")
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("attachment: removed");
|
||||
remove(window, cx)
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Context given to a message, such as files, pages and a selection: each a tag with its icon, to take off.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ContextChips {
|
||||
id: ElementId,
|
||||
items: Vec<(SharedString, IconName)>,
|
||||
on_remove: Option<Pick>,
|
||||
}
|
||||
|
||||
impl ContextChips {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
items: impl IntoIterator<Item = (impl Into<SharedString>, IconName)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|(label, icon)| (label.into(), icon))
|
||||
.collect(),
|
||||
on_remove: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the index of the context to take off.
|
||||
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_remove = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ContextChips {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(
|
||||
self.items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (label, icon))| {
|
||||
let tag =
|
||||
Tag::new((self.id.clone(), format!("context-{ix}")), label).icon(icon);
|
||||
match self.on_remove.clone() {
|
||||
Some(remove) => tag.on_remove(move |window, cx| {
|
||||
log::info!("context chips: removed {ix}");
|
||||
remove(ix, window, cx)
|
||||
}),
|
||||
None => tag,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
use std::{path::PathBuf, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, ExternalPaths, InteractiveElement, IntoElement,
|
||||
ParentElement, PathPromptOptions, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
forms::{
|
||||
Choice, Enter, OnValue, Pick, Run, Suggestions, TextInput, active_trigger, choose, dropped,
|
||||
replace_trigger,
|
||||
},
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Kbd,
|
||||
};
|
||||
|
||||
type OnPaths = Rc<dyn Fn(Vec<PathBuf>, &mut Window, &mut App)>;
|
||||
|
||||
/// Opens the system's file dialog to attach files; `on_pick` gets what was chosen.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AttachmentButton {
|
||||
id: ElementId,
|
||||
on_pick: OnPaths,
|
||||
}
|
||||
|
||||
impl AttachmentButton {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
on_pick: impl Fn(Vec<PathBuf>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
on_pick: Rc::new(on_pick),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AttachmentButton {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let pick = self.on_pick;
|
||||
IconButton::new(self.id, IconName::Paperclip)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Attach files")
|
||||
.on_click(move |_, window, cx| {
|
||||
let pick = pick.clone();
|
||||
let options = PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: true,
|
||||
prompt: Some("Attach".into()),
|
||||
};
|
||||
choose(
|
||||
"attachment button".into(),
|
||||
options,
|
||||
window,
|
||||
cx,
|
||||
move |paths, window, cx| pick(paths, window, cx),
|
||||
);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a message once there is one, or stops the answer while it streams.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SendButton {
|
||||
id: ElementId,
|
||||
ready: bool,
|
||||
on_send: Run,
|
||||
on_stop: Option<Run>,
|
||||
}
|
||||
|
||||
impl SendButton {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
ready: bool,
|
||||
on_send: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
ready,
|
||||
on_send: Rc::new(on_send),
|
||||
on_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// While an answer streams: the button stops it.
|
||||
pub fn busy(mut self, on_stop: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_stop = Some(Rc::new(on_stop));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SendButton {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
match self.on_stop {
|
||||
Some(stop) => IconButton::new(self.id, IconName::Square)
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Stop")
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("send button: stop");
|
||||
stop(window, cx)
|
||||
}),
|
||||
None => {
|
||||
let send = self.on_send;
|
||||
IconButton::new(self.id, IconName::ArrowUp)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Send")
|
||||
.disabled(!self.ready)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("send button: send");
|
||||
send(window, cx)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys a composer takes, said quietly: Enter sends, Shift-Enter breaks the line.
|
||||
#[derive(IntoElement)]
|
||||
pub struct InputHint;
|
||||
|
||||
impl RenderOnce for InputHint {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(Kbd::new("enter"))
|
||||
.child("to send ·")
|
||||
.child(Kbd::new("shift-enter"))
|
||||
.child("for a new line")
|
||||
}
|
||||
}
|
||||
|
||||
/// While files hover the composer, a veil over it that says a drop attaches them; `group` names the composer.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DragDropOverlay {
|
||||
group: SharedString,
|
||||
}
|
||||
|
||||
impl DragDropOverlay {
|
||||
pub fn new(group: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
group: group.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for DragDropOverlay {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.invisible()
|
||||
.group_drag_over::<ExternalPaths>(self.group, |style| style.visible())
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded(theme.radius(Radius::Xl))
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
.border_color(colors.focus)
|
||||
.bg(colors.surface.opacity(0.94))
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::Upload)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child("Drop to attach")
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows a composer offers at `/` or `@`, and what a pick does.
|
||||
fn offers(
|
||||
id: ElementId,
|
||||
field: &Entity<TextInput>,
|
||||
commands: &[Choice],
|
||||
context: &[Choice],
|
||||
on_command: Option<OnValue>,
|
||||
on_mention: Option<OnValue>,
|
||||
cx: &App,
|
||||
) -> Suggestions {
|
||||
let input = field.read(cx);
|
||||
let (text, caret) = (input.text().to_string(), input.cursor());
|
||||
let active =
|
||||
active_trigger(&text, caret, &['/', '@']).filter(|(at, mark)| *mark == '@' || *at == 0);
|
||||
let query = active.map_or(String::new(), |(at, mark)| {
|
||||
text[at + mark.len_utf8()..caret].to_lowercase()
|
||||
});
|
||||
let (listed, run) = match active {
|
||||
Some((_, '/')) => (commands, on_command),
|
||||
Some(_) => (context, on_mention),
|
||||
None => (&[][..], None),
|
||||
};
|
||||
let found: Vec<Choice> = listed
|
||||
.iter()
|
||||
.filter(|choice| choice.label.to_lowercase().contains(&query))
|
||||
.cloned()
|
||||
.collect();
|
||||
let rows = if run.is_some() {
|
||||
found.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (state, field) = (field.clone(), field.clone());
|
||||
let pick: Pick = Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
|
||||
let (at, caret) = (
|
||||
active.expect("rows show at a trigger").0,
|
||||
field.read(cx).cursor(),
|
||||
);
|
||||
replace_trigger(&field, at, caret, "", cx);
|
||||
log::info!("composer: picked {}", found[ix].value);
|
||||
if let Some(run) = &run {
|
||||
run(&found[ix].value, window, cx);
|
||||
}
|
||||
});
|
||||
Suggestions {
|
||||
id,
|
||||
state,
|
||||
trigger: active.map(|(at, _)| at),
|
||||
rows,
|
||||
pick,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a message is written: its attachments and context above, the field, and a row of the owner's tools with send. Enter sends, Shift-Enter breaks the line; `/` at the start offers commands and `@` offers context; files dropped on it attach.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PromptInput {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
on_send: Run,
|
||||
on_stop: Option<Run>,
|
||||
above: SmallVec<[AnyElement; 2]>,
|
||||
tools: SmallVec<[AnyElement; 4]>,
|
||||
commands: (Vec<Choice>, Option<OnValue>),
|
||||
context: (Vec<Choice>, Option<OnValue>),
|
||||
on_drop: Option<OnPaths>,
|
||||
}
|
||||
|
||||
impl PromptInput {
|
||||
/// `field` is the owner's multi-line text; `on_send` sends it.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
field: &Entity<TextInput>,
|
||||
on_send: impl Fn(&mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
on_send: Rc::new(on_send),
|
||||
on_stop: None,
|
||||
above: SmallVec::new(),
|
||||
tools: SmallVec::new(),
|
||||
commands: (Vec::new(), None),
|
||||
context: (Vec::new(), None),
|
||||
on_drop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// While an answer streams: send becomes stop.
|
||||
pub fn busy(mut self, on_stop: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_stop = Some(Rc::new(on_stop));
|
||||
self
|
||||
}
|
||||
|
||||
/// Shown over the field, such as attachments, context and a quote.
|
||||
pub fn above(mut self, element: impl IntoElement) -> Self {
|
||||
self.above.push(element.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// A tool in the row under the field, such as attach or the model.
|
||||
pub fn tool(mut self, element: impl IntoElement) -> Self {
|
||||
self.tools.push(element.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Commands offered at `/` at the start; `on_command` gets the one picked.
|
||||
pub fn commands(
|
||||
mut self,
|
||||
commands: impl IntoIterator<Item = Choice>,
|
||||
on_command: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.commands = (commands.into_iter().collect(), Some(Rc::new(on_command)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Context offered at `@`; `on_mention` gets the one picked.
|
||||
pub fn context(
|
||||
mut self,
|
||||
context: impl IntoIterator<Item = Choice>,
|
||||
on_mention: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.context = (context.into_iter().collect(), Some(Rc::new(on_mention)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Takes files dropped on the composer.
|
||||
pub fn on_drop(
|
||||
mut self,
|
||||
handler: impl Fn(Vec<PathBuf>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_drop = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PromptInput {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let ready = !self.field.read(cx).text().trim().is_empty();
|
||||
let focused = self.field.read(cx).focus().contains_focused(window, cx);
|
||||
let group = SharedString::from(format!("composer-{}", self.id));
|
||||
let (send, enter) = (self.on_send.clone(), self.on_send.clone());
|
||||
let suggestions = offers(
|
||||
(self.id.clone(), "offers").into(),
|
||||
&self.field,
|
||||
&self.commands.0,
|
||||
&self.context.0,
|
||||
self.commands.1.clone(),
|
||||
self.context.1.clone(),
|
||||
cx,
|
||||
);
|
||||
let button = SendButton::new((self.id.clone(), "send"), ready, move |window, cx| {
|
||||
send(window, cx)
|
||||
});
|
||||
let button = match self.on_stop.clone() {
|
||||
Some(stop) => button.busy(move |window, cx| stop(window, cx)),
|
||||
None => button,
|
||||
};
|
||||
let busy = self.on_stop.is_some();
|
||||
let body = div()
|
||||
.id(self.id.clone())
|
||||
.group(group.clone())
|
||||
.relative()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Xl))
|
||||
.border_1()
|
||||
.border_color(if focused {
|
||||
colors.focus
|
||||
} else {
|
||||
colors.border_strong
|
||||
})
|
||||
.bg(colors.surface)
|
||||
.children(self.above)
|
||||
.child(
|
||||
suggestions.wrap(
|
||||
div()
|
||||
.capture_action(move |_: &Enter, window, cx| {
|
||||
cx.stop_propagation();
|
||||
if ready && !busy {
|
||||
log::info!("composer: sent with Enter");
|
||||
enter(window, cx);
|
||||
}
|
||||
})
|
||||
.text_size(theme.text_size(TextSize::Base))
|
||||
.child(self.field.clone()),
|
||||
window,
|
||||
cx,
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.children(self.tools)
|
||||
.child(div().flex_1())
|
||||
.child(button),
|
||||
);
|
||||
match self.on_drop {
|
||||
Some(take) => body
|
||||
.on_drop(move |paths: &ExternalPaths, window, cx| {
|
||||
match dropped(paths.paths(), true) {
|
||||
Ok(paths) => {
|
||||
log::info!("composer: {} files dropped", paths.len());
|
||||
take(paths, window, cx)
|
||||
}
|
||||
Err(reason) => log::info!("composer: refused a drop: {reason}"),
|
||||
}
|
||||
})
|
||||
.child(DragDropOverlay::new(group)),
|
||||
None => body,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
mod actions;
|
||||
mod attach;
|
||||
mod cite;
|
||||
mod code;
|
||||
mod composer;
|
||||
mod list;
|
||||
mod media;
|
||||
mod message;
|
||||
mod params;
|
||||
mod play;
|
||||
mod search;
|
||||
mod status;
|
||||
mod stream;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod voice;
|
||||
|
||||
pub use actions::{
|
||||
BranchNavigator, MessageActions, MessageEditor, QuoteReply, continue_button, regenerate_button,
|
||||
stop_button,
|
||||
};
|
||||
pub use attach::{Attachment, AttachmentChip, ContextChips};
|
||||
pub use cite::{CitationBadge, Source, SourceCard, SourceList};
|
||||
pub use code::CodeBlock;
|
||||
pub use composer::{AttachmentButton, DragDropOverlay, InputHint, PromptInput, SendButton};
|
||||
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
|
||||
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
|
||||
pub use message::{
|
||||
ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role,
|
||||
};
|
||||
pub use params::{CostEstimator, Parameter, ParameterPanel, SystemPromptEditor, TokenCounter};
|
||||
pub use play::{AudioMessage, VideoMessage};
|
||||
pub use search::{DocumentChunkPreview, SearchProgress, StepState, WebResultCard};
|
||||
pub use status::{
|
||||
ErrorMessage, FeedbackForm, RateLimitNotice, ThinkingBlock, ThinkingDuration, ThinkingIndicator,
|
||||
};
|
||||
pub use stream::{StreamingCursor, StreamingMarkdown, StreamingText};
|
||||
pub use voice::{VoiceInputButton, VoiceWaveform};
|
||||
@@ -0,0 +1,313 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
forms::{Input, Run, Slider, TextInput},
|
||||
primitives::Tooltip,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
typography::{
|
||||
Ellipsis,
|
||||
format::{currency, decimals, group_digits},
|
||||
tabular,
|
||||
},
|
||||
};
|
||||
|
||||
/// Share of a limit where a count turns amber.
|
||||
const NEAR: f64 = 0.9;
|
||||
|
||||
/// One tuning knob: its key and name, its value, range and step, and a line on what it does.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Parameter {
|
||||
pub key: SharedString,
|
||||
pub label: SharedString,
|
||||
pub value: f64,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub step: f64,
|
||||
pub hint: Option<SharedString>,
|
||||
}
|
||||
|
||||
type OnParameter = Rc<dyn Fn(&SharedString, f64, &mut Window, &mut App)>;
|
||||
|
||||
/// How answers are made, knob by knob: each a slider with its value, such as temperature, top p and the longest answer.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ParameterPanel {
|
||||
id: ElementId,
|
||||
parameters: Vec<Parameter>,
|
||||
on_change: OnParameter,
|
||||
}
|
||||
|
||||
impl ParameterPanel {
|
||||
/// `on_change` gets a parameter's key and its new value.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
parameters: impl IntoIterator<Item = Parameter>,
|
||||
on_change: impl Fn(&SharedString, f64, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
let parameters: Vec<Parameter> = parameters.into_iter().collect();
|
||||
for parameter in ¶meters {
|
||||
assert!(
|
||||
parameter.min < parameter.max
|
||||
&& parameter.step > 0.0
|
||||
&& (parameter.min..=parameter.max).contains(¶meter.value),
|
||||
"parameter {} has a bad range",
|
||||
parameter.key
|
||||
);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
parameters,
|
||||
on_change: Rc::new(on_change),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ParameterPanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.children(self.parameters.into_iter().map(|parameter| {
|
||||
let change = self.on_change.clone();
|
||||
let key = parameter.key.clone();
|
||||
let places = decimals(parameter.step);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(Ellipsis::new(parameter.label)),
|
||||
)
|
||||
.child(
|
||||
tabular(div().flex_none().text_color(colors.fg_muted))
|
||||
.child(format!("{:.places$}", parameter.value)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Slider::new((self.id.clone(), format!("slider-{key}")), parameter.value)
|
||||
.range(parameter.min, parameter.max)
|
||||
.step(parameter.step)
|
||||
.on_change(move |value, window, cx| {
|
||||
log::info!("parameters: {key} {value}");
|
||||
change(&key, value, window, cx)
|
||||
}),
|
||||
)
|
||||
.children(parameter.hint.map(|hint| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(hint)
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A token count against a limit: quiet while there is room, amber near the end, red past it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TokenCounter {
|
||||
tokens: usize,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl TokenCounter {
|
||||
/// `tokens` as the model's own tokenizer counts them.
|
||||
pub fn new(tokens: usize) -> Self {
|
||||
Self {
|
||||
tokens,
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limit(mut self, limit: usize) -> Self {
|
||||
assert!(limit > 0, "a limit of none takes no tokens");
|
||||
self.limit = Some(limit);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TokenCounter {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count = group_digits(&self.tokens.to_string(), ',');
|
||||
let (text, color) = match self.limit {
|
||||
Some(limit) => {
|
||||
let share = self.tokens as f64 / limit as f64;
|
||||
let color = if share > 1.0 {
|
||||
colors.danger
|
||||
} else if share >= NEAR {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.fg_subtle
|
||||
};
|
||||
(
|
||||
format!("{count} / {} tokens", group_digits(&limit.to_string(), ',')),
|
||||
color,
|
||||
)
|
||||
}
|
||||
None => (format!("{count} tokens"), colors.fg_subtle),
|
||||
};
|
||||
tabular(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(color),
|
||||
)
|
||||
.child(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// What tokens cost in dollars, at prices per million tokens in and out.
|
||||
pub(crate) fn cost(input: usize, output: usize, per_million_in: f64, per_million_out: f64) -> f64 {
|
||||
(input as f64 * per_million_in + output as f64 * per_million_out) / 1_000_000.0
|
||||
}
|
||||
|
||||
/// What a message may cost: its tokens in and out at the model's prices per million; the parts in a tooltip.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CostEstimator {
|
||||
id: ElementId,
|
||||
input: usize,
|
||||
output: usize,
|
||||
prices: (f64, f64),
|
||||
}
|
||||
|
||||
impl CostEstimator {
|
||||
/// `prices` are dollars per million tokens, in and out.
|
||||
pub fn new(id: impl Into<ElementId>, input: usize, output: usize, prices: (f64, f64)) -> Self {
|
||||
assert!(prices.0 >= 0.0 && prices.1 >= 0.0, "a price below zero");
|
||||
Self {
|
||||
id: id.into(),
|
||||
input,
|
||||
output,
|
||||
prices,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CostEstimator {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let dollars = cost(self.input, self.output, self.prices.0, self.prices.1);
|
||||
let shown = if dollars > 0.0 && dollars < 0.01 {
|
||||
"< $0.01".to_string()
|
||||
} else {
|
||||
format!("≈ {}", currency(dollars, "USD"))
|
||||
};
|
||||
let parts = format!(
|
||||
"{} in, {} out",
|
||||
group_digits(&self.input.to_string(), ','),
|
||||
group_digits(&self.output.to_string(), ',')
|
||||
);
|
||||
tabular(
|
||||
div()
|
||||
.id(self.id)
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.tooltip(Tooltip::with_meta(shown.clone(), parts)),
|
||||
)
|
||||
.child(shown)
|
||||
}
|
||||
}
|
||||
|
||||
/// The instructions every answer follows: a titled field, its tokens against the limit, and a way back to the default.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SystemPromptEditor {
|
||||
id: ElementId,
|
||||
field: Entity<TextInput>,
|
||||
tokens: usize,
|
||||
limit: usize,
|
||||
on_reset: Option<Run>,
|
||||
}
|
||||
|
||||
impl SystemPromptEditor {
|
||||
/// `tokens` counts the field's text, as the model's tokenizer does.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
field: &Entity<TextInput>,
|
||||
tokens: usize,
|
||||
limit: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
field: field.clone(),
|
||||
tokens,
|
||||
limit,
|
||||
on_reset: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offers a way back to the default instructions.
|
||||
pub fn on_reset(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_reset = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SystemPromptEditor {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child("System prompt"),
|
||||
)
|
||||
.children(self.on_reset.map(|reset| {
|
||||
Button::new((self.id.clone(), "reset"), "Reset")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("system prompt: reset");
|
||||
reset(window, cx)
|
||||
})
|
||||
})),
|
||||
)
|
||||
.child(Input::new(&self.field))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_end()
|
||||
.child(TokenCounter::new(self.tokens).limit(self.limit)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::cost;
|
||||
|
||||
#[test]
|
||||
fn cost_counts_tokens_at_their_prices() {
|
||||
assert_eq!(cost(1_000_000, 0, 3.0, 15.0), 3.0);
|
||||
assert_eq!(cost(2_000, 1_000, 3.0, 15.0), 0.021);
|
||||
assert_eq!(cost(0, 0, 3.0, 15.0), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, TestAppContext, VisualTestContext, Window, div,
|
||||
};
|
||||
|
||||
use super::settle;
|
||||
use crate::{
|
||||
chat::PromptInput,
|
||||
forms::{self, Choice, TextInput},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
/// A composer over a field, and what it asked: sends, commands and context.
|
||||
struct Compose {
|
||||
field: Entity<TextInput>,
|
||||
busy: bool,
|
||||
sent: usize,
|
||||
picked: Vec<SharedString>,
|
||||
}
|
||||
|
||||
impl Render for Compose {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (send, command, mention) = (cx.entity(), cx.entity(), cx.entity());
|
||||
let input = PromptInput::new("compose", &self.field, move |_, cx| {
|
||||
send.update(cx, |compose, _| compose.sent += 1)
|
||||
})
|
||||
.commands(
|
||||
[Choice::new("summarize", "/summarize")],
|
||||
move |value, _, cx| command.update(cx, |compose, _| compose.picked.push(value.clone())),
|
||||
)
|
||||
.context([Choice::new("lift", "lift.rs")], move |value, _, cx| {
|
||||
mention.update(cx, |compose, _| compose.picked.push(value.clone()))
|
||||
});
|
||||
let input = if self.busy {
|
||||
input.busy(|_, _| {})
|
||||
} else {
|
||||
input
|
||||
};
|
||||
div().size_full().child(input)
|
||||
}
|
||||
}
|
||||
|
||||
fn open(busy: bool, cx: &mut TestAppContext) -> (Entity<Compose>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
forms::bind_keys(cx);
|
||||
});
|
||||
let (compose, cx) = cx.add_window_view(move |window, cx| Compose {
|
||||
field: cx.new(|cx| TextInput::new(window, cx).multi_line(1, 8)),
|
||||
busy,
|
||||
sent: 0,
|
||||
picked: Vec::new(),
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
window.activate_window();
|
||||
let field = compose.read(cx).field.focus_handle(cx);
|
||||
window.focus(&field);
|
||||
});
|
||||
settle(cx);
|
||||
(compose, cx)
|
||||
}
|
||||
|
||||
fn text(compose: &Entity<Compose>, cx: &mut VisualTestContext) -> String {
|
||||
cx.update(|_, cx| compose.read(cx).field.read(cx).text().to_string())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_sends_and_shift_enter_breaks_the_line(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(false, cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(
|
||||
compose.read_with(cx, |compose, _| compose.sent),
|
||||
0,
|
||||
"nothing to send"
|
||||
);
|
||||
cx.simulate_input("lift");
|
||||
cx.simulate_keystrokes("shift-enter");
|
||||
settle(cx);
|
||||
assert_eq!(text(&compose, cx), "lift\n");
|
||||
cx.simulate_input("tone");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 1);
|
||||
assert_eq!(
|
||||
text(&compose, cx),
|
||||
"lift\ntone",
|
||||
"Enter sends without a new line"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_slash_picks_a_command_and_an_at_sign_picks_context(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(false, cx);
|
||||
cx.simulate_input("/sum");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(text(&compose, cx), "", "the command's words go");
|
||||
cx.simulate_input("see @li");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(text(&compose, cx), "see ");
|
||||
compose.read_with(cx, |compose, _| {
|
||||
assert_eq!(compose.picked, ["summarize", "lift"]);
|
||||
assert_eq!(compose.sent, 0, "a pick is not a send");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_busy_composer_holds_its_message(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(true, cx);
|
||||
cx.simulate_input("wait");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 0);
|
||||
assert_eq!(text(&compose, cx), "wait");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
mod cite;
|
||||
mod compose;
|
||||
|
||||
use super::{
|
||||
ImageMessage, MessageAvatar, MessageBubble, MessageEditor, MessageList, Role, ThinkingBlock,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use std::{rc::Rc, time::Instant};
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
feedback::Timer,
|
||||
forms::OnFlag,
|
||||
motion::Pulse,
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, ControlSize, TextSize},
|
||||
};
|
||||
|
||||
/// Starts and stops dictation: a microphone at rest, a stop square while it records.
|
||||
#[derive(IntoElement)]
|
||||
pub struct VoiceInputButton {
|
||||
id: ElementId,
|
||||
recording: bool,
|
||||
on_toggle: OnFlag,
|
||||
}
|
||||
|
||||
impl VoiceInputButton {
|
||||
/// `on_toggle` gets true to start recording, false to stop.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
recording: bool,
|
||||
on_toggle: impl Fn(bool, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
recording,
|
||||
on_toggle: Rc::new(on_toggle),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for VoiceInputButton {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let (recording, toggle) = (self.recording, self.on_toggle);
|
||||
IconButton::new(
|
||||
self.id,
|
||||
if recording {
|
||||
IconName::Square
|
||||
} else {
|
||||
IconName::Mic
|
||||
},
|
||||
)
|
||||
.variant(if recording {
|
||||
ButtonVariant::Secondary
|
||||
} else {
|
||||
ButtonVariant::Ghost
|
||||
})
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(if recording {
|
||||
"Stop dictation"
|
||||
} else {
|
||||
"Dictate"
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("voice input: recording {}", !recording);
|
||||
toggle(!recording, window, cx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Sound as it records: a red dot, how long it has run, and bars that follow the latest levels, 0 to 1, oldest first. The dot's rings rest under reduced motion.
|
||||
#[derive(IntoElement)]
|
||||
pub struct VoiceWaveform {
|
||||
id: ElementId,
|
||||
since: Instant,
|
||||
levels: Vec<f32>,
|
||||
}
|
||||
|
||||
impl VoiceWaveform {
|
||||
pub fn new(id: impl Into<ElementId>, since: Instant, levels: impl Into<Vec<f32>>) -> Self {
|
||||
let levels: Vec<f32> = levels.into();
|
||||
assert!(
|
||||
levels.iter().all(|level| (0.0..=1.0).contains(level)),
|
||||
"levels lie within 0 to 1"
|
||||
);
|
||||
Self {
|
||||
id: id.into(),
|
||||
since,
|
||||
levels,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for VoiceWaveform {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let tall = theme.control_height(ControlSize::Sm);
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Pulse::new((self.id.clone(), "dot")).child(
|
||||
div()
|
||||
.size(theme.status_dot())
|
||||
.rounded_full()
|
||||
.bg(colors.danger),
|
||||
),
|
||||
)
|
||||
.child(Timer::new((self.id.clone(), "time"), self.since).size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.h(tall)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap_px()
|
||||
.children(self.levels.into_iter().map(|level| {
|
||||
div()
|
||||
.w(theme.progress_thickness())
|
||||
.h(tall * level.max(0.1))
|
||||
.rounded_full()
|
||||
.bg(colors.fg_muted)
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ use gpui::{
|
||||
use super::{Block, BlockData, BlockEditor, BlockKind, matching};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
documents::suggest::{Offer, offers, replace_trigger},
|
||||
documents::suggest::{Offer, offers},
|
||||
forms::replace_trigger,
|
||||
forms::{Backspace, Choice, Down, Enter, Pick, Redo, Submit, Undo, Up},
|
||||
menus::{Menu, MenuItem, OverflowMenu},
|
||||
motion::Reorder,
|
||||
|
||||
@@ -4,6 +4,7 @@ use gpui::{App, ElementId, Entity, SharedString, Window};
|
||||
|
||||
use crate::forms::{
|
||||
Choice, Pick, Suggestions, TextInput, active_trigger, emoji_found, handles_matching,
|
||||
replace_trigger,
|
||||
};
|
||||
|
||||
/// A trigger of the owner's: from the query and where it runs, the rows and what a pick does.
|
||||
@@ -23,20 +24,6 @@ fn nothing() -> (Vec<Choice>, Pick) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step.
|
||||
pub(crate) fn replace_trigger(
|
||||
field: &Entity<TextInput>,
|
||||
at: usize,
|
||||
caret: usize,
|
||||
text: &str,
|
||||
cx: &mut App,
|
||||
) {
|
||||
field.update(cx, |input, cx| {
|
||||
input.select(at..caret, cx);
|
||||
input.insert(text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// What `@` and `:` offer at the field's caret: people by handle, emoji by name; `extra` adds a trigger of the owner's, such as `/` in blocks.
|
||||
pub(crate) fn offers(
|
||||
id: ElementId,
|
||||
|
||||
@@ -32,6 +32,20 @@ pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Opt
|
||||
(opens && word).then_some((ix, ch))
|
||||
}
|
||||
|
||||
/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step.
|
||||
pub(crate) fn replace_trigger(
|
||||
field: &Entity<TextInput>,
|
||||
at: usize,
|
||||
caret: usize,
|
||||
text: &str,
|
||||
cx: &mut App,
|
||||
) {
|
||||
field.update(cx, |input, cx| {
|
||||
input.select(at..caret, cx);
|
||||
input.insert(text, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The dismissed trigger, while it is still the one being typed.
|
||||
fn dismissal(dismissed: Option<usize>, active: Option<usize>) -> Option<usize> {
|
||||
dismissed.filter(|at| active == Some(*at))
|
||||
|
||||
+3
-1
@@ -63,6 +63,7 @@ pub use date::{
|
||||
WeekPicker, YearPicker,
|
||||
};
|
||||
pub use expression::{ExpressionInput, evaluate, expression_highlights};
|
||||
pub(crate) use files::dropped;
|
||||
pub use files::{DropZone, FileInput};
|
||||
pub(crate) use glyphs::emoji_found;
|
||||
pub use glyphs::{EmojiPicker, IconPicker};
|
||||
@@ -75,7 +76,7 @@ pub use knob::Knob;
|
||||
pub use listbox::ListBox;
|
||||
pub use masked::{COUNTRIES, MaskedInput, PhoneInput};
|
||||
pub use mention::{MentionInput, mention_highlights};
|
||||
pub(crate) use mention::{Suggestions, active_trigger, handles_matching};
|
||||
pub(crate) use mention::{Suggestions, active_trigger, handles_matching, replace_trigger};
|
||||
pub use multi::MultiSelect;
|
||||
pub use number::{NumberInput, ScrubInput};
|
||||
pub use options::Choice;
|
||||
@@ -84,6 +85,7 @@ pub(crate) use options::{
|
||||
};
|
||||
pub use pairs::{FieldArray, KeyValueInput, ListInput};
|
||||
pub use path::PathInput;
|
||||
pub(crate) use path::choose;
|
||||
pub use pattern::{RegexInput, regex_highlights};
|
||||
pub use pin::PinInput;
|
||||
pub use radio::{Radio, RadioGroup};
|
||||
|
||||
@@ -299,3 +299,17 @@ fn a_range_gives_a_box_per_line_it_spans(cx: &mut TestAppContext) {
|
||||
"the second line starts at the edge"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn shift_enter_breaks_only_a_field_of_many_lines(cx: &mut TestAppContext) {
|
||||
let (fields, cx) = open(1, false, cx);
|
||||
focus(&fields[0], cx);
|
||||
cx.simulate_input("one line");
|
||||
cx.simulate_keystrokes("shift-enter");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
text(&fields[0], cx),
|
||||
"one line",
|
||||
"a single line stays single"
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ actions!(
|
||||
Undo,
|
||||
Redo,
|
||||
Enter,
|
||||
Newline,
|
||||
Submit,
|
||||
ShowCharacterPalette,
|
||||
]
|
||||
@@ -84,6 +85,7 @@ pub(crate) fn bind_keys(cx: &mut App) {
|
||||
KeyBinding::new("secondary-z", Undo, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-shift-z", Redo, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Enter, Some(CONTEXT)),
|
||||
KeyBinding::new("shift-enter", Newline, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)),
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
@@ -290,6 +292,12 @@ impl TextInput {
|
||||
}
|
||||
}
|
||||
|
||||
fn newline(&mut self, _: &Newline, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.rows.is_some() {
|
||||
self.replace(self.selection.clone(), "\n", false, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, _: &Submit, _: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("text input: submitted");
|
||||
cx.emit(InputEvent::Submit);
|
||||
@@ -380,6 +388,7 @@ impl Render for TextInput {
|
||||
.on_action(cx.listener(Self::undo))
|
||||
.on_action(cx.listener(Self::redo))
|
||||
.on_action(cx.listener(Self::enter))
|
||||
.on_action(cx.listener(Self::newline))
|
||||
.on_action(cx.listener(Self::submit))
|
||||
.on_action(cx.listener(Self::show_character_palette))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
|
||||
|
||||
Reference in new issue
Block a user