Add focus switching and scrollable preview pane

Tab toggles focus between inbox list and preview. Focused pane
gets a cyan border. When preview is focused, up/down scrolls the
email body instead of navigating the list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-17 21:29:13 +01:00
parent b781b0fa08
commit 174a1b1030

View file

@ -17,6 +17,12 @@ mod inbox;
const POLL_INTERVAL: Duration = Duration::from_secs(30);
#[derive(PartialEq)]
enum Focus {
Inbox,
Preview,
}
pub(crate) struct Email {
pub seq: u32,
pub subject: String,
@ -89,6 +95,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
let mut preview_body: String = String::new();
let mut preview_seq: Option<u32> = None;
let mut body_loading = false;
let mut focus = Focus::Inbox;
let mut preview_scroll: u16 = 0;
// --- Main loop ---
loop {
@ -131,6 +139,7 @@ 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;
match result {
Ok(body) => preview_body = body,
Err(e) => preview_body = format!("Error loading body: {}", e),
@ -159,19 +168,30 @@ 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)
} else {
Style::default().fg(Color::DarkGray)
};
let preview_border = if focus == Focus::Preview {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
if let Some(e) = &error {
let p = Paragraph::new(format!("IMAP error: {}", e))
.block(Block::default().title("Inbox").borders(Borders::ALL))
.block(Block::default().title("Inbox").borders(Borders::ALL).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))
.block(Block::default().title("Inbox").borders(Borders::ALL).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))
.block(Block::default().title("Inbox").borders(Borders::ALL).border_style(inbox_border))
.style(Style::default().fg(Color::Yellow));
frame.render_widget(p, layout[0]);
} else {
@ -192,7 +212,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
.block(
Block::default()
.title(title)
.borders(Borders::ALL),
.borders(Borders::ALL)
.border_style(inbox_border),
)
.style(Style::default().fg(Color::White))
.highlight_style(
@ -212,12 +233,13 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
preview_body.clone()
};
let preview = Paragraph::new(preview_text)
.block(Block::default().title("Preview").borders(Borders::ALL))
.block(Block::default().title("Preview").borders(Borders::ALL).border_style(preview_border))
.style(Style::default().fg(Color::White))
.wrap(ratatui::widgets::Wrap { trim: false });
.wrap(ratatui::widgets::Wrap { trim: false })
.scroll((preview_scroll, 0));
frame.render_widget(preview, layout[1]);
let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate")
let status = Paragraph::new(" 'q' quit | 'r' refresh | ↑/↓ navigate | Tab switch pane")
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, layout[2]);
})?;
@ -227,6 +249,12 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Tab => {
focus = match focus {
Focus::Inbox => Focus::Preview,
Focus::Preview => Focus::Inbox,
};
}
KeyCode::Char('r') => {
if !loading {
let _ = cmd_tx.send(WorkerCmd::Refresh);
@ -234,7 +262,8 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
last_fetch = Instant::now();
}
}
KeyCode::Down | KeyCode::Char('j') => {
KeyCode::Down | KeyCode::Char('j') => match focus {
Focus::Inbox => {
let len = emails.len();
if len > 0 {
let i = list_state.selected().map_or(0, |i| i + 1);
@ -250,12 +279,18 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
if preview_seq != Some(seq) {
preview_seq = Some(seq);
preview_body.clear();
preview_scroll = 0;
body_loading = true;
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
}
}
}
KeyCode::Up | KeyCode::Char('k') => {
Focus::Preview => {
preview_scroll = preview_scroll.saturating_add(1);
}
}
KeyCode::Up | KeyCode::Char('k') => match focus {
Focus::Inbox => {
let i = list_state.selected().map_or(0, |i| i.saturating_sub(1));
list_state.select(Some(i));
if !emails.is_empty() {
@ -263,11 +298,16 @@ pub fn main(config: &Config, terminal: &mut Terminal<CrosstermBackend<Stdout>>)
if preview_seq != Some(seq) {
preview_seq = Some(seq);
preview_body.clear();
preview_scroll = 0;
body_loading = true;
let _ = cmd_tx.send(WorkerCmd::FetchBody { seq });
}
}
}
Focus::Preview => {
preview_scroll = preview_scroll.saturating_sub(1);
}
}
_ => {}
}
}