add read-only mode for input
This commit is contained in:
@@ -105,6 +105,8 @@ pub struct InputState {
|
|||||||
redo_stack: Vec<HistoryEntry>,
|
redo_stack: Vec<HistoryEntry>,
|
||||||
/// Consecutive edits within this interval merge into one undo entry.
|
/// Consecutive edits within this interval merge into one undo entry.
|
||||||
group_interval: Duration,
|
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,
|
/// The shaped line and element bounds, recorded during each paint,
|
||||||
/// and shared with hit-testing and the IME geometry conversions.
|
/// and shared with hit-testing and the IME geometry conversions.
|
||||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
layout: Rc<RefCell<Option<InputLayout>>>,
|
||||||
@@ -135,6 +137,7 @@ impl InputState {
|
|||||||
undo_stack: Vec::new(),
|
undo_stack: Vec::new(),
|
||||||
redo_stack: Vec::new(),
|
redo_stack: Vec::new(),
|
||||||
group_interval: DEFAULT_GROUP_INTERVAL,
|
group_interval: DEFAULT_GROUP_INTERVAL,
|
||||||
|
read_only: false,
|
||||||
layout: Rc::new(RefCell::new(None)),
|
layout: Rc::new(RefCell::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,19 @@ impl InputState {
|
|||||||
self.group_interval = interval;
|
self.group_interval = interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets whether user edits are refused.
|
||||||
|
pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
|
||||||
|
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.
|
/// Records `old_text` at `range` being replaced by `new_len` bytes.
|
||||||
fn push_history(&mut self, range: Range<usize>, old_text: String, new_len: usize) {
|
fn push_history(&mut self, range: Range<usize>, old_text: String, new_len: usize) {
|
||||||
self.redo_stack.clear();
|
self.redo_stack.clear();
|
||||||
@@ -262,6 +278,10 @@ impl InputState {
|
|||||||
|
|
||||||
/// Reverts the most recent edit group (`cmd-z`).
|
/// Reverts the most recent edit group (`cmd-z`).
|
||||||
fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn undo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.read_only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(entry) = self.undo_stack.pop() else {
|
let Some(entry) = self.undo_stack.pop() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -277,6 +297,10 @@ impl InputState {
|
|||||||
|
|
||||||
/// Reapplies the most recently undone edit group (`cmd-shift-z`).
|
/// Reapplies the most recently undone edit group (`cmd-shift-z`).
|
||||||
fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn redo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.read_only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(entry) = self.redo_stack.pop() else {
|
let Some(entry) = self.redo_stack.pop() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -331,6 +355,10 @@ impl InputState {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
if self.read_only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if range.is_empty() && text.is_empty() {
|
if range.is_empty() && text.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -571,7 +599,8 @@ impl InputState {
|
|||||||
|
|
||||||
self.paint_text(origin, line_height, window, cx);
|
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(
|
self.paint_caret(
|
||||||
origin,
|
origin,
|
||||||
caret_x,
|
caret_x,
|
||||||
@@ -640,7 +669,11 @@ impl InputState {
|
|||||||
|
|
||||||
let index = self.index_at_x(event.position.x);
|
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 start = previous_word_start(&self.text, index);
|
||||||
let end = next_word_end(&self.text, index);
|
let end = next_word_end(&self.text, index);
|
||||||
self.select_range(start..end, window, cx);
|
self.select_range(start..end, window, cx);
|
||||||
@@ -748,7 +781,8 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"x" if command => {
|
"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);
|
self.delete_selection(window, cx);
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
}
|
}
|
||||||
@@ -774,6 +808,10 @@ impl InputState {
|
|||||||
cx.emit(InputStateEvent::Submitted);
|
cx.emit(InputStateEvent::Submitted);
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
}
|
}
|
||||||
|
"escape" => {
|
||||||
|
window.blur(cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
"tab" => {}
|
"tab" => {}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(text) = &keystroke.key_char
|
if let Some(text) = &keystroke.key_char
|
||||||
@@ -850,6 +888,10 @@ impl EntityInputHandler for InputState {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
if self.read_only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let range = range_utf16.map_or_else(
|
let range = range_utf16.map_or_else(
|
||||||
|| self.selection(),
|
|| self.selection(),
|
||||||
|range| utf16_range_to_utf8(&self.text, range),
|
|range| utf16_range_to_utf8(&self.text, range),
|
||||||
|
|||||||
+109
-2
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
Entity, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext,
|
Entity, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement as _, Render,
|
||||||
Window, div, point, px,
|
Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::input::{Input, InputState, InputStateEvent};
|
use gpui_native_core::input::{Input, InputState, InputStateEvent};
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
@@ -211,6 +211,113 @@ fn set_value_clears_the_history() {
|
|||||||
assert_eq!(value(&cx, &state), "preset");
|
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]
|
#[test]
|
||||||
fn shift_arrow_selects_and_backspace_removes_the_selection() {
|
fn shift_arrow_selects_and_backspace_removes_the_selection() {
|
||||||
let (cx, state, mut visual) = input_window("ab", false);
|
let (cx, state, mut visual) = input_window("ab", false);
|
||||||
|
|||||||
+2
-2
@@ -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.
|
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.
|
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 |
|
| 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 |
|
| 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<KeyBinding>` fields (override or unbind each). Step 1's bindings become data |
|
| 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<KeyBinding>` 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user