From 4fafba4b2016132738b8bbc4859f791b20caa3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 06:59:37 -0400 Subject: [PATCH] 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 --- AGENTS.md | 2 + TASKS.md | 1 + examples/gallery/pages/chat/compose.rs | 61 +++++++---- examples/gallery/pages/chat/mod.rs | 21 +++- src/chat/composer.rs | 62 ++++++----- src/chat/params.rs | 47 +++++++- src/chat/tests/compose.rs | 144 ++++++++++++++++++++++--- src/documents/suggest.rs | 4 +- src/forms/mention.rs | 101 +++++++++++------ src/forms/mod.rs | 4 +- 10 files changed, 347 insertions(+), 100 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 57b1346..9f43da1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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. +- 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 diff --git a/TASKS.md b/TASKS.md index d08b49d..a6bcd5c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | 2 | FAIL | the gallery's narrow passage built a vec of one range, which clippy refuses, so the checks failed | | 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 | diff --git a/examples/gallery/pages/chat/compose.rs b/examples/gallery/pages/chat/compose.rs index 1f1600c..350bfbe 100644 --- a/examples/gallery/pages/chat/compose.rs +++ b/examples/gallery/pages/chat/compose.rs @@ -29,13 +29,15 @@ macro_rules! asset { }; } -fn field(key: &'static str, text: &str, window: &mut Window, cx: &mut App) -> Entity { - let text = text.to_string(); - window.use_keyed_state(key, cx, move |window, cx| { +const SYSTEM: &str = "You answer about color in plain words. Cite sources."; + +/// The composer's text, which the prompt menu fills. +pub fn draft(window: &mut Window, cx: &mut App) -> Entity { + window.use_keyed_state("chat-compose", cx, |window, cx| { let mut input = TextInput::new(window, cx) .multi_line(1, 8) .placeholder("Ask anything, / for commands, @ for context"); - input.set_text(text, cx); + input.set_text("Compare these two lifts in dark mode", cx); input }) } @@ -64,13 +66,11 @@ fn attachments() -> Vec { ] } -pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { - let draft = field( - "chat-compose", - "Compare these two lifts in dark mode", - window, - cx, - ); +pub fn composer( + draft: &Entity, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement + use<> { let files = keep("chat-attachments", attachments, window, cx); let context = keep( "chat-context", @@ -131,7 +131,7 @@ pub fn composer(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { set(files, next, cx) }; 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(context_chips) .tool(AttachmentButton::new("chat-attach", move |paths, _, cx| { @@ -195,7 +195,11 @@ fn levels() -> Vec { .collect() } -pub fn pickers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { +pub fn pickers( + draft: &Entity, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement + use<> { let model = keep("chat-model", || SharedString::from("large"), window, cx); let mode = keep("chat-mode-pick", || SharedString::from("chat"), 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) } }; + let fill = |draft: &Entity, 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( "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.", @@ -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("Make images", images).on_click(flip(&tools, 2))), )) - .child(DropdownMenu::new( + .child(probe( "chat-templates", - "Prompts", - Menu::new() - .item(MenuItem::new("Review this code").on_click(|_, _| log::info!("gallery: template review"))) - .item(MenuItem::new("Explain like a textbook").on_click(|_, _| log::info!("gallery: template explain"))), + DropdownMenu::new( + "chat-templates", + "Prompts", + 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 system = window.use_keyed_state("chat-system", cx, |window, cx| { 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 }); + let reset = system.clone(); let system_tokens = tokens(system.read(cx).text()); let changed = values.clone(); section( @@ -303,7 +318,13 @@ pub fn tuning(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { 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( UsageBar::new(200_000.0) .part("System", 1_200.0) diff --git a/examples/gallery/pages/chat/mod.rs b/examples/gallery/pages/chat/mod.rs index a39b719..19fbe67 100644 --- a/examples/gallery/pages/chat/mod.rs +++ b/examples/gallery/pages/chat/mod.rs @@ -38,11 +38,28 @@ const SCRIPT: &[Step] = &[ Step::UpAt("chat-sources", 20.0, 10.0), Step::Wait(300), 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::UpAt("chat-sources", 20.0, 10.0), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { + let draft = compose::draft(window, cx); div() .child(talk::conversation(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(cite::citations(window, cx)) .child(cite::search(window, cx)) - .child(compose::composer(window, cx)) - .child(compose::pickers(window, cx)) + .child(compose::composer(&draft, window, cx)) + .child(compose::pickers(&draft, window, cx)) .child(compose::tuning(window, cx)) .into_any_element() } diff --git a/src/chat/composer.rs b/src/chat/composer.rs index 92194ab..5d0b05b 100644 --- a/src/chat/composer.rs +++ b/src/chat/composer.rs @@ -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) -> Self { + /// `on_drop` gets the files dropped; a drop with a folder is refused. + pub fn new(on_drop: impl Fn(Vec, &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::(self.group, |style| style.visible()) + .drag_over::(|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 }) + }) } } diff --git a/src/chat/params.rs b/src/chat/params.rs index ab710d4..22b787c 100644 --- a/src/chat/params.rs +++ b/src/chat/params.rs @@ -34,6 +34,15 @@ pub struct Parameter { type OnParameter = Rc; +/// 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" + ); + } } diff --git a/src/chat/tests/compose.rs b/src/chat/tests/compose.rs index fd01159..21f15f4 100644 --- a/src/chat/tests/compose.rs +++ b/src/chat/tests/compose.rs @@ -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, busy: bool, + commands: Vec, sent: usize, picked: Vec, } @@ -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, &mut VisualTestContext) { +fn open( + busy: bool, + commands: Vec, + cx: &mut TestAppContext, +) -> (Entity, &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, &mut VisualTes (compose, cx) } +fn summarize() -> Vec { + vec![Choice::new("summarize", "/summarize")] +} + +/// Commands `c00` to `c19`, some disabled. +fn numbered(disabled: &[usize]) -> Vec { + (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, cx: &mut VisualTestContext) -> Vec { + compose.read_with(cx, |compose, _| compose.picked.clone()) +} + fn text(compose: &Entity, 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::::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" + ); +} diff --git a/src/documents/suggest.rs b/src/documents/suggest.rs index 9cb399b..c21238f 100644 --- a/src/documents/suggest.rs +++ b/src/documents/suggest.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::{App, ElementId, Entity, SharedString, Window}; 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, }; @@ -36,7 +36,7 @@ pub(crate) fn offers( let (text, caret) = (input.text().to_string(), input.cursor()); let mut marks = vec!['@', ':']; 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 (rows, pick): (Vec, Pick) = match active { Some((at, '@')) => { diff --git a/src/forms/mention.rs b/src/forms/mention.rs index 3181e08..a719363 100644 --- a/src/forms/mention.rs +++ b/src/forms/mention.rs @@ -2,20 +2,30 @@ use std::{ops::Range, rc::Rc}; use gpui::{ App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, - SharedString, Stateful, Styled, Window, div, prelude::*, + ScrollHandle, SharedString, Stateful, Styled, Window, div, prelude::*, }; use super::{ Highlight, Input, TextInput, - options::{Choice, Pick, Popup}, + options::{Choice, Pick, Popup, step}, text::{Down, Enter, Up}, }; use crate::theme::ActiveTheme; const SHOWN: usize = 6; -/// The trigger being typed: its byte offset and char, ending at `caret`. -pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Option<(usize, char)> { +/// A character of a one-word handle. +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 (ix, ch) = before .char_indices() @@ -26,10 +36,8 @@ pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Opt .chars() .next_back() .is_none_or(char::is_whitespace); - let word = before[ix + ch.len_utf8()..] - .chars() - .all(|ch| ch.is_alphanumeric() || ch == '_'); - (opens && word).then_some((ix, ch)) + let query = before[ix + ch.len_utf8()..].chars().all(word); + (opens && query).then_some((ix, ch)) } /// 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, Highlight) struct Picking { highlighted: usize, dismissed: Option, + 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, 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 id: ElementId, pub state: Entity, @@ -124,13 +151,20 @@ impl Suggestions { let open = focused && !self.rows.is_empty() && self.trigger.is_some_and(|ix| dismissed != Some(ix)); - let count = self.rows.len(); - let highlighted = picking.read(cx).highlighted.min(count.saturating_sub(1)); + let rows: Rc<[Choice]> = self.rows.into(); + 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!( - "suggestions {:?}: open {open}, {count} rows, anchor {anchor:?}", - self.id + "suggestions {:?}: open {open}, {} rows, anchor {anchor:?}", + 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); div() .id(self.id) @@ -139,25 +173,24 @@ impl Suggestions { .capture_action(move |_: &Up, _, cx| { if open { cx.stop_propagation(); - up.update(cx, |picking, cx| { - picking.highlighted = (picking.highlighted + count - 1) % count; - cx.notify(); - }); + move_cursor(&up, &up_rows, -1, cx); } }) .capture_action(move |_: &Down, _, cx| { if open { cx.stop_propagation(); - down.update(cx, |picking, cx| { - picking.highlighted = (picking.highlighted + 1) % count; - cx.notify(); - }); + move_cursor(&down, &down_rows, 1, cx); } }) .capture_action(move |_: &Enter, window, cx| { if open { 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| { @@ -175,12 +208,12 @@ impl Suggestions { Popup { id: "suggestions".into(), anchor, - rows: &self.rows, + rows: &rows, highlighted: Some(highlighted), checked: None, pick: self.pick, dismiss: None, - scroll: None, + scroll: Some(&scroll), } .render(window, cx), ) @@ -240,7 +273,7 @@ impl RenderOnce for MentionInput { let input = self.state.read(cx); let (text, caret) = (input.text().to_string(), input.cursor()); let marks: Vec = 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 = active .map(|(ix, trigger)| { let names = &self @@ -288,16 +321,20 @@ impl RenderOnce for MentionInput { #[cfg(test)] mod tests { - use super::{active_trigger, dismissal, mention_spans}; + use super::{active_trigger, dismissal, mention_spans, one_word}; #[test] fn triggers_open_after_space_and_close_on_space() { let marks = ['@', '#']; - assert_eq!(active_trigger("hi @ad", 6, &marks), Some((3, '@'))); - assert_eq!(active_trigger("@", 1, &marks), Some((0, '@'))); - assert_eq!(active_trigger("mail@ad", 7, &marks), None); - assert_eq!(active_trigger("hi @ada lovelace", 16, &marks), None); - assert_eq!(active_trigger("go #rel", 7, &marks), Some((3, '#'))); + let at = |text: &str| active_trigger(text, text.len(), &marks, one_word); + assert_eq!(at("hi @ad"), Some((3, '@'))); + assert_eq!(at("@"), Some((0, '@'))); + assert_eq!(at("mail@ad"), None); + 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] diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 9092551..04fd1c5 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -76,7 +76,9 @@ 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, replace_trigger}; +pub(crate) use mention::{ + Suggestions, active_trigger, handles_matching, one_word, replace_trigger, +}; pub use multi::MultiSelect; pub use number::{NumberInput, ScrubInput}; pub use options::Choice;