input: Add CodeEditor mode. (#863)

## TODO

- [x] Cache highlight result.
- [x] Add a `code-editor` example with more languages test.
- [x] Support setup font to Input (Base on window scope text style).
- [x] Line number
- [x] Change font size and font family.
- [x] Enter newline to keep indent.
- [x] Height with flex_1
- [x] Add to support press `up`, `down` to move cursor to start or end
of the selection, if there is selected.
- [x] Add `Tab`, `Shift-Tab` to indent or outdent for cursor and
selection.
- [x] Double click to select at least 1 char.


https://github.com/user-attachments/assets/af74862d-15a9-4802-b4b4-2aeb606227ec
This commit is contained in:
Jason Lee 2025-05-20 15:51:00 +08:00 committed by GitHub
parent 67dd658635
commit 5658db42c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 952 additions and 171 deletions

View file

@ -0,0 +1,151 @@
use std::sync::LazyLock;
use gpui::*;
use gpui_component::{
checkbox::Checkbox,
dropdown::{Dropdown, DropdownEvent, DropdownState},
h_flex,
highlighter::{HighlightTheme, Highlighter},
input::{InputEvent, InputState, TabSize, TextInput},
v_flex, ActiveTheme as _,
};
use story::Assets;
static LIGHT_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_light());
static DARK_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_dark());
pub struct Example {
input_state: Entity<InputState>,
language_state: Entity<DropdownState<Vec<SharedString>>>,
language: SharedString,
is_dark: bool,
line_number: bool,
_subscribes: Vec<Subscription>,
}
const EXAMPLE: &str = include_str!("./code-editor.rs");
const LANGUAGES: [&str; 7] = ["rust", "javascript", "html", "css", "go", "python", "ruby"];
impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let default_language: SharedString = LANGUAGES[0].into();
let input_state = cx.new(|cx| {
InputState::new(window, cx)
.code_editor(Some(&default_language), &LIGHT_THEME)
.line_number(true)
.tab_size(TabSize {
tab_size: 4,
hard_tabs: false,
})
.default_value(EXAMPLE)
.placeholder("Enter your code here...")
});
let language_state = cx.new(|cx| {
DropdownState::new(
LANGUAGES.iter().map(|s| s.to_string().into()).collect(),
Some(0),
window,
cx,
)
});
let _subscribes = vec![
cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| {
cx.notify();
}),
cx.subscribe(
&language_state,
|this, state, _: &DropdownEvent<Vec<SharedString>>, cx| {
if let Some(val) = state.read(cx).selected_value() {
this.update_highlighter(Some(val.clone()), cx);
cx.notify();
}
},
),
];
Self {
input_state,
language_state,
language: default_language,
is_dark: false,
line_number: true,
_subscribes,
}
}
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::new(window, cx))
}
fn update_highlighter(&mut self, new_language: Option<SharedString>, cx: &mut Context<Self>) {
let is_dark = cx.theme().mode.is_dark();
let is_language_changed = new_language.is_some();
if new_language.is_some() {
self.language = new_language.unwrap();
}
let language = self.language.as_ref();
if self.is_dark != is_dark || is_language_changed {
self.is_dark = is_dark;
self.input_state.update(cx, |state, cx| {
if is_dark {
state.set_highlighter(Highlighter::new(Some(language), &DARK_THEME), cx);
} else {
state.set_highlighter(Highlighter::new(Some(language), &LIGHT_THEME), cx);
}
});
}
}
}
impl Render for Example {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.update_highlighter(None, cx);
v_flex()
.size_full()
.child(
h_flex()
.p_4()
.pb_0()
.gap_4()
.flex_shrink_0()
.items_center()
.justify_between()
.child(Dropdown::new(&self.language_state).title_prefix("Language: "))
.child(
Checkbox::new("line-numbger")
.checked(self.line_number)
.on_click(cx.listener(|this, checked: &bool, window, cx| {
this.line_number = *checked;
this.input_state.update(cx, |state, cx| {
state.set_line_number(this.line_number, window, cx);
});
cx.notify();
}))
.label("Line Number"),
),
)
.child(
div()
.id("source")
.w_full()
.flex_1()
.font_family("Menlo")
.p_4()
.text_size(px(13.))
.child(TextInput::new(&self.input_state).h_full()),
)
}
}
fn main() {
let app = Application::new().with_assets(Assets);
app.run(move |cx| {
story::init(cx);
cx.activate(true);
story::create_new_window("Code Editor", Example::view, cx);
});
}

View file

@ -1,24 +1,32 @@
use std::sync::LazyLock;
use gpui::*;
use gpui_component::{
h_flex,
highlighter::{HighlightTheme, Highlighter},
input::{InputState, TextInput},
text::TextView,
ActiveTheme as _,
};
use story::Assets;
static LIGHT_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_light());
static DARK_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_dark());
pub struct Example {
input_state: Entity<InputState>,
is_dark: bool,
_subscribe: Subscription,
}
const EXAMPLE: &str = include_str!("./html.html");
const LANG: &str = "html";
impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input_state = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.code_editor(Some(LANG), &LIGHT_THEME)
.default_value(EXAMPLE)
.placeholder("Enter your HTML here...")
});
@ -32,6 +40,7 @@ impl Example {
Self {
input_state,
is_dark: false,
_subscribe,
}
}
@ -42,7 +51,19 @@ impl Example {
}
impl Render for Example {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_dark = cx.theme().mode.is_dark();
if self.is_dark != is_dark {
self.is_dark = is_dark;
self.input_state.update(cx, |state, cx| {
if is_dark {
state.set_highlighter(Highlighter::new(Some(LANG), &DARK_THEME), cx);
} else {
state.set_highlighter(Highlighter::new(Some(LANG), &LIGHT_THEME), cx);
}
});
}
h_flex()
.h_full()
.child(
@ -52,6 +73,8 @@ impl Render for Example {
.w_1_2()
.border_r_1()
.border_color(cx.theme().border)
.font_family("Menlo")
.text_size(px(13.))
.child(TextInput::new(&self.input_state).h_full().appearance(false)),
)
.child(

View file

@ -1,16 +1,21 @@
use std::rc::Rc;
use std::{rc::Rc, sync::LazyLock};
use gpui::*;
use gpui_component::{
highlighter::HighlightTheme,
input::{InputState, TextInput},
highlighter::{HighlightTheme, Highlighter},
input::{InputEvent, InputState, TextInput},
text::{TextView, TextViewStyle},
ActiveTheme as _,
};
use story::Assets;
static LIGHT_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_light());
static DARK_THEME: LazyLock<HighlightTheme> = LazyLock::new(|| HighlightTheme::default_dark());
const LANG: &str = "markdown";
pub struct Example {
input_state: Entity<InputState>,
is_dark: bool,
}
const EXAMPLE: &str = include_str!("./markdown.md");
@ -19,19 +24,19 @@ impl Example {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input_state = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.code_editor(Some(LANG), &LIGHT_THEME)
.placeholder("Enter your Markdown here...")
.default_value(EXAMPLE)
});
let _subscribe = cx.subscribe(
&input_state,
|_, _, _: &gpui_component::input::InputEvent, cx| {
cx.notify();
},
);
let _subscribe = cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| {
cx.notify();
});
Self { input_state }
Self {
input_state,
is_dark: false,
}
}
fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
@ -40,13 +45,25 @@ impl Example {
}
impl Render for Example {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = if cx.theme().mode.is_dark() {
HighlightTheme::default_dark()
} else {
HighlightTheme::default_light()
};
let is_dark = cx.theme().mode.is_dark();
if self.is_dark != is_dark {
self.is_dark = is_dark;
self.input_state.update(cx, |state, cx| {
if is_dark {
state.set_highlighter(Highlighter::new(Some(LANG), &DARK_THEME), cx);
} else {
state.set_highlighter(Highlighter::new(Some(LANG), &LIGHT_THEME), cx);
}
});
}
div()
.flex()
.flex_row()

View file

@ -6,7 +6,7 @@ use std::{
use syntect::{highlighting, parsing};
static SYNTAXES: LazyLock<parsing::SyntaxSet> =
LazyLock::new(parsing::SyntaxSet::load_defaults_nonewlines);
LazyLock::new(parsing::SyntaxSet::load_defaults_newlines);
static DEFAULT_LIGHT: LazyLock<Arc<highlighting::Theme>> = LazyLock::new(|| {
let mut cursor = std::io::Cursor::new(include_bytes!("./themes/light.tmTheme"));

View file

@ -1,16 +1,20 @@
use std::ops::Range;
use gpui::{
fill, point, px, relative, size, App, Bounds, Corners, Element, ElementId, ElementInputHandler,
Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path,
Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine,
fill, hash, point, px, relative, size, App, Bounds, Corners, Element, ElementId,
ElementInputHandler, Entity, GlobalElementId, HighlightStyle, IntoElement, LayoutId,
MouseButton, MouseMoveEvent, PaintQuad, Path, Pixels, Point, SharedString, Style, TextAlign,
TextRun, UnderlineStyle, Window, WrappedLine,
};
use smallvec::SmallVec;
use crate::{ActiveTheme as _, Root};
use super::InputState;
use super::{mode::InputMode, InputState};
const RIGHT_MARGIN: Pixels = px(5.);
const BOTTOM_MARGIN_ROWS: usize = 1;
const LINE_NUMBER_MARGIN_RIGHT: Pixels = px(10.);
pub(super) struct TextElement {
input: Entity<InputState>,
@ -50,6 +54,7 @@ impl TextElement {
lines: &[WrappedLine],
line_height: Pixels,
bounds: &mut Bounds<Pixels>,
line_number_width: Pixels,
window: &mut Window,
cx: &mut App,
) -> (Option<PaintQuad>, Point<Pixels>) {
@ -162,7 +167,7 @@ impl TextElement {
cursor = Some(fill(
Bounds::new(
point(
bounds.left() + cursor_pos.x,
bounds.left() + cursor_pos.x + line_number_width,
bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.),
),
size(px(1.), cursor_height),
@ -180,6 +185,7 @@ impl TextElement {
lines: &[WrappedLine],
line_height: Pixels,
bounds: &mut Bounds<Pixels>,
line_number_width: Pixels,
_: &mut Window,
cx: &mut App,
) -> Option<Path<Pixels>> {
@ -293,19 +299,53 @@ impl TextElement {
// print_points_as_svg_path(&line_corners, &points);
let path_origin = bounds.origin + point(line_number_width, px(0.));
let first_p = *points.get(0).unwrap();
let mut builder = gpui::PathBuilder::fill();
builder.move_to(bounds.origin + first_p);
builder.move_to(path_origin + first_p);
for p in points.iter().skip(1) {
builder.line_to(bounds.origin + *p);
builder.line_to(path_origin + *p);
}
builder.build().ok()
}
fn highlight_text(&self, cx: &mut App) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
let input = self.input.read(cx);
let text = input.text.as_ref();
let cache_key = hash(&text);
match &input.mode {
InputMode::CodeEditor {
highlighter, cache, ..
} => {
if cache.0 == cache_key {
return Some(cache.1.clone());
}
if let Some(highlighter) = highlighter {
let styles = highlighter.highlight(&text);
self.input.update(cx, |input, _cx| {
input
.mode
.set_code_editor_cache((cache_key, styles.clone()));
});
Some(styles)
} else {
None
}
}
_ => None,
}
}
}
pub(super) struct PrepaintState {
lines: SmallVec<[WrappedLine; 1]>,
line_numbers: Option<SmallVec<[WrappedLine; 1]>>,
line_number_width: Pixels,
cursor: Option<PaintQuad>,
cursor_scroll_offset: Point<Pixels>,
selection_path: Option<Path<Pixels>>,
@ -392,12 +432,14 @@ impl Element for TextElement {
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let highlights = self.highlight_text(cx);
let multi_line = self.input.read(cx).is_multi_line();
let line_height = window.line_height();
let input = self.input.read(cx);
let text = input.text.clone();
let placeholder = self.placeholder.clone();
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let mut bounds = bounds;
let (display_text, text_color) = if text.is_empty() {
@ -411,6 +453,52 @@ impl Element for TextElement {
(text, cx.theme().foreground)
};
let text_style = window.text_style();
// Calculate the width of the line numbers
let mut line_number_width = px(0.);
let line_numbers = if input.mode.line_number() {
let mut line_numbers = SmallVec::new();
let total_lines = input.text_wrapper.lines.len();
let run_len = if total_lines > 999 { 4 } else { 3 };
let runs = vec![TextRun {
len: run_len,
font: style.font(),
color: cx.theme().muted_foreground,
background_color: None,
underline: None,
strikethrough: None,
}];
for (i, line_wrap) in input.text_wrapper.lines.iter().enumerate() {
let line_no = if run_len == 4 {
format!("{:>4}", i + 1).into()
} else {
format!("{:>3}", i + 1).into()
};
let line = window
.text_system()
.shape_text(line_no, font_size, &runs, None, None)
.unwrap();
line_number_width = (line.last().unwrap().width() + LINE_NUMBER_MARGIN_RIGHT)
.max(line_number_width);
line_numbers.extend(line);
for _ in 0..line_wrap.wrap_lines {
// Empty line no for wrapped lines
let line = window
.text_system()
.shape_text(" ".into(), font_size, &runs, None, None)
.unwrap();
line_numbers.extend(line);
}
}
Some(line_numbers)
} else {
None
};
let run = TextRun {
len: display_text.len(),
font: style.font(),
@ -444,12 +532,25 @@ impl Element for TextElement {
.filter(|run| run.len > 0)
.collect()
} else {
vec![run]
if let Some(highlights) = highlights {
let mut runs = vec![];
for (range, style) in highlights {
let run = text_style
.clone()
.highlight(style)
.to_run(range.end - range.start);
if run.len > 0 {
runs.push(run);
}
}
runs
} else {
vec![run]
}
};
let font_size = style.font_size.to_pixels(window.rem_size());
let wrap_width = if multi_line {
Some(bounds.size.width - RIGHT_MARGIN)
Some(bounds.size.width - line_number_width - RIGHT_MARGIN)
} else {
None
};
@ -490,14 +591,29 @@ impl Element for TextElement {
// Calculate the scroll offset to keep the cursor in view
let (cursor, cursor_scroll_offset) =
self.layout_cursor(&lines, line_height, &mut bounds, window, cx);
let (cursor, cursor_scroll_offset) = self.layout_cursor(
&lines,
line_height,
&mut bounds,
line_number_width,
window,
cx,
);
let selection_path = self.layout_selections(&lines, line_height, &mut bounds, window, cx);
let selection_path = self.layout_selections(
&lines,
line_height,
&mut bounds,
line_number_width,
window,
cx,
);
PrepaintState {
bounds,
lines,
line_numbers,
line_number_width,
cursor,
cursor_scroll_offset,
selection_path,
@ -566,8 +682,19 @@ impl Element for TextElement {
offset_y = px(2.5);
}
}
if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
for line in line_numbers.iter() {
let p = point(origin.x, origin.y + offset_y);
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
let line_size = line.size(line_height);
offset_y += line_size.height;
}
}
let mut offset_y = px(0.);
for line in prepaint.lines.iter() {
let p = point(origin.x, origin.y + offset_y);
let p = point(origin.x + prepaint.line_number_width, origin.y + offset_y);
_ = line.paint(p, line_height, TextAlign::Left, None, window, cx);
offset_y += line.size(line_height).height;
}
@ -595,6 +722,7 @@ impl Element for TextElement {
input.set_input_bounds(input_bounds, cx);
input.last_selected_range = Some(selected_range);
input.scroll_size = scroll_size;
input.line_number_width = prepaint.line_number_width;
input
.scroll_handle
.set_offset(prepaint.cursor_scroll_offset);

View file

@ -3,6 +3,7 @@ mod change;
mod clear_button;
mod element;
mod mask_pattern;
mod mode;
mod number_input;
mod otp_input;
mod state;
@ -11,6 +12,7 @@ mod text_wrapper;
pub(crate) use clear_button::*;
pub use mask_pattern::MaskPattern;
pub use mode::TabSize;
pub use number_input::{NumberInput, NumberInputEvent, StepAction};
pub use otp_input::*;
pub use state::*;

194
crates/ui/src/input/mode.rs Normal file
View file

@ -0,0 +1,194 @@
use gpui::{DefiniteLength, HighlightStyle, SharedString};
use std::ops::Range;
use std::rc::Rc;
use super::text_wrapper::TextWrapper;
use crate::highlighter::Highlighter;
#[derive(Debug, Copy, Clone)]
pub struct TabSize {
/// Default is 2
pub tab_size: usize,
/// Set true to use `\t` as tab indent, default is false
pub hard_tabs: bool,
}
impl Default for TabSize {
fn default() -> Self {
Self {
tab_size: 2,
hard_tabs: false,
}
}
}
impl TabSize {
pub(super) fn to_string(&self) -> SharedString {
if self.hard_tabs {
"\t".into()
} else {
" ".repeat(self.tab_size).into()
}
}
}
#[derive(Default, Clone)]
pub enum InputMode {
#[default]
SingleLine,
MultiLine {
tab: TabSize,
rows: usize,
height: Option<DefiniteLength>,
},
CodeEditor {
tab: TabSize,
rows: usize,
height: Option<DefiniteLength>,
/// Show line number
line_number: bool,
highlighter: Option<Rc<Highlighter<'static>>>,
cache: (u64, Vec<(Range<usize>, HighlightStyle)>),
},
AutoGrow {
rows: usize,
min_rows: usize,
max_rows: usize,
},
}
impl InputMode {
pub(super) fn set_rows(&mut self, new_rows: usize) {
match self {
InputMode::MultiLine { rows, .. } => {
*rows = new_rows;
}
InputMode::CodeEditor { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
rows,
min_rows,
max_rows,
} => {
*rows = new_rows.clamp(*min_rows, *max_rows);
}
_ => {}
}
}
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) {
let wrapped_lines = text_wrapper.wrapped_lines.len();
self.set_rows(wrapped_lines);
}
/// At least 1 row be return.
pub(super) fn rows(&self) -> usize {
match self {
InputMode::MultiLine { rows, .. } => *rows,
InputMode::CodeEditor { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
_ => 1,
}
.max(1)
}
/// At least 1 row be return.
#[allow(unused)]
pub(super) fn min_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } | InputMode::CodeEditor { .. } => 1,
InputMode::AutoGrow { min_rows, .. } => *min_rows,
_ => 1,
}
.max(1)
}
#[allow(unused)]
pub(super) fn max_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } | InputMode::CodeEditor { .. } => usize::MAX,
InputMode::AutoGrow { max_rows, .. } => *max_rows,
_ => 1,
}
}
pub(super) fn height(&self) -> Option<DefiniteLength> {
match self {
InputMode::MultiLine { height, .. } => *height,
InputMode::CodeEditor { height, .. } => *height,
_ => None,
}
}
pub(super) fn set_code_editor_cache(
&mut self,
cache: (u64, Vec<(Range<usize>, HighlightStyle)>),
) {
if let InputMode::CodeEditor { cache: c, .. } = self {
*c = cache;
}
}
/// Return false if the mode is not [`InputMode::CodeEditor`].
#[allow(unused)]
#[inline]
pub(super) fn line_number(&self) -> bool {
match self {
InputMode::CodeEditor { line_number, .. } => *line_number,
_ => false,
}
}
#[inline]
pub(super) fn tab_size(&self) -> Option<&TabSize> {
match self {
InputMode::MultiLine { tab, .. } => Some(tab),
InputMode::CodeEditor { tab, .. } => Some(tab),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::TabSize;
#[test]
fn test_tab_size() {
let tab = TabSize {
tab_size: 2,
hard_tabs: false,
};
assert_eq!(tab.to_string(), " ");
let tab = TabSize {
tab_size: 4,
hard_tabs: false,
};
assert_eq!(tab.to_string(), " ");
let tab = TabSize {
tab_size: 2,
hard_tabs: true,
};
assert_eq!(tab.to_string(), "\t");
let tab = TabSize {
tab_size: 4,
hard_tabs: true,
};
assert_eq!(tab.to_string(), "\t");
}
}

View file

@ -11,8 +11,8 @@ use std::rc::Rc;
use unicode_segmentation::*;
use gpui::{
actions, div, impl_internal_actions, point, prelude::FluentBuilder as _, px, App, AppContext,
Bounds, ClipboardItem, Context, DefiniteLength, Entity, EntityInputHandler, EventEmitter,
actions, div, impl_internal_actions, point, prelude::FluentBuilder as _, px, relative, 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,
@ -23,10 +23,20 @@ use gpui::{
// - Move cursor to skip line eof empty chars.
use super::{
blink_cursor::BlinkCursor, change::Change, element::TextElement, mask_pattern::MaskPattern,
number_input, text_wrapper::TextWrapper,
blink_cursor::BlinkCursor,
change::Change,
element::TextElement,
mask_pattern::MaskPattern,
mode::{InputMode, TabSize},
number_input,
text_wrapper::TextWrapper,
};
use crate::{
highlighter::{HighlightTheme, Highlighter},
history::History,
scroll::ScrollbarState,
Root,
};
use crate::{history::History, scroll::ScrollbarState, Root};
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct Enter {
@ -45,6 +55,8 @@ actions!(
DeleteToEndOfLine,
DeleteToPreviousWordStart,
DeleteToNextWordEnd,
Indent,
Outdent,
Up,
Down,
Left,
@ -112,6 +124,8 @@ pub fn init(cx: &mut App) {
KeyBinding::new("down", Down, Some(CONTEXT)),
KeyBinding::new("left", Left, Some(CONTEXT)),
KeyBinding::new("right", Right, Some(CONTEXT)),
KeyBinding::new("tab", Indent, Some(CONTEXT)),
KeyBinding::new("shift-tab", Outdent, Some(CONTEXT)),
KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)),
@ -191,95 +205,6 @@ pub fn init(cx: &mut App) {
number_input::init(cx);
}
#[derive(Debug, Default, Clone)]
pub enum InputMode {
#[default]
SingleLine,
MultiLine {
rows: usize,
height: Option<DefiniteLength>,
},
AutoGrow {
rows: usize,
min_rows: usize,
max_rows: usize,
},
}
impl InputMode {
pub(super) fn set_rows(&mut self, new_rows: usize) {
match self {
InputMode::MultiLine { rows, .. } => {
*rows = new_rows;
}
InputMode::AutoGrow {
rows,
min_rows,
max_rows,
} => {
*rows = new_rows.clamp(*min_rows, *max_rows);
}
_ => {}
}
}
pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) {
match self {
Self::AutoGrow { .. } => {
let wrapped_lines = text_wrapper.wrapped_lines.len();
self.set_rows(wrapped_lines);
}
_ => {}
}
}
/// At least 1 row be return.
pub(super) fn rows(&self) -> usize {
match self {
InputMode::MultiLine { rows, .. } => *rows,
InputMode::AutoGrow { rows, .. } => *rows,
_ => 1,
}
.max(1)
}
/// At least 1 row be return.
#[allow(unused)]
pub(super) fn min_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } => 1,
InputMode::AutoGrow { min_rows, .. } => *min_rows,
_ => 1,
}
.max(1)
}
#[allow(unused)]
pub(super) fn max_rows(&self) -> usize {
match self {
InputMode::MultiLine { .. } => usize::MAX,
InputMode::AutoGrow { max_rows, .. } => *max_rows,
_ => 1,
}
}
pub(super) fn set_height(&mut self, new_height: Option<DefiniteLength>) {
match self {
InputMode::MultiLine { height, .. } => {
*height = new_height;
}
_ => {}
}
}
pub(super) fn height(&self) -> Option<DefiniteLength> {
match self {
InputMode::MultiLine { height, .. } => *height,
_ => None,
}
}
}
/// InputState to keep editing state of the [`super::TextInput`].
pub struct InputState {
pub(super) focus_handle: FocusHandle,
@ -317,6 +242,7 @@ pub struct InputState {
pub(super) scrollbar_state: Rc<Cell<ScrollbarState>>,
/// The size of the scrollable content.
pub(crate) scroll_size: gpui::Size<Pixels>,
pub(crate) line_number_width: Pixels,
/// The mask pattern for formatting the input text
pub(crate) mask_pattern: MaskPattern,
@ -390,6 +316,7 @@ impl InputState {
scrollbar_state: Rc::new(Cell::new(ScrollbarState::default())),
scroll_size: gpui::size(px(0.), px(0.)),
preferred_x_offset: None,
line_number_width: px(0.),
placeholder: SharedString::default(),
mask_pattern: MaskPattern::default(),
_subscriptions,
@ -403,6 +330,7 @@ impl InputState {
self.mode = InputMode::MultiLine {
rows: 2,
height: None,
tab: TabSize::default(),
};
self
}
@ -417,12 +345,105 @@ impl InputState {
self
}
/// Set Input to use [`InputMode::CodeEditor`] mode.
///
/// Default options:
///
/// - line_number: true
/// - tab_size: 2
/// - hard_tabs: false
/// - height: full
///
/// Code Editor aim for help used to simple code editing or display, not a full-featured code editor.
///
/// ## Features
///
/// - Syntax Highlighting
/// - Auto Indent
/// - Line Number
pub fn code_editor(mut self, language: Option<&str>, theme: &'static HighlightTheme) -> Self {
let highlighter = Highlighter::new(language, theme);
self.mode = InputMode::CodeEditor {
rows: 2,
tab: TabSize::default(),
highlighter: Some(Rc::new(highlighter)),
cache: (0, vec![]),
line_number: true,
height: Some(relative(1.)),
};
self
}
/// Set placeholder
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
/// Set enable/disable line number, only for [`InputMode::CodeEditor`] mode.
pub fn line_number(mut self, line_number: bool) -> Self {
if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
self
}
/// Set line number, only for [`InputMode::CodeEditor`] mode.
pub fn set_line_number(&mut self, line_number: bool, _: &mut Window, cx: &mut Context<Self>) {
if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode {
*l = line_number;
}
cx.notify();
}
/// Set the tab size for the input.
///
/// Only for [`InputMode::MultiLine`] and [`InputMode::CodeEditor`] mode.
pub fn tab_size(mut self, tab: TabSize) -> Self {
match &mut self.mode {
InputMode::MultiLine { tab: t, .. } => *t = tab,
InputMode::CodeEditor { tab: t, .. } => *t = tab,
_ => {}
}
self
}
/// Set the number of rows for the multi-line Textarea.
///
/// This is only used when `multi_line` is set to true.
///
/// default: 2
pub fn rows(mut self, rows: usize) -> Self {
match &mut self.mode {
InputMode::MultiLine { rows: r, .. } => *r = rows,
InputMode::AutoGrow {
max_rows: max_r,
rows: r,
..
} => {
*r = rows;
*max_r = rows;
}
_ => {}
}
self
}
/// Set highlighter, only for [`InputMode::CodeEditor`] mode.
pub fn set_highlighter(&mut self, highlighter: Highlighter<'static>, cx: &mut Context<Self>) {
let new_highlighter = Rc::new(highlighter);
match &mut self.mode {
InputMode::CodeEditor {
highlighter, cache, ..
} => {
*highlighter = Some(new_highlighter);
*cache = (0, vec![]);
}
_ => {}
}
cx.notify();
}
/// Set placeholder
pub fn set_placeholder(
&mut self,
@ -565,7 +586,7 @@ impl InputState {
pub(super) fn is_multi_line(&self) -> bool {
matches!(
self.mode,
InputMode::MultiLine { .. } | InputMode::AutoGrow { .. }
InputMode::MultiLine { .. } | InputMode::AutoGrow { .. } | InputMode::CodeEditor { .. }
)
}
@ -579,28 +600,6 @@ impl InputState {
matches!(self.mode, InputMode::AutoGrow { .. })
}
/// Set the number of rows for the multi-line Textarea.
///
/// This is only used when `multi_line` is set to true.
///
/// default: 2
pub fn rows(mut self, rows: usize) -> Self {
match self.mode {
InputMode::MultiLine { height, .. } => {
self.mode = InputMode::MultiLine { rows, height };
}
InputMode::AutoGrow { max_rows, .. } => {
self.mode = InputMode::AutoGrow {
rows,
min_rows: rows,
max_rows,
};
}
_ => {}
}
self
}
/// Set the text of the input field.
///
/// And the selection_range will be reset to 0..0.
@ -714,6 +713,7 @@ impl InputState {
/// Set the default value of the input field.
pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
self.text = value.into();
self.text_wrapper.text = self.text.clone();
self
}
@ -758,6 +758,10 @@ impl InputState {
if self.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(self.selected_range.start.saturating_sub(1), window, cx);
}
self.pause_blink_cursor(cx);
self.move_vertical(-1, window, cx);
}
@ -766,6 +770,11 @@ impl InputState {
if self.is_single_line() {
return;
}
if !self.selected_range.is_empty() {
self.move_to(self.selected_range.end.saturating_sub(1), window, cx);
}
self.pause_blink_cursor(cx);
self.move_vertical(1, window, cx);
}
@ -966,6 +975,24 @@ impl InputState {
line
}
/// Get start line of selection start or end (The min value).
///
/// This is means is always get the first line of selection.
fn start_of_line_of_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) -> usize {
if self.is_single_line() {
return 0;
}
let offset = self.previous_boundary(self.selected_range.start.min(self.selected_range.end));
let line = self
.text_for_range(self.range_to_utf16(&(0..offset + 1)), &mut None, window, cx)
.unwrap_or_default()
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
line
}
/// Get end of line
fn end_of_line(&mut self, window: &mut Window, cx: &mut Context<Self>) -> usize {
if self.is_single_line() {
@ -1001,6 +1028,49 @@ impl InputState {
line
}
/// Get indent string of next line.
///
/// To get current and next line indent, to return more depth one.
pub(super) fn indent_of_next_line(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> String {
if self.is_single_line() {
return "".into();
}
let mut current_indent = String::new();
let mut next_indent = String::new();
let current_line_start_pos = self.start_of_line(window, cx);
let next_line_start_pos = self.end_of_line(window, cx);
for c in self.text.chars().skip(current_line_start_pos) {
if !c.is_whitespace() {
break;
}
if c == '\n' || c == '\r' {
break;
}
current_indent.push(c);
}
for c in self.text.chars().skip(next_line_start_pos) {
if !c.is_whitespace() {
break;
}
if c == '\n' || c == '\r' {
break;
}
next_indent.push(c);
}
if next_indent.len() > current_indent.len() {
return next_indent;
} else {
return current_indent;
}
}
pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.select_to(self.previous_boundary(self.cursor_offset()), window, cx)
@ -1091,6 +1161,9 @@ impl InputState {
pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context<Self>) {
if self.is_multi_line() {
let is_eof = self.selected_range.end == self.text.len();
// Get current line indent
let indent = self.indent_of_next_line(window, cx);
self.replace_text_in_range(None, "\n", window, cx);
// Move cursor to the start of the next line
@ -1099,6 +1172,14 @@ impl InputState {
new_offset += 1;
}
self.move_to(new_offset, window, cx);
// Add indent
self.replace_text_in_range(
Some(self.range_to_utf16(&(self.cursor_offset()..self.cursor_offset()))),
&indent,
window,
cx,
);
}
cx.emit(InputEvent::PressEnter {
@ -1106,6 +1187,125 @@ impl InputState {
});
}
pub(super) fn indent(&mut self, _: &Indent, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab_size) = self.mode.tab_size() else {
return;
};
let tab_indent = tab_size.to_string();
let selected_range = self.selected_range.clone();
let mut added_len = 0;
if !self.selected_range.is_empty() {
let mut offset = self.start_of_line_of_selection(window, cx);
let selected_text = self
.text_for_range(
self.range_to_utf16(&(offset..selected_range.end)),
&mut None,
window,
cx,
)
.unwrap_or("".into());
let mut lines_count = 0;
for line in selected_text.lines() {
lines_count += 1;
self.replace_text_in_range(
Some(self.range_to_utf16(&(offset..offset))),
&tab_indent,
window,
cx,
);
added_len += tab_indent.len();
// +1 for "\n"
offset += line.len() + tab_indent.len() + 1;
}
if lines_count > 1 {
self.selected_range =
selected_range.start + tab_indent.len()..selected_range.end + added_len;
} else {
self.selected_range =
selected_range.start + added_len..selected_range.end + added_len;
}
} else {
// Selected none
let offset = self.selected_range.start;
self.replace_text_in_range(
Some(self.range_to_utf16(&(offset..offset))),
&tab_indent,
window,
cx,
);
added_len = tab_indent.len();
self.selected_range = selected_range.start + added_len..selected_range.end + added_len;
}
}
pub(super) fn outdent(&mut self, _: &Outdent, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab_size) = self.mode.tab_size() else {
return;
};
let tab_indent = tab_size.to_string();
let selected_range = self.selected_range.clone();
let mut removed_len = 0;
if !self.selected_range.is_empty() {
let mut offset = self.start_of_line_of_selection(window, cx);
let selected_text = self
.text_for_range(
self.range_to_utf16(&(offset..selected_range.end)),
&mut None,
window,
cx,
)
.unwrap_or("".into());
let mut lines_count = 0;
for line in selected_text.lines() {
lines_count += 1;
if line.starts_with(tab_indent.as_ref()) {
self.replace_text_in_range(
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
"",
window,
cx,
);
removed_len += tab_indent.len();
}
// +1 for "\n"
offset += line.len().saturating_sub(tab_indent.len()) + 1;
}
if lines_count > 1 {
self.selected_range = selected_range.start.saturating_sub(tab_indent.len())
..selected_range.end.saturating_sub(removed_len);
} else {
self.selected_range = selected_range.start.saturating_sub(tab_indent.len())
..selected_range.end.saturating_sub(tab_indent.len());
}
} else {
// Selected none
let offset = self.start_of_line_of_selection(window, cx);
if self.text[offset..].starts_with(tab_indent.as_ref()) {
self.replace_text_in_range(
Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))),
"",
window,
cx,
);
removed_len = tab_indent.len();
self.selected_range = selected_range.start.saturating_sub(removed_len)
..selected_range.end.saturating_sub(removed_len);
}
}
}
pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.replace_text("", window, cx);
}
@ -1312,7 +1512,7 @@ impl InputState {
//
// - included the input padding.
// - included the scroll offset.
let inner_position = position - bounds.origin;
let inner_position = position - bounds.origin - point(self.line_number_width, px(0.));
let mut index = 0;
let mut y_offset = px(0.);
@ -1418,40 +1618,66 @@ impl InputState {
/// Select the word at the given offset.
///
/// The offset is the UTF-8 offset.
///
/// FIXME: When click on a non-word character, the word is not selected.
fn select_word(&mut self, offset: usize, window: &mut Window, cx: &mut Context<Self>) {
#[inline(always)]
fn is_word(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_')
}
let mut start = self.offset_to_utf16(offset);
let mut start = offset;
let mut end = start;
let prev_text = self
.text_for_range(0..start, &mut None, window, cx)
.text_for_range(self.range_to_utf16(&(0..start + 1)), &mut None, window, cx)
.unwrap_or_default();
let next_text = self
.text_for_range(end..self.text.len(), &mut None, window, cx)
.text_for_range(
self.range_to_utf16(&(end..self.text.len())),
&mut None,
window,
cx,
)
.unwrap_or_default();
let prev_chars = prev_text.chars().rev().peekable();
let next_chars = next_text.chars().peekable();
let prev_chars = prev_text.chars().rev();
let next_chars = next_text.chars();
let mut last_char_len = 0;
for (_, c) in prev_chars.enumerate() {
if !is_word(c) {
break;
}
start -= c.len_utf16();
last_char_len = c.len_utf8();
start = start.saturating_sub(last_char_len);
}
start += last_char_len;
for (_, c) in next_chars.enumerate() {
if !is_word(c) {
break;
}
end += c.len_utf16();
end += c.len_utf8();
}
self.selected_range = self.range_from_utf16(&(start..end));
// Ensure at least one character is selected
if start == end {
end = end + 1;
// Avoid select empty range
match self.text.get(start..end) {
None => return,
Some(part) => {
if part.trim().len() == 0 {
return;
}
}
}
}
self.selected_range = start..end;
self.selected_word_range = Some(self.selected_range.clone());
cx.notify()
}
@ -1711,7 +1937,7 @@ impl EntityInputHandler for InputState {
self.push_history(&range, &new_text, window, cx);
self.text = mask_text;
self.text_wrapper.update(self.text.clone(), cx);
self.text_wrapper.update(self.text.clone(), false, cx);
self.selected_range = new_pos..new_pos;
self.marked_range.take();
self.update_preferred_x_offset(cx);
@ -1832,6 +2058,8 @@ impl Focusable for InputState {
impl Render for InputState {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.text_wrapper.update(self.text.clone(), false, cx);
div()
.id("text-element")
.flex_1()

View file

@ -134,9 +134,12 @@ impl TextInput {
impl RenderOnce for TextInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25);
let font = window.text_style().font();
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
self.state.update(cx, |state, _| {
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;
});
@ -187,6 +190,8 @@ impl RenderOnce for TextInput {
.on_action(window.listener_for(&self.state, InputState::down))
.on_action(window.listener_for(&self.state, InputState::select_up))
.on_action(window.listener_for(&self.state, InputState::select_down))
.on_action(window.listener_for(&self.state, InputState::indent))
.on_action(window.listener_for(&self.state, InputState::outdent))
})
.on_action(window.listener_for(&self.state, InputState::select_all))
.on_action(window.listener_for(&self.state, InputState::select_to_start_of_line))
@ -222,6 +227,7 @@ impl RenderOnce for TextInput {
.input_py(self.size)
.input_h(self.size)
.cursor_text()
.text_size(font_size)
.when(state.is_multi_line(), |this| {
this.h_auto()
.when_some(self.height, |this, height| this.h(height))
@ -239,7 +245,6 @@ impl RenderOnce for TextInput {
.items_center()
.gap(gap_x)
.children(prefix)
// TODO: Define height here, and use it in the input element
.child(self.state.clone())
.child(
h_flex()

View file

@ -2,6 +2,14 @@ use std::ops::Range;
use gpui::{App, Font, LineFragment, Pixels, SharedString};
#[allow(unused)]
pub(super) struct LineWrap {
/// The number of soft wrapped lines of this line (Not include first line.)
pub(super) wrap_lines: usize,
/// The range of the line text in the entire text.
pub(super) range: Range<usize>,
}
/// Used to prepare the text with soft_wrap to be get lines to displayed in the TextArea
///
/// After use lines to calculate the scroll size of the TextArea
@ -9,6 +17,8 @@ pub(super) struct TextWrapper {
pub(super) text: SharedString,
/// The wrapped lines, value is start and end index of the line (by split \n).
pub(super) wrapped_lines: Vec<Range<usize>>,
/// The lines by split \n
pub(super) lines: Vec<LineWrap>,
pub(super) font: Font,
pub(super) font_size: Pixels,
/// If is none, it means the text is not wrapped
@ -24,49 +34,72 @@ impl TextWrapper {
font_size,
wrap_width,
wrapped_lines: Vec::new(),
lines: Vec::new(),
}
}
pub(super) fn set_wrap_width(&mut self, wrap_width: Option<Pixels>, cx: &mut App) {
if self.wrap_width == wrap_width {
self.wrap_width = wrap_width;
self.update(self.text.clone(), true, cx);
}
pub(super) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) {
self.font = font;
self.font_size = font_size;
self.update(self.text.clone(), true, cx);
}
/// Update the text wrapper and recalculate the wrapped lines.
///
/// If the `text` is the same as the current text, do nothing.
pub(super) fn update(&mut self, text: SharedString, force: bool, cx: &mut App) {
if self.text == text && !force {
return;
}
self.wrap_width = wrap_width;
self.update(self.text.clone(), cx);
}
pub(super) fn set_font(&mut self, font: Font, cx: &mut App) {
self.font = font;
self.update(self.text.clone(), cx);
}
pub(super) fn update(&mut self, text: SharedString, cx: &mut App) {
let mut wrapped_lines = vec![];
let mut lines = vec![];
let wrap_width = self.wrap_width.unwrap_or(Pixels::MAX);
let mut line_wrapper = cx
.text_system()
.line_wrapper(self.font.clone(), self.font_size);
let mut prev_line_ix = 0;
for line in text.lines() {
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) {
wrapped_lines.push(prev_boundary_ix..boundary.ix);
line_wraps.push(prev_boundary_ix..boundary.ix);
prev_boundary_ix = boundary.ix;
}
lines.push(LineWrap {
wrap_lines: line_wraps.len(),
range: prev_line_ix..line.len(),
});
wrapped_lines.extend(line_wraps);
// Reset of the line
if !line[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 {
wrapped_lines.push(prev_boundary_ix..line.len());
wrapped_lines.push(prev_line_ix + prev_boundary_ix..prev_line_ix + line.len());
}
prev_line_ix += line.len();
}
// Add last empty line.
if text.chars().last().unwrap_or('\n') == '\n' {
wrapped_lines.push(text.len()..text.len());
lines.push(LineWrap {
wrap_lines: 0,
range: text.len()..text.len(),
});
}
self.text = text;
self.wrapped_lines = wrapped_lines;
self.lines = lines;
}
}