From 791a54ef153ccb8170e8d6c64678fe50d61723ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Wed, 20 May 2026 20:02:41 +0200 Subject: [PATCH] style: put back color inside table --- Cargo.lock | 5 + Cargo.toml | 1 + MIGRATION.md | 7 +- config.sample.toml | 71 +++++++++- src/account/context.rs | 218 ++++++++++++++++++++++++++++- src/account/list.rs | 40 +++++- src/config.rs | 153 ++++++++++++++++++-- src/imap/envelope/list.rs | 30 +++- src/imap/envelope/search.rs | 7 +- src/imap/envelope/sort.rs | 17 ++- src/imap/mailbox/list.rs | 9 +- src/jmap/email/get.rs | 17 ++- src/jmap/email/query.rs | 55 ++++++-- src/jmap/mailbox/get.rs | 11 +- src/jmap/mailbox/query.rs | 39 +++++- src/maildir/envelope/list.rs | 26 +++- src/maildir/list.rs | 9 +- src/shared/attachments/download.rs | 10 +- src/shared/attachments/list.rs | 38 ++++- src/shared/envelopes/list.rs | 108 ++++++++++---- src/shared/envelopes/search.rs | 22 ++- src/shared/mailboxes/list.rs | 26 +++- src/wizard/discover.rs | 13 +- src/wizard/edit.rs | 18 ++- 24 files changed, 821 insertions(+), 129 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fa64e42..aea09ea9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -420,6 +423,7 @@ dependencies = [ "mio", "parking_lot", "rustix", + "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -803,6 +807,7 @@ dependencies = [ "clap", "comfy-table", "convert_case 0.11.0", + "crossterm", "dirs", "humansize", "io-discovery", diff --git a/Cargo.toml b/Cargo.toml index eddc5c89..cf26d5d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } comfy-table = "7" convert_case = { version = "0.11", optional = true } +crossterm = { version = "0.29", default-features = false, features = ["serde"] } dirs = "6" humansize = "2" io-discovery = { version = "0.0.1", default-features = false, features = ["pacc", "autoconfig", "rfc6186", "client"] } diff --git a/MIGRATION.md b/MIGRATION.md index 75b70566..c51395a8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -96,10 +96,15 @@ The full configuration schema is documented in [config.sample.toml](./config.sam - Removed `display-name`, `signature`, `signature-delim`: composition left the CLI. - Only `downloads-dir` remains for the `attachments download` command. -- Per-type table customization (`{account,folder,envelope}.list.table.*`) collapsed into a single `table-preset` plus a `table-arrangement` (`dynamic`, `dynamic-full-width`, `disabled`). Color customization is gone. - Composition / reading hooks live under `[message.composer.]` and `[message.reader.]`; each entry sets a `command` and optionally `default = true`. - The `message`, `template` and `pgp` top-level entries are removed. +#### Table customization + +- The per-type `{account,folder,envelope}.list.table.{preset,arrangement}` keys collapse into a single `table.{preset,arrangement}` (global / per-account). `table.arrangement` (`dynamic`, `dynamic-full-width`, `disabled`) is new. +- Rename `folder.list.table.*` → `mailbox.list.table.*` (mirrors the `folders` → `mailboxes` command rename). +- Rename `envelope.list.table.sender-color` → `envelope.list.table.from-color` (the column is now `FROM`). + #### Mailbox aliases The v1 `[folder.aliases]` block becomes `[mailbox.aliases]`. Two behaviour changes on top of the rename: diff --git a/config.sample.toml b/config.sample.toml index eab02d12..c1d0c3ed 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -24,12 +24,12 @@ #downloads-dir = "~/downloads" # https://docs.rs/comfy-table/latest/comfy_table/presets/index.html -#table-preset = "││──╞═╪╡┆ ┬┴┌┐└┘" +#table.preset = "││──╞═╪╡┆ ┬┴┌┐└┘" # https://docs.rs/comfy-table/latest/comfy_table/enum.ContentArrangement.html -#table-arrangement = "dynamic" -#table-arrangement = "dynamic-full-width" -#table-arrangement = "disabled" +#table.arrangement = "dynamic" +#table.arrangement = "dynamic-full-width" +#table.arrangement = "disabled" # `chrono` strftime format used to render the DATE column of `envelopes list`. # Defaults to `"%F %R%:z"`, e.g. `2026-05-06 14:30+02:00`. @@ -44,6 +44,65 @@ # hard fallback is 25. #envelope.list.page-size = 50 +# -------------------------------------------------------------------------------- +# Table rendering — envelopes list +# -------------------------------------------------------------------------------- + +# Per-column foreground colors for the `envelopes list` table. Each value is +# a crossterm-style color: a named variant (`"red"`, `"dark-magenta"`, +# `"reset"`, …), or a `{ Rgb = { r, g, b } }` / `{ AnsiValue = N }` table. +# Applied to both shared `envelopes list` and the protocol-specific +# `imap`/`jmap`/`maildir` envelope listings so a single key affects every +# backend. The values below are the v1.2.0 defaults and are used when the +# key is left unset. +#envelope.list.table.id-color = "red" +#envelope.list.table.flags-color = "reset" +#envelope.list.table.att-color = "reset" +#envelope.list.table.subject-color = "green" +#envelope.list.table.from-color = "blue" +#envelope.list.table.to-color = "blue" +#envelope.list.table.date-color = "dark-yellow" +#envelope.list.table.size-color = "reset" + +# Single-character glyphs used inside the FLAGS / ATT columns of the +# envelopes table. Defaults match v1.2.0. +#envelope.list.table.unseen-char = "*" # FLAGS slot 1, when `\Seen` is absent +#envelope.list.table.replied-char = "R" # FLAGS slot 2, when `\Answered` is set +#envelope.list.table.flagged-char = "!" # FLAGS slot 3, when `\Flagged` is set +#envelope.list.table.attachment-char = "@" # ATT column, when the message carries an attachment + +# -------------------------------------------------------------------------------- +# Table rendering — mailboxes list +# -------------------------------------------------------------------------------- + +# The `name-color` default matches v1.2.0 (`folder.list.table.name-color`); +# the other columns are new in v2 and default to the terminal foreground. +#mailbox.list.table.id-color = "reset" +#mailbox.list.table.name-color = "blue" +#mailbox.list.table.total-color = "reset" +#mailbox.list.table.unread-color = "reset" + +# -------------------------------------------------------------------------------- +# Table rendering — attachments list +# -------------------------------------------------------------------------------- + +# No v1 precedent; every column defaults to the terminal foreground. +#attachment.list.table.id-color = "reset" +#attachment.list.table.filename-color = "reset" +#attachment.list.table.type-color = "reset" +#attachment.list.table.size-color = "reset" +#attachment.list.table.inline-color = "reset" +#attachment.list.table.path-color = "reset" + +# -------------------------------------------------------------------------------- +# Table rendering — account list +# -------------------------------------------------------------------------------- + +# Defaults match v1.2.0. +#account.list.table.name-color = "green" +#account.list.table.backends-color = "blue" +#account.list.table.default-color = "reset" + # -------------------------------------------------------------------------------- # Mailbox aliases # -------------------------------------------------------------------------------- @@ -99,8 +158,8 @@ default = true # Per-account overrides for the global options above. #downloads-dir = "~/downloads/example" -#table-preset = "││──╞═╪╡┆ ┬┴┌┐└┘" -#table-arrangement = "dynamic" +#table.preset = "││──╞═╪╡┆ ┬┴┌┐└┘" +#table.arrangement = "dynamic" #envelope.list.datetime-fmt = "%F %R%:z" #envelope.list.datetime-local-tz = false diff --git a/src/account/context.rs b/src/account/context.rs index 577aa187..8ffcd497 100644 --- a/src/account/context.rs +++ b/src/account/context.rs @@ -30,15 +30,24 @@ use std::{collections::HashMap, env::temp_dir, path::PathBuf}; -use comfy_table::{presets, ContentArrangement}; +use comfy_table::{presets, Color as TableColor, ContentArrangement}; +use crossterm::style::Color; use dirs::download_dir; -use crate::config::{AccountConfig, ComposerConfig, Config, ReaderConfig, TableArrangementConfig}; +use crate::config::{ + AccountConfig, AttachmentListTableConfig, ComposerConfig, Config, EnvelopeListTableConfig, + MailboxListTableConfig, ReaderConfig, TableArrangementConfig, +}; const DEFAULT_DATETIME_FMT: &str = "%F %R%:z"; const DEFAULT_MAILBOX_ALIAS: &str = "inbox"; const DEFAULT_ENVELOPES_LIST_PAGE_SIZE: u32 = 25; +const DEFAULT_UNSEEN_CHAR: char = '*'; +const DEFAULT_REPLIED_CHAR: char = 'R'; +const DEFAULT_FLAGGED_CHAR: char = '!'; +const DEFAULT_ATTACHMENT_CHAR: char = '@'; + #[derive(Clone, Debug, Default)] pub struct Account { pub downloads_dir: Option, @@ -49,6 +58,13 @@ pub struct Account { pub datetime_local_tz: Option, pub envelopes_list_page_size: Option, + /// Per-column color + flag glyph overrides for `envelopes list`. + pub envelopes_list_table: EnvelopeListTableConfig, + /// Per-column color overrides for `mailboxes list`. + pub mailboxes_list_table: MailboxListTableConfig, + /// Per-column color overrides for `attachments list`. + pub attachments_list_table: AttachmentListTableConfig, + /// Mailbox aliases, keys lowercased. Populated from /// `mailbox.alias` at the global and account levels; account /// entries overwrite same-named global entries. @@ -87,6 +103,19 @@ impl Account { .envelopes_list_page_size .or(self.envelopes_list_page_size), + envelopes_list_table: merge_envelope_table( + self.envelopes_list_table, + other.envelopes_list_table, + ), + mailboxes_list_table: merge_mailbox_table( + self.mailboxes_list_table, + other.mailboxes_list_table, + ), + attachments_list_table: merge_attachment_table( + self.attachments_list_table, + other.attachments_list_table, + ), + mailbox_alias, composer, @@ -167,6 +196,175 @@ impl Account { .get(DEFAULT_MAILBOX_ALIAS) .map(String::as_str) } + + // ── envelopes list — flag glyphs ───────────────────────────────────── + + pub fn envelopes_list_table_unseen_char(&self) -> char { + self.envelopes_list_table + .unseen_char + .unwrap_or(DEFAULT_UNSEEN_CHAR) + } + pub fn envelopes_list_table_replied_char(&self) -> char { + self.envelopes_list_table + .replied_char + .unwrap_or(DEFAULT_REPLIED_CHAR) + } + pub fn envelopes_list_table_flagged_char(&self) -> char { + self.envelopes_list_table + .flagged_char + .unwrap_or(DEFAULT_FLAGGED_CHAR) + } + pub fn envelopes_list_table_attachment_char(&self) -> char { + self.envelopes_list_table + .attachment_char + .unwrap_or(DEFAULT_ATTACHMENT_CHAR) + } + + // ── envelopes list — column colors ─────────────────────────────────── + // + // Defaults mirror pimalaya-tui v1.2.0 + // (`ListEnvelopesTableConfig::{id,flags,subject,sender,date}_color`). + pub fn envelopes_list_table_id_color(&self) -> TableColor { + map_color_or(self.envelopes_list_table.id_color, Color::Red) + } + pub fn envelopes_list_table_flags_color(&self) -> TableColor { + map_color_or(self.envelopes_list_table.flags_color, Color::Reset) + } + pub fn envelopes_list_table_att_color(&self) -> TableColor { + // No v1 precedent for a standalone ATT column (v1 embedded the + // attachment glyph inside FLAGS); leave it neutral. + map_color_or(self.envelopes_list_table.att_color, Color::Reset) + } + pub fn envelopes_list_table_subject_color(&self) -> TableColor { + map_color_or(self.envelopes_list_table.subject_color, Color::Green) + } + pub fn envelopes_list_table_from_color(&self) -> TableColor { + map_color_or(self.envelopes_list_table.from_color, Color::Blue) + } + pub fn envelopes_list_table_to_color(&self) -> TableColor { + // `to` mirrors `from`'s default; v1 didn't surface a TO column. + map_color_or(self.envelopes_list_table.to_color, Color::Blue) + } + pub fn envelopes_list_table_date_color(&self) -> TableColor { + map_color_or(self.envelopes_list_table.date_color, Color::DarkYellow) + } + pub fn envelopes_list_table_size_color(&self) -> TableColor { + // New in v2, no v1 precedent. + map_color_or(self.envelopes_list_table.size_color, Color::Reset) + } + + // ── mailboxes list — column colors ─────────────────────────────────── + // + // `name` matches the v1 `folder.list.table.name-color` default + // (`pimalaya-tui::ListFoldersTableConfig::name_color`); the other + // columns are new in v2. + pub fn mailboxes_list_table_id_color(&self) -> TableColor { + map_color_or(self.mailboxes_list_table.id_color, Color::Reset) + } + pub fn mailboxes_list_table_name_color(&self) -> TableColor { + map_color_or(self.mailboxes_list_table.name_color, Color::Blue) + } + pub fn mailboxes_list_table_total_color(&self) -> TableColor { + map_color_or(self.mailboxes_list_table.total_color, Color::Reset) + } + pub fn mailboxes_list_table_unread_color(&self) -> TableColor { + map_color_or(self.mailboxes_list_table.unread_color, Color::Reset) + } + + // ── attachments list — column colors ───────────────────────────────── + // + // No v1 precedent; defaults left neutral. + pub fn attachments_list_table_id_color(&self) -> TableColor { + map_color_or(self.attachments_list_table.id_color, Color::Reset) + } + pub fn attachments_list_table_filename_color(&self) -> TableColor { + map_color_or(self.attachments_list_table.filename_color, Color::Reset) + } + pub fn attachments_list_table_type_color(&self) -> TableColor { + map_color_or(self.attachments_list_table.type_color, Color::Reset) + } + pub fn attachments_list_table_size_color(&self) -> TableColor { + map_color_or(self.attachments_list_table.size_color, Color::Reset) + } + pub fn attachments_list_table_inline_color(&self) -> TableColor { + map_color_or(self.attachments_list_table.inline_color, Color::Reset) + } + pub fn attachments_list_table_path_color(&self) -> TableColor { + map_color_or(self.attachments_list_table.path_color, Color::Reset) + } +} + +/// Maps a [`crossterm::style::Color`] (deserialized from TOML) into a +/// [`comfy_table::Color`] used by the renderers, substituting +/// `fallback` when the TOML field is unset. +pub(crate) fn map_color_or(color: Option, fallback: Color) -> TableColor { + match color.unwrap_or(fallback) { + Color::Reset => TableColor::Reset, + Color::Black => TableColor::Black, + Color::DarkGrey => TableColor::DarkGrey, + Color::Red => TableColor::Red, + Color::DarkRed => TableColor::DarkRed, + Color::Green => TableColor::Green, + Color::DarkGreen => TableColor::DarkGreen, + Color::Yellow => TableColor::Yellow, + Color::DarkYellow => TableColor::DarkYellow, + Color::Blue => TableColor::Blue, + Color::DarkBlue => TableColor::DarkBlue, + Color::Magenta => TableColor::Magenta, + Color::DarkMagenta => TableColor::DarkMagenta, + Color::Cyan => TableColor::Cyan, + Color::DarkCyan => TableColor::DarkCyan, + Color::White => TableColor::White, + Color::Grey => TableColor::Grey, + Color::Rgb { r, g, b } => TableColor::Rgb { r, g, b }, + Color::AnsiValue(n) => TableColor::AnsiValue(n), + } +} + +fn merge_envelope_table( + base: EnvelopeListTableConfig, + over: EnvelopeListTableConfig, +) -> EnvelopeListTableConfig { + EnvelopeListTableConfig { + unseen_char: over.unseen_char.or(base.unseen_char), + replied_char: over.replied_char.or(base.replied_char), + flagged_char: over.flagged_char.or(base.flagged_char), + attachment_char: over.attachment_char.or(base.attachment_char), + id_color: over.id_color.or(base.id_color), + flags_color: over.flags_color.or(base.flags_color), + att_color: over.att_color.or(base.att_color), + subject_color: over.subject_color.or(base.subject_color), + from_color: over.from_color.or(base.from_color), + to_color: over.to_color.or(base.to_color), + date_color: over.date_color.or(base.date_color), + size_color: over.size_color.or(base.size_color), + } +} + +fn merge_mailbox_table( + base: MailboxListTableConfig, + over: MailboxListTableConfig, +) -> MailboxListTableConfig { + MailboxListTableConfig { + id_color: over.id_color.or(base.id_color), + name_color: over.name_color.or(base.name_color), + total_color: over.total_color.or(base.total_color), + unread_color: over.unread_color.or(base.unread_color), + } +} + +fn merge_attachment_table( + base: AttachmentListTableConfig, + over: AttachmentListTableConfig, +) -> AttachmentListTableConfig { + AttachmentListTableConfig { + id_color: over.id_color.or(base.id_color), + filename_color: over.filename_color.or(base.filename_color), + type_color: over.type_color.or(base.type_color), + size_color: over.size_color.or(base.size_color), + inline_color: over.inline_color.or(base.inline_color), + path_color: over.path_color.or(base.path_color), + } } /// Lowercases every key of `aliases`, leaving values untouched. Used at @@ -184,13 +382,17 @@ impl From for Account { fn from(config: Config) -> Self { Self { downloads_dir: config.downloads_dir, - table_preset: config.table_preset, - table_arrangement: config.table_arrangement, + table_preset: config.table.preset, + table_arrangement: config.table.arrangement, datetime_fmt: config.envelope.list.datetime_fmt, datetime_local_tz: config.envelope.list.datetime_local_tz, envelopes_list_page_size: config.envelope.list.page_size, + envelopes_list_table: config.envelope.list.table, + mailboxes_list_table: config.mailbox.list.table, + attachments_list_table: config.attachment.list.table, + mailbox_alias: lowercase_alias_keys(config.mailbox.alias), composer: config.message.composer, @@ -203,13 +405,17 @@ impl From for Account { fn from(config: AccountConfig) -> Self { Self { downloads_dir: config.downloads_dir, - table_preset: config.table_preset, - table_arrangement: config.table_arrangement, + table_preset: config.table.preset, + table_arrangement: config.table.arrangement, datetime_fmt: config.envelope.list.datetime_fmt, datetime_local_tz: config.envelope.list.datetime_local_tz, envelopes_list_page_size: config.envelope.list.page_size, + envelopes_list_table: config.envelope.list.table, + mailboxes_list_table: config.mailbox.list.table, + attachments_list_table: config.attachment.list.table, + mailbox_alias: lowercase_alias_keys(config.mailbox.alias), composer: HashMap::new(), diff --git a/src/account/list.rs b/src/account/list.rs index badac17b..f9b41f06 100644 --- a/src/account/list.rs +++ b/src/account/list.rs @@ -19,12 +19,16 @@ use std::{fmt, path::PathBuf}; use anyhow::Result; use clap::Parser; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; +use crossterm::style::Color as CrosstermColor; use pimalaya_cli::printer::Printer; use pimalaya_config::toml::TomlConfig; use serde::Serialize; -use crate::config::{AccountConfig, Config, TableArrangementConfig}; +use crate::{ + account::context::map_color_or, + config::{AccountConfig, Config, TableArrangementConfig}, +}; /// List all accounts declared in the configuration. /// @@ -38,15 +42,25 @@ impl AccountListCommand { let config = load_config(config_paths)?; let preset = config - .table_preset + .table + .preset .clone() .unwrap_or_else(|| comfy_table::presets::UTF8_FULL_CONDENSED.to_string()); let arrangement = config - .table_arrangement + .table + .arrangement .clone() .unwrap_or(TableArrangementConfig::Dynamic) .into(); + let table_cfg = &config.account.list.table; + let colors = AccountColors { + // v1.2.0 defaults: name=Green, backends=Blue, default=Reset. + name: map_color_or(table_cfg.name_color, CrosstermColor::Green), + backends: map_color_or(table_cfg.backends_color, CrosstermColor::Blue), + default: map_color_or(table_cfg.default_color, CrosstermColor::Reset), + }; + let mut accounts: Vec = config .accounts .iter() @@ -57,6 +71,7 @@ impl AccountListCommand { let table = AccountsTable { preset, arrangement, + colors, accounts, }; @@ -64,6 +79,13 @@ impl AccountListCommand { } } +#[derive(Clone, Copy, Debug)] +struct AccountColors { + name: Color, + backends: Color, + default: Color, +} + fn load_config(paths: &[PathBuf]) -> Result { match Config::from_paths_or_default(paths)? { Some(config) => Ok(config), @@ -111,6 +133,8 @@ pub struct AccountsTable { pub preset: String, #[serde(skip)] pub arrangement: ContentArrangement, + #[serde(skip)] + colors: AccountColors, pub accounts: Vec, } @@ -129,9 +153,11 @@ impl fmt::Display for AccountsTable { .add_rows(self.accounts.iter().map(|account| { let mut row = Row::new(); row.max_height(1); - row.add_cell(Cell::new(&account.name)); - row.add_cell(Cell::new(account.backends.join(", "))); - row.add_cell(Cell::new(if account.default { "yes" } else { "" })); + row.add_cell(Cell::new(&account.name).fg(self.colors.name)); + row.add_cell(Cell::new(account.backends.join(", ")).fg(self.colors.backends)); + row.add_cell( + Cell::new(if account.default { "yes" } else { "" }).fg(self.colors.default), + ); row })); diff --git a/src/config.rs b/src/config.rs index 5126080d..37c85576 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,7 @@ use std::{collections::HashMap, fs, path::Path, path::PathBuf}; use anyhow::{Context, Result}; use comfy_table::ContentArrangement; +use crossterm::style::Color; use pimalaya_config::{ secret::Secret, toml::{shell_expanded_string, TomlConfig}, @@ -38,14 +39,20 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Config { pub downloads_dir: Option, - pub table_preset: Option, - pub table_arrangement: Option, + #[serde(default)] + pub table: TableConfig, #[serde(default)] pub envelope: EnvelopeConfig, #[serde(default)] pub mailbox: MailboxConfig, #[serde(default)] pub message: MessageConfig, + #[serde(default)] + pub attachment: AttachmentConfig, + /// `account list` rendering options (global only — there is no + /// per-account override for the listing of accounts). + #[serde(default)] + pub account: AccountListingConfig, pub accounts: HashMap, } @@ -98,8 +105,8 @@ pub struct AccountConfig { pub default: bool, pub downloads_dir: Option, - pub table_preset: Option, - pub table_arrangement: Option, + #[serde(default)] + pub table: TableConfig, #[serde(default)] pub envelope: EnvelopeConfig, @@ -107,6 +114,9 @@ pub struct AccountConfig { #[serde(default)] pub mailbox: MailboxConfig, + #[serde(default)] + pub attachment: AttachmentConfig, + #[allow(unused)] pub imap: Option, #[allow(unused)] @@ -127,21 +137,88 @@ pub struct EnvelopeConfig { /// Mailbox-level configuration. /// -/// Currently exposes user-defined aliases mapping a friendly name to a -/// backend-native id. Alias names are looked up case-insensitively at -/// resolution time, so `INBOX`, `Inbox` and `inbox` all hit the same -/// entry. Ids are stored verbatim. The entry `inbox` (case-insensitive) -/// acts as the implicit default mailbox when a shared command omits -/// `-m/--mailbox`. +/// Exposes user-defined aliases mapping a friendly name to a +/// backend-native id (looked up case-insensitively at resolution +/// time; the `inbox` alias acts as the implicit default mailbox when +/// a shared command omits `-m/--mailbox`) and the `mailboxes list` +/// rendering options. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct MailboxConfig { #[serde(default, alias = "aliases")] pub alias: HashMap, + + #[serde(default)] + pub list: MailboxListConfig, } -/// `envelopes list` rendering options. Mirrors the pre-v2 -/// `envelope.list.*` keys. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct MailboxListConfig { + #[serde(default)] + pub table: MailboxListTableConfig, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct MailboxListTableConfig { + pub id_color: Option, + pub name_color: Option, + pub total_color: Option, + pub unread_color: Option, +} + +/// `attachments list` rendering options. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct AttachmentConfig { + #[serde(default)] + pub list: AttachmentListConfig, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct AttachmentListConfig { + #[serde(default)] + pub table: AttachmentListTableConfig, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct AttachmentListTableConfig { + pub id_color: Option, + pub filename_color: Option, + pub type_color: Option, + pub size_color: Option, + pub inline_color: Option, + pub path_color: Option, +} + +/// `account list` rendering options. Top-level only — there is no +/// per-account override. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct AccountListingConfig { + #[serde(default)] + pub list: AccountListingListConfig, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct AccountListingListConfig { + #[serde(default)] + pub table: AccountListingTableConfig, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct AccountListingTableConfig { + pub name_color: Option, + pub backends_color: Option, + pub default_color: Option, +} + +/// `envelopes list` rendering options under `envelope.list.*`. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct EnvelopeListConfig { @@ -159,6 +236,43 @@ pub struct EnvelopeListConfig { /// flag wins when passed; otherwise the merged account/global /// config wins; otherwise the hard fallback (25) is used. pub page_size: Option, + + /// Per-column color overrides + flag glyph customization for the + /// rendered envelopes table. Keys mirror the v1.2.0 layout + /// (`envelope.list.table.id-color`, `envelope.list.table.unseen-char`, + /// etc.). Color values accept either a named [crossterm color] + /// (`"red"`, `"dark-magenta"`, …) or an `{ Rgb = { r = .., g = .., + /// b = .. } }`/`{ AnsiValue = N }` table. + /// + /// [crossterm color]: https://docs.rs/crossterm/latest/crossterm/style/enum.Color.html + #[serde(default)] + pub table: EnvelopeListTableConfig, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct EnvelopeListTableConfig { + /// Single character used in the FLAGS column for messages that + /// lack `\Seen`. Defaults to `*` (v1.2.0 default). + pub unseen_char: Option, + /// Single character used in the FLAGS column for messages with + /// `\Answered`. Defaults to `R`. + pub replied_char: Option, + /// Single character used in the FLAGS column for messages with + /// `\Flagged`. Defaults to `!`. + pub flagged_char: Option, + /// Single character used in the ATT column for messages with at + /// least one attachment. Defaults to `@`. + pub attachment_char: Option, + + pub id_color: Option, + pub flags_color: Option, + pub att_color: Option, + pub subject_color: Option, + pub from_color: Option, + pub to_color: Option, + pub date_color: Option, + pub size_color: Option, } /// Message-level configuration: user-defined composers and readers. @@ -211,6 +325,21 @@ pub struct ReaderConfig { pub default: bool, } +/// Global / per-account table rendering knobs shared across every list +/// command (envelopes, mailboxes, attachments). The per-column color +/// blocks live under `*.list.table.*-color` (see [`EnvelopeListTableConfig`] +/// & co.). +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct TableConfig { + /// `comfy_table` preset string (chars for borders / corners / + /// separators). Defaults to `UTF8_FULL_CONDENSED`. See + /// . + pub preset: Option, + /// Column-arrangement strategy. Defaults to `Dynamic`. + pub arrangement: Option, +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum TableArrangementConfig { diff --git a/src/imap/envelope/list.rs b/src/imap/envelope/list.rs index 40aee68e..039aef96 100644 --- a/src/imap/envelope/list.rs +++ b/src/imap/envelope/list.rs @@ -19,7 +19,7 @@ use std::{collections::BTreeMap, fmt, num::NonZeroU32}; use anyhow::{bail, Result}; use clap::Parser; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; use io_imap::types::{ core::Vec1, envelope::Address, @@ -102,6 +102,12 @@ impl ImapEnvelopeListCommand { let table = EnvelopesTable { preset: client.account.table_preset().to_string(), arrangement: client.account.table_arrangement(), + colors: EnvelopeColors { + id: client.account.envelopes_list_table_id_color(), + subject: client.account.envelopes_list_table_subject_color(), + from: client.account.envelopes_list_table_from_color(), + date: client.account.envelopes_list_table_date_color(), + }, envelopes: map_envelopes_table_entries(data), }; @@ -109,12 +115,22 @@ impl ImapEnvelopeListCommand { } } +#[derive(Clone, Copy, Debug)] +struct EnvelopeColors { + id: Color, + subject: Color, + from: Color, + date: Color, +} + #[derive(Clone, Debug, Serialize)] pub struct EnvelopesTable { #[serde(skip)] preset: String, #[serde(skip)] arrangement: ContentArrangement, + #[serde(skip)] + colors: EnvelopeColors, envelopes: Vec, } @@ -136,11 +152,13 @@ impl fmt::Display for EnvelopesTable { for entry in &self.envelopes { let mut row = Row::new(); row.max_height(1); - row.add_cell(Cell::new(entry.seq)); - row.add_cell(Cell::new(entry.uid)); - row.add_cell(Cell::new(&entry.subject)); - row.add_cell(Cell::new(&entry.from)); - row.add_cell(Cell::new(&entry.date)); + // SEQ and UID share the same `id_color` since both are + // protocol-side identifiers for the row. + row.add_cell(Cell::new(entry.seq).fg(self.colors.id)); + row.add_cell(Cell::new(entry.uid).fg(self.colors.id)); + row.add_cell(Cell::new(&entry.subject).fg(self.colors.subject)); + row.add_cell(Cell::new(&entry.from).fg(self.colors.from)); + row.add_cell(Cell::new(&entry.date).fg(self.colors.date)); table.add_row(row); } diff --git a/src/imap/envelope/search.rs b/src/imap/envelope/search.rs index 1e48065f..087cf7b9 100644 --- a/src/imap/envelope/search.rs +++ b/src/imap/envelope/search.rs @@ -19,7 +19,7 @@ use std::fmt; use anyhow::{anyhow, bail, Result}; use clap::Parser; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; use io_imap::types::{ core::{AString, Vec1}, datetime::NaiveDate, @@ -88,6 +88,7 @@ impl ImapEnvelopeSearchCommand { let table = SearchTable { preset: client.account.table_preset().to_string(), arrangement: client.account.table_arrangement(), + id_color: client.account.envelopes_list_table_id_color(), ids: ids .into_iter() .map(|id| SearchResult { id: id.get() }) @@ -110,6 +111,8 @@ pub struct SearchTable { preset: String, #[serde(skip)] arrangement: ContentArrangement, + #[serde(skip)] + id_color: Color, uid_mode: bool, ids: Vec, } @@ -126,7 +129,7 @@ impl fmt::Display for SearchTable { .set_header(Row::from([Cell::new(id_header)])); for result in &self.ids { - table.add_row(Row::from([Cell::new(result.id)])); + table.add_row(Row::from([Cell::new(result.id).fg(self.id_color)])); } writeln!(f)?; diff --git a/src/imap/envelope/sort.rs b/src/imap/envelope/sort.rs index eafc980c..37bd3979 100644 --- a/src/imap/envelope/sort.rs +++ b/src/imap/envelope/sort.rs @@ -19,7 +19,7 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; -use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use comfy_table::{presets, Cell, Color, ContentArrangement, Row, Table}; use io_imap::types::{ core::Vec1, extensions::sort::{SortCriterion, SortKey}, @@ -82,7 +82,8 @@ impl ImapEnvelopeSortCommand { let ids = client.sort(sort_criteria, search_criteria, !self.seq)?; - let table = SortResultsTable::new(ids, !self.seq); + let id_color = client.account.envelopes_list_table_id_color(); + let table = SortResultsTable::new(ids, !self.seq, id_color); printer.out(table)?; Ok(()) @@ -108,12 +109,18 @@ fn parse_sort_key(s: &str) -> Result { pub struct SortResultsTable { ids: Vec, uid_mode: bool, + #[serde(skip)] + id_color: Color, } impl SortResultsTable { - pub fn new(ids: Vec, uid_mode: bool) -> Self { + pub fn new(ids: Vec, uid_mode: bool, id_color: Color) -> Self { let ids = ids.into_iter().map(|id| id.get()).collect(); - Self { ids, uid_mode } + Self { + ids, + uid_mode, + id_color, + } } } @@ -129,7 +136,7 @@ impl fmt::Display for SortResultsTable { .set_header(Row::from([Cell::new(id_header)])); for id in &self.ids { - table.add_row(Row::from([Cell::new(id)])); + table.add_row(Row::from([Cell::new(id).fg(self.id_color)])); } writeln!(f)?; diff --git a/src/imap/mailbox/list.rs b/src/imap/mailbox/list.rs index 6215fa00..5569b709 100644 --- a/src/imap/mailbox/list.rs +++ b/src/imap/mailbox/list.rs @@ -19,7 +19,7 @@ use std::fmt; use anyhow::Result; use clap::Parser; -use comfy_table::{Cell, Row, Table}; +use comfy_table::{Cell, Color, Row, Table}; use io_email::mailbox::MailboxRole; use io_imap::types::{core::QuotedChar, flag::FlagNameAttribute, mailbox::Mailbox}; use pimalaya_cli::printer::Printer; @@ -60,6 +60,7 @@ impl ImapMailboxListCommand { let table = MailboxesTable { preset: client.account.table_preset().to_string(), + name_color: client.account.mailboxes_list_table_name_color(), mailboxes: mailboxes.into_iter().map(From::from).collect(), }; @@ -67,10 +68,12 @@ impl ImapMailboxListCommand { } } -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct MailboxesTable { #[serde(skip)] pub preset: String, + #[serde(skip)] + pub name_color: Color, pub mailboxes: Vec, } @@ -99,7 +102,7 @@ impl fmt::Display for MailboxesTable { .unwrap_or_default(); row.max_height(1) - .add_cell(Cell::new(&mbox.name)) + .add_cell(Cell::new(&mbox.name).fg(self.name_color)) .add_cell(Cell::new(&mbox.delimiter)) .add_cell(Cell::new(role)) .add_cell(Cell::new(mbox.attributes.join(", "))); diff --git a/src/jmap/email/get.rs b/src/jmap/email/get.rs index eac8d1cc..f5b13127 100644 --- a/src/jmap/email/get.rs +++ b/src/jmap/email/get.rs @@ -20,7 +20,10 @@ use clap::Parser; use log::warn; use pimalaya_cli::printer::Printer; -use crate::jmap::{client::JmapClient, email::query::EmailsTable}; +use crate::jmap::{ + client::JmapClient, + email::query::{EmailsChars, EmailsColors, EmailsTable}, +}; /// Get JMAP emails by ID (Email/get). /// @@ -43,6 +46,18 @@ impl JmapEmailGetCommand { let table = EmailsTable { preset: client.account.table_preset().to_string(), arrangement: client.account.table_arrangement(), + colors: EmailsColors { + id: client.account.envelopes_list_table_id_color(), + flags: client.account.envelopes_list_table_flags_color(), + subject: client.account.envelopes_list_table_subject_color(), + from: client.account.envelopes_list_table_from_color(), + date: client.account.envelopes_list_table_date_color(), + }, + chars: EmailsChars { + unseen: client.account.envelopes_list_table_unseen_char(), + flagged: client.account.envelopes_list_table_flagged_char(), + attachment: client.account.envelopes_list_table_attachment_char(), + }, emails: output.emails, }; diff --git a/src/jmap/email/query.rs b/src/jmap/email/query.rs index 3014d784..1f0db9b8 100644 --- a/src/jmap/email/query.rs +++ b/src/jmap/email/query.rs @@ -19,7 +19,7 @@ use std::fmt; use anyhow::Result; use clap::{Parser, ValueEnum}; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; use io_jmap::rfc8621::email::{ Email, EmailAddress, EmailComparator, EmailFilter, EmailSortProperty, }; @@ -165,6 +165,18 @@ impl JmapEmailQueryCommand { let table = EmailsTable { preset: client.account.table_preset().to_string(), arrangement: client.account.table_arrangement(), + colors: EmailsColors { + id: client.account.envelopes_list_table_id_color(), + flags: client.account.envelopes_list_table_flags_color(), + subject: client.account.envelopes_list_table_subject_color(), + from: client.account.envelopes_list_table_from_color(), + date: client.account.envelopes_list_table_date_color(), + }, + chars: EmailsChars { + unseen: client.account.envelopes_list_table_unseen_char(), + flagged: client.account.envelopes_list_table_flagged_char(), + attachment: client.account.envelopes_list_table_attachment_char(), + }, emails: output.emails, }; @@ -172,12 +184,32 @@ impl JmapEmailQueryCommand { } } +#[derive(Clone, Copy, Debug)] +pub struct EmailsColors { + pub id: Color, + pub flags: Color, + pub subject: Color, + pub from: Color, + pub date: Color, +} + +#[derive(Clone, Copy, Debug)] +pub struct EmailsChars { + pub unseen: char, + pub flagged: char, + pub attachment: char, +} + #[derive(Clone, Debug, Serialize)] pub struct EmailsTable { #[serde(skip)] pub preset: String, #[serde(skip)] pub arrangement: ContentArrangement, + #[serde(skip)] + pub colors: EmailsColors, + #[serde(skip)] + pub chars: EmailsChars, pub emails: Vec, } @@ -200,24 +232,25 @@ impl fmt::Display for EmailsTable { let mut flags = String::new(); let kw = e.keywords.as_ref(); if !kw.and_then(|k| k.get("$seen")).copied().unwrap_or(false) { - flags.push('U'); + flags.push(self.chars.unseen); } if kw.and_then(|k| k.get("$flagged")).copied().unwrap_or(false) { - flags.push('F'); + flags.push(self.chars.flagged); } if e.has_attachment.unwrap_or(false) { - flags.push('A'); + flags.push(self.chars.attachment); } let mut row = Row::new(); row.max_height(1); - row.add_cell(Cell::new(e.id.as_deref().unwrap_or(""))); - row.add_cell(Cell::new(&flags)); - row.add_cell(Cell::new(e.subject.as_deref().unwrap_or(""))); - row.add_cell(Cell::new(format_addresses( - e.from.as_deref().unwrap_or(&[]), - ))); - row.add_cell(Cell::new(e.received_at.as_deref().unwrap_or(""))); + row.add_cell(Cell::new(e.id.as_deref().unwrap_or("")).fg(self.colors.id)); + row.add_cell(Cell::new(&flags).fg(self.colors.flags)); + row.add_cell(Cell::new(e.subject.as_deref().unwrap_or("")).fg(self.colors.subject)); + row.add_cell( + Cell::new(format_addresses(e.from.as_deref().unwrap_or(&[]))) + .fg(self.colors.from), + ); + row.add_cell(Cell::new(e.received_at.as_deref().unwrap_or("")).fg(self.colors.date)); table.add_row(row); } diff --git a/src/jmap/mailbox/get.rs b/src/jmap/mailbox/get.rs index fa34ced0..07430421 100644 --- a/src/jmap/mailbox/get.rs +++ b/src/jmap/mailbox/get.rs @@ -20,7 +20,10 @@ use clap::Parser; use log::warn; use pimalaya_cli::printer::Printer; -use crate::jmap::{client::JmapClient, mailbox::query::MailboxesTable}; +use crate::jmap::{ + client::JmapClient, + mailbox::query::{MailboxColors, MailboxesTable}, +}; /// Get JMAP mailboxes by ID (Mailbox/get). #[derive(Debug, Parser)] @@ -40,6 +43,12 @@ impl JmapMailboxGetCommand { let table = MailboxesTable { preset: client.account.table_preset().to_string(), + colors: MailboxColors { + id: client.account.mailboxes_list_table_id_color(), + name: client.account.mailboxes_list_table_name_color(), + total: client.account.mailboxes_list_table_total_color(), + unread: client.account.mailboxes_list_table_unread_color(), + }, mailboxes: output.mailboxes, }; diff --git a/src/jmap/mailbox/query.rs b/src/jmap/mailbox/query.rs index 0cf5d126..db0fcff3 100644 --- a/src/jmap/mailbox/query.rs +++ b/src/jmap/mailbox/query.rs @@ -19,7 +19,7 @@ use std::{convert::Infallible, fmt, str::FromStr}; use anyhow::Result; use clap::{Parser, ValueEnum}; -use comfy_table::{Cell, Row, Table}; +use comfy_table::{Cell, Color, Row, Table}; use io_jmap::rfc8621::mailbox::{ Mailbox, MailboxFilter, MailboxRole, MailboxSortComparator, MailboxSortProperty, }; @@ -110,6 +110,12 @@ impl JmapMailboxQueryCommand { let table = MailboxesTable { preset: client.account.table_preset().to_string(), + colors: MailboxColors { + id: client.account.mailboxes_list_table_id_color(), + name: client.account.mailboxes_list_table_name_color(), + total: client.account.mailboxes_list_table_total_color(), + unread: client.account.mailboxes_list_table_unread_color(), + }, mailboxes: output.mailboxes, }; @@ -117,10 +123,31 @@ impl JmapMailboxQueryCommand { } } +#[derive(Clone, Copy, Debug)] +pub struct MailboxColors { + pub id: Color, + pub name: Color, + pub total: Color, + pub unread: Color, +} + +impl Default for MailboxColors { + fn default() -> Self { + Self { + id: Color::Reset, + name: Color::Reset, + total: Color::Reset, + unread: Color::Reset, + } + } +} + #[derive(Clone, Debug, Default, Serialize)] pub struct MailboxesTable { #[serde(skip)] pub preset: String, + #[serde(skip)] + pub colors: MailboxColors, pub mailboxes: Vec, } @@ -141,14 +168,16 @@ impl fmt::Display for MailboxesTable { .add_rows(self.mailboxes.iter().map(|r| { let mut row = Row::new(); row.max_height(1) - .add_cell(Cell::new(r.id.as_deref().unwrap_or("Unknown"))) - .add_cell(Cell::new(r.name.as_deref().unwrap_or("Unknown"))) + .add_cell(Cell::new(r.id.as_deref().unwrap_or("Unknown")).fg(self.colors.id)) + .add_cell( + Cell::new(r.name.as_deref().unwrap_or("Unknown")).fg(self.colors.name), + ) .add_cell(match r.role.as_ref() { Some(r) => Cell::new(r.to_string()), None => Cell::new(""), }) - .add_cell(Cell::new(r.total_emails)) - .add_cell(Cell::new(r.unread_emails)) + .add_cell(Cell::new(r.total_emails).fg(self.colors.total)) + .add_cell(Cell::new(r.unread_emails).fg(self.colors.unread)) .add_cell(Cell::new(if r.is_subscribed { "yes" } else { "" })); row })); diff --git a/src/maildir/envelope/list.rs b/src/maildir/envelope/list.rs index fd502814..fcfa3bec 100644 --- a/src/maildir/envelope/list.rs +++ b/src/maildir/envelope/list.rs @@ -19,7 +19,7 @@ use std::fmt; use anyhow::Result; use clap::Parser; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; use io_maildir::maildir::Maildir; use pimalaya_cli::printer::Printer; use serde::Serialize; @@ -79,6 +79,12 @@ impl MaildirEnvelopeListCommand { let table = EnvelopesTable { preset: client.account.table_preset().to_string(), arrangement: client.account.table_arrangement(), + colors: EnvelopeColors { + id: client.account.envelopes_list_table_id_color(), + subject: client.account.envelopes_list_table_subject_color(), + from: client.account.envelopes_list_table_from_color(), + date: client.account.envelopes_list_table_date_color(), + }, envelopes, }; @@ -86,12 +92,22 @@ impl MaildirEnvelopeListCommand { } } +#[derive(Clone, Copy, Debug)] +struct EnvelopeColors { + id: Color, + subject: Color, + from: Color, + date: Color, +} + #[derive(Clone, Debug, Serialize)] pub struct EnvelopesTable { #[serde(skip)] preset: String, #[serde(skip)] arrangement: ContentArrangement, + #[serde(skip)] + colors: EnvelopeColors, envelopes: Vec, } @@ -113,10 +129,10 @@ impl fmt::Display for EnvelopesTable { let mut row = Row::new(); row.max_height(1) - .add_cell(Cell::new(&entry.id)) - .add_cell(Cell::new(&entry.subject)) - .add_cell(Cell::new(&entry.from)) - .add_cell(Cell::new(&entry.date)); + .add_cell(Cell::new(&entry.id).fg(self.colors.id)) + .add_cell(Cell::new(&entry.subject).fg(self.colors.subject)) + .add_cell(Cell::new(&entry.from).fg(self.colors.from)) + .add_cell(Cell::new(&entry.date).fg(self.colors.date)); table.add_row(row); } diff --git a/src/maildir/list.rs b/src/maildir/list.rs index e2699c10..f2d50eaf 100644 --- a/src/maildir/list.rs +++ b/src/maildir/list.rs @@ -19,7 +19,7 @@ use std::{fmt, path::PathBuf}; use anyhow::Result; use clap::Parser; -use comfy_table::{Cell, Row, Table}; +use comfy_table::{Cell, Color, Row, Table}; use io_maildir::maildir::Maildir; use pimalaya_cli::printer::Printer; use serde::Serialize; @@ -40,6 +40,7 @@ impl MaildirMailboxListCommand { let table = MaildirsTable { preset: client.account.table_preset().to_string(), + name_color: client.account.mailboxes_list_table_name_color(), rows: maildirs.into_iter().map(From::from).collect(), }; @@ -47,10 +48,12 @@ impl MaildirMailboxListCommand { } } -#[derive(Clone, Debug, Default, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct MaildirsTable { #[serde(skip)] pub preset: String, + #[serde(skip)] + pub name_color: Color, #[serde(rename = "maildirs")] pub rows: Vec, } @@ -66,7 +69,7 @@ impl fmt::Display for MaildirsTable { let mut row = Row::new(); row.max_height(1) - .add_cell(Cell::new(&m.name)) + .add_cell(Cell::new(&m.name).fg(self.name_color)) .add_cell(Cell::new(format!("{}", m.path.display()))); row diff --git a/src/shared/attachments/download.rs b/src/shared/attachments/download.rs index b9c25b38..3b87c93c 100644 --- a/src/shared/attachments/download.rs +++ b/src/shared/attachments/download.rs @@ -27,7 +27,7 @@ use mail_parser::{MessageParser, MimeHeaders}; use pimalaya_cli::printer::Printer; use crate::shared::{ - attachments::list::{mime_string, Attachment, Attachments}, + attachments::list::{mime_string, Attachment, AttachmentColors, Attachments}, client::EmailClient, mailboxes::arg::MailboxArg, }; @@ -130,6 +130,14 @@ impl AttachmentDownloadCommand { arrangement: client.account.table_arrangement(), with_inline: written.iter().any(|a| a.inline), with_path: true, + colors: AttachmentColors { + id: client.account.attachments_list_table_id_color(), + filename: client.account.attachments_list_table_filename_color(), + r#type: client.account.attachments_list_table_type_color(), + size: client.account.attachments_list_table_size_color(), + inline: client.account.attachments_list_table_inline_color(), + path: client.account.attachments_list_table_path_color(), + }, attachments: written, }; diff --git a/src/shared/attachments/list.rs b/src/shared/attachments/list.rs index cd8702c1..ec3ea4da 100644 --- a/src/shared/attachments/list.rs +++ b/src/shared/attachments/list.rs @@ -19,7 +19,7 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; use humansize::{format_size, BINARY}; use mail_parser::{MessageParser, MessagePart, MimeHeaders}; use pimalaya_cli::printer::Printer; @@ -87,6 +87,14 @@ impl AttachmentListCommand { arrangement: client.account.table_arrangement(), with_inline: self.inline, with_path: false, + colors: AttachmentColors { + id: client.account.attachments_list_table_id_color(), + filename: client.account.attachments_list_table_filename_color(), + r#type: client.account.attachments_list_table_type_color(), + size: client.account.attachments_list_table_size_color(), + inline: client.account.attachments_list_table_inline_color(), + path: client.account.attachments_list_table_path_color(), + }, attachments, }; @@ -94,6 +102,16 @@ impl AttachmentListCommand { } } +#[derive(Clone, Copy, Debug)] +pub(crate) struct AttachmentColors { + pub id: Color, + pub filename: Color, + pub r#type: Color, + pub size: Color, + pub inline: Color, + pub path: Color, +} + /// One row of the `attachments list` / `attachments download` output. #[derive(Clone, Debug, Serialize)] pub struct Attachment { @@ -127,6 +145,8 @@ pub struct Attachments { pub with_inline: bool, #[serde(skip)] pub with_path: bool, + #[serde(skip)] + pub(crate) colors: AttachmentColors, pub attachments: Vec, } @@ -154,15 +174,19 @@ impl fmt::Display for Attachments { .add_rows(self.attachments.iter().map(|a| { let mut row = Row::new(); row.max_height(1); - row.add_cell(Cell::new(&a.id)); - row.add_cell(Cell::new(a.filename.as_deref().unwrap_or(""))); - row.add_cell(Cell::new(a.mime.as_deref().unwrap_or(""))); - row.add_cell(Cell::new(format_size(a.size, BINARY))); + row.add_cell(Cell::new(&a.id).fg(self.colors.id)); + row.add_cell( + Cell::new(a.filename.as_deref().unwrap_or("")).fg(self.colors.filename), + ); + row.add_cell(Cell::new(a.mime.as_deref().unwrap_or("")).fg(self.colors.r#type)); + row.add_cell(Cell::new(format_size(a.size, BINARY)).fg(self.colors.size)); if self.with_inline { - row.add_cell(Cell::new(if a.inline { "yes" } else { "no" })); + row.add_cell( + Cell::new(if a.inline { "yes" } else { "no" }).fg(self.colors.inline), + ); } if self.with_path { - row.add_cell(Cell::new(a.path.as_deref().unwrap_or(""))); + row.add_cell(Cell::new(a.path.as_deref().unwrap_or("")).fg(self.colors.path)); } row })); diff --git a/src/shared/envelopes/list.rs b/src/shared/envelopes/list.rs index 04302da0..d53b4cac 100644 --- a/src/shared/envelopes/list.rs +++ b/src/shared/envelopes/list.rs @@ -20,7 +20,7 @@ use std::{collections::BTreeSet, fmt}; use anyhow::Result; use chrono::{DateTime, FixedOffset, Local}; use clap::Parser; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; use humansize::{format_size, BINARY}; use io_email::{address::Address, envelope::Envelope, flag::Flag}; use pimalaya_cli::printer::Printer; @@ -93,6 +93,22 @@ impl EnvelopeListCommand { datetime_local_tz: client.account.datetime_local_tz(), recipient: self.recipient, with_attachment: self.has_attachment, + chars: FlagChars { + unseen: client.account.envelopes_list_table_unseen_char(), + replied: client.account.envelopes_list_table_replied_char(), + flagged: client.account.envelopes_list_table_flagged_char(), + attachment: client.account.envelopes_list_table_attachment_char(), + }, + colors: EnvelopeColors { + id: client.account.envelopes_list_table_id_color(), + flags: client.account.envelopes_list_table_flags_color(), + att: client.account.envelopes_list_table_att_color(), + subject: client.account.envelopes_list_table_subject_color(), + from: client.account.envelopes_list_table_from_color(), + to: client.account.envelopes_list_table_to_color(), + date: client.account.envelopes_list_table_date_color(), + size: client.account.envelopes_list_table_size_color(), + }, envelopes, }; @@ -100,6 +116,30 @@ impl EnvelopeListCommand { } } +/// Glyphs the FLAGS / ATT columns substitute in, sourced from the +/// merged account config (v1.2.0 defaults: `*`, `R`, `!`, `@`). +#[derive(Clone, Copy, Debug)] +pub(super) struct FlagChars { + pub unseen: char, + pub replied: char, + pub flagged: char, + pub attachment: char, +} + +/// Per-column foreground colors for the envelopes table. `Color::Reset` +/// means "use the terminal default" (i.e. no override). +#[derive(Clone, Copy, Debug)] +pub(super) struct EnvelopeColors { + pub id: Color, + pub flags: Color, + pub att: Color, + pub subject: Color, + pub from: Color, + pub to: Color, + pub date: Color, + pub size: Color, +} + #[derive(Clone, Debug, Serialize)] pub struct Envelopes { #[serde(skip)] @@ -116,6 +156,10 @@ pub struct Envelopes { pub recipient: bool, #[serde(skip)] pub with_attachment: bool, + #[serde(skip)] + pub(super) chars: FlagChars, + #[serde(skip)] + pub(super) colors: EnvelopeColors, pub envelopes: Vec, } @@ -139,22 +183,33 @@ impl fmt::Display for Envelopes { .add_rows(self.envelopes.iter().map(|env| { let mut row = Row::new(); row.max_height(1); - row.add_cell(Cell::new(&env.id)); - row.add_cell(Cell::new(format_flags(&env.flags))); + row.add_cell(Cell::new(&env.id).fg(self.colors.id)); + row.add_cell(Cell::new(format_flags(&env.flags, &self.chars)).fg(self.colors.flags)); if self.with_attachment { - row.add_cell(Cell::new(format_attachment(env.has_attachment))); + row.add_cell( + Cell::new(format_attachment(env.has_attachment, self.chars.attachment)) + .fg(self.colors.att), + ); } - row.add_cell(Cell::new(&env.subject)); + row.add_cell(Cell::new(&env.subject).fg(self.colors.subject)); let addresses = if self.recipient { &env.to } else { &env.from }; - row.add_cell(Cell::new(format_addresses(addresses))); + let from_or_to_color = if self.recipient { + self.colors.to + } else { + self.colors.from + }; + row.add_cell(Cell::new(format_addresses(addresses)).fg(from_or_to_color)); - row.add_cell(Cell::new(format_date( - env.date, - &self.datetime_fmt, - self.datetime_local_tz, - ))); - row.add_cell(Cell::new(format_size(env.size, BINARY))); + row.add_cell( + Cell::new(format_date( + env.date, + &self.datetime_fmt, + self.datetime_local_tz, + )) + .fg(self.colors.date), + ); + row.add_cell(Cell::new(format_size(env.size, BINARY)).fg(self.colors.size)); row })); @@ -167,39 +222,34 @@ impl fmt::Display for Envelopes { } } -/// 4-character flag widget: one slot per LCD variant. Unread (no -/// `Seen`) shows `N` in the first slot since unread is the -/// attention-grabbing case. -pub(super) fn format_flags(flags: &BTreeSet) -> String { - let mut out = String::with_capacity(4); +/// 3-character flag widget: unseen, replied, flagged. Each slot is a +/// space when the flag is absent, otherwise the configured glyph +/// (v1.2.0 defaults: `*`, `R`, `!`). +pub(super) fn format_flags(flags: &BTreeSet, chars: &FlagChars) -> String { + let mut out = String::with_capacity(3); out.push(if flags.contains(&Flag::Seen) { ' ' } else { - 'N' + chars.unseen }); out.push(if flags.contains(&Flag::Answered) { - 'r' + chars.replied } else { ' ' }); out.push(if flags.contains(&Flag::Flagged) { - '*' - } else { - ' ' - }); - out.push(if flags.contains(&Flag::Draft) { - 'D' + chars.flagged } else { ' ' }); out } -pub(super) fn format_attachment(has: Option) -> &'static str { +pub(super) fn format_attachment(has: Option, glyph: char) -> String { match has { - Some(true) => "@", - Some(false) => "", - None => "?", + Some(true) => glyph.to_string(), + Some(false) => String::new(), + None => "?".to_string(), } } diff --git a/src/shared/envelopes/search.rs b/src/shared/envelopes/search.rs index 39a386e2..4e077bcf 100644 --- a/src/shared/envelopes/search.rs +++ b/src/shared/envelopes/search.rs @@ -23,7 +23,11 @@ use clap::Parser; use io_email::search::{error::Error as SearchQueryError, query::SearchEmailsQuery}; use pimalaya_cli::printer::Printer; -use crate::shared::{client::EmailClient, envelopes::list::Envelopes, mailboxes::arg::MailboxArg}; +use crate::shared::{ + client::EmailClient, + envelopes::list::{EnvelopeColors, Envelopes, FlagChars}, + mailboxes::arg::MailboxArg, +}; /// Search envelopes for the active account using the shared search /// query DSL, regardless of the underlying backend (IMAP, JMAP or @@ -102,6 +106,22 @@ impl EnvelopeSearchCommand { datetime_local_tz: client.account.datetime_local_tz(), recipient: self.recipient, with_attachment: self.has_attachment, + chars: FlagChars { + unseen: client.account.envelopes_list_table_unseen_char(), + replied: client.account.envelopes_list_table_replied_char(), + flagged: client.account.envelopes_list_table_flagged_char(), + attachment: client.account.envelopes_list_table_attachment_char(), + }, + colors: EnvelopeColors { + id: client.account.envelopes_list_table_id_color(), + flags: client.account.envelopes_list_table_flags_color(), + att: client.account.envelopes_list_table_att_color(), + subject: client.account.envelopes_list_table_subject_color(), + from: client.account.envelopes_list_table_from_color(), + to: client.account.envelopes_list_table_to_color(), + date: client.account.envelopes_list_table_date_color(), + size: client.account.envelopes_list_table_size_color(), + }, envelopes, }; diff --git a/src/shared/mailboxes/list.rs b/src/shared/mailboxes/list.rs index c202e44d..c601a7e9 100644 --- a/src/shared/mailboxes/list.rs +++ b/src/shared/mailboxes/list.rs @@ -19,7 +19,7 @@ use std::fmt; use anyhow::Result; use clap::Parser; -use comfy_table::{Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; use io_email::mailbox::Mailbox; use pimalaya_cli::printer::Printer; use serde::Serialize; @@ -56,6 +56,12 @@ impl MailboxListCommand { arrangement: client.account.table_arrangement(), max_width: self.max_width, with_counts: self.counts, + colors: MailboxColors { + id: client.account.mailboxes_list_table_id_color(), + name: client.account.mailboxes_list_table_name_color(), + total: client.account.mailboxes_list_table_total_color(), + unread: client.account.mailboxes_list_table_unread_color(), + }, mailboxes, }; @@ -63,6 +69,14 @@ impl MailboxListCommand { } } +#[derive(Clone, Copy, Debug)] +struct MailboxColors { + id: Color, + name: Color, + total: Color, + unread: Color, +} + #[derive(Clone, Debug, Serialize)] pub struct Mailboxes { #[serde(skip)] @@ -73,6 +87,8 @@ pub struct Mailboxes { pub max_width: Option, #[serde(skip)] pub with_counts: bool, + #[serde(skip)] + colors: MailboxColors, pub mailboxes: Vec, } @@ -93,11 +109,11 @@ impl fmt::Display for Mailboxes { .add_rows(self.mailboxes.iter().map(|m| { let mut row = Row::new(); row.max_height(1); - row.add_cell(Cell::new(&m.id)); - row.add_cell(Cell::new(&m.name)); + row.add_cell(Cell::new(&m.id).fg(self.colors.id)); + row.add_cell(Cell::new(&m.name).fg(self.colors.name)); if self.with_counts { - row.add_cell(count_cell(m.total)); - row.add_cell(count_cell(m.unread)); + row.add_cell(count_cell(m.total).fg(self.colors.total)); + row.add_cell(count_cell(m.unread).fg(self.colors.unread)); } row })); diff --git a/src/wizard/discover.rs b/src/wizard/discover.rs index 389b525d..f44c6b7e 100644 --- a/src/wizard/discover.rs +++ b/src/wizard/discover.rs @@ -113,11 +113,12 @@ pub fn run_or_exit(target: &Path) -> Result { let config = Config { downloads_dir: None, - table_preset: None, - table_arrangement: None, + table: Default::default(), envelope: Default::default(), mailbox: Default::default(), message: Default::default(), + attachment: Default::default(), + account: Default::default(), accounts: HashMap::from([(account_name, account)]), }; @@ -185,10 +186,10 @@ fn build_account_from_discovery( Ok(AccountConfig { default: true, downloads_dir: None, - table_preset: None, - table_arrangement: None, + table: Default::default(), envelope: Default::default(), mailbox: Default::default(), + attachment: Default::default(), imap: None, jmap: Some(jmap_to_config(jmap)?), maildir: None, @@ -201,10 +202,10 @@ fn build_account_from_discovery( Ok(AccountConfig { default: true, downloads_dir: None, - table_preset: None, - table_arrangement: None, + table: Default::default(), envelope: Default::default(), mailbox: Default::default(), + attachment: Default::default(), imap: Some(imap_to_config(imap)?), jmap: None, maildir: None, diff --git a/src/wizard/edit.rs b/src/wizard/edit.rs index 82d1761b..5fdbce1c 100644 --- a/src/wizard/edit.rs +++ b/src/wizard/edit.rs @@ -94,8 +94,10 @@ pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Re .map(|a| a.default) .unwrap_or(is_first_account); let downloads_dir = existing.as_ref().and_then(|a| a.downloads_dir.clone()); - let table_preset = existing.as_ref().and_then(|a| a.table_preset.clone()); - let table_arrangement = existing.as_ref().and_then(|a| a.table_arrangement.clone()); + let table = existing + .as_ref() + .map(|a| a.table.clone()) + .unwrap_or_default(); let envelope = existing .as_ref() .map(|a| a.envelope.clone()) @@ -104,6 +106,10 @@ pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Re .as_ref() .map(|a| a.mailbox.clone()) .unwrap_or_default(); + let attachment = existing + .as_ref() + .map(|a| a.attachment.clone()) + .unwrap_or_default(); let maildir = existing.as_ref().and_then(|a| a.maildir.clone()); let account = if jmap_defaults.is_some() { @@ -111,10 +117,10 @@ pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Re AccountConfig { default, downloads_dir, - table_preset, - table_arrangement, + table, envelope, mailbox, + attachment, imap: None, jmap: Some(jmap_to_config(jmap)?), maildir, @@ -126,10 +132,10 @@ pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Re AccountConfig { default, downloads_dir, - table_preset, - table_arrangement, + table, envelope, mailbox, + attachment, imap: Some(imap_to_config(imap)?), jmap: None, maildir,