#[allow(dead_code)]
mod cli;
mod config;
mod emote;
mod http;
mod key;
mod kick;
mod net;
mod twitch;
use heatsync_core::{clip, edit, slash, vi};
use std::io::{self, IsTerminal};
use std::sync::mpsc::{Receiver, Sender};
use std::time::{Duration, Instant};
use config::TabPos;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use emote::fb::FbEmotes;
use emote::render::{EmoteStore, EMOTE_H};
use heatsync_core::emote::{tokenize, EmoteSet, Token};
use heatsync_core::heat::Tier;
use heatsync_core::{mock, Channel, Message, Platform};
use net::{ChatEvent, Sub};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal};
use ratatui_image::Image;
use unicode_width::UnicodeWidthStr;
const BRAND: Color = Color::Indexed(208);
enum Feed {
Mock(mock::Driver),
Live {
rx: Receiver<ChatEvent>,
start: Instant,
connected: bool,
},
}
#[derive(Clone, Copy, PartialEq)]
enum InputMode {
Normal,
Visual,
Insert,
Join,
Manage,
Search,
}
struct App {
channels: Vec<Channel>,
emotes: Vec<EmoteSet>, focus: usize,
vi: vi::Vi,
paused: bool,
feed: Feed,
store: Option<EmoteStore>, fb: Option<FbEmotes>, mode: InputMode,
input: String,
line: edit::Line,
status: Option<String>, out: Option<net::Tx>, twitch_tx: Option<std::sync::mpsc::Sender<twitch::Send>>, kick_tx: Option<std::sync::mpsc::Sender<kick::Send>>, tab_pos: TabPos,
manage_cursor: usize, emote_tx: Sender<(Platform, String, EmoteSet)>,
emote_rx: Receiver<(Platform, String, EmoteSet)>,
}
#[derive(Clone, Copy)]
enum EmoteMode<'a> {
Term(&'a EmoteStore),
Fb(&'a FbEmotes),
Text,
}
impl EmoteMode<'_> {
fn cells(&self, url: &str) -> Option<u16> {
match self {
EmoteMode::Term(s) => s.cells(url),
EmoteMode::Fb(f) => f.cells(url),
EmoteMode::Text => None,
}
}
fn square_cells(&self) -> Option<u16> {
match self {
EmoteMode::Term(s) => Some(s.square_cells()),
EmoteMode::Fb(f) => Some(f.square_cells()),
EmoteMode::Text => None,
}
}
fn next_flip_in(&self) -> Option<Duration> {
match self {
EmoteMode::Term(s) => s.next_flip_in(),
EmoteMode::Fb(f) => f.next_flip_in(),
EmoteMode::Text => None,
}
}
}
const USAGE: &str = "\
heatsync-tui — heat-sorted live multichat in the terminal
USAGE:
heatsync-tui [CHANNEL...] open the chat UI on those channels
heatsync-tui <SUBCOMMAND>
SUBCOMMANDS:
log print recent messages
search search the chat archive
hot, top busiest channels right now
status connection + auth status
login link a twitch account
login kick link a kick account
probe check a channel is reachable
diag dump diagnostics
render-test draw the emote/paint test pattern
OPTIONS:
--mock run against a synthetic feed, no network
-h, --help
-V, --version
";
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("log") => return cli::log(&args[1..]),
Some("search") => return cli::search(&args[1..]),
Some("hot") | Some("top") => return cli::hot(&args[1..]),
Some("probe") => return cli::probe(&args[1..]),
Some("diag") => return cli::diag(&args[1..]),
Some("render-test") => return cli::render_test(&args[1..]),
Some("status") => return cli::status(),
Some("login") if args.get(1).map(String::as_str) == Some("kick") => return kick::login(),
Some("login") => return cli::login(),
Some("--version") | Some("-V") => {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
return Ok(());
}
Some("--help") | Some("-h") | Some("help") => {
print!("{USAGE}");
return Ok(());
}
_ => {}
}
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
eprintln!(
"heatsync-tui: not a terminal — the chat UI needs an interactive tty.\n\
Run it directly, or use a subcommand that writes to stdout:\n\
\n\
{USAGE}"
);
std::process::exit(1);
}
let mock_mode = args.iter().any(|a| a == "--mock");
let chan_args: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect();
let cfg = config::load();
let app = if mock_mode {
let (emote_tx, emote_rx) = std::sync::mpsc::channel();
App {
channels: mock::channels(),
emotes: (0..mock::channels().len())
.map(|_| EmoteSet::new())
.collect(),
focus: 0,
vi: vi::Vi::default(),
paused: false,
feed: Feed::Mock(mock::Driver::new()),
store: None,
fb: None,
mode: InputMode::Normal,
input: String::new(),
line: edit::Line::default(),
status: None,
out: None,
twitch_tx: None,
kick_tx: None,
tab_pos: cfg.tab_pos,
manage_cursor: 0,
emote_tx,
emote_rx,
}
} else {
build_live(&chan_args, cfg.tab_pos, cfg.channels)
};
let had_fb = app.fb.is_some();
let mut terminal = ratatui::init();
let res = run(&mut terminal, app);
ratatui::restore();
if had_fb {
use std::io::Write;
print!("\x1b[2J\x1b[H");
let _ = io::stdout().flush();
}
res
}
fn build_live(chan_args: &[&String], tab_pos: TabPos, saved: Vec<Sub>) -> App {
let subs: Vec<Sub> = if !chan_args.is_empty() {
chan_args.iter().map(|a| parse_sub(a)).collect()
} else {
saved
};
eprintln!(
"heatsync: fetching emotes + connecting to {} channels…",
subs.len()
);
let store = EmoteStore::new();
let fb = if store.is_none() {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
FbEmotes::open(cols, rows)
} else {
None
};
let tier = match (&store, &fb) {
(Some(s), _) => s.tier_label(),
(None, Some(_)) => "framebuffer",
_ => "text (no terminal graphics)",
};
eprintln!("heatsync: emotes = {tier}");
if store.is_none() && fb.is_none() {
eprintln!(
" → for image emotes, run in a graphics terminal: foot (linux), \
windows terminal (win), wezterm (any) — else names show as text"
);
}
let (emote_tx, emote_rx) = std::sync::mpsc::channel();
let mut channels = Vec::new();
let mut emotes = Vec::new();
for (platform, name) in &subs {
channels.push(Channel::new(name, *platform, 200));
emotes.push(EmoteSet::new());
spawn_emote_fetch(&emote_tx, *platform, name.clone());
}
let token = std::env::var("HEATSYNC_TOKEN")
.ok()
.filter(|t| !t.is_empty());
let (rx, out) = net::spawn(subs, token);
let auth = config::load_auth();
let kick_tx = auth.kick_token.clone().map(kick::spawn);
let twitch_tx = match (auth.twitch_user, auth.twitch_oauth) {
(Some(u), Some(o)) => Some(twitch::spawn(u, o)),
_ => None,
};
App {
channels,
emotes,
focus: 0,
vi: vi::Vi::default(),
paused: false,
feed: Feed::Live {
rx,
start: Instant::now(),
connected: false,
},
store,
fb,
mode: InputMode::Normal,
input: String::new(),
line: edit::Line::default(),
status: None,
out: Some(out),
twitch_tx,
kick_tx,
tab_pos,
manage_cursor: 0,
emote_tx,
emote_rx,
}
}
fn spawn_emote_fetch(tx: &Sender<(Platform, String, EmoteSet)>, platform: Platform, name: String) {
let tx = tx.clone();
std::thread::spawn(move || {
let set = http::emote_set(&name, platform).unwrap_or_default();
let _ = tx.send((platform, name, set));
});
}
fn drain_emotes(app: &mut App) {
while let Ok((platform, name, set)) = app.emote_rx.try_recv() {
if let Some(i) = app
.channels
.iter()
.position(|c| c.platform == platform && c.name.eq_ignore_ascii_case(&name))
{
app.emotes[i] = set;
}
}
}
fn save_state(app: &App) {
if !matches!(app.feed, Feed::Live { .. }) {
return;
}
let channels = app
.channels
.iter()
.map(|c| (c.platform, c.name.clone()))
.collect();
config::save(&config::Config {
tab_pos: app.tab_pos,
channels,
});
}
fn parse_sub(tok: &str) -> Sub {
if let Some(rest) = tok.strip_prefix("kick:") {
(Platform::Kick, rest.to_string())
} else if let Some(rest) = tok.strip_prefix("twitch:") {
(Platform::Twitch, rest.to_string())
} else {
(Platform::Twitch, tok.to_string())
}
}
fn run<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
let tick = Duration::from_millis(200);
let mut last = Instant::now();
let mut draw_cost = Duration::ZERO;
loop {
drain_emotes(&mut app);
if let Some(store) = &app.store {
store.begin_frame();
}
let drew_at = Instant::now();
terminal.draw(|f| ui(f, &app))?;
draw_cost = (draw_cost * 3 + drew_at.elapsed()) / 4;
if let Some(fb) = &app.fb {
fb.blit();
}
let tick_left = tick.saturating_sub(last.elapsed());
let wait = match emote_mode(&app).next_flip_in() {
Some(flip) => flip.max(draw_cost * 2).min(tick_left),
None => tick_left,
};
if event::poll(wait)? {
if let Event::Key(k) = event::read()? {
if matches!(k.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& handle_key(&mut app, k) == Flow::Quit
{
return Ok(());
}
}
}
if last.elapsed() >= tick {
if !app.paused {
advance(&mut app);
}
last = Instant::now();
}
}
}
#[derive(PartialEq)]
enum Flow {
Continue,
Quit,
}
fn handle_key(app: &mut App, k: crossterm::event::KeyEvent) -> Flow {
if k.modifiers.contains(KeyModifiers::CONTROL) && k.code == KeyCode::Char('c') {
return Flow::Quit;
}
match app.mode {
InputMode::Insert => {
if !matches!(k.code, KeyCode::Esc | KeyCode::Enter) {
app.status = None; }
let Some(ck) = key::to_core(k) else {
return Flow::Continue;
};
match app.line.key(ck) {
edit::Act::Continue => {}
edit::Act::Send => return send_focused(app),
edit::Act::Leave => app.mode = InputMode::Normal,
}
}
InputMode::Join => match k.code {
KeyCode::Esc => {
app.mode = InputMode::Normal;
app.input.clear();
}
KeyCode::Enter => join_channel(app),
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) => app.input.push(c),
_ => {}
},
InputMode::Manage => {
let last = app.channels.len().saturating_sub(1);
match k.code {
KeyCode::Esc | KeyCode::Char('q') => app.mode = InputMode::Normal,
KeyCode::Char('j') | KeyCode::Down => {
app.manage_cursor = (app.manage_cursor + 1).min(last)
}
KeyCode::Char('k') | KeyCode::Up => {
app.manage_cursor = app.manage_cursor.saturating_sub(1)
}
KeyCode::Char('g') | KeyCode::Home => app.manage_cursor = 0,
KeyCode::Char('G') | KeyCode::End => app.manage_cursor = last,
KeyCode::Enter | KeyCode::Char('l') => {
if !app.channels.is_empty() {
app.focus = app.manage_cursor.min(last);
app.vi.reset();
app.mode = InputMode::Normal;
}
}
KeyCode::Char('a') | KeyCode::Char('n') | KeyCode::Char('o') => {
app.mode = InputMode::Join;
app.input.clear();
}
KeyCode::Char('d') | KeyCode::Char('x') => manage_delete(app),
KeyCode::Char('K') => manage_move(app, -1),
KeyCode::Char('J') => manage_move(app, 1),
_ => {}
}
}
InputMode::Normal | InputMode::Visual => return normal_key(app, k),
InputMode::Search => match k.code {
KeyCode::Esc => {
app.mode = if app.vi.visual.is_some() {
InputMode::Visual
} else {
InputMode::Normal
};
app.input.clear();
}
KeyCode::Enter => run_search(app),
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) => app.input.push(c),
_ => {}
},
}
Flow::Continue
}
fn normal_key(app: &mut App, k: crossterm::event::KeyEvent) -> Flow {
let last = app
.channels
.get(app.focus)
.map_or(0, |c| c.messages.len().saturating_sub(1));
let page = app.vi.view.get().count.max(1);
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
if app.vi.g_pending {
app.vi.g_pending = false;
match k.code {
KeyCode::Char('g') => app.vi.to_oldest(last),
KeyCode::Char('t') => return tab_and_continue(app, 1),
KeyCode::Char('T') => return tab_and_continue(app, -1),
_ => return Flow::Continue,
}
app.vi.keep_visible();
return Flow::Continue;
}
if let KeyCode::Char(c) = k.code {
if !ctrl {
if let Some(d) = c.to_digit(10) {
if app.vi.push_digit(d) {
return Flow::Continue;
}
}
}
}
if ctrl {
match k.code {
KeyCode::Char('d') => {
let n = app.vi.take_count() * page.div_ceil(2);
app.vi.down(n)
}
KeyCode::Char('u') => {
let n = app.vi.take_count() * page.div_ceil(2);
app.vi.up(n, last)
}
KeyCode::Char('f') => {
let n = app.vi.take_count() * page;
app.vi.down(n)
}
KeyCode::Char('b') => {
let n = app.vi.take_count() * page;
app.vi.up(n, last)
}
_ => return Flow::Continue,
}
app.vi.keep_visible();
return Flow::Continue;
}
match k.code {
KeyCode::Char('q') => return Flow::Quit,
KeyCode::Char('j') | KeyCode::Down => {
let n = app.vi.take_count();
app.vi.down(n)
}
KeyCode::Char('k') | KeyCode::Up => {
let n = app.vi.take_count();
app.vi.up(n, last)
}
KeyCode::Char('G') => app.vi.to_newest(),
KeyCode::Char('g') => {
app.vi.g_pending = true;
return Flow::Continue;
}
KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => return tab_and_continue(app, -1),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => return tab_and_continue(app, 1),
KeyCode::Char('v') | KeyCode::Char('V') => {
if app.vi.visual.is_some() {
app.vi.visual = None;
app.mode = InputMode::Normal;
} else {
app.vi.visual = Some(app.vi.cursor);
app.mode = InputMode::Visual;
}
return Flow::Continue;
}
KeyCode::Char('y') => {
yank(app);
return Flow::Continue;
}
KeyCode::Char('/') => return search_prompt(app, vi::Dir::Fwd),
KeyCode::Char('?') => return search_prompt(app, vi::Dir::Back),
KeyCode::Char('n') => return repeat_search(app, false),
KeyCode::Char('N') => return repeat_search(app, true),
KeyCode::Esc => {
if app.vi.visual.is_some() || app.mode == InputMode::Visual {
app.vi.visual = None;
app.mode = InputMode::Normal;
} else if !app.vi.following() {
app.vi.reset();
} else {
app.status = None;
}
return Flow::Continue;
}
KeyCode::Char('i') | KeyCode::Char('a') => {
app.mode = InputMode::Insert;
app.line.focus();
app.status = None;
return Flow::Continue;
}
KeyCode::Char('o') => {
app.mode = InputMode::Join;
app.input.clear();
app.status = None;
return Flow::Continue;
}
KeyCode::Char('x') => {
close_channel(app);
return Flow::Continue;
}
KeyCode::Char('m') => {
app.mode = InputMode::Manage;
app.manage_cursor = app.focus;
return Flow::Continue;
}
KeyCode::Char('T') => {
app.tab_pos = app.tab_pos.next();
save_state(app);
return Flow::Continue;
}
KeyCode::Char(' ') => {
app.paused = !app.paused;
return Flow::Continue;
}
_ => return Flow::Continue,
}
app.vi.keep_visible();
Flow::Continue
}
fn tab_and_continue(app: &mut App, delta: i32) -> Flow {
if delta > 0 {
next_tab(app)
} else {
prev_tab(app)
}
Flow::Continue
}
fn search_prompt(app: &mut App, dir: vi::Dir) -> Flow {
app.vi.dir = Some(dir);
app.mode = InputMode::Search;
app.input.clear();
Flow::Continue
}
fn matches(m: &Message, pat: &str) -> bool {
let pat = pat.to_lowercase();
m.text.to_lowercase().contains(&pat) || m.user.to_lowercase().contains(&pat)
}
fn message_at(app: &App, i: usize) -> Option<&Message> {
let ch = app.channels.get(app.focus)?;
let n = ch.messages.len();
(i < n).then(|| &ch.messages[n - 1 - i])
}
fn run_search(app: &mut App) {
let pat = app.input.trim().to_string();
app.input.clear();
app.mode = if app.vi.visual.is_some() {
InputMode::Visual
} else {
InputMode::Normal
};
if pat.is_empty() {
return;
}
app.vi.pattern = pat;
jump_to_match(app, app.vi.dir.unwrap_or(vi::Dir::Fwd));
}
fn repeat_search(app: &mut App, flip: bool) -> Flow {
if app.vi.pattern.is_empty() {
app.status = Some("no previous search".into());
return Flow::Continue;
}
let dir = app.vi.dir.unwrap_or(vi::Dir::Fwd);
jump_to_match(app, if flip { dir.flip() } else { dir });
Flow::Continue
}
fn jump_to_match(app: &mut App, dir: vi::Dir) {
let len = app.channels.get(app.focus).map_or(0, |c| c.messages.len());
let pat = app.vi.pattern.clone();
let found = vi::search(len, app.vi.cursor, dir, |i| {
message_at(app, i).is_some_and(|m| matches(m, &pat))
});
match found {
Some(i) => {
app.vi.cursor = i;
app.vi.keep_visible();
app.status = None;
}
None => app.status = Some(format!("no match: {pat}")),
}
}
fn yank(app: &mut App) {
let (lo, hi) = app.vi.selection().unwrap_or((app.vi.cursor, app.vi.cursor));
let mut lines: Vec<String> = Vec::new();
for i in (lo..=hi).rev() {
if let Some(m) = message_at(app, i) {
lines.push(format!("{}: {}", m.user, m.text));
}
}
if lines.is_empty() {
app.status = Some("nothing to yank".into());
return;
}
let n = lines.len();
let body = lines.join("\n") + "\n";
app.status = Some(match clip::copy(&body) {
Ok(via) => format!("yanked {n} line{} → {via}", if n == 1 { "" } else { "s" }),
Err(e) => format!("yank failed: {e}"),
});
app.vi.visual = None;
app.mode = InputMode::Normal;
}
fn next_tab(app: &mut App) {
if app.focus + 1 < app.channels.len() {
app.focus += 1;
app.vi.reset();
}
}
fn prev_tab(app: &mut App) {
if app.focus > 0 {
app.focus -= 1;
app.vi.reset();
}
}
fn join_channel(app: &mut App) {
let tok = app.input.trim().to_string();
app.mode = InputMode::Normal;
app.input.clear();
open_channel(app, &tok);
}
fn open_channel(app: &mut App, tok: &str) {
if tok.is_empty() {
return;
}
let (platform, name) = parse_sub(tok);
if let Some(i) = app
.channels
.iter()
.position(|c| c.platform == platform && c.name.eq_ignore_ascii_case(&name))
{
app.focus = i; return;
}
if let Some(out) = &app.out {
let _ = out.send(net::Outbound::Join {
platform,
channel: name.clone(),
});
}
app.channels.push(Channel::new(&name, platform, 200));
app.emotes.push(EmoteSet::new()); spawn_emote_fetch(&app.emote_tx, platform, name);
app.focus = app.channels.len() - 1;
app.vi.reset();
save_state(app);
}
fn manage_delete(app: &mut App) {
if app.channels.is_empty() {
return;
}
let i = app.manage_cursor.min(app.channels.len() - 1);
let (platform, name) = {
let c = &app.channels[i];
(c.platform, c.name.clone())
};
if let Some(out) = &app.out {
let _ = out.send(net::Outbound::Part {
platform,
channel: name,
});
}
app.channels.remove(i);
app.emotes.remove(i);
let last = app.channels.len().saturating_sub(1);
app.manage_cursor = i.min(last);
app.focus = app.focus.min(last);
save_state(app);
}
fn manage_move(app: &mut App, delta: isize) {
let n = app.channels.len();
if n < 2 {
return;
}
let i = app.manage_cursor.min(n - 1);
let j = i as isize + delta;
if j < 0 || j >= n as isize {
return;
}
let j = j as usize;
app.channels.swap(i, j);
app.emotes.swap(i, j);
if app.focus == i {
app.focus = j;
} else if app.focus == j {
app.focus = i;
}
app.manage_cursor = j;
save_state(app);
}
fn part_channel(app: &mut App, which: Option<&str>) {
if let Some(tok) = which {
let (platform, name) = parse_sub(tok);
match app
.channels
.iter()
.position(|c| c.platform == platform && c.name.eq_ignore_ascii_case(&name))
{
Some(i) => app.focus = i,
None => {
app.status = Some(format!("not open: {name}"));
return;
}
}
}
close_channel(app);
}
fn close_channel(app: &mut App) {
if app.channels.is_empty() {
return;
}
let (platform, name) = {
let c = &app.channels[app.focus];
(c.platform, c.name.clone())
};
if let Some(out) = &app.out {
let _ = out.send(net::Outbound::Part {
platform,
channel: name,
});
}
app.channels.remove(app.focus);
app.emotes.remove(app.focus);
app.focus = app.focus.min(app.channels.len().saturating_sub(1));
app.vi.reset();
save_state(app);
}
fn send_focused(app: &mut App) -> Flow {
let text = match slash::parse(&app.line.text()) {
slash::Cmd::Join(ch) => {
app.line.accept();
open_channel(app, &ch);
return Flow::Continue;
}
slash::Cmd::Part(which) => {
app.line.accept();
part_channel(app, which.as_deref());
return Flow::Continue;
}
slash::Cmd::Quit => return Flow::Quit,
slash::Cmd::Usage(u) => {
app.status = Some(u.into());
return Flow::Continue;
}
slash::Cmd::Send(t) => t,
};
if text.is_empty() || app.channels.is_empty() {
return Flow::Continue;
}
let (platform, name) = {
let c = &app.channels[app.focus];
(c.platform, c.name.clone())
};
match platform {
Platform::Twitch => match &app.twitch_tx {
Some(tx) => {
let _ = tx.send((name.clone(), text));
app.line.accept();
app.status = Some(format!("sent → {name}"));
}
None => app.status = Some("no twitch token — run: heatsync login".into()),
},
Platform::Kick => {
if let Some(ktx) = &app.kick_tx {
let _ = ktx.send((name.clone(), text));
app.line.accept();
app.status = Some(format!("sent → {name}"));
} else if let Some(out) = &app.out {
let _ = out.send(net::Outbound::Chat {
platform: Platform::Kick,
channel: name.clone(),
text,
});
app.line.accept();
app.status = Some(format!("sent → {name} (via ext)"));
} else {
app.status = Some("no kick auth — run: heatsync login kick".into());
}
}
}
Flow::Continue
}
fn advance(app: &mut App) -> bool {
let focus = app.focus;
let App {
channels,
emotes,
feed,
store,
fb,
status,
vi,
..
} = app;
let mut added = 0usize;
match feed {
Feed::Mock(driver) => {
let before = channels.get(focus).map_or(0, |c| c.messages.len());
driver.tick(channels);
added = channels
.get(focus)
.map_or(0, |c| c.messages.len())
.saturating_sub(before);
}
Feed::Live {
rx,
start,
connected,
} => {
let now = start.elapsed().as_millis() as u64;
while let Ok(ev) = rx.try_recv() {
match ev {
ChatEvent::Line(l) => {
if let Some(i) = channels.iter().position(|c| {
c.platform == l.platform && c.name.eq_ignore_ascii_case(&l.channel)
}) {
channels[i].record(
Message {
user: l.user,
text: l.content,
color: l.color,
heat: 0.0,
},
now,
);
if i == focus {
added += 1;
}
}
}
ChatEvent::Connected => *connected = true,
ChatEvent::Disconnected => *connected = false,
ChatEvent::Auth(ok) => {
*status = Some(if ok {
"authenticated".into()
} else {
"auth failed — check HEATSYNC_TOKEN".into()
});
}
ChatEvent::SendResult { ok, error } => {
if !ok {
*status = Some(format!("send failed: {}", error.unwrap_or_default()));
}
}
}
}
for ch in channels.iter_mut() {
ch.cool(now);
}
}
}
let last = channels
.get(focus)
.map_or(0, |c| c.messages.len().saturating_sub(1));
vi.absorb_new(added, last);
vi.clamp(last);
let want_urls = |f: &mut dyn FnMut(&str)| {
for (ci, ch) in channels.iter().enumerate() {
let set = &emotes[ci];
if set.is_empty() {
continue;
}
let start = ch.messages.len().saturating_sub(60);
for m in ch.messages.iter().skip(start) {
each_stack(&m.text, set, |key| f(key));
}
}
};
if let Some(store) = store {
want_urls(&mut |u| store.request(u));
store.pump()
} else if let Some(fb) = fb {
want_urls(&mut |u| fb.request(u));
fb.pump();
false
} else {
false
}
}
const TAB_COL_W: u16 = 16;
fn emote_mode(app: &App) -> EmoteMode<'_> {
if let Some(s) = &app.store {
EmoteMode::Term(s)
} else if let Some(f) = &app.fb {
EmoteMode::Fb(f)
} else {
EmoteMode::Text
}
}
fn ui(f: &mut Frame, app: &App) {
let mode = emote_mode(app);
let [main, footer] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(f.area());
if app.mode == InputMode::Manage {
draw_manage(f, main, app);
draw_footer(f, footer, app, app.channels.len());
return;
}
if app.channels.is_empty() {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" no channels — press ",
Style::default().fg(Color::Indexed(244)),
),
Span::styled("o", Style::default().fg(BRAND).add_modifier(Modifier::BOLD)),
Span::styled(" to join one", Style::default().fg(Color::Indexed(244))),
])),
main,
);
draw_footer(f, footer, app, 0);
return;
}
let (tabs, chat) = match app.tab_pos {
TabPos::Top => {
let a = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(main);
(a[0], a[1])
}
TabPos::Bottom => {
let a = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(main);
(a[1], a[0])
}
TabPos::Left => {
let a =
Layout::horizontal([Constraint::Length(TAB_COL_W), Constraint::Min(0)]).split(main);
(a[0], a[1])
}
TabPos::Right => {
let a =
Layout::horizontal([Constraint::Min(0), Constraint::Length(TAB_COL_W)]).split(main);
(a[1], a[0])
}
};
draw_tabs(f, tabs, app);
draw_active(f, chat, app, mode);
draw_footer(f, footer, app, app.channels.len());
}
fn draw_tabs(f: &mut Frame, area: Rect, app: &App) {
let tab_style = |i: usize, heat: f64| {
if i == app.focus {
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Indexed(Tier::of(heat).xterm()))
}
};
if app.tab_pos.is_vertical() {
for (i, ch) in app.channels.iter().enumerate() {
if i as u16 >= area.height {
break;
}
let label = fit(format!(" {} {:.0} ", ch.name, ch.heat), area.width);
let row = Rect {
x: area.x,
y: area.y + i as u16,
width: area.width,
height: 1,
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(label, tab_style(i, ch.heat)))),
row,
);
}
} else {
let mut spans = Vec::new();
for (i, ch) in app.channels.iter().enumerate() {
spans.push(Span::styled(
format!(" {}·{} {:.0} ", ch.name, ch.platform.tag(), ch.heat),
tab_style(i, ch.heat),
));
spans.push(Span::raw(" "));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
}
fn draw_active(f: &mut Frame, area: Rect, app: &App, mode: EmoteMode) {
let ch = &app.channels[app.focus];
let set = &app.emotes[app.focus];
let [barrow, body] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
f.render_widget(
Paragraph::new(heat_bar(ch.heat, barrow.width as usize)),
barrow,
);
if body.height == 0 || body.width == 0 {
return;
}
let cap = body.height as usize;
let mut plan: Vec<(&Message, usize, usize)> = Vec::new();
let mut used = 0usize;
for (i, m) in ch.messages.iter().rev().skip(app.vi.scroll).enumerate() {
let h = if has_emote(m, set, mode) {
EMOTE_H as usize
} else {
1
};
if used + h > cap {
break;
}
used += h;
plan.push((m, h, app.vi.scroll + i));
}
app.vi.view.set(vi::View { count: plan.len() });
plan.reverse();
let show_cursor = !app.vi.following();
let mut y = body.y + (cap - used) as u16; for (m, h, idx) in plan {
let text_row = y + (h as u16 - 1);
let (mut line, places) = layout_message(m, set, mode, body.width);
let marked = app.vi.selected(idx) || (show_cursor && idx == app.vi.cursor);
let para = if marked {
let hl = Style::default().bg(Color::White).fg(Color::Black);
for span in line.spans.iter_mut() {
span.style = span.style.patch(hl);
}
Paragraph::new(line).style(hl)
} else {
Paragraph::new(line)
};
f.render_widget(
para,
Rect {
x: body.x,
y: text_row,
width: body.width,
height: 1,
},
);
for p in &places {
let x = body.x + p.col;
match mode {
EmoteMode::Term(store) => {
if let Some(proto) = store.frame(&p.key) {
f.render_widget(
Image::new(proto),
Rect {
x,
y,
width: p.w,
height: h as u16,
},
);
}
}
EmoteMode::Fb(fb) => {
let pad = "\u{a0}".repeat(p.w as usize);
let lines: Vec<Line> = (0..h).map(|_| Line::raw(pad.clone())).collect();
f.render_widget(
Paragraph::new(lines),
Rect {
x,
y,
width: p.w,
height: h as u16,
},
);
fb.push(x, y, &p.key);
}
EmoteMode::Text => {}
}
}
y += h as u16;
}
}
fn draw_manage(f: &mut Frame, area: Rect, app: &App) {
let [head, list] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" channels ",
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {} open", app.channels.len()),
Style::default().fg(Color::Indexed(244)),
),
])),
head,
);
if app.channels.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" empty — press a to add a channel",
Style::default().fg(Color::Indexed(244)),
))),
list,
);
return;
}
for (i, ch) in app.channels.iter().enumerate() {
if i as u16 >= list.height {
break;
}
let sel = i == app.manage_cursor;
let hue = Color::Indexed(Tier::of(ch.heat).xterm());
let style = if sel {
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(hue)
};
let cursor = if sel { "❯" } else { " " };
let active = if i == app.focus { "●" } else { " " };
let label = format!(
" {cursor} {active} {}·{} {:>6.0} {} msg",
ch.name,
ch.platform.tag(),
ch.heat,
ch.messages.len(),
);
let row = Rect {
x: list.x,
y: list.y + i as u16,
width: list.width,
height: 1,
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(fit(label, list.width), style))),
row,
);
}
}
fn each_stack(text: &str, set: &EmoteSet, mut f: impl FnMut(&str)) {
let mut urls: Vec<String> = Vec::new();
let flush = |urls: &mut Vec<String>, f: &mut dyn FnMut(&str)| {
if !urls.is_empty() {
f(&urls.join("\n"));
urls.clear();
}
};
for tok in tokenize(text, set) {
match tok {
Token::Emote(name) => {
if let Some(e) = set.get(&name) {
if e.zero_width && !urls.is_empty() {
urls.push(e.url.clone()); } else {
flush(&mut urls, &mut f);
urls.push(e.url.clone()); }
}
}
Token::Text(_) => flush(&mut urls, &mut f),
}
}
flush(&mut urls, &mut f);
}
fn has_emote(m: &Message, set: &EmoteSet, mode: EmoteMode) -> bool {
if mode.square_cells().is_none() {
return false; }
let mut any = false;
each_stack(&m.text, set, |_| any = true);
any
}
fn fit(s: String, w: u16) -> String {
let w = w as usize;
if UnicodeWidthStr::width(s.as_str()) <= w {
s
} else {
s.chars().take(w.saturating_sub(1)).collect()
}
}
fn fit_exact(s: &str, w: u16) -> String {
let w = w as usize;
let mut out = String::new();
let mut used = 0usize;
for c in s.chars() {
let cw = UnicodeWidthStr::width(c.to_string().as_str());
if used + cw > w {
break;
}
out.push(c);
used += cw;
}
out.extend(std::iter::repeat_n(' ', w - used));
out
}
struct Place {
col: u16,
w: u16,
key: String,
}
fn layout_message(
m: &Message,
set: &EmoteSet,
mode: EmoteMode,
maxw: u16,
) -> (Line<'static>, Vec<Place>) {
let text_hue = Color::Indexed(heatsync_core::heat::color(m.heat));
let user_color = m
.color
.as_deref()
.and_then(parse_hex)
.unwrap_or(Color::Indexed(244));
let mut spans = vec![
Span::styled(m.user.clone(), Style::default().fg(user_color)),
Span::styled(": ", Style::default().fg(Color::Indexed(244))),
];
let mut col = UnicodeWidthStr::width(m.user.as_str()) as u16 + 2;
let mut places = Vec::new();
let toks = tokenize(&m.text, set);
let mut i = 0;
while i < toks.len() {
if col >= maxw {
break;
}
match &toks[i] {
Token::Text(t) => {
col += UnicodeWidthStr::width(t.as_str()) as u16;
spans.push(Span::styled(t.clone(), Style::default().fg(text_hue)));
i += 1;
}
Token::Emote(name) => {
let base_name = name.clone();
let mut urls: Vec<String> =
set.get(name).map(|e| e.url.clone()).into_iter().collect();
i += 1;
while let Some(Token::Emote(n2)) = toks.get(i) {
match set.get(n2) {
Some(e2) if e2.zero_width => {
urls.push(e2.url.clone());
i += 1;
}
_ => break,
}
}
let key = urls.join("\n");
let w = match mode.cells(&key) {
Some(w) => Some((w, true)),
None => mode.square_cells().map(|w| (w, false)),
}
.filter(|(w, _)| col + w <= maxw && !urls.is_empty());
if let Some((w, ready)) = w {
if ready {
places.push(Place { col, w, key });
spans.push(Span::raw(" ".repeat(w as usize)));
} else {
spans.push(Span::styled(
fit_exact(&base_name, w),
Style::default().fg(BRAND),
));
}
col += w;
} else {
col += UnicodeWidthStr::width(base_name.as_str()) as u16;
spans.push(Span::styled(
base_name,
Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
));
}
}
}
}
(Line::from(spans), places)
}
fn parse_hex(hex: &str) -> Option<Color> {
let h = hex.strip_prefix('#')?;
if h.len() != 6 {
return None;
}
let r = u8::from_str_radix(&h[0..2], 16).ok()?;
let g = u8::from_str_radix(&h[2..4], 16).ok()?;
let b = u8::from_str_radix(&h[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
fn heat_bar(heat: f64, width: usize) -> Line<'static> {
let width = width.max(1);
let frac = (heat / heatsync_core::heat::MYTHIC).clamp(0.0, 1.0);
let filled = (frac * width as f64).round() as usize;
let hue = Color::Indexed(heatsync_core::heat::color(heat));
Line::from(vec![
Span::styled("\u{2588}".repeat(filled), Style::default().fg(hue)),
Span::styled(
"\u{2591}".repeat(width - filled),
Style::default().fg(Color::Indexed(236)),
),
])
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App, n: usize) {
if app.mode == InputMode::Manage {
let hint = |k: &'static str, d: &'static str| {
[
Span::styled(k, Style::default().fg(BRAND)),
Span::styled(format!(" {d} "), Style::default().fg(Color::Indexed(244))),
]
};
let mut spans = vec![
Span::styled(
" manage ",
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
for pair in [
hint("jk", "move"),
hint("enter", "open"),
hint("a", "add"),
hint("d", "leave"),
hint("JK", "reorder"),
hint("esc", "back"),
] {
spans.extend(pair);
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if app.mode == InputMode::Join {
let spans = vec![
Span::styled(
" join ",
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ❯ ", Style::default().fg(BRAND)),
Span::styled(app.input.clone(), Style::default().fg(Color::Indexed(231))),
Span::styled("\u{2588}", Style::default().fg(BRAND)),
Span::styled(
" name or kick:name",
Style::default().fg(Color::Indexed(244)),
),
];
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if app.mode == InputMode::Insert && !app.channels.is_empty() {
let ch = &app.channels[app.focus];
let readonly = match ch.platform {
Platform::Twitch => app.twitch_tx.is_none(),
Platform::Kick => app.kick_tx.is_none() && app.out.is_none(),
};
let normal = app.line.mode() == edit::Mode::Normal;
let prompt = if normal {
format!(" {}·{} NORMAL ", ch.name, ch.platform.tag())
} else {
format!(" {}·{} ", ch.name, ch.platform.tag())
};
let tag = Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD);
let mut spans = vec![
Span::styled(prompt, tag),
Span::styled(" ❯ ", Style::default().fg(BRAND)),
];
if readonly {
spans.push(Span::styled(
"read-only — no send token · esc",
Style::default().fg(Color::Indexed(214)),
));
} else {
let chars: Vec<char> = app.line.text().chars().collect();
let at = app.line.cursor();
let body = Style::default().fg(Color::Indexed(231));
let block = Style::default().fg(Color::Black).bg(Color::White);
let take = |r: std::ops::Range<usize>| -> String { chars[r].iter().collect() };
spans.push(Span::styled(take(0..at.min(chars.len())), body));
if at < chars.len() {
spans.push(Span::styled(take(at..at + 1), block));
spans.push(Span::styled(take(at + 1..chars.len()), body));
} else {
spans.push(Span::styled(" ", block));
}
if !app.line.pending().is_empty() {
spans.push(Span::styled(
format!(" {}", app.line.pending()),
Style::default().fg(BRAND).add_modifier(Modifier::BOLD),
));
}
if let Some(msg) = &app.status {
spans.push(Span::styled(
format!(" {msg}"),
Style::default().fg(Color::Indexed(214)),
));
} else if app.line.is_empty() && !normal {
spans.push(Span::styled(
" /join <chan> /part /quit — anything else goes to chat",
Style::default().fg(Color::Indexed(244)),
));
} else if normal {
spans.push(Span::styled(
" hjkw0$ move dcy ops kj history esc leave",
Style::default().fg(Color::Indexed(244)),
));
}
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if app.mode == InputMode::Search {
let lead = if app.vi.dir == Some(vi::Dir::Back) {
"?"
} else {
"/"
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!(" {lead} "),
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", app.input),
Style::default().fg(Color::Indexed(231)),
),
Span::styled("\u{2588}", Style::default().fg(BRAND)),
])),
area,
);
return;
}
if app.mode == InputMode::Visual {
let n = app.vi.selection().map_or(1, |(lo, hi)| hi - lo + 1);
let hint = |k: &'static str, d: &'static str| {
[
Span::styled(k, Style::default().fg(BRAND)),
Span::styled(format!(" {d} "), Style::default().fg(Color::Indexed(244))),
]
};
let mut spans = vec![
Span::styled(
" visual ",
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {n} line{} ", if n == 1 { "" } else { "s" }),
Style::default().fg(Color::Indexed(231)),
),
];
for pair in [
hint("jk", "extend"),
hint("y", "yank"),
hint("/", "search"),
hint("esc", "cancel"),
] {
spans.extend(pair);
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
let (dot, dot_color, state) = match &app.feed {
Feed::Mock(_) => ("\u{25cb} ", Color::Indexed(244), "mock".to_string()),
Feed::Live {
connected: true, ..
} => ("\u{25cf} ", Color::Indexed(46), "live".to_string()),
Feed::Live {
connected: false, ..
} => ("\u{25cf} ", Color::Indexed(214), "connecting".to_string()),
};
let mut spans = vec![Span::styled(
" heatsync ",
Style::default()
.fg(Color::Black)
.bg(BRAND)
.add_modifier(Modifier::BOLD),
)];
if let Some(msg) = &app.status {
spans.push(Span::styled(
format!(" {msg}"),
Style::default().fg(Color::Indexed(214)),
));
}
spans.extend([
Span::styled(" hl ", Style::default().fg(BRAND)),
Span::raw("chan "),
Span::styled("jk ", Style::default().fg(BRAND)),
Span::raw("move "),
Span::styled("v ", Style::default().fg(BRAND)),
Span::raw("select "),
Span::styled("y ", Style::default().fg(BRAND)),
Span::raw("yank "),
Span::styled("/ ", Style::default().fg(BRAND)),
Span::raw("find "),
Span::styled("i ", Style::default().fg(BRAND)),
Span::raw("say "),
Span::styled("o ", Style::default().fg(BRAND)),
Span::raw("join "),
Span::styled("m ", Style::default().fg(BRAND)),
Span::raw("manage "),
Span::styled("q ", Style::default().fg(BRAND)),
Span::raw("quit "),
]);
let pending = match (app.vi.count, app.vi.g_pending) {
(0, false) => String::new(),
(0, true) => "g".into(),
(n, g) => format!("{n}{}", if g { "g" } else { "" }),
};
if !pending.is_empty() {
spans.push(Span::styled(
format!("{pending} "),
Style::default()
.fg(Color::Indexed(231))
.add_modifier(Modifier::BOLD),
));
}
if !app.vi.following() {
spans.push(Span::styled(
"SCROLLBACK ",
Style::default()
.fg(Color::Indexed(214))
.add_modifier(Modifier::BOLD),
));
}
if app.paused {
spans.push(Span::styled(
"PAUSED ",
Style::default()
.fg(Color::Indexed(214))
.add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(dot, Style::default().fg(dot_color)));
spans.push(Span::styled(
format!("{state} · {n} ch"),
Style::default().fg(Color::Indexed(244)),
));
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
#[cfg(test)]
mod placeholder_tests {
use super::fit_exact;
use unicode_width::UnicodeWidthStr;
#[test]
fn placeholder_matches_the_reserved_footprint_exactly() {
for (name, w) in [("pokiDance", 4u16), ("Cat", 4), ("x", 2), ("", 3)] {
let out = fit_exact(name, w);
assert_eq!(
UnicodeWidthStr::width(out.as_str()),
w as usize,
"{name:?} at width {w} → {out:?}"
);
}
}
#[test]
fn placeholder_never_splits_a_wide_glyph() {
let out = fit_exact("\u{1f525}ok", 1);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 1);
assert_eq!(out, " "); }
#[test]
fn placeholder_keeps_what_fits() {
assert_eq!(fit_exact("pokiDance", 4), "poki");
assert_eq!(fit_exact("Cat", 5), "Cat ");
}
}
#[cfg(test)]
mod fb_reserve_tests {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
#[test]
fn nbsp_reservation_diffs_against_space() {
let area = Rect::new(0, 0, 4, 1);
let reserved = Buffer::with_lines(["\u{a0}\u{a0}\u{a0} "]);
let scrolled = Buffer::filled(area, ratatui::buffer::Cell::EMPTY);
assert_eq!(reserved.diff(&scrolled).len(), 3);
assert!(scrolled.diff(&scrolled).is_empty());
}
}
#[cfg(test)]
mod stack_tests {
use super::*;
use heatsync_core::emote::Emote;
fn em(name: &str, zw: bool) -> Emote {
Emote {
name: name.into(),
url: format!("u/{name}"),
provider: "7tv".into(),
id: name.into(),
animated: false,
zero_width: zw,
}
}
#[test]
fn overlay_groups_onto_base() {
let set = EmoteSet::from_list([em("GAMBA", false), em("notL", true)]);
let mut keys = Vec::new();
each_stack("GAMBA notL", &set, |k| keys.push(k.to_string()));
assert_eq!(keys, vec!["u/GAMBA\nu/notL"]); }
#[test]
fn text_breaks_stack() {
let set = EmoteSet::from_list([em("GAMBA", false), em("KEKW", false)]);
let mut keys = Vec::new();
each_stack("GAMBA lol KEKW", &set, |k| keys.push(k.to_string()));
assert_eq!(keys, vec!["u/GAMBA", "u/KEKW"]); }
#[test]
fn multiple_overlays_stack() {
let set = EmoteSet::from_list([em("A", false), em("h1", true), em("h2", true)]);
let mut keys = Vec::new();
each_stack("A h1 h2", &set, |k| keys.push(k.to_string()));
assert_eq!(keys, vec!["u/A\nu/h1\nu/h2"]);
}
}