mod suggestion; use crate::theme::{ActiveTheme, Theme}; use gpui::prelude::*; use gpui::{Corner, anchored, deferred, point, px}; use std::sync::Arc; pub use suggestion::Suggestion; pub struct Input { id: gpui::ElementId, focus: gpui::FocusHandle, value: String, placeholder: gpui::SharedString, cursor_pos: usize, multiline: bool, suggestions: Vec, suggestions_open: bool, active_suggestion: usize, suggest: Option Vec + Send + Sync>>, on_change: Option) + Send + Sync>>, on_submit: Option) + Send + Sync>>, external_suggestions: bool, } impl Input { pub fn new( id: impl Into, cx: &mut gpui::Context, placeholder: impl Into, ) -> Self { Self { id: id.into(), focus: cx.focus_handle(), value: String::new(), placeholder: placeholder.into(), cursor_pos: 0, multiline: false, suggestions: vec![], suggestions_open: false, active_suggestion: 0, suggest: None, on_change: None, on_submit: None, external_suggestions: false, } } pub fn external_suggestions(mut self) -> Self { self.external_suggestions = true; self } pub fn with_suggest(mut self, f: Arc Vec + Send + Sync>) -> Self { self.suggest = Some(f); self } pub fn with_on_change( mut self, f: Arc) + Send + Sync>, ) -> Self { self.on_change = Some(f); self } pub fn multiline(mut self) -> Self { self.multiline = true; self } pub fn with_on_submit( mut self, f: Arc) + Send + Sync>, ) -> Self { self.on_submit = Some(f); self } pub fn value(&self) -> &str { &self.value } pub fn element_id(&self) -> &gpui::ElementId { &self.id } pub fn has_suggestions_open(&self) -> bool { self.suggestions_open } pub fn set_value(&mut self, value: impl Into, cx: &mut gpui::Context) { self.value = value.into(); self.cursor_pos = self.value.len(); self.refresh_suggestions(cx); cx.notify(); } pub fn set_value_silent(&mut self, value: impl Into, cx: &mut gpui::Context) { self.value = value.into(); self.cursor_pos = self.value.len(); self.suggestions_open = false; self.suggestions.clear(); cx.notify(); } pub fn clear(&mut self, cx: &mut gpui::Context) { self.set_value_silent("", cx); } pub fn focus(&self, window: &mut gpui::Window, cx: &mut gpui::Context) { window.focus(&self.focus); cx.notify(); } pub fn blur(&self, window: &mut gpui::Window, cx: &mut gpui::Context) { window.blur(); cx.notify(); } fn word_start_before(&self, pos: usize) -> usize { if pos == 0 { return 0; } let bytes = self.value.as_bytes(); let mut i = pos; while i > 0 && bytes[i - 1].is_ascii_whitespace() { i -= 1; } while i > 0 && !bytes[i - 1].is_ascii_whitespace() { i -= 1; } i } fn word_end_after(&self, pos: usize) -> usize { let len = self.value.len(); if pos >= len { return len; } let bytes = self.value.as_bytes(); let mut i = pos; while i < len && !bytes[i].is_ascii_whitespace() { i += 1; } while i < len && bytes[i].is_ascii_whitespace() { i += 1; } i } fn move_left(&mut self) { if self.cursor_pos > 0 { let mut new_pos = self.cursor_pos - 1; while new_pos > 0 && !self.value.is_char_boundary(new_pos) { new_pos -= 1; } self.cursor_pos = new_pos; } } fn move_right(&mut self) { if self.cursor_pos < self.value.len() { let mut new_pos = self.cursor_pos + 1; while new_pos < self.value.len() && !self.value.is_char_boundary(new_pos) { new_pos += 1; } self.cursor_pos = new_pos; } } fn refresh_suggestions(&mut self, cx: &mut gpui::Context) { if let Some(suggest) = &self.suggest { self.suggestions = suggest(&self.value); self.active_suggestion = 0; self.suggestions_open = !self.suggestions.is_empty() && !self.value.is_empty(); cx.notify(); } } fn open_suggestions(&mut self, cx: &mut gpui::Context) { if let Some(suggest) = &self.suggest { self.suggestions = suggest(&self.value); self.active_suggestion = 0; self.suggestions_open = !self.suggestions.is_empty() && !self.value.is_empty(); cx.notify(); } } fn accept_suggestion(&mut self, cx: &mut gpui::Context) { if !self.suggestions_open { return; } if let Some(s) = self.suggestions.get(self.active_suggestion).cloned() { self.value = s.insert.to_string(); self.cursor_pos = self.value.len(); self.suggestions_open = false; if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } cx.notify(); } } fn click_suggestion(&mut self, index: usize, cx: &mut gpui::Context) { if !self.suggestions_open { return; } if index >= self.suggestions.len() { return; } self.active_suggestion = index; self.accept_suggestion(cx); } fn submit(&mut self, cx: &mut gpui::Context) { self.suggestions_open = false; if let Some(on_submit) = self.on_submit.clone() { on_submit(&self.value, cx); } cx.notify(); } fn move_suggestion(&mut self, delta: isize, cx: &mut gpui::Context) { if !self.suggestions_open || self.suggestions.is_empty() { return; } let len = self.suggestions.len() as isize; let mut next = self.active_suggestion as isize + delta; if next < 0 { next = len - 1; } if next >= len { next = 0; } self.active_suggestion = next as usize; cx.notify(); } fn insert_text(&mut self, text: &str, cx: &mut gpui::Context) { self.value.insert_str(self.cursor_pos, text); self.cursor_pos += text.len(); if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } self.refresh_suggestions(cx); cx.notify(); } fn delete_backward(&mut self, cx: &mut gpui::Context) { if self.cursor_pos == 0 { return; } let old_pos = self.cursor_pos; self.move_left(); self.value.drain(self.cursor_pos..old_pos); if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } self.refresh_suggestions(cx); cx.notify(); } fn delete_forward(&mut self, cx: &mut gpui::Context) { if self.cursor_pos >= self.value.len() { return; } let mut end = self.cursor_pos + 1; while end < self.value.len() && !self.value.is_char_boundary(end) { end += 1; } self.value.drain(self.cursor_pos..end); if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } self.refresh_suggestions(cx); cx.notify(); } fn delete_word_backward(&mut self, cx: &mut gpui::Context) { if self.cursor_pos == 0 { return; } let word_start = self.word_start_before(self.cursor_pos); self.value.drain(word_start..self.cursor_pos); self.cursor_pos = word_start; if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } self.refresh_suggestions(cx); cx.notify(); } fn delete_word_forward(&mut self, cx: &mut gpui::Context) { if self.cursor_pos >= self.value.len() { return; } let word_end = self.word_end_after(self.cursor_pos); self.value.drain(self.cursor_pos..word_end); if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } self.refresh_suggestions(cx); cx.notify(); } fn cursor_line_info(&self) -> (usize, usize) { let mut line = 0; let mut line_start = 0; for (idx, ch) in self.value.char_indices() { if idx >= self.cursor_pos { break; } if ch == '\n' { line += 1; line_start = idx + ch.len_utf8(); } } (line, self.cursor_pos.saturating_sub(line_start)) } fn handle_key_down( &mut self, event: &gpui::KeyDownEvent, _window: &mut gpui::Window, cx: &mut gpui::Context, ) { // Note: This handler is only attached when the input has real focus // (see render() method), so we don't need to check is_focused here anymore let key = event.keystroke.key.as_str(); let ctrl = event.keystroke.modifiers.control; let shift = event.keystroke.modifiers.shift; if ctrl && (key == "h" || key == "l") { return; } match key { "enter" => { // For multiline, Enter inserts newline (modal handles exit via commands) if self.multiline { self.insert_text("\n", cx); return; } // For single-line, check suggestions then submit if self.suggestions_open { self.accept_suggestion(cx); } else { self.submit(cx); } } "escape" => { self.suggestions_open = false; cx.notify(); } "tab" => { if shift { if self.suggestions_open { self.move_suggestion(-1, cx); } } else { if self.suggestions_open { self.move_suggestion(1, cx); } else { self.open_suggestions(cx); } } } "up" => self.move_suggestion(-1, cx), "down" => self.move_suggestion(1, cx), "left" => { if ctrl { self.cursor_pos = self.word_start_before(self.cursor_pos); cx.notify(); } else { self.move_left(); cx.notify(); } } "right" => { if ctrl { self.cursor_pos = self.word_end_after(self.cursor_pos); cx.notify(); } else { self.move_right(); cx.notify(); } } "home" => { self.cursor_pos = 0; cx.notify(); } "end" => { self.cursor_pos = self.value.len(); cx.notify(); } "backspace" => { if ctrl { self.delete_word_backward(cx); } else { self.delete_backward(cx); } } "delete" => { if ctrl { self.delete_word_forward(cx); } else { self.delete_forward(cx); } } "w" if ctrl => { self.delete_word_backward(cx); } "a" if ctrl => { self.cursor_pos = 0; cx.notify(); } "e" if ctrl => { self.cursor_pos = self.value.len(); cx.notify(); } "u" if ctrl => { self.value.drain(0..self.cursor_pos); self.cursor_pos = 0; if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } self.refresh_suggestions(cx); cx.notify(); } "k" if ctrl => { self.value.truncate(self.cursor_pos); if let Some(on_change) = self.on_change.clone() { on_change(&self.value, cx); } self.refresh_suggestions(cx); cx.notify(); } _ => { if let Some(ch) = event.keystroke.key_char.as_ref() { if !ctrl && ch != "\n" && ch != "\r" && ch != "\t" { self.insert_text(ch, cx); } } } } } pub fn render_suggestions_external( &self, cx: &gpui::Context, ) -> Option { if !self.suggestions_open { return None; } let theme = cx.theme(); let items: Vec = self .suggestions .iter() .enumerate() .map(|(i, s)| { let is_active = i == self.active_suggestion; gpui::div() .on_mouse_down( gpui::MouseButton::Left, cx.listener(move |this, _e, _w, cx| { this.click_suggestion(i, cx); }), ) .cursor_pointer() .px_2() .py_1() .when(is_active, |el| el.bg(theme.selection)) .text_color(if is_active { theme.selection_foreground } else { theme.foreground }) .child(s.label.clone()) .into_any_element() }) .collect(); Some( gpui::div() .mt_1() .border_1() .border_color(theme.border) .bg(theme.panel) .rounded_md() .overflow_hidden() .children(items) .into_any_element(), ) } fn render_suggestions(&self, cx: &gpui::Context) -> impl IntoElement { if !self.suggestions_open || self.external_suggestions { return gpui::div().into_any_element(); } let theme = cx.theme(); let items: Vec = self .suggestions .iter() .enumerate() .map(|(i, s)| { let is_active = i == self.active_suggestion; gpui::div() .on_mouse_down( gpui::MouseButton::Left, cx.listener(move |this, _e, _w, cx| { this.click_suggestion(i, cx); }), ) .cursor_pointer() .px_2() .py_1() .when(is_active, |el| el.bg(theme.selection)) .text_color(if is_active { theme.selection_foreground } else { theme.foreground }) .child(s.label.clone()) .into_any_element() }) .collect(); let menu = gpui::div() .w_full() .border_1() .border_color(theme.border) .bg(theme.panel) .rounded_md() .overflow_hidden() .shadow_lg() .occlude() .children(items); deferred( anchored() .anchor(Corner::TopLeft) .offset(point(px(0.0), px(4.0))) .snap_to_window() .child(menu), ) .with_priority(1) .into_any_element() } fn render_multiline_content(&self, is_focused: bool, theme: &Theme) -> gpui::AnyElement { if self.value.is_empty() { let cursor = if is_focused { gpui::div().w_px().h_4().bg(theme.accent).into_any_element() } else { gpui::div().into_any_element() }; return gpui::div() .id(self.id.clone()) .flex() .flex_col() .items_start() .child( gpui::div().flex().items_center().child(cursor).child( gpui::div() .text_color(theme.muted) .child(self.placeholder.clone()), ), ) .into_any_element(); } let (cursor_line, cursor_col) = self.cursor_line_info(); let lines: Vec<&str> = self.value.split('\n').collect(); let mut rows = Vec::with_capacity(lines.len()); for (idx, line) in lines.iter().enumerate() { if is_focused && idx == cursor_line { let col = cursor_col.min(line.len()); let (before, after) = line.split_at(col); let after_text = if after.is_empty() { " " } else { after }; rows.push( gpui::div() .flex() .items_center() .child(before.to_string()) .child(gpui::div().w_px().h_4().bg(theme.accent)) .child(after_text.to_string()) .into_any_element(), ); } else { let text = if line.is_empty() { " " } else { line }; rows.push(gpui::div().child(text.to_string()).into_any_element()); } } gpui::div() .id(self.id.clone()) .flex() .flex_col() .items_start() .gap_1() .text_color(theme.foreground) .children(rows) .into_any_element() } } impl gpui::Render for Input { fn render( &mut self, window: &mut gpui::Window, cx: &mut gpui::Context, ) -> impl IntoElement { let theme = cx.theme(); let is_focused = self.focus.is_focused(window); let content = if self.multiline { self.render_multiline_content(is_focused, theme) } else if self.value.is_empty() { let cursor = if is_focused { gpui::div().w_px().h_4().bg(theme.accent).into_any_element() } else { gpui::div().into_any_element() }; gpui::div() .id(self.id.clone()) .flex() .flex_row() .items_center() .child(cursor) .child( gpui::div() .text_color(theme.muted) .child(self.placeholder.clone()), ) .into_any_element() } else { let before = &self.value[..self.cursor_pos]; let after = &self.value[self.cursor_pos..]; let cursor = if is_focused { gpui::div() .id(self.id.clone()) .w_px() .h_4() .bg(theme.accent) .into_any_element() } else { gpui::div().id(self.id.clone()).into_any_element() }; gpui::div() .id(self.id.clone()) .flex() .flex_row() .items_center() .child( gpui::div() .text_color(theme.foreground) .child(before.to_string()), ) .child(cursor) .child( gpui::div() .text_color(theme.foreground) .child(after.to_string()), ) .into_any_element() }; let focus_handle = self.focus.clone(); let base = gpui::div() .id(self.id.clone()) .key_context("Input") .track_focus(&self.focus) .on_mouse_down(gpui::MouseButton::Left, move |_ev, window, _cx| { window.focus(&focus_handle); }) .relative() .min_w(gpui::rems(12.)) .when(self.multiline, |el| el.min_h(gpui::rems(4.0))) .border_1() .border_color(if is_focused { theme.accent } else { theme.border }) .bg(theme.background) .rounded_md() .p_2() .cursor(gpui::CursorStyle::IBeam) .child(content) .child(self.render_suggestions(cx)); // Only attach key handler when this input actually has focus // This prevents the input from intercepting keyboard events // when it only has "visual focus" (focus ring) but not real focus if is_focused { base.on_key_down(cx.listener(Self::handle_key_down)) } else { base } } } impl gpui::Focusable for Input { fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { self.focus.clone() } }