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 gpui_component::{
button::{Button, ButtonVariant, ButtonVariants as _},
h_flex,
button::{Button, ButtonVariants as _},
input::{InputEvent, InputState, MaskPattern, TextInput},
v_flex, ContextModal, FocusableCycle, Icon, IconName, Sizable,
};
@ -157,14 +156,17 @@ impl FocusableCycle for InputStory {
[
self.input1.focus_handle(cx),
self.input2.focus_handle(cx),
self.input_esc.focus_handle(cx),
self.disabled_input.focus_handle(cx),
self.mask_input.focus_handle(cx),
self.prefix_input1.focus_handle(cx),
self.both_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.small_input.focus_handle(cx),
self.input_esc.focus_handle(cx),
]
.to_vec()
}
@ -189,7 +191,7 @@ impl Render for InputStory {
section("Normal Input")
.max_w_md()
.child(TextInput::new(&self.input1).cleanable())
.child(self.input2.clone()),
.child(TextInput::new(&self.input2)),
)
.child(
section("Input State")
@ -203,28 +205,18 @@ impl Render for InputStory {
.child(
TextInput::new(&self.prefix_input1)
.cleanable()
.prefix(Icon::new(IconName::Search).small().ml_3()),
.prefix(Icon::new(IconName::Search).small()),
)
.child(
TextInput::new(&self.both_input1)
.cleanable()
.prefix(div().child(Icon::new(IconName::Search).small()).ml_3())
.suffix(
Button::new("info")
.ghost()
.icon(IconName::Info)
.xsmall()
.mr_3(),
),
.prefix(div().child(Icon::new(IconName::Search).small()))
.suffix(Button::new("info").ghost().icon(IconName::Info).xsmall()),
)
.child(
TextInput::new(&self.suffix_input1).cleanable().suffix(
Button::new("info")
.ghost()
.icon(IconName::Info)
.xsmall()
.mr_3(),
),
TextInput::new(&self.suffix_input1)
.cleanable()
.suffix(Button::new("info").ghost().icon(IconName::Info).xsmall()),
),
)
.child(
@ -264,8 +256,8 @@ impl Render for InputStory {
.child(
section("Input Size")
.max_w_md()
.child(TextInput::new(&self.large_input).large())
.child(TextInput::new(&self.small_input).small()),
.child(TextInput::new(&self.large_input).large().cleanable())
.child(TextInput::new(&self.small_input).small().cleanable()),
)
.child(
section("Cleanable and ESC to clean")
@ -282,26 +274,5 @@ impl Render for InputStory {
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(
section("Small Size with suffix").max_w_md().child(
NumberInput::new(&self.number_input2).small().suffix(
Button::new("info")
.ghost()
.icon(IconName::Info)
.xsmall()
.mr_3(),
),
NumberInput::new(&self.number_input2)
.small()
.suffix(Button::new("info").ghost().icon(IconName::Info).xsmall()),
),
)
.child(

View file

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

View file

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

View file

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

View file

@ -1,7 +1,8 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
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 _};
@ -18,8 +19,8 @@ use super::InputState;
#[derive(IntoElement)]
pub struct TextInput {
state: Entity<InputState>,
style: StyleRefinement,
size: Size,
no_gap: bool,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
height: Option<DefiniteLength>,
@ -44,7 +45,7 @@ impl TextInput {
Self {
state: state.clone(),
size: Size::default(),
no_gap: false,
style: StyleRefinement::default(),
prefix: None,
suffix: None,
height: None,
@ -115,14 +116,6 @@ impl TextInput {
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 {
Button::new("toggle-mask")
.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 {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
const LINE_HEIGHT: Rems = Rems(1.25);
@ -161,25 +160,24 @@ impl RenderOnce for TextInput {
let state = self.state.read(cx);
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::Large => px(8.),
_ => 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 {
cx.theme().muted
} else {
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()
.id(("input", self.state.entity_id()))
.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::enter))
.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::cut))
.on_action(window.listener_for(&self.state, InputState::undo))
.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::right))
@ -263,39 +263,38 @@ impl RenderOnce for TextInput {
})
})
})
.when(prefix.is_none(), |this| this.input_pl(self.size))
.input_pr(self.size)
.input_px(self.size)
.items_center()
.gap(gap_x)
.children(prefix)
.child(self.state.clone())
.child(
h_flex()
.id("suffix")
.absolute()
.gap(gap_x)
.when(self.appearance, |this| this.bg(bg))
.items_center()
.when(suffix.is_none(), |this| this.pr_1())
.right_0()
.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(show_clear_button, |this| {
this.child(clear_button(cx).on_click({
let state = self.state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.clean(window, cx);
})
}
}))
})
.children(suffix),
)
.when(has_suffix, |this| {
this.pr(self.size.input_px() / 2.).child(
h_flex()
.id("suffix")
.gap(gap_x)
.when(self.appearance, |this| this.bg(bg))
.items_center()
.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(show_clear_button, |this| {
this.child(clear_button(cx).on_click({
let state = self.state.clone();
move |_, window, cx| {
state.update(cx, |state, cx| {
state.clean(window, cx);
})
}
}))
})
.children(suffix),
)
})
.refine_style(&self.style)
.when(state.is_multi_line(), |this| {
let entity_id = self.state.entity_id();
if state.last_layout.is_some() {

View file

@ -285,6 +285,26 @@ impl Size {
_ => 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 {
@ -374,40 +394,22 @@ impl<T: Styled> StyleSized<T> for T {
#[inline]
fn input_pl(self, size: Size) -> Self {
match size {
Size::Large => self.pl_5(),
Size::Medium => self.pl_3(),
_ => self.pl_2(),
}
self.pl(size.input_px())
}
#[inline]
fn input_pr(self, size: Size) -> Self {
match size {
Size::Large => self.pr_5(),
Size::Medium => self.pr_3(),
_ => self.pr_2(),
}
self.pr(size.input_px())
}
#[inline]
fn input_px(self, size: Size) -> Self {
match size {
Size::Large => self.px_5(),
Size::Medium => self.px_3(),
_ => self.px_2(),
}
self.px(size.input_px())
}
#[inline]
fn input_py(self, size: Size) -> Self {
match size {
Size::Large => self.py_5(),
Size::Medium => self.py_2(),
Size::Small => self.py_1(),
Size::XSmall => self.py_0(),
_ => self.py_1(),
}
self.py(size.input_py())
}
#[inline]