Skip to main content

custom/
custom.rs

1//! Customizing tuile: your own palette, your own spinner frames, and the AI widgets wired to
2//! a fake streaming model. Everything here is public API.
3//!
4//! ```sh
5//! cargo run -p tuile --example custom
6//! ```
7
8use tuile::prelude::*;
9
10/// A palette: name, dark?, primary; every other role is derived (override any with the setters).
11const BRAND: ThemeSpec = ThemeSpec::new("brand", true, Rgb(124, 58, 237));
12
13/// A spinner is just frames + interval; this one lives in your crate, not ours.
14const ORBIT: SpinnerDef = SpinnerDef::new("orbit", 120, &["●∙∙", "∙●∙", "∙∙●", "∙●∙"]);
15
16const REPLY: &str = "Sure - I'll switch the cache to an LRU with a 1 000 entry cap and add a hit/miss counter so we can see whether it earns its keep.";
17
18struct Demo {
19    chat: ChatState,
20    composer: ComposerState,
21    started: Instant,
22    stream_from: Option<Instant>,
23}
24
25impl App for Demo {
26    fn draw(&mut self, frame: &mut Frame, now: Instant) {
27        let area = frame.area();
28        let buf = frame.buffer_mut();
29        let th = theme::current();
30        fill(buf, area, th.background);
31
32        // stream the canned reply into the last message
33        if let Some(t0) = self.stream_from {
34            let shown = revealed(REPLY, elapsed(t0, now), 45.0);
35            if let Some(last) = self.chat.messages.last_mut() {
36                last.text.clear();
37                last.text.push_str(shown);
38            }
39            if shown.len() == REPLY.len() {
40                self.chat.finish_stream();
41                self.stream_from = None;
42            }
43        }
44
45        let [chat, status, composer] = Layout::vertical([
46            Constraint::Fill(1),
47            Constraint::Length(1),
48            Constraint::Length(5),
49        ])
50        .areas(pad(area, 1, 0));
51        ChatView::new()
52            .show_time(true)
53            .now(now)
54            .render(chat, buf, &mut self.chat);
55
56        // status line: a custom spinner while streaming, the context gauge always
57        let [left, right] =
58            Layout::horizontal([Constraint::Length(28), Constraint::Fill(1)]).areas(status);
59        if self.stream_from.is_some() {
60            Thinking::new("Generating")
61                .spinner(&ORBIT)
62                .started(self.started)
63                .now(now)
64                .render(left, buf);
65        } else {
66            Spinner::new(&spinners::SPARKLE)
67                .label("idle - Enter to send")
68                .now(now)
69                .render(left, buf);
70        }
71        let used = self
72            .chat
73            .messages
74            .iter()
75            .map(|m| m.text.len() as u32 / 4)
76            .sum();
77        ContextGauge::new(TokenUsage {
78            prompt: used,
79            completion: 0,
80            limit: 8_000,
81        })
82        .compact(true)
83        .render(right, buf);
84
85        PromptComposer::new()
86            .model("brand-1")
87            .focused(true)
88            .now(now)
89            .render(composer, buf, &mut self.composer);
90    }
91
92    fn event(&mut self, ev: Event, now: Instant) -> Flow {
93        if let Event::Key(k) = &ev
94            && (ctrl(k, 'c') || k.code == KeyCode::Esc)
95        {
96            return Flow::Quit;
97        }
98        self.chat.handle(&ev);
99        self.composer.handle(&ev);
100        if let Some(text) = self.composer.take_submitted() {
101            self.chat.push(ChatMessage::new(Role::User, text));
102            self.chat
103                .push(ChatMessage::new(Role::Assistant, "").streaming(true));
104            self.chat.scroll_to_end();
105            self.started = now;
106            self.stream_from = Some(now);
107        }
108        Flow::Continue
109    }
110
111    fn animating(&self, _now: Instant) -> bool {
112        true // spinner always visible
113    }
114}
115
116fn main() -> std::io::Result<()> {
117    theme::set(Theme::resolve(&BRAND, None));
118    let mut chat = ChatState::new();
119    chat.push(ChatMessage::new(
120        Role::System,
121        "brand-1 · custom theme · custom spinner",
122    ));
123    chat.push(ChatMessage::new(
124        Role::Assistant,
125        "Ask me anything. Replies are canned, the widgets are not.",
126    ));
127    run(&mut Demo {
128        chat,
129        composer: ComposerState::default(),
130        started: Instant::now(),
131        stream_from: None,
132    })
133}