fix: keep widget corners and border even when the launcher crops the widget

This commit is contained in:
Linus Rath
2026-09-27 00:34:29 +02:00
parent 78901f2375
commit 39628d84dc
11 changed files with 135 additions and 21 deletions
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget edge, drawn over the widget by layout/rn_widget.xml. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="@color/widget_frame" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Home-screen widget root (layout/rn_widget.xml): its rounded outline is what the widget clips to. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_surface" />
<corners android:radius="@dimen/widget_radius" />
</shape>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Night twin of layout/rn_widget.xml, which explains the override. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Overrides react-native-android-widget's rn_widget.xml (same ids and children;
layout-night/rn_widget.xml is its night twin).
The library draws each widget into one bitmap sized from the size the
launcher reports, and pins it to the top left without scaling. When the real
frame is smaller (after a rotation, or on a launcher that over-reports), the
bitmap's bottom edge is cut off and takes the rounded corners and the border
with it, so the bottom corners fall back to whatever the launcher clips to.
This root always has the real size, so it owns the shape instead: it clips to
a rounded outline (Android 12+; the launcher then leaves the corners to us),
fills in where the bitmap falls short, and draws the frame on top.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_surface"
android:clipToOutline="true"
android:foreground="@drawable/widget_frame"
tools:targetApi="31">
<ImageView
android:id="@+id/rn_widget_image_light"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="visible" />
<ImageView
android:id="@+id/rn_widget_image_dark"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:scaleType="matrix"
android:visibility="gone" />
<FrameLayout
android:id="@+id/rn_widget_clickable_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/rn_widget_collection_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -3,4 +3,6 @@
<color name="splashscreen_background">#09090b</color>
<!-- Matches the dark palette background (src/theme/tokens.ts). -->
<color name="window_background">#09090b</color>
<!-- DARK.bg in src/widgets/theme.ts. -->
<color name="widget_surface">#0a0a0a</color>
</resources>
@@ -6,4 +6,8 @@
<color name="colorPrimaryDark">#09090b</color>
<!-- Behind the React root until the first frame; matches the light palette background (src/theme/tokens.ts). -->
<color name="window_background">#ffffff</color>
<!-- Home-screen widget surface (LIGHT.bg in src/widgets/theme.ts); it only shows where the drawn widget falls short of the launcher's frame. -->
<color name="widget_surface">#ffffff</color>
<!-- Home-screen widget edge, one colour for both schemes: the app can force a theme the launcher's night mode doesn't know about. -->
<color name="widget_frame">#4d808080</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<!-- Home-screen widget corners; WIDGET_RADIUS in src/widgets/primitives.tsx. -->
<dimen name="widget_radius">22dp</dimen>
</resources>
+9 -11
View File
@@ -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[] = [];
+2 -2
View File
@@ -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,
}}
>
<FlexWidget style={{ flexDirection: 'row', alignItems: 'center' }}>
+6 -3
View File
@@ -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,
}}
+4 -5
View File
@@ -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',