diff --git a/Cargo.lock b/Cargo.lock index 62aeea0e..96c4d9bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -598,6 +598,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" +[[package]] +name = "catppuccin" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa1798ea21d5f88f057b9a4d075bbf3e9cc4bba98da0d6197a2019f61f633bf4" +dependencies = [ + "itertools 0.13.0", + "serde", + "serde_json", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1762,7 +1773,7 @@ dependencies = [ "gpui_macros", "http 0.1.0", "image", - "itertools", + "itertools 0.11.0", "lazy_static", "linkme", "log", @@ -2201,6 +2212,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.11" @@ -4227,6 +4247,7 @@ dependencies = [ name = "ui" version = "0.1.0" dependencies = [ + "catppuccin", "gpui", "log", ] diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 9173de83..ed771bd4 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -4,5 +4,6 @@ version = "0.1.0" edition = "2021" [dependencies] +catppuccin = "2.4.0" gpui.workspace = true log.wokrspace = true diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 47a93071..ae5cb84b 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -2,8 +2,9 @@ pub mod button; mod colors; pub mod cursor; pub mod disableable; -pub mod input; pub mod label; pub mod story; +pub mod text_field; +pub mod theme; pub use colors::*; diff --git a/crates/ui/src/story/input_story.rs b/crates/ui/src/story/input_story.rs index b5c9517d..d4d889a9 100644 --- a/crates/ui/src/story/input_story.rs +++ b/crates/ui/src/story/input_story.rs @@ -3,7 +3,7 @@ use gpui::{ Styled as _, ViewContext, VisualContext, WindowContext, }; -use crate::input::Input; +use crate::text_field::TextField; use super::story_case; @@ -24,19 +24,11 @@ impl Render for InputStory { .justify_start() .gap_3() .child({ - cx.new_view(|cx| { - let input = Input::new("input1", cx); - input.set_placeholder("Enter text here...", cx); - input - }) + TextField::new(cx, "Enter text here...", false) }) .child({ - cx.new_view(|cx| { - let input = Input::new("input1", cx); - input.set_placeholder("Enter text here...", cx); - input.set_text("Hello, world!", cx); + let input = TextField::new(cx, "Enter text here...", false); input - }) }), ) } diff --git a/crates/ui/src/story/story.rs b/crates/ui/src/story/story.rs index 95a19bb0..c72880fd 100644 --- a/crates/ui/src/story/story.rs +++ b/crates/ui/src/story/story.rs @@ -74,7 +74,7 @@ pub struct Stories { impl Stories { pub fn new() -> Self { Self { - active: StoryType::Button, + active: StoryType::Input, } } diff --git a/crates/ui/src/text_field.rs b/crates/ui/src/text_field.rs new file mode 100644 index 00000000..db9d4046 --- /dev/null +++ b/crates/ui/src/text_field.rs @@ -0,0 +1,5 @@ +pub mod blink_manager; +pub mod cursor_layout; +pub mod text_field; + +pub use text_field::*; \ No newline at end of file diff --git a/crates/ui/src/text_field/blink_manager.rs b/crates/ui/src/text_field/blink_manager.rs new file mode 100644 index 00000000..5e6d455e --- /dev/null +++ b/crates/ui/src/text_field/blink_manager.rs @@ -0,0 +1,34 @@ +use std::time::Duration; + +use gpui::ModelContext; + +pub struct BlinkManager { + blink_interval: Duration, + + blink_epoch: usize, + blinking_paused: bool, + visible: bool, + enabled: bool, +} + +impl BlinkManager { + pub fn new(blink_interval: Duration) -> Self { + Self { + blink_interval: Duration::from_millis(500), + blink_epoch: 0, + blinking_paused: false, + visible: true, + enabled: true, + } + } + + pub fn show_cursor(&self, cx: &mut ModelContext<'_, Self>) -> bool { + self.enabled && (!self.blinking_paused || self.visible) + } + + pub fn blink_cursor(&mut self, epoch: usize, cx: &mut ModelContext) {} + + pub fn disable(&mut self, _cx: &mut ModelContext) { + self.enabled = false; + } +} diff --git a/crates/ui/src/text_field/cursor_layout.rs b/crates/ui/src/text_field/cursor_layout.rs new file mode 100644 index 00000000..faed7765 --- /dev/null +++ b/crates/ui/src/text_field/cursor_layout.rs @@ -0,0 +1,51 @@ +use gpui::{outline, px, AppContext, Bounds, Hsla, Pixels, ShapedLine, Size, ViewContext}; + +pub struct CursorLayout { + origin: gpui::Point, + block_width: Pixels, + line_height: Pixels, + color: Hsla, + block_text: Option, +} + +impl CursorLayout { + pub fn new( + origin: gpui::Point, + block_width: Pixels, + line_height: Pixels, + color: Hsla, + block_text: Option, + ) -> CursorLayout { + CursorLayout { + origin, + block_width, + line_height, + color, + block_text, + } + } + + fn bounds(&self, origin: gpui::Point) -> Bounds { + Bounds { + origin: self.origin + origin, + size: Size { + width: px(2.0), + height: self.line_height, + }, + } + } + + pub fn paint(&mut self, origin: gpui::Point, cx: &mut ViewContext) { + let bounds = self.bounds(origin); + + let cursor = outline(bounds, self.color); + + cx.paint_quad(cursor); + + if let Some(block_text) = &self.block_text { + block_text + .paint(self.origin + origin, self.line_height, cx) + .unwrap() + } + } +} diff --git a/crates/ui/src/text_field/text_field.rs b/crates/ui/src/text_field/text_field.rs new file mode 100644 index 00000000..8769c553 --- /dev/null +++ b/crates/ui/src/text_field/text_field.rs @@ -0,0 +1,343 @@ +use std::{ops::Range, time::Duration}; + +use gpui::{ + div, Context, EventEmitter, FocusHandle, HighlightStyle, InteractiveElement, InteractiveText, + IntoElement, KeyDownEvent, Model, ParentElement, Render, RenderOnce, Styled, StyledText, + TextStyle, View, ViewContext, VisualContext, WindowContext, +}; + +use crate::{theme::Theme, disableable::Disableable}; + +use super::{blink_manager::BlinkManager, cursor_layout::CursorLayout}; + +#[derive(IntoElement, Clone)] +pub struct TextField { + focus_handle: FocusHandle, + disable: bool, + pub view: View, +} + +impl TextField { + pub fn new(cx: &mut WindowContext, placeholder: &str, disable: bool) -> Self { + let focus_handle = cx.focus_handle(); + let view = TextView::init(cx, &focus_handle, placeholder, disable); + + Self { + focus_handle, + view, + disable, + } + } + + pub fn focus(&self, cx: &mut WindowContext) { + cx.focus(&self.focus_handle); + } +} + +impl Disableable for TextField { + fn disabled(mut self, disabled: bool) -> Self { + self.disable = disabled; + self + } +} + +impl RenderOnce for TextField { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + // cx.focus(&self.focus_handle); + let theme = cx.global::(); + + let clone = self.view.clone(); + + div() + .border_color( + self.focus_handle + .is_focused(cx) + .then(|| theme.blue) + .unwrap_or(theme.crust), + ) + .border_1() + .track_focus(&self.focus_handle) + .on_key_down(move |event, cx| { + if self.disable { + return; + } + + self.view.update(cx, |text_view, vc| { + let prev = text_view.text.clone(); + vc.emit(TextEvent::KeyDown(event.clone())); + let keystroke = &event.keystroke.key; + let chars = text_view.text.chars().collect::>(); + + let m = event.keystroke.modifiers.platform; + + if m { + match keystroke.as_str() { + _ => {} + } + } else if !event + .keystroke + .ime_key + .clone() + .unwrap_or_default() + .is_empty() + { + let ime_key = &event.keystroke.ime_key.clone().unwrap_or_default(); + text_view.text.replace_range( + text_view.char_range_to_text_range(&text_view.text), + ime_key, + ); + let i = text_view.selection.start + ime_key.chars().count(); + text_view.selection = i..i; + } else { + match keystroke.as_str() { + "left" => { + if text_view.selection.start > 0 { + text_view.selection = + text_view.selection.start - 1..text_view.selection.end; + } + } + "right" => { + if text_view.selection.end < text_view.text.len() { + text_view.selection = + text_view.selection.start + 1..text_view.selection.end + 1; + } else { + text_view.selection = + text_view.selection.start + 1..text_view.selection.end; + } + } + "backspace" => { + if text_view.text.is_empty() { + return; + } + + if text_view.selection.start == text_view.selection.end { + let i = (text_view.selection.start - 1).min(chars.len()); + text_view.text = chars[0..i].iter().collect::() + + &(chars[text_view.selection.end.min(chars.len())..] + .iter() + .collect::()); + text_view.selection = i..i; + } + + text_view.text.replace_range( + text_view.char_range_to_text_range(&text_view.text), + "", + ); + + text_view.selection.end = text_view.selection.start; + } + _ => {} + } + } + + if prev != text_view.text { + vc.emit(TextEvent::Input { + text: text_view.text.clone(), + }); + } + + vc.notify(); + }) + }) + .rounded_lg() + .py_1p5() + .px_3() + .min_w_20() + .bg(self.disable.then(|| theme.crust).unwrap_or(theme.base)) + .child(clone) + } +} + +pub enum TextEvent { + Input { text: String }, + Blur, + 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, + |editor: &mut TextView, cx: &mut ViewContext<'_, TextView>| { + editor.blink_manager.update(cx, BlinkManager::disable); + cx.emit(TextEvent::Blur); + }, + ) + .detach(); + + cx.on_focus(focus_handle, |view, cx| { + view.select_all(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 + } +} + +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::default(); + style.color = theme.text; + style.font_family = theme.font_sans.clone(); + + 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.len() == 0 { + text = self.placeholder.to_string(); + style.color = theme.subtext0; + StyledText::new(text) + } 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/ui/src/theme.rs b/crates/ui/src/theme.rs new file mode 100644 index 00000000..5780e6d1 --- /dev/null +++ b/crates/ui/src/theme.rs @@ -0,0 +1,90 @@ +use std::sync::Arc; + +use catppuccin::{Flavor, FlavorColors}; +use gpui::{AppContext, Global, Hsla, Rgba, SharedString}; + +fn color_to_hsla(color: catppuccin::Color) -> Hsla { + Rgba { + r: color.rgb.r as f32 / 255.0, + g: color.rgb.g as f32 / 255.0, + b: color.rgb.b as f32 / 255.0, + a: 1.0, + } + .into() +} + +#[derive(Debug)] +pub struct Theme { + pub font_sans: SharedString, + pub font_mono: SharedString, + pub crust: Hsla, + pub text: Hsla, + pub base: Hsla, + pub mantle: Hsla, + pub green: Hsla, + pub red: Hsla, + pub blue: Hsla, + pub text_disabled: Hsla, + pub subtext1: Hsla, + pub subtext0: Hsla, + pub overlay2: Hsla, + pub overlay1: Hsla, + pub overlay0: Hsla, + pub surface2: Hsla, + pub surface1: Hsla, + pub surface0: Hsla, + pub lavender: Hsla, +} + +impl Global for Theme {} + +impl From for Theme { + fn from(colors: FlavorColors) -> Self { + Theme { + font_sans: "Inter".into(), + font_mono: "JetBrains Mono".into(), + crust: color_to_hsla(colors.crust), + text: color_to_hsla(colors.text), + base: color_to_hsla(colors.base), + mantle: color_to_hsla(colors.mantle), + green: color_to_hsla(colors.green), + red: color_to_hsla(colors.red), + blue: color_to_hsla(colors.blue), + subtext0: color_to_hsla(colors.subtext0), + subtext1: color_to_hsla(colors.subtext1), + text_disabled: color_to_hsla(colors.subtext0), + overlay0: color_to_hsla(colors.overlay0), + overlay1: color_to_hsla(colors.overlay1), + overlay2: color_to_hsla(colors.overlay2), + surface0: color_to_hsla(colors.surface0), + surface1: color_to_hsla(colors.surface1), + surface2: color_to_hsla(colors.surface2), + lavender: color_to_hsla(colors.lavender), + } + } +} + +impl Theme { + fn new() -> Self { + Self::from(catppuccin::PALETTE.mocha.colors) + } + + pub fn init(cx: &mut AppContext) { + cx.set_global(Theme::new()) + } + + pub fn change(flavour: Flavor, cx: &mut AppContext) { + cx.set_global(Self::from(flavour.colors)); + cx.refresh(); + } +} + +pub trait ActiveTheme { + fn theme(&self) -> &Theme; +} + +impl ActiveTheme for AppContext { + fn theme(&self) -> &Theme { + self.global::() + } +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 6eadf576..8702f3cc 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -2,9 +2,7 @@ use gpui::{prelude::FluentBuilder, *}; use std::sync::Arc; use ui::{ - button::{Button, ButtonStyle}, - disableable::Clickable as _, - Color, + button::{Button, ButtonStyle}, disableable::Clickable as _, theme::Theme, Color }; use util::ResultExt as _; @@ -67,7 +65,9 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { cx.on_action({ let app_state = app_state.clone(); move |action: &Open, cx: &mut AppContext| {} - }) + }); + + Theme::init(cx); } pub fn open_new(