diff --git a/src/widgets/__tests__/format.test.ts b/src/widgets/__tests__/format.test.ts new file mode 100644 index 00000000..a2b3cff7 --- /dev/null +++ b/src/widgets/__tests__/format.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { makeFmt } from '../format'; + +describe('count', () => { + it('keeps counts as they are below 10,000 and shortens bigger ones', () => { + const f = makeFmt('en', false); + expect(f.count(0)).toBe('0'); + expect(f.count(9999)).toBe('9999'); + expect(f.count(10007)).toBe('10K'); + expect(f.count(12345)).toBe('12.3K'); + expect(f.count(234567)).toBe('235K'); + expect(f.count(2_500_000)).toBe('2.5M'); + expect(makeFmt('de', false).count(12345)).toBe('12,3K'); + }); +}); diff --git a/src/widgets/format.ts b/src/widgets/format.ts index 54402ebc..bd12203e 100644 --- a/src/widgets/format.ts +++ b/src/widgets/format.ts @@ -26,6 +26,8 @@ export interface Fmt { /** "in 25 min", "in 2 h", "now". */ relative: (ms: number, now: number) => string; bytes: (n: number) => string; + /** A count for a big number: as is below 10,000, then compact ("12K"). */ + count: (n: number) => string; } function safeFormat(locale: string, options: Intl.DateTimeFormatOptions): (ms: number) => string { @@ -128,5 +130,19 @@ export function makeFmt(locale: string, hour12: boolean): Fmt { } return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; }, + count: (n) => { + if (n < 10000) return String(n); + // Not Intl's compact notation: German leaves thousands unshortened + // ("10.007"), which is what has to fit. + const [value, unit] = n >= 1_000_000 ? [n / 1_000_000, 'M'] : [n / 1000, 'K']; + const digits = value < 100 ? 1 : 0; + let text: string; + try { + text = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value); + } catch { + text = value.toFixed(digits).replace(/\.0$/, ''); + } + return `${text}${unit}`; + }, }; } diff --git a/src/widgets/layouts/hubs.tsx b/src/widgets/layouts/hubs.tsx index dc784325..1550e872 100644 --- a/src/widgets/layouts/hubs.tsx +++ b/src/widgets/layouts/hubs.tsx @@ -287,14 +287,18 @@ export const TodayHubLayout: Layout = ({ s, p, f, now, height }) => { /* Mail and next event */ /* ------------------------------------------------------------------------ */ -export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => { +export const MailAndNextLayout: Layout = ({ s, p, f, now, width, height }) => { const tight = height < 140; const pad = tight ? 10 : 14; const unreadMail = s.mail.inbox.filter((m) => m.unread); const unread = Math.max(inboxUnread(s), unreadMail.length); const latest = unreadMail[0]; - const digits = String(unread).length; + const digits = f.count(unread).length; const big = (tight ? 28 : 36) - (digits >= 4 ? 8 : 0); + // "unread" sits beside the number where it fits (a half-width column), + // under it otherwise (German, "ungelesen"). + const unreadLabel = f.t('widgets.mail.unread', 'unread'); + const labelBeside = digits * big * 0.6 + 4 + textWidth(unreadLabel, 13) <= (width - 2) / 2 - 2 * pad; const upcoming = s.calendar.supported ? upcomingTimed(s.calendar.events, now) : []; const event = upcoming[0]; @@ -325,16 +329,14 @@ export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => { - - + + {labelBeside ? : null} {/* Lines the small label up with the big number's baseline. */} - + {labelBeside ? ( + + ) : null} + {labelBeside ? null : } {latest ? ( diff --git a/src/widgets/layouts/mail.tsx b/src/widgets/layouts/mail.tsx index 410b5184..50dcfeeb 100644 --- a/src/widgets/layouts/mail.tsx +++ b/src/widgets/layouts/mail.tsx @@ -18,6 +18,7 @@ import { Surface, Txt, } from '../primitives'; +import type { Fmt } from '../format'; import type { FolderCount } from '../snapshot'; import type { IconName } from '../icons'; import type { WidgetPalette } from '../theme'; @@ -28,17 +29,19 @@ function inboxCounts(s: Parameters[0]['s']) { return { unread: inbox?.unread ?? 0, total: inbox?.total ?? 0 }; } -function CountText({ p, unread, total }: { p: WidgetPalette; unread: number; total: number }) { +function CountText({ p, f, unread, total, short }: { p: WidgetPalette; f: Fmt; unread: number; total: number; short?: boolean }) { return ( - - + + {short ? null : } ); } -export const InboxLayout: Layout = ({ s, p, f, now, height }) => { +export const InboxLayout: Layout = ({ s, p, f, now, width, height }) => { const compact = height < 190; + // At two cells wide the title, count, search and compose do not fit. + const narrow = width < 240; const headerHeight = compact ? 38 : 44; const rowHeight = compact ? MAIL_ROW_HEIGHT.compact : MAIL_ROW_HEIGHT.full; const rows = Math.max(1, Math.floor((height - headerHeight) / rowHeight)); @@ -55,8 +58,9 @@ export const InboxLayout: Layout = ({ s, p, f, now, height }) => { click={open(links.inbox())} trailing={( - - {compact ? null : } + + + {compact || narrow ? null : } @@ -73,7 +77,14 @@ export const InboxLayout: Layout = ({ s, p, f, now, height }) => { ); }; -export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => { +/** + * What a labelled button needs: icon, gap, label and its border. A glyph of + * the 13sp label averages a little under half its size; measured against the + * emulator, English fits at three cells and German does not. + */ +const buttonWidth = (label: string) => 15 + 6 + Math.ceil(label.length * 13 * 0.47) + 2; + +export const TriageLayout: Layout = ({ s, p, f, now, width, widgetId, local }) => { const candidates = s.mail.inbox.filter((m) => m.unread); const current = candidates.find((m) => m.id === local.triageId) ?? candidates[0]; const { unread } = inboxCounts(s); @@ -93,16 +104,29 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => { const position = candidates.indexOf(current) + 1; const target = { id: current.id, accountId: current.accountId, jmapAccountId: current.jmapAccountId, widgetId }; const notice = noticeFor(s.notice, 'archive', 'trash'); + // Long labels (German at three cells) drop to icons instead of being cut: + // first Archive and Delete, then Reply too. + const labels = { + archive: f.t('widgets.mail.archive', 'Archive'), + trash: f.t('widgets.mail.delete', 'Delete'), + reply: f.t('widgets.mail.reply', 'Reply'), + }; + const room = width - 2 - 24 - 32 - 18; + const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room; + const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room; return ( - + {/* A long name is cut, not the time and position after it. */} + + + - + { {notice ? : null} -