From 00b40db82cec6655bc7b06ce6fdc4d1469417f1b Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:25:55 +0700 Subject: [PATCH] chore: improve text input (#94) * update history * hide cursor & selection when window is deactivated - gpui-component * . * update input to catch up with gpui-component * adjust history --- Cargo.lock | 26 +- crates/coop/src/views/compose.rs | 2 - crates/ui/src/history.rs | 114 ++--- crates/ui/src/input/element.rs | 282 ++++++++--- crates/ui/src/input/mask_pattern.rs | 758 ++++++++++++++-------------- crates/ui/src/input/state.rs | 322 ++++++++---- crates/ui/src/input/text_input.rs | 12 +- crates/ui/src/input/text_wrapper.rs | 171 ++++--- crates/ui/src/styled.rs | 629 +++++++++++------------ 9 files changed, 1280 insertions(+), 1036 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc61681..18dfe2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,7 +1075,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1476,7 +1476,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "proc-macro2", "quote", @@ -2328,7 +2328,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2421,7 +2421,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2433,7 +2433,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "gpui", "tokio", @@ -2655,7 +2655,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "anyhow", "bytes", @@ -2672,7 +2672,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3449,7 +3449,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -4802,7 +4802,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "derive_refineable", "workspace-hack", @@ -4953,7 +4953,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "anyhow", "bytes", @@ -5479,7 +5479,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "anyhow", "serde", @@ -5864,7 +5864,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "arrayvec", "log", @@ -6838,7 +6838,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b9ff538747556f3f2f5ed4ee109654eb80cc9c9c" +source = "git+https://github.com/zed-industries/zed#ebad5ca50e11fc9d3fbbab397888d0944a825bb7" dependencies = [ "anyhow", "async-fs", diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index b58cc3d..2478120 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -103,8 +103,6 @@ impl Compose { .map(|profile| Contact::new(profile.public_key())) .collect_vec(); - log::info!("get contacts"); - Ok(contacts) }); diff --git a/crates/ui/src/history.rs b/crates/ui/src/history.rs index 48c0211..cb39a2e 100644 --- a/crates/ui/src/history.rs +++ b/crates/ui/src/history.rs @@ -1,7 +1,7 @@ use std::fmt::Debug; use std::time::{Duration, Instant}; -pub trait HistoryItem: Clone { +pub trait HistoryItem: Clone + PartialEq { fn version(&self) -> usize; fn set_version(&mut self, version: usize); } @@ -11,6 +11,11 @@ pub trait HistoryItem: Clone { /// This is now used in Input for undo/redo operations. You can also use this in /// your own models to keep track of changes, for example to track the tab /// history for prev/next features. +/// +/// ## Use cases +/// +/// - Undo/redo operations in Input +/// - Tracking tab history for prev/next features #[derive(Debug)] pub struct History { undos: Vec, @@ -20,6 +25,7 @@ pub struct History { pub(crate) ignore: bool, max_undo: usize, group_interval: Option, + unique: bool, } impl History @@ -35,6 +41,7 @@ where version: 0, max_undo: 1000, group_interval: None, + unique: false, } } @@ -44,6 +51,13 @@ where self } + /// Set the history to be unique, defaults to false. + /// If set to true, the history will only keep unique changes. + pub fn unique(mut self) -> Self { + self.unique = true; + self + } + /// Set the interval in milliseconds to group changes, defaults to None. pub fn group_interval(mut self, group_interval: Duration) -> Self { self.group_interval = Some(group_interval); @@ -73,11 +87,32 @@ where self.undos.remove(0); } + if self.unique { + self.undos.retain(|c| *c != item); + self.redos.retain(|c| *c != item); + } + let mut item = item; item.set_version(version); self.undos.push(item); } + /// Get the undo stack. + pub fn undos(&self) -> &Vec { + &self.undos + } + + /// Get the redo stack. + pub fn redos(&self) -> &Vec { + &self.redos + } + + /// Clear the undo and redo stacks. + pub fn clear(&mut self) { + self.undos.clear(); + self.redos.clear(); + } + pub fn undo(&mut self) -> Option> { if let Some(first_change) = self.undos.pop() { let mut changes = vec![first_change.clone()]; @@ -93,7 +128,7 @@ where changes.push(change); } - self.redos.extend(changes.iter().rev().cloned()); + self.redos.extend(changes.clone()); Some(changes) } else { None @@ -114,7 +149,7 @@ where let change = self.redos.pop().unwrap(); changes.push(change); } - self.undos.extend(changes.iter().rev().cloned()); + self.undos.extend(changes.clone()); Some(changes) } else { None @@ -130,76 +165,3 @@ where Self::new() } } - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Clone)] - struct TabIndex { - tab_index: usize, - version: usize, - } - - impl From for TabIndex { - fn from(value: usize) -> Self { - TabIndex { - tab_index: value, - version: 0, - } - } - } - - impl HistoryItem for TabIndex { - fn version(&self) -> usize { - self.version - } - - fn set_version(&mut self, version: usize) { - self.version = version; - } - } - - #[test] - fn test_history() { - let mut history: History = History::new().max_undo(100); - history.push(0.into()); - history.push(3.into()); - history.push(2.into()); - history.push(1.into()); - - assert_eq!(history.version(), 4); - let changes = history.undo().unwrap(); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].tab_index, 1); - - let changes = history.undo().unwrap(); - assert_eq!(changes.len(), 1); - assert_eq!(changes[0].tab_index, 2); - - history.push(5.into()); - - let changes = history.redo().unwrap(); - assert_eq!(changes[0].tab_index, 2); - - let changes = history.redo().unwrap(); - assert_eq!(changes[0].tab_index, 1); - - let changes = history.undo().unwrap(); - assert_eq!(changes[0].tab_index, 1); - - let changes = history.undo().unwrap(); - assert_eq!(changes[0].tab_index, 2); - - let changes = history.undo().unwrap(); - assert_eq!(changes[0].tab_index, 5); - - let changes = history.undo().unwrap(); - assert_eq!(changes[0].tab_index, 3); - - let changes = history.undo().unwrap(); - assert_eq!(changes[0].tab_index, 0); - - assert!(history.undo().is_none()); - } -} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index ddc55fa..4e94796 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -1,12 +1,14 @@ +use std::{ops::Range, rc::Rc}; + 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, + Entity, GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, + Point, SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, WrappedLine, }; use smallvec::SmallVec; use theme::ActiveTheme; -use super::InputState; +use super::{InputState, LastLayout}; use crate::Root; const CURSOR_THICKNESS: Pixels = px(2.); @@ -46,19 +48,30 @@ impl TextElement { }); } + /// Returns the: + /// + /// - cursor bounds + /// - scroll offset + /// - current line index fn layout_cursor( &self, lines: &[WrappedLine], line_height: Pixels, bounds: &mut Bounds, + line_number_width: Pixels, window: &mut Window, cx: &mut App, - ) -> (Option, Point) { + ) -> (Option>, Point, Option) { let input = self.input.read(cx); - let selected_range = &input.selected_range; + let mut selected_range = input.selected_range.clone(); + if let Some(marked_range) = &input.marked_range { + selected_range = marked_range.end..marked_range.end; + } + let cursor_offset = input.cursor_offset(); + let mut current_line_index = None; let mut scroll_offset = input.scroll_handle.offset(); - let mut cursor = None; + let mut cursor_bounds = None; // If the input has a fixed height (Otherwise is auto-grow), we need to add a bottom margin to the input. let bottom_margin = if input.is_auto_grow() { @@ -73,7 +86,7 @@ impl TextElement { let mut prev_lines_offset = 0; let mut offset_y = px(0.); - for line in lines.iter() { + for (line_ix, line) in lines.iter().enumerate() { // break loop if all cursor positions are found if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() { break; @@ -83,6 +96,7 @@ impl TextElement { if cursor_pos.is_none() { let offset = cursor_offset.saturating_sub(prev_lines_offset); if let Some(pos) = line.position_for_index(offset, line_height) { + current_line_index = Some(line_ix); cursor_pos = Some(line_origin + pos); } } @@ -154,26 +168,22 @@ impl TextElement { } } - bounds.origin += scroll_offset; - if input.show_cursor(window, cx) { // cursor blink - let cursor_height = - window.text_style().font_size.to_pixels(window.rem_size()) + px(2.); - cursor = Some(fill( - Bounds::new( - point( - bounds.left() + cursor_pos.x, - bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), - ), - size(CURSOR_THICKNESS, cursor_height), + let cursor_height = line_height; + cursor_bounds = Some(Bounds::new( + point( + bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x, + bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), ), - cx.theme().cursor, - )) + size(CURSOR_THICKNESS, cursor_height), + )); }; } - (cursor, scroll_offset) + bounds.origin += scroll_offset; + + (cursor_bounds, scroll_offset, current_line_index) } fn layout_selections( @@ -181,11 +191,17 @@ impl TextElement { lines: &[WrappedLine], line_height: Pixels, bounds: &mut Bounds, + line_number_width: Pixels, _: &mut Window, cx: &mut App, ) -> Option> { let input = self.input.read(cx); - let selected_range = &input.selected_range; + let mut selected_range = input.selected_range.clone(); + if let Some(marked_range) = &input.marked_range { + if !marked_range.is_empty() { + selected_range = marked_range.end..marked_range.end; + } + } if selected_range.is_empty() { return None; } @@ -295,20 +311,62 @@ 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.first().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() } + + /// Calculate the visible range of lines in the viewport. + /// + /// The visible range is based on unwrapped lines (Zero based). + fn calculate_visible_range( + &self, + state: &InputState, + line_height: Pixels, + input_height: Pixels, + ) -> Range { + if state.is_single_line() { + return 0..1; + } + + let scroll_top = -state.scroll_handle.offset().y; + let total_lines = state.text_wrapper.lines.len(); + + let mut visible_range = 0..total_lines; + let mut line_top = px(0.); + + for (ix, line) in state.text_wrapper.lines.iter().enumerate() { + line_top += line.height(line_height); + + if line_top < scroll_top { + visible_range.start = ix; + } + + if line_top > scroll_top + input_height { + visible_range.end = (ix + 1).min(total_lines); + break; + } + } + + visible_range + } } pub(super) struct PrepaintState { - lines: SmallVec<[WrappedLine; 1]>, - cursor: Option, + /// The lines of entire lines. + last_layout: LastLayout, + /// The lines only contains the visible lines in the viewport, based on `visible_range`. + line_numbers: Option>>, + line_number_width: Pixels, + /// Size of the scrollable area by entire lines. + scroll_size: Size, + cursor_bounds: Option>, cursor_scroll_offset: Point, selection_path: Option>, bounds: Bounds, @@ -348,8 +406,8 @@ fn print_points_as_svg_path(line_corners: &Vec>>, points: } impl Element for TextElement { - type PrepaintState = PrepaintState; type RequestLayoutState = (); + type PrepaintState = PrepaintState; fn id(&self) -> Option { None @@ -397,22 +455,27 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { - let multi_line = self.input.read(cx).is_multi_line(); let line_height = window.line_height(); let input = self.input.read(cx); + let multi_line = input.is_multi_line(); + let visible_range = self.calculate_visible_range(input, line_height, bounds.size.height); let text = input.text.clone(); + let is_empty = text.is_empty(); 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() { + let (display_text, text_color) = if is_empty { (placeholder, cx.theme().text_muted) } else if input.masked { ("*".repeat(text.chars().count()).into(), cx.theme().text) } else { - (text, cx.theme().text) + (text.clone(), cx.theme().text) }; + let line_number_width = px(0.); + let run = TextRun { len: display_text.len(), font: style.font(), @@ -422,7 +485,23 @@ impl Element for TextElement { strikethrough: None, }; - let runs = if let Some(marked_range) = input.marked_range.as_ref() { + let marked_run = TextRun { + len: 0, + font: style.font(), + color: text_color, + background_color: None, + underline: Some(UnderlineStyle { + thickness: px(1.), + color: Some(text_color), + wavy: false, + }), + strikethrough: None, + }; + + let runs = if !is_empty { + vec![run] + } else if let Some(marked_range) = &input.marked_range { + // IME marked text vec![ TextRun { len: marked_range.start, @@ -430,11 +509,7 @@ impl Element for TextElement { }, TextRun { len: marked_range.end - marked_range.start, - underline: Some(UnderlineStyle { - color: Some(run.color), - thickness: px(1.0), - wavy: false, - }), + underline: marked_run.underline, ..run.clone() }, TextRun { @@ -449,9 +524,8 @@ impl Element for TextElement { 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 }; @@ -459,7 +533,25 @@ impl Element for TextElement { let lines = window .text_system() .shape_text(display_text, font_size, &runs, wrap_width, None) - .unwrap(); + .expect("failed to shape text"); + + 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 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, + (total_wrapped_lines as f32 * line_height).max(bounds.size.height), + ); // `position_for_index` for example // @@ -492,15 +584,35 @@ 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_bounds, 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, - cursor, + last_layout: LastLayout { + lines: Rc::new(lines), + line_height, + visible_range, + }, + scroll_size, + line_numbers: None, + line_number_width, + cursor_bounds, cursor_scroll_offset, selection_path, } @@ -520,6 +632,7 @@ impl Element for TextElement { let focused = focus_handle.is_focused(window); let bounds = prepaint.bounds; let selected_range = self.input.read(cx).selected_range.clone(); + let visible_range = &prepaint.last_layout.visible_range; window.handle_input( &focus_handle, @@ -551,59 +664,76 @@ impl Element for TextElement { } }); + // Paint multi line text + let line_height = window.line_height(); + let origin = bounds.origin; + + let mut invisible_top_padding = px(0.); + for line in prepaint.last_layout.lines.iter().take(visible_range.start) { + invisible_top_padding += line.size(line_height).height; + } + + let mut mask_offset_y = px(0.); + if self.input.read(cx).masked { + // Move down offset for vertical centering the ***** + if cfg!(target_os = "macos") { + mask_offset_y = px(3.); + } else { + mask_offset_y = px(2.5); + } + } + + let mut offset_y = px(0.); + if let Some(line_numbers) = prepaint.line_numbers.as_ref() { + offset_y += invisible_top_padding; + + // Each item is the normal lines. + for lines in line_numbers.iter() { + for line in lines { + let p = point(origin.x, origin.y + offset_y); + let line_size = line.size(line_height); + _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); + offset_y += line_size.height; + } + } + } + // Paint selections if let Some(path) = prepaint.selection_path.take() { window.paint_path(path, cx.theme().element_disabled); } - // Paint multi line text - let line_height = window.line_height(); - let origin = bounds.origin; - - let mut offset_y = px(0.); - if self.input.read(cx).masked { - // Move down offset for vertical centering the ***** - if cfg!(target_os = "macos") { - offset_y = px(3.); - } else { - offset_y = px(2.5); - } - } - for line in prepaint.lines.iter() { - let p = point(origin.x, origin.y + offset_y); + // Paint text + let mut offset_y = mask_offset_y + invisible_top_padding; + for line in prepaint + .last_layout + .iter() + .skip(visible_range.start) + .take(visible_range.len()) + { + 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; } if focused { - if let Some(cursor) = prepaint.cursor.take() { - window.paint_quad(cursor); + if let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() { + cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y; + window.paint_quad(fill(cursor_bounds, cx.theme().cursor)); } } - let width = prepaint - .lines - .iter() - .map(|l| l.width()) - .max() - .unwrap_or_default(); - let height = offset_y; - let scroll_size = size(width, height); - self.input.update(cx, |input, cx| { - input.last_layout = Some(prepaint.lines.clone()); + input.last_layout = Some(prepaint.last_layout.clone()); input.last_bounds = Some(bounds); input.last_cursor_offset = Some(input.cursor_offset()); - input.last_line_height = line_height; - input.set_input_bounds(input_bounds, cx); - input.last_selected_range = Some(selected_range); - input.scroll_size = scroll_size; + input.scroll_size = prepaint.scroll_size; + input.line_number_width = prepaint.line_number_width; input .scroll_handle .set_offset(prepaint.cursor_scroll_offset); - cx.notify(); }); diff --git a/crates/ui/src/input/mask_pattern.rs b/crates/ui/src/input/mask_pattern.rs index f126c78..bd532b5 100644 --- a/crates/ui/src/input/mask_pattern.rs +++ b/crates/ui/src/input/mask_pattern.rs @@ -1,380 +1,378 @@ -use gpui::SharedString; - -#[derive(Clone, PartialEq, Debug)] -pub enum MaskToken { - /// 0 Digit, equivalent to `[0]` - // Digit0, - /// Digit, equivalent to `[0-9]` - Digit, - /// Letter, equivalent to `[a-zA-Z]` - Letter, - /// Letter or digit, equivalent to `[a-zA-Z0-9]` - LetterOrDigit, - /// Separator - Sep(char), - /// Any character - Any, -} - -#[allow(unused)] -impl MaskToken { - /// Check if the token is any character. - pub fn is_any(&self) -> bool { - matches!(self, MaskToken::Any) - } - - /// Check if the token is a match for the given character. - /// - /// The separator is always a match any input character. - fn is_match(&self, ch: char) -> bool { - match self { - MaskToken::Digit => ch.is_ascii_digit(), - MaskToken::Letter => ch.is_ascii_alphabetic(), - MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(), - MaskToken::Any => true, - MaskToken::Sep(c) => *c == ch, - } - } - - /// Is the token a separator (Can be ignored) - fn is_sep(&self) -> bool { - matches!(self, MaskToken::Sep(_)) - } - - /// Check if the token is a number. - pub fn is_number(&self) -> bool { - matches!(self, MaskToken::Digit) - } - - pub fn placeholder(&self) -> char { - match self { - MaskToken::Sep(c) => *c, - _ => '_', - } - } - - fn mask_char(&self, ch: char) -> char { - match self { - MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch, - MaskToken::Sep(c) => *c, - MaskToken::Any => ch, - } - } - - fn unmask_char(&self, ch: char) -> Option { - match self { - MaskToken::Digit => Some(ch), - MaskToken::Letter => Some(ch), - MaskToken::LetterOrDigit => Some(ch), - MaskToken::Any => Some(ch), - _ => None, - } - } -} - -#[derive(Clone, Default)] -pub enum MaskPattern { - #[default] - None, - Pattern { - pattern: SharedString, - tokens: Vec, - }, - Number { - /// Group separator, e.g. "," or " " - separator: Option, - /// Number of fraction digits, e.g. 2 for 123.45 - fraction: Option, - }, -} - -impl From<&str> for MaskPattern { - fn from(pattern: &str) -> Self { - Self::new(pattern) - } -} - -impl MaskPattern { - /// Create a new mask pattern - /// - /// - `9` - Digit - /// - `A` - Letter - /// - `#` - Letter or Digit - /// - `*` - Any character - /// - other characters - Separator - /// - /// For example: - /// - /// - `(999)999-9999` - US phone number: (123)456-7890 - /// - `99999-9999` - ZIP code: 12345-6789 - /// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4 - /// - `*999*` - Custom pattern: (123) or [123] - pub fn new(pattern: &str) -> Self { - let tokens = pattern - .chars() - .map(|ch| match ch { - // '0' => MaskToken::Digit0, - '9' => MaskToken::Digit, - 'A' => MaskToken::Letter, - '#' => MaskToken::LetterOrDigit, - '*' => MaskToken::Any, - _ => MaskToken::Sep(ch), - }) - .collect(); - - Self::Pattern { - pattern: pattern.to_owned().into(), - tokens, - } - } - - #[allow(unused)] - fn tokens(&self) -> Option<&Vec> { - match self { - Self::Pattern { tokens, .. } => Some(tokens), - Self::Number { .. } => None, - Self::None => None, - } - } - - /// Create a new mask pattern with group separator, e.g. "," or " " - pub fn number(sep: Option) -> Self { - Self::Number { - separator: sep, - fraction: None, - } - } - - pub fn placeholder(&self) -> Option { - match self { - Self::Pattern { tokens, .. } => { - Some(tokens.iter().map(|token| token.placeholder()).collect()) - } - Self::Number { .. } => None, - Self::None => None, - } - } - - /// Return true if the mask pattern is None or no any pattern. - pub fn is_none(&self) -> bool { - match self { - Self::Pattern { tokens, .. } => tokens.is_empty(), - Self::Number { .. } => false, - Self::None => true, - } - } - - /// Check is the mask text is valid. - /// - /// If the mask pattern is None, always return true. - pub fn is_valid(&self, mask_text: &str) -> bool { - if self.is_none() { - return true; - } - - let mut text_index = 0; - let mask_text_chars: Vec = mask_text.chars().collect(); - match self { - Self::Pattern { tokens, .. } => { - for token in tokens { - if text_index >= mask_text_chars.len() { - break; - } - - let ch = mask_text_chars[text_index]; - if token.is_match(ch) { - text_index += 1; - } - } - text_index == mask_text.len() - } - Self::Number { separator, .. } => { - if mask_text.is_empty() { - return true; - } - - // check if the text is valid number - let mut parts = mask_text.split('.'); - let int_part = parts.next().unwrap_or(""); - let frac_part = parts.next(); - - if int_part.is_empty() { - return false; - } - - // check if the integer part is valid - if !int_part - .chars() - .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) - { - return false; - } - - // check if the fraction part is valid - if let Some(frac) = frac_part { - if !frac - .chars() - .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) - { - return false; - } - } - - true - } - Self::None => true, - } - } - - /// Check if valid input char at the given position. - pub fn is_valid_at(&self, ch: char, pos: usize) -> bool { - if self.is_none() { - return true; - } - - match self { - Self::Pattern { tokens, .. } => { - if let Some(token) = tokens.get(pos) { - if token.is_match(ch) { - return true; - } - - if token.is_sep() { - // If next token is match, it's valid - if let Some(next_token) = tokens.get(pos + 1) { - if next_token.is_match(ch) { - return true; - } - } - } - } - - false - } - Self::Number { .. } => true, - Self::None => true, - } - } - - /// Format the text according to the mask pattern - /// - /// For example: - /// - /// - pattern: (999)999-999 - /// - text: 123456789 - /// - mask_text: (123)456-789 - pub fn mask(&self, text: &str) -> SharedString { - if self.is_none() { - return text.to_owned().into(); - } - - match self { - Self::Number { - separator, - fraction, - } => { - if let Some(sep) = *separator { - // Remove the existing group separator - let text = text.replace(sep, ""); - - let mut parts = text.split('.'); - let int_part = parts.next().unwrap_or(""); - - // Limit the fraction part to the given range, if not enough, pad with 0 - let frac_part = parts.next().map(|part| { - part.chars() - .take(fraction.unwrap_or(usize::MAX)) - .collect::() - }); - - // Reverse the integer part for easier grouping - let chars: Vec = int_part.chars().rev().collect(); - let mut result = String::new(); - for (i, ch) in chars.iter().enumerate() { - if i > 0 && i % 3 == 0 { - result.push(sep); - } - result.push(*ch); - } - let int_with_sep: String = result.chars().rev().collect(); - - let final_str = if let Some(frac) = frac_part { - if fraction == &Some(0) { - int_with_sep - } else { - format!("{int_with_sep}.{frac}") - } - } else { - int_with_sep - }; - return final_str.into(); - } - - text.to_owned().into() - } - Self::Pattern { tokens, .. } => { - let mut result = String::new(); - let mut text_index = 0; - let text_chars: Vec = text.chars().collect(); - for (pos, token) in tokens.iter().enumerate() { - if text_index >= text_chars.len() { - break; - } - let ch = text_chars[text_index]; - // Break if expected char is not match - if !token.is_sep() && !self.is_valid_at(ch, pos) { - break; - } - let mask_ch = token.mask_char(ch); - result.push(mask_ch); - if ch == mask_ch { - text_index += 1; - continue; - } - } - result.into() - } - Self::None => text.to_owned().into(), - } - } - - /// Extract original text from masked text - pub fn unmask(&self, mask_text: &str) -> String { - match self { - Self::Number { separator, .. } => { - if let Some(sep) = *separator { - let mut result = String::new(); - for ch in mask_text.chars() { - if ch == sep { - continue; - } - result.push(ch); - } - - if result.contains('.') { - result = result.trim_end_matches('0').to_string(); - } - return result; - } - - mask_text.to_owned() - } - Self::Pattern { tokens, .. } => { - let mut result = String::new(); - let mask_text_chars: Vec = mask_text.chars().collect(); - for (text_index, token) in tokens.iter().enumerate() { - if text_index >= mask_text_chars.len() { - break; - } - let ch = mask_text_chars[text_index]; - let unmask_ch = token.unmask_char(ch); - if let Some(ch) = unmask_ch { - result.push(ch); - } - } - result - } - Self::None => mask_text.to_owned(), - } - } -} +use gpui::SharedString; + +#[derive(Clone, PartialEq, Debug)] +pub enum MaskToken { + /// Digit, equivalent to `[0-9]` + Digit, + /// Letter, equivalent to `[a-zA-Z]` + Letter, + /// Letter or digit, equivalent to `[a-zA-Z0-9]` + LetterOrDigit, + /// Separator + Sep(char), + /// Any character + Any, +} + +#[allow(unused)] +impl MaskToken { + /// Check if the token is any character. + pub fn is_any(&self) -> bool { + matches!(self, MaskToken::Any) + } + + /// Check if the token is a match for the given character. + /// + /// The separator is always a match any input character. + fn is_match(&self, ch: char) -> bool { + match self { + MaskToken::Digit => ch.is_ascii_digit(), + MaskToken::Letter => ch.is_ascii_alphabetic(), + MaskToken::LetterOrDigit => ch.is_ascii_alphanumeric(), + MaskToken::Any => true, + MaskToken::Sep(c) => *c == ch, + } + } + + /// Is the token a separator (Can be ignored) + fn is_sep(&self) -> bool { + matches!(self, MaskToken::Sep(_)) + } + + /// Check if the token is a number. + pub fn is_number(&self) -> bool { + matches!(self, MaskToken::Digit) + } + + pub fn placeholder(&self) -> char { + match self { + MaskToken::Sep(c) => *c, + _ => '_', + } + } + + fn mask_char(&self, ch: char) -> char { + match self { + MaskToken::Digit | MaskToken::LetterOrDigit | MaskToken::Letter => ch, + MaskToken::Sep(c) => *c, + MaskToken::Any => ch, + } + } + + fn unmask_char(&self, ch: char) -> Option { + match self { + MaskToken::Digit => Some(ch), + MaskToken::Letter => Some(ch), + MaskToken::LetterOrDigit => Some(ch), + MaskToken::Any => Some(ch), + _ => None, + } + } +} + +#[derive(Clone, Default)] +pub enum MaskPattern { + #[default] + None, + Pattern { + pattern: SharedString, + tokens: Vec, + }, + Number { + /// Group separator, e.g. "," or " " + separator: Option, + /// Number of fraction digits, e.g. 2 for 123.45 + fraction: Option, + }, +} + +impl From<&str> for MaskPattern { + fn from(pattern: &str) -> Self { + Self::new(pattern) + } +} + +impl MaskPattern { + /// Create a new mask pattern + /// + /// - `9` - Digit + /// - `A` - Letter + /// - `#` - Letter or Digit + /// - `*` - Any character + /// - other characters - Separator + /// + /// For example: + /// + /// - `(999)999-9999` - US phone number: (123)456-7890 + /// - `99999-9999` - ZIP code: 12345-6789 + /// - `AAAA-99-####` - Custom pattern: ABCD-12-3AB4 + /// - `*999*` - Custom pattern: (123) or [123] + pub fn new(pattern: &str) -> Self { + let tokens = pattern + .chars() + .map(|ch| match ch { + // '0' => MaskToken::Digit0, + '9' => MaskToken::Digit, + 'A' => MaskToken::Letter, + '#' => MaskToken::LetterOrDigit, + '*' => MaskToken::Any, + _ => MaskToken::Sep(ch), + }) + .collect(); + + Self::Pattern { + pattern: pattern.to_owned().into(), + tokens, + } + } + + #[allow(unused)] + fn tokens(&self) -> Option<&Vec> { + match self { + Self::Pattern { tokens, .. } => Some(tokens), + Self::Number { .. } => None, + Self::None => None, + } + } + + /// Create a new mask pattern with group separator, e.g. "," or " " + pub fn number(sep: Option) -> Self { + Self::Number { + separator: sep, + fraction: None, + } + } + + pub fn placeholder(&self) -> Option { + match self { + Self::Pattern { tokens, .. } => { + Some(tokens.iter().map(|token| token.placeholder()).collect()) + } + Self::Number { .. } => None, + Self::None => None, + } + } + + /// Return true if the mask pattern is None or no any pattern. + pub fn is_none(&self) -> bool { + match self { + Self::Pattern { tokens, .. } => tokens.is_empty(), + Self::Number { .. } => false, + Self::None => true, + } + } + + /// Check is the mask text is valid. + /// + /// If the mask pattern is None, always return true. + pub fn is_valid(&self, mask_text: &str) -> bool { + if self.is_none() { + return true; + } + + let mut text_index = 0; + let mask_text_chars: Vec = mask_text.chars().collect(); + match self { + Self::Pattern { tokens, .. } => { + for token in tokens { + if text_index >= mask_text_chars.len() { + break; + } + + let ch = mask_text_chars[text_index]; + if token.is_match(ch) { + text_index += 1; + } + } + text_index == mask_text.len() + } + Self::Number { separator, .. } => { + if mask_text.is_empty() { + return true; + } + + // check if the text is valid number + let mut parts = mask_text.split('.'); + let int_part = parts.next().unwrap_or(""); + let frac_part = parts.next(); + + if int_part.is_empty() { + return false; + } + + // check if the integer part is valid + if !int_part + .chars() + .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) + { + return false; + } + + // check if the fraction part is valid + if let Some(frac) = frac_part { + if !frac + .chars() + .all(|ch| ch.is_ascii_digit() || Some(ch) == *separator) + { + return false; + } + } + + true + } + Self::None => true, + } + } + + /// Check if valid input char at the given position. + pub fn is_valid_at(&self, ch: char, pos: usize) -> bool { + if self.is_none() { + return true; + } + + match self { + Self::Pattern { tokens, .. } => { + if let Some(token) = tokens.get(pos) { + if token.is_match(ch) { + return true; + } + + if token.is_sep() { + // If next token is match, it's valid + if let Some(next_token) = tokens.get(pos + 1) { + if next_token.is_match(ch) { + return true; + } + } + } + } + + false + } + Self::Number { .. } => true, + Self::None => true, + } + } + + /// Format the text according to the mask pattern + /// + /// For example: + /// + /// - pattern: (999)999-999 + /// - text: 123456789 + /// - mask_text: (123)456-789 + pub fn mask(&self, text: &str) -> SharedString { + if self.is_none() { + return text.to_owned().into(); + } + + match self { + Self::Number { + separator, + fraction, + } => { + if let Some(sep) = *separator { + // Remove the existing group separator + let text = text.replace(sep, ""); + + let mut parts = text.split('.'); + let int_part = parts.next().unwrap_or(""); + + // Limit the fraction part to the given range, if not enough, pad with 0 + let frac_part = parts.next().map(|part| { + part.chars() + .take(fraction.unwrap_or(usize::MAX)) + .collect::() + }); + + // Reverse the integer part for easier grouping + let chars: Vec = int_part.chars().rev().collect(); + let mut result = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(sep); + } + result.push(*ch); + } + let int_with_sep: String = result.chars().rev().collect(); + + let final_str = if let Some(frac) = frac_part { + if fraction == &Some(0) { + int_with_sep + } else { + format!("{int_with_sep}.{frac}") + } + } else { + int_with_sep + }; + return final_str.into(); + } + + text.to_owned().into() + } + Self::Pattern { tokens, .. } => { + let mut result = String::new(); + let mut text_index = 0; + let text_chars: Vec = text.chars().collect(); + for (pos, token) in tokens.iter().enumerate() { + if text_index >= text_chars.len() { + break; + } + let ch = text_chars[text_index]; + // Break if expected char is not match + if !token.is_sep() && !self.is_valid_at(ch, pos) { + break; + } + let mask_ch = token.mask_char(ch); + result.push(mask_ch); + if ch == mask_ch { + text_index += 1; + continue; + } + } + result.into() + } + Self::None => text.to_owned().into(), + } + } + + /// Extract original text from masked text + pub fn unmask(&self, mask_text: &str) -> String { + match self { + Self::Number { separator, .. } => { + if let Some(sep) = *separator { + let mut result = String::new(); + for ch in mask_text.chars() { + if ch == sep { + continue; + } + result.push(ch); + } + + if result.contains('.') { + result = result.trim_end_matches('0').to_string(); + } + return result; + } + + mask_text.to_owned() + } + Self::Pattern { tokens, .. } => { + let mut result = String::new(); + let mask_text_chars: Vec = mask_text.chars().collect(); + for (text_index, token) in tokens.iter().enumerate() { + if text_index >= mask_text_chars.len() { + break; + } + let ch = mask_text_chars[text_index]; + let unmask_ch = token.unmask_char(ch); + if let Some(ch) = unmask_ch { + result.push(ch); + } + } + result + } + Self::None => mask_text.to_owned(), + } + } +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index ca1a504..7849d11 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -1,6 +1,7 @@ use std::cell::Cell; -use std::ops::Range; +use std::ops::{Deref, Range}; use std::rc::Rc; +use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ @@ -308,6 +309,24 @@ impl InputMode { } } +#[derive(Clone)] +pub(super) struct LastLayout { + /// The last layout lines. + pub(super) lines: Rc>, + /// The line_height of text layout, this will change will InputElement painted. + pub(super) line_height: Pixels, + /// The visible range (no wrap) of lines in the viewport. + pub(super) visible_range: Range, +} + +impl Deref for LastLayout { + type Target = Rc>; + + fn deref(&self) -> &Self::Target { + &self.lines + } +} + /// InputState to keep editing state of the [`super::TextInput`]. pub struct InputState { pub(super) focus_handle: FocusHandle, @@ -322,11 +341,10 @@ pub struct InputState { /// Range for save the selected word, use to keep word range when drag move. pub(super) selected_word_range: Option>, pub(super) selection_reversed: bool, + /// The marked range is the temporary insert text on IME typing. pub(super) marked_range: Option>, - pub(super) last_layout: Option>, + pub(super) last_layout: Option, pub(super) last_cursor_offset: Option, - /// The line_height of text layout, this will change will InputElement painted. - pub(super) last_line_height: Pixels, /// The input container bounds pub(super) input_bounds: Bounds, /// The text bounds @@ -343,12 +361,13 @@ pub struct InputState { pub(super) scrollbar_state: Rc>, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, + pub(crate) line_number_width: Pixels, /// The mask pattern for formatting the input text pub(crate) mask_pattern: MaskPattern, pub(super) placeholder: SharedString, - /// To remember the horizontal column (x-coordinate) of the cursor position. + /// To remember the horizontal column (x-coordinate) of the cursor position for keep column for move up/down. preferred_x_offset: Option, _subscriptions: Vec, } @@ -362,7 +381,9 @@ impl InputState { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); let blink_cursor = cx.new(|_| BlinkCursor::new()); - let history = History::new().group_interval(std::time::Duration::from_secs(1)); + let history = History::new() + .max_undo(2000) + .group_interval(Duration::from_millis(600)); let _subscriptions = vec![ // Observe the blink cursor to repaint the view when it changes. @@ -411,11 +432,11 @@ impl InputState { last_layout: None, last_bounds: None, last_selected_range: None, - last_line_height: px(19.), last_cursor_offset: None, scroll_handle: ScrollHandle::new(), scrollbar_state: Rc::new(Cell::new(ScrollbarState::default())), scroll_size: gpui::size(px(0.), px(0.)), + line_number_width: px(0.), preferred_x_offset: None, placeholder: SharedString::default(), mask_pattern: MaskPattern::default(), @@ -469,32 +490,37 @@ impl InputState { /// Called after moving the cursor. Updates preferred_x_offset if we know where the cursor now is. fn update_preferred_x_offset(&mut self, _cx: &mut Context) { - if let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) { - let offset = self.cursor_offset(); - let line_height = self.last_line_height; + let (Some(_), Some(bounds)) = (&self.last_layout, &self.last_bounds) else { + return; + }; - // Find which line and sub-line the cursor is on and its position - let (_line_index, _sub_line_index, cursor_pos) = - self.line_and_position_for_offset(offset, lines, line_height); + // Find which line and sub-line the cursor is on and its position + let (_, _, cursor_pos) = self.line_and_position_for_offset(self.cursor_offset()); - if let Some(pos) = cursor_pos { - // Adjust by scroll offset - let scroll_offset = bounds.origin; - self.preferred_x_offset = Some(pos.x + scroll_offset.x); - } + if let Some(pos) = cursor_pos { + self.preferred_x_offset = Some(pos.x + bounds.origin.x); } } /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. - fn line_and_position_for_offset( + /// + /// Returns: + /// + /// - The index of the line (zero-based) containing the offset. + /// - The index of the sub-line (zero-based) within the line containing the offset. + /// - The position of the offset. + pub(super) fn line_and_position_for_offset( &self, offset: usize, - lines: &[WrappedLine], - line_height: Pixels, ) -> (usize, usize, Option>) { + let Some(last_layout) = &self.last_layout else { + return (0, 0, None); + }; + let line_height = last_layout.line_height; + let mut prev_lines_offset = 0; let mut y_offset = px(0.); - for (line_index, line) in lines.iter().enumerate() { + for (line_index, line) in last_layout.lines.iter().enumerate() { let local_offset = offset.saturating_sub(prev_lines_offset); if let Some(pos) = line.position_for_index(local_offset, line_height) { let sub_line_index = (pos.y.0 / line_height.0) as usize; @@ -515,14 +541,15 @@ impl InputState { return; } - let (Some(lines), Some(bounds)) = (&self.last_layout, &self.last_bounds) else { + let (Some(last_layout), Some(bounds)) = (&self.last_layout, &self.last_bounds) else { return; }; let offset = self.cursor_offset(); - let line_height = self.last_line_height; + let preferred_x_offset = self.preferred_x_offset; + let line_height = last_layout.line_height; let (current_line_index, current_sub_line, current_pos) = - self.line_and_position_for_offset(offset, lines, line_height); + self.line_and_position_for_offset(offset); let Some(current_pos) = current_pos else { return; @@ -544,24 +571,17 @@ impl InputState { return; } - // Handle moving below the last line - if direction == 1 && new_line_index == 0 && new_sub_line > 0 && lines.len() == 1 { - // Move cursor to the end of the text - self.move_to(self.text.len(), window, cx); - return; - } - if new_sub_line < 0 { if new_line_index > 0 { new_line_index -= 1; - new_sub_line = lines[new_line_index].wrap_boundaries.len() as i32; + new_sub_line = last_layout.lines[new_line_index].wrap_boundaries.len() as i32; } else { new_sub_line = 0; } } else { - let max_sub_line = lines[new_line_index].wrap_boundaries.len() as i32; + let max_sub_line = last_layout.lines[new_line_index].wrap_boundaries.len() as i32; if new_sub_line > max_sub_line { - if new_line_index < lines.len() - 1 { + if new_line_index < last_layout.lines.len() - 1 { new_line_index += 1; new_sub_line = 0; } else { @@ -575,7 +595,7 @@ impl InputState { return; } - let target_line = &lines[new_line_index]; + let target_line = &last_layout.lines[new_line_index]; let line_x = current_x - bounds.origin.x; let target_sub_line = new_sub_line as usize; @@ -583,12 +603,12 @@ impl InputState { let index_res = target_line.index_for_position(approx_pos, line_height); let new_local_index = match index_res { - Ok(i) => i + 1, + Ok(i) => i, Err(i) => i, }; let mut prev_lines_offset = 0; - for (i, l) in lines.iter().enumerate() { + for (i, l) in last_layout.lines.iter().enumerate() { if i == new_line_index { break; } @@ -598,6 +618,8 @@ impl InputState { let new_offset = (prev_lines_offset + new_local_index).min(self.text.len()); self.selected_range = new_offset..new_offset; self.pause_blink_cursor(cx); + // Set back the preferred_x_offset + self.preferred_x_offset = preferred_x_offset; cx.notify(); } @@ -669,8 +691,8 @@ impl InputState { cx: &mut Context, ) { let text: SharedString = text.into(); - let range = self.range_to_utf16(&(self.cursor_offset()..self.cursor_offset())); - self.replace_text_in_range(Some(range), &text, window, cx); + let range_utf16 = self.range_to_utf16(&(self.cursor_offset()..self.cursor_offset())); + self.replace_text_in_range(Some(range_utf16), &text, window, cx); self.selected_range = self.selected_range.end..self.selected_range.end; } @@ -699,12 +721,27 @@ impl InputState { self.replace_text_in_range(Some(range), &text, window, cx); } + /// Set with disabled mode. + /// + /// See also: [`Self::set_disabled`], [`Self::is_disabled`]. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + /// Set the disabled state of the input field. + /// + /// See also: [`Self::disabled`], [`Self::is_disabled`]. pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { self.disabled = disabled; cx.notify(); } + /// Return is the input field is disabled. + pub fn is_disabled(&self) -> bool { + self.disabled + } + /// Set with password masked state. pub fn masked(mut self, masked: bool) -> Self { self.masked = masked; @@ -767,10 +804,6 @@ impl InputState { self.mask_pattern.unmask(&self.text).into() } - pub fn disabled(&self) -> bool { - self.disabled - } - /// Focus the input field. pub fn focus(&self, window: &mut Window, _: &mut Context) { self.focus_handle.focus(window); @@ -798,6 +831,13 @@ impl InputState { if self.is_single_line() { return; } + if !self.selected_range.is_empty() { + self.move_to( + self.previous_boundary(self.selected_range.start.saturating_sub(1)), + window, + cx, + ); + } self.pause_blink_cursor(cx); self.move_vertical(-1, window, cx); } @@ -806,6 +846,13 @@ impl InputState { if self.is_single_line() { return; } + if !self.selected_range.is_empty() { + self.move_to( + self.next_boundary(self.selected_range.end.saturating_sub(1)), + window, + cx, + ); + } self.pause_blink_cursor(cx); self.move_vertical(1, window, cx); } @@ -833,7 +880,7 @@ impl InputState { return; } let offset = self.start_of_line(window, cx).saturating_sub(1); - self.select_to(offset, window, cx); + self.select_to(self.previous_boundary(offset), window, cx); } pub(super) fn select_down( @@ -882,11 +929,11 @@ impl InputState { self.replace_text_in_range(None, "\n", window, cx); // Move cursor to the start of the next line - let mut new_offset = self.next_boundary(self.cursor_offset()) - 1; + let mut new_offset = self.cursor_offset() - 1; if is_eof { new_offset += 1; } - self.move_to(new_offset, window, cx); + self.move_to(self.next_boundary(new_offset), window, cx); } } @@ -991,8 +1038,8 @@ impl InputState { /// Return the start offset of the previous word. fn previous_start_of_word(&mut self) -> usize { let offset = self.selected_range.start; - let prev_str = &self.text[..offset].to_string(); - UnicodeSegmentation::split_word_bound_indices(prev_str as &str) + let prev_str = self.text_for_range_utf8(0..offset); + UnicodeSegmentation::split_word_bound_indices(prev_str) .filter(|(_, s)| !s.trim_start().is_empty()) .next_back() .map(|(i, _)| i) @@ -1002,8 +1049,8 @@ impl InputState { /// Return the next end offset of the next word. fn next_end_of_word(&mut self) -> usize { let offset = self.cursor_offset(); - let next_str = &self.text[offset..].to_string(); - UnicodeSegmentation::split_word_bound_indices(next_str as &str) + let next_str = self.text_for_range_utf8(offset..self.text.len()); + UnicodeSegmentation::split_word_bound_indices(next_str) .find(|(_, s)| !s.trim_start().is_empty()) .map(|(i, s)| offset + i + s.len()) .unwrap_or(self.text.len()) @@ -1034,7 +1081,7 @@ impl InputState { // ignore if offset is "\n" if self .text_for_range( - self.range_to_utf16(&(offset - 1..offset)), + self.range_to_utf16(&(offset.saturating_sub(1)..offset)), &mut None, window, cx, @@ -1150,11 +1197,11 @@ impl InputState { self.replace_text_in_range(None, "\n", window, cx); // Move cursor to the start of the next line - let mut new_offset = self.next_boundary(self.cursor_offset()) - 1; + let mut new_offset = self.cursor_offset() - 1; if is_eof { new_offset += 1; } - self.move_to(new_offset, window, cx); + self.move_to(self.next_boundary(new_offset), window, cx); } cx.emit(InputEvent::PressEnter { @@ -1167,6 +1214,10 @@ impl InputState { } pub(super) fn escape(&mut self, _: &Escape, window: &mut Window, cx: &mut Context) { + if self.marked_range.is_some() { + self.unmark_text(window, cx); + } + if !self.selected_range.is_empty() { return self.unselect(window, cx); } @@ -1192,6 +1243,14 @@ impl InputState { return; } + // If there have IME marked range and is empty (Means pressed Esc to abort IME typing) + // Clear the marked range. + if let Some(marked_range) = &self.marked_range { + if marked_range.is_empty() { + self.marked_range = None; + } + } + self.selecting = true; let offset = self.index_for_mouse_position(event.position, window, cx); @@ -1221,10 +1280,15 @@ impl InputState { pub(super) fn on_scroll_wheel( &mut self, event: &ScrollWheelEvent, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) { - let delta = event.delta.pixel_delta(self.last_line_height); + let line_height = self + .last_layout + .as_ref() + .map(|layout| layout.line_height) + .unwrap_or(window.line_height()); + let delta = event.delta.pixel_delta(line_height); self.update_scroll_offset(Some(self.scroll_handle.offset() + delta), cx); } @@ -1256,7 +1320,9 @@ impl InputState { return; } - let selected_text = self.text[self.selected_range.clone()].to_string(); + let selected_text = self + .text_for_range_utf8(self.selected_range.clone()) + .to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); } @@ -1265,7 +1331,9 @@ impl InputState { return; } - let selected_text = self.text[self.selected_range.clone()].to_string(); + let selected_text = self + .text_for_range_utf8(self.selected_range.clone()) + .to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); self.replace_text_in_range(None, "", window, cx); } @@ -1356,6 +1424,10 @@ impl InputState { } pub(super) fn cursor_offset(&self) -> usize { + if let Some(marked_range) = &self.marked_range { + return marked_range.end; + } + if self.selection_reversed { self.selected_range.start } else { @@ -1374,12 +1446,13 @@ impl InputState { return 0; } - let (Some(bounds), Some(lines)) = (self.last_bounds.as_ref(), self.last_layout.as_ref()) + let (Some(bounds), Some(last_layout)) = + (self.last_bounds.as_ref(), self.last_layout.as_ref()) else { return 0; }; - let line_height = self.last_line_height; + let line_height = last_layout.line_height; // TIP: About the IBeam cursor // @@ -1396,7 +1469,7 @@ impl InputState { let mut index = 0; let mut y_offset = px(0.); - for line in lines.iter() { + for line in last_layout.iter() { let line_origin = self.line_origin_with_y_offset(&mut y_offset, line, line_height); let pos = inner_position - line_origin; @@ -1508,21 +1581,29 @@ impl InputState { let mut start = self.offset_to_utf16(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)), &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 pre_chars_count = prev_chars.clone().count(); - for c in prev_chars { + for (ix, c) in prev_chars.enumerate() { if !is_word(c) { break; } - start -= c.len_utf16(); + if ix < pre_chars_count { + start = start.saturating_sub(c.len_utf8()); + } } for c in next_chars { @@ -1530,10 +1611,14 @@ impl InputState { break; } - end += c.len_utf16(); + end += c.len_utf8(); } - self.selected_range = self.range_from_utf16(&(start..end)); + if start == end { + return; + } + + self.selected_range = start..end; self.selected_word_range = Some(self.selected_range.clone()); cx.notify() } @@ -1599,7 +1684,9 @@ impl InputState { /// Returns the true to let InputElement to render cursor, when Input is focused and current BlinkCursor is visible. pub(crate) fn show_cursor(&self, window: &Window, cx: &App) -> bool { - self.focus_handle.is_focused(window) && self.blink_cursor.read(cx).visible() + self.focus_handle.is_focused(window) + && self.blink_cursor.read(cx).visible() + && window.is_window_active() } fn on_focus(&mut self, _: &mut Window, cx: &mut Context) { @@ -1718,6 +1805,11 @@ impl InputState { self.mode.update_auto_grow(&self.text_wrapper); } } + + pub(crate) fn text_for_range_utf8(&mut self, range: impl Into>) -> &str { + let range = self.range_from_utf16(&self.range_to_utf16(&range.into())); + &self.text[range] + } } impl EntityInputHandler for InputState { @@ -1780,8 +1872,10 @@ impl EntityInputHandler for InputState { .or(self.marked_range.clone()) .unwrap_or(self.selected_range.clone()); - let pending_text: SharedString = - (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); + let pending_text: SharedString = (self.text_for_range_utf8(0..range.start).to_owned() + + new_text + + self.text_for_range_utf8(range.end..self.text.len())) + .into(); // Check if the new text is valid if !self.is_valid_input(&pending_text) { @@ -1794,7 +1888,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, false, cx); self.selected_range = new_pos..new_pos; self.marked_range.take(); self.update_preferred_x_offset(cx); @@ -1804,6 +1898,7 @@ impl EntityInputHandler for InputState { cx.notify(); } + /// Mark text is the IME temporary insert on typing. fn replace_and_mark_text_in_range( &mut self, range_utf16: Option>, @@ -1821,20 +1916,32 @@ impl EntityInputHandler for InputState { .map(|range_utf16| self.range_from_utf16(range_utf16)) .or(self.marked_range.clone()) .unwrap_or(self.selected_range.clone()); - let pending_text: SharedString = - (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); + + let pending_text: SharedString = (self.text_for_range_utf8(0..range.start).to_owned() + + new_text + + self.text_for_range_utf8(range.end..self.text.len())) + .into(); + if !self.is_valid_input(&pending_text) { return; } self.push_history(&range, new_text, window, cx); self.text = pending_text; - self.marked_range = Some(range.start..range.start + new_text.len()); - self.selected_range = new_selected_range_utf16 - .as_ref() - .map(|range_utf16| self.range_from_utf16(range_utf16)) - .map(|new_range| new_range.start + range.start..new_range.end + range.end) - .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); + self.text_wrapper.update(&self.text, false, cx); + if new_text.is_empty() { + // Cancel selection, when cancel IME input. + self.selected_range = range.start..range.start; + self.marked_range = None; + } else { + self.marked_range = Some(range.start..range.start + new_text.len()); + self.selected_range = new_selected_range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .map(|new_range| new_range.start + range.start..new_range.end + range.end) + .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); + } + self.mode.update_auto_grow(&self.text_wrapper); cx.emit(InputEvent::Change(self.unmask_value())); cx.notify(); } @@ -1848,8 +1955,8 @@ impl EntityInputHandler for InputState { _window: &mut Window, _cx: &mut Context, ) -> Option> { - let line_height = self.last_line_height; - let lines = self.last_layout.as_ref()?; + let last_layout = self.last_layout.as_ref()?; + let line_height = last_layout.line_height; let range = self.range_from_utf16(&range_utf16); let mut start_origin = None; @@ -1857,28 +1964,35 @@ impl EntityInputHandler for InputState { let mut y_offset = px(0.); let mut index_offset = 0; - for line in lines.iter() { - if let Some(p) = - line.position_for_index(range.start.saturating_sub(index_offset), line_height) - { - start_origin = Some(p + point(px(0.), y_offset)); - } - if let Some(p) = - line.position_for_index(range.end.saturating_sub(index_offset), line_height) - { - end_origin = Some(p + point(px(0.), y_offset)); - } - - y_offset += line.size(line_height).height; + for line in last_layout.lines.iter() { if start_origin.is_some() && end_origin.is_some() { break; } - index_offset += line.len(); + if start_origin.is_none() { + if let Some(p) = + line.position_for_index(range.start.saturating_sub(index_offset), line_height) + { + start_origin = Some(p + point(px(0.), y_offset)); + } + } + + if end_origin.is_none() { + if let Some(p) = + line.position_for_index(range.end.saturating_sub(index_offset), line_height) + { + end_origin = Some(p + point(px(0.), y_offset)); + } + } + + index_offset += line.len() + 1; + y_offset += line.size(line_height).height; } let start_origin = start_origin.unwrap_or_default(); - let end_origin = end_origin.unwrap_or_default(); + let mut end_origin = end_origin.unwrap_or_default(); + // Ensure at same line. + end_origin.y = start_origin.y; Some(Bounds::from_corners( bounds.origin + start_origin, @@ -1893,11 +2007,11 @@ impl EntityInputHandler for InputState { _window: &mut Window, _cx: &mut Context, ) -> Option { - let line_height = self.last_line_height; + let last_layout = self.last_layout.as_ref()?; + let line_height = last_layout.line_height; let line_point = self.last_bounds?.localize(&point)?; - let lines = self.last_layout.as_ref()?; - for line in lines.iter() { + for line in last_layout.lines.iter() { if let Ok(utf8_index) = line.index_for_position(line_point, line_height) { return Some(self.offset_to_utf16(utf8_index)); } @@ -1915,11 +2029,13 @@ impl Focusable for InputState { impl Render for InputState { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.text_wrapper.update(&self.text, false, cx); + div() - .id("text-element") + .id("input-state") .flex_1() - .flex_grow() .when(self.is_multi_line(), |this| this.h_full()) + .flex_grow() .overflow_x_hidden() .child(TextElement::new(cx.entity().clone()).placeholder(self.placeholder.clone())) } diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index efd78b0..ed1dbb9 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -1,7 +1,8 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, Styled, Window, + IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, + Window, }; use theme::ActiveTheme; @@ -10,11 +11,12 @@ use crate::button::{Button, ButtonVariants as _}; use crate::indicator::Indicator; use crate::input::clear_button::clear_button; use crate::scroll::{Scrollbar, ScrollbarAxis}; -use crate::{h_flex, IconName, Sizable, Size, StyleSized}; +use crate::{h_flex, IconName, Sizable, Size, StyleSized, StyledExt}; #[derive(IntoElement)] pub struct TextInput { state: Entity, + style: StyleRefinement, size: Size, no_gap: bool, prefix: Option, @@ -38,6 +40,7 @@ impl TextInput { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), + style: StyleRefinement::default(), size: Size::default(), no_gap: false, prefix: None, @@ -276,8 +279,6 @@ impl RenderOnce for TextInput { let entity_id = self.state.entity_id(); if state.last_layout.is_some() { - let scroll_size = state.scroll_size; - this.relative().child( div() .absolute() @@ -290,7 +291,7 @@ impl RenderOnce for TextInput { entity_id, state.scrollbar_state.clone(), state.scroll_handle.clone(), - scroll_size, + state.scroll_size, ) .axis(ScrollbarAxis::Vertical), ), @@ -299,5 +300,6 @@ impl RenderOnce for TextInput { this } }) + .refine_style(&self.style) } } diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs index 660d29c..46ef910 100644 --- a/crates/ui/src/input/text_wrapper.rs +++ b/crates/ui/src/input/text_wrapper.rs @@ -1,72 +1,99 @@ -use std::ops::Range; - -use gpui::{App, Font, LineFragment, Pixels, SharedString}; - -/// 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 -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>, - pub(super) font: Font, - pub(super) font_size: Pixels, - /// If is none, it means the text is not wrapped - pub(super) wrap_width: Option, -} - -#[allow(unused)] -impl TextWrapper { - pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - text: SharedString::default(), - font, - font_size, - wrap_width, - wrapped_lines: Vec::new(), - } - } - - pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { - if self.wrap_width == wrap_width { - 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 wrap_width = self.wrap_width.unwrap_or(Pixels::MAX); - let mut line_wrapper = cx - .text_system() - .line_wrapper(self.font.clone(), self.font_size); - - for line in text.lines() { - let mut prev_boundary_ix = 0; - for boundary in line_wrapper.wrap_line(&[LineFragment::text(line)], wrap_width) { - wrapped_lines.push(prev_boundary_ix..boundary.ix); - prev_boundary_ix = boundary.ix; - } - - // Reset of the line - if !line[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { - wrapped_lines.push(prev_boundary_ix..line.len()); - } - } - - // Add last empty line. - if text.chars().last().unwrap_or('\n') == '\n' { - wrapped_lines.push(text.len()..text.len()); - } - - self.text = text; - self.wrapped_lines = wrapped_lines; - } -} +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, +} + +impl LineWrap { + pub(super) fn height(&self, line_height: Pixels) -> Pixels { + line_height * (self.wrap_lines + 1) + } +} + +/// 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 +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>, + /// The lines by split \n + pub(super) lines: Vec, + pub(super) font: Font, + pub(super) font_size: Pixels, + /// If is none, it means the text is not wrapped + pub(super) wrap_width: Option, +} + +#[allow(unused)] +impl TextWrapper { + pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + text: SharedString::default(), + font, + font_size, + wrap_width, + wrapped_lines: Vec::new(), + lines: Vec::new(), + } + } + + pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { + 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); + } + + pub(super) fn update(&mut self, text: &SharedString, force: bool, cx: &mut App) { + if &self.text == text && !force { + return; + } + + 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.split('\n') { + 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; + } + + lines.push(LineWrap { + wrap_lines: line_wraps.len(), + range: prev_line_ix..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_line_ix + prev_boundary_ix..prev_line_ix + line.len()); + } + + prev_line_ix += line.len() + 1; + } + + self.text = text.clone(); + self.wrapped_lines = wrapped_lines; + self.lines = lines; + } +} diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index 2a28cde..aa96a5f 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,309 +1,320 @@ -use std::fmt::{self, Display, Formatter}; - -use gpui::{div, px, App, Axis, Div, Element, ElementId, EntityId, Pixels, Styled, Window}; -use serde::{Deserialize, Serialize}; -use theme::ActiveTheme; - -use crate::scroll::{Scrollable, ScrollbarAxis}; - -/// Returns a `Div` as horizontal flex layout. -pub fn h_flex() -> Div { - div().h_flex() -} - -/// Returns a `Div` as vertical flex layout. -pub fn v_flex() -> Div { - div().v_flex() -} - -macro_rules! font_weight { - ($fn:ident, $const:ident) => { - /// [docs](https://tailwindcss.com/docs/font-weight) - fn $fn(self) -> Self { - self.font_weight(gpui::FontWeight::$const) - } - }; -} - -/// Extends [`gpui::Styled`] with specific styling methods. -pub trait StyledExt: Styled + Sized { - /// Apply self into a horizontal flex layout. - fn h_flex(self) -> Self { - self.flex().flex_row().items_center() - } - - /// Apply self into a vertical flex layout. - fn v_flex(self) -> Self { - self.flex().flex_col() - } - - /// Render a border with a width of 1px, color ring color - fn outline(self, _window: &Window, cx: &App) -> Self { - self.border_color(cx.theme().ring) - } - - /// Wraps the element in a ScrollView. - /// - /// Current this is only have a vertical scrollbar. - fn scrollable(self, view_id: EntityId, axis: ScrollbarAxis) -> Scrollable - where - Self: Element, - { - Scrollable::new(view_id, self, axis) - } - - font_weight!(font_thin, THIN); - font_weight!(font_extralight, EXTRA_LIGHT); - font_weight!(font_light, LIGHT); - font_weight!(font_normal, NORMAL); - font_weight!(font_medium, MEDIUM); - font_weight!(font_semibold, SEMIBOLD); - font_weight!(font_bold, BOLD); - font_weight!(font_extrabold, EXTRA_BOLD); - font_weight!(font_black, BLACK); - - /// Set as Popover style - fn popover_style(self, cx: &mut App) -> Self { - self.bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .rounded_lg() - } -} - -impl StyledExt for E {} - -/// A size for elements. -#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)] -pub enum Size { - Size(Pixels), - XSmall, - Small, - #[default] - Medium, - Large, -} - -impl From for Size { - fn from(size: Pixels) -> Self { - Size::Size(size) - } -} - -/// A trait for defining element that can be selected. -pub trait Selectable: Sized { - fn element_id(&self) -> &ElementId; - /// Set the selected state of the element. - fn selected(self, selected: bool) -> Self; -} - -/// A trait for defining element that can be disabled. -pub trait Disableable { - /// Set the disabled state of the element. - fn disabled(self, disabled: bool) -> Self; -} - -/// A trait for setting the size of an element. -pub trait Sizable: Sized { - /// Set the ui::Size of this element. - /// - /// Also can receive a `ButtonSize` to convert to `IconSize`, - /// Or a `Pixels` to set a custom size: `px(30.)` - fn with_size(self, size: impl Into) -> Self; - - /// Set to Size::XSmall - fn xsmall(self) -> Self { - self.with_size(Size::XSmall) - } - - /// Set to Size::Small - fn small(self) -> Self { - self.with_size(Size::Small) - } - - /// Set to Size::Medium - fn medium(self) -> Self { - self.with_size(Size::Medium) - } - - /// Set to Size::Large - fn large(self) -> Self { - self.with_size(Size::Large) - } -} - -#[allow(unused)] -pub trait StyleSized { - fn input_font_size(self, size: Size) -> Self; - fn input_size(self, size: Size) -> Self; - fn input_pl(self, size: Size) -> Self; - fn input_pr(self, size: Size) -> Self; - fn input_px(self, size: Size) -> Self; - fn input_py(self, size: Size) -> Self; - fn input_h(self, size: Size) -> Self; - fn list_size(self, size: Size) -> Self; - fn list_px(self, size: Size) -> Self; - fn list_py(self, size: Size) -> Self; - /// Apply size with the given `Size`. - fn size_with(self, size: Size) -> Self; -} - -impl StyleSized for T { - fn input_font_size(self, size: Size) -> Self { - match size { - Size::XSmall => self.text_xs(), - Size::Small => self.text_sm(), - Size::Medium => self.text_base(), - Size::Large => self.text_lg(), - Size::Size(size) => self.text_size(size), - } - } - - fn input_size(self, size: Size) -> Self { - self.input_px(size).input_py(size).input_h(size) - } - - fn input_pl(self, size: Size) -> Self { - match size { - Size::Large => self.pl_5(), - Size::Medium => self.pl_3(), - _ => self.pl_2(), - } - } - - fn input_pr(self, size: Size) -> Self { - match size { - Size::Large => self.pr_5(), - Size::Medium => self.pr_3(), - _ => self.pr_2(), - } - } - - fn input_px(self, size: Size) -> Self { - match size { - Size::Large => self.px_5(), - Size::Medium => self.px_3(), - _ => self.px_2(), - } - } - - fn input_py(self, size: Size) -> Self { - match size { - Size::Large => self.py_5(), - Size::Medium => self.py_2(), - _ => self.py_1(), - } - } - - fn input_h(self, size: Size) -> Self { - match size { - Size::XSmall => self.h_7(), - Size::Small => self.h_8(), - Size::Medium => self.h_9(), - Size::Large => self.h_12(), - _ => self.h(px(24.)), - } - .input_font_size(size) - } - - fn list_size(self, size: Size) -> Self { - self.list_px(size).list_py(size).input_font_size(size) - } - - fn list_px(self, size: Size) -> Self { - match size { - Size::Small => self.px_2(), - _ => self.px_3(), - } - } - - fn list_py(self, size: Size) -> Self { - match size { - Size::Large => self.py_2(), - Size::Medium => self.py_1(), - Size::Small => self.py_0p5(), - _ => self.py_1(), - } - } - - fn size_with(self, size: Size) -> Self { - match size { - Size::Large => self.size_11(), - Size::Medium => self.size_8(), - Size::Small => self.size_5(), - Size::XSmall => self.size_4(), - Size::Size(size) => self.size(size), - } - } -} - -pub trait AxisExt { - fn is_horizontal(&self) -> bool; - fn is_vertical(&self) -> bool; -} - -impl AxisExt for Axis { - fn is_horizontal(&self) -> bool { - self == &Axis::Horizontal - } - - fn is_vertical(&self) -> bool { - self == &Axis::Vertical - } -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Placement { - Top, - Bottom, - Left, - Right, -} - -impl Display for Placement { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Placement::Top => write!(f, "Top"), - Placement::Bottom => write!(f, "Bottom"), - Placement::Left => write!(f, "Left"), - Placement::Right => write!(f, "Right"), - } - } -} - -impl Placement { - pub fn is_horizontal(&self) -> bool { - matches!(self, Placement::Left | Placement::Right) - } - - pub fn is_vertical(&self) -> bool { - matches!(self, Placement::Top | Placement::Bottom) - } - - pub fn axis(&self) -> Axis { - match self { - Placement::Top | Placement::Bottom => Axis::Vertical, - Placement::Left | Placement::Right => Axis::Horizontal, - } - } -} - -/// A enum for defining the side of the element. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Side { - Left, - Right, -} - -impl Side { - pub(crate) fn is_left(&self) -> bool { - matches!(self, Self::Left) - } -} - -/// A trait for defining element that can be collapsed. -pub trait Collapsible { - fn collapsed(self, collapsed: bool) -> Self; - fn is_collapsed(&self) -> bool; -} +use std::fmt::{self, Display, Formatter}; + +use gpui::{ + div, px, App, Axis, Div, Element, ElementId, EntityId, Pixels, Refineable, StyleRefinement, + Styled, Window, +}; +use serde::{Deserialize, Serialize}; +use theme::ActiveTheme; + +use crate::scroll::{Scrollable, ScrollbarAxis}; + +/// Returns a `Div` as horizontal flex layout. +pub fn h_flex() -> Div { + div().h_flex() +} + +/// Returns a `Div` as vertical flex layout. +pub fn v_flex() -> Div { + div().v_flex() +} + +macro_rules! font_weight { + ($fn:ident, $const:ident) => { + /// [docs](https://tailwindcss.com/docs/font-weight) + fn $fn(self) -> Self { + self.font_weight(gpui::FontWeight::$const) + } + }; +} + +/// Extends [`gpui::Styled`] with specific styling methods. +pub trait StyledExt: Styled + Sized { + /// Refine the style of this element, applying the given style refinement. + fn refine_style(mut self, style: &StyleRefinement) -> Self { + self.style().refine(style); + self + } + + /// Apply self into a horizontal flex layout. + #[inline] + fn h_flex(self) -> Self { + self.flex().flex_row().items_center() + } + + /// Apply self into a vertical flex layout. + #[inline] + fn v_flex(self) -> Self { + self.flex().flex_col() + } + + /// Render a border with a width of 1px, color ring color + fn outline(self, _window: &Window, cx: &App) -> Self { + self.border_color(cx.theme().ring) + } + + /// Wraps the element in a ScrollView. + /// + /// Current this is only have a vertical scrollbar. + fn scrollable(self, view_id: EntityId, axis: ScrollbarAxis) -> Scrollable + where + Self: Element, + { + Scrollable::new(view_id, self, axis) + } + + font_weight!(font_thin, THIN); + font_weight!(font_extralight, EXTRA_LIGHT); + font_weight!(font_light, LIGHT); + font_weight!(font_normal, NORMAL); + font_weight!(font_medium, MEDIUM); + font_weight!(font_semibold, SEMIBOLD); + font_weight!(font_bold, BOLD); + font_weight!(font_extrabold, EXTRA_BOLD); + font_weight!(font_black, BLACK); + + /// Set as Popover style + fn popover_style(self, cx: &mut App) -> Self { + self.bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .shadow_lg() + .rounded_lg() + } +} + +impl StyledExt for E {} + +/// A size for elements. +#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub enum Size { + Size(Pixels), + XSmall, + Small, + #[default] + Medium, + Large, +} + +impl From for Size { + fn from(size: Pixels) -> Self { + Size::Size(size) + } +} + +/// A trait for defining element that can be selected. +pub trait Selectable: Sized { + fn element_id(&self) -> &ElementId; + /// Set the selected state of the element. + fn selected(self, selected: bool) -> Self; +} + +/// A trait for defining element that can be disabled. +pub trait Disableable { + /// Set the disabled state of the element. + fn disabled(self, disabled: bool) -> Self; +} + +/// A trait for setting the size of an element. +pub trait Sizable: Sized { + /// Set the ui::Size of this element. + /// + /// Also can receive a `ButtonSize` to convert to `IconSize`, + /// Or a `Pixels` to set a custom size: `px(30.)` + fn with_size(self, size: impl Into) -> Self; + + /// Set to Size::XSmall + fn xsmall(self) -> Self { + self.with_size(Size::XSmall) + } + + /// Set to Size::Small + fn small(self) -> Self { + self.with_size(Size::Small) + } + + /// Set to Size::Medium + fn medium(self) -> Self { + self.with_size(Size::Medium) + } + + /// Set to Size::Large + fn large(self) -> Self { + self.with_size(Size::Large) + } +} + +#[allow(unused)] +pub trait StyleSized { + fn input_font_size(self, size: Size) -> Self; + fn input_size(self, size: Size) -> Self; + fn input_pl(self, size: Size) -> Self; + fn input_pr(self, size: Size) -> Self; + fn input_px(self, size: Size) -> Self; + fn input_py(self, size: Size) -> Self; + fn input_h(self, size: Size) -> Self; + fn list_size(self, size: Size) -> Self; + fn list_px(self, size: Size) -> Self; + fn list_py(self, size: Size) -> Self; + /// Apply size with the given `Size`. + fn size_with(self, size: Size) -> Self; +} + +impl StyleSized for T { + fn input_font_size(self, size: Size) -> Self { + match size { + Size::XSmall => self.text_xs(), + Size::Small => self.text_sm(), + Size::Medium => self.text_base(), + Size::Large => self.text_lg(), + Size::Size(size) => self.text_size(size), + } + } + + fn input_size(self, size: Size) -> Self { + self.input_px(size).input_py(size).input_h(size) + } + + fn input_pl(self, size: Size) -> Self { + match size { + Size::Large => self.pl_5(), + Size::Medium => self.pl_3(), + _ => self.pl_2(), + } + } + + fn input_pr(self, size: Size) -> Self { + match size { + Size::Large => self.pr_5(), + Size::Medium => self.pr_3(), + _ => self.pr_2(), + } + } + + fn input_px(self, size: Size) -> Self { + match size { + Size::Large => self.px_5(), + Size::Medium => self.px_3(), + _ => self.px_2(), + } + } + + fn input_py(self, size: Size) -> Self { + match size { + Size::Large => self.py_5(), + Size::Medium => self.py_2(), + _ => self.py_1(), + } + } + + fn input_h(self, size: Size) -> Self { + match size { + Size::XSmall => self.h_7(), + Size::Small => self.h_8(), + Size::Medium => self.h_9(), + Size::Large => self.h_12(), + _ => self.h(px(24.)), + } + .input_font_size(size) + } + + fn list_size(self, size: Size) -> Self { + self.list_px(size).list_py(size).input_font_size(size) + } + + fn list_px(self, size: Size) -> Self { + match size { + Size::Small => self.px_2(), + _ => self.px_3(), + } + } + + fn list_py(self, size: Size) -> Self { + match size { + Size::Large => self.py_2(), + Size::Medium => self.py_1(), + Size::Small => self.py_0p5(), + _ => self.py_1(), + } + } + + fn size_with(self, size: Size) -> Self { + match size { + Size::Large => self.size_11(), + Size::Medium => self.size_8(), + Size::Small => self.size_5(), + Size::XSmall => self.size_4(), + Size::Size(size) => self.size(size), + } + } +} + +pub trait AxisExt { + fn is_horizontal(&self) -> bool; + fn is_vertical(&self) -> bool; +} + +impl AxisExt for Axis { + fn is_horizontal(&self) -> bool { + self == &Axis::Horizontal + } + + fn is_vertical(&self) -> bool { + self == &Axis::Vertical + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Placement { + Top, + Bottom, + Left, + Right, +} + +impl Display for Placement { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Placement::Top => write!(f, "Top"), + Placement::Bottom => write!(f, "Bottom"), + Placement::Left => write!(f, "Left"), + Placement::Right => write!(f, "Right"), + } + } +} + +impl Placement { + pub fn is_horizontal(&self) -> bool { + matches!(self, Placement::Left | Placement::Right) + } + + pub fn is_vertical(&self) -> bool { + matches!(self, Placement::Top | Placement::Bottom) + } + + pub fn axis(&self) -> Axis { + match self { + Placement::Top | Placement::Bottom => Axis::Vertical, + Placement::Left | Placement::Right => Axis::Horizontal, + } + } +} + +/// A enum for defining the side of the element. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Side { + Left, + Right, +} + +impl Side { + pub(crate) fn is_left(&self) -> bool { + matches!(self, Self::Left) + } +} + +/// A trait for defining element that can be collapsed. +pub trait Collapsible { + fn collapsed(self, collapsed: bool) -> Self; + fn is_collapsed(&self) -> bool; +}