- 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
401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
use gpui::{
|
|
App, ClipboardItem, Context, CursorStyle, InteractiveElement, IntoElement, KeyBinding,
|
|
MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, Window, actions,
|
|
div,
|
|
};
|
|
|
|
use super::{
|
|
InputEvent, TextInput,
|
|
edit::{self, next_grapheme, prev_grapheme},
|
|
element::TextElement,
|
|
};
|
|
|
|
actions!(
|
|
ely_input,
|
|
[
|
|
Backspace,
|
|
Delete,
|
|
DeleteWordLeft,
|
|
Left,
|
|
Right,
|
|
Up,
|
|
Down,
|
|
SelectLeft,
|
|
SelectRight,
|
|
SelectUp,
|
|
SelectDown,
|
|
WordLeft,
|
|
WordRight,
|
|
SelectWordLeft,
|
|
SelectWordRight,
|
|
Home,
|
|
End,
|
|
SelectHome,
|
|
SelectEnd,
|
|
SelectAll,
|
|
Copy,
|
|
Cut,
|
|
Paste,
|
|
Undo,
|
|
Redo,
|
|
Enter,
|
|
Newline,
|
|
Submit,
|
|
ShowCharacterPalette,
|
|
]
|
|
);
|
|
|
|
const CONTEXT: &str = "ElyInput";
|
|
|
|
/// Binds the text keys. `init` calls it.
|
|
pub(crate) fn bind_keys(cx: &mut App) {
|
|
let word = if cfg!(target_os = "macos") {
|
|
"alt"
|
|
} else {
|
|
"ctrl"
|
|
};
|
|
let mut bindings = vec![
|
|
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
|
|
KeyBinding::new("delete", Delete, Some(CONTEXT)),
|
|
KeyBinding::new(&format!("{word}-backspace"), DeleteWordLeft, Some(CONTEXT)),
|
|
KeyBinding::new("left", Left, Some(CONTEXT)),
|
|
KeyBinding::new("right", Right, Some(CONTEXT)),
|
|
KeyBinding::new("up", Up, Some(CONTEXT)),
|
|
KeyBinding::new("down", Down, Some(CONTEXT)),
|
|
KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
|
|
KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
|
|
KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)),
|
|
KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)),
|
|
KeyBinding::new(&format!("{word}-left"), WordLeft, Some(CONTEXT)),
|
|
KeyBinding::new(&format!("{word}-right"), WordRight, Some(CONTEXT)),
|
|
KeyBinding::new(&format!("{word}-shift-left"), SelectWordLeft, Some(CONTEXT)),
|
|
KeyBinding::new(
|
|
&format!("{word}-shift-right"),
|
|
SelectWordRight,
|
|
Some(CONTEXT),
|
|
),
|
|
KeyBinding::new("home", Home, Some(CONTEXT)),
|
|
KeyBinding::new("end", End, Some(CONTEXT)),
|
|
KeyBinding::new("shift-home", SelectHome, Some(CONTEXT)),
|
|
KeyBinding::new("shift-end", SelectEnd, Some(CONTEXT)),
|
|
KeyBinding::new("secondary-a", SelectAll, Some(CONTEXT)),
|
|
KeyBinding::new("secondary-c", Copy, Some(CONTEXT)),
|
|
KeyBinding::new("secondary-x", Cut, Some(CONTEXT)),
|
|
KeyBinding::new("secondary-v", Paste, Some(CONTEXT)),
|
|
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") {
|
|
bindings.extend([
|
|
KeyBinding::new("cmd-left", Home, Some(CONTEXT)),
|
|
KeyBinding::new("cmd-right", End, Some(CONTEXT)),
|
|
KeyBinding::new("cmd-shift-left", SelectHome, Some(CONTEXT)),
|
|
KeyBinding::new("cmd-shift-right", SelectEnd, Some(CONTEXT)),
|
|
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)),
|
|
]);
|
|
} else {
|
|
bindings.push(KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)));
|
|
}
|
|
cx.bind_keys(bindings);
|
|
}
|
|
|
|
impl TextInput {
|
|
fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context<Self>) {
|
|
let range = if self.selection.is_empty() {
|
|
prev_grapheme(&self.text, self.cursor())..self.cursor()
|
|
} else {
|
|
self.selection.clone()
|
|
};
|
|
self.replace(range, "", false, cx);
|
|
}
|
|
|
|
fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
|
|
let range = if self.selection.is_empty() {
|
|
self.cursor()..next_grapheme(&self.text, self.cursor())
|
|
} else {
|
|
self.selection.clone()
|
|
};
|
|
self.replace(range, "", false, cx);
|
|
}
|
|
|
|
fn delete_word_left(&mut self, _: &DeleteWordLeft, _: &mut Window, cx: &mut Context<Self>) {
|
|
let range = if self.selection.is_empty() {
|
|
edit::prev_word(&self.text, self.cursor())..self.cursor()
|
|
} else {
|
|
self.selection.clone()
|
|
};
|
|
self.replace(range, "", false, cx);
|
|
}
|
|
|
|
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
|
|
let to = if self.selection.is_empty() {
|
|
prev_grapheme(&self.text, self.cursor())
|
|
} else {
|
|
self.selection.start
|
|
};
|
|
self.move_to(to, cx);
|
|
}
|
|
|
|
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
|
|
let to = if self.selection.is_empty() {
|
|
next_grapheme(&self.text, self.cursor())
|
|
} else {
|
|
self.selection.end
|
|
};
|
|
self.move_to(to, cx);
|
|
}
|
|
|
|
/// The offset one visual line above or below the caret.
|
|
fn vertical(&self, down: bool) -> usize {
|
|
let caret = self.cursor();
|
|
let (Some(layout), Some(at)) = (
|
|
self.layout.as_ref(),
|
|
self.position_for(self.display_offset(caret)),
|
|
) else {
|
|
return caret;
|
|
};
|
|
let step = if down {
|
|
layout.line_height
|
|
} else {
|
|
-layout.line_height
|
|
};
|
|
let target = gpui::point(at.x, at.y + step + layout.line_height / 2.0);
|
|
if target.y < gpui::Pixels::ZERO {
|
|
return 0;
|
|
}
|
|
self.offset_for_point(layout.bounds.origin + target - self.scroll)
|
|
}
|
|
|
|
fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context<Self>) {
|
|
let to = self.vertical(false);
|
|
self.move_to(to, cx);
|
|
}
|
|
|
|
fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context<Self>) {
|
|
let to = if self.rows.is_some() {
|
|
self.vertical(true)
|
|
} else {
|
|
self.text.len()
|
|
};
|
|
self.move_to(to, cx);
|
|
}
|
|
|
|
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_to(prev_grapheme(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_to(next_grapheme(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
|
|
let to = self.vertical(false);
|
|
self.select_to(to, cx);
|
|
}
|
|
|
|
fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
|
|
let to = self.vertical(true);
|
|
self.select_to(to, cx);
|
|
}
|
|
|
|
fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_to(edit::prev_word(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_to(edit::next_word(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_to(edit::prev_word(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_to(edit::next_word(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_to(edit::line_start(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.move_to(edit::line_end(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn select_home(&mut self, _: &SelectHome, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_to(edit::line_start(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn select_end(&mut self, _: &SelectEnd, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.select_to(edit::line_end(&self.text, self.cursor()), cx);
|
|
}
|
|
|
|
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
|
|
self.selection = 0..self.text.len();
|
|
self.reversed = false;
|
|
cx.notify();
|
|
}
|
|
|
|
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
|
|
if self.masked || self.selection.is_empty() {
|
|
return;
|
|
}
|
|
cx.write_to_clipboard(ClipboardItem::new_string(
|
|
self.text[self.selection.clone()].to_string(),
|
|
));
|
|
}
|
|
|
|
fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
|
|
if self.masked || self.selection.is_empty() {
|
|
return;
|
|
}
|
|
cx.write_to_clipboard(ClipboardItem::new_string(
|
|
self.text[self.selection.clone()].to_string(),
|
|
));
|
|
self.replace(self.selection.clone(), "", false, cx);
|
|
}
|
|
|
|
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
|
return;
|
|
};
|
|
self.replace(self.selection.clone(), &text, false, cx);
|
|
}
|
|
|
|
fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context<Self>) {
|
|
if self.disabled {
|
|
return;
|
|
}
|
|
if let Some(previous) = self.history.undo(self.snapshot()) {
|
|
self.restore(previous, cx);
|
|
}
|
|
}
|
|
|
|
fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context<Self>) {
|
|
if self.disabled {
|
|
return;
|
|
}
|
|
if let Some(next) = self.history.redo(self.snapshot()) {
|
|
self.restore(next, cx);
|
|
}
|
|
}
|
|
|
|
fn enter(&mut self, _: &Enter, _: &mut Window, cx: &mut Context<Self>) {
|
|
if self.rows.is_some() {
|
|
self.replace(self.selection.clone(), "\n", false, cx);
|
|
} else {
|
|
log::info!("text input: submitted");
|
|
cx.emit(InputEvent::Submit);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
fn show_character_palette(
|
|
&mut self,
|
|
_: &ShowCharacterPalette,
|
|
window: &mut Window,
|
|
_: &mut Context<Self>,
|
|
) {
|
|
window.show_character_palette();
|
|
}
|
|
|
|
fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.disabled {
|
|
return;
|
|
}
|
|
window.focus(&self.focus);
|
|
let at = self.offset_for_point(event.position);
|
|
match event.click_count {
|
|
2 => {
|
|
let word = edit::word_at(&self.text, at);
|
|
self.selection = word;
|
|
self.reversed = false;
|
|
cx.notify();
|
|
}
|
|
3.. => {
|
|
self.selection = edit::line_start(&self.text, at)..edit::line_end(&self.text, at);
|
|
self.reversed = false;
|
|
cx.notify();
|
|
}
|
|
_ if event.modifiers.shift => self.select_to(at, cx),
|
|
_ => {
|
|
self.selecting = true;
|
|
self.move_to(at, cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
|
|
if self.selecting && event.dragging() {
|
|
let at = self.offset_for_point(event.position);
|
|
self.select_to(at, cx);
|
|
}
|
|
}
|
|
|
|
fn mouse_up(&mut self, _: &gpui::MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
|
|
self.selecting = false;
|
|
}
|
|
}
|
|
|
|
impl Render for TextInput {
|
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
div()
|
|
.id("ely-text-input")
|
|
.key_context(CONTEXT)
|
|
.track_focus(&self.focus)
|
|
.w_full()
|
|
.cursor(if self.disabled {
|
|
CursorStyle::OperationNotAllowed
|
|
} else {
|
|
CursorStyle::IBeam
|
|
})
|
|
.on_action(cx.listener(Self::backspace))
|
|
.on_action(cx.listener(Self::delete))
|
|
.on_action(cx.listener(Self::delete_word_left))
|
|
.on_action(cx.listener(Self::left))
|
|
.on_action(cx.listener(Self::right))
|
|
.on_action(cx.listener(Self::up))
|
|
.on_action(cx.listener(Self::down))
|
|
.on_action(cx.listener(Self::select_left))
|
|
.on_action(cx.listener(Self::select_right))
|
|
.on_action(cx.listener(Self::select_up))
|
|
.on_action(cx.listener(Self::select_down))
|
|
.on_action(cx.listener(Self::word_left))
|
|
.on_action(cx.listener(Self::word_right))
|
|
.on_action(cx.listener(Self::select_word_left))
|
|
.on_action(cx.listener(Self::select_word_right))
|
|
.on_action(cx.listener(Self::home))
|
|
.on_action(cx.listener(Self::end))
|
|
.on_action(cx.listener(Self::select_home))
|
|
.on_action(cx.listener(Self::select_end))
|
|
.on_action(cx.listener(Self::select_all))
|
|
.on_action(cx.listener(Self::copy))
|
|
.on_action(cx.listener(Self::cut))
|
|
.on_action(cx.listener(Self::paste))
|
|
.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))
|
|
.on_mouse_move(cx.listener(Self::mouse_move))
|
|
.on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
|
|
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
|
|
.child(TextElement::new(cx.entity()))
|
|
}
|
|
}
|