add password field
This commit is contained in:
+223
-44
@@ -7,13 +7,14 @@ use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
App, AppContext as _, Bounds, ClipboardItem, Context, CursorStyle, DispatchPhase, ElementId,
|
||||
Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _,
|
||||
IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent,
|
||||
SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, TextAlign, TextRun,
|
||||
UTF16Selection, Window, actions, canvas, div, fill, point, px,
|
||||
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DispatchPhase,
|
||||
ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior,
|
||||
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta,
|
||||
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
|
||||
TextAlign, TextRun, UTF16Selection, Window, actions, canvas, div, fill, point, px,
|
||||
};
|
||||
use gpui_native_icons::{IconRole, icon};
|
||||
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
||||
|
||||
use crate::common::{Sizable, h_flex};
|
||||
@@ -28,6 +29,10 @@ const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
|
||||
const HORIZONTAL_SCROLL_LINES_HEIGHT: f32 = 20.0;
|
||||
/// Maximum undo entries kept; the oldest drops off the front.
|
||||
const MAX_HISTORY: usize = 100;
|
||||
/// The character a password field paints instead of each grapheme.
|
||||
const MASK: &str = "\u{2022}";
|
||||
/// The byte length of [`MASK`]; the masked display is `graphemes × MASK_LEN`.
|
||||
const MASK_LEN: usize = 3;
|
||||
|
||||
/// Significant changes in an [`InputState`], observable via `cx.subscribe`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -274,12 +279,60 @@ where
|
||||
cx.bind_keys(bindings.into().unwrap_or_default().into_bindings());
|
||||
}
|
||||
|
||||
/// The shaped line plus its mask map when the field masks its content.
|
||||
struct ShapedText {
|
||||
line: gpui::ShapedLine,
|
||||
mask_map: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
/// The field's inner padding.
|
||||
struct FieldPadding {
|
||||
left: Pixels,
|
||||
right: Pixels,
|
||||
}
|
||||
|
||||
/// Input layout information for hit testing and caret positioning.
|
||||
struct InputLayout {
|
||||
line: gpui::ShapedLine,
|
||||
bounds: Bounds<Pixels>,
|
||||
/// Where the text was painted inside `bounds` (padding minus scroll).
|
||||
origin: gpui::Point<Pixels>,
|
||||
/// Maps real buffer offsets to display offsets for masked input.
|
||||
mask_map: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
/// Maps a display text to its corresponding buffer offset.
|
||||
fn masked_display(text: &str) -> (String, Option<Vec<usize>>) {
|
||||
let mut display = String::new();
|
||||
let mut map = Vec::with_capacity(text.len() + 1);
|
||||
for (offset, _) in text.grapheme_indices(true) {
|
||||
map.push(offset);
|
||||
display.push_str(MASK);
|
||||
}
|
||||
map.push(text.len());
|
||||
(display, Some(map))
|
||||
}
|
||||
|
||||
/// Maps a real buffer offset onto the masked display; identity when unmasked.
|
||||
fn display_index(mask_map: &Option<Vec<usize>>, real: usize) -> usize {
|
||||
match mask_map {
|
||||
None => real,
|
||||
Some(map) => {
|
||||
let index = map.iter().take_while(|&&offset| offset < real).count();
|
||||
index * MASK_LEN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a display offset from a click back onto the real buffer.
|
||||
fn real_index(mask_map: &Option<Vec<usize>>, display: usize) -> usize {
|
||||
match mask_map {
|
||||
None => display,
|
||||
Some(map) => {
|
||||
let index = (display / MASK_LEN).min(map.len().saturating_sub(1));
|
||||
map[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for key down events.
|
||||
@@ -433,6 +486,10 @@ pub struct InputState {
|
||||
group_interval: Duration,
|
||||
/// When set, user edits are refused, focus, movement, selection and copy stay.
|
||||
read_only: bool,
|
||||
/// When set, the field is a password field.
|
||||
password: bool,
|
||||
/// Whether a password field currently shows its content.
|
||||
revealed: bool,
|
||||
/// Horizontal scroll of the content inside the field, clamped at paint.
|
||||
scroll_offset: Pixels,
|
||||
/// Set by every caret-affecting change.
|
||||
@@ -473,6 +530,8 @@ impl InputState {
|
||||
redo_stack: Vec::new(),
|
||||
group_interval: DEFAULT_GROUP_INTERVAL,
|
||||
read_only: false,
|
||||
password: false,
|
||||
revealed: false,
|
||||
scroll_offset: Pixels::ZERO,
|
||||
scroll_to_caret_pending: true,
|
||||
caret_blink: caret_blink.clone(),
|
||||
@@ -567,6 +626,40 @@ impl InputState {
|
||||
self.read_only
|
||||
}
|
||||
|
||||
/// Sets whether the field is a password field.
|
||||
pub fn set_password(&mut self, password: bool, cx: &mut Context<Self>) {
|
||||
if self.password != password {
|
||||
self.password = password;
|
||||
if !password {
|
||||
self.revealed = false;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the field is a password field.
|
||||
pub fn is_password(&self) -> bool {
|
||||
self.password
|
||||
}
|
||||
|
||||
/// Sets whether a password field currently shows its content.
|
||||
pub fn set_revealed(&mut self, revealed: bool, cx: &mut Context<Self>) {
|
||||
if self.revealed != revealed {
|
||||
self.revealed = revealed;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a password field currently shows its content.
|
||||
pub fn is_revealed(&self) -> bool {
|
||||
self.revealed
|
||||
}
|
||||
|
||||
/// Whether the painted line masks the buffer.
|
||||
fn masked(&self) -> bool {
|
||||
self.password && !self.revealed
|
||||
}
|
||||
|
||||
/// Records `old_text` at `range` being replaced by `new_len` bytes.
|
||||
fn push_history(&mut self, range: Range<usize>, old_text: String, new_len: usize) {
|
||||
self.redo_stack.clear();
|
||||
@@ -746,6 +839,9 @@ impl InputState {
|
||||
|
||||
/// Copies the selected text to the clipboard.
|
||||
fn copy_selection(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
if self.password {
|
||||
return false;
|
||||
}
|
||||
let range = self.selection();
|
||||
if range.is_empty() {
|
||||
return false;
|
||||
@@ -764,9 +860,13 @@ impl InputState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shapes the buffer with the paint-time text style.
|
||||
fn shape_current_text(&self, window: &Window) -> gpui::ShapedLine {
|
||||
let text = self.text.clone();
|
||||
/// Shapes the masked or plain buffer with the paint-time text style.
|
||||
fn shape_current_text(&self, window: &Window) -> ShapedText {
|
||||
let (text, mask_map) = if self.masked() {
|
||||
masked_display(&self.text)
|
||||
} else {
|
||||
(self.text.clone(), None::<Vec<usize>>)
|
||||
};
|
||||
let text_style = window.text_style();
|
||||
|
||||
let font = Font {
|
||||
@@ -787,23 +887,27 @@ impl InputState {
|
||||
strikethrough: None,
|
||||
}];
|
||||
|
||||
window
|
||||
let line = window
|
||||
.text_system()
|
||||
.shape_line(text.into(), font_size, &runs, None)
|
||||
.shape_line(text.into(), font_size, &runs, None);
|
||||
|
||||
ShapedText { line, mask_map }
|
||||
}
|
||||
|
||||
/// Stores the paint geometry for hit-testing and the IME conversions.
|
||||
fn store_layout(
|
||||
&mut self,
|
||||
line: gpui::ShapedLine,
|
||||
mask_map: Option<Vec<usize>>,
|
||||
bounds: Bounds<Pixels>,
|
||||
padding: Pixels,
|
||||
right_padding: Pixels,
|
||||
line_height: Pixels,
|
||||
) -> (gpui::Point<Pixels>, Pixels) {
|
||||
let selection = self.selection();
|
||||
let caret_x = line.x_for_index(selection.end);
|
||||
let caret_x = line.x_for_index(display_index(&mask_map, selection.end));
|
||||
|
||||
let visible = (bounds.size.width - padding * 2.0).max(Pixels::ZERO);
|
||||
let visible = (bounds.size.width - padding - right_padding).max(Pixels::ZERO);
|
||||
let max_scroll = (line.width - visible).max(Pixels::ZERO);
|
||||
|
||||
if self.scroll_to_caret_pending {
|
||||
@@ -827,6 +931,7 @@ impl InputState {
|
||||
line,
|
||||
bounds,
|
||||
origin,
|
||||
mask_map,
|
||||
});
|
||||
|
||||
(origin, caret_x)
|
||||
@@ -851,7 +956,10 @@ impl InputState {
|
||||
return;
|
||||
};
|
||||
|
||||
let start_x = layout.line.x_for_index(selection.start);
|
||||
let start_x = layout
|
||||
.line
|
||||
.x_for_index(display_index(&layout.mask_map, selection.start));
|
||||
|
||||
let rect = Bounds::from_corners(
|
||||
point(origin.x + start_x.min(caret_x), bounds.top() + px(1.)),
|
||||
point(origin.x + start_x.max(caret_x), bounds.bottom() - px(1.)),
|
||||
@@ -865,6 +973,7 @@ impl InputState {
|
||||
&self,
|
||||
origin: gpui::Point<Pixels>,
|
||||
line_height: Pixels,
|
||||
right_padding: Pixels,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
@@ -872,10 +981,20 @@ impl InputState {
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
return;
|
||||
};
|
||||
layout
|
||||
.line
|
||||
.paint(origin, line_height, TextAlign::Left, None, window, cx)
|
||||
.ok();
|
||||
|
||||
let mask = Bounds::from_corners(
|
||||
point(layout.bounds.left(), layout.bounds.top()),
|
||||
point(
|
||||
layout.bounds.right() - right_padding,
|
||||
layout.bounds.bottom(),
|
||||
),
|
||||
);
|
||||
window.with_content_mask(Some(ContentMask { bounds: mask }), |window| {
|
||||
layout
|
||||
.line
|
||||
.paint(origin, line_height, TextAlign::Left, None, window, cx)
|
||||
.ok();
|
||||
});
|
||||
}
|
||||
|
||||
/// Paints the caret at the cursor position.
|
||||
@@ -899,9 +1018,9 @@ impl InputState {
|
||||
/// Paints the field contents; returns whether a drag-select is in progress.
|
||||
fn paint(
|
||||
&mut self,
|
||||
line: gpui::ShapedLine,
|
||||
text: ShapedText,
|
||||
bounds: Bounds<Pixels>,
|
||||
padding: Pixels,
|
||||
padding: FieldPadding,
|
||||
colors: ColorTokens,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -915,13 +1034,20 @@ impl InputState {
|
||||
.line_height
|
||||
.to_pixels(text_style.font_size, window.rem_size());
|
||||
|
||||
let (origin, caret_x) = self.store_layout(line, bounds, padding, line_height);
|
||||
let (origin, caret_x) = self.store_layout(
|
||||
text.line,
|
||||
text.mask_map,
|
||||
bounds,
|
||||
padding.left,
|
||||
padding.right,
|
||||
line_height,
|
||||
);
|
||||
|
||||
if focused && !self.selection().is_empty() {
|
||||
self.paint_selection(origin, caret_x, bounds, colors.selection, window);
|
||||
}
|
||||
|
||||
self.paint_text(origin, line_height, window, cx);
|
||||
self.paint_text(origin, line_height, padding.right, window, cx);
|
||||
|
||||
// The composition underline shows the in-progress IME text.
|
||||
if let Some(range) = self.marked_range.clone()
|
||||
@@ -951,10 +1077,10 @@ impl InputState {
|
||||
let Some(layout) = layout.as_ref() else {
|
||||
return 0;
|
||||
};
|
||||
layout
|
||||
.line
|
||||
.closest_index_for_x(x - layout.origin.x)
|
||||
.min(self.text.len())
|
||||
real_index(
|
||||
&layout.mask_map,
|
||||
layout.line.closest_index_for_x(x - layout.origin.x),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extends the selection to `x` while drag-selecting.
|
||||
@@ -977,8 +1103,14 @@ impl InputState {
|
||||
return;
|
||||
};
|
||||
|
||||
let start_x = layout.line.x_for_index(range.start);
|
||||
let end_x = layout.line.x_for_index(range.end);
|
||||
let start_x = layout
|
||||
.line
|
||||
.x_for_index(display_index(&layout.mask_map, range.start));
|
||||
|
||||
let end_x = layout
|
||||
.line
|
||||
.x_for_index(display_index(&layout.mask_map, range.end));
|
||||
|
||||
let thickness = px(1.0);
|
||||
let underline_y = origin.y + line_height - thickness;
|
||||
let rect = Bounds::from_corners(
|
||||
@@ -1186,8 +1318,7 @@ impl InputState {
|
||||
}
|
||||
|
||||
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Read-only refuses cut entirely, clipboard included.
|
||||
if self.read_only {
|
||||
if self.read_only || self.password {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1358,13 +1489,15 @@ impl EntityInputHandler for InputState {
|
||||
let layout = self.layout.borrow();
|
||||
let layout = layout.as_ref()?;
|
||||
|
||||
let start = layout
|
||||
.line
|
||||
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.start));
|
||||
let start = layout.line.x_for_index(display_index(
|
||||
&layout.mask_map,
|
||||
utf16_to_utf8_offset(&self.text, range_utf16.start),
|
||||
));
|
||||
|
||||
let end = layout
|
||||
.line
|
||||
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.end));
|
||||
let end = layout.line.x_for_index(display_index(
|
||||
&layout.mask_map,
|
||||
utf16_to_utf8_offset(&self.text, range_utf16.end),
|
||||
));
|
||||
|
||||
// `origin` is already an absolute window point.
|
||||
Some(Bounds::from_corners(
|
||||
@@ -1381,12 +1514,13 @@ impl EntityInputHandler for InputState {
|
||||
) -> Option<usize> {
|
||||
let layout = self.layout.borrow();
|
||||
let layout = layout.as_ref()?;
|
||||
Some(
|
||||
Some(real_index(
|
||||
&layout.mask_map,
|
||||
layout
|
||||
.line
|
||||
.closest_index_for_x(window_point.x - layout.origin.x)
|
||||
.min(self.text.len()),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn text_length_utf16(
|
||||
@@ -1535,6 +1669,19 @@ impl RenderOnce for Input {
|
||||
|
||||
let focused = focus_handle.is_focused(window);
|
||||
let is_empty = self.state.read(cx).text.is_empty();
|
||||
let is_password = self.state.read(cx).is_password();
|
||||
let revealed = self.state.read(cx).is_revealed();
|
||||
|
||||
let right_padding = if is_password {
|
||||
metrics.dense_padding_x + metrics.dense_icon_size
|
||||
} else {
|
||||
metrics.dense_padding_x
|
||||
};
|
||||
|
||||
let field_padding = FieldPadding {
|
||||
left: metrics.dense_padding_x,
|
||||
right: right_padding,
|
||||
};
|
||||
|
||||
let fallback_label = self
|
||||
.a11y_label
|
||||
@@ -1577,24 +1724,22 @@ impl RenderOnce for Input {
|
||||
move |bounds, window, cx| {
|
||||
// The hitbox backs the I-beam hover cursor.
|
||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||
let line = state.read(cx).shape_current_text(window);
|
||||
(line, hitbox)
|
||||
let text = state.read(cx).shape_current_text(window);
|
||||
(text, hitbox)
|
||||
}
|
||||
},
|
||||
{
|
||||
let state = self.state.clone();
|
||||
let focus = focus_handle.clone();
|
||||
let padding = metrics.padding_x / 2.0;
|
||||
let disabled = self.disabled;
|
||||
|
||||
// `line` and the hitbox come from the prepaint closure above.
|
||||
move |bounds, (line, hitbox), window, cx| {
|
||||
move |bounds, (text, hitbox), window, cx| {
|
||||
if !disabled {
|
||||
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
||||
}
|
||||
|
||||
let dragging = state.update(cx, |state, cx| {
|
||||
state.paint(line, bounds, padding, colors, window, cx)
|
||||
state.paint(text, bounds, field_padding, colors, window, cx)
|
||||
});
|
||||
|
||||
if disabled {
|
||||
@@ -1635,6 +1780,40 @@ impl RenderOnce for Input {
|
||||
.absolute()
|
||||
.inset_0(),
|
||||
)
|
||||
.when(is_password && !self.disabled, |input| {
|
||||
input.child(
|
||||
div()
|
||||
.id((self.id.clone(), "input-reveal"))
|
||||
.debug_selector(|| "input-reveal".to_string())
|
||||
.absolute()
|
||||
.right(metrics.dense_padding_x)
|
||||
.top_0p5()
|
||||
.bottom_0p5()
|
||||
.flex()
|
||||
.items_center()
|
||||
.cursor_pointer()
|
||||
.rounded(theme.radius.sm)
|
||||
.hover(|style| style.bg(colors.muted))
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let state = self.state.clone();
|
||||
move |_, _window, cx| {
|
||||
cx.stop_propagation();
|
||||
state.update(cx, |state, cx| {
|
||||
state.set_revealed(!state.is_revealed(), cx)
|
||||
});
|
||||
}
|
||||
})
|
||||
.child(
|
||||
icon(if revealed {
|
||||
IconRole::EyeOff
|
||||
} else {
|
||||
IconRole::Eye
|
||||
})
|
||||
.size(metrics.icon_size)
|
||||
.color(colors.muted_foreground),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(!self.disabled, |input| {
|
||||
input
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
|
||||
Reference in New Issue
Block a user