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}
-
+
-
+
-
+
@@ -129,8 +153,12 @@ export const TriageLayout: Layout = ({ s, p, f, now, widgetId, local }) => {
);
};
-export const UnreadCountLayout: Layout = ({ s, p, f }) => {
+export const UnreadCountLayout: Layout = ({ s, p, f, width }) => {
const { unread, total } = inboxCounts(s);
+ const big = f.count(unread);
+ const bigSize = big.length <= 3 ? 44 : big.length === 4 ? 38 : 32;
+ // The total goes when the two do not fit on one line next to each other.
+ const showTotal = big.length * bigSize * 0.6 + (f.count(total).length + 2) * 15 * 0.55 + 4 <= width - 30;
const senders = s.mail.inbox.filter((m) => m.unread);
const faces = senders.slice(0, 3);
const more = Math.max(0, unread - faces.length);
@@ -143,9 +171,11 @@ export const UnreadCountLayout: Layout = ({ s, p, f }) => {
-
-
-
+
+ {showTotal ? : null}
+ {showTotal ? (
+
+ ) : null}
diff --git a/src/widgets/layouts/more-mail.tsx b/src/widgets/layouts/more-mail.tsx
index 4a0d45ed..104c54c7 100644
--- a/src/widgets/layouts/more-mail.tsx
+++ b/src/widgets/layouts/more-mail.tsx
@@ -671,7 +671,7 @@ export const TagLayout: Layout = ({ s, p, f, now }) => {
-
+
diff --git a/src/widgets/primitives.tsx b/src/widgets/primitives.tsx
index dce01a75..dd5b401b 100644
--- a/src/widgets/primitives.tsx
+++ b/src/widgets/primitives.tsx
@@ -337,10 +337,12 @@ export function Header({
>
{iconName ? : null}
{iconName ? : null}
-
- {count ? : null}
- {count ? : null}
-
+ {/* The title gives way (truncates) before the actions on the right. */}
+
+
+ {count ? : null}
+ {count ? : null}
+
{trailing}