@@ -1116,6 +1116,148 @@ impl App {
11161116 }
11171117}
11181118
1119+ /// Column widths for the resolver table, in the order `ui.rs` renders them.
1120+ ///
1121+ /// Sized here rather than left to ratatui's constraint solver because which
1122+ /// column gives way first is a judgement call worth testing: an 80-column
1123+ /// terminal has to show a full IPv4 address and undamaged numbers, so the
1124+ /// spelled-out status goes before a single digit does (issue #33).
1125+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
1126+ pub struct TableLayout {
1127+ /// The one-glyph verdict at the left edge, so a scan down the margin
1128+ /// finds the failures.
1129+ pub mark : u16 ,
1130+ pub resolver : u16 ,
1131+ pub loc : u16 ,
1132+ pub ip : u16 ,
1133+ pub ping : u16 ,
1134+ pub ttl : u16 ,
1135+ pub exp : u16 ,
1136+ /// Zero when the spelled-out status had to go; the mark column still
1137+ /// carries the verdict.
1138+ pub status : u16 ,
1139+ pub answer : u16 ,
1140+ }
1141+
1142+ const COL_MARK : u16 = 1 ;
1143+ const COL_PING : u16 = 5 ; // four digits of milliseconds under a "Ping" header
1144+ const COL_TTL : u16 = 6 ; // a week in seconds, 604800
1145+ const COL_EXP : u16 = 3 ; // the coarse countdown's widest, "99d"
1146+ const COL_STATUS : u16 = 8 ; // "SERVFAIL" / "PAST TTL" / "UPSTREAM"
1147+ /// Fixed, not sized to the configured locations: site discovery replaces any
1148+ /// of them with a "→CODE" of its own, and `Site::code` caps at 7 characters.
1149+ const COL_LOC : u16 = 8 ;
1150+ const COL_IP_MIN : u16 = 15 ; // a full IPv4 literal — never cropped
1151+ const COL_IP_MAX : u16 = 39 ; // a full IPv6 literal
1152+ const COL_NAME_MIN : u16 = 10 ;
1153+ const COL_NAME_MAX : u16 = 20 ;
1154+ const COL_ANSWER_MIN : u16 = 16 ; // one full IPv4 literal, plus a space
1155+ /// What the Answer column is worth on a terminal wide enough for a map panel
1156+ /// too: a second address, or a long CNAME target. The table asks for this
1157+ /// much before the panel takes the rest, so freeing columns for narrow
1158+ /// terminals doesn't quietly hand the map a slice of the answers.
1159+ const COL_ANSWER_ROOMY : u16 = 27 ;
1160+ /// Everything whose width is fixed by the shape of its content.
1161+ const COL_FIXED : u16 = COL_MARK + COL_LOC + COL_PING + COL_TTL + COL_EXP ;
1162+ /// The table's own borders.
1163+ const COL_BORDERS : u16 = 2 ;
1164+
1165+ impl TableLayout {
1166+ /// Widths that fit `width` columns of terminal, for this resolver list.
1167+ pub fn fit ( width : u16 , resolvers : & [ Resolver ] ) -> Self {
1168+ let ( mut resolver, mut ip) = content_widths ( resolvers) ;
1169+ let mut status = COL_STATUS ;
1170+ let inner = width. saturating_sub ( COL_BORDERS ) ;
1171+ let need = |resolver, ip, status| COL_FIXED + resolver + ip + status + spacing ( status) ;
1172+
1173+ // Shed in the order that costs the least: first the spelled-out
1174+ // status (the mark glyph still names the verdict), then an IPv6
1175+ // resolver's full address, then the resolver name. The numbers, the
1176+ // 15 columns an IPv4 address needs, and the first answer are never
1177+ // touched — fitting those at 80 columns is the whole point.
1178+ if need ( resolver, ip, status) + COL_ANSWER_MIN > inner {
1179+ status = 0 ;
1180+ }
1181+ if need ( resolver, ip, status) + COL_ANSWER_MIN > inner {
1182+ ip = COL_IP_MIN ;
1183+ }
1184+ let over = ( need ( resolver, ip, status) + COL_ANSWER_MIN ) . saturating_sub ( inner) ;
1185+ resolver = resolver. saturating_sub ( over) . max ( COL_NAME_MIN ) ;
1186+
1187+ Self {
1188+ mark : COL_MARK ,
1189+ resolver,
1190+ loc : COL_LOC ,
1191+ ip,
1192+ ping : COL_PING ,
1193+ ttl : COL_TTL ,
1194+ exp : COL_EXP ,
1195+ status,
1196+ // Whatever is left: the answer is the column that grows on a
1197+ // wide terminal, since it's the only one with unbounded content.
1198+ answer : inner
1199+ . saturating_sub ( need ( resolver, ip, status) )
1200+ . max ( COL_ANSWER_MIN ) ,
1201+ }
1202+ }
1203+
1204+ /// Width `ui.rs` reserves for the table before handing what's left to the
1205+ /// map panel: every column at its full size, borders included.
1206+ pub fn reserved_width ( resolvers : & [ Resolver ] ) -> u16 {
1207+ let ( resolver, ip) = content_widths ( resolvers) ;
1208+ COL_FIXED
1209+ + resolver
1210+ + ip
1211+ + COL_STATUS
1212+ + COL_ANSWER_ROOMY
1213+ + spacing ( COL_STATUS )
1214+ + COL_BORDERS
1215+ }
1216+ }
1217+
1218+ /// One space between each pair of rendered columns; the status column drops
1219+ /// out entirely when it has no width, taking its gap with it.
1220+ fn spacing ( status : u16 ) -> u16 {
1221+ if status == 0 { 7 } else { 8 }
1222+ }
1223+
1224+ /// Name and IP widths the current list would like: enough for its widest
1225+ /// entry, clamped so one long custom name can't eat the answer.
1226+ fn content_widths ( resolvers : & [ Resolver ] ) -> ( u16 , u16 ) {
1227+ let widest = |f : fn ( & Resolver ) -> usize | -> u16 {
1228+ resolvers
1229+ . iter ( )
1230+ . map ( f)
1231+ . max ( )
1232+ . unwrap_or ( 0 )
1233+ . try_into ( )
1234+ . unwrap_or ( u16:: MAX )
1235+ } ;
1236+ (
1237+ widest ( |r| r. name . chars ( ) . count ( ) ) . clamp ( COL_NAME_MIN , COL_NAME_MAX ) ,
1238+ widest ( |r| r. ip . to_string ( ) . len ( ) ) . clamp ( COL_IP_MIN , COL_IP_MAX ) ,
1239+ )
1240+ }
1241+
1242+ /// Coarse countdown for the per-row Exp column: at most two digits and a
1243+ /// unit, `59s` → `1m` → `59m` → `1h` → `23h` → `1d` → `99d`.
1244+ ///
1245+ /// The table shows one of these per resolver, and a whole column of seconds
1246+ /// ticking out of unison is a distraction with no payoff: above a minute the
1247+ /// exact second never changes what you'd do (issue #33). Truncating rather
1248+ /// than rounding keeps the reading a lower bound — `1m` means at least a
1249+ /// minute is left. Past 99 days it saturates: DNS TTLs that long are a
1250+ /// configuration accident, and the precise figure is in the TTL column and
1251+ /// the advisory note anyway.
1252+ pub fn fmt_countdown ( total : u64 ) -> String {
1253+ match total {
1254+ s if s < 60 => format ! ( "{s}s" ) ,
1255+ s if s < 3_600 => format ! ( "{}m" , s / 60 ) ,
1256+ s if s < 86_400 => format ! ( "{}h" , s / 3_600 ) ,
1257+ s => format ! ( "{}d" , ( s / 86_400 ) . min( 99 ) ) ,
1258+ }
1259+ }
1260+
11191261/// Compact human duration for countdowns and TTLs: `42s`, `4m10s`, `23h59m`,
11201262/// `2d3h`. Two units max keeps it within a narrow table column.
11211263pub fn fmt_secs ( total : u64 ) -> String {
@@ -1402,6 +1544,97 @@ mod tests {
14021544 assert ! ( !app. globe. target( ) ) ;
14031545 }
14041546
1547+ #[ test]
1548+ fn countdown_is_two_digits_and_a_unit ( ) {
1549+ // Every step of the ladder the issue asked for.
1550+ for ( secs, want) in [
1551+ ( 1 , "1s" ) ,
1552+ ( 59 , "59s" ) ,
1553+ ( 60 , "1m" ) ,
1554+ ( 3_599 , "59m" ) ,
1555+ ( 3_600 , "1h" ) ,
1556+ ( 86_399 , "23h" ) ,
1557+ ( 86_400 , "1d" ) ,
1558+ ( 99 * 86_400 , "99d" ) ,
1559+ ] {
1560+ assert_eq ! ( fmt_countdown( secs) , want, "{secs}s" ) ;
1561+ }
1562+ // Truncating, not rounding: "1m" means at least a minute is left.
1563+ assert_eq ! ( fmt_countdown( 119 ) , "1m" ) ;
1564+ assert_eq ! ( fmt_countdown( 0 ) , "0s" ) ;
1565+ // Saturates rather than widening the column for an absurd TTL.
1566+ assert_eq ! ( fmt_countdown( 100 * 86_400 ) , "99d" ) ;
1567+ assert_eq ! ( fmt_countdown( u64 :: MAX ) , "99d" ) ;
1568+ // Never wider than three cells, whatever it's handed.
1569+ for secs in [ 0 , 59 , 60 , 3_599 , 3_600 , 86_399 , 86_400 , u64:: MAX ] {
1570+ assert ! ( fmt_countdown( secs) . len( ) <= 3 , "{secs}" ) ;
1571+ }
1572+ }
1573+
1574+ #[ test]
1575+ fn table_fits_every_field_at_eighty_columns ( ) {
1576+ let resolvers = resolvers:: defaults ( ) ;
1577+ let layout = TableLayout :: fit ( 80 , & resolvers) ;
1578+ // The numbers and a full IPv4 address survive; the spelled-out
1579+ // status is what gave way, and one whole answer still fits.
1580+ assert_eq ! ( layout. ip, COL_IP_MIN ) ;
1581+ assert_eq ! ( layout. ping, COL_PING ) ;
1582+ assert_eq ! ( layout. ttl, COL_TTL ) ;
1583+ assert_eq ! ( layout. exp, COL_EXP ) ;
1584+ assert_eq ! ( layout. status, 0 ) ;
1585+ assert ! ( layout. answer >= COL_ANSWER_MIN ) ;
1586+ assert ! ( layout. resolver >= COL_NAME_MIN ) ;
1587+
1588+ let total = layout. mark
1589+ + layout. resolver
1590+ + layout. loc
1591+ + layout. ip
1592+ + layout. ping
1593+ + layout. ttl
1594+ + layout. exp
1595+ + layout. answer
1596+ + spacing ( layout. status )
1597+ + COL_BORDERS ;
1598+ assert_eq ! ( total, 80 ) ;
1599+ }
1600+
1601+ #[ test]
1602+ fn table_spends_extra_width_on_the_answer ( ) {
1603+ let resolvers = resolvers:: defaults ( ) ;
1604+ // The width reserved beside a map panel shows every column whole,
1605+ // with the roomy answer — no narrower than it was before issue #33.
1606+ let reserved = TableLayout :: reserved_width ( & resolvers) ;
1607+ let wide = TableLayout :: fit ( reserved, & resolvers) ;
1608+ assert_eq ! ( wide. status, COL_STATUS ) ;
1609+ assert_eq ! ( wide. answer, COL_ANSWER_ROOMY ) ;
1610+ assert_eq ! ( wide. resolver, COL_NAME_MAX ) ;
1611+
1612+ // Past that, only the answer grows — nothing else moves.
1613+ let roomier = TableLayout :: fit ( reserved + 40 , & resolvers) ;
1614+ assert_eq ! ( roomier. answer, COL_ANSWER_ROOMY + 40 ) ;
1615+ assert_eq ! ( roomier. resolver, wide. resolver) ;
1616+ assert_eq ! ( roomier. ip, wide. ip) ;
1617+ }
1618+
1619+ #[ test]
1620+ fn ipv6_resolvers_get_their_full_address_only_when_it_fits ( ) {
1621+ let mut resolvers = resolvers:: defaults ( ) ;
1622+ resolvers. push ( Resolver {
1623+ name : "Custom v6" . into ( ) ,
1624+ location : "EU" . into ( ) ,
1625+ ip : "2606:4700:4700::1111" . parse ( ) . unwrap ( ) ,
1626+ coords : None ,
1627+ probe : None ,
1628+ } ) ;
1629+ // Wide: the address is shown whole, so the table simply asks for
1630+ // more room and the map panel gets what's left.
1631+ let reserved = TableLayout :: reserved_width ( & resolvers) ;
1632+ assert_eq ! ( TableLayout :: fit( reserved, & resolvers) . ip, 20 ) ;
1633+ // Narrow: it falls back to IPv4 width and ratatui clips the tail —
1634+ // the alternative is cropping the columns the issue asked us to fit.
1635+ assert_eq ! ( TableLayout :: fit( 80 , & resolvers) . ip, COL_IP_MIN ) ;
1636+ }
1637+
14051638 #[ test]
14061639 fn fmt_secs_is_compact_two_units ( ) {
14071640 assert_eq ! ( fmt_secs( 42 ) , "42s" ) ;
0 commit comments