diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 703429a..2b1790e 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -51,7 +51,7 @@ slider = [] progress = [] avatar = [] badge = [] -input = [] +input = ["button"] popover = [] scroll_area = [] segmented = [] diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index f6b703f..33f42ba 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -134,6 +134,7 @@ impl RenderOnce for Button { let on_click = self.on_click; let a11y_label = self.a11y_label.or_else(|| self.label.clone()); + let icon_only = self.label.is_none() && self.icon.is_some(); let (background, foreground, hover, pressed) = match self.variant { ButtonVariant::Primary => ( @@ -166,8 +167,10 @@ impl RenderOnce for Button { .id(self.id) .role(Role::Button) .justify_center() - .h(metrics.control_height) - .px(metrics.padding_x) + .when(icon_only, |button| button.size(metrics.control_height)) + .when(!icon_only, |button| { + button.h(metrics.control_height).px(metrics.padding_x) + }) .line_height(relative(1.0)) .gap_1p5() .rounded(theme.radius.control) diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 54593c3..dea7a37 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -14,9 +14,10 @@ use gpui::{ 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_icons::IconRole; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size}; +use crate::button::{Button, ButtonVariant}; use crate::common::{Sizable, h_flex}; /// The key context input bindings match against. @@ -1786,31 +1787,34 @@ impl RenderOnce for Input { .id((self.id.clone(), "input-reveal")) .debug_selector(|| "input-reveal".to_string()) .absolute() - .right(metrics.dense_padding_x) + .right(metrics.dense_padding_x / 2.0) .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) - }); - } - }) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child( - icon(if revealed { - IconRole::EyeOff - } else { - IconRole::Eye - }) - .size(metrics.icon_size) - .color(colors.muted_foreground), + Button::new((self.id.clone(), "input-reveal-button")) + .icon(if revealed { + IconRole::EyeOff + } else { + IconRole::Eye + }) + .variant(ButtonVariant::Ghost) + .small() + .accessibility_label(if revealed { + "Hide password" + } else { + "Show password" + }) + .on_click({ + let state = self.state.clone(); + move |_, _, cx| { + state.update(cx, |state, cx| { + state.set_revealed(!state.is_revealed(), cx) + }); + } + }), ), ) }) diff --git a/docs/PLAN.md b/docs/PLAN.md index 0802a29..6da90b2 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -206,7 +206,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | Unit | Crate | Component | Notes | | --- | --- | --- | --- | -| 1 | core | Button | Variants (primary/secondary/ghost/destructive) painted from token ramps; single activation path; disabled + a11y role. The API template for everything after. | +| 1 | core | Button | Variants (primary/secondary/ghost/destructive) painted from token ramps; single activation path; disabled + a11y role. The API template for everything after. An icon with no label renders the button square — the icon-only shape for toolbars and reveal controls | | 2 | core | Checkbox | Controlled `checked` + `on_change(next)`; Check icon from the icons crate. | | 3 | core | Switch | Controlled pill + thumb; thumb slides once motion exists. | | 4 | core | Radio + RadioGroup | Group owns the selected value; radios never self-uncheck. | @@ -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 | 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) | +| 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 — a ghost icon-only 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). The reveal press stops propagation so it never places the caret. The `input` feature now depends on `button` | | 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 d019929..8664993 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -323,6 +323,13 @@ impl Render for Gallery { .label("Open") .icon(IconRole::Folder) .tooltip(Tooltip::text("Opens the selected file")), + ) + .child( + Button::new("gallery-button-icon-only") + .icon(IconRole::Folder) + .variant(ButtonVariant::Ghost) + .accessibility_label("Open the selected file") + .tooltip(Tooltip::text("Opens the selected file")), ), ) .child(