use gpui::prelude::FluentBuilder as _; use gpui::{ App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, Window, div, px, }; use gpui_base::InputBase; use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode}; use theme::{ActiveTheme, button_radius}; use crate::indicator::Indicator; use crate::input::clear_button; use crate::{Sizable, Size, StyleSized, StyledExt, h_flex, v_flex}; #[cfg(any(target_os = "linux", target_os = "freebsd"))] fn primary_paste_offset( value: &str, position: gpui::Point, mut caret_bounds: impl FnMut(usize) -> Option>, ) -> Option { value .char_indices() .map(|(offset, _)| offset) .chain(std::iter::once(value.len())) .filter_map(|offset| { let bounds = caret_bounds(offset)?; let vertical_distance = if position.y < bounds.top() { bounds.top() - position.y } else if position.y > bounds.bottom() { position.y - bounds.bottom() } else { px(0.) }; Some(( offset, vertical_distance, (position.x - bounds.left()).abs(), )) }) .min_by(|left, right| left.1.cmp(&right.1).then_with(|| left.2.cmp(&right.2))) .map(|(offset, _, _)| offset) } /// The background of an input frame, which reads muted while the input is disabled. fn input_background(disabled: bool, cx: &App) -> Hsla { if disabled { cx.theme().surface } else { cx.theme().input_bg } } /// The colors base paints input text with, read from the coop theme. fn input_editor_style(cx: &App) -> InputEditorStyle { let theme = cx.theme(); InputEditorStyle { foreground: theme.text, muted_foreground: theme.text_faint, background: theme.input_bg, border: theme.border_faint, selection: theme.selection, caret: theme.caret, ..InputEditorStyle::default() } } /// The input's own padding, resolved to pixels. fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges { let mut probe = div().input_px(size).input_py(size).refine_style(style); let padding = probe.style().padding.clone(); let base_size = window.text_style().font_size; let rem_size = window.rem_size(); let resolve = |value: Option| { value .map(|value| value.to_pixels(base_size, rem_size)) .unwrap_or(px(0.)) }; Edges { left: resolve(padding.left), right: resolve(padding.right), top: resolve(padding.top), bottom: resolve(padding.bottom), } } /// A text input element bound to an [`InputState`] or a [`TextareaState`]. #[derive(IntoElement)] pub struct Input { state: Entity>, style: StyleRefinement, size: Size, appearance: bool, cleanable: bool, disabled: bool, } /// A styled multi-line text input. pub type Textarea = Input; impl Sizable for Input { fn with_size(mut self, size: impl Into) -> Self { self.size = size.into(); self } } impl Input { /// Create a new [`Input`] element bind to the given state. pub fn new(state: &Entity>) -> Self { Self { state: state.clone(), size: Size::default(), style: StyleRefinement::default(), appearance: true, cleanable: false, disabled: false, } } /// Set the appearance of the input field, if false the input field will no border, background. pub fn appearance(mut self, appearance: bool) -> Self { self.appearance = appearance; self } /// Set whether to show the clear button when the input field is not empty, default is false. pub fn cleanable(mut self, cleanable: bool) -> Self { self.cleanable = cleanable; self } /// Set to disable the input field. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } } impl Styled for Input { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } impl RenderOnce for Input { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { const LINE_HEIGHT: Rems = Rems(1.25); let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); let multi_line = self.state.read(cx).is_multi_line(); let editor_paddings = if multi_line { input_paddings(self.size, &self.style, window) } else { Edges::default() }; self.state.update(cx, |state, cx| { state.set_editor_style(input_editor_style(cx)); state.set_editor_paddings(editor_paddings); state.set_disabled(self.disabled, cx); if state.is_single_line() { state.set_text_align(text_align, cx); } }); let state = self.state.read(cx); let presentation = state.presentation(); let disabled = presentation.is_disabled(); let loading = presentation.is_loading(); let text_is_empty = state.text().len() == 0; let gap_x = match self.size { Size::Small => px(4.), Size::Large => px(8.), _ => px(6.), }; let background = input_background(disabled, cx); let show_clear_button = self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line; let has_suffix = loading || show_clear_button; let state_entity = self.state.clone(); InputBase::new(("input", self.state.entity_id())) .flex() .size_full() .line_height(LINE_HEIGHT) .when(!multi_line, |this| { this.input_px(self.size).input_py(self.size) }) .input_h(self.size) .when(!disabled, |this| this.cursor_text()) .on_mouse_down(MouseButton::Left, { let state_entity = state_entity.clone(); move |_, window, cx| state_entity.update(cx, |state, cx| state.focus(window, cx)) }) .when( cfg!(any(target_os = "linux", target_os = "freebsd")), |this| { #[cfg(any(target_os = "linux", target_os = "freebsd"))] let this = this.on_mouse_down(MouseButton::Middle, { let state_entity = state_entity.clone(); move |event, window, cx| { if disabled || !state_entity.read(cx).is_editable() { return; } // Read before changing focus or selection: those can change PRIMARY. let Some(text) = cx.read_from_primary().and_then(|item| item.text()) else { return; }; state_entity.update(cx, |state, cx| { let value = state.value(); let Some(offset) = primary_paste_offset(&value, event.position, |offset| { state.range_to_bounds(&(offset..offset)) }) else { return; }; let text = if state.is_multi_line() { text } else { text.replace(['\r', '\n'], "") }; state.focus(window, cx); state.set_selected_range(offset..offset, cx); state.insert(text, window, cx); }); cx.stop_propagation(); } }); this }, ) .items_center() .when(multi_line, |this| this.h_auto()) .when(self.appearance, |this| { this.bg(background) .when(self.disabled, |this| this.opacity(0.5)) .rounded(px(button_radius())) }) .tab_index(0) .gap(gap_x) .refine_style(&self.style) .when(!multi_line, |this| this.child(state_entity.clone())) .when(multi_line, |this| { this.child( v_flex() .size_full() .child(div().relative().flex_1().child(state_entity.clone())), ) }) .when(has_suffix, |this| { this.pr_2().child( h_flex() .id("suffix") .gap(gap_x) .items_center() .when(loading, |this| this.child(Indicator::new())) .when(show_clear_button, |this| { this.child(clear_button(cx).on_click({ let state = state_entity.clone(); move |_, window, cx| { state.update(cx, |state, cx| { state.clean(window, cx); state.focus(window, cx); }) } })) }), ) }) } } #[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] mod tests { use super::*; use crate::input::InputState; use gpui::{AppContext, ClipboardItem, Context, Render, TestAppContext, point}; struct TestInput { input: Entity, disabled: bool, } impl Render for TestInput { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .w(px(300.)) .h(px(40.)) .child(Input::new(&self.input).disabled(self.disabled)) } } #[gpui::test] fn middle_click_pastes_primary_at_clicked_position(cx: &mut TestAppContext) { cx.update(|cx| { theme::init(cx); crate::init(cx); }); let (view, cx) = cx.add_window_view(|window, cx| TestInput { input: cx.new(|cx| InputState::new(window, cx).default_value("aéz")), disabled: false, }); let input = view.read_with(cx, |view, _| view.input.clone()); cx.update(|window, cx| { window.draw(cx).clear(cx); cx.write_to_clipboard(ClipboardItem::new_string("wrong clipboard".into())); cx.write_to_primary(ClipboardItem::new_string("nsec1test\r\n".into())); }); let position = input.read_with(cx, |state, _| { state.range_to_bounds(&(3..3)).map(|bounds| bounds.center()) }); assert!(position.is_some()); if let Some(position) = position { cx.simulate_mouse_down(position, MouseButton::Middle, Default::default()); cx.simulate_mouse_up(position, MouseButton::Middle, Default::default()); } assert_eq!( input.read_with(cx, |state, _| state.value()), "aénsec1testz" ); cx.update(|window, cx| { window.dispatch_action(Box::new(gpui_base::input::Undo), cx); }); assert_eq!(input.read_with(cx, |state, _| state.value()), "aéz"); cx.update(|window, cx| { input.update(cx, |state, cx| state.set_value("", window, cx)); window.draw(cx).clear(cx); }); cx.simulate_mouse_down( point(px(200.), px(20.)), MouseButton::Middle, Default::default(), ); cx.simulate_mouse_up( point(px(200.), px(20.)), MouseButton::Middle, Default::default(), ); assert_eq!(input.read_with(cx, |state, _| state.value()), "nsec1test"); } #[gpui::test] fn clipboard_shortcuts_paste_without_using_primary(cx: &mut TestAppContext) { cx.update(|cx| { theme::init(cx); crate::init(cx); }); let (view, cx) = cx.add_window_view(|window, cx| TestInput { input: cx.new(|cx| InputState::new(window, cx)), disabled: false, }); let input = view.read_with(cx, |view, _| view.input.clone()); cx.update(|window, cx| { input.update(cx, |state, cx| state.focus(window, cx)); window.draw(cx).clear(cx); cx.write_to_clipboard(ClipboardItem::new_string("clipboard".into())); cx.write_to_primary(ClipboardItem::new_string("primary".into())); }); cx.simulate_keystrokes("shift-insert"); assert_eq!(input.read_with(cx, |state, _| state.value()), "clipboard"); cx.simulate_keystrokes("ctrl-a ctrl-v"); assert_eq!(input.read_with(cx, |state, _| state.value()), "clipboard"); } #[test] fn primary_paste_uses_visible_unicode_carets_and_clicked_row() { let offset = primary_paste_offset("é\nx", point(px(8.), px(35.)), |offset| { let (x, y) = match offset { 0 => return None, 2 => (10., 0.), 3 => (0., 20.), 4 => (10., 20.), _ => panic!("offset must be a UTF-8 character boundary"), }; Some(gpui::Bounds::new( point(px(x), px(y)), gpui::size(px(0.), px(20.)), )) }); assert_eq!(offset, Some(4)); assert_eq!( primary_paste_offset("text", point(px(0.), px(0.)), |_| None), None ); } #[gpui::test] fn middle_click_preserves_disabled_and_readonly_fields(cx: &mut TestAppContext) { cx.update(|cx| { theme::init(cx); crate::init(cx); }); let (view, cx) = cx.add_window_view(|window, cx| TestInput { input: cx.new(|cx| InputState::new(window, cx).default_value("unchanged")), disabled: true, }); let input = view.read_with(cx, |view, _| view.input.clone()); cx.update(|window, cx| { window.draw(cx).clear(cx); cx.write_to_primary(ClipboardItem::new_string("must not paste".into())); }); cx.simulate_mouse_down( point(px(20.), px(20.)), MouseButton::Middle, Default::default(), ); cx.simulate_mouse_up( point(px(20.), px(20.)), MouseButton::Middle, Default::default(), ); assert_eq!(input.read_with(cx, |state, _| state.value()), "unchanged"); cx.update(|window, cx| { view.update(cx, |view, cx| { view.disabled = false; cx.notify(); }); input.update(cx, |state, cx| state.set_readonly(true, cx)); window.draw(cx).clear(cx); }); cx.simulate_mouse_down( point(px(20.), px(20.)), MouseButton::Middle, Default::default(), ); cx.simulate_mouse_up( point(px(20.), px(20.)), MouseButton::Middle, Default::default(), ); assert_eq!(input.read_with(cx, |state, _| state.value()), "unchanged"); } }