minor changes
This commit is contained in:
Generated
+1673
File diff suppressed because it is too large
Load Diff
+28
@@ -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"
|
||||
@@ -1,3 +1,81 @@
|
||||
# lazymail
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
edition = "2024"
|
||||
max_width = 100
|
||||
newline_style = "Unix"
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, " ");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user