add password field
This commit is contained in:
+219
-40
@@ -7,13 +7,14 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, ClipboardItem, Context, CursorStyle, DispatchPhase, ElementId,
|
App, AppContext as _, Bounds, ClipboardItem, ContentMask, Context, CursorStyle, DispatchPhase,
|
||||||
Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior, InteractiveElement as _,
|
ElementId, Entity, EntityInputHandler, EventEmitter, Font, HitboxBehavior,
|
||||||
IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
|
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||||
MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta, ScrollEvent,
|
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollDelta,
|
||||||
SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, TextAlign, TextRun,
|
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
|
||||||
UTF16Selection, Window, actions, canvas, div, fill, point, px,
|
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 gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
|
||||||
|
|
||||||
use crate::common::{Sizable, h_flex};
|
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;
|
const HORIZONTAL_SCROLL_LINES_HEIGHT: f32 = 20.0;
|
||||||
/// Maximum undo entries kept; the oldest drops off the front.
|
/// Maximum undo entries kept; the oldest drops off the front.
|
||||||
const MAX_HISTORY: usize = 100;
|
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`.
|
/// Significant changes in an [`InputState`], observable via `cx.subscribe`.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -274,12 +279,60 @@ where
|
|||||||
cx.bind_keys(bindings.into().unwrap_or_default().into_bindings());
|
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.
|
/// Input layout information for hit testing and caret positioning.
|
||||||
struct InputLayout {
|
struct InputLayout {
|
||||||
line: gpui::ShapedLine,
|
line: gpui::ShapedLine,
|
||||||
bounds: Bounds<Pixels>,
|
bounds: Bounds<Pixels>,
|
||||||
/// Where the text was painted inside `bounds` (padding minus scroll).
|
/// Where the text was painted inside `bounds` (padding minus scroll).
|
||||||
origin: gpui::Point<Pixels>,
|
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.
|
/// Handler for key down events.
|
||||||
@@ -433,6 +486,10 @@ pub struct InputState {
|
|||||||
group_interval: Duration,
|
group_interval: Duration,
|
||||||
/// When set, user edits are refused, focus, movement, selection and copy stay.
|
/// When set, user edits are refused, focus, movement, selection and copy stay.
|
||||||
read_only: bool,
|
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.
|
/// Horizontal scroll of the content inside the field, clamped at paint.
|
||||||
scroll_offset: Pixels,
|
scroll_offset: Pixels,
|
||||||
/// Set by every caret-affecting change.
|
/// Set by every caret-affecting change.
|
||||||
@@ -473,6 +530,8 @@ impl InputState {
|
|||||||
redo_stack: Vec::new(),
|
redo_stack: Vec::new(),
|
||||||
group_interval: DEFAULT_GROUP_INTERVAL,
|
group_interval: DEFAULT_GROUP_INTERVAL,
|
||||||
read_only: false,
|
read_only: false,
|
||||||
|
password: false,
|
||||||
|
revealed: false,
|
||||||
scroll_offset: Pixels::ZERO,
|
scroll_offset: Pixels::ZERO,
|
||||||
scroll_to_caret_pending: true,
|
scroll_to_caret_pending: true,
|
||||||
caret_blink: caret_blink.clone(),
|
caret_blink: caret_blink.clone(),
|
||||||
@@ -567,6 +626,40 @@ impl InputState {
|
|||||||
self.read_only
|
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.
|
/// 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) {
|
fn push_history(&mut self, range: Range<usize>, old_text: String, new_len: usize) {
|
||||||
self.redo_stack.clear();
|
self.redo_stack.clear();
|
||||||
@@ -746,6 +839,9 @@ impl InputState {
|
|||||||
|
|
||||||
/// Copies the selected text to the clipboard.
|
/// Copies the selected text to the clipboard.
|
||||||
fn copy_selection(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
|
fn copy_selection(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||||
|
if self.password {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let range = self.selection();
|
let range = self.selection();
|
||||||
if range.is_empty() {
|
if range.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
@@ -764,9 +860,13 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shapes the buffer with the paint-time text style.
|
/// Shapes the masked or plain buffer with the paint-time text style.
|
||||||
fn shape_current_text(&self, window: &Window) -> gpui::ShapedLine {
|
fn shape_current_text(&self, window: &Window) -> ShapedText {
|
||||||
let text = self.text.clone();
|
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 text_style = window.text_style();
|
||||||
|
|
||||||
let font = Font {
|
let font = Font {
|
||||||
@@ -787,23 +887,27 @@ impl InputState {
|
|||||||
strikethrough: None,
|
strikethrough: None,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
window
|
let line = window
|
||||||
.text_system()
|
.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.
|
/// Stores the paint geometry for hit-testing and the IME conversions.
|
||||||
fn store_layout(
|
fn store_layout(
|
||||||
&mut self,
|
&mut self,
|
||||||
line: gpui::ShapedLine,
|
line: gpui::ShapedLine,
|
||||||
|
mask_map: Option<Vec<usize>>,
|
||||||
bounds: Bounds<Pixels>,
|
bounds: Bounds<Pixels>,
|
||||||
padding: Pixels,
|
padding: Pixels,
|
||||||
|
right_padding: Pixels,
|
||||||
line_height: Pixels,
|
line_height: Pixels,
|
||||||
) -> (gpui::Point<Pixels>, Pixels) {
|
) -> (gpui::Point<Pixels>, Pixels) {
|
||||||
let selection = self.selection();
|
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);
|
let max_scroll = (line.width - visible).max(Pixels::ZERO);
|
||||||
|
|
||||||
if self.scroll_to_caret_pending {
|
if self.scroll_to_caret_pending {
|
||||||
@@ -827,6 +931,7 @@ impl InputState {
|
|||||||
line,
|
line,
|
||||||
bounds,
|
bounds,
|
||||||
origin,
|
origin,
|
||||||
|
mask_map,
|
||||||
});
|
});
|
||||||
|
|
||||||
(origin, caret_x)
|
(origin, caret_x)
|
||||||
@@ -851,7 +956,10 @@ impl InputState {
|
|||||||
return;
|
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(
|
let rect = Bounds::from_corners(
|
||||||
point(origin.x + start_x.min(caret_x), bounds.top() + px(1.)),
|
point(origin.x + start_x.min(caret_x), bounds.top() + px(1.)),
|
||||||
point(origin.x + start_x.max(caret_x), bounds.bottom() - px(1.)),
|
point(origin.x + start_x.max(caret_x), bounds.bottom() - px(1.)),
|
||||||
@@ -865,6 +973,7 @@ impl InputState {
|
|||||||
&self,
|
&self,
|
||||||
origin: gpui::Point<Pixels>,
|
origin: gpui::Point<Pixels>,
|
||||||
line_height: Pixels,
|
line_height: Pixels,
|
||||||
|
right_padding: Pixels,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
@@ -872,10 +981,20 @@ impl InputState {
|
|||||||
let Some(layout) = layout.as_ref() else {
|
let Some(layout) = layout.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
layout
|
||||||
.line
|
.line
|
||||||
.paint(origin, line_height, TextAlign::Left, None, window, cx)
|
.paint(origin, line_height, TextAlign::Left, None, window, cx)
|
||||||
.ok();
|
.ok();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paints the caret at the cursor position.
|
/// 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.
|
/// Paints the field contents; returns whether a drag-select is in progress.
|
||||||
fn paint(
|
fn paint(
|
||||||
&mut self,
|
&mut self,
|
||||||
line: gpui::ShapedLine,
|
text: ShapedText,
|
||||||
bounds: Bounds<Pixels>,
|
bounds: Bounds<Pixels>,
|
||||||
padding: Pixels,
|
padding: FieldPadding,
|
||||||
colors: ColorTokens,
|
colors: ColorTokens,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
@@ -915,13 +1034,20 @@ impl InputState {
|
|||||||
.line_height
|
.line_height
|
||||||
.to_pixels(text_style.font_size, window.rem_size());
|
.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() {
|
if focused && !self.selection().is_empty() {
|
||||||
self.paint_selection(origin, caret_x, bounds, colors.selection, window);
|
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.
|
// The composition underline shows the in-progress IME text.
|
||||||
if let Some(range) = self.marked_range.clone()
|
if let Some(range) = self.marked_range.clone()
|
||||||
@@ -951,10 +1077,10 @@ impl InputState {
|
|||||||
let Some(layout) = layout.as_ref() else {
|
let Some(layout) = layout.as_ref() else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
layout
|
real_index(
|
||||||
.line
|
&layout.mask_map,
|
||||||
.closest_index_for_x(x - layout.origin.x)
|
layout.line.closest_index_for_x(x - layout.origin.x),
|
||||||
.min(self.text.len())
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extends the selection to `x` while drag-selecting.
|
/// Extends the selection to `x` while drag-selecting.
|
||||||
@@ -977,8 +1103,14 @@ impl InputState {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let start_x = layout.line.x_for_index(range.start);
|
let start_x = layout
|
||||||
let end_x = layout.line.x_for_index(range.end);
|
.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 thickness = px(1.0);
|
||||||
let underline_y = origin.y + line_height - thickness;
|
let underline_y = origin.y + line_height - thickness;
|
||||||
let rect = Bounds::from_corners(
|
let rect = Bounds::from_corners(
|
||||||
@@ -1186,8 +1318,7 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
// Read-only refuses cut entirely, clipboard included.
|
if self.read_only || self.password {
|
||||||
if self.read_only {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1358,13 +1489,15 @@ impl EntityInputHandler for InputState {
|
|||||||
let layout = self.layout.borrow();
|
let layout = self.layout.borrow();
|
||||||
let layout = layout.as_ref()?;
|
let layout = layout.as_ref()?;
|
||||||
|
|
||||||
let start = layout
|
let start = layout.line.x_for_index(display_index(
|
||||||
.line
|
&layout.mask_map,
|
||||||
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.start));
|
utf16_to_utf8_offset(&self.text, range_utf16.start),
|
||||||
|
));
|
||||||
|
|
||||||
let end = layout
|
let end = layout.line.x_for_index(display_index(
|
||||||
.line
|
&layout.mask_map,
|
||||||
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.end));
|
utf16_to_utf8_offset(&self.text, range_utf16.end),
|
||||||
|
));
|
||||||
|
|
||||||
// `origin` is already an absolute window point.
|
// `origin` is already an absolute window point.
|
||||||
Some(Bounds::from_corners(
|
Some(Bounds::from_corners(
|
||||||
@@ -1381,12 +1514,13 @@ impl EntityInputHandler for InputState {
|
|||||||
) -> Option<usize> {
|
) -> Option<usize> {
|
||||||
let layout = self.layout.borrow();
|
let layout = self.layout.borrow();
|
||||||
let layout = layout.as_ref()?;
|
let layout = layout.as_ref()?;
|
||||||
Some(
|
Some(real_index(
|
||||||
|
&layout.mask_map,
|
||||||
layout
|
layout
|
||||||
.line
|
.line
|
||||||
.closest_index_for_x(window_point.x - layout.origin.x)
|
.closest_index_for_x(window_point.x - layout.origin.x)
|
||||||
.min(self.text.len()),
|
.min(self.text.len()),
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_length_utf16(
|
fn text_length_utf16(
|
||||||
@@ -1535,6 +1669,19 @@ impl RenderOnce for Input {
|
|||||||
|
|
||||||
let focused = focus_handle.is_focused(window);
|
let focused = focus_handle.is_focused(window);
|
||||||
let is_empty = self.state.read(cx).text.is_empty();
|
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
|
let fallback_label = self
|
||||||
.a11y_label
|
.a11y_label
|
||||||
@@ -1577,24 +1724,22 @@ impl RenderOnce for Input {
|
|||||||
move |bounds, window, cx| {
|
move |bounds, window, cx| {
|
||||||
// The hitbox backs the I-beam hover cursor.
|
// The hitbox backs the I-beam hover cursor.
|
||||||
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
|
||||||
let line = state.read(cx).shape_current_text(window);
|
let text = state.read(cx).shape_current_text(window);
|
||||||
(line, hitbox)
|
(text, hitbox)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
let focus = focus_handle.clone();
|
let focus = focus_handle.clone();
|
||||||
let padding = metrics.padding_x / 2.0;
|
|
||||||
let disabled = self.disabled;
|
let disabled = self.disabled;
|
||||||
|
|
||||||
// `line` and the hitbox come from the prepaint closure above.
|
move |bounds, (text, hitbox), window, cx| {
|
||||||
move |bounds, (line, hitbox), window, cx| {
|
|
||||||
if !disabled {
|
if !disabled {
|
||||||
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
window.set_cursor_style(CursorStyle::IBeam, &hitbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dragging = state.update(cx, |state, cx| {
|
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 {
|
if disabled {
|
||||||
@@ -1635,6 +1780,40 @@ impl RenderOnce for Input {
|
|||||||
.absolute()
|
.absolute()
|
||||||
.inset_0(),
|
.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| {
|
.when(!self.disabled, |input| {
|
||||||
input
|
input
|
||||||
.on_mouse_down(MouseButton::Left, {
|
.on_mouse_down(MouseButton::Left, {
|
||||||
|
|||||||
@@ -590,3 +590,146 @@ fn enter_emits_submitted() {
|
|||||||
// The content is left in place for the subscriber to read or clear.
|
// The content is left in place for the subscriber to read or clear.
|
||||||
assert_eq!(value(&cx, &state), "draft");
|
assert_eq!(value(&cx, &state), "draft");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn password_window(initial: &str) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||||
|
let (cx, state, visual) = input_window(initial, false);
|
||||||
|
cx.update(|cx| {
|
||||||
|
state.update(cx, |state, cx| state.set_password(true, cx));
|
||||||
|
});
|
||||||
|
(cx, state, visual)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_fields_keep_the_real_buffer_while_masked() {
|
||||||
|
let (cx, state, mut visual) = password_window("");
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.simulate_keystrokes("s e c r e t");
|
||||||
|
// The buffer holds the real secret; masking is paint-only.
|
||||||
|
assert_eq!(value(&cx, &state), "secret");
|
||||||
|
|
||||||
|
// Editing still works through the mask.
|
||||||
|
visual.simulate_keystrokes("backspace");
|
||||||
|
assert_eq!(value(&cx, &state), "secre");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_fields_refuse_copy_and_cut() {
|
||||||
|
let (cx, state, mut visual) = password_window("secret");
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
|
||||||
|
// Select everything and try to copy.
|
||||||
|
visual.simulate_keystrokes("cmd-a");
|
||||||
|
visual.simulate_keystrokes("cmd-c");
|
||||||
|
let clipboard = cx.update(|cx| {
|
||||||
|
cx.read_from_clipboard()
|
||||||
|
.and_then(|item| item.text())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
assert_eq!(clipboard, "");
|
||||||
|
|
||||||
|
// Cut neither leaks the secret nor deletes it.
|
||||||
|
visual.simulate_keystrokes("cmd-x");
|
||||||
|
assert_eq!(value(&cx, &state), "secret");
|
||||||
|
let clipboard = cx.update(|cx| {
|
||||||
|
cx.read_from_clipboard()
|
||||||
|
.and_then(|item| item.text())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
|
assert_eq!(clipboard, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_fields_still_allow_paste() {
|
||||||
|
let (cx, state, mut visual) = password_window("");
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
cx.update(|cx| {
|
||||||
|
cx.write_to_clipboard(gpui::ClipboardItem::new_string("hunter2".to_string()));
|
||||||
|
});
|
||||||
|
visual.simulate_keystrokes("cmd-v");
|
||||||
|
assert_eq!(value(&cx, &state), "hunter2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicks_in_password_fields_map_to_real_offsets() {
|
||||||
|
let (cx, state, mut visual) = password_window("abcdefgh");
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
|
||||||
|
// Clicking at the text origin moves the caret to the start, so typing
|
||||||
|
// lands there: the masked display maps clicks back onto real offsets.
|
||||||
|
click(&mut visual, point(px(12.), px(15.)));
|
||||||
|
visual.simulate_keystrokes("x");
|
||||||
|
assert_eq!(value(&cx, &state), "xabcdefgh");
|
||||||
|
|
||||||
|
// Clicking past the last bullet clamps to the end.
|
||||||
|
click(&mut visual, point(px(500.), px(15.)));
|
||||||
|
visual.simulate_keystrokes("y");
|
||||||
|
assert_eq!(value(&cx, &state), "xabcdefghy");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||||
|
visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
|
||||||
|
visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RevealHarness {
|
||||||
|
state: Entity<InputState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for RevealHarness {
|
||||||
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut gpui::Context<Self>,
|
||||||
|
) -> impl gpui::IntoElement {
|
||||||
|
div()
|
||||||
|
.w(px(400.))
|
||||||
|
.p_4()
|
||||||
|
.child(Input::new("reveal-input", self.state.clone()).placeholder("Password"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reveal_window() -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
bind_input_keys(cx, InputBindings::default());
|
||||||
|
});
|
||||||
|
let state = cx.update(|cx| InputState::new_with_text("", cx));
|
||||||
|
cx.update(|cx| {
|
||||||
|
state.update(cx, |state, cx| state.set_password(true, cx));
|
||||||
|
});
|
||||||
|
let state_for_window = state.clone();
|
||||||
|
let window = cx.add_window(move |_, _| RevealHarness {
|
||||||
|
state: state_for_window,
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
(cx, state, visual)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking the reveal toggle flips masking on and off, so apps can reveal
|
||||||
|
/// a secret without a custom control.
|
||||||
|
#[test]
|
||||||
|
fn the_reveal_toggle_flips_masking() {
|
||||||
|
let (cx, state, mut visual) = reveal_window();
|
||||||
|
|
||||||
|
let bounds = visual
|
||||||
|
.debug_bounds("input-reveal")
|
||||||
|
.expect("reveal toggle missing");
|
||||||
|
let center = point(
|
||||||
|
bounds.left() + bounds.size.width / 2.0,
|
||||||
|
bounds.top() + bounds.size.height / 2.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Masked: clicking reveals.
|
||||||
|
click(&mut visual, center);
|
||||||
|
let revealed = cx.update(|cx| state.read(cx).is_revealed());
|
||||||
|
assert!(revealed, "the toggle should reveal the field");
|
||||||
|
|
||||||
|
// Revealed: clicking masks again.
|
||||||
|
click(&mut visual, center);
|
||||||
|
let revealed = cx.update(|cx| state.read(cx).is_revealed());
|
||||||
|
assert!(!revealed, "the toggle should mask the field again");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||||
|
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
|
||||||
|
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
|
||||||
|
<line x1="2" x2="22" y1="2" y2="22" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
+18
-5
@@ -1,10 +1,5 @@
|
|||||||
//! System icons for GPUI: components and apps reference semantic roles,
|
//! System icons for GPUI: components and apps reference semantic roles,
|
||||||
//! and the crate resolves each role to a glyph for the current platform.
|
//! and the crate resolves each role to a glyph for the current platform.
|
||||||
//!
|
|
||||||
//! Resolver cascade (plan §4.4): Segoe Fluent Icons (Windows), freedesktop
|
|
||||||
//! icon themes (Linux/Omarchy), and the bundled Lucide subset everywhere
|
|
||||||
//! else — macOS rides the Lucide fallback until SF Symbols shows it matters.
|
|
||||||
//! A missing glyph falls back silently; nothing ever fails to paint.
|
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -32,6 +27,8 @@ pub enum IconRole {
|
|||||||
Folder,
|
Folder,
|
||||||
Warning,
|
Warning,
|
||||||
Info,
|
Info,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IconRole {
|
impl IconRole {
|
||||||
@@ -49,6 +46,8 @@ impl IconRole {
|
|||||||
Self::Folder,
|
Self::Folder,
|
||||||
Self::Warning,
|
Self::Warning,
|
||||||
Self::Info,
|
Self::Info,
|
||||||
|
Self::Eye,
|
||||||
|
Self::EyeOff,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +65,8 @@ impl IconRole {
|
|||||||
Self::Folder => "icons/folder.svg",
|
Self::Folder => "icons/folder.svg",
|
||||||
Self::Warning => "icons/triangle-alert.svg",
|
Self::Warning => "icons/triangle-alert.svg",
|
||||||
Self::Info => "icons/info.svg",
|
Self::Info => "icons/info.svg",
|
||||||
|
Self::Eye => "icons/eye.svg",
|
||||||
|
Self::EyeOff => "icons/eye-off.svg",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +84,8 @@ impl IconRole {
|
|||||||
Self::Folder => "folder",
|
Self::Folder => "folder",
|
||||||
Self::Warning => "warning",
|
Self::Warning => "warning",
|
||||||
Self::Info => "info",
|
Self::Info => "info",
|
||||||
|
Self::Eye => "eye",
|
||||||
|
Self::EyeOff => "eye-off",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +136,14 @@ impl IconRole {
|
|||||||
Self::Folder => &["folder-symbolic", "folder"],
|
Self::Folder => &["folder-symbolic", "folder"],
|
||||||
Self::Warning => &["dialog-warning-symbolic", "dialog-warning", "warning"],
|
Self::Warning => &["dialog-warning-symbolic", "dialog-warning", "warning"],
|
||||||
Self::Info => &["dialog-information-symbolic", "dialog-information", "info"],
|
Self::Info => &["dialog-information-symbolic", "dialog-information", "info"],
|
||||||
|
Self::Eye => &["view-visible-symbolic", "view-visible", "visibility", "eye"],
|
||||||
|
Self::EyeOff => &[
|
||||||
|
"view-conceal-symbolic",
|
||||||
|
"view-hide-symbolic",
|
||||||
|
"view-hide",
|
||||||
|
"visibility-off",
|
||||||
|
"eye-off",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,6 +252,8 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> {
|
|||||||
"icons/folder.svg" => Some(include_bytes!("../assets/folder.svg")),
|
"icons/folder.svg" => Some(include_bytes!("../assets/folder.svg")),
|
||||||
"icons/triangle-alert.svg" => Some(include_bytes!("../assets/triangle-alert.svg")),
|
"icons/triangle-alert.svg" => Some(include_bytes!("../assets/triangle-alert.svg")),
|
||||||
"icons/info.svg" => Some(include_bytes!("../assets/info.svg")),
|
"icons/info.svg" => Some(include_bytes!("../assets/info.svg")),
|
||||||
|
"icons/eye.svg" => Some(include_bytes!("../assets/eye.svg")),
|
||||||
|
"icons/eye-off.svg" => Some(include_bytes!("../assets/eye-off.svg")),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub(crate) fn glyph(role: IconRole) -> Option<char> {
|
|||||||
IconRole::Folder => 0xE8B7, // Folder
|
IconRole::Folder => 0xE8B7, // Folder
|
||||||
IconRole::Warning => 0xE7BA, // Warning
|
IconRole::Warning => 0xE7BA, // Warning
|
||||||
IconRole::Info => 0xE946, // Info
|
IconRole::Info => 0xE946, // Info
|
||||||
|
IconRole::Eye => 0xE7B3, // RedEye
|
||||||
|
IconRole::EyeOff => 0xED1A, // Hide
|
||||||
};
|
};
|
||||||
char::from_u32(codepoint)
|
char::from_u32(codepoint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,11 +292,7 @@ pub struct SizeTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SizeTable {
|
impl SizeTable {
|
||||||
/// Every metric derives from the root font size; components fetch, and
|
|
||||||
/// never scale these themselves.
|
|
||||||
pub fn with_root_font_size(rem_size: Pixels) -> Self {
|
pub fn with_root_font_size(rem_size: Pixels) -> Self {
|
||||||
// One row per size class: control height, padding, label text, icon,
|
|
||||||
// avatar, strip band, and plate inset — all as root-size multiples.
|
|
||||||
struct ClassFactors {
|
struct ClassFactors {
|
||||||
height: f32,
|
height: f32,
|
||||||
padding: f32,
|
padding: f32,
|
||||||
@@ -306,6 +302,7 @@ impl SizeTable {
|
|||||||
strip: f32,
|
strip: f32,
|
||||||
inset: f32,
|
inset: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
let small = ClassFactors {
|
let small = ClassFactors {
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
padding: 0.75,
|
padding: 0.75,
|
||||||
@@ -315,6 +312,7 @@ impl SizeTable {
|
|||||||
strip: 1.5,
|
strip: 1.5,
|
||||||
inset: 0.125,
|
inset: 0.125,
|
||||||
};
|
};
|
||||||
|
|
||||||
let medium = ClassFactors {
|
let medium = ClassFactors {
|
||||||
height: 1.75,
|
height: 1.75,
|
||||||
padding: 1.0,
|
padding: 1.0,
|
||||||
@@ -324,6 +322,7 @@ impl SizeTable {
|
|||||||
strip: 2.0,
|
strip: 2.0,
|
||||||
inset: 0.25,
|
inset: 0.25,
|
||||||
};
|
};
|
||||||
|
|
||||||
let large = ClassFactors {
|
let large = ClassFactors {
|
||||||
height: 2.25,
|
height: 2.25,
|
||||||
padding: 1.25,
|
padding: 1.25,
|
||||||
@@ -333,6 +332,7 @@ impl SizeTable {
|
|||||||
strip: 3.0,
|
strip: 3.0,
|
||||||
inset: 0.375,
|
inset: 0.375,
|
||||||
};
|
};
|
||||||
|
|
||||||
let metrics = |f: &ClassFactors| ControlMetrics {
|
let metrics = |f: &ClassFactors| ControlMetrics {
|
||||||
control_height: rem_size * f.height,
|
control_height: rem_size * f.height,
|
||||||
input_height: rem_size * (f.height + 0.125),
|
input_height: rem_size * (f.height + 0.125),
|
||||||
@@ -353,8 +353,7 @@ impl SizeTable {
|
|||||||
medium: metrics(&medium),
|
medium: metrics(&medium),
|
||||||
large: metrics(&large),
|
large: metrics(&large),
|
||||||
};
|
};
|
||||||
// Dense text stops growing with the large class, and small's is
|
|
||||||
// one step below its label.
|
|
||||||
table.small.caption_size = table.small.text_size * 0.85;
|
table.small.caption_size = table.small.text_size * 0.85;
|
||||||
table.large.caption_size = table.medium.text_size;
|
table.large.caption_size = table.medium.text_size;
|
||||||
table
|
table
|
||||||
|
|||||||
+1
-1
@@ -265,7 +265,7 @@ Wave M1 — unblocks the port; each through the review gate:
|
|||||||
| M3 | core | Segmented control ✅ Landed (2026-11-01) | All/Popular/Recent explore filters | bezel's toggle-group pill; single-select only, multiple waits. Shipped as a controlled single-select (`Segmented::new(id, selected)` + `options((value, label))` + `on_change(T)` — exactly one segment always active); muted pill track, the selected segment on a raised `surface` plate with border; left/right/home/end move and activate when the strip has focus (no wrap), `RadioGroup`/`RadioButton` roles with `aria_toggled`; renders hugging via a wrapper so it works in block and flex parents alike. Plain `segmented` feature, no deps. **Follow-ups (2026-11-01):** the selected plate dropped its border for a small raised shadow — a third `Elevations.control` class, profiled per platform like popover/dialog (D24); `Size::Small` became the default and medium/large resolve through the size table — taller tracks (`control_height × 1.15 / 1.35`), a scaled track inset (`0.125 / 0.25 / 0.375 rem`) and segment padding (`0.5× / 0.75× / 0.75×` of `padding_x`), with the large label staying at the medium size; options take optional leading icons (`SegmentedOption::new(value, label).icon(role)`, tuples still accepted through `From`); and the selected plate is now a measured, absolutely-positioned layer that springs between segments (per-segment measure canvases feed keyed geometry; the plate springs on `left` and `width` separately, Switch-style — mounts at its target so the first frame never animates, retargets mid-flight, and snaps under reduce-motion). **Follow-up 2 (2026-11-01):** the track inset also scales per size (`0.125 / 0.25 / 0.375 rem`), and a settled-plate regression test guards the plate's existence and slot (`debug_bounds("segmented-plate")`) |
|
| M3 | core | Segmented control ✅ Landed (2026-11-01) | All/Popular/Recent explore filters | bezel's toggle-group pill; single-select only, multiple waits. Shipped as a controlled single-select (`Segmented::new(id, selected)` + `options((value, label))` + `on_change(T)` — exactly one segment always active); muted pill track, the selected segment on a raised `surface` plate with border; left/right/home/end move and activate when the strip has focus (no wrap), `RadioGroup`/`RadioButton` roles with `aria_toggled`; renders hugging via a wrapper so it works in block and flex parents alike. Plain `segmented` feature, no deps. **Follow-ups (2026-11-01):** the selected plate dropped its border for a small raised shadow — a third `Elevations.control` class, profiled per platform like popover/dialog (D24); `Size::Small` became the default and medium/large resolve through the size table — taller tracks (`control_height × 1.15 / 1.35`), a scaled track inset (`0.125 / 0.25 / 0.375 rem`) and segment padding (`0.5× / 0.75× / 0.75×` of `padding_x`), with the large label staying at the medium size; options take optional leading icons (`SegmentedOption::new(value, label).icon(role)`, tuples still accepted through `From`); and the selected plate is now a measured, absolutely-positioned layer that springs between segments (per-segment measure canvases feed keyed geometry; the plate springs on `left` and `width` separately, Switch-style — mounts at its target so the first frame never animates, retargets mid-flight, and snaps under reduce-motion). **Follow-up 2 (2026-11-01):** the track inset also scales per size (`0.125 / 0.25 / 0.375 rem`), and a settled-plate regression test guards the plate's existence and slot (`debug_bounds("segmented-plate")`) |
|
||||||
| M4 | core | Context menu ✅ Landed (2026-11-01) | repo rows, file-tree rows, tab bar | gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Shipped as a self-contained `ContextMenu::new(id).target(el).items(...)` — open state and pointer position are namespaced keyed state, right-click opens at the pointer, left clicks pass through to the target untouched, and any click outside, escape, or activation closes. Composes `Menu`, which gained `trigger_button(MouseButton)` and `open_at(Point)` for pointer-anchored opening (outside-click dismissal now closes on any button). Plain `context_menu` feature, depends on `menu` |
|
| M4 | core | Context menu ✅ Landed (2026-11-01) | repo rows, file-tree rows, tab bar | gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Shipped as a self-contained `ContextMenu::new(id).target(el).items(...)` — open state and pointer position are namespaced keyed state, right-click opens at the pointer, left clicks pass through to the target untouched, and any click outside, escape, or activation closes. Composes `Menu`, which gained `trigger_button(MouseButton)` and `open_at(Point)` for pointer-anchored opening (outside-click dismissal now closes on any button). Plain `context_menu` feature, depends on `menu` |
|
||||||
| M5 | core | Empty state ✅ Landed (2026-11-01) | inbox, issues, search misses | bezel: icon + headline + one line of hint. Shipped as `EmptyState::new(headline)` with optional `.icon(role)`, `.hint(text)`, and `.action(element)` — no id (nothing interactive), no size classes (content, always medium metrics); the illustration is the component's own `icon_size × 2` decoration, muted like the hint, `line_height 1.3` on the headline, everything centered. Plain `empty_state` feature, no deps |
|
| M5 | core | Empty state ✅ Landed (2026-11-01) | inbox, issues, search misses | bezel: icon + headline + one line of hint. Shipped as `EmptyState::new(headline)` with optional `.icon(role)`, `.hint(text)`, and `.action(element)` — no id (nothing interactive), no size classes (content, always medium metrics); the illustration is the component's own `icon_size × 2` decoration, muted like the hint, `line_height 1.3` on the headline, everything centered. Plain `empty_state` feature, no deps |
|
||||||
| M6 | input | Password input | identity unlock + `nsec` import passphrase | lifts §6.2's "password masking on first need" — the need arrived |
|
| M6 | core | Password input ✅ Landed (2026-11-01) | identity unlock + `nsec` import passphrase | Lifts §6.2's "password masking on first need" — the need arrived. `InputState::set_password(bool)` masks the painted line one bullet per grapheme (paint-only: the buffer, undo, and caret all stay real; a mask map of grapheme byte offsets converts between the buffer and the painted display for hit-testing, the caret, selection, the IME underline, and `bounds_for_range`). Masked fields refuse copy and cut so the secret never reaches the clipboard; paste, editing, and reveal stay available. No element builder — the flag is state, like read-only. **Follow-up (2026-11-01):** a built-in reveal toggle — an eye button (`IconRole::Eye`/`EyeOff`, native glyphs per platform) pinned inside the field's right edge, shown on every password field. The state now tracks the field kind (`set_password`/`is_password`) and its visibility (`set_revealed`/`is_revealed`) separately, so toggling reveal never changes the field's kind; the paint reserves the button's corner (`right_padding` threaded through `paint`/`store_layout`, with a content mask so text never runs under the icon) |
|
||||||
| M7 | input | Multiline textarea | discussion composer, patch + commit messages | bezel: fixed rows or min/max auto-grow; gpuikit: rows on InputState. Reuses Input's buffer; D12's editor crate stays separate |
|
| M7 | input | Multiline textarea | discussion composer, patch + commit messages | bezel: fixed rows or min/max auto-grow; gpuikit: rows on InputState. Reuses Input's buffer; D12's editor crate stays separate |
|
||||||
| M8 | core | Avatar image | profile pictures in rows and panel tabs | lifts the deferred image slot: gpui `img` + initials fallback (signed adds its pixel-art fallback app-side) |
|
| M8 | core | Avatar image | profile pictures in rows and panel tabs | lifts the deferred image slot: gpui `img` + initials fallback (signed adds its pixel-art fallback app-side) |
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ struct Gallery {
|
|||||||
toggle_pressed: bool,
|
toggle_pressed: bool,
|
||||||
volume: f32,
|
volume: f32,
|
||||||
input_state: Entity<InputState>,
|
input_state: Entity<InputState>,
|
||||||
|
password_state: Entity<InputState>,
|
||||||
popover_open: bool,
|
popover_open: bool,
|
||||||
menu_open: bool,
|
menu_open: bool,
|
||||||
strategy: Option<Strategy>,
|
strategy: Option<Strategy>,
|
||||||
@@ -350,6 +351,10 @@ impl Render for Gallery {
|
|||||||
Input::new("gallery-input", self.input_state.clone())
|
Input::new("gallery-input", self.input_state.clone())
|
||||||
.placeholder("Type here…"),
|
.placeholder("Type here…"),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
Input::new("gallery-password", self.password_state.clone())
|
||||||
|
.placeholder("Password"),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
@@ -1446,6 +1451,8 @@ fn main() {
|
|||||||
application().with_assets(IconAssets).run(|cx: &mut App| {
|
application().with_assets(IconAssets).run(|cx: &mut App| {
|
||||||
gpui_native_core::init(cx);
|
gpui_native_core::init(cx);
|
||||||
let input_state = InputState::new(cx);
|
let input_state = InputState::new(cx);
|
||||||
|
let password_state = InputState::new(cx);
|
||||||
|
password_state.update(cx, |state, cx| state.set_password(true, cx));
|
||||||
let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
||||||
if let Err(err) = cx.open_window(
|
if let Err(err) = cx.open_window(
|
||||||
WindowOptions {
|
WindowOptions {
|
||||||
@@ -1482,6 +1489,7 @@ fn main() {
|
|||||||
toggle_pressed: false,
|
toggle_pressed: false,
|
||||||
volume: 40.0,
|
volume: 40.0,
|
||||||
input_state,
|
input_state,
|
||||||
|
password_state,
|
||||||
popover_open: false,
|
popover_open: false,
|
||||||
menu_open: false,
|
menu_open: false,
|
||||||
strategy: Some(Strategy::Curated),
|
strategy: Some(Strategy::Curated),
|
||||||
|
|||||||
Reference in New Issue
Block a user