Update input

This commit is contained in:
Jason Lee 2024-06-22 17:42:42 +08:00
parent b503d5f7e3
commit ae8c0ab657
11 changed files with 556 additions and 18 deletions

23
Cargo.lock generated
View file

@ -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",
]

View file

@ -4,5 +4,6 @@ version = "0.1.0"
edition = "2021"
[dependencies]
catppuccin = "2.4.0"
gpui.workspace = true
log.wokrspace = true

View file

@ -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::*;

View file

@ -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
})
}),
)
}

View file

@ -74,7 +74,7 @@ pub struct Stories {
impl Stories {
pub fn new() -> Self {
Self {
active: StoryType::Button,
active: StoryType::Input,
}
}

View file

@ -0,0 +1,5 @@
pub mod blink_manager;
pub mod cursor_layout;
pub mod text_field;
pub use text_field::*;

View file

@ -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<Self>) {}
pub fn disable(&mut self, _cx: &mut ModelContext<Self>) {
self.enabled = false;
}
}

View file

@ -0,0 +1,51 @@
use gpui::{outline, px, AppContext, Bounds, Hsla, Pixels, ShapedLine, Size, ViewContext};
pub struct CursorLayout {
origin: gpui::Point<Pixels>,
block_width: Pixels,
line_height: Pixels,
color: Hsla,
block_text: Option<ShapedLine>,
}
impl CursorLayout {
pub fn new(
origin: gpui::Point<Pixels>,
block_width: Pixels,
line_height: Pixels,
color: Hsla,
block_text: Option<ShapedLine>,
) -> CursorLayout {
CursorLayout {
origin,
block_width,
line_height,
color,
block_text,
}
}
fn bounds(&self, origin: gpui::Point<Pixels>) -> Bounds<Pixels> {
Bounds {
origin: self.origin + origin,
size: Size {
width: px(2.0),
height: self.line_height,
},
}
}
pub fn paint(&mut self, origin: gpui::Point<Pixels>, cx: &mut ViewContext<Self>) {
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()
}
}
}

View file

@ -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<TextView>,
}
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::<Theme>();
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::<Vec<char>>();
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::<String>()
+ &(chars[text_view.selection.end.min(chars.len())..]
.iter()
.collect::<String>());
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<TextEvent> for TextView {}
pub struct TextView {
pub text: String,
pub placeholder: String,
pub word_click: (usize, u16),
pub selection: Range<usize>,
pub disable: bool,
pub blink_manager: Model<BlinkManager>,
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<Self> {
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>) {
self.selection = 0..self.text.len();
cx.notify();
}
pub fn word_ranges(&self) -> Vec<Range<usize>> {
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<usize> {
let start = text
.chars()
.take(self.selection.start)
.collect::<String>()
.len();
let end = text
.chars()
.take(self.selection.end)
.collect::<String>()
.len();
start..end
}
}
impl Render for TextView {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let theme = cx.global::<Theme>();
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();
});
})
}
}

90
crates/ui/src/theme.rs Normal file
View file

@ -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<FlavorColors> 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::<Theme>()
}
}

View file

@ -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<AppState>, 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(