Compare commits

...

12 Commits

Author SHA1 Message Date
Manos Pitsidianakis 622ded8021
compose: add attribution line for replies 2021-01-08 15:01:38 +02:00
Manos Pitsidianakis 6d63429ad3
Add scrolling context to StatusBar
- Whenever a scrolling context is entered/exited, send a ScrollUpdate event.
- StatusBar maintains a stack of scrolling contexts and displays the
last one, if it exists. Each context is associated with a ComponentId.
- To handle dangling contexts after their Components aren't visible
anymore, send a VisibilityChange event in situations where that scenario
is possible.
2021-01-08 15:01:38 +02:00
Manos Pitsidianakis 5eb4342af8
Update dependencies, update indexmap to ^1.6 2021-01-08 15:01:38 +02:00
Manos Pitsidianakis eca10a5660
melib/backends: add mailbox management events to RefreshEventKind
Add mailbox management events from RFC 5423 Internet Message Store
Events

https://tools.ietf.org/html/rfc5423#page-8
2021-01-08 15:01:38 +02:00
Manos Pitsidianakis a697dfabbd
melib/jmap: use receivedAt as alternative to Date in Envelope gen 2021-01-08 15:01:38 +02:00
Manos Pitsidianakis 23997bdec0
melib/jmap: add UTCDate queries in EmailFilterCondition
Not necessarily working, added as stubs for future work

Closes #62
2021-01-08 15:01:37 +02:00
Manos Pitsidianakis 2e6a1e1ef8
melib/datetime: rename tests for consistency 2021-01-08 15:01:37 +02:00
Manos Pitsidianakis fe200a3218
melib/datetime: isolate unsafe blocks
Isolate unsafe blocks where possible to make code review easier
2021-01-08 15:01:37 +02:00
Manos Pitsidianakis bf9143d8e4
melib/datetime: use Cow<'_, CStr> in timestamp_to_string()
Use Cow to avoid unnecessary allocations when provided a nul-terminated
format string
2021-01-08 15:01:37 +02:00
Manos Pitsidianakis 441dcb62ca
melib/datetime: add format string constants 2021-01-08 15:01:37 +02:00
Manos Pitsidianakis 4cd3e28244
melib/datetime: fix import style inconsistencies 2021-01-08 15:01:37 +02:00
Manos Pitsidianakis 3dba6fdf60
melib/datetime: add posix locale arg in timestamp_to_string() 2021-01-08 15:01:37 +02:00
26 changed files with 1035 additions and 543 deletions

750
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,7 @@ serde = "1.0.71"
serde_derive = "1.0.71"
serde_json = "1.0"
toml = { version = "0.5.6", features = ["preserve_order", ] }
indexmap = { version = "^1.5", features = ["serde-1", ] }
indexmap = { version = "^1.6", features = ["serde-1", ] }
linkify = "0.4.0"
notify = "4.0.1" # >:c
termion = "1.5.1"

View File

@ -446,6 +446,31 @@ Store sent mail after successful submission.
This setting is meant to be disabled for non-standard behaviour in gmail, which auto-saves sent mail on its own.
.\" default value
.Pq Em true
.It Ic attribution_format_string Ar String
.Pq Em optional
The attribution line appears above the quoted reply text.
The format specifiers for the replied address are:
.Bl -bullet -compact
.It
.Li %+f
— the sender's name and email address.
.It
.Li %+n
— the sender's name (or email address, if no name is included).
.It
.Li %+a
— the sender's email address.
.El
The format string is passed to
.Xr strftime 3
with the replied envelope's date.
.\" default value
.Pq Em "On %a, %0e %b %Y %H:%M, %+f wrote:%n"
.It Ic attribution_use_posix_locale Ar boolean
.Pq Em optional
Whether the strftime call for the attribution string uses the POSIX locale instead of the user's active locale.
.\" default value
.Pq Em true
.El
.Sh SHORTCUTS
Shortcuts can take the following values:

View File

@ -192,7 +192,7 @@ impl Card {
self.key.as_str()
}
pub fn last_edited(&self) -> String {
datetime::timestamp_to_string(self.last_edited, None)
datetime::timestamp_to_string(self.last_edited, None, false)
}
pub fn set_id(&mut self, new_val: CardId) {

View File

@ -201,7 +201,7 @@ impl<V: VCardVersion> TryInto<Card> for VCard<V> {
T102200Z
T102200-0800
*/
card.birthday = crate::datetime::timestamp_from_string(val.value.as_str(), "%Y%m%d")
card.birthday = crate::datetime::timestamp_from_string(val.value.as_str(), "%Y%m%d\0")
.unwrap_or_default();
}
if let Some(val) = self.0.remove("EMAIL") {

View File

@ -246,6 +246,14 @@ pub enum RefreshEventKind {
NewFlags(EnvelopeHash, (Flag, Vec<String>)),
Rescan,
Failure(MeliError),
MailboxCreate(Mailbox),
MailboxDelete(MailboxHash),
MailboxRename {
old_mailbox_hash: MailboxHash,
new_mailbox: Mailbox,
},
MailboxSubscribe(MailboxHash),
MailboxUnsubscribe(MailboxHash),
}
#[derive(Debug, Clone)]

View File

@ -269,12 +269,6 @@ impl std::convert::From<EmailObject> for crate::Envelope {
env.push_references(env.in_reply_to().unwrap().clone());
}
if let Some(v) = t.headers.get("References") {
let parse_result = crate::email::parser::address::msg_id_list(v.as_bytes());
if let Ok((_, v)) = parse_result {
for v in v {
env.push_references(v);
}
}
env.set_references(v.as_bytes());
}
if let Some(v) = t.headers.get("Date") {
@ -282,6 +276,8 @@ impl std::convert::From<EmailObject> for crate::Envelope {
if let Ok(d) = crate::email::parser::dates::rfc5322_date(v.as_bytes()) {
env.set_datetime(d);
}
} else if let Ok(d) = crate::email::parser::dates::rfc5322_date(t.received_at.as_bytes()) {
env.set_datetime(d);
}
env.set_has_attachments(t.has_attachment);
if let Some(ref mut subject) = t.subject {
@ -581,6 +577,7 @@ impl From<crate::search::Query> for Filter<EmailFilterCondition, EmailObject> {
fn from(val: crate::search::Query) -> Self {
let mut ret = Filter::Condition(EmailFilterCondition::new().into());
fn rec(q: &crate::search::Query, f: &mut Filter<EmailFilterCondition, EmailObject>) {
use crate::datetime::{timestamp_to_string, RFC3339_FMT};
use crate::search::Query::*;
match q {
Subject(t) => {
@ -604,23 +601,48 @@ impl From<crate::search::Query> for Filter<EmailFilterCondition, EmailObject> {
Body(t) => {
*f = Filter::Condition(EmailFilterCondition::new().body(t.clone()).into());
}
Before(_) => {
//TODO, convert UNIX timestamp into UtcDate
Before(t) => {
*f = Filter::Condition(
EmailFilterCondition::new()
.before(timestamp_to_string(*t, Some(RFC3339_FMT), true))
.into(),
);
}
After(_) => {
//TODO
After(t) => {
*f = Filter::Condition(
EmailFilterCondition::new()
.after(timestamp_to_string(*t, Some(RFC3339_FMT), true))
.into(),
);
}
Between(_, _) => {
//TODO
Between(a, b) => {
*f = Filter::Condition(
EmailFilterCondition::new()
.after(timestamp_to_string(*a, Some(RFC3339_FMT), true))
.into(),
);
*f &= Filter::Condition(
EmailFilterCondition::new()
.before(timestamp_to_string(*b, Some(RFC3339_FMT), true))
.into(),
);
}
On(_) => {
//TODO
On(t) => {
rec(&Between(*t, *t), f);
}
InReplyTo(_) => {
//TODO, look inside Headers
InReplyTo(ref s) => {
*f = Filter::Condition(
EmailFilterCondition::new()
.header(vec!["In-Reply-To".to_string().into(), s.to_string().into()])
.into(),
);
}
References(_) => {
//TODO
References(ref s) => {
*f = Filter::Condition(
EmailFilterCondition::new()
.header(vec!["References".to_string().into(), s.to_string().into()])
.into(),
);
}
AllAddresses(_) => {
//TODO

View File

@ -38,37 +38,41 @@
//! assert_eq!(s, "2020-01-08");
//! ```
use crate::error::{Result, ResultIntoMeliError};
use std::borrow::Cow;
use std::convert::TryInto;
use std::ffi::{CStr, CString};
pub type UnixTimestamp = u64;
use libc::{locale_t, timeval, timezone};
pub const RFC3339_FMT_WITH_TIME: &str = "%Y-%m-%dT%H:%M:%S\0";
pub const RFC3339_FMT: &str = "%Y-%m-%d\0";
pub const RFC822_FMT_WITH_TIME: &str = "%a, %e %h %Y %H:%M:%S \0";
pub const RFC822_FMT: &str = "%e %h %Y %H:%M:%S \0";
pub const DEFAULT_FMT: &str = "%a, %d %b %Y %R\0";
extern "C" {
fn strptime(
s: *const ::std::os::raw::c_char,
format: *const ::std::os::raw::c_char,
tm: *mut ::libc::tm,
) -> *const ::std::os::raw::c_char;
s: *const std::os::raw::c_char,
format: *const std::os::raw::c_char,
tm: *mut libc::tm,
) -> *const std::os::raw::c_char;
fn strftime(
s: *mut ::std::os::raw::c_char,
max: ::libc::size_t,
format: *const ::std::os::raw::c_char,
tm: *const ::libc::tm,
) -> ::libc::size_t;
s: *mut std::os::raw::c_char,
max: libc::size_t,
format: *const std::os::raw::c_char,
tm: *const libc::tm,
) -> libc::size_t;
fn mktime(tm: *const ::libc::tm) -> ::libc::time_t;
fn mktime(tm: *const libc::tm) -> libc::time_t;
fn localtime_r(timep: *const ::libc::time_t, tm: *mut ::libc::tm) -> *mut ::libc::tm;
fn localtime_r(timep: *const libc::time_t, tm: *mut libc::tm) -> *mut libc::tm;
fn gettimeofday(tv: *mut timeval, tz: *mut timezone) -> i32;
fn gettimeofday(tv: *mut libc::timeval, tz: *mut libc::timezone) -> i32;
}
struct Locale {
new_locale: locale_t,
old_locale: locale_t,
new_locale: libc::locale_t,
old_locale: libc::locale_t,
}
impl Drop for Locale {
@ -83,9 +87,9 @@ impl Drop for Locale {
// How to unit test this? Test machine is not guaranteed to have non-english locales.
impl Locale {
fn new(
mask: ::std::os::raw::c_int,
locale: *const ::std::os::raw::c_char,
base: locale_t,
mask: std::os::raw::c_int,
locale: *const std::os::raw::c_char,
base: libc::locale_t,
) -> Result<Self> {
let new_locale = unsafe { libc::newlocale(mask, locale, base) };
if new_locale.is_null() {
@ -103,35 +107,58 @@ impl Locale {
}
}
pub fn timestamp_to_string(timestamp: UnixTimestamp, fmt: Option<&str>) -> String {
let mut new_tm: ::libc::tm = unsafe { std::mem::zeroed() };
pub fn timestamp_to_string(timestamp: UnixTimestamp, fmt: Option<&str>, posix: bool) -> String {
let mut new_tm: libc::tm = unsafe { std::mem::zeroed() };
unsafe {
let i: i64 = timestamp.try_into().unwrap_or(0);
localtime_r(&i as *const i64, &mut new_tm as *mut ::libc::tm);
localtime_r(&i as *const i64, &mut new_tm as *mut libc::tm);
}
let fmt = fmt
let format: Cow<'_, CStr> = if let Some(cs) = fmt
.map(str::as_bytes)
.map(CStr::from_bytes_with_nul)
.and_then(|res| res.ok())
{
Cow::from(cs)
} else if let Some(cstring) = fmt
.map(str::as_bytes)
.map(CString::new)
.map(|res| res.ok())
.and_then(|opt| opt);
let format: &CStr = if let Some(ref s) = fmt {
&s
.and_then(|res| res.ok())
{
Cow::from(cstring)
} else {
unsafe { CStr::from_bytes_with_nul_unchecked(b"%a, %d %b %Y %T %z\0") }
unsafe { CStr::from_bytes_with_nul_unchecked(DEFAULT_FMT.as_bytes()).into() }
};
let mut vec: [u8; 256] = [0; 256];
let ret = unsafe {
strftime(
vec.as_mut_ptr() as *mut _,
256,
format.as_ptr(),
&new_tm as *const _,
)
let ret = {
let _with_locale: Option<Result<Locale>> = if posix {
Some(
Locale::new(
libc::LC_TIME,
b"C\0".as_ptr() as *const i8,
std::ptr::null_mut(),
)
.chain_err_summary(|| "Could not set locale for datetime conversion")
.chain_err_kind(crate::error::ErrorKind::External),
)
} else {
None
};
unsafe {
strftime(
vec.as_mut_ptr() as *mut _,
256,
format.as_ptr(),
&new_tm as *const _,
)
}
};
String::from_utf8_lossy(&vec[0..ret]).into_owned()
}
fn tm_to_secs(tm: ::libc::tm) -> std::result::Result<i64, ()> {
fn tm_to_secs(tm: libc::tm) -> std::result::Result<i64, ()> {
let mut is_leap = false;
let mut year = tm.tm_year;
let mut month = tm.tm_mon;
@ -244,63 +271,58 @@ where
T: Into<Vec<u8>>,
{
let s = CString::new(s)?;
let mut new_tm: ::libc::tm = unsafe { std::mem::zeroed() };
for fmt in &[
&b"%a, %e %h %Y %H:%M:%S \0"[..],
&b"%e %h %Y %H:%M:%S \0"[..],
] {
unsafe {
let fmt = CStr::from_bytes_with_nul_unchecked(fmt);
let mut new_tm: libc::tm = unsafe { std::mem::zeroed() };
for fmt in &[RFC822_FMT_WITH_TIME, RFC822_FMT] {
let fmt = unsafe { CStr::from_bytes_with_nul_unchecked(fmt.as_bytes()) };
let ret = {
let _with_locale = Locale::new(
libc::LC_TIME,
b"C\0".as_ptr() as *const i8,
std::ptr::null_mut(),
)
.chain_err_summary(|| "Could not set locale for datetime conversion")
.chain_err_kind(crate::error::ErrorKind::External)?;
unsafe { strptime(s.as_ptr(), fmt.as_ptr(), &mut new_tm as *mut _) }
};
let ret = {
let _with_locale = Locale::new(
::libc::LC_TIME,
b"C\0".as_ptr() as *const i8,
std::ptr::null_mut(),
)
.chain_err_summary(|| "Could not set locale for datetime conversion")
.chain_err_kind(crate::error::ErrorKind::External)?;
strptime(s.as_ptr(), fmt.as_ptr(), &mut new_tm as *mut _)
};
if ret.is_null() {
continue;
}
let rest = CStr::from_ptr(ret);
let tm_gmtoff = if rest.to_bytes().len() > 4
&& rest.to_bytes().is_ascii()
&& rest.to_bytes()[1..5].iter().all(u8::is_ascii_digit)
{
let offset = std::str::from_utf8_unchecked(&rest.to_bytes()[0..5]);
if let (Ok(mut hr_offset), Ok(mut min_offset)) =
(offset[1..3].parse::<i64>(), offset[3..5].parse::<i64>())
{
if rest.to_bytes()[0] == b'-' {
hr_offset = -hr_offset;
min_offset = -min_offset;
}
hr_offset * 60 * 60 + min_offset * 60
} else {
0
}
} else {
let rest = if rest.to_bytes().starts_with(b"(") && rest.to_bytes().ends_with(b")") {
&rest.to_bytes()[1..rest.to_bytes().len() - 1]
} else {
rest.to_bytes()
};
if let Ok(idx) = TIMEZONE_ABBR.binary_search_by(|probe| probe.0.cmp(rest)) {
let (hr_offset, min_offset) = TIMEZONE_ABBR[idx].1;
(hr_offset as i64) * 60 * 60 + (min_offset as i64) * 60
} else {
0
}
};
return Ok(tm_to_secs(new_tm)
.map(|res| (res - tm_gmtoff) as u64)
.unwrap_or(0));
if ret.is_null() {
continue;
}
let rest = unsafe { CStr::from_ptr(ret) };
let tm_gmtoff = if rest.to_bytes().len() > 4
&& rest.to_bytes().is_ascii()
&& rest.to_bytes()[1..5].iter().all(u8::is_ascii_digit)
{
// safe since rest.to_bytes().is_ascii()
let offset = unsafe { std::str::from_utf8_unchecked(&rest.to_bytes()[0..5]) };
if let (Ok(mut hr_offset), Ok(mut min_offset)) =
(offset[1..3].parse::<i64>(), offset[3..5].parse::<i64>())
{
if rest.to_bytes()[0] == b'-' {
hr_offset = -hr_offset;
min_offset = -min_offset;
}
hr_offset * 60 * 60 + min_offset * 60
} else {
0
}
} else {
let rest = if rest.to_bytes().starts_with(b"(") && rest.to_bytes().ends_with(b")") {
&rest.to_bytes()[1..rest.to_bytes().len() - 1]
} else {
rest.to_bytes()
};
if let Ok(idx) = TIMEZONE_ABBR.binary_search_by(|probe| probe.0.cmp(rest)) {
let (hr_offset, min_offset) = TIMEZONE_ABBR[idx].1;
(hr_offset as i64) * 60 * 60 + (min_offset as i64) * 60
} else {
0
}
};
return Ok(tm_to_secs(new_tm)
.map(|res| (res - tm_gmtoff) as u64)
.unwrap_or(0));
}
Ok(0)
}
@ -310,59 +332,58 @@ where
T: Into<Vec<u8>>,
{
let s = CString::new(s)?;
let mut new_tm: ::libc::tm = unsafe { std::mem::zeroed() };
for fmt in &[&b"%Y-%m-%dT%H:%M:%S\0"[..], &b"%Y-%m-%d\0"[..]] {
unsafe {
let fmt = CStr::from_bytes_with_nul_unchecked(fmt);
let ret = {
let _with_locale = Locale::new(
::libc::LC_TIME,
b"C\0".as_ptr() as *const i8,
std::ptr::null_mut(),
)
.chain_err_summary(|| "Could not set locale for datetime conversion")
.chain_err_kind(crate::error::ErrorKind::External)?;
strptime(s.as_ptr(), fmt.as_ptr(), &mut new_tm as *mut _)
};
if ret.is_null() {
continue;
}
let rest = CStr::from_ptr(ret);
let tm_gmtoff = if rest.to_bytes().len() > 4
&& rest.to_bytes().is_ascii()
&& rest.to_bytes()[1..3].iter().all(u8::is_ascii_digit)
&& rest.to_bytes()[4..6].iter().all(u8::is_ascii_digit)
{
let offset = std::str::from_utf8_unchecked(&rest.to_bytes()[0..6]);
if let (Ok(mut hr_offset), Ok(mut min_offset)) =
(offset[1..3].parse::<i64>(), offset[4..6].parse::<i64>())
{
if rest.to_bytes()[0] == b'-' {
hr_offset = -hr_offset;
min_offset = -min_offset;
}
hr_offset * 60 * 60 + min_offset * 60
} else {
0
}
} else {
let rest = if rest.to_bytes().starts_with(b"(") && rest.to_bytes().ends_with(b")") {
&rest.to_bytes()[1..rest.to_bytes().len() - 1]
} else {
rest.to_bytes()
};
if let Ok(idx) = TIMEZONE_ABBR.binary_search_by(|probe| probe.0.cmp(rest)) {
let (hr_offset, min_offset) = debug!(TIMEZONE_ABBR[idx]).1;
(hr_offset as i64) * 60 * 60 + (min_offset as i64) * 60
} else {
0
}
};
return Ok(tm_to_secs(new_tm)
.map(|res| (res - tm_gmtoff) as u64)
.unwrap_or(0));
let mut new_tm: libc::tm = unsafe { std::mem::zeroed() };
for fmt in &[RFC3339_FMT_WITH_TIME, RFC3339_FMT] {
let fmt = unsafe { CStr::from_bytes_with_nul_unchecked(fmt.as_bytes()) };
let ret = {
let _with_locale = Locale::new(
libc::LC_TIME,
b"C\0".as_ptr() as *const i8,
std::ptr::null_mut(),
)
.chain_err_summary(|| "Could not set locale for datetime conversion")
.chain_err_kind(crate::error::ErrorKind::External)?;
unsafe { strptime(s.as_ptr(), fmt.as_ptr(), &mut new_tm as *mut _) }
};
if ret.is_null() {
continue;
}
let rest = unsafe { CStr::from_ptr(ret) };
let tm_gmtoff = if rest.to_bytes().len() > 4
&& rest.to_bytes().is_ascii()
&& rest.to_bytes()[1..3].iter().all(u8::is_ascii_digit)
&& rest.to_bytes()[4..6].iter().all(u8::is_ascii_digit)
{
// safe since rest.to_bytes().is_ascii()
let offset = unsafe { std::str::from_utf8_unchecked(&rest.to_bytes()[0..6]) };
if let (Ok(mut hr_offset), Ok(mut min_offset)) =
(offset[1..3].parse::<i64>(), offset[4..6].parse::<i64>())
{
if rest.to_bytes()[0] == b'-' {
hr_offset = -hr_offset;
min_offset = -min_offset;
}
hr_offset * 60 * 60 + min_offset * 60
} else {
0
}
} else {
let rest = if rest.to_bytes().starts_with(b"(") && rest.to_bytes().ends_with(b")") {
&rest.to_bytes()[1..rest.to_bytes().len() - 1]
} else {
rest.to_bytes()
};
if let Ok(idx) = TIMEZONE_ABBR.binary_search_by(|probe| probe.0.cmp(rest)) {
let (hr_offset, min_offset) = TIMEZONE_ABBR[idx].1;
(hr_offset as i64) * 60 * 60 + (min_offset as i64) * 60
} else {
0
}
};
return Ok(tm_to_secs(new_tm)
.map(|res| (res - tm_gmtoff) as u64)
.unwrap_or(0));
}
Ok(0)
}
@ -372,8 +393,12 @@ pub fn timestamp_from_string<T>(s: T, fmt: &str) -> Result<Option<UnixTimestamp>
where
T: Into<Vec<u8>>,
{
let mut new_tm: ::libc::tm = unsafe { std::mem::zeroed() };
let fmt = CString::new(fmt)?;
let mut new_tm: libc::tm = unsafe { std::mem::zeroed() };
let fmt: Cow<'_, CStr> = if let Ok(cs) = CStr::from_bytes_with_nul(fmt.as_bytes()) {
Cow::from(cs)
} else {
Cow::from(CString::new(fmt.as_bytes())?)
};
unsafe {
let ret = strptime(
CString::new(s)?.as_ptr(),
@ -389,8 +414,8 @@ where
pub fn now() -> UnixTimestamp {
use std::mem::MaybeUninit;
let mut tv = MaybeUninit::<::libc::timeval>::uninit();
let mut tz = MaybeUninit::<::libc::timezone>::uninit();
let mut tv = MaybeUninit::<libc::timeval>::uninit();
let mut tz = MaybeUninit::<libc::timezone>::uninit();
unsafe {
let ret = gettimeofday(tv.as_mut_ptr(), tz.as_mut_ptr());
if ret == -1 {
@ -401,12 +426,12 @@ pub fn now() -> UnixTimestamp {
}
#[test]
fn test_timestamp() {
timestamp_to_string(0, None);
fn test_datetime_timestamp() {
timestamp_to_string(0, None, false);
}
#[test]
fn test_rfcs() {
fn test_datetime_rfcs() {
if unsafe { libc::setlocale(libc::LC_ALL, b"\0".as_ptr() as _) }.is_null() {
println!("Unable to set locale.");
}
@ -420,7 +445,7 @@ fn test_rfcs() {
/*
macro_rules! mkt {
($year:literal, $month:literal, $day:literal, $hour:literal, $minute:literal, $second:literal) => {
::libc::tm {
libc::tm {
tm_sec: $second,
tm_min: $minute,
tm_hour: $hour,

View File

@ -53,7 +53,7 @@ impl Default for Draft {
let mut headers = HeaderMap::default();
headers.insert(
HeaderName::new_unchecked("Date"),
crate::datetime::timestamp_to_string(crate::datetime::now(), None),
crate::datetime::timestamp_to_string(crate::datetime::now(), None, true),
);
headers.insert(HeaderName::new_unchecked("From"), "".into());
headers.insert(HeaderName::new_unchecked("To"), "".into());

View File

@ -41,7 +41,11 @@ pub mod dbg {
() => {
eprint!(
"[{}][{:?}] {}:{}_{}: ",
crate::datetime::timestamp_to_string(crate::datetime::now(), Some("%Y-%m-%d %T")),
crate::datetime::timestamp_to_string(
crate::datetime::now(),
Some("%Y-%m-%d %T"),
false
),
std::thread::current()
.name()
.map(std::string::ToString::to_string)

View File

@ -85,7 +85,8 @@ pub fn log<S: AsRef<str>>(val: S, level: LoggingLevel) {
if level <= b.level {
b.dest
.write_all(
crate::datetime::timestamp_to_string(crate::datetime::now(), None).as_bytes(),
crate::datetime::timestamp_to_string(crate::datetime::now(), None, false)
.as_bytes(),
)
.unwrap();
b.dest.write_all(b" [").unwrap();

View File

@ -66,6 +66,22 @@ pub enum PageMovement {
End,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScrollContext {
shown_lines: usize,
total_lines: usize,
has_more_lines: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum ScrollUpdate {
End(ComponentId),
Update {
id: ComponentId,
context: ScrollContext,
},
}
/// Types implementing this Trait can draw on the terminal and receive events.
/// If a type wants to skip drawing if it has not changed anything, it can hold some flag in its
/// fields (eg self.dirty = false) and act upon that in their `draw` implementation.

View File

@ -414,6 +414,27 @@ impl ContactList {
let top_idx = page_no * rows;
if self.length >= rows {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::Update {
id: self.id,
context: ScrollContext {
shown_lines: top_idx + rows,
total_lines: self.length,
has_more_lines: false,
},
},
)));
} else {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
}
/* If cursor position has changed, remove the highlight from the previous position and
* apply it in the new one. */
if self.cursor_pos != self.new_cursor_pos && prev_page_no == page_no {
@ -621,6 +642,11 @@ impl Component for ContactList {
self.mode = ViewMode::View(manager.id());
self.view = Some(manager);
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
return true;
}
@ -639,6 +665,11 @@ impl Component for ContactList {
self.mode = ViewMode::View(manager.id());
self.view = Some(manager);
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
return true;
}

View File

@ -314,10 +314,21 @@ impl Composer {
}
}
ret.draft.body = {
let mut ret = format!(
"On {} {} wrote:\n",
envelope.date_as_str(),
envelope.from()[0],
let mut ret = attribution_string(
account_settings!(
context[ret.account_hash]
.composing
.attribution_format_string
)
.as_ref()
.map(|s| s.as_str()),
envelope.from().get(0),
envelope.date(),
*account_settings!(
context[ret.account_hash]
.composing
.attribution_use_posix_locale
),
);
for l in reply_body.lines() {
ret.push('>');
@ -925,6 +936,9 @@ impl Component for Composer {
}
fn process_event(&mut self, mut event: &mut UIEvent, context: &mut Context) -> bool {
if let UIEvent::VisibilityChange(_) = event {
self.pager.process_event(event, context);
}
let shortcuts = self.get_shortcuts(context);
match (&mut self.mode, &mut event) {
(ViewMode::Edit, _) => {
@ -2207,3 +2221,36 @@ pub fn send_draft_async(
ret
}))
}
/* Sender details
* %+f the sender's name and email address.
* %+n the sender's name (or email address, if no name is included).
* %+a the sender's email address.
*/
fn attribution_string(
fmt: Option<&str>,
sender: Option<&Address>,
date: UnixTimestamp,
posix: bool,
) -> String {
let fmt = fmt.unwrap_or("On %a, %0e %b %Y %H:%M, %+f wrote:%n");
let fmt = fmt.replace(
"%+f",
&sender
.map(|addr| addr.to_string())
.unwrap_or_else(|| "\"\"".to_string()),
);
let fmt = fmt.replace(
"%+n",
&sender
.map(|addr| addr.get_display_name().unwrap_or_else(|| addr.get_email()))
.unwrap_or_else(|| "\"\"".to_string()),
);
let fmt = fmt.replace(
"%+a",
&sender
.map(|addr| addr.get_email())
.unwrap_or_else(|| "\"\"".to_string()),
);
melib::datetime::timestamp_to_string(date, Some(fmt.as_str()), posix)
}

View File

@ -703,6 +703,8 @@ impl Component for Listing {
fallback = *cur;
}
if self.component.coordinates() == (*account_hash, *mailbox_hash) {
self.component
.process_event(&mut UIEvent::VisibilityChange(false), context);
self.component.set_coordinates((
self.accounts[self.cursor_pos.0].hash,
self.accounts[self.cursor_pos.0].entries[fallback].3,
@ -730,6 +732,8 @@ impl Component for Listing {
let account_hash = self.accounts[self.cursor_pos.0].hash;
self.cursor_pos.1 = MenuEntryCursor::Mailbox(*idx);
self.status = None;
self.component
.process_event(&mut UIEvent::VisibilityChange(false), context);
self.component
.set_coordinates((account_hash, *mailbox_hash));
self.menu_content.empty();
@ -1148,6 +1152,11 @@ impl Component for Listing {
match *event {
UIEvent::Input(Key::Right) => {
self.focus = ListingFocus::Mailbox;
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
self.ratio = 90;
self.set_dirty(true);
return true;
@ -1161,6 +1170,11 @@ impl Component for Listing {
self.set_dirty(true);
self.focus = ListingFocus::Mailbox;
self.ratio = 90;
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
return true;
}
UIEvent::Input(ref k)
@ -1171,6 +1185,11 @@ impl Component for Listing {
self.focus = ListingFocus::Mailbox;
self.ratio = 90;
self.set_dirty(true);
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::UpdateStatus(
@ -1650,6 +1669,20 @@ impl Listing {
),
);
if self.show_menu_scrollbar == ShowMenuScrollbar::True && total_height > rows {
if self.focus == ListingFocus::Menu {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::Update {
id: self.id,
context: ScrollContext {
shown_lines: skip_offset + rows,
total_lines: total_height,
has_more_lines: false,
},
},
)));
}
ScrollBar::default().set_show_arrows(true).draw(
grid,
(
@ -1664,6 +1697,12 @@ impl Listing {
/* length */
total_height,
);
} else if total_height < rows {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
}
context.dirty_areas.push_back(area);
@ -1964,6 +2003,8 @@ impl Listing {
if let Some((_, _, _, mailbox_hash)) =
self.accounts[self.cursor_pos.0].entries.get(idx)
{
self.component
.process_event(&mut UIEvent::VisibilityChange(false), context);
self.component
.set_coordinates((account_hash, *mailbox_hash));
/* Check if per-mailbox configuration overrides general configuration */

View File

@ -1627,6 +1627,8 @@ impl Component for CompactListing {
) =>
{
self.unfocused = false;
self.view
.process_event(&mut UIEvent::VisibilityChange(false), context);
self.dirty = true;
/* If self.row_updates is not empty and we exit a thread, the row_update events
* will be performed but the list will not be drawn. So force a draw in any case.

View File

@ -1002,6 +1002,7 @@ impl ConversationsListing {
.as_ref()
.map(String::as_str)
.or(Some("%Y-%m-%d %T")),
false,
),
}
}
@ -1490,6 +1491,8 @@ impl Component for ConversationsListing {
) =>
{
self.unfocused = false;
self.view
.process_event(&mut UIEvent::VisibilityChange(false), context);
self.dirty = true;
/* If self.row_updates is not empty and we exit a thread, the row_update events
* will be performed but the list will not be drawn. So force a draw in any case.

View File

@ -1010,7 +1010,7 @@ impl PlainListing {
n if n < 4 * 24 * 60 * 60 => {
format!("{} days ago{}", n / (24 * 60 * 60), " ".repeat(9))
}
_ => melib::datetime::timestamp_to_string(envelope.datetime(), None),
_ => melib::datetime::timestamp_to_string(envelope.datetime(), None, false),
}
}
@ -1146,6 +1146,8 @@ impl Component for PlainListing {
&& shortcut!(k == shortcuts[PlainListing::DESCRIPTION]["exit_thread"]) =>
{
self.unfocused = false;
self.view
.process_event(&mut UIEvent::VisibilityChange(false), context);
self.dirty = true;
/* If self.row_updates is not empty and we exit a thread, the row_update events
* will be performed but the list will not be drawn. So force a draw in any case.

View File

@ -1224,6 +1224,9 @@ impl Component for ThreadListing {
}
UIEvent::Input(Key::Char('i')) if self.unfocused => {
self.unfocused = false;
if let Some(ref mut s) = self.view {
s.process_event(&mut UIEvent::VisibilityChange(false), context);
}
self.dirty = true;
self.view = None;
return true;

View File

@ -64,6 +64,7 @@ pub struct StatusBar {
progress_spinner: ProgressSpinner,
in_progress_jobs: HashSet<JobId>,
done_jobs: HashSet<JobId>,
scroll_contexts: IndexMap<ComponentId, ScrollContext>,
auto_complete: AutoComplete,
cmd_history: Vec<String>,
@ -104,6 +105,7 @@ impl StatusBar {
progress_spinner,
in_progress_jobs: HashSet::default(),
done_jobs: HashSet::default(),
scroll_contexts: IndexMap::default(),
cmd_history: crate::command::history::old_cmd_history(),
}
}
@ -140,6 +142,33 @@ impl StatusBar {
grid[(x, y)].set_attrs(attribute.attrs | Attr::BOLD);
}
}
if let Some((
_,
ScrollContext {
shown_lines,
total_lines,
has_more_lines,
},
)) = self.scroll_contexts.last()
{
let s = format!(
"| {shown_percentage}% {line_desc}{shown_lines}/{total_lines}{has_more_lines}",
line_desc = if grid.ascii_drawing { "lines:" } else { "" },
shown_percentage = (*shown_lines as f32 / (*total_lines as f32) * 100.0) as usize,
shown_lines = *shown_lines,
total_lines = *total_lines,
has_more_lines = if *has_more_lines { "(+)" } else { "" }
);
write_string_to_grid(
&s,
grid,
attribute.fg,
attribute.bg,
attribute.attrs,
((x + 1, y), bottom_right!(area)),
None,
);
}
let (mut x, y) = bottom_right!(area);
if self.progress_spinner.is_active() {
@ -706,6 +735,21 @@ impl Component for StatusBar {
self.progress_spinner.set_dirty(true);
self.in_progress_jobs.insert(*job_id);
}
UIEvent::StatusEvent(StatusEvent::ScrollUpdate(ScrollUpdate::End(component_id))) => {
if self.scroll_contexts.remove(component_id).is_some() {
self.dirty = true;
}
return true;
}
UIEvent::StatusEvent(StatusEvent::ScrollUpdate(ScrollUpdate::Update {
id,
context,
})) => {
if self.scroll_contexts.insert(*id, *context) != Some(*context) {
self.dirty = true;
}
return true;
}
UIEvent::Timer(_) => {
if self.progress_spinner.process_event(event, context) {
return true;
@ -975,6 +1019,21 @@ impl Component for Tabbed {
),
);
if height.wrapping_div(rows + 1) > 0 || width.wrapping_div(cols + 1) > 0 {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::Update {
id: self.id,
context: ScrollContext {
shown_lines: std::cmp::min(
(height).saturating_sub(rows + 1),
self.help_screen_cursor.1,
) + rows,
total_lines: height,
has_more_lines: false,
},
},
)));
ScrollBar::default().set_show_arrows(true).draw(
grid,
(
@ -989,6 +1048,12 @@ impl Component for Tabbed {
/* length */
height,
);
} else {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
}
self.dirty = false;
return;
@ -1192,6 +1257,21 @@ impl Component for Tabbed {
),
);
if height.wrapping_div(rows + 1) > 0 || width.wrapping_div(cols + 1) > 0 {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::Update {
id: self.id,
context: ScrollContext {
shown_lines: std::cmp::min(
(height).saturating_sub(rows),
self.help_screen_cursor.1,
) + rows,
total_lines: height,
has_more_lines: false,
},
},
)));
ScrollBar::default().set_show_arrows(true).draw(
grid,
(
@ -1206,6 +1286,12 @@ impl Component for Tabbed {
/* length */
height,
);
} else {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
}
}
self.dirty = false;
@ -1250,6 +1336,11 @@ impl Component for Tabbed {
if self.show_shortcuts {
/* children below the shortcut overlay must be redrawn */
self.set_dirty(true);
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
}
self.show_shortcuts = !self.show_shortcuts;
self.dirty = true;
@ -1281,6 +1372,8 @@ impl Component for Tabbed {
return true;
}
if let Some(c_idx) = self.children.iter().position(|x| x.id() == *id) {
self.children[c_idx]
.process_event(&mut UIEvent::VisibilityChange(false), context);
self.children.remove(c_idx);
self.cursor_pos = 0;
self.set_dirty(true);

View File

@ -533,28 +533,30 @@ impl Component for Pager {
}
if (rows < height) || self.search.is_some() {
const RESULTS_STR: &str = "Results for ";
let shown_percentage =
((self.cursor.1 + rows) as f32 / (height as f32) * 100.0) as usize;
let shown_lines = self.cursor.1 + rows;
let total_lines = height;
let scrolling = if rows < height {
format!(
"{shown_percentage}% {line_desc}{shown_lines}/{total_lines}{has_more_lines}",
line_desc = if grid.ascii_drawing { "lines:" } else { "" },
shown_percentage = shown_percentage,
shown_lines = shown_lines,
total_lines = total_lines,
has_more_lines = if self.line_breaker.is_finished() {
""
} else {
"(+)"
}
)
if rows < height {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::Update {
id: self.id,
context: ScrollContext {
shown_lines,
total_lines,
has_more_lines: !self.line_breaker.is_finished(),
},
},
)));
} else {
String::new()
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
};
let search_results = if let Some(ref search) = self.search {
format!(
if let Some(ref search) = self.search {
let status_message = format!(
"{results_str}{search_pattern}: {current_pos}/{total_results}{has_more_lines}",
results_str = RESULTS_STR,
search_pattern = &search.pattern,
@ -569,39 +571,29 @@ impl Component for Pager {
} else {
"(+)"
}
)
} else {
String::new()
};
let status_message = format!(
"{search_results}{divider}{scrolling}",
search_results = search_results,
divider = if self.search.is_some() { " " } else { "" },
scrolling = scrolling,
);
let mut attribute = crate::conf::value(context, "status.bar");
if !context.settings.terminal.use_color() {
attribute.attrs |= Attr::REVERSE;
}
let (_, y) = write_string_to_grid(
&status_message,
grid,
attribute.fg,
attribute.bg,
attribute.attrs,
(
set_y(upper_left!(area), get_y(bottom_right!(area))),
bottom_right!(area),
),
None,
);
/* set search pattern to italics */
if let Some(ref search) = self.search {
);
let mut attribute = crate::conf::value(context, "status.bar");
if !context.settings.terminal.use_color() {
attribute.attrs |= Attr::REVERSE;
}
let (_, y) = write_string_to_grid(
&status_message,
grid,
attribute.fg,
attribute.bg,
attribute.attrs,
(
set_y(upper_left!(area), get_y(bottom_right!(area))),
bottom_right!(area),
),
None,
);
/* set search pattern to italics */
let start_x = get_x(upper_left!(area)) + RESULTS_STR.len();
for c in grid.row_iter(start_x..(start_x + search.pattern.grapheme_width()), y) {
grid[c].set_attrs(attribute.attrs | Attr::ITALICS);
}
}
};
}
context.dirty_areas.push_back(area);
}
@ -746,6 +738,13 @@ impl Component for Pager {
self.initialised = false;
self.dirty = true;
}
UIEvent::VisibilityChange(false) => {
context
.replies
.push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate(
ScrollUpdate::End(self.id),
)));
}
_ => {}
}
false

View File

@ -1011,6 +1011,14 @@ impl Account {
Some(crate::types::NotificationType::Error(err.kind)),
));
}
RefreshEventKind::MailboxCreate(_new_mailbox) => {}
RefreshEventKind::MailboxDelete(_mailbox_hash) => {}
RefreshEventKind::MailboxRename {
old_mailbox_hash: _,
new_mailbox: _,
} => {}
RefreshEventKind::MailboxSubscribe(_mailbox_hash) => {}
RefreshEventKind::MailboxUnsubscribe(_mailbox_hash) => {}
}
}
None

View File

@ -58,6 +58,20 @@ pub struct ComposingSettings {
/// Default: true
#[serde(default = "true_val")]
pub store_sent_mail: bool,
/// The attribution line appears above the quoted reply text.
/// The format specifiers for the replied address are:
/// - `%+f` — the sender's name and email address.
/// - `%+n` — the sender's name (or email address, if no name is included).
/// - `%+a` — the sender's email address.
/// The format string is passed to strftime(3) with the replied envelope's date.
/// Default: "On %a, %0e %b %Y %H:%M, %+f wrote:%n"
#[serde(default = "none")]
pub attribution_format_string: Option<String>,
/// Whether the strftime call for the attribution string uses the POSIX locale instead of
/// the user's active locale
/// Default: true
#[serde(default = "true_val")]
pub attribution_use_posix_locale: bool,
}
impl Default for ComposingSettings {
@ -70,6 +84,8 @@ impl Default for ComposingSettings {
insert_user_agent: true,
default_header_values: HashMap::default(),
store_sent_mail: true,
attribution_format_string: None,
attribution_use_posix_locale: true,
}
}
}

View File

@ -266,6 +266,20 @@ pub struct ComposingSettingsOverride {
#[doc = " Default: true"]
#[serde(default)]
pub store_sent_mail: Option<bool>,
#[doc = " The attribution line appears above the quoted reply text."]
#[doc = " The format specifiers for the replied address are:"]
#[doc = " - `%+f` — the sender's name and email address."]
#[doc = " - `%+n` — the sender's name (or email address, if no name is included)."]
#[doc = " - `%+a` — the sender's email address."]
#[doc = " The format string is passed to strftime(3) with the replied envelope's date."]
#[doc = " Default: \"On %a, %0e %b %Y %H:%M, %+f wrote:%n\""]
#[serde(default)]
pub attribution_format_string: Option<Option<String>>,
#[doc = " Whether the strftime call for the attribution string uses the POSIX locale instead of"]
#[doc = " the user's active locale"]
#[doc = " Default: true"]
#[serde(default)]
pub attribution_use_posix_locale: Option<bool>,
}
impl Default for ComposingSettingsOverride {
fn default() -> Self {
@ -277,6 +291,8 @@ impl Default for ComposingSettingsOverride {
insert_user_agent: None,
default_header_values: None,
store_sent_mail: None,
attribution_format_string: None,
attribution_use_posix_locale: None,
}
}
}

View File

@ -683,11 +683,9 @@ impl State {
}
}
let ((x, mut y), box_displ_area_bottom_right) = box_displ_area;
for line in msg_lines
.into_iter()
.chain(Some(String::new()))
.chain(Some(melib::datetime::timestamp_to_string(*timestamp, None)))
{
for line in msg_lines.into_iter().chain(Some(String::new())).chain(Some(
melib::datetime::timestamp_to_string(*timestamp, None, false),
)) {
write_string_to_grid(
&line,
&mut self.overlay_grid,

View File

@ -38,7 +38,7 @@ pub use self::helpers::*;
use super::command::Action;
use super::jobs::{JobExecutor, JobId};
use super::terminal::*;
use crate::components::{Component, ComponentId};
use crate::components::{Component, ComponentId, ScrollUpdate};
use std::sync::Arc;
use melib::backends::{AccountHash, BackendEvent, MailboxHash};
@ -57,6 +57,7 @@ pub enum StatusEvent {
JobFinished(JobId),
JobCanceled(JobId),
SetMouse(bool),
ScrollUpdate(ScrollUpdate),
}
/// `ThreadEvent` encapsulates all of the possible values we need to transfer between our threads
@ -149,6 +150,7 @@ pub enum UIEvent {
ConfigReload {
old_settings: crate::conf::Settings,
},
VisibilityChange(bool),
}
pub struct CallbackFn(pub Box<dyn FnOnce(&mut crate::Context) -> () + Send + 'static>);