kptui: message entry should scroll horizontally
This commit is contained in:
@@ -113,13 +113,22 @@ fn view_mode(width: u16, has_active_conversation: bool, requested: ViewMode) ->
|
|||||||
|
|
||||||
fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) {
|
fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
let mode = view_mode(area.width, app.active_conversation_id.is_some(), requested_view);
|
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 show_input =
|
||||||
|
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
|
||||||
let chunks = if show_input {
|
let chunks = if show_input {
|
||||||
Layout::default()
|
Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Min(1), Constraint::Length(3), Constraint::Length(1)])
|
.constraints([
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
.split(area)
|
.split(area)
|
||||||
} else {
|
} else {
|
||||||
Layout::default()
|
Layout::default()
|
||||||
@@ -149,12 +158,13 @@ fn ui(frame: &mut Frame, app: &AppState, requested_view: ViewMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(input_area) = input_area {
|
if let Some(input_area) = input_area {
|
||||||
render_input(frame, app, input_area);
|
let input_scroll_x = render_input(frame, app, input_area);
|
||||||
if app.focus == Focus::Input {
|
if app.focus == Focus::Input {
|
||||||
|
let cursor_col = visual_width_u16(app.input.as_str());
|
||||||
let mut x = input_area
|
let mut x = input_area
|
||||||
.x
|
.x
|
||||||
.saturating_add(1)
|
.saturating_add(1)
|
||||||
.saturating_add(app.input.len() as u16);
|
.saturating_add(cursor_col.saturating_sub(input_scroll_x));
|
||||||
let max_x = input_area
|
let max_x = input_area
|
||||||
.x
|
.x
|
||||||
.saturating_add(input_area.width.saturating_sub(2));
|
.saturating_add(input_area.width.saturating_sub(2));
|
||||||
@@ -184,7 +194,10 @@ fn render_conversations(frame: &mut Frame, app: &AppState, area: Rect, in_split:
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let header = Line::from(vec![
|
let header = Line::from(vec![
|
||||||
Span::styled(c.title.clone(), Style::default().add_modifier(Modifier::BOLD)),
|
Span::styled(
|
||||||
|
c.title.clone(),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
Span::raw(unread),
|
Span::raw(unread),
|
||||||
]);
|
]);
|
||||||
let preview = Line::from(Span::styled(
|
let preview = Line::from(Span::styled(
|
||||||
@@ -234,7 +247,10 @@ fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bo
|
|||||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
|
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
Span::styled(message.sender.clone(), Style::default().add_modifier(Modifier::BOLD)),
|
Span::styled(
|
||||||
|
message.sender.clone(),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
Span::styled(ts, Style::default().fg(Color::DarkGray)),
|
Span::styled(ts, Style::default().fg(Color::DarkGray)),
|
||||||
]));
|
]));
|
||||||
@@ -268,23 +284,27 @@ fn render_transcript(frame: &mut Frame, app: &AppState, area: Rect, in_split: bo
|
|||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) {
|
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) -> u16 {
|
||||||
let title = if app.focus == Focus::Input {
|
let title = if app.focus == Focus::Input {
|
||||||
"Reply (Enter to send)"
|
"Reply (Enter to send)"
|
||||||
} else {
|
} else {
|
||||||
"Reply (press i to type)"
|
"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())
|
let input = Paragraph::new(app.input.as_str())
|
||||||
.block(Block::default().borders(Borders::ALL).title(title));
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.scroll((0, input_scroll_x));
|
||||||
frame.render_widget(input, area);
|
frame.render_widget(input, area);
|
||||||
|
input_scroll_x
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) {
|
fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode) {
|
||||||
let mut parts = vec![
|
let mut parts = vec![
|
||||||
format!(
|
format!("{} convs", app.conversations.len()),
|
||||||
"{} convs",
|
|
||||||
app.conversations.len()
|
|
||||||
),
|
|
||||||
match mode {
|
match mode {
|
||||||
ViewMode::Split => "split".to_string(),
|
ViewMode::Split => "split".to_string(),
|
||||||
ViewMode::List => "list".to_string(),
|
ViewMode::List => "list".to_string(),
|
||||||
@@ -295,7 +315,10 @@ fn render_status(frame: &mut Frame, app: &AppState, area: Rect, mode: ViewMode)
|
|||||||
parts.push(app.status.clone());
|
parts.push(app.status.clone());
|
||||||
}
|
}
|
||||||
let line = parts.join(" | ");
|
let line = parts.join(" | ");
|
||||||
frame.render_widget(Paragraph::new(line).block(Block::default().borders(Borders::TOP)), area);
|
frame.render_widget(
|
||||||
|
Paragraph::new(line).block(Block::default().borders(Borders::TOP)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -322,7 +345,9 @@ fn main() -> Result<()> {
|
|||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_app(terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>) -> Result<()> {
|
fn run_app(
|
||||||
|
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
|
||||||
|
) -> Result<()> {
|
||||||
let (request_tx, request_rx) = mpsc::channel::<daemon::Request>();
|
let (request_tx, request_rx) = mpsc::channel::<daemon::Request>();
|
||||||
let (event_tx, event_rx) = mpsc::channel::<daemon::Event>();
|
let (event_tx, event_rx) = mpsc::channel::<daemon::Event>();
|
||||||
let _worker = daemon::spawn_worker(request_rx, event_tx);
|
let _worker = daemon::spawn_worker(request_rx, event_tx);
|
||||||
@@ -361,14 +386,12 @@ fn run_app(terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<s
|
|||||||
app.selected_conversation_id = Some(id);
|
app.selected_conversation_id = Some(id);
|
||||||
} else {
|
} else {
|
||||||
app.selected_idx = 0;
|
app.selected_idx = 0;
|
||||||
app.selected_conversation_id =
|
app.selected_conversation_id = Some(app.conversations[0].id.clone());
|
||||||
Some(app.conversations[0].id.clone());
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app.selected_idx = app.selected_idx.min(app.conversations.len() - 1);
|
app.selected_idx = app.selected_idx.min(app.conversations.len() - 1);
|
||||||
app.selected_conversation_id = Some(
|
app.selected_conversation_id =
|
||||||
app.conversations[app.selected_idx].id.clone(),
|
Some(app.conversations[app.selected_idx].id.clone());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
daemon::Event::Messages {
|
daemon::Event::Messages {
|
||||||
@@ -428,9 +451,7 @@ fn run_app(terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<s
|
|||||||
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
if app.active_conversation_id.as_deref() == Some(conversation_id.as_str()) {
|
||||||
if !app.refresh_messages_in_flight {
|
if !app.refresh_messages_in_flight {
|
||||||
request_tx
|
request_tx
|
||||||
.send(daemon::Request::RefreshMessages {
|
.send(daemon::Request::RefreshMessages { conversation_id })
|
||||||
conversation_id,
|
|
||||||
})
|
|
||||||
.ok();
|
.ok();
|
||||||
app.refresh_messages_in_flight = true;
|
app.refresh_messages_in_flight = true;
|
||||||
}
|
}
|
||||||
@@ -493,9 +514,21 @@ fn run_app(terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<s
|
|||||||
if app.active_conversation_id.is_some() {
|
if app.active_conversation_id.is_some() {
|
||||||
requested_view = ViewMode::Chat;
|
requested_view = ViewMode::Chat;
|
||||||
if let Some(cid) = app.active_conversation_id.clone() {
|
if let Some(cid) = app.active_conversation_id.clone() {
|
||||||
request_tx.send(daemon::Request::MarkRead { conversation_id: cid.clone() }).ok();
|
request_tx
|
||||||
request_tx.send(daemon::Request::SyncConversation { conversation_id: cid.clone() }).ok();
|
.send(daemon::Request::MarkRead {
|
||||||
request_tx.send(daemon::Request::RefreshMessages { conversation_id: cid }).ok();
|
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;
|
app.refresh_messages_in_flight = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,9 +573,21 @@ fn run_app(terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<s
|
|||||||
app.open_selected_conversation();
|
app.open_selected_conversation();
|
||||||
requested_view = ViewMode::Chat;
|
requested_view = ViewMode::Chat;
|
||||||
if let Some(cid) = app.active_conversation_id.clone() {
|
if let Some(cid) = app.active_conversation_id.clone() {
|
||||||
request_tx.send(daemon::Request::MarkRead { conversation_id: cid.clone() }).ok();
|
request_tx
|
||||||
request_tx.send(daemon::Request::SyncConversation { conversation_id: cid.clone() }).ok();
|
.send(daemon::Request::MarkRead {
|
||||||
request_tx.send(daemon::Request::RefreshMessages { conversation_id: cid }).ok();
|
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;
|
app.refresh_messages_in_flight = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -644,7 +689,11 @@ fn scroll_down(app: &mut AppState, amount: u16, max_scroll: u16) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn transcript_inner_width(size: Size, app: &AppState, requested_view: ViewMode) -> u16 {
|
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 mode = view_mode(
|
||||||
|
size.width,
|
||||||
|
app.active_conversation_id.is_some(),
|
||||||
|
requested_view,
|
||||||
|
);
|
||||||
let outer_width = match mode {
|
let outer_width = match mode {
|
||||||
ViewMode::Split => {
|
ViewMode::Split => {
|
||||||
let left_width = (size.width / 3).clamp(24, 40);
|
let left_width = (size.width / 3).clamp(24, 40);
|
||||||
@@ -666,10 +715,11 @@ fn visual_line_count(s: &str, inner_width: u16) -> u16 {
|
|||||||
((w + iw - 1) / iw).min(u16::MAX as usize) as u16
|
((w + iw - 1) / iw).min(u16::MAX as usize) as u16
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transcript_content_visual_lines(
|
fn visual_width_u16(s: &str) -> u16 {
|
||||||
messages: &[daemon::ChatMessage],
|
s.width().min(u16::MAX as usize) as u16
|
||||||
inner_width: u16,
|
}
|
||||||
) -> u16 {
|
|
||||||
|
fn transcript_content_visual_lines(messages: &[daemon::ChatMessage], inner_width: u16) -> u16 {
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
@@ -699,7 +749,11 @@ fn transcript_content_visual_lines(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn transcript_viewport_height(size: Size, app: &AppState, requested_view: ViewMode) -> 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 mode = view_mode(
|
||||||
|
size.width,
|
||||||
|
app.active_conversation_id.is_some(),
|
||||||
|
requested_view,
|
||||||
|
);
|
||||||
let show_input =
|
let show_input =
|
||||||
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
|
matches!(mode, ViewMode::Chat | ViewMode::Split) && app.active_conversation_id.is_some();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user