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. Outside Tern it
3//! chats line by line on stdin and stdout.
4
5use tern_sdk::{
6	Input, Options, Session, SurfaceOptions, nodes,
7	ui::{self, Side, Tone, View},
8	wire::feature,
9};
10
11/// What the editor's handlers report.
12#[derive(Clone, Debug)]
13enum Msg {
14	/// Tern submits text into the composer (`send`).
15	Send(String),
16	/// A click asked for the keys.
17	Focus,
18}
19
20/// The editor's id: `dock` region, key `ed`.
21const EDITOR: &str = "dock.ed";
22
23/// The chat's state.
24#[derive(Default)]
25struct Chat {
26	/// Sent messages and replies: (who, text).
27	transcript: Vec<(&'static str, String)>,
28	/// The draft.
29	draft:      String,
30	/// The caret, in chars.
31	cursor:     usize,
32}
33
34impl Chat {
35	/// The caret's byte offset in the draft.
36	fn byte(&self, chars: usize) -> usize {
37		self
38			.draft
39			.char_indices()
40			.nth(chars)
41			.map_or(self.draft.len(), |(i, _)| i)
42	}
43
44	/// Inserts typed or pasted text at the caret.
45	fn insert(&mut self, text: &str) {
46		let at = self.byte(self.cursor);
47		self.draft.insert_str(at, text);
48		self.cursor += text.chars().count();
49	}
50
51	/// Sends `text`, and the program's reply.
52	fn send(&mut self, text: String) {
53		if text.trim().is_empty() {
54			return;
55		}
56		let reply = format!("You said *{}* ({} chars).", text.trim(), text.chars().count());
57		self.transcript.push(("you", text));
58		self.transcript.push(("bot", reply));
59	}
60
61	/// The view: the transcript, then the composer and a status strip.
62	fn view(&self) -> View<Msg> {
63		let messages = self.transcript.iter().enumerate().map(|(i, (who, text))| {
64			let node = ui::md(text.clone()).key(i);
65			if *who == "you" {
66				ui::card().key(i).tone("user").child(node.key("t"))
67			} else {
68				ui::card().key(i).variant("bare").child(node.key("t"))
69			}
70		});
71		let cursor: usize = self
72			.draft
73			.chars()
74			.take(self.cursor)
75			.map(char::len_utf16)
76			.sum();
77		View::new().main(ui::col().children(messages)).dock(nodes![
78			ui::editor()
79				.key("ed")
80				.text(self.draft.clone())
81				.cursor(cursor)
82				.placeholder("Message (Enter sends, Escape quits)")
83				.sendable(true)
84				.on_send(|text| Msg::Send(text.to_owned()))
85				.on_focus(Msg::Focus),
86			ui::status()
87				.key("bar")
88				.child(ui::seg("chat").icon("message"))
89				.child(
90					ui::seg(format!("{} messages", self.transcript.len()))
91						.side(Side::Right)
92						.tone(if self.transcript.is_empty() {
93							Tone::Muted
94						} else {
95							Tone::Success
96						}),
97				),
98		])
99	}
100}
101
102#[cfg(test)]
103mod tests {
104	use super::*;
105
106	#[test]
107	fn composer_edits_codepoints_and_reports_utf16_cursors() {
108		let mut chat = Chat::default();
109		chat.insert("a😀é");
110		assert_eq!(chat.cursor, 3);
111		assert_eq!(chat.byte(2), 5);
112		chat.cursor = 2;
113		chat.insert("界");
114		assert_eq!(chat.draft, "a😀界é");
115		let view = chat.view();
116		let editor = &view.regions()[1].unwrap().child_nodes()[0];
117		assert_eq!(editor.props()["cursor"], 4);
118		let at = chat.byte(chat.cursor - 1);
119		chat.draft.remove(at);
120		chat.cursor -= 1;
121		assert_eq!(chat.draft, "a😀é");
122		let at = chat.byte(chat.cursor);
123		chat.draft.remove(at);
124		assert_eq!(chat.draft, "a😀");
125	}
126}
127
128fn main() -> Result<(), tern_sdk::Error> {
129	let Some(mut session) = Session::connect(Options::new().feature(feature::SEND))? else {
130		return plain();
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(_) | Input::Clipboard(_) => continue,
172		}
173		session.render(sf, chat.view())?;
174	}
175	session.close()?;
176	println!("Chat closed after {} messages.", chat.transcript.len());
177	Ok(())
178}
179
180/// Chats line by line on stdin and stdout, outside Tern.
181fn plain() -> Result<(), tern_sdk::Error> {
182	use std::io::{BufRead, Write};
183
184	let mut chat = Chat::default();
185	let mut out = std::io::stdout().lock();
186	write!(out, "> ")?;
187	out.flush()?;
188	for line in std::io::stdin().lock().lines() {
189		let before = chat.transcript.len();
190		chat.send(line?);
191		// `send` adds the message, then the reply; a blank line adds neither.
192		for (_, reply) in chat.transcript.iter().skip(before + 1) {
193			writeln!(out, "{reply}")?;
194		}
195		write!(out, "> ")?;
196		out.flush()?;
197	}
198	writeln!(out)?;
199	Ok(())
200}