replace multiline with InputLayout enum

This commit is contained in:
temportalflux
2026-07-11 09:31:07 -04:00
parent 13676cf649
commit 9c212a52f7
6 changed files with 184 additions and 192 deletions
+2
View File
@@ -3,6 +3,7 @@ mod colors;
mod cursor;
mod element;
mod history;
mod layout;
mod paint;
mod state;
mod state_input_handler;
@@ -12,4 +13,5 @@ pub use colors::*;
pub(self) use cursor::*;
pub use element::*;
pub(self) use history::*;
pub use layout::*;
pub use state::*;
@@ -15,7 +15,6 @@ pub struct Input {
pub(super) interactivity: Interactivity,
pub(super) placeholder: Option<SharedString>,
pub(super) colors: PaintColors,
pub(super) multiline: bool,
}
impl Input {
@@ -27,7 +26,6 @@ impl Input {
interactivity: Interactivity::new(),
placeholder: None,
colors: PaintColors::default(),
multiline: false,
};
input.register_actions();
input
+24
View File
@@ -0,0 +1,24 @@
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum InputLayout {
SingleLine,
MultiLine,
}
impl InputLayout {
pub(super) fn sanitize_content<'s>(&self, content: &'s str) -> std::borrow::Cow<'s, str> {
match self {
// Strip newlines for single-line input
Self::SingleLine => {
std::borrow::Cow::Owned(content.replace('\n', " ").replace('\r', ""))
}
Self::MultiLine => std::borrow::Cow::Borrowed(content),
}
}
pub fn axis(&self) -> gpui::Axis {
match self {
Self::SingleLine => gpui::Axis::Horizontal,
Self::MultiLine => gpui::Axis::Vertical,
}
}
}
+65 -81
View File
@@ -1,8 +1,8 @@
use crate::input::{Input, InputLineLayout, InputState, PaintColors};
use gpui::{
App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId, ElementInputHandler,
Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent,
Along, App, Bounds, ContentMask, CursorStyle, DispatchPhase, Element, ElementId,
ElementInputHandler, Entity, FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior,
Hsla, InspectorElementId, LayoutId, Length, MouseButton, MouseDownEvent, MouseMoveEvent,
MouseUpEvent, Pixels, Point, ScrollWheelEvent, SharedString, TextAlign, TextRun, TextStyle,
Window, WrappedLine, fill, point, px, relative, size,
};
@@ -39,7 +39,6 @@ impl Element for Input {
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut resolved_text_style = None;
let multiline = self.multiline;
let layout_id = self.interactivity.request_layout(
global_id,
@@ -47,11 +46,12 @@ impl Element for Input {
window,
cx,
|element_style, window, cx| {
let layout = self.input.read(cx).get_layout();
window.with_text_style(element_style.text_style().cloned(), |window| {
resolved_text_style = Some(window.text_style());
let mut layout_style = element_style.clone();
if multiline {
if matches!(layout, super::InputLayout::MultiLine) {
if let Length::Auto = layout_style.size.width {
layout_style.size.width = relative(1.).into();
}
@@ -85,10 +85,9 @@ impl Element for Input {
.text_style
.line_height_in_pixels(window.rem_size());
let wrap_width = if self.multiline {
bounds.size.width
} else {
px(100000.)
let wrap_width = match self.input.read(cx).get_layout() {
super::InputLayout::SingleLine => px(100000.),
super::InputLayout::MultiLine => bounds.size.width,
};
self.input.update(cx, |input, _cx| {
@@ -137,7 +136,7 @@ impl Element for Input {
let input = self.input.clone();
let placeholder = self.placeholder.clone();
let text_style = layout_state.text_style.clone();
let multiline = self.multiline;
let layout = input.read(cx).get_layout();
let is_focused = focus_handle.is_focused(window);
let cursor_visible = self
.input
@@ -152,34 +151,31 @@ impl Element for Input {
window,
cx,
|_style, window, cx| {
handle_mouse(&input, bounds, multiline, window, cx);
handle_mouse(&input, bounds, layout.axis(), window, cx);
window.with_content_mask(Some(ContentMask { bounds }), |window| {
if multiline {
paint_multiline(
&input,
&focus_handle,
bounds,
&text_style,
placeholder.as_ref(),
&colors,
cursor_visible,
window,
cx,
);
} else {
paint_singleline(
&input,
&focus_handle,
bounds,
&text_style,
placeholder.as_ref(),
&colors,
cursor_visible,
window,
cx,
);
}
window.with_content_mask(Some(ContentMask { bounds }), |window| match layout {
super::InputLayout::SingleLine => paint_singleline(
&input,
&focus_handle,
bounds,
&text_style,
placeholder.as_ref(),
&colors,
cursor_visible,
window,
cx,
),
super::InputLayout::MultiLine => paint_multiline(
&input,
&focus_handle,
bounds,
&text_style,
placeholder.as_ref(),
&colors,
cursor_visible,
window,
cx,
),
});
},
);
@@ -190,20 +186,20 @@ impl Element for Input {
fn handle_mouse(
input: &Entity<InputState>,
bounds: Bounds<Pixels>,
multiline: bool,
axis: gpui::Axis,
window: &mut Window,
cx: &App,
) {
mouse_down(input.clone(), bounds, multiline, window);
mouse_down(input.clone(), bounds, axis, window);
mouse_up(input.clone(), window);
mouse_move(input.clone(), bounds, multiline, window);
handle_scroll(input.clone(), bounds, multiline, window, cx);
mouse_move(input.clone(), bounds, axis, window);
handle_scroll(input.clone(), bounds, axis, window, cx);
}
fn mouse_down(
input: Entity<InputState>,
bounds: Bounds<Pixels>,
multiline: bool,
axis: gpui::Axis,
window: &mut Window,
) {
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
@@ -219,7 +215,7 @@ fn mouse_down(
input.update(cx, |input, cx| {
let text_position =
screen_to_text_position(event.position, bounds, input.scroll_offset, multiline);
screen_to_text_position(event.position, bounds, input.scroll_offset, axis);
input.on_mouse_down(
text_position,
event.click_count,
@@ -249,7 +245,7 @@ fn mouse_up(input: Entity<InputState>, window: &mut Window) {
fn mouse_move(
input: Entity<InputState>,
bounds: Bounds<Pixels>,
multiline: bool,
axis: gpui::Axis,
window: &mut Window,
) {
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, cx| {
@@ -259,7 +255,7 @@ fn mouse_move(
input.update(cx, |input, cx| {
let text_position =
screen_to_text_position(event.position, bounds, input.scroll_offset, multiline);
screen_to_text_position(event.position, bounds, input.scroll_offset, axis);
input.on_mouse_move(text_position, cx);
});
});
@@ -268,23 +264,20 @@ fn mouse_move(
fn handle_scroll(
input: Entity<InputState>,
bounds: Bounds<Pixels>,
multiline: bool,
axis: gpui::Axis,
window: &mut Window,
cx: &App,
) {
let max_scroll = if multiline {
let total_height = input.read(cx).total_content_height();
(total_height - bounds.size.height).max(px(0.))
} else {
let text_width = input
.read(cx)
.line_layouts
.first()
.and_then(|l| l.wrapped_line.as_ref())
.map(|w| w.width())
.unwrap_or(px(0.));
(text_width - bounds.size.width).max(px(0.))
let content_size = match axis {
gpui::Axis::Horizontal => {
let state = input.read(cx);
let line = state.line_layouts.first();
let line = line.and_then(|l| l.wrapped_line.as_ref());
line.map(|w| w.width()).unwrap_or(px(0.))
}
gpui::Axis::Vertical => input.read(cx).total_content_height(),
};
let max_scroll = (content_size - bounds.size.along(axis)).max(px(0.));
window.on_mouse_event(move |event: &ScrollWheelEvent, phase, _window, cx| {
if phase != DispatchPhase::Bubble {
@@ -296,17 +289,17 @@ fn handle_scroll(
let pixel_delta = event.delta.pixel_delta(px(20.));
input.update(cx, |input, cx| {
if multiline {
input.scroll_offset =
(input.scroll_offset - pixel_delta.y).clamp(px(0.), max_scroll);
} else {
let delta = if pixel_delta.x.abs() > pixel_delta.y.abs() {
pixel_delta.x
} else {
pixel_delta.y
};
input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll);
}
let delta = match axis {
gpui::Axis::Horizontal => pixel_delta.y,
gpui::Axis::Vertical => {
if pixel_delta.x.abs() > pixel_delta.y.abs() {
pixel_delta.x
} else {
pixel_delta.y
}
}
};
input.scroll_offset = (input.scroll_offset - delta).clamp(px(0.), max_scroll);
cx.notify();
});
});
@@ -318,19 +311,10 @@ fn screen_to_text_position(
screen_position: Point<Pixels>,
bounds: Bounds<Pixels>,
scroll_offset: Pixels,
multiline: bool,
axis: gpui::Axis,
) -> Point<Pixels> {
if multiline {
point(
screen_position.x - bounds.origin.x,
screen_position.y - bounds.origin.y + scroll_offset,
)
} else {
point(
screen_position.x - bounds.origin.x + scroll_offset,
screen_position.y - bounds.origin.y,
)
}
let point = screen_position - bounds.origin;
point.apply_along(axis, |pos| pos + scroll_offset)
}
fn paint_multiline(
+89 -91
View File
@@ -1,5 +1,5 @@
use super::actions::*;
use crate::input::unicode::UnicodeString;
use crate::input::{InputLayout, unicode::UnicodeString};
use gpui::{
App, AppContext, ClipboardItem, Context, Entity, EntityId, EntityInputHandler, EventEmitter,
FocusHandle, Focusable, Pixels, Point, SharedString, Subscription, TextRun, TextStyle, Window,
@@ -54,7 +54,7 @@ pub struct InputState {
pub(super) scroll_offset: Pixels,
pub(super) available_height: Pixels,
pub(super) available_width: Pixels,
pub(super) multiline: bool,
pub(super) layout: InputLayout,
/// Stack of previous states for undo.
undo_stack: Vec<super::HistoryEntry>,
/// Stack of undone states for redo.
@@ -121,7 +121,7 @@ impl InputState {
scroll_offset: px(0.),
available_height: px(0.),
available_width: px(0.),
multiline: false,
layout: InputLayout::SingleLine,
undo_stack: Vec::new(),
cached_utf16_len: None,
redo_stack: Vec::new(),
@@ -201,16 +201,14 @@ impl InputState {
&mut self.content
}
pub fn get_layout(&self) -> InputLayout {
self.layout
}
/// Sets the text content, resetting selection to the beginning.
/// This clears the undo/redo history.
pub fn set_content(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
let content = content.into();
self.content = if self.multiline {
content
} else {
// Strip newlines for single-line input
content.replace('\n', " ").replace('\r', "")
};
pub fn set_content(&mut self, content: impl AsRef<str>, cx: &mut Context<Self>) {
self.content = self.layout.sanitize_content(content.as_ref()).to_string();
self.selected_range = 0..0;
self.selection_reversed = false;
self.marked_range = None;
@@ -375,13 +373,7 @@ impl InputState {
.unwrap_or(self.selected_range.clone());
let range = range.start.min(self.content.len())..range.end.min(self.content.len());
let sanitized_text;
let text_to_insert = if self.multiline {
text
} else {
sanitized_text = text.replace('\n', " ").replace('\r', "");
&sanitized_text
};
let text_to_insert = self.layout.sanitize_content(text);
// Record patch for undo before modifying content
self.push_undo_patch(range.clone(), text_to_insert.len());
@@ -396,7 +388,7 @@ impl InputState {
self.cached_utf16_len = Some(cached_len - removed_utf16_len + added_utf16_len);
}
self.content.replace_range(range.clone(), text_to_insert);
self.content.replace_range(range.clone(), &text_to_insert);
self.selected_range =
range.start + text_to_insert.len()..range.start + text_to_insert.len();
self.marked_range.take();
@@ -478,38 +470,44 @@ impl InputState {
pub(crate) fn up(&mut self, _: &Up, _window: &mut Window, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
if !self.multiline {
// In single-line mode, up moves to start
self.selected_range = 0..0;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
return;
}
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) {
self.selected_range = new_offset..new_offset;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
match self.layout {
InputLayout::SingleLine => {
// In single-line mode, up moves to start
self.selected_range = 0..0;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
}
InputLayout::MultiLine => {
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) {
self.selected_range = new_offset..new_offset;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
}
}
}
}
pub(crate) fn down(&mut self, _: &Down, _window: &mut Window, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
if !self.multiline {
// In single-line mode, down moves to end
let end = self.content.len();
self.selected_range = end..end;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
return;
}
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) {
self.selected_range = new_offset..new_offset;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
match self.layout {
InputLayout::SingleLine => {
// In single-line mode, down moves to end
let end = self.content.len();
self.selected_range = end..end;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
}
InputLayout::MultiLine => {
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) {
self.selected_range = new_offset..new_offset;
self.selection_reversed = false;
self.scroll_to_cursor();
cx.notify();
}
}
}
}
@@ -523,23 +521,26 @@ impl InputState {
pub(crate) fn select_up(&mut self, _: &SelectUp, _window: &mut Window, cx: &mut Context<Self>) {
self.pause_cursor_blink(cx);
if !self.multiline {
// In single-line mode, select_up selects to start
self.select_to(0, cx);
return;
}
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) {
if self.selection_reversed {
self.selected_range.start = new_offset;
} else {
self.selected_range.end = new_offset;
match self.layout {
InputLayout::SingleLine => {
// In single-line mode, select_up selects to start
self.select_to(0, cx);
}
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;
InputLayout::MultiLine => {
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), -1) {
if self.selection_reversed {
self.selected_range.start = new_offset;
} else {
self.selected_range.end = new_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;
}
self.scroll_to_cursor();
cx.notify();
}
}
self.scroll_to_cursor();
cx.notify();
}
}
@@ -550,23 +551,26 @@ impl InputState {
cx: &mut Context<Self>,
) {
self.pause_cursor_blink(cx);
if !self.multiline {
// In single-line mode, select_down selects to end
self.select_to(self.content.len(), cx);
return;
}
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) {
if self.selection_reversed {
self.selected_range.start = new_offset;
} else {
self.selected_range.end = new_offset;
match self.layout {
InputLayout::SingleLine => {
// In single-line mode, select_down selects to end
self.select_to(self.content.len(), cx);
}
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;
InputLayout::MultiLine => {
if let Some(new_offset) = self.move_vertically(self.cursor_offset(), 1) {
if self.selection_reversed {
self.selected_range.start = new_offset;
} else {
self.selected_range.end = new_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;
}
self.scroll_to_cursor();
cx.notify();
}
}
self.scroll_to_cursor();
cx.notify();
}
}
@@ -642,7 +646,7 @@ impl InputState {
}
pub(crate) fn enter(&mut self, _: &Enter, window: &mut Window, cx: &mut Context<Self>) {
if self.multiline {
if matches!(&self.layout, InputLayout::MultiLine) {
self.replace_text_in_range(None, "\n", window, cx);
}
}
@@ -714,15 +718,11 @@ impl InputState {
}
pub(crate) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
if self.multiline {
self.replace_text_in_range(None, &text, window, cx);
} else {
// Strip newlines for single-line input
let text = text.replace('\n', " ").replace('\r', "");
self.replace_text_in_range(None, &text, window, cx);
}
}
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
return;
};
let text = self.layout.sanitize_content(&text);
self.replace_text_in_range(None, &text, window, cx);
}
pub(crate) fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
@@ -986,11 +986,9 @@ impl InputState {
}
let cursor_offset = self.cursor_offset();
if self.multiline {
self.scroll_to_cursor_vertical(cursor_offset);
} else {
self.scroll_to_cursor_horizontal(cursor_offset);
match self.layout {
InputLayout::SingleLine => self.scroll_to_cursor_horizontal(cursor_offset),
InputLayout::MultiLine => self.scroll_to_cursor_vertical(cursor_offset),
}
}
@@ -59,14 +59,7 @@ impl EntityInputHandler for super::InputState {
let range = range.start.min(self.content().len())..range.end.min(self.content().len());
// Strip newlines for single-line input
let sanitized_text;
let text_to_insert = if self.multiline {
new_text
} else {
sanitized_text = new_text.replace('\n', " ").replace('\r', "");
&sanitized_text
};
let text_to_insert = self.layout.sanitize_content(new_text);
// Record patch for undo before modifying content
self.push_undo_patch(range.clone(), text_to_insert.len());
@@ -82,7 +75,7 @@ impl EntityInputHandler for super::InputState {
}
self.content_mut()
.replace_range(range.clone(), text_to_insert);
.replace_range(range.clone(), &text_to_insert);
self.selected_range =
range.start + text_to_insert.len()..range.start + text_to_insert.len();
self.marked_range.take();
@@ -108,14 +101,7 @@ impl EntityInputHandler for super::InputState {
let range = range.start.min(self.content().len())..range.end.min(self.content().len());
// Strip newlines for single-line input
let sanitized_text;
let text_to_insert = if self.multiline {
new_text
} else {
sanitized_text = new_text.replace('\n', " ").replace('\r', "");
&sanitized_text
};
let text_to_insert = self.layout.sanitize_content(new_text);
// Update cached UTF-16 length incrementally if available
if let Some(cached_len) = self.cached_utf16_len {
@@ -128,7 +114,7 @@ impl EntityInputHandler for super::InputState {
}
self.content_mut()
.replace_range(range.clone(), text_to_insert);
.replace_range(range.clone(), &text_to_insert);
if !text_to_insert.is_empty() {
self.marked_range = Some(range.start..range.start + text_to_insert.len());