diff --git a/crates/story/examples/code-editor.rs b/crates/story/examples/code-editor.rs index 8ce41edb..681bca7e 100644 --- a/crates/story/examples/code-editor.rs +++ b/crates/story/examples/code-editor.rs @@ -5,7 +5,7 @@ use gpui_component::{ h_flex, highlighter::{Language, LanguageConfig, LanguageRegistry}, input::{InputEvent, InputState, Marker, TabSize, TextInput}, - v_flex, ActiveTheme, ContextModal, IconName, IndexPath, Sizable, + v_flex, ActiveTheme, ContextModal, IconName, IndexPath, Selectable, Sizable, }; use story::Assets; @@ -30,6 +30,7 @@ pub struct Example { language: Lang, line_number: bool, need_update: bool, + soft_wrap: bool, _subscribes: Vec, } @@ -99,6 +100,7 @@ impl Example { tab_size: 4, hard_tabs: false, }) + .soft_wrap(false) .default_value(default_language.1) .placeholder("Enter your code here...") }); @@ -140,6 +142,7 @@ impl Example { language: default_language.0, line_number: true, need_update: false, + soft_wrap: false, _subscribes, } } @@ -220,6 +223,14 @@ impl Example { }) }); } + + fn toggle_soft_wrap(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + 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 { @@ -232,7 +243,6 @@ impl Render for Example { .id("source") .w_full() .flex_1() - .gap_2() .child( TextInput::new(&self.editor) .bordered(false) @@ -272,7 +282,15 @@ impl Render for Example { }); 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({ let loc = self.editor.read(cx).line_column(); diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 4e4e6bf7..1f21b4ae 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -14,9 +14,9 @@ use crate::{ 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 LINE_NUMBER_MARGIN_RIGHT: Pixels = px(10.); +pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.); pub(super) struct TextElement { state: Entity, @@ -129,16 +129,17 @@ impl TextElement { let selection_changed = state.last_selected_range != Some(selected_range); if cursor_moved || selection_changed { - scroll_offset.x = - if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) { - // cursor is out of right - bounds.size.width - RIGHT_MARGIN - cursor_pos.x - } else if scroll_offset.x + cursor_pos.x < px(0.) { - // cursor is out of left - scroll_offset.x - cursor_pos.x - } else { - scroll_offset.x - }; + scroll_offset.x = if scroll_offset.x + cursor_pos.x + > (bounds.size.width - line_number_width - RIGHT_MARGIN) + { + // cursor is out of right + bounds.size.width - line_number_width - RIGHT_MARGIN - cursor_pos.x + } else if scroll_offset.x + cursor_pos.x < px(0.) { + // cursor is out of left + scroll_offset.x - cursor_pos.x + } else { + scroll_offset.x + }; scroll_offset.y = if scroll_offset.y + cursor_pos.y + line_height > bounds.size.height - bottom_margin { @@ -512,12 +513,13 @@ impl Element for TextElement { style.size.width = relative(1.).into(); if state.mode.is_multi_line() { style.flex_grow = 1.0; - if let Some(h) = state.mode.height() { - style.size.height = h.into(); - style.min_size.height = line_height.into(); + style.size.height = relative(1.).into(); + if state.mode.is_auto_grow() { + // 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 { - style.size.height = relative(1.).into(); - style.min_size.height = (state.mode.rows() * line_height).into(); + style.min_size.height = line_height.into(); } } else { // For single-line inputs, the minimum height should be the line height @@ -583,7 +585,7 @@ impl Element for TextElement { ) .unwrap(); 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 { px(0.) }; @@ -674,21 +676,21 @@ impl Element for TextElement { .expect("failed to shape text"); // measure.end(); - let total_wrapped_lines = lines - .iter() - .map(|line| { - // +1 is the first line, `wrap_boundaries` is the wrapped lines after the `\n`. - 1 + line.wrap_boundaries.len() - }) - .sum::(); + let mut max_line_width = px(0.); + let mut total_wrapped_lines = 0; + for line in lines.iter() { + // FIXME: The `shape_text` measured width is not stable, sometime will large, sometime small. + max_line_width = max_line_width.max(line.width()); + // +1 is the first line, `wrap_boundaries` is the wrapped lines after the `\n`. + 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( - 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), ); @@ -879,6 +881,7 @@ impl Element for TextElement { let active_line_color = cx.theme().highlight_theme.style.active_line; + // Paint active line let mut offset_y = px(0.); if let Some(line_numbers) = prepaint.line_numbers.as_ref() { offset_y += invisible_top_padding; @@ -887,7 +890,7 @@ impl Element for TextElement { for (ix, lines) in line_numbers.iter().enumerate() { let is_active = prepaint.current_line_index == Some(visible_range.start + ix); 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); // Paint the current line background 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; } } @@ -913,7 +915,6 @@ impl Element for TextElement { // Paint text let mut offset_y = mask_offset_y + invisible_top_padding; - for line in prepaint .last_layout .iter() @@ -928,6 +929,7 @@ impl Element for TextElement { offset_y += line.size(line_height).height; } + // Paint blinking cursor if focused { if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() { 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| { state.last_layout = Some(prepaint.last_layout.clone()); state.last_bounds = Some(bounds); diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index 4e87218c..ccd2024b 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use std::{cell::RefCell, ops::Range}; -use gpui::{App, DefiniteLength, SharedString}; +use gpui::{App, SharedString}; use crate::{highlighter::SyntaxHighlighter, input::marker::Marker}; @@ -41,7 +41,6 @@ pub enum InputMode { MultiLine { tab: TabSize, rows: usize, - height: Option, }, AutoGrow { rows: usize, @@ -51,7 +50,6 @@ pub enum InputMode { CodeEditor { tab: TabSize, rows: usize, - height: Option, /// Show line number line_number: bool, language: SharedString, @@ -104,18 +102,6 @@ impl InputMode { } } - pub(super) fn set_height(&mut self, new_height: Option) { - 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) { let wrapped_lines = text_wrapper.wrapped_lines.len(); self.set_rows(wrapped_lines); @@ -152,14 +138,6 @@ impl InputMode { } } - pub(super) fn height(&self) -> Option { - match self { - InputMode::MultiLine { height, .. } => *height, - InputMode::CodeEditor { height, .. } => *height, - _ => None, - } - } - /// Return false if the mode is not [`InputMode::CodeEditor`]. #[allow(unused)] #[inline] diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 86702de9..5f20e42a 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -11,8 +11,8 @@ use std::rc::Rc; use unicode_segmentation::*; use gpui::{ - actions, div, point, prelude::FluentBuilder as _, px, relative, App, AppContext, Bounds, - ClipboardItem, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, + actions, div, point, prelude::FluentBuilder as _, px, App, AppContext, Bounds, ClipboardItem, + Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, UTF16Selection, Window, WrappedLine, @@ -224,7 +224,7 @@ pub(super) struct LastLayout { pub(super) visible_range: Range, /// The wrap width of text layout, this will change will InputElement painted. pub(super) wrap_width: Option, - /// 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, } @@ -362,7 +362,6 @@ impl InputState { pub fn multi_line(mut self) -> Self { self.mode = InputMode::MultiLine { rows: 2, - height: None, tab: TabSize::default(), }; self @@ -404,7 +403,6 @@ impl InputState { language, highlighter: Rc::new(RefCell::new(None)), line_number: true, - height: Some(relative(1.)), markers: Rc::new(vec![]), }; self diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index 7c7c8e1e..405accf1 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -1,13 +1,14 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, - Window, + IntoElement, IsZero, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, + Styled, Window, }; use crate::button::{Button, ButtonVariants as _}; use crate::indicator::Indicator; use crate::input::clear_button; +use crate::input::element::{LINE_NUMBER_RIGHT_MARGIN, RIGHT_MARGIN}; use crate::scroll::Scrollbar; use crate::ActiveTheme; 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()); self.state.update(cx, |state, cx| { - state.mode.set_height(self.height); state.text_wrapper.set_font(font, font_size, cx); state.disabled = self.disabled; }); @@ -299,8 +299,43 @@ impl RenderOnce for TextInput { ) }) .refine_style(&self.style) - .when(state.mode.is_multi_line(), |this| { - if state.last_layout.is_some() { + .when(state.mode.is_multi_line(), |mut this| { + 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 { Scrollbar::both(&state.scroll_state, &state.scroll_handle) } else { @@ -311,10 +346,10 @@ impl RenderOnce for TextInput { div() .absolute() .top_0() - .left_0() - .right(px(1.)) + .left(left) + .right_0() .bottom_0() - .child(scrollbar.scroll_size(state.scroll_size)), + .child(scrollbar.scroll_size(scroll_size)), ) } else { this