diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 47c01a6..54593c3 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -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>, +} + +/// 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, /// Where the text was painted inside `bounds` (padding minus scroll). origin: gpui::Point, + /// Maps real buffer offsets to display offsets for masked input. + mask_map: Option>, +} + +/// Maps a display text to its corresponding buffer offset. +fn masked_display(text: &str) -> (String, Option>) { + 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>, 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>, 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) { + 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) { + 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, 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) -> 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::>) + }; 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>, bounds: Bounds, padding: Pixels, + right_padding: Pixels, line_height: Pixels, ) -> (gpui::Point, 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, 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, - padding: Pixels, + padding: FieldPadding, colors: ColorTokens, window: &mut Window, cx: &mut Context, @@ -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) { - // 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 { 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, { diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 39a41d8..88be8c3 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -590,3 +590,146 @@ fn enter_emits_submitted() { // The content is left in place for the subscriber to read or clear. assert_eq!(value(&cx, &state), "draft"); } + +fn password_window(initial: &str) -> (TestAppContext, Entity, 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) { + visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default()); +} + +struct RevealHarness { + state: Entity, +} + +impl Render for RevealHarness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + div() + .w(px(400.)) + .p_4() + .child(Input::new("reveal-input", self.state.clone()).placeholder("Password")) + } +} + +fn reveal_window() -> (TestAppContext, Entity, 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"); +} diff --git a/crates/icons/assets/eye-off.svg b/crates/icons/assets/eye-off.svg new file mode 100644 index 0000000..ec4c06b --- /dev/null +++ b/crates/icons/assets/eye-off.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/icons/assets/eye.svg b/crates/icons/assets/eye.svg new file mode 100644 index 0000000..663693c --- /dev/null +++ b/crates/icons/assets/eye.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index a9dfbcb..4415bce 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -1,10 +1,5 @@ //! System icons for GPUI: components and apps reference semantic roles, //! 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::{ @@ -32,6 +27,8 @@ pub enum IconRole { Folder, Warning, Info, + Eye, + EyeOff, } impl IconRole { @@ -49,6 +46,8 @@ impl IconRole { Self::Folder, Self::Warning, Self::Info, + Self::Eye, + Self::EyeOff, ] } @@ -66,6 +65,8 @@ impl IconRole { Self::Folder => "icons/folder.svg", Self::Warning => "icons/triangle-alert.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::Warning => "warning", Self::Info => "info", + Self::Eye => "eye", + Self::EyeOff => "eye-off", } } @@ -133,6 +136,14 @@ impl IconRole { Self::Folder => &["folder-symbolic", "folder"], Self::Warning => &["dialog-warning-symbolic", "dialog-warning", "warning"], 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/triangle-alert.svg" => Some(include_bytes!("../assets/triangle-alert.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, } } diff --git a/crates/icons/src/segoe.rs b/crates/icons/src/segoe.rs index ce11546..f257145 100644 --- a/crates/icons/src/segoe.rs +++ b/crates/icons/src/segoe.rs @@ -18,6 +18,8 @@ pub(crate) fn glyph(role: IconRole) -> Option { IconRole::Folder => 0xE8B7, // Folder IconRole::Warning => 0xE7BA, // Warning IconRole::Info => 0xE946, // Info + IconRole::Eye => 0xE7B3, // RedEye + IconRole::EyeOff => 0xED1A, // Hide }; char::from_u32(codepoint) } diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 42d8a22..0bb3027 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -292,11 +292,7 @@ pub struct 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 { - // One row per size class: control height, padding, label text, icon, - // avatar, strip band, and plate inset — all as root-size multiples. struct ClassFactors { height: f32, padding: f32, @@ -306,6 +302,7 @@ impl SizeTable { strip: f32, inset: f32, } + let small = ClassFactors { height: 1.5, padding: 0.75, @@ -315,6 +312,7 @@ impl SizeTable { strip: 1.5, inset: 0.125, }; + let medium = ClassFactors { height: 1.75, padding: 1.0, @@ -324,6 +322,7 @@ impl SizeTable { strip: 2.0, inset: 0.25, }; + let large = ClassFactors { height: 2.25, padding: 1.25, @@ -333,6 +332,7 @@ impl SizeTable { strip: 3.0, inset: 0.375, }; + let metrics = |f: &ClassFactors| ControlMetrics { control_height: rem_size * f.height, input_height: rem_size * (f.height + 0.125), @@ -353,8 +353,7 @@ impl SizeTable { medium: metrics(&medium), 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.large.caption_size = table.medium.text_size; table diff --git a/docs/PLAN.md b/docs/PLAN.md index 6850504..0802a29 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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")`) | | 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 | -| 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 | | 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) | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index a55b763..d019929 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -45,6 +45,7 @@ struct Gallery { toggle_pressed: bool, volume: f32, input_state: Entity, + password_state: Entity, popover_open: bool, menu_open: bool, strategy: Option, @@ -350,6 +351,10 @@ impl Render for Gallery { Input::new("gallery-input", self.input_state.clone()) .placeholder("Type here…"), ) + .child( + Input::new("gallery-password", self.password_state.clone()) + .placeholder("Password"), + ) .child( div() .text_xs() @@ -1446,6 +1451,8 @@ fn main() { application().with_assets(IconAssets).run(|cx: &mut App| { gpui_native_core::init(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); if let Err(err) = cx.open_window( WindowOptions { @@ -1482,6 +1489,7 @@ fn main() { toggle_pressed: false, volume: 40.0, input_state, + password_state, popover_open: false, menu_open: false, strategy: Some(Strategy::Curated),