use tern_sdk::{
Input, Options, Session, SurfaceOptions, nodes,
ui::{self, Side, Tone, View},
wire::feature,
};
#[derive(Clone, Debug)]
enum Msg {
Send(String),
Focus,
}
const EDITOR: &str = "dock.ed";
#[derive(Default)]
struct Chat {
transcript: Vec<(&'static str, String)>,
draft: String,
cursor: usize,
}
impl Chat {
fn byte(&self, chars: usize) -> usize {
self
.draft
.char_indices()
.nth(chars)
.map_or(self.draft.len(), |(i, _)| i)
}
fn insert(&mut self, text: &str) {
let at = self.byte(self.cursor);
self.draft.insert_str(at, text);
self.cursor += text.chars().count();
}
fn send(&mut self, text: String) {
if text.trim().is_empty() {
return;
}
let reply = format!("You said *{}* ({} chars).", text.trim(), text.chars().count());
self.transcript.push(("you", text));
self.transcript.push(("bot", reply));
}
fn view(&self) -> View<Msg> {
let messages = self.transcript.iter().enumerate().map(|(i, (who, text))| {
let node = ui::md(text.clone()).key(i);
if *who == "you" {
ui::card().key(i).tone("user").child(node.key("t"))
} else {
ui::card().key(i).variant("bare").child(node.key("t"))
}
});
let cursor: usize = self
.draft
.chars()
.take(self.cursor)
.map(char::len_utf16)
.sum();
View::new().main(ui::col().children(messages)).dock(nodes![
ui::editor()
.key("ed")
.text(self.draft.clone())
.cursor(cursor)
.placeholder("Message (Enter sends, Escape quits)")
.sendable(true)
.on_send(|text| Msg::Send(text.to_owned()))
.on_focus(Msg::Focus),
ui::status()
.key("bar")
.child(ui::seg("chat").icon("message"))
.child(
ui::seg(format!("{} messages", self.transcript.len()))
.side(Side::Right)
.tone(if self.transcript.is_empty() {
Tone::Muted
} else {
Tone::Success
}),
),
])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composer_edits_codepoints_and_reports_utf16_cursors() {
let mut chat = Chat::default();
chat.insert("a😀é");
assert_eq!(chat.cursor, 3);
assert_eq!(chat.byte(2), 5);
chat.cursor = 2;
chat.insert("界");
assert_eq!(chat.draft, "a😀界é");
let view = chat.view();
let editor = &view.regions()[1].unwrap().child_nodes()[0];
assert_eq!(editor.props()["cursor"], 4);
let at = chat.byte(chat.cursor - 1);
chat.draft.remove(at);
chat.cursor -= 1;
assert_eq!(chat.draft, "a😀é");
let at = chat.byte(chat.cursor);
chat.draft.remove(at);
assert_eq!(chat.draft, "a😀");
}
}
fn main() -> Result<(), tern_sdk::Error> {
let Some(mut session) = Session::connect(Options::new().feature(feature::SEND))? else {
println!("chat needs a terminal that speaks TSP (Tern).");
return Ok(());
};
let sf = session.open(SurfaceOptions::inline().title("chat"))?;
let mut chat = Chat::default();
session.render(sf, chat.view())?;
session.focus(sf, Some(EDITOR))?;
while let Some(input) = session.next(None)? {
match input {
Input::Key(key) => {
if key.is("escape") || key.is("ctrl+c") || key.is("ctrl+d") {
break;
}
match key.name.as_str() {
"enter" if !key.shift && !key.alt => {
let text = std::mem::take(&mut chat.draft);
chat.cursor = 0;
chat.send(text);
},
"enter" => chat.insert("\n"),
"backspace" if chat.cursor > 0 => {
let at = chat.byte(chat.cursor - 1);
chat.draft.remove(at);
chat.cursor -= 1;
},
"delete" if chat.cursor < chat.draft.chars().count() => {
let at = chat.byte(chat.cursor);
chat.draft.remove(at);
},
"left" => chat.cursor = chat.cursor.saturating_sub(1),
"right" => chat.cursor = (chat.cursor + 1).min(chat.draft.chars().count()),
"home" => chat.cursor = 0,
"end" => chat.cursor = chat.draft.chars().count(),
"paste" => chat.insert(key.text.as_deref().unwrap_or_default()),
_ => match key.typed() {
Some(text) => chat.insert(text),
None => continue,
},
}
},
Input::Msg(Msg::Send(text), _) => chat.send(text),
Input::Msg(Msg::Focus, _) => session.focus(sf, Some(EDITOR))?,
Input::Event(_) => continue,
}
session.render(sf, chat.view())?;
}
session.close()?;
println!("Chat closed after {} messages.", chat.transcript.len());
Ok(())
}