minor changes

This commit is contained in:
2026-09-12 21:15:42 -04:00
parent 8f291956fc
commit 742efa545a
29 changed files with 6779 additions and 1 deletions
Generated
+1673
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "lazymail"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
description = "A Lazygit-style terminal UI for browsing the git mailing list (public-inbox / lore.kernel.org)"
license = "MIT"
[dependencies]
anyhow = "1.0.104"
crossterm = "0.29.0"
ratatui = { version = "0.30.2", features = ["crossterm"] }
[lints.rust]
unsafe_op_in_unsafe_fn = "deny"
unused_must_use = "deny"
# Stub scaffold: pedantic/nursery on, with project-wide opinions below.
# `module_name_repetitions`: `MockBackend`/`LoreBackend` mirror their real-world
# counterparts, so the repetition is the point.
# `must_use_candidate` / `missing_errors_doc`: ad-hoc anyhow errors and tiny
# style builders make these two lints pure noise in this crate.
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
nursery = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
must_use_candidate = "allow"
missing_errors_doc = "allow"
+78
View File
@@ -1,3 +1,81 @@
# lazymail # lazymail
A TUI for the git mailing list. A TUI for the git mailing list.
## Status
Early scaffold: a Lazygit-style TUI over in-memory mock data, with a real
on-disk file picker for patch attachments.
- **Browse** — three panes: threads, messages, preview.
- **Participants** — filter threads and messages by participant (`f`), or
narrow to the selected thread's author with one key (`F`).
- **Preview** — shows the message body with any inline diff hidden; press `p`
to open the patch in a wide overlay.
- **Live** — threads and messages auto-refresh on a 2s tick while live (`t`).
- **Sender** — a separate screen for composing and sending a patch series
(`s`), with a yazi-like file picker rooted at `$HOME`.
- **Help** — `?` separates global keybinds from context-local ones.
The real backend will fetch each list's public-inbox archive (e.g.
[`lore.kernel.org/git`](https://lore.kernel.org/git/)) or shell out to
`b4`/`lei`. Sending is not implemented yet.
## Layout
Browse:
```
┌ lazymail · git.vger.kernel.org ● LIVE · ⌕ [email protected] 8 threads ┐
├─ Threads (25%) ────┬─ Messages (35%) ──────────┬─ Preview ─────────────────────┤
│ ▍[PATCH v4 0/5]... │ ▍[PATCH v4 0/5] cover ... │ From: Victoria Dye <vdye@...> │
│ author · date │ From ... · to git@... │ Date: ... │
│ [PATCH v2 0/2]... │ [PATCH v4 1/5] ... │ Subject: [PATCH v4 0/5] ... │
│ ... │ ... │ body (diff hidden) │
│ │ │ patch hidden · press p │
├────────────────────┴───────────────────────────┴───────────────────────────────┤
│ 1-3 focus · Tab cycle · j/k move · / filter · f participants · p patch ... │
└─────────────────────────────────────────────────────────────────────────────────┘
```
Sender (the right column is the file picker, active while the `Patches` field is
focused):
```
┌ lazymail · sender · git.vger.kernel.org ───────────────────────────────────────┐
├─ Compose ──────────────────────────────────┬─ ~/patches/v4 ─────────────────────┤
│ ▸ To: [email protected] │ ../ │
│ Cc: (none) │ ▍0001-refs-reftable.patch 3.2 KB │
│ Subject: [PATCH 0/2] ... │ 0002-tests.patch 1.1 KB │
│ Body: │ README.md │
│ Cover letter stub. │ │
├────────────────────────────────────────────┴────────────────────────────────────┤
│ tab field · ←/→ cursor · j/k picker · enter attach · . hidden · ^S send ... │
└─────────────────────────────────────────────────────────────────────────────────┘
```
## Run
```sh
cargo run
```
Keys:
- **Browse**: `1`/`2`/`3` or `Tab`/`h`/`l` focus panes · `j`/`k` or arrows move ·
`enter` drill into the next pane (threads → messages → preview) · `PgUp`/`PgDn`
scroll the preview · `/` text filter · `f` participant filter · `F` filter by the
selected thread's author · `c` clear participant filter · `p` open the patch
overlay (Preview focused) · `t` toggle live refresh · `r` refresh ·
`L` mailing lists · `s` sender · `?` help · `q` quit.
- **Participants**: type to search · `j`/`k` move · `enter` apply · `esc` close.
- **Patch overlay**: `j`/`k` scroll · `esc`/`p`/`enter` close.
- **Lists**: `j`/`k` move · `enter` switch list · `esc` close.
- **Sender**: `Tab` cycle fields · arrows/`Home`/`End` move the cursor ·
`Backspace`/`Delete` edit · in the `Patches` field `j`/`k` move the picker,
`l`/`Right`/`enter` open a directory or attach a file, `h`/`Left`/`Backspace`
go up, `.` toggle hidden files, `J`/`K` move the attached-patch selection,
`a` add a stub patch, `d` remove ·
`Ctrl+S` send (stub) · `esc` back.
- **Help**: `?` shows global bindings first, then the bindings local to the
current screen, mode, and focused pane.
+5
View File
@@ -0,0 +1,5 @@
edition = "2024"
max_width = 100
newline_style = "Unix"
use_field_init_shorthand = true
use_try_shorthand = true
+94
View File
@@ -0,0 +1,94 @@
//! User actions produced by key events and consumed by [`App`](crate::app::App).
use crate::app::Panel;
/// Semantic user intent, decoupled from concrete key bindings.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Leave the application.
Quit,
/// Focus a specific pane.
Focus(Panel),
/// Focus the next pane (Tab order).
CycleFocus,
/// Focus the previous pane.
CycleFocusBack,
/// Move the selection up in the focused list.
MoveUp,
/// Move the selection down in the focused list.
MoveDown,
/// Toggle the help overlay.
ToggleHelp,
/// Enter/leave filter-input mode.
ToggleFilter,
/// Open the selected message (future: pager/browser). Stub no-op.
Open,
/// Insert one character into the focused text input.
Input(char),
/// Delete the last character of the focused text input.
Backspace,
/// Leave the current mode / close the overlay / return to browsing.
Back,
/// Re-fetch data from the backend.
Refresh,
/// Open the mailing-list picker overlay.
OpenLists,
/// Open the Sender screen.
OpenSender,
/// Confirm the current overlay selection (switch list).
Confirm,
/// Focus the next compose field.
NextField,
/// Focus the previous compose field.
PrevField,
/// Attach a stub patch to the compose form.
AddPatch,
/// Remove the selected patch from the compose form.
RemovePatch,
/// Trigger the (stubbed) send.
Send,
/// Advance one clock tick (auto-refresh gating).
Tick,
/// Toggle live auto-refresh.
ToggleLive,
/// Toggle patch visibility in the preview.
TogglePatch,
/// Open the participants overlay.
OpenParticipants,
/// Remove the participant filter.
ClearParticipantFilter,
/// Filter threads/messages by the selected thread's author (one-key shortcut).
FilterBySelectedAuthor,
/// Apply the filter input to the thread list.
ApplyFilter,
/// Cancel filtering and clear the filter input.
CancelFilter,
/// Move the cursor one character left in the focused text field.
CursorLeft,
/// Move the cursor one character right in the focused text field.
CursorRight,
/// Move the cursor to the start of the focused text field.
CursorHome,
/// Move the cursor to the end of the focused text field.
CursorEnd,
/// Delete the character after the cursor.
DeleteForward,
/// Move the cursor up one line in the body.
CursorUp,
/// Move the cursor down one line in the body.
CursorDown,
/// Activate the selected file-picker entry.
PickerEnter,
/// Move the file-picker selection up.
PickerUp,
/// Move the file-picker selection down.
PickerDown,
/// Ascend to the file picker's parent directory.
PickerParent,
/// Toggle hidden entries in the file picker.
PickerToggleHidden,
/// Scroll the preview down.
ScrollDown,
/// Scroll the preview up.
ScrollUp,
}
+771
View File
@@ -0,0 +1,771 @@
//! Application state: screen, focus, mode, selections, backend handle.
use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use crate::action::Action;
use crate::backend::{self, Backend};
use crate::compose::{Compose, ComposeField};
use crate::config::{BackendKind, Config};
use crate::models::{Author, MailingList, Message, Participant, Thread};
use crate::picker::{self, PickerOutcome};
/// The three panes of the browse screen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Panel {
/// Left column: list of threads.
Threads,
/// Middle column: messages of the selected thread.
Messages,
/// Right column: preview of the selected message.
Preview,
}
impl Panel {
/// The pane after `self` in Tab order, wrapping.
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Threads => Self::Messages,
Self::Messages => Self::Preview,
Self::Preview => Self::Threads,
}
}
/// The pane before `self` in Tab order, wrapping.
#[must_use]
pub const fn prev(self) -> Self {
match self {
Self::Threads => Self::Preview,
Self::Messages => Self::Threads,
Self::Preview => Self::Messages,
}
}
}
/// Which top-level screen is shown.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Screen {
/// Read the list: threads, messages, preview.
Browse,
/// Compose and send a patch series.
Sender,
}
/// How the app interprets keys.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// Global keys apply.
Normal,
/// Typing edits the filter input.
Filter,
/// Help overlay is open; almost any key closes it.
Help,
/// Mailing-list picker is open.
Lists,
/// Participants overlay is open.
Participants,
/// Patch overlay (wide diff view) is open.
Patch,
}
/// Top-level application state.
pub struct App {
/// Data source for the active list.
pub backend: Box<dyn Backend>,
/// Backend selector, kept so a list switch can rebuild the backend.
pub kind: BackendKind,
/// All configured mailing lists.
pub lists: Vec<MailingList>,
/// Index of the active list.
pub active_list: usize,
/// Cursor in the list picker.
pub list_selected: usize,
/// Active top-level screen.
pub screen: Screen,
/// Sender compose state.
pub compose: Compose,
/// Focused pane (browse screen only).
pub focus: Panel,
/// Key interpretation mode.
pub mode: Mode,
/// Filter input edited in [`Mode::Filter`].
pub filter_input: String,
/// Whether the text filter is applied to the thread list.
pub filter_active: bool,
/// Selected thread index, clamped by `clamp_selections`.
pub thread_selected: usize,
/// Selected message index within the selected thread.
pub message_selected: usize,
/// Participant filter: only threads/messages involving this author.
pub participant_filter: Option<Author>,
/// Participant-overlay search query.
pub participant_query: String,
/// Selected participant in the overlay.
pub participant_selected: usize,
/// Filesystem picker for attaching patch files.
pub picker: picker::FilePicker,
/// Whether auto-refresh ticks are enabled.
pub live: bool,
/// Clock of the last auto-refresh.
pub last_tick: Instant,
/// Preview scroll offset (lines).
pub preview_scroll: u16,
/// Patch-overlay scroll offset (lines).
pub patch_scroll: u16,
/// Set by [`Action::Quit`] to end the event loop.
pub quit: bool,
}
impl App {
/// Create the app with the backend selected by `config`.
#[must_use]
pub fn new(config: &Config) -> Self {
let backend = backend::from_config(config);
let lists = config.lists.clone();
let active_list = config.active_list.min(lists.len().saturating_sub(1));
let compose = lists
.get(active_list)
.map_or_else(|| Compose::new(&backend::default_list()), Compose::new);
Self {
backend,
kind: config.kind,
lists,
active_list,
list_selected: active_list,
screen: Screen::Browse,
compose,
focus: Panel::Threads,
mode: Mode::Normal,
filter_input: String::new(),
filter_active: false,
thread_selected: 0,
message_selected: 0,
participant_filter: None,
participant_query: String::new(),
participant_selected: 0,
picker: picker::FilePicker::new_home(),
live: true,
last_tick: Instant::now(),
preview_scroll: 0,
patch_scroll: 0,
quit: false,
}
}
/// Apply one action.
///
/// # Errors
///
/// Propagates backend failures from [`Action::Refresh`] and [`Action::Tick`].
// Allow: this is a pure one-arm-per-action dispatcher; splitting it would
// scatter the routing logic without reducing its complexity.
#[allow(clippy::too_many_lines)]
pub fn handle(&mut self, action: Action) -> anyhow::Result<()> {
match action {
Action::Quit => self.quit = true,
Action::Focus(panel) => self.focus = panel,
Action::CycleFocus => self.focus = self.focus.next(),
Action::CycleFocusBack => self.focus = self.focus.prev(),
Action::MoveUp => self.move_selection(-1),
Action::MoveDown => self.move_selection(1),
Action::ToggleHelp => self.toggle(Mode::Help),
Action::ToggleFilter => self.toggle(Mode::Filter),
Action::Open => {
// Deliberately not Panel::next(): Enter must not wrap back to Threads.
self.focus = match self.focus {
Panel::Threads => Panel::Messages,
Panel::Messages | Panel::Preview => Panel::Preview,
};
}
Action::Input(ch) => self.input_char(ch),
Action::Backspace => self.backspace(),
Action::Back => self.back(),
Action::Refresh => {
self.backend.refresh()?;
self.clamp_selections();
}
Action::Tick => {
if self.live && self.last_tick.elapsed() >= Duration::from_secs(2) {
self.last_tick = Instant::now();
self.backend.refresh()?;
self.clamp_selections();
}
}
Action::ToggleLive => self.live = !self.live,
Action::TogglePatch => {
if self.focus == Panel::Preview && self.has_patch() {
self.patch_scroll = 0;
self.mode = Mode::Patch;
}
}
Action::FilterBySelectedAuthor => {
let author = self.selected_thread().map(|thread| thread.author.clone());
if let Some(author) = author {
self.participant_filter = Some(author);
self.clamp_selections();
}
}
Action::OpenParticipants => {
self.mode = Mode::Participants;
self.participant_query.clear();
self.participant_selected = 0;
}
Action::ClearParticipantFilter => {
self.participant_filter = None;
self.clamp_selections();
}
Action::ApplyFilter => {
self.filter_active = true;
self.mode = Mode::Normal;
self.clamp_selections();
}
Action::CancelFilter => {
self.filter_input.clear();
self.filter_active = false;
self.mode = Mode::Normal;
self.clamp_selections();
}
Action::CursorLeft => {
if self.screen == Screen::Sender {
self.compose.cursor_left();
}
}
Action::CursorRight => {
if self.screen == Screen::Sender {
self.compose.cursor_right();
}
}
Action::CursorHome => {
if self.screen == Screen::Sender {
self.compose.cursor_home();
}
}
Action::CursorEnd => {
if self.screen == Screen::Sender {
self.compose.cursor_end();
}
}
Action::DeleteForward => {
if self.screen == Screen::Sender {
self.compose.delete_forward();
}
}
Action::CursorUp => {
if self.screen == Screen::Sender {
self.compose.cursor_up();
}
}
Action::CursorDown => {
if self.screen == Screen::Sender {
self.compose.cursor_down();
}
}
Action::PickerEnter => self.picker_enter(),
Action::PickerUp => {
if self.picker_active() {
self.picker.move_selection(-1);
}
}
Action::PickerDown => {
if self.picker_active() {
self.picker.move_selection(1);
}
}
Action::PickerParent => {
if self.picker_active() {
self.picker.go_up();
}
}
Action::PickerToggleHidden => {
if self.picker_active() {
self.picker.toggle_hidden();
}
}
Action::ScrollUp => {
if self.mode == Mode::Patch {
self.patch_scroll = self.patch_scroll.saturating_sub(1);
} else {
self.preview_scroll = self.preview_scroll.saturating_sub(1);
}
}
Action::ScrollDown => {
if self.mode == Mode::Patch {
self.patch_scroll = self.patch_scroll.saturating_add(1);
} else {
self.preview_scroll = self.preview_scroll.saturating_add(1);
}
}
Action::OpenLists => {
self.list_selected = self.active_list;
self.mode = Mode::Lists;
}
Action::OpenSender => self.open_sender(),
Action::Confirm => match self.mode {
Mode::Lists => self.select_list(self.list_selected),
Mode::Participants => self.confirm_participant(),
_ => {}
},
Action::NextField => {
if self.screen == Screen::Sender {
self.compose.next_field();
}
}
Action::PrevField => {
if self.screen == Screen::Sender {
self.compose.prev_field();
}
}
Action::AddPatch => {
if self.screen == Screen::Sender {
self.compose.add_patch();
}
}
Action::RemovePatch => {
if self.screen == Screen::Sender {
self.compose.remove_selected_patch();
}
}
Action::Send => {
if self.screen == Screen::Sender {
self.compose.status = Some("send is a stub — not implemented yet".to_owned());
}
}
}
Ok(())
}
/// Threads of the active list, filtered by the active text and participant filters.
#[must_use]
pub fn threads(&self) -> Vec<&Thread> {
self.backend
.threads()
.iter()
.filter(|thread| self.thread_matches(thread))
.collect()
}
/// The selected thread, if any.
#[must_use]
pub fn selected_thread(&self) -> Option<&Thread> {
self.threads().get(self.thread_selected).copied()
}
/// Messages of the selected thread (empty when nothing is selected),
/// filtered by the participant filter.
#[must_use]
pub fn messages(&self) -> Vec<&Message> {
let Some(thread) = self.selected_thread() else {
return Vec::new();
};
self.backend
.messages(thread.id)
.iter()
.filter(|message| self.message_matches(message))
.collect()
}
/// The selected message, if any.
#[must_use]
pub fn selected_message(&self) -> Option<&Message> {
self.messages().get(self.message_selected).copied()
}
/// Aggregated participants across the whole backend, deduplicated by
/// email (case-insensitive), filtered by `participant_query`, and sorted
/// by message count (desc) then name (asc).
#[must_use]
pub fn participants(&self) -> Vec<Participant> {
let mut map: HashMap<String, (Participant, HashSet<usize>)> = HashMap::new();
for thread in self.backend.threads() {
let thread_id = thread.id.0;
for message in self.backend.messages(thread.id) {
for author in message.participants() {
let key = author.email.to_lowercase();
map.entry(key)
.and_modify(|(participant, threads)| {
participant.message_count += 1;
if threads.insert(thread_id) {
participant.thread_count += 1;
}
})
.or_insert_with(|| {
(
Participant {
author: author.clone(),
thread_count: 1,
message_count: 1,
},
HashSet::from([thread_id]),
)
});
}
}
}
let query = self.participant_query.to_lowercase();
let mut result: Vec<Participant> = map
.into_values()
.map(|(participant, _)| participant)
.filter(|participant| {
query.is_empty()
|| participant.author.name.to_lowercase().contains(&query)
|| participant.author.email.to_lowercase().contains(&query)
})
.collect();
result.sort_by(|a, b| {
b.message_count.cmp(&a.message_count).then_with(|| {
a.author
.name
.to_lowercase()
.cmp(&b.author.name.to_lowercase())
})
});
result
}
/// Whether the selected message carries a patch.
#[must_use]
pub fn has_patch(&self) -> bool {
self.selected_message().is_some_and(Message::has_patch)
}
/// Whether a thread passes the active text and participant filters.
fn thread_matches(&self, thread: &Thread) -> bool {
if self.filter_active {
let query = self.filter_input.to_lowercase();
let matches = thread.subject.to_lowercase().contains(&query)
|| thread.author.name.to_lowercase().contains(&query)
|| thread.author.email.to_lowercase().contains(&query);
if !matches {
return false;
}
}
if let Some(filter) = &self.participant_filter {
let email = filter.email.to_lowercase();
let has_participant = self.backend.messages(thread.id).iter().any(|message| {
message
.participants()
.iter()
.any(|author| author.email.to_lowercase() == email)
});
if !has_participant {
return false;
}
}
true
}
/// Whether a message passes the participant filter.
fn message_matches(&self, message: &Message) -> bool {
self.participant_filter.as_ref().is_none_or(|filter| {
let email = filter.email.to_lowercase();
message
.participants()
.iter()
.any(|author| author.email.to_lowercase() == email)
})
}
/// Whether the file picker currently owns keys (Sender screen, patches field).
fn picker_active(&self) -> bool {
self.screen == Screen::Sender && self.compose.field == ComposeField::Patches
}
/// Activate the picker's selected entry, attaching any selected file.
fn picker_enter(&mut self) {
if !self.picker_active() {
return;
}
match self.picker.enter() {
PickerOutcome::Attach(path) => {
if let Err(err) = self.compose.attach_file(&path) {
self.compose.status = Some(format!("attach failed: {err}"));
}
}
PickerOutcome::None | PickerOutcome::Descend => {}
}
}
/// Select the highlighted participant as the participant filter.
fn confirm_participant(&mut self) {
let participants = self.participants();
if participants.is_empty() {
return;
}
let idx = self
.participant_selected
.min(participants.len().saturating_sub(1));
let selected = participants
.get(idx)
.map(|participant| participant.author.clone());
self.participant_filter = selected;
self.mode = Mode::Normal;
self.clamp_selections();
}
/// Switch to `mode`, or back to [`Mode::Normal`] when already there.
fn toggle(&mut self, mode: Mode) {
self.mode = if self.mode == mode {
Mode::Normal
} else {
mode
};
}
/// Make `index` the active list and reload its snapshot.
fn select_list(&mut self, index: usize) {
let Some(list) = self.lists.get(index).cloned() else {
return;
};
self.active_list = index;
self.backend = backend::for_list(&list, self.kind);
self.thread_selected = 0;
self.message_selected = 0;
self.participant_filter = None;
self.filter_active = false;
self.filter_input.clear();
self.compose.to = list.address;
self.mode = Mode::Normal;
self.clamp_selections();
}
/// Show the Sender screen, seeding `To:` from the active list if empty.
fn open_sender(&mut self) {
self.screen = Screen::Sender;
self.mode = Mode::Normal;
if self.compose.to.is_empty() {
if let Some(list) = self.lists.get(self.active_list) {
self.compose.to = list.address.clone();
}
}
}
/// Leave the current mode, or return to browsing from the Sender screen.
fn back(&mut self) {
if self.mode == Mode::Participants {
self.mode = Mode::Normal;
self.participant_query.clear();
} else if self.mode != Mode::Normal {
self.mode = Mode::Normal;
} else if self.screen == Screen::Sender {
self.screen = Screen::Browse;
}
}
/// Route one character to the filter, participant query, or focused compose field.
fn input_char(&mut self, ch: char) {
match self.mode {
Mode::Filter => self.filter_input.push(ch),
Mode::Participants => {
self.participant_query.push(ch);
self.participant_selected = self
.participant_selected
.min(self.participants().len().saturating_sub(1));
}
Mode::Normal if self.screen == Screen::Sender => self.compose.insert_char(ch),
_ => {}
}
}
/// Delete the last character of the filter, query, or focused compose field.
fn backspace(&mut self) {
match self.mode {
Mode::Filter => {
self.filter_input.pop();
}
Mode::Participants => {
self.participant_query.pop();
self.participant_selected = self
.participant_selected
.min(self.participants().len().saturating_sub(1));
}
Mode::Normal if self.screen == Screen::Sender => self.compose.backspace(),
_ => {}
}
}
/// Move the selection of whichever list currently owns the keys.
fn move_selection(&mut self, delta: i8) {
if self.mode == Mode::Lists {
let len = self.lists.len();
if len > 0 {
self.list_selected = step(self.list_selected, len, delta);
}
} else if self.mode == Mode::Participants {
let len = self.participants().len();
if len > 0 {
self.participant_selected = step(self.participant_selected, len, delta);
}
} else if self.screen == Screen::Sender && self.compose.field == ComposeField::Patches {
self.compose.move_patch_selection(delta);
} else {
self.move_pane_selection(delta);
}
}
/// Move the selection in the focused browse pane; wraps at the edges.
fn move_pane_selection(&mut self, delta: i8) {
match self.focus {
Panel::Threads => {
let len = self.threads().len();
if len == 0 {
return;
}
let next = step(self.thread_selected, len, delta);
if next != self.thread_selected {
self.thread_selected = next;
// Thread changed: preview must start at its first message.
self.message_selected = 0;
}
}
Panel::Messages => {
let len = self.messages().len();
if len == 0 {
return;
}
self.message_selected = step(self.message_selected, len, delta);
}
Panel::Preview => {
// Future: scroll the preview.
}
}
}
/// Keep every selection within the bounds of the current (filtered) snapshot.
fn clamp_selections(&mut self) {
let threads = self.threads().len().saturating_sub(1);
self.thread_selected = self.thread_selected.min(threads);
let messages = self.messages().len().saturating_sub(1);
self.message_selected = self.message_selected.min(messages);
let lists = self.lists.len().saturating_sub(1);
self.list_selected = self.list_selected.min(lists);
let participants = self.participants().len().saturating_sub(1);
self.participant_selected = self.participant_selected.min(participants);
let patches = self.compose.patches.len().saturating_sub(1);
self.compose.patch_selected = self.compose.patch_selected.min(patches);
}
}
/// Index after `delta` steps with wrap-around; `0` for empty lists.
const fn step(index: usize, len: usize, delta: i8) -> usize {
if len == 0 {
return 0;
}
match delta {
d if d < 0 => (index + len - 1) % len,
d if d > 0 => (index + 1) % len,
_ => index,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tick_does_not_refresh_before_interval() {
let mut app = App::new(&Config::default());
let before: Vec<String> = app
.backend
.threads()
.iter()
.map(|t| t.subject.clone())
.collect();
app.handle(Action::Tick).unwrap();
let after: Vec<String> = app
.backend
.threads()
.iter()
.map(|t| t.subject.clone())
.collect();
assert_eq!(before, after);
}
#[test]
fn tick_refreshes_after_interval() {
let mut app = App::new(&Config::default());
app.last_tick = Instant::now().checked_sub(Duration::from_secs(3)).unwrap();
let before: Vec<String> = app
.backend
.threads()
.iter()
.map(|t| t.subject.clone())
.collect();
app.handle(Action::Tick).unwrap();
let after: Vec<String> = app
.backend
.threads()
.iter()
.map(|t| t.subject.clone())
.collect();
assert_ne!(before, after);
}
#[test]
fn messages_filtered_by_participant() {
let mut app = App::new(&Config::default());
let first_thread_id = app.backend.threads()[0].id;
let first_cc = app.backend.messages(first_thread_id)[0].cc[0].clone();
app.participant_filter = Some(first_cc.clone());
let filtered = app.messages();
assert!(!filtered.is_empty());
for message in &filtered {
assert!(
message
.participants()
.iter()
.any(|author| author.email == first_cc.email)
);
}
}
#[test]
fn participants_are_deduped_and_sorted() {
let app = App::new(&Config::default());
let participants = app.participants();
assert!(!participants.is_empty());
for pair in participants.windows(2) {
let (a, b) = (&pair[0], &pair[1]);
assert!(
a.message_count > b.message_count
|| (a.message_count == b.message_count
&& a.author.name.to_lowercase() <= b.author.name.to_lowercase())
);
}
let mut emails: Vec<String> = participants
.iter()
.map(|p| p.author.email.to_lowercase())
.collect();
emails.sort();
let unique_len = emails.len();
emails.dedup();
assert_eq!(emails.len(), unique_len);
}
#[test]
fn confirm_participant_clamps_out_of_range_selection() {
let mut app = App::new(&Config::default());
let target = app.participants()[0].author.clone();
// Narrow the query to a single participant so the visible list has len 1.
app.participant_query = target.email.clone();
assert_eq!(app.participants().len(), 1);
app.mode = Mode::Participants;
app.participant_selected = usize::MAX;
app.confirm_participant();
assert_eq!(app.participant_filter, Some(target));
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn open_drills_down_threads_messages_preview_without_wrapping() {
let mut app = App::new(&Config::default());
app.focus = Panel::Threads;
app.handle(Action::Open).unwrap();
assert_eq!(app.focus, Panel::Messages);
app.handle(Action::Open).unwrap();
assert_eq!(app.focus, Panel::Preview);
// Already at the deepest pane: Enter is a no-op rather than wrapping.
app.handle(Action::Open).unwrap();
assert_eq!(app.focus, Panel::Preview);
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Stub backend for the real lore.kernel.org archive.
//!
//! Not implemented: the real backend will fetch the list's public-inbox
//! archive (atom feed or mbox.gz endpoints) or shell out to `b4`/`lei`.
//! See <https://public-inbox.org/> for the archive software.
use crate::models::{MailingList, Message, Thread, ThreadId};
use super::Backend;
/// Archive-backed data source. Unreachable until networking lands.
#[derive(Clone, Debug)]
pub struct LoreBackend {
/// The list this backend would fetch from.
list: MailingList,
}
impl LoreBackend {
/// Build a stub backend for `list`.
#[must_use]
pub fn new(list: &MailingList) -> Self {
Self { list: list.clone() }
}
}
impl Backend for LoreBackend {
fn list_name(&self) -> &str {
&self.list.name
}
fn threads(&self) -> &[Thread] {
&[]
}
fn messages(&self, _thread_id: ThreadId) -> &[Message] {
&[]
}
fn refresh(&mut self) -> anyhow::Result<()> {
// TODO: fetch `self.list.archive_url` (public-inbox atom feed).
todo!("LoreBackend networking is not implemented yet")
}
}
+312
View File
@@ -0,0 +1,312 @@
//! In-memory stub data mimicking a lore.kernel.org archive.
//!
//! Nothing here performs I/O. The real backend will fetch from
//! <https://lore.kernel.org/git/> or shell out to `b4`/`lei`.
use std::collections::HashMap;
use crate::models::{Author, MailingList, Message, MessageId, Thread, ThreadId};
use super::Backend;
/// Static "mailing list content" the mock rotates through.
struct ThreadSeed {
/// Series prefix, e.g. `PATCH v4`; empty for plain discussions.
prefix: &'static str,
subject: &'static str,
author_name: &'static str,
author_email: &'static str,
/// Total message count, including the cover letter when > 1.
message_count: usize,
/// Short display date for the thread list.
date: &'static str,
}
const THREAD_SEEDS: [ThreadSeed; 10] = [
ThreadSeed {
prefix: "PATCH v4",
subject: "refs: introduce a reftable backend for packed refs",
author_name: "Patrick Steinhardt",
author_email: "[email protected]",
message_count: 6,
date: "Sep 10",
},
ThreadSeed {
prefix: "PATCH v2",
subject: "t/perf: add benchmarks for ref operations",
author_name: "Victoria Dye",
author_email: "[email protected]",
message_count: 3,
date: "Sep 10",
},
ThreadSeed {
prefix: "RFC PATCH",
subject: "log --format: add a reftable dump backend",
author_name: "Karthik Nayak",
author_email: "[email protected]",
message_count: 2,
date: "Sep 09",
},
ThreadSeed {
prefix: "PATCH",
subject: "tests: convert remaining scripts to test-tool helpers",
author_name: "Ævar Arnfjörð Bjarmason",
author_email: "[email protected]",
message_count: 4,
date: "Sep 09",
},
ThreadSeed {
prefix: "",
subject: "git maintenance: schedule tasks on macOS via launchd",
author_name: "Derrick Stolee",
author_email: "[email protected]",
message_count: 1,
date: "Sep 08",
},
ThreadSeed {
prefix: "PATCH v3",
subject: "bundle-uri: support sparse filters in clones",
author_name: "Junio C Hamano",
author_email: "[email protected]",
message_count: 5,
date: "Sep 08",
},
ThreadSeed {
prefix: "PATCH",
subject: "rebase --update-refs: silence reflog message noise",
author_name: "Phillip Wood",
author_email: "[email protected]",
message_count: 2,
date: "Sep 07",
},
ThreadSeed {
prefix: "",
subject: "Git v2.48.0 released",
author_name: "Junio C Hamano",
author_email: "[email protected]",
message_count: 1,
date: "Sep 07",
},
ThreadSeed {
prefix: "PATCH v6",
subject: "worktree list: add --porcelain output",
author_name: "Elijah Newren",
author_email: "[email protected]",
message_count: 7,
date: "Sep 06",
},
ThreadSeed {
prefix: "PATCH",
subject: "submodule: parallelize fetch with --jobs",
author_name: "Taylor Blau",
author_email: "[email protected]",
message_count: 3,
date: "Sep 06",
},
];
/// Fixed pool of reviewers assigned as `Cc:` on mock messages.
const REVIEWERS: [(&str, &str); 6] = [
("Junio C Hamano", "[email protected]"),
("Taylor Blau", "[email protected]"),
("Ævar Arnfjörð Bjarmason", "[email protected]"),
("Victoria Dye", "[email protected]"),
("Elijah Newren", "[email protected]"),
("Phillip Wood", "[email protected]"),
];
/// In-memory data source. [`Backend::refresh`] rotates the visible window
/// over [`THREAD_SEEDS`] so `r` visibly changes the data.
#[derive(Debug)]
pub struct MockBackend {
list: MailingList,
/// Refresh counter: each refresh rotates the thread window.
generation: usize,
threads: Vec<Thread>,
messages: HashMap<ThreadId, Vec<Message>>,
}
impl MockBackend {
/// Build a backend for `list` with an initial snapshot.
///
/// The starting generation is derived from the list name so different
/// lists show different (but deterministic) thread windows.
#[must_use]
pub fn new(list: &MailingList) -> Self {
let mut backend = Self {
list: list.clone(),
generation: fnv1a(list.name.as_bytes()) % THREAD_SEEDS.len(),
threads: Vec::new(),
messages: HashMap::new(),
};
backend.rebuild();
backend
}
/// Visible thread count varies with the generation so refreshes differ.
const fn visible_thread_count(generation: usize) -> usize {
THREAD_SEEDS.len().saturating_sub(2) + generation % 3
}
/// Regenerate the snapshot from the seed pool, rotated by `generation`.
fn rebuild(&mut self) {
self.threads.clear();
self.messages.clear();
let visible = Self::visible_thread_count(self.generation);
let start = self.generation % THREAD_SEEDS.len();
for offset in 0..visible {
let seed = &THREAD_SEEDS[(start + offset) % THREAD_SEEDS.len()];
let thread_id = ThreadId(offset);
self.threads.push(Thread {
id: thread_id,
subject: series_subject(seed),
author: seed_author(seed),
date: seed.date.to_owned(),
message_count: seed.message_count,
});
let list = (0..seed.message_count)
.map(|index| message_from_seed(seed, &self.list, offset, index))
.collect();
self.messages.insert(thread_id, list);
}
}
}
impl Backend for MockBackend {
fn list_name(&self) -> &str {
&self.list.name
}
fn threads(&self) -> &[Thread] {
&self.threads
}
fn messages(&self, thread_id: ThreadId) -> &[Message] {
self.messages.get(&thread_id).map_or(&[], Vec::as_slice)
}
fn refresh(&mut self) -> anyhow::Result<()> {
self.generation += 1;
self.rebuild();
Ok(())
}
}
/// Deterministic FNV-1a hash used to vary mock data per list.
fn fnv1a(bytes: &[u8]) -> usize {
let mut hash: usize = 0x811c_9dc5;
let mut index = 0;
while index < bytes.len() {
hash ^= usize::from(bytes[index]);
hash = hash.wrapping_mul(0x0100_0193);
index += 1;
}
hash
}
fn seed_author(seed: &ThreadSeed) -> Author {
Author {
name: seed.author_name.to_owned(),
email: seed.author_email.to_owned(),
}
}
/// Thread subject: `[<prefix> 0/<n>] <subject>` for series, plain otherwise.
fn series_subject(seed: &ThreadSeed) -> String {
if seed.prefix.is_empty() || seed.message_count <= 1 {
seed.subject.to_owned()
} else {
format!(
"[{} 0/{}] {}",
seed.prefix,
seed.message_count - 1,
seed.subject
)
}
}
fn message_from_seed(
seed: &ThreadSeed,
list: &MailingList,
thread_offset: usize,
index: usize,
) -> Message {
let subject = if seed.prefix.is_empty() || seed.message_count <= 1 {
seed.subject.to_owned()
} else {
format!(
"[{} {}/{}] {}",
seed.prefix,
index,
seed.message_count - 1,
seed.subject
)
};
let to = vec![Author {
name: list.name.clone(),
email: list.address.clone(),
}];
let count = 1 + (thread_offset + index) % 2;
let start = (thread_offset * 7 + index) % REVIEWERS.len();
let cc = (0..count)
.map(|i| {
let (name, email) = REVIEWERS[(start + i) % REVIEWERS.len()];
Author {
name: name.to_owned(),
email: email.to_owned(),
}
})
.collect();
Message {
id: MessageId(index),
subject,
author: seed_author(seed),
to,
cc,
date: message_date(thread_offset, index),
body: message_body(seed, index),
}
}
fn message_date(thread_offset: usize, index: usize) -> String {
format!(
"Fri, 11 Sep 2026 {:02}:{:02}:{:02} +0200",
8 + thread_offset % 9,
index * 7 % 60,
(index * 13 + thread_offset) % 60
)
}
fn message_body(seed: &ThreadSeed, index: usize) -> String {
let total = seed.message_count.saturating_sub(1);
let intro = if seed.message_count <= 1 {
format!(
"This is a standalone message about {}.\n\nDetails and rationale follow; review and\nfeedback are welcome.",
seed.subject
)
} else if index == 0 {
format!(
"Cover letter for the {} series: {}.\n\nThe full series follows. See the individual\npatches for the actual changes.",
seed.prefix, seed.subject
)
} else {
format!(
"Patch {index}/{total} of the {} series.\n\nOne focused change:\n- {} (summary stub)\n- tests updated accordingly",
seed.prefix, seed.subject
)
};
if index == 0 && seed.message_count > 1 {
// Cover letters carry no diff.
return intro;
}
let diff = format!(
"---\n diff --git a/refs/reftable/backend.c b/refs/reftable/backend.c\n\
index abcdef0..fedcba1 100644\n\
--- a/refs/reftable/backend.c\n\
+++ b/refs/reftable/backend.c\n\
@@ -1{index},3 +1{index},3 @@\n\
-// old behavior (removed)\n\
+// new behavior (lazymail placeholder diff)"
);
format!("{intro}\n\n{diff}")
}
+61
View File
@@ -0,0 +1,61 @@
//! Data sources for the mailing list.
//!
//! [`Backend`] is the seam every real data source plugs into (`b4`/`lei`
//! subprocesses, the lore HTTP API). The UI only talks to this trait.
pub mod lore;
pub mod mock;
pub use lore::LoreBackend;
pub use mock::MockBackend;
use crate::config::{BackendKind, Config};
use crate::models::{MailingList, Message, Thread, ThreadId};
/// Read model of one mailing list.
pub trait Backend {
/// Human-readable list name shown in the header, e.g. `git.vger.kernel.org`.
fn list_name(&self) -> &str;
/// All threads of the current snapshot, in display order.
fn threads(&self) -> &[Thread];
/// The messages of one thread, in order (cover letter first when present).
fn messages(&self, thread_id: ThreadId) -> &[Message];
/// Re-fetch the snapshot.
///
/// `MockBackend` rotates its data; real backends will hit the archive.
fn refresh(&mut self) -> anyhow::Result<()>;
}
/// The list used when none are configured, so callers never index blindly.
#[must_use]
pub fn default_list() -> MailingList {
MailingList {
name: "git.vger.kernel.org".to_owned(),
address: "[email protected]".to_owned(),
archive_url: "https://lore.kernel.org/git/".to_owned(),
}
}
/// Build a backend for one mailing list.
#[must_use]
pub fn for_list(list: &MailingList, kind: BackendKind) -> Box<dyn Backend> {
match kind {
BackendKind::Mock => Box::new(MockBackend::new(list)),
BackendKind::Lore => Box::new(LoreBackend::new(list)),
}
}
/// Build the backend selected by `config`.
#[must_use]
pub fn from_config(config: &Config) -> Box<dyn Backend> {
config.current_list().map_or_else(
|| {
let fallback = default_list();
for_list(&fallback, config.kind)
},
|list| for_list(list, config.kind),
)
}
+513
View File
@@ -0,0 +1,513 @@
//! Sender screen state: the compose form and its patch attachments.
use std::path::{Path, PathBuf};
use crate::models::MailingList;
/// Fields of the compose form, in Tab order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ComposeField {
/// Recipients (`To:`).
To,
/// Carbon-copy recipients (`Cc:`).
Cc,
/// Mail subject.
Subject,
/// Cover-letter / message body.
Body,
/// Attached patch files.
Patches,
}
impl ComposeField {
/// The field after `self` in Tab order, wrapping.
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::To => Self::Cc,
Self::Cc => Self::Subject,
Self::Subject => Self::Body,
Self::Body => Self::Patches,
Self::Patches => Self::To,
}
}
/// The field before `self` in Tab order, wrapping.
#[must_use]
pub const fn prev(self) -> Self {
match self {
Self::To => Self::Patches,
Self::Cc => Self::To,
Self::Subject => Self::Cc,
Self::Body => Self::Subject,
Self::Patches => Self::Body,
}
}
/// Short label rendered before the field's content.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::To => "To:",
Self::Cc => "Cc:",
Self::Subject => "Subject:",
Self::Body => "Body:",
Self::Patches => "Patches:",
}
}
}
/// One attached patch file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PatchAttachment {
/// File name, as `git format-patch` would emit it.
pub filename: String,
/// Patch subject.
pub subject: String,
/// Source path on disk (`None` for stub patches).
pub path: Option<PathBuf>,
/// Patch content (capped at 1 MiB).
pub content: String,
/// Content length in bytes.
pub size: u64,
}
/// Sender screen state.
#[derive(Clone, Debug)]
pub struct Compose {
/// Recipients, seeded from the active list address.
pub to: String,
/// Cc list (comma separated), a stub.
pub cc: String,
/// Mail subject.
pub subject: String,
/// Cover-letter body, a stub.
pub body: String,
/// Attached patch files.
pub patches: Vec<PatchAttachment>,
/// Focused form field.
pub field: ComposeField,
/// Selected patch index (meaningful when `field == Patches`).
pub patch_selected: usize,
/// Cursor position (character index) into the focused text field.
pub cursor: usize,
/// Send status line, shown at the bottom of the form.
pub status: Option<String>,
}
impl Compose {
/// Seed a compose form for `list` with stub content.
#[must_use]
pub fn new(list: &MailingList) -> Self {
Self {
to: list.address.clone(),
cc: String::new(),
subject: "[PATCH 0/2] <describe your series>".to_owned(),
body: "Cover letter stub.\n\nPatch 1 makes one focused change;\npatch 2 updates the tests.\nNothing here is sent yet."
.to_owned(),
patches: vec![
PatchAttachment {
filename: "0001-stub-change.patch".to_owned(),
subject: "stub: first change".to_owned(),
path: None,
content: String::new(),
size: 0,
},
PatchAttachment {
filename: "0002-stub-tests.patch".to_owned(),
subject: "stub: cover the change with tests".to_owned(),
path: None,
content: String::new(),
size: 0,
},
],
field: ComposeField::To,
patch_selected: 0,
cursor: 0,
status: None,
}
}
/// Focus the next form field, wrapping.
pub fn next_field(&mut self) {
self.field = self.field.next();
let len = self.current_text().map_or(0, |text| text.chars().count());
self.cursor = len;
}
/// Focus the previous form field, wrapping.
pub fn prev_field(&mut self) {
self.field = self.field.prev();
let len = self.current_text().map_or(0, |text| text.chars().count());
self.cursor = len;
}
/// Move the patch selection by `delta`, wrapping.
pub fn move_patch_selection(&mut self, delta: i8) {
let len = self.patches.len();
if len == 0 {
self.patch_selected = 0;
return;
}
let index = self.patch_selected.min(len - 1);
self.patch_selected = if delta < 0 {
(index + len - 1) % len
} else {
(index + 1) % len
};
}
/// Attach a new stub patch and select it.
pub fn add_patch(&mut self) {
let number = self.next_patch_number();
self.patches.push(PatchAttachment {
filename: format!("{number:04}-stub-patch.patch"),
subject: format!("stub patch {number}"),
path: None,
content: String::new(),
size: 0,
});
self.patch_selected = self.patches.len() - 1;
}
/// Remove the selected patch, keeping the selection in range.
pub fn remove_selected_patch(&mut self) {
if self.patches.is_empty() {
return;
}
let index = self.patch_selected.min(self.patches.len() - 1);
self.patches.remove(index);
self.patch_selected = index.min(self.patches.len().saturating_sub(1));
}
/// The focused text field, or `None` when the patch list is focused.
#[must_use]
pub fn current_text(&self) -> Option<&str> {
match self.field {
ComposeField::To => Some(&self.to),
ComposeField::Cc => Some(&self.cc),
ComposeField::Subject => Some(&self.subject),
ComposeField::Body => Some(&self.body),
ComposeField::Patches => None,
}
}
/// The focused text field for mutation, or `None` for the patch list.
#[must_use]
pub const fn current_text_mut(&mut self) -> Option<&mut String> {
match self.field {
ComposeField::To => Some(&mut self.to),
ComposeField::Cc => Some(&mut self.cc),
ComposeField::Subject => Some(&mut self.subject),
ComposeField::Body => Some(&mut self.body),
ComposeField::Patches => None,
}
}
fn focused_text(&self) -> Option<&str> {
self.current_text()
}
const fn focused_text_mut(&mut self) -> Option<&mut String> {
self.current_text_mut()
}
/// Insert `ch` at the cursor and advance it.
pub fn insert_char(&mut self, ch: char) {
let cursor = self.cursor;
let Some(text) = self.focused_text_mut() else {
return;
};
let byte = char_to_byte(text, cursor);
text.insert(byte, ch);
self.cursor = cursor + 1;
}
/// Delete the character before the cursor.
pub fn backspace(&mut self) {
let cursor = self.cursor;
if cursor == 0 {
return;
}
let Some(text) = self.focused_text_mut() else {
return;
};
let prev = char_to_byte(text, cursor - 1);
let curr = char_to_byte(text, cursor);
text.replace_range(prev..curr, "");
self.cursor = cursor - 1;
}
/// Delete the character at the cursor.
pub fn delete_forward(&mut self) {
let cursor = self.cursor;
let Some(text) = self.focused_text_mut() else {
return;
};
let byte = char_to_byte(text, cursor);
let next = char_to_byte(text, cursor + 1);
text.replace_range(byte..next, "");
}
/// Move the cursor one character left.
pub const fn cursor_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
/// Move the cursor one character right.
pub fn cursor_right(&mut self) {
let max = self.focused_text().map_or(0, |text| text.chars().count());
self.cursor = (self.cursor + 1).min(max);
}
/// Move the cursor to the start of the field.
pub const fn cursor_home(&mut self) {
self.cursor = 0;
}
/// Move the cursor to the end of the field.
pub fn cursor_end(&mut self) {
let len = self.focused_text().map_or(0, |text| text.chars().count());
self.cursor = len;
}
/// Move the cursor up one line in the body, preserving the column.
pub fn cursor_up(&mut self) {
if self.field != ComposeField::Body {
self.cursor_home();
return;
}
let Some(text) = self.focused_text() else {
return;
};
let cursor = self.cursor;
let next = move_cursor_vertical(text, cursor, true);
self.cursor = next;
}
/// Move the cursor down one line in the body, preserving the column.
pub fn cursor_down(&mut self) {
if self.field != ComposeField::Body {
self.cursor_end();
return;
}
let Some(text) = self.focused_text() else {
return;
};
let cursor = self.cursor;
let next = move_cursor_vertical(text, cursor, false);
self.cursor = next;
}
/// Attach a patch file, reading its content and deriving subject.
///
/// # Errors
///
/// Propagates I/O errors from reading `path`.
pub fn attach_file(&mut self, path: &Path) -> anyhow::Result<()> {
let mut content = std::fs::read_to_string(path)?;
if content.len() > MIB {
let mut end = MIB;
while !content.is_char_boundary(end) {
end -= 1;
}
content.truncate(end);
}
let subject = content
.lines()
.find_map(|line| {
let line = line.trim();
line.strip_prefix("Subject:")
.map(|rest| rest.trim().to_owned())
.filter(|rest| !rest.is_empty())
})
.unwrap_or_else(|| {
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("patch")
.to_owned()
});
let filename = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("patch")
.to_owned();
let size = content.len() as u64;
self.patches.push(PatchAttachment {
filename,
subject,
path: Some(path.to_path_buf()),
content,
size,
});
self.patch_selected = self.patches.len() - 1;
let name = self
.patches
.last()
.map_or("patch", |patch| patch.filename.as_str());
self.status = Some(format!("attached {name}"));
Ok(())
}
/// Highest numeric filename prefix plus one, so new names stay unique.
fn next_patch_number(&self) -> usize {
self.patches
.iter()
.filter_map(|patch| patch.filename.split('-').next())
.filter_map(|prefix| prefix.parse::<usize>().ok())
.max()
.map_or(1, |max| max + 1)
}
}
/// Byte index of the `char_index`-th character, or the string length when out of range.
fn char_to_byte(s: &str, char_index: usize) -> usize {
s.char_indices()
.nth(char_index)
.map_or(s.len(), |(index, _)| index)
}
/// Maximum bytes of patch content retained when attaching a file.
const MIB: usize = 1 << 20;
/// Character ranges `(start, end)` of every line in `text`.
fn lines(text: &str) -> Vec<(usize, usize)> {
let mut result = Vec::new();
let mut start = 0;
for (byte, ch) in text.char_indices() {
if ch == '\n' {
let end = text[..byte].chars().count();
result.push((start, end));
start = end + 1;
}
}
result.push((start, text.chars().count()));
result
}
/// New cursor after moving `up`/down one line, preserving the column.
fn move_cursor_vertical(text: &str, cursor: usize, up: bool) -> usize {
let lines = lines(text);
let Some(current) = lines
.iter()
.position(|&(start, end)| cursor >= start && cursor <= end)
else {
return cursor;
};
let column = cursor - lines[current].0;
let target = if up {
if current == 0 {
return cursor;
}
current - 1
} else {
if current + 1 >= lines.len() {
return cursor;
}
current + 1
};
let (start, end) = lines[target];
start + column.min(end - start)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn compose() -> Compose {
Compose::new(&crate::backend::default_list())
}
fn unique_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir =
std::env::temp_dir().join(format!("lazymail-compose-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn insert_and_cursor_on_subject() {
let mut compose = compose();
compose.field = ComposeField::Subject;
compose.subject.clear();
compose.cursor = 0;
compose.insert_char('a');
compose.insert_char('b');
assert_eq!(compose.subject, "ab");
assert_eq!(compose.cursor, 2);
assert_eq!(compose.current_text(), Some("ab"));
}
#[test]
fn backspace_and_delete_forward() {
let mut compose = compose();
compose.field = ComposeField::Subject;
compose.subject = "abc".to_owned();
compose.cursor = 3;
compose.backspace();
assert_eq!(compose.subject, "ab");
assert_eq!(compose.cursor, 2);
compose.cursor = 0;
compose.delete_forward();
assert_eq!(compose.subject, "b");
assert_eq!(compose.cursor, 0);
}
#[test]
fn cursor_moves_clamp() {
let mut compose = compose();
compose.field = ComposeField::Subject;
compose.subject = "ab".to_owned();
compose.cursor = 0;
compose.cursor_left();
assert_eq!(compose.cursor, 0);
compose.cursor_end();
assert_eq!(compose.cursor, 2);
compose.cursor_right();
assert_eq!(compose.cursor, 2);
compose.cursor_home();
assert_eq!(compose.cursor, 0);
}
#[test]
fn attach_file_reads_subject_and_size() {
let dir = unique_dir();
let path = dir.join("0001-fix.patch");
std::fs::write(&path, "Subject: fix the thing\n\nbody").unwrap();
let mut compose = compose();
compose.attach_file(&path).unwrap();
assert_eq!(compose.patches.len(), 3);
let attached = compose.patches.last().unwrap();
assert_eq!(attached.filename, "0001-fix.patch");
assert_eq!(attached.subject, "fix the thing");
assert_eq!(
usize::try_from(attached.size).unwrap(),
"Subject: fix the thing\n\nbody".len()
);
assert_eq!(attached.path, Some(path.clone()));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn body_cursor_up_down_preserves_column() {
let mut compose = compose();
compose.field = ComposeField::Body;
compose.body = "abc\ndefgh\nij".to_owned();
compose.cursor = 11;
compose.cursor_up();
assert_eq!(compose.cursor, 5);
compose.cursor_up();
assert_eq!(compose.cursor, 1);
compose.cursor_up();
assert_eq!(compose.cursor, 1);
compose.cursor_down();
assert_eq!(compose.cursor, 5);
compose.cursor_down();
assert_eq!(compose.cursor, 11);
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Configuration stub: defaults only, no file parsing yet.
use crate::models::MailingList;
/// Which data source `lazymail` reads from.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BackendKind {
/// In-memory mock data (the only working source today).
#[default]
Mock,
/// The real lore.kernel.org archive (backend still a stub).
// Allow: only reachable once CLI/config parsing lands (stub scaffold).
#[allow(dead_code)]
Lore,
}
/// Application configuration.
///
/// Stub: hardcoded defaults until real config parsing lands.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Config {
/// Mailing lists the user can switch between.
pub lists: Vec<MailingList>,
/// Index into `lists` of the list shown at startup.
pub active_list: usize,
/// Data source selector.
pub kind: BackendKind,
}
impl Config {
/// The list at `active_list`, or `None` when none are configured.
#[must_use]
pub fn current_list(&self) -> Option<&MailingList> {
self.lists.get(self.active_list)
}
}
impl Default for Config {
fn default() -> Self {
Self {
lists: vec![
MailingList {
name: "git.vger.kernel.org".to_owned(),
address: "[email protected]".to_owned(),
archive_url: "https://lore.kernel.org/git/".to_owned(),
},
MailingList {
name: "linux-kernel.vger.kernel.org".to_owned(),
address: "[email protected]".to_owned(),
archive_url: "https://lore.kernel.org/lkml/".to_owned(),
},
MailingList {
name: "linux-arm-kernel.vger.kernel.org".to_owned(),
address: "[email protected]".to_owned(),
archive_url: "https://lore.kernel.org/linux-arm-kernel/".to_owned(),
},
],
active_list: 0,
kind: BackendKind::Mock,
}
}
}
+399
View File
@@ -0,0 +1,399 @@
//! Key events → [`Action`] translation.
//!
//! Pure mapping: the active screen, mode and compose field influence the
//! result, so the bindings stay testable without a terminal.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::action::Action;
use crate::app::{Mode, Panel, Screen};
use crate::compose::ComposeField;
/// The context a key press is interpreted in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KeyContext {
/// Active top-level screen.
pub screen: Screen,
/// Key interpretation mode.
pub mode: Mode,
/// Focused compose field (Sender screen only).
pub field: ComposeField,
/// Focused pane (browse screen only).
pub focus: Panel,
}
/// Translate one key press into an action.
// Allow: `&KeyContext` is the contract-specified signature, even though the
// struct is small enough to copy.
#[allow(clippy::trivially_copy_pass_by_ref)]
#[must_use]
pub const fn map_key(key: &KeyEvent, ctx: &KeyContext) -> Option<Action> {
match ctx.mode {
Mode::Normal => match ctx.screen {
Screen::Browse => map_browse_key(key),
Screen::Sender => map_sender_key(key, ctx.field),
},
Mode::Filter => map_filter_key(key),
Mode::Help => Some(map_help_key(key)),
Mode::Lists => map_lists_key(key),
Mode::Participants => map_participants_key(key),
Mode::Patch => map_patch_key(key),
}
}
const fn map_browse_key(key: &KeyEvent) -> Option<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('c' | 'q') => Some(Action::Quit),
_ => None,
};
}
if key.modifiers.contains(KeyModifiers::ALT) {
return None;
}
match key.code {
KeyCode::Char('q') => Some(Action::Quit),
KeyCode::Char('1') => Some(Action::Focus(Panel::Threads)),
KeyCode::Char('2') => Some(Action::Focus(Panel::Messages)),
KeyCode::Char('3') => Some(Action::Focus(Panel::Preview)),
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => Some(Action::CycleFocus),
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => Some(Action::CycleFocusBack),
KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown),
KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp),
KeyCode::PageDown => Some(Action::ScrollDown),
KeyCode::PageUp => Some(Action::ScrollUp),
KeyCode::Enter => Some(Action::Open),
KeyCode::Char('/') => Some(Action::ToggleFilter),
KeyCode::Char('?') => Some(Action::ToggleHelp),
KeyCode::Char('r') => Some(Action::Refresh),
KeyCode::Char('L') => Some(Action::OpenLists),
KeyCode::Char('s') => Some(Action::OpenSender),
KeyCode::Char('t') => Some(Action::ToggleLive),
KeyCode::Char('f') => Some(Action::OpenParticipants),
KeyCode::Char('F') => Some(Action::FilterBySelectedAuthor),
KeyCode::Char('c') => Some(Action::ClearParticipantFilter),
KeyCode::Char('p') => Some(Action::TogglePatch),
KeyCode::Esc => Some(Action::Back),
_ => None,
}
}
const fn map_sender_key(key: &KeyEvent, field: ComposeField) -> Option<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('c' | 'q') => Some(Action::Quit),
KeyCode::Char('s') => Some(Action::Send),
_ => None,
};
}
if key.modifiers.contains(KeyModifiers::ALT) {
return None;
}
let patches = matches!(field, ComposeField::Patches);
match key.code {
KeyCode::Tab => Some(Action::NextField),
KeyCode::BackTab => Some(Action::PrevField),
KeyCode::Esc => Some(Action::Back),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right if patches => {
Some(Action::PickerEnter)
}
KeyCode::Char('.') if patches => Some(Action::PickerToggleHidden),
KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') if patches => {
Some(Action::PickerParent)
}
KeyCode::Down | KeyCode::Char('j') if patches => Some(Action::PickerDown),
KeyCode::Up | KeyCode::Char('k') if patches => Some(Action::PickerUp),
KeyCode::Char('a') if patches => Some(Action::AddPatch),
KeyCode::Char('d') if patches => Some(Action::RemovePatch),
KeyCode::Char('J') if patches => Some(Action::MoveDown),
KeyCode::Char('K') if patches => Some(Action::MoveUp),
KeyCode::Left => Some(Action::CursorLeft),
KeyCode::Right => Some(Action::CursorRight),
KeyCode::Home => Some(Action::CursorHome),
KeyCode::End => Some(Action::CursorEnd),
KeyCode::Up => Some(Action::CursorUp),
KeyCode::Down => Some(Action::CursorDown),
KeyCode::Delete => Some(Action::DeleteForward),
KeyCode::Backspace => Some(Action::Backspace),
KeyCode::Char(ch) => Some(Action::Input(ch)),
_ => None,
}
}
const fn map_lists_key(key: &KeyEvent) -> Option<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('c' | 'q') => Some(Action::Quit),
_ => None,
};
}
match key.code {
KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown),
KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp),
KeyCode::Enter => Some(Action::Confirm),
KeyCode::Esc | KeyCode::Char('q') => Some(Action::Back),
_ => None,
}
}
const fn map_filter_key(key: &KeyEvent) -> Option<Action> {
match key.code {
KeyCode::Char('c' | 'q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::Quit)
}
KeyCode::Char(ch) => Some(Action::Input(ch)),
KeyCode::Backspace => Some(Action::Backspace),
KeyCode::Enter => Some(Action::ApplyFilter),
KeyCode::Esc => Some(Action::CancelFilter),
_ => None,
}
}
const fn map_participants_key(key: &KeyEvent) -> Option<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('c' | 'q') => Some(Action::Quit),
_ => None,
};
}
match key.code {
KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown),
KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp),
KeyCode::Enter => Some(Action::Confirm),
KeyCode::Esc => Some(Action::Back),
KeyCode::Char(ch) => Some(Action::Input(ch)),
_ => None,
}
}
const fn map_patch_key(key: &KeyEvent) -> Option<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return match key.code {
KeyCode::Char('c' | 'q') => Some(Action::Quit),
_ => None,
};
}
match key.code {
KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown),
KeyCode::Up | KeyCode::Char('k') => Some(Action::ScrollUp),
KeyCode::Esc | KeyCode::Char('p') | KeyCode::Enter => Some(Action::Back),
_ => None,
}
}
const fn map_help_key(key: &KeyEvent) -> Action {
match key.code {
KeyCode::Char('c' | 'q') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit,
_ => Action::Back,
}
}
#[cfg(test)]
mod tests {
use super::*;
const BROWSE: KeyContext = KeyContext {
screen: Screen::Browse,
mode: Mode::Normal,
field: ComposeField::To,
focus: Panel::Threads,
};
const fn sender(field: ComposeField) -> KeyContext {
KeyContext {
screen: Screen::Sender,
mode: Mode::Normal,
field,
focus: Panel::Threads,
}
}
const fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn browse_live_participants_patch() {
assert_eq!(
map_key(&key(KeyCode::Char('t')), &BROWSE),
Some(Action::ToggleLive)
);
assert_eq!(
map_key(&key(KeyCode::Char('f')), &BROWSE),
Some(Action::OpenParticipants)
);
assert_eq!(
map_key(&key(KeyCode::Char('p')), &BROWSE),
Some(Action::TogglePatch)
);
assert_eq!(
map_key(&key(KeyCode::Char('F')), &BROWSE),
Some(Action::FilterBySelectedAuthor)
);
assert_eq!(
map_key(&key(KeyCode::Char('c')), &BROWSE),
Some(Action::ClearParticipantFilter)
);
}
#[test]
fn sender_cursor_keys_route_to_cursor_actions() {
let ctx = sender(ComposeField::Subject);
assert_eq!(map_key(&key(KeyCode::Left), &ctx), Some(Action::CursorLeft));
assert_eq!(
map_key(&key(KeyCode::Right), &ctx),
Some(Action::CursorRight)
);
assert_eq!(map_key(&key(KeyCode::Home), &ctx), Some(Action::CursorHome));
assert_eq!(map_key(&key(KeyCode::End), &ctx), Some(Action::CursorEnd));
assert_eq!(map_key(&key(KeyCode::Up), &ctx), Some(Action::CursorUp));
assert_eq!(map_key(&key(KeyCode::Down), &ctx), Some(Action::CursorDown));
assert_eq!(
map_key(&key(KeyCode::Delete), &ctx),
Some(Action::DeleteForward)
);
}
#[test]
fn sender_patches_field_routes_to_picker() {
let ctx = sender(ComposeField::Patches);
assert_eq!(
map_key(&key(KeyCode::Char('h')), &ctx),
Some(Action::PickerParent)
);
assert_eq!(
map_key(&key(KeyCode::Left), &ctx),
Some(Action::PickerParent)
);
assert_eq!(
map_key(&key(KeyCode::Backspace), &ctx),
Some(Action::PickerParent)
);
assert_eq!(
map_key(&key(KeyCode::Char('j')), &ctx),
Some(Action::PickerDown)
);
assert_eq!(
map_key(&key(KeyCode::Char('k')), &ctx),
Some(Action::PickerUp)
);
assert_eq!(
map_key(&key(KeyCode::Char('l')), &ctx),
Some(Action::PickerEnter)
);
assert_eq!(
map_key(&key(KeyCode::Enter), &ctx),
Some(Action::PickerEnter)
);
assert_eq!(
map_key(&key(KeyCode::Char('.')), &ctx),
Some(Action::PickerToggleHidden)
);
}
#[test]
fn sender_patches_uppercase_jk_move_patch_selection() {
let ctx = sender(ComposeField::Patches);
assert_eq!(
map_key(&key(KeyCode::Char('J')), &ctx),
Some(Action::MoveDown)
);
assert_eq!(
map_key(&key(KeyCode::Char('K')), &ctx),
Some(Action::MoveUp)
);
// Outside the patches field, uppercase J/K are plain text input.
let subject = sender(ComposeField::Subject);
assert_eq!(
map_key(&key(KeyCode::Char('J')), &subject),
Some(Action::Input('J'))
);
assert_eq!(
map_key(&key(KeyCode::Char('K')), &subject),
Some(Action::Input('K'))
);
}
#[test]
fn browse_page_keys_scroll_the_preview() {
assert_eq!(
map_key(&key(KeyCode::PageUp), &BROWSE),
Some(Action::ScrollUp)
);
assert_eq!(
map_key(&key(KeyCode::PageDown), &BROWSE),
Some(Action::ScrollDown)
);
}
#[test]
fn filter_mode_apply_and_cancel() {
let ctx = KeyContext {
screen: Screen::Browse,
mode: Mode::Filter,
field: ComposeField::To,
focus: Panel::Threads,
};
assert_eq!(
map_key(&key(KeyCode::Enter), &ctx),
Some(Action::ApplyFilter)
);
assert_eq!(
map_key(&key(KeyCode::Esc), &ctx),
Some(Action::CancelFilter)
);
assert_eq!(
map_key(&key(KeyCode::Char('x')), &ctx),
Some(Action::Input('x'))
);
assert_eq!(
map_key(&key(KeyCode::Backspace), &ctx),
Some(Action::Backspace)
);
}
#[test]
fn participants_mode_bindings() {
let ctx = KeyContext {
screen: Screen::Browse,
mode: Mode::Participants,
field: ComposeField::To,
focus: Panel::Threads,
};
assert_eq!(
map_key(&key(KeyCode::Char('j')), &ctx),
Some(Action::MoveDown)
);
assert_eq!(map_key(&key(KeyCode::Down), &ctx), Some(Action::MoveDown));
assert_eq!(
map_key(&key(KeyCode::Char('k')), &ctx),
Some(Action::MoveUp)
);
assert_eq!(map_key(&key(KeyCode::Enter), &ctx), Some(Action::Confirm));
assert_eq!(map_key(&key(KeyCode::Esc), &ctx), Some(Action::Back));
assert_eq!(
map_key(&key(KeyCode::Char('x')), &ctx),
Some(Action::Input('x'))
);
}
#[test]
fn patch_mode_scroll_and_back() {
let ctx = KeyContext {
screen: Screen::Browse,
mode: Mode::Patch,
field: ComposeField::To,
focus: Panel::Threads,
};
assert_eq!(
map_key(&key(KeyCode::Char('j')), &ctx),
Some(Action::ScrollDown)
);
assert_eq!(
map_key(&key(KeyCode::Char('k')), &ctx),
Some(Action::ScrollUp)
);
assert_eq!(map_key(&key(KeyCode::Esc), &ctx), Some(Action::Back));
assert_eq!(map_key(&key(KeyCode::Char('p')), &ctx), Some(Action::Back));
}
}
+78
View File
@@ -0,0 +1,78 @@
//! `lazymail` — a Lazygit-style TUI for browsing the git mailing list.
//!
//! Entry point: terminal setup/teardown, panic hook, event loop.
mod action;
mod app;
mod backend;
mod compose;
mod config;
mod event;
mod models;
pub mod picker;
mod theme;
mod ui;
use anyhow::Context;
use crossterm::event::{self as crossterm_event, Event, KeyEventKind};
use ratatui::{DefaultTerminal, Frame};
use std::time::Duration;
use crate::action::Action;
use crate::app::App;
use crate::config::Config;
use crate::event::KeyContext;
fn main() -> anyhow::Result<()> {
install_panic_hook();
let app = App::new(&Config::default());
let mut terminal = ratatui::try_init().context("failed to enter the alternate screen")?;
let result = run(&mut terminal, app);
ratatui::restore();
result
}
/// Restore the terminal before the panic message prints, so a panic can
/// never leave the user with a broken tty.
fn install_panic_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
ratatui::restore();
default_hook(info);
}));
}
/// Event loop: draw, read one key, apply its action, repeat until quit.
fn run(terminal: &mut DefaultTerminal, mut app: App) -> anyhow::Result<()> {
loop {
terminal
.draw(|frame| render(frame, &app))
.context("failed to draw a frame")?;
let ctx = KeyContext {
screen: app.screen,
mode: app.mode,
field: app.compose.field,
focus: app.focus,
};
if crossterm_event::poll(Duration::from_millis(250))? {
let event = crossterm_event::read().context("failed to read input")?;
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
if let Some(action) = crate::event::map_key(&key, &ctx) {
app.handle(action)?;
}
}
}
} else {
app.handle(Action::Tick)?;
}
if app.quit {
break;
}
}
Ok(())
}
fn render(frame: &mut Frame, app: &App) {
ui::render(frame, app);
}
+236
View File
@@ -0,0 +1,236 @@
//! Domain models: threads, messages, authors, mailing lists.
use std::fmt;
/// Stable identifier of a thread within one backend snapshot.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ThreadId(pub usize);
/// Stable identifier of a message within one thread.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MessageId(pub usize);
/// One mailing list the app can browse or send to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MailingList {
/// Short display name shown in the header and the list picker.
pub name: String,
/// Posting address used as the `To:` field when composing.
pub address: String,
/// Public-inbox archive URL the lore backend will fetch from.
pub archive_url: String,
}
/// Sender of a thread or message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Author {
/// Display name.
pub name: String,
/// Mail address.
pub email: String,
}
impl fmt::Display for Author {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} <{}>", self.name, self.email)
}
}
/// One discussion on the list (a patch series counts as one thread).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Thread {
/// Snapshot-local id.
pub id: ThreadId,
/// Subject of the cover letter / first message.
pub subject: String,
/// Author of the thread's first message.
pub author: Author,
/// Short display date, e.g. `Sep 10`.
pub date: String,
/// Number of messages, including the cover letter when present.
pub message_count: usize,
}
/// One mail (patch or plain message) inside a thread.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Message {
/// Snapshot-local id.
pub id: MessageId,
/// Mail subject, e.g. `[PATCH v4 2/6] ...`.
pub subject: String,
/// Sender.
pub author: Author,
/// `To:` recipients.
pub to: Vec<Author>,
/// `Cc:` recipients.
pub cc: Vec<Author>,
/// Full `Date:` header value.
pub date: String,
/// Body plus trailing diff placeholder, rendered in the preview pane.
pub body: String,
}
/// Marker that begins the diff portion of a patch body.
pub const PATCH_MARKER: &str = "diff --git ";
impl Message {
/// The diff portion of `body`, starting at the `---` line when one
/// immediately precedes the marker, otherwise at the marker's line.
///
/// Returns `None` when the body contains no patch.
#[must_use]
pub fn patch(&self) -> Option<&str> {
let pos = self.body.find(PATCH_MARKER)?;
let line_start = self.body[..pos].rfind('\n').map_or(0, |i| i + 1);
if line_start > 0 {
let prev_line_start = self.body[..line_start - 1].rfind('\n').map_or(0, |i| i + 1);
if self.body[prev_line_start..line_start - 1].trim() == "---" {
return Some(&self.body[prev_line_start..]);
}
}
Some(&self.body[line_start..])
}
/// The body with the trailing patch removed, trimmed of trailing whitespace.
#[must_use]
pub fn body_without_patch(&self) -> &str {
self.patch().map_or_else(
|| self.body.trim_end(),
|patch| {
let start = self.body.len() - patch.len();
self.body[..start].trim_end()
},
)
}
/// Whether `body` carries a diff.
#[must_use]
pub fn has_patch(&self) -> bool {
self.patch().is_some()
}
/// All participants (author, then `to`, then `cc`), deduplicated by exact
/// email address.
#[must_use]
pub fn participants(&self) -> Vec<&Author> {
let mut seen: Vec<&str> = Vec::new();
let mut out: Vec<&Author> = Vec::new();
push_unique(&self.author, &mut seen, &mut out);
for author in &self.to {
push_unique(author, &mut seen, &mut out);
}
for author in &self.cc {
push_unique(author, &mut seen, &mut out);
}
out
}
}
/// Push `author` into `out` unless its email has already been seen.
fn push_unique<'a>(author: &'a Author, seen: &mut Vec<&'a str>, out: &mut Vec<&'a Author>) {
if !seen.contains(&author.email.as_str()) {
seen.push(author.email.as_str());
out.push(author);
}
}
/// One participant aggregated across all threads for the participants overlay.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Participant {
/// The participant.
pub author: Author,
/// Number of distinct threads they appear in.
pub thread_count: usize,
/// Total number of messages they appear on.
pub message_count: usize,
}
#[cfg(test)]
mod tests {
use super::*;
fn author(name: &str, email: &str) -> Author {
Author {
name: name.to_owned(),
email: email.to_owned(),
}
}
#[test]
fn patch_splits_diff_from_intro() {
let message = Message {
id: MessageId(0),
subject: "x".to_owned(),
author: author("A", "[email protected]"),
to: Vec::new(),
cc: Vec::new(),
date: String::new(),
body: "intro\n\n---\ndiff --git a/foo b/foo\n+change".to_owned(),
};
assert_eq!(
message.patch(),
Some("---\ndiff --git a/foo b/foo\n+change")
);
assert_eq!(message.body_without_patch(), "intro");
assert!(message.has_patch());
}
#[test]
fn patch_without_preceding_separator_starts_at_marker_line() {
let message = Message {
id: MessageId(0),
subject: "x".to_owned(),
author: author("A", "[email protected]"),
to: Vec::new(),
cc: Vec::new(),
date: String::new(),
body: "intro\n\ndiff --git a/foo b/foo\n+change".to_owned(),
};
assert_eq!(message.patch(), Some("diff --git a/foo b/foo\n+change"));
assert_eq!(message.body_without_patch(), "intro");
}
#[test]
fn no_patch_body_returns_none_and_trimmed() {
let message = Message {
id: MessageId(0),
subject: "x".to_owned(),
author: author("A", "[email protected]"),
to: Vec::new(),
cc: Vec::new(),
date: String::new(),
body: " just a body \n".to_owned(),
};
assert_eq!(message.patch(), None);
assert!(!message.has_patch());
assert_eq!(message.body_without_patch(), " just a body");
}
#[test]
fn participants_dedup_by_email() {
let message = Message {
id: MessageId(0),
subject: "x".to_owned(),
author: author("Alice", "[email protected]"),
to: vec![
author("Bob", "[email protected]"),
author("Alice Again", "[email protected]"),
],
cc: vec![
author("Bob", "[email protected]"),
author("Carol", "[email protected]"),
],
date: String::new(),
body: String::new(),
};
let emails: Vec<&str> = message
.participants()
.iter()
.map(|a| a.email.as_str())
.collect();
assert_eq!(
emails,
vec!["[email protected]", "[email protected]", "[email protected]"]
);
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Filesystem picker: browse a directory and attach patch files.
use std::path::{Path, PathBuf};
/// Whether an entry is a directory or a regular file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EntryKind {
Dir,
File,
}
/// One directory entry.
#[derive(Debug)]
pub struct PickerEntry {
/// File name (not the full path).
pub name: String,
/// Full path on disk.
pub path: PathBuf,
/// Directory or file.
pub kind: EntryKind,
/// Size in bytes (`0` for directories).
pub size: u64,
/// Whether this file looks like a patch/email attachment.
pub is_patch: bool,
}
/// Directory browser state.
#[derive(Debug)]
pub struct FilePicker {
/// The directory being browsed.
pub dir: PathBuf,
/// Current entries, directories first then files, case-insensitive name.
pub entries: Vec<PickerEntry>,
/// Index of the selected entry.
pub selected: usize,
/// Whether hidden (dot) entries are shown.
pub show_hidden: bool,
/// Error reading the directory, if any.
pub error: Option<String>,
}
/// Result of activating the selected entry.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PickerOutcome {
/// Nothing selected (empty directory).
None,
/// Descended into a directory.
Descend,
/// A file to attach.
Attach(PathBuf),
}
impl FilePicker {
/// A picker rooted at `$HOME`, or the current directory when unset.
#[must_use]
pub fn new_home() -> Self {
let dir = std::env::var_os("HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
let mut picker = Self {
dir,
entries: Vec::new(),
selected: 0,
show_hidden: false,
error: None,
};
picker.refresh();
picker
}
/// Re-read `dir`, filtering hidden entries unless `show_hidden`.
pub fn refresh(&mut self) {
self.entries.clear();
self.error = None;
let entries = match std::fs::read_dir(&self.dir) {
Ok(entries) => entries,
Err(err) => {
self.error = Some(format!("cannot read {}: {err}", self.dir.display()));
self.selected = 0;
return;
}
};
for entry in entries {
let Ok(entry) = entry else { continue };
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') && !self.show_hidden {
continue;
}
let Ok(metadata) = entry.metadata() else {
continue;
};
let kind = if metadata.is_dir() {
EntryKind::Dir
} else {
EntryKind::File
};
let size = if metadata.is_dir() { 0 } else { metadata.len() };
let is_patch = matches!(kind, EntryKind::File) && is_patch_extension(&name);
self.entries.push(PickerEntry {
name,
path: entry.path(),
kind,
size,
is_patch,
});
}
self.entries.sort_by(|a, b| {
let a_dir = matches!(a.kind, EntryKind::Dir);
let b_dir = matches!(b.kind, EntryKind::Dir);
b_dir.cmp(&a_dir).then_with(|| {
a.name
.to_ascii_lowercase()
.cmp(&b.name.to_ascii_lowercase())
})
});
self.selected = self.selected.min(self.entries.len().saturating_sub(1));
}
/// Move the selection by `delta`, wrapping at the edges.
pub fn move_selection(&mut self, delta: i8) {
let len = self.entries.len();
if len == 0 {
return;
}
self.selected = if delta < 0 {
(self.selected + len - 1) % len
} else {
(self.selected + 1) % len
};
}
/// Activate the selected entry: descend into a directory or attach a file.
pub fn enter(&mut self) -> PickerOutcome {
let Some(entry) = self.entries.get(self.selected) else {
return PickerOutcome::None;
};
let kind = entry.kind;
let path = entry.path.clone();
match kind {
EntryKind::Dir => {
self.dir = path;
self.refresh();
PickerOutcome::Descend
}
EntryKind::File => PickerOutcome::Attach(path),
}
}
/// Move to the parent directory, if any.
pub fn go_up(&mut self) -> bool {
let Some(parent) = self.dir.parent().map(Path::to_path_buf) else {
return false;
};
if parent.as_os_str().is_empty() {
return false;
}
self.dir = parent;
self.selected = 0;
self.refresh();
true
}
/// Toggle hidden entries on/off and re-read.
pub fn toggle_hidden(&mut self) {
self.show_hidden = !self.show_hidden;
self.refresh();
}
/// The selected entry, if any.
#[must_use]
pub fn selected_entry(&self) -> Option<&PickerEntry> {
self.entries.get(self.selected)
}
}
/// Whether `name` has a patch/email file extension (case-insensitive).
fn is_patch_extension(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, ext)| {
matches!(
ext.to_ascii_lowercase().as_str(),
"patch" | "diff" | "mbox" | "eml"
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn unique_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir =
std::env::temp_dir().join(format!("lazymail-picker-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn picker_at(dir: PathBuf) -> FilePicker {
FilePicker {
dir,
entries: Vec::new(),
selected: 0,
show_hidden: false,
error: None,
}
}
#[test]
fn filters_hidden_and_toggles() {
let dir = unique_dir();
std::fs::write(dir.join("visible.txt"), "x").unwrap();
std::fs::write(dir.join(".hidden.txt"), "x").unwrap();
let mut picker = picker_at(dir.clone());
picker.refresh();
let names: Vec<&str> = picker.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["visible.txt"]);
picker.toggle_hidden();
let names: Vec<&str> = picker.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec![".hidden.txt", "visible.txt"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn sorts_dirs_before_files() {
let dir = unique_dir();
std::fs::create_dir(dir.join("zeta_dir")).unwrap();
std::fs::write(dir.join("alpha.txt"), "x").unwrap();
std::fs::create_dir(dir.join("beta_dir")).unwrap();
let mut picker = picker_at(dir.clone());
picker.refresh();
let names: Vec<&str> = picker.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["beta_dir", "zeta_dir", "alpha.txt"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn move_selection_wraps() {
let dir = unique_dir();
std::fs::write(dir.join("a"), "x").unwrap();
std::fs::write(dir.join("b"), "x").unwrap();
std::fs::write(dir.join("c"), "x").unwrap();
let mut picker = picker_at(dir.clone());
picker.refresh();
assert_eq!(picker.selected, 0);
picker.move_selection(-1);
assert_eq!(picker.selected, 2);
picker.move_selection(1);
assert_eq!(picker.selected, 0);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn go_up_returns_to_parent() {
let dir = unique_dir();
std::fs::create_dir(dir.join("sub")).unwrap();
let mut picker = picker_at(dir.clone());
picker.refresh();
assert_eq!(picker.enter(), PickerOutcome::Descend);
assert_eq!(picker.dir, dir.join("sub"));
assert!(picker.go_up());
assert_eq!(picker.dir, dir);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn recognizes_patch_extensions() {
assert!(is_patch_extension("0001-fix.patch"));
assert!(is_patch_extension("foo.PATCH"));
assert!(is_patch_extension("series.diff"));
assert!(is_patch_extension("msg.mbox"));
assert!(is_patch_extension("mail.eml"));
assert!(!is_patch_extension("notes.txt"));
assert!(!is_patch_extension("README"));
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Colors and shared style helpers for all widgets.
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Block;
/// Accent color: focused borders, selection, brand text.
pub const ACCENT: Color = Color::Cyan;
/// Muted color: unfocused borders, secondary text.
pub const MUTED: Color = Color::DarkGray;
/// Background of the highlighted row.
const SELECT_BG: Color = Color::Rgb(23, 42, 58);
/// Style for brand/key text on the header and footer.
#[must_use]
pub const fn accent() -> Style {
Style::new().fg(ACCENT)
}
/// Style for secondary text (dates, authors, hints).
#[must_use]
pub const fn muted() -> Style {
Style::new().fg(MUTED)
}
/// Style for the highlighted list row.
#[must_use]
pub const fn selection() -> Style {
Style::new()
.fg(ACCENT)
.bg(SELECT_BG)
.add_modifier(Modifier::BOLD)
}
/// Bordered pane block; the border is [`ACCENT`] when focused.
#[must_use]
pub fn pane_block(title: &str, focused: bool) -> Block<'_> {
let border = if focused { ACCENT } else { MUTED };
Block::bordered()
.border_style(Style::new().fg(border))
.title(title)
}
+209
View File
@@ -0,0 +1,209 @@
//! File-picker pane: a yazi-like mini browser for attaching patch files.
use std::path::{Path, PathBuf};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap};
use crate::app::App;
use crate::compose::ComposeField;
use crate::picker::{EntryKind, PickerEntry};
use crate::theme;
/// Emphasized style for attachable patch files.
const PATCH_STYLE: Style = Style::new().fg(Color::Green).add_modifier(Modifier::BOLD);
/// Render the file picker: a bordered directory listing with a hint footer.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let picker = &app.picker;
let focused = app.compose.field == ComposeField::Patches;
let title = format!(" {} ", directory_label(&picker.dir, picker.show_hidden));
let block = theme::pane_block(&title, focused);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.is_empty() {
return;
}
let [list_area, hint_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(inner);
render_hint(frame, hint_area);
if let Some(error) = &picker.error {
frame.render_widget(
Paragraph::new(error.as_str())
.style(theme::muted())
.wrap(Wrap { trim: true }),
list_area,
);
return;
}
if list_area.is_empty() {
return;
}
if picker.entries.is_empty() {
frame.render_widget(
Paragraph::new("empty directory").style(theme::muted()),
list_area,
);
return;
}
let height = usize::from(list_area.height);
let offset = scroll_offset(picker.selected, picker.entries.len(), height);
let mut state = ListState::default()
.with_selected(Some(
picker.selected.min(picker.entries.len().saturating_sub(1)),
))
.with_offset(offset);
let list = List::new(picker.entries.iter().map(entry_item))
.highlight_style(theme::selection())
.highlight_symbol("▸ ");
frame.render_stateful_widget(list, list_area, &mut state);
}
/// One picker row: directories accent with a trailing `/`, patch files green
/// and bold with a size, other files muted.
fn entry_item(entry: &PickerEntry) -> ListItem<'static> {
let line = match entry.kind {
EntryKind::Dir => Line::from(Span::styled(format!("{}/", entry.name), theme::accent())),
EntryKind::File => {
let style = if entry.is_patch {
PATCH_STYLE
} else {
theme::muted()
};
Line::from(vec![
Span::styled(entry.name.clone(), style),
Span::styled(format!(" {}", human_size(entry.size)), style),
])
}
};
ListItem::new(line)
}
/// Footer hint line shown inside the picker block.
fn render_hint(frame: &mut Frame, area: Rect) {
if area.is_empty() {
return;
}
let line = Line::from(vec![
Span::styled("j/k", theme::accent()),
Span::raw(" move"),
Span::styled(" · ", theme::muted()),
Span::styled("l/Enter", theme::accent()),
Span::raw(" open"),
Span::styled(" · ", theme::muted()),
Span::styled("h", theme::accent()),
Span::raw(" parent"),
Span::styled(" · ", theme::muted()),
Span::styled(".", theme::accent()),
Span::raw(" hidden"),
]);
frame.render_widget(Paragraph::new(line), area);
}
/// Block title: the directory (home abbreviated to `~`) plus a hidden marker.
fn directory_label(dir: &Path, show_hidden: bool) -> String {
let label = std::env::var_os("HOME")
.map(PathBuf::from)
.map_or_else(|| dir.display().to_string(), |home| abbreviate(dir, &home));
with_hidden(&label, show_hidden)
}
/// Abbreviate a leading `home` prefix to `~`.
fn abbreviate(dir: &Path, home: &Path) -> String {
let Ok(rest) = dir.strip_prefix(home) else {
return dir.display().to_string();
};
if rest.as_os_str().is_empty() {
"~".to_owned()
} else {
format!("~/{}", rest.display())
}
}
/// Append the `[hidden]` suffix when hidden entries are shown.
fn with_hidden(label: &str, show_hidden: bool) -> String {
if show_hidden {
format!("{label} [hidden]")
} else {
label.to_owned()
}
}
/// Offset that keeps `selected` visible in a `height`-row viewport.
pub const fn scroll_offset(selected: usize, len: usize, height: usize) -> usize {
if height == 0 || len <= height {
return 0;
}
let first_visible = selected.saturating_add(1).saturating_sub(height);
let last_window = len - height;
if first_visible < last_window {
first_visible
} else {
last_window
}
}
/// Human-readable byte size: `12 B`, `1.5 KB`, `3.0 MB`.
pub fn human_size(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
if bytes < KIB {
format!("{bytes} B")
} else if bytes < MIB {
scaled(bytes, KIB, "KB")
} else {
scaled(bytes, MIB, "MB")
}
}
/// `bytes` as a one-decimal multiple of `unit`, avoiding float conversions.
fn scaled(bytes: u64, unit: u64, suffix: &str) -> String {
let whole = bytes / unit;
let tenth = (bytes % unit) * 10 / unit;
format!("{whole}.{tenth} {suffix}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_size_scales_units() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1023), "1023 B");
assert_eq!(human_size(1024), "1.0 KB");
assert_eq!(human_size(1536), "1.5 KB");
assert_eq!(human_size(1024 * 1024), "1.0 MB");
assert_eq!(human_size(1024 * 1024 + 512 * 1024), "1.5 MB");
}
#[test]
fn scroll_offset_keeps_selection_visible() {
assert_eq!(scroll_offset(0, 10, 5), 0);
assert_eq!(scroll_offset(4, 10, 5), 0);
assert_eq!(scroll_offset(5, 10, 5), 1);
assert_eq!(scroll_offset(9, 10, 5), 5);
assert_eq!(scroll_offset(3, 3, 5), 0);
assert_eq!(scroll_offset(2, 10, 0), 0);
}
#[test]
fn abbreviates_home_prefix() {
let home = Path::new("/home/me");
assert_eq!(abbreviate(Path::new("/home/me"), home), "~");
assert_eq!(abbreviate(Path::new("/home/me/src"), home), "~/src");
assert_eq!(abbreviate(Path::new("/etc"), home), "/etc");
}
#[test]
fn hidden_marker_is_appended() {
assert_eq!(with_hidden("~/src", true), "~/src [hidden]");
assert_eq!(with_hidden("~/src", false), "~/src");
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Footer bar: context-sensitive keybinding hints (or the filter prompt).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::{App, Mode, Panel, Screen};
use crate::compose::ComposeField;
use crate::theme;
/// One `key` → `label` hint pair.
type Binding = (&'static str, &'static str);
/// Hints while browsing with the thread or message list focused.
const BROWSE_BINDINGS: &[Binding] = &[
("tab", "cycle"),
("j/k", "move"),
("enter", "open"),
("/", "filter"),
("f", "people"),
("F", "author"),
("c", "clear"),
("t", "live"),
("s", "sender"),
("?", "help"),
("q", "quit"),
];
/// Hints while browsing with the preview focused.
const BROWSE_PREVIEW_BINDINGS: &[Binding] = &[
("p", "patch"),
("pgup/pgdn", "scroll"),
("tab", "cycle"),
("/", "filter"),
("f", "people"),
("F", "author"),
("c", "clear"),
("t", "live"),
("s", "sender"),
("?", "help"),
("q", "quit"),
];
/// Hints on the Sender screen while a text field is focused.
const SENDER_TEXT_BINDINGS: &[Binding] = &[
("tab", "field"),
("←/→", "cursor"),
("home/end", "line"),
("⌫", "edit"),
("^S", "send"),
("esc", "back"),
("^C", "quit"),
];
/// Hints on the Sender screen while the patches field is focused.
const SENDER_PATCHES_BINDINGS: &[Binding] = &[
("tab", "field"),
("j/k", "pick"),
("J/K", "patch"),
("enter", "open"),
("h", "parent"),
(".", "hidden"),
("a", "add"),
("d", "remove"),
("^S", "send"),
("esc", "back"),
("^C", "quit"),
];
/// Hints while typing a filter.
const FILTER_BINDINGS: &[Binding] = &[("type", "edit"), ("enter", "apply"), ("esc", "cancel")];
/// Hints while the participants overlay is open.
const PARTICIPANTS_BINDINGS: &[Binding] = &[
("type", "search"),
("j/k", "move"),
("enter", "apply"),
("esc", "cancel"),
];
/// Hints while the patch overlay is open.
const PATCH_BINDINGS: &[Binding] = &[("j/k", "scroll"), ("esc/p/enter", "close")];
/// Hints while the mailing-list picker is open.
const LISTS_BINDINGS: &[Binding] = &[("j/k", "move"), ("enter", "select"), ("esc", "cancel")];
/// Render the one-line footer bar.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
if app.mode == Mode::Filter {
render_filter_prompt(frame, area, app);
} else {
render_bindings(frame, area, bindings(app));
}
}
/// The hint set for the current screen, mode and focused pane or field.
const fn bindings(app: &App) -> &'static [Binding] {
match app.mode {
Mode::Filter => FILTER_BINDINGS,
Mode::Participants => PARTICIPANTS_BINDINGS,
Mode::Patch => PATCH_BINDINGS,
Mode::Lists => LISTS_BINDINGS,
Mode::Normal | Mode::Help => match app.screen {
Screen::Browse => browse_bindings(app.focus),
Screen::Sender => sender_bindings(app.compose.field),
},
}
}
/// Browse hints for the focused pane.
const fn browse_bindings(focus: Panel) -> &'static [Binding] {
match focus {
Panel::Threads | Panel::Messages => BROWSE_BINDINGS,
Panel::Preview => BROWSE_PREVIEW_BINDINGS,
}
}
/// Sender hints for the focused compose field.
const fn sender_bindings(field: ComposeField) -> &'static [Binding] {
match field {
ComposeField::Patches => SENDER_PATCHES_BINDINGS,
ComposeField::To | ComposeField::Cc | ComposeField::Subject | ComposeField::Body => {
SENDER_TEXT_BINDINGS
}
}
}
/// Render `key label` pairs separated by bars.
fn render_bindings(frame: &mut Frame, area: Rect, bindings: &[Binding]) {
let mut spans = Vec::with_capacity(bindings.len() * 3);
for (index, (key, label)) in bindings.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" │ ", theme::muted()));
}
spans.push(Span::styled(*key, theme::accent()));
spans.push(Span::from(format!(" {label}")));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
/// Filter prompt replacing the hints while typing a filter.
fn render_filter_prompt(frame: &mut Frame, area: Rect, app: &App) {
let prompt = Line::from(vec![
Span::styled(" filter: ", theme::accent()),
Span::from(app.filter_input.as_str()),
Span::styled("▌", theme::accent()),
Span::styled(" enter apply │ esc cancel", theme::muted()),
]);
frame.render_widget(Paragraph::new(prompt), area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn keys(app: &App) -> Vec<&'static str> {
bindings(app).iter().map(|(key, _)| *key).collect()
}
#[test]
fn browse_hints_include_participant_and_patch_keys() {
let app = App::new(&Config::default());
let hints = keys(&app);
for key in ["t", "f", "F", "c"] {
assert!(hints.contains(&key), "browse hints must mention {key}");
}
// `p` opens the patch only when the preview pane is focused.
assert!(!hints.contains(&"p"), "threads focus must not advertise p");
let mut preview = App::new(&Config::default());
preview.focus = Panel::Preview;
assert!(
keys(&preview).contains(&"p"),
"preview focus must mention p"
);
}
#[test]
fn hints_follow_mode_and_compose_field() {
let mut app = App::new(&Config::default());
app.mode = Mode::Patch;
assert_eq!(keys(&app), ["j/k", "esc/p/enter"]);
app.mode = Mode::Normal;
app.screen = Screen::Sender;
app.compose.field = ComposeField::Patches;
assert!(keys(&app).contains(&"a"));
app.compose.field = ComposeField::To;
assert!(keys(&app).contains(&"home/end"));
}
}
+60
View File
@@ -0,0 +1,60 @@
//! Header bar: app brand, screen context, list name, live state, filters.
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::{App, Screen};
use crate::theme;
/// Render the one-line header bar.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
if area.width == 0 || area.height == 0 {
return;
}
let mut spans = vec![Span::styled(" lazymail ", theme::selection())];
if app.screen == Screen::Sender {
spans.push(Span::from(" · sender"));
}
spans.push(Span::from(format!(" · {}", app.backend.list_name())));
if let Some(filter) = &app.participant_filter {
spans.push(Span::styled(" · ", theme::muted()));
spans.push(Span::styled(format!("⌕ {}", filter.email), theme::accent()));
}
if app.filter_active {
spans.push(Span::styled(" · ", theme::muted()));
spans.push(Span::styled(
format!("⌕ \"{}\"", app.filter_input),
theme::accent(),
));
}
let (live_label, live_style) = if app.live {
("● LIVE", theme::accent())
} else {
("○ PAUSED", theme::muted())
};
let live = Span::styled(live_label, live_style);
if app.screen == Screen::Sender {
spans.push(Span::styled(" · ", theme::muted()));
spans.push(live);
frame.render_widget(Paragraph::new(Line::from(spans)), area);
return;
}
let count = format!("{} threads", app.threads().len());
let status = format!(" · {count}");
// Two extra columns for breathing room.
let status_width =
u16::try_from(live_label.chars().count() + status.chars().count() + 2).unwrap_or(32);
let [left, right] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(status_width)]).areas(area);
frame.render_widget(Paragraph::new(Line::from(spans)), left);
let right_line = Line::from(vec![live, Span::styled(status, theme::muted())]);
frame.render_widget(
Paragraph::new(right_line).alignment(Alignment::Right),
right,
);
}
+305
View File
@@ -0,0 +1,305 @@
//! Help overlay: centered cheat sheet split into global and context-local keys.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap};
use crate::app::{App, Mode, Panel, Screen};
use crate::compose::ComposeField;
use crate::theme;
/// One `keys` → `description` help row.
type Binding = (&'static str, &'static str);
/// Keys available from any browse context.
const GLOBAL_BINDINGS: &[Binding] = &[
("q / ^C", "quit"),
("?", "help"),
("r", "refresh"),
("t", "toggle live"),
("L", "mailing lists"),
("s", "compose/sender"),
("tab / shift-tab", "cycle pane"),
("h / l / ← / →", "cycle pane"),
("1 / 2 / 3", "focus pane"),
];
/// Browse keys while the thread or message list is focused.
const BROWSE_BINDINGS: &[Binding] = &[
("j / k / ↑ / ↓", "move"),
("enter", "open"),
("/", "filter"),
("f", "participants"),
("F", "filter author"),
("c", "clear author"),
("esc", "back"),
];
/// Browse keys while the preview is focused.
const BROWSE_PREVIEW_BINDINGS: &[Binding] = &[
("p", "view patch"),
("pgup / pgdn", "scroll"),
("enter", "open"),
("/", "filter"),
("f", "participants"),
("F", "filter author"),
("c", "clear author"),
("esc", "back"),
];
/// Sender keys while a text field is focused.
const SENDER_TEXT_BINDINGS: &[Binding] = &[
("tab / shift-tab", "switch field"),
("← / →", "move cursor"),
("home / end", "line ends"),
("backspace / del", "edit"),
("^S", "send (stub)"),
("esc", "back to browse"),
];
/// Sender keys while the patches field (file picker) is focused.
const SENDER_PATCHES_BINDINGS: &[Binding] = &[
("tab / shift-tab", "switch field"),
("j / k / ↑ / ↓", "pick file"),
("J / K", "select patch"),
("l / → / enter", "open dir/file"),
("h / ← / bksp", "parent dir"),
(".", "hidden files"),
("a", "add stub patch"),
("d", "remove patch"),
("^S", "send (stub)"),
("esc", "back to browse"),
];
/// Filter-mode keys.
const FILTER_BINDINGS: &[Binding] = &[
("type", "edit filter"),
("enter", "apply"),
("esc", "cancel"),
];
/// Participants-overlay keys.
const PARTICIPANTS_BINDINGS: &[Binding] = &[
("type", "search"),
("j / k / ↑ / ↓", "move"),
("enter", "apply"),
("esc", "cancel"),
];
/// Patch-overlay keys.
const PATCH_BINDINGS: &[Binding] = &[("j / k / ↑ / ↓", "scroll"), ("esc / p / enter", "close")];
/// Mailing-list picker keys.
const LISTS_BINDINGS: &[Binding] = &[
("j / k / ↑ / ↓", "move"),
("enter", "select"),
("esc", "cancel"),
];
/// The context the overlay documents.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Context {
/// Browse screen, per focused pane.
Browse(Panel),
/// Sender screen, per focused compose field.
Sender(ComposeField),
/// Text filter input.
Filter,
/// Participants overlay.
Participants,
/// Patch overlay.
Patch,
/// Mailing-list picker.
Lists,
}
impl Context {
/// Human-readable context name, e.g. `Browse / Threads`.
fn label(self) -> String {
match self {
Self::Browse(panel) => format!("Browse / {}", panel_label(panel)),
Self::Sender(field) => format!("Sender / {}", field_label(field)),
Self::Filter => "Filter".to_owned(),
Self::Participants => "Participants".to_owned(),
Self::Patch => "Patch".to_owned(),
Self::Lists => "Lists".to_owned(),
}
}
}
/// Render the centered help overlay on top of the panes.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
frame.render_widget(Clear, area);
let [_, middle, _] = Layout::vertical([
Constraint::Percentage(10),
Constraint::Min(18),
Constraint::Percentage(10),
])
.areas(area);
let [_, center, _] = Layout::horizontal([
Constraint::Percentage(5),
Constraint::Fill(1),
Constraint::Percentage(5),
])
.areas(middle);
let ctx = context(app);
let mut lines = Vec::new();
lines.push(section_heading("Global"));
lines.extend(binding_lines(GLOBAL_BINDINGS));
lines.push(Line::from(""));
lines.push(section_heading(&format!("Local — {}", ctx.label())));
lines.extend(binding_lines(local_bindings(app)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"any key closes this overlay",
theme::muted(),
)));
let block = Block::bordered()
.border_style(theme::accent())
.title(format!(" help · {} ", ctx.label()))
.padding(Padding::uniform(1));
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, center);
}
/// A section label line, e.g. `Global` or `Local — Filter`.
fn section_heading(title: &str) -> Line<'static> {
Line::from(Span::styled(
title.to_owned(),
theme::accent().add_modifier(Modifier::BOLD),
))
}
/// Render `bindings` as lines holding up to two `keys description` columns.
fn binding_lines(bindings: &[Binding]) -> Vec<Line<'static>> {
let key_width = bindings
.iter()
.map(|(keys, _)| keys.chars().count())
.max()
.unwrap_or(0);
bindings
.chunks(2)
.map(|pair| {
let mut spans = Vec::with_capacity(pair.len() * 4);
for (index, (keys, description)) in pair.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(format!("{keys:<key_width$}"), theme::accent()));
spans.push(Span::raw(format!(" {description}")));
}
Line::from(spans)
})
.collect()
}
/// The context the help overlay describes: mode first, then screen + focus.
const fn context(app: &App) -> Context {
match app.mode {
Mode::Filter => Context::Filter,
Mode::Participants => Context::Participants,
Mode::Patch => Context::Patch,
Mode::Lists => Context::Lists,
Mode::Normal | Mode::Help => match app.screen {
Screen::Browse => Context::Browse(app.focus),
Screen::Sender => Context::Sender(app.compose.field),
},
}
}
/// The local binding table for `app`'s current context.
const fn local_bindings(app: &App) -> &'static [Binding] {
match context(app) {
Context::Browse(Panel::Preview) => BROWSE_PREVIEW_BINDINGS,
Context::Browse(Panel::Threads | Panel::Messages) => BROWSE_BINDINGS,
Context::Sender(ComposeField::Patches) => SENDER_PATCHES_BINDINGS,
Context::Sender(
ComposeField::To | ComposeField::Cc | ComposeField::Subject | ComposeField::Body,
) => SENDER_TEXT_BINDINGS,
Context::Filter => FILTER_BINDINGS,
Context::Participants => PARTICIPANTS_BINDINGS,
Context::Patch => PATCH_BINDINGS,
Context::Lists => LISTS_BINDINGS,
}
}
/// Pane name for a browse context label.
const fn panel_label(panel: Panel) -> &'static str {
match panel {
Panel::Threads => "Threads",
Panel::Messages => "Messages",
Panel::Preview => "Preview",
}
}
/// Field name for a sender context label.
const fn field_label(field: ComposeField) -> &'static str {
match field {
ComposeField::To => "To",
ComposeField::Cc => "Cc",
ComposeField::Subject => "Subject",
ComposeField::Body => "Body",
ComposeField::Patches => "Patches",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn context_names_screen_mode_and_focus() {
let mut app = App::new(&Config::default());
assert_eq!(context(&app), Context::Browse(Panel::Threads));
app.focus = Panel::Preview;
assert_eq!(context(&app).label(), "Browse / Preview");
app.mode = Mode::Participants;
assert_eq!(context(&app), Context::Participants);
app.mode = Mode::Normal;
app.screen = Screen::Sender;
assert_eq!(context(&app).label(), "Sender / To");
app.compose.field = ComposeField::Patches;
assert_eq!(context(&app).label(), "Sender / Patches");
}
#[test]
fn local_bindings_follow_the_context() {
let mut app = App::new(&Config::default());
assert_eq!(local_bindings(&app), BROWSE_BINDINGS);
app.focus = Panel::Preview;
assert_eq!(local_bindings(&app), BROWSE_PREVIEW_BINDINGS);
app.mode = Mode::Patch;
assert_eq!(local_bindings(&app), PATCH_BINDINGS);
}
#[test]
fn render_draws_sections_and_survives_tiny_terminals() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Cell;
let app = App::new(&Config::default());
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), &app))
.unwrap();
let buffer = terminal.backend().buffer().clone();
let rendered: String = buffer.content().iter().map(Cell::symbol).collect();
assert!(rendered.contains("Global"));
assert!(rendered.contains("Local — Browse / Threads"));
for (width, height) in [(80, 24), (40, 10), (20, 5), (1, 1)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), &app))
.unwrap();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Screen splitting: header/body/footer rows and the three body columns.
use ratatui::layout::{Constraint, Layout, Rect};
/// Split the full screen into header (1 row), body, footer (1 row).
#[must_use]
pub fn root(area: Rect) -> [Rect; 3] {
Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(area)
}
/// Split the body into Threads (~25%), Messages (~35%), Preview (rest).
#[must_use]
pub fn columns(body: Rect) -> [Rect; 3] {
Layout::horizontal([
Constraint::Percentage(25),
Constraint::Percentage(35),
Constraint::Fill(1),
])
.areas(body)
}
+51
View File
@@ -0,0 +1,51 @@
//! Mailing-list picker overlay: centered list of configured lists.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Padding};
use crate::app::App;
use crate::theme;
/// Render the centered list picker on top of the browse screen.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
frame.render_widget(Clear, area);
let [_, middle, _] = Layout::vertical([
Constraint::Percentage(25),
Constraint::Min(10),
Constraint::Percentage(25),
])
.areas(area);
let [_, center, _] = Layout::horizontal([
Constraint::Percentage(20),
Constraint::Fill(1),
Constraint::Percentage(20),
])
.areas(middle);
let items = app.lists.iter().enumerate().map(|(index, list)| {
let marker = if index == app.active_list { "●" } else { " " };
let name = Line::from(vec![
Span::raw(format!("{marker} ")),
Span::raw(list.name.as_str()),
]);
let meta = Line::from(Span::styled(
format!(" {} · {}", list.address, list.archive_url),
theme::muted(),
));
ListItem::new(vec![name, meta])
});
let mut state = ListState::default();
state.select(Some(app.list_selected));
let block = Block::bordered()
.border_style(theme::accent())
.title(" lists ")
.padding(Padding::uniform(1));
let list = List::new(items)
.block(block)
.highlight_style(theme::selection())
.highlight_symbol("▍");
frame.render_stateful_widget(list, center, &mut state);
}
+95
View File
@@ -0,0 +1,95 @@
//! Messages pane: the mails of the selected thread (middle column).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use crate::app::{App, Panel};
use crate::models::{Author, Message};
use crate::theme;
use crate::ui::filepicker::scroll_offset;
/// Render the message list of the selected thread.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let messages = app.messages();
let title = format!(" Messages ({}) ", messages.len());
let block = theme::pane_block(title.as_str(), app.focus == Panel::Messages);
if messages.is_empty() {
frame.render_widget(
Paragraph::new("no messages")
.style(theme::muted())
.block(block),
area,
);
return;
}
let items = messages.iter().map(|message| {
ListItem::new(vec![
Line::from(message.subject.as_str()),
Line::from(Span::styled(participant_line(message), theme::muted())),
])
});
let mut state = ListState::default();
state.select(Some(app.message_selected));
let visible = usize::from(block.inner(area).height) / 2;
*state.offset_mut() = scroll_offset(app.message_selected, messages.len(), visible);
let list = List::new(items)
.block(block)
.highlight_style(theme::selection())
.highlight_symbol("▍");
frame.render_stateful_widget(list, area, &mut state);
}
/// One muted line: `From Name <email> · to a@x, b@y · cc c@z +N`.
fn participant_line(message: &Message) -> String {
let to = recipients("to", &message.to);
let cc = recipients("cc", &message.cc);
match (to, cc) {
(Some(to), Some(cc)) => format!("From {} · {to} · {cc}", message.author),
(Some(to), None) => format!("From {} · {to}", message.author),
(None, Some(cc)) => format!("From {} · {cc}", message.author),
(None, None) => format!("From {}", message.author),
}
}
/// Format up to two `prefix email` recipients, collapsing the rest into `+N`.
fn recipients(prefix: &str, authors: &[Author]) -> Option<String> {
const SHOWN: usize = 2;
if authors.is_empty() {
return None;
}
let shown = authors
.iter()
.take(SHOWN)
.map(|author| author.email.as_str())
.collect::<Vec<_>>()
.join(", ");
let extra = authors.len().saturating_sub(SHOWN);
Some(if extra == 0 {
format!("{prefix} {shown}")
} else {
format!("{prefix} {shown} +{extra}")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn author(email: &str) -> Author {
Author {
name: "N".to_owned(),
email: email.to_owned(),
}
}
#[test]
fn recipients_collapse_beyond_two() {
let list = vec![author("a@x"), author("b@x"), author("c@x")];
assert_eq!(recipients("to", &list), Some("to a@x, b@x +1".to_owned()));
assert_eq!(recipients("cc", &[]), None);
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Top-level rendering: screen splitting and delegation to the widgets.
pub mod filepicker;
pub mod footer;
pub mod header;
pub mod help;
pub mod layout;
pub mod lists;
pub mod messages;
pub mod participants;
pub mod patch;
pub mod preview;
pub mod sender;
pub mod threads;
use ratatui::Frame;
use crate::app::{App, Mode, Screen};
/// Render the full screen.
pub fn render(frame: &mut Frame, app: &App) {
let [header_area, body_area, footer_area] = layout::root(frame.area());
header::render(frame, header_area, app);
match app.screen {
Screen::Browse => {
let [threads_area, messages_area, preview_area] = layout::columns(body_area);
threads::render(frame, threads_area, app);
messages::render(frame, messages_area, app);
preview::render(frame, preview_area, app);
}
Screen::Sender => sender::render(frame, body_area, app),
}
footer::render(frame, footer_area, app);
if app.mode == Mode::Help {
help::render(frame, frame.area(), app);
}
if app.mode == Mode::Lists {
lists::render(frame, frame.area(), app);
}
if app.mode == Mode::Participants {
participants::render(frame, frame.area(), app);
}
if app.mode == Mode::Patch {
patch::render(frame, frame.area(), app);
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Participants overlay: pick a participant to filter threads and messages.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Padding, Paragraph};
use crate::app::App;
use crate::models::Participant;
use crate::theme;
/// Render the centered participants overlay.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
if area.width == 0 || area.height == 0 {
return;
}
frame.render_widget(Clear, area);
let [_, middle, _] = Layout::vertical([
Constraint::Percentage(15),
Constraint::Percentage(70),
Constraint::Percentage(15),
])
.areas(area);
let [_, center, _] = Layout::horizontal([
Constraint::Percentage(20),
Constraint::Percentage(60),
Constraint::Percentage(20),
])
.areas(middle);
if center.width == 0 || center.height == 0 {
return;
}
let block = Block::bordered()
.border_style(theme::accent())
.title(" participants ")
.padding(Padding::uniform(1));
let inner = block.inner(center);
frame.render_widget(block, center);
if inner.width == 0 || inner.height == 0 {
return;
}
let participants = app.participants();
let [search_area, status_area, list_area, footer_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(inner);
render_search(frame, search_area, app);
render_status(frame, status_area, app, participants.len());
render_list(frame, list_area, app, &participants);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"type to search · j/k move · Enter apply · Esc cancel",
theme::muted(),
))),
footer_area,
);
}
/// The `Filter: <query>▌` search line.
fn render_search(frame: &mut Frame, area: Rect, app: &App) {
let mut spans = vec![Span::styled("Filter: ", theme::accent())];
if app.participant_query.is_empty() {
spans.push(Span::styled("type to filter…", theme::muted()));
} else {
spans.push(Span::raw(app.participant_query.as_str()));
}
spans.push(Span::styled("▌", theme::accent()));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
/// Active-filter banner, or the number of (query-filtered) participants.
fn render_status(frame: &mut Frame, area: Rect, app: &App, count: usize) {
let line = app.participant_filter.as_ref().map_or_else(
|| {
Line::from(Span::styled(
format!("{count} participants"),
theme::muted(),
))
},
|filter| {
Line::from(vec![
Span::styled("active filter: ", theme::muted()),
Span::styled(filter.email.as_str(), theme::accent()),
])
},
);
frame.render_widget(Paragraph::new(line), area);
}
/// The scrollable participant list.
fn render_list(frame: &mut Frame, area: Rect, app: &App, participants: &[Participant]) {
if area.width == 0 || area.height == 0 {
return;
}
if participants.is_empty() {
frame.render_widget(
Paragraph::new("no participants").style(theme::muted()),
area,
);
return;
}
let selected = app.participant_selected.min(participants.len() - 1);
let visible = usize::from(area.height);
let offset = selected.saturating_sub(visible.saturating_sub(1));
// The highlight symbol occupies one column left of every row.
let row_width = usize::from(area.width).saturating_sub(1);
let filter = app
.participant_filter
.as_ref()
.map(|author| author.email.as_str());
let items = participants
.iter()
.map(|participant| row(participant, filter, row_width));
let mut state = ListState::default();
state.select(Some(selected));
*state.offset_mut() = offset;
let list = List::new(items)
.highlight_style(theme::selection())
.highlight_symbol("▍");
frame.render_stateful_widget(list, area, &mut state);
}
/// One participant row: `Name <email>` left, counts right.
fn row(participant: &Participant, filter: Option<&str>, width: usize) -> ListItem<'static> {
let active = filter.is_some_and(|email| email.eq_ignore_ascii_case(&participant.author.email));
let marker = if active { 2 } else { 0 };
let thread_label = if participant.thread_count == 1 {
"thread"
} else {
"threads"
};
let message_label = if participant.message_count == 1 {
"msg"
} else {
"msgs"
};
let meta = format!(
"{} {thread_label} · {} {message_label}",
participant.thread_count, participant.message_count
);
let meta_len = meta.chars().count();
let left = format!("{} <{}>", participant.author.name, participant.author.email);
let available = width.saturating_sub(marker + meta_len + 1);
let left = truncate(&left, available);
let pad = width.saturating_sub(marker + left.chars().count() + meta_len);
let mut spans = Vec::with_capacity(4);
if active {
spans.push(Span::styled("● ", theme::accent()));
}
spans.push(Span::raw(left));
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(meta, theme::muted()));
ListItem::new(Line::from(spans))
}
/// Truncate `text` to at most `max` characters, appending `…` when shortened.
fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_owned();
}
if max == 0 {
return String::new();
}
let mut out: String = text.chars().take(max - 1).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
#[test]
fn truncate_keeps_short_text_and_ellipsizes_long() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("abcdef", 4), "abc…");
assert_eq!(truncate("abcdef", 0), "");
}
#[test]
fn overlay_renders_on_degenerate_areas() {
let app = App::new(&Config::default());
for (width, height) in [(0, 0), (1, 1), (10, 3), (120, 40)] {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| render(frame, frame.area(), &app))
.expect("overlay draws");
}
}
#[test]
fn overlay_shows_search_list_and_active_filter() {
let mut app = App::new(&Config::default());
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| render(frame, frame.area(), &app))
.expect("overlay draws");
let text = buffer_text(terminal.backend());
assert!(text.contains("participants"));
assert!(text.contains("Filter:"));
assert!(text.contains("type to search"));
assert!(text.contains(&format!("{} participants", app.participants().len())));
app.participant_filter = app.participants().first().map(|p| p.author.clone());
terminal
.draw(|frame| render(frame, frame.area(), &app))
.expect("filtered overlay draws");
let text = buffer_text(terminal.backend());
assert!(text.contains("active filter:"));
assert!(text.contains('●'));
}
fn buffer_text(backend: &TestBackend) -> String {
backend
.buffer()
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect()
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Patch overlay: wide, scrollable diff view of the selected message.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Padding, Paragraph};
use crate::app::App;
use crate::theme;
/// Added lines (the theme has no diff colors of its own).
const ADDED: Color = Color::Green;
/// Removed lines.
const REMOVED: Color = Color::Red;
/// Share of the frame the overlay covers, in percent.
const OVERLAY_WIDTH: u16 = 90;
const OVERLAY_HEIGHT: u16 = 85;
/// Shown when the selected message carries no diff.
const NO_PATCH: &str = "no patch for this message";
/// Render the patch overlay: a centered, wide, scrollable diff view.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
if area.width < 4 || area.height < 4 {
return;
}
let center = centered(area);
if center.width < 4 || center.height < 4 {
return;
}
frame.render_widget(Clear, area);
let Some(message) = app.selected_message() else {
render_empty(frame, center, " patch ");
return;
};
let Some(patch) = message.patch() else {
render_empty(frame, center, &format!(" patch · {} ", message.subject));
return;
};
let block = Block::bordered()
.border_style(theme::accent())
.title(format!(" patch · {} ", message.subject))
.title_bottom(Line::from(Span::styled(
" j/k scroll · Esc/p close ",
theme::muted(),
)))
.padding(Padding::horizontal(1));
let inner = block.inner(center);
if inner.width == 0 || inner.height == 0 {
frame.render_widget(block, center);
return;
}
let total = patch.lines().count();
let viewport = usize::from(inner.height);
let max_scroll = total.saturating_sub(viewport);
let offset = usize::from(app.patch_scroll).min(max_scroll);
let position = format!(" line {}/{} ", offset.saturating_add(1), total);
let block =
block.title_bottom(Line::from(Span::styled(position, theme::accent())).right_aligned());
let visible = patch
.lines()
.skip(offset)
.map(diff_line)
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(visible).block(block), center);
}
/// Centered overlay rectangle covering [`OVERLAY_WIDTH`] by
/// [`OVERLAY_HEIGHT`] percent of `area`.
fn centered(area: Rect) -> Rect {
let [_, row, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Percentage(OVERLAY_HEIGHT),
Constraint::Fill(1),
])
.areas(area);
let [_, center, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Percentage(OVERLAY_WIDTH),
Constraint::Fill(1),
])
.areas(row);
center
}
/// Overlay frame with a muted placeholder instead of a diff.
fn render_empty(frame: &mut Frame, area: Rect, title: &str) {
let block = Block::bordered()
.border_style(theme::accent())
.title(title.to_owned())
.padding(Padding::horizontal(1));
let paragraph = Paragraph::new(NO_PATCH).style(theme::muted()).block(block);
frame.render_widget(paragraph, area);
}
/// Style one patch line: headers cyan, additions green, removals red,
/// context muted.
fn diff_line(line: &str) -> Line<'_> {
let head = line.trim_start();
if head.starts_with("diff --git")
|| head.starts_with("index ")
|| head.starts_with("---")
|| head.starts_with("+++")
|| head.starts_with("@@")
{
Line::styled(line, theme::accent())
} else if line.starts_with('+') {
Line::styled(line, Style::new().fg(ADDED))
} else if line.starts_with('-') {
Line::styled(line, Style::new().fg(REMOVED))
} else {
Line::styled(line, theme::muted())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::models::Message;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
/// Render `app` into a test terminal and return the visible text.
fn render_text(app: &App, width: u16, height: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), app))
.unwrap();
let buffer = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
text.push_str(buffer.cell((x, y)).map_or(" ", |cell| cell.symbol()));
}
text.push('\n');
}
text
}
/// First thread/message pair whose message carries a diff.
fn patched_selection(app: &App) -> (usize, usize) {
app.backend
.threads()
.iter()
.enumerate()
.find_map(|(thread_index, thread)| {
app.backend
.messages(thread.id)
.iter()
.position(Message::has_patch)
.map(|message_index| (thread_index, message_index))
})
.expect("mock backend has a patched message")
}
/// First thread/message pair whose message carries no diff.
fn unpatched_selection(app: &App) -> (usize, usize) {
app.backend
.threads()
.iter()
.enumerate()
.find_map(|(thread_index, thread)| {
app.backend
.messages(thread.id)
.iter()
.position(|message| !message.has_patch())
.map(|message_index| (thread_index, message_index))
})
.expect("mock backend has an unpatched message")
}
#[test]
fn patch_overlay_renders_diff_footer_and_position() {
let mut app = App::new(&Config::default());
let (thread, message) = patched_selection(&app);
app.thread_selected = thread;
app.message_selected = message;
assert!(app.has_patch());
let text = render_text(&app, 100, 30);
assert!(text.contains("diff --git"));
assert!(text.contains("j/k scroll · Esc/p close"));
assert!(text.contains("line 1/"));
assert!(text.contains("patch ·"));
}
#[test]
fn patch_scroll_advances_and_clamps_to_the_last_line() {
let mut app = App::new(&Config::default());
let (thread, message) = patched_selection(&app);
app.thread_selected = thread;
app.message_selected = message;
let tail = app
.selected_message()
.and_then(|selected| selected.patch())
.and_then(|patch| patch.lines().last())
.expect("patched message has diff lines")
.to_owned();
app.patch_scroll = 1;
let text = render_text(&app, 100, 10);
assert!(text.contains("line 2/"));
app.patch_scroll = u16::MAX;
let text = render_text(&app, 100, 10);
assert!(
text.contains(tail.as_str()),
"last diff line missing: {tail:?}"
);
assert!(text.contains("Esc/p close"));
}
#[test]
fn patch_overlay_reports_missing_patch() {
let mut app = App::new(&Config::default());
let (thread, message) = unpatched_selection(&app);
app.thread_selected = thread;
app.message_selected = message;
assert!(!app.has_patch());
let text = render_text(&app, 100, 30);
assert!(text.contains("no patch for this message"));
}
#[test]
fn patch_overlay_reports_no_selection() {
let mut app = App::new(&Config::default());
app.thread_selected = usize::MAX;
let text = render_text(&app, 100, 30);
assert!(text.contains("no patch for this message"));
}
#[test]
fn patch_overlay_survives_tiny_areas() {
let app = App::new(&Config::default());
for (width, height) in [(0, 0), (1, 1), (4, 4), (10, 6)] {
let _ = render_text(&app, width, height);
}
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Preview pane: headers and body of the selected message (right column).
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};
use crate::app::{App, Panel};
use crate::models::{Author, Message};
use crate::theme;
/// Header labels rendered with a muted prefix, in display order.
const HEADERS: [&str; 5] = ["From:", "To:", "Cc:", "Date:", "Subject:"];
/// Render the selected message: headers fixed on top, then the body with any
/// diff stripped. `app.preview_scroll` shifts the body by wrapped lines.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
if area.width < 2 || area.height < 2 {
return;
}
let block = theme::pane_block(" Preview ", app.focus == Panel::Preview);
let Some(message) = app.selected_message() else {
frame.render_widget(
Paragraph::new("no message selected")
.style(theme::muted())
.block(block),
area,
);
return;
};
let inner = block.inner(area);
if inner.width == 0 || inner.height == 0 {
frame.render_widget(block, area);
return;
}
let header_height = u16::try_from(HEADERS.len())
.unwrap_or(u16::MAX)
.min(inner.height);
let [header_area, body_area] =
Layout::vertical([Constraint::Length(header_height), Constraint::Fill(1)]).areas(inner);
frame.render_widget(Paragraph::new(header_lines(message)), header_area);
let mut body: Vec<Line> = Vec::new();
if message.has_patch() {
body.push(patch_hint(inner.width));
body.push(Line::from(""));
}
body.extend(message.body_without_patch().split('\n').map(Line::from));
let viewport = usize::from(body_area.height);
let max_scroll = body.len().saturating_sub(viewport);
let offset = usize::from(app.preview_scroll).min(max_scroll);
let scroll = u16::try_from(offset).unwrap_or(u16::MAX);
let paragraph = Paragraph::new(body)
.wrap(Wrap { trim: true })
.scroll((scroll, 0));
frame.render_widget(paragraph, body_area);
}
/// Header lines in display order, each one row tall.
fn header_lines(message: &Message) -> Vec<Line<'static>> {
let values = [
message.author.to_string(),
join_authors(&message.to),
join_authors(&message.cc),
message.date.clone(),
message.subject.clone(),
];
HEADERS
.iter()
.zip(values)
.map(|(label, value)| {
Line::from(vec![
Span::styled(format!("{label:<9} "), theme::muted()),
Span::from(value),
])
})
.collect()
}
/// Comma-separated `Name <email>` list.
fn join_authors(authors: &[Author]) -> String {
authors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
/// Full-width separator announcing that the diff is hidden.
fn patch_hint(width: u16) -> Line<'static> {
const LABEL: &str = " patch hidden · press p to view ";
let label_width = u16::try_from(LABEL.chars().count()).unwrap_or(u16::MAX);
let fill = width.saturating_sub(label_width);
let left = fill / 2;
let right = fill - left;
let text = format!(
"{}{LABEL}{}",
"─".repeat(usize::from(left)),
"─".repeat(usize::from(right))
);
Line::from(Span::styled(text, theme::accent()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
/// Render `app` into a test terminal and return the visible text.
fn render_text(app: &App, width: u16, height: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), app))
.unwrap();
let buffer = terminal.backend().buffer();
let mut text = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
text.push_str(buffer.cell((x, y)).map_or(" ", |cell| cell.symbol()));
}
text.push('\n');
}
text
}
/// First thread/message pair whose message carries a diff.
fn patched_selection(app: &App) -> (usize, usize) {
app.backend
.threads()
.iter()
.enumerate()
.find_map(|(thread_index, thread)| {
app.backend
.messages(thread.id)
.iter()
.position(Message::has_patch)
.map(|message_index| (thread_index, message_index))
})
.expect("mock backend has a patched message")
}
#[test]
fn preview_shows_headers_without_diff() {
let mut app = App::new(&Config::default());
let (thread, message) = patched_selection(&app);
app.thread_selected = thread;
app.message_selected = message;
assert!(app.has_patch());
let text = render_text(&app, 80, 24);
for label in HEADERS {
assert!(text.contains(label), "missing header {label}");
}
assert!(text.contains("patch hidden"));
assert!(!text.contains("diff --git"));
assert!(!text.contains("@@"));
}
#[test]
fn preview_scroll_is_clamped_and_never_shows_blank_past_the_end() {
let mut app = App::new(&Config::default());
let (thread, message) = patched_selection(&app);
app.thread_selected = thread;
app.message_selected = message;
let top = render_text(&app, 80, 8);
assert!(top.contains("patch hidden"));
assert!(top.contains("From:"));
app.preview_scroll = 2;
let scrolled = render_text(&app, 80, 8);
assert!(!scrolled.contains("patch hidden"));
app.preview_scroll = u16::MAX;
let clamped = render_text(&app, 80, 8);
// Row 6 is the single body row between the five header rows and the
// bottom border; the clamp must keep real content in it.
assert!(
clamped
.lines()
.nth(6)
.is_some_and(|row| !row.trim().is_empty())
);
assert!(clamped.contains("From:"));
}
#[test]
fn preview_shows_empty_state_without_a_thread() {
let mut app = App::new(&Config::default());
app.thread_selected = usize::MAX;
let text = render_text(&app, 60, 12);
assert!(text.contains("no message selected"));
}
#[test]
fn preview_survives_tiny_areas() {
let app = App::new(&Config::default());
for (width, height) in [(0, 0), (1, 1), (2, 2), (5, 3)] {
let _ = render_text(&app, width, height);
}
}
}
+391
View File
@@ -0,0 +1,391 @@
//! Sender screen: compose form (left) and file picker (right).
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use crate::app::App;
use crate::compose::{Compose, ComposeField, PatchAttachment};
use crate::theme;
use crate::ui::filepicker::{self, human_size, scroll_offset};
/// Columns consumed by the `▸ ` marker and the padded field label.
const FIELD_PREFIX: usize = 11;
/// Rows above the body: `To`, `Cc`, `Subject`, and a separator.
const FIELDS_HEIGHT: u16 = 4;
/// Indent applied to wrapped body lines.
const BODY_INDENT: usize = 2;
/// Render the Sender screen body: compose form left, file picker right.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let [form_area, picker_area] =
Layout::horizontal([Constraint::Percentage(58), Constraint::Percentage(42)]).areas(area);
render_form(frame, form_area, app);
filepicker::render(frame, picker_area, app);
}
/// Render the compose form: fields, body, attached patches, and status.
fn render_form(frame: &mut Frame, area: Rect, app: &App) {
let compose = &app.compose;
let block = theme::pane_block(" Compose ", true);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.is_empty() {
return;
}
let status_height = u16::from(compose.status.is_some());
let fields_height = FIELDS_HEIGHT.min(inner.height.saturating_sub(status_height));
let flexible = inner.height - fields_height - status_height;
let desired = u16::try_from(compose.patches.len())
.unwrap_or(u16::MAX)
.saturating_add(2);
let patches_height = desired.min(flexible.saturating_sub(2));
let body_height = flexible - patches_height;
let [fields_area, body_area, patches_area, status_area] = Layout::vertical([
Constraint::Length(fields_height),
Constraint::Length(body_height),
Constraint::Length(patches_height),
Constraint::Length(status_height),
])
.areas(inner);
render_fields(frame, fields_area, compose);
render_body(frame, body_area, compose);
render_patches(frame, patches_area, compose);
render_status(frame, status_area, compose);
}
/// Render the single-line `To`, `Cc`, and `Subject` fields.
fn render_fields(frame: &mut Frame, area: Rect, compose: &Compose) {
let width = usize::from(area.width).saturating_sub(FIELD_PREFIX);
let fields = [
(ComposeField::To, compose.to.as_str()),
(ComposeField::Cc, compose.cc.as_str()),
(ComposeField::Subject, compose.subject.as_str()),
];
let mut lines: Vec<Line<'static>> = fields
.into_iter()
.map(|(field, value)| {
let focused = compose.field == field;
text_line(field, value, focused.then_some(compose.cursor), width)
})
.collect();
lines.push(Line::from(""));
frame.render_widget(Paragraph::new(lines), area);
}
/// One `label: value` line; a `Some` cursor marks the focused field.
fn text_line(
field: ComposeField,
value: &str,
cursor: Option<usize>,
width: usize,
) -> Line<'static> {
let mut spans = label_spans(field, cursor.is_some());
if let Some(cursor) = cursor {
spans.extend(value_spans(value, cursor, width));
} else {
let shown: String = value.chars().take(width).collect();
spans.push(Span::raw(shown));
}
Line::from(spans)
}
/// The `▸ ` marker plus the padded label; accent and bold when focused.
fn label_spans(field: ComposeField, focused: bool) -> Vec<Span<'static>> {
let marker_style = if focused {
theme::accent()
} else {
Style::default()
};
let marker = Span::styled(if focused { "▸ " } else { " " }, marker_style);
let label = field.label();
let label_style = if focused {
theme::accent().add_modifier(Modifier::BOLD)
} else {
theme::muted()
};
vec![marker, Span::styled(format!("{label:<9}"), label_style)]
}
/// Value spans for a focused single-line field, windowed so the cursor stays
/// visible; the character under the cursor is drawn in reverse video.
fn value_spans(value: &str, cursor: usize, width: usize) -> Vec<Span<'static>> {
let width = width.max(1);
let chars: Vec<char> = value.chars().collect();
let cursor = cursor.min(chars.len());
let mut display = chars;
if cursor == display.len() {
display.push(' ');
}
let start = cursor.saturating_add(1).saturating_sub(width);
let end = (start + width).min(display.len());
let before: String = display[start..cursor].iter().collect();
let at = display[cursor];
let after: String = display[cursor + 1..end].iter().collect();
vec![
Span::raw(before),
Span::styled(at.to_string(), cursor_style()),
Span::raw(after),
]
}
/// Reverse-video accent block used as the text cursor.
const fn cursor_style() -> Style {
theme::accent().add_modifier(Modifier::REVERSED)
}
/// Render the body label and a wrapped, cursor-aware window of the body text.
fn render_body(frame: &mut Frame, area: Rect, compose: &Compose) {
let focused = compose.field == ComposeField::Body;
let [label_area, text_area] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
frame.render_widget(
Paragraph::new(Line::from(label_spans(ComposeField::Body, focused))),
label_area,
);
if text_area.is_empty() {
return;
}
let width = usize::from(text_area.width)
.saturating_sub(BODY_INDENT)
.max(1);
let (lines, cursor_line, cursor_column) = wrap_body(&compose.body, compose.cursor, width);
let height = usize::from(text_area.height);
let offset = if focused {
scroll_offset(cursor_line, lines.len(), height)
} else {
0
};
let rendered: Vec<Line<'static>> = lines
.iter()
.enumerate()
.skip(offset)
.take(height)
.map(|(index, line)| body_line(line, focused && index == cursor_line, cursor_column))
.collect();
frame.render_widget(Paragraph::new(rendered), text_area);
}
/// One indented body line; the cursor line draws a reversed-video cell.
fn body_line(line: &str, cursor_here: bool, cursor_column: usize) -> Line<'static> {
let indent = Span::raw(" ".repeat(BODY_INDENT));
if !cursor_here {
return Line::from(vec![indent, Span::raw(line.to_owned())]);
}
let chars: Vec<char> = line.chars().collect();
let column = cursor_column.min(chars.len());
let before: String = chars[..column].iter().collect();
let at = chars.get(column).copied().unwrap_or(' ');
let after: String = chars[(column + 1).min(chars.len())..].iter().collect();
Line::from(vec![
indent,
Span::raw(before),
Span::styled(at.to_string(), cursor_style()),
Span::raw(after),
])
}
/// Wrap `body` to `width` columns; also return the cursor's line and column.
fn wrap_body(body: &str, cursor: usize, width: usize) -> (Vec<String>, usize, usize) {
let width = width.max(1);
let chars: Vec<char> = body.chars().collect();
let cursor = cursor.min(chars.len());
let mut lines: Vec<String> = Vec::new();
let mut cursor_line = 0;
let mut cursor_column = 0;
let mut offset = 0;
loop {
let mut end = offset;
while end < chars.len() && chars[end] != '\n' {
end += 1;
}
let mut chunks: Vec<String> = Vec::new();
let mut start = offset;
while start < end {
let stop = (start + width).min(end);
chunks.push(chars[start..stop].iter().collect());
start = stop;
}
if chunks.is_empty() {
chunks.push(String::new());
}
if cursor >= offset && cursor <= end {
let column = cursor - offset;
cursor_line = lines.len() + column / width;
cursor_column = column % width;
while chunks.len() <= cursor_line - lines.len() {
chunks.push(String::new());
}
}
lines.extend(chunks);
if end >= chars.len() {
break;
}
offset = end + 1;
}
(lines, cursor_line, cursor_column)
}
/// Render the attached-patch list; the selected row is highlighted.
fn render_patches(frame: &mut Frame, area: Rect, compose: &Compose) {
let focused = compose.field == ComposeField::Patches;
let title = format!(" Patches ({}) ", compose.patches.len());
let block = theme::pane_block(&title, focused);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.is_empty() {
return;
}
if compose.patches.is_empty() {
let hint = Paragraph::new("no patches — press a to add").style(theme::muted());
frame.render_widget(hint, inner);
return;
}
let height = usize::from(inner.height);
let offset = scroll_offset(compose.patch_selected, compose.patches.len(), height);
let mut state = ListState::default()
.with_selected(Some(
compose
.patch_selected
.min(compose.patches.len().saturating_sub(1)),
))
.with_offset(offset);
let list = List::new(compose.patches.iter().map(patch_item))
.highlight_style(theme::selection())
.highlight_symbol("▸ ");
frame.render_stateful_widget(list, inner, &mut state);
}
/// One attached-patch row: filename, subject, and human-readable size.
fn patch_item(patch: &PatchAttachment) -> ListItem<'static> {
ListItem::new(Line::from(vec![
Span::raw(patch.filename.clone()),
Span::raw(" "),
Span::styled(patch.subject.clone(), theme::muted()),
Span::raw(" "),
Span::styled(human_size(patch.size), theme::muted()),
]))
}
/// Render the status line when a status message is set.
fn render_status(frame: &mut Frame, area: Rect, compose: &Compose) {
let Some(status) = &compose.status else {
return;
};
let line = Line::from(vec![
Span::styled(" status: ", theme::muted()),
Span::styled(status.as_str(), theme::accent()),
]);
frame.render_widget(Paragraph::new(line), area);
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use crate::app::Screen;
use crate::config::Config;
fn sender_app() -> App {
let mut app = App::new(&Config::default());
app.screen = Screen::Sender;
app
}
#[test]
fn renders_sender_without_panic_on_tiny_rects() {
let mut app = sender_app();
for (width, height) in [(1, 1), (2, 2), (3, 4), (10, 3), (20, 6), (100, 30)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
for field in [
ComposeField::To,
ComposeField::Cc,
ComposeField::Subject,
ComposeField::Body,
ComposeField::Patches,
] {
app.compose.field = field;
terminal
.draw(|frame| crate::ui::render(frame, &app))
.unwrap();
}
}
}
#[test]
fn renders_form_contents_and_visible_cursor() {
let mut app = sender_app();
app.compose.field = ComposeField::Subject;
app.compose.subject = "hello".to_owned();
app.compose.cursor = 1;
app.compose.status = Some("attached patch".to_owned());
let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
terminal
.draw(|frame| crate::ui::render(frame, &app))
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(text.contains("Compose"));
assert!(text.contains("Subject:"));
assert!(text.contains("Patches"));
assert!(text.contains("j/k"));
assert!(text.contains("attached patch"));
assert!(
buffer
.content()
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
}
#[test]
fn wrap_body_tracks_cursor_at_end() {
let (lines, line, column) = wrap_body("abcd", 4, 4);
assert_eq!(lines, vec!["abcd", ""]);
assert_eq!((line, column), (1, 0));
}
#[test]
fn wrap_body_tracks_cursor_mid_line() {
let (lines, line, column) = wrap_body("abcdef", 6, 4);
assert_eq!(lines, vec!["abcd", "ef"]);
assert_eq!((line, column), (1, 2));
}
#[test]
fn wrap_body_handles_empty_and_newlines() {
let (lines, line, column) = wrap_body("", 0, 10);
assert_eq!(lines, vec![""]);
assert_eq!((line, column), (0, 0));
let (lines, line, column) = wrap_body("a\n\nb", 4, 10);
assert_eq!(lines, vec!["a", "", "b"]);
assert_eq!((line, column), (2, 1));
}
#[test]
fn value_spans_windows_around_cursor() {
let spans = value_spans("abcdef", 6, 4);
let text: String = spans.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, "def ");
}
#[test]
fn value_spans_handles_empty_value() {
let spans = value_spans("", 0, 4);
let text: String = spans.iter().map(|span| span.content.as_ref()).collect();
assert_eq!(text, " ");
}
}
+44
View File
@@ -0,0 +1,44 @@
//! Threads pane: the discussions on the list (left column).
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use crate::app::{App, Panel};
use crate::theme;
use crate::ui::filepicker::scroll_offset;
/// Render the thread list.
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let threads = app.threads();
let title = format!(" Threads ({}) ", threads.len());
let block = theme::pane_block(title.as_str(), app.focus == Panel::Threads);
if threads.is_empty() {
frame.render_widget(
Paragraph::new("no threads (backend stub)")
.style(theme::muted())
.block(block),
area,
);
return;
}
let items = threads.iter().map(|thread| {
let meta = Span::styled(
format!(" {} · {}", thread.author.name, thread.date),
theme::muted(),
);
ListItem::new(vec![Line::from(thread.subject.as_str()), Line::from(meta)])
});
let mut state = ListState::default();
state.select(Some(app.thread_selected));
let visible = usize::from(block.inner(area).height) / 2;
*state.offset_mut() = scroll_offset(app.thread_selected, threads.len(), visible);
let list = List::new(items)
.block(block)
.highlight_style(theme::selection())
.highlight_symbol("▍");
frame.render_stateful_widget(list, area, &mut state);
}