add read-only mode for input

This commit is contained in:
2026-10-29 14:42:11 +07:00
parent d95baae418
commit e2331f2ba5
3 changed files with 156 additions and 7 deletions
+45 -3
View File
@@ -105,6 +105,8 @@ pub struct InputState {
redo_stack: Vec<HistoryEntry>,
/// 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<RefCell<Option<InputLayout>>>,
@@ -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<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.
fn push_history(&mut self, range: Range<usize>, 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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
if self.read_only {
return;
}
let range = range_utf16.map_or_else(
|| self.selection(),
|range| utf16_range_to_utf8(&self.text, range),
+109 -2
View File
@@ -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);