add icon button

This commit is contained in:
2026-11-01 18:12:51 +07:00
parent acf907461b
commit 125fa3e62d
5 changed files with 40 additions and 26 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ slider = []
progress = [] progress = []
avatar = [] avatar = []
badge = [] badge = []
input = [] input = ["button"]
popover = [] popover = []
scroll_area = [] scroll_area = []
segmented = [] segmented = []
+5 -2
View File
@@ -134,6 +134,7 @@ impl RenderOnce for Button {
let on_click = self.on_click; let on_click = self.on_click;
let a11y_label = self.a11y_label.or_else(|| self.label.clone()); 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 { let (background, foreground, hover, pressed) = match self.variant {
ButtonVariant::Primary => ( ButtonVariant::Primary => (
@@ -166,8 +167,10 @@ impl RenderOnce for Button {
.id(self.id) .id(self.id)
.role(Role::Button) .role(Role::Button)
.justify_center() .justify_center()
.h(metrics.control_height) .when(icon_only, |button| button.size(metrics.control_height))
.px(metrics.padding_x) .when(!icon_only, |button| {
button.h(metrics.control_height).px(metrics.padding_x)
})
.line_height(relative(1.0)) .line_height(relative(1.0))
.gap_1p5() .gap_1p5()
.rounded(theme.radius.control) .rounded(theme.radius.control)
+21 -17
View File
@@ -14,9 +14,10 @@ use gpui::{
ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, ScrollEvent, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
TextAlign, TextRun, 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_icons::IconRole;
use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size}; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Size};
use crate::button::{Button, ButtonVariant};
use crate::common::{Sizable, h_flex}; use crate::common::{Sizable, h_flex};
/// The key context input bindings match against. /// The key context input bindings match against.
@@ -1786,31 +1787,34 @@ impl RenderOnce for Input {
.id((self.id.clone(), "input-reveal")) .id((self.id.clone(), "input-reveal"))
.debug_selector(|| "input-reveal".to_string()) .debug_selector(|| "input-reveal".to_string())
.absolute() .absolute()
.right(metrics.dense_padding_x) .right(metrics.dense_padding_x / 2.0)
.top_0p5() .top_0p5()
.bottom_0p5() .bottom_0p5()
.flex() .flex()
.items_center() .items_center()
.cursor_pointer() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.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( .child(
icon(if revealed { Button::new((self.id.clone(), "input-reveal-button"))
.icon(if revealed {
IconRole::EyeOff IconRole::EyeOff
} else { } else {
IconRole::Eye IconRole::Eye
}) })
.size(metrics.icon_size) .variant(ButtonVariant::Ghost)
.color(colors.muted_foreground), .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)
});
}
}),
), ),
) )
}) })
+2 -2
View File
@@ -206,7 +206,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move
| Unit | Crate | Component | Notes | | 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. | | 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. | | 3 | core | Switch | Controlled pill + thumb; thumb slides once motion exists. |
| 4 | core | Radio + RadioGroup | Group owns the selected value; radios never self-uncheck. | | 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")`) | | 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 | 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 | | 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) |
+7
View File
@@ -323,6 +323,13 @@ impl Render for Gallery {
.label("Open") .label("Open")
.icon(IconRole::Folder) .icon(IconRole::Folder)
.tooltip(Tooltip::text("Opens the selected file")), .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( .child(