787 lines
28 KiB
Rust
787 lines
28 KiB
Rust
use kordophoned_client as daemon;
|
|
|
|
use anyhow::Result;
|
|
use crossterm::event::{Event as CEvent, KeyCode, KeyEventKind, KeyModifiers};
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
|
use ratatui::prelude::*;
|
|
use ratatui::widgets::*;
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ViewMode {
|
|
List,
|
|
Chat,
|
|
Split,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Focus {
|
|
Navigation,
|
|
Input,
|
|
}
|
|
|
|
struct AppState {
|
|
conversations: Vec<daemon::ConversationSummary>,
|
|
selected_idx: usize,
|
|
selected_conversation_id: Option<String>,
|
|
messages: Vec<daemon::ChatMessage>,
|
|
active_conversation_id: Option<String>,
|
|
active_conversation_title: String,
|
|
status: String,
|
|
input: String,
|
|
focus: Focus,
|
|
transcript_scroll: u16,
|
|
pinned_to_bottom: bool,
|
|
refresh_conversations_in_flight: bool,
|
|
refresh_messages_in_flight: bool,
|
|
}
|
|
|
|
impl AppState {
|
|
fn new() -> Self {
|
|
Self {
|
|
conversations: Vec::new(),
|
|
selected_idx: 0,
|
|
selected_conversation_id: None,
|
|
messages: Vec::new(),
|
|
active_conversation_id: None,
|
|
active_conversation_title: String::new(),
|
|
status: String::new(),
|
|
input: String::new(),
|
|
focus: Focus::Navigation,
|
|
transcript_scroll: 0,
|
|
pinned_to_bottom: true,
|
|
refresh_conversations_in_flight: false,
|
|
refresh_messages_in_flight: false,
|
|
}
|
|
}
|
|
|
|
fn select_next(&mut self) {
|
|
if self.conversations.is_empty() {
|
|
self.selected_idx = 0;
|
|
self.selected_conversation_id = None;
|
|
return;
|
|
}
|
|
self.selected_idx = (self.selected_idx + 1).min(self.conversations.len() - 1);
|
|
self.selected_conversation_id = self
|
|
.conversations
|
|
.get(self.selected_idx)
|
|
.map(|c| c.id.clone());
|
|
}
|
|
|
|
fn select_prev(&mut self) {
|
|
if self.conversations.is_empty() {
|
|
self.selected_idx = 0;
|
|
self.selected_conversation_id = None;
|
|
return;
|
|
}
|
|
self.selected_idx = self.selected_idx.saturating_sub(1);
|
|
self.selected_conversation_id = self
|
|
.conversations
|
|
.get(self.selected_idx)
|
|
.map(|c| c.id.clone());
|
|
}
|
|
|
|
fn open_selected_conversation(&mut self) {
|
|
if let Some(conv) = self.conversations.get(self.selected_idx) {
|
|
self.active_conversation_id = Some(conv.id.clone());
|
|
self.active_conversation_title = conv.title.clone();
|
|
self.selected_conversation_id = Some(conv.id.clone());
|
|
self.messages.clear();
|
|
self.transcript_scroll = 0;
|
|
self.pinned_to_bottom = true;
|
|
self.focus = Focus::Input;
|
|
self.status = "Loading…".to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) -> ViewMode {
|
|
let min_conversations = 24u16;
|
|
let min_chat = 44u16;
|
|
let min_total = min_conversations + 1 + min_chat;
|
|
if width >= min_total {
|
|
return ViewMode::Split;
|
|
}
|
|
if has_active_conversation {
|
|
requested
|
|
} else {
|
|
ViewMode::List
|
|
}
|
|
}
|
|
|
|
fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) {
|
|
let area = frame.area();
|
|
let mode = view_mode(
|
|
area.width,
|
|
app.active_conversation_id.is_some(),
|
|
requested_view,
|
|
);
|
|
|
|
let show_input =
|
|
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
|
|
let chunks = if show_input {
|
|
Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Min(1),
|
|
Constraint::Length(3),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(area)
|
|
} else {
|
|
Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(1), Constraint::Length(1)])
|
|
.split(area)
|
|
};
|
|
|
|
let (main_area, input_area, status_area) = if show_input {
|
|
(chunks[0], Some(chunks[1]), chunks[2])
|
|
} else {
|
|
(chunks[0], None, chunks[1])
|
|
};
|
|
|
|
match mode {
|
|
ViewMode::Split => {
|
|
let left_width = (main_area.width / 3).clamp(24, 40);
|
|
let cols = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([Constraint::Length(left_width), Constraint::Min(1)])
|
|
.split(main_area);
|
|
render_conversations(frame, app, cols[0], true);
|
|
render_transcript(frame, app, cols[1], true);
|
|
}
|
|
ViewMode::List => render_conversations(frame, app, main_area, false),
|
|
ViewMode::Chat => render_transcript(frame, app, main_area, false),
|
|
}
|
|
|
|
if let Some(input_area) = input_area {
|
|
let input_scroll_x = render_input(frame, app, input_area);
|
|
if app.focus == Focus::Input {
|
|
let cursor_col = visual_width_u16(app.input.as_str());
|
|
let mut x = input_area
|
|
.x
|
|
.saturating_add(1)
|
|
.saturating_add(cursor_col.saturating_sub(input_scroll_x));
|
|
let max_x = input_area
|
|
.x
|
|
.saturating_add(input_area.width.saturating_sub(2));
|
|
x = x.min(max_x);
|
|
let y = input_area.y + 1;
|
|
frame.set_cursor_position(Position { x, y });
|
|
}
|
|
}
|
|
|
|
render_status(frame, app, status_area, mode);
|
|
}
|
|
|
|
fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) {
|
|
let title = if in_split {
|
|
"Conversations (↑/↓, Enter)"
|
|
} else {
|
|
"Conversations (↑/↓, Enter to open)"
|
|
};
|
|
|
|
let items = app
|
|
.conversations
|
|
.iter()
|
|
.map(|c| {
|
|
let unread = if c.unread_count > 0 {
|
|
format!(" ({})", c.unread_count)
|
|
} else {
|
|
String::new()
|
|
};
|
|
let header = Line::from(vec![
|
|
Span::styled(
|
|
c.title.clone(),
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(unread),
|
|
]);
|
|
let preview = Line::from(Span::styled(
|
|
c.preview.clone(),
|
|
Style::default().fg(Color::DarkGray),
|
|
));
|
|
ListItem::new(vec![header, preview])
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let mut state = ListState::default();
|
|
state.select(if app.conversations.is_empty() {
|
|
None
|
|
} else {
|
|
Some(app.selected_idx)
|
|
});
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.highlight_style(
|
|
Style::default()
|
|
.bg(Color::Blue)
|
|
.fg(Color::White)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol("▸ ");
|
|
|
|
frame.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bool) {
|
|
let title = if let Some(_) = app.active_conversation_id {
|
|
if in_split {
|
|
format!("{} (Esc: nav, Tab: focus)", app.active_conversation_title)
|
|
} else {
|
|
format!("{} (Esc: back)", app.active_conversation_title)
|
|
}
|
|
} else {
|
|
"Chat".to_string()
|
|
};
|
|
|
|
let mut lines: Vec<Line> = Vec::new();
|
|
for message in &app.messages {
|
|
let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix)
|
|
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
|
|
|
|
lines.push(Line::from(vec![
|
|
Span::styled(
|
|
message.sender.clone(),
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(" "),
|
|
Span::styled(ts, Style::default().fg(Color::DarkGray)),
|
|
]));
|
|
|
|
let mut rendered_any_text = false;
|
|
for text_line in message.text.lines() {
|
|
rendered_any_text = true;
|
|
lines.push(Line::from(Span::raw(text_line.to_string())));
|
|
}
|
|
if !rendered_any_text {
|
|
lines.push(Line::from(Span::styled(
|
|
"<non-text message>",
|
|
Style::default().fg(Color::DarkGray),
|
|
)));
|
|
}
|
|
lines.push(Line::from(Span::raw("")));
|
|
}
|
|
|
|
if lines.is_empty() {
|
|
lines.push(Line::from(Span::styled(
|
|
"No messages.",
|
|
Style::default().fg(Color::DarkGray),
|
|
)));
|
|
}
|
|
|
|
let paragraph = Paragraph::new(Text::from(lines))
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.wrap(Wrap { trim: false })
|
|
.scroll((app.transcript_scroll, 0));
|
|
|
|
frame.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) -> u16 {
|
|
let title = if app.focus == Focus::Input {
|
|
"Reply (Enter to send)"
|
|
} else {
|
|
"Reply (press i to type)"
|
|
};
|
|
|
|
let inner_width = area.width.saturating_sub(2).max(1);
|
|
let cursor_col = visual_width_u16(app.input.as_str());
|
|
let input_scroll_x = cursor_col.saturating_sub(inner_width.saturating_sub(1));
|
|
|
|
let input = Paragraph::new(app.input.as_str())
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.scroll((0, input_scroll_x));
|
|
frame.render_widget(input, area);
|
|
input_scroll_x
|
|
}
|
|
|
|
fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) {
|
|
let mut parts = vec![
|
|
format!("{} convs", app.conversations.len()),
|
|
match mode {
|
|
ViewMode::Split => "split".to_string(),
|
|
ViewMode::List => "list".to_string(),
|
|
ViewMode::Chat => "chat".to_string(),
|
|
},
|
|
];
|
|
if !app.status.trim().is_empty() {
|
|
parts.push(app.status.clone());
|
|
}
|
|
let line = parts.join(" | ");
|
|
frame.render_widget(
|
|
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),
|
|
area,
|
|
);
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
enable_raw_mode()?;
|
|
let mut stdout = std::io::stdout();
|
|
crossterm::execute!(
|
|
stdout,
|
|
crossterm::terminal::EnterAlternateScreen,
|
|
crossterm::event::EnableMouseCapture
|
|
)?;
|
|
let backend = ratatui::backend::CrosstermBackend::new(stdout);
|
|
let mut terminal = ratatui::Terminal::new(backend)?;
|
|
|
|
let res = run_app(&mut terminal);
|
|
|
|
disable_raw_mode()?;
|
|
crossterm::execute!(
|
|
terminal.backend_mut(),
|
|
crossterm::event::DisableMouseCapture,
|
|
crossterm::terminal::LeaveAlternateScreen
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
res
|
|
}
|
|
|
|
fn run_app(
|
|
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
|
|
) -> Result<()> {
|
|
let (request_tx, request_rx) = mpsc::channel::<daemon::Request>();
|
|
let (event_tx, event_rx) = mpsc::channel::<daemon::Event>();
|
|
let _worker = daemon::spawn_worker(request_rx, event_tx);
|
|
|
|
let tick_rate = Duration::from_millis(150);
|
|
let refresh_rate = Duration::from_secs(2);
|
|
let mut last_tick = Instant::now();
|
|
let mut last_refresh = Instant::now() - refresh_rate;
|
|
|
|
let mut requested_view = ViewMode::List;
|
|
let mut app = AppState::new();
|
|
app.status = "Connecting…".to_string();
|
|
request_tx.send(daemon::Request::RefreshConversations).ok();
|
|
app.refresh_conversations_in_flight = true;
|
|
|
|
loop {
|
|
let size = terminal.size()?;
|
|
|
|
while let Ok(evt) = event_rx.try_recv() {
|
|
match evt {
|
|
daemon::Event::Conversations(convs) => {
|
|
let keep_selected_id = app
|
|
.selected_conversation_id
|
|
.clone()
|
|
.or_else(|| app.active_conversation_id.clone());
|
|
|
|
app.refresh_conversations_in_flight = false;
|
|
app.status.clear();
|
|
app.conversations = convs;
|
|
if app.conversations.is_empty() {
|
|
app.selected_idx = 0;
|
|
app.selected_conversation_id = None;
|
|
} else if let Some(id) = keep_selected_id {
|
|
if let Some(idx) = app.conversations.iter().position(|c| c.id == id) {
|
|
app.selected_idx = idx;
|
|
app.selected_conversation_id = Some(id);
|
|
} else {
|
|
app.selected_idx = 0;
|
|
app.selected_conversation_id = Some(app.conversations[0].id.clone());
|
|
}
|
|
} else {
|
|
app.selected_idx = app.selected_idx.min(app.conversations.len() - 1);
|
|
app.selected_conversation_id =
|
|
Some(app.conversations[app.selected_idx].id.clone());
|
|
}
|
|
}
|
|
daemon::Event::Messages {
|
|
conversation_id,
|
|
messages,
|
|
} => {
|
|
app.refresh_messages_in_flight = false;
|
|
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
|
let was_pinned = app.pinned_to_bottom;
|
|
app.messages = messages;
|
|
app.pinned_to_bottom = was_pinned;
|
|
}
|
|
}
|
|
daemon::Event::MessageSent {
|
|
conversation_id,
|
|
outgoing_id,
|
|
} => {
|
|
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
|
app.status = outgoing_id
|
|
.as_deref()
|
|
.map(|id| format!("Sent ({id})"))
|
|
.unwrap_or_else(|| "Sent".to_string());
|
|
app.refresh_messages_in_flight = false;
|
|
request_tx
|
|
.send(daemon::Request::RefreshMessages { conversation_id })
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
}
|
|
}
|
|
daemon::Event::MarkedRead => {}
|
|
daemon::Event::ConversationSyncTriggered { conversation_id } => {
|
|
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
|
app.status = "Syncing…".to_string();
|
|
}
|
|
}
|
|
daemon::Event::ConversationsUpdated => {
|
|
if !app.refresh_conversations_in_flight {
|
|
request_tx.send(daemon::Request::RefreshConversations).ok();
|
|
app.refresh_conversations_in_flight = true;
|
|
}
|
|
if let Some(cid) = app.active_conversation_id.clone() {
|
|
if !app.refresh_messages_in_flight {
|
|
request_tx
|
|
.send(daemon::Request::RefreshMessages {
|
|
conversation_id: cid,
|
|
})
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
}
|
|
}
|
|
}
|
|
daemon::Event::MessagesUpdated { conversation_id } => {
|
|
if !app.refresh_conversations_in_flight {
|
|
request_tx.send(daemon::Request::RefreshConversations).ok();
|
|
app.refresh_conversations_in_flight = true;
|
|
}
|
|
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
|
if !app.refresh_messages_in_flight {
|
|
request_tx
|
|
.send(daemon::Request::RefreshMessages { conversation_id })
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
}
|
|
}
|
|
}
|
|
daemon::Event::UpdateStreamReconnected => {
|
|
if !app.refresh_conversations_in_flight {
|
|
request_tx.send(daemon::Request::RefreshConversations).ok();
|
|
app.refresh_conversations_in_flight = true;
|
|
}
|
|
if let Some(cid) = app.active_conversation_id.clone() {
|
|
if !app.refresh_messages_in_flight {
|
|
request_tx
|
|
.send(daemon::Request::RefreshMessages {
|
|
conversation_id: cid,
|
|
})
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
}
|
|
}
|
|
}
|
|
daemon::Event::Error(e) => {
|
|
app.refresh_conversations_in_flight = false;
|
|
app.refresh_messages_in_flight = false;
|
|
app.status = e;
|
|
}
|
|
}
|
|
}
|
|
|
|
apply_transcript_scroll_policy(&mut app, size, requested_view);
|
|
terminal.draw(|f| ui(f, &app, requested_view))?;
|
|
|
|
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
|
|
if crossterm::event::poll(timeout)? {
|
|
if let CEvent::Key(key) = crossterm::event::read()? {
|
|
if key.kind != KeyEventKind::Press {
|
|
continue;
|
|
}
|
|
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
match (key.code, ctrl) {
|
|
(KeyCode::Char('c'), true) => return Ok(()),
|
|
_ => {}
|
|
}
|
|
|
|
let screen_mode = view_mode(
|
|
size.width,
|
|
app.active_conversation_id.is_some(),
|
|
requested_view,
|
|
);
|
|
|
|
let max_scroll = max_transcript_scroll(&app, size, requested_view);
|
|
|
|
match screen_mode {
|
|
ViewMode::List => match key.code {
|
|
KeyCode::Up => app.select_prev(),
|
|
KeyCode::Down => app.select_next(),
|
|
KeyCode::Enter => {
|
|
app.open_selected_conversation();
|
|
if app.active_conversation_id.is_some() {
|
|
requested_view = ViewMode::Chat;
|
|
if let Some(cid) = app.active_conversation_id.clone() {
|
|
request_tx
|
|
.send(daemon::Request::MarkRead {
|
|
conversation_id: cid.clone(),
|
|
})
|
|
.ok();
|
|
request_tx
|
|
.send(daemon::Request::SyncConversation {
|
|
conversation_id: cid.clone(),
|
|
})
|
|
.ok();
|
|
request_tx
|
|
.send(daemon::Request::RefreshMessages {
|
|
conversation_id: cid,
|
|
})
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
},
|
|
ViewMode::Chat => match key.code {
|
|
KeyCode::Esc => {
|
|
requested_view = ViewMode::List;
|
|
app.focus = Focus::Navigation;
|
|
}
|
|
KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input,
|
|
_ => {
|
|
handle_chat_keys(&mut app, &request_tx, key.code, max_scroll);
|
|
}
|
|
},
|
|
ViewMode::Split => match key.code {
|
|
KeyCode::Tab => {
|
|
app.focus = match app.focus {
|
|
Focus::Navigation => Focus::Input,
|
|
Focus::Input => Focus::Navigation,
|
|
}
|
|
}
|
|
KeyCode::Esc => app.focus = Focus::Navigation,
|
|
KeyCode::Char('i') if app.focus != Focus::Input => app.focus = Focus::Input,
|
|
KeyCode::Up => {
|
|
if app.focus == Focus::Navigation {
|
|
app.select_prev()
|
|
} else {
|
|
scroll_up(&mut app, 1);
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if app.focus == Focus::Navigation {
|
|
app.select_next()
|
|
} else {
|
|
scroll_down(&mut app, 1, max_scroll);
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if app.focus == Focus::Navigation {
|
|
app.open_selected_conversation();
|
|
requested_view = ViewMode::Chat;
|
|
if let Some(cid) = app.active_conversation_id.clone() {
|
|
request_tx
|
|
.send(daemon::Request::MarkRead {
|
|
conversation_id: cid.clone(),
|
|
})
|
|
.ok();
|
|
request_tx
|
|
.send(daemon::Request::SyncConversation {
|
|
conversation_id: cid.clone(),
|
|
})
|
|
.ok();
|
|
request_tx
|
|
.send(daemon::Request::RefreshMessages {
|
|
conversation_id: cid,
|
|
})
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
}
|
|
} else {
|
|
handle_chat_keys(&mut app, &request_tx, key.code, max_scroll);
|
|
}
|
|
}
|
|
_ => handle_chat_keys(&mut app, &request_tx, key.code, max_scroll),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
if last_refresh.elapsed() >= refresh_rate {
|
|
if !app.refresh_conversations_in_flight {
|
|
request_tx.send(daemon::Request::RefreshConversations).ok();
|
|
app.refresh_conversations_in_flight = true;
|
|
}
|
|
|
|
if let Some(cid) = app.active_conversation_id.clone() {
|
|
if !app.refresh_messages_in_flight {
|
|
request_tx
|
|
.send(daemon::Request::RefreshMessages {
|
|
conversation_id: cid,
|
|
})
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
}
|
|
}
|
|
|
|
last_refresh = Instant::now();
|
|
}
|
|
|
|
if last_tick.elapsed() >= tick_rate {
|
|
last_tick = Instant::now();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_chat_keys(
|
|
app: &mut AppState,
|
|
request_tx: &mpsc::Sender<daemon::Request>,
|
|
code: KeyCode,
|
|
max_scroll: u16,
|
|
) {
|
|
match code {
|
|
KeyCode::PageUp => scroll_up(app, 10),
|
|
KeyCode::PageDown => scroll_down(app, 10, max_scroll),
|
|
_ => {}
|
|
}
|
|
|
|
if app.focus != Focus::Input {
|
|
return;
|
|
}
|
|
|
|
match code {
|
|
KeyCode::Enter => {
|
|
let text = app.input.trim().to_string();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
let Some(conversation_id) = app.active_conversation_id.clone() else {
|
|
app.status = "No conversation selected".to_string();
|
|
return;
|
|
};
|
|
request_tx
|
|
.send(daemon::Request::SendMessage {
|
|
conversation_id,
|
|
text,
|
|
})
|
|
.ok();
|
|
app.refresh_messages_in_flight = true;
|
|
app.input.clear();
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.input.pop();
|
|
}
|
|
KeyCode::Char(c) => {
|
|
if !c.is_control() {
|
|
app.input.push(c);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn scroll_up(app: &mut AppState, amount: u16) {
|
|
if amount > 0 {
|
|
app.pinned_to_bottom = false;
|
|
}
|
|
app.transcript_scroll = app.transcript_scroll.saturating_sub(amount);
|
|
}
|
|
|
|
fn scroll_down(app: &mut AppState, amount: u16, max_scroll: u16) {
|
|
app.transcript_scroll = app.transcript_scroll.saturating_add(amount);
|
|
if app.transcript_scroll >= max_scroll {
|
|
app.transcript_scroll = max_scroll;
|
|
app.pinned_to_bottom = true;
|
|
}
|
|
}
|
|
|
|
fn transcript_inner_width(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
|
|
let mode = view_mode(
|
|
size.width,
|
|
app.active_conversation_id.is_some(),
|
|
requested_view,
|
|
);
|
|
let outer_width = match mode {
|
|
ViewMode::Split => {
|
|
let left_width = (size.width / 3).clamp(24, 40);
|
|
size.width.saturating_sub(left_width)
|
|
}
|
|
ViewMode::Chat => size.width,
|
|
ViewMode::List => 0,
|
|
};
|
|
|
|
outer_width.saturating_sub(2).max(1)
|
|
}
|
|
|
|
fn visual_line_count(s: &str, inner_width: u16) -> u16 {
|
|
let w = s.width();
|
|
if w == 0 {
|
|
return 1;
|
|
}
|
|
let iw = inner_width.max(1) as usize;
|
|
((w + iw - 1) / iw).min(u16::MAX as usize) as u16
|
|
}
|
|
|
|
fn visual_width_u16(s: &str) -> u16 {
|
|
s.width().min(u16::MAX as usize) as u16
|
|
}
|
|
|
|
fn transcript_content_visual_lines(messages: &[daemon::ChatMessage], inner_width: u16) -> u16 {
|
|
if messages.is_empty() {
|
|
return 1;
|
|
}
|
|
|
|
let mut total: u32 = 0;
|
|
for message in messages {
|
|
let ts = time::OffsetDateTime::from_unix_timestamp(message.date_unix)
|
|
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
|
|
.format(&time::format_description::well_known::Rfc3339)
|
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
|
|
let header = format!("{} {}", message.sender, ts);
|
|
total += visual_line_count(&header, inner_width) as u32;
|
|
|
|
let mut rendered_any_text = false;
|
|
for text_line in message.text.lines() {
|
|
rendered_any_text = true;
|
|
total += visual_line_count(text_line, inner_width) as u32;
|
|
}
|
|
if !rendered_any_text {
|
|
total += visual_line_count("<non-text message>", inner_width) as u32;
|
|
}
|
|
|
|
total += 1; // spacer line
|
|
}
|
|
|
|
total.min(u16::MAX as u32) as u16
|
|
}
|
|
|
|
fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
|
|
let mode = view_mode(
|
|
size.width,
|
|
app.active_conversation_id.is_some(),
|
|
requested_view,
|
|
);
|
|
let show_input =
|
|
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
|
|
|
|
let transcript_height = if show_input {
|
|
size.height.saturating_sub(4) // input (3) + status (1)
|
|
} else {
|
|
size.height.saturating_sub(1) // status
|
|
};
|
|
|
|
match mode {
|
|
ViewMode::Chat | ViewMode::Split => transcript_height.saturating_sub(2), // borders
|
|
ViewMode::List => 0,
|
|
}
|
|
}
|
|
|
|
fn max_transcript_scroll(app: &AppState, size: Size, requested_view: ViewMode) -> u16 {
|
|
let viewport_height = transcript_viewport_height(size, app, requested_view);
|
|
let inner_width = transcript_inner_width(size, app, requested_view);
|
|
let content = transcript_content_visual_lines(&app.messages, inner_width);
|
|
content.saturating_sub(viewport_height)
|
|
}
|
|
|
|
fn apply_transcript_scroll_policy(app: &mut AppState, size: Size, requested_view: ViewMode) {
|
|
let max_scroll = max_transcript_scroll(app, size, requested_view);
|
|
if app.pinned_to_bottom {
|
|
app.transcript_scroll = max_scroll;
|
|
} else {
|
|
app.transcript_scroll = app.transcript_scroll.min(max_scroll);
|
|
}
|
|
}
|