input: Add to support horizontal scrolling to multi-line text. (#1131)

- Add `soft_wrap` option to build InputState, default true.

<img width="1156" height="1166" alt="image"
src="https://github.com/user-attachments/assets/2eab2db0-b3fc-48ce-83cb-1d6715313247"
/>

---------

Co-authored-by: Jason Lee <huacnlee@gmail.com>
This commit is contained in:
iincity 2025-09-01 12:33:21 +08:00 committed by GitHub
parent 34d2a75894
commit ca548bd856
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 18 deletions

View file

@ -23,6 +23,7 @@ pub fn init(cx: &mut App) {
pub struct TextareaStory { pub struct TextareaStory {
textarea: Entity<InputState>, textarea: Entity<InputState>,
textarea_auto_grow: Entity<InputState>, textarea_auto_grow: Entity<InputState>,
textarea_no_wrap: Entity<InputState>,
} }
impl super::Story for TextareaStory { impl super::Story for TextareaStory {
@ -84,9 +85,18 @@ impl TextareaStory {
.default_value("Hello 世界this is GPUI component.") .default_value("Hello 世界this is GPUI component.")
}); });
let textarea_no_wrap = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.rows(6)
.soft_wrap(false)
.default_value("This is a very long line of text to test if the horizontal scrolling function is working properly, and it should not wrap automatically but display a horizontal scrollbar.\nThe second line is also very long text, used to test the horizontal scrolling effect under multiple lines, and you can input more content to test.\nThe third line: Here you can input other long text content that requires horizontal scrolling.\n")
});
Self { Self {
textarea, textarea,
textarea_auto_grow, textarea_auto_grow,
textarea_no_wrap,
} }
} }
@ -141,8 +151,6 @@ impl Render for TextareaStory {
.id("textarea-story") .id("textarea-story")
.on_action(cx.listener(Self::tab)) .on_action(cx.listener(Self::tab))
.on_action(cx.listener(Self::tab_prev)) .on_action(cx.listener(Self::tab_prev))
.size_full()
.justify_start()
.gap_3() .gap_3()
.child( .child(
section("Textarea").child( section("Textarea").child(
@ -179,12 +187,11 @@ impl Render for TextareaStory {
), ),
), ),
) )
.child(section("Textarea Auto Grow").child(TextInput::new(&self.textarea_auto_grow)))
.child( .child(
section("Textarea Auto Grow").child( section("No Wrap")
v_flex() .max_w_md()
.w_full() .child(TextInput::new(&self.textarea_no_wrap).h(px(200.))),
.child(TextInput::new(&self.textarea_auto_grow)),
),
) )
} }
} }

View file

@ -660,7 +660,7 @@ impl Element for TextElement {
vec![run] vec![run]
}; };
let wrap_width = if multi_line { let wrap_width = if multi_line && state.soft_wrap {
Some(bounds.size.width - line_number_width) Some(bounds.size.width - line_number_width)
} else { } else {
None None

View file

@ -266,6 +266,7 @@ pub struct InputState {
pub(super) disabled: bool, pub(super) disabled: bool,
pub(super) masked: bool, pub(super) masked: bool,
pub(super) clean_on_escape: bool, pub(super) clean_on_escape: bool,
pub(super) soft_wrap: bool,
pub(super) pattern: Option<regex::Regex>, pub(super) pattern: Option<regex::Regex>,
pub(super) validate: Option<Box<dyn Fn(&str, &mut Context<Self>) -> bool + 'static>>, pub(super) validate: Option<Box<dyn Fn(&str, &mut Context<Self>) -> bool + 'static>>,
pub(crate) scroll_handle: ScrollHandle, pub(crate) scroll_handle: ScrollHandle,
@ -335,6 +336,7 @@ impl InputState {
disabled: false, disabled: false,
masked: false, masked: false,
clean_on_escape: false, clean_on_escape: false,
soft_wrap: true,
loading: false, loading: false,
pattern: None, pattern: None,
validate: None, validate: None,
@ -751,6 +753,18 @@ impl InputState {
self self
} }
/// Set the soft wrap mode for multi-line input, default is true.
pub fn soft_wrap(mut self, wrap: bool) -> Self {
self.soft_wrap = wrap;
self
}
/// Update the soft wrap mode for multi-line input, default is true.
pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context<Self>) {
self.soft_wrap = wrap;
cx.notify();
}
/// Set the regular expression pattern of the input field. /// Set the regular expression pattern of the input field.
pub fn pattern(mut self, pattern: regex::Regex) -> Self { pub fn pattern(mut self, pattern: regex::Regex) -> Self {
self.pattern = Some(pattern); self.pattern = Some(pattern);
@ -2100,7 +2114,14 @@ impl InputState {
// Update text_wrapper wrap_width if changed. // Update text_wrapper wrap_width if changed.
if let Some(last_layout) = self.last_layout.as_ref() { if let Some(last_layout) = self.last_layout.as_ref() {
if wrap_width_changed { if wrap_width_changed {
self.text_wrapper.set_wrap_width(last_layout.wrap_width, cx); let wrap_width = if !self.soft_wrap {
// None to disable wrapping (will use Pixels::MAX)
None
} else {
last_layout.wrap_width
};
self.text_wrapper.set_wrap_width(wrap_width, cx);
self.mode.update_auto_grow(&self.text_wrapper); self.mode.update_auto_grow(&self.text_wrapper);
cx.notify(); cx.notify();
} }

View file

@ -301,6 +301,12 @@ impl RenderOnce for TextInput {
.refine_style(&self.style) .refine_style(&self.style)
.when(state.mode.is_multi_line(), |this| { .when(state.mode.is_multi_line(), |this| {
if state.last_layout.is_some() { if state.last_layout.is_some() {
let scrollbar = if !state.soft_wrap {
Scrollbar::both(&state.scroll_state, &state.scroll_handle)
} else {
Scrollbar::vertical(&state.scroll_state, &state.scroll_handle)
};
this.relative().child( this.relative().child(
div() div()
.absolute() .absolute()
@ -308,10 +314,7 @@ impl RenderOnce for TextInput {
.left_0() .left_0()
.right(px(1.)) .right(px(1.))
.bottom_0() .bottom_0()
.child( .child(scrollbar.scroll_size(state.scroll_size)),
Scrollbar::vertical(&state.scroll_state, &state.scroll_handle)
.scroll_size(state.scroll_size),
),
) )
} else { } else {
this this

View file

@ -64,7 +64,7 @@ impl TextWrapper {
let mut wrapped_lines = vec![]; let mut wrapped_lines = vec![];
let mut lines = vec![]; let mut lines = vec![];
let wrap_width = self.wrap_width.unwrap_or(Pixels::MAX); let wrap_width = self.wrap_width;
let mut line_wrapper = cx let mut line_wrapper = cx
.text_system() .text_system()
.line_wrapper(self.font.clone(), self.font_size); .line_wrapper(self.font.clone(), self.font_size);
@ -73,10 +73,13 @@ impl TextWrapper {
let mut line_wraps = vec![]; let mut line_wraps = vec![];
let mut prev_boundary_ix = 0; let mut prev_boundary_ix = 0;
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap
for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) { if let Some(wrap_width) = wrap_width {
line_wraps.push(prev_boundary_ix..boundary.ix); // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
prev_boundary_ix = boundary.ix; for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) {
line_wraps.push(prev_boundary_ix..boundary.ix);
prev_boundary_ix = boundary.ix;
}
} }
lines.push(LineWrap { lines.push(LineWrap {