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:
ZacharyZhang-NY committed 2026-09-26 06:59:37 -04:00
1 parent d362f08d41
commit eb4e9422c9
10 files changed
+347 -100

No files matched your search

+2
View File
@@ -129,6 +129,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Clippy's `single_range_in_vec_init` rejects `[a..b]` and `vec![a..b]`; bind the range first. - Clippy's `single_range_in_vec_init` rejects `[a..b]` and `vec![a..b]`; bind the range first.
- A component built on `lists::Tree` fills its box; the host gives it a height. A fixed-width column beside a `flex_1` one takes `flex_none`, or it shrinks. - A component built on `lists::Tree` fills its box; the host gives it a height. A fixed-width column beside a `flex_1` one takes `flex_none`, or it shrinks.
- Editor rows read their text through `CodeEditor::row_text`: a multi-line ghost cuts its line, and the rest follows the ghost's last line. - Editor rows read their text through `CodeEditor::row_text`: a multi-line ghost cuts its line, and the rest follows the ghost's last line.
- gpui styles a drag-over only on an element with its own hitbox. A veil that shows while files hover takes the drop itself (`chat::DragDropOverlay`).
- gpui 0.2.2 never clears `debug_bounds`: a test sees an element appear, not leave.
## Decisions ## Decisions
+1
View File
@@ -261,3 +261,4 @@ Per-component lines live in `tasks/`. Tags there:
| T23b | 1 | FAIL | source cards and web results with a handler were no Tab stops; in a narrow column the sources row and a passage's header ran past their edge | | T23b | 1 | FAIL | source cards and web results with a handler were no Tab stops; in a narrow column the sources row and a passage's header ran past their edge |
| T23b | 2 | FAIL | the gallery's narrow passage built a vec of one range, which clippy refuses, so the checks failed | | T23b | 2 | FAIL | the gallery's narrow passage built a vec of one range, which clippy refuses, so the checks failed |
| T23b | 3 | PASS | | | T23b | 3 | PASS | |
| T23c | 1 | FAIL | suggestion arrows landed on disabled rows and left the cursor out of view; a dot ended a file mention; the drop veil never showed, since gpui styles a drag-over only on an element with a hitbox; a parameter's places came from its step alone; the gallery's prompt menu and Reset only logged |
+41 -20
View File
@@ -29,13 +29,15 @@ macro_rules! asset {
}; };
} }
fn field(key: &'static str, text: &str, window: &mut Window, cx: &mut App) -> Entity<TextInput> { const SYSTEM: &str = "You answer about color in plain words. Cite sources.";
let text = text.to_string();
window.use_keyed_state(key, cx, move |window, cx| { /// The composer's text, which the prompt menu fills.
pub fn draft(window: &mut Window, cx: &mut App) -> Entity<TextInput> {
window.use_keyed_state("chat-compose", cx, |window, cx| {
let mut input = TextInput::new(window, cx) let mut input = TextInput::new(window, cx)
.multi_line(1, 8) .multi_line(1, 8)
.placeholder("Ask anything, / for commands, @ for context"); .placeholder("Ask anything, / for commands, @ for context");
input.set_text(text, cx); input.set_text("Compare these two lifts in dark mode", cx);
input input
}) })
} }
@@ -64,13 +66,11 @@ fn attachments() -> Vec<Attachment> {
] ]
} }
pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { pub fn composer(
let draft = field( draft: &Entity<TextInput>,
"chat-compose", window: &mut Window,
"Compare these two lifts in dark mode", cx: &mut App,
window, ) -> impl IntoElement + use<> {
cx,
);
let files = keep("chat-attachments", attachments, window, cx); let files = keep("chat-attachments", attachments, window, cx);
let context = keep( let context = keep(
"chat-context", "chat-context",
@@ -131,7 +131,7 @@ pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
set(files, next, cx) set(files, next, cx)
}; };
let (sent, stopped, mentioned) = (busy.clone(), busy.clone(), context.clone()); let (sent, stopped, mentioned) = (busy.clone(), busy.clone(), context.clone());
let mut input = PromptInput::new("chat-composer", &draft, move |_, cx| set(&sent, true, cx)) let mut input = PromptInput::new("chat-composer", draft, move |_, cx| set(&sent, true, cx))
.above(chips) .above(chips)
.above(context_chips) .above(context_chips)
.tool(AttachmentButton::new("chat-attach", move |paths, _, cx| { .tool(AttachmentButton::new("chat-attach", move |paths, _, cx| {
@@ -195,7 +195,11 @@ fn levels() -> Vec<f32> {
.collect() .collect()
} }
pub fn pickers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { pub fn pickers(
draft: &Entity<TextInput>,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement + use<> {
let model = keep("chat-model", || SharedString::from("large"), window, cx); let model = keep("chat-model", || SharedString::from("large"), window, cx);
let mode = keep("chat-mode-pick", || SharedString::from("chat"), window, cx); let mode = keep("chat-mode-pick", || SharedString::from("chat"), window, cx);
let tools = keep("chat-tools", || (true, false, false), window, cx); let tools = keep("chat-tools", || (true, false, false), window, cx);
@@ -216,6 +220,13 @@ pub fn pickers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
set(&tools, (web, code, images), cx) set(&tools, (web, code, images), cx)
} }
}; };
let fill = |draft: &Entity<TextInput>, prompt: &'static str| {
let draft = draft.clone();
move |_: &mut Window, cx: &mut App| {
log::info!("gallery: prompt template");
draft.update(cx, |input, cx| input.set_text(prompt, cx))
}
};
section( section(
"ModelSelector / ModeSelector / ToolToggleMenu / PromptTemplateMenu", "ModelSelector / ModeSelector / ToolToggleMenu / PromptTemplateMenu",
"The model is a forms::Select with a note on each; the mode a buttons::SegmentedControl; tools and saved prompts are menus::DropdownMenu, one with checks and one that inserts a prompt.", "The model is a forms::Select with a note on each; the mode a buttons::SegmentedControl; tools and saved prompts are menus::DropdownMenu, one with checks and one that inserts a prompt.",
@@ -255,12 +266,15 @@ pub fn pickers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.item(MenuItem::check("Run code", code).on_click(flip(&tools, 1))) .item(MenuItem::check("Run code", code).on_click(flip(&tools, 1)))
.item(MenuItem::check("Make images", images).on_click(flip(&tools, 2))), .item(MenuItem::check("Make images", images).on_click(flip(&tools, 2))),
)) ))
.child(DropdownMenu::new( .child(probe(
"chat-templates", "chat-templates",
"Prompts", DropdownMenu::new(
Menu::new() "chat-templates",
.item(MenuItem::new("Review this code").on_click(|_, _| log::info!("gallery: template review"))) "Prompts",
.item(MenuItem::new("Explain like a textbook").on_click(|_, _| log::info!("gallery: template explain"))), Menu::new()
.item(MenuItem::new("Review this code").on_click(fill(draft, "Review the code below. List bugs first, then anything unclear, each with its line and a fix.")))
.item(MenuItem::new("Explain like a textbook").on_click(fill(draft, "Explain the idea below to a newcomer: define terms before using them, one idea per paragraph."))),
),
)), )),
) )
} }
@@ -270,9 +284,10 @@ pub fn tuning(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let (temperature, top_p, longest) = *values.read(cx); let (temperature, top_p, longest) = *values.read(cx);
let system = window.use_keyed_state("chat-system", cx, |window, cx| { let system = window.use_keyed_state("chat-system", cx, |window, cx| {
let mut input = TextInput::new(window, cx).multi_line(3, 8); let mut input = TextInput::new(window, cx).multi_line(3, 8);
input.set_text("You answer about color in plain words. Cite sources.", cx); input.set_text(SYSTEM, cx);
input input
}); });
let reset = system.clone();
let system_tokens = tokens(system.read(cx).text()); let system_tokens = tokens(system.read(cx).text());
let changed = values.clone(); let changed = values.clone();
section( section(
@@ -303,7 +318,13 @@ pub fn tuning(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
set(&changed, (t, p, l), cx) set(&changed, (t, p, l), cx)
}, },
)) ))
.child(SystemPromptEditor::new("chat-system", &system, system_tokens, 400).on_reset(|_, _| log::info!("gallery: reset system prompt"))) .child(probe(
"chat-system",
div().w(px(560.)).child(
SystemPromptEditor::new("chat-system", &system, system_tokens, 400)
.on_reset(move |_, cx| reset.update(cx, |input, cx| input.set_text(SYSTEM, cx))),
),
))
.child( .child(
UsageBar::new(200_000.0) UsageBar::new(200_000.0)
.part("System", 1_200.0) .part("System", 1_200.0)
+19 -2
View File
@@ -38,11 +38,28 @@ const SCRIPT: &[Step] = &[
Step::UpAt("chat-sources", 20.0, 10.0), Step::UpAt("chat-sources", 20.0, 10.0),
Step::Wait(300), Step::Wait(300),
Step::Shot("sources"), Step::Shot("sources"),
Step::DownAt("chat-templates", 30.0, 12.0),
Step::UpAt("chat-templates", 30.0, 12.0),
Step::Wait(200),
Step::Key("down"),
Step::Key("enter"),
Step::Wait(200),
Step::DownAt("chat-system", 160.0, 48.0),
Step::UpAt("chat-system", 160.0, 48.0),
Step::Key("cmd-a"),
Step::Type("Answer in one line."),
Step::Wait(200),
Step::Shot("prompts"),
Step::DownAt("chat-system", 548.0, 12.0),
Step::UpAt("chat-system", 548.0, 12.0),
Step::Wait(200),
Step::Shot("reset"),
Step::DownAt("chat-sources", 20.0, 10.0), Step::DownAt("chat-sources", 20.0, 10.0),
Step::UpAt("chat-sources", 20.0, 10.0), Step::UpAt("chat-sources", 20.0, 10.0),
]; ];
fn render(window: &mut Window, cx: &mut App) -> AnyElement { fn render(window: &mut Window, cx: &mut App) -> AnyElement {
let draft = compose::draft(window, cx);
div() div()
.child(talk::conversation(window, cx)) .child(talk::conversation(window, cx))
.child(talk::streaming(window, cx)) .child(talk::streaming(window, cx))
@@ -54,8 +71,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(controls::thinking(window, cx)) .child(controls::thinking(window, cx))
.child(cite::citations(window, cx)) .child(cite::citations(window, cx))
.child(cite::search(window, cx)) .child(cite::search(window, cx))
.child(compose::composer(window, cx)) .child(compose::composer(&draft, window, cx))
.child(compose::pickers(window, cx)) .child(compose::pickers(&draft, window, cx))
.child(compose::tuning(window, cx)) .child(compose::tuning(window, cx))
.into_any_element() .into_any_element()
} }
+33 -29
View File
@@ -2,7 +2,7 @@ use std::{path::PathBuf, rc::Rc};
use gpui::{ use gpui::{
AnyElement, App, ElementId, Entity, ExternalPaths, InteractiveElement, IntoElement, 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; 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)] #[derive(IntoElement)]
pub struct DragDropOverlay { pub struct DragDropOverlay {
group: SharedString, on_drop: OnPaths,
} }
impl DragDropOverlay { 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 { 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 { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme(); let theme = cx.theme();
let colors = theme.colors.clone(); let colors = theme.colors.clone();
let take = self.on_drop;
div() div()
.absolute() .absolute()
.inset_0() .inset_0()
.invisible() .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() .flex()
.items_center() .items_center()
.justify_center() .justify_center()
.gap_2()
.rounded(theme.radius(Radius::Xl)) .rounded(theme.radius(Radius::Xl))
.border_1() .border_1()
.border_dashed() .border_dashed()
@@ -177,11 +187,18 @@ impl RenderOnce for DragDropOverlay {
.text_size(theme.text_size(TextSize::Sm)) .text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted) .text_color(colors.fg_muted)
.child( .child(
Icon::new(IconName::Upload) div()
.size(IconSize::Sm) .debug_selector(|| "drag-drop-overlay".into())
.color(colors.fg_muted), .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 { ) -> Suggestions {
let input = field.read(cx); let input = field.read(cx);
let (text, caret) = (input.text().to_string(), input.cursor()); let (text, caret) = (input.text().to_string(), input.cursor());
let active = let active = active_trigger(&text, caret, &['/', '@'], |ch| !ch.is_whitespace())
active_trigger(&text, caret, &['/', '@']).filter(|(at, mark)| *mark == '@' || *at == 0); .filter(|(at, mark)| *mark == '@' || *at == 0);
let query = active.map_or(String::new(), |(at, mark)| { let query = active.map_or(String::new(), |(at, mark)| {
text[at + mark.len_utf8()..caret].to_lowercase() text[at + mark.len_utf8()..caret].to_lowercase()
}); });
@@ -326,7 +343,6 @@ impl RenderOnce for PromptInput {
let colors = theme.colors.clone(); let colors = theme.colors.clone();
let ready = !self.field.read(cx).text().trim().is_empty(); let ready = !self.field.read(cx).text().trim().is_empty();
let focused = self.field.read(cx).focus().contains_focused(window, cx); 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 (send, enter) = (self.on_send.clone(), self.on_send.clone());
let suggestions = offers( let suggestions = offers(
(self.id.clone(), "offers").into(), (self.id.clone(), "offers").into(),
@@ -347,7 +363,6 @@ impl RenderOnce for PromptInput {
let busy = self.on_stop.is_some(); let busy = self.on_stop.is_some();
let body = div() let body = div()
.id(self.id.clone()) .id(self.id.clone())
.group(group.clone())
.relative() .relative()
.w_full() .w_full()
.flex() .flex()
@@ -388,19 +403,8 @@ impl RenderOnce for PromptInput {
.child(div().flex_1()) .child(div().flex_1())
.child(button), .child(button),
); );
match self.on_drop { body.when_some(self.on_drop, |body, take| {
Some(take) => body body.child(DragDropOverlay { on_drop: take })
.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,
}
} }
} }
+45 -2
View File
@@ -34,6 +34,15 @@ pub struct Parameter {
type OnParameter = Rc<dyn Fn(&SharedString, f64, &mut Window, &mut App)>; 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. /// How answers are made, knob by knob: each a slider with its value, such as temperature, top p and the longest answer.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct ParameterPanel { pub struct ParameterPanel {
@@ -79,7 +88,7 @@ impl RenderOnce for ParameterPanel {
.children(self.parameters.into_iter().map(|parameter| { .children(self.parameters.into_iter().map(|parameter| {
let change = self.on_change.clone(); let change = self.on_change.clone();
let key = parameter.key.clone(); let key = parameter.key.clone();
let places = decimals(parameter.step); let places = places(&parameter);
div() div()
.flex() .flex()
.flex_col() .flex_col()
@@ -302,7 +311,7 @@ impl RenderOnce for SystemPromptEditor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::cost; use super::{Parameter, cost, places};
#[test] #[test]
fn cost_counts_tokens_at_their_prices() { 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(2_000, 1_000, 3.0, 15.0), 0.021);
assert_eq!(cost(0, 0, 3.0, 15.0), 0.0); 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
View File
@@ -1,19 +1,22 @@
use gpui::{ use gpui::{
AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement, Render, SharedString, AppContext as _, Context, Entity, ExternalPaths, FileDropEvent, Focusable, IntoElement,
Styled, TestAppContext, VisualTestContext, Window, div, Modifiers, ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext,
Window, div, point, px,
}; };
use super::settle; use super::settle;
use crate::{ use crate::{
chat::PromptInput, chat::PromptInput,
forms::{self, Choice, TextInput}, forms::{self, Choice, TextInput},
theme::Theme, motion,
theme::{ActiveTheme, Theme},
}; };
/// A composer over a field, and what it asked: sends, commands and context. /// A composer over a field, and what it asked: sends, commands and context.
struct Compose { struct Compose {
field: Entity<TextInput>, field: Entity<TextInput>,
busy: bool, busy: bool,
commands: Vec<Choice>,
sent: usize, sent: usize,
picked: Vec<SharedString>, picked: Vec<SharedString>,
} }
@@ -24,13 +27,13 @@ impl Render for Compose {
let input = PromptInput::new("compose", &self.field, move |_, cx| { let input = PromptInput::new("compose", &self.field, move |_, cx| {
send.update(cx, |compose, _| compose.sent += 1) send.update(cx, |compose, _| compose.sent += 1)
}) })
.commands( .commands(self.commands.clone(), move |value, _, cx| {
[Choice::new("summarize", "/summarize")], command.update(cx, |compose, _| compose.picked.push(value.clone()))
move |value, _, cx| command.update(cx, |compose, _| compose.picked.push(value.clone())), })
)
.context([Choice::new("lift", "lift.rs")], move |value, _, cx| { .context([Choice::new("lift", "lift.rs")], move |value, _, cx| {
mention.update(cx, |compose, _| compose.picked.push(value.clone())) mention.update(cx, |compose, _| compose.picked.push(value.clone()))
}); })
.on_drop(|_, _, _| {});
let input = if self.busy { let input = if self.busy {
input.busy(|_, _| {}) input.busy(|_, _| {})
} else { } 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| { cx.update(|cx| {
Theme::init(cx); Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
forms::bind_keys(cx); forms::bind_keys(cx);
}); });
let (compose, cx) = cx.add_window_view(move |window, cx| Compose { let (compose, cx) = cx.add_window_view(move |window, cx| Compose {
field: cx.new(|cx| TextInput::new(window, cx).multi_line(1, 8)), field: cx.new(|cx| TextInput::new(window, cx).multi_line(1, 8)),
busy, busy,
commands,
sent: 0, sent: 0,
picked: Vec::new(), picked: Vec::new(),
}); });
@@ -60,13 +69,44 @@ fn open(busy: bool, cx: &mut TestAppContext) -> (Entity<Compose>, &mut VisualTes
(compose, cx) (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 { fn text(compose: &Entity<Compose>, cx: &mut VisualTestContext) -> String {
cx.update(|_, cx| compose.read(cx).field.read(cx).text().to_string()) cx.update(|_, cx| compose.read(cx).field.read(cx).text().to_string())
} }
#[gpui::test] #[gpui::test]
fn enter_sends_and_shift_enter_breaks_the_line(cx: &mut TestAppContext) { 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"); cx.simulate_keystrokes("enter");
assert_eq!( assert_eq!(
compose.read_with(cx, |compose, _| compose.sent), 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] #[gpui::test]
fn a_slash_picks_a_command_and_an_at_sign_picks_context(cx: &mut TestAppContext) { 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"); cx.simulate_input("/sum");
settle(cx); settle(cx);
cx.simulate_keystrokes("enter"); 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] #[gpui::test]
fn a_busy_composer_holds_its_message(cx: &mut TestAppContext) { 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"); cx.simulate_input("wait");
settle(cx); settle(cx);
cx.simulate_keystrokes("enter"); cx.simulate_keystrokes("enter");
assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 0); assert_eq!(compose.read_with(cx, |compose, _| compose.sent), 0);
assert_eq!(text(&compose, cx), "wait"); 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"
);
}
+2 -2
View File
@@ -3,7 +3,7 @@ use std::rc::Rc;
use gpui::{App, ElementId, Entity, SharedString, Window}; use gpui::{App, ElementId, Entity, SharedString, Window};
use crate::forms::{ use crate::forms::{
Choice, Pick, Suggestions, TextInput, active_trigger, emoji_found, handles_matching, Choice, Pick, Suggestions, TextInput, active_trigger, emoji_found, handles_matching, one_word,
replace_trigger, replace_trigger,
}; };
@@ -36,7 +36,7 @@ pub(crate) fn offers(
let (text, caret) = (input.text().to_string(), input.cursor()); let (text, caret) = (input.text().to_string(), input.cursor());
let mut marks = vec!['@', ':']; let mut marks = vec!['@', ':'];
marks.extend(extra.as_ref().map(|(mark, _)| *mark)); marks.extend(extra.as_ref().map(|(mark, _)| *mark));
let active = active_trigger(&text, caret, &marks); let active = active_trigger(&text, caret, &marks, one_word);
let query = active.map_or("", |(at, mark)| &text[at + mark.len_utf8()..caret]); let query = active.map_or("", |(at, mark)| &text[at + mark.len_utf8()..caret]);
let (rows, pick): (Vec<Choice>, Pick) = match active { let (rows, pick): (Vec<Choice>, Pick) = match active {
Some((at, '@')) => { Some((at, '@')) => {
+69 -32
View File
@@ -2,20 +2,30 @@ use std::{ops::Range, rc::Rc};
use gpui::{ use gpui::{
App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Stateful, Styled, Window, div, prelude::*, ScrollHandle, SharedString, Stateful, Styled, Window, div, prelude::*,
}; };
use super::{ use super::{
Highlight, Input, TextInput, Highlight, Input, TextInput,
options::{Choice, Pick, Popup}, options::{Choice, Pick, Popup, step},
text::{Down, Enter, Up}, text::{Down, Enter, Up},
}; };
use crate::theme::ActiveTheme; use crate::theme::ActiveTheme;
const SHOWN: usize = 6; const SHOWN: usize = 6;
/// The trigger being typed: its byte offset and char, ending at `caret`. /// A character of a one-word handle.
pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Option<(usize, char)> { pub(crate) fn one_word(ch: char) -> bool {
ch.is_alphanumeric() || ch == '_'
}
/// The trigger being typed, its query all `word` characters: its byte offset and char, ending at `caret`.
pub(crate) fn active_trigger(
text: &str,
caret: usize,
triggers: &[char],
word: fn(char) -> bool,
) -> Option<(usize, char)> {
let before = &text[..caret]; let before = &text[..caret];
let (ix, ch) = before let (ix, ch) = before
.char_indices() .char_indices()
@@ -26,10 +36,8 @@ pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Opt
.chars() .chars()
.next_back() .next_back()
.is_none_or(char::is_whitespace); .is_none_or(char::is_whitespace);
let word = before[ix + ch.len_utf8()..] let query = before[ix + ch.len_utf8()..].chars().all(word);
.chars() (opens && query).then_some((ix, ch))
.all(|ch| ch.is_alphanumeric() || ch == '_');
(opens && word).then_some((ix, ch))
} }
/// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step. /// Replaces the trigger and its query, `at..caret`, with `text`, as one undo step.
@@ -98,9 +106,28 @@ pub fn mention_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)
struct Picking { struct Picking {
highlighted: usize, highlighted: usize,
dismissed: Option<usize>, dismissed: Option<usize>,
scroll: ScrollHandle,
} }
/// Rows offered at a trigger in a field's text, and what a pick does: arrows choose, Enter or a press picks, Escape dismisses until the trigger ends. /// The row the cursor rests on: `at` kept in range and off disabled rows.
fn usable(rows: &[Choice], at: usize) -> usize {
let at = at.min(rows.len().saturating_sub(1));
match rows.get(at) {
Some(row) if row.disabled => step(rows, at, 1),
_ => at,
}
}
/// Moves the cursor `by` rows past disabled ones and scrolls it into view.
fn move_cursor(picking: &Entity<Picking>, rows: &[Choice], by: isize, cx: &mut App) {
picking.update(cx, |picking, cx| {
picking.highlighted = step(rows, picking.highlighted, by);
picking.scroll.scroll_to_item(picking.highlighted);
cx.notify();
});
}
/// Rows offered at a trigger in a field's text, and what a pick does: arrows choose past disabled rows, Enter or a press picks, Escape dismisses until the trigger ends.
pub(crate) struct Suggestions { pub(crate) struct Suggestions {
pub id: ElementId, pub id: ElementId,
pub state: Entity<TextInput>, pub state: Entity<TextInput>,
@@ -124,13 +151,20 @@ impl Suggestions {
let open = focused let open = focused
&& !self.rows.is_empty() && !self.rows.is_empty()
&& self.trigger.is_some_and(|ix| dismissed != Some(ix)); && self.trigger.is_some_and(|ix| dismissed != Some(ix));
let count = self.rows.len(); let rows: Rc<[Choice]> = self.rows.into();
let highlighted = picking.read(cx).highlighted.min(count.saturating_sub(1)); let highlighted = usable(&rows, picking.read(cx).highlighted);
if highlighted != picking.read(cx).highlighted {
picking.update(cx, |picking, _| picking.highlighted = highlighted);
}
let scroll = picking.read(cx).scroll.clone();
log::debug!( log::debug!(
"suggestions {:?}: open {open}, {count} rows, anchor {anchor:?}", "suggestions {:?}: open {open}, {} rows, anchor {anchor:?}",
self.id self.id,
rows.len()
); );
let (up, down, escape) = (picking.clone(), picking.clone(), picking); let (up, down, chosen, escape) =
(picking.clone(), picking.clone(), picking.clone(), picking);
let (up_rows, down_rows, enter_rows) = (rows.clone(), rows.clone(), rows.clone());
let (enter, trigger) = (self.pick.clone(), self.trigger); let (enter, trigger) = (self.pick.clone(), self.trigger);
div() div()
.id(self.id) .id(self.id)
@@ -139,25 +173,24 @@ impl Suggestions {
.capture_action(move |_: &Up, _, cx| { .capture_action(move |_: &Up, _, cx| {
if open { if open {
cx.stop_propagation(); cx.stop_propagation();
up.update(cx, |picking, cx| { move_cursor(&up, &up_rows, -1, cx);
picking.highlighted = (picking.highlighted + count - 1) % count;
cx.notify();
});
} }
}) })
.capture_action(move |_: &Down, _, cx| { .capture_action(move |_: &Down, _, cx| {
if open { if open {
cx.stop_propagation(); cx.stop_propagation();
down.update(cx, |picking, cx| { move_cursor(&down, &down_rows, 1, cx);
picking.highlighted = (picking.highlighted + 1) % count;
cx.notify();
});
} }
}) })
.capture_action(move |_: &Enter, window, cx| { .capture_action(move |_: &Enter, window, cx| {
if open { if open {
cx.stop_propagation(); cx.stop_propagation();
enter(highlighted, window, cx); let at = chosen.read(cx).highlighted;
if enter_rows[at].disabled {
log::info!("suggestions: enter on disabled row {at}");
} else {
enter(at, window, cx);
}
} }
}) })
.on_key_down(move |event, _, cx| { .on_key_down(move |event, _, cx| {
@@ -175,12 +208,12 @@ impl Suggestions {
Popup { Popup {
id: "suggestions".into(), id: "suggestions".into(),
anchor, anchor,
rows: &self.rows, rows: &rows,
highlighted: Some(highlighted), highlighted: Some(highlighted),
checked: None, checked: None,
pick: self.pick, pick: self.pick,
dismiss: None, dismiss: None,
scroll: None, scroll: Some(&scroll),
} }
.render(window, cx), .render(window, cx),
) )
@@ -240,7 +273,7 @@ impl RenderOnce for MentionInput {
let input = self.state.read(cx); let input = self.state.read(cx);
let (text, caret) = (input.text().to_string(), input.cursor()); let (text, caret) = (input.text().to_string(), input.cursor());
let marks: Vec<char> = self.triggers.iter().map(|(trigger, _)| *trigger).collect(); let marks: Vec<char> = self.triggers.iter().map(|(trigger, _)| *trigger).collect();
let active = active_trigger(&text, caret, &marks); let active = active_trigger(&text, caret, &marks, one_word);
let matches: Vec<SharedString> = active let matches: Vec<SharedString> = active
.map(|(ix, trigger)| { .map(|(ix, trigger)| {
let names = &self let names = &self
@@ -288,16 +321,20 @@ impl RenderOnce for MentionInput {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{active_trigger, dismissal, mention_spans}; use super::{active_trigger, dismissal, mention_spans, one_word};
#[test] #[test]
fn triggers_open_after_space_and_close_on_space() { fn triggers_open_after_space_and_close_on_space() {
let marks = ['@', '#']; let marks = ['@', '#'];
assert_eq!(active_trigger("hi @ad", 6, &marks), Some((3, '@'))); let at = |text: &str| active_trigger(text, text.len(), &marks, one_word);
assert_eq!(active_trigger("@", 1, &marks), Some((0, '@'))); assert_eq!(at("hi @ad"), Some((3, '@')));
assert_eq!(active_trigger("mail@ad", 7, &marks), None); assert_eq!(at("@"), Some((0, '@')));
assert_eq!(active_trigger("hi @ada lovelace", 16, &marks), None); assert_eq!(at("mail@ad"), None);
assert_eq!(active_trigger("go #rel", 7, &marks), Some((3, '#'))); assert_eq!(at("hi @ada lovelace"), None);
assert_eq!(at("go #rel"), Some((3, '#')));
assert_eq!(at("see @lift."), None, "a handle is one word");
let file = active_trigger("see @lift.rs", 12, &marks, |ch| !ch.is_whitespace());
assert_eq!(file, Some((4, '@')), "a file name holds dots");
} }
#[test] #[test]
+3 -1
View File
@@ -76,7 +76,9 @@ pub use knob::Knob;
pub use listbox::ListBox; pub use listbox::ListBox;
pub use masked::{COUNTRIES, MaskedInput, PhoneInput}; pub use masked::{COUNTRIES, MaskedInput, PhoneInput};
pub use mention::{MentionInput, mention_highlights}; pub use mention::{MentionInput, mention_highlights};
pub(crate) use mention::{Suggestions, active_trigger, handles_matching, replace_trigger}; pub(crate) use mention::{
Suggestions, active_trigger, handles_matching, one_word, replace_trigger,
};
pub use multi::MultiSelect; pub use multi::MultiSelect;
pub use number::{NumberInput, ScrubInput}; pub use number::{NumberInput, ScrubInput};
pub use options::Choice; pub use options::Choice;