From 36ef119e6f6d33bb3afed3c48ce97ba91603c5cd Mon Sep 17 00:00:00 2001 From: MinusGix Date: Fri, 8 Mar 2024 03:06:22 -0600 Subject: [PATCH] Text Editor Placeholder (#356) * TextLayoutProvider::text return Rope by value * text_editor: impl placeholder --- examples/editor/src/main.rs | 3 +- src/views/editor/mod.rs | 103 +++++++++++------------------- src/views/editor/phantom_text.rs | 1 + src/views/editor/text.rs | 20 +++--- src/views/editor/text_document.rs | 66 +++++++++++++++++-- src/views/editor/visual_line.rs | 18 +++--- src/views/text_editor.rs | 35 ++++++++-- 7 files changed, 150 insertions(+), 96 deletions(-) diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs index 14e507c8..38b48a28 100644 --- a/examples/editor/src/main.rs +++ b/examples/editor/src/main.rs @@ -27,7 +27,8 @@ fn app_view() -> impl View { .update(|_| { // This hooks up to both editors! println!("Editor changed"); - }); + }) + .placeholder("Some placeholder text"); let doc = editor_a.doc(); let gutter_a = editor_a.editor().gutter; let gutter_b = editor_b.editor().gutter; diff --git a/src/views/editor/mod.rs b/src/views/editor/mod.rs index 56a58adf..52f0d11c 100644 --- a/src/views/editor/mod.rs +++ b/src/views/editor/mod.rs @@ -361,28 +361,8 @@ impl Editor { &self.lines } - // Get the text layout for a document line, creating it if needed. - pub fn text_layout(&self, line: usize) -> Arc { - self.text_layout_trigger(line, true) - } - - pub fn text_layout_trigger(&self, line: usize, trigger: bool) -> Arc { - let cache_rev = self.doc().cache_rev().get_untracked(); - let id = self.style().id(); - let text_prov = self.text_prov(); - self.lines - .get_init_text_layout(cache_rev, id, &text_prov, line, trigger) - } - - pub fn text_prov(&self) -> EditorTextProv { - let doc = self.doc.get_untracked(); - EditorTextProv { - text: doc.text(), - doc, - lines: self.lines.clone(), - style: self.style.get_untracked(), - viewport: self.viewport.get_untracked(), - } + pub fn text_prov(&self) -> &Self { + self } fn preedit(&self) -> PreeditData { @@ -589,7 +569,7 @@ impl Editor { // === Information === pub fn phantom_text(&self, line: usize) -> PhantomTextLine { - self.doc().phantom_text(line) + self.doc().phantom_text(self, line) } pub fn line_height(&self, line: usize) -> f32 { @@ -603,7 +583,11 @@ impl Editor { // === Line Information === /// Iterate over the visual lines in the view, starting at the given line. - pub fn iter_vlines(&self, backwards: bool, start: VLine) -> impl Iterator { + pub fn iter_vlines( + &self, + backwards: bool, + start: VLine, + ) -> impl Iterator + '_ { self.lines.iter_vlines(self.text_prov(), backwards, start) } @@ -614,7 +598,7 @@ impl Editor { backwards: bool, start: VLine, end: VLine, - ) -> impl Iterator { + ) -> impl Iterator + '_ { self.lines .iter_vlines_over(self.text_prov(), backwards, start, end) } @@ -626,7 +610,7 @@ impl Editor { &self, backwards: bool, start: RVLine, - ) -> impl Iterator> { + ) -> impl Iterator> + '_ { self.lines.iter_rvlines(self.text_prov(), backwards, start) } @@ -639,7 +623,7 @@ impl Editor { backwards: bool, start: RVLine, end_line: usize, - ) -> impl Iterator> { + ) -> impl Iterator> + '_ { self.lines .iter_rvlines_over(self.text_prov(), backwards, start, end_line) } @@ -885,7 +869,7 @@ impl Editor { let hit_point = text_layout.text.hit_point(Point::new(point.x, y)); // We have to unapply the phantom text shifting in order to get back to the column in // the actual buffer - let phantom_text = self.doc().phantom_text(line); + let phantom_text = self.doc().phantom_text(self, line); let col = phantom_text.before_col(hit_point.index); // Ensure that the column doesn't end up out of bounds, so things like clicking on the far // right end will just go to the end of the line. @@ -981,31 +965,23 @@ impl std::fmt::Debug for Editor { } } -#[derive(Clone)] -pub struct EditorTextProv { - text: Rope, - doc: Rc, - style: Rc, - lines: Rc, - - viewport: Rect, -} -impl EditorTextProv { +// Text layout creation +impl Editor { // Get the text layout for a document line, creating it if needed. pub fn text_layout(&self, line: usize) -> Arc { self.text_layout_trigger(line, true) } pub fn text_layout_trigger(&self, line: usize, trigger: bool) -> Arc { - let cache_rev = self.doc.cache_rev().get_untracked(); - let id = self.style.id(); + let cache_rev = self.doc().cache_rev().get_untracked(); + let id = self.style().id(); self.lines .get_init_text_layout(cache_rev, id, self, line, trigger) } fn try_get_text_layout(&self, line: usize) -> Option> { - let cache_rev = self.doc.cache_rev().get_untracked(); - let id = self.style.id(); + let cache_rev = self.doc().cache_rev().get_untracked(); + let id = self.style().id(); self.lines.try_get_text_layout(cache_rev, id, line) } @@ -1076,10 +1052,10 @@ impl EditorTextProv { Some(rendered_whitespaces) } } -impl TextLayoutProvider for EditorTextProv { +impl TextLayoutProvider for Editor { // TODO: should this just return a `Rope`? - fn text(&self) -> &Rope { - &self.text + fn text(&self) -> Rope { + Editor::text(self) } fn new_text_layout( @@ -1091,10 +1067,12 @@ impl TextLayoutProvider for EditorTextProv { // TODO: we could share text layouts between different editor views given some knowledge of // their wrapping let text = self.rope_text(); + let style = self.style(); + let doc = self.doc(); let line_content_original = text.line_content(line); - let font_size = self.style.font_size(line); + let font_size = style.font_size(line); // Get the line content with newline characters replaced with spaces // and the content without the newline characters @@ -1108,18 +1086,18 @@ impl TextLayoutProvider for EditorTextProv { line_content_original.to_string() }; // Combine the phantom text with the line content - let phantom_text = self.doc.phantom_text(line); + let phantom_text = doc.phantom_text(self, line); let line_content = phantom_text.combine_with_text(&line_content); - let family = self.style.font_family(line); + let family = style.font_family(line); let attrs = Attrs::new() - .color(self.style.color(EditorColor::Foreground)) + .color(style.color(EditorColor::Foreground)) .family(&family) .font_size(font_size as f32) - .line_height(LineHeightValue::Px(self.style.line_height(line))); + .line_height(LineHeightValue::Px(style.line_height(line))); let mut attrs_list = AttrsList::new(attrs); - self.style.apply_attr_styles(line, attrs, &mut attrs_list); + style.apply_attr_styles(line, attrs, &mut attrs_list); // Apply phantom text specific styling for (offset, size, col, phantom) in phantom_text.offset_size_iter() { @@ -1144,14 +1122,15 @@ impl TextLayoutProvider for EditorTextProv { let mut text_layout = TextLayout::new(); // TODO: we could move tab width setting to be done by the document - text_layout.set_tab_width(self.style.tab_width(line)); + text_layout.set_tab_width(style.tab_width(line)); text_layout.set_text(&line_content, attrs_list); - match self.style.wrap() { + match style.wrap() { WrapMethod::None => {} WrapMethod::EditorWidth => { + let width = self.viewport.get_untracked().width(); text_layout.set_wrap(Wrap::Word); - text_layout.set_size(self.viewport.width() as f32, f32::MAX); + text_layout.set_size(width as f32, f32::MAX); } WrapMethod::WrapWidth { width } => { text_layout.set_wrap(Wrap::Word); @@ -1165,20 +1144,16 @@ impl TextLayoutProvider for EditorTextProv { &line_content_original, &text_layout, &phantom_text, - self.style.render_whitespace(), + style.render_whitespace(), ); - let indent_line = self.style.indent_line(line, &line_content_original); + let indent_line = style.indent_line(line, &line_content_original); let indent = if indent_line != line { // TODO: This creates the layout if it isn't already cached, but it doesn't cache the // result because the current method of managing the cache is not very smart. let layout = self.try_get_text_layout(indent_line).unwrap_or_else(|| { - self.new_text_layout( - indent_line, - self.style.font_size(indent_line), - self.lines.wrap(), - ) + self.new_text_layout(indent_line, style.font_size(indent_line), self.lines.wrap()) }); layout.indent + 1.0 } else { @@ -1193,17 +1168,17 @@ impl TextLayoutProvider for EditorTextProv { whitespaces, indent, }; - self.style.apply_layout_styles(line, &mut layout_line); + style.apply_layout_styles(line, &mut layout_line); Arc::new(layout_line) } fn before_phantom_col(&self, line: usize, col: usize) -> usize { - self.doc.before_phantom_col(line, col) + self.doc().before_phantom_col(self, line, col) } fn has_multiline_phantom(&self) -> bool { - self.doc.has_multiline_phantom() + self.doc().has_multiline_phantom(self) } } diff --git a/src/views/editor/phantom_text.rs b/src/views/editor/phantom_text.rs index d6ffae74..a8fead70 100644 --- a/src/views/editor/phantom_text.rs +++ b/src/views/editor/phantom_text.rs @@ -26,6 +26,7 @@ pub struct PhantomText { pub enum PhantomTextKind { /// Input methods Ime, + Placeholder, /// Completion lens / Inline completion Completion, /// Inlay hints supplied by an LSP/PSP (like type annotations) diff --git a/src/views/editor/text.rs b/src/views/editor/text.rs index 44cb115c..c8dba4cf 100644 --- a/src/views/editor/text.rs +++ b/src/views/editor/text.rs @@ -194,16 +194,16 @@ pub trait Document: DocumentPhantom + Downcast { impl_downcast!(Document); pub trait DocumentPhantom { - fn phantom_text(&self, line: usize) -> PhantomTextLine; + fn phantom_text(&self, editor: &Editor, line: usize) -> PhantomTextLine; /// Translate a column position into the position it would be before combining with /// the phantom text. - fn before_phantom_col(&self, line: usize, col: usize) -> usize { - let phantom = self.phantom_text(line); + fn before_phantom_col(&self, editor: &Editor, line: usize, col: usize) -> usize { + let phantom = self.phantom_text(editor, line); phantom.before_col(col) } - fn has_multiline_phantom(&self) -> bool { + fn has_multiline_phantom(&self, _editor: &Editor) -> bool { true } } @@ -478,16 +478,16 @@ where D: Document, F: Fn(&Editor, &Command, Option, ModifiersState) -> CommandExecuted, { - fn phantom_text(&self, line: usize) -> PhantomTextLine { - self.doc.phantom_text(line) + fn phantom_text(&self, editor: &Editor, line: usize) -> PhantomTextLine { + self.doc.phantom_text(editor, line) } - fn has_multiline_phantom(&self) -> bool { - self.doc.has_multiline_phantom() + fn has_multiline_phantom(&self, editor: &Editor) -> bool { + self.doc.has_multiline_phantom(editor) } - fn before_phantom_col(&self, line: usize, col: usize) -> usize { - self.doc.before_phantom_col(line, col) + fn before_phantom_col(&self, editor: &Editor, line: usize, col: usize) -> usize { + self.doc.before_phantom_col(editor, line, col) } } impl CommonAction for ExtCmdDocument diff --git a/src/views/editor/text_document.rs b/src/views/editor/text_document.rs index 80fe6696..aaca578f 100644 --- a/src/views/editor/text_document.rs +++ b/src/views/editor/text_document.rs @@ -15,16 +15,17 @@ use floem_editor_core::{ selection::Selection, word::WordCursor, }; -use floem_reactive::{RwSignal, Scope}; +use floem_reactive::{create_effect, RwSignal, Scope}; use floem_winit::keyboard::ModifiersState; use lapce_xi_rope::{Rope, RopeDelta}; use smallvec::{smallvec, SmallVec}; use super::{ actions::{handle_command_default, CommonAction}, + color::EditorColor, command::{Command, CommandExecuted}, id::EditorId, - phantom_text::PhantomTextLine, + phantom_text::{PhantomText, PhantomTextKind, PhantomTextLine}, text::{Document, DocumentPhantom, PreeditData, SystemClipboard}, Editor, }; @@ -64,6 +65,8 @@ pub struct TextDocument { /// Whether to automatically indent the new line via heuristics pub auto_indent: Cell, + pub placeholders: RwSignal>, + // (cmd: &Command, count: Option, modifiers: ModifierState) /// Ran before a command is executed. If it says that it executed the command, then handlers /// after it will not be called. @@ -78,13 +81,25 @@ impl TextDocument { let preedit = PreeditData { preedit: cx.create_rw_signal(None), }; + let cache_rev = cx.create_rw_signal(0); + + let placeholders = cx.create_rw_signal(HashMap::new()); + + // Whenever the placeholders change, update the cache rev + create_effect(move |_| { + placeholders.track(); + cache_rev.try_update(|cache_rev| { + *cache_rev += 1; + }); + }); TextDocument { buffer: cx.create_rw_signal(buffer), - cache_rev: cx.create_rw_signal(0), + cache_rev, preedit, keep_indent: Cell::new(true), auto_indent: Cell::new(false), + placeholders, pre_command: Rc::new(RefCell::new(HashMap::new())), on_updates: Rc::new(RefCell::new(SmallVec::new())), } @@ -126,6 +141,17 @@ impl TextDocument { pub fn clear_on_updates(&self) { self.on_updates.borrow_mut().clear(); } + + pub fn add_placeholder(&self, editor_id: EditorId, placeholder: String) { + self.placeholders.update(|placeholders| { + placeholders.insert(editor_id, placeholder); + }); + } + + fn placeholder(&self, editor_id: EditorId) -> Option { + self.placeholders + .with_untracked(|placeholders| placeholders.get(&editor_id).cloned()) + } } impl Document for TextDocument { fn text(&self) -> Rope { @@ -216,12 +242,38 @@ impl Document for TextDocument { } } impl DocumentPhantom for TextDocument { - fn phantom_text(&self, _line: usize) -> PhantomTextLine { - PhantomTextLine::default() + fn phantom_text(&self, editor: &Editor, _line: usize) -> PhantomTextLine { + let mut text = SmallVec::new(); + + if self.buffer.with_untracked(Buffer::is_empty) { + if let Some(placeholder) = self.placeholder(editor.id()) { + text.push(PhantomText { + kind: PhantomTextKind::Placeholder, + col: 0, + text: placeholder, + font_size: None, + fg: Some(editor.color(EditorColor::Dim)), + bg: None, + under_line: None, + }); + } + } + + PhantomTextLine { text } } - fn has_multiline_phantom(&self) -> bool { - false + fn has_multiline_phantom(&self, editor: &Editor) -> bool { + if !self.buffer.with_untracked(Buffer::is_empty) { + return false; + } + + self.placeholders.with_untracked(|placeholder| { + let Some(placeholder) = placeholder.get(&editor.id()) else { + return false; + }; + + placeholder.lines().count() > 1 + }) } } impl CommonAction for TextDocument { diff --git a/src/views/editor/visual_line.rs b/src/views/editor/visual_line.rs index 142a9d61..278d18ae 100644 --- a/src/views/editor/visual_line.rs +++ b/src/views/editor/visual_line.rs @@ -64,7 +64,7 @@ use std::{ }; use floem_editor_core::{ - buffer::rope_text::{RopeText, RopeTextRef}, + buffer::rope_text::{RopeText, RopeTextVal}, cursor::CursorAffinity, word::WordCursor, }; @@ -191,12 +191,12 @@ impl TextLayoutCache { /// views that did not naturally fit into our 'document' model. As well as when we want to extract /// the editor view code int a separate crate for Floem. pub trait TextLayoutProvider { - fn text(&self) -> &Rope; + fn text(&self) -> Rope; /// Shorthand for getting a rope text version of `text`. /// This MUST hold the same rope that `text` would return. - fn rope_text(&self) -> RopeTextRef { - RopeTextRef::new(self.text()) + fn rope_text(&self) -> RopeTextVal { + RopeTextVal::new(self.text()) } // TODO(minor): Do we really need to pass font size to this? The outer-api is providing line @@ -220,7 +220,7 @@ pub trait TextLayoutProvider { fn has_multiline_phantom(&self) -> bool; } impl TextLayoutProvider for &T { - fn text(&self) -> &Rope { + fn text(&self) -> Rope { (**self).text() } @@ -1525,7 +1525,7 @@ impl VLineInfo { // TODO: we could generalize `RopeText::line_end_offset` to any interval, and then just use it here instead of basically reimplementing it. pub fn line_end_offset(&self, text_prov: &impl TextLayoutProvider, caret: bool) -> usize { let text = text_prov.text(); - let rope_text = RopeTextRef::new(text); + let rope_text = text_prov.rope_text(); let mut offset = self.interval.end; let mut line_content: &str = &text.slice_to_cow(self.interval); @@ -1544,7 +1544,7 @@ impl VLineInfo { /// Returns the offset of the first non-blank character in the line. pub fn first_non_blank_character(&self, text_prov: &impl TextLayoutProvider) -> usize { - WordCursor::new(text_prov.text(), self.interval.start).next_non_blank_char() + WordCursor::new(&text_prov.text(), self.interval.start).next_non_blank_char() } } @@ -2038,8 +2038,8 @@ mod tests { } } impl<'a> TextLayoutProvider for TestTextLayoutProvider<'a> { - fn text(&self) -> &Rope { - self.text + fn text(&self) -> Rope { + self.text.clone() } // An implementation relatively close to the actual new text layout impl but simplified. diff --git a/src/views/text_editor.rs b/src/views/text_editor.rs index ab32d55a..7762dac8 100644 --- a/src/views/text_editor.rs +++ b/src/views/text_editor.rs @@ -101,6 +101,12 @@ impl TextEditor { self.editor.doc() } + /// Try downcasting the document to a [`TextDocument`]. + /// Returns `None` if the document is not a [`TextDocument`]. + fn text_doc(&self) -> Option> { + self.doc().downcast_rc().ok() + } + // TODO(minor): should this be named `text`? Ideally most users should use the rope text version pub fn rope_text(&self) -> RopeTextVal { self.editor.rope_text() @@ -204,6 +210,21 @@ impl TextEditor { self } + /// Set the placeholder text that is displayed when the document is empty. + /// Can span multiple lines. + /// This is per-editor, not per-document. + /// Equivalent to calling [`TextDocument::add_placeholder`] + /// Default: `None` + /// + /// Note: only works for the default backing [`TextDocument`] doc + pub fn placeholder(self, text: impl Into) -> Self { + if let Some(doc) = self.text_doc() { + doc.add_placeholder(self.editor_id(), text.into()); + } + + self + } + /// When commands are run on the document, this function is called. /// If it returns [`CommandExecuted::Yes`] then further handlers after it, including the /// default handler, are not executed. @@ -229,18 +250,22 @@ impl TextEditor { /// CommandExecuted::No /// }); /// ``` - /// Note that these are specific to each text editor view. + /// Note that these are specific to each text editor view. + /// + /// Note: only works for the default backing [`TextDocument`] doc pub fn pre_command(self, f: impl Fn(PreCommand) -> CommandExecuted + 'static) -> Self { - let doc: Result, _> = self.editor.doc().downcast_rc(); - if let Ok(doc) = doc { + if let Some(doc) = self.text_doc() { doc.add_pre_command(self.editor.id(), f); } self } + /// Listen for deltas applied to the editor. + /// Useful for anything that has positions based in the editor that can be updated after + /// typing, such as syntax highlighting. + /// Note: only works for the default backing [`TextDocument`] doc pub fn update(self, f: impl Fn(OnUpdate) + 'static) -> Self { - let doc: Result, _> = self.editor.doc().downcast_rc(); - if let Ok(doc) = doc { + if let Some(doc) = self.text_doc() { doc.add_on_update(f); } self