input: Fix horizontal scrolling offset. (#1191)

Continue #1131 to fix some scroll offset details.

- Keep line number at left.
- Show scrollbar next the line_numbers right side.
- Fix scroll_size calculate issue that will always show vertical
scrollbar, even not have enough contents.

<img width="888" height="641" alt="image"
src="https://github.com/user-attachments/assets/5d388377-3f6d-4530-b837-6c719e1eb48f"
/>
This commit is contained in:
Jason Lee 2025-09-02 14:46:28 +08:00 committed by GitHub
parent ca548bd856
commit 6569bead8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 152 additions and 73 deletions

View file

@ -5,7 +5,7 @@ use gpui_component::{
h_flex, h_flex,
highlighter::{Language, LanguageConfig, LanguageRegistry}, highlighter::{Language, LanguageConfig, LanguageRegistry},
input::{InputEvent, InputState, Marker, TabSize, TextInput}, input::{InputEvent, InputState, Marker, TabSize, TextInput},
v_flex, ActiveTheme, ContextModal, IconName, IndexPath, Sizable, v_flex, ActiveTheme, ContextModal, IconName, IndexPath, Selectable, Sizable,
}; };
use story::Assets; use story::Assets;
@ -30,6 +30,7 @@ pub struct Example {
language: Lang, language: Lang,
line_number: bool, line_number: bool,
need_update: bool, need_update: bool,
soft_wrap: bool,
_subscribes: Vec<Subscription>, _subscribes: Vec<Subscription>,
} }
@ -99,6 +100,7 @@ impl Example {
tab_size: 4, tab_size: 4,
hard_tabs: false, hard_tabs: false,
}) })
.soft_wrap(false)
.default_value(default_language.1) .default_value(default_language.1)
.placeholder("Enter your code here...") .placeholder("Enter your code here...")
}); });
@ -140,6 +142,7 @@ impl Example {
language: default_language.0, language: default_language.0,
line_number: true, line_number: true,
need_update: false, need_update: false,
soft_wrap: false,
_subscribes, _subscribes,
} }
} }
@ -220,6 +223,14 @@ impl Example {
}) })
}); });
} }
fn toggle_soft_wrap(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
self.soft_wrap = !self.soft_wrap;
self.editor.update(cx, |state, cx| {
state.set_soft_wrap(self.soft_wrap, window, cx);
});
cx.notify();
}
} }
impl Render for Example { impl Render for Example {
@ -232,7 +243,6 @@ impl Render for Example {
.id("source") .id("source")
.w_full() .w_full()
.flex_1() .flex_1()
.gap_2()
.child( .child(
TextInput::new(&self.editor) TextInput::new(&self.editor)
.bordered(false) .bordered(false)
@ -272,7 +282,15 @@ impl Render for Example {
}); });
cx.notify(); cx.notify();
})), })),
), )
.child({
Button::new("soft-wrap")
.ghost()
.xsmall()
.label("Soft Wrap")
.selected(self.soft_wrap)
.on_click(cx.listener(Self::toggle_soft_wrap))
}),
) )
.child({ .child({
let loc = self.editor.read(cx).line_column(); let loc = self.editor.read(cx).line_column();

View file

@ -14,9 +14,9 @@ use crate::{
use super::{mode::InputMode, InputState, LastLayout}; use super::{mode::InputMode, InputState, LastLayout};
const RIGHT_MARGIN: Pixels = px(5.); pub(super) const RIGHT_MARGIN: Pixels = px(10.);
const BOTTOM_MARGIN_ROWS: usize = 1; const BOTTOM_MARGIN_ROWS: usize = 1;
const LINE_NUMBER_MARGIN_RIGHT: Pixels = px(10.); pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.);
pub(super) struct TextElement { pub(super) struct TextElement {
state: Entity<InputState>, state: Entity<InputState>,
@ -129,16 +129,17 @@ impl TextElement {
let selection_changed = state.last_selected_range != Some(selected_range); let selection_changed = state.last_selected_range != Some(selected_range);
if cursor_moved || selection_changed { if cursor_moved || selection_changed {
scroll_offset.x = scroll_offset.x = if scroll_offset.x + cursor_pos.x
if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) { > (bounds.size.width - line_number_width - RIGHT_MARGIN)
// cursor is out of right {
bounds.size.width - RIGHT_MARGIN - cursor_pos.x // cursor is out of right
} else if scroll_offset.x + cursor_pos.x < px(0.) { bounds.size.width - line_number_width - RIGHT_MARGIN - cursor_pos.x
// cursor is out of left } else if scroll_offset.x + cursor_pos.x < px(0.) {
scroll_offset.x - cursor_pos.x // cursor is out of left
} else { scroll_offset.x - cursor_pos.x
scroll_offset.x } else {
}; scroll_offset.x
};
scroll_offset.y = if scroll_offset.y + cursor_pos.y + line_height scroll_offset.y = if scroll_offset.y + cursor_pos.y + line_height
> bounds.size.height - bottom_margin > bounds.size.height - bottom_margin
{ {
@ -512,12 +513,13 @@ impl Element for TextElement {
style.size.width = relative(1.).into(); style.size.width = relative(1.).into();
if state.mode.is_multi_line() { if state.mode.is_multi_line() {
style.flex_grow = 1.0; style.flex_grow = 1.0;
if let Some(h) = state.mode.height() { style.size.height = relative(1.).into();
style.size.height = h.into(); if state.mode.is_auto_grow() {
style.min_size.height = line_height.into(); // Auto grow to let height match to rows, but not exceed max rows.
let rows = state.mode.max_rows().min(state.mode.rows());
style.min_size.height = (rows * line_height).into();
} else { } else {
style.size.height = relative(1.).into(); style.min_size.height = line_height.into();
style.min_size.height = (state.mode.rows() * line_height).into();
} }
} else { } else {
// For single-line inputs, the minimum height should be the line height // For single-line inputs, the minimum height should be the line height
@ -583,7 +585,7 @@ impl Element for TextElement {
) )
.unwrap(); .unwrap();
let line_number_width = if state.mode.line_number() { let line_number_width = if state.mode.line_number() {
empty_line_number.last().unwrap().width() + LINE_NUMBER_MARGIN_RIGHT empty_line_number.last().unwrap().width() + LINE_NUMBER_RIGHT_MARGIN
} else { } else {
px(0.) px(0.)
}; };
@ -674,21 +676,21 @@ impl Element for TextElement {
.expect("failed to shape text"); .expect("failed to shape text");
// measure.end(); // measure.end();
let total_wrapped_lines = lines let mut max_line_width = px(0.);
.iter() let mut total_wrapped_lines = 0;
.map(|line| { for line in lines.iter() {
// +1 is the first line, `wrap_boundaries` is the wrapped lines after the `\n`. // FIXME: The `shape_text` measured width is not stable, sometime will large, sometime small.
1 + line.wrap_boundaries.len() max_line_width = max_line_width.max(line.width());
}) // +1 is the first line, `wrap_boundaries` is the wrapped lines after the `\n`.
.sum::<usize>(); total_wrapped_lines += 1 + line.wrap_boundaries.len();
}
let max_line_width = lines
.iter()
.map(|line| line.width())
.max()
.unwrap_or(bounds.size.width);
let scroll_size = size( let scroll_size = size(
max_line_width + line_number_width + RIGHT_MARGIN, if max_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width {
max_line_width + line_number_width + RIGHT_MARGIN
} else {
max_line_width
},
(total_wrapped_lines as f32 * line_height).max(bounds.size.height), (total_wrapped_lines as f32 * line_height).max(bounds.size.height),
); );
@ -879,6 +881,7 @@ impl Element for TextElement {
let active_line_color = cx.theme().highlight_theme.style.active_line; let active_line_color = cx.theme().highlight_theme.style.active_line;
// Paint active line
let mut offset_y = px(0.); let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() { if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding; offset_y += invisible_top_padding;
@ -887,7 +890,7 @@ impl Element for TextElement {
for (ix, lines) in line_numbers.iter().enumerate() { for (ix, lines) in line_numbers.iter().enumerate() {
let is_active = prepaint.current_line_index == Some(visible_range.start + ix); let is_active = prepaint.current_line_index == Some(visible_range.start + ix);
for line in lines { for line in lines {
let p = point(origin.x, origin.y + offset_y); let p = point(input_bounds.origin.x, origin.y + offset_y);
let line_size = line.size(line_height); let line_size = line.size(line_height);
// Paint the current line background // Paint the current line background
if is_active { if is_active {
@ -898,7 +901,6 @@ impl Element for TextElement {
)); ));
} }
} }
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
offset_y += line_size.height; offset_y += line_size.height;
} }
} }
@ -913,7 +915,6 @@ impl Element for TextElement {
// Paint text // Paint text
let mut offset_y = mask_offset_y + invisible_top_padding; let mut offset_y = mask_offset_y + invisible_top_padding;
for line in prepaint for line in prepaint
.last_layout .last_layout
.iter() .iter()
@ -928,6 +929,7 @@ impl Element for TextElement {
offset_y += line.size(line_height).height; offset_y += line.size(line_height).height;
} }
// Paint blinking cursor
if focused { if focused {
if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() { if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() {
cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y; cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y;
@ -935,6 +937,54 @@ impl Element for TextElement {
} }
} }
// Paint line numbers
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding;
// Paint line number background
window.paint_quad(fill(
Bounds {
origin: input_bounds.origin,
size: size(
prepaint.last_layout.line_number_width,
input_bounds.size.height,
),
},
cx.theme()
.highlight_theme
.style
.background
.unwrap_or(cx.theme().input),
));
// Each item is the normal lines.
for (ix, lines) in line_numbers.iter().enumerate() {
for line in lines {
let p = point(input_bounds.origin.x, origin.y + offset_y);
let is_active = prepaint.current_line_index == Some(visible_range.start + ix);
let line_size = line.size(line_height);
// paint active line number background
if is_active {
if let Some(bg_color) = active_line_color {
window.paint_quad(fill(
Bounds::new(
p,
size(prepaint.last_layout.line_number_width, line_height),
),
bg_color,
));
}
}
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
offset_y += line_size.height;
}
}
}
self.state.update(cx, |state, cx| { self.state.update(cx, |state, cx| {
state.last_layout = Some(prepaint.last_layout.clone()); state.last_layout = Some(prepaint.last_layout.clone());
state.last_bounds = Some(bounds); state.last_bounds = Some(bounds);

View file

@ -1,7 +1,7 @@
use std::rc::Rc; use std::rc::Rc;
use std::{cell::RefCell, ops::Range}; use std::{cell::RefCell, ops::Range};
use gpui::{App, DefiniteLength, SharedString}; use gpui::{App, SharedString};
use crate::{highlighter::SyntaxHighlighter, input::marker::Marker}; use crate::{highlighter::SyntaxHighlighter, input::marker::Marker};
@ -41,7 +41,6 @@ pub enum InputMode {
MultiLine { MultiLine {
tab: TabSize, tab: TabSize,
rows: usize, rows: usize,
height: Option<DefiniteLength>,
}, },
AutoGrow { AutoGrow {
rows: usize, rows: usize,
@ -51,7 +50,6 @@ pub enum InputMode {
CodeEditor { CodeEditor {
tab: TabSize, tab: TabSize,
rows: usize, rows: usize,
height: Option<DefiniteLength>,
/// Show line number /// Show line number
line_number: bool, line_number: bool,
language: SharedString, language: SharedString,
@ -104,18 +102,6 @@ impl InputMode {
} }
} }
pub(super) fn set_height(&mut self, new_height: Option<DefiniteLength>) {
match self {
InputMode::MultiLine { height, .. } => {
*height = new_height;
}
InputMode::CodeEditor { height, .. } => {
*height = new_height;
}
_ => {}
}
}
pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) { pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) {
let wrapped_lines = text_wrapper.wrapped_lines.len(); let wrapped_lines = text_wrapper.wrapped_lines.len();
self.set_rows(wrapped_lines); self.set_rows(wrapped_lines);
@ -152,14 +138,6 @@ impl InputMode {
} }
} }
pub(super) fn height(&self) -> Option<DefiniteLength> {
match self {
InputMode::MultiLine { height, .. } => *height,
InputMode::CodeEditor { height, .. } => *height,
_ => None,
}
}
/// Return false if the mode is not [`InputMode::CodeEditor`]. /// Return false if the mode is not [`InputMode::CodeEditor`].
#[allow(unused)] #[allow(unused)]
#[inline] #[inline]

View file

@ -11,8 +11,8 @@ use std::rc::Rc;
use unicode_segmentation::*; use unicode_segmentation::*;
use gpui::{ use gpui::{
actions, div, point, prelude::FluentBuilder as _, px, relative, App, AppContext, Bounds, actions, div, point, prelude::FluentBuilder as _, px, App, AppContext, Bounds, ClipboardItem,
ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle,
ScrollWheelEvent, SharedString, Styled as _, Subscription, UTF16Selection, Window, WrappedLine, ScrollWheelEvent, SharedString, Styled as _, Subscription, UTF16Selection, Window, WrappedLine,
@ -224,7 +224,7 @@ pub(super) struct LastLayout {
pub(super) visible_range: Range<usize>, pub(super) visible_range: Range<usize>,
/// The wrap width of text layout, this will change will InputElement painted. /// The wrap width of text layout, this will change will InputElement painted.
pub(super) wrap_width: Option<Pixels>, pub(super) wrap_width: Option<Pixels>,
/// The line number width of text layout. /// The line number area width of text layout, if not line number, this will be 0px.
pub(super) line_number_width: Pixels, pub(super) line_number_width: Pixels,
} }
@ -362,7 +362,6 @@ impl InputState {
pub fn multi_line(mut self) -> Self { pub fn multi_line(mut self) -> Self {
self.mode = InputMode::MultiLine { self.mode = InputMode::MultiLine {
rows: 2, rows: 2,
height: None,
tab: TabSize::default(), tab: TabSize::default(),
}; };
self self
@ -404,7 +403,6 @@ impl InputState {
language, language,
highlighter: Rc::new(RefCell::new(None)), highlighter: Rc::new(RefCell::new(None)),
line_number: true, line_number: true,
height: Some(relative(1.)),
markers: Rc::new(vec![]), markers: Rc::new(vec![]),
}; };
self self

View file

@ -1,13 +1,14 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, IntoElement, IsZero, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement,
Window, Styled, Window,
}; };
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
use crate::indicator::Indicator; use crate::indicator::Indicator;
use crate::input::clear_button; use crate::input::clear_button;
use crate::input::element::{LINE_NUMBER_RIGHT_MARGIN, RIGHT_MARGIN};
use crate::scroll::Scrollbar; use crate::scroll::Scrollbar;
use crate::ActiveTheme; use crate::ActiveTheme;
use crate::{h_flex, StyledExt}; use crate::{h_flex, StyledExt};
@ -153,7 +154,6 @@ impl RenderOnce for TextInput {
let font_size = window.text_style().font_size.to_pixels(window.rem_size()); let font_size = window.text_style().font_size.to_pixels(window.rem_size());
self.state.update(cx, |state, cx| { self.state.update(cx, |state, cx| {
state.mode.set_height(self.height);
state.text_wrapper.set_font(font, font_size, cx); state.text_wrapper.set_font(font, font_size, cx);
state.disabled = self.disabled; state.disabled = self.disabled;
}); });
@ -299,8 +299,43 @@ 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(), |mut this| {
if state.last_layout.is_some() { let paddings = this.style().padding.clone();
let base_size = window.text_style().font_size;
let rem_size = window.rem_size();
let paddings = gpui::Edges {
left: paddings
.left
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
right: paddings
.right
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
top: paddings
.top
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
bottom: paddings
.bottom
.map(|v| v.to_pixels(base_size, rem_size))
.unwrap_or(px(0.)),
};
if let Some(last_layout) = state.last_layout.as_ref() {
let left = if last_layout.line_number_width.is_zero() {
px(0.)
} else {
// Align left edge to the Line number.
paddings.left + last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN
};
let scroll_size = gpui::Size {
width: state.scroll_size.width - left + paddings.right + RIGHT_MARGIN,
height: state.scroll_size.height,
};
let scrollbar = if !state.soft_wrap { let scrollbar = if !state.soft_wrap {
Scrollbar::both(&state.scroll_state, &state.scroll_handle) Scrollbar::both(&state.scroll_state, &state.scroll_handle)
} else { } else {
@ -311,10 +346,10 @@ impl RenderOnce for TextInput {
div() div()
.absolute() .absolute()
.top_0() .top_0()
.left_0() .left(left)
.right(px(1.)) .right_0()
.bottom_0() .bottom_0()
.child(scrollbar.scroll_size(state.scroll_size)), .child(scrollbar.scroll_size(scroll_size)),
) )
} else { } else {
this this