input: Fix Input prefix, suffix position, and mask * in vertical center. (#965)

- Fix Input, NumberInput padding x with prefix, suffix.
- Fix Input mask char to render in vertical center.

<img width="498" alt="image"
src="https://github.com/user-attachments/assets/f7b34e17-2503-4a24-9e16-3a30b48bacb5"
/>

- Fix to only use `Tab`, `Shift-Tab` to indent in multi-line mode.
- Improve blink cursor to render with 1.5px width and same as
line-height.
This commit is contained in:
Jason Lee 2025-06-17 13:59:02 +08:00 committed by GitHub
parent 56e282315d
commit de491a790c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 109 additions and 145 deletions

View file

@ -6,8 +6,7 @@ use gpui::{
use crate::section; use crate::section;
use gpui_component::{ use gpui_component::{
button::{Button, ButtonVariant, ButtonVariants as _}, button::{Button, ButtonVariants as _},
h_flex,
input::{InputEvent, InputState, MaskPattern, TextInput}, input::{InputEvent, InputState, MaskPattern, TextInput},
v_flex, ContextModal, FocusableCycle, Icon, IconName, Sizable, v_flex, ContextModal, FocusableCycle, Icon, IconName, Sizable,
}; };
@ -157,14 +156,17 @@ impl FocusableCycle for InputStory {
[ [
self.input1.focus_handle(cx), self.input1.focus_handle(cx),
self.input2.focus_handle(cx), self.input2.focus_handle(cx),
self.input_esc.focus_handle(cx),
self.disabled_input.focus_handle(cx), self.disabled_input.focus_handle(cx),
self.mask_input.focus_handle(cx), self.mask_input.focus_handle(cx),
self.prefix_input1.focus_handle(cx), self.prefix_input1.focus_handle(cx),
self.both_input1.focus_handle(cx), self.both_input1.focus_handle(cx),
self.suffix_input1.focus_handle(cx), self.suffix_input1.focus_handle(cx),
self.currency_input.focus_handle(cx),
self.phone_input.focus_handle(cx),
self.mask_input2.focus_handle(cx),
self.large_input.focus_handle(cx), self.large_input.focus_handle(cx),
self.small_input.focus_handle(cx), self.small_input.focus_handle(cx),
self.input_esc.focus_handle(cx),
] ]
.to_vec() .to_vec()
} }
@ -189,7 +191,7 @@ impl Render for InputStory {
section("Normal Input") section("Normal Input")
.max_w_md() .max_w_md()
.child(TextInput::new(&self.input1).cleanable()) .child(TextInput::new(&self.input1).cleanable())
.child(self.input2.clone()), .child(TextInput::new(&self.input2)),
) )
.child( .child(
section("Input State") section("Input State")
@ -203,28 +205,18 @@ impl Render for InputStory {
.child( .child(
TextInput::new(&self.prefix_input1) TextInput::new(&self.prefix_input1)
.cleanable() .cleanable()
.prefix(Icon::new(IconName::Search).small().ml_3()), .prefix(Icon::new(IconName::Search).small()),
) )
.child( .child(
TextInput::new(&self.both_input1) TextInput::new(&self.both_input1)
.cleanable() .cleanable()
.prefix(div().child(Icon::new(IconName::Search).small()).ml_3()) .prefix(div().child(Icon::new(IconName::Search).small()))
.suffix( .suffix(Button::new("info").ghost().icon(IconName::Info).xsmall()),
Button::new("info")
.ghost()
.icon(IconName::Info)
.xsmall()
.mr_3(),
),
) )
.child( .child(
TextInput::new(&self.suffix_input1).cleanable().suffix( TextInput::new(&self.suffix_input1)
Button::new("info") .cleanable()
.ghost() .suffix(Button::new("info").ghost().icon(IconName::Info).xsmall()),
.icon(IconName::Info)
.xsmall()
.mr_3(),
),
), ),
) )
.child( .child(
@ -264,8 +256,8 @@ impl Render for InputStory {
.child( .child(
section("Input Size") section("Input Size")
.max_w_md() .max_w_md()
.child(TextInput::new(&self.large_input).large()) .child(TextInput::new(&self.large_input).large().cleanable())
.child(TextInput::new(&self.small_input).small()), .child(TextInput::new(&self.small_input).small().cleanable()),
) )
.child( .child(
section("Cleanable and ESC to clean") section("Cleanable and ESC to clean")
@ -282,26 +274,5 @@ impl Render for InputStory {
window.focused_input(cx).map(|input| input.read(cx).value()) window.focused_input(cx).map(|input| input.read(cx).value())
))), ))),
) )
.child(
h_flex()
.items_center()
.w_full()
.gap_3()
.child(
Button::new("btn-submit")
.flex_1()
.with_variant(ButtonVariant::Primary)
.label("Submit")
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(Box::new(Tab), cx)
})),
)
.child(
Button::new("btn-cancel")
.flex_1()
.label("Cancel")
.into_element(),
),
)
} }
} }

View file

@ -217,13 +217,9 @@ impl Render for NumberInputStory {
) )
.child( .child(
section("Small Size with suffix").max_w_md().child( section("Small Size with suffix").max_w_md().child(
NumberInput::new(&self.number_input2).small().suffix( NumberInput::new(&self.number_input2)
Button::new("info") .small()
.ghost() .suffix(Button::new("info").ghost().icon(IconName::Info).xsmall()),
.icon(IconName::Info)
.xsmall()
.mr_3(),
),
), ),
) )
.child( .child(

View file

@ -1,9 +1,10 @@
use std::time::Duration; use std::time::Duration;
use gpui::{Context, Timer}; use gpui::{px, Context, Pixels, Timer};
static INTERVAL: Duration = Duration::from_millis(500); static INTERVAL: Duration = Duration::from_millis(500);
static PAUSE_DELAY: Duration = Duration::from_millis(300); static PAUSE_DELAY: Duration = Duration::from_millis(300);
pub(super) const CURSOR_WIDTH: Pixels = px(1.5);
/// To manage the Input cursor blinking. /// To manage the Input cursor blinking.
/// ///

View file

@ -10,6 +10,7 @@ use smallvec::SmallVec;
use crate::{ use crate::{
highlighter::{LanguageRegistry, SyntaxHighlighter}, highlighter::{LanguageRegistry, SyntaxHighlighter},
input::blink_cursor::CURSOR_WIDTH,
ActiveTheme as _, Root, ActiveTheme as _, Root,
}; };
@ -174,14 +175,13 @@ impl TextElement {
if input.show_cursor(window, cx) { if input.show_cursor(window, cx) {
// cursor blink // cursor blink
let cursor_height = let cursor_height = line_height;
window.text_style().font_size.to_pixels(window.rem_size()) + px(2.);
cursor_bounds = Some(Bounds::new( cursor_bounds = Some(Bounds::new(
point( point(
bounds.left() + cursor_pos.x + line_number_width, bounds.left() + cursor_pos.x + line_number_width,
bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.), bounds.top() + cursor_pos.y + ((line_height - cursor_height) / 2.),
), ),
size(px(1.), cursor_height), size(CURSOR_WIDTH, cursor_height),
)); ));
}; };
} }
@ -864,13 +864,13 @@ impl Element for TextElement {
invisible_top_padding += line.size(line_height).height; invisible_top_padding += line.size(line_height).height;
} }
let mut offset_y = px(0.); let mut mask_offset_y = px(0.);
if self.input.read(cx).masked { if self.input.read(cx).masked {
// Move down offset for vertical centering the ***** // Move down offset for vertical centering the *****
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
offset_y = px(3.); mask_offset_y = px(3.);
} else { } else {
offset_y = px(2.5); mask_offset_y = px(2.5);
} }
} }
@ -879,6 +879,7 @@ impl Element for TextElement {
.style .style
.active_line; .active_line;
let mut offset_y = px(0.);
if let Some(line_numbers) = prepaint.line_numbers.as_ref() { if let Some(line_numbers) = prepaint.line_numbers.as_ref() {
offset_y += invisible_top_padding; offset_y += invisible_top_padding;
@ -909,7 +910,7 @@ impl Element for TextElement {
} }
// Paint text // Paint text
let mut offset_y = invisible_top_padding; let mut offset_y = mask_offset_y + invisible_top_padding;
for line in prepaint for line in prepaint
.last_layout .last_layout
.iter() .iter()

View file

@ -122,11 +122,6 @@ impl RenderOnce for NumberInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focused = self.state.focus_handle(cx).is_focused(window); let focused = self.state.focus_handle(cx).is_focused(window);
let btn_size = match self.size {
Size::XSmall | Size::Small => Size::Size(px(16.)),
_ => Size::XSmall,
};
h_flex() h_flex()
.id(self.id) .id(self.id)
.key_context(KEY_CONTENT) .key_context(KEY_CONTENT)
@ -134,11 +129,7 @@ impl RenderOnce for NumberInput {
.on_action(window.listener_for(&self.state, InputState::on_action_decrement)) .on_action(window.listener_for(&self.state, InputState::on_action_decrement))
.flex_1() .flex_1()
.input_size(self.size) .input_size(self.size)
.px(match self.size { .px(self.size.input_px() / 2.)
Size::XSmall => px(1.),
Size::Small => px(2.),
_ => px(3.),
})
.bg(cx.theme().background) .bg(cx.theme().background)
.border_color(cx.theme().input) .border_color(cx.theme().input)
.border_1() .border_1()
@ -147,8 +138,9 @@ impl RenderOnce for NumberInput {
.child( .child(
Button::new("minus") Button::new("minus")
.ghost() .ghost()
.with_size(btn_size) .with_size(self.size.smaller())
.icon(IconName::Minus) .icon(IconName::Minus)
.compact()
.on_click({ .on_click({
let state = self.state.clone(); let state = self.state.clone();
move |_, window, cx| { move |_, window, cx| {
@ -159,15 +151,17 @@ impl RenderOnce for NumberInput {
.child( .child(
TextInput::new(&self.state) TextInput::new(&self.state)
.appearance(false) .appearance(false)
.no_gap() .px(px(2.))
.gap_0()
.when_some(self.prefix, |this, prefix| this.prefix(prefix)) .when_some(self.prefix, |this, prefix| this.prefix(prefix))
.when_some(self.suffix, |this, suffix| this.suffix(suffix)), .when_some(self.suffix, |this, suffix| this.suffix(suffix)),
) )
.child( .child(
Button::new("plus") Button::new("plus")
.ghost() .ghost()
.with_size(btn_size) .with_size(self.size.smaller())
.icon(IconName::Plus) .icon(IconName::Plus)
.compact()
.on_click({ .on_click({
let state = self.state.clone(); let state = self.state.clone();
move |_, window, cx| { move |_, window, cx| {

View file

@ -1,7 +1,8 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _, div, px, relative, AnyElement, App, DefiniteLength, Entity, InteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, Styled as _, Window, IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled,
Window,
}; };
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
@ -18,8 +19,8 @@ use super::InputState;
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct TextInput { pub struct TextInput {
state: Entity<InputState>, state: Entity<InputState>,
style: StyleRefinement,
size: Size, size: Size,
no_gap: bool,
prefix: Option<AnyElement>, prefix: Option<AnyElement>,
suffix: Option<AnyElement>, suffix: Option<AnyElement>,
height: Option<DefiniteLength>, height: Option<DefiniteLength>,
@ -44,7 +45,7 @@ impl TextInput {
Self { Self {
state: state.clone(), state: state.clone(),
size: Size::default(), size: Size::default(),
no_gap: false, style: StyleRefinement::default(),
prefix: None, prefix: None,
suffix: None, suffix: None,
height: None, height: None,
@ -115,14 +116,6 @@ impl TextInput {
self self
} }
/// Set true to not use gap between input and prefix, suffix, and clear button.
///
/// Default: false
pub(super) fn no_gap(mut self) -> Self {
self.no_gap = true;
self
}
fn render_toggle_mask_button(state: Entity<InputState>) -> impl IntoElement { fn render_toggle_mask_button(state: Entity<InputState>) -> impl IntoElement {
Button::new("toggle-mask") Button::new("toggle-mask")
.icon(IconName::Eye) .icon(IconName::Eye)
@ -147,6 +140,12 @@ impl TextInput {
} }
} }
impl Styled for TextInput {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for TextInput { impl RenderOnce for TextInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25); const LINE_HEIGHT: Rems = Rems(1.25);
@ -161,25 +160,24 @@ impl RenderOnce for TextInput {
let state = self.state.read(cx); let state = self.state.read(cx);
let focused = state.focus_handle.is_focused(window); let focused = state.focus_handle.is_focused(window);
let mut gap_x = match self.size { let gap_x = match self.size {
Size::Small => px(4.), Size::Small => px(4.),
Size::Large => px(8.), Size::Large => px(8.),
_ => px(4.), _ => px(4.),
}; };
if self.no_gap {
gap_x = px(0.);
}
let prefix = self.prefix;
let suffix = self.suffix;
let show_clear_button =
self.cleanable && !state.loading && !state.text.is_empty() && state.is_single_line();
let bg = if state.disabled { let bg = if state.disabled {
cx.theme().muted cx.theme().muted
} else { } else {
cx.theme().background cx.theme().background
}; };
let prefix = self.prefix;
let suffix = self.suffix;
let show_clear_button =
self.cleanable && !state.loading && !state.text.is_empty() && state.is_single_line();
let has_suffix = suffix.is_some() || state.loading || self.mask_toggle || show_clear_button;
div() div()
.id(("input", self.state.entity_id())) .id(("input", self.state.entity_id()))
.flex() .flex()
@ -196,14 +194,16 @@ impl RenderOnce for TextInput {
.on_action(window.listener_for(&self.state, InputState::delete_next_word)) .on_action(window.listener_for(&self.state, InputState::delete_next_word))
.on_action(window.listener_for(&self.state, InputState::enter)) .on_action(window.listener_for(&self.state, InputState::enter))
.on_action(window.listener_for(&self.state, InputState::escape)) .on_action(window.listener_for(&self.state, InputState::escape))
.on_action(window.listener_for(&self.state, InputState::indent_inline))
.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::paste)) .on_action(window.listener_for(&self.state, InputState::paste))
.on_action(window.listener_for(&self.state, InputState::cut)) .on_action(window.listener_for(&self.state, InputState::cut))
.on_action(window.listener_for(&self.state, InputState::undo)) .on_action(window.listener_for(&self.state, InputState::undo))
.on_action(window.listener_for(&self.state, InputState::redo)) .on_action(window.listener_for(&self.state, InputState::redo))
.when(state.is_multi_line(), |this| {
this.on_action(window.listener_for(&self.state, InputState::indent_inline))
.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::left)) .on_action(window.listener_for(&self.state, InputState::left))
.on_action(window.listener_for(&self.state, InputState::right)) .on_action(window.listener_for(&self.state, InputState::right))
@ -263,39 +263,38 @@ impl RenderOnce for TextInput {
}) })
}) })
}) })
.when(prefix.is_none(), |this| this.input_pl(self.size)) .input_px(self.size)
.input_pr(self.size)
.items_center() .items_center()
.gap(gap_x) .gap(gap_x)
.children(prefix) .children(prefix)
.child(self.state.clone()) .child(self.state.clone())
.child( .when(has_suffix, |this| {
h_flex() this.pr(self.size.input_px() / 2.).child(
.id("suffix") h_flex()
.absolute() .id("suffix")
.gap(gap_x) .gap(gap_x)
.when(self.appearance, |this| this.bg(bg)) .when(self.appearance, |this| this.bg(bg))
.items_center() .items_center()
.when(suffix.is_none(), |this| this.pr_1()) .when(state.loading, |this| {
.right_0() this.child(Indicator::new().color(cx.theme().muted_foreground))
.when(state.loading, |this| { })
this.child(Indicator::new().color(cx.theme().muted_foreground)) .when(self.mask_toggle, |this| {
}) this.child(Self::render_toggle_mask_button(self.state.clone()))
.when(self.mask_toggle, |this| { })
this.child(Self::render_toggle_mask_button(self.state.clone())) .when(show_clear_button, |this| {
}) this.child(clear_button(cx).on_click({
.when(show_clear_button, |this| { let state = self.state.clone();
this.child(clear_button(cx).on_click({ move |_, window, cx| {
let state = self.state.clone(); state.update(cx, |state, cx| {
move |_, window, cx| { state.clean(window, cx);
state.update(cx, |state, cx| { })
state.clean(window, cx); }
}) }))
} })
})) .children(suffix),
}) )
.children(suffix), })
) .refine_style(&self.style)
.when(state.is_multi_line(), |this| { .when(state.is_multi_line(), |this| {
let entity_id = self.state.entity_id(); let entity_id = self.state.entity_id();
if state.last_layout.is_some() { if state.last_layout.is_some() {

View file

@ -285,6 +285,26 @@ impl Size {
_ => other, _ => other,
} }
} }
pub fn input_px(&self) -> Pixels {
match self {
Self::Large => px(20.),
Self::Medium => px(12.),
Self::Small => px(8.),
Self::XSmall => px(4.),
_ => px(8.),
}
}
pub fn input_py(&self) -> Pixels {
match self {
Size::Large => px(16.),
Size::Medium => px(8.),
Size::Small => px(4.),
Size::XSmall => px(0.),
_ => px(4.),
}
}
} }
impl From<Pixels> for Size { impl From<Pixels> for Size {
@ -374,40 +394,22 @@ impl<T: Styled> StyleSized<T> for T {
#[inline] #[inline]
fn input_pl(self, size: Size) -> Self { fn input_pl(self, size: Size) -> Self {
match size { self.pl(size.input_px())
Size::Large => self.pl_5(),
Size::Medium => self.pl_3(),
_ => self.pl_2(),
}
} }
#[inline] #[inline]
fn input_pr(self, size: Size) -> Self { fn input_pr(self, size: Size) -> Self {
match size { self.pr(size.input_px())
Size::Large => self.pr_5(),
Size::Medium => self.pr_3(),
_ => self.pr_2(),
}
} }
#[inline] #[inline]
fn input_px(self, size: Size) -> Self { fn input_px(self, size: Size) -> Self {
match size { self.px(size.input_px())
Size::Large => self.px_5(),
Size::Medium => self.px_3(),
_ => self.px_2(),
}
} }
#[inline] #[inline]
fn input_py(self, size: Size) -> Self { fn input_py(self, size: Size) -> Self {
match size { self.py(size.input_py())
Size::Large => self.py_5(),
Size::Medium => self.py_2(),
Size::Small => self.py_1(),
Size::XSmall => self.py_0(),
_ => self.py_1(),
}
} }
#[inline] #[inline]