use crate::app::{App, Feed, InputMode};
use crate::config::TabPos;
use crate::emote::render::EMOTE_H;
use crate::input::slot_of;
use crate::layout::{fit, layout_message, layout_text, EmoteMode, Look, Place, RowPlan, Rows};
use crate::palette;
use heatsync_core::edit;
use heatsync_core::emote::{segments, EmoteSet, Segment};
use heatsync_core::heat::Tier;
use heatsync_core::i18n::{tr, trf};
use heatsync_core::scroll;
use heatsync_core::TabKind;
use heatsync_core::{Channel, Platform};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use ratatui_image::Image;
const TAB_COL_W: u16 = 16;
pub(crate) fn emote_mode(app: &App) -> EmoteMode<'_> {
match (&app.store, &app.fb) {
(Some(s), _) if s.usable() => EmoteMode::Term(s),
(None, Some(f)) => EmoteMode::Fb(f),
_ => EmoteMode::Text,
}
}
pub(crate) fn ui(f: &mut Frame, app: &App) {
app.chat_body.set(Rect::default());
let mode = emote_mode(app);
let preview_h = preview_height(app, mode);
let [main, preview, footer] = Layout::vertical([
Constraint::Min(0),
Constraint::Length(preview_h),
Constraint::Length(1),
])
.areas(f.area());
if preview_h > 0 {
draw_preview(f, preview, app, mode);
}
if let (InputMode::Pick, Some(p)) = (app.mode, &app.picker) {
f.render_widget(Paragraph::new(p.lines(main.width, main.height)), main);
draw_footer(f, footer, app, app.channels.len());
return;
}
if matches!(
app.mode,
InputMode::Manage | InputMode::SlotPick | InputMode::SlotEdit(_)
) {
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(format!(" {} ", tr("draw.no_channels_press")), palette::DIM),
Span::styled("o", palette::HEAD),
Span::styled(format!(" {}", tr("draw.to_join_one")), palette::DIM),
])),
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 preview_height(app: &App, mode: EmoteMode) -> u16 {
if app.mode != InputMode::Insert || app.channels.is_empty() || mode.square_cells().is_none() {
return 0;
}
let text = app.line.text();
if text.is_empty() {
return 0;
}
let set = &app.emotes[app.focus];
if segments(&text, set)
.iter()
.any(|s| matches!(s, Segment::Stack(_)))
{
EMOTE_H
} else {
0
}
}
fn draw_preview(f: &mut Frame, area: Rect, app: &App, mode: EmoteMode) {
let set = &app.emotes[app.focus];
let mut b = Rows::new(area.width);
b.prefix(Span::styled(" ❯ ", palette::TEXT), 3);
let text = app.line.text();
layout_text(&mut b, &text, set, EmoteSet::none(), mode);
if let Some(row) = b.finish().into_iter().next() {
draw_planned(f, mode, area, area.y, row.line, &row.places, EMOTE_H);
}
}
pub(crate) fn draw_tabs(f: &mut Frame, area: Rect, app: &App) {
let tab_style = |i: usize, ch: &Channel| {
let base = if i == app.focus {
palette::TAG
} else {
palette::tab(ch.unread())
};
if ch.temp {
base.add_modifier(Modifier::UNDERLINED)
} else {
base
}
};
let plus = |ch: &Channel| {
if ch.merged() {
format!("+{}", ch.extra.len())
} else {
String::new()
}
};
if app.tab_pos.is_vertical() {
for (i, ch) in app.channels.iter().enumerate() {
if i as u16 >= area.height {
break;
}
let label = fit(
if ch.kind.is_special() {
format!(" {} ", ch.name)
} else {
format!(" {}{} {:.0} ", ch.name, plus(ch), 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)))),
row,
);
}
} else {
let mut spans = Vec::new();
for (i, ch) in app.channels.iter().enumerate() {
let label = if ch.kind.is_special() {
format!(" {} ", ch.name)
} else {
format!(
" {}·{}{} {:.0} ",
ch.name,
ch.platform.tag(),
plus(ch),
ch.heat
)
};
spans.push(Span::styled(label, tab_style(i, ch)));
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 pin = ch.visible_pin();
let [barrow, pinrow, body] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(u16::from(pin.is_some())),
Constraint::Min(0),
])
.areas(area);
f.render_widget(
Paragraph::new(heat_bar(ch.heat, barrow.width as usize)),
barrow,
);
if let Some(p) = pin {
let line = fit(format!("◉ {}: {}", p.user, p.text), pinrow.width);
f.render_widget(
Paragraph::new(Line::from(Span::styled(line, palette::DIM_TAG))),
pinrow,
);
}
if body.height == 0 || body.width == 0 {
return;
}
let cap = body.height as usize;
let mut plan: Vec<Vec<RowPlan>> = Vec::new();
let mut used = 0usize;
app.view_h.set(body.height);
app.chat_body.set(body);
let cursor_on = app.mode == InputMode::Cursor;
let end = if cursor_on {
scroll::index_of(ch.seq, ch.messages.len(), app.cursor).map_or(0, |i| i + 1)
} else {
ch.messages.len()
};
for (n, m) in ch.messages.range(..end).rev().enumerate() {
let user = if crate::streamrow::is_row(m) {
Some(std::borrow::Cow::Owned(crate::streamrow::look(
m,
&app.paints,
&app.special.follow,
&app.seen,
)))
} else {
app.paints
.look_for(m.uid.as_deref(), &m.user, m.color.is_some())
};
let mut rows = layout_message(
m,
set,
mode,
body.width,
&Look {
me: &app.me,
tag_platform: ch.merged(),
stamps: app.stamps,
user: user.as_deref(),
},
);
if cursor_on && n == 0 {
for r in rows.iter_mut() {
for sp in r.line.spans.iter_mut() {
sp.style = sp.style.add_modifier(Modifier::REVERSED);
}
}
}
let h: usize = rows.iter().map(|r| r.h as usize).sum();
if used + h > cap {
if used == 0 {
let mut acc = 0usize;
let keep_from = rows
.iter()
.rposition(|r| {
acc += r.h as usize;
acc > cap
})
.map(|i| i + 1)
.unwrap_or(0);
rows.drain(..keep_from);
used = rows.iter().map(|r| r.h as usize).sum();
plan.push(rows);
}
break;
}
used += h;
plan.push(rows);
}
plan.reverse();
let mut y = body.y + (cap - used) as u16; for rows in plan {
for row in rows {
draw_planned(f, mode, body, y, row.line, &row.places, row.h);
y += row.h;
}
}
}
fn draw_planned(
f: &mut Frame,
mode: EmoteMode,
area: Rect,
y: u16,
line: Line,
places: &[Place],
h: u16,
) {
f.render_widget(
Paragraph::new(line),
Rect {
x: area.x,
y: y + h - 1,
width: area.width,
height: 1,
},
);
for p in places {
let x = area.x + p.col;
match mode {
EmoteMode::Term(store) => {
if let Some(proto) = store.frame(&p.key) {
let rect = Rect {
x,
y,
width: p.w,
height: h,
};
if store.is_sixel(&p.key) {
fill_nbsp(f, rect);
store.queue_blit(rect, &p.key);
} else {
f.render_widget(Image::new(proto), rect);
}
}
}
EmoteMode::Fb(fb) => {
fill_nbsp(
f,
Rect {
x,
y,
width: p.w,
height: h,
},
);
fb.push(x, y, &p.key);
}
EmoteMode::Text => {}
}
}
}
fn fill_nbsp(f: &mut Frame, rect: Rect) {
let pad = "\u{a0}".repeat(rect.width as usize);
let lines: Vec<Line> = (0..rect.height).map(|_| Line::raw(pad.clone())).collect();
f.render_widget(Paragraph::new(lines), rect);
}
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(format!(" {} ", tr("draw.channels")), palette::TAG),
Span::styled(
format!(
" {}",
trf("draw.n_open", &[("n", &app.channels.len().to_string())])
),
palette::DIM,
),
])),
head,
);
if app.channels.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {}", tr("draw.manage_empty")),
palette::DIM,
))),
list,
);
return;
}
for (i, ch) in app.channels.iter().enumerate() {
if i as u16 >= list.height {
break;
}
let sel = i == app.manage_cursor;
let style = if sel {
palette::TAG
} else {
palette::heat(Tier::of(ch.heat))
};
let style = if ch.temp {
style.add_modifier(Modifier::UNDERLINED)
} else {
style
};
let cursor = if sel { "❯" } else { " " };
let active = if i == app.focus { "●" } else { " " };
let tags = ch
.subs()
.map(|(p, _)| p.tag())
.collect::<Vec<_>>()
.join("+");
let label = format!(
" {cursor} {active} {}·{} {:>6.0} {}",
ch.name,
tags,
ch.heat,
trf("draw.n_msg", &[("n", &ch.messages.len().to_string())]),
);
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 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 = palette::heat_fill(Tier::of(heat));
Line::from(vec![
Span::styled("\u{2588}".repeat(filled), hue),
Span::styled("\u{2591}".repeat(width - filled), palette::TRACK),
])
}
fn hint(k: &'static str, d: &'static str) -> [Span<'static>; 2] {
[
Span::styled(k, palette::HEAD),
Span::styled(format!(" {} ", tr(d)), palette::DIM),
]
}
fn logged_out_live(app: &App) -> bool {
app.hs.is_none() && matches!(app.feed, Feed::Live { .. })
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App, n: usize) {
let tag = palette::TAG;
if app.mode == InputMode::Manage {
let mut spans = vec![
Span::styled(format!(" {} ", tr("draw.mode_manage")), tag),
Span::raw(" "),
];
for pair in [
hint("jk", "draw.h_move"),
hint("enter", "draw.h_open"),
hint("a", "draw.h_add"),
hint("e", "draw.h_edit"),
hint("x", "draw.h_leave"),
hint("JK", "draw.h_reorder"),
hint("esc", "draw.h_back"),
] {
spans.extend(pair);
}
if let Some(msg) = &app.status {
spans.push(Span::styled(format!(" {msg}"), palette::WARN));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if app.mode == InputMode::SlotPick {
let mut spans = vec![
Span::styled(format!(" {} ", tr("draw.mode_edit")), tag),
Span::raw(" "),
];
if let Some(ch) = app.channels.get(app.manage_cursor) {
for (key, p) in [
("t", Platform::Twitch),
("k", Platform::Kick),
("y", Platform::Youtube),
] {
spans.push(Span::styled(key, palette::HEAD));
spans.push(Span::styled(
format!(
" {}:{} ",
p.tag(),
slot_of(ch, p).unwrap_or_else(|| "—".to_string())
),
palette::DIM,
));
}
}
spans.extend(hint("esc", "draw.h_back"));
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if let InputMode::SlotEdit(p) = app.mode {
let spans = vec![
Span::styled(
format!(" {} ", trf("draw.edit_slot", &[("tag", p.tag())])),
tag,
),
Span::styled(" ❯ ", palette::TEXT),
Span::styled(app.input.clone(), palette::TEXT),
Span::styled("\u{2588}", palette::TEXT),
Span::styled(format!(" {}", tr("draw.slot_edit_hint")), palette::DIM),
];
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if app.mode == InputMode::Pick {
let mut spans = vec![
Span::styled(format!(" {} ", tr("draw.mode_pick")), tag),
Span::raw(" "),
];
for pair in [
hint("type", "draw.h_filter"),
hint("↑↓", "draw.h_move"),
hint("enter", "draw.h_join"),
hint("+", "draw.h_merge"),
hint("esc", "draw.h_close"),
] {
spans.extend(pair);
}
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 hs = app.hs.is_some();
let readonly = match (&app.feed, ch.kind, ch.platform) {
(Feed::Mock(_), ..) => false,
(_, TabKind::Mentions, _) => false,
(_, TabKind::Feed | TabKind::Whispers, _) => !hs,
(_, _, Platform::Twitch) => !hs && app.twitch_tx.is_none(),
(_, _, Platform::Kick) => !hs && app.kick_tx.is_none() && app.out.is_none(),
(_, _, Platform::Youtube) => app.out.is_none() || app.special.ws_cli,
};
let normal = app.line.mode() == edit::Mode::Normal;
let target = if ch.kind.is_special() {
ch.name.clone()
} else {
format!("{}·{}", ch.name, ch.platform.tag())
};
let prompt = if normal {
format!(" {target} {} ", tr("draw.normal"))
} else {
format!(" {target} ")
};
let tag = palette::TAG;
let mut spans = vec![
Span::styled(prompt, tag),
Span::styled(" ❯ ", palette::TEXT),
];
if let Some((_, user)) = &app.reply {
spans.push(Span::styled(format!("↪@{user} "), palette::DIM_TAG));
}
if readonly && app.line.text().is_empty() {
spans.push(Span::styled(tr("draw.readonly"), palette::WARN));
} else {
let chars: Vec<char> = app.line.text().chars().collect();
let at = app.line.cursor();
let body = palette::TEXT;
let block = palette::SEL;
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()),
palette::HEAD,
));
}
if let Some(msg) = &app.status {
spans.push(Span::styled(format!(" {msg}"), palette::WARN));
} else if app.line.is_empty() && !normal {
let hint = match ch.kind {
TabKind::Feed => tr("draw.hint_feed"),
TabKind::Whispers => tr("draw.hint_whispers"),
TabKind::Mentions => tr("draw.hint_mentions"),
TabKind::Channel => tr("draw.hint_channel"),
};
spans.push(Span::styled(format!(" {hint}"), palette::DIM));
} else if normal {
spans.push(Span::styled(
format!(" {}", tr("draw.normal_hint")),
palette::DIM,
));
}
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
let (dot, dot_color, state) = match &app.feed {
Feed::Mock(_) => ("\u{25cb} ", palette::DIM, tr("draw.state_mock")),
Feed::Live {
connected: true, ..
} => ("\u{25cf} ", palette::LIVE, tr("draw.state_live")),
Feed::Live {
connected: false, ..
} => ("\u{25cf} ", palette::WARN, tr("draw.state_connecting")),
};
let mut spans = vec![Span::styled(" heatsync ", palette::TAG)];
if app.mode == InputMode::Cursor {
if let Some(ch) = app.channels.get(app.focus) {
let newer = scroll::newer(ch.seq, ch.messages.len(), app.cursor);
spans.push(Span::styled(
format!(" {} ", trf("draw.n_new", &[("n", &newer.to_string())])),
palette::DIM_TAG,
));
}
for pair in [
hint("jk", "draw.h_move"),
hint("r", "draw.h_reply"),
hint("y", "draw.h_yank"),
hint("d", "draw.h_delete"),
hint("b", "draw.h_block_emote"),
hint("G", "draw.h_live"),
] {
spans.extend(pair);
}
if let Some(msg) = &app.status {
spans.push(Span::styled(format!(" {msg}"), palette::WARN));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
if let Some(msg) = &app.status {
spans.push(Span::styled(format!(" {msg}"), palette::WARN));
} else if logged_out_live(app) {
spans.push(Span::styled(
format!(" {}", tr("draw.not_logged_in")),
palette::DIM,
));
}
spans.push(Span::raw(" "));
for pair in [
hint("jk", "draw.h_chan"),
hint("i", "draw.h_say"),
hint("o", "draw.h_pick"),
hint("m", "draw.h_manage"),
] {
spans.extend(pair);
}
if app.channels.get(app.focus).is_some_and(|c| c.temp) {
spans.extend(hint("s", "draw.h_keep"));
}
spans.extend(hint("q", "draw.h_quit"));
if app.paused {
spans.push(Span::styled(
format!("{} ", tr("draw.paused")),
palette::WARN_BOLD,
));
}
spans.push(Span::styled(dot, dot_color));
spans.push(Span::styled(
trf(
"draw.footer_state",
&[("state", state), ("n", &n.to_string())],
),
palette::DIM,
));
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
#[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());
}
}