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:
parent
3e647dbe52
commit
e6ff04a97d
2 changed files with 86 additions and 53 deletions
23
src/inbox.rs
23
src/inbox.rs
|
|
@ -229,16 +229,39 @@ fn clean_text(text: &str) -> String {
|
||||||
/// - Strip image references 
|
/// - Strip image references 
|
||||||
/// - Simplify links [text](url) → text
|
/// - Simplify links [text](url) → text
|
||||||
/// - Remove bare long URLs
|
/// - Remove bare long URLs
|
||||||
|
/// - Remove pipe characters from HTML table remnants
|
||||||
/// - Collapse runs of 3+ blank lines to 2
|
/// - Collapse runs of 3+ blank lines to 2
|
||||||
fn clean_markdown(text: &str) -> String {
|
fn clean_markdown(text: &str) -> String {
|
||||||
let re_img = regex::Regex::new(r"!\[[^\]]*\]\([^)]*\)").unwrap();
|
let re_img = regex::Regex::new(r"!\[[^\]]*\]\([^)]*\)").unwrap();
|
||||||
let re_link = 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_bare_url = regex::Regex::new(r"https?://\S{80,}").unwrap();
|
||||||
let re_blank_lines = regex::Regex::new(r"\n{3,}").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_img.replace_all(text, "");
|
||||||
let result = re_link.replace_all(&result, "$1");
|
let result = re_link.replace_all(&result, "$1");
|
||||||
let result = re_bare_url.replace_all(&result, "");
|
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");
|
let result = re_blank_lines.replace_all(&result, "\n\n");
|
||||||
result.trim().to_string()
|
result.trim().to_string()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
116
src/lib.rs
116
src/lib.rs
|
|
@ -7,7 +7,7 @@ use crossterm::event;
|
||||||
use crossterm::event::{Event, KeyCode};
|
use crossterm::event::{Event, KeyCode};
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
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::Terminal;
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|
@ -21,7 +21,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
enum Focus {
|
enum Focus {
|
||||||
Inbox,
|
Inbox,
|
||||||
Preview,
|
Message,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct Email {
|
pub(crate) struct Email {
|
||||||
|
|
@ -106,11 +106,11 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
let mut loading = true;
|
let mut loading = true;
|
||||||
let mut list_state = ListState::default();
|
let mut list_state = ListState::default();
|
||||||
let mut last_fetch = Instant::now();
|
let mut last_fetch = Instant::now();
|
||||||
let mut preview_body: String = String::new();
|
let mut message_body: String = String::new();
|
||||||
let mut preview_seq: Option<u32> = None;
|
let mut message_seq: Option<u32> = None;
|
||||||
let mut body_loading = false;
|
let mut body_loading = false;
|
||||||
let mut focus = Focus::Inbox;
|
let mut focus = Focus::Inbox;
|
||||||
let mut preview_scroll: u16 = 0;
|
let mut message_scroll: u16 = 0;
|
||||||
|
|
||||||
// --- Main loop ---
|
// --- Main loop ---
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -132,18 +132,18 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
list_state.select(Some(new_idx));
|
list_state.select(Some(new_idx));
|
||||||
let seq = emails[new_idx].seq;
|
let seq = emails[new_idx].seq;
|
||||||
if preview_seq != Some(seq) {
|
if message_seq != Some(seq) {
|
||||||
preview_seq = Some(seq);
|
message_seq = Some(seq);
|
||||||
preview_body.clear();
|
message_body.clear();
|
||||||
preview_scroll = 0;
|
message_scroll = 0;
|
||||||
body_loading = true;
|
body_loading = true;
|
||||||
wanted_body_seq.store(seq, Ordering::Relaxed);
|
wanted_body_seq.store(seq, Ordering::Relaxed);
|
||||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
list_state.select(None);
|
list_state.select(None);
|
||||||
preview_seq = None;
|
message_seq = None;
|
||||||
preview_body.clear();
|
message_body.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WorkerResult::Refreshed(Err(e)) => {
|
WorkerResult::Refreshed(Err(e)) => {
|
||||||
|
|
@ -162,11 +162,11 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
}
|
}
|
||||||
WorkerResult::Body { seq, result } => {
|
WorkerResult::Body { seq, result } => {
|
||||||
body_loading = false;
|
body_loading = false;
|
||||||
if preview_seq == Some(seq) {
|
if message_seq == Some(seq) {
|
||||||
preview_scroll = 0;
|
message_scroll = 0;
|
||||||
match result {
|
match result {
|
||||||
Ok(body) => preview_body = body,
|
Ok(body) => message_body = body,
|
||||||
Err(e) => preview_body = format!("Error loading body: {}", e),
|
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);
|
.split(area);
|
||||||
|
|
||||||
// --- Top: Inbox list ---
|
// --- Top: Inbox list ---
|
||||||
let inbox_border = if focus == Focus::Inbox {
|
let (inbox_title_style, inbox_prefix) = if focus == Focus::Inbox {
|
||||||
Style::default().fg(Color::Cyan)
|
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ")
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
(Style::default().fg(Color::DarkGray), " ")
|
||||||
};
|
};
|
||||||
let preview_border = if focus == Focus::Preview {
|
let (message_title_style, message_prefix) = if focus == Focus::Message {
|
||||||
Style::default().fg(Color::Cyan)
|
(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ")
|
||||||
} else {
|
} 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 {
|
if let Some(e) = &error {
|
||||||
let p = Paragraph::new(format!("IMAP error: {}", e))
|
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));
|
.style(Style::default().fg(Color::Red));
|
||||||
frame.render_widget(p, layout[0]);
|
frame.render_widget(p, layout[0]);
|
||||||
} else if emails.is_empty() && loading {
|
} else if emails.is_empty() && loading {
|
||||||
let p = Paragraph::new("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));
|
.style(Style::default().fg(Color::Yellow));
|
||||||
frame.render_widget(p, layout[0]);
|
frame.render_widget(p, layout[0]);
|
||||||
} else if emails.is_empty() {
|
} else if emails.is_empty() {
|
||||||
let p = Paragraph::new("No messages in inbox.")
|
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));
|
.style(Style::default().fg(Color::Yellow));
|
||||||
frame.render_widget(p, layout[0]);
|
frame.render_widget(p, layout[0]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -231,7 +237,7 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let title = if loading {
|
let title_str = if loading {
|
||||||
format!("Inbox ({} messages) [loading...]", emails.len())
|
format!("Inbox ({} messages) [loading...]", emails.len())
|
||||||
} else {
|
} else {
|
||||||
format!("Inbox ({} messages)", emails.len())
|
format!("Inbox ({} messages)", emails.len())
|
||||||
|
|
@ -240,8 +246,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.title(title)
|
.title(inbox_title(&title_str))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::BOTTOM)
|
||||||
.border_style(inbox_border),
|
.border_style(inbox_border),
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::White))
|
.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);
|
frame.render_stateful_widget(list, layout[0], &mut list_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bottom: Email preview ---
|
// --- Bottom: Message ---
|
||||||
let preview_text = if body_loading {
|
let message_text = if body_loading {
|
||||||
ratatui::text::Text::raw("Loading...")
|
ratatui::text::Text::raw("Loading...")
|
||||||
} else {
|
} else {
|
||||||
tui_markdown::from_str(&preview_body)
|
tui_markdown::from_str(&message_body)
|
||||||
};
|
};
|
||||||
let preview = Paragraph::new(preview_text)
|
let message_title = Line::from(Span::styled(
|
||||||
.block(Block::default().title("Preview").borders(Borders::ALL).border_style(preview_border))
|
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))
|
.style(Style::default().fg(Color::White))
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false })
|
.wrap(ratatui::widgets::Wrap { trim: false })
|
||||||
.scroll((preview_scroll, 0));
|
.scroll((message_scroll, 0));
|
||||||
frame.render_widget(preview, layout[1]);
|
frame.render_widget(preview, layout[1]);
|
||||||
|
|
||||||
let status = Paragraph::new(" 'q' quit | 'r' refresh | 'd' delete | ↑/↓ navigate | Tab switch pane")
|
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::Char('q') | KeyCode::Esc => break,
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
focus = match focus {
|
focus = match focus {
|
||||||
Focus::Inbox => Focus::Preview,
|
Focus::Inbox => Focus::Message,
|
||||||
Focus::Preview => Focus::Inbox,
|
Focus::Message => Focus::Inbox,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') => {
|
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));
|
let new_idx = i.min(len.saturating_sub(1));
|
||||||
list_state.select(Some(new_idx));
|
list_state.select(Some(new_idx));
|
||||||
let seq = emails[new_idx].seq;
|
let seq = emails[new_idx].seq;
|
||||||
if preview_seq != Some(seq) {
|
if message_seq != Some(seq) {
|
||||||
preview_seq = Some(seq);
|
message_seq = Some(seq);
|
||||||
preview_body.clear();
|
message_body.clear();
|
||||||
preview_scroll = 0;
|
message_scroll = 0;
|
||||||
body_loading = true;
|
body_loading = true;
|
||||||
wanted_body_seq.store(seq, Ordering::Relaxed);
|
wanted_body_seq.store(seq, Ordering::Relaxed);
|
||||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Focus::Preview => {
|
Focus::Message => {
|
||||||
preview_scroll = preview_scroll.saturating_add(1);
|
message_scroll = message_scroll.saturating_add(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') => match focus {
|
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));
|
list_state.select(Some(i));
|
||||||
if !emails.is_empty() {
|
if !emails.is_empty() {
|
||||||
let seq = emails[i].seq;
|
let seq = emails[i].seq;
|
||||||
if preview_seq != Some(seq) {
|
if message_seq != Some(seq) {
|
||||||
preview_seq = Some(seq);
|
message_seq = Some(seq);
|
||||||
preview_body.clear();
|
message_body.clear();
|
||||||
preview_scroll = 0;
|
message_scroll = 0;
|
||||||
body_loading = true;
|
body_loading = true;
|
||||||
wanted_body_seq.store(seq, Ordering::Relaxed);
|
wanted_body_seq.store(seq, Ordering::Relaxed);
|
||||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Focus::Preview => {
|
Focus::Message => {
|
||||||
preview_scroll = preview_scroll.saturating_sub(1);
|
message_scroll = message_scroll.saturating_sub(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') if focus == Focus::Inbox => {
|
KeyCode::Char('d') if focus == Focus::Inbox => {
|
||||||
|
|
@ -347,16 +357,16 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
|
||||||
emails.remove(idx);
|
emails.remove(idx);
|
||||||
if emails.is_empty() {
|
if emails.is_empty() {
|
||||||
list_state.select(None);
|
list_state.select(None);
|
||||||
preview_seq = None;
|
message_seq = None;
|
||||||
preview_body.clear();
|
message_body.clear();
|
||||||
} else {
|
} else {
|
||||||
let new_idx = idx.min(emails.len().saturating_sub(1));
|
let new_idx = idx.min(emails.len().saturating_sub(1));
|
||||||
list_state.select(Some(new_idx));
|
list_state.select(Some(new_idx));
|
||||||
let new_seq = emails[new_idx].seq;
|
let new_seq = emails[new_idx].seq;
|
||||||
if preview_seq != Some(new_seq) {
|
if message_seq != Some(new_seq) {
|
||||||
preview_seq = Some(new_seq);
|
message_seq = Some(new_seq);
|
||||||
preview_body.clear();
|
message_body.clear();
|
||||||
preview_scroll = 0;
|
message_scroll = 0;
|
||||||
body_loading = true;
|
body_loading = true;
|
||||||
wanted_body_seq.store(new_seq, Ordering::Relaxed);
|
wanted_body_seq.store(new_seq, Ordering::Relaxed);
|
||||||
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });
|
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue