diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index f66e403..950454e 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -105,6 +105,8 @@ pub struct InputState { redo_stack: Vec, /// Consecutive edits within this interval merge into one undo entry. group_interval: Duration, + /// When set, user edits are refused, focus, movement, selection and copy stay. + read_only: bool, /// The shaped line and element bounds, recorded during each paint, /// and shared with hit-testing and the IME geometry conversions. layout: Rc>>, @@ -135,6 +137,7 @@ impl InputState { undo_stack: Vec::new(), redo_stack: Vec::new(), group_interval: DEFAULT_GROUP_INTERVAL, + read_only: false, layout: Rc::new(RefCell::new(None)), } } @@ -212,6 +215,19 @@ impl InputState { self.group_interval = interval; } + /// Sets whether user edits are refused. + pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context) { + if self.read_only != read_only { + self.read_only = read_only; + cx.notify(); + } + } + + /// Whether user edits are refused. + pub fn is_read_only(&self) -> bool { + self.read_only + } + /// 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(); @@ -262,6 +278,10 @@ impl InputState { /// Reverts the most recent edit group (`cmd-z`). fn undo(&mut self, window: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + let Some(entry) = self.undo_stack.pop() else { return; }; @@ -277,6 +297,10 @@ impl InputState { /// Reapplies the most recently undone edit group (`cmd-shift-z`). fn redo(&mut self, window: &mut Window, cx: &mut Context) { + if self.read_only { + return; + } + let Some(entry) = self.redo_stack.pop() else { return; }; @@ -331,6 +355,10 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { + if self.read_only { + return; + } + if range.is_empty() && text.is_empty() { return; } @@ -571,7 +599,8 @@ impl InputState { self.paint_text(origin, line_height, window, cx); - if focused && self.selection().is_empty() { + // A caret promises that typing lands there; in read-only it does not. + if focused && !self.read_only && self.selection().is_empty() { self.paint_caret( origin, caret_x, @@ -640,7 +669,11 @@ impl InputState { let index = self.index_at_x(event.position.x); - if event.click_count == 2 { + if event.click_count >= 3 { + // A single-line field is one line: triple-click selects all of it. + let len = self.text.len(); + self.select_range(0..len, window, cx); + } else if event.click_count == 2 { let start = previous_word_start(&self.text, index); let end = next_word_end(&self.text, index); self.select_range(start..end, window, cx); @@ -748,7 +781,8 @@ impl InputState { } } "x" if command => { - if self.copy_selection(window, cx) { + // Read-only refuses cut entirely, clipboard included. + if !self.read_only && self.copy_selection(window, cx) { self.delete_selection(window, cx); cx.stop_propagation(); } @@ -774,6 +808,10 @@ impl InputState { cx.emit(InputStateEvent::Submitted); cx.stop_propagation(); } + "escape" => { + window.blur(cx); + cx.stop_propagation(); + } "tab" => {} _ => { if let Some(text) = &keystroke.key_char @@ -850,6 +888,10 @@ impl EntityInputHandler for InputState { window: &mut Window, cx: &mut Context, ) { + if self.read_only { + return; + } + let range = range_utf16.map_or_else( || self.selection(), |range| utf16_range_to_utf8(&self.text, range), diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index c499fab..a5d69c1 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -1,6 +1,6 @@ use gpui::{ - Entity, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, - Window, div, point, px, + Entity, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Render, + Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; use gpui_native_core::input::{Input, InputState, InputStateEvent}; use gpui_native_theme::Theme; @@ -211,6 +211,113 @@ fn set_value_clears_the_history() { assert_eq!(value(&cx, &state), "preset"); } +#[test] +fn read_only_refuses_edits_but_keeps_selection_and_copy() { + let (cx, state, mut visual) = input_window("hello", false); + focus(&mut visual, &state); + visual.update(|_, cx| { + state.update(cx, |state, cx| state.set_read_only(true, cx)); + }); + + // Typing and backspace are refused. + visual.simulate_keystrokes("x backspace"); + assert_eq!(value(&cx, &state), "hello"); + + // Paste is refused. + cx.update(|cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string("Z".to_string())); + }); + visual.simulate_keystrokes("cmd-v"); + assert_eq!(value(&cx, &state), "hello"); + + // Selection and copy still work. + visual.simulate_keystrokes("shift-left shift-left"); + 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, "lo"); + + // Cut neither changes the field nor touches the clipboard. + visual.simulate_keystrokes("cmd-x"); + assert_eq!(value(&cx, &state), "hello"); + let clipboard = cx.update(|cx| { + cx.read_from_clipboard() + .and_then(|item| item.text()) + .unwrap_or_default() + }); + assert_eq!(clipboard, "lo"); +} + +#[test] +fn read_only_blocks_undo_redo_but_set_value_still_writes() { + let (cx, state, mut visual) = input_window("", false); + focus(&mut visual, &state); + + visual.simulate_keystrokes("a"); + visual.simulate_keystrokes("cmd-z"); + assert_eq!(value(&cx, &state), ""); + + visual.update(|_, cx| { + state.update(cx, |state, cx| state.set_read_only(true, cx)); + }); + visual.simulate_keystrokes("cmd-shift-z"); + assert_eq!(value(&cx, &state), ""); + + visual.update(|window, cx| { + state.update(cx, |state, cx| state.set_value("preset", window, cx)); + }); + assert_eq!(value(&cx, &state), "preset"); +} + +#[test] +fn escape_blurs_the_field() { + let (mut cx, state, mut visual) = input_window("", false); + let (sink, _subscription) = record_events(&mut cx, &state); + focus(&mut visual, &state); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + visual.run_until_parked(); + assert_eq!(count(&sink, &InputStateEvent::Focused), 1); + + visual.simulate_keystrokes("escape"); + let focused = visual.update(|window, cx| state.read(cx).focus_handle().is_focused(window)); + assert!(!focused); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + visual.run_until_parked(); + assert_eq!(count(&sink, &InputStateEvent::Blurred), 1); +} + +#[test] +fn triple_click_selects_all_and_typing_replaces_it() { + let (cx, state, mut visual) = input_window("hello world", false); + focus(&mut visual, &state); + + let at = point(px(30.), px(14.)); + visual.simulate_event(MouseDownEvent { + position: at, + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count: 3, + first_mouse: false, + }); + visual.simulate_event(MouseUpEvent { + position: at, + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count: 3, + }); + + // The selection spans the whole value: one keystroke replaces all of it. + visual.simulate_keystrokes("x"); + assert_eq!(value(&cx, &state), "x"); +} + #[test] fn shift_arrow_selects_and_backspace_removes_the_selection() { let (cx, state, mut visual) = input_window("ab", false); diff --git a/docs/PLAN.md b/docs/PLAN.md index 6d213f4..7eee282 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -221,7 +221,7 @@ Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide te Our divergence from gpui-kit held: the per-size metrics resolve from **the theme's size table at paint time**, not from constants in the component layer. All ten controls implement `Sizable`; `Size::Custom(side)` derives the other metrics from the side length. Still open, when a real need arrives: TOML overrides for the size table in `ThemeDefinition`, and per-platform size tables from the Phase F native-metrics work. -### 6.2 Input completion (unit 10 finishing pass) — in progress (steps 1–2 landed 2026-10-29) +### 6.2 Input completion (unit 10 finishing pass) — in progress (steps 1–3 landed 2026-10-29) Gap analysis against gpuikit's input (2026-10-29; source read as an API reference, §9 — no porting). gpuikit splits input three ways: core state whose edits are actions, infrastructure (`bindings` / `blink` / `bidi`), and a chrome-less element with chrome in wrapper elements. We keep one component with chrome included; only the ideas below are adopted. @@ -233,7 +233,7 @@ Remaining, in order: | --- | --- | --- | | 1 | macOS modifier split | ✅ Landed. `cmd-left/right` → home/end, `cmd-up/down` → start/end of field, `cmd-backspace` → delete to start, `alt` modifiers → word ops. The handler used to treat cmd and alt alike — wrong on macOS (D8); ctrl stays the word-modifier fallback off-mac | | 2 | Undo/redo | ✅ Landed. Patch-based history (range, replaced text, new length, prior selection), 300 ms grouping (`set_group_interval`, `Duration::ZERO` disables merging), `cmd-z` / `cmd-shift-z`, 100-entry cap; `set_value` clears the stacks, any new edit clears redo. Unlike gpuikit — which skips recording inside the interval and so undoes groups lossily — entries merge so a group fully reverses. Redo lands the caret at the end of the restored text; undo restores the pre-edit selection | -| 3 | Read-only, Escape-blur, triple-click | Read-only: focus, movement, selection, copy still work; edits and paste refused — distinct from disabled. Escape → `window.blur()`. Triple-click selects the line (all of it, single-line) | +| 3 | Read-only, Escape-blur, triple-click | ✅ Landed. Read-only: focus, movement, selection, copy still work; edits, paste, cut (clipboard included), undo/redo and IME refused — distinct from disabled; programmatic setters unaffected; the caret is hidden while read-only (a caret promises typing lands there). Escape → `window.blur()`. Triple-click selects all (single-line) | | 4 | Actions + key context | Replace the monolithic `on_key_down` match with `actions!(input, [...])` + an `"Input"` key context; `bind_input_keys(cx, overrides)` over a per-platform-default `InputBindings` table of `Option` fields (override or unbind each). Step 1's bindings become data | | 5 | Caret blink | 500 ms tick on the GPUI executor, paused (caret visible) while typing or moving, suppressed in read-only | | 6 | Paint polish | IME marked-text underline; I-beam cursor on hover; two-sided horizontal scroll (clamped offset in state, follows caret) plus horizontal wheel scroll |