1use tern_sdk::{
5 Input, Options, Session, SurfaceOptions, nodes,
6 ui::{self, Side, Tone, View},
7 wire::feature,
8};
9
10#[derive(Clone, Debug)]
12enum Msg {
13 Send(String),
15 Focus,
17}
18
19const EDITOR: &str = "dock.ed";
21
22#[derive(Default)]
24struct Chat {
25 transcript: Vec<(&'static str, String)>,
27 draft: String,
29 cursor: usize,
31}
32
33impl Chat {
34 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 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 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 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}