@@ -919,6 +919,21 @@ fn format_reset(now: u64, resets_at: u64) -> String {
919919 }
920920}
921921
922+ /// `16h 4m` today, `1d 12h · 19 Aug` when the rollover lands on another day.
923+ ///
924+ /// The countdown stays the primary answer — it is the only "how long do I wait"
925+ /// figure a window that missed the donut gets — and the date rides behind it for
926+ /// the windows far enough out that `1d 12h` hides which working day that is. A
927+ /// reset already past reads `now`, and a date beside that word would contradict
928+ /// it, so that case stays bare.
929+ fn format_reset_dated ( now : u64 , resets_at : u64 , offset : i64 ) -> String {
930+ let relative = format_reset ( now, resets_at) ;
931+ if resets_at <= now || local_day ( resets_at, offset) == local_day ( now, offset) {
932+ return relative;
933+ }
934+ format ! ( "{relative} · {}" , format_date( resets_at, offset) )
935+ }
936+
922937/// The local UTC offset in seconds, read once per refresh.
923938///
924939/// There is no time zone crate here and `std` has no local-time API, so the
@@ -968,6 +983,22 @@ fn format_clock(epoch: u64, offset: i64) -> String {
968983 format ! ( "{:02}:{:02} {day}" , seconds / 3_600 , ( seconds % 3_600 ) / 60 )
969984}
970985
986+ /// `14:55 Thu` when the window rolls over today, `14:55 Thu 21 Aug` when it
987+ /// does not.
988+ ///
989+ /// The weekday alone is unambiguous inside a seven-day window but it is not
990+ /// legible: `Thu` six days out is a calendar lookup the reader has to do in
991+ /// their head, and the weekly window is the one that lands that far away.
992+ /// Today's reset stays short, so the date's presence is itself the signal that
993+ /// the rollover is not today.
994+ fn format_clock_dated ( now : u64 , epoch : u64 , offset : i64 ) -> String {
995+ let clock = format_clock ( epoch, offset) ;
996+ if clock. is_empty ( ) || local_day ( epoch, offset) == local_day ( now, offset) {
997+ return clock;
998+ }
999+ format ! ( "{clock} {}" , format_date( epoch, offset) )
1000+ }
1001+
9711002/// What a card says when a date is not knowable. Written out rather than left
9721003/// blank: a missing row reads as an oversight, `unknown` reads as an answer.
9731004const UNKNOWN : & str = "unknown" ;
@@ -990,6 +1021,14 @@ fn format_date(epoch: u64, offset: i64) -> String {
9901021 format ! ( "{day} {}" , MONTHS [ ( month - 1 ) as usize ] )
9911022}
9921023
1024+ /// The local calendar day an instant falls on, as a day number.
1025+ ///
1026+ /// The unit every "is this today?" question on this card is asked in: elapsed
1027+ /// seconds answer a different question and get 23:59 → 00:01 wrong.
1028+ fn local_day ( epoch : u64 , offset : i64 ) -> i64 {
1029+ ( epoch as i64 + offset) . div_euclid ( 86_400 )
1030+ }
1031+
9931032/// Howard Hinnant's civil-from-days, the inverse of the days-from-civil in
9941033/// [`parse_rfc3339_epoch`]. Returns `(year, month 1..=12, day 1..=31)`.
9951034fn civil_from_days ( days : i64 ) -> ( i64 , i64 , i64 ) {
@@ -1026,8 +1065,7 @@ fn format_renewal(now: u64, renews_at: Option<u64>, offset: i64) -> String {
10261065 }
10271066 // Counted in local calendar days, not in elapsed seconds: "tomorrow" means
10281067 // the next day on the wall, and a renewal 20 hours away can be either.
1029- let day_of = |epoch : u64 | ( epoch as i64 + offset) . div_euclid ( 86_400 ) ;
1030- let when = match day_of ( at) - day_of ( now) {
1068+ let when = match local_day ( at, offset) - local_day ( now, offset) {
10311069 d if d <= 0 => "today" . to_string ( ) ,
10321070 1 => "tomorrow" . to_string ( ) ,
10331071 d => format ! ( "in {d}d" ) ,
@@ -1204,6 +1242,32 @@ fn enabled_providers(cfg: &Config) -> Vec<Box<dyn Provider>> {
12041242 chosen
12051243}
12061244
1245+ /// The blank columns between two provider cards.
1246+ ///
1247+ /// Nothing draws a divider — the pane is transparent and a rule between the
1248+ /// cards would compete with the one each card already draws above its facts —
1249+ /// so the whitespace is the only thing separating them, and adjacent cards read
1250+ /// as one wide table of rows rather than two independent answers.
1251+ const CARD_GAP : u16 = 3 ;
1252+
1253+ /// The width a card needs before it can spare the full gutter: the longest row
1254+ /// it draws, `label + bar + percentage + a dated countdown`, plus the column
1255+ /// `draw_card` keeps clear on the right.
1256+ const CARD_COMFORTABLE : u16 = 44 ;
1257+
1258+ /// The gutter, but never at the expense of a card that is already cutting its
1259+ /// own rows. Two providers on the 96-column popup get the full gap; a pane
1260+ /// squeezed narrower spends its columns on content instead.
1261+ fn card_gap ( width : u16 , cards : usize ) -> u16 {
1262+ let cards = cards. max ( 1 ) as u16 ;
1263+ let each = width. saturating_sub ( CARD_GAP * ( cards - 1 ) ) / cards;
1264+ if each >= CARD_COMFORTABLE {
1265+ CARD_GAP
1266+ } else {
1267+ 1
1268+ }
1269+ }
1270+
12071271fn draw ( f : & mut Frame , app : & App ) {
12081272 let rows = Layout :: vertical ( [ Constraint :: Min ( 1 ) , Constraint :: Length ( 1 ) ] ) . split ( f. area ( ) ) ;
12091273 // Herdr frames and titles the popup pane already, so this draws no outer
@@ -1218,13 +1282,16 @@ fn draw(f: &mut Frame, app: &App) {
12181282 rows[ 0 ] ,
12191283 ) ;
12201284 } else {
1221- let share = 100 / app. slots . len ( ) as u16 ;
1285+ // `Fill` rather than `Percentage(100 / n)`, which throws away the
1286+ // remainder — three providers took 99% of the pane and left a column
1287+ // dark on the right.
12221288 let columns = Layout :: horizontal (
12231289 app. slots
12241290 . iter ( )
1225- . map ( |_| Constraint :: Percentage ( share ) )
1291+ . map ( |_| Constraint :: Fill ( 1 ) )
12261292 . collect :: < Vec < _ > > ( ) ,
12271293 )
1294+ . spacing ( card_gap ( rows[ 0 ] . width , app. slots . len ( ) ) )
12281295 . split ( rows[ 0 ] ) ;
12291296 // Built once, here, because the same list has to size the cards and
12301297 // fill them: a count taken from one list and a render taken from
@@ -1351,7 +1418,7 @@ fn draw_card(f: &mut Frame, app: &App, slot: &Slot, facts: &[Fact], area: Rect,
13511418 Slot :: Ready ( report) => {
13521419 let mut note = freshness ( app. now , report. measured_at ) ;
13531420 if let Some ( at) = hottest. and_then ( |window| window. resets_at ) {
1354- let clock = format_clock ( at, app. offset ) ;
1421+ let clock = format_clock_dated ( app . now , at, app. offset ) ;
13551422 if !clock. is_empty ( ) {
13561423 note = format ! ( "{note} · resets {clock}" ) ;
13571424 }
@@ -1409,7 +1476,10 @@ fn window_line(
14091476 let color = window_color ( theme, window, & app. cfg ) ;
14101477 let filled = ( ( window. used_percent / 100.0 ) * BAR_W as f64 ) . round ( ) as usize ;
14111478 let filled = filled. min ( BAR_W ) ;
1412- let label_w = width. saturating_sub ( BAR_W + 16 ) . clamp ( 6 , 8 ) ;
1479+ // The reservation is the bar plus " NNN%" plus the reset text, whose worst
1480+ // case is the dated form (`1d 12h · 19 Aug`) and its two leading spaces. On
1481+ // a card too narrow for all of it the label is what yields.
1482+ let label_w = width. saturating_sub ( BAR_W + 23 ) . clamp ( 6 , 8 ) ;
14131483 let mut spans = vec ! [
14141484 Span :: styled(
14151485 format!( " {:<label_w$}" , clip( & window. label, label_w) ) ,
@@ -1427,7 +1497,7 @@ fn window_line(
14271497 ] ;
14281498 if let Some ( at) = window. resets_at {
14291499 spans. push ( Span :: styled (
1430- format ! ( " {}" , format_reset ( app. now, at) ) ,
1500+ format ! ( " {}" , format_reset_dated ( app. now, at, app . offset ) ) ,
14311501 Style :: default ( ) . fg ( muted) ,
14321502 ) ) ;
14331503 }
@@ -1965,6 +2035,47 @@ mod tests {
19652035 assert_eq ! ( format_clock( 1_787_065_551 , -18 * 3_600 ) , "21:05 Mon" ) ;
19662036 }
19672037
2038+ #[ test]
2039+ fn a_reset_on_another_day_carries_its_date ( ) {
2040+ // 1787065551 is 2026-08-18T15:05:51Z, a Tuesday.
2041+ let now = 1_787_065_551 ;
2042+ // Later the same day: the weekday alone already says everything a date
2043+ // would, and the status line is one row on a half-pane-wide card.
2044+ assert_eq ! ( format_clock_dated( now, now + 3_600 , 0 ) , "16:05 Tue" ) ;
2045+ // Another day, so the date earns its columns.
2046+ assert_eq ! (
2047+ format_clock_dated( now, now + 86_400 + 3_600 , 0 ) ,
2048+ "16:05 Wed 19 Aug"
2049+ ) ;
2050+ // Whether two instants share a day is only answerable in the viewer's
2051+ // zone: this pair is one day in UTC and two at +7h.
2052+ let evening = 1_787_065_551 + 6 * 3_600 ; // 21:05 Tue UTC
2053+ assert_eq ! ( format_clock_dated( now, evening, 0 ) , "21:05 Tue" ) ;
2054+ assert_eq ! (
2055+ format_clock_dated( now, evening, 7 * 3_600 ) ,
2056+ "04:05 Wed 19 Aug"
2057+ ) ;
2058+ }
2059+
2060+ #[ test]
2061+ fn a_countdown_names_the_day_when_it_is_not_today ( ) {
2062+ // 1787000000 is 2026-08-17T20:53:20Z, a Monday.
2063+ let now = 1_787_000_000 ;
2064+ // 23:33 the same evening — the countdown is the whole answer.
2065+ assert_eq ! (
2066+ format_reset_dated( now, now + 2 * 3_600 + 40 * 60 , 0 ) ,
2067+ "2h 40m"
2068+ ) ;
2069+ // A day and a half out, where "1d 12h" hides which working day it is.
2070+ assert_eq ! (
2071+ format_reset_dated( now, now + 86_400 + 12 * 3_600 , 0 ) ,
2072+ "1d 12h · 19 Aug"
2073+ ) ;
2074+ // A reset already past reads `now`; a date beside that word would
2075+ // contradict it.
2076+ assert_eq ! ( format_reset_dated( now, now - 2 * 86_400 , 0 ) , "now" ) ;
2077+ }
2078+
19682079 #[ test]
19692080 fn a_renewal_is_written_as_a_date_and_a_countdown ( ) {
19702081 // 1787065551 is 2026-08-18T15:05:51Z.
@@ -2135,7 +2246,15 @@ mod tests {
21352246 screen. contains( "12%" ) ,
21362247 "the quieter window is still shown: {screen}"
21372248 ) ;
2138- assert ! ( screen. contains( "1d 12h" ) , "{screen}" ) ;
2249+ // The countdown, and — because that rollover is not today — the day it
2250+ // lands on.
2251+ assert ! ( screen. contains( "1d 12h · 19 Aug" ) , "{screen}" ) ;
2252+ // The other half of the rule: a window rolling over later the same
2253+ // evening stays bare, so the date's presence means "not today".
2254+ assert ! (
2255+ screen. contains( "2h 40m" ) && !screen. contains( "2h 40m · " ) ,
2256+ "a same-day rollover carries no date: {screen}"
2257+ ) ;
21392258 // The facts that give the percentage its context.
21402259 assert ! ( screen. contains( "19.1M · 97% cached" ) , "{screen}" ) ;
21412260 assert ! ( screen. contains( "162k / 258k" ) , "{screen}" ) ;
@@ -2158,8 +2277,44 @@ mod tests {
21582277 let screen = render ( & mut app, 60 , 26 ) ;
21592278 assert ! ( screen. contains( "as of 12m ago" ) , "{screen}" ) ;
21602279 assert ! (
2161- screen. contains( "resets 08:53 Wed" ) ,
2162- "the wall clock too: {screen}"
2280+ screen. contains( "resets 08:53 Wed 19 Aug" ) ,
2281+ "the wall clock and its date too: {screen}"
2282+ ) ;
2283+ }
2284+
2285+ #[ test]
2286+ fn cards_are_separated_by_a_gutter_that_yields_before_the_content_does ( ) {
2287+ // Two providers on the real 96-column popup: 46 columns each and the
2288+ // full gutter between them.
2289+ assert_eq ! ( card_gap( 96 , 2 ) , CARD_GAP ) ;
2290+ assert_eq ! ( card_gap( 96 , 1 ) , CARD_GAP ) ;
2291+ // Squeezed, the cards are already cutting their own rows, so the
2292+ // whitespace is the first thing to go rather than the last.
2293+ assert_eq ! ( card_gap( 96 , 3 ) , 1 ) ;
2294+ assert_eq ! ( card_gap( 60 , 2 ) , 1 ) ;
2295+ // Never a divide by zero, however the slots come out.
2296+ assert_eq ! ( card_gap( 0 , 0 ) , 1 ) ;
2297+ }
2298+
2299+ #[ test]
2300+ fn a_gutter_actually_separates_two_cards_on_screen ( ) {
2301+ // The pane paints no background and draws no divider, so the blank
2302+ // columns are the only thing that keeps two cards from reading as one
2303+ // wide table of rows.
2304+ let mut app = app_with ( vec ! [ codex_slot( ) , codex_slot( ) ] ) ;
2305+ let screen = render ( & mut app, 96 , 26 ) ;
2306+ let bar = screen
2307+ . lines ( )
2308+ . find ( |line| line. contains ( "weekly" ) )
2309+ . expect ( "a window row is drawn" ) ;
2310+ let first = bar. find ( "weekly" ) . expect ( "the left card" ) ;
2311+ let second = bar. rfind ( "weekly" ) . expect ( "the right card" ) ;
2312+ assert ! ( first < second, "two distinct cards: {bar:?}" ) ;
2313+ let between = & bar[ first..second] ;
2314+ let blanks = between. len ( ) - between. trim_end ( ) . len ( ) ;
2315+ assert ! (
2316+ blanks >= 4 ,
2317+ "at least the gutter plus each card's own margin: {bar:?}"
21632318 ) ;
21642319 }
21652320
0 commit comments