From 9288dc91c2db2bcbebec81e8868acd3b8d0fcc90 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 9 Dec 2024 20:17:05 +0800 Subject: [PATCH] input: Add to support multi-line Input. (#463) --- Cargo.lock | 13 +- crates/story/Cargo.toml | 1 + crates/story/src/input_story.rs | 35 ++ crates/ui/src/input/element.rs | 537 ++++++++++++++++++++++ crates/ui/src/input/input.rs | 766 ++++++++++++++++++-------------- crates/ui/src/input/mod.rs | 1 + 6 files changed, 1009 insertions(+), 344 deletions(-) create mode 100644 crates/ui/src/input/element.rs diff --git a/Cargo.lock b/Cargo.lock index 9cfa5778..9cbf9a65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -474,7 +474,7 @@ dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", - "itertools 0.11.0", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -3380,7 +3380,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.2.0", "proc-macro2", "quote", "syn 2.0.86", @@ -5013,6 +5013,7 @@ dependencies = [ "serde", "serde_json", "ui", + "unindent", ] [[package]] @@ -5701,6 +5702,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + [[package]] name = "url" version = "2.5.2" diff --git a/crates/story/Cargo.toml b/crates/story/Cargo.toml index df147f39..536472a6 100644 --- a/crates/story/Cargo.toml +++ b/crates/story/Cargo.toml @@ -12,6 +12,7 @@ regex = "1" chrono = "0.4" serde = "1" serde_json = "1" +unindent = "0.2.3" [lints] workspace = true diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index cec9a8fc..7fa7a76b 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -31,6 +31,7 @@ pub fn init(cx: &mut AppContext) { pub struct InputStory { input1: View, input2: View, + textarea: View, number_input1_value: i64, number_input1: View, number_input2: View, @@ -83,6 +84,38 @@ impl InputStory { let input2 = cx.new_view(|cx| TextInput::new(cx).placeholder("Enter text here...")); cx.subscribe(&input2, Self::on_input_event).detach(); + let textarea = cx.new_view(|cx| { + let mut input = TextInput::new(cx) + .multi_line() + .rows(10) + .placeholder("Enter text here..."); + input.set_text( + unindent::unindent( + r#"Hello 世界,this is GPUI component. + + The GPUI Component is a collection of UI components for GPUI framework, including. + + Button, Input, Checkbox, Radio, Dropdown, Tab, and more... + + Here is an application that is built by using GPUI Component. + + > This application is still under development, not published yet. + + ![image](https://github.com/user-attachments/assets/559a648d-19df-4b5a-b563-b78cc79c8894) + + ![image](https://github.com/user-attachments/assets/5e06ad5d-7ea0-43db-8d13-86a240da4c8d) + + ## Demo + + If you want to see the demo, here is a some demo applications. + "#, + ), + cx, + ); + input + }); + cx.subscribe(&textarea, Self::on_input_event).detach(); + let number_input1_value = 1; let number_input1 = cx.new_view(|cx| { let input = NumberInput::new(cx).placeholder("Number Input", cx); @@ -143,6 +176,7 @@ impl InputStory { Self { input1, input2, + textarea, number_input1, number_input1_value, number_input2, @@ -342,6 +376,7 @@ impl Render for InputStory { .child(self.number_input2.clone()), ), ) + .child(section("Textarea", cx).child(self.textarea.clone())) .child( section("Input State", cx) .child(self.disabled_input.clone()) diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs new file mode 100644 index 00000000..5cf77ee1 --- /dev/null +++ b/crates/ui/src/input/element.rs @@ -0,0 +1,537 @@ +use gpui::{ + fill, point, px, relative, size, Bounds, Corners, Element, ElementId, ElementInputHandler, + GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path, Pixels, + Point, Style, TextRun, UnderlineStyle, View, WindowContext, WrappedLine, +}; +use smallvec::SmallVec; + +use crate::theme::ActiveTheme as _; + +use super::TextInput; + +const RIGHT_MARGIN: Pixels = px(5.); +const CURSOR_INSET: Pixels = px(0.5); + +pub(super) struct TextElement { + input: View, +} + +impl TextElement { + pub(super) fn new(input: View) -> Self { + Self { input } + } + + fn paint_mouse_listeners(&mut self, cx: &mut WindowContext) { + cx.on_mouse_event({ + let input = self.input.clone(); + + move |event: &MouseMoveEvent, _, cx| { + if event.pressed_button == Some(MouseButton::Left) { + input.update(cx, |input, cx| { + input.on_drag_move(event, cx); + }); + } + } + }); + } + + fn layout_cursor( + &self, + lines: &[WrappedLine], + line_height: Pixels, + bounds: &mut Bounds, + cx: &mut WindowContext, + ) -> (Option, Point) { + let input = self.input.read(cx); + let selected_range = &input.selected_range; + let cursor_offset = input.cursor_offset(); + let mut scroll_offset = input.scroll_handle.offset(); + let mut cursor = None; + + // The cursor corresponds to the current cursor position in the text no only the line. + let mut cursor_pos = None; + let mut cursor_start = None; + let mut cursor_end = None; + + let mut prev_lines_offset = 0; + let mut offset_y = px(0.); + for line in lines.iter() { + // break loop if all cursor positions are found + if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() { + break; + } + + let line_origin = point(px(0.), offset_y); + if cursor_pos.is_none() { + let offset = cursor_offset.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(offset, line_height) { + cursor_pos = Some(line_origin + pos); + } + } + if cursor_start.is_none() { + let offset = selected_range.start.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(offset, line_height) { + cursor_start = Some(line_origin + pos); + } + } + if cursor_end.is_none() { + let offset = selected_range.end.saturating_sub(prev_lines_offset); + if let Some(pos) = line.position_for_index(offset, line_height) { + cursor_end = Some(line_origin + pos); + } + } + + offset_y += line.size(line_height).height; + // +1 for skip the last `\n` + prev_lines_offset += line.len() + 1; + } + + if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) = + (cursor_pos, cursor_start, cursor_end) + { + let cursor_moved = input.last_cursor_offset != Some(cursor_offset); + let selection_changed = input.last_selected_range != Some(selected_range.clone()); + + if cursor_moved || selection_changed { + scroll_offset.x = + if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) { + // cursor is out of right + bounds.size.width - RIGHT_MARGIN - cursor_pos.x + } else if scroll_offset.x + cursor_pos.x < px(0.) { + // cursor is out of left + scroll_offset.x - cursor_pos.x + } else { + scroll_offset.x + }; + scroll_offset.y = if scroll_offset.y + cursor_pos.y > (bounds.size.height) { + // cursor is out of bottom + bounds.size.height - cursor_pos.y + } else if scroll_offset.y + cursor_pos.y < px(0.) { + // cursor is out of top + scroll_offset.y - cursor_pos.y + } else { + scroll_offset.y + }; + + if input.selection_reversed { + if scroll_offset.x + cursor_start.x < px(0.) { + // selection start is out of left + scroll_offset.x = -cursor_start.x; + } + if scroll_offset.y + cursor_start.y < px(0.) { + // selection start is out of top + scroll_offset.y = -cursor_start.y; + } + } else { + if scroll_offset.x + cursor_end.x <= px(0.) { + // selection end is out of left + scroll_offset.x = -cursor_end.x; + } + if scroll_offset.y + cursor_end.y <= px(0.) { + // selection end is out of top + scroll_offset.y = -cursor_end.y; + } + } + } + + bounds.origin = bounds.origin + scroll_offset; + + if input.show_cursor(cx) { + // cursor blink + cursor = Some(fill( + Bounds::new( + point( + bounds.left() + cursor_pos.x, + bounds.top() + cursor_pos.y + CURSOR_INSET, + ), + size(px(1.5), line_height), + ), + crate::blue_500(), + )) + }; + } + + (cursor, scroll_offset) + } + + fn layout_selections( + &self, + lines: &[WrappedLine], + line_height: Pixels, + bounds: &mut Bounds, + cx: &mut WindowContext, + ) -> Option> { + let input = self.input.read(cx); + let selected_range = &input.selected_range; + if selected_range.is_empty() { + return None; + } + + let (start_ix, end_ix) = if selected_range.start < selected_range.end { + (selected_range.start, selected_range.end) + } else { + (selected_range.end, selected_range.start) + }; + + let mut prev_lines_offset = 0; + let mut line_corners = vec![]; + + let mut offset_y = px(0.); + for line in lines.iter() { + let line_size = line.size(line_height); + let line_wrap_width = line_size.width; + + let line_origin = point(px(0.), offset_y); + + let line_cursor_start = + line.position_for_index(start_ix.saturating_sub(prev_lines_offset), line_height); + let line_cursor_end = + line.position_for_index(end_ix.saturating_sub(prev_lines_offset), line_height); + + if line_cursor_start.is_some() || line_cursor_end.is_some() { + let start = line_cursor_start + .unwrap_or_else(|| line.position_for_index(0, line_height).unwrap()); + + let end = line_cursor_end + .unwrap_or_else(|| line.position_for_index(line.len(), line_height).unwrap()); + + // Split the selection into multiple items + let wrapped_lines = + (end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize; + + let mut end_x = end.x; + if wrapped_lines > 0 { + end_x = line_wrap_width; + } + + line_corners.push(Corners { + top_left: line_origin + point(start.x, start.y), + top_right: line_origin + point(end_x, start.y), + bottom_left: line_origin + point(start.x, start.y + line_height), + bottom_right: line_origin + point(end_x, start.y + line_height), + }); + + // wrapped lines + for i in 1..=wrapped_lines { + let start = point(px(0.), start.y + i as f32 * line_height); + let mut end = point(end.x, end.y + i as f32 * line_height); + if i < wrapped_lines { + end.x = line_size.width; + } + + line_corners.push(Corners { + top_left: line_origin + point(start.x, start.y), + top_right: line_origin + point(end.x, start.y), + bottom_left: line_origin + point(start.x, start.y + line_height), + bottom_right: line_origin + point(end.x, start.y + line_height), + }); + } + } + + if line_cursor_start.is_some() && line_cursor_end.is_some() { + break; + } + + offset_y += line_size.height; + // +1 for skip the last `\n` + prev_lines_offset += line.len() + 1; + } + + let mut points = vec![]; + if line_corners.is_empty() { + return None; + } + + // Fix corners to make sure the left to right direction + for corners in &mut line_corners { + if corners.top_left.x > corners.top_right.x { + std::mem::swap(&mut corners.top_left, &mut corners.top_right); + std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right); + } + } + + for corners in &line_corners { + points.push(corners.top_right); + points.push(corners.bottom_right); + points.push(corners.bottom_left); + } + + let mut rev_line_corners = line_corners.iter().rev().peekable(); + while let Some(corners) = rev_line_corners.next() { + points.push(corners.top_left); + if let Some(next) = rev_line_corners.peek() { + if next.top_left.x > corners.top_left.x { + points.push(point(next.top_left.x, corners.top_left.y)); + } + } + } + + // print_points_as_svg_path(&line_corners, &points); + + let first_p = *points.get(0).unwrap(); + let mut path = gpui::Path::new(bounds.origin + first_p); + for p in points.iter().skip(1) { + path.line_to(bounds.origin + *p); + } + Some(path) + } +} + +pub(super) struct PrepaintState { + lines: SmallVec<[WrappedLine; 1]>, + cursor: Option, + cursor_scroll_offset: Point, + selection_path: Option>, + bounds: Bounds, +} + +impl IntoElement for TextElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// A debug function to print points as SVG path. +#[allow(unused)] +fn print_points_as_svg_path( + line_corners: &Vec>>, + points: &Vec>, +) { + for corners in line_corners { + println!( + "tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})", + corners.top_left.x.0 as i32, + corners.top_left.y.0 as i32, + corners.top_right.x.0 as i32, + corners.top_right.y.0 as i32, + corners.bottom_left.x.0 as i32, + corners.bottom_left.y.0 as i32, + corners.bottom_right.x.0 as i32, + corners.bottom_right.y.0 as i32, + ); + } + + if points.len() > 0 { + println!("M{},{}", points[0].x.0 as i32, points[0].y.0 as i32); + for p in points.iter().skip(1) { + println!("L{},{}", p.x.0 as i32, p.y.0 as i32); + } + } +} + +impl Element for TextElement { + type RequestLayoutState = (); + type PrepaintState = PrepaintState; + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + cx: &mut WindowContext, + ) -> (LayoutId, Self::RequestLayoutState) { + let input = self.input.read(cx); + let mut style = Style::default(); + style.size.width = relative(1.).into(); + if self.input.read(cx).is_multi_line() { + style.size.height = relative(1.).into(); + style.min_size.height = (input.rows.max(1) as f32 * cx.line_height()).into(); + } else { + style.size.height = cx.line_height().into(); + }; + (cx.request_layout(style, []), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState { + let multi_line = self.input.read(cx).is_multi_line(); + let line_height = cx.line_height(); + let input = self.input.read(cx); + let text = input.text.clone(); + let placeholder = input.placeholder.clone(); + let style = cx.text_style(); + let mut bounds = bounds; + + let (display_text, text_color) = if text.is_empty() { + (placeholder, cx.theme().muted_foreground) + } else if input.masked { + ( + "*".repeat(text.chars().count()).into(), + cx.theme().foreground, + ) + } else { + (text, cx.theme().foreground) + }; + + let run = TextRun { + len: display_text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + + let runs = if let Some(marked_range) = input.marked_range.as_ref() { + vec![ + TextRun { + len: marked_range.start, + ..run.clone() + }, + TextRun { + len: marked_range.end - marked_range.start, + underline: Some(UnderlineStyle { + color: Some(run.color), + thickness: px(1.0), + wavy: false, + }), + ..run.clone() + }, + TextRun { + len: display_text.len() - marked_range.end, + ..run.clone() + }, + ] + .into_iter() + .filter(|run| run.len > 0) + .collect() + } else { + vec![run] + }; + + let font_size = style.font_size.to_pixels(cx.rem_size()); + let wrap_width = if multi_line { + Some(bounds.size.width - RIGHT_MARGIN) + } else { + None + }; + + let lines = cx + .text_system() + .shape_text(display_text, font_size, &runs, wrap_width) + .unwrap(); + + // `position_for_index` for example + // + // #### text + // + // Hello 世界,this is GPUI component. + // The GPUI Component is a collection of UI components for + // GPUI framework, including Button, Input, Checkbox, Radio, + // Dropdown, Tab, and more... + // + // wrap_width: 444px, line_height: 20px + // + // #### lines[0] + // + // | index | pos | line | + // |-------|------------------|------| + // | 5 | (37 px, 0.0) | 0 | + // | 38 | (261.7 px, 20.0) | 0 | + // | 40 | None | - | + // + // #### lines[1] + // + // | index | position | line | + // |-------|-----------------------|------| + // | 5 | (43.578125 px, 0.0) | 0 | + // | 56 | (422.21094 px, 0.0) | 0 | + // | 57 | (11.6328125 px, 20.0) | 1 | + // | 114 | (429.85938 px, 20.0) | 1 | + // | 115 | (11.3125 px, 40.0) | 2 | + + // Calculate the scroll offset to keep the cursor in view + + let (cursor, cursor_scroll_offset) = + self.layout_cursor(&lines, line_height, &mut bounds, cx); + + let selection_path = self.layout_selections(&lines, line_height, &mut bounds, cx); + + PrepaintState { + bounds, + lines, + cursor, + cursor_scroll_offset, + selection_path, + } + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + input_bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, + ) { + let focus_handle = self.input.read(cx).focus_handle.clone(); + let focused = focus_handle.is_focused(cx); + let bounds = prepaint.bounds; + let selected_range = self.input.read(cx).selected_range.clone(); + + cx.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.input.clone()), + ); + + // Paint selections + if let Some(path) = prepaint.selection_path.take() { + cx.paint_path(path, cx.theme().selection); + } + + // Paint multi line text + let line_height = cx.line_height(); + let origin = bounds.origin; + + let mut offset_y = px(0.); + for line in prepaint.lines.iter() { + let p = point(origin.x, origin.y + offset_y); + _ = line.paint(p, line_height, cx); + offset_y += line.size(line_height).height; + } + + if focused { + if let Some(cursor) = prepaint.cursor.take() { + cx.paint_quad(cursor); + } + } + + let width = prepaint + .lines + .iter() + .map(|l| l.width()) + .max() + .unwrap_or_default(); + let height = prepaint + .lines + .iter() + .map(|l| l.size(line_height).height.0) + .sum::(); + + let scroll_size = size(width, px(height)); + + self.input.update(cx, |input, _cx| { + input.last_layout = Some(prepaint.lines.clone()); + input.last_bounds = Some(bounds); + input.last_cursor_offset = Some(input.cursor_offset()); + input.last_line_height = line_height; + input.input_bounds = input_bounds; + input.last_selected_range = Some(selected_range); + input + .scroll_handle + .set_offset(prepaint.cursor_scroll_offset); + input.scroll_size = scroll_size; + }); + + self.paint_mouse_listeners(cx); + } +} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 24f3b1fc..46274b56 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -3,37 +3,53 @@ //! Based on the `Input` example from the `gpui` crate. //! https://github.com/zed-industries/zed/blob/main/crates/gpui/examples/input.rs +use smallvec::SmallVec; +use std::cell::Cell; use std::ops::Range; +use std::rc::Rc; +use unicode_segmentation::*; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + actions, div, point, px, AnyElement, AppContext, Bounds, ClickEvent, ClipboardItem, + Context as _, Entity, EventEmitter, FocusHandle, FocusableView, Half, InteractiveElement as _, + IntoElement, KeyBinding, KeyDownEvent, Model, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement as _, Pixels, Point, Rems, Render, ScrollHandle, ScrollWheelEvent, + SharedString, Styled as _, UTF16Selection, ViewContext, ViewInputHandler, WindowContext, + WrappedLine, +}; + +// TODO: +// - Press Up,Down to move cursor up, down line if multi-line +// - Move cursor to skip line eof empty chars. use super::blink_cursor::BlinkCursor; use super::change::Change; +use super::element::TextElement; use super::ClearButton; + use crate::history::History; use crate::indicator::Indicator; +use crate::scroll::{Scrollbar, ScrollbarAxis, ScrollbarState}; use crate::theme::ActiveTheme; -use crate::StyledExt as _; -use crate::{event::InteractiveElementExt as _, Size}; +use crate::Size; +use crate::StyledExt; use crate::{Sizable, StyleSized}; -use gpui::prelude::FluentBuilder as _; -use gpui::{ - actions, div, fill, point, px, relative, rems, size, AnyElement, AppContext, Bounds, - ClickEvent, ClipboardItem, Context as _, Element, ElementId, ElementInputHandler, EventEmitter, - FocusHandle, FocusableView, GlobalElementId, InteractiveElement as _, IntoElement, KeyBinding, - KeyDownEvent, LayoutId, Model, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - PaintQuad, ParentElement as _, Pixels, Point, Render, ShapedLine, SharedString, Style, - Styled as _, TextRun, UTF16Selection, UnderlineStyle, View, ViewContext, ViewInputHandler, - WindowContext, -}; -use unicode_segmentation::*; actions!( input, [ Backspace, Delete, + DeleteToBeginningOfLine, + DeleteToEndOfLine, Enter, + Up, + Down, Left, Right, + SelectUp, + SelectDown, SelectLeft, SelectRight, SelectAll, @@ -67,16 +83,28 @@ pub fn init(cx: &mut AppContext) { cx.bind_keys([ KeyBinding::new("backspace", Backspace, Some(CONTEXT)), KeyBinding::new("delete", Delete, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-delete", DeleteToEndOfLine, Some(CONTEXT)), KeyBinding::new("enter", Enter, Some(CONTEXT)), + KeyBinding::new("up", Up, Some(CONTEXT)), + KeyBinding::new("down", Down, Some(CONTEXT)), KeyBinding::new("left", Left, Some(CONTEXT)), KeyBinding::new("right", Right, Some(CONTEXT)), KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)), KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)), + KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)), + KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)), KeyBinding::new("home", Home, Some(CONTEXT)), KeyBinding::new("end", End, Some(CONTEXT)), KeyBinding::new("shift-home", SelectToHome, Some(CONTEXT)), KeyBinding::new("shift-end", SelectToEnd, Some(CONTEXT)), #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-shift-a", SelectToHome, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-shift-e", SelectToEnd, Some(CONTEXT)), + #[cfg(target_os = "macos")] KeyBinding::new("shift-cmd-left", SelectToHome, Some(CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("shift-cmd-right", SelectToEnd, Some(CONTEXT)), @@ -118,30 +146,42 @@ pub fn init(cx: &mut AppContext) { } pub struct TextInput { - focus_handle: FocusHandle, - text: SharedString, - history: History, - blink_cursor: Model, - prefix: Option) -> AnyElement + 'static>>, - suffix: Option) -> AnyElement + 'static>>, - loading: bool, - placeholder: SharedString, - selected_range: Range, - selection_reversed: bool, - marked_range: Option>, - last_layout: Option, - last_bounds: Option>, - last_cursor_offset: Option, - last_selected_range: Option>, - scroll_offset: Point, - is_selecting: bool, - disabled: bool, - masked: bool, - appearance: bool, - cleanable: bool, - size: Size, + pub(super) focus_handle: FocusHandle, + pub(super) text: SharedString, + multi_line: bool, + pub(super) history: History, + pub(super) blink_cursor: Model, + pub(super) prefix: Option) -> AnyElement + 'static>>, + pub(super) suffix: Option) -> AnyElement + 'static>>, + pub(super) loading: bool, + pub(super) placeholder: SharedString, + pub(super) selected_range: Range, + /// Range for save the selected word, use to keep word range when drag move. + pub(super) selected_word_range: Option>, + pub(super) selection_reversed: bool, + pub(super) marked_range: Option>, + pub(super) last_layout: Option>, + pub(super) last_cursor_offset: Option, + /// The line_height of text layout, this will change will InputElement painted. + pub(super) last_line_height: Pixels, + /// The input container bounds + pub(super) input_bounds: Bounds, + /// The text bounds + pub(super) last_bounds: Option>, + pub(super) last_selected_range: Option>, + pub(super) is_selecting: bool, + pub(super) disabled: bool, + pub(super) masked: bool, + pub(super) appearance: bool, + pub(super) cleanable: bool, + pub(super) size: Size, + pub(super) rows: usize, pattern: Option, validate: Option bool + 'static>>, + pub(crate) scroll_handle: ScrollHandle, + scrollbar_state: Rc>, + /// The size of the scrollable content. + pub(crate) scroll_size: gpui::Size, } impl EventEmitter for TextInput {} @@ -154,17 +194,15 @@ impl TextInput { let input = Self { focus_handle: focus_handle.clone(), text: "".into(), + multi_line: false, blink_cursor, history, placeholder: "".into(), selected_range: 0..0, + selected_word_range: None, selection_reversed: false, marked_range: None, - last_layout: None, - last_bounds: None, - last_cursor_offset: None, - last_selected_range: None, - scroll_offset: point(px(0.), px(0.)), + input_bounds: Bounds::default(), is_selecting: false, disabled: false, masked: false, @@ -176,6 +214,15 @@ impl TextInput { size: Size::Medium, pattern: None, validate: None, + rows: 2, + last_layout: None, + last_bounds: None, + last_selected_range: None, + last_line_height: px(20.), + last_cursor_offset: None, + scroll_handle: ScrollHandle::new(), + scrollbar_state: Rc::new(Cell::new(ScrollbarState::default())), + scroll_size: gpui::size(px(0.), px(0.)), }; // Observe the blink cursor to repaint the view when it changes. @@ -200,11 +247,41 @@ impl TextInput { input } + /// Use the text input field as a multi-line Textarea. + pub fn multi_line(mut self) -> Self { + self.multi_line = true; + self + } + + #[inline] + pub(super) fn is_multi_line(&self) -> bool { + self.multi_line + } + + #[inline] + pub(super) fn is_single_line(&self) -> bool { + !self.multi_line + } + + /// Set the number of rows for the multi-line Textarea. + /// + /// This is only used when `multi_line` is set to true. + /// + /// default: 2 + pub fn rows(mut self, rows: usize) -> Self { + self.rows = rows; + self + } + /// Set the text of the input field. + /// + /// And the selection_range will be reset to 0..0. pub fn set_text(&mut self, text: impl Into, cx: &mut ViewContext) { self.history.ignore = true; self.replace_text(text, cx); self.history.ignore = false; + // Ensure cursor to start when set text + self.selected_range = 0..0; cx.notify(); } @@ -351,6 +428,26 @@ impl TextInput { } } + fn up(&mut self, _: &Up, cx: &mut ViewContext) { + if self.is_single_line() { + return; + } + self.pause_blink_cursor(cx); + + let offset = self.start_of_line(cx).saturating_sub(1); + self.move_to(offset, cx); + } + + fn down(&mut self, _: &Down, cx: &mut ViewContext) { + if self.is_single_line() { + return; + } + self.pause_blink_cursor(cx); + + let offset = (self.end_of_line(cx) + 1).min(self.text.len()); + self.move_to(offset, cx); + } + fn select_left(&mut self, _: &SelectLeft, cx: &mut ViewContext) { self.select_to(self.previous_boundary(self.cursor_offset()), cx); } @@ -359,6 +456,22 @@ impl TextInput { self.select_to(self.next_boundary(self.cursor_offset()), cx); } + fn select_up(&mut self, _: &SelectUp, cx: &mut ViewContext) { + if self.is_single_line() { + return; + } + let offset = self.start_of_line(cx).saturating_sub(1); + self.select_to(offset, cx); + } + + fn select_down(&mut self, _: &SelectDown, cx: &mut ViewContext) { + if self.is_single_line() { + return; + } + let offset = (self.end_of_line(cx) + 1).min(self.text.len()); + self.select_to(offset, cx); + } + fn select_all(&mut self, _: &SelectAll, cx: &mut ViewContext) { self.move_to(0, cx); self.select_to(self.text.len(), cx) @@ -366,20 +479,69 @@ impl TextInput { fn home(&mut self, _: &Home, cx: &mut ViewContext) { self.pause_blink_cursor(cx); - self.move_to(0, cx); + let offset = self.start_of_line(cx); + self.move_to(offset, cx); } fn end(&mut self, _: &End, cx: &mut ViewContext) { self.pause_blink_cursor(cx); - self.move_to(self.text.len(), cx); + let offset = self.end_of_line(cx); + self.move_to(offset, cx); } fn select_to_home(&mut self, _: &SelectToHome, cx: &mut ViewContext) { - self.select_to(0, cx); + let offset = self.start_of_line(cx); + self.select_to(offset, cx); } fn select_to_end(&mut self, _: &SelectToEnd, cx: &mut ViewContext) { - self.select_to(self.text.len(), cx); + let offset = self.end_of_line(cx); + self.select_to(offset, cx); + } + + /// Get start of line + fn start_of_line(&mut self, cx: &mut ViewContext) -> usize { + if self.is_single_line() { + return 0; + } + + let offset = self.previous_boundary(self.cursor_offset()); + let line = self + .text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, cx) + .unwrap_or_default() + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + line + } + + /// Get end of line + fn end_of_line(&mut self, cx: &mut ViewContext) -> usize { + if self.is_single_line() { + return self.text.len(); + } + + let offset = self.next_boundary(self.cursor_offset()); + // ignore if offset is "\n" + if self + .text_for_range(self.range_to_utf16(&(offset - 1..offset)), &mut None, cx) + .unwrap_or_default() + .eq("\n") + { + return offset; + } + + let line = self + .text_for_range( + self.range_to_utf16(&(offset..self.text.len())), + &mut None, + cx, + ) + .unwrap_or_default() + .find('\n') + .map(|i| i + offset) + .unwrap_or(self.text.len()); + line } fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext) { @@ -398,7 +560,38 @@ impl TextInput { self.pause_blink_cursor(cx); } + fn delete_to_beginning_of_line( + &mut self, + _: &DeleteToBeginningOfLine, + cx: &mut ViewContext, + ) { + let offset = self.start_of_line(cx); + self.replace_text_in_range( + Some(self.range_to_utf16(&(offset..self.cursor_offset()))), + "", + cx, + ); + self.pause_blink_cursor(cx); + } + + fn delete_to_end_of_line(&mut self, _: &DeleteToEndOfLine, cx: &mut ViewContext) { + let offset = self.end_of_line(cx); + self.replace_text_in_range( + Some(self.range_to_utf16(&(self.cursor_offset()..offset))), + "", + cx, + ); + self.pause_blink_cursor(cx); + } + fn enter(&mut self, _: &Enter, cx: &mut ViewContext) { + if self.is_multi_line() { + self.replace_text_in_range(None, "\n", cx); + // Move cursor to the start of the next line + // TODO: To be test this line is valid + self.move_to(self.next_boundary(self.cursor_offset()) - 1, cx); + } + cx.emit(InputEvent::PressEnter); } @@ -408,8 +601,7 @@ impl TextInput { fn on_mouse_down(&mut self, event: &MouseDownEvent, cx: &mut ViewContext) { self.is_selecting = true; - let offset = self.index_for_mouse_position(event.position); - + let offset = self.index_for_mouse_position(event.position, cx); // Double click to select word if event.button == MouseButton::Left && event.click_count == 2 { self.select_word(offset, cx); @@ -425,6 +617,21 @@ impl TextInput { fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut ViewContext) { self.is_selecting = false; + self.selected_word_range = None; + } + + fn on_scroll_wheel(&mut self, event: &ScrollWheelEvent, _: &mut ViewContext) { + let delta = event.delta.pixel_delta(self.last_line_height); + let safe_y_range = + (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.0))..px(0.); + let safe_x_range = + (-self.scroll_size.width + self.input_bounds.size.width).min(px(0.0))..px(0.); + + let mut offset = self.scroll_handle.offset() + delta; + offset.y = offset.y.clamp(safe_y_range.start, safe_y_range.end); + offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end); + + self.scroll_handle.set_offset(offset); } fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext) { @@ -445,14 +652,19 @@ impl TextInput { return; } - let selected_text = self.text[self.selected_range.clone()].to_string(); + let range = self.range_from_utf16(&self.selected_range); + let selected_text = self.text[range].to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); self.replace_text_in_range(None, "", cx); } fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { if let Some(clipboard) = cx.read_from_clipboard() { - let new_text = clipboard.text().unwrap_or_default().replace('\n', ""); + let mut new_text = clipboard.text().unwrap_or_default(); + if !self.multi_line { + new_text = new_text.replace('\n', ""); + } + self.replace_text_in_range(None, &new_text, cx); } } @@ -504,7 +716,7 @@ impl TextInput { cx.notify() } - fn cursor_offset(&self) -> usize { + pub(super) fn cursor_offset(&self) -> usize { if self.selection_reversed { self.selected_range.start } else { @@ -512,23 +724,96 @@ impl TextInput { } } - fn index_for_mouse_position(&self, position: Point) -> usize { + fn index_for_mouse_position(&self, position: Point, _: &WindowContext) -> usize { // If the text is empty, always return 0 if self.text.is_empty() { return 0; } - let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref()) + let (Some(bounds), Some(lines)) = (self.last_bounds.as_ref(), self.last_layout.as_ref()) else { return 0; }; - if position.y < bounds.top() { - return 0; + + let line_height = self.last_line_height; + + // TIP: About the IBeam cursor + // + // If cursor style is IBeam, the mouse mouse position is in the middle of the cursor (This is special in OS) + + // The position is relative to the bounds of the text input + // + // bounds.origin: + // + // - included the input padding. + // - included the scroll offset. + let inner_position = position - bounds.origin; + + let mut index = 0; + let mut y_offset = px(0.); + + for line in lines.iter() { + let line_origin = self.line_origin_with_y_offset(&mut y_offset, &line, line_height); + let mut pos = inner_position - line_origin; + // Ignore the y position in single line mode, only check x position. + if self.is_single_line() { + pos.y = line_height.half(); + } + + let index_result = line.index_for_position(pos, line_height); + if let Ok(v) = index_result { + // Add 1 for place cursor after the character. + index += v + 1; + break; + } else if let Ok(_) = line.index_for_position(point(px(0.), pos.y), line_height) { + // Click in the this line but not in the text, move cursor to the end of the line. + // The fallback index is saved in Err from `index_for_position` method. + index += index_result.unwrap_err(); + break; + } else if line.len() == 0 { + // empty line + let line_bounds = Bounds { + origin: line_origin, + size: gpui::size(bounds.size.width, line_height), + }; + let pos = inner_position; + if line_bounds.contains(&pos) { + break; + } + } else { + index += line.len(); + } + + // add 1 for \n + index += 1; } - if position.y > bounds.bottom() { - return self.text.len(); + + if index > self.text.len() { + self.text.len() + } else { + index + } + } + + /// Returns a y offsetted point for the line origin. + fn line_origin_with_y_offset( + &self, + y_offset: &mut Pixels, + line: &WrappedLine, + line_height: Pixels, + ) -> Point { + // NOTE: About line.wrap_boundaries.len() + // + // If only 1 line, the value is 0 + // If have 2 line, the value is 1 + if self.is_multi_line() { + let p = point(px(0.), *y_offset); + let height = line_height + line.wrap_boundaries.len() as f32 * line_height; + *y_offset = *y_offset + height; + p + } else { + point(px(0.), px(0.)) } - line.closest_index_for_x(position.x - bounds.left()) } fn select_to(&mut self, offset: usize, cx: &mut ViewContext) { @@ -537,10 +822,22 @@ impl TextInput { } else { self.selected_range.end = offset }; + if self.selected_range.end < self.selected_range.start { self.selection_reversed = !self.selection_reversed; self.selected_range = self.selected_range.end..self.selected_range.start; } + + // Ensure keep word selected range + if let Some(word_range) = self.selected_word_range.as_ref() { + if self.selected_range.start > word_range.start { + self.selected_range.start = word_range.start; + } + if self.selected_range.end < word_range.end { + self.selected_range.end = word_range.end; + } + } + cx.notify() } @@ -579,6 +876,7 @@ impl TextInput { } self.selected_range = self.range_from_utf16(&(start..end)); + self.selected_word_range = Some(self.selected_range.clone()); cx.notify() } @@ -670,7 +968,7 @@ impl TextInput { self.pause_blink_cursor(cx) } - fn on_drag_move(&mut self, event: &MouseMoveEvent, cx: &mut ViewContext) { + pub(super) fn on_drag_move(&mut self, event: &MouseMoveEvent, cx: &mut ViewContext) { if self.text.is_empty() { return; } @@ -687,31 +985,10 @@ impl TextInput { return; } - let offset = self.offset_of_position(event.position); + let offset = self.index_for_mouse_position(event.position, cx); self.select_to(offset, cx); } - fn offset_of_position(&self, position: Point) -> usize { - let bounds = self.last_bounds.unwrap_or_default(); - let position = position - bounds.origin; - self.last_layout - .as_ref() - .map(|line| match line.index_for_x(position.x) { - Some(ix) => ix, - None => { - let last_index = line.len(); - // If the mouse is on the right side of the last character, move to the end - // Otherwise, move to the start of the line - if position.x > line.x_for_index(last_index) { - last_index - } else { - 0 - } - } - }) - .unwrap_or(0) - } - fn is_valid_input(&self, new_text: &str) -> bool { if new_text.is_empty() { return true; @@ -834,23 +1111,42 @@ impl ViewInputHandler for TextInput { cx.notify(); } + /// Used to position IME candidates. + /// TODO: Fix position of IME candidates in multi-line text input. fn bounds_for_range( &mut self, range_utf16: Range, bounds: Bounds, - _cx: &mut ViewContext, + _: &mut ViewContext, ) -> Option> { - let last_layout = self.last_layout.as_ref()?; + let line_height = self.last_line_height; + let lines = self.last_layout.as_ref()?; let range = self.range_from_utf16(&range_utf16); + + let mut start_origin = None; + let mut end_origin = None; + let mut y_offset = px(0.); + let mut index_offset = 0; + + for line in lines.iter() { + if let Some(p) = line.position_for_index(range.start - index_offset, line_height) { + start_origin = Some(p + point(px(0.), y_offset)); + } + if let Some(p) = line.position_for_index(range.end - index_offset, line_height) { + end_origin = Some(p + point(px(0.), y_offset)); + } + + y_offset += line.size(line_height).height; + if start_origin.is_some() && end_origin.is_some() { + break; + } + + index_offset += line.len(); + } + Some(Bounds::from_corners( - point( - bounds.left() + last_layout.x_for_index(range.start), - bounds.top(), - ), - point( - bounds.left() + last_layout.x_for_index(range.end), - bounds.bottom(), - ), + bounds.origin + start_origin.unwrap_or_default(), + bounds.origin + end_origin.unwrap_or_default(), )) } } @@ -861,247 +1157,9 @@ impl FocusableView for TextInput { } } -struct TextElement { - input: View, -} -impl TextElement { - fn paint_mouse_listeners(&mut self, cx: &mut WindowContext) { - cx.on_mouse_event({ - let input = self.input.clone(); - - move |event: &MouseMoveEvent, _, cx| { - if event.pressed_button == Some(MouseButton::Left) { - input.update(cx, |input, cx| { - input.on_drag_move(event, cx); - }); - } - } - }); - } -} -struct PrepaintState { - scroll_offset: Point, - line: Option, - cursor: Option, - selection: Option, - bounds: Bounds, -} - -impl IntoElement for TextElement { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for TextElement { - type RequestLayoutState = (); - type PrepaintState = PrepaintState; - - fn id(&self) -> Option { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - cx: &mut WindowContext, - ) -> (LayoutId, Self::RequestLayoutState) { - let mut style = Style::default(); - style.size.width = relative(1.).into(); - style.size.height = cx.line_height().into(); - (cx.request_layout(style, []), ()) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - bounds: Bounds, - _request_layout: &mut Self::RequestLayoutState, - cx: &mut WindowContext, - ) -> Self::PrepaintState { - let input = self.input.read(cx); - let text = input.text.clone(); - let placeholder = input.placeholder.clone(); - let selected_range = input.selected_range.clone(); - let cursor = input.cursor_offset(); - let style = cx.text_style(); - - let (display_text, text_color) = if text.is_empty() { - (placeholder, cx.theme().muted_foreground) - } else if input.masked { - ( - "*".repeat(text.chars().count()).into(), - cx.theme().foreground, - ) - } else { - (text, cx.theme().foreground) - }; - - let run = TextRun { - len: display_text.len(), - font: style.font(), - color: text_color, - background_color: None, - underline: None, - strikethrough: None, - }; - - let runs = if let Some(marked_range) = input.marked_range.as_ref() { - vec![ - TextRun { - len: marked_range.start, - ..run.clone() - }, - TextRun { - len: marked_range.end - marked_range.start, - underline: Some(UnderlineStyle { - color: Some(run.color), - thickness: px(1.0), - wavy: false, - }), - ..run.clone() - }, - TextRun { - len: display_text.len() - marked_range.end, - ..run.clone() - }, - ] - .into_iter() - .filter(|run| run.len > 0) - .collect() - } else { - vec![run] - }; - - let font_size = style.font_size.to_pixels(cx.rem_size()); - let line = cx - .text_system() - .shape_line(display_text, font_size, &runs) - .unwrap(); - - // Calculate the scroll offset to keep the cursor in view - let mut scroll_offset = input.scroll_offset; - let mut bounds = bounds; - let right_margin = px(5.); - let cursor_pos = line.x_for_index(cursor); - let cursor_start = line.x_for_index(selected_range.start); - let cursor_end = line.x_for_index(selected_range.end); - let cursor_moved = input.last_cursor_offset != Some(cursor); - let selection_changed = input.last_selected_range != Some(selected_range.clone()); - - if cursor_moved || selection_changed { - scroll_offset.x = if scroll_offset.x + cursor_pos > (bounds.size.width - right_margin) { - // cursor is out of right - bounds.size.width - right_margin - cursor_pos - } else if scroll_offset.x + cursor_pos < px(0.) { - // cursor is out of left - scroll_offset.x - cursor_pos - } else { - scroll_offset.x - }; - - if input.selection_reversed { - if scroll_offset.x + cursor_start < px(0.) { - // selection start is out of left - scroll_offset.x = -cursor_start; - } - } else { - if scroll_offset.x + cursor_end <= px(0.) { - // selection end is out of left - scroll_offset.x = -cursor_end; - } - } - } - - bounds.origin = bounds.origin + scroll_offset; - - let inset = px(0.5); - let (selection, cursor) = if selected_range.is_empty() && input.show_cursor(cx) { - // cursor blink - ( - None, - Some(fill( - Bounds::new( - point(bounds.left() + cursor_pos, bounds.top() + inset), - size(px(2.), bounds.bottom() - bounds.top() - inset * 2), - ), - crate::blue_500(), - )), - ) - } else { - // selection background - ( - Some(fill( - Bounds::from_corners( - point( - bounds.left() + line.x_for_index(selected_range.start), - bounds.top(), - ), - point( - bounds.left() + line.x_for_index(selected_range.end), - bounds.bottom(), - ), - ), - cx.theme().selection, - )), - None, - ) - }; - - PrepaintState { - scroll_offset, - bounds, - line: Some(line), - cursor, - selection, - } - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _: Bounds, - _request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - cx: &mut WindowContext, - ) { - let focus_handle = self.input.read(cx).focus_handle.clone(); - let focused = focus_handle.is_focused(cx); - let bounds = prepaint.bounds; - let selected_range = self.input.read(cx).selected_range.clone(); - let cursor = self.input.read(cx).cursor_offset(); - - cx.handle_input( - &focus_handle, - ElementInputHandler::new(bounds, self.input.clone()), - ); - if let Some(selection) = prepaint.selection.take() { - cx.paint_quad(selection) - } - let line = prepaint.line.take().unwrap(); - line.paint(bounds.origin, cx.line_height(), cx).unwrap(); - - if focused { - if let Some(cursor) = prepaint.cursor.take() { - cx.paint_quad(cursor); - } - } - self.input.update(cx, |input, _cx| { - input.scroll_offset = prepaint.scroll_offset; - input.last_layout = Some(line); - input.last_bounds = Some(bounds); - input.last_cursor_offset = Some(cursor); - input.last_selected_range = Some(selected_range); - }); - - self.paint_mouse_listeners(cx); - } -} - impl Render for TextInput { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + const LINE_HEIGHT: Rems = Rems(1.25); let focused = self.focus_handle.is_focused(cx); let prefix = self.prefix.as_ref().map(|build| build(cx)); @@ -1109,15 +1167,22 @@ impl Render for TextInput { div() .flex() + .id("input") .key_context(CONTEXT) .track_focus(&self.focus_handle) .when(!self.disabled, |this| { this.on_action(cx.listener(Self::backspace)) .on_action(cx.listener(Self::delete)) + .on_action(cx.listener(Self::delete_to_beginning_of_line)) + .on_action(cx.listener(Self::delete_to_end_of_line)) .on_action(cx.listener(Self::enter)) }) + .on_action(cx.listener(Self::up)) + .on_action(cx.listener(Self::down)) .on_action(cx.listener(Self::left)) .on_action(cx.listener(Self::right)) + .on_action(cx.listener(Self::select_up)) + .on_action(cx.listener(Self::select_down)) .on_action(cx.listener(Self::select_left)) .on_action(cx.listener(Self::select_right)) .on_action(cx.listener(Self::select_all)) @@ -1132,20 +1197,16 @@ impl Render for TextInput { .on_action(cx.listener(Self::undo)) .on_action(cx.listener(Self::redo)) .on_action(cx.listener(Self::redo)) - // Double click to select all - .on_double_click(cx.listener(|view, _, cx| { - view.select_all(&SelectAll, cx); - })) .on_key_down(cx.listener(Self::on_key_down_for_blink_cursor)) .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) - // .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up)) - // .on_mouse_move(cx.listener(Self::on_mouse_move)) + .on_scroll_wheel(cx.listener(Self::on_scroll_wheel)) .size_full() - .line_height(rems(1.25)) - .text_size(rems(0.875)) + .line_height(LINE_HEIGHT) .input_py(self.size) .input_h(self.size) + .cursor_text() + .when(self.multi_line, |this| this.h_auto()) .when(self.appearance, |this| { this.bg(if self.disabled { cx.theme().muted @@ -1168,18 +1229,41 @@ impl Render for TextInput { .id("TextElement") .flex_grow() .overflow_x_hidden() - .cursor_text() - .child(TextElement { - input: cx.view().clone(), - }), + .child(TextElement::new(cx.view().clone())), ) .when(self.loading, |this| { this.child(Indicator::new().color(cx.theme().muted_foreground)) }) .when( - self.cleanable && !self.loading && !self.text.is_empty(), + self.cleanable && !self.loading && !self.text.is_empty() && self.is_single_line(), |this| this.child(ClearButton::new(cx).on_click(cx.listener(Self::clean))), ) .children(suffix) + .when(self.is_multi_line(), |this| { + let entity_id = cx.view().entity_id(); + if self.last_layout.is_some() { + let scroll_size = self.scroll_size; + + this.relative().child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child( + Scrollbar::vertical( + entity_id, + self.scrollbar_state.clone(), + self.scroll_handle.clone(), + scroll_size, + ) + .axis(ScrollbarAxis::Vertical), + ), + ) + } else { + this + } + }) } } diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index 41312e06..2495af91 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,6 +1,7 @@ mod blink_cursor; mod change; mod clear_button; +mod element; mod input; mod otp_input;