From 39628d84dc4532d80a6c711eb7975efb444c0ab4 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 27 Sep 2026 00:34:29 +0200
Subject: [PATCH] fix: keep widget corners and border even when the launcher
crops the widget
---
.../src/main/res/drawable/widget_frame.xml | 9 ++++
.../src/main/res/drawable/widget_surface.xml | 7 +++
.../src/main/res/layout-night/rn_widget.xml | 38 ++++++++++++++
android/app/src/main/res/layout/rn_widget.xml | 50 +++++++++++++++++++
.../app/src/main/res/values-night/colors.xml | 2 +
android/app/src/main/res/values/colors.xml | 4 ++
android/app/src/main/res/values/dimens.xml | 4 ++
src/widgets/layouts/calendar.tsx | 20 ++++----
src/widgets/layouts/hubs.tsx | 4 +-
src/widgets/primitives.tsx | 9 ++--
src/widgets/theme.ts | 9 ++--
11 files changed, 135 insertions(+), 21 deletions(-)
create mode 100644 android/app/src/main/res/drawable/widget_frame.xml
create mode 100644 android/app/src/main/res/drawable/widget_surface.xml
create mode 100644 android/app/src/main/res/layout-night/rn_widget.xml
create mode 100644 android/app/src/main/res/layout/rn_widget.xml
create mode 100644 android/app/src/main/res/values/dimens.xml
diff --git a/android/app/src/main/res/drawable/widget_frame.xml b/android/app/src/main/res/drawable/widget_frame.xml
new file mode 100644
index 00000000..1e0d98a9
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_frame.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_surface.xml b/android/app/src/main/res/drawable/widget_surface.xml
new file mode 100644
index 00000000..730f8463
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_surface.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout-night/rn_widget.xml b/android/app/src/main/res/layout-night/rn_widget.xml
new file mode 100644
index 00000000..0eb7803b
--- /dev/null
+++ b/android/app/src/main/res/layout-night/rn_widget.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/rn_widget.xml b/android/app/src/main/res/layout/rn_widget.xml
new file mode 100644
index 00000000..2d50ffec
--- /dev/null
+++ b/android/app/src/main/res/layout/rn_widget.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
index 8c8b16ea..32088aa9 100644
--- a/android/app/src/main/res/values-night/colors.xml
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -3,4 +3,6 @@
#09090b
#09090b
+
+ #0a0a0a
\ No newline at end of file
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index a8383caf..ee512c47 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -6,4 +6,8 @@
#09090b
#ffffff
+
+ #ffffff
+
+ #4d808080
\ No newline at end of file
diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 00000000..44ea3ed1
--- /dev/null
+++ b/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,4 @@
+
+
+ 22dp
+
diff --git a/src/widgets/layouts/calendar.tsx b/src/widgets/layouts/calendar.tsx
index ced4a0fe..171a4437 100644
--- a/src/widgets/layouts/calendar.tsx
+++ b/src/widgets/layouts/calendar.tsx
@@ -29,8 +29,6 @@ import type { EventItem, WidgetSnapshot } from '../snapshot';
import { chipBackground, normalizeHex, type WidgetPalette } from '../theme';
import type { Layout } from './types';
-/** Surface's 1dp frame; Android adds a border to the padding, so it eats space. */
-const FRAME = 1;
const HOUR = 3600000;
const GRID_START_HOUR = 8;
const GRID_END_HOUR = 18;
@@ -207,7 +205,7 @@ export const AgendaLayout: Layout = ({ s, p, f, now, height }) => {
);
}
// Header bar plus its 1dp rule.
- const room = height - 2 * FRAME - headerHeight - 1;
+ const room = height - headerHeight - 1;
const { sections, hidden } = planAgenda(days, room);
const rows: React.JSX.Element[] = [];
sections.forEach((day, d) => {
@@ -366,12 +364,12 @@ export const WeekLayout: Layout = ({ s, p, f, now, width, height }) => {
const events = s.calendar.events;
const first = startOfWeek(now, s.weekStart);
const days = Array.from({ length: 7 }, (_, i) => addDays(first, i));
- const colWidth = (width - 2 * FRAME - 2 * WEEK_PAD) / 7;
+ const colWidth = (width - 2 * WEEK_PAD) / 7;
const segments = allDaySegments(events, days);
const strip = segments.some((seg) => seg.event !== null);
const gridHeight = Math.max(
0,
- height - 2 * FRAME - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
+ height - WEEK_PAD - WEEK_LABEL_HEIGHT - 2 - WEEK_DAY_SIZE - WEEK_GAP
- (strip ? WEEK_STRIP_HEIGHT + WEEK_GAP : 0) - 1,
);
const windowStart = startOfDay(now) + GRID_START_HOUR * HOUR;
@@ -566,11 +564,11 @@ export const MonthLayout: Layout = ({ s, p, f, now, width, height }) => {
const weeks = monthGrid(s.calendar.events, now, s.weekStart);
const next = upcoming(s.calendar.events, now);
const headerHeight = height >= 340 ? 44 : 38;
- const cellWidth = (width - 2 * FRAME - 2 * MONTH_PAD) / 7;
+ const cellWidth = (width - 2 * MONTH_PAD) / 7;
const chipsHeight = (n: number) =>
n === 0 ? 0 : MONTH_CHIPS_TOP + n * MONTH_CHIP_HEIGHT + (n - 1) * 4 + MONTH_CHIPS_BOTTOM;
const gridRoom = (n: number) =>
- height - 2 * FRAME - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
+ height - headerHeight - MONTH_LABEL_HEIGHT - MONTH_GRID_TOP - chipsHeight(n);
// Two chips when the weeks still get a comfortable row, else one, else none.
let chips = Math.min(2, next.length);
while (chips > 0 && gridRoom(chips) / weeks.length < MONTH_CELL_MIN) chips -= 1;
@@ -700,7 +698,7 @@ function TodayColumn({
const tomorrowList = eventsOnDay(events, addDays(today, 1));
// The "N more this week" line is always reserved; chips share the rest.
- const room = height - 2 * FRAME - 2 * TODAY_PAD - TODAY_LINE;
+ const room = height - 2 * TODAY_PAD - TODAY_LINE;
const row = TODAY_CHIP + TODAY_GAP;
let withTomorrow = tomorrowList.length > 0;
let slots = Math.floor((room - (withTomorrow ? TOMORROW_BLOCK : 0) + TODAY_GAP) / row);
@@ -792,7 +790,7 @@ function TodayColumn({
export const TodayEventsLayout: Layout = ({ s, p, f, now, height }) => {
// The date block is ~108dp tall at 40sp; a short 4x2 gets a smaller number
// and, if still tight, loses the month line.
- const room = height - 2 * FRAME - 2 * TODAY_PAD;
+ const room = height - 2 * TODAY_PAD;
const big = room >= 108 ? 40 : 32;
const showMonth = room >= 3 * 18 + Math.ceil(big * 1.33);
return (
@@ -820,7 +818,7 @@ export const NextEventLayout: Layout = ({ s, p, f, now, height }) => {
const when = running ? f.t('widgets.calendar.now', 'Now') : capitalize(f.relative(e.start, now), f.locale);
// Status line, gap, one title line and the time range always show; then the
// place, the calendar and a second title line, as long as they fit.
- const room = height - 2 * FRAME - 28;
+ const room = height - 28;
let used = 16 + 6 + 22 + 18;
const place = placeOf(f, e);
const showPlace = !!place && used + 16 <= room;
@@ -888,7 +886,7 @@ function TwoLineChip({ f, e, now }: { f: Fmt; e: EventItem; now: number }) {
}
export const DateNextLayout: Layout = ({ s, p, f, now, height }) => {
- const room = height - 2 * FRAME - 2 * DATE_PAD - DATE_CIRCLE - 8;
+ const room = height - 2 * DATE_PAD - DATE_CIRCLE - 8;
const count = room >= 2 * DATE_CHIP + 4 ? 2 : 1;
const next = s.calendar.supported ? upcoming(s.calendar.events, now).slice(0, count) : [];
const chips: React.JSX.Element[] = [];
diff --git a/src/widgets/layouts/hubs.tsx b/src/widgets/layouts/hubs.tsx
index 71bc13be..955e2638 100644
--- a/src/widgets/layouts/hubs.tsx
+++ b/src/widgets/layouts/hubs.tsx
@@ -306,8 +306,8 @@ export const MailAndNextLayout: Layout = ({ s, p, f, now, height }) => {
height: 'match_parent',
padding: pad,
backgroundColor: unread > 0 ? p.unreadRow : p.bg,
- borderTopLeftRadius: WIDGET_RADIUS - 1,
- borderBottomLeftRadius: WIDGET_RADIUS - 1,
+ borderTopLeftRadius: WIDGET_RADIUS,
+ borderBottomLeftRadius: WIDGET_RADIUS,
}}
>
diff --git a/src/widgets/primitives.tsx b/src/widgets/primitives.tsx
index 0c22f23d..dce01a75 100644
--- a/src/widgets/primitives.tsx
+++ b/src/widgets/primitives.tsx
@@ -17,9 +17,14 @@ import { chipBackground, normalizeHex, type WidgetPalette } from './theme';
import { filledIcon, icon, type FilledIconName, type IconName } from './icons';
import type { WidgetClick } from './clicks';
-/** Corner radius of the widget surface; launchers clip to roughly this on Android 12+. */
+/** Corner radius of the widget surface; `widget_radius` in android/app/src/main/res/values/dimens.xml. */
export const WIDGET_RADIUS = 22;
+/**
+ * The widget's background. Its edge and the clip to rounded corners come from
+ * the root layout (android/app/src/main/res/layout/rn_widget.xml), which has the
+ * launcher's real size; this bitmap is drawn at the reported one and can be cut off.
+ */
export function Surface({
p,
children,
@@ -39,8 +44,6 @@ export function Surface({
width: 'match_parent',
backgroundColor: p.bg,
borderRadius: WIDGET_RADIUS,
- borderWidth: 1,
- borderColor: p.frame,
overflow: 'hidden',
...style,
}}
diff --git a/src/widgets/theme.ts b/src/widgets/theme.ts
index 3eabe612..cbaa1f08 100644
--- a/src/widgets/theme.ts
+++ b/src/widgets/theme.ts
@@ -10,15 +10,16 @@ export type WidgetScheme = 'light' | 'dark';
export interface WidgetPalette {
scheme: WidgetScheme;
- /** Widget surface (webmail `--color-background`). */
+ /**
+ * Widget surface (webmail `--color-background`). The edge around it is drawn
+ * natively, as `widget_frame` in android/app/src/main/res/values/colors.xml.
+ */
bg: ColorProp;
fg: ColorProp;
muted: ColorProp;
/** Search pill, segmented track, quota ring track (webmail `--color-muted`). */
mutedBg: ColorProp;
border: ColorProp;
- /** Outer widget edge. */
- frame: ColorProp;
primary: ColorProp;
primaryFg: ColorProp;
accent: ColorProp;
@@ -66,7 +67,6 @@ export const LIGHT: WidgetPalette = {
muted: '#64748b',
mutedBg: '#f1f5f9',
border: '#e2e8f0',
- frame: '#e2e8f0',
primary: '#3b82f6',
primaryFg: '#ffffff',
accent: '#dbeafe',
@@ -108,7 +108,6 @@ export const DARK: WidgetPalette = {
muted: '#a3a3a3',
mutedBg: '#262626',
border: '#2d2d2d',
- frame: 'rgba(128, 128, 128, 0.3)',
primary: '#fafafa',
primaryFg: '#171717',
accent: '#1e3a8a',