use crate::ActiveTheme;
use gpui_kit::base::{
Input as BaseInput, InputBase, Textarea,
input::{InputState, TextareaState},
};
use gpui_kit::prelude::FluentBuilder;
use gpui_kit::rems;
use gpui_kit::{
AnyElement, App, ElementId, Entity, Focusable, InteractiveElement, Interactivity, IntoElement,
MouseButton, ParentElement, RenderOnce, StatefulInteractiveElement, StyleRefinement, Styled,
Window, div,
};
#[derive(IntoElement)]
pub struct Input {
base: InputBase,
editor: BaseInput,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
native_mask_text: bool,
}
impl Input {
pub fn prefix(mut self, content: impl IntoElement) -> Self {
self.prefix = Some(content.into_any_element());
self
}
pub fn suffix(mut self, content: impl IntoElement) -> Self {
self.suffix = Some(content.into_any_element());
self
}
}
impl Styled for Input {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl InteractiveElement for Input {
fn interactivity(&mut self) -> &mut Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Input {}
impl RenderOnce for Input {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let affix = |content| {
div()
.flex()
.items_center()
.flex_shrink_0()
.text_color(cx.macos().secondary)
.child(content)
};
self.base
.children(self.prefix.map(affix))
.child(
div()
.flex_1()
.min_w_0()
.when(self.native_mask_text, |editor| {
editor.font_family("Helvetica Neue").text_size(rems(0.8))
})
.child(self.editor),
)
.children(self.suffix.map(affix))
}
}
fn frame(id: impl Into<ElementId>, focused: bool, cx: &App) -> InputBase {
let t = cx.macos();
InputBase::new(id)
.focused(focused)
.w_full()
.min_w_0()
.px(rems(0.375))
.border_1()
.rounded(rems(0.375))
.border_color(t.control_border())
.bg(t.surface)
.text_color(t.foreground)
.font_family(t.font.clone())
.text_size(rems(0.8125))
.line_height(gpui_kit::relative(1.2))
.styles(|s| s.focused(|s| s.border_color(t.accent).shadow(t.focus_shadow())))
}
pub fn input(
id: impl Into<ElementId>,
state: &Entity<InputState>,
window: &Window,
cx: &mut App,
) -> Input {
let style = cx.macos().input_style();
state.update(cx, |state, cx| {
state.set_editor_style(style);
if state.presentation().is_masked() {
state.set_mask_character('●', cx);
}
});
let masked = state.read(cx).presentation().is_masked();
let native_mask_text = masked && !state.read(cx).value().is_empty();
let focused = window.is_window_active() && state.read(cx).focus_handle(cx).is_focused(window);
let target = state.clone();
let base = frame(id, focused, cx)
.when(masked, |frame| frame.role(gpui_kit::Role::PasswordInput))
.min_h(rems(1.5))
.py(rems(0.1875))
.flex()
.items_center()
.gap(rems(0.25))
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
target.update(cx, |state, cx| state.focus(window, cx));
});
Input {
base,
editor: BaseInput::new(state),
prefix: None,
suffix: None,
native_mask_text,
}
}
pub fn textarea(
id: impl Into<ElementId>,
state: &Entity<TextareaState>,
window: &Window,
cx: &mut App,
) -> InputBase {
let style = cx.macos().input_style();
state.update(cx, |state, _| state.set_editor_style(style));
let focused = window.is_window_active() && state.read(cx).focus_handle(cx).is_focused(window);
let target = state.clone();
frame(id, focused, cx)
.min_h(rems(6.))
.line_height(gpui_kit::relative(1.4))
.py(rems(0.3125))
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
target.update(cx, |state, cx| state.focus(window, cx));
})
.child(Textarea::new(state))
}
pub fn number_input(state: &Entity<InputState>, cx: &mut App) -> gpui_kit::base::NumberInput {
use gpui_kit::ParentElement as _;
let t = cx.macos().clone();
state.update(cx, |state, cx| {
state.set_editor_style(t.input_style());
state.set_text_align(gpui_kit::TextAlign::Right, cx);
});
let minus = t.clone();
gpui_kit::base::NumberInput::new(state)
.w(rems(7.5))
.h(rems(1.5))
.controls_right()
.overflow_hidden()
.flex()
.items_center()
.border_1()
.rounded(rems(0.375))
.border_color(t.control_border())
.bg(t.surface)
.text_color(t.foreground)
.font_family(t.font.clone())
.text_size(rems(0.8125))
.line_height(gpui_kit::relative(1.2))
.input(
gpui_kit::div()
.flex_1()
.min_w_0()
.px(rems(0.375))
.child(BaseInput::new(state)),
)
.decrement_button(move |button| {
button
.w(rems(1.25))
.text_color(minus.foreground)
.bg(minus.control_fill())
.border_l_1()
.border_color(minus.divider())
.hover(|s| s.bg(minus.hover_fill()))
.child(crate::icon(crate::IconName::ChevronDown).size(rems(0.75)))
})
.increment_button(move |button| {
button
.w(rems(1.25))
.text_color(t.foreground)
.bg(t.control_fill())
.border_l_1()
.border_b_1()
.border_color(t.divider())
.hover(|s| s.bg(t.hover_fill()))
.child(crate::icon(crate::IconName::ChevronUp).size(rems(0.75)))
})
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::{Context, Modifiers, Render, TestAppContext, prelude::*};
struct Harness(Entity<InputState>);
impl Render for Harness {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
input("price", &self.0, window, cx)
.debug_selector(|| "price-frame".into())
.w(rems(15.))
.prefix(div().debug_selector(|| "price-prefix".into()).child("$"))
.suffix(div().debug_selector(|| "price-suffix".into()).child("USD"))
}
}
#[gpui_kit::test]
fn input_and_affixes_scale_with_the_window_rem(cx: &mut TestAppContext) {
cx.update(crate::init);
let (view, cx) = cx.add_window_view(|window, cx| {
Harness(cx.new(|cx| InputState::new(window, cx).default_value("181.170")))
});
let mut previous_suffix_width = gpui_kit::Pixels::ZERO;
for rem_size in [16., 20., 24.] {
cx.update(|window, cx| {
window.set_rem_size(gpui_kit::px(rem_size));
view.update(cx, |_, cx| cx.notify());
window.draw(cx).clear(cx);
});
let frame = cx.debug_bounds("price-frame").unwrap();
let prefix = cx.debug_bounds("price-prefix").unwrap();
let suffix = cx.debug_bounds("price-suffix").unwrap();
assert_eq!(frame.size.width, gpui_kit::px(15. * rem_size));
assert!(suffix.size.width > previous_suffix_width);
assert!(prefix.right() < suffix.left());
assert_eq!(
prefix.left() - frame.left(),
gpui_kit::px(rem_size * 0.375 + 1.),
"native field padding scales independently of its one-point border"
);
assert!(suffix.right() < frame.right());
previous_suffix_width = suffix.size.width;
}
}
#[gpui_kit::test]
fn affixes_preserve_editing_and_focus(cx: &mut TestAppContext) {
cx.update(crate::init);
let (view, cx) =
cx.add_window_view(|window, cx| Harness(cx.new(|cx| InputState::new(window, cx))));
cx.update(|window, cx| window.draw(cx).clear(cx));
let prefix = cx.debug_bounds("price-prefix").unwrap();
let suffix = cx.debug_bounds("price-suffix").unwrap();
assert!(prefix.right() < suffix.left());
cx.simulate_click(suffix.center(), Modifiers::default());
cx.update(|window, cx| {
assert!(view.read(cx).0.read(cx).focus_handle(cx).is_focused(window));
});
cx.simulate_input("181.170");
cx.update(|_, cx| {
assert_eq!(view.read(cx).0.read(cx).value().as_ref(), "181.170");
});
}
#[gpui_kit::test]
fn native_secure_mask_preserves_privacy_unicode_selection_and_ime(cx: &mut TestAppContext) {
use gpui_kit::{ClipboardItem, EntityInputHandler, Role, test::TestWindowExt};
struct SecureHarness(Entity<InputState>);
impl Render for SecureHarness {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
input("native-secret", &self.0, window, cx).w(rems(10.625))
}
}
cx.update(crate::init);
let (view, cx) = cx.add_window_view(|window, cx| {
SecureHarness(cx.new(|cx| {
InputState::new(window, cx)
.masked(true)
.default_value("A猫🦀Z")
}))
});
cx.update(|window, cx| {
window.draw(cx).clear(cx);
assert_eq!(
window.find("native-secret").role(),
Some(Role::PasswordInput)
);
assert_eq!(window.find("native-secret").value(), None);
let state = view.read(cx).0.clone();
state.update(cx, |state, cx| {
assert_eq!(state.mask_character(), '●');
state.focus(window, cx);
state.select_all(window, cx);
cx.write_to_clipboard(ClipboardItem::new_string("sentinel".into()));
});
});
cx.simulate_keystrokes("cmd-c cmd-x");
cx.update(|window, cx| {
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("sentinel".into())
);
let state = view.read(cx).0.clone();
state.update(cx, |state, cx| {
assert_eq!(state.value(), "A猫🦀Z");
state.set_selected_range(1..8, cx);
state.replace_and_mark_text_in_range(None, "日本", Some(1..1), window, cx);
assert_eq!(state.value(), "A日本Z");
assert_eq!(state.selected_range(), 4..4);
});
window.draw(cx).clear(cx);
assert_eq!(window.find("native-secret").value(), None);
state.update(cx, |state, cx| {
state.replace_and_mark_text_in_range(None, "日本語", Some(3..3), window, cx);
assert_eq!(state.selected_range(), 10..10);
});
window.draw(cx).clear(cx);
state.update(cx, |state, cx| {
state.replace_text_in_range(None, "日🦀", window, cx);
assert_eq!(state.value(), "A日🦀Z");
assert_eq!(state.selected_range(), 8..8);
});
window.draw(cx).clear(cx);
assert_eq!(
window.find("native-secret").role(),
Some(Role::PasswordInput)
);
assert_eq!(window.find("native-secret").value(), None);
state.update(cx, |state, cx| state.set_masked(false, window, cx));
window.draw(cx).clear(cx);
assert_eq!(window.find("native-secret").role(), Some(Role::TextInput));
assert_eq!(state.read(cx).value(), "A日🦀Z");
});
}
}