T23c round 1 fix
- suggestions: arrows step past disabled rows and scroll the cursor into view; the cursor starts on an enabled row; Enter refuses a disabled one - active_trigger takes the query's word test: one word for mentions, any non-space for the composer, so a file name keeps its dot - DragDropOverlay takes the drop itself, which gives it the hitbox gpui needs to style a drag-over - ParameterPanel shows the places of the step, value and both ends - gallery: the prompt menu fills the draft; Reset restores the system prompt - AGENTS: drag-over styles need a hitbox; debug_bounds never clear
This commit is contained in:
1 parent
d362f08d41
commit
eb4e9422c9
10 files changed
+347
-100
No files matched your search
+33
-29
@@ -2,7 +2,7 @@ use std::{path::PathBuf, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, ExternalPaths, InteractiveElement, IntoElement,
|
||||
ParentElement, PathPromptOptions, RenderOnce, SharedString, Styled, Window, div,
|
||||
ParentElement, PathPromptOptions, RenderOnce, SharedString, Styled, Window, div, prelude::*,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
@@ -142,16 +142,17 @@ impl RenderOnce for InputHint {
|
||||
}
|
||||
}
|
||||
|
||||
/// While files hover the composer, a veil over it that says a drop attaches them; `group` names the composer.
|
||||
/// While files hover its parent, a veil over it that says a drop attaches them; it takes the drop. The parent is `relative`.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DragDropOverlay {
|
||||
group: SharedString,
|
||||
on_drop: OnPaths,
|
||||
}
|
||||
|
||||
impl DragDropOverlay {
|
||||
pub fn new(group: impl Into<SharedString>) -> Self {
|
||||
/// `on_drop` gets the files dropped; a drop with a folder is refused.
|
||||
pub fn new(on_drop: impl Fn(Vec<PathBuf>, &mut Window, &mut App) + 'static) -> Self {
|
||||
Self {
|
||||
group: group.into(),
|
||||
on_drop: Rc::new(on_drop),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,15 +161,24 @@ impl RenderOnce for DragDropOverlay {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let take = self.on_drop;
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.invisible()
|
||||
.group_drag_over::<ExternalPaths>(self.group, |style| style.visible())
|
||||
.drag_over::<ExternalPaths>(|style, _, _, _| style.visible())
|
||||
.on_drop(
|
||||
move |paths: &ExternalPaths, window, cx| match dropped(paths.paths(), true) {
|
||||
Ok(paths) => {
|
||||
log::info!("drag drop overlay: {} files dropped", paths.len());
|
||||
take(paths, window, cx)
|
||||
}
|
||||
Err(reason) => log::info!("drag drop overlay: refused a drop: {reason}"),
|
||||
},
|
||||
)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.rounded(theme.radius(Radius::Xl))
|
||||
.border_1()
|
||||
.border_dashed()
|
||||
@@ -177,11 +187,18 @@ impl RenderOnce for DragDropOverlay {
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::Upload)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
div()
|
||||
.debug_selector(|| "drag-drop-overlay".into())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::Upload)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_muted),
|
||||
)
|
||||
.child("Drop to attach"),
|
||||
)
|
||||
.child("Drop to attach")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +214,8 @@ fn offers(
|
||||
) -> 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 active = active_trigger(&text, caret, &['/', '@'], |ch| !ch.is_whitespace())
|
||||
.filter(|(at, mark)| *mark == '@' || *at == 0);
|
||||
let query = active.map_or(String::new(), |(at, mark)| {
|
||||
text[at + mark.len_utf8()..caret].to_lowercase()
|
||||
});
|
||||
@@ -326,7 +343,6 @@ impl RenderOnce for PromptInput {
|
||||
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(),
|
||||
@@ -347,7 +363,6 @@ impl RenderOnce for PromptInput {
|
||||
let busy = self.on_stop.is_some();
|
||||
let body = div()
|
||||
.id(self.id.clone())
|
||||
.group(group.clone())
|
||||
.relative()
|
||||
.w_full()
|
||||
.flex()
|
||||
@@ -388,19 +403,8 @@ impl RenderOnce for PromptInput {
|
||||
.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,
|
||||
}
|
||||
body.when_some(self.on_drop, |body, take| {
|
||||
body.child(DragDropOverlay { on_drop: take })
|
||||
})
|
||||
}
|
||||
}
|
||||
+45
-2
@@ -34,6 +34,15 @@ pub struct Parameter {
|
||||
|
||||
type OnParameter = Rc<dyn Fn(&SharedString, f64, &mut Window, &mut App)>;
|
||||
|
||||
/// Places that show the value, both ends and every step exactly.
|
||||
fn places(parameter: &Parameter) -> usize {
|
||||
[parameter.value, parameter.min, parameter.max]
|
||||
.into_iter()
|
||||
.filter(|number| *number != 0.0)
|
||||
.map(|number| decimals(number.abs()))
|
||||
.fold(decimals(parameter.step), usize::max)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -79,7 +88,7 @@ impl RenderOnce for ParameterPanel {
|
||||
.children(self.parameters.into_iter().map(|parameter| {
|
||||
let change = self.on_change.clone();
|
||||
let key = parameter.key.clone();
|
||||
let places = decimals(parameter.step);
|
||||
let places = places(¶meter);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -302,7 +311,7 @@ impl RenderOnce for SystemPromptEditor {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::cost;
|
||||
use super::{Parameter, cost, places};
|
||||
|
||||
#[test]
|
||||
fn cost_counts_tokens_at_their_prices() {
|
||||
@@ -310,4 +319,38 @@ mod tests {
|
||||
assert_eq!(cost(2_000, 1_000, 3.0, 15.0), 0.021);
|
||||
assert_eq!(cost(0, 0, 3.0, 15.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_shows_the_places_of_its_step_and_range() {
|
||||
let knob = |value, min, max, step| Parameter {
|
||||
key: "k".into(),
|
||||
label: "K".into(),
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
hint: None,
|
||||
};
|
||||
assert_eq!(
|
||||
places(&knob(0.25, 0.25, 1.25, 0.5)),
|
||||
2,
|
||||
"the range starts off the step's grid"
|
||||
);
|
||||
assert_eq!(
|
||||
places(&knob(1.0, 0.0, 1.25, 0.5)),
|
||||
2,
|
||||
"the top end needs two"
|
||||
);
|
||||
assert_eq!(places(&knob(0.7, 0.0, 2.0, 0.1)), 1);
|
||||
assert_eq!(
|
||||
places(&knob(-2.0, -2.0, 2.0, 1.0)),
|
||||
0,
|
||||
"whole numbers on both sides"
|
||||
);
|
||||
assert_eq!(
|
||||
places(&knob(0.1 + 0.2, 0.0, 1.0, 0.1)),
|
||||
1,
|
||||
"float noise adds no places"
|
||||
);
|
||||
}
|
||||
}
|
||||
+132
-12
@@ -1,19 +1,22 @@
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, TestAppContext, VisualTestContext, Window, div,
|
||||
AppContext as _, Context, Entity, ExternalPaths, FileDropEvent, Focusable, IntoElement,
|
||||
Modifiers, ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext,
|
||||
Window, div, point, px,
|
||||
};
|
||||
|
||||
use super::settle;
|
||||
use crate::{
|
||||
chat::PromptInput,
|
||||
forms::{self, Choice, TextInput},
|
||||
theme::Theme,
|
||||
motion,
|
||||
theme::{ActiveTheme, Theme},
|
||||
};
|
||||
|
||||
/// A composer over a field, and what it asked: sends, commands and context.
|
||||
struct Compose {
|
||||
field: Entity<TextInput>,
|
||||
busy: bool,
|
||||
commands: Vec<Choice>,
|
||||
sent: usize,
|
||||
picked: Vec<SharedString>,
|
||||
}
|
||||
@@ -24,13 +27,13 @@ impl Render for Compose {
|
||||
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())),
|
||||
)
|
||||
.commands(self.commands.clone(), 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()))
|
||||
});
|
||||
})
|
||||
.on_drop(|_, _, _| {});
|
||||
let input = if self.busy {
|
||||
input.busy(|_, _| {})
|
||||
} else {
|
||||
@@ -40,14 +43,20 @@ impl Render for Compose {
|
||||
}
|
||||
}
|
||||
|
||||
fn open(busy: bool, cx: &mut TestAppContext) -> (Entity<Compose>, &mut VisualTestContext) {
|
||||
fn open(
|
||||
busy: bool,
|
||||
commands: Vec<Choice>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Compose>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
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,
|
||||
commands,
|
||||
sent: 0,
|
||||
picked: Vec::new(),
|
||||
});
|
||||
@@ -60,13 +69,44 @@ fn open(busy: bool, cx: &mut TestAppContext) -> (Entity<Compose>, &mut VisualTes
|
||||
(compose, cx)
|
||||
}
|
||||
|
||||
fn summarize() -> Vec<Choice> {
|
||||
vec![Choice::new("summarize", "/summarize")]
|
||||
}
|
||||
|
||||
/// Commands `c00` to `c19`, some disabled.
|
||||
fn numbered(disabled: &[usize]) -> Vec<Choice> {
|
||||
(0..20)
|
||||
.map(|ix| {
|
||||
let choice = Choice::new(format!("c{ix:02}"), format!("/c{ix:02}"));
|
||||
if disabled.contains(&ix) {
|
||||
choice.disabled()
|
||||
} else {
|
||||
choice
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Frames 2ms apart, past reduced motion's 1ms entrances.
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
fn picked(compose: &Entity<Compose>, cx: &mut VisualTestContext) -> Vec<SharedString> {
|
||||
compose.read_with(cx, |compose, _| compose.picked.clone())
|
||||
}
|
||||
|
||||
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);
|
||||
let (compose, cx) = open(false, summarize(), cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
assert_eq!(
|
||||
compose.read_with(cx, |compose, _| compose.sent),
|
||||
@@ -90,7 +130,7 @@ fn enter_sends_and_shift_enter_breaks_the_line(cx: &mut TestAppContext) {
|
||||
|
||||
#[gpui::test]
|
||||
fn a_slash_picks_a_command_and_an_at_sign_picks_context(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(false, cx);
|
||||
let (compose, cx) = open(false, summarize(), cx);
|
||||
cx.simulate_input("/sum");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
@@ -109,10 +149,90 @@ fn a_slash_picks_a_command_and_an_at_sign_picks_context(cx: &mut TestAppContext)
|
||||
|
||||
#[gpui::test]
|
||||
fn a_busy_composer_holds_its_message(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(true, cx);
|
||||
let (compose, cx) = open(true, summarize(), 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");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_file_name_keeps_its_suggestions_past_a_dot(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(false, summarize(), cx);
|
||||
cx.simulate_input("see @lift.");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(text(&compose, cx), "see ");
|
||||
assert_eq!(picked(&compose, cx), ["lift"]);
|
||||
assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 0);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn arrows_pass_disabled_rows_and_keep_the_cursor_in_view(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(false, numbered(&[1]), cx);
|
||||
cx.simulate_input("/");
|
||||
frames(cx);
|
||||
for _ in 0..16 {
|
||||
cx.simulate_keystrokes("down");
|
||||
frames(cx);
|
||||
}
|
||||
let (anchor, height) = cx.update(|window, cx| {
|
||||
let anchor = compose.read(cx).field.read(cx).bounds_for(0);
|
||||
let height = cx.theme().list_max_height().to_pixels(window.rem_size());
|
||||
(anchor.expect("the slash is laid out"), height)
|
||||
});
|
||||
let foot = point(
|
||||
anchor.left() + px(24.0),
|
||||
anchor.bottom() + motion::NUDGE + height - px(8.0),
|
||||
);
|
||||
cx.simulate_mouse_move(foot, None, Modifiers::none());
|
||||
cx.simulate_click(foot, Modifiers::none());
|
||||
assert_eq!(
|
||||
picked(&compose, cx),
|
||||
["c17"],
|
||||
"sixteen steps past one disabled row land on the eighteenth, at the list's foot"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_cursor_starts_on_the_first_enabled_row(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(false, numbered(&[0]), cx);
|
||||
cx.simulate_input("/");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(picked(&compose, cx), ["c01"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_on_only_disabled_rows_picks_and_sends_nothing(cx: &mut TestAppContext) {
|
||||
let (compose, cx) = open(false, vec![Choice::new("c00", "/c00").disabled()], cx);
|
||||
cx.simulate_input("/");
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
settle(cx);
|
||||
assert_eq!(picked(&compose, cx), Vec::<SharedString>::new());
|
||||
assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 0);
|
||||
assert_eq!(text(&compose, cx), "/");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn files_over_the_composer_raise_the_veil(cx: &mut TestAppContext) {
|
||||
let (_, cx) = open(false, summarize(), cx);
|
||||
assert_eq!(
|
||||
cx.debug_bounds("drag-drop-overlay"),
|
||||
None,
|
||||
"no veil at rest"
|
||||
);
|
||||
cx.simulate_event(FileDropEvent::Entered {
|
||||
position: point(px(8.0), px(8.0)),
|
||||
paths: ExternalPaths::default(),
|
||||
});
|
||||
settle(cx);
|
||||
assert!(
|
||||
cx.debug_bounds("drag-drop-overlay").is_some(),
|
||||
"files over the composer raise the veil"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user