From c78e0a5163aa9f04d6bbaebda26059d7d1d477d5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 3 Jun 2026 13:18:03 +0000 Subject: [PATCH] fix: chat input crashing when moving the cursor (#33) Reviewed-on: https://git.reya.su/reya/coop/pulls/33 --- Cargo.lock | 51 +- crates/chat_ui/src/lib.rs | 12 +- crates/ui/Cargo.toml | 3 +- crates/ui/LICENSE | 191 -- crates/ui/src/history.rs | 31 +- crates/ui/src/input/blink_cursor.rs | 29 +- crates/ui/src/input/change.rs | 3 +- crates/ui/src/input/clear_button.rs | 12 +- crates/ui/src/input/cursor.rs | 11 +- .../ui/src/input/display_map/display_map.rs | 336 ++++ crates/ui/src/input/display_map/fold_map.rs | 343 ++++ crates/ui/src/input/display_map/folding.rs | 96 + crates/ui/src/input/display_map/mod.rs | 69 + .../ui/src/input/display_map/text_wrapper.rs | 930 +++++++++ crates/ui/src/input/display_map/wrap_map.rs | 222 +++ crates/ui/src/input/element.rs | 1676 +++++++++++++++-- crates/ui/src/input/indent.rs | 424 +++++ .../ui/src/input/{text_input.rs => input.rs} | 179 +- crates/ui/src/input/mask_pattern.rs | 4 +- crates/ui/src/input/mod.rs | 24 +- crates/ui/src/input/mode.rs | 180 +- crates/ui/src/input/movement.rs | 264 +++ .../ui/src/input/popovers/code_action_menu.rs | 337 ++++ .../ui/src/input/popovers/completion_menu.rs | 446 +++++ crates/ui/src/input/popovers/context_menu.rs | 142 ++ .../src/input/popovers/diagnostic_popover.rs | 95 + crates/ui/src/input/popovers/hover_popover.rs | 292 +++ crates/ui/src/input/popovers/mod.rs | 41 + crates/ui/src/input/rope_ext.rs | 440 ++++- crates/ui/src/input/selection.rs | 140 ++ crates/ui/src/input/state.rs | 1453 +++++++------- crates/ui/src/input/text_wrapper.rs | 227 --- crates/ui/src/list/list.rs | 8 +- desktop/src/dialogs/import.rs | 6 +- desktop/src/dialogs/restore.rs | 4 +- desktop/src/dialogs/settings.rs | 4 +- desktop/src/panels/backup.rs | 10 +- desktop/src/panels/contact_list.rs | 6 +- desktop/src/panels/messaging_relays.rs | 6 +- desktop/src/panels/profile.rs | 10 +- desktop/src/panels/relay_list.rs | 6 +- desktop/src/sidebar/mod.rs | 5 +- 42 files changed, 7175 insertions(+), 1593 deletions(-) delete mode 100644 crates/ui/LICENSE create mode 100644 crates/ui/src/input/display_map/display_map.rs create mode 100644 crates/ui/src/input/display_map/fold_map.rs create mode 100644 crates/ui/src/input/display_map/folding.rs create mode 100644 crates/ui/src/input/display_map/mod.rs create mode 100644 crates/ui/src/input/display_map/text_wrapper.rs create mode 100644 crates/ui/src/input/display_map/wrap_map.rs create mode 100644 crates/ui/src/input/indent.rs rename crates/ui/src/input/{text_input.rs => input.rs} (69%) create mode 100644 crates/ui/src/input/movement.rs create mode 100644 crates/ui/src/input/popovers/code_action_menu.rs create mode 100644 crates/ui/src/input/popovers/completion_menu.rs create mode 100644 crates/ui/src/input/popovers/context_menu.rs create mode 100644 crates/ui/src/input/popovers/diagnostic_popover.rs create mode 100644 crates/ui/src/input/popovers/hover_popover.rs create mode 100644 crates/ui/src/input/popovers/mod.rs create mode 100644 crates/ui/src/input/selection.rs delete mode 100644 crates/ui/src/input/text_wrapper.rs diff --git a/Cargo.lock b/Cargo.lock index 6864164..5cd980b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6059,18 +6059,12 @@ dependencies = [ ] [[package]] -name = "rope" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46ac5758a5562f05b786b88cf3600b9334abeb7d" +name = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" dependencies = [ - "heapless 0.9.3", - "log", - "rayon", - "sum_tree", - "tracing", - "unicode-segmentation", - "util", - "ztracing", + "str_indices", ] [[package]] @@ -6889,6 +6883,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -7648,6 +7654,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "tree-sitter" +version = "0.26.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dab76d0b724ba557954125188cf0633a1ca43199ced82d95c7b9c32cc3de1f3" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + [[package]] name = "try-lock" version = "0.2.5" @@ -7717,13 +7743,14 @@ dependencies = [ "log", "lsp-types", "regex", - "rope", + "ropey", "serde", "serde_json", "smallvec", "smol", "sum_tree", "theme", + "tree-sitter", "unicode-segmentation", "uuid", ] diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index f309f86..310e75f 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -24,7 +24,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; +use ui::input::{Input, InputEvent, InputState}; use ui::menu::DropdownMenu; use ui::notification::Notification; use ui::scroll::Scrollbar; @@ -119,7 +119,6 @@ impl ChatPanel { InputState::new(window, cx) .placeholder(format!("Message {}", name)) .auto_grow(1, 20) - .prevent_new_line_on_enter() .clean_on_escape() }); @@ -1498,7 +1497,7 @@ impl Render for ChatPanel { .border_b_1() .border_color(cx.theme().border) .child( - TextInput::new(&self.subject_input) + Input::new(&self.subject_input) .text_sm() .small() .bordered(false), @@ -1553,12 +1552,7 @@ impl Render for ChatPanel { this.upload(window, cx); })), ) - .child( - TextInput::new(&self.input) - .appearance(false) - .text_sm() - .flex_1(), - ) + .child(Input::new(&self.input).appearance(false).text_sm().flex_1()) .child( h_flex() .pl_1() diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index ce03c8a..a060255 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -22,5 +22,6 @@ uuid = "1.10" regex = "1" image = "0.25.1" lsp-types = "0.97.0" -rope = { git = "https://github.com/zed-industries/zed" } +ropey = { version = "=2.0.0-beta.1", features = ["metric_lines_lf", "metric_utf16"] } sum_tree = { git = "https://github.com/zed-industries/zed" } +tree-sitter = "0.26" diff --git a/crates/ui/LICENSE b/crates/ui/LICENSE deleted file mode 100644 index dd3ad77..0000000 --- a/crates/ui/LICENSE +++ /dev/null @@ -1,191 +0,0 @@ -Copyright 2024 Longbridge - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS diff --git a/crates/ui/src/history.rs b/crates/ui/src/history.rs index 348507b..d933c4c 100644 --- a/crates/ui/src/history.rs +++ b/crates/ui/src/history.rs @@ -1,6 +1,8 @@ use std::fmt::Debug; use std::time::{Duration, Instant}; +/// A HistoryItem represents a single change in the history. +/// It must implement Clone and PartialEq to be used in the History. pub trait HistoryItem: Clone + PartialEq { fn version(&self) -> usize; fn set_version(&mut self, version: usize); @@ -22,10 +24,11 @@ pub struct History { redos: Vec, last_changed_at: Instant, version: usize, - max_undo: usize, + pub(crate) ignore: bool, + max_undos: usize, group_interval: Option, + grouping: bool, unique: bool, - pub ignore: bool, } impl History @@ -39,15 +42,16 @@ where ignore: false, last_changed_at: Instant::now(), version: 0, - max_undo: 1000, + max_undos: 1000, group_interval: None, + grouping: false, unique: false, } } /// Set the maximum number of undo steps to keep, defaults to 1000. - pub fn max_undo(mut self, max_undo: usize) -> Self { - self.max_undo = max_undo; + pub fn max_undos(mut self, max_undos: usize) -> Self { + self.max_undos = max_undos; self } @@ -64,10 +68,20 @@ where self } + /// Start grouping changes, this will prevent the version from being incremented until `end_grouping` is called. + pub fn start_grouping(&mut self) { + self.grouping = true; + } + + /// End grouping changes, this will allow the version to be incremented again. + pub fn end_grouping(&mut self) { + self.grouping = false; + } + /// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago. fn inc_version(&mut self) -> usize { let t = Instant::now(); - if Some(self.last_changed_at.elapsed()) > self.group_interval { + if !self.grouping && Some(self.last_changed_at.elapsed()) > self.group_interval { self.version += 1; } @@ -80,10 +94,11 @@ where self.version } + /// Push a new change to the history. pub fn push(&mut self, item: I) { let version = self.inc_version(); - if self.undos.len() >= self.max_undo { + if self.undos.len() >= self.max_undos { self.undos.remove(0); } @@ -113,6 +128,7 @@ where self.redos.clear(); } + /// Undo the last change and return the changes that were undone. pub fn undo(&mut self) -> Option> { if let Some(first_change) = self.undos.pop() { let mut changes = vec![first_change.clone()]; @@ -135,6 +151,7 @@ where } } + /// Redo the last undone change and return the changes that were redone. pub fn redo(&mut self) -> Option> { if let Some(first_change) = self.redos.pop() { let mut changes = vec![first_change.clone()]; diff --git a/crates/ui/src/input/blink_cursor.rs b/crates/ui/src/input/blink_cursor.rs index 27cdc08..98b112d 100644 --- a/crates/ui/src/input/blink_cursor.rs +++ b/crates/ui/src/input/blink_cursor.rs @@ -1,9 +1,14 @@ use std::time::Duration; -use gpui::{px, Context, Pixels}; +use gpui::{Context, Pixels, Task, px}; static INTERVAL: Duration = Duration::from_millis(500); static PAUSE_DELAY: Duration = Duration::from_millis(300); + +// On Windows, Linux, we should use integer to avoid blurry cursor. +#[cfg(not(target_os = "macos"))] +pub(super) const CURSOR_WIDTH: Pixels = px(2.); +#[cfg(target_os = "macos")] pub(super) const CURSOR_WIDTH: Pixels = px(1.5); /// To manage the Input cursor blinking. @@ -12,10 +17,12 @@ pub(super) const CURSOR_WIDTH: Pixels = px(1.5); /// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint. /// /// The input painter will check if this in visible state, then it will draw the cursor. -pub struct BlinkCursor { +pub(crate) struct BlinkCursor { visible: bool, paused: bool, epoch: usize, + + _task: Task<()>, } impl BlinkCursor { @@ -24,6 +31,7 @@ impl BlinkCursor { visible: false, paused: false, epoch: 0, + _task: Task::ready(()), } } @@ -53,14 +61,12 @@ impl BlinkCursor { // Schedule the next blink let epoch = self.next_epoch(); - cx.spawn(async move |this, cx| { + self._task = cx.spawn(async move |this, cx| { cx.background_executor().timer(INTERVAL).await; - if let Some(this) = this.upgrade() { this.update(cx, |this, cx| this.blink(epoch, cx)); } - }) - .detach(); + }); } pub fn visible(&self) -> bool { @@ -76,7 +82,7 @@ impl BlinkCursor { // delay 500ms to start the blinking let epoch = self.next_epoch(); - cx.spawn(async move |this, cx| { + self._task = cx.spawn(async move |this, cx| { cx.background_executor().timer(PAUSE_DELAY).await; if let Some(this) = this.upgrade() { @@ -85,13 +91,6 @@ impl BlinkCursor { this.blink(epoch, cx); }); } - }) - .detach(); - } -} - -impl Default for BlinkCursor { - fn default() -> Self { - Self::new() + }); } } diff --git a/crates/ui/src/input/change.rs b/crates/ui/src/input/change.rs index 6779339..bd771a9 100644 --- a/crates/ui/src/input/change.rs +++ b/crates/ui/src/input/change.rs @@ -1,7 +1,6 @@ use std::fmt::Debug; -use crate::history::HistoryItem; -use crate::input::cursor::Selection; +use crate::{history::HistoryItem, input::Selection}; #[derive(Debug, PartialEq, Clone)] pub struct Change { diff --git a/crates/ui/src/input/clear_button.rs b/crates/ui/src/input/clear_button.rs index dfe170f..511609c 100644 --- a/crates/ui/src/input/clear_button.rs +++ b/crates/ui/src/input/clear_button.rs @@ -1,15 +1,15 @@ use gpui::{App, Styled}; use theme::ActiveTheme; -use crate::button::{Button, ButtonVariants}; -use crate::{Icon, IconName, Sizable}; +use crate::button::{Button, ButtonVariants as _}; +use crate::{Icon, IconName, Sizable as _}; #[inline] pub(crate) fn clear_button(cx: &App) -> Button { Button::new("clean") .icon(Icon::new(IconName::CloseCircle)) - .tooltip("Clear") - .small() - .transparent() - .text_color(cx.theme().text_muted) + .ghost() + .xsmall() + .tab_stop(false) + .text_color(cx.theme().icon_muted) } diff --git a/crates/ui/src/input/cursor.rs b/crates/ui/src/input/cursor.rs index 6a91562..e25a7df 100644 --- a/crates/ui/src/input/cursor.rs +++ b/crates/ui/src/input/cursor.rs @@ -1,4 +1,4 @@ -use std::ops::Range; +use std::ops::{Range, RangeBounds}; /// A selection in the text, represented by start and end byte indices. #[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] @@ -42,5 +42,12 @@ impl From for Range { value.start..value.end } } +impl RangeBounds for Selection { + fn start_bound(&self) -> std::ops::Bound<&usize> { + std::ops::Bound::Included(&self.start) + } -pub type Position = lsp_types::Position; + fn end_bound(&self) -> std::ops::Bound<&usize> { + std::ops::Bound::Excluded(&self.end) + } +} diff --git a/crates/ui/src/input/display_map/display_map.rs b/crates/ui/src/input/display_map/display_map.rs new file mode 100644 index 0000000..c40d6fc --- /dev/null +++ b/crates/ui/src/input/display_map/display_map.rs @@ -0,0 +1,336 @@ +/// DisplayMap: Public facade for Editor/Input display mapping. +/// +/// This combines WrapMap and FoldMap to provide a unified API: +/// - BufferPoint ↔ DisplayPoint conversion +/// - Fold management (candidates, toggle, query) +/// - Automatic projection updates on text/layout changes +use std::ops::Range; + +use gpui::{App, Font, Pixels}; +use ropey::Rope; + +use super::fold_map::FoldMap; +use super::folding::FoldRange; +use super::text_wrapper::{LineItem, WrapDisplayPoint}; +use super::wrap_map::WrapMap; +use super::{BufferPoint, DisplayPoint}; +use crate::input::display_map::WrapPoint; +use crate::input::rope_ext::RopeExt as _; +use crate::input::Point as TreeSitterPoint; + +/// DisplayMap is the main interface for Editor/Input coordinate mapping. +/// +/// It manages the two-layer projection: +/// 1. Buffer → Wrap (soft-wrapping) +/// 2. Wrap → Display (folding) +/// +/// Editor/Input only needs to work with BufferPoint and DisplayPoint. +pub struct DisplayMap { + wrap_map: WrapMap, + fold_map: FoldMap, +} + +impl DisplayMap { + pub fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + wrap_map: WrapMap::new(font, font_size, wrap_width), + fold_map: FoldMap::new(), + } + } + + // ==================== Core Coordinate Mapping ==================== + + /// Convert buffer position to display position + pub fn buffer_pos_to_display_pos(&self, pos: BufferPoint) -> DisplayPoint { + // Buffer → Wrap + let wrap_pos = self.wrap_map.buffer_pos_to_wrap_pos(pos); + + // Wrap → Display + if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_pos.row) { + DisplayPoint::new(display_row, wrap_pos.col) + } else { + // Cursor is in a folded region, find nearest visible row + let display_row = self.fold_map.nearest_visible_display_row(wrap_pos.row); + DisplayPoint::new(display_row, 0) // Column 0 at fold boundary + } + } + + /// Convert display position to buffer position + pub fn display_pos_to_buffer_pos(&self, pos: DisplayPoint) -> BufferPoint { + // Display → Wrap + let wrap_row = self.fold_map.display_row_to_wrap_row(pos.row).unwrap_or(0); + + // Wrap → Buffer + let wrap_pos = WrapPoint::new(wrap_row, pos.col); + self.wrap_map.wrap_pos_to_buffer_pos(wrap_pos) + } + + /// Get total number of visible display rows + #[inline] + pub fn display_row_count(&self) -> usize { + self.fold_map.display_row_count() + } + + /// Get the buffer line for a given display row + pub fn display_row_to_buffer_line(&self, display_row: usize) -> usize { + // Display → Wrap + let wrap_row = self + .fold_map + .display_row_to_wrap_row(display_row) + .unwrap_or(0); + + // Wrap → Buffer line + self.wrap_map.wrap_row_to_buffer_line(wrap_row) + } + + /// Get the display row range for a buffer line: [start, end) + /// Returns None if the buffer line is completely hidden + pub fn buffer_line_to_display_row_range(&self, line: usize) -> Option> { + // Buffer line → Wrap row range + let wrap_row_range = self.wrap_map.buffer_line_to_wrap_row_range(line); + + // Find first and last visible display rows in this range + let mut first_display_row = None; + let mut last_display_row = None; + + for wrap_row in wrap_row_range { + if let Some(display_row) = self.fold_map.wrap_row_to_display_row(wrap_row) { + if first_display_row.is_none() { + first_display_row = Some(display_row); + } + last_display_row = Some(display_row); + } + } + + if let (Some(start), Some(end)) = (first_display_row, last_display_row) { + Some(start..end + 1) + } else { + None // Completely folded + } + } + + /// Check if a buffer line is completely hidden + #[inline] + pub fn is_buffer_line_hidden(&self, line: usize) -> bool { + self.buffer_line_to_display_row_range(line).is_none() + } + + /// Set fold candidates (from tree-sitter/LSP) + pub fn set_fold_candidates(&mut self, candidates: Vec) { + self.fold_map.set_candidates(candidates); + self.rebuild_fold_projection(); + } + + /// Set a fold at the given start_line (must be in candidates) + pub fn set_folded(&mut self, start_line: usize, folded: bool) { + self.fold_map.set_folded(start_line, folded); + self.rebuild_fold_projection(); + } + + /// Toggle fold at the given start_line + pub fn toggle_fold(&mut self, start_line: usize) { + self.fold_map.toggle_fold(start_line); + self.rebuild_fold_projection(); + } + + /// Check if a line is currently folded + #[inline] + pub fn is_folded_at(&self, start_line: usize) -> bool { + self.fold_map.is_folded_at(start_line) + } + + /// Check if a line is a fold candidate + #[inline] + pub fn is_fold_candidate(&self, start_line: usize) -> bool { + self.fold_map.is_fold_candidate(start_line) + } + + /// Get all currently folded ranges + #[inline] + pub fn folded_ranges(&self) -> &[FoldRange] { + self.fold_map.folded_ranges() + } + + /// Clear all folds + pub fn clear_folds(&mut self) { + self.fold_map.clear_folds(); + self.rebuild_fold_projection(); + } + + // ==================== Text and Layout Updates ==================== + + /// Adjust folds and candidates for a text edit before updating the wrap map. + /// + /// Must be called with the OLD text (before replacement) and the edit range/new_text + /// so we can compute which old lines were affected. + pub fn adjust_folds_for_edit(&mut self, old_text: &Rope, range: &Range, new_text: &str) { + if self.fold_map.folded_ranges().is_empty() && self.fold_map.fold_candidates().is_empty() { + return; + } + + let edit_start_line = old_text.offset_to_point(range.start).row; + let edit_end_line = old_text.offset_to_point(range.end.min(old_text.len())).row; + + let old_lines_in_range = edit_end_line.saturating_sub(edit_start_line); + let new_lines_in_range = new_text.chars().filter(|c| *c == '\n').count(); + let line_delta = new_lines_in_range as isize - old_lines_in_range as isize; + + self.fold_map + .adjust_folds_for_edit(edit_start_line, edit_end_line, line_delta); + } + + /// Incrementally update fold candidates after a text edit. + /// + /// Extracts new fold candidates only within the edited byte range + /// and merges them with existing (already adjusted) candidates. + pub fn update_fold_candidates_for_edit( + &mut self, + tree: &super::folding::Tree, + edit_byte_range: Range, + new_text: &Rope, + ) { + let new_start_line = new_text.offset_to_point(edit_byte_range.start).row; + let new_end_line = new_text + .offset_to_point(edit_byte_range.end.min(new_text.len())) + .row; + + let new_candidates = super::folding::extract_fold_ranges_in_range(tree, edit_byte_range); + self.fold_map + .merge_candidates_for_edit(new_start_line, new_end_line, new_candidates); + } + + /// Update text (incremental or full) + pub fn on_text_changed( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + cx: &mut App, + ) { + self.wrap_map + .on_text_changed(changed_text, range, new_text, cx); + self.rebuild_fold_projection(); + } + + /// Update layout parameters (wrap width or font) + pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { + self.wrap_map.on_layout_changed(wrap_width, cx); + self.rebuild_fold_projection(); + } + + /// Set font parameters + pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + self.wrap_map.set_font(font, font_size, cx); + self.rebuild_fold_projection(); + } + + /// Ensure text is prepared (initializes wrapper if needed) + pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) { + let did_initialize = self.wrap_map.ensure_text_prepared(text, cx); + if did_initialize { + self.rebuild_fold_projection(); + } + } + + /// Initialize with text + pub fn set_text(&mut self, text: &Rope, cx: &mut App) { + self.wrap_map.set_text(text, cx); + self.rebuild_fold_projection(); + } + + // ==================== Internal Helpers ==================== + + /// Rebuild fold projection after wrap_map or fold state changes + /// Only rebuilds if there are actually folded ranges + fn rebuild_fold_projection(&mut self) { + if !self.fold_map.folded_ranges().is_empty() { + self.fold_map.rebuild(&self.wrap_map); + } else { + // No active folds: identity mapping (wrap_row == display_row). + // Just update cached count so query methods work without Vec allocation. + self.fold_map + .mark_dirty_with_wrap_count(self.wrap_map.wrap_row_count()); + } + } + + // ==================== Wrap Display Point Operations ==================== + + /// Convert byte offset to wrap display point (with soft wrap info). + #[inline] + pub(crate) fn offset_to_wrap_display_point(&self, offset: usize) -> WrapDisplayPoint { + self.wrap_map.wrapper().offset_to_display_point(offset) + } + + /// Convert wrap display point to byte offset. + #[inline] + pub(crate) fn wrap_display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { + self.wrap_map.wrapper().display_point_to_offset(point) + } + + /// Convert wrap display point to TreeSitterPoint (buffer line/col). + #[inline] + pub(crate) fn wrap_display_point_to_point( + &self, + point: WrapDisplayPoint, + ) -> TreeSitterPoint { + self.wrap_map.wrapper().display_point_to_point(point) + } + + /// Convert a wrap row to a display row (skipping folded rows). + /// Returns None if the wrap row is folded. + #[inline] + pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option { + self.fold_map.wrap_row_to_display_row(wrap_row) + } + + /// Find the nearest visible display row for a given wrap row. + #[inline] + pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize { + self.fold_map.nearest_visible_display_row(wrap_row) + } + + /// Convert a display row to a wrap row. + #[inline] + pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option { + self.fold_map.display_row_to_wrap_row(display_row) + } + + /// Get the longest row index (by byte length). + #[inline] + pub(crate) fn longest_row(&self) -> usize { + self.wrap_map.wrapper().longest_row.row + } + + // ==================== Access Methods ==================== + + /// Get access to line items (for rendering) + #[inline] + pub(crate) fn lines(&self) -> &[LineItem] { + self.wrap_map.lines() + } + + /// Get the rope text + #[inline] + pub fn text(&self) -> &Rope { + self.wrap_map.text() + } + + /// Calculate how many wrap rows of a buffer line are visible (not folded) + #[inline] + pub fn visible_wrap_row_count_for_buffer_line(&self, line: usize) -> usize { + self.wrap_map + .visible_wrap_row_count_for_line(line, &self.fold_map) + } + + /// Get the wrap row count (before folding) + #[inline] + pub fn wrap_row_count(&self) -> usize { + self.wrap_map.wrap_row_count() + } + + /// Get the buffer line count (logical lines) + #[inline] + pub fn buffer_line_count(&self) -> usize { + self.wrap_map.buffer_line_count() + } +} diff --git a/crates/ui/src/input/display_map/fold_map.rs b/crates/ui/src/input/display_map/fold_map.rs new file mode 100644 index 0000000..bcec679 --- /dev/null +++ b/crates/ui/src/input/display_map/fold_map.rs @@ -0,0 +1,343 @@ +/// FoldMap: Folding projection layer (Wrap rows → Display rows). +/// +/// This module manages code folding by: +/// - Filtering out wrap rows that belong to folded regions +/// - Maintaining bidirectional mapping: wrap_row ↔ display_row +/// - Handling fold state changes and rebuilding the projection +use super::folding::FoldRange; +use super::wrap_map::WrapMap; + +/// FoldMap projects wrap rows to display rows by hiding folded regions. +pub struct FoldMap { + /// Mapping: display_row → wrap_row + /// index = display_row, value = actual wrap_row + visible_wrap_rows: Vec, + + /// Reverse mapping: wrap_row → display_row + /// index = wrap_row, value = Some(display_row) if visible, None if folded + wrap_row_to_display_row: Vec>, + + /// Candidate fold ranges (from tree-sitter/LSP) + /// Sorted by start_line, unique start_line + candidates: Vec, + + /// Currently folded ranges + /// Subset of candidates, sorted by start_line + folded: Vec, + + /// Flag indicating if the fold projection needs rebuilding + /// Used for lazy evaluation to avoid expensive rebuilds on every text change + needs_rebuild: bool, + + /// Cached wrap_row_count from last rebuild + /// Used to detect if WrapMap changed and rebuild is needed + cached_wrap_row_count: usize, +} + +impl FoldMap { + pub fn new() -> Self { + Self { + visible_wrap_rows: Vec::new(), + wrap_row_to_display_row: Vec::new(), + candidates: Vec::new(), + folded: Vec::new(), + needs_rebuild: true, + cached_wrap_row_count: 0, + } + } + + /// Update cached wrap_row_count without full rebuild. + /// Used when no folds are active (identity mapping assumed). + pub(super) fn mark_dirty_with_wrap_count(&mut self, wrap_row_count: usize) { + self.needs_rebuild = true; + self.cached_wrap_row_count = wrap_row_count; + } + + /// Get total number of visible display rows + pub fn display_row_count(&self) -> usize { + if self.folded.is_empty() { + return self.cached_wrap_row_count; + } + self.visible_wrap_rows.len() + } + + /// Convert wrap_row to display_row + /// Returns None if the wrap_row is hidden by folding + pub fn wrap_row_to_display_row(&self, wrap_row: usize) -> Option { + if self.folded.is_empty() { + return if wrap_row < self.cached_wrap_row_count { + Some(wrap_row) + } else { + None + }; + } + self.wrap_row_to_display_row + .get(wrap_row) + .copied() + .flatten() + } + + /// Convert display_row to wrap_row + pub fn display_row_to_wrap_row(&self, display_row: usize) -> Option { + if self.folded.is_empty() { + return if display_row < self.cached_wrap_row_count { + Some(display_row) + } else { + None + }; + } + self.visible_wrap_rows.get(display_row).copied() + } + + /// Find the nearest visible display_row for a given wrap_row + pub fn nearest_visible_display_row(&self, wrap_row: usize) -> usize { + if self.folded.is_empty() { + return wrap_row.min(self.cached_wrap_row_count.saturating_sub(1)); + } + + if let Some(dr) = self.wrap_row_to_display_row(wrap_row) { + return dr; + } + + match self.visible_wrap_rows.binary_search(&wrap_row) { + Ok(idx) => idx, + Err(insert_pos) => insert_pos.saturating_sub(1), + } + } + + /// Set fold candidates (from tree-sitter/LSP), full replacement. + pub fn set_candidates(&mut self, mut candidates: Vec) { + // Sort and deduplicate by start_line + candidates.sort_by_key(|r| r.start_line); + candidates.dedup_by_key(|r| r.start_line); + self.candidates = candidates; + + // Remove any folded ranges that are no longer in candidates + self.folded.retain(|fold| { + self.candidates + .iter() + .any(|c| c.start_line == fold.start_line) + }); + } + + /// Merge new candidates extracted from an edited region into existing candidates. + /// + /// Replaces candidates within [edit_start_line, edit_end_line] with `new_candidates`, + /// keeping candidates outside the edit range intact. + pub fn merge_candidates_for_edit( + &mut self, + edit_start_line: usize, + edit_end_line: usize, + new_candidates: Vec, + ) { + // Remove old candidates within the edit range (already done by adjust_folds_for_edit) + // But do it again in case adjust wasn't called or range differs + self.candidates + .retain(|c| c.start_line < edit_start_line || c.start_line > edit_end_line); + + // Add new candidates + self.candidates.extend(new_candidates); + self.candidates.sort_by_key(|r| r.start_line); + self.candidates.dedup_by_key(|r| r.start_line); + } + + /// Set a fold at the given start_line (must be in candidates) + pub fn set_folded(&mut self, start_line: usize, folded: bool) { + if folded { + // Find the candidate range for this start_line + if let Some(candidate) = self.candidates.iter().find(|c| c.start_line == start_line) { + // Add to folded if not already present + if !self.folded.iter().any(|f| f.start_line == start_line) { + self.folded.push(*candidate); + self.folded.sort_by_key(|r| r.start_line); + self.needs_rebuild = true; + } + } + } else { + // Remove from folded + self.folded.retain(|f| f.start_line != start_line); + self.needs_rebuild = true; + } + } + + /// Toggle fold at the given start_line + pub fn toggle_fold(&mut self, start_line: usize) { + let is_folded = self.is_folded_at(start_line); + self.set_folded(start_line, !is_folded); + } + + /// Check if a line is currently folded + pub fn is_folded_at(&self, start_line: usize) -> bool { + self.folded.iter().any(|f| f.start_line == start_line) + } + + /// Check if a line is a fold candidate + pub fn is_fold_candidate(&self, start_line: usize) -> bool { + self.candidates.iter().any(|c| c.start_line == start_line) + } + + /// Get all fold candidates + #[inline] + pub fn fold_candidates(&self) -> &[FoldRange] { + &self.candidates + } + + /// Get all currently folded ranges + #[inline] + pub fn folded_ranges(&self) -> &[FoldRange] { + &self.folded + } + + /// Clear all folds + #[inline] + pub fn clear_folds(&mut self) { + self.folded.clear(); + } + + /// Adjust folds and candidates after a text edit. + /// + /// - Folds/candidates overlapping the edited line range are removed + /// - Folds/candidates after the edit are shifted by line_delta + /// + /// This avoids expensive full tree traversal on every keystroke. + pub fn adjust_folds_for_edit( + &mut self, + edit_start_line: usize, + edit_end_line: usize, + line_delta: isize, + ) { + // Adjust folded ranges + if !self.folded.is_empty() { + self.folded.retain(|fold| { + !(fold.start_line <= edit_end_line && fold.end_line >= edit_start_line) + }); + + if line_delta != 0 { + for fold in &mut self.folded { + if fold.start_line > edit_end_line { + fold.start_line = (fold.start_line as isize + line_delta).max(0) as usize; + fold.end_line = (fold.end_line as isize + line_delta).max(0) as usize; + } + } + } + } + + // Adjust candidates the same way + if !self.candidates.is_empty() { + self.candidates + .retain(|c| !(c.start_line <= edit_end_line && c.end_line >= edit_start_line)); + + if line_delta != 0 { + for c in &mut self.candidates { + if c.start_line > edit_end_line { + c.start_line = (c.start_line as isize + line_delta).max(0) as usize; + c.end_line = (c.end_line as isize + line_delta).max(0) as usize; + } + } + } + } + + self.needs_rebuild = true; + } + + /// Rebuild the fold mapping after wrap_map or fold state changes + /// + /// This is the core algorithm that projects wrap rows to display rows. + pub fn rebuild(&mut self, wrap_map: &WrapMap) { + let wrap_row_count = wrap_map.wrap_row_count(); + + // Performance optimization: skip rebuild if nothing changed + if !self.needs_rebuild && wrap_row_count == self.cached_wrap_row_count { + return; + } + + self.cached_wrap_row_count = wrap_row_count; + + self.visible_wrap_rows.clear(); + self.wrap_row_to_display_row = vec![None; wrap_row_count]; + + if self.folded.is_empty() { + // Fast path: no folds, all wrap rows are visible + self.visible_wrap_rows = (0..wrap_row_count).collect(); + for (display_row, &wrap_row) in self.visible_wrap_rows.iter().enumerate() { + self.wrap_row_to_display_row[wrap_row] = Some(display_row); + } + self.needs_rebuild = false; + return; + } + + // Build set of hidden wrap_row ranges from folded buffer lines + let mut hidden_ranges = Vec::new(); + for fold in &self.folded { + // Hide wrap rows from (start_line + 1) to (end_line - 1) (inclusive) + // Both the first line and last line of the fold remain visible + let hide_start_line = fold.start_line + 1; + let hide_end_line = fold.end_line.saturating_sub(1); + + if hide_start_line > hide_end_line { + continue; // No middle lines to hide (0 or 1 lines between start and end) + } + + // Get wrap_row ranges for the hidden buffer lines + let start_wrap_row = wrap_map.buffer_line_to_first_wrap_row(hide_start_line); + let end_wrap_row = if hide_end_line + 1 < wrap_map.buffer_line_count() { + wrap_map.buffer_line_to_first_wrap_row(hide_end_line + 1) + } else { + wrap_row_count + }; + + if start_wrap_row < end_wrap_row { + hidden_ranges.push(start_wrap_row..end_wrap_row); + } + } + + // Merge overlapping hidden ranges + hidden_ranges.sort_by_key(|r| r.start); + let mut merged_hidden = Vec::new(); + for range in hidden_ranges { + if let Some(last) = merged_hidden.last_mut() { + if range.start <= *last { + // Overlapping or adjacent, merge + *last = (*last).max(range.end); + } else { + merged_hidden.push(range.start); + merged_hidden.push(range.end); + } + } else { + merged_hidden.push(range.start); + merged_hidden.push(range.end); + } + } + + // Scan all wrap rows and filter out hidden ones + let mut display_row = 0; + let mut hidden_iter = merged_hidden.chunks_exact(2); + let mut current_hidden = hidden_iter.next(); + + for wrap_row in 0..wrap_row_count { + // Check if wrap_row is in current hidden range + let is_hidden = if let Some(&[start, end]) = current_hidden { + if wrap_row >= end { + current_hidden = hidden_iter.next(); + if let Some(&[new_start, new_end]) = current_hidden { + wrap_row >= new_start && wrap_row < new_end + } else { + false + } + } else { + wrap_row >= start && wrap_row < end + } + } else { + false + }; + + if !is_hidden { + self.visible_wrap_rows.push(wrap_row); + self.wrap_row_to_display_row[wrap_row] = Some(display_row); + display_row += 1; + } + } + + self.needs_rebuild = false; + } +} diff --git a/crates/ui/src/input/display_map/folding.rs b/crates/ui/src/input/display_map/folding.rs new file mode 100644 index 0000000..3c711fc --- /dev/null +++ b/crates/ui/src/input/display_map/folding.rs @@ -0,0 +1,96 @@ +use std::ops::Range; + +#[cfg(not(target_family = "wasm"))] +use tree_sitter::Node; +#[cfg(not(target_family = "wasm"))] +pub use tree_sitter::Tree; + +#[cfg(target_family = "wasm")] +/// Stub type for tree-sitter Tree on WASM (tree-sitter not available). +pub struct Tree; + +#[cfg(not(target_family = "wasm"))] +/// Minimum line span for a node to be considered foldable. +const MIN_FOLD_LINES: usize = 2; + +/// A fold range representing a foldable code region. +/// +/// The fold range spans from start_line to end_line (inclusive). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FoldRange { + /// Start line (inclusive) + pub start_line: usize, + /// End line (inclusive) + pub end_line: usize, +} + +impl FoldRange { + pub fn new(start_line: usize, end_line: usize) -> Self { + assert!( + start_line <= end_line, + "fold start_line must be <= end_line" + ); + Self { + start_line, + end_line, + } + } +} + +#[cfg(not(target_family = "wasm"))] +/// Check if a named node qualifies as a fold candidate. +/// +/// Uses a structural heuristic: any **named** node spanning ≥ MIN_FOLD_LINES +/// is foldable. tree-sitter already parses code into semantic units (functions, +/// classes, blocks, etc.), so named nodes naturally correspond to meaningful +/// foldable regions across all languages without a per-language node-type list. +fn is_foldable_node(node: &Node) -> bool { + let start = node.start_position().row; + let end = node.end_position().row; + end.saturating_sub(start) >= MIN_FOLD_LINES +} + +#[cfg(not(target_family = "wasm"))] +/// Extract fold ranges only within a byte range (for incremental updates after edits). +/// +/// Skips subtrees entirely outside the range, making it O(nodes in range) +/// instead of O(all nodes in tree). +pub fn extract_fold_ranges_in_range(tree: &Tree, byte_range: Range) -> Vec { + let mut ranges = Vec::new(); + let root = tree.root_node(); + let mut cursor = root.walk(); + // Skip the root, it's not foldable. Use named_children to skip literal tokens. + for child in root.named_children(&mut cursor) { + collect_foldable_nodes_in_range(child, &byte_range, &mut ranges); + } + + ranges.sort_by_key(|r| r.start_line); + ranges.dedup_by_key(|r| r.start_line); + ranges +} + +#[cfg(not(target_family = "wasm"))] +/// Recursively collect foldable nodes, skipping subtrees outside byte_range. +fn collect_foldable_nodes_in_range( + node: Node, + byte_range: &Range, + ranges: &mut Vec, +) { + if node.end_byte() <= byte_range.start || node.start_byte() >= byte_range.end { + return; + } + + if !is_foldable_node(&node) { + return; + } + + ranges.push(FoldRange { + start_line: node.start_position().row, + end_line: node.end_position().row, + }); + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + collect_foldable_nodes_in_range(child, byte_range, ranges); + } +} diff --git a/crates/ui/src/input/display_map/mod.rs b/crates/ui/src/input/display_map/mod.rs new file mode 100644 index 0000000..41fcfc6 --- /dev/null +++ b/crates/ui/src/input/display_map/mod.rs @@ -0,0 +1,69 @@ +/// Display mapping system for Editor/Input. +/// +/// This module implements a layered display mapping architecture: +/// - **WrapMap**: Handles soft-wrapping (buffer → wrap rows) +/// - **FoldMap**: Handles folding (wrap rows → display rows) +/// - **DisplayMap**: Public facade for Editor/Input +/// +/// The goal is to provide a clean, unified API where Editor only needs to know +/// about `BufferPoint ↔ DisplayPoint` mapping, without worrying about internal wrap/fold complexity. +mod display_map; +mod fold_map; +#[cfg(not(target_family = "wasm"))] +mod folding; +#[cfg(target_family = "wasm")] +pub mod folding; +mod text_wrapper; +mod wrap_map; + +// Re-export public API +// Re-export FoldRange and extract_fold_ranges +pub use folding::FoldRange; + +pub use self::display_map::DisplayMap; +pub(crate) use self::text_wrapper::LineLayout; + +/// Position in the buffer (logical text). +/// +/// - `line`: 0-based logical line number (split by `\n`) +/// - `col`: 0-based column offset (byte offset) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BufferPoint { + pub line: usize, + pub col: usize, +} + +impl BufferPoint { + pub fn new(line: usize, col: usize) -> Self { + Self { line, col } + } +} + +/// Position after soft-wrapping but before folding (internal). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(super) struct WrapPoint { + pub row: usize, + pub col: usize, +} + +impl WrapPoint { + pub fn new(row: usize, col: usize) -> Self { + Self { row, col } + } +} + +/// Final display position (after soft-wrapping and folding). +/// +/// - `row`: 0-based display row (final visible row) +/// - `col`: 0-based display column +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DisplayPoint { + pub row: usize, + pub col: usize, +} + +impl DisplayPoint { + pub fn new(row: usize, col: usize) -> Self { + Self { row, col } + } +} diff --git a/crates/ui/src/input/display_map/text_wrapper.rs b/crates/ui/src/input/display_map/text_wrapper.rs new file mode 100644 index 0000000..99acd14 --- /dev/null +++ b/crates/ui/src/input/display_map/text_wrapper.rs @@ -0,0 +1,930 @@ +use std::ops::Range; +use gpui::Half; + +use gpui::{ + App, Font, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px, + size, +}; +use ropey::Rope; +use smallvec::SmallVec; + +use crate::input::{LastLayout, Point as TreeSitterPoint, RopeExt, WhitespaceIndicators}; + +/// A line with soft wrapped lines info. +#[derive(Debug, Clone)] +pub(crate) struct LineItem { + /// The original line text, without end `\n`. + line: Rope, + /// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line). + /// + /// Not contains the line end `\n`. + pub(crate) wrapped_lines: Vec>, +} + +impl LineItem { + /// Get the bytes length of this line. + #[inline] + pub(crate) fn len(&self) -> usize { + self.line.len() + } + + /// Get number of soft wrapped lines of this line (include the first line). + #[inline] + pub(crate) fn lines_len(&self) -> usize { + self.wrapped_lines.len() + } +} + +#[derive(Debug, Default)] +pub(crate) struct LongestRow { + /// The 0-based row index. + pub row: usize, + /// The bytes length of the longest line. + pub len: usize, +} + +/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor. +/// +/// After use lines to calculate the scroll size of the Editor. +pub(crate) struct TextWrapper { + text: Rope, + /// Total wrapped lines (Inlucde the first line), value is start and end index of the line. + soft_lines: usize, + font: Font, + font_size: Pixels, + /// If is none, it means the text is not wrapped + wrap_width: Option, + /// The longest (row, bytes len) in characters, used to calculate the horizontal scroll width. + pub(crate) longest_row: LongestRow, + /// The lines by split \n + pub(crate) lines: Vec, + + _initialized: bool, +} + +#[allow(unused)] +impl TextWrapper { + pub(crate) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + text: Rope::new(), + font, + font_size, + wrap_width, + soft_lines: 0, + longest_row: LongestRow::default(), + lines: Vec::new(), + _initialized: false, + } + } + + #[inline] + pub(crate) fn set_default_text(&mut self, text: &Rope) { + self.text = text.clone(); + } + + /// Get reference to the rope text. + #[inline] + pub(crate) fn text(&self) -> &Rope { + &self.text + } + + /// Get the total number of lines including wrapped lines. + #[inline] + pub(crate) fn len(&self) -> usize { + self.soft_lines + } + + /// Get the line item by row index. + #[inline] + pub(crate) fn line(&self, row: usize) -> Option<&LineItem> { + self.lines.iter().skip(row).next() + } + + pub(crate) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { + if wrap_width == self.wrap_width { + return; + } + + self.wrap_width = wrap_width; + self.update_all(&self.text.clone(), cx); + } + + pub(crate) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + if self.font.eq(&font) && self.font_size == font_size { + return; + } + + self.font = font; + self.font_size = font_size; + self.update_all(&self.text.clone(), cx); + } + + pub(crate) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) -> bool { + if self._initialized { + return false; + } + self._initialized = true; + self.update_all(text, cx); + true + } + + /// Update the text wrapper and recalculate the wrapped lines. + /// + /// If the `text` is the same as the current text, do nothing. + /// + /// - `changed_text`: The text [`Rope`] that has changed. + /// - `range`: The `selected_range` before change. + /// - `new_text`: The inserted text. + /// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same. + /// - `cx`: The application context. + pub(crate) fn update( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + cx: &mut App, + ) { + let mut line_wrapper = cx + .text_system() + .line_wrapper(self.font.clone(), self.font_size); + self._update( + changed_text, + range, + new_text, + &mut |line_str, wrap_width| { + line_wrapper + .wrap_line(&[LineFragment::text(line_str)], wrap_width) + .collect() + }, + ); + } + + fn _update( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + wrap_line: &mut F, + ) where + F: FnMut(&str, Pixels) -> Vec, + { + // Remove the old changed lines. + let start_row = self.text.offset_to_point(range.start).row; + let start_row = start_row.min(self.lines.len().saturating_sub(1)); + let end_row = self.text.offset_to_point(range.end).row; + let end_row = end_row.min(self.lines.len().saturating_sub(1)); + let rows_range = start_row..=end_row; + + if rows_range.contains(&self.longest_row.row) { + self.longest_row = LongestRow::default(); + } + + let mut longest_row_ix = self.longest_row.row; + let mut longest_row_len = self.longest_row.len; + + // To add the new lines. + let new_start_row = changed_text.offset_to_point(range.start).row; + let new_start_offset = changed_text.line_start_offset(new_start_row); + let new_end_row = changed_text + .offset_to_point(range.start + new_text.len()) + .row; + let new_end_offset = changed_text.line_end_offset(new_end_row); + let new_range = new_start_offset..new_end_offset; + + let mut new_lines = vec![]; + let wrap_width = self.wrap_width; + + // line not contains `\n`. + for (ix, line) in Rope::from(changed_text.slice(new_range)) + .iter_lines() + .enumerate() + { + let line_str = line.to_string(); + let mut wrapped_lines = vec![]; + let mut prev_boundary_ix = 0; + + if line_str.len() > longest_row_len { + longest_row_ix = new_start_row + ix; + longest_row_len = line_str.len(); + } + + // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap + if let Some(wrap_width) = wrap_width { + // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. + for boundary in wrap_line(&line_str, wrap_width) { + wrapped_lines.push(prev_boundary_ix..boundary.ix); + prev_boundary_ix = boundary.ix; + } + } + + // Reset of the line + if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { + wrapped_lines.push(prev_boundary_ix..line.len()); + } + + new_lines.push(LineItem { + line: Rope::from(line), + wrapped_lines, + }); + } + + if self.lines.len() == 0 { + self.lines = new_lines; + } else { + self.lines.splice(rows_range, new_lines); + } + + self.text = changed_text.clone(); + self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum(); + self.longest_row = LongestRow { + row: longest_row_ix, + len: longest_row_len, + } + } + + /// Update the text wrapper and recalculate the wrapped lines. + /// + /// If the `text` is the same as the current text, do nothing. + fn update_all(&mut self, text: &Rope, cx: &mut App) { + self.update(text, &(0..text.len()), &text, cx); + } + + /// Return display point (with soft wrap) from the given byte offset in the text. + /// + /// Panics if the `offset` is out of bounds. + pub(crate) fn offset_to_display_point(&self, offset: usize) -> WrapDisplayPoint { + let row = self.text.offset_to_point(offset).row; + let start = self.text.line_start_offset(row); + let line = &self.lines[row]; + + let mut wrapped_row = self + .lines + .iter() + .take(row) + .map(|l| l.lines_len()) + .sum::(); + + let local_offset = offset.saturating_sub(start); + for (ix, range) in line.wrapped_lines.iter().enumerate() { + if range.contains(&local_offset) { + return WrapDisplayPoint::new( + wrapped_row + ix, + ix, + local_offset.saturating_sub(range.start), + ); + } + } + + // Otherwise return the eof of the line. + let last_range = line.wrapped_lines.last().unwrap_or(&(0..0)); + let ix = line.lines_len().saturating_sub(1); + return WrapDisplayPoint::new(wrapped_row + ix, ix, last_range.len()); + } + + /// Return byte offset in the text from the given display point (with soft wrap). + /// + /// Panics if the `point.row` is out of bounds. + pub(crate) fn display_point_to_offset(&self, point: WrapDisplayPoint) -> usize { + let mut wrapped_row = 0; + for (row, line) in self.lines.iter().enumerate() { + if wrapped_row + line.lines_len() > point.row { + let line_start = self.text.line_start_offset(row); + let local_row = point.row.saturating_sub(wrapped_row); + if let Some(range) = line.wrapped_lines.get(local_row) { + return line_start + (range.start + point.column).min(range.end); + } else { + // If not found, return the end of the line. + return line_start + line.len(); + } + } + + wrapped_row += line.lines_len(); + } + + return self.text.len(); + } + + pub(crate) fn display_point_to_point(&self, point: WrapDisplayPoint) -> TreeSitterPoint { + let offset = self.display_point_to_offset(point); + self.text.offset_to_point(offset) + } + + pub(crate) fn point_to_display_point(&self, point: TreeSitterPoint) -> WrapDisplayPoint { + let offset = self.text.point_to_offset(point); + self.offset_to_display_point(offset) + } +} + +/// A display point within the soft-wrapped text. +/// +/// This represents a position in the text after soft-wrapping, +/// with an additional `local_row` field tracking the wrap line +/// within the original buffer line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WrapDisplayPoint { + /// The 0-based soft wrapped row index in the text. + pub row: usize, + /// The 0-based row index in local line (include first line). + /// + /// This value only valid when return from [`TextWrapper::offset_to_display_point`], otherwise it will be ignored. + pub local_row: usize, + /// The 0-based column byte index in the display line (with soft wrap). + pub column: usize, +} + +impl WrapDisplayPoint { + pub fn new(row: usize, local_row: usize, column: usize) -> Self { + Self { + row, + local_row, + column, + } + } +} + +/// The layout info of a line with soft wrapped lines. +pub(crate) struct LineLayout { + /// Total bytes length of this line. + len: usize, + /// The soft wrapped lines of this line (Include the first line). + pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>, + pub(crate) longest_width: Pixels, + pub(crate) whitespace_indicators: Option, + /// Whitespace indicators: (line_index, x_position, is_tab) + pub(crate) whitespace_chars: Vec<(usize, Pixels, bool)>, +} + +impl LineLayout { + pub(crate) fn new() -> Self { + Self { + len: 0, + longest_width: px(0.), + wrapped_lines: SmallVec::new(), + whitespace_chars: Vec::new(), + whitespace_indicators: None, + } + } + + pub(crate) fn lines(mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) -> Self { + self.set_wrapped_lines(wrapped_lines); + self + } + + pub(crate) fn set_wrapped_lines(&mut self, wrapped_lines: SmallVec<[ShapedLine; 1]>) { + self.len = wrapped_lines.iter().map(|l| l.len).sum(); + let width = wrapped_lines + .iter() + .map(|l| l.width) + .max() + .unwrap_or_default(); + self.longest_width = width; + self.wrapped_lines = wrapped_lines; + } + + pub(crate) fn with_whitespaces(mut self, indicators: Option) -> Self { + self.whitespace_indicators = indicators; + let Some(indicators) = self.whitespace_indicators.as_ref() else { + return self; + }; + + let space_indicator_offset = indicators.space.width.half(); + + for (line_index, wrapped_line) in self.wrapped_lines.iter().enumerate() { + for (relative_offset, c) in wrapped_line.text.char_indices() { + if matches!(c, ' ' | '\t') { + let is_tab = c == '\t'; + let start_x = wrapped_line.x_for_index(relative_offset); + let end_x = wrapped_line.x_for_index(relative_offset + c.len_utf8()); + // Center the indicator in the actual character's space + let x_position = if c == ' ' { + (start_x + end_x).half() - space_indicator_offset + } else { + start_x + }; + + self.whitespace_chars.push((line_index, x_position, is_tab)); + } + } + } + self + } + + #[inline] + pub(crate) fn len(&self) -> usize { + self.len + } + + /// Get the position (x, y) for the given index in this line layout. + /// + /// - The `offset` is a local byte index in this line layout. + /// - When `line_end_affinity` is true, an offset at a soft wrap boundary is placed at + /// the end of the current visual line rather than the start of the next one. + /// - The return value is relative to the top-left corner of this line layout, start from (0, 0) + pub(crate) fn position_for_index( + &self, + offset: usize, + last_layout: &LastLayout, + line_end_affinity: bool, + ) -> Option> { + let mut acc_len = 0; + let mut offset_y = px(0.); + + let x_offset = last_layout.alignment_offset(self.longest_width); + + for (i, line) in self.wrapped_lines.iter().enumerate() { + let is_last = i + 1 == self.wrapped_lines.len(); + + let matches = if line.len == 0 { + // Empty visual lines still own their boundary offset. + offset == acc_len + } else if is_last || line_end_affinity { + // Inclusive: cursor can sit at end of this visual line. + offset >= acc_len && offset <= acc_len + line.len + } else { + // Exclusive: boundary offset belongs to the next visual line. + offset >= acc_len && offset < acc_len + line.len + }; + + if matches { + let x = line.x_for_index(offset.saturating_sub(acc_len)) + x_offset; + return Some(point(x, offset_y)); + } + + // Always advance by actual line length. The last line gets +1 so the + // cursor can be placed after the final character. + acc_len += if is_last { line.len + 1 } else { line.len }; + offset_y += last_layout.line_height; + } + + None + } + + /// Get the closest index for the given x in this line layout. + pub(crate) fn closest_index_for_x(&self, x: Pixels, last_layout: &LastLayout) -> usize { + let mut acc_len = 0; + let x_offset = last_layout.alignment_offset(self.longest_width); + let x = x - x_offset; + + for (i, line) in self.wrapped_lines.iter().enumerate() { + let is_last = i + 1 == self.wrapped_lines.len(); + if x <= line.width { + let mut ix = line.closest_index_for_x(x); + if !is_last && ix == line.text.len() { + // For soft wrap line, we can't put the cursor at the end of the line. + let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); + ix = ix.saturating_sub(c_len); + } + + return acc_len + ix; + } + acc_len += line.text.len(); + } + + acc_len + } + + /// Get the index for the given position (x, y) in this line layout. + /// + /// The `pos` is relative to the top-left corner of this line layout, start from (0, 0) + /// The return value is a local byte index in this line layout, start from 0. + pub(crate) fn closest_index_for_position( + &self, + pos: Point, + last_layout: &LastLayout, + ) -> Option { + let mut offset = 0; + let mut line_top = px(0.); + let x_offset = last_layout.alignment_offset(self.longest_width); + for (i, line) in self.wrapped_lines.iter().enumerate() { + let is_last = i + 1 == self.wrapped_lines.len(); + let line_bottom = line_top + last_layout.line_height; + if pos.y >= line_top && pos.y < line_bottom { + let mut ix = line.closest_index_for_x(pos.x - x_offset); + if !is_last && ix == line.text.len() { + // For soft wrap line, we can't put the cursor at the end of the line. + let c_len = line.text.chars().last().map(|c| c.len_utf8()).unwrap_or(0); + ix = ix.saturating_sub(c_len); + } + return Some(offset + ix); + } + + offset += line.text.len(); + line_top = line_bottom; + } + + None + } + + pub(crate) fn index_for_position( + &self, + pos: Point, + last_layout: &LastLayout, + ) -> Option { + let mut offset = 0; + let mut line_top = px(0.); + let x_offset = last_layout.alignment_offset(self.longest_width); + for line in self.wrapped_lines.iter() { + let line_bottom = line_top + last_layout.line_height; + if pos.y >= line_top && pos.y < line_bottom { + let ix = line.index_for_x(pos.x - x_offset)?; + return Some(offset + ix); + } + + offset += line.text.len(); + line_top = line_bottom; + } + + None + } + + pub(crate) fn size(&self, line_height: Pixels) -> Size { + size(self.longest_width, self.wrapped_lines.len() * line_height) + } + + pub(crate) fn paint( + &self, + pos: Point, + line_height: Pixels, + text_align: TextAlign, + align_width: Option, + window: &mut Window, + cx: &mut App, + ) { + for (ix, line) in self.wrapped_lines.iter().enumerate() { + _ = line.paint( + pos + point(px(0.), ix * line_height), + line_height, + text_align, + align_width, + window, + cx, + ); + } + + // Paint whitespace indicators + if let Some(indicators) = self.whitespace_indicators.as_ref() { + for (line_index, x_position, is_tab) in &self.whitespace_chars { + let invisible = if *is_tab { + indicators.tab.clone() + } else { + indicators.space.clone() + }; + + let origin = point( + pos.x + *x_position, + pos.y + *line_index as f32 * line_height, + ); + + _ = invisible.paint(origin, line_height, text_align, align_width, window, cx); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + + use gpui::{Boundary, FontFeatures, FontStyle, FontWeight, px}; + + #[test] + fn test_update() { + let font = gpui::Font { + family: "Arial".into(), + weight: FontWeight::default(), + style: FontStyle::Normal, + features: FontFeatures::default(), + fallbacks: None, + }; + + let mut wrapper = TextWrapper::new(font, px(14.), None); + let mut text = Rope::from( + "Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。", + ); + + fn fake_wrap_line(_line: &str, _wrap_width: Pixels) -> Vec { + vec![] + } + + #[track_caller] + fn assert_wrapper_lines(text: &Rope, wrapper: &TextWrapper, expected_lines: &[&[&str]]) { + let mut actual_lines = vec![]; + let mut offset = 0; + for line in wrapper.lines.iter() { + actual_lines.push( + line.wrapped_lines + .iter() + .map(|range| text.slice(offset + range.start..offset + range.end)) + .collect::>(), + ); + // +1 \n + offset += line.len() + 1; + } + assert_eq!(actual_lines, expected_lines); + } + + wrapper._update(&text, &(0..text.len()), &text, &mut fake_wrap_line); + assert_eq!(wrapper.lines.len(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["Hello, 世界!\r"], + &["This is second line."], + &["This is third line."], + &["这里是第 4 行。"], + ], + ); + + // Add a new text to end + let range = text.len()..text.len(); + let new_text = "New text"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines.len(), 4); + assert_eq!(wrapper.lines.len(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["Hello, 世界!\r"], + &["This is second line."], + &["This is third line."], + &["这里是第 4 行。New text"], + ], + ); + + // Replace first line `Hello` to `AAA` + let range = 0..5; + let new_text = "AAA"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "AAA, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines.len(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["AAA, 世界!\r"], + &["This is second line."], + &["This is third line."], + &["这里是第 4 行。New text"], + ], + ); + + // Remove the second line + let start_offset = text.line_start_offset(1); + let end_offset = text.line_end_offset(1); + let range = start_offset..end_offset + 1; + text.replace(range.clone(), ""); + wrapper._update(&text, &range, &Rope::from(""), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "AAA, 世界!\r\nThis is third line.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines.len(), 3); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["AAA, 世界!\r"], + &["This is third line."], + &["这里是第 4 行。New text"], + ], + ); + + // Replace the first 2 lines to "This is a new line." + let range = text.line_start_offset(0)..text.line_end_offset(1) + 1; + let new_text = "This is a new line.\nThis is new line 2.\n"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a new line.\nThis is new line 2.\n这里是第 4 行。New text" + ); + assert_eq!(wrapper.lines.len(), 3); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["This is a new line."], + &["This is new line 2."], + &["这里是第 4 行。New text"], + ], + ); + + // Add a new line at the end + let range = text.len()..text.len(); + let new_text = "\nThis is a new line at the end."; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a new line.\nThis is new line 2.\n这里是第 4 行。New text\nThis is a new line at the end." + ); + assert_eq!(wrapper.lines.len(), 4); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["This is a new line."], + &["This is new line 2."], + &["这里是第 4 行。New text"], + &["This is a new line at the end."], + ], + ); + + // Add a new line at the beginning + let range = 0..0; + let new_text = "This is a new line at the beginning.\n"; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a new line at the beginning.\nThis is a new line.\nThis is new line 2.\n这里是第 4 行。New text\nThis is a new line at the end." + ); + assert_eq!(wrapper.lines.len(), 5); + assert_wrapper_lines( + &text, + &wrapper, + &[ + &["This is a new line at the beginning."], + &["This is a new line."], + &["This is new line 2."], + &["这里是第 4 行。New text"], + &["This is a new line at the end."], + ], + ); + + // Remove all to at least one line in `lines`. + let range = 0..text.len(); + let new_text = ""; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &Rope::from(new_text), &mut fake_wrap_line); + assert_eq!(text.to_string(), ""); + assert_eq!(wrapper.lines.len(), 1); + assert_eq!(wrapper.lines[0].wrapped_lines, vec![0..0]); + + // Test update_all + let range = 0..text.len(); + let new_text = "This is a full text.\nThis is a second line."; + text.replace(range.clone(), new_text); + wrapper._update(&text, &range, &text, &mut fake_wrap_line); + assert_eq!( + text.to_string(), + "This is a full text.\nThis is a second line." + ); + assert_eq!(wrapper.lines.len(), 2); + } + + #[test] + fn test_line_layout() { + let mut line_layout = LineLayout::new(); + + let line1 = ShapedLine::default().with_len(100); + let line2 = ShapedLine::default().with_len(50); + let wrapped_lines = smallvec::smallvec![line1, line2]; + line_layout.set_wrapped_lines(wrapped_lines); + assert_eq!(line_layout.len(), 150); + assert_eq!(line_layout.wrapped_lines.len(), 2); + } + + #[test] + fn test_position_for_index_prefers_first_leading_empty_visual_line() { + let mut line_layout = LineLayout::new(); + line_layout.set_wrapped_lines(smallvec::smallvec![ + ShapedLine::default(), + ShapedLine::default(), + ShapedLine::default().with_len(3), + ]); + + let last_layout = LastLayout { + visible_range: 0..1, + visible_buffer_lines: vec![0], + visible_line_byte_offsets: vec![0], + visible_top: px(0.), + visible_range_offset: 0..0, + lines: Rc::new(vec![]), + line_height: px(20.), + wrap_width: None, + line_number_width: px(0.), + cursor_bounds: None, + text_align: TextAlign::Left, + content_width: px(0.), + }; + + assert_eq!( + line_layout.position_for_index(0, &last_layout, false), + Some(point(px(0.), px(0.))) + ); + } + + #[test] + fn test_offset_to_display_point() { + let font = gpui::Font { + family: "Arial".into(), + weight: FontWeight::default(), + style: FontStyle::Normal, + features: FontFeatures::default(), + fallbacks: None, + }; + + let mut wrapper = TextWrapper::new(font, px(14.), None); + wrapper.text = Rope::from( + "Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。", + ); + wrapper.lines = vec![ + // range: 0..15 + LineItem { + line: Rope::from("Hello, 世界!\r"), + wrapped_lines: vec![0..15], + }, + // range: 16..36 + LineItem { + line: Rope::from("This is second line."), + wrapped_lines: vec![0..10, 10..20], + }, + // range: 37..56 + LineItem { + line: Rope::from("This is third line."), + wrapped_lines: vec![0..9, 9..15, 15..20], + }, + // range: 57..79 + LineItem { + line: Rope::from("这里是第 4 行。"), + wrapped_lines: vec![0..22], + }, + ]; + + assert_eq!( + wrapper.offset_to_display_point(12), + WrapDisplayPoint::new(0, 0, 12) + ); + assert_eq!( + wrapper.offset_to_display_point(15), + WrapDisplayPoint::new(0, 0, 15) + ); + + assert_eq!( + wrapper.offset_to_display_point(16), + WrapDisplayPoint::new(1, 0, 0) + ); + assert_eq!( + wrapper.offset_to_display_point(21), + WrapDisplayPoint::new(1, 0, 5) + ); + assert_eq!( + wrapper.offset_to_display_point(27), + WrapDisplayPoint::new(2, 1, 1) + ); + assert_eq!( + wrapper.offset_to_display_point(37), + WrapDisplayPoint::new(3, 0, 0) + ); + assert_eq!( + wrapper.offset_to_display_point(54), + WrapDisplayPoint::new(5, 2, 2) + ); + assert_eq!( + wrapper.offset_to_display_point(59), + WrapDisplayPoint::new(6, 0, 2) + ); + + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(6, 0, 2)), + 59 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(5, 2, 2)), + 54 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(3, 0, 0)), + 37 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(2, 1, 1)), + 27 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(1, 0, 5)), + 21 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(1, 0, 0)), + 16 + ); + assert_eq!( + wrapper.display_point_to_offset(WrapDisplayPoint::new(0, 0, 15)), + 15 + ); + } +} diff --git a/crates/ui/src/input/display_map/wrap_map.rs b/crates/ui/src/input/display_map/wrap_map.rs new file mode 100644 index 0000000..a93cf72 --- /dev/null +++ b/crates/ui/src/input/display_map/wrap_map.rs @@ -0,0 +1,222 @@ +/// WrapMap: Soft-wrapping layer (Buffer → Wrap rows). +/// +/// This module wraps the existing TextWrapper and provides: +/// - BufferPoint ↔ WrapPoint mapping +/// - Efficient buffer_line → wrap_row queries via prefix sum cache +/// - Incremental updates when text or layout changes +use std::ops::Range; + +use gpui::{App, Font, Pixels}; +use ropey::Rope; + +use super::fold_map::FoldMap; +use super::text_wrapper::{LineItem, TextWrapper, WrapDisplayPoint}; +use super::{BufferPoint, WrapPoint}; +use crate::input::rope_ext::RopeExt; + +/// WrapMap manages soft-wrapping and provides buffer ↔ wrap coordinate mapping. +pub struct WrapMap { + /// The underlying text wrapper (reuses existing implementation) + wrapper: TextWrapper, + + /// Prefix sum cache: buffer_line_starts[line] = first wrap_row for buffer line `line` + /// This allows O(1) lookup of buffer_line → wrap_row + buffer_line_starts: Vec, + + /// Cached line count from last rebuild + cached_line_count: usize, + + /// Cached total wrap row count from last rebuild. + /// Used together with `cached_line_count` to detect if the cache is stale. + /// When soft wrap changes a line's wrap count without changing buffer line count, + /// this catches the staleness. + cached_wrap_row_count: usize, +} + +impl WrapMap { + pub fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { + Self { + wrapper: TextWrapper::new(font, font_size, wrap_width), + buffer_line_starts: Vec::new(), + cached_line_count: 0, + cached_wrap_row_count: 0, + } + } + + /// Get total number of wrap rows (visual rows after soft-wrapping) + #[inline] + pub fn wrap_row_count(&self) -> usize { + self.wrapper.len() + } + + /// Get total number of buffer lines (logical lines) + #[inline] + pub fn buffer_line_count(&self) -> usize { + self.wrapper.lines.len() + } + + /// Convert buffer position to wrap position + pub(super) fn buffer_pos_to_wrap_pos(&self, pos: BufferPoint) -> WrapPoint { + let BufferPoint { line, col } = pos; + + // Clamp to valid range + let line = line.min(self.buffer_line_count().saturating_sub(1)); + let line_item = self.wrapper.lines.get(line); + + let col = if let Some(line_item) = line_item { + col.min(line_item.len()) + } else { + 0 + }; + + // Calculate offset in rope + let line_start_offset = self.wrapper.text().line_start_offset(line); + let offset = line_start_offset + col; + + // Use TextWrapper's existing conversion + let display_point = self.wrapper.offset_to_display_point(offset); + + WrapPoint::new(display_point.row, display_point.column) + } + + /// Convert wrap position to buffer position + pub(super) fn wrap_pos_to_buffer_pos(&self, pos: WrapPoint) -> BufferPoint { + let WrapPoint { row, col } = pos; + + // Clamp wrap_row to valid range + let row = row.min(self.wrap_row_count().saturating_sub(1)); + + // Use TextWrapper's existing conversion + let display_point = WrapDisplayPoint::new(row, 0, col); + let offset = self.wrapper.display_point_to_offset(display_point); + + // Convert offset to buffer position + let point = self.wrapper.text().offset_to_point(offset); + let line_start = self.wrapper.text().line_start_offset(point.row); + let col = offset.saturating_sub(line_start); + + BufferPoint::new(point.row, col) + } + + /// Get the buffer line for a given wrap row + pub fn wrap_row_to_buffer_line(&self, wrap_row: usize) -> usize { + if wrap_row >= self.wrap_row_count() { + return self.buffer_line_count().saturating_sub(1); + } + + // Binary search in prefix sum cache + match self.buffer_line_starts.binary_search(&wrap_row) { + Ok(line) => line, + Err(insert_pos) => insert_pos.saturating_sub(1), + } + } + + /// Get the first wrap row for a given buffer line + pub fn buffer_line_to_first_wrap_row(&self, line: usize) -> usize { + if line >= self.buffer_line_starts.len() { + return self.wrap_row_count(); + } + self.buffer_line_starts[line] + } + + /// Get the wrap row range for a buffer line: [start, end) + pub fn buffer_line_to_wrap_row_range(&self, line: usize) -> Range { + let start = self.buffer_line_to_first_wrap_row(line); + let end = if line + 1 < self.buffer_line_starts.len() { + self.buffer_line_starts[line + 1] + } else { + self.wrap_row_count() + }; + start..end + } + + /// Update text (incremental or full) + pub fn on_text_changed( + &mut self, + changed_text: &Rope, + range: &Range, + new_text: &Rope, + cx: &mut App, + ) { + self.wrapper.update(changed_text, range, new_text, cx); + self.rebuild_cache(); + } + + /// Update layout parameters (wrap width or font) + pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { + self.wrapper.set_wrap_width(wrap_width, cx); + self.rebuild_cache(); + } + + /// Set font parameters + pub fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { + self.wrapper.set_font(font, font_size, cx); + self.rebuild_cache(); + } + + /// Ensure text is prepared (initializes wrapper if needed) + pub fn ensure_text_prepared(&mut self, text: &Rope, cx: &mut App) -> bool { + let did_initialize = self.wrapper.prepare_if_need(text, cx); + if did_initialize { + self.rebuild_cache(); + } + did_initialize + } + + /// Initialize with text + pub fn set_text(&mut self, text: &Rope, cx: &mut App) { + self.wrapper.set_default_text(text); + self.wrapper.prepare_if_need(text, cx); + self.rebuild_cache(); + } + + /// Rebuild the prefix sum cache: buffer_line_starts + fn rebuild_cache(&mut self) { + let line_count = self.wrapper.lines.len(); + let wrap_row_count = self.wrapper.len(); + + // Skip if nothing changed: both buffer line count and total wrap row count must match. + // Checking wrap_row_count is essential because soft-wrap can change the number of + // wrap rows per line without changing the buffer line count. + if line_count == self.cached_line_count + && wrap_row_count == self.cached_wrap_row_count + && !self.buffer_line_starts.is_empty() + { + return; + } + + self.buffer_line_starts.clear(); + + let mut wrap_row = 0; + for line_item in &self.wrapper.lines { + self.buffer_line_starts.push(wrap_row); + wrap_row += line_item.lines_len(); + } + + self.cached_line_count = line_count; + self.cached_wrap_row_count = wrap_row_count; + } + + /// Get access to the underlying wrapper (for rendering/hit-testing) + pub(crate) fn wrapper(&self) -> &TextWrapper { + &self.wrapper + } + + /// Get access to line items (for rendering) + pub(crate) fn lines(&self) -> &[LineItem] { + &self.wrapper.lines + } + + /// Get the rope text + pub fn text(&self) -> &Rope { + self.wrapper.text() + } + + /// Calculate how many wrap rows of a buffer line are visible (not folded) + pub fn visible_wrap_row_count_for_line(&self, line: usize, fold_map: &FoldMap) -> usize { + let wrap_range = self.buffer_line_to_wrap_row_range(line); + wrap_range + .filter(|&wr| fold_map.wrap_row_to_display_row(wr).is_some()) + .count() + } +} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index b77329a..c53166e 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -2,23 +2,224 @@ use std::ops::Range; use std::rc::Rc; use gpui::{ - App, Bounds, Corners, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, Hitbox, - IntoElement, LayoutId, MouseButton, MouseMoveEvent, Path, Pixels, Point, ShapedLine, - SharedString, Size, Style, TextAlign, TextRun, UnderlineStyle, Window, fill, point, px, - relative, size, + AnyElement, App, Bounds, Corners, Edges, Element, ElementId, ElementInputHandler, Entity, + GlobalElementId, Half, HighlightStyle, Hitbox, HitboxBehavior, Hsla, InteractiveElement, + IntoElement, LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, Path, Pixels, Point, + Position, ShapedLine, SharedString, Size, Style, Styled as _, TextAlign, TextRun, TextStyle, + UnderlineStyle, Window, fill, point, px, relative, size, }; -use rope::Rope; +use ropey::Rope; use smallvec::SmallVec; use theme::ActiveTheme; -use super::blink_cursor::CURSOR_WIDTH; -use super::rope_ext::RopeExt; -use super::state::{InputState, LastLayout}; -use crate::Root; +use super::mode::InputMode; +use super::{InputState, LastLayout, WhitespaceIndicators}; +use crate::button::{Button, ButtonVariants as _}; +use crate::input::RopeExt as _; +use crate::input::blink_cursor::CURSOR_WIDTH; +use crate::input::display_map::LineLayout; +use crate::scroll::Scrollbar; +use crate::{IconName, Root, Selectable, Sizable as _}; const BOTTOM_MARGIN_ROWS: usize = 3; pub(super) const RIGHT_MARGIN: Pixels = px(10.); pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.); +const FOLD_ICON_WIDTH: Pixels = px(14.); +const FOLD_ICON_HITBOX_WIDTH: Pixels = px(18.); +const MAX_HIGHLIGHT_LINE_LENGTH: usize = 10_000; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct EditorScrollbarLayout { + bounds: Bounds, + scroll_size: Size, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct EditorScrollbarSnapshot { + layout: EditorScrollbarLayout, + cursor_scroll_offset: Point, + soft_wrap: bool, +} + +impl EditorScrollbarSnapshot { + fn new( + input_bounds: Bounds, + last_layout: &LastLayout, + scroll_size: Size, + cursor_scroll_offset: Point, + state: &InputState, + ) -> Self { + Self { + layout: EditorScrollbarLayout::new( + input_bounds, + last_layout.line_number_width, + scroll_size, + state.editor_scrollbar_paddings.get(), + ), + cursor_scroll_offset, + soft_wrap: state.soft_wrap, + } + } +} + +impl EditorScrollbarLayout { + fn new( + input_bounds: Bounds, + line_number_width: Pixels, + scroll_size: Size, + paddings: Edges, + ) -> Self { + let left = if line_number_width == px(0.) { + px(0.) + } else { + paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN + }; + + Self { + bounds: Bounds::new( + point( + input_bounds.origin.x + left, + input_bounds.origin.y - paddings.top, + ), + size( + input_bounds.size.width - left + paddings.right, + input_bounds.size.height + paddings.top + paddings.bottom, + ), + ), + scroll_size: size( + scroll_size.width - left + paddings.right + RIGHT_MARGIN, + scroll_size.height, + ), + } + } +} + +pub(super) struct EditorScrollbar { + state: Entity, +} + +impl EditorScrollbar { + pub(super) fn new(state: Entity) -> Self { + Self { state } + } +} + +impl IntoElement for EditorScrollbar { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EditorScrollbar { + type PrepaintState = Option; + type RequestLayoutState = (); + + fn id(&self) -> Option { + Some("editor-scrollbar".into()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.position = Position::Absolute; + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let state = self.state.read(cx); + let snapshot = state.editor_scrollbar_snapshot.get()?; + let scroll_handle = state.scroll_handle.clone(); + + if scroll_handle.offset() != snapshot.cursor_scroll_offset { + scroll_handle.set_offset(snapshot.cursor_scroll_offset); + } + + let mut scrollbar = if !snapshot.soft_wrap { + Scrollbar::new(&scroll_handle) + } else { + Scrollbar::vertical(&scroll_handle) + } + .scroll_size(snapshot.layout.scroll_size) + .into_any_element(); + + scrollbar.prepaint_as_root( + snapshot.layout.bounds.origin, + snapshot.layout.bounds.size.into(), + window, + cx, + ); + Some(scrollbar) + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(scrollbar) = prepaint.as_mut() { + scrollbar.paint(window, cx); + } + } +} + +fn clamp_auto_grow_vertical_scroll_offset( + mode: &InputMode, + scroll_top: Pixels, + scroll_height: Pixels, + input_height: Pixels, +) -> Pixels { + if mode.is_auto_grow() { + scroll_top.clamp((input_height - scroll_height).min(px(0.)), px(0.)) + } else { + scroll_top + } +} + +use super::MASK_CHAR; + +/// Convert a byte offset in the original text to a byte offset in the masked display string. +/// +/// The masked string consists of `MASK_CHAR` repeated once per character in the original text. +/// Since `MASK_CHAR` may be multi-byte in UTF-8, the byte offset in the masked string is +/// `char_index * MASK_CHAR.len_utf8()`. +fn masked_display_offset(text: &Rope, original_offset: usize) -> usize { + text.offset_to_char_index(original_offset) * MASK_CHAR.len_utf8() +} + +/// Layout information for fold icons. +struct FoldIconLayout { + /// Hitbox for the line number area (used for hover detection) + line_number_hitbox: Hitbox, + /// List of (display_row, is_folded, icon_element) pairs for each fold candidate + icons: Vec<(usize, bool, gpui::AnyElement)>, +} pub(super) struct TextElement { pub(crate) state: Entity, @@ -51,6 +252,20 @@ impl TextElement { } } }); + + window.on_mouse_event({ + let state = self.state.clone(); + move |_: &MouseUpEvent, phase, _, cx| { + if !phase.bubble() { + return; + } + + // Stop auto-scroll when mouse up, and also stop selecting. + state.update(cx, |state, _| { + state.selecting = false; + }); + } + }); } /// Returns the: @@ -64,6 +279,7 @@ impl TextElement { &self, last_layout: &LastLayout, bounds: &mut Bounds, + scroll_size: Size, _: &mut Window, cx: &mut App, ) -> (Option>, Point, Option) { @@ -72,22 +288,28 @@ impl TextElement { let line_height = last_layout.line_height; let visible_range = &last_layout.visible_range; let lines = &last_layout.lines; - let text_wrapper = &state.text_wrapper; let line_number_width = last_layout.line_number_width; let mut selected_range = state.selected_range; + if let Some(ime_marked_range) = &state.ime_marked_range { selected_range = (ime_marked_range.end..ime_marked_range.end).into(); } + let is_selected_all = selected_range.len() == state.text.len(); + + let mut cursor = state.cursor(); + if state.masked { + selected_range.start = masked_display_offset(&state.text, selected_range.start); + selected_range.end = masked_display_offset(&state.text, selected_range.end); + cursor = masked_display_offset(&state.text, cursor); + } - let cursor = state.cursor(); let mut current_row = None; let mut scroll_offset = state.scroll_handle.offset(); 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 top_bottom_margin = if state.mode.is_auto_grow() { - #[allow(clippy::if_same_then_else)] line_height } else if visible_range.len() < BOTTOM_MARGIN_ROWS * 8 { line_height @@ -102,8 +324,10 @@ impl TextElement { let mut prev_lines_offset = 0; let mut offset_y = px(0.); - - for (ix, wrap_line) in text_wrapper.lines.iter().enumerate() { + let buffer_lines = state.display_map.lines(); + let visible_buffer_lines = &last_layout.visible_buffer_lines; + let mut vi = 0; // index into visible_buffer_lines / lines + for (ix, wrap_line) in buffer_lines.iter().enumerate() { let row = ix; let line_origin = point(px(0.), offset_y); @@ -112,40 +336,44 @@ impl TextElement { break; } - let in_visible_range = ix >= visible_range.start; - if let Some(line) = in_visible_range - .then(|| lines.get(ix.saturating_sub(visible_range.start))) - .flatten() - { - // If in visible range lines + // Check if this buffer line has a LineLayout in the compact lines vec + let line_layout = if vi < visible_buffer_lines.len() && visible_buffer_lines[vi] == ix { + let l = &lines[vi]; + vi += 1; + Some(l) + } else { + None + }; + + if let Some(line) = line_layout { if cursor_pos.is_none() { let offset = cursor.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, line_height) { + if let Some(pos) = + line.position_for_index(offset, last_layout, state.cursor_line_end_affinity) + { current_row = Some(row); cursor_pos = Some(line_origin + pos); } } if cursor_start.is_none() { let offset = selected_range.start.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, line_height) { + if let Some(pos) = line.position_for_index(offset, last_layout, false) { cursor_start = Some(line_origin + pos); } } if cursor_end.is_none() { let offset = selected_range.end.saturating_sub(prev_lines_offset); - if let Some(pos) = line.position_for_index(offset, line_height) { + if let Some(pos) = line.position_for_index(offset, last_layout, false) { cursor_end = Some(line_origin + pos); } } offset_y += line.size(line_height).height; // +1 for the last `\n` - prev_lines_offset += line.len() + 1; + prev_lines_offset += wrap_line.len() + 1; } else { - // If not in the visible range. - - // Just increase the offset_y and prev_lines_offset. - // This will let the scroll_offset to track the cursor position correctly. + // Not visible (before visible range or hidden/folded). + // Just increase the offset_y and prev_lines_offset for scroll tracking. if prev_lines_offset >= cursor && cursor_pos.is_none() { current_row = Some(row); cursor_pos = Some(line_origin); @@ -157,7 +385,9 @@ impl TextElement { cursor_end = Some(line_origin); } - offset_y += wrap_line.height(line_height); + let visible_wrap_rows = + state.display_map.visible_wrap_row_count_for_buffer_line(ix); + offset_y += line_height * visible_wrap_rows; // +1 for the last `\n` prev_lines_offset += wrap_line.len() + 1; } @@ -167,12 +397,21 @@ impl TextElement { (cursor_pos, cursor_start, cursor_end) { let selection_changed = state.last_selected_range != Some(selected_range); - if selection_changed { + + if selection_changed && !is_selected_all { + // For Right alignment use 0 margin: cursor is clamped to bounds separately, + // so we never scroll the text for cursor-at-edge, avoiding a first-click jump. + let safety_margin = match last_layout.text_align { + TextAlign::Left => RIGHT_MARGIN, + TextAlign::Right => px(0.), + TextAlign::Center => CURSOR_WIDTH, + }; + scroll_offset.x = if scroll_offset.x + cursor_pos.x - > (bounds.size.width - line_number_width - RIGHT_MARGIN) + > (bounds.size.width - line_number_width - safety_margin) { // cursor is out of right - bounds.size.width - line_number_width - RIGHT_MARGIN - cursor_pos.x + bounds.size.width - line_number_width - safety_margin - cursor_pos.x } else if scroll_offset.x + cursor_pos.x < px(0.) { // cursor is out of left scroll_offset.x - cursor_pos.x @@ -193,6 +432,7 @@ impl TextElement { scroll_offset.y }; + // For selection to move scroll if state.selection_reversed { if scroll_offset.x + cursor_start.x < px(0.) { // selection start is out of left @@ -203,6 +443,8 @@ impl TextElement { scroll_offset.y = -cursor_start.y; } } else { + // TODO: Consider to remove this part, + // maybe is not necessary (But selection_reversed is needed). if scroll_offset.x + cursor_end.x <= px(0.) { // selection end is out of left scroll_offset.x = -cursor_end.x; @@ -215,10 +457,23 @@ impl TextElement { } // cursor bounds - let cursor_height = line_height; + let cursor_height = match state.size { + crate::Size::Large => 1., + crate::Size::Small => 0.75, + _ => 0.85, + } * line_height; + + // For Right alignment, clamp cursor within the right edge of bounds so it + // stays visible without having to shift the text via scroll_offset. + let cursor_x = bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x; + let cursor_x = if last_layout.text_align == TextAlign::Right { + cursor_x.min(bounds.right() - CURSOR_WIDTH) + } else { + cursor_x + }; cursor_bounds = Some(Bounds::new( point( - bounds.left() + cursor_pos.x + line_number_width + scroll_offset.x, + cursor_x, bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), ), size(CURSOR_WIDTH, cursor_height), @@ -228,6 +483,12 @@ impl TextElement { if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { scroll_offset = deferred_scroll_offset; } + scroll_offset.y = clamp_auto_grow_vertical_scroll_offset( + &state.mode, + scroll_offset.y, + scroll_size.height, + bounds.size.height, + ); bounds.origin += scroll_offset; @@ -238,7 +499,7 @@ impl TextElement { pub(crate) fn layout_match_range( range: Range, last_layout: &LastLayout, - bounds: &mut Bounds, + bounds: &Bounds, ) -> Option> { if range.is_empty() { return None; @@ -252,34 +513,47 @@ impl TextElement { let line_height = last_layout.line_height; let visible_top = last_layout.visible_top; - let visible_start_offset = last_layout.visible_range_offset.start; let lines = &last_layout.lines; let line_number_width = last_layout.line_number_width; let start_ix = range.start; let end_ix = range.end; - let mut prev_lines_offset = visible_start_offset; + // Start from visible_top (which already accounts for all lines before visible range) let mut offset_y = visible_top; let mut line_corners = vec![]; - for line in lines.iter() { + // Iterate only over visible (non-hidden) buffer lines + for (prev_lines_offset, line) in last_layout + .visible_line_byte_offsets + .iter() + .zip(lines.iter()) + { + let prev_lines_offset = *prev_lines_offset; let line_size = line.size(line_height); let line_wrap_width = line_size.width; let line_origin = point(px(0.), offset_y); - let line_cursor_start = - line.position_for_index(start_ix.saturating_sub(prev_lines_offset), line_height); - let line_cursor_end = - line.position_for_index(end_ix.saturating_sub(prev_lines_offset), line_height); + let line_cursor_start = line.position_for_index( + start_ix.saturating_sub(prev_lines_offset), + last_layout, + false, + ); + let line_cursor_end = line.position_for_index( + end_ix.saturating_sub(prev_lines_offset), + last_layout, + false, + ); if line_cursor_start.is_some() || line_cursor_end.is_some() { let start = line_cursor_start - .unwrap_or_else(|| line.position_for_index(0, line_height).unwrap()); + .unwrap_or_else(|| line.position_for_index(0, last_layout, false).unwrap()); - let end = line_cursor_end - .unwrap_or_else(|| line.position_for_index(line.len(), line_height).unwrap()); + let end = line_cursor_end.unwrap_or_else(|| { + line.position_for_index(line.len(), last_layout, false) + .unwrap() + }); // Split the selection into multiple items let wrapped_lines = @@ -322,8 +596,6 @@ impl TextElement { } offset_y += line_size.height; - // +1 for skip the last `\n` - prev_lines_offset += line.len() + 1; } let mut points = vec![]; @@ -368,13 +640,53 @@ impl TextElement { builder.build().ok() } + fn layout_search_matches( + &self, + _last_layout: &LastLayout, + _bounds: &Bounds, + _cx: &mut App, + ) -> Vec<(Path, bool)> { + vec![] + } + + fn layout_hover_highlight( + &self, + _last_layout: &LastLayout, + _bounds: &Bounds, + _cx: &mut App, + ) -> Option> { + None + } + + fn layout_document_colors( + &self, + document_colors: &[(Range, Hsla)], + last_layout: &LastLayout, + bounds: &Bounds, + _cx: &mut App, + ) -> Vec<(Path, Hsla)> { + let mut paths = vec![]; + for (range, color) in document_colors.iter() { + if let Some(path) = Self::layout_match_range(range.clone(), last_layout, bounds) { + paths.push((path, *color)); + } + } + + paths + } + fn layout_selections( &self, last_layout: &LastLayout, bounds: &mut Bounds, + window: &mut Window, cx: &mut App, ) -> Option> { let state = self.state.read(cx); + if !state.focus_handle.is_focused(window) { + return None; + } + let mut selected_range = state.selected_range; if let Some(ime_marked_range) = &state.ime_marked_range && !ime_marked_range.is_empty() @@ -385,6 +697,11 @@ impl TextElement { return None; } + if state.masked { + selected_range.start = masked_display_offset(&state.text, selected_range.start); + selected_range.end = masked_display_offset(&state.text, selected_range.end); + } + let (start_ix, end_ix) = if selected_range.start < selected_range.end { (selected_range.start, selected_range.end) } else { @@ -402,31 +719,44 @@ impl TextElement { /// Returns /// /// - visible_range: The visible range is based on unwrapped lines (Zero based). + /// - visible_buffer_lines: Indices of non-hidden buffer lines within the visible range. /// - visible_top: The top position of the first visible line in the scroll viewport. fn calculate_visible_range( &self, state: &InputState, line_height: Pixels, input_height: Pixels, - ) -> (Range, Pixels) { + ) -> (Range, Vec, Pixels) { // Add extra rows to avoid showing empty space when scroll to bottom. let extra_rows = 1; let mut visible_top = px(0.); if state.mode.is_single_line() { - return (0..1, visible_top); + return (0..1, vec![0], visible_top); } - let total_lines = state.text_wrapper.len(); - let scroll_top = if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { + let total_lines = state.display_map.wrap_row_count(); + let mut scroll_top = if let Some(deferred_scroll_offset) = state.deferred_scroll_offset { deferred_scroll_offset.y } else { state.scroll_handle.offset().y }; let mut visible_range = 0..total_lines; + scroll_top = clamp_auto_grow_vertical_scroll_offset( + &state.mode, + scroll_top, + line_height * total_lines, + input_height, + ); let mut line_bottom = px(0.); - for (ix, line) in state.text_wrapper.lines.iter().enumerate() { - let wrapped_height = line.height(line_height); + for (ix, _line) in state.display_map.lines().iter().enumerate() { + let visible_wrap_rows = state.display_map.visible_wrap_row_count_for_buffer_line(ix); + + if visible_wrap_rows == 0 { + continue; + } + + let wrapped_height = line_height * visible_wrap_rows; line_bottom += wrapped_height; if line_bottom < -scroll_top { @@ -440,7 +770,441 @@ impl TextElement { } } - (visible_range, visible_top) + // Collect non-hidden buffer lines within the visible range + let mut visible_buffer_lines = Vec::with_capacity(visible_range.len()); + for ix in visible_range.start..visible_range.end { + let visible_wrap_rows = state.display_map.visible_wrap_row_count_for_buffer_line(ix); + if visible_wrap_rows > 0 { + visible_buffer_lines.push(ix); + } + } + + (visible_range, visible_buffer_lines, visible_top) + } + + /// Return (line_number_width, line_number_len) + fn layout_line_numbers( + state: &InputState, + text: &Rope, + font_size: Pixels, + style: &TextStyle, + window: &mut Window, + ) -> (Pixels, usize) { + let total_lines = text.lines_len(); + let line_number_len = match total_lines { + 0..=9999 => 5, + 10000..=99999 => 6, + 100000..=999999 => 7, + _ => 8, + }; + + let mut line_number_width = if state.mode.line_number() { + let empty_line_number = window.text_system().shape_line( + "+".repeat(line_number_len).into(), + font_size, + &[TextRun { + len: line_number_len, + font: style.font(), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + empty_line_number.width + LINE_NUMBER_RIGHT_MARGIN + } else if state.mode.is_code_editor() && state.mode.is_multi_line() { + LINE_NUMBER_RIGHT_MARGIN + } else { + px(0.) + }; + + if state.mode.is_folding() { + // Add extra space for fold icons + line_number_width += FOLD_ICON_HITBOX_WIDTH + } + + (line_number_width, line_number_len) + } + + /// Layout shaped lines for whitespace indicators (space and tab). + /// + /// Returns `WhitespaceIndicators` with shaped lines for space and tab characters. + fn layout_whitespace_indicators( + state: &InputState, + text_size: Pixels, + style: &TextStyle, + window: &mut Window, + cx: &App, + ) -> Option { + if !state.show_whitespaces { + return None; + } + + let invisible_color = cx.theme().text_muted; + let space_font_size = text_size.half(); + let tab_font_size = text_size; + + let space_text = SharedString::new_static("•"); + let space = window.text_system().shape_line( + space_text.clone(), + space_font_size, + &[TextRun { + len: space_text.len(), + font: style.font(), + color: invisible_color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + let tab_text = SharedString::new_static("→"); + let tab = window.text_system().shape_line( + tab_text.clone(), + tab_font_size, + &[TextRun { + len: tab_text.len(), + font: style.font(), + color: invisible_color, + background_color: None, + underline: None, + strikethrough: None, + }], + None, + ); + + Some(WhitespaceIndicators { space, tab }) + } + + /// Compute inline completion ghost lines for rendering. + /// + /// Returns (first_line, ghost_lines) where: + /// - first_line: Shaped text for the first line (goes after cursor on same line) + /// - ghost_lines: Shaped lines for subsequent lines (shift content down) + fn layout_inline_completion( + _state: &InputState, + _visible_range: &Range, + _font_size: Pixels, + _window: &mut Window, + _cx: &App, + ) -> (Option, Vec) { + (None, vec![]) + } + + /// Return (line_number_width, line_number_len) + /// Layout fold icon hitboxes during prepaint phase. + /// + /// This creates hitboxes for the fold icon area, positioned to the right of line numbers. + /// Icons are created and prepainted here to avoid panics. + fn layout_fold_icons( + &self, + origin_x: Pixels, + bounds: &Bounds, + last_layout: &LastLayout, + window: &mut Window, + cx: &mut App, + ) -> FoldIconLayout { + // First pass: collect fold information from state + struct FoldInfo { + buffer_line: usize, + is_folded: bool, + display_row: usize, + offset_y: Pixels, + } + + let line_number_hitbox = window.insert_hitbox( + Bounds::new( + point(origin_x, bounds.origin.y + last_layout.visible_top), + size(last_layout.line_number_width, bounds.size.height), + ), + HitboxBehavior::Normal, + ); + + let mut icon_layout = FoldIconLayout { + line_number_hitbox, + icons: vec![], + }; + + let fold_infos: Vec = { + let state = self.state.read(cx); + if !state.mode.is_folding() { + return icon_layout; + } + + let mut infos = Vec::with_capacity(last_layout.visible_buffer_lines.len()); + let mut offset_y = last_layout.visible_top; + + for (line, &buffer_line) in last_layout + .lines + .iter() + .zip(last_layout.visible_buffer_lines.iter()) + { + if state.display_map.is_fold_candidate(buffer_line) { + let is_folded = state.display_map.is_folded_at(buffer_line); + infos.push(FoldInfo { + buffer_line, + is_folded, + display_row: buffer_line, + offset_y, + }); + } + + offset_y += line.wrapped_lines.len() * last_layout.line_height; + } + + infos + }; // state is dropped here + + // Second pass: create and prepaint icons + let line_height = last_layout.line_height; + let line_number_width = + last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN - FOLD_ICON_HITBOX_WIDTH; + let icon_relative_pos = point( + (FOLD_ICON_HITBOX_WIDTH - FOLD_ICON_WIDTH).half(), + (line_height - FOLD_ICON_WIDTH).half(), + ); + + for (ix, info) in fold_infos.iter().enumerate() { + // Position fold icon to the right of line numbers. + // Use origin_x (unscrolled) so icons stay fixed in the gutter during horizontal scroll. + let fold_icon_bounds = Bounds::new( + point( + origin_x + icon_relative_pos.x + line_number_width, + bounds.origin.y + icon_relative_pos.y + info.offset_y, + ), + size(FOLD_ICON_HITBOX_WIDTH, line_height), + ); + + // Create and prepaint icon + let mut icon = Button::new(("fold", ix)) + .ghost() + .icon(if info.is_folded { + IconName::CaretRight + } else { + IconName::CaretDown + }) + .xsmall() + .rounded_xs() + .size(FOLD_ICON_WIDTH) + .selected(info.is_folded) + .on_mouse_down(MouseButton::Left, { + let state = self.state.clone(); + let buffer_line = info.buffer_line; + move |_, _: &mut Window, cx: &mut App| { + cx.stop_propagation(); + + state.update(cx, |state, cx| { + state.display_map.toggle_fold(buffer_line); + cx.notify(); + }); + } + }) + .into_any_element(); + + icon.prepaint_as_root( + fold_icon_bounds.origin, + fold_icon_bounds.size.into(), + window, + cx, + ); + + icon_layout + .icons + .push((info.display_row, info.is_folded, icon)); + } + + icon_layout + } + + /// Paint fold icons using prepaint hitboxes. + /// + /// This handles: + /// - Rendering fold icons (chevron-right for folded, chevron-down for expanded) + /// - Mouse click handling to toggle fold state + /// - Cursor style changes on hover + /// - Only show icon on hover or for current line + fn paint_fold_icons( + &mut self, + fold_icon_layout: &mut FoldIconLayout, + current_row: Option, + window: &mut Window, + cx: &mut App, + ) { + let is_hovered = fold_icon_layout.line_number_hitbox.is_hovered(window); + for (display_row, is_folded, icon) in fold_icon_layout.icons.iter_mut() { + let is_current_line = current_row == Some(*display_row); + + if !is_hovered && !is_current_line && !*is_folded { + continue; + } + + icon.paint(window, cx); + } + } + + #[allow(clippy::too_many_arguments)] + fn layout_lines( + state: &InputState, + display_text: &Rope, + last_layout: &LastLayout, + font_size: Pixels, + runs: &[TextRun], + bg_segments: &[(Range, Hsla)], + whitespace_indicators: Option, + window: &mut Window, + ) -> Vec { + let is_single_line = state.mode.is_single_line(); + let buffer_lines = state.display_map.lines(); + + if is_single_line { + let shaped_line = window.text_system().shape_line( + display_text.to_string().into(), + font_size, + runs, + None, + ); + + let line_layout = LineLayout::new() + .lines(smallvec::smallvec![shaped_line]) + .with_whitespaces(whitespace_indicators); + return vec![line_layout]; + } + + // Empty to use placeholder, the placeholder is not in the wrapper map. + if state.text.len() == 0 { + let placeholder_text = display_text.to_string(); + let mut placeholder_lines = SmallVec::new(); + + for (line, line_runs) in placeholder_line_runs(&placeholder_text, runs) { + let shaped_line = window.text_system().shape_line( + line.to_string().into(), + font_size, + &line_runs, + None, + ); + placeholder_lines.push(shaped_line); + } + + // Keep placeholder lines in a single layout to stay parallel with visible_* metadata. + let line_layout = LineLayout::new() + .lines(placeholder_lines) + .with_whitespaces(whitespace_indicators); + return vec![line_layout]; + } + + let mut lines = Vec::with_capacity(last_layout.visible_buffer_lines.len()); + // run_offset tracks position in the runs vec coordinate space (only visible line bytes). + // This is separate from the visible_text offset because runs from highlight_lines + // only cover visible (non-folded) lines. + let mut run_offset = 0; + + for (vi, &buffer_line) in last_layout.visible_buffer_lines.iter().enumerate() { + let line_text: String = display_text.slice_line(buffer_line).into(); + let line_item = buffer_lines + .get(buffer_line) + .expect("line should exists in wrapper"); + + debug_assert_eq!(line_item.len(), line_text.len()); + + let mut wrapped_lines = SmallVec::with_capacity(1); + + for range in &line_item.wrapped_lines { + let line_runs = runs_for_range(runs, run_offset, range); + let line_runs = if bg_segments.is_empty() { + line_runs + } else { + split_runs_by_bg_segments( + last_layout.visible_line_byte_offsets[vi] + (range.start), + &line_runs, + bg_segments, + ) + }; + + let sub_line: SharedString = line_text[range.clone()].to_string().into(); + let shaped_line = window + .text_system() + .shape_line(sub_line, font_size, &line_runs, None); + + wrapped_lines.push(shaped_line); + } + + let line_layout = LineLayout::new() + .lines(wrapped_lines) + .with_whitespaces(whitespace_indicators.clone()); + lines.push(line_layout); + + // +1 for the `\n` + run_offset += line_text.len() + 1; + } + + lines + } + + /// First usize is the offset of skipped. + fn highlight_lines( + &mut self, + visible_buffer_lines: &[usize], + _visible_top: Pixels, + _visible_byte_range: Range, + cx: &mut App, + ) -> Option, HighlightStyle)>> { + let state = self.state.read(cx); + let text = &state.text; + let is_multi_line = state.mode.is_multi_line(); + + let mut styles = Vec::with_capacity(visible_buffer_lines.len()); + + // Helper to flush a contiguous range of lines. These ranges are disjoint, + // so appending avoids repeatedly cloning and recombining prior styles. + let flush_range = |start_line: usize, end_line: usize, _skip: bool, styles: &mut Vec<_>| { + let byte_start = text.line_start_offset(start_line); + let byte_end = if is_multi_line { + // +1 for `\n` + text.line_start_offset(end_line + 1) + } else { + text.line_end_offset(end_line) + }; + let range_styles = vec![(byte_start..byte_end, HighlightStyle::default())]; + styles.extend(range_styles); + }; + + // Group contiguous visible lines into ranges and call styles() once per range + let mut visible_iter = visible_buffer_lines.iter().peekable(); + let mut range_start: Option = None; + + while let Some(&line) = visible_iter.next() { + // Check if this line is too long for highlighting + let line_len = text.slice_line(line).len(); + if line_len > MAX_HIGHLIGHT_LINE_LENGTH { + // Flush any accumulated range first + if let Some(start) = range_start.take() { + flush_range(start, line - 1, false, &mut styles); + } + + flush_range(line, line, true, &mut styles); + continue; + } + + range_start.get_or_insert(line); + + // Check if next line is contiguous, if so keep accumulating + if visible_iter + .peek() + .map(|&&next| next == line + 1) + .unwrap_or(false) + { + continue; + } + + // Flush the contiguous range + let start_line = range_start.take().unwrap(); + flush_range(start_line, line, false, &mut styles); + } + + Some(styles) } } @@ -455,11 +1219,33 @@ pub(super) struct PrepaintState { scroll_size: Size, cursor_bounds: Option>, cursor_scroll_offset: Point, + /// row index (zero based), no wrap, same line as the cursor. + current_row: Option, selection_path: Option>, hover_highlight_path: Option>, search_match_paths: Vec<(Path, bool)>, + document_color_paths: Vec<(Path, Hsla)>, hover_definition_hitbox: Option, + indent_guides_path: Option>, bounds: Bounds, + /// Fold icon layout data + fold_icon_layout: FoldIconLayout, + // Inline completion rendering data + /// Shaped ghost lines to paint after cursor row (completion lines 2+) + ghost_lines: Vec, + /// First line of inline completion (painted after cursor on same line) + ghost_first_line: Option, + ghost_lines_height: Pixels, +} + +impl PrepaintState { + /// Returns cursor bounds adjusted for scroll offset, if available. + fn cursor_bounds_with_scroll(&self) -> Option> { + self.cursor_bounds.map(|mut bounds| { + bounds.origin.y += self.cursor_scroll_offset.y; + bounds + }) + } } impl IntoElement for TextElement { @@ -470,6 +1256,34 @@ impl IntoElement for TextElement { } } +/// A debug function to print points as SVG path. +#[allow(unused)] +fn print_points_as_svg_path(line_corners: &Vec>, points: Vec>) { + for corners in line_corners { + println!( + "tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})", + corners.top_left.as_f32() as i32, + corners.top_left.as_f32() as i32, + corners.top_right.as_f32() as i32, + corners.top_right.as_f32() as i32, + corners.bottom_left.as_f32() as i32, + corners.bottom_left.as_f32() as i32, + corners.bottom_right.as_f32() as i32, + corners.bottom_right.as_f32() as i32, + ); + } + + if !points.is_empty() { + println!( + "M{},{}", + points[0].x.as_f32() as i32, + points[0].y.as_f32() as i32 + ); + for p in points.iter().skip(1) { + println!("L{},{}", p.x.as_f32() as i32, p.y.as_f32() as i32); + } + } +} impl Element for TextElement { type PrepaintState = PrepaintState; type RequestLayoutState = (); @@ -521,37 +1335,98 @@ impl Element for TextElement { window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { + let style = window.text_style(); + let font = style.font(); + let text_size = style.font_size.to_pixels(window.rem_size()); + + self.state.update(cx, |state, cx| { + state.display_map.set_font(font, text_size, cx); + state.display_map.ensure_text_prepared(&state.text, cx); + }); + let state = self.state.read(cx); let line_height = window.line_height(); - let (visible_range, visible_top) = + let (visible_range, visible_buffer_lines, visible_top) = self.calculate_visible_range(state, line_height, bounds.size.height); let visible_start_offset = state.text.line_start_offset(visible_range.start); let visible_end_offset = state .text .line_end_offset(visible_range.end.saturating_sub(1)); + let highlight_styles = self.highlight_lines( + &visible_buffer_lines, + visible_top, + visible_start_offset..visible_end_offset, + cx, + ); + let state = self.state.read(cx); let multi_line = state.mode.is_multi_line(); let text = state.text.clone(); - let is_empty = text.is_empty(); + let is_empty = text.len() == 0; 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 text_style = window.text_style(); + let fg = text_style.color; let (display_text, text_color) = if is_empty { - (Rope::from(placeholder.as_str()), cx.theme().text_muted) + (&Rope::from(placeholder.as_str()), cx.theme().text_muted) } else if state.masked { ( - Rope::from("*".repeat(text.chars_count()).as_str()), - cx.theme().text, + &Rope::from(MASK_CHAR.to_string().repeat(text.chars().count())), + fg, ) } else { - (text.clone(), cx.theme().text) + (&text, fg) }; - let line_number_width = px(0.); + // Calculate the width of the line numbers + let (line_number_width, line_number_len) = + Self::layout_line_numbers(state, &text, text_size, &text_style, window); + + let mut bounds = bounds; + let wrap_width = if multi_line && state.soft_wrap { + Some(bounds.size.width - line_number_width - RIGHT_MARGIN) + } else { + None + }; + + let visible_line_byte_offsets: Vec = visible_buffer_lines + .iter() + .map(|&bl| state.text.line_start_offset(bl)) + .collect(); + + // For password input (masked: true), convert byte offsets to masked display byte offsets so that + // layout_match_range and position_for_index work in the correct coordinate space. + let (visible_line_byte_offsets, visible_range_offset) = if state.masked { + let offsets = visible_line_byte_offsets + .iter() + .map(|&o| masked_display_offset(&text, o)) + .collect(); + let range_offset = masked_display_offset(&text, visible_start_offset) + ..masked_display_offset(&text, visible_end_offset); + (offsets, range_offset) + } else { + ( + visible_line_byte_offsets, + visible_start_offset..visible_end_offset, + ) + }; + + let mut last_layout = LastLayout { + visible_range, + visible_buffer_lines, + visible_line_byte_offsets, + visible_top, + visible_range_offset, + line_height, + wrap_width, + line_number_width, + lines: Rc::new(vec![]), + cursor_bounds: None, + text_align: state.text_align, + content_width: bounds.size.width, + }; let run = TextRun { len: display_text.len(), @@ -561,7 +1436,6 @@ impl Element for TextElement { underline: None, strikethrough: None, }; - let marked_run = TextRun { len: 0, font: style.font(), @@ -576,7 +1450,28 @@ impl Element for TextElement { }; let runs = if !is_empty { - vec![run] + if let Some(highlight_styles) = highlight_styles { + let mut runs = Vec::with_capacity(highlight_styles.len()); + + runs.extend(highlight_styles.iter().map(|(range, style)| { + let mut run = text_style.clone().highlight(*style).to_run(range.len()); + + if let Some(ime_marked_range) = &state.ime_marked_range + && range.start >= ime_marked_range.start + && range.end <= ime_marked_range.end + { + run.color = marked_run.color; + run.strikethrough = marked_run.strikethrough; + run.underline = marked_run.underline; + } + + run + })); + + runs.into_iter().filter(|run| run.len > 0).collect() + } else { + vec![run] + } } else if let Some(ime_marked_range) = &state.ime_marked_range { // IME marked text vec![ @@ -601,38 +1496,36 @@ impl Element for TextElement { vec![run] }; - let wrap_width = if multi_line && state.soft_wrap { - Some(bounds.size.width - line_number_width - RIGHT_MARGIN) - } else { - None - }; + let document_colors = []; - // NOTE: Here 50 lines about 150µs - // let measure = crate::Measure::new("shape_text"); - let visible_text = display_text - .slice_rows(visible_range.start as u32..visible_range.end as u32) - .to_string(); + // Create shaped lines for whitespace indicators before layout + let whitespace_indicators = + Self::layout_whitespace_indicators(state, text_size, &text_style, window, cx); - let lines = window - .text_system() - .shape_text(visible_text.into(), font_size, &runs, wrap_width, None) - .expect("failed to shape text"); - // measure.end(); + let lines = Self::layout_lines( + state, + display_text, + &last_layout, + text_size, + &runs, + &document_colors, + whitespace_indicators, + window, + ); let mut longest_line_width = wrap_width.unwrap_or(px(0.)); - if state.mode.is_multi_line() && !state.soft_wrap && lines.len() > 1 { - let longtest_line: SharedString = state - .text - .line(state.text.summary().longest_row as usize) - .to_string() - .into(); + // 1. Single line + // 2. Multi-line with soft wrap disabled. + if state.mode.is_single_line() || !state.soft_wrap { + let longest_row = state.display_map.longest_row(); + let longest_line: SharedString = state.text.slice_line(longest_row).to_string().into(); longest_line_width = window .text_system() .shape_line( - longtest_line.clone(), - font_size, + longest_line.clone(), + text_size, &[TextRun { - len: longtest_line.len(), + len: longest_line.len(), font: style.font(), color: gpui::black(), background_color: None, @@ -643,30 +1536,44 @@ impl Element for TextElement { ) .width; } + last_layout.lines = Rc::new(lines); - let total_wrapped_lines = state.text_wrapper.len(); - let empty_bottom_height = px(0.); + let (ghost_first_line, ghost_lines) = Self::layout_inline_completion( + state, + &last_layout.visible_range, + text_size, + window, + cx, + ); + let ghost_line_count = ghost_lines.len(); + let ghost_lines_height = ghost_line_count as f32 * line_height; - let scroll_size = size( + let total_wrapped_lines = state.display_map.wrap_row_count(); + let empty_bottom_height = if state.mode.is_code_editor() { + bounds + .size + .height + .half() + .max(BOTTOM_MARGIN_ROWS * line_height) + } else { + px(0.) + }; + + let mut scroll_size = size( if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { longest_line_width + line_number_width + RIGHT_MARGIN } else { longest_line_width }, - (total_wrapped_lines as f32 * line_height + empty_bottom_height) + (total_wrapped_lines as f32 * line_height + empty_bottom_height + ghost_lines_height) .max(bounds.size.height), ); - let mut last_layout = LastLayout { - visible_range, - visible_top, - visible_range_offset: visible_start_offset..visible_end_offset, - line_height, - wrap_width, - line_number_width, - lines: Rc::new(lines), - cursor_bounds: None, - }; + // TODO: should be add some gap to right, to convenient to focus on boundary position + if last_layout.text_align == TextAlign::Right || last_layout.text_align == TextAlign::Center + { + scroll_size.width = longest_line_width + line_number_width; + } // `position_for_index` for example // @@ -699,15 +1606,88 @@ impl Element for TextElement { // Calculate the scroll offset to keep the cursor in view - let (cursor_bounds, cursor_scroll_offset, _) = - self.layout_cursor(&last_layout, &mut bounds, window, cx); + // Save the unscrolled x before layout_cursor modifies bounds.origin with scroll_offset. + // Fold icons and their hitboxes must use this value so they stay fixed in the gutter + // regardless of horizontal scroll position. + let input_bounds = bounds; + let original_x = bounds.origin.x; + + let (cursor_bounds, cursor_scroll_offset, current_row) = + self.layout_cursor(&last_layout, &mut bounds, scroll_size, window, cx); last_layout.cursor_bounds = cursor_bounds; - let selection_path = self.layout_selections(&last_layout, &mut bounds, cx); - let search_match_paths = vec![]; - let hover_highlight_path = None; - let line_numbers = None; - let hover_definition_hitbox = None; + let search_match_paths = self.layout_search_matches(&last_layout, &bounds, cx); + let selection_path = self.layout_selections(&last_layout, &mut bounds, window, cx); + let hover_highlight_path = self.layout_hover_highlight(&last_layout, &bounds, cx); + let document_color_paths = + self.layout_document_colors(&document_colors, &last_layout, &bounds, cx); + + let state = self.state.read(cx); + let line_numbers = if state.mode.line_number() { + let mut line_numbers = Vec::with_capacity(last_layout.visible_buffer_lines.len()); + let other_line_runs = vec![TextRun { + len: line_number_len, + font: style.font(), + color: cx.theme().text_muted, + background_color: None, + underline: None, + strikethrough: None, + }]; + let current_line_runs = vec![TextRun { + len: line_number_len, + font: style.font(), + color: cx.theme().text, + background_color: None, + underline: None, + strikethrough: None, + }]; + + // build line numbers + for (line, &buffer_line) in last_layout + .lines + .iter() + .zip(last_layout.visible_buffer_lines.iter()) + { + let line_no: SharedString = + format!("{:>width$}", buffer_line + 1, width = line_number_len).into(); + + let runs = if current_row == Some(buffer_line) { + ¤t_line_runs + } else { + &other_line_runs + }; + + let mut sub_lines: SmallVec<[ShapedLine; 1]> = SmallVec::new(); + sub_lines.push( + window + .text_system() + .shape_line(line_no, text_size, runs, None), + ); + for _ in 0..line.wrapped_lines.len().saturating_sub(1) { + sub_lines.push(ShapedLine::default()); + } + line_numbers.push(sub_lines); + } + Some(line_numbers) + } else { + None + }; + + let indent_guides_path = + self.layout_indent_guides(state, &bounds, &last_layout, &text_style, window); + + state + .editor_scrollbar_snapshot + .set(Some(EditorScrollbarSnapshot::new( + input_bounds, + &last_layout, + scroll_size, + cursor_scroll_offset, + state, + ))); + + let fold_icon_layout = + self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx); PrepaintState { bounds, @@ -716,10 +1696,17 @@ impl Element for TextElement { line_numbers, cursor_bounds, cursor_scroll_offset, + current_row, selection_path, search_match_paths, hover_highlight_path, - hover_definition_hitbox, + hover_definition_hitbox: None, + document_color_paths, + indent_guides_path, + fold_icon_layout, + ghost_first_line, + ghost_lines, + ghost_lines_height, } } @@ -738,6 +1725,7 @@ impl Element for TextElement { let focused = focus_handle.is_focused(window); let bounds = prepaint.bounds; let selected_range = self.state.read(cx).selected_range; + let text_align = prepaint.last_layout.text_align; window.handle_input( &focus_handle, @@ -775,28 +1763,26 @@ impl Element for TextElement { let invisible_top_padding = prepaint.last_layout.visible_top; - let mut mask_offset_y = px(0.); - if self.state.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); - } - } - // Paint active line 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 (lines, _) in line_numbers + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { let height = line_height * lines.len() as f32; offset_y += height; } } + // Paint indent guides + if let Some(path) = prepaint.indent_guides_path.take() { + window.paint_path(path, cx.theme().border.opacity(0.85)); + } + // Paint selections if window.is_window_active() { let secondary_selection = cx.theme().selection; @@ -818,23 +1804,94 @@ impl Element for TextElement { } } - // Paint text - let mut offset_y = mask_offset_y + invisible_top_padding; - for line in prepaint.last_layout.lines.iter() { + // Paint document colors + for (path, color) in prepaint.document_color_paths.iter() { + window.paint_path(path.clone(), *color); + } + + // Paint text with inline completion ghost line support + let mut offset_y = invisible_top_padding; + let ghost_lines = &prepaint.ghost_lines; + let has_ghost_lines = !ghost_lines.is_empty(); + + // Keep scrollbar offset always be positive,Start from the left position + let scroll_offset = if text_align == TextAlign::Right { + (prepaint.scroll_size.width - prepaint.bounds.size.width).max(px(0.)) + } else if text_align == TextAlign::Center { + (prepaint.scroll_size.width - prepaint.bounds.size.width) + .half() + .max(px(0.)) + } else { + px(0.) + }; + + // Track the y-position of the cursor row for positioning the first line suffix + let mut cursor_row_y = None; + + for (line, &buffer_line) in prepaint + .last_layout + .lines + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { + let row = buffer_line; + let line_y = origin.y + offset_y; let p = point( - origin.x + prepaint.last_layout.line_number_width, - origin.y + offset_y, + origin.x + prepaint.last_layout.line_number_width + (scroll_offset), + line_y, + ); + + // Paint the actual line + line.paint( + p, + line_height, + text_align, + Some(prepaint.last_layout.content_width), + window, + cx, ); - _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); offset_y += line.size(line_height).height; + + if Some(row) == prepaint.current_row { + cursor_row_y = Some(line_y); + } + + // After the cursor row, paint ghost lines (which shifts subsequent content down) + if has_ghost_lines && Some(row) == prepaint.current_row { + let ghost_x = origin.x + prepaint.last_layout.line_number_width; + + for ghost_line in ghost_lines { + let ghost_p = point(ghost_x, origin.y + offset_y); + + // Paint semi-transparent background for ghost line + let ghost_bounds = Bounds::new( + ghost_p, + size( + bounds.size.width - prepaint.last_layout.line_number_width, + line_height, + ), + ); + window.paint_quad(fill(ghost_bounds, cx.theme().surface_background)); + + // Paint ghost line text + _ = ghost_line.paint( + ghost_p, + line_height, + text_align, + Some(prepaint.last_layout.content_width), + window, + cx, + ); + offset_y += line_height; + } + } } // Paint blinking cursor if focused && show_cursor - && let Some(mut cursor_bounds) = prepaint.cursor_bounds.take() + && let Some(cursor_bounds) = prepaint.cursor_bounds_with_scroll() { - cursor_bounds.origin.y += prepaint.cursor_scroll_offset.y; window.paint_quad(fill(cursor_bounds, cx.theme().cursor)); } @@ -843,29 +1900,44 @@ impl Element for TextElement { if let Some(line_numbers) = prepaint.line_numbers.as_ref() { offset_y += invisible_top_padding; - // Paint line number background window.paint_quad(fill( Bounds { origin: input_bounds.origin, size: size( prepaint.last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN, - input_bounds.size.height, + input_bounds.size.height + prepaint.ghost_lines_height, ), }, - cx.theme().background, + cx.theme().surface_background, )); // Each item is the normal lines. - for lines in line_numbers.iter() { + for (lines, &buffer_line) in line_numbers + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { let p = point(input_bounds.origin.x, origin.y + offset_y); for line in lines { _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); offset_y += line_height; } + + // Add ghost line height after cursor row for line numbers alignment + if !prepaint.ghost_lines.is_empty() && prepaint.current_row == Some(buffer_line) { + offset_y += prepaint.ghost_lines_height; + } } } + // Paint fold icons (only visible on hover or for current line) + self.paint_fold_icons( + &mut prepaint.fold_icon_layout, + prepaint.current_row, + window, + cx, + ); + self.state.update(cx, |state, cx| { state.last_layout = Some(prepaint.last_layout.clone()); state.last_bounds = Some(bounds); @@ -883,6 +1955,340 @@ impl Element for TextElement { window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox); } + // Paint inline completion first line suffix (after cursor on same line) + if focused + && let Some(first_line) = &prepaint.ghost_first_line + && let (Some(cursor_bounds), Some(cursor_row_y)) = + (prepaint.cursor_bounds_with_scroll(), cursor_row_y) + { + let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width; + let p = point(first_line_x, cursor_row_y); + + // Paint background to cover any existing text + let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height)); + window.paint_quad(fill(bg_bounds, cx.theme().surface_background)); + + // Paint first line completion text + _ = first_line.paint(p, line_height, text_align, None, window, cx); + } + self.paint_mouse_listeners(window, cx); } } + +/// Split placeholder text into display lines and trim runs to each line. +fn placeholder_line_runs<'a>( + display_text: &'a str, + runs: &[TextRun], +) -> Vec<(&'a str, Vec)> { + let mut result = Vec::new(); + let mut line_offset = 0; + + for line in display_text.split('\n') { + let line_runs = runs_for_range(runs, line_offset, &(0..line.len())); + debug_assert_eq!( + line_runs.iter().map(|run| run.len).sum::(), + line.len() + ); + result.push((line, line_runs)); + // Advance in the whole-placeholder coordinate space, including the separator. + line_offset += line.len() + 1; + } + + result +} + +/// Get the runs for the given range. +/// +/// The range is the byte range of the wrapped line. +pub(super) fn runs_for_range( + runs: &[TextRun], + line_offset: usize, + range: &Range, +) -> Vec { + let mut result = vec![]; + let range = (line_offset + range.start)..(line_offset + range.end); + let mut cursor = 0; + + for run in runs { + let run_start = cursor; + let run_end = cursor + run.len; + + if run_end <= range.start { + cursor = run_end; + continue; + } + + if run_start >= range.end { + break; + } + + let start = range.start.max(run_start) - run_start; + let end = range.end.min(run_end) - run_start; + let len = end - start; + + if len > 0 { + result.push(TextRun { len, ..run.clone() }); + } + + cursor = run_end; + } + + result +} + +fn split_runs_by_bg_segments( + start_offset: usize, + runs: &[TextRun], + bg_segments: &[(Range, Hsla)], +) -> Vec { + let mut result = vec![]; + + let mut cursor = start_offset; + for run in runs { + let mut run_start = cursor; + let run_end = cursor + run.len; + + for (bg_range, bg_color) in bg_segments { + if run_end <= bg_range.start || run_start >= bg_range.end { + continue; + } + + // Overlap exists + if run_start < bg_range.start { + // Add the part before the background range + result.push(TextRun { + len: bg_range.start - run_start, + ..run.clone() + }); + } + + // Add the overlapping part with background color + let overlap_start = run_start.max(bg_range.start); + let overlap_end = run_end.min(bg_range.end); + let text_color = if bg_color.l >= 0.5 { + gpui::black() + } else { + gpui::white() + }; + + let run_len = overlap_end.saturating_sub(overlap_start); + if run_len > 0 { + result.push(TextRun { + len: run_len, + color: text_color, + ..run.clone() + }); + + cursor = bg_range.end; + run_start = cursor; + } + } + + if run_end > cursor { + // Add the part after the background range + result.push(TextRun { + len: run_end - cursor, + ..run.clone() + }); + } + + cursor = run_end; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_editor_scrollbar_layout_uses_current_scroll_size() { + let input_bounds = Bounds::new(point(px(10.), px(20.)), size(px(300.), px(80.))); + let paddings = Edges { + top: px(2.), + right: px(3.), + bottom: px(5.), + left: px(7.), + }; + + let layout = + EditorScrollbarLayout::new(input_bounds, px(40.), size(px(1000.), px(200.)), paddings); + + assert_eq!( + layout.bounds, + Bounds::new(point(px(47.), px(18.)), size(px(266.), px(87.))) + ); + assert_eq!(layout.scroll_size, size(px(976.), px(200.))); + + let layout_without_gutter = + EditorScrollbarLayout::new(input_bounds, px(0.), size(px(500.), px(120.)), paddings); + + assert_eq!( + layout_without_gutter.bounds, + Bounds::new(point(px(10.), px(18.)), size(px(303.), px(87.))) + ); + assert_eq!(layout_without_gutter.scroll_size, size(px(513.), px(120.))); + } + + #[test] + fn test_auto_grow_scroll_offset_is_clamped_to_current_viewport() { + let mode = InputMode::auto_grow(3, 8); + + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&mode, px(-260.), px(340.), px(160.)), + px(-180.) + ); + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&mode, px(-40.), px(340.), px(160.)), + px(-40.) + ); + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&mode, px(20.), px(340.), px(160.)), + px(0.) + ); + + let plain_text = InputMode::plain_text().multi_line(true); + assert_eq!( + clamp_auto_grow_vertical_scroll_offset(&plain_text, px(-260.), px(340.), px(160.)), + px(-260.) + ); + } + + #[test] + fn test_runs_for_range() { + let run = TextRun { + len: 0, + font: gpui::font(".SystemUIFont"), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + + // use hello this-is-test + let runs = vec![ + // use + TextRun { + len: 3, + ..run.clone() + }, + // \s + TextRun { + len: 1, + ..run.clone() + }, + // hello + TextRun { + len: 5, + ..run.clone() + }, + // \s + TextRun { + len: 1, + ..run.clone() + }, + // this-is-test + TextRun { + len: 12, + ..run.clone() + }, + ]; + + #[track_caller] + fn assert_runs(actual: Vec, expected: &[usize]) { + let left = actual.iter().map(|run| run.len).collect::>(); + assert_eq!(left, expected); + } + + assert_runs(runs_for_range(&runs, 0, &(0..0)), &[]); + assert_runs(runs_for_range(&runs, 0, &(0..100)), &[3, 1, 5, 1, 12]); + + assert_runs(runs_for_range(&runs, 0, &(0..6)), &[3, 1, 2]); + assert_runs(runs_for_range(&runs, 0, &(1..6)), &[2, 1, 2]); + assert_runs(runs_for_range(&runs, 0, &(3..10)), &[1, 5, 1]); + assert_runs(runs_for_range(&runs, 0, &(5..8)), &[3]); + assert_runs(runs_for_range(&runs, 3, &(0..3)), &[1, 2]); + assert_runs(runs_for_range(&runs, 3, &(2..10)), &[4, 1, 3]); + assert_runs(runs_for_range(&runs, 9, &(0..8)), &[1, 7]); + } + + #[test] + fn test_placeholder_line_runs() { + let run = TextRun { + len: 0, + font: gpui::font(".SystemUIFont"), + color: gpui::black(), + background_color: None, + underline: None, + strikethrough: None, + }; + + let runs = vec![ + TextRun { + len: 2, + ..run.clone() + }, + TextRun { + len: 2, + ..run.clone() + }, + TextRun { len: 1, ..run }, + ]; + + let placeholder_runs = placeholder_line_runs("ab\n\nc", &runs); + + let lines = placeholder_runs + .iter() + .map(|(line, _)| *line) + .collect::>(); + assert_eq!(lines, vec!["ab", "", "c"]); + + let run_lengths = placeholder_runs + .iter() + .map(|(_, line_runs)| line_runs.iter().map(|run| run.len).collect::>()) + .collect::>(); + assert_eq!(run_lengths, vec![vec![2], vec![], vec![1]]); + } + + #[test] + fn test_split_runs_by_bg_segments() { + let run = TextRun { + len: 0, + font: gpui::font(".SystemUIFont"), + color: gpui::blue(), + background_color: None, + underline: None, + strikethrough: None, + }; + + let runs = vec![ + TextRun { + len: 5, + ..run.clone() + }, + TextRun { + len: 7, + ..run.clone() + }, + TextRun { + len: 24, + ..run.clone() + }, + ]; + + let bg_segments = vec![(8..12, gpui::red()), (12..18, gpui::blue())]; + let result = split_runs_by_bg_segments(5, &runs, &bg_segments); + assert_eq!( + result.iter().map(|run| run.len).collect::>(), + vec![3, 2, 2, 5, 1, 23] + ); + assert_eq!(result[0].color, gpui::blue()); + assert_eq!(result[1].color, gpui::black()); + assert_eq!(result[2].color, gpui::black()); + assert_eq!(result[3].color, gpui::black()); + assert_eq!(result[4].color, gpui::black()); + assert_eq!(result[5].color, gpui::blue()); + } +} diff --git a/crates/ui/src/input/indent.rs b/crates/ui/src/input/indent.rs new file mode 100644 index 0000000..54dd386 --- /dev/null +++ b/crates/ui/src/input/indent.rs @@ -0,0 +1,424 @@ +use gpui::{ + Bounds, Context, EntityInputHandler as _, Hsla, Path, PathBuilder, Pixels, SharedString, + TextRun, TextStyle, Window, point, px, +}; +use ropey::RopeSlice; + +use crate::input::element::TextElement; +use crate::input::mode::InputMode; +use crate::input::{Indent, IndentInline, InputState, LastLayout, Outdent, OutdentInline, RopeExt}; + +#[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() + } + } + + /// Count the indent size of the line in spaces. + pub fn indent_count(&self, line: &RopeSlice) -> usize { + let mut count = 0; + for ch in line.chars() { + match ch { + '\t' => count += self.tab_size, + ' ' => count += 1, + _ => break, + } + } + + count + } +} + +impl InputMode { + #[inline] + pub(super) fn is_indentable(&self) -> bool { + match self { + InputMode::PlainText { multi_line, .. } | InputMode::CodeEditor { multi_line, .. } => { + *multi_line + } + _ => false, + } + } + + #[inline] + pub(super) fn has_indent_guides(&self) -> bool { + match self { + InputMode::CodeEditor { + indent_guides, + multi_line, + .. + } => *indent_guides && *multi_line, + _ => false, + } + } + + #[inline] + pub(super) fn tab_size(&self) -> TabSize { + match self { + InputMode::PlainText { tab, .. } => *tab, + InputMode::CodeEditor { tab, .. } => *tab, + _ => TabSize::default(), + } + } +} + +impl TextElement { + /// Measure the indent width in pixels for given column count. + fn measure_indent_width(&self, style: &TextStyle, column: usize, window: &Window) -> Pixels { + let font_size = style.font_size.to_pixels(window.rem_size()); + let layout = window.text_system().shape_line( + SharedString::from(" ".repeat(column)), + font_size, + &[TextRun { + len: column, + font: style.font(), + color: Hsla::default(), + background_color: None, + strikethrough: None, + underline: None, + }], + None, + ); + + layout.width + } + + pub(super) fn layout_indent_guides( + &self, + state: &InputState, + bounds: &Bounds, + last_layout: &LastLayout, + text_style: &TextStyle, + window: &mut Window, + ) -> Option> { + if !state.mode.has_indent_guides() { + return None; + } + + let indent_width = + self.measure_indent_width(text_style, state.mode.tab_size().tab_size, window); + + let tab_size = state.mode.tab_size(); + let line_height = last_layout.line_height; + let mut builder = PathBuilder::stroke(px(1.)); + let mut offset_y = last_layout.visible_top; + let mut last_indents = vec![]; + + for (&buffer_line, line_layout) in last_layout + .visible_buffer_lines + .iter() + .zip(last_layout.lines.iter()) + { + let line = state.text.slice_line(buffer_line); + let mut current_indents = vec![]; + if line.len() > 0 { + let indent_count = tab_size.indent_count(&line); + for offset in (0..indent_count).step_by(tab_size.tab_size) { + let x = if indent_count > 0 { + indent_width * offset as f32 / tab_size.tab_size as f32 + } else { + px(0.) + }; + + let pos = point(x + last_layout.line_number_width, offset_y); + + builder.move_to(pos); + builder.line_to(point(pos.x, pos.y + line_height)); + current_indents.push(pos.x); + } + } else if !last_indents.is_empty() { + for x in &last_indents { + let pos = point(*x, offset_y); + builder.move_to(pos); + builder.line_to(point(pos.x, pos.y + line_height)); + } + current_indents = last_indents.clone(); + } + + offset_y += line_layout.wrapped_lines.len() * line_height; + last_indents = current_indents; + } + + builder.translate(bounds.origin); + let path = builder.build().unwrap(); + Some(path) + } +} + +impl InputState { + /// Set whether to show indent guides in code editor mode, default is true. + /// + /// Only for [`InputMode::CodeEditor`] mode. + pub fn indent_guides(mut self, indent_guides: bool) -> Self { + debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line()); + if let InputMode::CodeEditor { + indent_guides: l, .. + } = &mut self.mode + { + *l = indent_guides; + } + self + } + + /// Set indent guides in code editor mode. + /// + /// Only for [`InputMode::CodeEditor`] mode. + pub fn set_indent_guides( + &mut self, + indent_guides: bool, + _: &mut Window, + cx: &mut Context, + ) { + debug_assert!(self.mode.is_code_editor()); + if let InputMode::CodeEditor { + indent_guides: l, .. + } = &mut self.mode + { + *l = indent_guides; + } + cx.notify(); + } + + /// Set the tab size for the input. + /// + /// Only for [`InputMode::PlainText`] and [`InputMode::CodeEditor`] mode with multi_line. + pub fn tab_size(mut self, tab: TabSize) -> Self { + debug_assert!(self.mode.is_multi_line() || self.mode.is_code_editor()); + match &mut self.mode { + InputMode::PlainText { tab: t, .. } => *t = tab, + InputMode::CodeEditor { tab: t, .. } => *t = tab, + _ => {} + } + self + } + + pub(super) fn indent_inline( + &mut self, + _: &IndentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.indent(false, window, cx); + } + + pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { + self.indent(true, window, cx); + } + + pub(super) fn outdent_inline( + &mut self, + _: &OutdentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.outdent(false, window, cx); + } + + pub(super) fn outdent_block( + &mut self, + _: &Outdent, + window: &mut Window, + cx: &mut Context, + ) { + self.outdent(true, window, cx); + } + + pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { + if !self.mode.is_indentable() { + cx.propagate(); + return; + }; + + let tab_indent = self.mode.tab_size().to_string(); + let selected_range = self.selected_range; + let mut added_len = 0; + let is_selected = !self.selected_range.is_empty(); + + if is_selected || block { + let start_offset = self.start_of_line_of_selection(window, cx); + let mut offset = start_offset; + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + for line in selected_text.split('\n') { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset))), + &tab_indent, + window, + cx, + ); + added_len += tab_indent.len(); + // +1 for "\n", the `\r` is included in the `line`. + offset += line.len() + tab_indent.len() + 1; + } + + if is_selected { + self.selected_range = (start_offset..selected_range.end + added_len).into(); + } else { + self.selected_range = + (selected_range.start + added_len..selected_range.end + added_len).into(); + } + } else { + // Selected none + let offset = self.selected_range.start; + self.replace_text_in_range_silent( + 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).into(); + } + } + + pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { + if !self.mode.is_indentable() { + cx.propagate(); + return; + }; + + let tab_indent = self.mode.tab_size().to_string(); + let selected_range = self.selected_range; + let mut removed_len = 0; + let is_selected = !self.selected_range.is_empty(); + + if is_selected || block { + let start_offset = self.start_of_line_of_selection(window, cx); + let mut offset = start_offset; + + let selected_text = self + .text_for_range( + self.range_to_utf16(&(offset..selected_range.end)), + &mut None, + window, + cx, + ) + .unwrap_or("".into()); + + for line in selected_text.split('\n') { + if line.starts_with(tab_indent.as_ref()) { + self.replace_text_in_range_silent( + 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; + } else { + offset += line.len() + 1; + } + } + + if is_selected { + self.selected_range = + (start_offset..selected_range.end.saturating_sub(removed_len)).into(); + } else { + self.selected_range = (selected_range.start.saturating_sub(removed_len) + ..selected_range.end.saturating_sub(removed_len)) + .into(); + } + } else { + // Selected none + let start_offset = self.selected_range.start; + let offset = self.start_of_line_of_selection(window, cx); + let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); + // FIXME: To improve performance + if self + .text + .slice(offset..self.text.len()) + .to_string() + .starts_with(tab_indent.as_ref()) + { + self.replace_text_in_range_silent( + Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), + "", + window, + cx, + ); + removed_len = tab_indent.len(); + let new_offset = start_offset.saturating_sub(removed_len); + self.selected_range = (new_offset..new_offset).into(); + } + } + } +} + +#[cfg(test)] +mod tests { + use ropey::RopeSlice; + + 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"); + } + + #[test] + fn test_tab_size_indent_count() { + let tab = TabSize { + tab_size: 4, + hard_tabs: false, + }; + assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); + assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 2); + assert_eq!(tab.indent_count(&RopeSlice::from(" abc")), 4); + assert_eq!(tab.indent_count(&RopeSlice::from("\tabc")), 4); + assert_eq!(tab.indent_count(&RopeSlice::from(" \tabc")), 6); + assert_eq!(tab.indent_count(&RopeSlice::from(" \t abc ")), 6); + assert_eq!(tab.indent_count(&RopeSlice::from("abc")), 0); + } +} diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/input.rs similarity index 69% rename from crates/ui/src/input/text_input.rs rename to crates/ui/src/input/input.rs index d38d031..68d4e1f 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/input.rs @@ -1,19 +1,30 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, + AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, - Window, + TextAlign, Window, div, px, relative, }; use theme::ActiveTheme; -use super::clear_button::clear_button; -use super::state::{InputState, CONTEXT}; -use crate::button::{Button, ButtonVariants}; +use super::InputState; +use super::element::EditorScrollbar; +use crate::button::{Button, ButtonVariants as _}; use crate::indicator::Indicator; -use crate::{h_flex, IconName, Sizable, Size, StyleSized, StyledExt}; +use crate::input::clear_button; +use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex}; +/// Returns `(background, foreground)` colors for input-like components. +pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { + if disabled { + (cx.theme().surface_background, cx.theme().text_muted) + } else { + (cx.theme().surface_background, cx.theme().text) + } +} + +/// A text input element bind to an [`InputState`]. #[derive(IntoElement)] -pub struct TextInput { +pub struct Input { state: Entity, style: StyleRefinement, size: Size, @@ -26,17 +37,30 @@ pub struct TextInput { disabled: bool, bordered: bool, focus_bordered: bool, + tab_index: isize, + selected: bool, } -impl Sizable for TextInput { +impl Sizable for Input { fn with_size(mut self, size: impl Into) -> Self { self.size = size.into(); self } } -impl TextInput { - /// Create a new [`TextInput`] element bind to the [`InputState`]. +impl Selectable for Input { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl Input { + /// Create a new [`Input`] element bind to the [`InputState`]. pub fn new(state: &Entity) -> Self { Self { state: state.clone(), @@ -51,6 +75,8 @@ impl TextInput { disabled: false, bordered: true, focus_bordered: true, + tab_index: 0, + selected: false, } } @@ -94,9 +120,9 @@ impl TextInput { self } - /// Set true to show the clear button when the input field is not empty. - pub fn cleanable(mut self) -> Self { - self.cleanable = true; + /// Set whether to show the clear button when the input field is not empty, default is false. + pub fn cleanable(mut self, cleanable: bool) -> Self { + self.cleanable = cleanable; self } @@ -112,79 +138,123 @@ impl TextInput { self } - fn render_toggle_mask_button(state: Entity) -> impl IntoElement { + /// Set the tab index for the input, default is 0. + pub fn tab_index(mut self, index: isize) -> Self { + self.tab_index = index; + self + } + + fn render_toggle_mask_button(state: &Entity, cx: &App) -> impl IntoElement { + let _masked = state.read(cx).masked; Button::new("toggle-mask") .icon(IconName::Eye) .xsmall() .ghost() - .on_mouse_down(MouseButton::Left, { + .tab_stop(false) + .on_click({ let state = state.clone(); move |_, window, cx| { state.update(cx, |state, cx| { - state.set_masked(false, window, cx); - }) - } - }) - .on_mouse_up(MouseButton::Left, { - let state = state.clone(); - move |_, window, cx| { - state.update(cx, |state, cx| { - state.set_masked(true, window, cx); + state.set_masked(!state.masked, window, cx); }) } }) } + + /// This method must after the refine_style. + fn render_editor( + paddings: EdgesRefinement, + input_state: &Entity, + state: &InputState, + window: &Window, + ) -> impl IntoElement { + let base_size = window.text_style().font_size; + let rem_size = window.rem_size(); + + let paddings = Edges { + left: paddings + .left + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + right: paddings + .right + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + top: paddings + .top + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + bottom: paddings + .bottom + .map(|v| v.to_pixels(base_size, rem_size)) + .unwrap_or(px(0.)), + }; + + state.editor_scrollbar_paddings.set(paddings); + state.editor_scrollbar_snapshot.set(None); + + v_flex().size_full().child( + div() + .relative() + .flex_1() + .child(input_state.clone()) + .child(EditorScrollbar::new(input_state.clone())), + ) + } } -impl Styled for TextInput { +impl Styled for Input { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } -impl RenderOnce for TextInput { +impl RenderOnce for Input { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { const LINE_HEIGHT: Rems = Rems(1.25); + let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); - let font = window.text_style().font(); - let font_size = window.text_style().font_size.to_pixels(window.rem_size()); - - self.state.update(cx, |state, cx| { - state.text_wrapper.set_font(font, font_size, cx); - state.text_wrapper.prepare_if_need(&state.text, cx); + self.state.update(cx, |state, _| { state.disabled = self.disabled; + state.size = self.size; + // Only for single line mode + if state.mode.is_single_line() { + state.text_align = text_align; + } }); let state = self.state.read(cx); - let focused = state.focus_handle.is_focused(window) && !state.disabled; + let _focused = state.focus_handle.is_focused(window) && !state.disabled; let gap_x = match self.size { Size::Small => px(4.), Size::Large => px(8.), - _ => px(4.), + _ => px(6.), }; - let bg = if state.disabled { + let (bg, _) = input_style(state.disabled, cx); + + let bg = if state.mode.is_code_editor() { cx.theme().surface_background } else { - cx.theme().elevated_surface_background + bg }; let prefix = self.prefix; let suffix = self.suffix; - let show_clear_button = self.cleanable + && !state.disabled && !state.loading - && !state.text.is_empty() + && state.text.len() > 0 && state.mode.is_single_line(); - let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button; div() .id(("input", self.state.entity_id())) .flex() - .key_context(CONTEXT) - .track_focus(&state.focus_handle) + .key_context(crate::input::CONTEXT) + .track_focus(&state.focus_handle.clone()) + .tab_index(self.tab_index) .when(!state.disabled, |this| { this.on_action(window.listener_for(&self.state, InputState::backspace)) .on_action(window.listener_for(&self.state, InputState::delete)) @@ -205,9 +275,6 @@ impl RenderOnce for TextInput { .on_action(window.listener_for(&self.state, InputState::outdent_inline)) .on_action(window.listener_for(&self.state, InputState::indent_block)) .on_action(window.listener_for(&self.state, InputState::outdent_block)) - .on_action( - window.listener_for(&self.state, InputState::shift_to_new_line), - ) }) }) .on_action(window.listener_for(&self.state, InputState::left)) @@ -260,8 +327,8 @@ impl RenderOnce for TextInput { .input_px(self.size) .input_py(self.size) .input_h(self.size) - .cursor_text() - .text_size(font_size) + .input_font_size(self.size) + .when(!self.disabled, |this| this.cursor_text()) .items_center() .when(state.mode.is_multi_line(), |this| { this.h_auto() @@ -269,33 +336,34 @@ impl RenderOnce for TextInput { }) .when(self.appearance, |this| { this.bg(bg) + .when(self.disabled, |this| this.opacity(0.5)) .rounded(cx.theme().radius) .when(self.bordered, |this| { this.border_color(cx.theme().border) .border_1() .when(cx.theme().shadow, |this| this.shadow_xs()) - .when(focused && self.focus_bordered, |this| { - this.border_color(cx.theme().border_focused) - }) }) }) .items_center() .gap(gap_x) .refine_style(&self.style) .children(prefix) - .child(self.state.clone()) + .when(state.mode.is_multi_line(), |mut this| { + let paddings = this.style().padding.clone(); + this.child(Self::render_editor(paddings, &self.state, state, window)) + }) + .when(!state.mode.is_multi_line(), |this| { + this.child(self.state.clone()) + }) .when(has_suffix, |this| { this.pr_2().child( h_flex() .id("suffix") .gap(gap_x) - .when(self.appearance, |this| this.bg(bg)) .items_center() - .when(state.loading, |this| { - this.child(Indicator::new().color(cx.theme().text_muted)) - }) + .when(state.loading, |this| this.child(Indicator::new())) .when(self.mask_toggle, |this| { - this.child(Self::render_toggle_mask_button(self.state.clone())) + this.child(Self::render_toggle_mask_button(&self.state, cx)) }) .when(show_clear_button, |this| { this.child(clear_button(cx).on_click({ @@ -303,6 +371,7 @@ impl RenderOnce for TextInput { move |_, window, cx| { state.update(cx, |state, cx| { state.clean(window, cx); + state.focus(window, cx); }) } })) diff --git a/crates/ui/src/input/mask_pattern.rs b/crates/ui/src/input/mask_pattern.rs index b1022f0..e78f234 100644 --- a/crates/ui/src/input/mask_pattern.rs +++ b/crates/ui/src/input/mask_pattern.rs @@ -319,14 +319,14 @@ impl MaskPattern { if fraction == &Some(0) { int_with_sep } else { - format!("{int_with_sep}.{frac}") + format!("{}.{}", int_with_sep, frac) } } else { int_with_sep }; let final_str = if let Some(sign) = maybe_signed { - format!("{sign}{final_str}") + format!("{}{}", sign, final_str) } else { final_str }; diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index a3a07f4..63d5bce 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -1,15 +1,29 @@ +pub(super) const MASK_CHAR: char = '*'; + mod blink_cursor; mod change; +mod clear_button; mod cursor; +mod display_map; mod element; +mod indent; +#[allow(clippy::module_inception)] +mod input; mod mask_pattern; mod mode; +mod movement; mod rope_ext; +mod selection; mod state; -mod text_input; -mod text_wrapper; - -pub(crate) mod clear_button; +pub(crate) use clear_button::*; +pub use cursor::*; +#[cfg(target_family = "wasm")] +pub use display_map::folding::Tree; +pub use display_map::{BufferPoint, DisplayMap, DisplayPoint, FoldRange}; +pub use indent::TabSize; +pub use input::*; +pub use mask_pattern::MaskPattern; +pub use rope_ext::{InputEdit, Point, RopeExt, RopeLines}; +pub use ropey::Rope; pub use state::*; -pub use text_input::*; diff --git a/crates/ui/src/input/mode.rs b/crates/ui/src/input/mode.rs index b781262..aea247a 100644 --- a/crates/ui/src/input/mode.rs +++ b/crates/ui/src/input/mode.rs @@ -1,54 +1,122 @@ -use gpui::SharedString; +use std::cell::RefCell; +use std::rc::Rc; -use super::text_wrapper::TextWrapper; +use gpui::{SharedString, Task}; +use ropey::Rope; -#[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, +use super::display_map::DisplayMap; +use crate::input::TabSize; + +#[allow(dead_code)] +pub(super) struct PendingBackgroundParse { + pub parse_task: Rc>>>, + pub language: SharedString, + pub text: Rope, + pub is_folding: 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 { +#[derive(Clone)] +pub(crate) enum InputMode { + /// A plain text input mode. + PlainText { + multi_line: bool, tab: TabSize, rows: usize, }, + /// An auto grow input mode. AutoGrow { rows: usize, min_rows: usize, max_rows: usize, }, + /// A code editor input mode. + CodeEditor { + multi_line: bool, + tab: TabSize, + rows: usize, + /// Show line number + line_number: bool, + language: SharedString, + indent_guides: bool, + folding: bool, + parse_task: Rc>>>, + }, +} + +impl Default for InputMode { + fn default() -> Self { + InputMode::plain_text() + } } #[allow(unused)] impl InputMode { + /// Create a plain input mode with default settings. + pub(super) fn plain_text() -> Self { + InputMode::PlainText { + multi_line: false, + tab: TabSize::default(), + rows: 1, + } + } + + /// Create a code editor input mode with default settings. + pub(super) fn code_editor(language: impl Into) -> Self { + InputMode::CodeEditor { + rows: 2, + multi_line: true, + tab: TabSize::default(), + language: language.into(), + line_number: true, + indent_guides: true, + folding: true, + parse_task: Rc::new(RefCell::new(None)), + } + } + + /// Create an auto grow input mode with given min and max rows. + pub(super) fn auto_grow(min_rows: usize, max_rows: usize) -> Self { + InputMode::AutoGrow { + rows: min_rows, + min_rows, + max_rows, + } + } + + pub(super) fn multi_line(mut self, multi_line: bool) -> Self { + match &mut self { + InputMode::PlainText { multi_line: ml, .. } => *ml = multi_line, + InputMode::CodeEditor { multi_line: ml, .. } => *ml = multi_line, + InputMode::AutoGrow { .. } => {} + } + self + } + #[inline] pub(super) fn is_single_line(&self) -> bool { - matches!(self, InputMode::SingleLine) + !self.is_multi_line() + } + + #[inline] + pub(super) fn is_code_editor(&self) -> bool { + matches!(self, InputMode::CodeEditor { .. }) + } + + /// Return true if the mode is code editor and `folding: true`, `multi_line: true`. + #[inline] + pub(crate) fn is_folding(&self) -> bool { + if cfg!(target_family = "wasm") { + return false; + } + + matches!( + self, + InputMode::CodeEditor { + folding: true, + multi_line: true, + .. + } + ) } #[inline] @@ -58,15 +126,19 @@ impl InputMode { #[inline] pub(super) fn is_multi_line(&self) -> bool { - matches!( - self, - InputMode::MultiLine { .. } | InputMode::AutoGrow { .. } - ) + match self { + InputMode::PlainText { multi_line, .. } => *multi_line, + InputMode::CodeEditor { multi_line, .. } => *multi_line, + InputMode::AutoGrow { max_rows, .. } => *max_rows > 1, + } } pub(super) fn set_rows(&mut self, new_rows: usize) { match self { - InputMode::MultiLine { rows, .. } => { + InputMode::PlainText { rows, .. } => { + *rows = new_rows; + } + InputMode::CodeEditor { rows, .. } => { *rows = new_rows; } InputMode::AutoGrow { @@ -76,25 +148,28 @@ impl InputMode { } => { *rows = new_rows.clamp(*min_rows, *max_rows); } - _ => {} } } - pub(super) fn update_auto_grow(&mut self, text_wrapper: &TextWrapper) { + pub(super) fn update_auto_grow(&mut self, display_map: &DisplayMap) { if self.is_single_line() { return; } - let wrapped_lines = text_wrapper.len(); + let wrapped_lines = display_map.wrap_row_count(); self.set_rows(wrapped_lines); } /// At least 1 row be return. pub(super) fn rows(&self) -> usize { + if !self.is_multi_line() { + return 1; + } + match self { - InputMode::MultiLine { rows, .. } => *rows, + InputMode::PlainText { rows, .. } => *rows, + InputMode::CodeEditor { rows, .. } => *rows, InputMode::AutoGrow { rows, .. } => *rows, - _ => 1, } .max(1) } @@ -103,7 +178,6 @@ impl InputMode { #[allow(unused)] pub(super) fn min_rows(&self) -> usize { match self { - InputMode::MultiLine { .. } => 1, InputMode::AutoGrow { min_rows, .. } => *min_rows, _ => 1, } @@ -112,18 +186,26 @@ impl InputMode { #[allow(unused)] pub(super) fn max_rows(&self) -> usize { + if !self.is_multi_line() { + return 1; + } + match self { - InputMode::MultiLine { .. } => usize::MAX, InputMode::AutoGrow { max_rows, .. } => *max_rows, - _ => 1, + _ => usize::MAX, } } + /// Return false if the mode is not [`InputMode::CodeEditor`]. #[inline] - pub(super) fn tab_size(&self) -> Option<&TabSize> { + pub(super) fn line_number(&self) -> bool { match self { - InputMode::MultiLine { tab, .. } => Some(tab), - _ => None, + InputMode::CodeEditor { + line_number, + multi_line, + .. + } => *line_number && *multi_line, + _ => false, } } } diff --git a/crates/ui/src/input/movement.rs b/crates/ui/src/input/movement.rs new file mode 100644 index 0000000..5e6160f --- /dev/null +++ b/crates/ui/src/input/movement.rs @@ -0,0 +1,264 @@ +use gpui::{Context, Point, Window}; + +use crate::input::{ + InputState, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, + MoveToEnd, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveUp, RopeExt as _, +}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum MoveDirection { + Up, + Down, +} + +impl InputState { + /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. + pub(super) fn update_preferred_column(&mut self) { + let Some(last_layout) = &self.last_layout else { + self.preferred_column = None; + return; + }; + + let point = self.text.offset_to_point(self.cursor()); + let Some(line) = last_layout.line(point.row) else { + self.preferred_column = None; + return; + }; + + let Some(pos) = line.position_for_index(point.column, last_layout, false) else { + self.preferred_column = None; + return; + }; + + self.preferred_column = Some((pos.x, point.column)); + } + + /// Move the cursor to the given offset. + /// + /// The offset is the UTF-8 offset. + /// + /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. + pub(crate) fn move_to( + &mut self, + offset: usize, + direction: Option, + cx: &mut Context, + ) { + let offset = offset.clamp(0, self.text.len()); + self.cursor_line_end_affinity = false; + self.selected_range = (offset..offset).into(); + self.scroll_to(offset, direction, cx); + self.pause_blink_cursor(cx); + self.update_preferred_column(); + cx.notify() + } + + /// Move the cursor vertically by one line (up or down) while preserving the column if possible. + /// + /// move_lines: Number of lines to move vertically (positive for down, negative for up). + pub(super) fn move_vertical( + &mut self, + move_lines: isize, + _: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() { + return; + } + let Some(last_layout) = &self.last_layout else { + return; + }; + + let offset = self.cursor(); + let was_preferred_column = self.preferred_column; + + let mut display_point = self.display_map.offset_to_wrap_display_point(offset); + + // Convert wrap row → display row (skips folded rows), move, then convert back + let current_display_row = self + .display_map + .wrap_row_to_display_row(display_point.row) + .unwrap_or_else(|| { + self.display_map + .nearest_visible_display_row(display_point.row) + }); + let max_display_row = self.display_map.display_row_count().saturating_sub(1); + let target_display_row = current_display_row + .saturating_add_signed(move_lines) + .min(max_display_row); + let target_wrap_row = self + .display_map + .display_row_to_wrap_row(target_display_row) + .unwrap_or(display_point.row); + + display_point.row = target_wrap_row; + display_point.column = 0; + let mut new_offset = self.display_map.wrap_display_point_to_offset(display_point); + + if let Some((preferred_x, column)) = was_preferred_column { + // Get display point again to update local_row. + let mut next_display_point = self.display_map.offset_to_wrap_display_point(new_offset); + next_display_point.column = 0; + let next_point = self + .display_map + .wrap_display_point_to_point(next_display_point); + let line_start_offset = self.text.line_start_offset(next_point.row); + + // If in visible range, prefer to use position to get column. + if let Some(line) = last_layout.line(next_point.row) { + if let Some(x) = line.closest_index_for_position( + Point { + x: preferred_x, + y: next_display_point.local_row * last_layout.line_height, + }, + last_layout, + ) { + new_offset = line_start_offset + x; + } + } else { + // Not in visible range, use column directly. + let max_line_len = self.text.slice_line(next_point.row).len(); + new_offset = line_start_offset + column.min(max_line_len); + } + } + + self.pause_blink_cursor(cx); + let direction = if move_lines < 0 { + MoveDirection::Up + } else { + MoveDirection::Down + }; + self.move_to(new_offset, Some(direction), cx); + // Set back the preferred_column + self.preferred_column = was_preferred_column; + cx.notify(); + } + + pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context) { + self.pause_blink_cursor(cx); + if self.selected_range.is_empty() { + self.move_to(self.previous_boundary(self.cursor()), None, cx); + } else { + self.move_to(self.selected_range.start, None, cx) + } + } + + pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context) { + self.pause_blink_cursor(cx); + if self.selected_range.is_empty() { + self.move_to(self.next_boundary(self.selected_range.end), None, cx); + } else { + self.move_to(self.selected_range.end, None, cx) + } + } + + pub(super) fn up(&mut self, _action: &MoveUp, window: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { + return; + } + + if !self.selected_range.is_empty() { + self.move_to( + self.previous_boundary(self.selected_range.start.saturating_sub(1)), + Some(MoveDirection::Up), + cx, + ); + } + self.pause_blink_cursor(cx); + self.move_vertical(-1, window, cx); + } + + pub(super) fn down(&mut self, _action: &MoveDown, window: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { + return; + } + + if !self.selected_range.is_empty() { + self.move_to( + self.next_boundary(self.selected_range.end.saturating_sub(1)), + Some(MoveDirection::Down), + cx, + ); + } + + self.pause_blink_cursor(cx); + self.move_vertical(1, window, cx); + } + + pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context) { + if self.mode.is_single_line() { + return; + } + + let Some(last_layout) = &self.last_layout else { + return; + }; + + let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; + self.move_vertical(-display_lines, window, cx); + } + + pub(super) fn page_down( + &mut self, + _: &MovePageDown, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() { + return; + } + + let Some(last_layout) = &self.last_layout else { + return; + }; + + let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; + self.move_vertical(display_lines, window, cx); + } + + pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { + self.pause_blink_cursor(cx); + let offset = self.start_of_line(); + self.move_to(offset, Some(MoveDirection::Up), cx); + } + + pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { + self.pause_blink_cursor(cx); + let offset = self.end_of_line(); + self.move_to(offset, Some(MoveDirection::Down), cx); + self.cursor_line_end_affinity = true; + } + + pub(super) fn move_to_start( + &mut self, + _: &MoveToStart, + _: &mut Window, + cx: &mut Context, + ) { + self.move_to(0, None, cx); + } + + pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.text.len(), None, cx); + } + + pub(super) fn move_to_previous_word( + &mut self, + _: &MoveToPreviousWord, + _: &mut Window, + cx: &mut Context, + ) { + let offset = self.previous_start_of_word(); + self.move_to(offset, None, cx); + } + + pub(super) fn move_to_next_word( + &mut self, + _: &MoveToNextWord, + _: &mut Window, + cx: &mut Context, + ) { + let offset = self.next_end_of_word(); + self.move_to(offset, None, cx); + } +} diff --git a/crates/ui/src/input/popovers/code_action_menu.rs b/crates/ui/src/input/popovers/code_action_menu.rs new file mode 100644 index 0000000..9e2ab5b --- /dev/null +++ b/crates/ui/src/input/popovers/code_action_menu.rs @@ -0,0 +1,337 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{ + Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter, + InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, RenderOnce, + SharedString, Styled, StyledText, Subscription, Window, deferred, div, px, relative, +}; +use lsp_types::CodeAction; +use theme::ActiveTheme; + +const MAX_MENU_WIDTH: Pixels = px(320.); +const MAX_MENU_HEIGHT: Pixels = px(480.); + +use crate::input::popovers::editor_popover; +use crate::input::{self, InputState}; +use crate::list::{List, ListDelegate, ListEvent, ListState}; +use crate::{IndexPath, Selectable, actions, h_flex}; + +#[derive(Debug, Clone)] +pub(crate) struct CodeActionItem { + /// The `id` of the `CodeActionProvider` that provided this item. + pub(crate) provider_id: SharedString, + pub(crate) action: CodeAction, +} + +struct MenuDelegate { + menu: Entity, + items: Vec>, + selected_ix: usize, +} + +impl MenuDelegate { + fn set_items(&mut self, items: Vec) { + self.items = items.into_iter().map(Rc::new).collect(); + self.selected_ix = 0; + } + + fn selected_item(&self) -> Option<&Rc> { + self.items.get(self.selected_ix) + } +} + +#[derive(IntoElement)] +struct MenuItem { + ix: usize, + item: Rc, + children: Vec, + selected: bool, +} + +impl MenuItem { + fn new(ix: usize, item: Rc) -> Self { + Self { + ix, + item, + children: vec![], + selected: false, + } + } +} +impl Selectable for MenuItem { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl ParentElement for MenuItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} +impl RenderOnce for MenuItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let item = self.item; + + let highlights = vec![]; + + h_flex() + .id(self.ix) + .gap_2() + .p_1() + .text_xs() + .line_height(relative(1.)) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().secondary_hover)) + .when(self.selected, |this| { + this.bg(cx.theme().secondary_background) + .text_color(cx.theme().secondary_foreground) + }) + .child( + div().child(StyledText::new(item.action.title.clone()).with_highlights(highlights)), + ) + .children(self.children) + } +} + +impl EventEmitter for MenuDelegate {} + +impl ListDelegate for MenuDelegate { + type Item = MenuItem; + + fn items_count(&self, _: usize, _: &gpui::App) -> usize { + self.items.len() + } + + fn render_item( + &mut self, + ix: crate::IndexPath, + _: &mut Window, + _: &mut Context>, + ) -> Option { + let item = self.items.get(ix.row)?; + Some(MenuItem::new(ix.row, item.clone())) + } + + fn set_selected_index( + &mut self, + ix: Option, + _: &mut Window, + cx: &mut Context>, + ) { + self.selected_ix = ix.map(|i| i.row).unwrap_or(0); + cx.notify(); + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + let Some(item) = self.selected_item() else { + return; + }; + + self.menu.update(cx, |this, cx| { + this.select_item(&item, window, cx); + }); + } +} + +/// A context menu for code completions and code actions. +pub struct CodeActionMenu { + offset: usize, + state: Entity, + list: Entity>, + open: bool, + + _subscriptions: Vec, +} + +impl CodeActionMenu { + /// Creates a new `CompletionMenu` with the given offset and completion items. + /// + /// NOTE: This element should not call from InputState::new, unless that will stack overflow. + pub(crate) fn new( + state: Entity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + cx.new(|cx| { + let view = cx.entity(); + let menu = MenuDelegate { + menu: view, + items: vec![], + selected_ix: 0, + }; + + let list = cx.new(|cx| ListState::new(menu, window, cx)); + + let _subscriptions = + vec![ + cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| { + match ev { + ListEvent::Confirm(_) => { + this.hide(cx); + } + _ => {} + } + cx.notify(); + }), + ]; + + Self { + offset: 0, + state, + list, + open: false, + _subscriptions, + } + }) + } + + fn select_item(&mut self, item: &CodeActionItem, window: &mut Window, cx: &mut Context) { + let state = self.state.clone(); + let item = item.clone(); + + cx.spawn_in(window, { + async move |_, cx| { + state.update_in(cx, |state, window, cx| { + state.perform_code_action(&item, window, cx); + }) + } + }) + .detach(); + + self.hide(cx); + } + + pub(crate) fn handle_action( + &mut self, + action: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.open { + return false; + } + + cx.propagate(); + if input::Enter::is_primary(&*action) { + self.on_action_enter(window, cx); + } else if action.partial_eq(&input::Escape) { + self.on_action_escape(window, cx); + } else if action.partial_eq(&input::MoveUp) { + self.on_action_up(window, cx); + } else if action.partial_eq(&input::MoveDown) { + self.on_action_down(window, cx); + } else { + return false; + } + + true + } + + fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context) { + let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else { + return; + }; + self.select_item(&item, window, cx); + } + + fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context) { + self.hide(cx); + } + + fn on_action_up(&mut self, window: &mut Window, cx: &mut Context) { + self.list.update(cx, |this, cx| { + this.on_action_select_prev(&actions::SelectUp, window, cx) + }); + } + + fn on_action_down(&mut self, window: &mut Window, cx: &mut Context) { + self.list.update(cx, |this, cx| { + this.on_action_select_next(&actions::SelectDown, window, cx) + }); + } + + pub(crate) fn is_open(&self) -> bool { + self.open + } + + /// Hide the completion menu and reset the trigger start offset. + pub(crate) fn hide(&mut self, cx: &mut Context) { + self.open = false; + cx.notify(); + } + + pub(crate) fn show( + &mut self, + offset: usize, + items: impl Into>, + window: &mut Window, + cx: &mut Context, + ) { + let items = items.into(); + self.offset = offset; + self.open = true; + self.list.update(cx, |this, cx| { + this.delegate_mut().set_items(items); + this.set_selected_index(Some(IndexPath::new(0)), window, cx); + }); + + cx.notify(); + } + + fn origin(&self, cx: &App) -> Option> { + let state = self.state.read(cx); + let Some(last_layout) = state.last_layout.as_ref() else { + return None; + }; + let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else { + return None; + }; + + let scroll_origin = self.state.read(cx).scroll_handle.offset(); + + Some( + scroll_origin + cursor_origin - state.input_bounds.origin + + Point::new(-px(4.), last_layout.line_height + px(4.)), + ) + } +} + +impl Render for CodeActionMenu { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.open { + return Empty.into_any_element(); + } + + if self.list.read(cx).delegate().items.is_empty() { + self.open = false; + return Empty.into_any_element(); + } + + let Some(pos) = self.origin(cx) else { + return Empty.into_any_element(); + }; + + let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x); + + deferred( + editor_popover("code-action-menu", cx) + .absolute() + .left(pos.x) + .top(pos.y) + .max_w(max_width) + .min_w(px(120.)) + .child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)) + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.hide(cx); + })), + ) + .into_any_element() + } +} diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs new file mode 100644 index 0000000..c9a0933 --- /dev/null +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -0,0 +1,446 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{ + Action, AnyElement, App, AppContext, Context, DismissEvent, Empty, Entity, EventEmitter, + Half as _, HighlightStyle, InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, + Render, RenderOnce, SharedString, Styled, StyledText, Subscription, Window, deferred, div, px, + relative, +}; +use lsp_types::{CompletionItem, CompletionTextEdit}; +use theme::ActiveTheme; + +const MAX_MENU_WIDTH: Pixels = px(320.); +const MAX_MENU_HEIGHT: Pixels = px(240.); +const POPOVER_GAP: Pixels = px(4.); + +use crate::input::popovers::{editor_popover, render_markdown}; +use crate::input::{self, InputState, RopeExt}; +use crate::list::{List, ListDelegate, ListEvent, ListState}; +use crate::{IndexPath, Selectable, actions, h_flex}; + +struct ContextMenuDelegate { + query: SharedString, + menu: Entity, + items: Vec>, + selected_ix: usize, +} + +impl ContextMenuDelegate { + fn set_items(&mut self, items: Vec) { + self.items = items.into_iter().map(Rc::new).collect(); + self.selected_ix = 0; + } + + fn selected_item(&self) -> Option<&Rc> { + self.items.get(self.selected_ix) + } +} + +#[derive(IntoElement)] +struct CompletionMenuItem { + ix: usize, + item: Rc, + children: Vec, + selected: bool, + highlight_prefix: SharedString, +} + +impl CompletionMenuItem { + fn new(ix: usize, item: Rc) -> Self { + Self { + ix, + item, + children: vec![], + selected: false, + highlight_prefix: "".into(), + } + } + + fn highlight_prefix(mut self, s: impl Into) -> Self { + self.highlight_prefix = s.into(); + self + } +} +impl Selectable for CompletionMenuItem { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl ParentElement for CompletionMenuItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl RenderOnce for CompletionMenuItem { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let item = self.item; + + let matched_len = item + .filter_text + .as_ref() + .map(|s| s.len()) + .unwrap_or(self.highlight_prefix.len()) + .min(item.label.len()); + + let highlights = vec![( + 0..matched_len, + HighlightStyle { + color: Some(cx.theme().selection), + ..Default::default() + }, + )]; + + h_flex() + .id(self.ix) + .gap_2() + .p_1() + .text_xs() + .line_height(relative(1.)) + .rounded(cx.theme().radius.half()) + .when(item.deprecated.unwrap_or(false), |this| this.line_through()) + .hover(|this| this.bg(cx.theme().secondary_hover)) + .when(self.selected, |this| { + this.bg(cx.theme().secondary_background) + .text_color(cx.theme().secondary_foreground) + }) + .child(div().child(StyledText::new(item.label.clone()).with_highlights(highlights))) + .children(self.children) + } +} + +impl EventEmitter for ContextMenuDelegate {} + +impl ListDelegate for ContextMenuDelegate { + type Item = CompletionMenuItem; + + fn items_count(&self, _: usize, _: &gpui::App) -> usize { + self.items.len() + } + + fn render_item( + &mut self, + ix: crate::IndexPath, + _: &mut Window, + _: &mut Context>, + ) -> Option { + let item = self.items.get(ix.row)?; + Some(CompletionMenuItem::new(ix.row, item.clone()).highlight_prefix(self.query.clone())) + } + + fn set_selected_index( + &mut self, + ix: Option, + _: &mut Window, + cx: &mut Context>, + ) { + self.selected_ix = ix.map(|i| i.row).unwrap_or(0); + cx.notify(); + } + + fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { + let Some(item) = self.selected_item() else { + return; + }; + + self.menu.update(cx, |this, cx| { + this.select_item(&item, window, cx); + }); + } +} + +/// A context menu for code completions and code actions. +pub struct CompletionMenu { + offset: usize, + editor: Entity, + list: Entity>, + open: bool, + + /// The offset of the first character that triggered the completion. + pub(crate) trigger_start_offset: Option, + query: SharedString, + _subscriptions: Vec, +} + +impl CompletionMenu { + /// Creates a new `CompletionMenu` with the given offset and completion items. + /// + /// NOTE: This element should not call from InputState::new, unless that will stack overflow. + pub(crate) fn new( + editor: Entity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + cx.new(|cx| { + let view = cx.entity(); + let menu = ContextMenuDelegate { + query: SharedString::default(), + menu: view, + items: vec![], + selected_ix: 0, + }; + + let list = cx.new(|cx| ListState::new(menu, window, cx)); + + let _subscriptions = + vec![ + cx.subscribe(&list, |this: &mut Self, _, ev: &ListEvent, cx| { + match ev { + ListEvent::Confirm(_) => { + this.hide(cx); + } + _ => {} + } + cx.notify(); + }), + ]; + + Self { + offset: 0, + editor, + list, + open: false, + trigger_start_offset: None, + query: SharedString::default(), + _subscriptions, + } + }) + } + + fn select_item(&mut self, item: &CompletionItem, window: &mut Window, cx: &mut Context) { + let offset = self.offset; + let item = item.clone(); + let mut range = self.trigger_start_offset.unwrap_or(self.offset)..self.offset; + + let editor = self.editor.clone(); + + cx.spawn_in(window, async move |_, cx| { + editor.update_in(cx, |editor, window, cx| { + editor.completion_inserting = true; + + let mut new_text = item.label.clone(); + if let Some(text_edit) = item.text_edit.as_ref() { + match text_edit { + CompletionTextEdit::Edit(edit) => { + new_text = edit.new_text.clone(); + range.start = editor.text.position_to_offset(&edit.range.start); + range.end = editor.text.position_to_offset(&edit.range.end); + } + CompletionTextEdit::InsertAndReplace(edit) => { + new_text = edit.new_text.clone(); + range.start = editor.text.position_to_offset(&edit.replace.start); + range.end = editor.text.position_to_offset(&edit.replace.end); + } + } + } else if let Some(insert_text) = item.insert_text.clone() { + new_text = insert_text; + range = offset..offset; + } + + editor.replace_text_in_range_silent( + Some(editor.range_to_utf16(&range)), + &new_text, + window, + cx, + ); + editor.completion_inserting = false; + // FIXME: Input not get the focus + editor.focus(window, cx); + }) + }) + .detach(); + + self.hide(cx); + } + + pub(crate) fn handle_action( + &mut self, + action: Box, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.open { + return false; + } + + cx.propagate(); + if input::Enter::is_primary(&*action) { + self.on_action_enter(window, cx); + } else if action.partial_eq(&input::Escape) { + self.on_action_escape(window, cx); + } else if action.partial_eq(&input::MoveUp) { + self.on_action_up(window, cx); + } else if action.partial_eq(&input::MoveDown) { + self.on_action_down(window, cx); + } else { + return false; + } + + true + } + + fn on_action_enter(&mut self, window: &mut Window, cx: &mut Context) { + let Some(item) = self.list.read(cx).delegate().selected_item().cloned() else { + return; + }; + self.select_item(&item, window, cx); + } + + fn on_action_escape(&mut self, _: &mut Window, cx: &mut Context) { + self.hide(cx); + } + + fn on_action_up(&mut self, window: &mut Window, cx: &mut Context) { + self.list.update(cx, |this, cx| { + this.on_action_select_prev(&actions::SelectUp, window, cx) + }); + } + + fn on_action_down(&mut self, window: &mut Window, cx: &mut Context) { + self.list.update(cx, |this, cx| { + this.on_action_select_next(&actions::SelectDown, window, cx) + }); + } + + pub(crate) fn is_open(&self) -> bool { + self.open + } + + /// Hide the completion menu and reset the trigger start offset. + pub(crate) fn hide(&mut self, cx: &mut Context) { + self.open = false; + self.trigger_start_offset = None; + cx.notify(); + } + + /// Sets the trigger start offset if it is not already set. + pub(crate) fn update_query(&mut self, start_offset: usize, query: impl Into) { + if self.trigger_start_offset.is_none() { + self.trigger_start_offset = Some(start_offset); + } + self.query = query.into(); + } + + pub(crate) fn show( + &mut self, + offset: usize, + items: impl Into>, + window: &mut Window, + cx: &mut Context, + ) { + let items = items.into(); + self.offset = offset; + self.open = true; + self.list.update(cx, |this, cx| { + let longest_ix = items + .iter() + .enumerate() + .max_by_key(|(_, item)| { + item.label.len() + item.detail.as_ref().map(|d| d.len()).unwrap_or(0) + }) + .map(|(ix, _)| ix) + .unwrap_or(0); + + this.delegate_mut().query = self.query.clone(); + this.delegate_mut().set_items(items); + this.set_selected_index(Some(IndexPath::new(0)), window, cx); + this.set_item_to_measure_index(IndexPath::new(longest_ix), window, cx); + }); + + cx.notify(); + } + + fn origin(&self, cx: &App) -> Option> { + let editor = self.editor.read(cx); + let Some(last_layout) = editor.last_layout.as_ref() else { + return None; + }; + let Some(cursor_origin) = last_layout.cursor_bounds.map(|b| b.origin) else { + return None; + }; + + let scroll_origin = self.editor.read(cx).scroll_handle.offset(); + + Some( + scroll_origin + cursor_origin - editor.input_bounds.origin + + Point::new(-px(4.), last_layout.line_height + px(4.)), + ) + } +} + +impl Render for CompletionMenu { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.open { + return Empty.into_any_element(); + } + + if self.list.read(cx).delegate().items.is_empty() { + self.open = false; + return Empty.into_any_element(); + } + + let Some(pos) = self.origin(cx) else { + return Empty.into_any_element(); + }; + + let selected_documentation = self + .list + .read(cx) + .delegate() + .selected_item() + .and_then(|item| item.documentation.clone()); + + let max_width = MAX_MENU_WIDTH.min(window.bounds().size.width - pos.x); + let abs_pos = self.editor.read(cx).input_bounds.origin + pos; + let vertical_layout = + abs_pos.x + MAX_MENU_WIDTH + POPOVER_GAP + MAX_MENU_WIDTH + POPOVER_GAP + > window.bounds().size.width; + + deferred( + div() + .absolute() + .left(pos.x) + .top(pos.y) + .flex() + .flex_row() + .gap(POPOVER_GAP) + .items_start() + .when(vertical_layout, |this| this.flex_col()) + .child( + editor_popover("completion-menu", cx) + .max_w(max_width) + .min_w(px(120.)) + .child(List::new(&self.list).max_h(MAX_MENU_HEIGHT)), + ) + .when_some(selected_documentation, |this, documentation| { + let mut doc = match documentation { + lsp_types::Documentation::String(s) => s.clone(), + lsp_types::Documentation::MarkupContent(mc) => mc.value.clone(), + }; + if vertical_layout { + doc = doc.split("\n").next().unwrap_or_default().to_string(); + } + + this.child( + div().child( + editor_popover("completion-menu", cx) + .w(MAX_MENU_WIDTH) + .px_2() + .child(render_markdown("doc", doc, window, cx)), + ), + ) + }) + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.hide(cx); + })), + ) + .into_any_element() + } +} diff --git a/crates/ui/src/input/popovers/context_menu.rs b/crates/ui/src/input/popovers/context_menu.rs new file mode 100644 index 0000000..980bf91 --- /dev/null +++ b/crates/ui/src/input/popovers/context_menu.rs @@ -0,0 +1,142 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Anchor, App, AppContext as _, Context, DismissEvent, Entity, IntoElement, MouseDownEvent, + ParentElement as _, Pixels, Point, Render, Styled, Subscription, Window, anchored, deferred, + div, px, +}; + +use crate::input::popovers::ContextMenu; +use crate::input::{self, InputState}; +use crate::menu::PopupMenu; + +/// Context menu for mouse right clicks. +pub(crate) struct InputContextMenu { + editor: Entity, + menu: Entity, + mouse_position: Point, + open: bool, + + _subscriptions: Vec, +} + +impl InputState { + pub(crate) fn handle_right_click_menu( + &mut self, + event: &MouseDownEvent, + offset: usize, + window: &mut Window, + cx: &mut Context, + ) { + // Show Mouse context menu + if !self.selected_range.contains(offset) { + self.move_to(offset, None, cx); + } + + self.context_menu_content = Some(ContextMenu::RightClick(self.context_menu.clone())); + + let is_code_editor = self.mode.is_code_editor(); + if is_code_editor { + self.handle_hover_definition(offset, window, cx); + } + + let is_enable = !self.disabled; + let has_goto_definition = is_enable && self.lsp.definition_provider.is_some(); + let has_code_action = is_enable && !self.lsp.code_action_providers.is_empty(); + let is_selected = !self.selected_range.is_empty(); + let has_paste = is_enable && cx.read_from_clipboard().is_some(); + + let action_context = self.focus_handle.clone(); + self.context_menu.update(cx, |this, cx| { + this.mouse_position = event.position; + this.menu.update(cx, |menu, cx| { + let new_menu = if let Some(builder) = &self.context_menu_builder { + builder(PopupMenu::new(cx), window, cx) + } else { + PopupMenu::new(cx) + .when(is_code_editor, |m| { + m.menu_with_enable( + "Go to Definition", + Box::new(input::GoToDefinition), + has_goto_definition, + ) + .menu_with_enable( + "Show Code Actions", + Box::new(input::ToggleCodeActions), + has_code_action, + ) + .separator() + }) + .menu_with_enable("Cut", Box::new(input::Cut), is_enable && is_selected) + .menu_with_enable("Copy", Box::new(input::Copy), is_selected) + .menu_with_enable("Paste", Box::new(input::Paste), has_paste) + .separator() + .menu("Select All", Box::new(input::SelectAll)) + }; + + menu.menu_items = new_menu.menu_items; + menu.action_context = Some(action_context); + cx.notify(); + }); + cx.defer_in(window, |this, _, cx| { + this.open = true; + cx.notify(); + }); + }); + } +} + +impl InputContextMenu { + pub(crate) fn new( + editor: Entity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + cx.new(|cx| { + let menu = cx.new(|cx| PopupMenu::new(cx).small()); + + let _subscriptions = vec![cx.subscribe_in(&menu, window, { + move |this: &mut Self, _, _: &DismissEvent, window, cx| { + this.close(window, cx); + } + })]; + + Self { + editor, + menu, + mouse_position: Point::default(), + open: false, + _subscriptions, + } + }) + } + + #[inline] + pub(crate) fn is_open(&self) -> bool { + self.open + } + + #[inline] + pub(crate) fn close(&mut self, window: &mut Window, cx: &mut Context) { + self.open = false; + self.editor.update(cx, |this, cx| { + this.focus(window, cx); + }); + } +} + +impl Render for InputContextMenu { + fn render(&mut self, _: &mut Window, _cx: &mut Context) -> impl IntoElement { + if !self.open { + return div().into_any_element(); + } + + deferred( + anchored() + .snap_to_window_with_margin(px(8.)) + .anchor(Anchor::TopLeft) + .position(self.mouse_position) + .child(div().cursor_default().child(self.menu.clone())), + ) + .into_any_element() + } +} diff --git a/crates/ui/src/input/popovers/diagnostic_popover.rs b/crates/ui/src/input/popovers/diagnostic_popover.rs new file mode 100644 index 0000000..84631e3 --- /dev/null +++ b/crates/ui/src/input/popovers/diagnostic_popover.rs @@ -0,0 +1,95 @@ +use std::rc::Rc; + +use gpui::{ + prelude::FluentBuilder as _, px, App, AppContext as _, Bounds, Context, Empty, Entity, + IntoElement, Pixels, Point, Render, Styled, Window, +}; + +use crate::{ + highlighter::DiagnosticEntry, + input::{ + popovers::{render_markdown, Popover}, + InputState, + }, +}; + +pub struct DiagnosticPopover { + state: Entity, + pub(crate) diagnostic: Rc, + bounds: Bounds, + open: bool, +} + +impl DiagnosticPopover { + pub fn new( + diagnostic: &DiagnosticEntry, + state: Entity, + cx: &mut App, + ) -> Entity { + let diagnostic = Rc::new(diagnostic.clone()); + + cx.new(|_| Self { + diagnostic, + state, + bounds: Bounds::default(), + open: true, + }) + } + + pub(crate) fn show(&mut self, cx: &mut Context) { + self.open = true; + cx.notify(); + } + + pub(crate) fn hide(&mut self, cx: &mut Context) { + self.open = false; + cx.notify(); + } + + pub(crate) fn check_to_hide(&mut self, mouse_position: Point, cx: &mut Context) { + if !self.open { + return; + } + + let padding = px(5.); + let bounds = Bounds { + origin: self.bounds.origin.map(|v| v - padding), + size: self.bounds.size.map(|v| v + padding * 2.), + }; + + if !bounds.contains(&mouse_position) { + self.hide(cx); + } + } +} + +impl Render for DiagnosticPopover { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.open { + return Empty.into_any_element(); + } + + let message = self.diagnostic.message.clone(); + + let (border, bg, fg) = ( + self.diagnostic.severity.border(cx), + self.diagnostic.severity.bg(cx), + self.diagnostic.severity.fg(cx), + ); + + Popover::new( + "diagnostic-popover", + self.state.clone(), + self.diagnostic.range.clone(), + move |window, cx| render_markdown("message", message.clone(), window, cx), + ) + .when(!self.open, |this| this.invisible()) + .px_1() + .py_0p5() + .bg(bg) + .text_color(fg) + .border_1() + .border_color(border) + .into_any_element() + } +} diff --git a/crates/ui/src/input/popovers/hover_popover.rs b/crates/ui/src/input/popovers/hover_popover.rs new file mode 100644 index 0000000..96d2c3b --- /dev/null +++ b/crates/ui/src/input/popovers/hover_popover.rs @@ -0,0 +1,292 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + AnyElement, App, AppContext as _, AvailableSpace, Bounds, Element, ElementId, Entity, + InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, ParentElement as _, Pixels, + Render, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, deferred, div, point, + px, +}; + +use crate::{ + StyledExt, + input::{InputState, popovers::render_markdown}, +}; + +pub struct HoverPopover { + editor: Entity, + /// The symbol range byte of the hover trigger. + pub(crate) symbol_range: Range, + pub(crate) hover: Rc, +} + +impl HoverPopover { + pub fn new( + editor: Entity, + symbol_range: Range, + hover: &lsp_types::Hover, + cx: &mut App, + ) -> Entity { + let hover = Rc::new(hover.clone()); + + cx.new(|_| Self { + editor, + symbol_range, + hover, + }) + } + + pub(crate) fn is_same(&self, offset: usize) -> bool { + self.symbol_range.contains(&offset) + } +} + +impl Render for HoverPopover { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + let contents = match self.hover.contents.clone() { + lsp_types::HoverContents::Scalar(scalar) => match scalar { + lsp_types::MarkedString::String(s) => s, + lsp_types::MarkedString::LanguageString(ls) => ls.value, + }, + lsp_types::HoverContents::Array(arr) => arr + .into_iter() + .map(|item| match item { + lsp_types::MarkedString::String(s) => s, + lsp_types::MarkedString::LanguageString(ls) => ls.value, + }) + .collect::>() + .join("\n\n"), + lsp_types::HoverContents::Markup(markup) => markup.value, + }; + + Popover::new( + "hover-popover", + self.editor.clone(), + self.symbol_range.clone(), + move |window, cx| render_markdown("message", contents.clone(), window, cx), + ) + .into_any_element() + } +} + +pub(crate) struct Popover { + id: ElementId, + style: StyleRefinement, + editor: Entity, + range: Range, + width_limit: Range, + content_builder: Box AnyElement>, +} + +impl Styled for Popover { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl Popover { + pub fn new( + id: impl Into, + editor: Entity, + range: Range, + f: F, + ) -> Self + where + F: Fn(&mut Window, &mut App) -> E + 'static, + E: IntoElement, + { + Self { + id: id.into(), + editor, + range, + style: StyleRefinement::default(), + width_limit: px(200.)..px(500.), + content_builder: Box::new(move |window, cx| (f)(window, cx).into_any_element()), + } + } + + /// Get the bounds of the range in the editor, if it is visible. + fn trigger_bounds(&self, cx: &App) -> Option> { + let editor = self.editor.read(cx); + let Some(last_layout) = editor.last_layout.as_ref() else { + return None; + }; + + let Some(last_bounds) = editor.last_bounds else { + return None; + }; + + let (_, _, start_pos) = editor.line_and_position_for_offset(self.range.start); + let (_, _, end_pos) = editor.line_and_position_for_offset(self.range.end); + + let Some(start_pos) = start_pos else { + return None; + }; + let Some(end_pos) = end_pos else { + return None; + }; + + Some(Bounds::from_corners( + last_bounds.origin + start_pos, + last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), + )) + } +} + +impl IntoElement for Popover { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +pub(crate) struct PopoverLayoutState { + bounds: Bounds, + element: Option, +} + +impl Element for Popover { + type RequestLayoutState = PopoverLayoutState; + type PrepaintState = (); + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let trigger_bounds = match self.trigger_bounds(cx) { + Some(bounds) => bounds, + None => { + return ( + div().into_any_element().request_layout(window, cx), + PopoverLayoutState { + bounds: Bounds::default(), + element: None, + }, + ); + } + }; + + let max_width = self + .width_limit + .end + .min(window.bounds().size.width - SNAP_TO_EDGE * 2) + .max(px(200.)); + let max_height = (window.bounds().size.height - SNAP_TO_EDGE * 2).min(px(320.)); + + let mut popover = deferred( + div() + .id("hover-popover-content") + .flex_none() + .occlude() + .p_1() + .text_xs() + .popover_style(cx) + .shadow_md() + .max_w(max_width) + .max_h(max_height) + .overflow_y_scroll() + .refine_style(&self.style) + .child((self.content_builder)(window, cx)), + ) + .into_any_element(); + + let popover_size = popover.layout_as_root(AvailableSpace::min_size(), window, cx); + const SNAP_TO_EDGE: Pixels = px(8.); + let top_space = trigger_bounds.top() - SNAP_TO_EDGE; + let right_space = window.bounds().size.width - trigger_bounds.left() - SNAP_TO_EDGE; + + let mut pos = point( + trigger_bounds.left(), + trigger_bounds.top() - popover_size.height, + ); + if popover_size.height > top_space { + pos.y = trigger_bounds.bottom(); + } + if popover_size.width > right_space { + pos.x = trigger_bounds.right() - popover_size.width; + } + + let mut empty = div().into_any_element(); + let layout_id = empty.request_layout(window, cx); + ( + layout_id, + PopoverLayoutState { + bounds: Bounds { + origin: pos, + size: popover_size, + }, + element: Some(popover), + }, + ) + } + + fn prepaint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let bounds = request_layout.bounds; + let Some(popover) = request_layout.element.as_mut() else { + return; + }; + + window.with_absolute_element_offset(bounds.origin, |window| { + popover.prepaint(window, cx); + }) + } + + fn paint( + &mut self, + _: Option<&gpui::GlobalElementId>, + _: Option<&gpui::InspectorElementId>, + _: Bounds, + request_layout: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let bounds = request_layout.bounds; + let Some(popover) = request_layout.element.as_mut() else { + return; + }; + + popover.paint(window, cx); + + let editor = self.editor.clone(); + // Mouse down out to hide. + window.on_mouse_event(move |event: &MouseDownEvent, _, _, cx| { + if !bounds.contains(&event.position) { + let _ = editor.update(cx, |editor, cx| { + editor.clear_hover_state(cx); + }); + } + }); + + // Mouse out of trigger + popover bounds + let editor = self.editor.clone(); + let trigger_bounds = self.trigger_bounds(cx).unwrap_or(bounds); + let keep_open_region = trigger_bounds.union(&bounds); + window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| { + if !keep_open_region.contains(&event.position) { + let _ = editor.update(cx, |editor, cx| { + editor.clear_hover_state(cx); + }); + } + }) + } +} diff --git a/crates/ui/src/input/popovers/mod.rs b/crates/ui/src/input/popovers/mod.rs new file mode 100644 index 0000000..597d812 --- /dev/null +++ b/crates/ui/src/input/popovers/mod.rs @@ -0,0 +1,41 @@ +mod code_action_menu; +mod completion_menu; +mod context_menu; +mod diagnostic_popover; +mod hover_popover; + +pub(crate) use code_action_menu::*; +pub(crate) use completion_menu::*; +pub(crate) use context_menu::*; +pub(crate) use diagnostic_popover::*; +use gpui::{ + App, Div, ElementId, Entity, InteractiveElement as _, IntoElement, SharedString, Stateful, + StyleRefinement, Styled as _, Window, div, px, rems, +}; +pub(crate) use hover_popover::*; + +use crate::StyledExt as _; + +pub(crate) enum ContextMenu { + Completion(Entity), + CodeAction(Entity), + RightClick(Entity), +} + +impl ContextMenu { + pub(crate) fn is_open(&self, cx: &App) -> bool { + match self { + ContextMenu::Completion(menu) => menu.read(cx).is_open(), + ContextMenu::CodeAction(menu) => menu.read(cx).is_open(), + ContextMenu::RightClick(menu) => menu.read(cx).is_open(), + } + } + + pub(crate) fn render(&self) -> impl IntoElement { + match self { + ContextMenu::Completion(menu) => menu.clone().into_any_element(), + ContextMenu::CodeAction(menu) => menu.clone().into_any_element(), + ContextMenu::RightClick(menu) => menu.clone().into_any_element(), + } + } +} diff --git a/crates/ui/src/input/rope_ext.rs b/crates/ui/src/input/rope_ext.rs index 7962982..609112a 100644 --- a/crates/ui/src/input/rope_ext.rs +++ b/crates/ui/src/input/rope_ext.rs @@ -1,70 +1,49 @@ use std::ops::Range; -use rope::{Point, Rope}; +use ropey::{LineType, Rope, RopeSlice}; +use sum_tree::Bias; +#[cfg(not(target_family = "wasm"))] +pub use tree_sitter::{InputEdit, Point}; -use super::cursor::Position; - -/// An extension trait for `Rope` to provide additional utility methods. -pub trait RopeExt { - /// Get the line at the given row (0-based) index, including the `\r` at the end, but not `\n`. - /// - /// Return empty rope if the row (0-based) is out of bounds. - fn line(&self, row: usize) -> Rope; - - /// Start offset of the line at the given row (0-based) index. - fn line_start_offset(&self, row: usize) -> usize; - - /// Line the end offset (including `\n`) of the line at the given row (0-based) index. - /// - /// Return the end of the rope if the row is out of bounds. - fn line_end_offset(&self, row: usize) -> usize; - - /// Return the number of lines in the rope. - fn lines_len(&self) -> usize; - - /// Return the lines iterator. - /// - /// Each line is including the `\r` at the end, but not `\n`. - fn lines(&self) -> RopeLines; - - /// Check is equal to another rope. - fn eq(&self, other: &Rope) -> bool; - - /// Total number of characters in the rope. - fn chars_count(&self) -> usize; - - /// Get char at the given offset (byte). - /// - /// If the offset is in the middle of a multi-byte character will panic. - /// - /// If the offset is out of bounds, return None. - fn char_at(&self, offset: usize) -> Option; - - /// Get the byte offset from the given line, column [`Position`] (0-based). - fn position_to_offset(&self, line_col: &Position) -> usize; - - /// Get the line, column [`Position`] (0-based) from the given byte offset. - fn offset_to_position(&self, offset: usize) -> Position; - - /// Get the word byte range at the given offset (byte). - #[allow(dead_code)] - fn word_range(&self, offset: usize) -> Option>; - - /// Get word at the given offset (byte). - #[allow(dead_code)] - fn word_at(&self, offset: usize) -> String; +#[cfg(target_family = "wasm")] +/// Stub type for tree-sitter Point on WASM (tree-sitter not available). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Point { + pub row: usize, + pub column: usize, } +#[cfg(target_family = "wasm")] +impl Point { + pub fn new(row: usize, column: usize) -> Self { + Self { row, column } + } +} + +#[cfg(target_family = "wasm")] +/// Stub type for tree-sitter InputEdit on WASM (tree-sitter not available). +#[derive(Debug, Clone, Copy)] +pub struct InputEdit { + pub start_byte: usize, + pub old_end_byte: usize, + pub new_end_byte: usize, + pub start_position: Point, + pub old_end_position: Point, + pub new_end_position: Point, +} + +pub type Position = lsp_types::Position; + /// An iterator over the lines of a `Rope`. -pub struct RopeLines { +pub struct RopeLines<'a> { + rope: &'a Rope, row: usize, end_row: usize, - rope: Rope, } -impl RopeLines { +impl<'a> RopeLines<'a> { /// Create a new `RopeLines` iterator. - pub fn new(rope: Rope) -> Self { + pub fn new(rope: &'a Rope) -> Self { let end_row = rope.lines_len(); Self { row: 0, @@ -73,9 +52,8 @@ impl RopeLines { } } } - -impl Iterator for RopeLines { - type Item = Rope; +impl<'a> Iterator for RopeLines<'a> { + type Item = RopeSlice<'a>; #[inline] fn next(&mut self) -> Option { @@ -83,7 +61,7 @@ impl Iterator for RopeLines { return None; } - let line = self.rope.line(self.row); + let line = self.rope.slice_line(self.row); self.row += 1; Some(line) } @@ -101,23 +79,261 @@ impl Iterator for RopeLines { } } -impl std::iter::ExactSizeIterator for RopeLines {} -impl std::iter::FusedIterator for RopeLines {} +impl std::iter::ExactSizeIterator for RopeLines<'_> {} +impl std::iter::FusedIterator for RopeLines<'_> {} + +/// An extension trait for [`Rope`] to provide additional utility methods. +pub trait RopeExt { + /// Start offset of the line at the given row (0-based) index. + /// + /// # Example + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// + /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// assert_eq!(rope.line_start_offset(0), 0); + /// assert_eq!(rope.line_start_offset(1), 6); + /// ``` + fn line_start_offset(&self, row: usize) -> usize; + + /// Line the end offset (including `\n`) of the line at the given row (0-based) index. + /// + /// Return the end of the rope if the row is out of bounds. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// assert_eq!(rope.line_end_offset(0), 5); // "Hello\n" + /// assert_eq!(rope.line_end_offset(1), 12); // "World\r\n" + /// ``` + fn line_end_offset(&self, row: usize) -> usize; + + /// Return a line slice at the given row (0-based) index. including `\r` if present, but not `\n`. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// assert_eq!(rope.slice_line(0).to_string(), "Hello"); + /// assert_eq!(rope.slice_line(1).to_string(), "World\r"); + /// assert_eq!(rope.slice_line(2).to_string(), "This is a test 中文"); + /// assert_eq!(rope.slice_line(6).to_string(), ""); // out of bounds + /// ``` + fn slice_line(&self, row: usize) -> RopeSlice<'_>; + + /// Return a slice of rows in the given range (0-based, end exclusive). + /// + /// If the range is out of bounds, it will be clamped to the valid range. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// assert_eq!(rope.slice_lines(0..2).to_string(), "Hello\nWorld\r"); + /// assert_eq!(rope.slice_lines(1..3).to_string(), "World\r\nThis is a test 中文"); + /// assert_eq!(rope.slice_lines(2..5).to_string(), "This is a test 中文\nRope"); + /// assert_eq!(rope.slice_lines(3..10).to_string(), "Rope"); + /// assert_eq!(rope.slice_lines(5..10).to_string(), ""); // out of bounds + /// ``` + fn slice_lines(&self, rows_range: Range) -> RopeSlice<'_>; + + /// Return an iterator over all lines in the rope. + /// + /// Each line slice includes `\r` if present, but not `\n`. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// let lines: Vec<_> = rope.iter_lines().map(|r| r.to_string()).collect(); + /// assert_eq!(lines, vec!["Hello", "World\r", "This is a test 中文", "Rope"]); + /// ``` + fn iter_lines(&self) -> RopeLines<'_>; + + /// Return the number of lines in the rope. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// assert_eq!(rope.lines_len(), 4); + /// ``` + fn lines_len(&self) -> usize; + + /// Return the length of the row (0-based) in characters, including `\r` if present, but not `\n`. + /// + /// If the row is out of bounds, return 0. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// assert_eq!(rope.line_len(0), 5); // "Hello" + /// assert_eq!(rope.line_len(1), 6); // "World\r" + /// assert_eq!(rope.line_len(2), 21); // "This is a test 中文" + /// assert_eq!(rope.line_len(4), 0); // out of bounds + /// ``` + fn line_len(&self, row: usize) -> usize; + + /// Replace the text in the given byte range with new text. + /// + /// # Panics + /// + /// - If the range is not on char boundary. + /// - If the range is out of bounds. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let mut rope = Rope::from("Hello\nWorld\r\nThis is a test 中文\nRope"); + /// rope.replace(6..11, "Universe"); + /// assert_eq!(rope.to_string(), "Hello\nUniverse\r\nThis is a test 中文\nRope"); + /// ``` + fn replace(&mut self, range: Range, new_text: &str); + + /// Get char at the given offset (byte). + /// + /// - If the offset is in the middle of a multi-byte character will panic. + /// - If the offset is out of bounds, return None. + fn char_at(&self, offset: usize) -> Option; + + /// Get the byte offset from the given line, column [`Position`] (0-based). + /// + /// The column is in characters. + fn position_to_offset(&self, line_col: &Position) -> usize; + + /// Get the line, column [`Position`] (0-based) from the given byte offset. + /// + /// The column is in characters. + fn offset_to_position(&self, offset: usize) -> Position; + + /// Get point (row, column) from the given byte offset. + /// + /// The column is in bytes. + fn offset_to_point(&self, offset: usize) -> Point; + + /// Get byte offset from the given point (row, column). + /// + /// The column is 0-based in bytes. + fn point_to_offset(&self, point: Point) -> usize; + + /// Get the word byte range at the given byte offset (0-based). + fn word_range(&self, offset: usize) -> Option>; + + /// Get word at the given byte offset (0-based). + fn word_at(&self, offset: usize) -> String; + + /// Convert offset in UTF-16 to byte offset (0-based). + /// + /// Runs in O(log N) time. + fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize; + + /// Convert byte offset (0-based) to offset in UTF-16. + /// + /// Runs in O(log N) time. + fn offset_to_offset_utf16(&self, offset: usize) -> usize; + + /// Get a clipped offset (avoid in a char boundary). + /// + /// - If Bias::Left and inside the char boundary, return the ix - 1; + /// - If Bias::Right and in inside char boundary, return the ix + 1; + /// - Otherwise return the ix. + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// use sum_tree::Bias; + /// + /// let rope = Rope::from("Hello 中文🎉 test\nRope"); + /// assert_eq!(rope.clip_offset(5, Bias::Left), 5); + /// // Inside multi-byte character '中' (3 bytes) + /// assert_eq!(rope.clip_offset(7, Bias::Left), 6); + /// assert_eq!(rope.clip_offset(7, Bias::Right), 9); + /// ``` + fn clip_offset(&self, offset: usize, bias: Bias) -> usize; + + /// Convert offset in characters to byte offset (0-based). + /// + /// Run in O(n) time. + /// + /// # Example + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("a 中文🎉 test\nRope"); + /// assert_eq!(rope.char_index_to_offset(0), 0); + /// assert_eq!(rope.char_index_to_offset(1), 1); + /// assert_eq!(rope.char_index_to_offset(3), "a 中".len()); + /// assert_eq!(rope.char_index_to_offset(5), "a 中文🎉".len()); + /// ``` + fn char_index_to_offset(&self, char_index: usize) -> usize; + + /// Convert byte offset (0-based) to offset in characters. + /// + /// Run in O(n) time. + /// + /// # Example + /// + /// ``` + /// use gpui_component::{Rope, RopeExt}; + /// let rope = Rope::from("a 中文🎉 test\nRope"); + /// assert_eq!(rope.offset_to_char_index(0), 0); + /// assert_eq!(rope.offset_to_char_index(1), 1); + /// assert_eq!(rope.offset_to_char_index(3), 3); + /// assert_eq!(rope.offset_to_char_index(4), 3); + /// ``` + fn offset_to_char_index(&self, offset: usize) -> usize; +} impl RopeExt for Rope { - fn line(&self, row: usize) -> Rope { - let start = self.line_start_offset(row); - let end = start + self.line_len(row as u32) as usize; + fn slice_line(&self, row: usize) -> RopeSlice<'_> { + let total_lines = self.lines_len(); + if row >= total_lines { + return self.slice(0..0); + } + + let line = self.line(row, LineType::LF); + if line.len() > 0 { + let line_end = line.len() - 1; + if line.is_char_boundary(line_end) && line.char(line_end) == '\n' { + return line.slice(..line_end); + } + } + + line + } + + fn slice_lines(&self, rows_range: Range) -> RopeSlice<'_> { + let start = self.line_start_offset(rows_range.start); + let end = self.line_end_offset(rows_range.end.saturating_sub(1)); self.slice(start..end) } + fn iter_lines(&self) -> RopeLines<'_> { + RopeLines::new(self) + } + + fn line_len(&self, row: usize) -> usize { + self.slice_line(row).len() + } + fn line_start_offset(&self, row: usize) -> usize { - let row = row as u32; self.point_to_offset(Point::new(row, 0)) } + fn offset_to_point(&self, offset: usize) -> Point { + let offset = self.clip_offset(offset, Bias::Left); + let row = self.byte_to_line_idx(offset, LineType::LF); + let line_start = self.line_to_byte_idx(row, LineType::LF); + let column = offset.saturating_sub(line_start); + Point::new(row, column) + } + + fn point_to_offset(&self, point: Point) -> usize { + if point.row >= self.lines_len() { + return self.len(); + } + + let line_start = self.line_to_byte_idx(point.row, LineType::LF); + line_start + point.column + } + fn position_to_offset(&self, pos: &Position) -> usize { - let line = self.line(pos.line as usize); + let line = self.slice_line(pos.line as usize); self.line_start_offset(pos.line as usize) + line .chars() @@ -128,34 +344,22 @@ impl RopeExt for Rope { fn offset_to_position(&self, offset: usize) -> Position { let point = self.offset_to_point(offset); - let line = self.line(point.row as usize); - let column = line.clip_offset(point.column as usize, sum_tree::Bias::Left); - let character = line.slice(0..column).chars().count(); - Position::new(point.row, character as u32) + let line = self.slice_line(point.row); + let offset = line.utf16_to_byte_idx(line.byte_to_utf16_idx(point.column)); + let character = line.slice(..offset).chars().count(); + Position::new(point.row as u32, character as u32) } fn line_end_offset(&self, row: usize) -> usize { - if row > self.max_point().row as usize { + if row > self.lines_len() { return self.len(); } - self.line_start_offset(row) + self.line_len(row as u32) as usize + self.line_start_offset(row) + self.line_len(row) } fn lines_len(&self) -> usize { - self.max_point().row as usize + 1 - } - - fn lines(&self) -> RopeLines { - RopeLines::new(self.clone()) - } - - fn eq(&self, other: &Rope) -> bool { - self.summary() == other.summary() - } - - fn chars_count(&self) -> usize { - self.chars().count() + self.len_lines(LineType::LF) } fn char_at(&self, offset: usize) -> Option { @@ -163,8 +367,7 @@ impl RopeExt for Rope { return None; } - let offset = self.clip_offset(offset, sum_tree::Bias::Left); - self.slice(offset..self.len()).chars().next() + self.get_char(offset).ok() } fn word_range(&self, offset: usize) -> Option> { @@ -172,10 +375,9 @@ impl RopeExt for Rope { return None; } - let offset = self.clip_offset(offset, sum_tree::Bias::Left); - let mut left = String::new(); - for c in self.reversed_chars_at(offset) { + let offset = self.clip_offset(offset, Bias::Left); + for c in self.chars_at(offset).reversed() { if c.is_alphanumeric() || c == '_' { left.insert(0, c); } else { @@ -191,11 +393,7 @@ impl RopeExt for Rope { let end = offset + right.len(); - if start == end { - None - } else { - Some(start..end) - } + if start == end { None } else { Some(start..end) } } fn word_at(&self, offset: usize) -> String { @@ -205,4 +403,54 @@ impl RopeExt for Rope { String::new() } } + + #[inline] + fn offset_utf16_to_offset(&self, offset_utf16: usize) -> usize { + if offset_utf16 > self.len_utf16() { + return self.len(); + } + + self.utf16_to_byte_idx(offset_utf16) + } + + #[inline] + fn offset_to_offset_utf16(&self, offset: usize) -> usize { + if offset > self.len() { + return self.len_utf16(); + } + + self.byte_to_utf16_idx(offset) + } + + fn replace(&mut self, range: Range, new_text: &str) { + let range = + self.clip_offset(range.start, Bias::Left)..self.clip_offset(range.end, Bias::Right); + self.remove(range.clone()); + self.insert(range.start, new_text); + } + + fn clip_offset(&self, offset: usize, bias: Bias) -> usize { + if offset > self.len() { + return self.len(); + } + + if self.is_char_boundary(offset) { + return offset; + } + + if bias == Bias::Left { + self.floor_char_boundary(offset) + } else { + self.ceil_char_boundary(offset) + } + } + + fn char_index_to_offset(&self, char_offset: usize) -> usize { + self.chars().take(char_offset).map(|c| c.len_utf8()).sum() + } + + fn offset_to_char_index(&self, offset: usize) -> usize { + let offset = self.clip_offset(offset, Bias::Right); + self.slice(..offset).chars().count() + } } diff --git a/crates/ui/src/input/selection.rs b/crates/ui/src/input/selection.rs new file mode 100644 index 0000000..ad01817 --- /dev/null +++ b/crates/ui/src/input/selection.rs @@ -0,0 +1,140 @@ +use std::ops::Range; + +use gpui::{Context, Window}; +use ropey::Rope; +use sum_tree::Bias; + +use crate::input::{InputState, RopeExt}; + +impl InputState { + /// Select the word at the given offset on double-click. + /// + /// The offset is the UTF-8 offset. + pub(super) fn select_word(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + let Some(range) = TextSelector::word_range(&self.text, offset) else { + return; + }; + + self.selected_range = (range.start..range.end).into(); + self.selected_word_range = Some(self.selected_range); + cx.notify() + } + + /// Select the line at the given offset on triple-click. + /// + /// The offset is the UTF-8 offset. + pub(super) fn select_line(&mut self, offset: usize, _: &mut Window, cx: &mut Context) { + let range = TextSelector::line_range(&self.text, offset); + self.selected_range = (range.start..range.end).into(); + self.selected_word_range = None; + cx.notify() + } +} + +struct TextSelector; +impl TextSelector { + /// Select a line in the given text at the specified offset. + /// + /// The offset is the UTF-8 offset. + /// + /// Returns the start and end offsets of the selected line. + pub fn line_range(text: &Rope, offset: usize) -> Range { + let offset = text.clip_offset(offset, Bias::Left); + let row = text.offset_to_point(offset).row; + let start = text.line_start_offset(row); + let end = text.line_end_offset(row); + + start..end + } + + /// Select a word in the given text at the specified offset. + /// + /// The offset is the UTF-8 offset. + /// + /// Returns the start and end offsets of the selected word. + pub fn word_range(text: &Rope, offset: usize) -> Option> { + let offset = text.clip_offset(offset, Bias::Left); + let char = text.char_at(offset)?; + let end = offset + char.len_utf8(); + let prev_chars = text.chars_at(offset).reversed().take(128); + let next_chars = text.chars_at(end).take(128); + + Some(word_range_from_chars(offset, char, prev_chars, next_chars)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CharType { + /// a-z, A-Z, 0-9, _ + Word, + /// '\t', ' ', '\u{00A0}' etc. + Whitespace, + /// \n, \r + Newline, + /// . , ; : ( ) [ ] { } ... or CJK characters: `汉`, `🎉` etc. + Other, +} + +impl From for CharType { + fn from(c: char) -> Self { + match c { + c if is_word_char(c) => CharType::Word, + c if c == '\n' || c == '\r' => CharType::Newline, + c if c.is_whitespace() => CharType::Whitespace, + _ => CharType::Other, + } + } +} + +impl CharType { + fn is_connectable(self, c: char) -> bool { + matches!( + (self, CharType::from(c)), + (CharType::Word, CharType::Word) | (CharType::Whitespace, CharType::Whitespace) + ) + } +} + +fn is_word_char(c: char) -> bool { + matches!(c, '_') + // ASCII alphanumeric characters, for English, numbers: `Hello123`, etc. + || c.is_ascii_alphanumeric() + // Latin script in Unicode for French, German, Spanish, etc. + || matches!(c, '\u{00C0}'..='\u{00FF}') + || matches!(c, '\u{0100}'..='\u{017F}') + || matches!(c, '\u{0180}'..='\u{024F}') + // Cyrillic for Russian, Ukrainian, etc. + || matches!(c, '\u{0400}'..='\u{04FF}') + // Vietnamese + || matches!(c, '\u{1E00}'..='\u{1EFF}') + || matches!(c, '\u{0300}'..='\u{036F}') +} + +pub(crate) fn word_range_from_chars( + offset: usize, + c: char, + prev_chars: impl Iterator, + next_chars: impl Iterator, +) -> Range { + let char_type = CharType::from(c); + let mut start = offset; + let mut end = offset + c.len_utf8(); + + for prev in prev_chars.take(128) { + if char_type.is_connectable(prev) { + start -= prev.len_utf8(); + } else { + break; + } + } + + for next in next_chars.take(128) { + if char_type.is_connectable(next) { + end += next.len_utf8(); + } else { + break; + } + } + + start..end +} diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index c2d268b..6fdd4ff 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -1,39 +1,61 @@ +//! A text input field that allows the user to enter text. +//! +//! Based on the `Input` example from the `gpui` crate. +//! https://github.com/zed-industries/zed/blob/main/crates/gpui/examples/input.rs +use std::cell::Cell; use std::ops::Range; use std::rc::Rc; -use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - Action, App, AppContext, Bounds, ClipboardItem, Context, Entity, EntityInputHandler, - EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, + Action, App, AppContext, Bounds, ClipboardItem, Context, Edges, Entity, EntityInputHandler, + EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, - Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, SharedString, Styled as _, Subscription, - UTF16Selection, Window, WrappedLine, actions, div, point, px, + Pixels, Point, Render, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Styled as _, + Subscription, TextAlign, UTF16Selection, Window, actions, div, point, px, }; -use lsp_types::Position; -use rope::{OffsetUtf16, Rope}; +use ropey::{Rope, RopeSlice}; use serde::Deserialize; -use smallvec::SmallVec; use sum_tree::Bias; use unicode_segmentation::*; use super::blink_cursor::BlinkCursor; use super::change::Change; -use super::cursor::Selection; -use super::element::TextElement; +use super::element::{EditorScrollbarSnapshot, TextElement}; use super::mask_pattern::MaskPattern; -use super::mode::{InputMode, TabSize}; -use super::rope_ext::RopeExt; -use super::text_wrapper::{LineItem, TextWrapper}; -use crate::Root; +use super::mode::InputMode; +use super::{DisplayMap, MASK_CHAR}; +use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::history::History; +use crate::input::blink_cursor::CURSOR_WIDTH; +use crate::input::display_map::LineLayout; use crate::input::element::RIGHT_MARGIN; +use crate::input::movement::MoveDirection; +use crate::input::rope_ext::Position; +use crate::input::{RopeExt as _, Selection}; +use crate::{Root, Size}; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = input, no_json)] pub struct Enter { /// Is confirm with secondary. pub secondary: bool, + /// Whether the Shift modifier was held when Enter was pressed. + pub shift: bool, +} + +impl Enter { + /// Returns true if `action` is a primary `Enter` action (`secondary: false`), + /// regardless of whether Shift was held. + pub fn is_primary(action: &dyn Action) -> bool { + action.partial_eq(&Enter { + secondary: false, + shift: false, + }) || action.partial_eq(&Enter { + secondary: false, + shift: true, + }) + } } actions!( @@ -57,10 +79,6 @@ actions!( MoveEnd, MovePageUp, MovePageDown, - SelectUp, - SelectDown, - SelectLeft, - SelectRight, SelectAll, SelectToStartOfLine, SelectToEndOfLine, @@ -74,7 +92,6 @@ actions!( Paste, Undo, Redo, - NewLine, MoveToStartOfLine, MoveToEndOfLine, MoveToStart, @@ -84,23 +101,28 @@ actions!( Escape, ToggleCodeActions, Search, + GoToDefinition, ] ); #[derive(Clone)] pub enum InputEvent { Change, - PressEnter { secondary: bool }, + PressEnter { secondary: bool, shift: bool }, Focus, Blur, } pub(super) const CONTEXT: &str = "Input"; -pub fn init(cx: &mut App) { +pub(crate) fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("backspace", Backspace, Some(CONTEXT)), + KeyBinding::new("shift-backspace", Backspace, Some(CONTEXT)), + #[cfg(target_os = "macos")] + KeyBinding::new("ctrl-backspace", Backspace, Some(CONTEXT)), KeyBinding::new("delete", Delete, Some(CONTEXT)), + KeyBinding::new("shift-delete", Delete, Some(CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("cmd-backspace", DeleteToBeginningOfLine, Some(CONTEXT)), #[cfg(target_os = "macos")] @@ -113,8 +135,30 @@ pub fn init(cx: &mut App) { KeyBinding::new("alt-delete", DeleteToNextWordEnd, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-delete", DeleteToNextWordEnd, Some(CONTEXT)), - KeyBinding::new("enter", Enter { secondary: false }, Some(CONTEXT)), - KeyBinding::new("secondary-enter", Enter { secondary: true }, Some(CONTEXT)), + KeyBinding::new( + "enter", + Enter { + secondary: false, + shift: false, + }, + Some(CONTEXT), + ), + KeyBinding::new( + "shift-enter", + Enter { + secondary: false, + shift: true, + }, + Some(CONTEXT), + ), + KeyBinding::new( + "secondary-enter", + Enter { + secondary: true, + shift: false, + }, + Some(CONTEXT), + ), KeyBinding::new("escape", Escape, Some(CONTEXT)), KeyBinding::new("up", MoveUp, Some(CONTEXT)), KeyBinding::new("down", MoveDown, Some(CONTEXT)), @@ -140,7 +184,6 @@ pub fn init(cx: &mut App) { KeyBinding::new("end", MoveEnd, Some(CONTEXT)), KeyBinding::new("shift-home", SelectToStartOfLine, Some(CONTEXT)), KeyBinding::new("shift-end", SelectToEndOfLine, Some(CONTEXT)), - KeyBinding::new("shift-enter", NewLine, Some(CONTEXT)), #[cfg(target_os = "macos")] KeyBinding::new("ctrl-shift-a", SelectToStartOfLine, Some(CONTEXT)), #[cfg(target_os = "macos")] @@ -218,16 +261,31 @@ pub fn init(cx: &mut App) { ]); } +/// Whitespace indicators for rendering spaces and tabs. +#[derive(Clone, Default)] +pub(crate) struct WhitespaceIndicators { + /// Shaped line for space character indicator (•) + pub(crate) space: ShapedLine, + /// Shaped line for tab character indicator (→) + pub(crate) tab: ShapedLine, +} + #[derive(Clone)] pub(super) struct LastLayout { /// The visible range (no wrap) of lines in the viewport, the value is row (0-based) index. + /// This is the buffer line range that encompasses all visible lines. pub(super) visible_range: Range, + /// The list of visible buffer line indices (excludes hidden/folded lines). + /// Parallel to `lines`: `visible_buffer_lines[i]` is the buffer line index of `lines[i]`. + pub(super) visible_buffer_lines: Vec, + /// Byte offset of each visible buffer line in the Rope (parallel to visible_buffer_lines/lines). + pub(super) visible_line_byte_offsets: Vec, /// The first visible line top position in scroll viewport. pub(super) visible_top: Pixels, /// The range of byte offset of the visible lines. pub(super) visible_range_offset: Range, - /// The last layout lines (Only have visible lines). - pub(super) lines: Rc>, + /// The last layout lines (Only have visible lines, no empty entries for hidden lines). + pub(super) lines: Rc>, /// The line_height of text layout, this will change will InputElement painted. pub(super) line_height: Pixels, /// The wrap width of text layout, this will change will InputElement painted. @@ -236,14 +294,39 @@ pub(super) struct LastLayout { pub(super) line_number_width: Pixels, /// The cursor position (top, left) in pixels. pub(super) cursor_bounds: Option>, + /// The text align of the text layout. + pub(super) text_align: TextAlign, + /// The content width of the text layout. + pub(super) content_width: Pixels, } -/// InputState to keep editing state of the [`super::TextInput`]. +impl LastLayout { + /// Get the line layout for the given buffer row (0-based). + /// + /// Uses binary search on `visible_buffer_lines` to find the line. + /// Returns None if the row is not visible (out of range or folded). + pub(crate) fn line(&self, row: usize) -> Option<&LineLayout> { + let pos = self.visible_buffer_lines.binary_search(&row).ok()?; + self.lines.get(pos) + } + + /// Get the alignment offset for the given line width. + pub(super) fn alignment_offset(&self, line_width: Pixels) -> Pixels { + match self.text_align { + TextAlign::Left => px(0.), + TextAlign::Center => (self.content_width - line_width).half().max(px(0.)), + TextAlign::Right => (self.content_width - line_width).max(px(0.)), + } + } +} + +/// InputState to keep editing state of the [`super::Input`]. +#[allow(clippy::type_complexity)] pub struct InputState { pub(super) focus_handle: FocusHandle, pub(super) mode: InputMode, pub(super) text: Rope, - pub(super) text_wrapper: TextWrapper, + pub(super) display_map: DisplayMap, pub(super) history: History, pub(super) blink_cursor: Entity, pub loading: bool, @@ -252,6 +335,7 @@ pub struct InputState { /// - "Hello 世界💝" = 16 /// - "💝" = 4 pub(super) selected_range: Selection, + pub(super) replaceable: bool, /// 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, @@ -265,19 +349,25 @@ pub struct InputState { pub(super) last_bounds: Option>, pub(super) last_selected_range: Option, pub(super) selecting: bool, + pub(super) size: Size, pub(super) disabled: bool, pub(super) masked: bool, pub(super) clean_on_escape: bool, + pub(super) submit_on_enter: bool, pub(super) soft_wrap: bool, + pub(super) show_whitespaces: bool, + /// This flag tells the renderer to prefer the end of the current visual line. + pub(crate) cursor_line_end_affinity: bool, pub(super) pattern: Option, - pub(super) new_line_on_enter: bool, - #[allow(clippy::type_complexity)] pub(super) validate: Option) -> bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, /// The deferred scroll offset to apply on next layout. pub(crate) deferred_scroll_offset: Option>, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, + pub(super) editor_scrollbar_paddings: Cell>, + pub(super) editor_scrollbar_snapshot: Cell>, + pub(super) text_align: TextAlign, /// The mask pattern for formatting the input text pub(crate) mask_pattern: MaskPattern, @@ -285,13 +375,14 @@ pub struct InputState { /// A flag to indicate if we should ignore the next completion event. pub(super) silent_replace_text: bool, + /// A flag to indicate if we should emit InputEvents. + pub(super) emit_events: bool, /// To remember the horizontal column (x-coordinate) of the cursor position for keep column for move up/down. /// /// The first element is the x-coordinate (Pixels), preferred to use this. /// The second element is the column (usize), fallback to use this. - preferred_column: Option<(Pixels, usize)>, - + pub(super) preferred_column: Option<(Pixels, usize)>, _subscriptions: Vec, } @@ -302,9 +393,9 @@ impl InputState { /// /// See also: [`Self::multi_line`], [`Self::auto_grow`] to set other mode. pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); + let focus_handle = cx.focus_handle().tab_stop(true); let blink_cursor = cx.new(|_| BlinkCursor::new()); - let history = History::new().group_interval(Duration::from_secs(1)); + let history = History::new().group_interval(std::time::Duration::from_secs(1)); let _subscriptions = vec![ // Observe the blink cursor to repaint the view when it changes. @@ -328,15 +419,12 @@ impl InputState { Self { focus_handle: focus_handle.clone(), - text: Rope::default(), - text_wrapper: TextWrapper::new( - text_style.font(), - text_style.font_size.to_pixels(window.rem_size()), - None, - ), + text: "".into(), + display_map: DisplayMap::new(text_style.font(), window.rem_size(), None), blink_cursor, history, selected_range: Selection::default(), + replaceable: true, selected_word_range: None, selection_reversed: false, ime_marked_range: None, @@ -345,45 +433,83 @@ impl InputState { disabled: false, masked: false, clean_on_escape: false, + submit_on_enter: false, soft_wrap: true, - new_line_on_enter: true, + show_whitespaces: false, loading: false, pattern: None, validate: None, - mode: InputMode::SingleLine, + mode: InputMode::default(), last_layout: None, last_bounds: None, last_selected_range: None, last_cursor: None, scroll_handle: ScrollHandle::new(), scroll_size: gpui::size(px(0.), px(0.)), + editor_scrollbar_paddings: Cell::new(Edges { + top: px(0.), + right: px(0.), + bottom: px(0.), + left: px(0.), + }), + editor_scrollbar_snapshot: Cell::new(None), deferred_scroll_offset: None, preferred_column: None, placeholder: SharedString::default(), mask_pattern: MaskPattern::default(), + text_align: TextAlign::Left, silent_replace_text: false, + emit_events: true, + size: Size::default(), _subscriptions, + cursor_line_end_affinity: false, } } - /// Set Input to use [`InputMode::MultiLine`] mode. + /// Set Input to use multi line mode. /// /// Default rows is 2. - pub fn multi_line(mut self) -> Self { - self.mode = InputMode::MultiLine { - rows: 2, - tab: TabSize::default(), - }; + pub fn multi_line(mut self, multi_line: bool) -> Self { + self.mode = self.mode.multi_line(multi_line); self } /// Set Input to use [`InputMode::AutoGrow`] mode with min, max rows limit. pub fn auto_grow(mut self, min_rows: usize, max_rows: usize) -> Self { - self.mode = InputMode::AutoGrow { - rows: min_rows, - min_rows, - max_rows, - }; + self.mode = InputMode::auto_grow(min_rows, max_rows); + self + } + + /// Set Input to use [`InputMode::CodeEditor`] mode. + /// + /// Default options: + /// + /// - line_number: true + /// - tab_size: 2 + /// - hard_tabs: false + /// - height: 100% + /// - multi_line: true + /// - indent_guides: true + /// + /// If `highlighter` is None, will use the default highlighter. + /// + /// 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 + /// - Large Text support, up to 50K lines. + pub fn code_editor(mut self, language: impl Into) -> Self { + let language: SharedString = language.into(); + self.mode = InputMode::code_editor(language); + self + } + + /// Set whether search UI allows replacement, default is true. + pub fn replaceable(mut self, allow: bool) -> Self { + self.replaceable = allow; self } @@ -393,16 +519,49 @@ impl InputState { self } - /// Set the tab size for the input. + /// Set enable/disable code folding, only for [`InputMode::CodeEditor`] mode. /// - /// Only for [`InputMode::MultiLine`] and [`InputMode::CodeEditor`] mode. - pub fn tab_size(mut self, tab: TabSize) -> Self { - if let InputMode::MultiLine { tab: t, .. } = &mut self.mode { - *t = tab + /// Default: true + pub fn folding(mut self, folding: bool) -> Self { + debug_assert!(self.mode.is_code_editor()); + if let InputMode::CodeEditor { folding: f, .. } = &mut self.mode { + *f = folding; } self } + /// Set code folding at runtime, only for [`InputMode::CodeEditor`] mode. + /// + /// When disabling, all existing folds are cleared. + pub fn set_folding(&mut self, folding: bool, _: &mut Window, cx: &mut Context) { + debug_assert!(self.mode.is_code_editor()); + if let InputMode::CodeEditor { folding: f, .. } = &mut self.mode { + *f = folding; + } + if !folding { + self.display_map.clear_folds(); + } + cx.notify(); + } + + /// Set enable/disable line number, only for [`InputMode::CodeEditor`] mode. + pub fn line_number(mut self, line_number: bool) -> Self { + debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line()); + 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) { + debug_assert!(self.mode.is_code_editor() && self.mode.is_multi_line()); + if let InputMode::CodeEditor { line_number: l, .. } = &mut self.mode { + *l = line_number; + } + cx.notify(); + } + /// Set the number of rows for the multi-line Textarea. /// /// This is only used when `multi_line` is set to true. @@ -410,7 +569,9 @@ impl InputState { /// default: 2 pub fn rows(mut self, rows: usize) -> Self { match &mut self.mode { - InputMode::MultiLine { rows: r, .. } => *r = rows, + InputMode::PlainText { rows: r, .. } | InputMode::CodeEditor { rows: r, .. } => { + *r = rows + } InputMode::AutoGrow { max_rows: max_r, rows: r, @@ -419,11 +580,35 @@ impl InputState { *r = rows; *max_r = rows; } - _ => {} } self } + /// Set highlighter language for for [`InputMode::CodeEditor`] mode. + pub fn set_highlighter( + &mut self, + new_language: impl Into, + cx: &mut Context, + ) { + if let InputMode::CodeEditor { + language, + parse_task, + .. + } = &mut self.mode + { + *language = new_language.into(); + parse_task.borrow_mut().take(); + } + cx.notify(); + } + + fn reset_highlighter(&mut self, cx: &mut Context) { + if let InputMode::CodeEditor { parse_task, .. } = &mut self.mode { + parse_task.borrow_mut().take(); + } + cx.notify(); + } + /// Set placeholder pub fn set_placeholder( &mut self, @@ -435,29 +620,6 @@ impl InputState { cx.notify(); } - /// Called after moving the cursor. Updates preferred_column if we know where the cursor now is. - fn update_preferred_column(&mut self) { - let Some(last_layout) = &self.last_layout else { - self.preferred_column = None; - return; - }; - - let point = self.text.offset_to_point(self.cursor()); - let row = (point.row as usize).saturating_sub(last_layout.visible_range.start); - let Some(line) = last_layout.lines.get(row) else { - self.preferred_column = None; - return; - }; - - let Some(pos) = line.position_for_index(point.column as usize, last_layout.line_height) - else { - self.preferred_column = None; - return; - }; - - self.preferred_column = Some((pos.x, point.column as usize)); - } - /// Find which line and sub-line the given offset belongs to, along with the position within that sub-line. /// /// Returns: @@ -465,7 +627,6 @@ impl InputState { /// - 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. - #[allow(unused)] pub(super) fn line_and_position_for_offset( &self, offset: usize, @@ -475,82 +636,35 @@ impl InputState { }; let line_height = last_layout.line_height; - let mut prev_lines_offset = last_layout.visible_range_offset.start; let mut y_offset = last_layout.visible_top; - - for (line_index, line) in last_layout.lines.iter().enumerate() { + for (vi, line) in last_layout.lines.iter().enumerate() { + let prev_lines_offset = last_layout.visible_line_byte_offsets[vi]; 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.signum() / line_height.signum()) as usize; + if let Some(pos) = line.position_for_index(local_offset, last_layout, false) { + let sub_line_index = (pos.y / line_height) as usize; let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset); - return (line_index, sub_line_index, Some(adjusted_pos)); + return (vi, sub_line_index, Some(adjusted_pos)); } y_offset += line.size(line_height).height; - prev_lines_offset += line.len() + 1; } - (0, 0, None) } - /// Move the cursor vertically by one line (up or down) while preserving the column if possible. - /// - /// move_lines: Number of lines to move vertically (positive for down, negative for up). - fn move_vertical(&mut self, move_lines: isize, _: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - let Some(last_layout) = &self.last_layout else { - return; - }; - - let offset = self.cursor(); - let was_preferred_column = self.preferred_column; - - let row = self.text.offset_to_point(offset).row; - let new_row = row.saturating_add_signed(move_lines as i32); - let line_start_offset = self.text.point_to_offset(rope::Point::new(new_row, 0)); - - let mut new_offset = line_start_offset; - - if let Some((preferred_x, column)) = was_preferred_column { - let new_column = column.min(self.text.line(new_row as usize).len()); - new_offset = line_start_offset + new_column; - - // If in visible range, prefer to use position to get column. - let new_row = new_row as usize; - if new_row >= last_layout.visible_range.start { - let visible_row = new_row.saturating_sub(last_layout.visible_range.start); - if let Some(line) = last_layout.lines.get(visible_row) - && let Ok(x) = line.closest_index_for_position( - Point { - x: preferred_x, - y: px(0.), - }, - last_layout.line_height, - ) - { - new_offset = line_start_offset + x; - } - } - } - self.pause_blink_cursor(cx); - self.move_to(new_offset, cx); - // Set back the preferred_column - self.preferred_column = was_preferred_column; - cx.notify(); - } - /// Set the text of the input field. /// /// And the selection_range will be reset to 0..0. - pub fn set_value(&mut self, value: T, window: &mut Window, cx: &mut Context) - where - T: Into, - { + pub fn set_value( + &mut self, + value: impl Into, + window: &mut Window, + cx: &mut Context, + ) { self.history.ignore = true; + self.emit_events = false; self.replace_text(value, window, cx); self.history.ignore = false; + self.emit_events = true; // Ensure cursor to start when set text if self.mode.is_single_line() { @@ -561,16 +675,20 @@ impl InputState { // Move scroll to top self.scroll_handle.set_offset(point(px(0.), px(0.))); + + self.history.clear(); cx.notify(); } /// Insert text at the current cursor position. /// /// And the cursor will be moved to the end of inserted text. - pub fn insert(&mut self, text: T, window: &mut Window, cx: &mut Context) - where - T: Into, - { + pub fn insert( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { let was_disabled = self.disabled; self.disabled = false; let text: SharedString = text.into(); @@ -583,10 +701,12 @@ impl InputState { /// Replace text at the current cursor position. /// /// And the cursor will be moved to the end of replaced text. - pub fn replace(&mut self, text: T, window: &mut Window, cx: &mut Context) - where - T: Into, - { + pub fn replace( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { let was_disabled = self.disabled; self.disabled = false; let text: SharedString = text.into(); @@ -595,22 +715,35 @@ impl InputState { self.disabled = was_disabled; } - fn replace_text(&mut self, text: T, window: &mut Window, cx: &mut Context) - where - T: Into, - { + fn replace_text( + &mut self, + text: impl Into, + window: &mut Window, + cx: &mut Context, + ) { let was_disabled = self.disabled; self.disabled = false; let text: SharedString = text.into(); let range = 0..self.text.chars().map(|c| c.len_utf16()).sum(); self.replace_text_in_range_silent(Some(range), &text, window, cx); + self.reset_highlighter(cx); self.disabled = was_disabled; } + /// Set with disabled mode. + /// + /// See also: [`Self::set_disabled`], [`Self::is_disabled`]. + #[allow(unused)] + pub(crate) fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + /// Set with password masked state. /// /// Only for [`InputMode::SingleLine`] mode. pub fn masked(mut self, masked: bool) -> Self { + debug_assert!(self.mode.is_single_line()); self.masked = masked; self } @@ -619,6 +752,7 @@ impl InputState { /// /// Only for [`InputMode::SingleLine`] mode. pub fn set_masked(&mut self, masked: bool, _: &mut Window, cx: &mut Context) { + debug_assert!(self.mode.is_single_line()); self.masked = masked; cx.notify(); } @@ -629,20 +763,31 @@ impl InputState { self } + /// Set true to treat `Enter` as a submit action in multi-line mode, + /// while `Shift+Enter` inserts a newline. + /// + /// Default is `false` (both `Enter` and `Shift+Enter` insert a newline). + pub fn submit_on_enter(mut self, submit: bool) -> Self { + self.submit_on_enter = submit; + self + } + /// Set the soft wrap mode for multi-line input, default is true. pub fn soft_wrap(mut self, wrap: bool) -> Self { + debug_assert!(self.mode.is_multi_line()); self.soft_wrap = wrap; self } - /// Disables new lines on enter when multi-line is enabled. - pub fn prevent_new_line_on_enter(mut self) -> Self { - self.new_line_on_enter = false; + /// Set whether to show whitespace characters. + pub fn show_whitespaces(mut self, show: bool) -> Self { + self.show_whitespaces = show; self } /// Update the soft wrap mode for multi-line input, default is true. pub fn set_soft_wrap(&mut self, wrap: bool, _: &mut Window, cx: &mut Context) { + debug_assert!(self.mode.is_multi_line()); self.soft_wrap = wrap; if wrap { let wrap_width = self @@ -651,22 +796,29 @@ impl InputState { .and_then(|b| b.wrap_width) .unwrap_or(self.input_bounds.size.width); - self.text_wrapper.set_wrap_width(Some(wrap_width), cx); + self.display_map.on_layout_changed(Some(wrap_width), cx); // Reset scroll to left 0 let mut offset = self.scroll_handle.offset(); offset.x = px(0.); self.scroll_handle.set_offset(offset); } else { - self.text_wrapper.set_wrap_width(None, cx); + self.display_map.on_layout_changed(None, cx); } cx.notify(); } + /// Update whether to show whitespace characters. + pub fn set_show_whitespaces(&mut self, show: bool, _: &mut Window, cx: &mut Context) { + self.show_whitespaces = show; + cx.notify(); + } + /// Set the regular expression pattern of the input field. /// /// Only for [`InputMode::SingleLine`] mode. pub fn pattern(mut self, pattern: regex::Regex) -> Self { + debug_assert!(self.mode.is_single_line()); self.pattern = Some(pattern); self } @@ -680,6 +832,7 @@ impl InputState { _window: &mut Window, _cx: &mut Context, ) { + debug_assert!(self.mode.is_single_line()); self.pattern = Some(pattern); } @@ -687,37 +840,24 @@ impl InputState { /// /// Only for [`InputMode::SingleLine`] mode. pub fn validate(mut self, f: impl Fn(&str, &mut Context) -> bool + 'static) -> Self { + debug_assert!(self.mode.is_single_line()); self.validate = Some(Box::new(f)); self } - /// Set true to show indicator at the input right. + /// Set true to show spinner at the input right. /// /// Only for [`InputMode::SingleLine`] mode. pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { + debug_assert!(self.mode.is_single_line()); self.loading = loading; cx.notify(); } - /// 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 with disabled mode. - pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { - self.disabled = disabled; - cx.notify(); - } - /// Set the default value of the input field. pub fn default_value(mut self, value: impl Into) -> Self { let text: SharedString = value.into(); self.text = Rope::from(text.as_str()); - self.text_wrapper.set_default_text(&self.text); self } @@ -726,6 +866,12 @@ impl InputState { SharedString::new(self.text.to_string()) } + /// Return the portion of the value within the input field that + /// is selected by the user + pub fn selected_value(&self) -> SharedString { + SharedString::new(self.selected_text().to_string()) + } + /// Return the value without mask. pub fn unmask_value(&self) -> SharedString { self.mask_pattern.unmask(&self.text.to_string()).into() @@ -752,12 +898,9 @@ impl InputState { cx: &mut Context, ) { let position: Position = position.into(); - let max_point = self.text.max_point(); - let row = position.line.min(max_point.row); - let col = position.character.min(self.text.line_len(row)); - let offset = self.text.point_to_offset(rope::Point::new(row, col)); + let offset = self.text.position_to_offset(&position); - self.move_to(offset, cx); + self.move_to(offset, None, cx); self.update_preferred_column(); self.focus(window, cx); } @@ -770,86 +913,6 @@ impl InputState { }); } - pub(super) fn left(&mut self, _: &MoveLeft, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - if self.selected_range.is_empty() { - self.move_to(self.previous_boundary(self.cursor()), cx); - } else { - self.move_to(self.selected_range.start, cx) - } - } - - pub(super) fn right(&mut self, _: &MoveRight, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - if self.selected_range.is_empty() { - self.move_to(self.next_boundary(self.selected_range.end), cx); - } else { - self.move_to(self.selected_range.end, cx) - } - } - - pub(super) fn up(&mut self, _action: &MoveUp, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - - if !self.selected_range.is_empty() { - self.move_to( - self.previous_boundary(self.selected_range.start.saturating_sub(1)), - cx, - ); - } - self.pause_blink_cursor(cx); - self.move_vertical(-1, window, cx); - } - - pub(super) fn down(&mut self, _action: &MoveDown, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - - if !self.selected_range.is_empty() { - self.move_to( - self.next_boundary(self.selected_range.end.saturating_sub(1)), - cx, - ); - } - - self.pause_blink_cursor(cx); - self.move_vertical(1, window, cx); - } - - pub(super) fn page_up(&mut self, _: &MovePageUp, window: &mut Window, cx: &mut Context) { - if self.mode.is_single_line() { - return; - } - - let Some(last_layout) = &self.last_layout else { - return; - }; - - let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; - self.move_vertical(-display_lines, window, cx); - } - - pub(super) fn page_down( - &mut self, - _: &MovePageDown, - window: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_single_line() { - return; - } - - let Some(last_layout) = &self.last_layout else { - return; - }; - - let display_lines = (self.input_bounds.size.height / last_layout.line_height) as isize; - self.move_vertical(display_lines, window, cx); - } - pub(super) fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { self.select_to(self.previous_boundary(self.cursor()), cx); } @@ -879,67 +942,6 @@ impl InputState { cx.notify(); } - pub(super) fn home(&mut self, _: &MoveHome, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.start_of_line(); - self.move_to(offset, cx); - } - - pub(super) fn end(&mut self, _: &MoveEnd, _: &mut Window, cx: &mut Context) { - self.pause_blink_cursor(cx); - let offset = self.end_of_line(); - self.move_to(offset, cx); - } - - pub(super) fn shift_to_new_line( - &mut self, - _: &NewLine, - window: &mut Window, - cx: &mut Context, - ) { - if self.mode.is_multi_line() { - // Get current line indent - let indent = "".to_string(); - // Add newline and indent - let new_line_text = format!("\n{indent}"); - self.replace_text_in_range_silent(None, &new_line_text, window, cx); - self.pause_blink_cursor(cx); - } - } - - pub(super) fn move_to_start( - &mut self, - _: &MoveToStart, - _: &mut Window, - cx: &mut Context, - ) { - self.move_to(0, cx); - } - - pub(super) fn move_to_end(&mut self, _: &MoveToEnd, _: &mut Window, cx: &mut Context) { - self.move_to(self.text.len(), cx); - } - - pub(super) fn move_to_previous_word( - &mut self, - _: &MoveToPreviousWord, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.previous_start_of_word(); - self.move_to(offset, cx); - } - - pub(super) fn move_to_next_word( - &mut self, - _: &MoveToNextWord, - _: &mut Window, - cx: &mut Context, - ) { - let offset = self.next_end_of_word(); - self.move_to(offset, cx); - } - pub(super) fn select_to_start( &mut self, _: &SelectToStart, @@ -1000,7 +1002,7 @@ impl InputState { } /// Return the start offset of the previous word. - fn previous_start_of_word(&mut self) -> usize { + pub(super) fn previous_start_of_word(&mut self) -> usize { let offset = self.selected_range.start; let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); // FIXME: Avoid to_string @@ -1013,7 +1015,7 @@ impl InputState { } /// Return the next end offset of the next word. - fn next_end_of_word(&mut self) -> usize { + pub(super) fn next_end_of_word(&mut self) -> usize { let offset = self.cursor(); let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); let right_part = self.text.slice(offset..self.text.len()).to_string(); @@ -1024,30 +1026,69 @@ impl InputState { .unwrap_or(self.text.len()) } - /// Get start of line byte offset of cursor - fn start_of_line(&self) -> usize { + /// Get start of line byte offset of cursor. + /// + /// When soft wrap is active, first press goes to visual line start, + /// second press (already at visual start) goes to logical line start. + pub(super) fn start_of_line(&self) -> usize { if self.mode.is_single_line() { return 0; } let row = self.text.offset_to_point(self.cursor()).row; - self.text.line_start_offset(row as usize) + let logical_start = self.text.line_start_offset(row); + + if self.soft_wrap && self.mode.is_code_editor() { + let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); + if let Some(line) = self.display_map.lines().get(row) + && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) + { + let visual_start = logical_start + range.start; + if self.cursor() != visual_start { + return visual_start; + } + } + } + + logical_start } - /// Get end of line byte offset of cursor - fn end_of_line(&self) -> usize { + /// Get end of line byte offset of cursor. + /// + /// When soft wrap is active, first press goes to visual line end, + /// second press (already at visual end) goes to logical line end. + pub(super) fn end_of_line(&self) -> usize { if self.mode.is_single_line() { return self.text.len(); } let row = self.text.offset_to_point(self.cursor()).row; - self.text.line_end_offset(row as usize) + let logical_start = self.text.line_start_offset(row); + let logical_end = self.text.line_end_offset(row); + + if self.soft_wrap && self.mode.is_code_editor() { + let wrap_point = self.display_map.offset_to_wrap_display_point(self.cursor()); + if let Some(line) = self.display_map.lines().get(row) + && let Some(range) = line.wrapped_lines.get(wrap_point.local_row) + { + let visual_end = logical_start + range.end; + if self.cursor() != visual_end { + return visual_end; + } + } + } + + logical_end } /// 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) -> usize { + pub(super) fn start_of_line_of_selection( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> usize { if self.mode.is_single_line() { return 0; } @@ -1065,6 +1106,45 @@ impl InputState { .unwrap_or(0) } + /// 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) -> String { + if self.mode.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(); + let next_line_start_pos = self.end_of_line(); + for c in self.text.slice(current_line_start_pos..).chars() { + if !c.is_whitespace() { + break; + } + if c == '\n' || c == '\r' { + break; + } + current_indent.push(c); + } + + for c in self.text.slice(next_line_start_pos..).chars() { + if !c.is_whitespace() { + break; + } + if c == '\n' || c == '\r' { + break; + } + next_indent.push(c); + } + + if next_indent.len() > current_indent.len() { + next_indent + } else { + current_indent + } + } + pub(super) fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { if self.selected_range.is_empty() { self.select_to(self.previous_boundary(self.cursor()), cx) @@ -1087,6 +1167,12 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + let mut offset = self.start_of_line(); if offset == self.cursor() { offset = offset.saturating_sub(1); @@ -1097,7 +1183,6 @@ impl InputState { window, cx, ); - self.pause_blink_cursor(cx); } @@ -1107,6 +1192,12 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + let mut offset = self.end_of_line(); if offset == self.cursor() { offset = (offset + 1).clamp(0, self.text.len()); @@ -1126,6 +1217,12 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + let offset = self.previous_start_of_word(); self.replace_text_in_range_silent( Some(self.range_to_utf16(&(offset..self.cursor()))), @@ -1142,6 +1239,12 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + if !self.selected_range.is_empty() { + self.replace_text_in_range(None, "", window, cx); + self.pause_blink_cursor(cx); + return; + } + let offset = self.next_end_of_word(); self.replace_text_in_range_silent( Some(self.range_to_utf16(&(self.cursor()..offset))), @@ -1153,186 +1256,40 @@ impl InputState { } pub(super) fn enter(&mut self, action: &Enter, window: &mut Window, cx: &mut Context) { - if self.mode.is_multi_line() && self.new_line_on_enter { + // In multi-line mode with `submit_on_enter` enabled, a plain `Enter` + // (without Shift) is treated as submit: propagate the action and emit + // PressEnter without inserting a newline. `Shift+Enter` still inserts + // a newline. + let insert_newline = self.mode.is_multi_line() && (!self.submit_on_enter || action.shift); + + if insert_newline { // Get current line indent - let indent = "".to_string(); + let indent = if self.mode.is_code_editor() { + self.indent_of_next_line() + } else { + "".to_string() + }; + // Add newline and indent - let new_line_text = format!("\n{indent}"); + let new_line_text = format!("\n{}", indent); self.replace_text_in_range_silent(None, &new_line_text, window, cx); self.pause_blink_cursor(cx); } else { - // Single line input, just emit the event (e.g.: In a modal dialog to confirm). + // Single line input or submit-on-enter: just emit the event + // (e.g.: in a dialog to confirm, or a chat textarea to send). cx.propagate(); } cx.emit(InputEvent::PressEnter { secondary: action.secondary, + shift: action.shift, }); } - pub(super) fn indent_inline( - &mut self, - _: &IndentInline, - window: &mut Window, - cx: &mut Context, - ) { - self.indent(false, window, cx); - } - - pub(super) fn indent_block(&mut self, _: &Indent, window: &mut Window, cx: &mut Context) { - self.indent(true, window, cx); - } - - pub(super) fn outdent_inline( - &mut self, - _: &OutdentInline, - window: &mut Window, - cx: &mut Context, - ) { - self.outdent(false, window, cx); - } - - pub(super) fn outdent_block( - &mut self, - _: &Outdent, - window: &mut Window, - cx: &mut Context, - ) { - self.outdent(true, window, cx); - } - - pub(super) fn indent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - let Some(tab_size) = self.mode.tab_size() else { - return; - }; - - let tab_indent = tab_size.to_string(); - let selected_range = self.selected_range; - let mut added_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset))), - &tab_indent, - window, - cx, - ); - added_len += tab_indent.len(); - // +1 for "\n", the `\r` is included in the `line`. - offset += line.len() + tab_indent.len() + 1; - } - - if is_selected { - self.selected_range = (start_offset..selected_range.end + added_len).into(); - } else { - self.selected_range = - (selected_range.start + added_len..selected_range.end + added_len).into(); - } - } else { - // Selected none - let offset = self.selected_range.start; - self.replace_text_in_range_silent( - 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).into(); - } - } - - pub(super) fn outdent(&mut self, block: bool, window: &mut Window, cx: &mut Context) { - let Some(tab_size) = self.mode.tab_size() else { - return; - }; - - let tab_indent = tab_size.to_string(); - let selected_range = self.selected_range; - let mut removed_len = 0; - let is_selected = !self.selected_range.is_empty(); - - if is_selected || block { - let start_offset = self.start_of_line_of_selection(window, cx); - let mut offset = start_offset; - - let selected_text = self - .text_for_range( - self.range_to_utf16(&(offset..selected_range.end)), - &mut None, - window, - cx, - ) - .unwrap_or("".into()); - - for line in selected_text.split('\n') { - if line.starts_with(tab_indent.as_ref()) { - self.replace_text_in_range_silent( - 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; - } else { - offset += line.len() + 1; - } - } - - if is_selected { - self.selected_range = - (start_offset..selected_range.end.saturating_sub(removed_len)).into(); - } else { - self.selected_range = (selected_range.start.saturating_sub(removed_len) - ..selected_range.end.saturating_sub(removed_len)) - .into(); - } - } else { - // Selected none - let start_offset = self.selected_range.start; - let offset = self.start_of_line_of_selection(window, cx); - let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); - // FIXME: To improve performance - if self - .text - .slice(offset..self.text.len()) - .to_string() - .starts_with(tab_indent.as_ref()) - { - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(offset..offset + tab_indent.len()))), - "", - window, - cx, - ); - removed_len = tab_indent.len(); - let new_offset = start_offset.saturating_sub(removed_len); - self.selected_range = (new_offset..new_offset).into(); - } - } - } - pub(super) fn clean(&mut self, window: &mut Window, cx: &mut Context) { self.replace_text("", window, cx); + self.selected_range = (0..0).into(); + self.scroll_to(0, None, cx); } pub(super) fn escape(&mut self, _action: &Escape, window: &mut Window, cx: &mut Context) { @@ -1362,7 +1319,13 @@ impl InputState { } self.selecting = true; - let offset = self.index_for_mouse_position(event.position, window, cx); + let offset = self.index_for_mouse_position(event.position); + + // Triple click to select line + if event.button == MouseButton::Left && event.click_count >= 3 { + self.select_line(offset, window, cx); + return; + } // Double click to select word if event.button == MouseButton::Left && event.click_count == 2 { @@ -1370,16 +1333,10 @@ impl InputState { return; } - // Triple click to select line - if event.button == MouseButton::Left && event.click_count == 3 { - self.select_line(window, cx); - return; - } - if event.modifiers.shift { self.select_to(offset, cx); } else { - self.move_to(offset, cx) + self.move_to(offset, None, cx) } } @@ -1389,6 +1346,9 @@ impl InputState { _window: &mut Window, _cx: &mut Context, ) { + if self.selected_range.is_empty() { + self.selection_reversed = false; + } self.selecting = false; self.selected_word_range = None; } @@ -1405,7 +1365,14 @@ impl InputState { .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); + + let old_offset = self.scroll_handle.offset(); + self.update_scroll_offset(Some(old_offset + delta), cx); + + // Only stop propagation if the offset actually changed + if self.scroll_handle.offset() != old_offset { + cx.stop_propagation(); + } } pub(super) fn update_scroll_offset( @@ -1414,11 +1381,17 @@ impl InputState { cx: &mut Context, ) { let mut offset = offset.unwrap_or(self.scroll_handle.offset()); + // In addition to left alignment, a cursor position will be reserved on the right side + let safe_x_offset = if self.text_align == TextAlign::Left { + px(0.) + } else { + -CURSOR_WIDTH + }; let safe_y_range = (-self.scroll_size.height + self.input_bounds.size.height).min(px(0.0))..px(0.); - let safe_x_range = - (-self.scroll_size.width + self.input_bounds.size.width).min(px(0.0))..px(0.); + let safe_x_range = (-self.scroll_size.width + self.input_bounds.size.width + safe_x_offset) + .min(safe_x_offset)..px(0.); offset.y = if self.mode.is_single_line() { px(0.) @@ -1430,55 +1403,86 @@ impl InputState { cx.notify(); } - pub(crate) fn scroll_to(&mut self, offset: usize, cx: &mut Context) { + /// Scroll to make the given offset visible. + /// + /// If `direction` is Some, will keep edges at the same side. + pub(crate) fn scroll_to( + &mut self, + offset: usize, + direction: Option, + cx: &mut Context, + ) { let Some(last_layout) = self.last_layout.as_ref() else { return; }; - let Some(bounds) = self.last_bounds.as_ref() else { return; }; let mut scroll_offset = self.scroll_handle.offset(); + let was_offset = scroll_offset; let line_height = last_layout.line_height; let point = self.text.offset_to_point(offset); - let row = point.row as usize; + + let row = point.row; let mut row_offset_y = px(0.); - for (ix, wrap_line) in self.text_wrapper.lines.iter().enumerate() { + for (ix, _wrap_line) in self.display_map.lines().iter().enumerate() { if ix == row { break; } - row_offset_y += wrap_line.height(line_height); + // Only accumulate height for visible (non-folded) wrap rows + let visible_wrap_rows = self.display_map.visible_wrap_row_count_for_buffer_line(ix); + row_offset_y += line_height * visible_wrap_rows; } + // For Right alignment use 0 margin: the cursor indicator is clamped inside bounds + // in layout_cursor, so shifting the text here would cause a first-click visual jump. + let safety_margin = match last_layout.text_align { + TextAlign::Left => RIGHT_MARGIN, + TextAlign::Right => px(0.), + TextAlign::Center => CURSOR_WIDTH, + }; if let Some(line) = last_layout .lines .get(row.saturating_sub(last_layout.visible_range.start)) { - // Check to scroll horizontally - if let Some(pos) = line.position_for_index(point.column as usize, line_height) { + // Check to scroll horizontally and soft wrap lines + if let Some(pos) = line.position_for_index(point.column, last_layout, false) { let bounds_width = bounds.size.width - last_layout.line_number_width; let col_offset_x = pos.x; - if col_offset_x - RIGHT_MARGIN < -scroll_offset.x { + row_offset_y += pos.y; + if col_offset_x - safety_margin < -scroll_offset.x { // If the position is out of the visible area, scroll to make it visible - scroll_offset.x = -col_offset_x + RIGHT_MARGIN; - } else if col_offset_x + RIGHT_MARGIN > -scroll_offset.x + bounds_width { - scroll_offset.x = -(col_offset_x - bounds_width + RIGHT_MARGIN); + scroll_offset.x = -col_offset_x + safety_margin; + } else if col_offset_x + safety_margin > -scroll_offset.x + bounds_width { + scroll_offset.x = -(col_offset_x - bounds_width + safety_margin); } } } // Check if row_offset_y is out of the viewport // If row offset is not in the viewport, scroll to make it visible - if row_offset_y - line_height < -scroll_offset.y { + let edge_height = if direction.is_some() && self.mode.is_code_editor() { + 3 * line_height + } else { + line_height + }; + if row_offset_y - edge_height + line_height < -scroll_offset.y { // Scroll up - scroll_offset.y = -row_offset_y + line_height; - } else if row_offset_y + line_height > -scroll_offset.y + bounds.size.height { + scroll_offset.y = -row_offset_y + edge_height - line_height; + } else if row_offset_y + edge_height > -scroll_offset.y + bounds.size.height { // Scroll down - scroll_offset.y = -(row_offset_y - bounds.size.height + line_height); + scroll_offset.y = -(row_offset_y - bounds.size.height + edge_height); + } + + // Avoid necessary scroll, when it was already in the correct position. + if direction == Some(MoveDirection::Up) { + scroll_offset.y = scroll_offset.y.max(was_offset.y); + } else if direction == Some(MoveDirection::Down) { + scroll_offset.y = scroll_offset.y.min(was_offset.y); } scroll_offset.x = scroll_offset.x.min(px(0.)); @@ -1501,7 +1505,7 @@ impl InputState { return; } - let selected_text = self.text.slice(self.selected_range.into()).to_string(); + let selected_text = self.text.slice(self.selected_range).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); } @@ -1510,27 +1514,21 @@ impl InputState { return; } - let selected_text = self.text.slice(self.selected_range.into()).to_string(); + let selected_text = self.text.slice(self.selected_range).to_string(); cx.write_to_clipboard(ClipboardItem::new_string(selected_text)); self.replace_text_in_range_silent(None, "", window, cx); } pub(super) fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - let read_clipboard = cx.read_from_primary(); - #[cfg(any(target_os = "windows", target_os = "macos"))] - let read_clipboard = cx.read_from_clipboard(); - - if let Some(clipboard) = read_clipboard { + if let Some(clipboard) = cx.read_from_clipboard() { let mut new_text = clipboard.text().unwrap_or_default(); - if !self.mode.is_multi_line() { new_text = new_text.replace('\n', ""); } self.replace_text_in_range_silent(None, &new_text, window, cx); - //self.scroll_to(self.cursor(), cx); + self.scroll_to(self.cursor(), None, cx); } } @@ -1539,12 +1537,13 @@ impl InputState { return; } + let range = + text.clip_offset(range.start, Bias::Left)..text.clip_offset(range.end, Bias::Right); let old_text = text.slice(range.clone()).to_string(); - let new_range = range.start..range.start + new_text.len(); self.history - .push(Change::new(range.clone(), &old_text, new_range, new_text)); + .push(Change::new(range, &old_text, new_range, new_text)); } pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { @@ -1569,20 +1568,6 @@ impl InputState { self.history.ignore = false; } - /// Move the cursor to the given offset. - /// - /// The offset is the UTF-8 offset. - /// - /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. - pub(crate) fn move_to(&mut self, offset: usize, cx: &mut Context) { - let offset = offset.clamp(0, self.text.len()); - self.selected_range = (offset..offset).into(); - self.scroll_to(offset, cx); - self.pause_blink_cursor(cx); - self.update_preferred_column(); - cx.notify() - } - /// Get byte offset of the cursor. /// /// The offset is the UTF-8 offset. @@ -1598,14 +1583,33 @@ impl InputState { } } - pub(crate) fn index_for_mouse_position( - &self, - position: Point, - _window: &Window, - _cx: &App, - ) -> usize { + /// Visible row range in the last laid-out viewport, `None` before first layout. + pub fn visible_row_range(&self) -> Option> { + self.last_layout.as_ref().map(|l| l.visible_range.clone()) + } + + /// Current scroll offset of the editor viewport. + pub fn scroll_offset(&self) -> gpui::Point { + self.scroll_handle.offset() + } + + /// Laid-out line height; `None` before first layout. + pub fn line_height(&self) -> Option { + self.last_layout.as_ref().map(|l| l.line_height) + } + + /// Returns the current selection as a byte range into the text. + /// + /// The range is empty (`start == end`) when no text is selected; in + /// that case the offset equals `cursor()`. Byte offsets are measured + /// in the underlying rope's byte units. + pub fn selected_range(&self) -> std::ops::Range { + self.selected_range.into() + } + + pub(crate) fn index_for_mouse_position(&self, position: Point) -> usize { // If the text is empty, always return 0 - if self.text.is_empty() { + if self.text.len() == 0 { return 0; } @@ -1630,90 +1634,58 @@ impl InputState { // - included the scroll offset. let inner_position = position - bounds.origin - point(line_number_width, px(0.)); - let mut index = last_layout.visible_range_offset.start; let mut y_offset = last_layout.visible_top; - for (ix, line) in self - .text_wrapper + + // Traverse visible buffer lines (compact, no hidden entries) + for (vi, (line_layout, _buffer_line)) in last_layout .lines .iter() - .skip(last_layout.visible_range.start) + .zip(last_layout.visible_buffer_lines.iter()) .enumerate() { - let line_origin = self.line_origin_with_y_offset(&mut y_offset, line, line_height); + let line_start_offset = last_layout.visible_line_byte_offsets[vi]; + + // Calculate line origin for this display row + let line_origin = point(px(0.), y_offset); let pos = inner_position - line_origin; - let Some(rendered_line) = last_layout.lines.get(ix) else { - if pos.y < line_origin.y + line_height { - break; - } - - continue; - }; - // Return offset by use closest_index_for_x if is single line mode. if self.mode.is_single_line() { - return rendered_line.unwrapped_layout.closest_index_for_x(pos.x); - } - - let index_result = rendered_line.closest_index_for_position(pos, line_height); - - if let Ok(v) = index_result { - index += v; - break; - } else if rendered_line - .index_for_position(point(px(0.), pos.y), line_height) - .is_ok() - { - // Click in the this line but not in the text, move cursor to the end of the line. - // The fallback index is saved in Err from `index_for_position` method. - index += index_result.unwrap_err(); - break; - } else if rendered_line.text.trim_end_matches('\r').is_empty() { - // empty line on Windows is `\r`, other is '' - let line_bounds = Bounds { - origin: line_origin, - size: gpui::size(bounds.size.width, line_height), + let local_index = line_layout.closest_index_for_x(pos.x, last_layout); + let index = line_start_offset + local_index; + return if self.masked { + self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) + } else { + index.min(self.text.len()) }; - let pos = inner_position; - index += rendered_line.len(); - if line_bounds.contains(&pos) { - break; - } - } else { - index += rendered_line.len(); } - // +1 for revert `lines` split `\n` - index += 1; + // Check if mouse is in this line's bounds + if let Some(local_index) = line_layout.closest_index_for_position(pos, last_layout) { + let index = line_start_offset + local_index; + return if self.masked { + self.text.char_index_to_offset(index / MASK_CHAR.len_utf8()) + } else { + index.min(self.text.len()) + }; + } else if pos.y < px(0.) { + // Mouse is above this line, return start of this line + return if self.masked { + self.text + .char_index_to_offset(line_start_offset / MASK_CHAR.len_utf8()) + } else { + line_start_offset + }; + } + + y_offset += line_layout.size(line_height).height; } - if index > self.text.len() { - self.text.len() - } else { - index - } + // Mouse is below all visible lines, return end of text + self.text.len() } /// Returns a y offsetted point for the line origin. - fn line_origin_with_y_offset( - &self, - y_offset: &mut Pixels, - line: &LineItem, - line_height: Pixels, - ) -> Point { - // NOTE: About line.wrap_boundaries.len() - // - // If only 1 line, the value is 0 - // If have 2 line, the value is 1 - if self.mode.is_multi_line() { - let p = point(px(0.), *y_offset); - *y_offset += line.height(line_height); - p - } else { - point(px(0.), px(0.)) - } - } - /// Select the text from the current cursor position to the given offset. /// /// The offset is the UTF-8 offset. @@ -1721,6 +1693,7 @@ impl InputState { /// Ensure the offset use self.next_boundary or self.previous_boundary to get the correct offset. pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context) { let offset = offset.clamp(0, self.text.len()); + if self.selection_reversed { self.selected_range.start = offset } else { @@ -1741,78 +1714,14 @@ impl InputState { self.selected_range.end = word_range.end; } } + if self.selected_range.is_empty() { self.update_preferred_column(); } + cx.notify() } - /// 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) { - #[inline(always)] - fn is_word(c: char) -> bool { - c.is_alphanumeric() || matches!(c, '_') - } - - let mut start = offset; - let mut end = start; - - let prev_text = self - .text_for_range(self.range_to_utf16(&(0..start)), &mut None, window, cx) - .unwrap_or_default(); - - let next_text = self - .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(); - let next_chars = next_text.chars(); - let pre_chars_count = prev_chars.clone().count(); - - for (ix, c) in prev_chars.enumerate() { - if !is_word(c) { - break; - } - - if ix < pre_chars_count { - start = start.saturating_sub(c.len_utf8()); - } - } - - for c in next_chars { - if !is_word(c) { - break; - } - - end += c.len_utf8(); - } - - if start == end { - return; - } - - self.selected_range = (start..end).into(); - self.selected_word_range = Some(self.selected_range); - cx.notify() - } - - /// Selects the entire line containing the cursor. - fn select_line(&mut self, _window: &mut Window, cx: &mut Context) { - let offset = self.start_of_line(); - let end = self.end_of_line(); - self.move_to(end, cx); - self.select_to(offset, cx); - } - /// Unselects the currently selected text. pub fn unselect(&mut self, _: &mut Window, cx: &mut Context) { let offset = self.cursor(); @@ -1822,12 +1731,12 @@ impl InputState { #[inline] pub(super) fn offset_from_utf16(&self, offset: usize) -> usize { - self.text.offset_utf16_to_offset(OffsetUtf16(offset)) + self.text.offset_utf16_to_offset(offset) } #[inline] pub(super) fn offset_to_utf16(&self, offset: usize) -> usize { - self.text.offset_to_offset_utf16(offset).0 + self.text.offset_to_offset_utf16(offset) } #[inline] @@ -1840,29 +1749,60 @@ impl InputState { self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end) } - fn previous_boundary(&self, offset: usize) -> usize { + /// If offset falls on a hidden (folded) line, clamp backward to the end of + /// the fold header line (last visible position before the fold). + fn clamp_offset_to_visible_backward(&self, offset: usize) -> usize { + let line = self.text.offset_to_point(offset).row; + if self.display_map.is_buffer_line_hidden(line) { + for fold in self.display_map.folded_ranges() { + if line > fold.start_line && line <= fold.end_line { + return self.text.line_end_offset(fold.start_line); + } + } + } + offset + } + + /// If offset falls on a hidden (folded) line, clamp forward to the start of + /// the fold end line (first visible position after the fold). + fn clamp_offset_to_visible_forward(&self, offset: usize) -> usize { + let line = self.text.offset_to_point(offset).row; + if self.display_map.is_buffer_line_hidden(line) { + for fold in self.display_map.folded_ranges() { + if line > fold.start_line && line <= fold.end_line { + return self.text.line_start_offset(fold.end_line); + } + } + } + offset + } + + pub(super) fn previous_boundary(&self, offset: usize) -> usize { let mut offset = self.text.clip_offset(offset.saturating_sub(1), Bias::Left); if let Some(ch) = self.text.char_at(offset) && ch == '\r' { offset -= 1; } - offset + + self.clamp_offset_to_visible_backward(offset) } - fn next_boundary(&self, offset: usize) -> usize { + pub(super) fn next_boundary(&self, offset: usize) -> usize { let mut offset = self.text.clip_offset(offset + 1, Bias::Right); if let Some(ch) = self.text.char_at(offset) && ch == '\r' { offset += 1; } - offset + + self.clamp_offset_to_visible_forward(offset) } /// 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.focus_handle.is_focused(window) + && !self.disabled && self.blink_cursor.read(cx).visible() && window.is_window_active() } @@ -1882,9 +1822,10 @@ impl InputState { root.focused_input = None; }); cx.emit(InputEvent::Blur); + cx.notify(); } - fn pause_blink_cursor(&mut self, cx: &mut Context) { + pub(super) fn pause_blink_cursor(&mut self, cx: &mut Context) { self.blink_cursor.update(cx, |cursor, cx| { cursor.pause(cx); }); @@ -1900,7 +1841,7 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - if self.text.is_empty() { + if self.text.len() == 0 { return; } @@ -1916,7 +1857,7 @@ impl InputState { return; } - let offset = self.index_for_mouse_position(event.position, window, cx); + let offset = self.index_for_mouse_position(event.position); self.select_to(offset, cx); } @@ -1976,7 +1917,7 @@ impl InputState { let wrap_width_changed = self.input_bounds.size.width != new_bounds.size.width; self.input_bounds = new_bounds; - // Update text_wrapper wrap_width if changed. + // Update display_map wrap_width if changed. if let Some(last_layout) = self.last_layout.as_ref() && wrap_width_changed { @@ -1987,31 +1928,35 @@ impl InputState { last_layout.wrap_width }; - self.text_wrapper.set_wrap_width(wrap_width, cx); - self.mode.update_auto_grow(&self.text_wrapper); + self.display_map.on_layout_changed(wrap_width, cx); + self.mode.update_auto_grow(&self.display_map); cx.notify(); } } - /// Replace text by [`lsp_types::Range`]. + pub(super) fn selected_text(&self) -> RopeSlice<'_> { + let range_utf16 = self.range_to_utf16(&self.selected_range.into()); + let range = self.range_from_utf16(&range_utf16); + self.text.slice(range) + } + + /// Return the rendered bounds for a UTF-8 byte range in the current input contents. /// - /// See also: [`EntityInputHandler::replace_text_in_range`] - #[allow(unused)] - pub(crate) fn replace_text_in_lsp_range( - &mut self, - lsp_range: &lsp_types::Range, - new_text: &str, - window: &mut Window, - cx: &mut Context, - ) { - let start = self.text.position_to_offset(&lsp_range.start); - let end = self.text.position_to_offset(&lsp_range.end); - self.replace_text_in_range_silent( - Some(self.range_to_utf16(&(start..end))), - new_text, - window, - cx, - ); + /// Returns `None` when the requested range is not currently laid out or visible. + pub fn range_to_bounds(&self, range: &Range) -> Option> { + let last_layout = self.last_layout.as_ref()?; + let last_bounds = self.last_bounds?; + + let (_, _, start_pos) = self.line_and_position_for_offset(range.start); + let (_, _, end_pos) = self.line_and_position_for_offset(range.end); + + let start_pos = start_pos?; + let end_pos = end_pos?; + + Some(Bounds::from_corners( + last_bounds.origin + start_pos, + last_bounds.origin + end_pos + point(px(0.), last_layout.line_height), + )) } /// Replace text in range in silent. @@ -2083,7 +2028,9 @@ impl EntityInputHandler for InputState { return; } - self.pause_blink_cursor(cx); + if self.blink_cursor.read(cx).visible() { + self.pause_blink_cursor(cx); + } let range = range_utf16 .as_ref() @@ -2117,13 +2064,21 @@ impl EntityInputHandler for InputState { } self.push_history(&old_text, &range, new_text); - self.text_wrapper - .update(&self.text, &range, &Rope::from(new_text), false, cx); + self.history.end_grouping(); + + // Adjust folds before updating wrap map: remove overlapping folds and shift others + self.display_map + .adjust_folds_for_edit(&old_text, &range, new_text); + self.display_map + .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); + self.selected_range = (new_offset..new_offset).into(); self.ime_marked_range.take(); self.update_preferred_column(); - self.mode.update_auto_grow(&self.text_wrapper); - cx.emit(InputEvent::Change); + self.mode.update_auto_grow(&self.display_map); + if self.emit_events { + cx.emit(InputEvent::Change); + } cx.notify(); } @@ -2133,7 +2088,7 @@ impl EntityInputHandler for InputState { range_utf16: Option>, new_text: &str, new_selected_range_utf16: Option>, - _: &mut Window, + _window: &mut Window, cx: &mut Context, ) { if self.disabled { @@ -2160,9 +2115,11 @@ impl EntityInputHandler for InputState { } } - self.push_history(&old_text, &range, new_text); - self.text_wrapper - .update(&self.text, &range, &Rope::from(new_text), false, cx); + // Adjust folds before updating wrap map: remove overlapping folds and shift others + self.display_map + .adjust_folds_for_edit(&old_text, &range, new_text); + self.display_map + .on_text_changed(&self.text, &range, &Rope::from(new_text), cx); if new_text.is_empty() { // Cancel selection, when cancel IME input. @@ -2177,9 +2134,10 @@ impl EntityInputHandler for InputState { .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()) .into(); } - self.mode.update_auto_grow(&self.text_wrapper); - cx.emit(InputEvent::Change); + self.mode.update_auto_grow(&self.display_map); + self.history.start_grouping(); + self.push_history(&old_text, &range, new_text); cx.notify(); } @@ -2200,28 +2158,34 @@ impl EntityInputHandler for InputState { let mut end_origin = None; let line_number_origin = point(line_number_width, px(0.)); let mut y_offset = last_layout.visible_top; - let mut index_offset = last_layout.visible_range_offset.start; - for line in last_layout.lines.iter() { + for (vi, line) in last_layout.lines.iter().enumerate() { if start_origin.is_some() && end_origin.is_some() { break; } + let index_offset = last_layout.visible_line_byte_offsets[vi]; + if start_origin.is_none() - && let Some(p) = - line.position_for_index(range.start.saturating_sub(index_offset), line_height) + && let Some(p) = line.position_for_index( + range.start.saturating_sub(index_offset), + last_layout, + false, + ) { start_origin = Some(p + point(px(0.), y_offset)); } if end_origin.is_none() - && let Some(p) = - line.position_for_index(range.end.saturating_sub(index_offset), line_height) + && let Some(p) = line.position_for_index( + range.end.saturating_sub(index_offset), + last_layout, + false, + ) { end_origin = Some(p + point(px(0.), y_offset)); } - index_offset += line.len() + 1; y_offset += line.size(line_height).height; } @@ -2244,12 +2208,11 @@ impl EntityInputHandler for InputState { _cx: &mut Context, ) -> Option { let last_layout = self.last_layout.as_ref()?; - let line_height = last_layout.line_height; let line_point = self.last_bounds?.localize(&point)?; - let offset = last_layout.visible_range_offset.start; - for line in last_layout.lines.iter() { - if let Ok(utf8_index) = line.index_for_position(line_point, line_height) { + for (vi, line) in last_layout.lines.iter().enumerate() { + let offset = last_layout.visible_line_byte_offsets[vi]; + if let Some(utf8_index) = line.index_for_position(line_point, last_layout) { return Some(self.offset_to_utf16(offset + utf8_index)); } } @@ -2265,9 +2228,7 @@ impl Focusable for InputState { } impl Render for InputState { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - self.text_wrapper.update_all(&self.text, false, cx); - + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .id("input-state") .flex_1() diff --git a/crates/ui/src/input/text_wrapper.rs b/crates/ui/src/input/text_wrapper.rs deleted file mode 100644 index 5bdbb5d..0000000 --- a/crates/ui/src/input/text_wrapper.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::ops::Range; - -use gpui::{App, Font, LineFragment, Pixels}; -use rope::Rope; - -use super::rope_ext::RopeExt; - -/// A line with soft wrapped lines info. -#[derive(Clone)] -pub(super) struct LineItem { - /// The original line text. - line: Rope, - /// The soft wrapped lines relative byte range (0..line.len) of this line (Include first line). - /// - /// FIXME: Here in somecase, the `line_wrapper.wrap_line` has returned different - /// like the `window.text_system().shape_text`. So, this value may not equal - /// the actual rendered lines. - wrapped_lines: Vec>, -} - -impl LineItem { - /// Get the bytes length of this line. - #[inline] - pub(super) fn len(&self) -> usize { - self.line.len() - } - - /// Get number of soft wrapped lines of this line (include the first line). - #[inline] - pub(super) fn lines_len(&self) -> usize { - self.wrapped_lines.len() - } - - /// Get the height of this line item with given line height. - pub(super) fn height(&self, line_height: Pixels) -> Pixels { - self.lines_len() as f32 * line_height - } -} - -/// Used to prepare the text with soft wrap to be get lines to displayed in the Editor. -/// -/// After use lines to calculate the scroll size of the Editor. -pub(super) struct TextWrapper { - text: Rope, - - /// Total wrapped lines (Inlucde the first line), value is start and end index of the line. - soft_lines: usize, - font: Font, - font_size: Pixels, - - /// If is none, it means the text is not wrapped - wrap_width: Option, - - /// The lines by split \n - pub(super) lines: Vec, - - _initialized: bool, -} - -#[allow(unused)] -impl TextWrapper { - pub(super) fn new(font: Font, font_size: Pixels, wrap_width: Option) -> Self { - Self { - text: Rope::new(), - font, - font_size, - wrap_width, - soft_lines: 0, - lines: Vec::new(), - _initialized: false, - } - } - - #[inline] - pub(super) fn set_default_text(&mut self, text: &Rope) { - self.text = text.clone(); - } - - /// Get the total number of lines including wrapped lines. - #[inline] - pub(super) fn len(&self) -> usize { - self.soft_lines - } - - /// Get the line item by row index. - #[inline] - pub(super) fn line(&self, row: usize) -> Option<&LineItem> { - self.lines.get(row) - } - - pub(super) fn set_wrap_width(&mut self, wrap_width: Option, cx: &mut App) { - if wrap_width == self.wrap_width { - return; - } - self.wrap_width = wrap_width; - self.update_all(&self.text.clone(), true, cx); - } - - pub(super) fn set_font(&mut self, font: Font, font_size: Pixels, cx: &mut App) { - if self.font.eq(&font) && self.font_size == font_size { - return; - } - self.font = font; - self.font_size = font_size; - self.update_all(&self.text.clone(), true, cx); - } - - pub(super) fn prepare_if_need(&mut self, text: &Rope, cx: &mut App) { - if self._initialized { - return; - } - self._initialized = true; - self.update_all(text, true, cx); - } - - /// Update the text wrapper and recalculate the wrapped lines. - /// - /// If the `text` is the same as the current text, do nothing. - /// - /// - `changed_text`: The text [`Rope`] that has changed. - /// - `range`: The `selected_range` before change. - /// - `new_text`: The inserted text. - /// - `force`: Whether to force the update, if false, the update will be skipped if the text is the same. - /// - `cx`: The application context. - pub(super) fn update( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - force: bool, - cx: &mut App, - ) { - let mut line_wrapper = cx - .text_system() - .line_wrapper(self.font.clone(), self.font_size); - self._update( - changed_text, - range, - new_text, - force, - &mut |line_str, wrap_width| { - line_wrapper - .wrap_line(&[LineFragment::text(line_str)], wrap_width) - .collect() - }, - ); - } - - fn _update( - &mut self, - changed_text: &Rope, - range: &Range, - new_text: &Rope, - force: bool, - wrap_line: &mut F, - ) where - F: FnMut(&str, Pixels) -> Vec, - { - if self.text.eq(changed_text) && !force { - return; - } - - // Remove the old changed lines. - let start_row = self.text.offset_to_point(range.start).row as usize; - let start_row = start_row.min(self.lines.len().saturating_sub(1)); - let end_row = self.text.offset_to_point(range.end).row as usize; - let end_row = end_row.min(self.lines.len().saturating_sub(1)); - let rows_range = start_row..=end_row; - - // To add the new lines. - let new_start_row = changed_text.offset_to_point(range.start).row as usize; - let new_start_offset = changed_text.line_start_offset(new_start_row); - let new_end_row = changed_text - .offset_to_point(range.start + new_text.len()) - .row as usize; - let new_end_offset = changed_text.line_end_offset(new_end_row); - let new_range = new_start_offset..new_end_offset; - - let mut new_lines = vec![]; - - let wrap_width = self.wrap_width; - - for line in changed_text.slice(new_range).lines() { - let line_str = line.to_string(); - let mut wrapped_lines = vec![]; - let mut prev_boundary_ix = 0; - - // If wrap_width is Pixels::MAX, skip wrapping to disable word wrap - if let Some(wrap_width) = wrap_width { - // Here only have wrapped line, if there is no wrap meet, the `line_wraps` result will empty. - for boundary in wrap_line(&line_str, wrap_width) { - wrapped_lines.push(prev_boundary_ix..boundary.ix); - prev_boundary_ix = boundary.ix; - } - } - - // Reset of the line - if !line_str[prev_boundary_ix..].is_empty() || prev_boundary_ix == 0 { - wrapped_lines.push(prev_boundary_ix..line.len()); - } - - new_lines.push(LineItem { - line: line.clone(), - wrapped_lines, - }); - } - - // dbg!(&new_lines.len()); - // dbg!(self.lines.len()); - if self.lines.is_empty() { - self.lines = new_lines; - } else { - self.lines.splice(rows_range, new_lines); - } - - // dbg!(self.lines.len()); - self.text = changed_text.clone(); - self.soft_lines = self.lines.iter().map(|l| l.lines_len()).sum(); - } - - /// Update the text wrapper and recalculate the wrapped lines. - /// - /// If the `text` is the same as the current text, do nothing. - pub(crate) fn update_all(&mut self, text: &Rope, force: bool, cx: &mut App) { - self.update(text, &(0..text.len()), text, force, cx); - } -} diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index d951056..5a14721 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -13,7 +13,7 @@ use smol::Timer; use theme::ActiveTheme; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::input::{InputEvent, InputState, TextInput}; +use crate::input::{Input, InputEvent, InputState}; use crate::list::ListDelegate; use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache}; use crate::scroll::{Scrollbar, ScrollbarHandle}; @@ -288,7 +288,7 @@ where }); }); } - InputEvent::PressEnter { secondary } => self.on_action_confirm( + InputEvent::PressEnter { secondary, .. } => self.on_action_confirm( &Confirm { secondary: *secondary, }, @@ -632,10 +632,10 @@ where .border_b_1() .border_color(cx.theme().border) .child( - TextInput::new(&input) + Input::new(&input) .with_size(self.options.size) .appearance(false) - .cleanable() + .cleanable(true) .p_0() .prefix( Icon::new(IconName::Search).text_color(cx.theme().text_muted), diff --git a/desktop/src/dialogs/import.rs b/desktop/src/dialogs/import.rs index 205221b..7810a53 100644 --- a/desktop/src/dialogs/import.rs +++ b/desktop/src/dialogs/import.rs @@ -11,7 +11,7 @@ use smallvec::{SmallVec, smallvec}; use state::{CoopAuthUrlHandler, NostrRegistry, StateEvent}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; -use ui::input::{InputEvent, InputState, TextInput}; +use ui::input::{Input, InputEvent, InputState}; use ui::{Disableable, v_flex}; #[derive(Debug)] @@ -250,7 +250,7 @@ impl Render for ImportKey { .text_sm() .text_color(cx.theme().text_muted) .child("nsec or bunker://") - .child(TextInput::new(&self.key_input)), + .child(Input::new(&self.key_input)), ) .when( self.key_input.read(cx).value().starts_with("ncryptsec1"), @@ -261,7 +261,7 @@ impl Render for ImportKey { .text_sm() .text_color(cx.theme().text_muted) .child("Password:") - .child(TextInput::new(&self.pass_input)), + .child(Input::new(&self.pass_input)), ) }, ) diff --git a/desktop/src/dialogs/restore.rs b/desktop/src/dialogs/restore.rs index cde7591..1577541 100644 --- a/desktop/src/dialogs/restore.rs +++ b/desktop/src/dialogs/restore.rs @@ -10,7 +10,7 @@ use gpui::{ use nostr_connect::prelude::*; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; -use ui::input::{InputEvent, InputState, TextInput}; +use ui::input::{Input, InputEvent, InputState}; use ui::{WindowExtension, v_flex}; #[derive(Debug)] @@ -107,7 +107,7 @@ impl Render for RestoreEncryption { .text_sm() .text_color(cx.theme().text_muted) .child("Secret Key") - .child(TextInput::new(&self.key_input)), + .child(Input::new(&self.key_input)), ) .child( Button::new("restore") diff --git a/desktop/src/dialogs/settings.rs b/desktop/src/dialogs/settings.rs index 331e2b7..f6e67dc 100644 --- a/desktop/src/dialogs/settings.rs +++ b/desktop/src/dialogs/settings.rs @@ -7,7 +7,7 @@ use settings::{AppSettings, AuthMode}; use theme::{ActiveTheme, Theme, ThemeMode}; use ui::button::{Button, ButtonVariants}; use ui::group_box::{GroupBox, GroupBoxVariants}; -use ui::input::{InputState, TextInput}; +use ui::input::{Input, InputState}; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::Notification; use ui::switch::Switch; @@ -218,7 +218,7 @@ impl Render for Preferences { .child( h_flex() .gap_1() - .child(TextInput::new(&self.file_input).text_xs().small()) + .child(Input::new(&self.file_input).text_xs().small()) .child( Button::new("update-file-server") .icon(IconName::Check) diff --git a/desktop/src/panels/backup.rs b/desktop/src/panels/backup.rs index ba0310a..3ba18d3 100644 --- a/desktop/src/panels/backup.rs +++ b/desktop/src/panels/backup.rs @@ -10,7 +10,7 @@ use state::KEYRING; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputState, TextInput}; +use ui::input::{Input, InputState}; use ui::{IconName, Sizable, StyledExt, divider, v_flex}; const MSG: &str = "Store your account keys in a safe location. \ @@ -40,8 +40,8 @@ pub struct BackupPanel { impl BackupPanel { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let npub_input = cx.new(|cx| InputState::new(window, cx).disabled(true)); - let nsec_input = cx.new(|cx| InputState::new(window, cx).disabled(true).masked(true)); + let npub_input = cx.new(|cx| InputState::new(window, cx)); + let nsec_input = cx.new(|cx| InputState::new(window, cx).masked(true)); // Run at the end of current cycle cx.defer_in(window, |this, window, cx| { @@ -156,7 +156,7 @@ impl Render for BackupPanel { .child(SharedString::from("Public Key:")), ) .child( - TextInput::new(&self.npub_input) + Input::new(&self.npub_input) .small() .bordered(false) .disabled(true), @@ -174,7 +174,7 @@ impl Render for BackupPanel { .child(SharedString::from("Secret Key:")), ) .child( - TextInput::new(&self.nsec_input) + Input::new(&self.nsec_input) .small() .bordered(false) .disabled(true), diff --git a/desktop/src/panels/contact_list.rs b/desktop/src/panels/contact_list.rs index 628d75d..a9f86f2 100644 --- a/desktop/src/panels/contact_list.rs +++ b/desktop/src/panels/contact_list.rs @@ -16,7 +16,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; +use ui::input::{Input, InputEvent, InputState}; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -301,10 +301,10 @@ impl Render for ContactListPanel { .gap_1() .w_full() .child( - TextInput::new(&self.input) + Input::new(&self.input) .small() .bordered(false) - .cleanable(), + .cleanable(true), ) .child( Button::new("add") diff --git a/desktop/src/panels/messaging_relays.rs b/desktop/src/panels/messaging_relays.rs index 66fca40..1125c13 100644 --- a/desktop/src/panels/messaging_relays.rs +++ b/desktop/src/panels/messaging_relays.rs @@ -14,7 +14,7 @@ use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; +use ui::input::{Input, InputEvent, InputState}; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex}; const MSG: &str = "Messaging Relays are relays that hosted all your messages. \ @@ -317,10 +317,10 @@ impl Render for MessagingRelayPanel { .gap_1() .w_full() .child( - TextInput::new(&self.input) + Input::new(&self.input) .small() .bordered(false) - .cleanable(), + .cleanable(true), ) .child( Button::new("add") diff --git a/desktop/src/panels/profile.rs b/desktop/src/panels/profile.rs index b9b26ce..606b6ae 100644 --- a/desktop/src/panels/profile.rs +++ b/desktop/src/panels/profile.rs @@ -15,7 +15,7 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputState, TextInput}; +use ui::input::{Input, InputState}; use ui::notification::Notification; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; @@ -65,7 +65,7 @@ impl ProfilePanel { // Use multi-line input for bio let bio_input = cx.new(|cx| { InputState::new(window, cx) - .multi_line() + .multi_line(true) .auto_grow(3, 8) .placeholder("A short introduce about you.") }); @@ -352,7 +352,7 @@ impl Render for ProfilePanel { .text_color(cx.theme().text_muted) .child(SharedString::from("What should people call you?")), ) - .child(TextInput::new(&self.name_input).bordered(false).small()), + .child(Input::new(&self.name_input).bordered(false).small()), ) .child( v_flex() @@ -363,7 +363,7 @@ impl Render for ProfilePanel { .text_color(cx.theme().text_muted) .child(SharedString::from("A short introduction about you:")), ) - .child(TextInput::new(&self.bio_input).bordered(false).small()), + .child(Input::new(&self.bio_input).bordered(false).small()), ) .child( v_flex() @@ -374,7 +374,7 @@ impl Render for ProfilePanel { .text_color(cx.theme().text_muted) .child(SharedString::from("Website:")), ) - .child(TextInput::new(&self.website_input).bordered(false).small()), + .child(Input::new(&self.website_input).bordered(false).small()), ) .child( v_flex() diff --git a/desktop/src/panels/relay_list.rs b/desktop/src/panels/relay_list.rs index 577eb20..8e8dfb8 100644 --- a/desktop/src/panels/relay_list.rs +++ b/desktop/src/panels/relay_list.rs @@ -15,7 +15,7 @@ use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; +use ui::input::{Input, InputEvent, InputState}; use ui::menu::DropdownMenu; use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex}; @@ -369,10 +369,10 @@ impl Render for RelayListPanel { .gap_1() .w_full() .child( - TextInput::new(&self.input) + Input::new(&self.input) .small() .bordered(false) - .cleanable(), + .cleanable(true), ) .child( Button::new("metadata") diff --git a/desktop/src/sidebar/mod.rs b/desktop/src/sidebar/mod.rs index a9d1b99..fb74388 100644 --- a/desktop/src/sidebar/mod.rs +++ b/desktop/src/sidebar/mod.rs @@ -20,7 +20,7 @@ use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::input::{InputEvent, InputState, TextInput}; +use ui::input::{Input, InputEvent, InputState}; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; @@ -252,7 +252,6 @@ impl Sidebar { fn set_finding(&mut self, status: bool, _window: &mut Window, cx: &mut Context) { // Disable the input to prevent duplicate requests self.find_input.update(cx, |this, cx| { - this.set_disabled(status, cx); this.set_loading(status, cx); }); // Set the search status @@ -513,7 +512,7 @@ impl Render for Sidebar { .border_color(cx.theme().border) .bg(cx.theme().tab_background) .child( - TextInput::new(&self.find_input) + Input::new(&self.find_input) .appearance(false) .bordered(false) .small()