Clean up HTML table pipes in message body and improve focus indicators

- Strip pipe characters from HTML-to-markdown table remnants in email body
- Add bold + arrow prefix (▶) to focused pane title for clear focus indication
- Rename Focus::Preview and all preview_* variables to Message/message_*

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-18 15:15:43 +01:00
parent 3e647dbe52
commit e6ff04a97d
2 changed files with 86 additions and 53 deletions

View file

@ -229,16 +229,39 @@ fn clean_text(text: &str) -> String {
/// - Strip image references ![...](...)
/// - Simplify links [text](url) → text
/// - Remove bare long URLs
/// - Remove pipe characters from HTML table remnants
/// - Collapse runs of 3+ blank lines to 2
fn clean_markdown(text: &str) -> String {
let re_img = regex::Regex::new(r"!\[[^\]]*\]\([^)]*\)").unwrap();
let re_link = regex::Regex::new(r"\[([^\]]*)\]\([^)]*\)").unwrap();
let re_bare_url = regex::Regex::new(r"https?://\S{80,}").unwrap();
let re_blank_lines = regex::Regex::new(r"\n{3,}").unwrap();
// Two or more pipes possibly separated by whitespace (table borders / empty cells)
let re_multi_pipes = regex::Regex::new(r"\|[\s|]*\|").unwrap();
// Single remaining pipe with surrounding whitespace
let re_pipe = regex::Regex::new(r"\s*\|\s*").unwrap();
let re_multi_space = regex::Regex::new(r"[ \t]{2,}").unwrap();
let result = re_img.replace_all(text, "");
let result = re_link.replace_all(&result, "$1");
let result = re_bare_url.replace_all(&result, "");
// Process line by line to strip HTML table pipe remnants
let result: String = result
.lines()
.map(|line| {
// Drop lines that are entirely pipes and whitespace (table dividers)
if line.chars().all(|c| c == '|' || c.is_whitespace()) {
return String::new();
}
let line = re_multi_pipes.replace_all(line, " ");
let line = re_pipe.replace_all(&line, " ");
let line = re_multi_space.replace_all(&line, " ");
line.trim().to_string()
})
.collect::<Vec<_>>()
.join("\n");
let result = re_blank_lines.replace_all(&result, "\n\n");
result.trim().to_string()
}

View file

@ -7,7 +7,7 @@ use crossterm::event;
use crossterm::event::{Event, KeyCode};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::{Color, Modifier, Style};
use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::Terminal;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use crate::config::Config;
@ -21,7 +21,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(30);
#[derive(PartialEq)]
enum Focus {
Inbox,
Preview,
Message,
}
pub(crate) struct Email {
@ -106,11 +106,11 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
let mut loading = true;
let mut list_state = ListState::default();
let mut last_fetch = Instant::now();
let mut preview_body: String = String::new();
let mut preview_seq: Option<u32> = None;
let mut message_body: String = String::new();
let mut message_seq: Option<u32> = None;
let mut body_loading = false;
let mut focus = Focus::Inbox;
let mut preview_scroll: u16 = 0;
let mut message_scroll: u16 = 0;
// --- Main loop ---
loop {
@ -132,18 +132,18 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.unwrap_or(0);
list_state.select(Some(new_idx));
let seq = emails[new_idx].seq;
if preview_seq != Some(seq) {
preview_seq = Some(seq);
preview_body.clear();
preview_scroll = 0;
if message_seq != Some(seq) {
message_seq = Some(seq);
message_body.clear();
message_scroll = 0;
body_loading = true;
wanted_body_seq.store(seq, Ordering::Relaxed);
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
}
} else {
list_state.select(None);
preview_seq = None;
preview_body.clear();
message_seq = None;
message_body.clear();
}
}
WorkerResult::Refreshed(Err(e)) => {
@ -162,11 +162,11 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
}
WorkerResult::Body { seq, result } => {
body_loading = false;
if preview_seq == Some(seq) {
preview_scroll = 0;
if message_seq == Some(seq) {
message_scroll = 0;
match result {
Ok(body) => preview_body = body,
Err(e) => preview_body = format!("Error loading body: {}", e),
Ok(body) => message_body = body,
Err(e) => message_body = format!("Error loading body: {}", e),
}
}
}
@ -197,30 +197,36 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.split(area);
// --- Top: Inbox list ---
let inbox_border = if focus == Focus::Inbox {
Style::default().fg(Color::Cyan)
let (inbox_title_style, inbox_prefix) = if focus == Focus::Inbox {
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "")
} else {
Style::default().fg(Color::DarkGray)
(Style::default().fg(Color::DarkGray), " ")
};
let preview_border = if focus == Focus::Preview {
Style::default().fg(Color::Cyan)
let (message_title_style, message_prefix) = if focus == Focus::Message {
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "")
} else {
Style::default().fg(Color::DarkGray)
(Style::default().fg(Color::DarkGray), " ")
};
let inbox_border = inbox_title_style;
let message_border = message_title_style;
let inbox_title = |label: &str| {
Line::from(Span::styled(format!("{}{}", inbox_prefix, label), inbox_title_style))
};
if let Some(e) = &error {
let p = Paragraph::new(format!("IMAP error: {}", e))
.block(Block::default().title("Inbox").borders(Borders::ALL).border_style(inbox_border))
.block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border))
.style(Style::default().fg(Color::Red));
frame.render_widget(p, layout[0]);
} else if emails.is_empty() && loading {
let p = Paragraph::new("Loading...")
.block(Block::default().title("Inbox").borders(Borders::ALL).border_style(inbox_border))
.block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border))
.style(Style::default().fg(Color::Yellow));
frame.render_widget(p, layout[0]);
} else if emails.is_empty() {
let p = Paragraph::new("No messages in inbox.")
.block(Block::default().title("Inbox").borders(Borders::ALL).border_style(inbox_border))
.block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border))
.style(Style::default().fg(Color::Yellow));
frame.render_widget(p, layout[0]);
} else {
@ -231,7 +237,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
})
.collect();
let title = if loading {
let title_str = if loading {
format!("Inbox ({} messages) [loading...]", emails.len())
} else {
format!("Inbox ({} messages)", emails.len())
@ -240,8 +246,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.title(inbox_title(&title_str))
.borders(Borders::BOTTOM)
.border_style(inbox_border),
)
.style(Style::default().fg(Color::White))
@ -255,17 +261,21 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
frame.render_stateful_widget(list, layout[0], &mut list_state);
}
// --- Bottom: Email preview ---
let preview_text = if body_loading {
// --- Bottom: Message ---
let message_text = if body_loading {
ratatui::text::Text::raw("Loading...")
} else {
tui_markdown::from_str(&preview_body)
tui_markdown::from_str(&message_body)
};
let preview = Paragraph::new(preview_text)
.block(Block::default().title("Preview").borders(Borders::ALL).border_style(preview_border))
let message_title = Line::from(Span::styled(
format!("{}Message", message_prefix),
message_title_style,
));
let preview = Paragraph::new(message_text)
.block(Block::default().title(message_title).borders(Borders::BOTTOM).border_style(message_border))
.style(Style::default().fg(Color::White))
.wrap(ratatui::widgets::Wrap { trim: false })
.scroll((preview_scroll, 0));
.scroll((message_scroll, 0));
frame.render_widget(preview, layout[1]);
let status = Paragraph::new(" 'q' quit | 'r' refresh | 'd' delete | ↑/↓ navigate | Tab switch pane")
@ -280,8 +290,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Tab => {
focus = match focus {
Focus::Inbox => Focus::Preview,
Focus::Preview => Focus::Inbox,
Focus::Inbox => Focus::Message,
Focus::Message => Focus::Inbox,
};
}
KeyCode::Char('r') => {
@ -305,18 +315,18 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
let new_idx = i.min(len.saturating_sub(1));
list_state.select(Some(new_idx));
let seq = emails[new_idx].seq;
if preview_seq != Some(seq) {
preview_seq = Some(seq);
preview_body.clear();
preview_scroll = 0;
if message_seq != Some(seq) {
message_seq = Some(seq);
message_body.clear();
message_scroll = 0;
body_loading = true;
wanted_body_seq.store(seq, Ordering::Relaxed);
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
}
}
}
Focus::Preview => {
preview_scroll = preview_scroll.saturating_add(1);
Focus::Message => {
message_scroll = message_scroll.saturating_add(1);
}
}
KeyCode::Up | KeyCode::Char('k') => match focus {
@ -325,18 +335,18 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
list_state.select(Some(i));
if !emails.is_empty() {
let seq = emails[i].seq;
if preview_seq != Some(seq) {
preview_seq = Some(seq);
preview_body.clear();
preview_scroll = 0;
if message_seq != Some(seq) {
message_seq = Some(seq);
message_body.clear();
message_scroll = 0;
body_loading = true;
wanted_body_seq.store(seq, Ordering::Relaxed);
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
}
}
}
Focus::Preview => {
preview_scroll = preview_scroll.saturating_sub(1);
Focus::Message => {
message_scroll = message_scroll.saturating_sub(1);
}
}
KeyCode::Char('d') if focus == Focus::Inbox => {
@ -347,16 +357,16 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
emails.remove(idx);
if emails.is_empty() {
list_state.select(None);
preview_seq = None;
preview_body.clear();
message_seq = None;
message_body.clear();
} else {
let new_idx = idx.min(emails.len().saturating_sub(1));
list_state.select(Some(new_idx));
let new_seq = emails[new_idx].seq;
if preview_seq != Some(new_seq) {
preview_seq = Some(new_seq);
preview_body.clear();
preview_scroll = 0;
if message_seq != Some(new_seq) {
message_seq = Some(new_seq);
message_body.clear();
message_scroll = 0;
body_loading = true;
wanted_body_seq.store(new_seq, Ordering::Relaxed);
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });