use std::ops::Range;
use std::time::{Duration, Instant};
use unicode_segmentation::UnicodeSegmentation as _;
use fluidattacks_designs_domain::color::{SUCCESS_500, WHITE};
use fluidattacks_designs_domain::icon::{Size as IconSize, Style as IconStyle};
use fluidattacks_designs_domain::inputs::{
rows_height, Size, State, Validation, ADORNMENT, CURSOR_BLINK_MS, CURSOR_WIDTH, ERROR_TEXT,
FOCUS_RING, GAP, HEIGHT, HELP, HELP_GAP, LABEL, LABEL_GAP, LEADING_ICON, LINE_HEIGHT,
OUTLINE_GAP, PADDING_X, PADDING_Y, PLACEHOLDER, RADIUS, REQUIRED, SELECTION, TEXT, TEXT_SIZE,
TOGGLE_WIDTH,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, fill, point, px, relative, rgb, rgb_to_hsla, rgba, size, App, Bounds, ClipboardItem,
Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler,
FocusHandle, Focusable, GlobalElementId, InteractiveElement as _, IntoElement, KeyBinding,
LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement as _, Pixels, Point, RenderOnce, ShapedLine, SharedString,
StatefulInteractiveElement as _, Style, Styled as _, TextAlign, TextRun, UTF16Selection,
Window,
};
use crate::icon::Icon;
use crate::theme::{ActiveTheme as _, Theme};
const CONTEXT: &str = "DesignsTextInput";
#[allow(clippy::derive_partial_eq_without_eq)]
mod action_defs {
gpui::actions!(
designs_text_input,
[
Backspace,
Delete,
Left,
Right,
SelectLeft,
SelectRight,
SelectAll,
Home,
End,
ShowCharacterPalette,
Paste,
Cut,
Copy,
]
);
}
use action_defs::{
Backspace, Copy, Cut, Delete, End, Home, Left, Paste, Right, SelectAll, SelectLeft,
SelectRight, ShowCharacterPalette,
};
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
KeyBinding::new("delete", Delete, Some(CONTEXT)),
KeyBinding::new("left", Left, Some(CONTEXT)),
KeyBinding::new("right", Right, Some(CONTEXT)),
KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)),
KeyBinding::new("home", Home, Some(CONTEXT)),
KeyBinding::new("cmd-left", Home, Some(CONTEXT)),
KeyBinding::new("end", End, Some(CONTEXT)),
KeyBinding::new("cmd-right", End, Some(CONTEXT)),
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)),
KeyBinding::new("cmd-v", Paste, Some(CONTEXT)),
KeyBinding::new("cmd-c", Copy, Some(CONTEXT)),
KeyBinding::new("cmd-x", Cut, Some(CONTEXT)),
]);
}
#[derive(Clone, Copy, Default)]
struct Mask {
enabled: bool,
revealed: bool,
}
pub struct TextInputState {
focus_handle: FocusHandle,
value: SharedString,
placeholder: SharedString,
selected_range: Range<usize>,
selection_reversed: bool,
marked_range: Option<Range<usize>>,
last_layout: Option<ShapedLine>,
last_bounds: Option<Bounds<Pixels>>,
is_selecting: bool,
mask: Mask,
max_length: Option<usize>,
blink_scheduled: bool,
caret_epoch: Instant,
}
impl TextInputState {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
.detach();
cx.on_blur(&focus_handle, window, |_, _, cx| cx.notify())
.detach();
Self {
focus_handle,
value: SharedString::default(),
placeholder: SharedString::default(),
selected_range: 0..0,
selection_reversed: false,
marked_range: None,
last_layout: None,
last_bounds: None,
is_selecting: false,
mask: Mask::default(),
max_length: None,
blink_scheduled: false,
caret_epoch: Instant::now(),
}
}
#[must_use]
pub const fn masked(mut self, masked: bool) -> Self {
self.mask.enabled = masked;
self
}
#[must_use]
pub const fn max_length(mut self, max_length: usize) -> Self {
self.max_length = Some(max_length);
self
}
pub(crate) const fn set_max_length(&mut self, max_length: usize) {
self.max_length = Some(max_length);
}
#[must_use]
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
pub fn set_placeholder(
&mut self,
placeholder: impl Into<SharedString>,
cx: &mut Context<Self>,
) {
self.placeholder = placeholder.into();
cx.notify();
}
pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut Context<Self>) {
self.value = value.into();
let end = self.value.len();
self.selected_range = end..end;
self.selection_reversed = false;
self.marked_range = None;
cx.notify();
}
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.move_to(self.previous_boundary(self.cursor_offset()), cx);
} else {
self.move_to(self.selected_range.start, cx);
}
}
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.move_to(self.next_boundary(self.selected_range.end), cx);
} else {
self.move_to(self.selected_range.end, cx);
}
}
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(self.previous_boundary(self.cursor_offset()), cx);
}
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(self.next_boundary(self.cursor_offset()), cx);
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(0, cx);
self.select_to(self.value.len(), cx);
}
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(0, cx);
}
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(self.value.len(), cx);
}
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.select_to(self.previous_boundary(self.cursor_offset()), cx);
}
self.replace_text_in_range(None, "", window, cx);
}
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
if self.selected_range.is_empty() {
self.select_to(self.next_boundary(self.cursor_offset()), cx);
}
self.replace_text_in_range(None, "", window, cx);
}
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
self.replace_text_in_range(None, &text.replace('\n', " "), window, cx);
}
}
fn selected_text(&self) -> &str {
self.value
.get(self.selected_range.clone())
.unwrap_or_default()
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(self.selected_text().to_owned()));
}
}
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
if !self.selected_range.is_empty() {
cx.write_to_clipboard(ClipboardItem::new_string(self.selected_text().to_owned()));
self.replace_text_in_range(None, "", window, cx);
}
}
fn on_mouse_down(&mut self, event: &MouseDownEvent, _: &mut Window, cx: &mut Context<Self>) {
self.is_selecting = true;
if event.modifiers.shift {
self.select_to(self.index_for_mouse_position(event.position), cx);
} else {
self.move_to(self.index_for_mouse_position(event.position), cx);
}
}
const fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
self.is_selecting = false;
}
fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
if self.is_selecting {
self.select_to(self.index_for_mouse_position(event.position), cx);
}
}
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.selected_range = offset..offset;
self.caret_epoch = Instant::now();
cx.notify();
}
const fn cursor_offset(&self) -> usize {
if self.selection_reversed {
self.selected_range.start
} else {
self.selected_range.end
}
}
fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
if self.value.is_empty() {
return 0;
}
let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref())
else {
return 0;
};
if position.y < bounds.top() {
return 0;
}
if position.y > bounds.bottom() {
return self.value.len();
}
let display =
line.closest_index_for_x(px(f32::from(position.x) - f32::from(bounds.left())));
self.value_offset(display)
}
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
if self.selection_reversed {
self.selected_range.start = offset;
} else {
self.selected_range.end = offset;
}
if self.selected_range.end < self.selected_range.start {
self.selection_reversed = !self.selection_reversed;
self.selected_range = self.selected_range.end..self.selected_range.start;
}
self.caret_epoch = Instant::now();
cx.notify();
}
fn offset_from_utf16(&self, offset: usize) -> usize {
let mut utf8_offset = 0_usize;
let mut utf16_count = 0_usize;
for ch in self.value.chars() {
if utf16_count >= offset {
break;
}
utf16_count = utf16_count.saturating_add(ch.len_utf16());
utf8_offset = utf8_offset.saturating_add(ch.len_utf8());
}
utf8_offset
}
fn offset_to_utf16(&self, offset: usize) -> usize {
let mut utf16_offset = 0_usize;
let mut utf8_count = 0_usize;
for ch in self.value.chars() {
if utf8_count >= offset {
break;
}
utf8_count = utf8_count.saturating_add(ch.len_utf8());
utf16_offset = utf16_offset.saturating_add(ch.len_utf16());
}
utf16_offset
}
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
}
fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
}
fn target_range(&self, range_utf16: Option<&Range<usize>>) -> Range<usize> {
range_utf16
.map(|utf16| self.range_from_utf16(utf16))
.or_else(|| self.marked_range.clone())
.unwrap_or_else(|| self.selected_range.clone())
}
fn previous_boundary(&self, offset: usize) -> usize {
let mut previous = 0;
for (idx, _) in self.value.grapheme_indices(true) {
if idx >= offset {
break;
}
previous = idx;
}
previous
}
fn next_boundary(&self, offset: usize) -> usize {
self.value
.grapheme_indices(true)
.find_map(|(idx, _)| (idx > offset).then_some(idx))
.unwrap_or(self.value.len())
}
const fn is_hidden(&self) -> bool {
self.mask.enabled && !self.mask.revealed
}
fn display_text(&self) -> SharedString {
if self.is_hidden() {
"\u{2022}".repeat(self.value.chars().count()).into()
} else {
self.value.clone()
}
}
fn display_offset(&self, offset: usize) -> usize {
if self.is_hidden() {
let chars = self
.value
.get(..offset)
.map_or(0, |prefix| prefix.chars().count());
chars.saturating_mul(3)
} else {
offset
}
}
#[must_use]
fn value_offset(&self, offset: usize) -> usize {
if self.is_hidden() {
let chars = offset.checked_div(3).unwrap_or(0);
self.value
.char_indices()
.nth(chars)
.map_or(self.value.len(), |(idx, _)| idx)
} else {
offset
}
}
fn splice(&mut self, range: &Range<usize>, new_text: &str) -> usize {
let prefix = self.value.get(..range.start).unwrap_or_default();
let suffix = self.value.get(range.end..).unwrap_or_default();
let new_text = self.max_length.map_or(new_text, |max| {
let kept = prefix
.graphemes(true)
.count()
.saturating_add(suffix.graphemes(true).count());
let room = max.saturating_sub(kept);
let end = new_text
.grapheme_indices(true)
.nth(room)
.map_or(new_text.len(), |(idx, _)| idx);
new_text.get(..end).unwrap_or_default()
});
self.value = format!("{prefix}{new_text}{suffix}").into();
self.caret_epoch = Instant::now();
range.start.saturating_add(new_text.len())
}
}
impl Focusable for TextInputState {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl gpui::Render for TextInputState {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.w_full()
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.cursor(CursorStyle::IBeam)
.on_action(cx.listener(Self::backspace))
.on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::left))
.on_action(cx.listener(Self::right))
.on_action(cx.listener(Self::select_left))
.on_action(cx.listener(Self::select_right))
.on_action(cx.listener(Self::select_all))
.on_action(cx.listener(Self::home))
.on_action(cx.listener(Self::end))
.on_action(|_: &ShowCharacterPalette, window: &mut Window, _| {
window.show_character_palette();
})
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::copy))
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
.on_mouse_move(cx.listener(Self::on_mouse_move))
.child(TextElement { state: cx.entity() })
}
}
impl EntityInputHandler for TextInputState {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
actual_range: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
let range = self.range_from_utf16(&range_utf16);
actual_range.replace(self.range_to_utf16(&range));
Some(self.value.get(range).unwrap_or_default().to_owned())
}
fn selected_text_range(
&mut self,
_ignore_disabled_input: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.range_to_utf16(&self.selected_range),
reversed: self.selection_reversed,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.marked_range
.as_ref()
.map(|range| self.range_to_utf16(range))
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
self.marked_range = None;
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
let range = self.target_range(range_utf16.as_ref());
let caret = self.splice(&range, new_text);
self.selected_range = caret..caret;
self.marked_range.take();
cx.notify();
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range_utf16: Option<Range<usize>>,
_: &mut Window,
cx: &mut Context<Self>,
) {
let range = self.target_range(range_utf16.as_ref());
let caret = self.splice(&range, new_text);
self.marked_range = if new_text.is_empty() {
None
} else {
Some(range.start..caret)
};
self.selected_range = new_selected_range_utf16
.as_ref()
.map(|utf16| self.range_from_utf16(utf16))
.map(|new_range| {
new_range.start.saturating_add(range.start)
..new_range.end.saturating_add(range.start)
})
.unwrap_or(caret..caret);
cx.notify();
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let last_layout = self.last_layout.as_ref()?;
let range = self.range_from_utf16(&range_utf16);
Some(Bounds::from_corners(
point(
x_at(&bounds, last_layout.x_for_index(range.start)),
bounds.top(),
),
point(
x_at(&bounds, last_layout.x_for_index(range.end)),
bounds.bottom(),
),
))
}
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
let line_point = self.last_bounds?.localize(&point)?;
let last_layout = self.last_layout.as_ref()?;
let utf8_index =
last_layout.index_for_x(px(f32::from(point.x) - f32::from(line_point.x)))?;
Some(self.offset_to_utf16(utf8_index))
}
}
#[must_use]
fn reveal_toggle(
state: &Entity<TextInputState>,
border: u32,
revealed: bool,
glyph: u32,
) -> impl IntoElement {
let entity = state.clone();
div()
.id(ElementId::NamedInteger(
"input-reveal".into(),
state.entity_id().as_u64(),
))
.flex()
.items_center()
.justify_center()
.w(px(TOGGLE_WIDTH))
.my(px(-PADDING_Y))
.mr(px(-PADDING_X))
.h(px(HEIGHT))
.border_l_1()
.border_color(rgb(border))
.cursor_pointer()
.on_click(move |_, _, cx| {
entity.update(cx, |input, cx| {
input.mask.revealed = !input.mask.revealed;
cx.notify();
});
})
.child(
Icon::new(if revealed { "eye-slash" } else { "eye" })
.style(IconStyle::Light)
.size(IconSize::Lg)
.color(glyph),
)
}
pub fn validation_icon(error: bool, success: bool, theme: &Theme) -> Option<Icon> {
if error {
Some(
Icon::new("circle-exclamation")
.size(IconSize::Lg)
.color(theme.shade(ERROR_TEXT)),
)
} else if success {
Some(
Icon::new("circle-check")
.size(IconSize::Lg)
.color(theme.shade(SUCCESS_500)),
)
} else {
None
}
}
pub fn x_at(bounds: &Bounds<Pixels>, offset: Pixels) -> Pixels {
px(f32::from(bounds.left()) + f32::from(offset))
}
fn highlight_quads(
bounds: &Bounds<Pixels>,
line: &ShapedLine,
cursor: usize,
selected_range: &Range<usize>,
tones: (u32, u32),
) -> (Option<PaintQuad>, Option<PaintQuad>) {
let (caret, selection) = tones;
let height = px(f32::from(bounds.bottom()) - f32::from(bounds.top()));
if selected_range.is_empty() {
let origin = point(x_at(bounds, line.x_for_index(cursor)), bounds.top());
let quad = fill(
Bounds::new(origin, size(px(CURSOR_WIDTH), height)),
rgb(caret),
);
(None, Some(quad))
} else {
let quad = fill(
Bounds::from_corners(
point(
x_at(bounds, line.x_for_index(selected_range.start)),
bounds.top(),
),
point(
x_at(bounds, line.x_for_index(selected_range.end)),
bounds.bottom(),
),
),
rgba(selection << 8 | 0xff),
);
(Some(quad), None)
}
}
pub fn text_runs(
display_len: usize,
marked: Option<&Range<usize>>,
base: &TextRun,
) -> Vec<TextRun> {
let Some(marked) = marked else {
return vec![base.clone()];
};
[
TextRun {
len: marked.start,
..base.clone()
},
TextRun {
len: marked.end.saturating_sub(marked.start),
underline: Some(gpui::UnderlineStyle {
color: Some(base.color),
thickness: px(1.0),
wavy: false,
}),
..base.clone()
},
TextRun {
len: display_len.saturating_sub(marked.end),
..base.clone()
},
]
.into_iter()
.filter(|text_run| text_run.len > 0)
.collect()
}
fn schedule_blink(state: &Entity<TextInputState>, until_flip: u64, window: &Window, cx: &mut App) {
state.update(cx, |input, _| input.blink_scheduled = true);
let entity = state.clone();
let view = window.current_view();
window
.spawn(cx, async move |task_cx| {
task_cx
.background_executor()
.timer(Duration::from_millis(until_flip))
.await;
task_cx
.update(|_, app| {
entity.update(app, |input, _| input.blink_scheduled = false);
app.notify(view);
})
.ok();
})
.detach();
}
struct TextElement {
state: Entity<TextInputState>,
}
struct TextPrepaint {
line: Option<ShapedLine>,
cursor: Option<PaintQuad>,
selection: Option<PaintQuad>,
}
impl IntoElement for TextElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TextElement {
type RequestLayoutState = ();
type PrepaintState = TextPrepaint;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.size.width = relative(1.0).into();
style.size.height = window.line_height().into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
&mut (): &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let theme = cx.theme();
let (placeholder_color, body_color, selection_color) = (
theme.shade(PLACEHOLDER),
theme.shade(TEXT),
theme.shade(SELECTION),
);
let state = self.state.read(cx);
let value = state.display_text();
let selected_range = state.display_offset(state.selected_range.start)
..state.display_offset(state.selected_range.end);
let cursor = state.display_offset(state.cursor_offset());
let marked_range = state.marked_range.clone();
let style = window.text_style();
let (display_text, text_color) = if value.is_empty() {
(
state.placeholder.clone(),
rgb_to_hsla(rgb(placeholder_color)),
)
} else {
(value, rgb_to_hsla(rgb(body_color)))
};
let run = TextRun {
len: display_text.len(),
font: style.font(),
color: text_color,
background_color: None,
underline: None,
strikethrough: None,
letter_spacing: None,
};
let runs = text_runs(display_text.len(), marked_range.as_ref(), &run);
let font_size = style.font_size.to_pixels(window.rem_size());
let line = window
.text_system()
.shape_line(display_text, font_size, &runs, None);
let (selection, caret) = highlight_quads(
&bounds,
&line,
cursor,
&selected_range,
(body_color, selection_color),
);
TextPrepaint {
line: Some(line),
cursor: caret,
selection,
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
&mut (): &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let state = self.state.read(cx);
let focus_handle = state.focus_handle.clone();
let elapsed = state.caret_epoch.elapsed().as_millis();
let blink_phase = elapsed
.checked_div(u128::from(CURSOR_BLINK_MS))
.unwrap_or(0);
let blink_visible = blink_phase & 1 == 0;
let into_phase = elapsed
.checked_rem(u128::from(CURSOR_BLINK_MS))
.unwrap_or(0);
let until_flip = CURSOR_BLINK_MS.saturating_sub(u64::try_from(into_phase).unwrap_or(0));
window.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.state.clone()),
cx,
);
if let Some(selection) = prepaint.selection.take() {
window.paint_quad(selection);
}
if let Some(line) = prepaint.line.take() {
line.paint(
bounds.origin,
window.line_height(),
TextAlign::default(),
None,
window,
cx,
)
.ok();
let focused = focus_handle.is_focused(window);
if focused && !self.state.read(cx).blink_scheduled {
schedule_blink(&self.state, until_flip, window, cx);
}
let caret = prepaint.cursor.take().filter(|_| focused && blink_visible);
if let Some(cursor) = caret {
window.paint_quad(cursor);
}
self.state.update(cx, |input, _| {
input.last_layout = Some(line);
input.last_bounds = Some(bounds);
});
}
}
}
#[derive(IntoElement)]
pub struct TextInput {
state: Entity<TextInputState>,
label: Option<SharedString>,
label_tooltip: Option<(ElementId, SharedString)>,
required: bool,
error: Option<SharedString>,
success: bool,
disabled: bool,
leading_icon: Option<SharedString>,
help_text: Option<SharedString>,
help_link: Option<(ElementId, SharedString, SharedString)>,
}
impl TextInput {
#[must_use]
pub fn new(state: &Entity<TextInputState>) -> Self {
Self {
state: state.clone(),
label: None,
label_tooltip: None,
required: false,
error: None,
success: false,
disabled: false,
leading_icon: None,
help_text: None,
help_link: None,
}
}
#[must_use]
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub const fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
#[must_use]
pub fn label_tooltip(mut self, id: impl Into<ElementId>, tip: impl Into<SharedString>) -> Self {
self.label_tooltip = Some((id.into(), tip.into()));
self
}
#[must_use]
pub fn has_icon(mut self, has_icon: bool) -> Self {
self.leading_icon = has_icon.then(|| SharedString::from(LEADING_ICON));
self
}
#[must_use]
pub fn leading_glyph(mut self, name: impl Into<SharedString>) -> Self {
self.leading_icon = Some(name.into());
self
}
#[must_use]
pub fn help_text(mut self, help_text: impl Into<SharedString>) -> Self {
self.help_text = Some(help_text.into());
self
}
#[must_use]
pub fn help_link(
mut self,
id: impl Into<ElementId>,
label: impl Into<SharedString>,
href: impl Into<SharedString>,
) -> Self {
self.help_link = Some((id.into(), label.into(), href.into()));
self
}
#[must_use]
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self
}
#[must_use]
pub const fn success(mut self, success: bool) -> Self {
self.success = success;
self
}
#[must_use]
pub const fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
fn field(self, state: State, cx: &App) -> gpui::Div {
let mask = self.state.read(cx).mask;
let theme = cx.theme();
field_frame(state, theme)
.when_some(self.leading_icon.clone(), |el, name| {
el.child(
Icon::new(name)
.style(IconStyle::Light)
.size(IconSize::Xl)
.color(theme.shade(ADORNMENT)),
)
})
.map(|el| {
if self.disabled {
el.cursor(CursorStyle::OperationNotAllowed)
.child(self.static_text(cx))
} else {
el.child(div().flex_1().overflow_hidden().child(self.state.clone()))
}
})
.when_some(
validation_icon(
matches!(state, State::Error),
matches!(state, State::Success),
theme,
),
gpui::ParentElement::child,
)
.when(mask.enabled && !self.disabled, |el| {
el.child(reveal_toggle(
&self.state,
theme.shade(state.border()),
mask.revealed,
theme.shade(TEXT),
))
})
}
fn static_text(&self, cx: &App) -> gpui::Div {
let input_state = self.state.read(cx);
let empty = input_state.value.is_empty();
let shown = if empty {
input_state.placeholder.clone()
} else {
input_state.display_text()
};
div()
.flex_1()
.overflow_hidden()
.when(empty, |el| {
el.text_color(rgb(cx.theme().shade(PLACEHOLDER)))
})
.child(shown)
}
}
#[must_use]
pub fn label_row(
label: SharedString,
required: bool,
tooltip: Option<(ElementId, SharedString)>,
theme: &Theme,
) -> impl IntoElement {
div()
.flex()
.items_center()
.gap(px(LABEL_GAP))
.text_color(rgb(theme.shade(LABEL)))
.child(label)
.when(required, |el| {
el.child(
Icon::new("asterisk")
.size(IconSize::Xs)
.color(theme.shade(REQUIRED)),
)
})
.when_some(tooltip, |el, (tooltip_id, tip)| {
el.child(
crate::Tooltip::new(tooltip_id, tip).child(
Icon::new("circle-info")
.size(IconSize::Sm)
.color(theme.shade(HELP)),
),
)
})
}
#[must_use]
pub fn field_frame(state: State, theme: &Theme) -> gpui::Div {
sized_field_frame(state, Size::default(), theme)
}
pub fn sized_field_frame(state: State, size: Size, theme: &Theme) -> gpui::Div {
field_skin(state, size.radius(), theme)
.items_center()
.h(px(size.height()))
.px(px(size.padding_x()))
.py(px(size.padding_y()))
}
#[must_use]
pub fn block_field_frame(state: State, rows: u16, theme: &Theme) -> gpui::Div {
field_skin(state, RADIUS, theme)
.items_start()
.h(px(rows_height(rows)))
.flex_shrink_0()
.overflow_hidden()
.px(px(PADDING_X))
.py(px(PADDING_Y))
}
#[must_use]
fn field_skin(state: State, radius: f32, theme: &Theme) -> gpui::Div {
let border = theme.shade(state.border());
let hover_border = theme.shade(State::Hover.border());
div()
.flex()
.justify_between()
.gap(px(GAP))
.w_full()
.rounded(px(radius))
.border_1()
.border_color(rgb(border))
.when_some(state.background(), |el, background| {
el.bg(rgb(theme.shade(background)))
})
.text_color(rgb(theme.shade(state.text())))
.map(|el| match state {
State::Focus => el.shadow(vec![gpui::BoxShadow {
color: rgb_to_hsla(rgb(border)),
offset: point(px(0.0), px(0.0)),
blur_radius: px(0.0),
spread_radius: px(FOCUS_RING),
inset: false,
}]),
State::Default => {
let background = theme.shade(State::Hover.background().unwrap_or(WHITE));
el.hover(move |style| style.border_color(rgb(hover_border)).bg(rgb(background)))
}
_ => el,
})
}
#[must_use]
pub fn bottom_row(
error: Option<SharedString>,
counter: Option<String>,
theme: &Theme,
) -> gpui::Div {
div()
.flex()
.items_center()
.justify_between()
.w_full()
.child(
div()
.text_color(rgb(theme.shade(ERROR_TEXT)))
.when_some(error, gpui::ParentElement::child),
)
.when_some(counter, |row, count| row.child(crate::Tag::new(count)))
}
#[must_use]
pub fn help_row(
help: Option<SharedString>,
link: Option<(ElementId, SharedString, SharedString)>,
theme: &Theme,
) -> gpui::Div {
let tone = theme.shade(HELP);
div()
.flex()
.items_center()
.gap(px(HELP_GAP))
.text_color(rgb(tone))
.when(help.is_some(), |el| {
el.child(Icon::new("circle-info").size(IconSize::Sm).color(tone))
})
.when_some(help, gpui::ParentElement::child)
.when_some(link, |el, (link_id, label, href)| {
el.child(crate::Link::new(link_id, label).href(href))
})
}
impl RenderOnce for TextInput {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focused = self.state.read(cx).focus_handle.is_focused(window);
let validation = if self.error.is_some() && !self.disabled {
Validation::Error
} else if self.success {
Validation::Success
} else {
Validation::None
};
let state = State::resolve(focused, validation, self.disabled);
let label = self.label.take();
let label_tooltip = self.label_tooltip.take();
let required = self.required;
let error = if self.disabled {
None
} else {
self.error.take()
};
let counter = {
let input_state = self.state.read(cx);
input_state
.max_length
.map(|max| format!("{}/{max}", input_state.value.graphemes(true).count()))
};
let help = self.help_text.take();
let help_link = self.help_link.take();
let theme = cx.theme();
let bottom =
(error.is_some() || counter.is_some()).then(|| bottom_row(error, counter, theme));
div()
.flex()
.flex_col()
.items_start()
.gap(px(OUTLINE_GAP))
.w_full()
.font_family(FONT_PRIMARY)
.text_size(px(TEXT_SIZE.px()))
.line_height(px(LINE_HEIGHT))
.when_some(label, |el, label| {
el.child(label_row(label, required, label_tooltip, theme))
})
.child(self.field(state, cx))
.when_some(bottom, gpui::ParentElement::child)
.when(help.is_some() || help_link.is_some(), |el| {
el.child(help_row(help, help_link, theme))
})
}
}