From b70f5d6dc799beef5c48461c9f9fb3d928e61cd5 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 24 Jun 2024 11:21:30 +0800 Subject: [PATCH] Add cursor_layout --- crates/ui/src/label.rs | 2 +- crates/ui/src/story/button_story.rs | 2 +- crates/ui/src/story/input_story.rs | 6 +- crates/ui/src/text_field.rs | 5 - crates/ui/src/text_field/blink_manager.rs | 10 +- crates/ui/src/text_field/cursor_layout.rs | 5 +- .../src/text_field/{text_field.rs => mod.rs} | 260 +++-------------- crates/ui/src/text_field/text_view.rs | 270 ++++++++++++++++++ crates/workspace/src/lib.rs | 18 +- 9 files changed, 339 insertions(+), 239 deletions(-) delete mode 100644 crates/ui/src/text_field.rs rename crates/ui/src/text_field/{text_field.rs => mod.rs} (53%) create mode 100644 crates/ui/src/text_field/text_view.rs diff --git a/crates/ui/src/label.rs b/crates/ui/src/label.rs index ff3c74c3..b3fa79f4 100644 --- a/crates/ui/src/label.rs +++ b/crates/ui/src/label.rs @@ -1,5 +1,5 @@ use gpui::{ - div, prelude::FluentBuilder as _, AbsoluteLength, DefiniteLength, Div, Half, Hsla, IntoElement, + div, prelude::FluentBuilder as _, AbsoluteLength, DefiniteLength, Div, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, WindowContext, }; diff --git a/crates/ui/src/story/button_story.rs b/crates/ui/src/story/button_story.rs index 7ff7e54a..513870ed 100644 --- a/crates/ui/src/story/button_story.rs +++ b/crates/ui/src/story/button_story.rs @@ -1,5 +1,5 @@ use gpui::{ - div, ClickEvent, IntoElement, ParentElement as _, Render, RenderOnce, Styled as _, ViewContext, + div, ClickEvent, IntoElement, ParentElement as _, Render, Styled as _, ViewContext, WindowContext, }; diff --git a/crates/ui/src/story/input_story.rs b/crates/ui/src/story/input_story.rs index f96565fc..9083bf47 100644 --- a/crates/ui/src/story/input_story.rs +++ b/crates/ui/src/story/input_story.rs @@ -1,6 +1,6 @@ use gpui::{ div, ClickEvent, IntoElement, ParentElement as _, Render, Styled as _, ViewContext, - WindowContext, + VisualContext as _, WindowContext, }; use crate::text_field::TextField; @@ -24,8 +24,8 @@ impl Render for InputStory { .flex_col() .justify_start() .gap_3() - .child(TextField::new(cx, "Enter text here...", false)) - .child(TextField::new(cx, "Enter text here...", false)), + .child(cx.new_view(|cx| TextField::new("Enter text here...", false, cx))) + .child(cx.new_view(|cx| TextField::new("Enter text here...", false, cx))), ) } } diff --git a/crates/ui/src/text_field.rs b/crates/ui/src/text_field.rs deleted file mode 100644 index e26c7945..00000000 --- a/crates/ui/src/text_field.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod blink_manager; -mod cursor_layout; -mod text_field; - -pub use text_field::*; diff --git a/crates/ui/src/text_field/blink_manager.rs b/crates/ui/src/text_field/blink_manager.rs index 8bf06b23..e178ad9c 100644 --- a/crates/ui/src/text_field/blink_manager.rs +++ b/crates/ui/src/text_field/blink_manager.rs @@ -11,7 +11,7 @@ pub struct BlinkManager { } impl BlinkManager { - pub fn new(blink_interval: Duration) -> Self { + pub fn new(blink_interval: Duration, _cx: &mut ModelContext) -> Self { Self { blink_interval, blink_epoch: 0, @@ -36,4 +36,12 @@ impl BlinkManager { pub fn disable(&mut self, _cx: &mut ModelContext) { self.enabled = false; } + + pub fn enable(&mut self, _cx: &mut ModelContext) { + self.enabled = true; + } + + pub fn visible(&self) -> bool { + self.visible + } } diff --git a/crates/ui/src/text_field/cursor_layout.rs b/crates/ui/src/text_field/cursor_layout.rs index b78a4766..6e9cb643 100644 --- a/crates/ui/src/text_field/cursor_layout.rs +++ b/crates/ui/src/text_field/cursor_layout.rs @@ -1,5 +1,6 @@ -use gpui::{outline, px, Bounds, Hsla, Pixels, ShapedLine, Size, ViewContext}; +use gpui::{outline, px, Bounds, Hsla, Pixels, ShapedLine, Size, ViewContext, WindowContext}; +#[derive(Debug, Clone)] pub struct CursorLayout { origin: gpui::Point, #[allow(unused)] @@ -36,7 +37,7 @@ impl CursorLayout { } } - pub fn paint(&mut self, origin: gpui::Point, cx: &mut ViewContext) { + pub fn paint(&mut self, origin: gpui::Point, cx: &mut WindowContext) { let bounds = self.bounds(origin); let cursor = outline(bounds, self.color); diff --git a/crates/ui/src/text_field/text_field.rs b/crates/ui/src/text_field/mod.rs similarity index 53% rename from crates/ui/src/text_field/text_field.rs rename to crates/ui/src/text_field/mod.rs index a7c85a0e..33c09378 100644 --- a/crates/ui/src/text_field/text_field.rs +++ b/crates/ui/src/text_field/mod.rs @@ -1,30 +1,41 @@ -use std::{ops::Range, time::Duration}; - -use gpui::{ - div, ClipboardItem, Context, EventEmitter, FocusHandle, HighlightStyle, InteractiveElement, - InteractiveText, IntoElement, KeyDownEvent, Model, ParentElement, Render, RenderOnce, Styled, - StyledText, TextStyle, View, ViewContext, VisualContext, WindowContext, -}; +mod blink_manager; +mod cursor_layout; +mod text_view; use crate::{disableable::Disableable, theme::Theme}; +use blink_manager::BlinkManager; +use gpui::{ + div, px, relative, ClipboardItem, Context, Element, EventEmitter, FocusHandle, HighlightStyle, + InteractiveElement, InteractiveText, IntoElement, KeyDownEvent, Model, ParentElement, Pixels, + Render, Style, Styled, StyledText, TextStyle, View, ViewContext, VisualContext, WindowContext, +}; +use std::{ops::Range, time::Duration}; +use text_view::TextView; -use super::{blink_manager::BlinkManager, cursor_layout::CursorLayout}; +const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); -#[derive(IntoElement, Clone)] +#[derive(Clone)] pub struct TextField { focus_handle: FocusHandle, disable: bool, + blink_manager: Model, pub view: View, } impl TextField { - pub fn new(cx: &mut WindowContext, placeholder: &str, disable: bool) -> Self { + pub fn new(placeholder: &str, disable: bool, cx: &mut ViewContext) -> Self { let focus_handle = cx.focus_handle(); let view = TextView::init(cx, &focus_handle, placeholder, disable); + let blink_manager = cx.new_model(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx)); + + cx.on_focus(&focus_handle, Self::handle_focus).detach(); + cx.on_blur(&focus_handle, Self::handle_blur).detach(); + Self { focus_handle, view, + blink_manager, disable, } } @@ -32,6 +43,22 @@ impl TextField { pub fn focus(&self, cx: &mut WindowContext) { cx.focus(&self.focus_handle); } + + fn handle_focus(&mut self, cx: &mut ViewContext) { + cx.emit(TextEvent::Focus); + self.blink_manager.update(cx, BlinkManager::enable); + cx.notify(); + } + + fn handle_blur(&mut self, cx: &mut ViewContext) { + cx.emit(TextEvent::Blur); + self.blink_manager.update(cx, BlinkManager::disable); + cx.notify(); + } + + pub fn show_cursor(&self, cx: &mut WindowContext) -> bool { + self.blink_manager.read(cx).visible() && self.focus_handle.is_focused(cx) + } } impl Disableable for TextField { @@ -41,12 +68,13 @@ impl Disableable for TextField { } } -impl RenderOnce for TextField { - fn render(self, cx: &mut WindowContext) -> impl IntoElement { +impl Render for TextField { + fn render<'a>(&mut self, cx: &mut ViewContext<'a, Self>) -> impl IntoElement { cx.focus(&self.focus_handle); let theme = cx.global::(); - let clone = self.view.clone(); + let text_view = self.view.clone(); + let text_view1 = text_view.clone(); div() .border_color(if self.focus_handle.is_focused(cx) { @@ -57,7 +85,7 @@ impl RenderOnce for TextField { .border_1() .track_focus(&self.focus_handle) .on_key_down(move |ev, cx| { - self.view.update(cx, |editor, cx| { + text_view1.update(cx, |editor, cx| { let prev = editor.text.clone(); cx.emit(TextEvent::KeyDown(ev.clone())); let keystroke = &ev.keystroke.key; @@ -181,213 +209,15 @@ impl RenderOnce for TextField { } else { theme.base }) - .child(clone) + .child(text_view) } } pub enum TextEvent { Input { text: String }, Blur, + Focus, KeyDown(KeyDownEvent), } -impl EventEmitter for TextView {} - -pub struct TextView { - pub text: String, - pub placeholder: String, - pub word_click: (usize, u16), - pub selection: Range, - pub disable: bool, - pub blink_manager: Model, - pub cursor: CursorLayout, -} - -const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); - -impl TextView { - pub fn init( - cx: &mut WindowContext, - focus_handle: &FocusHandle, - placeholder: &str, - disable: bool, - ) -> View { - let blink_manager = cx.new_model(|_cx| BlinkManager::new(CURSOR_BLINK_INTERVAL)); - - let cursor = CursorLayout::new( - gpui::Point::new(gpui::px(0.0), gpui::px(0.0)), - gpui::px(2.0), - gpui::px(20.0), - gpui::Hsla { - h: 0.0, - s: 0.0, - l: 0.0, - a: 1.0, - }, - None, - ); - - let m = Self { - text: String::new(), - placeholder: placeholder.to_string(), - word_click: (0, 0), - selection: 0..0, - blink_manager, - cursor, - disable, - }; - - let view = cx.new_view(|cx| { - cx.on_blur( - focus_handle, - |view: &mut TextView, cx: &mut ViewContext<'_, TextView>| { - view.blink_manager.update(cx, BlinkManager::disable); - cx.emit(TextEvent::Blur); - }, - ) - .detach(); - - cx.on_focus(focus_handle, |view, cx| { - view.blink_manager.update(cx, |bm, cx| { - bm.blink_cursor(0, cx); - }); - }) - .detach(); - m - }); - - cx.subscribe( - &view, - move |subscriber, emitter: &TextEvent, cx| match emitter { - TextEvent::Input { text: _ } => { - subscriber.update(cx, |editor, cx| { - editor.word_click = (0, 0); - }); - } - TextEvent::Blur => { - subscriber.update(cx, |editor, cx| { - editor.blink_manager.update(cx, BlinkManager::disable); - editor.word_click = (0, 0); - }); - } - _ => {} - }, - ) - .detach(); - - view - } - - pub fn select_all(&mut self, cx: &mut ViewContext) { - self.selection = 0..self.text.len(); - cx.notify(); - } - - pub fn word_ranges(&self) -> Vec> { - let mut words = Vec::new(); - let mut last_was_boundary = true; - let mut word_start = 0; - let s = self.text.clone(); - - for (i, c) in s.char_indices() { - if c.is_alphanumeric() || c == '_' { - if last_was_boundary { - word_start = i; - } - last_was_boundary = false; - } else { - if !last_was_boundary { - words.push(word_start..i); - } - last_was_boundary = true; - } - } - - // Check if the last characters form a word and push it if so - if !last_was_boundary { - words.push(word_start..s.len()); - } - - words - } - - pub fn char_range_to_text_range(&self, text: &str) -> Range { - let start = text - .chars() - .take(self.selection.start) - .collect::() - .len(); - let end = text - .chars() - .take(self.selection.end) - .collect::() - .len(); - start..end - } - - pub fn set_text(&mut self, text: impl ToString, cx: &mut ViewContext) { - self.text = text.to_string(); - self.selection = self.text.len()..self.text.len(); - cx.notify(); - cx.emit(TextEvent::Input { - text: self.text.clone(), - }); - } -} - -impl Render for TextView { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let theme = cx.global::(); - let mut text = self.text.clone(); - - let mut style = TextStyle { - color: theme.text, - font_family: theme.font_sans.clone(), - ..Default::default() - }; - - let mut selection_style = HighlightStyle::default(); - let mut color = theme.lavender; - color.fade_out(0.8); - selection_style.background_color = Some(color); - - let highlights = vec![(self.char_range_to_text_range(&text), selection_style)]; - - let styled_text: StyledText = if text.is_empty() { - text = self.placeholder.to_string(); - style.color = theme.subtext0; - StyledText::new(text).with_highlights(&style, highlights) - } else { - StyledText::new(text).with_highlights(&style, highlights) - }; - - let view = cx.view().clone(); - - InteractiveText::new("text", styled_text).on_click(self.word_ranges(), move |ev, cx| { - view.update(cx, |text_view, cx| { - let (index, mut count) = text_view.word_click; - if index == ev { - count += 1; - } else { - count = 1; - } - match count { - 2 => { - let word_ranges = text_view.word_ranges(); - text_view.selection = word_ranges.get(ev).unwrap().clone(); - } - 3 => { - // Should select the line - } - 4 => { - count = 0; - text_view.selection = 0..text_view.text.len(); - } - _ => {} - } - text_view.word_click = (ev, count); - cx.notify(); - }); - }) - } -} +impl EventEmitter for TextField {} diff --git a/crates/ui/src/text_field/text_view.rs b/crates/ui/src/text_field/text_view.rs new file mode 100644 index 00000000..b7c4fc07 --- /dev/null +++ b/crates/ui/src/text_field/text_view.rs @@ -0,0 +1,270 @@ +use std::{ops::Range, time::Duration}; + +use gpui::{ + div, px, relative, ClipboardItem, Context, Element, EventEmitter, FocusHandle, HighlightStyle, + InteractiveElement, InteractiveText, IntoElement, KeyDownEvent, Model, ParentElement, Pixels, + Render, Style, Styled, StyledText, TextStyle, View, ViewContext, VisualContext, WindowContext, +}; + +use crate::{disableable::Disableable, hls, theme::Theme}; + +use super::{ + blink_manager::BlinkManager, cursor_layout::CursorLayout, TextEvent, CURSOR_BLINK_INTERVAL, +}; + +pub struct TextView { + pub text: String, + pub placeholder: String, + pub word_click: (usize, u16), + pub selection: Range, + pub disable: bool, + pub blink_manager: Model, + pub cursor: CursorLayout, +} + +impl EventEmitter for TextView {} + +impl TextView { + pub fn init( + cx: &mut WindowContext, + focus_handle: &FocusHandle, + placeholder: &str, + disable: bool, + ) -> View { + let blink_manager = cx.new_model(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx)); + + let cursor = CursorLayout::new( + gpui::Point::new(gpui::px(0.0), gpui::px(0.0)), + px(2.0), + px(20.0), + hls(212., 92., 45.), + None, + ); + + let m = Self { + text: String::new(), + placeholder: placeholder.to_string(), + word_click: (0, 0), + selection: 0..0, + blink_manager, + cursor, + disable, + }; + + let view = cx.new_view(|cx| { + cx.on_blur( + focus_handle, + |view: &mut TextView, cx: &mut ViewContext<'_, TextView>| { + view.blink_manager.update(cx, BlinkManager::disable); + cx.emit(TextEvent::Blur); + }, + ) + .detach(); + + cx.on_focus(focus_handle, |view, cx| { + view.blink_manager.update(cx, |bm, cx| { + bm.blink_cursor(0, cx); + }); + }) + .detach(); + m + }); + + cx.subscribe( + &view, + move |subscriber, emitter: &TextEvent, cx| match emitter { + TextEvent::Input { text: _ } => { + subscriber.update(cx, |editor, cx| { + editor.word_click = (0, 0); + }); + } + TextEvent::Blur => { + subscriber.update(cx, |editor, cx| { + editor.blink_manager.update(cx, BlinkManager::disable); + editor.word_click = (0, 0); + }); + } + _ => {} + }, + ) + .detach(); + + view + } + + pub fn select_all(&mut self, cx: &mut ViewContext) { + self.selection = 0..self.text.len(); + cx.notify(); + } + + pub fn word_ranges(&self) -> Vec> { + let mut words = Vec::new(); + let mut last_was_boundary = true; + let mut word_start = 0; + let s = self.text.clone(); + + for (i, c) in s.char_indices() { + if c.is_alphanumeric() || c == '_' { + if last_was_boundary { + word_start = i; + } + last_was_boundary = false; + } else { + if !last_was_boundary { + words.push(word_start..i); + } + last_was_boundary = true; + } + } + + // Check if the last characters form a word and push it if so + if !last_was_boundary { + words.push(word_start..s.len()); + } + + words + } + + pub fn char_range_to_text_range(&self, text: &str) -> Range { + let start = text + .chars() + .take(self.selection.start) + .collect::() + .len(); + let end = text + .chars() + .take(self.selection.end) + .collect::() + .len(); + start..end + } + + pub fn set_text(&mut self, text: impl ToString, cx: &mut ViewContext) { + self.text = text.to_string(); + self.selection = self.text.len()..self.text.len(); + cx.notify(); + cx.emit(TextEvent::Input { + text: self.text.clone(), + }); + } + + fn paint_cursors(&self, layout: &TextLayout, cx: &mut WindowContext) { + let mut cursor = self.cursor.clone(); + dbg!("--------- paint_cursors", &cursor); + cursor.paint(layout.content_origin, cx); + } +} + +impl IntoElement for TextView { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +pub struct TextLayout { + content_origin: gpui::Point, +} + +impl Element for TextView { + type RequestLayoutState = (); + + type PrepaintState = TextLayout; + + fn id(&self) -> Option { + None + } + + fn request_layout( + &mut self, + id: Option<&gpui::GlobalElementId>, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = relative(24.).into(); + + (cx.request_layout(style, None), ()) + } + + fn prepaint( + &mut self, + id: Option<&gpui::GlobalElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState { + TextLayout { + content_origin: bounds.origin, + } + } + + fn paint( + &mut self, + id: Option<&gpui::GlobalElementId>, + bounds: gpui::Bounds, + request_layout: &mut Self::RequestLayoutState, + layout: &mut Self::PrepaintState, + cx: &mut WindowContext, + ) { + self.paint_cursors(layout, cx); + } +} + +impl Render for TextView { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let theme = cx.global::(); + let mut text = self.text.clone(); + + let mut style = TextStyle { + color: theme.text, + font_family: theme.font_sans.clone(), + ..Default::default() + }; + + let mut selection_style = HighlightStyle::default(); + let mut color = theme.lavender; + color.fade_out(0.8); + selection_style.background_color = Some(color); + + let highlights = vec![(self.char_range_to_text_range(&text), selection_style)]; + + let styled_text: StyledText = if text.is_empty() { + text = self.placeholder.to_string(); + style.color = theme.subtext0; + StyledText::new(text).with_highlights(&style, highlights) + } else { + StyledText::new(text).with_highlights(&style, highlights) + }; + + let view = cx.view().clone(); + + InteractiveText::new("text", styled_text).on_click(self.word_ranges(), move |ev, cx| { + view.update(cx, |text_view, cx| { + let (index, mut count) = text_view.word_click; + if index == ev { + count += 1; + } else { + count = 1; + } + match count { + 2 => { + let word_ranges = text_view.word_ranges(); + text_view.selection = word_ranges.get(ev).unwrap().clone(); + } + 3 => { + // Should select the line + } + 4 => { + count = 0; + text_view.selection = 0..text_view.text.len(); + } + _ => {} + } + text_view.word_click = (ev, count); + cx.notify(); + }); + }) + } +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index e419f0e1..3e7aa140 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,13 +1,7 @@ use gpui::{prelude::FluentBuilder, *}; use std::sync::Arc; -use ui::{ - button::{Button, ButtonStyle}, - disableable::Clickable as _, - text_field::TextField, - theme::Theme, - Color, -}; +use ui::{button::Button, disableable::Clickable as _, text_field::TextField, theme::Theme, Color}; use util::ResultExt as _; mod app_state; @@ -127,10 +121,12 @@ impl Render for Workspace { .child(ui::story::Stories::view(cx)), ) .child({ - let txt = TextField::new(cx, "Enter text here...", false); - txt.view - .update(cx, |this, cx| this.set_text("This is default text.", cx)); - txt + cx.new_view(|cx| { + let txt = TextField::new("Enter text here...", false, cx); + txt.view + .update(cx, |this, cx| this.set_text("This is default text.", cx)); + txt + }) }) } }