Skip to main content

chat/
chat.rs

1//! An `inline` surface: a transcript in `main`, an `editor` in `dock` that
2//! the program edits from keys. Enter sends, Escape quits.
3
4use tern_sdk::{
5	Input, Options, Session, SurfaceOptions, nodes,
6	ui::{self, Side, Tone, View},
7	wire::feature,
8};
9
10/// What the editor's handlers report.
11#[derive(Clone, Debug)]
12enum Msg {
13	/// Tern submits text into the composer (`send`).
14	Send(String),
15	/// A click asked for the keys.
16	Focus,
17}
18
19/// The editor's id: `dock` region, key `ed`.
20const EDITOR: &str = "dock.ed";
21
22/// The chat's state.
23#[derive(Default)]
24struct Chat {
25	/// Sent messages and replies: (who, text).
26	transcript: Vec<(&'static str, String)>,
27	/// The draft.
28	draft:      String,
29	/// The caret, in chars.
30	cursor:     usize,
31}
32
33impl Chat {
34	/// The caret's byte offset in the draft.
35	fn byte(&self, chars: usize) -> usize {
36		self
37			.draft
38			.char_indices()
39			.nth(chars)
40			.map_or(self.draft.len(), |(i, _)| i)
41	}
42
43	/// Inserts typed or pasted text at the caret.
44	fn insert(&mut self, text: &str) {
45		let at = self.byte(self.cursor);
46		self.draft.insert_str(at, text);
47		self.cursor += text.chars().count();
48	}
49
50	/// Sends `text`, and the program's reply.
51	fn send(&mut self, text: String) {
52		if text.trim().is_empty() {
53			return;
54		}
55		let reply = format!("You said *{}* ({} chars).", text.trim(), text.chars().count());
56		self.transcript.push(("you", text));
57		self.transcript.push(("bot", reply));
58	}
59
60	/// The view: the transcript, then the composer and a status strip.
61	fn view(&self) -> View<Msg> {
62		let messages = self.transcript.iter().enumerate().map(|(i, (who, text))| {
63			let node = ui::md(text.clone()).key(i);
64			if *who == "you" {
65				ui::card().key(i).tone("user").child(node.key("t"))
66			} else {
67				ui::card().key(i).variant("bare").child(node.key("t"))
68			}
69		});
70		let cursor: usize = self
71			.draft
72			.chars()
73			.take(self.cursor)
74			.map(char::len_utf16)
75			.sum();
76		View::new().main(ui::col().children(messages)).dock(nodes![
77			ui::editor()
78				.key("ed")
79				.text(self.draft.clone())
80				.cursor(cursor)
81				.placeholder("Message (Enter sends, Escape quits)")
82				.sendable(true)
83				.on_send(|text| Msg::Send(text.to_owned()))
84				.on_focus(Msg::Focus),
85			ui::status()
86				.key("bar")
87				.child(ui::seg("chat").icon("message"))
88				.child(
89					ui::seg(format!("{} messages", self.transcript.len()))
90						.side(Side::Right)
91						.tone(if self.transcript.is_empty() {
92							Tone::Muted
93						} else {
94							Tone::Success
95						}),
96				),
97		])
98	}
99}
100
101#[cfg(test)]
102mod tests {
103	use super::*;
104
105	#[test]
106	fn composer_edits_codepoints_and_reports_utf16_cursors() {
107		let mut chat = Chat::default();
108		chat.insert("a😀é");
109		assert_eq!(chat.cursor, 3);
110		assert_eq!(chat.byte(2), 5);
111		chat.cursor = 2;
112		chat.insert("界");
113		assert_eq!(chat.draft, "a😀界é");
114		let view = chat.view();
115		let editor = &view.regions()[1].unwrap().child_nodes()[0];
116		assert_eq!(editor.props()["cursor"], 4);
117		let at = chat.byte(chat.cursor - 1);
118		chat.draft.remove(at);
119		chat.cursor -= 1;
120		assert_eq!(chat.draft, "a😀é");
121		let at = chat.byte(chat.cursor);
122		chat.draft.remove(at);
123		assert_eq!(chat.draft, "a😀");
124	}
125}
126
127fn main() -> Result<(), tern_sdk::Error> {
128	let Some(mut session) = Session::connect(Options::new().feature(feature::SEND))? else {
129		println!("chat needs a terminal that speaks TSP (Tern).");
130		return Ok(());
131	};
132	let sf = session.open(SurfaceOptions::inline().title("chat"))?;
133	let mut chat = Chat::default();
134	session.render(sf, chat.view())?;
135	session.focus(sf, Some(EDITOR))?;
136	while let Some(input) = session.next(None)? {
137		match input {
138			Input::Key(key) => {
139				if key.is("escape") || key.is("ctrl+c") || key.is("ctrl+d") {
140					break;
141				}
142				match key.name.as_str() {
143					"enter" if !key.shift && !key.alt => {
144						let text = std::mem::take(&mut chat.draft);
145						chat.cursor = 0;
146						chat.send(text);
147					},
148					"enter" => chat.insert("\n"),
149					"backspace" if chat.cursor > 0 => {
150						let at = chat.byte(chat.cursor - 1);
151						chat.draft.remove(at);
152						chat.cursor -= 1;
153					},
154					"delete" if chat.cursor < chat.draft.chars().count() => {
155						let at = chat.byte(chat.cursor);
156						chat.draft.remove(at);
157					},
158					"left" => chat.cursor = chat.cursor.saturating_sub(1),
159					"right" => chat.cursor = (chat.cursor + 1).min(chat.draft.chars().count()),
160					"home" => chat.cursor = 0,
161					"end" => chat.cursor = chat.draft.chars().count(),
162					"paste" => chat.insert(key.text.as_deref().unwrap_or_default()),
163					_ => match key.typed() {
164						Some(text) => chat.insert(text),
165						None => continue,
166					},
167				}
168			},
169			Input::Msg(Msg::Send(text), _) => chat.send(text),
170			Input::Msg(Msg::Focus, _) => session.focus(sf, Some(EDITOR))?,
171			Input::Event(_) => continue,
172		}
173		session.render(sf, chat.view())?;
174	}
175	session.close()?;
176	println!("Chat closed after {} messages.", chat.transcript.len());
177	Ok(())
178}