From be05e12d1689101d3d3fb7f0a66f817b8f09d569 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 1 Nov 2026 20:06:43 +0700 Subject: [PATCH] add multiline support to input --- crates/core/src/input.rs | 1023 +++++++++++++++++++++++++--------- crates/core/tests/input.rs | 175 +++++- docs/PLAN.md | 8 +- examples/gallery/src/main.rs | 9 + 4 files changed, 948 insertions(+), 267 deletions(-) diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index dea7a37..9a5a192 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -7,12 +7,12 @@ use unicode_segmentation::UnicodeSegmentation; use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DispatchPhase, - ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior, + App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DefiniteLength, + DispatchPhase, ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, - TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point, px, + TextAlign, TextRun, UTF16Selection, Window, WrappedLine, actions, canvas, div, fill, point, px, }; use gpui_native_icons::IconRole; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size}; @@ -26,8 +26,10 @@ pub const INPUT_CONTEXT: &str = "Input"; const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300); /// How often the focused caret blinks. const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500); -/// Pixel height of one scroll "line" for horizontal wheel scrolling. -const HORIZONTAL_SCROLL_LINES_HEIGHT: f32 = 20.0; +/// Pixel height of one scroll "line" for wheel scrolling. +const SCROLL_LINES_HEIGHT: f32 = 20.0; +/// The row count a multiline field shows until the app sets one. +const DEFAULT_ROWS: usize = 4; /// Maximum undo entries kept; the oldest drops off the front. const MAX_HISTORY: usize = 100; /// The character a password field paints instead of each grapheme. @@ -35,6 +37,11 @@ const MASK: &str = "\u{2022}"; /// The byte length of [`MASK`]; the masked display is `graphemes × MASK_LEN`. const MASK_LEN: usize = 3; +/// Handler for key down events. +type KeyDownHandler = std::rc::Rc; +/// Handler for mouse down events. +type MouseDownHandler = std::rc::Rc; + /// Significant changes in an [`InputState`], observable via `cx.subscribe`. #[derive(Clone, Debug, PartialEq, Eq)] pub enum InputStateEvent { @@ -48,11 +55,27 @@ pub enum InputStateEvent { Submitted, } +/// Which keystroke submits a multiline field. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SubmitOn { + /// Enter inserts a newline; modifier + Enter submits. + #[default] + ModifiedEnter, + /// Enter submits; Shift + Enter inserts a newline. + Enter, +} + actions!( input, [ Escape, Submit, + SecondarySubmit, + InsertNewline, + Up, + Down, + SelectUp, + SelectDown, Backspace, Delete, DeleteWordLeft, @@ -84,6 +107,8 @@ actions!( pub struct InputBindings { pub escape: Option, pub submit: Option, + pub secondary_submit: Option, + pub insert_newline: Option, pub backspace: Option, pub delete: Option, pub delete_word_left: Option, @@ -91,8 +116,12 @@ pub struct InputBindings { pub delete_to_beginning_of_line: Option, pub left: Option, pub right: Option, + pub up: Option, + pub down: Option, pub select_left: Option, pub select_right: Option, + pub select_up: Option, + pub select_down: Option, pub word_left: Option, pub word_right: Option, pub select_word_left: Option, @@ -118,6 +147,8 @@ impl Default for InputBindings { let bindings = Self { escape: Some(KeyBinding::new("escape", Escape, context)), submit: Some(KeyBinding::new("enter", Submit, context)), + secondary_submit: Some(KeyBinding::new("cmd-enter", SecondarySubmit, context)), + insert_newline: Some(KeyBinding::new("shift-enter", InsertNewline, context)), backspace: Some(KeyBinding::new("backspace", Backspace, context)), delete: Some(KeyBinding::new("delete", Delete, context)), delete_word_left: Some(KeyBinding::new("alt-backspace", DeleteWordLeft, context)), @@ -129,8 +160,12 @@ impl Default for InputBindings { )), left: Some(KeyBinding::new("left", Left, context)), right: Some(KeyBinding::new("right", Right, context)), + up: Some(KeyBinding::new("up", Up, context)), + down: Some(KeyBinding::new("down", Down, context)), select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)), select_right: Some(KeyBinding::new("shift-right", SelectRight, context)), + select_up: Some(KeyBinding::new("shift-up", SelectUp, context)), + select_down: Some(KeyBinding::new("shift-down", SelectDown, context)), word_left: Some(KeyBinding::new("alt-left", WordLeft, context)), word_right: Some(KeyBinding::new("alt-right", WordRight, context)), select_word_left: Some(KeyBinding::new("alt-shift-left", SelectWordLeft, context)), @@ -151,6 +186,8 @@ impl Default for InputBindings { let bindings = Self { escape: Some(KeyBinding::new("escape", Escape, context)), submit: Some(KeyBinding::new("enter", Submit, context)), + secondary_submit: Some(KeyBinding::new("ctrl-enter", SecondarySubmit, context)), + insert_newline: Some(KeyBinding::new("shift-enter", InsertNewline, context)), backspace: Some(KeyBinding::new("backspace", Backspace, context)), delete: Some(KeyBinding::new("delete", Delete, context)), delete_word_left: Some(KeyBinding::new("ctrl-backspace", DeleteWordLeft, context)), @@ -162,8 +199,12 @@ impl Default for InputBindings { )), left: Some(KeyBinding::new("left", Left, context)), right: Some(KeyBinding::new("right", Right, context)), + up: Some(KeyBinding::new("up", Up, context)), + down: Some(KeyBinding::new("down", Down, context)), select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)), select_right: Some(KeyBinding::new("shift-right", SelectRight, context)), + select_up: Some(KeyBinding::new("shift-up", SelectUp, context)), + select_down: Some(KeyBinding::new("shift-down", SelectDown, context)), word_left: Some(KeyBinding::new("ctrl-left", WordLeft, context)), word_right: Some(KeyBinding::new("ctrl-right", WordRight, context)), select_word_left: Some(KeyBinding::new("ctrl-shift-left", SelectWordLeft, context)), @@ -194,6 +235,8 @@ impl InputBindings { Self { escape: None, submit: None, + secondary_submit: None, + insert_newline: None, backspace: None, delete: None, delete_word_left: None, @@ -201,8 +244,12 @@ impl InputBindings { delete_to_beginning_of_line: None, left: None, right: None, + up: None, + down: None, select_left: None, select_right: None, + select_up: None, + select_down: None, word_left: None, word_right: None, select_word_left: None, @@ -225,6 +272,8 @@ impl InputBindings { let mut bindings: Vec = [ self.escape, self.submit, + self.secondary_submit, + self.insert_newline, self.backspace, self.delete, self.delete_word_left, @@ -232,8 +281,12 @@ impl InputBindings { self.delete_to_beginning_of_line, self.left, self.right, + self.up, + self.down, self.select_left, self.select_right, + self.select_up, + self.select_down, self.word_left, self.word_right, self.select_word_left, @@ -270,77 +323,125 @@ impl InputBindings { } } -/// Registers input keybindings on the application. -/// -/// Pass `None` for the platform defaults. -pub fn bind_input_keys(cx: &mut App, bindings: B) -where - B: Into>, -{ - cx.bind_keys(bindings.into().unwrap_or_default().into_bindings()); +/// The shaped text: one wrapped line per buffer line, plus the mask map when masking. +struct ShapedText { + lines: Vec, + mask_map: Option>, } -/// The shaped line plus its mask map when the field masks its content. -struct ShapedText { - line: gpui::ShapedLine, - mask_map: Option>, +/// One shaped source line and its first byte's offset in the display text. +struct ShapedLineEntry { + start: usize, + line: WrappedLine, } /// The field's inner padding. +#[derive(Clone, Copy)] struct FieldPadding { left: Pixels, right: Pixels, + /// Top inset; multiline fields balance it below the last row. + top: Pixels, +} + +/// One visual (wrapped) row: a display-byte range and its y offset from the text origin. +struct VisualRow { + range: Range, + y: Pixels, + /// Index of the source line this row belongs to. + line_ix: usize, } /// Input layout information for hit testing and caret positioning. struct InputLayout { - line: gpui::ShapedLine, + lines: Vec, + /// The visual rows in order, top to bottom. + rows: Vec, bounds: Bounds, - /// Where the text was painted inside `bounds` (padding minus scroll). + /// Where the text origin (top-left of row 0) sits inside `bounds`. origin: gpui::Point, - /// Maps real buffer offsets to display offsets for masked input. - mask_map: Option>, + /// Maps display offsets to real buffer offsets for masked input. + mask_map: Option>, + /// The height of one visual row. + line_height: Pixels, + /// Inner width available to the text. + text_width: Pixels, + /// Height of all rows stacked. + content_height: Pixels, + /// Height of the region the rows scroll within. + visible_height: Pixels, } -/// Maps a display text to its corresponding buffer offset. -fn masked_display(text: &str) -> (String, Option>) { - let mut display = String::new(); - let mut map = Vec::with_capacity(text.len() + 1); - for (offset, _) in text.grapheme_indices(true) { - map.push(offset); - display.push_str(MASK); +impl InputLayout { + /// The x of a display index relative to its row's start. + fn row_x(&self, row_ix: usize, display: usize) -> Pixels { + let row = &self.rows[row_ix]; + let entry = &self.lines[row.line_ix]; + let layout = &entry.line.unwrapped_layout; + let row_start = row.range.start - entry.start; + layout.x_for_index(display - entry.start) - layout.x_for_index(row_start) } - map.push(text.len()); - (display, Some(map)) -} -/// Maps a real buffer offset onto the masked display; identity when unmasked. -fn display_index(mask_map: &Option>, real: usize) -> usize { - match mask_map { - None => real, - Some(map) => { - let index = map.iter().take_while(|&&offset| offset < real).count(); - index * MASK_LEN + /// The row containing `display` and the caret's x relative to the row start. + fn row_for_index(&self, display: usize) -> Option<(usize, Pixels)> { + if self.rows.is_empty() { + return None; } + let row_ix = row_ix_for_display(&self.rows, display); + let display = display.min(self.rows[row_ix].range.end); + Some((row_ix, self.row_x(row_ix, display))) } -} -/// Maps a display offset from a click back onto the real buffer. -fn real_index(mask_map: &Option>, display: usize) -> usize { - match mask_map { - None => display, - Some(map) => { - let index = (display / MASK_LEN).min(map.len().saturating_sub(1)); - map[index] + /// The display index within `row_ix` closest to `x`, which is relative to the row start. + fn index_at_row_x(&self, row_ix: usize, x: Pixels) -> usize { + let row = &self.rows[row_ix]; + let entry = &self.lines[row.line_ix]; + let layout = &entry.line.unwrapped_layout; + let row_start = row.range.start - entry.start; + layout + .closest_index_for_x(layout.x_for_index(row_start) + x) + .clamp(row_start, row.range.end - entry.start) + + entry.start + } + + /// The display index at a window point, using the last painted layout. + fn index_at(&self, position: gpui::Point) -> usize { + if self.rows.is_empty() { + return 0; } + let rel_y = (position.y - self.origin.y) / self.line_height; + let row_ix = (rel_y.floor().max(0.0) as usize).min(self.rows.len() - 1); + self.index_at_row_x(row_ix, position.x - self.origin.x) + } + + /// The display range of the row containing `display`. + fn row_range_at(&self, display: usize) -> Option> { + self.rows + .iter() + .find(|row| display <= row.range.end) + .map(|row| row.range.clone()) + } + + /// The overlaps of a display range with each visual row: + /// `(row index, clipped range, the range continues past the row)`. + /// + /// A range that strictly passes an empty row paints that row full width. + fn row_overlaps(&self, range: Range) -> Vec<(usize, Range, bool)> { + let mut overlaps = Vec::new(); + for (row_ix, row) in self.rows.iter().enumerate() { + let touches = range.start < row.range.end + || (row.range.is_empty() && range.start <= row.range.start); + let beyond = touches && range.end > row.range.end; + let start = range.start.clamp(row.range.start, row.range.end); + let end = range.end.clamp(row.range.start, row.range.end); + if start < end || beyond { + overlaps.push((row_ix, start..end, beyond)); + } + } + overlaps } } -/// Handler for key down events. -type KeyDownHandler = std::rc::Rc; -/// Handler for mouse down events. -type MouseDownHandler = std::rc::Rc; - /// History entry for undo/redo operations. #[derive(Clone, Debug)] struct HistoryEntry { @@ -462,7 +563,7 @@ impl CaretBlink { } } -/// Input state for a single-line text field. +/// The application-owned state of a text field. pub struct InputState { text: String, /// The caret's byte offset. @@ -491,8 +592,14 @@ pub struct InputState { password: bool, /// Whether a password field currently shows its content. revealed: bool, - /// Horizontal scroll of the content inside the field, clamped at paint. - scroll_offset: Pixels, + /// Whether the field holds multiple lines of text. + multiline: bool, + /// Which keystroke submits a multiline field. + submit_on: SubmitOn, + /// The x the caret keeps across vertical movement, reset on any other move. + goal_x: Option, + /// Content scroll inside the field: x for single-line, y for multiline, clamped at paint. + scroll_offset: gpui::Point, /// Set by every caret-affecting change. scroll_to_caret_pending: bool, /// Drives the focused caret's blink. @@ -533,7 +640,10 @@ impl InputState { read_only: false, password: false, revealed: false, - scroll_offset: Pixels::ZERO, + multiline: false, + submit_on: SubmitOn::default(), + goal_x: None, + scroll_offset: point(Pixels::ZERO, Pixels::ZERO), scroll_to_caret_pending: true, caret_blink: caret_blink.clone(), layout: Rc::new(RefCell::new(None)), @@ -548,9 +658,10 @@ impl InputState { /// Creates the state entity with initial text, caret at the end. /// - /// Newlines become spaces. + /// Newlines become spaces; flip the field to multiline with [`set_multiline`] + /// before `set_value` to seed lines. pub fn new_with_text(text: impl Into, cx: &mut App) -> Entity { - let text = sanitize(text.into()); + let text = sanitize(text.into(), false); cx.new(|cx| { let mut state = Self::new_inner(cx); state.text = text; @@ -567,8 +678,9 @@ impl InputState { /// Replaces the whole value and collapses the selection to the end. /// - /// Newlines become spaces. Emits [`InputStateEvent::TextChanged`] - /// when the value changed, setting the current value is a no-op. + /// Newlines become spaces unless the field is multiline. Emits + /// [`InputStateEvent::TextChanged`] when the value changed; setting the + /// current value is a no-op. pub fn set_value(&mut self, value: V, window: &mut Window, cx: &mut Context) where V: Into, @@ -593,7 +705,7 @@ impl InputState { ) where V: Into, { - let text = sanitize(value.into()); + let text = sanitize(value.into(), self.multiline); if text == self.text { return; } @@ -656,6 +768,33 @@ impl InputState { self.revealed } + /// Sets whether the field holds multiple lines. + pub fn set_multiline(&mut self, multiline: bool, cx: &mut Context) { + if self.multiline != multiline { + self.multiline = multiline; + self.scroll_offset = point(Pixels::ZERO, Pixels::ZERO); + cx.notify(); + } + } + + /// Whether the field holds multiple lines. + pub fn is_multiline(&self) -> bool { + self.multiline + } + + /// Sets which keystroke submits a multiline field. + pub fn set_submit_on(&mut self, submit_on: SubmitOn, cx: &mut Context) { + if self.submit_on != submit_on { + self.submit_on = submit_on; + cx.notify(); + } + } + + /// Which keystroke submits a multiline field. + pub fn submit_on(&self) -> SubmitOn { + self.submit_on + } + /// Whether the painted line masks the buffer. fn masked(&self) -> bool { self.password && !self.revealed @@ -722,15 +861,31 @@ impl InputState { fn notify(&mut self, window: &mut Window, cx: &mut Context) { cx.notify(); self.scroll_to_caret_pending = true; + self.goal_x = None; self.caret_blink.update(cx, |blink, cx| blink.pause(cx)); window.notify_text_input_changed(&self.focus_handle); } - /// Scrolls the field horizontally by a wheel delta, clamped at paint. - fn scroll_by(&mut self, delta: Pixels, window: &mut Window, cx: &mut Context) { - self.scroll_offset = (self.scroll_offset - delta).max(Pixels::ZERO); + /// Scrolls the field by a wheel delta, clamped at paint. + fn scroll_by( + &mut self, + delta: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if self.multiline { + self.scroll_offset.y = (self.scroll_offset.y + delta.y).max(Pixels::ZERO); + } else { + self.scroll_offset.x = (self.scroll_offset.x - delta.x).max(Pixels::ZERO); + } + let scrollable = self + .layout + .borrow() + .as_ref() + .is_some_and(|layout| layout.content_height > layout.visible_height); cx.notify(); window.notify_text_input_changed(&self.focus_handle); + self.multiline && scrollable } /// Emits `Focused`/`Blurred` when the field's focus state changed since the last paint. @@ -774,7 +929,7 @@ impl InputState { return; } - let text = sanitize(text); + let text = sanitize(text, self.multiline); let old_text = self.text[range.clone()].to_string(); self.push_history(range.clone(), old_text, text.len()); self.text.replace_range(range.clone(), &text); @@ -861,12 +1016,13 @@ impl InputState { } } - /// Shapes the masked or plain buffer with the paint-time text style. - fn shape_current_text(&self, window: &Window) -> ShapedText { + /// Shapes the masked or plain buffer, wrapping to `wrap_width` when set, + /// with the paint-time text style. + fn shape_current_text(&self, wrap_width: Option, window: &Window) -> ShapedText { let (text, mask_map) = if self.masked() { masked_display(&self.text) } else { - (self.text.clone(), None::>) + (self.text.clone(), None) }; let text_style = window.text_style(); @@ -888,65 +1044,142 @@ impl InputState { strikethrough: None, }]; - let line = window - .text_system() - .shape_line(text.into(), font_size, &runs, None); + let shaped = + window + .text_system() + .shape_text(text.into(), font_size, &runs, wrap_width, None); - ShapedText { line, mask_map } + let mut lines = Vec::new(); + match shaped { + Ok(shaped) => { + let mut start = 0; + for line in shaped { + let end = start + line.len(); + lines.push(ShapedLineEntry { start, line }); + start = end + '\n'.len_utf8(); + } + } + Err(err) => eprintln!("shaping input text failed: {err}"), + } + + ShapedText { lines, mask_map } } /// Stores the paint geometry for hit-testing and the IME conversions. fn store_layout( &mut self, - line: gpui::ShapedLine, - mask_map: Option>, + text: ShapedText, bounds: Bounds, - padding: Pixels, - right_padding: Pixels, + padding: FieldPadding, line_height: Pixels, - ) -> (gpui::Point, Pixels) { - let selection = self.selection(); - let caret_x = line.x_for_index(display_index(&mask_map, selection.end)); + ) -> gpui::Point { + // The visual row table: one entry per wrapped row, top to bottom. + let mut rows = Vec::new(); + let mut y = Pixels::ZERO; - let visible = (bounds.size.width - padding - right_padding).max(Pixels::ZERO); - let max_scroll = (line.width - visible).max(Pixels::ZERO); - - if self.scroll_to_caret_pending { - self.scroll_to_caret_pending = false; - // Pull the scroll just far enough to reveal the caret on whichever edge it left. - if caret_x - self.scroll_offset > visible { - self.scroll_offset = caret_x - visible; - } - if caret_x < self.scroll_offset { - self.scroll_offset = caret_x; + for (line_ix, entry) in text.lines.iter().enumerate() { + let mut row_start = 0; + let boundaries = + entry.line.wrap_boundaries().iter().map(|boundary| { + entry.line.runs()[boundary.run_ix].glyphs[boundary.glyph_ix].index + }); + for end in boundaries.chain([entry.line.len()]) { + rows.push(VisualRow { + range: entry.start + row_start..entry.start + end, + y, + line_ix, + }); + y += line_height; + row_start = end; } } - self.scroll_offset = self.scroll_offset.clamp(Pixels::ZERO, max_scroll); + + let content_height = y; + + let visible_height = if self.multiline { + (bounds.size.height - padding.top * 2.0).max(Pixels::ZERO) + } else { + bounds.size.height + }; + + let caret = if rows.is_empty() { + (Pixels::ZERO, Pixels::ZERO) + } else { + let display = display_index(&text.mask_map, self.selection().end); + let row_ix = row_ix_for_display(&rows, display); + let row = &rows[row_ix]; + let entry = &text.lines[row.line_ix]; + let shaped = &entry.line.unwrapped_layout; + let row_start = row.range.start - entry.start; + let x = shaped.x_for_index(display.min(row.range.end) - entry.start) + - shaped.x_for_index(row_start); + (row.y, x) + }; + + if self.multiline { + let max_scroll = (content_height - visible_height).max(Pixels::ZERO); + if self.scroll_to_caret_pending { + self.scroll_to_caret_pending = false; + let (caret_y, _) = caret; + if caret_y + line_height > self.scroll_offset.y + visible_height { + self.scroll_offset.y = caret_y + line_height - visible_height; + } + if caret_y < self.scroll_offset.y { + self.scroll_offset.y = caret_y; + } + } + self.scroll_offset.y = self.scroll_offset.y.clamp(Pixels::ZERO, max_scroll); + self.scroll_offset.x = Pixels::ZERO; + } else { + let visible = (bounds.size.width - padding.left - padding.right).max(Pixels::ZERO); + let line_width = text + .lines + .first() + .map(|entry| entry.line.width()) + .unwrap_or(Pixels::ZERO); + let max_scroll = (line_width - visible).max(Pixels::ZERO); + if self.scroll_to_caret_pending { + self.scroll_to_caret_pending = false; + // Pull the scroll just far enough to reveal the caret on whichever edge it left. + let (_, caret_x) = caret; + if caret_x - self.scroll_offset.x > visible { + self.scroll_offset.x = caret_x - visible; + } + if caret_x < self.scroll_offset.x { + self.scroll_offset.x = caret_x; + } + } + self.scroll_offset.x = self.scroll_offset.x.clamp(Pixels::ZERO, max_scroll); + self.scroll_offset.y = Pixels::ZERO; + } let origin = point( - bounds.left() + padding - self.scroll_offset, - bounds.top() + (bounds.size.height - line_height) / 2.0, + bounds.left() + padding.left - self.scroll_offset.x, + if self.multiline { + bounds.top() + padding.top - self.scroll_offset.y + } else { + bounds.top() + (bounds.size.height - line_height) / 2.0 + }, ); + let text_width = (bounds.size.width - padding.left - padding.right).max(Pixels::ZERO); *self.layout.borrow_mut() = Some(InputLayout { - line, + lines: text.lines, + rows, bounds, origin, - mask_map, + mask_map: text.mask_map, + line_height, + text_width, + content_height, + visible_height, }); - (origin, caret_x) + origin } /// Paints the selection highlight behind the text. - fn paint_selection( - &self, - origin: gpui::Point, - caret_x: Pixels, - bounds: Bounds, - color: gpui::Hsla, - window: &mut Window, - ) { + fn paint_selection(&self, origin: gpui::Point, color: gpui::Hsla, window: &mut Window) { let selection = self.selection(); if selection.is_empty() { return; @@ -957,23 +1190,28 @@ impl InputState { return; }; - let start_x = layout - .line - .x_for_index(display_index(&layout.mask_map, selection.start)); + let start = display_index(&layout.mask_map, selection.start); + let end = display_index(&layout.mask_map, selection.end); - let rect = Bounds::from_corners( - point(origin.x + start_x.min(caret_x), bounds.top() + px(1.)), - point(origin.x + start_x.max(caret_x), bounds.bottom() - px(1.)), - ); - - window.paint_quad(fill(rect, color)); + for (row_ix, clipped, beyond) in layout.row_overlaps(start..end) { + let top = origin.y + layout.rows[row_ix].y; + let left = origin.x + layout.row_x(row_ix, clipped.start); + let right = origin.x + + if beyond { + layout.text_width + } else { + layout.row_x(row_ix, clipped.end) + }; + let rect = + Bounds::from_corners(point(left, top), point(right, top + layout.line_height)); + window.paint_quad(fill(rect, color)); + } } /// Paints the text itself. fn paint_text( &self, origin: gpui::Point, - line_height: Pixels, right_padding: Pixels, window: &mut Window, cx: &mut App, @@ -990,11 +1228,24 @@ impl InputState { layout.bounds.bottom(), ), ); + window.with_content_mask(Some(ContentMask { bounds: mask }), |window| { - layout - .line - .paint(origin, line_height, TextAlign::Left, None, window, cx) - .ok(); + let mut line_top = Pixels::ZERO; + for entry in &layout.lines { + let line_origin = point(origin.x, origin.y + line_top); + entry + .line + .paint( + line_origin, + layout.line_height, + TextAlign::Left, + None, + window, + cx, + ) + .ok(); + line_top += layout.line_height * (entry.line.wrap_boundaries().len() + 1) as f32; + } }); } @@ -1002,17 +1253,26 @@ impl InputState { fn paint_caret( &self, origin: gpui::Point, - caret_x: Pixels, font_size: Pixels, - line_height: Pixels, color: gpui::Hsla, window: &mut Window, ) { - let caret_top = origin.y + (line_height - font_size) / 2.0; + let layout = self.layout.borrow(); + let Some(layout) = layout.as_ref() else { + return; + }; + + let display = display_index(&layout.mask_map, self.cursor); + let Some((row_ix, caret_x)) = layout.row_for_index(display) else { + return; + }; + + let caret_top = origin.y + layout.rows[row_ix].y + (layout.line_height - font_size) / 2.0; let caret = Bounds::from_corners( point(origin.x + caret_x, caret_top), point(origin.x + caret_x + px(1.5), caret_top + font_size), ); + window.paint_quad(fill(caret, color)); } @@ -1035,58 +1295,46 @@ impl InputState { .line_height .to_pixels(text_style.font_size, window.rem_size()); - let (origin, caret_x) = self.store_layout( - text.line, - text.mask_map, - bounds, - padding.left, - padding.right, - line_height, - ); + let origin = self.store_layout(text, bounds, padding, line_height); if focused && !self.selection().is_empty() { - self.paint_selection(origin, caret_x, bounds, colors.selection, window); + self.paint_selection(origin, colors.selection, window); } - self.paint_text(origin, line_height, padding.right, window, cx); + self.paint_text(origin, padding.right, window, cx); // The composition underline shows the in-progress IME text. if let Some(range) = self.marked_range.clone() && !range.is_empty() { - self.paint_marked_underline(origin, line_height, range, colors.foreground, window); + self.paint_marked_underline(origin, range, colors.foreground, window); } // A caret promises that typing lands there; in read-only it does not. if focused && self.caret_visible(focused, cx) && self.selection().is_empty() { - self.paint_caret( - origin, - caret_x, - font_size, - line_height, - colors.foreground, - window, - ); + self.paint_caret(origin, font_size, colors.foreground, window); } self.dragging } - /// The text index at window x, using the last painted layout. - fn index_at_x(&self, x: Pixels) -> usize { + /// The text index at a window point, using the last painted layout. + fn index_at(&self, position: gpui::Point) -> usize { let layout = self.layout.borrow(); let Some(layout) = layout.as_ref() else { return 0; }; - real_index( - &layout.mask_map, - layout.line.closest_index_for_x(x - layout.origin.x), - ) + real_index(&layout.mask_map, layout.index_at(position)) } - /// Extends the selection to `x` while drag-selecting. - fn drag_select_to(&mut self, x: Pixels, window: &mut Window, cx: &mut Context) { - self.cursor = self.index_at_x(x); + /// Extends the selection to `position` while drag-selecting. + fn drag_select_to( + &mut self, + position: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + self.cursor = self.index_at(position); self.notify(window, cx); } @@ -1094,7 +1342,6 @@ impl InputState { fn paint_marked_underline( &self, origin: gpui::Point, - line_height: Pixels, range: Range, color: gpui::Hsla, window: &mut Window, @@ -1104,26 +1351,25 @@ impl InputState { return; }; - let start_x = layout - .line - .x_for_index(display_index(&layout.mask_map, range.start)); - - let end_x = layout - .line - .x_for_index(display_index(&layout.mask_map, range.end)); - + let start = display_index(&layout.mask_map, range.start); + let end = display_index(&layout.mask_map, range.end); let thickness = px(1.0); - let underline_y = origin.y + line_height - thickness; - let rect = Bounds::from_corners( - point(origin.x + start_x, underline_y), - point(origin.x + end_x, underline_y + thickness), - ); - window.paint_quad(fill(rect, color)); + for (row_ix, clipped, beyond) in layout.row_overlaps(start..end) { + let top = origin.y + layout.rows[row_ix].y + layout.line_height - thickness; + let left = origin.x + layout.row_x(row_ix, clipped.start); + let right = origin.x + + if beyond { + layout.text_width + } else { + layout.row_x(row_ix, clipped.end) + }; + let rect = Bounds::from_corners(point(left, top), point(right, top + thickness)); + window.paint_quad(fill(rect, color)); + } } - /// Registers the window-level listeners that continue a drag-select - /// while the pointer moves outside the field. + /// Register drag listeners. fn register_drag_listeners(entity: &Entity, window: &mut Window) { window.on_mouse_event({ let state = entity.clone(); @@ -1133,7 +1379,7 @@ impl InputState { return; } state.update(cx, |state, cx| { - state.drag_select_to(event.position.x, window, cx) + state.drag_select_to(event.position, window, cx) }); } }); @@ -1157,12 +1403,25 @@ impl InputState { ) { self.dragging = true; - let index = self.index_at_x(event.position.x); + let index = self.index_at(event.position); if event.click_count >= 3 { - // A single-line field is one line: triple-click selects all of it. - let len = self.text.len(); - self.select_range(0..len, window, cx); + // Triple-click selects the clicked row, which is all of it when single-line. + let row_range = self.layout.borrow().as_ref().and_then(|layout| { + let display = display_index(&layout.mask_map, index); + layout.row_range_at(display).map(|range| { + real_index(&layout.mask_map, range.start) + ..real_index(&layout.mask_map, range.end) + }) + }); + + match row_range { + Some(range) => self.select_range(range, window, cx), + None => { + let len = self.text.len(); + self.select_range(0..len, window, cx); + } + } } else if event.click_count == 2 { let start = previous_word_start(&self.text, index); let end = next_word_end(&self.text, index); @@ -1252,6 +1511,64 @@ impl InputState { self.move_cursor(to, true, window, cx); } + fn up(&mut self, _: &Up, window: &mut Window, cx: &mut Context) { + self.move_vertical(-1, false, window, cx); + } + + fn down(&mut self, _: &Down, window: &mut Window, cx: &mut Context) { + self.move_vertical(1, false, window, cx); + } + + fn select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { + self.move_vertical(-1, true, window, cx); + } + + fn select_down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { + self.move_vertical(1, true, window, cx); + } + + /// Moves the caret one visual row up or down, keeping the goal column. + fn move_vertical( + &mut self, + delta: isize, + extend: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !self.multiline { + return; + } + + let target = { + let layout = self.layout.borrow(); + let Some(layout) = layout.as_ref() else { + return; + }; + let display = display_index(&layout.mask_map, self.cursor); + let Some((row_ix, caret_x)) = layout.row_for_index(display) else { + return; + }; + let target = row_ix as isize + delta; + let (display, goal_x) = if target < 0 { + (0, None) + } else if target as usize >= layout.rows.len() { + (display_index(&layout.mask_map, self.text.len()), None) + } else { + let goal_x = self.goal_x.unwrap_or(caret_x); + (layout.index_at_row_x(target as usize, goal_x), Some(goal_x)) + }; + Some((real_index(&layout.mask_map, display), goal_x)) + }; + + let Some((to, goal_x)) = target else { + return; + }; + + self.move_cursor(to, extend, window, cx); + // Set after `move_cursor`, which resets the goal on every move. + self.goal_x = goal_x; + } + fn home(&mut self, _: &Home, window: &mut Window, cx: &mut Context) { self.move_cursor(0, false, window, cx); } @@ -1378,9 +1695,31 @@ impl InputState { } fn submit(&mut self, _: &Submit, _window: &mut Window, cx: &mut Context) { + // A multiline field in the default mode turns Enter into a newline + if self.multiline && self.submit_on == SubmitOn::ModifiedEnter { + let range = self.selection(); + self.replace_range(range, "\n", _window, cx); + } else { + cx.emit(InputStateEvent::Submitted); + } + } + + fn secondary_submit( + &mut self, + _: &SecondarySubmit, + _window: &mut Window, + cx: &mut Context, + ) { cx.emit(InputStateEvent::Submitted); } + fn insert_newline(&mut self, _: &InsertNewline, window: &mut Window, cx: &mut Context) { + if self.multiline { + let range = self.selection(); + self.replace_range(range, "\n", window, cx); + } + } + fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context) { window.blur(cx); } @@ -1460,7 +1799,7 @@ impl EntityInputHandler for InputState { |range| utf16_range_to_utf8(&self.text, range), ); - let new_text = sanitize(new_text); + let new_text = sanitize(new_text, self.multiline); let old_text = self.text[range.clone()].to_string(); self.push_history(range.clone(), old_text, new_text.len()); self.text.replace_range(range.clone(), &new_text); @@ -1490,20 +1829,43 @@ impl EntityInputHandler for InputState { let layout = self.layout.borrow(); let layout = layout.as_ref()?; - let start = layout.line.x_for_index(display_index( + let start = display_index( &layout.mask_map, utf16_to_utf8_offset(&self.text, range_utf16.start), - )); - - let end = layout.line.x_for_index(display_index( + ); + let end = display_index( &layout.mask_map, utf16_to_utf8_offset(&self.text, range_utf16.end), - )); + ); // `origin` is already an absolute window point. + if start == end { + let (row_ix, x) = layout.row_for_index(start)?; + let top = layout.origin.y + layout.rows[row_ix].y; + return Some(Bounds::from_corners( + point(layout.origin.x + x, top), + point(layout.origin.x + x, top + layout.line_height), + )); + } + + let overlaps = layout.row_overlaps(start..end); + let (first_ix, first_range, _) = overlaps.first().cloned()?; + let (last_ix, last_range, last_beyond) = overlaps.last().cloned()?; + + let left = layout.origin.x + layout.row_x(first_ix, first_range.start); + let right = layout.origin.x + + if last_beyond { + layout.text_width + } else { + layout.row_x(last_ix, last_range.end) + }; + Some(Bounds::from_corners( - point(layout.origin.x + start.min(end), layout.bounds.top()), - point(layout.origin.x + start.max(end), layout.bounds.bottom()), + point(left, layout.origin.y + layout.rows[first_ix].y), + point( + right, + layout.origin.y + layout.rows[last_ix].y + layout.line_height, + ), )) } @@ -1515,13 +1877,7 @@ impl EntityInputHandler for InputState { ) -> Option { let layout = self.layout.borrow(); let layout = layout.as_ref()?; - Some(real_index( - &layout.mask_map, - layout - .line - .closest_index_for_x(window_point.x - layout.origin.x) - .min(self.text.len()), - )) + Some(real_index(&layout.mask_map, layout.index_at(window_point)).min(self.text.len())) } fn text_length_utf16( @@ -1533,76 +1889,7 @@ impl EntityInputHandler for InputState { } } -/// Newlines have no meaning in a single-line field. -fn sanitize(text: impl Into) -> String { - text.into().replace('\n', " ").replace('\r', "") -} - -/// Floors `index` to the nearest grapheme-cluster boundary at or below it. -fn previous_grapheme_start(text: &str, index: usize) -> usize { - text.grapheme_indices(true) - .rev() - .find(|(offset, _)| *offset < index) - .map(|(offset, _)| offset) - .unwrap_or(0) -} - -/// Advances `index` to the next grapheme-cluster boundary. -fn next_grapheme_start(text: &str, index: usize) -> usize { - text.grapheme_indices(true) - .find(|(offset, _)| *offset > index) - .map(|(offset, _)| offset) - .unwrap_or(text.len()) -} - -/// The start of the word before `from`. -fn previous_word_start(text: &str, from: usize) -> usize { - let mut best = 0; - for (offset, word) in text.split_word_bound_indices() { - if offset >= from { - break; - } - if !word.starts_with(char::is_whitespace) { - best = offset; - } - } - best -} - -/// The end of the word after `from`. -fn next_word_end(text: &str, from: usize) -> usize { - for (offset, word) in text.split_word_bound_indices() { - let end = offset + word.len(); - if end > from && !word.starts_with(char::is_whitespace) { - return end; - } - } - text.len() -} - -/// Converts a UTF-16 offset into a UTF-8 byte offset. -fn utf16_to_utf8_offset(text: &str, utf16_offset: usize) -> usize { - let mut utf16_count = 0; - for (byte_offset, char) in text.char_indices() { - if utf16_count >= utf16_offset { - return byte_offset; - } - utf16_count += char.len_utf16(); - } - text.len() -} - -/// Converts a UTF-8 byte range to a UTF-16 range. -fn utf8_range_to_utf16(text: &str, range: Range) -> Range { - utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end) -} - -/// Converts a UTF-16 range to a UTF-8 byte range. -fn utf16_range_to_utf8(text: &str, range: Range) -> Range { - utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end) -} - -/// A single-line text field bound to an application-owned [`InputState`]. +/// A text field bound to an application-owned [`InputState`]. #[derive(IntoElement)] pub struct Input { id: ElementId, @@ -1611,6 +1898,8 @@ pub struct Input { size: Size, disabled: bool, a11y_label: Option, + /// Multiline field height in rows; ignored when the state is single-line. + rows: usize, } impl Input { @@ -1626,6 +1915,7 @@ impl Input { size: Size::default(), disabled: false, a11y_label: None, + rows: DEFAULT_ROWS, } } @@ -1638,6 +1928,12 @@ impl Input { self } + /// Sets the height of a multiline field, in rows. + pub fn rows(mut self, rows: usize) -> Self { + self.rows = rows.max(1); + self + } + /// Renders the field inert, unfocusable, and dimmed; disabled always wins. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; @@ -1672,6 +1968,7 @@ impl RenderOnce for Input { let is_empty = self.state.read(cx).text.is_empty(); let is_password = self.state.read(cx).is_password(); let revealed = self.state.read(cx).is_revealed(); + let multiline = self.state.read(cx).is_multiline(); let right_padding = if is_password { metrics.dense_padding_x + metrics.dense_icon_size @@ -1679,11 +1976,23 @@ impl RenderOnce for Input { metrics.dense_padding_x }; + // The field's own breathing room; the text starts here from the top when multiline. + let vertical_inset = metrics.dense_padding_x * 0.5; + let field_padding = FieldPadding { left: metrics.dense_padding_x, right: right_padding, + top: vertical_inset, }; + let line_height = multiline.then(|| match window.text_style().line_height { + DefiniteLength::Fraction(ratio) => metrics.text_size * ratio, + DefiniteLength::Absolute(length) => length.to_pixels(window.rem_size()), + }); + let field_height = line_height.map_or(metrics.input_height, |line_height| { + line_height * self.rows as f32 + vertical_inset * 2.0 + }); + let fallback_label = self .a11y_label .clone() @@ -1698,7 +2007,7 @@ impl RenderOnce for Input { .relative() .overflow_hidden() .w_full() - .h(metrics.input_height) + .h(field_height) .px(metrics.dense_padding_x) .rounded(radius.control) .border_1() @@ -1706,6 +2015,9 @@ impl RenderOnce for Input { .bg(colors.input) .text_size(metrics.text_size) .text_color(colors.foreground) + .when_some(line_height, |input, line_height| { + input.items_start().line_height(line_height) + }) .when(self.disabled, |input| input.opacity(0.5)) .when(!self.disabled, |input| { input.track_focus(&focus_handle).tab_stop(true) @@ -1715,6 +2027,7 @@ impl RenderOnce for Input { input.child( div() .text_color(colors.muted_foreground) + .when(multiline, |placeholder| placeholder.pt(vertical_inset)) .child(self.placeholder.clone()), ) }) @@ -1725,7 +2038,11 @@ impl RenderOnce for Input { move |bounds, window, cx| { // The hitbox backs the I-beam hover cursor. let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); - let text = state.read(cx).shape_current_text(window); + let wrap_width = multiline.then(|| { + (bounds.size.width - field_padding.left - field_padding.right) + .max(Pixels::ZERO) + }); + let text = state.read(cx).shape_current_text(wrap_width, window); (text, hitbox) } }, @@ -1761,14 +2078,21 @@ impl RenderOnce for Input { { return; } + let delta = match event.delta { - ScrollDelta::Pixels(delta) => delta.x, - ScrollDelta::Lines(lines) => { - px(lines.x * HORIZONTAL_SCROLL_LINES_HEIGHT) - } + ScrollDelta::Pixels(delta) => delta, + ScrollDelta::Lines(lines) => point( + px(lines.x * SCROLL_LINES_HEIGHT), + px(lines.y * SCROLL_LINES_HEIGHT), + ), }; - state + + let consumed = state .update(cx, |state, cx| state.scroll_by(delta, window, cx)); + + if consumed { + cx.stop_propagation(); + } } }); @@ -1855,6 +2179,18 @@ impl RenderOnce for Input { state.update(cx, |state, cx| state.submit(action, window, cx)) } }) + .on_action({ + let state = self.state.clone(); + move |action: &SecondarySubmit, window, cx| { + state.update(cx, |state, cx| state.secondary_submit(action, window, cx)) + } + }) + .on_action({ + let state = self.state.clone(); + move |action: &InsertNewline, window, cx| { + state.update(cx, |state, cx| state.insert_newline(action, window, cx)) + } + }) .on_action({ let state = self.state.clone(); move |action: &Backspace, window, cx| { @@ -1911,6 +2247,30 @@ impl RenderOnce for Input { state.update(cx, |state, cx| state.select_right(action, window, cx)) } }) + .on_action({ + let state = self.state.clone(); + move |action: &Up, window, cx| { + state.update(cx, |state, cx| state.up(action, window, cx)) + } + }) + .on_action({ + let state = self.state.clone(); + move |action: &Down, window, cx| { + state.update(cx, |state, cx| state.down(action, window, cx)) + } + }) + .on_action({ + let state = self.state.clone(); + move |action: &SelectUp, window, cx| { + state.update(cx, |state, cx| state.select_up(action, window, cx)) + } + }) + .on_action({ + let state = self.state.clone(); + move |action: &SelectDown, window, cx| { + state.update(cx, |state, cx| state.select_down(action, window, cx)) + } + }) .on_action({ let state = self.state.clone(); move |action: &WordLeft, window, cx| { @@ -1997,3 +2357,142 @@ impl RenderOnce for Input { }) } } + +/// Registers input keybindings on the application. +/// +/// Pass `None` for the platform defaults. +pub fn bind_input_keys(cx: &mut App, bindings: B) +where + B: Into>, +{ + cx.bind_keys(bindings.into().unwrap_or_default().into_bindings()); +} + +/// The row holding `display`. +fn row_ix_for_display(rows: &[VisualRow], display: usize) -> usize { + rows.iter() + .position(|row| display <= row.range.end) + .unwrap_or(rows.len() - 1) +} + +/// Builds the masked display: one bullet per grapheme, newlines kept as line breaks. +fn masked_display(text: &str) -> (String, Option>) { + let mut display = String::new(); + let mut map = Vec::with_capacity(text.len() + 1); + for (offset, grapheme) in text.grapheme_indices(true) { + if grapheme == "\n" { + display.push('\n'); + map.push((offset, 1)); + } else { + display.push_str(MASK); + map.push((offset, MASK_LEN)); + } + } + // The end sentinel maps a click past the last glyph to the buffer end. + map.push((text.len(), 0)); + (display, Some(map)) +} + +/// Maps a real buffer offset onto the masked display; identity when unmasked. +fn display_index(mask_map: &Option>, real: usize) -> usize { + match mask_map { + None => real, + Some(map) => map + .iter() + .take_while(|&&(offset, _)| offset < real) + .map(|&(_, len)| len) + .sum(), + } +} + +/// Maps a display offset from a click back onto the real buffer. +fn real_index(mask_map: &Option>, display: usize) -> usize { + match mask_map { + None => display, + Some(map) => { + let mut consumed = 0; + for &(offset, len) in map { + if consumed >= display { + return offset; + } + consumed += len; + } + map.last().map(|&(offset, _)| offset).unwrap_or(0) + } + } +} + +/// Newlines have no meaning in a single-line field; multiline fields keep +/// them and drop carriage returns. +fn sanitize(text: impl Into, multiline: bool) -> String { + let text = text.into(); + if multiline { + text.replace('\r', "") + } else { + text.replace('\n', " ").replace('\r', "") + } +} + +/// Floors `index` to the nearest grapheme-cluster boundary at or below it. +fn previous_grapheme_start(text: &str, index: usize) -> usize { + text.grapheme_indices(true) + .rev() + .find(|(offset, _)| *offset < index) + .map(|(offset, _)| offset) + .unwrap_or(0) +} + +/// Advances `index` to the next grapheme-cluster boundary. +fn next_grapheme_start(text: &str, index: usize) -> usize { + text.grapheme_indices(true) + .find(|(offset, _)| *offset > index) + .map(|(offset, _)| offset) + .unwrap_or(text.len()) +} + +/// The start of the word before `from`. +fn previous_word_start(text: &str, from: usize) -> usize { + let mut best = 0; + for (offset, word) in text.split_word_bound_indices() { + if offset >= from { + break; + } + if !word.starts_with(char::is_whitespace) { + best = offset; + } + } + best +} + +/// The end of the word after `from`. +fn next_word_end(text: &str, from: usize) -> usize { + for (offset, word) in text.split_word_bound_indices() { + let end = offset + word.len(); + if end > from && !word.starts_with(char::is_whitespace) { + return end; + } + } + text.len() +} + +/// Converts a UTF-16 offset into a UTF-8 byte offset. +fn utf16_to_utf8_offset(text: &str, utf16_offset: usize) -> usize { + let mut utf16_count = 0; + for (byte_offset, char) in text.char_indices() { + if utf16_count >= utf16_offset { + return byte_offset; + } + utf16_count += char.len_utf16(); + } + text.len() +} + +/// Converts a UTF-8 byte range to a UTF-16 range. +fn utf8_range_to_utf16(text: &str, range: Range) -> Range { + utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end) +} + +/// Converts a UTF-16 range to a UTF-8 byte range. +fn utf16_range_to_utf8(text: &str, range: Range) -> Range { + utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end) +} diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 88be8c3..1e9e625 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -4,7 +4,8 @@ use gpui::{ TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_core::input::{ - INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, bind_input_keys, + INPUT_CONTEXT, Input, InputBindings, InputState, InputStateEvent, Submit, SubmitOn, + bind_input_keys, }; use gpui_native_theme::Theme; use std::cell::RefCell; @@ -599,6 +600,34 @@ fn password_window(initial: &str) -> (TestAppContext, Entity, Visual (cx, state, visual) } +fn multiline_window(initial: &str) -> (TestAppContext, Entity, VisualTestContext) { + let mut cx = TestAppContext::single(); + cx.update(|cx| { + Theme::init(cx); + bind_input_keys(cx, InputBindings::default()); + }); + let state = cx.update(InputState::new); + cx.update(|cx| { + state.update(cx, |state, cx| state.set_multiline(true, cx)); + }); + let state_for_window = state.clone(); + let window = cx.add_window(move |_, _| Harness { + state: state_for_window, + disabled: false, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + if !initial.is_empty() { + // Seeding after the mode flips so newlines survive the sanitizer. + visual.update(|window, cx| { + state.update(cx, |state, cx| state.set_value(initial, window, cx)); + }); + } + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, state, visual) +} + #[test] fn password_fields_keep_the_real_buffer_while_masked() { let (cx, state, mut visual) = password_window(""); @@ -733,3 +762,147 @@ fn the_reveal_toggle_flips_masking() { let revealed = cx.update(|cx| state.read(cx).is_revealed()); assert!(!revealed, "the toggle should mask the field again"); } + +#[test] +fn the_caret_can_rest_on_a_line_break() { + let (cx, state, mut visual) = multiline_window("ab\ncdef"); + focus(&mut visual, &state); + + // Five lefts land the caret on the line break at the end of row 1 — + // an offset between the two rows — and painting must not panic there. + visual.simulate_keystrokes("left left left left left"); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + visual.simulate_keystrokes("x"); + assert_eq!(value(&cx, &state), "abx\ncdef"); +} + +#[test] +fn multiline_fields_keep_newlines() { + let (cx, state, mut visual) = multiline_window(""); + focus(&mut visual, &state); + visual.update(|window, cx| { + state.update(cx, |state, cx| state.set_value("a\nb", window, cx)); + }); + assert_eq!(value(&cx, &state), "a\nb"); + + // Paste keeps line breaks too. + cx.update(|cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string("x\ny".to_string())); + }); + visual.simulate_keystrokes("cmd-v"); + assert_eq!(value(&cx, &state), "a\nbx\ny"); +} + +#[test] +fn enter_inserts_a_newline_and_modifier_enter_submits() { + let (mut cx, state, mut visual) = multiline_window(""); + let (sink, _subscription) = record_events(&mut cx, &state); + focus(&mut visual, &state); + + visual.simulate_keystrokes("a b"); + visual.simulate_keystrokes("enter"); + assert_eq!(value(&cx, &state), "ab\n"); + assert_eq!(count(&sink, &InputStateEvent::Submitted), 0); + + visual.simulate_keystrokes("cmd-enter"); + assert_eq!(count(&sink, &InputStateEvent::Submitted), 1); + assert_eq!(value(&cx, &state), "ab\n"); +} + +#[test] +fn submit_on_enter_swaps_the_keys() { + let (mut cx, state, mut visual) = multiline_window(""); + let (sink, _subscription) = record_events(&mut cx, &state); + visual.update(|_, cx| { + state.update(cx, |state, cx| state.set_submit_on(SubmitOn::Enter, cx)); + }); + focus(&mut visual, &state); + + // Enter submits; Shift + Enter breaks the line. + visual.simulate_keystrokes("enter"); + assert_eq!(count(&sink, &InputStateEvent::Submitted), 1); + assert_eq!(value(&cx, &state), ""); + visual.simulate_keystrokes("shift-enter"); + assert_eq!(value(&cx, &state), "\n"); +} + +#[test] +fn up_and_down_move_between_rows() { + let (cx, state, mut visual) = multiline_window("ab\ncdef"); + focus(&mut visual, &state); + + // From the end of row 1, shift-up reaches the end of the shorter row 0. + visual.simulate_keystrokes("shift-up"); + visual.simulate_keystrokes("backspace"); + assert_eq!(value(&cx, &state), "ab"); + + // From the start of row 0, shift-down reaches the start of row 1. + visual.update(|window, cx| { + state.update(cx, |state, cx| state.set_value("ab\ncdef", window, cx)); + }); + visual.simulate_keystrokes("home"); + visual.simulate_keystrokes("shift-down"); + visual.simulate_keystrokes("backspace"); + assert_eq!(value(&cx, &state), "cdef"); +} + +#[test] +fn single_line_fields_ignore_up_and_down() { + let (cx, state, mut visual) = input_window("ab", false); + focus(&mut visual, &state); + visual.simulate_keystrokes("up down"); + visual.simulate_keystrokes("x"); + assert_eq!(value(&cx, &state), "abx"); +} + +#[test] +fn triple_click_selects_the_clicked_row() { + let (cx, state, mut visual) = multiline_window("ab\ncdef"); + focus(&mut visual, &state); + + // The second row's center sits one line height below the first's. + let at = point(px(30.), px(44.)); + visual.simulate_event(MouseDownEvent { + position: at, + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count: 3, + first_mouse: false, + }); + visual.simulate_event(MouseUpEvent { + position: at, + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count: 3, + }); + visual.simulate_keystrokes("cmd-c"); + let clipboard = cx.update(|cx| { + cx.read_from_clipboard() + .and_then(|item| item.text()) + .unwrap_or_default() + }); + assert_eq!(clipboard, "cdef"); +} + +#[test] +fn vertical_scroll_keeps_the_caret_in_view() { + let (_cx, state, mut visual) = multiline_window("l1\nl2\nl3\nl4\nl5\nl6"); + focus(&mut visual, &state); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + + // The field shows four of the six rows and the caret ends on the last, + // so the view scrolls down and row 1 sits above the field's top. + let first_row_top = visual + .update(|window, cx| { + state.update(cx, |state, cx| { + state.bounds_for_range(0..1, Default::default(), window, cx) + }) + }) + .map(|bounds| bounds.top()) + .unwrap_or_default(); + assert!(first_row_top < px(0.), "first row top: {first_row_top:?}"); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 6da90b2..817dcd8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -250,7 +250,7 @@ Remaining, in order: | 5 | Caret blink | ✅ Landed. A private `CaretBlink` entity per `InputState`: 500 ms tick on the GPUI executor, visible on focus and hidden after one interval. Any field change (`InputState::notify`) pauses it — caret solid, cycle restarts — so typing, moving and dragging never hide it; generation counters invalidate stale ticks. Read-only suppresses the caret entirely. `caret_visible(focused, cx)` is the single gate used by paint and tests | | 6 | Paint polish | ✅ Landed. IME composition underline (1 px, foreground token) under the marked range. I-beam hover cursor via a canvas-prepaint hitbox + `set_cursor_style` in paint. Two-sided horizontal scroll: `scroll_offset` lives in state; caret-affecting changes set a pending flag that the next paint consumes with a fresh layout (two-sided pull-back + clamp to content), while `scroll_by` (wheel, `ScrollEvent`, lines→pixels via a fixed unit) bypasses the flag so manual pans survive. Scroll-aware hit-testing, IME `bounds_for_range` and drag select all come free through the scroll-adjusted origin | -Deferred until a real need: bidi/RTL; prefix/suffix adornments; `SubmitOn` enter/cmd-enter modes (multiline concern); `Undo`/`Redo` events; selection/cursor color overrides (theme tokens are deliberate); max length, clear button (gpuikit has none of these either — decide on first need). Password masking and the form field wrapper lifted 2026-11-01 — §6.3 M6/M9. +Deferred until a real need: bidi/RTL; prefix/suffix adornments; `Undo`/`Redo` events; selection/cursor color overrides (theme tokens are deliberate); max length, clear button (gpuikit has none of these either — decide on first need). Password masking and `SubmitOn` lifted 2026-11-01 — §6.3 M6/M7; the form field wrapper lifts with M9. ### 6.3 Migration-driven additions — signed is the first downstream app @@ -266,7 +266,7 @@ Wave M1 — unblocks the port; each through the review gate: | M4 | core | Context menu ✅ Landed (2026-11-01) | repo rows, file-tree rows, tab bar | gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Shipped as a self-contained `ContextMenu::new(id).target(el).items(...)` — open state and pointer position are namespaced keyed state, right-click opens at the pointer, left clicks pass through to the target untouched, and any click outside, escape, or activation closes. Composes `Menu`, which gained `trigger_button(MouseButton)` and `open_at(Point)` for pointer-anchored opening (outside-click dismissal now closes on any button). Plain `context_menu` feature, depends on `menu` | | M5 | core | Empty state ✅ Landed (2026-11-01) | inbox, issues, search misses | bezel: icon + headline + one line of hint. Shipped as `EmptyState::new(headline)` with optional `.icon(role)`, `.hint(text)`, and `.action(element)` — no id (nothing interactive), no size classes (content, always medium metrics); the illustration is the component's own `icon_size × 2` decoration, muted like the hint, `line_height 1.3` on the headline, everything centered. Plain `empty_state` feature, no deps | | M6 | core | Password input ✅ Landed (2026-11-01) | identity unlock + `nsec` import passphrase | Lifts §6.2's "password masking on first need" — the need arrived. `InputState::set_password(bool)` masks the painted line one bullet per grapheme (paint-only: the buffer, undo, and caret all stay real; a mask map of grapheme byte offsets converts between the buffer and the painted display for hit-testing, the caret, selection, the IME underline, and `bounds_for_range`). Masked fields refuse copy and cut so the secret never reaches the clipboard; paste, editing, and reveal stay available. No element builder — the flag is state, like read-only. **Follow-up (2026-11-01):** a built-in reveal toggle — a ghost icon-only Button (`IconRole::Eye`/`EyeOff`, native glyphs per platform) pinned inside the field's right edge, shown on every password field. The state now tracks the field kind (`set_password`/`is_password`) and its visibility (`set_revealed`/`is_revealed`) separately, so toggling reveal never changes the field's kind; the paint reserves the button's corner (`right_padding` threaded through `paint`/`store_layout`, with a content mask so text never runs under the icon). The reveal press stops propagation so it never places the caret. The `input` feature now depends on `button` | -| M7 | input | Multiline textarea | discussion composer, patch + commit messages | bezel: fixed rows or min/max auto-grow; gpuikit: rows on InputState. Reuses Input's buffer; D12's editor crate stays separate | +| M7 | input | Multiline textarea ✅ Landed (2026-11-01) | discussion composer, patch + commit messages | bezel: fixed rows or min/max auto-grow; gpuikit: rows on InputState. Reuses Input's buffer; D12's editor crate stays separate. Shipped as a state mode — `InputState::set_multiline(bool)` — sharing the single-line machinery: shaping moved to gpui `shape_text`, which wraps to the field's inner width, and the paint layout gained a visual-row table (byte range + y per wrapped row) that the caret, selection, IME underline, hit-testing, triple-click and `bounds_for_range` all consume; `scroll_offset` became 2-D (x single-line, y multiline) with scroll-to-caret per axis. Height = the ambient line-height ratio × the element's `rows(usize)` (default 4); content taller than the field scrolls, and the field stops wheel propagation only while overflowing (min/max auto-grow deferred). Enter inserts a newline and modifier+Enter submits — `SubmitOn::ModifiedEnter` is the default, `SubmitOn::Enter` plus the `InsertNewline` shift-enter binding gives chat-style enter-submit — lifting §6.2's deferral. Up/down (+ shift variants) move between visual rows with a goal column; triple-click selects the clicked row; the sanitizer is mode-aware (multiline keeps newlines, drops `\r`; single-line unchanged), and the mask map gained per-grapheme display lengths so masked fields survive line breaks. No theme changes — all geometry derives from existing tokens. 7 headless tests join `tests/input.rs` | | M8 | core | Avatar image | profile pictures in rows and panel tabs | lifts the deferred image slot: gpui `img` + initials fallback (signed adds its pixel-art fallback app-side) | Wave M2 — completes the surface: Field wrapper (M9: label/description/error around any control — the settings dialog; lifts another §6.2 deferral), Tag (M10: removable GRASP-server and relay entries), Status dot (M11: sync-drift beads on rows), Combobox (M12: type-to-filter pickers; gpuikit's `on_create`/`keep_unmatched` blur modes are worth copying), Command palette (M13: filtered actions — gpuikit keeps the matcher caller-owned, adopt that), VirtualList in `data` (M14: a thin `uniform_list` binding for commit histories and the inbox). @@ -354,8 +354,8 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 4. ~~Phase D input~~ ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput stays deferred; multiline returns as §6.3 M7. 5. ~~Phase E overlays~~ ✅ — Popover (unit 11), ScrollArea (unit 11.5), Menu (unit 12), Tooltip + Dialog/AlertDialog + Toast (unit 11+ row; Sheet dropped). Phase gate held: escape/dismiss semantics and focus behavior per overlay. 6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list). -7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct), plus the `StyledExt` trait (`refine_style` — adapted from gpui-kit `base`, D15 — replacing the per-component `style().refine` pattern; 5 sites) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. **Windows source + icon resolvers landed (2026-10-30)**: `SourceKind::Windows` (WinRT `UISettings` + `Personalize` fallback + `ColorValuesChanged`/`HighContrastChanged`, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real `windows` 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: **side-by-side gallery screenshots per platform** — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput stays deferred until needed; multiline is §6.3 M7. -8. Then — Phase G is the migration additions (§6.3): Badge, Select, Segmented control, Context menu, Empty state, password input, multiline Textarea, Avatar image first — each unblocks a concrete signed screen. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. `crates/editor` starts when the port reaches file viewing (M15). +7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Also extracted `crates/utils` (2026-10-30): `h_flex`/`v_flex` (core's `common` re-exports them), `focus_handle` (the namespaced keyed handle, 8 call sites), `focus_ring` (7 sites), `adjacent_selectable` + `nav_key` (4 sites), and the selectable-row paint helpers `selectable_label_color`/`paint_selectable_row` (3 sites, reading `&Theme` directly — no intermediate color struct), plus the `StyledExt` trait (`refine_style` — adapted from gpui-kit `base`, D15 — replacing the per-component `style().refine` pattern; 5 sites) — `core` and `data` depend on it; the facade doesn't re-export it. **Linux theme sources landed (2026-10-30)**: Omarchy (colors.toml parse + state-dir detection + inotify on `current`), KDE (kdeglobals `[Colors:*]` + AccentColor + `~/.config` inotify), GNOME/generic (portal `ReadAll` + `SettingChanged` via zbus 5 blocking on its own thread, gsettings fallback with the libadwaita accent table) — `SourceKind::Omarchy/Kde/Gnome`, detection cascade Omarchy → KDE → portal → fallback, all parsers fixture-tested on every OS and the Linux build compile-checked via `cargo check --target x86_64-unknown-linux-gnu`. **Windows source + icon resolvers landed (2026-10-30)**: `SourceKind::Windows` (WinRT `UISettings` + `Personalize` fallback + `ColorValuesChanged`/`HighContrastChanged`, §5.3), the Segoe Fluent codepoint renderer, and the freedesktop theme walker (§4.4); the WinRT surface was verified against the real `windows` 0.62 bindings via a standalone cross-check (gpui's own manifest build needs llvm-rc from macOS). Remaining Phase F: **side-by-side gallery screenshots per platform** — Omarchy/KDE/GNOME need a Linux run, Windows needs its first hardware build. NumberInput stays deferred until needed; multiline landed as §6.3 M7 (2026-11-01). +8. Then — Phase G is the migration additions (§6.3): Badge ✅, Select ✅, Segmented control ✅, Context menu ✅, Empty state ✅, password input ✅, multiline textarea ✅, then Avatar image (M8) — each unblocks a concrete signed screen. Pickers (Calendar/DatePicker, TimeField, ColorPicker) stay decided-at-unit-time. `crates/editor` starts when the port reaches file viewing (M15). 9. Then — Phase H, the signed migration itself (§7): theme swap first (native-theme → `gpui-native-theme`), then views one at a time, keeping the gpui-component build side by side until parity. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events. diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 8664993..d3dcbf1 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -46,6 +46,7 @@ struct Gallery { volume: f32, input_state: Entity, password_state: Entity, + notes_state: Entity, popover_open: bool, menu_open: bool, strategy: Option, @@ -362,6 +363,11 @@ impl Render for Gallery { Input::new("gallery-password", self.password_state.clone()) .placeholder("Password"), ) + .child( + Input::new("gallery-notes", self.notes_state.clone()) + .placeholder("Notes — Enter breaks the line, cmd-Enter submits") + .rows(4), + ) .child( div() .text_xs() @@ -1460,6 +1466,8 @@ fn main() { let input_state = InputState::new(cx); let password_state = InputState::new(cx); password_state.update(cx, |state, cx| state.set_password(true, cx)); + let notes_state = InputState::new(cx); + notes_state.update(cx, |state, cx| state.set_multiline(true, cx)); let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx); if let Err(err) = cx.open_window( WindowOptions { @@ -1497,6 +1505,7 @@ fn main() { volume: 40.0, input_state, password_state, + notes_state, popover_open: false, menu_open: false, strategy: Some(Strategy::Curated),