Skip to main content

omp_tui/components/
input.rs

1use xutf::Text;
2
3use crate::{
4	component::{Component, EventCtx, Flow, Hit, HitTag, PaintCtx, Slot, next_slot},
5	context::{Theme, UiContext},
6	frame::{Rect, Style},
7	input::{
8		Key, Mouse, byte_at_column, sanitize_paste, word_left_column, word_right_column,
9		word_rubout_start,
10	},
11	props::{Prop, PropValue, Props},
12	rich::cell_width,
13};
14
15#[derive(Default)]
16struct InputState {
17	text:   String,
18	cursor: u16,
19	mask:   bool,
20	masked: String,
21}
22
23impl InputState {
24	fn refresh_mask(&mut self) {
25		self.masked.clear();
26		if self.mask {
27			self
28				.masked
29				.reserve(self.text.chars().count().saturating_mul('•'.len_utf8()));
30			self
31				.masked
32				.extend(std::iter::repeat_n('•', self.text.chars().count()));
33		}
34	}
35}
36
37/// An editable, single-line text field.
38pub struct Input {
39	props: Props,
40	slot:  Slot,
41	state: InputState,
42}
43
44impl Input {
45	/// Creates an empty input.
46	pub fn new() -> Self {
47		Self { props: Props::new(), slot: next_slot(), state: InputState::default() }
48	}
49
50	/// Sets one input property.
51	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
52		self.props.set(prop, value);
53		self.sync_prop(prop);
54		self
55	}
56
57	/// Sets one input property from a string.
58	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
59		self.props.set(prop, value);
60		self.sync_prop(prop);
61		self
62	}
63
64	fn sync_prop(&mut self, prop: Prop) {
65		match prop {
66			Prop::Value => {
67				self.state.text = self
68					.props
69					.str_of(Prop::Value)
70					.map(ToString::to_string)
71					.unwrap_or_default();
72				self.state.cursor = cell_width(&self.state.text);
73				self.state.refresh_mask();
74			},
75			Prop::Mask => {
76				self.state.mask = self.props.flag(Prop::Mask);
77				self.state.refresh_mask();
78			},
79			_ => {},
80		}
81	}
82
83	fn edit(&mut self, key: Key) -> bool {
84		match key {
85			Key::Left | Key::Ctrl('b') => self.state.cursor = self.state.cursor.saturating_sub(1),
86			Key::Right | Key::Ctrl('f') => {
87				self.state.cursor = (self.state.cursor + 1).min(cell_width(&self.state.text));
88			},
89			Key::Home => self.state.cursor = 0,
90			Key::End => self.state.cursor = cell_width(&self.state.text),
91			Key::Backspace => {
92				if self.state.cursor > 0 {
93					let end = byte_at_column(&self.state.text, self.state.cursor);
94					let start = self.state.text[..end]
95						.grapheme_indices()
96						.next_back()
97						.map_or(0, |(offset, _)| offset);
98					let removed = cell_width(&self.state.text[start..end]);
99					self.state.text.replace_range(start..end, "");
100					self.state.cursor = self.state.cursor.saturating_sub(removed);
101				}
102			},
103			Key::Delete | Key::Ctrl('d') => {
104				let start = byte_at_column(&self.state.text, self.state.cursor);
105				if start < self.state.text.len() {
106					let grapheme_len = self.state.text[start..]
107						.graphemes()
108						.next()
109						.map_or(0, str::len);
110					self
111						.state
112						.text
113						.replace_range(start..start + grapheme_len, "");
114				}
115			},
116			Key::Space => {
117				let at = byte_at_column(&self.state.text, self.state.cursor);
118				self.state.text.insert(at, ' ');
119				self.state.cursor += 1;
120			},
121			Key::Char(character) => {
122				let at = byte_at_column(&self.state.text, self.state.cursor);
123				self.state.text.insert(at, character);
124				self.state.cursor += cell_width(character.encode_utf8(&mut [0u8; 4]));
125			},
126			Key::Ctrl('a') => self.state.cursor = 0,
127			Key::Ctrl('e') => self.state.cursor = cell_width(&self.state.text),
128			Key::Ctrl('k') => {
129				let at = byte_at_column(&self.state.text, self.state.cursor);
130				self.state.text.truncate(at);
131			},
132			Key::Ctrl('u') => {
133				let at = byte_at_column(&self.state.text, self.state.cursor);
134				self.state.text.replace_range(..at, "");
135				self.state.cursor = 0;
136			},
137			Key::Ctrl('w') => {
138				let end = byte_at_column(&self.state.text, self.state.cursor);
139				let start = word_rubout_start(&self.state.text, end);
140				let removed = cell_width(&self.state.text[start..end]);
141				self.state.text.replace_range(start..end, "");
142				self.state.cursor = self.state.cursor.saturating_sub(removed);
143			},
144			Key::WordLeft => self.state.cursor = word_left_column(&self.state.text, self.state.cursor),
145			Key::WordRight => {
146				self.state.cursor = word_right_column(&self.state.text, self.state.cursor);
147			},
148			Key::WordDelete => {
149				let start = byte_at_column(&self.state.text, self.state.cursor);
150				let end_column = word_right_column(&self.state.text, self.state.cursor);
151				let end = byte_at_column(&self.state.text, end_column);
152				self.state.text.replace_range(start..end, "");
153			},
154			_ => return false,
155		}
156		self.state.refresh_mask();
157		true
158	}
159}
160
161impl Default for Input {
162	fn default() -> Self {
163		Self::new()
164	}
165}
166
167impl Component for Input {
168	fn props(&self) -> &Props {
169		&self.props
170	}
171
172	fn props_mut(&mut self) -> &mut Props {
173		&mut self.props
174	}
175
176	fn slot(&self) -> Slot {
177		self.slot
178	}
179
180	fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
181		let placeholder = self
182			.props
183			.str_of(Prop::Placeholder)
184			.map_or(0, |placeholder| cell_width(placeholder));
185		(16, placeholder.saturating_add(3).max(30))
186	}
187
188	fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
189		1
190	}
191
192	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
193		if rect.y >= pc.clip {
194			return;
195		}
196		pc.hits
197			.push(Hit { rect, slot: self.slot, tag: HitTag::Press });
198		let focused = pc.focus == Some(self.slot);
199		let shown = if self.state.mask {
200			&self.state.masked
201		} else {
202			&self.state.text
203		};
204		let x = pc.frame.put(
205			rect.x,
206			rect.y,
207			pc.ctx.charset.cursor(),
208			if focused {
209				Style::new().fg(pc.ctx.theme.accent)
210			} else {
211				dim(&pc.ctx.theme)
212			},
213		);
214		if shown.is_empty() && !focused {
215			if let Some(placeholder) = self.props.str_of(Prop::Placeholder) {
216				pc.frame
217					.put(x, rect.y, placeholder, dim(&pc.ctx.theme).italic());
218			}
219			return;
220		}
221		let available = rect.width.saturating_sub(3);
222		let total = cell_width(shown);
223		let left = if total > available {
224			self
225				.state
226				.cursor
227				.saturating_sub(available.saturating_sub(8))
228		} else {
229			0
230		};
231		let start = byte_at_column(shown, left);
232		let visible = &shown[start..];
233		if focused {
234			// The real terminal cursor marks the insertion point — one
235			// cursor treatment across every core single-line editor.
236			let split = byte_at_column(visible, self.state.cursor.saturating_sub(left));
237			pc.frame.put(x, rect.y, visible, base(&pc.ctx.theme));
238			pc.frame
239				.set_cursor(x.saturating_add(cell_width(&visible[..split])), rect.y);
240		} else {
241			pc.frame.put(x, rect.y, visible, base(&pc.ctx.theme));
242		}
243	}
244
245	fn focusable(&self) -> bool {
246		true
247	}
248
249	fn key(&mut self, _ec: &mut EventCtx<'_>, key: Key) -> Flow {
250		if self.edit(key) {
251			Flow::Consumed
252		} else {
253			Flow::Skip
254		}
255	}
256
257	fn mouse(
258		&mut self,
259		_ec: &mut EventCtx<'_>,
260		tag: HitTag,
261		at: (u16, u16),
262		rect: Rect,
263		mouse: Mouse,
264	) -> Flow {
265		match mouse {
266			Mouse::Click if tag == HitTag::Press => {
267				let column = at.0.saturating_sub(rect.x.saturating_add(2));
268				self.state.cursor = column.min(cell_width(&self.state.text));
269				Flow::Consumed
270			},
271			Mouse::Click
272			| Mouse::RightClick
273			| Mouse::MiddleClick
274			| Mouse::Move
275			| Mouse::Drag
276			| Mouse::Release
277			| Mouse::WheelUp
278			| Mouse::WheelDown
279			| Mouse::WheelLeft
280			| Mouse::WheelRight => Flow::Skip,
281		}
282	}
283
284	fn paste(&mut self, _ec: &mut EventCtx<'_>, text: &str) -> Flow {
285		let sanitized = sanitize_paste(text);
286		if sanitized.is_empty() {
287			return Flow::Skip;
288		}
289		let paste = sanitized.replace(['\n', '\t'], " ");
290		let at = byte_at_column(&self.state.text, self.state.cursor);
291		self.state.text.insert_str(at, &paste);
292		self.state.cursor = self.state.cursor.saturating_add(cell_width(&paste));
293		self.state.refresh_mask();
294		Flow::Consumed
295	}
296
297	fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
298		if let Some(id) = self.props.id() {
299			out.insert(id.to_string(), serde_json::Value::String(self.state.text.clone()));
300		}
301	}
302}
303
304const fn base(theme: &Theme) -> Style {
305	Style::new().fg(theme.fg)
306}
307const fn dim(theme: &Theme) -> Style {
308	Style::new().fg(theme.muted)
309}
310
311#[cfg(test)]
312mod tests {
313	use super::*;
314	use crate::{Frame, Size, test_support::frame_row_text};
315
316	fn event_ctx(ctx: &UiContext) -> EventCtx<'_> {
317		EventCtx::new(ctx, 32, 1)
318	}
319
320	#[test]
321	fn key_and_paste_edit_exported_text() {
322		let mut input = Input::new().with(Prop::Id, "name");
323		let ctx = UiContext::default();
324		assert_eq!(input.key(&mut event_ctx(&ctx), Key::Char('a')), Flow::Consumed);
325		assert_eq!(input.key(&mut event_ctx(&ctx), Key::Char('b')), Flow::Consumed);
326		assert_eq!(input.paste(&mut event_ctx(&ctx), " c\td"), Flow::Consumed);
327		let mut values = serde_json::Map::new();
328		input.value(&mut values);
329		assert_eq!(values["name"], serde_json::json!("ab c d"));
330	}
331
332	#[test]
333	fn paint_draws_text_and_press_hit() {
334		let mut input = Input::new().with(Prop::Value, "hello");
335		let ctx = UiContext::default();
336		let mut frame = Frame::new(Size::new(32, 1));
337		let mut hits = Vec::new();
338		let slot = input.slot();
339		let mut wakes = Vec::new();
340		let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
341		input.paint(&mut pc, Rect::new(0, 0, 32, 1));
342		assert!(frame_row_text(&frame, 0).contains("hello"));
343		assert_eq!(hits[0].slot, slot);
344	}
345}