use crate::app::{App, Feed, InputMode};
use crate::feed::{spawn_emote_fetch, spawn_history, spawn_pin};
use crate::net::Sub;
use crate::{hsauth, key, modcmd, net, picker, special, talk};
use crate::{parse_sub, parse_tab, save_state, yt_video_id};
use crossterm::event::{KeyCode, KeyModifiers};
use heatsync_core::complete::Completion;
use heatsync_core::{clip, edit, layout, scroll, slash};
use heatsync_core::{Channel, Message, Platform};
use std::time::Instant;
#[derive(PartialEq)]
pub(crate) enum Flow {
Continue,
Quit,
}
pub(crate) 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 => {
match k.code {
KeyCode::Tab => {
complete_step(app, 1);
return Flow::Continue;
}
KeyCode::BackTab => {
complete_step(app, -1);
return Flow::Continue;
}
_ => app.completion = None,
}
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;
app.reply = None;
}
}
}
InputMode::Pick => pick_key(app, k),
InputMode::Cursor => return cursor_key(app, k),
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.mode = InputMode::Normal;
}
}
KeyCode::Char('a') | KeyCode::Char('o') => open_picker(app),
KeyCode::Char('x') => manage_delete(app),
KeyCode::Char('e') => {
let real = app
.channels
.get(app.manage_cursor)
.is_some_and(|c| !c.kind.is_special());
if real {
app.mode = InputMode::SlotPick;
app.status = None;
}
}
KeyCode::Char('K') => manage_move(app, -1),
KeyCode::Char('J') => manage_move(app, 1),
_ => {}
}
}
InputMode::SlotPick => match k.code {
KeyCode::Esc | KeyCode::Char('q') => app.mode = InputMode::Manage,
KeyCode::Char('t') => slot_open(app, Platform::Twitch),
KeyCode::Char('k') => slot_open(app, Platform::Kick),
KeyCode::Char('y') => slot_open(app, Platform::Youtube),
_ => {}
},
InputMode::SlotEdit(p) => match k.code {
KeyCode::Esc => {
app.mode = InputMode::SlotPick;
app.input.clear();
}
KeyCode::Enter => slot_apply(app, p),
KeyCode::Backspace => {
app.input.pop();
}
KeyCode::Char(c) => app.input.push(c),
_ => {}
},
InputMode::Normal => return normal_key(app, k),
}
Flow::Continue
}
fn normal_key(app: &mut App, k: crossterm::event::KeyEvent) -> Flow {
match k.code {
KeyCode::Char('q') => Flow::Quit,
KeyCode::Char('j') | KeyCode::Char('l') | KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
tab_and_continue(app, 1)
}
KeyCode::Char('k')
| KeyCode::Char('h')
| KeyCode::Up
| KeyCode::Left
| KeyCode::BackTab => tab_and_continue(app, -1),
KeyCode::Char('i') | KeyCode::Char('a') => {
app.mode = InputMode::Insert;
app.line.focus();
app.status = None;
Flow::Continue
}
KeyCode::Char('o') => {
open_picker(app);
Flow::Continue
}
KeyCode::Enter => {
special::enter(app);
Flow::Continue
}
KeyCode::Char('x') => {
close_channel(app);
Flow::Continue
}
KeyCode::Char('s') => {
crate::temp::keep(app);
Flow::Continue
}
KeyCode::Char('m') => {
app.mode = InputMode::Manage;
app.manage_cursor = app.focus;
Flow::Continue
}
KeyCode::Char('v') => {
if let Some(ch) = app
.channels
.get(app.focus)
.filter(|c| !c.messages.is_empty())
{
app.cursor = scroll::abs_of(ch.seq, ch.messages.len(), ch.messages.len() - 1);
app.mode = InputMode::Cursor;
app.status = None;
}
Flow::Continue
}
KeyCode::Char('P') => {
let hid = app
.channels
.get_mut(app.focus)
.is_some_and(|c| c.dismiss_pin());
app.status = Some(if hid { "pin hidden" } else { "no pin here" }.into());
Flow::Continue
}
KeyCode::Char('t') => {
app.stamps = !app.stamps;
app.status = Some(format!(
"timestamps {}",
if app.stamps { "on" } else { "off" }
));
save_state(app);
Flow::Continue
}
KeyCode::Char('T') => {
app.tab_pos = app.tab_pos.next();
save_state(app);
Flow::Continue
}
KeyCode::Char(' ') => {
app.paused = !app.paused;
Flow::Continue
}
KeyCode::Esc => {
app.status = None;
Flow::Continue
}
_ => 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 next_tab(app: &mut App) {
if app.focus + 1 < app.channels.len() {
app.focus += 1;
}
}
fn prev_tab(app: &mut App) {
if app.focus > 0 {
app.focus -= 1;
}
}
fn open_picker(app: &mut App) {
app.status = None;
app.mode = InputMode::Pick;
if let Some(f) = app.pick_cache.fresh() {
app.picker = Some(picker::Picker::new(Some(f.clone()), false));
return;
}
app.picker = Some(picker::Picker::new(app.pick_cache.any().cloned(), true));
if app.pick_inflight {
return;
}
app.pick_inflight = true;
let mine: Vec<(Platform, String)> = app
.channels
.iter()
.flat_map(|c| c.subs().map(|(p, n)| (p, n.to_string())))
.collect();
let tx = app.pick_tx.clone();
let hs = app.hs.clone();
std::thread::spawn(move || {
let _ = tx.send(picker::fetch(&mine, hs.as_ref()));
});
}
pub(crate) fn drain_picker(app: &mut App) {
while let Ok(f) = app.pick_rx.try_recv() {
app.pick_inflight = false;
if f.error.is_none() {
app.pick_cache = picker::Cache(Some((Instant::now(), f.clone())));
}
if let Some(p) = &mut app.picker {
p.set(f);
}
}
}
fn pick_key(app: &mut App, k: crossterm::event::KeyEvent) {
let Some(p) = &mut app.picker else {
app.mode = InputMode::Normal;
return;
};
match p.key(k) {
picker::Action::None => {}
picker::Action::Close => {
app.picker = None;
app.mode = InputMode::Normal;
}
picker::Action::Join(spec) => {
app.picker = None;
app.mode = InputMode::Normal;
crate::temp::open(app, &spec);
}
picker::Action::Merge(spec) => {
app.picker = None;
app.mode = InputMode::Normal;
let cur: Vec<String> = app
.channels
.get(app.focus)
.map(|c| c.subs().map(|(p, n)| picker::spec_of(p, n)).collect())
.unwrap_or_default();
let tok = if cur.is_empty() {
spec
} else {
format!("{}+{spec}", cur.join("+"))
};
open_channel(app, &tok);
}
}
}
pub(crate) fn open_channel(app: &mut App, tok: &str) {
let subs = parse_tab(tok);
let Some((platform, name)) = subs.first().cloned() else {
return;
};
if let Some(i) = app.channels.iter().position(|c| c.matches(platform, &name)) {
let fresh: Vec<Sub> = subs
.into_iter()
.filter(|(p, n)| !app.channels[i].matches(*p, n))
.collect();
for (p, n) in &fresh {
start_sub(app, *p, n);
}
app.channels[i].extra.extend(fresh);
app.channels[i].temp = false; app.focus = i;
save_state(app);
return;
}
add_tab(app, &subs, false);
save_state(app);
}
pub(crate) fn add_tab(app: &mut App, subs: &[Sub], temp: bool) {
let Some((platform, name)) = subs.first() else {
return;
};
let mut ch = Channel::new(name, *platform, 200);
ch.extra = subs[1..].to_vec();
ch.temp = temp;
for (p, n) in ch.subs() {
start_sub(app, p, n);
}
app.channels.push(ch);
app.emotes.push(app.globals.clone());
app.focus = app.channels.len() - 1;
}
fn share_layout(app: &mut App) {
let tabs: Vec<layout::Tab> = app
.channels
.iter()
.filter(|c| !c.temp) .map(|c| {
let mut t = layout::Tab::default();
for (p, n) in c.subs() {
let n = Some(n.to_ascii_lowercase());
match p {
Platform::Twitch => t.twitch = t.twitch.take().or(n),
Platform::Kick => t.kick = t.kick.take().or(n),
Platform::Youtube => {}
}
}
t
})
.collect();
let l = layout::build(&tabs);
if l.is_empty() {
app.status = Some("nothing to share — open a twitch or kick channel".into());
return;
}
let link = format!("https://heatsync.org/m/{l}");
app.status = Some(match clip::copy(&link) {
Ok(via) => format!("copied ({via}) {link}"),
Err(e) => format!("{e} — {link}"),
});
}
fn open_layout(app: &mut App, link: &str) {
let tabs = layout::parse(link);
if tabs.is_empty() {
app.status = Some("no channels in that link".into());
return;
}
let mut yt = 0;
let mut opened = 0;
for t in &tabs {
let mut specs: Vec<String> = Vec::new();
if let Some(n) = &t.twitch {
specs.push(n.clone());
}
if let Some(n) = &t.kick {
specs.push(format!("kick:{n}"));
}
if t.youtube.is_some() {
yt += 1;
}
if !specs.is_empty() {
open_channel(app, &specs.join("+"));
opened += 1;
}
}
app.status = Some(match (opened, yt) {
(0, _) => "link only has youtube — join a live video with /join yt:<id>".into(),
(n, 0) => format!("opened {n} tab(s)"),
(n, y) => format!("opened {n} tab(s), skipped {y} youtube (needs a live video id)"),
});
}
fn start_sub(app: &App, p: Platform, n: &str) {
if let Some(out) = &app.out {
let _ = out.send(net::Outbound::Join {
platform: p,
channel: n.to_string(),
});
}
if p != Platform::Youtube {
spawn_emote_fetch(&app.emote_tx, p, n.to_string());
spawn_history(&app.hist_tx, p, n.to_string());
spawn_pin(&app.pin_tx, p, n.to_string());
}
}
pub(crate) fn slot_of(ch: &Channel, p: Platform) -> Option<String> {
ch.subs().find(|(q, _)| *q == p).map(|(_, n)| n.to_string())
}
fn slot_open(app: &mut App, p: Platform) {
if app.channels.is_empty() {
app.mode = InputMode::Manage;
return;
}
let i = app.manage_cursor.min(app.channels.len() - 1);
app.input = slot_of(&app.channels[i], p).unwrap_or_default();
app.mode = InputMode::SlotEdit(p);
}
fn slot_apply(app: &mut App, p: Platform) {
let raw = app.input.trim().to_string();
app.input.clear();
app.mode = InputMode::Manage;
if app.channels.is_empty() {
return;
}
let new = match p {
Platform::Youtube if !raw.is_empty() => yt_video_id(&raw),
_ => raw,
};
let i = app.manage_cursor.min(app.channels.len() - 1);
let old = slot_of(&app.channels[i], p);
if old.as_deref().is_some_and(|o| o.eq_ignore_ascii_case(&new)) {
return; }
if new.is_empty() {
let Some(old) = old else { return };
let ch = &mut app.channels[i];
if ch.extra.is_empty() {
app.status = Some("last source — x leaves the tab".to_string());
return;
}
if ch.platform == p {
let (np, nn) = ch.extra.remove(0);
ch.platform = np;
ch.name = nn;
} else if let Some(j) = ch.extra.iter().position(|(q, _)| *q == p) {
ch.extra.remove(j);
}
if let Some(out) = &app.out {
let _ = out.send(net::Outbound::Part {
platform: p,
channel: old,
});
}
save_state(app);
return;
}
if app.channels[i].matches(p, &new) {
app.status = Some(format!("{}:{new} already in this tab", p.tag()));
return;
}
if app
.channels
.iter()
.enumerate()
.any(|(j, c)| j != i && c.matches(p, &new))
{
app.status = Some(format!("{}:{new} is open in another tab", p.tag()));
return;
}
match old {
Some(old) => {
if let Some(out) = &app.out {
let _ = out.send(net::Outbound::Part {
platform: p,
channel: old,
});
}
let ch = &mut app.channels[i];
if ch.platform == p {
ch.name = new.clone();
} else if let Some(j) = ch.extra.iter().position(|(q, _)| *q == p) {
ch.extra[j].1 = new.clone();
}
}
None => app.channels[i].extra.push((p, new.clone())),
}
start_sub(app, p, &new);
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);
part_subs(&app.out, &app.channels[i]);
special::on_close(app, i);
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;
if app.channels[i].kind.is_special() || app.channels[j].kind.is_special() {
return;
}
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);
}
pub(crate) fn part_subs(out: &Option<net::Tx>, ch: &Channel) {
if let Some(out) = out {
for (p, n) in ch.subs() {
let _ = out.send(net::Outbound::Part {
platform: p,
channel: n.to_string(),
});
}
}
}
fn close_channel(app: &mut App) {
if app.channels.is_empty() {
return;
}
part_subs(&app.out, &app.channels[app.focus]);
special::on_close(app, app.focus);
app.channels.remove(app.focus);
app.emotes.remove(app.focus);
app.focus = app.focus.min(app.channels.len().saturating_sub(1));
save_state(app);
}
fn send_focused(app: &mut App) -> Flow {
let reply = app.reply.as_ref().map(|(id, _)| id.clone());
let flow = send_line(app, reply);
if app.line.is_empty() {
app.reply = None;
}
flow
}
fn send_line(app: &mut App, reply: Option<String>) -> 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::Open(ch) => {
app.line.accept();
crate::temp::open(app, &ch);
return Flow::Continue;
}
slash::Cmd::Keep => {
app.line.accept();
crate::temp::keep(app);
return Flow::Continue;
}
slash::Cmd::Part(which) => {
app.line.accept();
part_channel(app, which.as_deref());
return Flow::Continue;
}
slash::Cmd::Tab(kind) => {
app.line.accept();
special::open(app, kind);
return Flow::Continue;
}
slash::Cmd::Whisper(to, t) => {
if talk::direct(app, false, &to, &t) {
app.line.accept();
}
return Flow::Continue;
}
slash::Cmd::Dm(to, t) => {
if talk::direct(app, true, &to, &t) {
app.line.accept();
}
return Flow::Continue;
}
slash::Cmd::Op(t) => {
if talk::post(app, &t, None) {
app.line.accept();
}
return Flow::Continue;
}
slash::Cmd::Re(t) => {
if talk::re(app, &t) {
app.line.accept();
}
return Flow::Continue;
}
slash::Cmd::Share => {
app.line.accept();
share_layout(app);
return Flow::Continue;
}
slash::Cmd::Layout(l) => {
app.line.accept();
open_layout(app, &l);
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 kind = app.channels[app.focus].kind;
if kind.is_special() {
if special::send(app, kind, &text, reply) {
app.line.accept();
}
return Flow::Continue;
}
if let Some(p) = modcmd::parse(&text, |u| last_id_of(app, u)) {
run_mod(app, p);
return Flow::Continue;
}
if matches!(app.feed, Feed::Mock(_)) {
let ch = &mut app.channels[app.focus];
let now = ch.last_ms;
ch.record(
Message {
platform: ch.platform,
user: "you".into(),
text,
color: Some("#ff8700".into()),
badges: Vec::new(),
id: None,
gone: None,
reply_to: None,
note: None,
extra: Default::default(),
emotes: None,
tags: Vec::new(),
heat: 0.0,
uid: None,
},
now,
);
app.line.accept();
return Flow::Continue;
}
let (platform, name) = {
let c = &app.channels[app.focus];
(c.platform, c.name.clone())
};
match platform {
Platform::Twitch => {
if let Some(client) = app.hs.clone() {
send_via_heatsync(app, client, platform, name.clone(), text, reply);
app.line.accept();
app.status = Some(format!("sent → {name}"));
} else {
match &app.twitch_tx {
Some(tx) => {
if tx.send((name.clone(), text, reply)).is_ok() {
app.line.accept();
app.status = Some(format!("sent → {name}"));
crate::temp::sent(app, platform, &name);
} else {
app.status =
Some("twitch sender stopped — run: heatsync-tui login".into());
}
}
None => app.status = Some("not logged in — run: heatsync-tui login".into()),
}
}
}
Platform::Kick => {
if let Some(client) = app.hs.clone() {
send_via_heatsync(app, client, platform, name.clone(), text, reply);
app.line.accept();
app.status = Some(format!("sent → {name}"));
} else if let Some(ktx) = &app.kick_tx {
let _ = ktx.send((name.clone(), text, reply));
app.line.accept();
app.status = Some(format!("sent → {name}"));
crate::temp::sent(app, platform, &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("not logged in — run: heatsync-tui login kick".into());
}
}
Platform::Youtube if app.special.ws_cli => {
app.status = Some("youtube sends need the extension".into())
}
Platform::Youtube => match &app.out {
Some(out) => {
let _ = out.send(net::Outbound::Chat {
platform: Platform::Youtube,
channel: name.clone(),
text,
});
app.line.accept();
app.status = Some(format!("sent → {name} (via ext)"));
}
None => app.status = Some("no send path — set HEATSYNC_TOKEN".into()),
},
}
Flow::Continue
}
fn send_via_heatsync(
app: &App,
client: hsauth::Client,
platform: Platform,
name: String,
text: String,
reply: Option<String>,
) {
let chan = name.clone();
hsauth::spawn_send(app.hs_tx.clone(), platform, name, move || {
match client.send(platform, &chan, &text, reply.as_deref()) {
Ok(()) => hsauth::Outcome {
ok: true,
text: String::new(),
},
Err(e) => hsauth::Outcome { ok: false, text: e },
}
});
}
fn complete_step(app: &mut App, dir: i32) {
if app.line.mode() != edit::Mode::Insert {
return;
}
if app.completion.is_none() {
let Some(ch) = app.channels.get(app.focus) else {
return;
};
let mut users: Vec<&str> = Vec::new();
for m in ch.messages.iter().rev() {
if !m.user.is_empty() && !users.iter().any(|u| u.eq_ignore_ascii_case(&m.user)) {
users.push(&m.user);
}
if users.len() >= 50 {
break;
}
}
let text = app.line.text();
app.completion = Completion::build(
&text,
app.line.cursor(),
app.emotes[app.focus].names(),
users.into_iter(),
);
}
if let Some(c) = &mut app.completion {
let (lo, hi, s) = c.advance(dir);
let s = s.to_string();
app.line.replace_range(lo, hi, &s);
}
}
fn run_mod(app: &mut App, parsed: modcmd::Parsed) {
run_mod_from(app, parsed, true)
}
fn run_mod_from(app: &mut App, parsed: modcmd::Parsed, from_composer: bool) {
let (action, target) = match parsed {
modcmd::Parsed::Usage(u) => {
app.status = Some(u.into()); return;
}
modcmd::Parsed::Run(a, t) => (a, t),
};
let (platform, name) = {
let c = &app.channels[app.focus];
(c.platform, c.name.clone())
};
if matches!(app.feed, Feed::Mock(_)) {
app.status = Some("offline demo — mod actions are off".into());
return;
}
match platform {
Platform::Twitch => {}
Platform::Kick => {
app.status = Some("mod tools aren't on kick yet — use kick.com for that".into());
return;
}
Platform::Youtube => {
app.status = Some("mod tools are twitch-only for now".into());
return;
}
}
let Some(client) = app.hs.clone() else {
app.status = Some("log in first to use mod tools — run: heatsync-tui login".into());
return;
};
if from_composer {
app.line.accept();
}
app.status = Some("working…".into());
let chan = name.clone();
hsauth::spawn_note(app.hs_tx.clone(), platform, name, move || {
client.mod_action(platform, &chan, &target, &action)
});
}
fn last_id_of(app: &App, user: &str) -> Option<String> {
app.channels
.get(app.focus)?
.messages
.iter()
.rev()
.find(|m| m.user.eq_ignore_ascii_case(user) && m.id.is_some())
.and_then(|m| m.id.clone())
}
fn cursor_key(app: &mut App, k: crossterm::event::KeyEvent) -> Flow {
let Some(ch) = app.channels.get(app.focus) else {
app.mode = InputMode::Normal;
return Flow::Continue;
};
let (seq, len) = (ch.seq, ch.messages.len());
let half = i64::from((app.view_h.get() / 2).max(1));
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
let mv = |app: &mut App, d: i64| app.cursor = scroll::step(seq, len, app.cursor, d);
match k.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('G') => app.mode = InputMode::Normal,
KeyCode::Char('d') if ctrl => mv(app, half),
KeyCode::Char('u') if ctrl => mv(app, -half),
KeyCode::Char('j') | KeyCode::Down => mv(app, 1),
KeyCode::Char('k') | KeyCode::Up => mv(app, -1),
KeyCode::Char('g') | KeyCode::Home => mv(app, i64::MIN),
KeyCode::Char('r') => cursor_reply(app),
KeyCode::Char('y') => cursor_yank(app),
KeyCode::Char('d') => cursor_delete(app),
_ => {}
}
Flow::Continue
}
fn cursor_line(app: &App) -> Option<&Message> {
let ch = app.channels.get(app.focus)?;
let i = scroll::index_of(ch.seq, ch.messages.len(), app.cursor)?;
ch.messages.get(i)
}
fn cursor_reply(app: &mut App) {
let target = cursor_line(app)
.filter(|m| m.note.is_none() && !m.user.is_empty())
.and_then(|m| Some((m.id.clone()?, m.user.clone())));
if let Some(cmd) = cursor_line(app).and_then(talk::reply_prefill) {
app.reply = None;
app.mode = InputMode::Insert;
app.line.reset();
app.line.replace_range(0, 0, &cmd);
app.line.focus();
app.status = None;
return;
}
match target {
Some(t) => {
app.reply = Some(t);
app.mode = InputMode::Insert;
app.line.focus();
app.status = None;
}
None => app.status = Some("can't reply to that line".into()),
}
}
fn cursor_yank(app: &mut App) {
let Some(m) = cursor_line(app) else { return };
let text = if m.text.is_empty() {
m.note.as_ref().map(|n| n.what.clone()).unwrap_or_default()
} else {
m.text.clone()
};
app.status = Some(match clip::copy(&text) {
Ok(via) => format!("yanked ({via})"),
Err(e) => e,
});
}
fn cursor_delete(app: &mut App) {
match cursor_line(app).and_then(|m| m.id.clone()) {
Some(id) => run_mod_from(
app,
modcmd::Parsed::Run(hsauth::ModAction::Delete, id),
false,
),
None => app.status = Some("can't delete that line".into()),
}
}