SIGN IN SIGN UP

input: Keep a masked value out of the clipboard, and settle boolean reader naming (#2810)

## Summary

Refs #2669. Two parts: the masked-input fix, and the naming rule its new
reader forced a decision on.

### 1. A masked `Input` leaked its plain text

`copy` and `cut` read `self.text` without looking at `masked`, so a
password went to the system clipboard verbatim. Word-wise movement,
word-wise delete, and double-click word selection each ran their
boundary search over the real text too — so the number of mask
characters that disappeared on a word delete, and the width of the
highlight after a double click, told an onlooker where the word
boundaries in the password were.

This treats `masked` as the single switch for all of it, rather than
adding a per-action flag like the `.disable_copy(bool)` the issue
proposed. A second switch would allow "masked but copyable", which no
other implementation offers, and `mask_toggle` already gives the user a
way to reveal the value when they do want to copy it.

- **Copy / Cut** return early while masked, and the context menu reads
the same answer through a new
`InputContextMenuCapabilities::is_copyable`, so a menu item is never
enabled for an action that would silently do nothing. Cut does not
delete either — matching Chrome and `PasswordBox` rather than Qt, whose
`cut()` deletes without copying as a side effect of its implementation.
- **Word boundaries** collapse to the whole value, which is what the
mask actually renders — one unbroken run of mask characters. A word
delete takes everything before the caret; a double click takes all of
it.
- **Paste and Select All** are untouched, and clearing the mask restores
everything.

`masked` is only exposed on the single-line `impl
InputBaseState<InputMode>`, so `select_line` and Home/End already span
the whole value and needed no change.

#### Prior art

Every implementation I checked refuses to let the plain text out:

| | Copy while masked |
|---|---|
| `NSSecureTextField` | disabled in the class itself, not configurable |
| WPF / WinUI `PasswordBox` | by design, only Paste is supported |
| Qt `QLineEdit` | `copy()` documented to act only when `echoMode` is
`Normal` |
| GTK4 `GtkText` | copies the invisible char, not the text |
| Chrome `<input type="password">` | blocked |

The browser row I verified directly, back-to-back on one page, since
[w3c/clipboard-apis#5](https://github.com/w3c/clipboard-apis/issues/5)
was never resolved:

```
plain    input: queryCommandEnabled('copy')=true,  copy event fires,       clipboard <- "PLAINTEXT_CONTROL"
password input: queryCommandEnabled('copy')=false, copy event never fires, clipboard unchanged
```

`cut` is likewise disabled and `selectAll` stays enabled. The
word-collapse behaviour is Chrome's too — with the caret at offset 7 of
`aaa bbb ccc`, a word delete leaves `" ccc"` in the password field but
only removes `ccc` in the plain field, and a double click selects
`0..11` rather than one word.

Qt reaches the same place by a different route: only
`MoveToPreviousWord` checks `echoMode` (falling back to `home()`), but
`cursorWordBackward` runs on `m_textLayout`, and `updateDisplayText`
fills that layout with `m_passwordCharacter` — a run of mask characters
has no word boundaries to skip to.

### 2. Boolean reader naming

The new reader sat next to `can_go_to_definition`, `can_zoom` and
`can_close`, so the same question was being asked two different ways.
The `can_` readers were the smaller group by a wide margin — 6 against
122 `is_` and 14 `has_` — and three of them read a field already spelled
as an adjective (`closable`, `zoomable`), so the reader had drifted from
the field, not the other way round.

The rule, now written into the Coding Guides, `CLAUDE.md` and
`docs/ARCHITECTURE.md`:

> A boolean reader is either `has_<noun>`, when the value holds
something, or `is_<adjective>`, when it describes a state or a
permission. Reach for the adjective whenever the action has one. When
the action is a verb phrase with no adjective form, name the thing it
needs instead. Do not add new `can_` readers.

`can_go_to_definition` was reporting `lsp.definition_provider.is_some()`
— exactly parallel to the `has_code_actions` beside it, which reports
`!lsp.code_action_providers.is_empty()` — so it becomes
`has_definition`.

## Test Plan

Three new tests in `crates/base/src/input/base/state.rs`:

- `test_masked_input_keeps_its_value_out_of_the_clipboard` — Copy leaves
a clipboard sentinel intact, Cut neither copies nor deletes, and
unmasking restores both.
- `test_masked_input_collapses_word_boundaries` — word delete and double
click take the whole value while masked, and the same calls take one
word once unmasked.
- `test_masked_input_disables_the_copy_context_menu_items` —
`is_copyable()` is false while masked even with a selection.

`cargo test -p gpui-base --lib` (560 passed), `cargo test -p
gpui-component --lib` (452 passed), `cargo test -p gpui-base --doc` (13
passed), `cargo check --workspace --all-targets`, `cargo clippy -p
gpui-base -p gpui-component --all-targets`, and `typos` are all clean.

To check by hand: the Story gallery's Input page already has a masked
example with `mask_toggle` (`crates/story/src/stories/input_story.rs`).

## Breaking Changes

Three boolean readers are renamed. `InputContextMenuCapabilities` also
gains a field, but it is built through its builder and read through
methods, so that part is not breaking.

```diff
- if capabilities.can_go_to_definition() { .. }
+ if capabilities.has_definition() { .. }

- if tile.can_zoom() { .. }
+ if tile.is_zoomable() { .. }

- if tile.can_close() { .. }
+ if tile.is_closable() { .. }

- if group.can_close(cx) { .. }
+ if group.is_closable(cx) { .. }
```

Affected types: `InputContextMenuCapabilities`, `TileState`,
`TabGroupContext`, `TabGroupConstraints`, and `TabGroup`.
`TabGroupContext`'s `can_close` field is renamed to `closable`, matching
the spelling the other containers already use; it is private, so only
the reader is visible to callers.

## AI Assistance

Prior-art research, browser verification, implementation, rename, and
tests were done with Claude Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
J
Jason Lee committed
7d0aaf55852add1e0af48cabd7ae6fa3ae791cde
Parent: cb87f2c
Committed by GitHub <noreply@github.com> on 8/24/2026, 3:03:58 AM