use std::borrow::Cow;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use super::config::draw_config_editor;
use super::palette::palette_overlay_suggestions;
use super::reply::ReplyPreviewLineKind;
use super::*;
const REPLY_BODY_GUIDE_COLUMN: usize = 80;
const HEADER_BG: Color = Color::Blue;
#[derive(Clone, Copy)]
enum VerticalScrollWrapMode {
Disabled,
Enabled,
}
pub(super) fn draw(
frame: &mut Frame<'_>,
state: &AppState,
config: &RuntimeConfig,
_bootstrap: &BootstrapState,
) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(10),
Constraint::Length(1),
])
.split(frame.area());
let uptime_label = format_uptime_label(state.started_at.elapsed().as_secs());
let sync_progress_text = state
.background_sync_progress_text()
.map(|value| sanitize_inline_ui_text(&value));
let header_sections = if let Some(progress_text) = sync_progress_text.as_ref() {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(1),
Constraint::Length(progress_text.chars().count().min(64) as u16 + 1),
])
.split(areas[0])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1)])
.split(areas[0])
};
let header = vec![
Span::styled(
" CRIEW ",
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("v{}", env!("CARGO_PKG_VERSION")),
Style::default()
.fg(Color::Yellow)
.bg(HEADER_BG)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
header_context_text(state),
Style::default()
.fg(Color::White)
.bg(HEADER_BG)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{} threads", state.filtered_thread_indices.len()),
Style::default()
.fg(Color::White)
.bg(HEADER_BG)
.add_modifier(Modifier::DIM),
),
Span::styled(" | ", Style::default().fg(Color::White).bg(HEADER_BG)),
Span::styled(
format!("keymap {}", state.runtime.ui_keymap.as_str()),
Style::default()
.fg(Color::White)
.bg(HEADER_BG)
.add_modifier(Modifier::DIM),
),
Span::styled(" | ", Style::default().fg(Color::White).bg(HEADER_BG)),
Span::styled(
format!("up {uptime_label}"),
Style::default()
.fg(Color::White)
.bg(HEADER_BG)
.add_modifier(Modifier::DIM),
),
];
let header_background = Paragraph::new("").style(Style::default().bg(HEADER_BG));
frame.render_widget(header_background, areas[0]);
let header_widget = Paragraph::new(Line::from(header)).style(Style::default().bg(HEADER_BG));
frame.render_widget(header_widget, header_sections[0]);
if let Some(progress_text) = sync_progress_text {
let progress = Paragraph::new(format!("{progress_text} "))
.alignment(Alignment::Right)
.style(Style::default().fg(Color::Yellow).bg(HEADER_BG));
frame.render_widget(progress, header_sections[1]);
}
match state.ui_page {
UiPage::Mail => {
let panes = mail_page_panes(areas[1], state.mail_pane_layout);
draw_subscriptions(frame, panes[0], state);
draw_threads(frame, panes[1], state);
draw_preview(frame, panes[2], state, config);
}
UiPage::CodeBrowser => {
draw_code_browser_page(frame, areas[1], state);
}
}
let shortcuts_text = match state.ui_page {
UiPage::Mail if state.reply_panel.is_some() => {
if state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.preview_open)
{
"j/k scroll preview | Enter/c confirm | Esc close | S send".to_string()
} else if state
.reply_panel
.as_ref()
.is_some_and(|panel| panel.reply_notice.is_some())
{
"Enter/Esc close notice | P preview | S send".to_string()
} else {
"Esc normal/close | Enter/o open below+insert | h/j/k/l move | i insert | x delete | p send preview | S send | :preview :send :q :q!".to_string()
}
}
UiPage::Mail if state.palette.open => {
"/ search | Tab page | : palette | Enter | e/r reply".to_string()
}
UiPage::Mail => format!(
"/ search | Tab page | : palette | Enter | e/r reply | [ ] expand pane | {{ }} shrink pane | {}",
main_page_navigation_shortcuts(&state.main_page_keymap)
),
UiPage::CodeBrowser if state.is_code_edit_active() => {
"Esc normal/exit | h/j/k/l move | i insert | x delete | s save | E external vim | :w :q :q! :wq :vim".to_string()
}
UiPage::CodeBrowser if state.palette.open => {
"Tab page | : palette | Enter expand/collapse | e inline edit | E external vim"
.to_string()
}
UiPage::CodeBrowser => format!(
"Tab page | : palette | Enter expand/collapse | e inline edit | E external vim | {}",
main_page_navigation_shortcuts(&state.main_page_keymap)
),
};
let footer_background =
Paragraph::new("").style(Style::default().fg(Color::White).bg(Color::DarkGray));
frame.render_widget(footer_background, areas[2]);
let footer_status_text = footer_status_text(&state.status);
let footer_sections = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(shortcuts_text.chars().count() as u16),
Constraint::Min(1),
])
.split(areas[2]);
let shortcuts =
Paragraph::new(shortcuts_text).style(Style::default().fg(Color::White).bg(Color::DarkGray));
frame.render_widget(shortcuts, footer_sections[0]);
if let Some(status_line) = footer_status_text {
let status = Paragraph::new(status_line)
.alignment(Alignment::Right)
.style(Style::default().fg(Color::White).bg(Color::DarkGray));
frame.render_widget(status, footer_sections[1]);
}
if state.palette.open {
draw_command_palette(frame, state);
}
if state.search.active {
draw_search_overlay(frame, state);
}
if state.config_editor.open {
draw_config_editor(frame, state);
}
if state.keymap_editor.open {
draw_keymap_editor(frame, state);
}
if state.reply_panel.is_some() {
draw_reply_panel(frame, state);
}
}
fn format_uptime_label(uptime_secs: u64) -> String {
let hours = uptime_secs / 3_600;
let minutes = (uptime_secs % 3_600) / 60;
let seconds = uptime_secs % 60;
if hours > 0 {
return format!("{hours:02}h:{minutes:02}m:{seconds:02}s");
}
if minutes > 0 {
return format!("{minutes:02}m:{seconds:02}s");
}
format!("{seconds}s")
}
fn footer_status_text(status: &str) -> Option<String> {
let sanitized = sanitize_inline_ui_text(status);
if sanitized.trim().is_empty() {
None
} else {
Some(sanitized)
}
}
fn header_context_text(state: &AppState) -> String {
let page_label = match state.ui_page {
UiPage::Mail => "Mail",
UiPage::CodeBrowser => "Code",
};
format!("{page_label} / {}", state.active_thread_mailbox)
}
fn draw_code_browser_page(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_kernel_tree(frame, panes[0], state);
draw_code_source_preview(frame, panes[1], state);
}
pub(super) fn mail_page_panes(area: Rect, layout: MailPaneLayout) -> [Rect; 3] {
if area.width == 0 {
return [area, area, area];
}
let preview_width = area.width.min(layout.preview_width);
let left_width = area.width.saturating_sub(preview_width);
let preview = Rect {
x: area.x + left_width,
y: area.y,
width: preview_width,
height: area.height,
};
if left_width == 0 {
let empty = Rect {
x: area.x,
y: area.y,
width: 0,
height: area.height,
};
return [empty, empty, preview];
}
let subscriptions_width = left_width.min(layout.subscriptions_width);
let threads_width = left_width.saturating_sub(subscriptions_width);
let subscriptions = Rect {
x: area.x,
y: area.y,
width: subscriptions_width,
height: area.height,
};
let threads = Rect {
x: area.x + subscriptions_width,
y: area.y,
width: threads_width,
height: area.height,
};
[subscriptions, threads, preview]
}
fn draw_subscriptions(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let rows = state.subscription_rows();
let items: Vec<ListItem> = rows
.iter()
.map(|row| ListItem::new(row.text.clone()))
.collect();
let selected_row = if rows.is_empty() {
None
} else {
Some(
state
.subscription_row_index
.min(rows.len().saturating_sub(1)),
)
};
let mut list_state = ListState::default();
list_state.select(selected_row);
let list = List::new(items)
.block(panel_block(Pane::Subscriptions, state.focus))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut list_state);
}
fn draw_kernel_tree(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
frame.render_widget(Clear, area);
let block = panel_block_with_title("Kernel Tree", state.code_focus == CodePaneFocus::Tree);
let items: Vec<ListItem> = if state.kernel_tree_rows.is_empty() {
vec![ListItem::new(
"<no files found under configured kernel trees>",
)]
} else {
state
.kernel_tree_rows
.iter()
.map(|row| ListItem::new(row.display_text()))
.collect()
};
let selected_row = if state.kernel_tree_rows.is_empty() {
None
} else {
Some(
state
.kernel_tree_row_index
.min(state.kernel_tree_rows.len().saturating_sub(1)),
)
};
let mut list_state = ListState::default();
list_state.select(selected_row);
let list = List::new(items).block(block).highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut list_state);
}
fn draw_code_source_preview(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
frame.render_widget(Clear, area);
let title = if state.is_code_edit_active() {
let dirty = if state.code_edit_dirty { "*" } else { "-" };
format!(
"Source Preview [{} dirty:{}]",
state.code_edit_mode.label(),
dirty
)
} else {
"Source Preview".to_string()
};
let block = panel_block_with_title(&title, state.code_focus == CodePaneFocus::Source);
let inner_area = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Clear, inner_area);
let preview = load_code_source_preview(state);
let code_preview_scroll_limit = max_vertical_scroll(
&preview,
inner_area.width,
inner_area.height,
VerticalScrollWrapMode::Disabled,
);
state
.code_preview_scroll_limit
.set(code_preview_scroll_limit);
let scroll = clamp_vertical_scroll(
&preview,
inner_area.width,
inner_area.height,
state.code_preview_scroll,
VerticalScrollWrapMode::Disabled,
);
let paragraph = Paragraph::new(preview).scroll((scroll, 0));
frame.render_widget(paragraph, inner_area);
if let Some(cursor_position) = code_edit_cursor_position(state, inner_area) {
frame.set_cursor_position(cursor_position);
}
}
pub(super) fn subscription_line(
item: &SubscriptionItem,
startup_sync_status: Option<StartupSyncMailboxStatus>,
) -> String {
let marker = if item.enabled { "y" } else { "n" };
let suffix = startup_sync_status
.map(StartupSyncMailboxStatus::ui_suffix)
.unwrap_or("");
format!("[{marker}] {}{suffix}", item.label)
}
fn draw_threads(frame: &mut Frame<'_>, area: Rect, state: &AppState) {
let max_thread_line_chars = thread_line_max_chars(area);
let mut visible_count_by_thread: HashMap<i64, usize> = HashMap::new();
for index in &state.filtered_thread_indices {
if let Some(row) = state.threads.get(*index) {
*visible_count_by_thread.entry(row.thread_id).or_insert(0) += 1;
}
}
let mut items: Vec<ListItem> = Vec::new();
let mut selected = None;
let mut previous_thread_id: Option<i64> = None;
for (position, index) in state.filtered_thread_indices.iter().enumerate() {
let Some(row) = state.threads.get(*index) else {
continue;
};
if previous_thread_id != Some(row.thread_id) {
previous_thread_id = Some(row.thread_id);
let visible_count = visible_count_by_thread
.get(&row.thread_id)
.copied()
.unwrap_or(1);
items.push(
ListItem::new(thread_group_line(
row.thread_id,
visible_count,
state.series_summaries.get(&row.thread_id),
))
.style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
),
);
}
if position == state.thread_index {
selected = Some(items.len());
}
items.push(ListItem::new(thread_line(row, max_thread_line_chars)));
}
let mut list_state = ListState::default();
list_state.select(selected);
let list = List::new(items)
.block(panel_block(Pane::Threads, state.focus))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut list_state);
}
fn thread_group_line(
thread_id: i64,
visible_count: usize,
series: Option<&patch_worker::SeriesSummary>,
) -> String {
let noun = if visible_count == 1 { "msg" } else { "msgs" };
let mut line = format!("Thread {thread_id} ({visible_count} {noun})");
if let Some(series) = series {
line.push_str(&format!(
" | v{} {}/{} | integrity={} | status={}",
series.version,
series.present_count(),
series.expected_total,
series.integrity.short_label(),
series.status_label()
));
}
line
}
fn thread_line_max_chars(area: Rect) -> usize {
let available = area.width.saturating_sub(2) as usize;
available.min(THREAD_LINE_MAX_CHARS)
}
pub(super) fn thread_line(row: &ThreadRow, max_chars: usize) -> String {
let max_chars = max_chars.min(THREAD_LINE_MAX_CHARS);
let indent = " ".repeat(row.depth as usize);
let subject = if row.subject.trim().is_empty() {
"(no subject)"
} else {
row.subject.trim()
};
truncate_with_ellipsis(&format!("{indent}{subject}"), max_chars)
}
pub(super) fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let value_len = value.chars().count();
if value_len <= max_chars {
return value.to_string();
}
if max_chars <= 3 {
return ".".repeat(max_chars);
}
let mut truncated = String::new();
for ch in value.chars().take(max_chars - 3) {
truncated.push(ch);
}
truncated.push_str("...");
truncated
}
pub(super) fn sanitize_inline_ui_text(value: &str) -> String {
let mut sanitized = String::with_capacity(value.len());
let mut pending_space = false;
for character in value.chars() {
if character.is_control() || character.is_whitespace() {
pending_space = !sanitized.is_empty();
continue;
}
if pending_space {
sanitized.push(' ');
pending_space = false;
}
sanitized.push(character);
}
sanitized.trim().to_string()
}
fn draw_preview(frame: &mut Frame<'_>, area: Rect, state: &AppState, config: &RuntimeConfig) {
let (warning, preview) = if let Some(thread) = state.selected_thread() {
if let Some(mail_preview) = state.selected_mail_preview() {
let preview = if let Some(series_details) =
load_series_preview(state, config, thread.thread_id)
{
Cow::Owned(format!("{series_details}\n\n{}", mail_preview.content))
} else {
Cow::Borrowed(mail_preview.content.as_str())
};
(mail_preview.warning.as_deref(), preview)
} else {
(
None,
Cow::Borrowed("<mail preview unavailable; change selection to reload>"),
)
}
} else {
(
None,
Cow::Owned(format!(
"No synced thread data\n\nRun:\n criew sync --fixture-dir <DIR>\n\nConfig: {}\nDatabase: {}",
config.config_path.display(),
config.database_path.display(),
)),
)
};
let block = panel_block(Pane::Preview, state.focus);
let inner_area = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Clear, inner_area);
let content_area = if let Some(warning_text) = warning {
let warning_height = warning_text
.lines()
.count()
.min(inner_area.height.saturating_sub(1) as usize) as u16;
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(warning_height), Constraint::Min(1)])
.split(inner_area);
let warning = Paragraph::new(Text::from(warning_text))
.style(
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.wrap(Wrap { trim: false });
frame.render_widget(warning, sections[0]);
sections[1]
} else {
inner_area
};
let preview_scroll_limit = max_vertical_scroll(
preview.as_ref(),
content_area.width,
content_area.height,
VerticalScrollWrapMode::Enabled,
);
state.preview_scroll_limit.set(preview_scroll_limit);
let scroll = clamp_vertical_scroll(
preview.as_ref(),
content_area.width,
content_area.height,
state.preview_scroll,
VerticalScrollWrapMode::Enabled,
);
let paragraph = Paragraph::new(preview.as_ref())
.scroll((scroll, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, content_area);
}
fn clamp_vertical_scroll(
text: &str,
area_width: u16,
area_height: u16,
requested_scroll: u16,
wrap_mode: VerticalScrollWrapMode,
) -> u16 {
requested_scroll.min(max_vertical_scroll(
text,
area_width,
area_height,
wrap_mode,
))
}
fn max_vertical_scroll(
text: &str,
area_width: u16,
area_height: u16,
wrap_mode: VerticalScrollWrapMode,
) -> u16 {
if area_height == 0 {
return 0;
}
let visible_lines = area_height as usize;
let total_lines = visual_line_count(text, area_width, wrap_mode);
total_lines
.saturating_sub(visible_lines)
.min(u16::MAX as usize) as u16
}
fn visual_line_count(text: &str, area_width: u16, wrap_mode: VerticalScrollWrapMode) -> usize {
match wrap_mode {
VerticalScrollWrapMode::Disabled => text.split('\n').count().max(1),
VerticalScrollWrapMode::Enabled => {
if area_width == 0 {
return 0;
}
text.split('\n')
.map(|line| wrapped_visual_line_count(line, area_width))
.sum::<usize>()
.max(1)
}
}
}
fn wrapped_visual_line_count(line: &str, area_width: u16) -> usize {
if area_width == 0 {
return 0;
}
let width = area_width as usize;
let display_width = display_column(line, line.chars().count()).max(1);
display_width.saturating_add(width - 1) / width
}
fn load_series_preview(state: &AppState, config: &RuntimeConfig, thread_id: i64) -> Option<String> {
let series = state.series_summaries.get(&thread_id)?;
let mut lines = vec![
format!(
"Series: v{} {}/{} | integrity={} | status={}",
series.version,
series.present_count(),
series.expected_total,
series.integrity.short_label(),
series.status_label()
),
format!("Anchor: <{}>", series.anchor_message_id),
];
if !series.missing_seq.is_empty() {
lines.push(format!(
"Missing: {}",
series
.missing_seq
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
if !series.duplicate_seq.is_empty() {
lines.push(format!(
"Duplicate: {}",
series
.duplicate_seq
.iter()
.map(|value| value.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
match patch_worker::load_latest_report(
&config.database_path,
&state.active_thread_mailbox,
thread_id,
) {
Ok(Some(report)) => {
if let Some(summary) = report.last_summary.as_deref() {
lines.push(format!("Last run: {summary}"));
}
if let Some(exit_code) = report.last_exit_code {
lines.push(format!("Exit code: {exit_code}"));
}
if let Some(command) = report.last_command.as_deref() {
lines.push(format!("Command: {command}"));
}
if let Some(error) = report.last_error.as_deref() {
lines.push(format!("Error: {error}"));
}
}
Ok(None) => {}
Err(error) => {
lines.push(format!("Series report load failed: {error}"));
}
}
Some(lines.join("\n"))
}
fn load_code_source_preview(state: &AppState) -> String {
if state.is_code_edit_active() {
return render_code_edit_preview(state);
}
if !state.supports_code_browser() {
return "No kernel tree configured.\n\nSet [kernel].tree or [kernel].trees in config."
.to_string();
}
let Some(row) = state.selected_kernel_tree_row() else {
return "<kernel tree is empty>".to_string();
};
match row.kind {
KernelTreeRowKind::File | KernelTreeRowKind::RootFile => {
load_source_file_preview(&row.path)
}
KernelTreeRowKind::MissingPath => {
format!("<missing path>\n\n{}", row.path.display())
}
KernelTreeRowKind::RootDirectory | KernelTreeRowKind::Directory => format!(
"Directory: {}\n\nSelect a file in the tree to preview source content.",
row.path.display()
),
}
}
fn render_code_edit_preview(state: &AppState) -> String {
let target = state
.code_edit_target
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<unknown file>".to_string());
let dirty = if state.code_edit_dirty { "yes" } else { "no" };
let mut lines = vec![
format!("File: {target}"),
format!(
"Mode: {} | dirty: {} | cursor: {}:{}",
state.code_edit_mode.label(),
dirty,
state.code_edit_cursor_row + 1,
state.code_edit_cursor_col + 1
),
"Use h/j/k/l move, i insert, x delete, s save, E external vim, : command".to_string(),
String::new(),
];
if state.code_edit_buffer.is_empty() {
lines.push("<empty file>".to_string());
} else {
for (index, line) in state.code_edit_buffer.iter().enumerate() {
let marker = if index == state.code_edit_cursor_row {
">"
} else {
" "
};
let rendered_line = sanitize_source_preview_text(line);
lines.push(format!("{:>4}{marker} {}", index + 1, rendered_line));
}
}
if matches!(state.code_edit_mode, CodeEditMode::VimCommand) {
lines.push(String::new());
lines.push(format!(":{}", state.code_edit_command_input));
}
lines.join("\n")
}
pub(super) fn code_edit_cursor_position(state: &AppState, inner_area: Rect) -> Option<(u16, u16)> {
if !state.is_code_edit_active() || inner_area.width == 0 || inner_area.height == 0 {
return None;
}
let (logical_row, logical_col) = if matches!(state.code_edit_mode, CodeEditMode::VimCommand) {
(
code_edit_command_line_logical_row(state),
1 + state.code_edit_command_input.chars().count(),
)
} else {
let row = state
.code_edit_cursor_row
.min(state.code_edit_buffer.len().saturating_sub(1));
let line = state
.code_edit_buffer
.get(row)
.map(String::as_str)
.unwrap_or_default();
let column = state.code_edit_cursor_col.min(line.chars().count());
(
code_edit_source_line_logical_row(row),
code_edit_source_line_prefix_width(row) + display_column(line, column),
)
};
let scroll = state.code_preview_scroll as usize;
if logical_row < scroll {
return None;
}
let visible_row = logical_row - scroll;
if visible_row >= inner_area.height as usize {
return None;
}
let clamped_col = logical_col.min(inner_area.width.saturating_sub(1) as usize);
Some((
inner_area.x.saturating_add(clamped_col as u16),
inner_area.y.saturating_add(visible_row as u16),
))
}
fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) {
let Some(panel) = state.reply_panel.as_ref() else {
return;
};
let area = centered_rect(88, 84, frame.area());
if panel.preview_open {
draw_send_preview_panel(frame, area, panel);
return;
}
if panel.reply_notice.is_some() {
draw_reply_notice_panel(frame, area, panel);
return;
}
frame.render_widget(Clear, area);
let title = format!(
"Reply Panel [{} dirty:{} confirmed:{} focus:{}]",
panel.mode.label(),
if panel.dirty { "*" } else { "-" },
if panel.preview_confirmed { "yes" } else { "no" },
panel.section.label()
);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightGreen));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Clear, inner);
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(8), Constraint::Min(8)])
.split(inner);
let header_area = sections[0];
let body_area = sections[1];
let header_block = Block::default()
.title("Headers ([edit] / [read-only])")
.borders(Borders::ALL)
.border_style(reply_panel_section_style(
matches!(
panel.section,
ReplySection::From | ReplySection::To | ReplySection::Cc | ReplySection::Subject
),
false,
));
let header_inner = header_block.inner(header_area);
frame.render_widget(header_block, header_area);
frame.render_widget(Clear, header_inner);
frame.render_widget(
Paragraph::new(render_reply_header_content(panel)).wrap(Wrap { trim: false }),
header_inner,
);
let body_block = Block::default()
.title("Reply Body")
.borders(Borders::ALL)
.border_style(reply_panel_section_style(
matches!(panel.section, ReplySection::Body)
|| matches!(panel.mode, ReplyEditMode::Command),
matches!(panel.mode, ReplyEditMode::Command),
));
let body_inner = body_block.inner(body_area);
frame.render_widget(body_block, body_area);
frame.render_widget(Clear, body_inner);
let body_content_area = if body_inner.height > 1 {
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(body_inner);
frame.render_widget(
Paragraph::new(render_reply_body_guide_line(sections[0].width as usize)).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
sections[0],
);
sections[1]
} else {
body_inner
};
frame.render_widget(
Paragraph::new(render_reply_body_content(panel))
.scroll((panel.scroll, 0))
.wrap(Wrap { trim: false }),
body_content_area,
);
if let Some(cursor) = reply_panel_cursor_position(panel, header_inner, body_content_area) {
frame.set_cursor_position(cursor);
}
}
fn render_reply_header_content(panel: &ReplyPanelState) -> String {
[
format!(
"{} {}{}",
reply_section_marker(panel, ReplySection::From),
reply_editable_field_prefix(ReplySection::From),
sanitize_source_preview_text(&panel.from)
),
format!(
"{} {}{}",
reply_section_marker(panel, ReplySection::To),
reply_editable_field_prefix(ReplySection::To),
sanitize_source_preview_text(&panel.to)
),
format!(
"{} {}{}",
reply_section_marker(panel, ReplySection::Cc),
reply_editable_field_prefix(ReplySection::Cc),
sanitize_source_preview_text(&panel.cc)
),
format!(
"{} {}{}",
reply_section_marker(panel, ReplySection::Subject),
reply_editable_field_prefix(ReplySection::Subject),
sanitize_source_preview_text(&panel.subject)
),
format!(" [read-only] In-Reply-To: <{}>", panel.in_reply_to),
format!(
" [read-only] References: {}",
panel
.references
.iter()
.map(|value| format!("<{value}>"))
.collect::<Vec<String>>()
.join(" ")
),
]
.join("\n")
}
fn render_reply_body_content(panel: &ReplyPanelState) -> String {
let mut lines = Vec::with_capacity(panel.body.len() + 2);
for (index, line) in panel.body.iter().enumerate() {
let marker = if matches!(panel.section, ReplySection::Body) && panel.body_row == index {
">"
} else {
" "
};
lines.push(format!(
"{:>4}{marker} {}",
index + 1,
sanitize_source_preview_text(line)
));
}
if matches!(panel.mode, ReplyEditMode::Command) {
lines.push(String::new());
lines.push(format!(":{}", panel.command_input));
}
lines.join("\n")
}
fn render_reply_body_guide_line(width: usize) -> String {
if width == 0 {
return String::new();
}
let body_start = reply_body_prefix_width(0);
if body_start >= width {
return String::new();
}
let guide_offset = reply_body_prefix_width(0) + REPLY_BODY_GUIDE_COLUMN - 1;
let label = "| 80 cols";
let mut chars = vec![' '; width];
let pre_label_end = guide_offset.min(width);
for ch in chars.iter_mut().take(pre_label_end).skip(body_start) {
*ch = '=';
}
for (index, ch) in label.chars().enumerate() {
let position = guide_offset + index;
if position >= width {
break;
}
chars[position] = ch;
}
let label_end = (guide_offset + label.len()).min(width);
for ch in chars.iter_mut().take(width).skip(label_end) {
*ch = '=';
}
chars.into_iter().collect()
}
fn reply_panel_section_style(focused: bool, command_mode: bool) -> Style {
if focused {
if command_mode {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::LightGreen)
}
} else {
Style::default().fg(Color::DarkGray)
}
}
fn reply_section_marker(panel: &ReplyPanelState, section: ReplySection) -> &'static str {
if panel.section == section { ">" } else { " " }
}
fn reply_panel_cursor_position(
panel: &ReplyPanelState,
header_inner: Rect,
body_inner: Rect,
) -> Option<(u16, u16)> {
if panel.preview_open || panel.reply_notice.is_some() {
return None;
}
if matches!(panel.mode, ReplyEditMode::Command) {
return reply_body_cursor_position(
body_inner,
panel.scroll,
reply_command_line_logical_row(panel),
1 + panel.command_input.chars().count(),
);
}
match panel.section {
ReplySection::From => reply_fixed_cursor_position(
header_inner,
0,
reply_field_prefix_width(ReplySection::From)
+ display_column(&panel.from, panel.cursor_col),
),
ReplySection::To => reply_fixed_cursor_position(
header_inner,
1,
reply_field_prefix_width(ReplySection::To)
+ display_column(&panel.to, panel.cursor_col),
),
ReplySection::Cc => reply_fixed_cursor_position(
header_inner,
2,
reply_field_prefix_width(ReplySection::Cc)
+ display_column(&panel.cc, panel.cursor_col),
),
ReplySection::Subject => reply_fixed_cursor_position(
header_inner,
3,
reply_field_prefix_width(ReplySection::Subject)
+ display_column(&panel.subject, panel.cursor_col),
),
ReplySection::Body => {
let line = panel
.body
.get(panel.body_row)
.map(String::as_str)
.unwrap_or_default();
reply_body_cursor_position(
body_inner,
panel.scroll,
reply_body_line_logical_row(panel.body_row),
reply_body_prefix_width(panel.body_row) + display_column(line, panel.cursor_col),
)
}
}
}
fn reply_fixed_cursor_position(
inner_area: Rect,
logical_row: usize,
logical_col: usize,
) -> Option<(u16, u16)> {
if inner_area.width == 0 || inner_area.height == 0 || logical_row >= inner_area.height as usize
{
return None;
}
let clamped_col = logical_col.min(inner_area.width.saturating_sub(1) as usize);
Some((
inner_area.x.saturating_add(clamped_col as u16),
inner_area.y.saturating_add(logical_row as u16),
))
}
fn reply_body_cursor_position(
inner_area: Rect,
scroll: u16,
logical_row: usize,
logical_col: usize,
) -> Option<(u16, u16)> {
if inner_area.width == 0 || inner_area.height == 0 {
return None;
}
let scroll = scroll as usize;
if logical_row < scroll {
return None;
}
let visible_row = logical_row - scroll;
if visible_row >= inner_area.height as usize {
return None;
}
let clamped_col = logical_col.min(inner_area.width.saturating_sub(1) as usize);
Some((
inner_area.x.saturating_add(clamped_col as u16),
inner_area.y.saturating_add(visible_row as u16),
))
}
fn draw_send_preview_panel(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) {
frame.render_widget(Clear, area);
let title = if !panel.preview_errors.is_empty() {
"Send Preview [invalid]"
} else if !panel.preview_warnings.is_empty() {
"Send Preview [warning]"
} else if has_authored_reply_lines(panel) {
"Send Preview [reply highlighted]"
} else {
"Send Preview"
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner = block.inner(area);
frame.render_widget(block, area);
let content_area = draw_send_preview_messages(frame, inner, panel);
let preview = Paragraph::new(render_reply_preview_text(panel))
.scroll((panel.preview_scroll, 0))
.wrap(Wrap { trim: false });
frame.render_widget(preview, content_area);
}
fn draw_send_preview_messages(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) -> Rect {
let mut remaining_height = area.height.saturating_sub(1);
let error_height = preview_message_height(&panel.preview_errors, remaining_height);
remaining_height = remaining_height.saturating_sub(error_height);
let warning_height = preview_message_height(&panel.preview_warnings, remaining_height);
remaining_height = remaining_height.saturating_sub(warning_height);
let info_messages = preview_info_messages(panel);
let info_height = preview_message_height(&info_messages, remaining_height);
if error_height == 0 && warning_height == 0 && info_height == 0 {
return area;
}
let mut constraints = Vec::new();
if error_height > 0 {
constraints.push(Constraint::Length(error_height));
}
if warning_height > 0 {
constraints.push(Constraint::Length(warning_height));
}
if info_height > 0 {
constraints.push(Constraint::Length(info_height));
}
constraints.push(Constraint::Min(1));
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut section_index = 0usize;
if error_height > 0 {
draw_send_preview_message_block(
frame,
sections[section_index],
&panel.preview_errors,
Style::default()
.fg(Color::White)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
);
section_index += 1;
}
if warning_height > 0 {
draw_send_preview_message_block(
frame,
sections[section_index],
&panel.preview_warnings,
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
section_index += 1;
}
if info_height > 0 {
draw_send_preview_message_block(
frame,
sections[section_index],
&info_messages,
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
section_index += 1;
}
sections[section_index]
}
fn preview_message_height(messages: &[String], remaining_height: u16) -> u16 {
if messages.is_empty() || remaining_height == 0 {
return 0;
}
let line_count = messages
.iter()
.map(|message| message.lines().count().max(1))
.sum::<usize>();
line_count.min(remaining_height as usize) as u16
}
fn draw_send_preview_message_block(
frame: &mut Frame<'_>,
area: Rect,
messages: &[String],
style: Style,
) {
let text = messages
.iter()
.map(|value| format!("- {value}"))
.collect::<Vec<String>>()
.join("\n");
let paragraph = Paragraph::new(text).style(style).wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_reply_preview_text(panel: &ReplyPanelState) -> Text<'static> {
Text::from(
panel
.preview_lines
.iter()
.map(|line| {
Line::from(Span::styled(
line.text.clone(),
reply_preview_line_style(line.kind),
))
})
.collect::<Vec<Line<'static>>>(),
)
}
fn preview_info_messages(panel: &ReplyPanelState) -> Vec<String> {
if has_authored_reply_lines(panel) {
vec!["Your authored reply lines are highlighted below.".to_string()]
} else {
Vec::new()
}
}
fn has_authored_reply_lines(panel: &ReplyPanelState) -> bool {
panel
.preview_lines
.iter()
.any(|line| matches!(line.kind, ReplyPreviewLineKind::Authored))
}
fn reply_preview_line_style(kind: ReplyPreviewLineKind) -> Style {
match kind {
ReplyPreviewLineKind::Header => Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
ReplyPreviewLineKind::Blank => Style::default(),
ReplyPreviewLineKind::Authored => Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
ReplyPreviewLineKind::QuoteAttribution => Style::default().fg(Color::Cyan),
ReplyPreviewLineKind::Quoted => Style::default().fg(Color::White),
ReplyPreviewLineKind::Placeholder => Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
}
}
fn draw_reply_notice_panel(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) {
let Some(notice) = panel.reply_notice.as_ref() else {
return;
};
frame.render_widget(Clear, area);
let border = match notice.kind {
ReplyNoticeKind::Warning => Style::default().fg(Color::LightRed),
ReplyNoticeKind::Info => Style::default().fg(Color::LightGreen),
};
let block = Block::default()
.title(notice.title.as_str())
.borders(Borders::ALL)
.border_style(border);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(Clear, inner);
let content_sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(35),
Constraint::Min(3),
Constraint::Percentage(35),
])
.split(inner);
let text = format!("{}\n\n{}", notice.message, notice.hint);
let paragraph = Paragraph::new(Text::from(text))
.alignment(Alignment::Center)
.style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, content_sections[1]);
}
fn code_edit_source_line_logical_row(buffer_row: usize) -> usize {
4 + buffer_row
}
fn code_edit_source_line_prefix_width(buffer_row: usize) -> usize {
let number_width = ((buffer_row + 1).to_string().chars().count()).max(4);
number_width + 2
}
fn code_edit_command_line_logical_row(state: &AppState) -> usize {
4 + state.code_edit_buffer.len() + 1
}
fn display_column(line: &str, char_col: usize) -> usize {
let mut display_col = 0usize;
for character in line.chars().take(char_col) {
match character {
'\t' => {
display_col += PREVIEW_TAB_SPACES.chars().count();
}
'\n' | '\r' => {}
_ if character.is_control() => {}
_ => {
display_col += 1;
}
}
}
display_col
}
pub(super) fn load_source_file_preview(path: &Path) -> String {
let content = match fs::read(path) {
Ok(value) => value,
Err(error) => return format!("<failed to read {}: {}>", path.display(), error),
};
let truncated_by_bytes = content.len() > CODE_PREVIEW_MAX_BYTES;
let content_slice = if truncated_by_bytes {
&content[..CODE_PREVIEW_MAX_BYTES]
} else {
content.as_slice()
};
let text = String::from_utf8_lossy(content_slice)
.replace("\r\n", "\n")
.replace('\r', "\n");
let sanitized = sanitize_source_preview_text(&text);
let mut source_lines = sanitized.lines();
let mut lines = Vec::new();
for line in source_lines.by_ref().take(CODE_PREVIEW_MAX_LINES) {
lines.push(line);
}
let truncated_by_lines = source_lines.next().is_some();
let body = if lines.is_empty() {
"<empty file>".to_string()
} else {
lines.join("\n")
};
let mut preview = format!("File: {}\n\n{}", path.display(), body);
if truncated_by_bytes || truncated_by_lines {
preview.push_str("\n\n<truncated preview>");
}
preview
}
fn sanitize_source_preview_text(input: &str) -> String {
let mut sanitized = String::with_capacity(input.len());
for character in input.chars() {
match character {
'\n' => sanitized.push('\n'),
'\t' => sanitized.push_str(PREVIEW_TAB_SPACES),
_ if character.is_control() => {}
_ => sanitized.push(character),
}
}
sanitized
}
fn draw_search_overlay(frame: &mut Frame<'_>, state: &AppState) {
let area = centered_rect(70, 20, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title("Search Threads")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightBlue));
let inner = block.inner(area);
frame.render_widget(block, area);
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(inner);
let input = Paragraph::new(format!("> {}", state.search.input));
frame.render_widget(input, sections[0]);
let hint = Paragraph::new("Enter: apply and locate first match Esc: cancel");
frame.render_widget(hint, sections[1]);
let current = if state.search.applied_query.is_empty() {
"Current filter: <none>".to_string()
} else {
format!("Current filter: {}", state.search.applied_query)
};
frame.render_widget(Paragraph::new(current), sections[2]);
}
fn draw_command_palette(frame: &mut Frame<'_>, state: &AppState) {
let area = centered_rect(70, 28, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title("Command Palette")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightGreen));
let inner = block.inner(area);
frame.render_widget(block, area);
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(inner);
let input = Paragraph::new(format!("> {}", state.palette.input));
frame.render_widget(input, sections[0]);
let hints = Paragraph::new(
"Tab: complete (built-in + !local) Enter: execute !<cmd>: local shell in [kernel].tree (or ~) Esc: close",
);
frame.render_widget(hints, sections[1]);
let show_local_result = state.palette.last_local_result.is_some()
&& (state.palette.input.trim().is_empty()
|| state.palette.input.trim_start().starts_with('!'));
if show_local_result {
if let Some(result) = state.palette.last_local_result.as_ref() {
let header = Paragraph::new(format!(
"Local Result: !{} | exit={} | cwd={}",
result.command,
result.exit_code,
result.cwd.display()
));
frame.render_widget(header, sections[2]);
let output = Paragraph::new(result.output.clone())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
)
.wrap(Wrap { trim: false });
frame.render_widget(output, sections[3]);
}
} else {
let suggestions = palette_overlay_suggestions(state);
let candidate_title = if suggestions.is_empty() {
"Candidates: <none>"
} else if state.palette.show_suggestions {
"Completion Candidates"
} else {
"Command Candidates"
};
let candidate_header = Paragraph::new(candidate_title);
frame.render_widget(candidate_header, sections[2]);
let items: Vec<ListItem> = suggestions
.iter()
.take(8)
.map(|suggestion| match suggestion.description.as_deref() {
Some(description) if !description.is_empty() => {
ListItem::new(format!("{} - {}", suggestion.value, description))
}
_ => ListItem::new(suggestion.value.clone()),
})
.collect();
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
);
frame.render_widget(list, sections[3]);
}
}
pub(super) fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1]);
horizontal[1]
}
#[cfg(test)]
mod tests {
use super::format_uptime_label;
#[test]
fn uptime_label_uses_the_largest_needed_unit() {
assert_eq!(format_uptime_label(59), "59s");
assert_eq!(format_uptime_label(61), "01m:01s");
assert_eq!(format_uptime_label(3_661), "01h:01m:01s");
}
}