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 {
textarea: Entity<InputState>,
textarea_auto_grow: Entity<InputState>,
textarea_no_wrap: Entity<InputState>,
}
impl super::Story for TextareaStory {
@ -84,9 +85,18 @@ impl TextareaStory {
.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 {
textarea,
textarea_auto_grow,
textarea_no_wrap,
}
}
@ -141,8 +151,6 @@ impl Render for TextareaStory {
.id("textarea-story")
.on_action(cx.listener(Self::tab))
.on_action(cx.listener(Self::tab_prev))
.size_full()
.justify_start()
.gap_3()
.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(
section("Textarea Auto Grow").child(
v_flex()
.w_full()
.child(TextInput::new(&self.textarea_auto_grow)),
),
section("No Wrap")
.max_w_md()
.child(TextInput::new(&self.textarea_no_wrap).h(px(200.))),
)
}
}

View file

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

View file

@ -266,6 +266,7 @@ pub struct InputState {
pub(super) disabled: bool,
pub(super) masked: bool,
pub(super) clean_on_escape: bool,
pub(super) soft_wrap: bool,
pub(super) pattern: Option<regex::Regex>,
pub(super) validate: Option<Box<dyn Fn(&str, &mut Context<Self>) -> bool + 'static>>,
pub(crate) scroll_handle: ScrollHandle,
@ -335,6 +336,7 @@ impl InputState {
disabled: false,
masked: false,
clean_on_escape: false,
soft_wrap: true,
loading: false,
pattern: None,
validate: None,
@ -751,6 +753,18 @@ impl InputState {
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.
pub fn pattern(mut self, pattern: regex::Regex) -> Self {
self.pattern = Some(pattern);
@ -2100,7 +2114,14 @@ impl InputState {
// Update text_wrapper wrap_width if changed.
if let Some(last_layout) = self.last_layout.as_ref() {
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);
cx.notify();
}

View file

@ -301,6 +301,12 @@ impl RenderOnce for TextInput {
.refine_style(&self.style)
.when(state.mode.is_multi_line(), |this| {
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(
div()
.absolute()
@ -308,10 +314,7 @@ impl RenderOnce for TextInput {
.left_0()
.right(px(1.))
.bottom_0()
.child(
Scrollbar::vertical(&state.scroll_state, &state.scroll_handle)
.scroll_size(state.scroll_size),
),
.child(scrollbar.scroll_size(state.scroll_size)),
)
} else {
this

View file

@ -64,7 +64,7 @@ impl TextWrapper {
let mut wrapped_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
.text_system()
.line_wrapper(self.font.clone(), self.font_size);
@ -73,10 +73,13 @@ impl TextWrapper {
let mut line_wraps = vec![];
let mut prev_boundary_ix = 0;
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
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;
// If wrap_width is Pixels::MAX, skip wrapping to disable word wrap
if let Some(wrap_width) = wrap_width {
// Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty.
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 {