mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
Size controls for touch on iPad and other touch screens (#8748)
* feat(app): size controls for touch on any touch device, not just compact layouts
One gate picks touch or pointer sizing: touch when the layout is compact or
the device has a touch screen. The touch fact is read once, so attaching a
mouse or keyboard to a tablet never shrinks its controls. Stylesheets take
`bySizing({ touch, pointer })`, which resolves to a breakpoint value, so the
existing `{ xs, md }` consumers follow the gate without call-site changes.
Touch values follow the iOS HIG 44pt hit region: controls, icon buttons,
rows, bars and menu rows are 44 on touch; `sm` controls are 32. Glyphs move
to a touch ladder (14/18/20/24). Pointer sizes are unchanged.
Merges duplicated header control heights, density's private icon ladder and
the compact-only icon button sizes into control-geometry.
* feat(app): size the composer for touch through control geometry
The composer toolbar, attach / dictation / realtime / send buttons, context
meter and model triggers take TOOLBAR_CONTROL_SIZES (28 pointer, 44 touch)
instead of private 28 constants. The density budget, button glyphs and the
fullscreen glyph rail offset resolve the same gate, replacing the isWeb /
isNative glyph branches. Track rows join the menu row height.
Desktop geometry is unchanged. On touch the toolbar steps down density
earlier: iPad portrait goes from condensed to tight.
* feat(app): fold composer controls before the model label truncates
The composer toolbar walks one width budget: full, condensed, tight,
compact, then icon-only. Tight keeps the model at its family label;
below that, thinking, mode and features fold into the model control's
sheet (the phone toolbar), and only then does the model drop its label.
A touch iPad in portrait with an attachment now reads
"GPT-6-Astra Medium" instead of "GP…".
The context meter glyph and combobox rows and icons take the density
ladder. Desktop is pixel-identical in the content area.
* refactor(app): keep the sizing gate free of the theme
constants/layout sized its bars through control-geometry, which loads the
theme and runs Platform.select at import. The gate (resolveControlSizing,
bySizing, TOUCH_TARGET) moves to control-sizing.ts with no theme import;
control-geometry re-exports it, so its callers are unchanged.
* test(app): expect touch sizing in the compact Changes and plugin sidebar specs
The compact Changes options button is a 44pt icon button with a 20pt glyph,
and its framed actions trigger is 32 tall. In the compact sidebar the nav
block is capped at half the height, so 44pt rows put the plugin header row
below the cap; the spec scrolls it into view as a user would.
* fix(app): close composer menus when the controls fold or unfold
Folding swaps the toolbar controls for the model sheet. A menu opened on the
separate controls stayed marked open across the fold, so widening the
composer again reopened Thinking without a click.
This commit is contained in:
+5
-3
@@ -68,9 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
|
||||
|
||||
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
|
||||
|
||||
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -150,7 +152,7 @@ One row off the rail makes the whole card look unconsidered.
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||
Use `md: 600` for the compact/wide layout boundary, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint; touch sizing comes from the control sizing gate in [Buttons](#4-buttons). Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||
|
||||
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
|
||||
|
||||
|
||||
@@ -928,11 +928,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
throw new Error("Compact Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(actionsBox.width).toBe(48);
|
||||
expect(actionsBox.height).toBe(28);
|
||||
expect(optionsBox.width).toBe(32);
|
||||
expect(optionsBox.height).toBe(32);
|
||||
expect(glyphBox.width).toBe(18);
|
||||
expect(glyphBox.height).toBe(18);
|
||||
expect(actionsBox.height).toBe(32);
|
||||
expect(optionsBox.width).toBe(44);
|
||||
expect(optionsBox.height).toBe(44);
|
||||
expect(glyphBox.width).toBe(20);
|
||||
expect(glyphBox.height).toBe(20);
|
||||
|
||||
await expect(actions).not.toContainText("Commit");
|
||||
await expect(actions.locator("svg")).toHaveCount(2);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
recordComposerToolbarFrames,
|
||||
} from "../support/helpers/composer-control-density";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
const SETTLE_MS = 1_000;
|
||||
@@ -92,3 +93,34 @@ test.describe("Composer control density across tab switches", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Composer controls folding on a touch device", () => {
|
||||
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-density-fold-",
|
||||
title: "Folding chat",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
|
||||
const thinkingMenu = page.getByTestId("combobox-desktop-container");
|
||||
|
||||
await thinking.click();
|
||||
await expect(thinkingMenu).toBeVisible();
|
||||
|
||||
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
|
||||
await page.setViewportSize({ width: 640, height: 834 });
|
||||
await expect(thinking).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 1194, height: 834 });
|
||||
await expect(thinking).toBeVisible();
|
||||
await expect(thinkingMenu).toHaveCount(0);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,7 +317,10 @@ test.describe("Plugin sidebar items", () => {
|
||||
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
|
||||
await expect(compactSync).toBeInViewport();
|
||||
await expectFooterRow(page, compactSync);
|
||||
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
|
||||
// The compact nav block is capped at half the sidebar and scrolls past it.
|
||||
const compactDeploys = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
await compactDeploys.scrollIntoViewIfNeeded();
|
||||
await expect(compactDeploys).toBeInViewport();
|
||||
await qaScreenshot(page, "phase2-compact-sidebar");
|
||||
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
|
||||
await compactSync.click();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
|
||||
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
@@ -292,7 +293,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
trigger: {
|
||||
height: 28,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useHostReportsUsage } from "@/usage";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
import { formatContextPercentage } from "./context-window-meter.utils";
|
||||
@@ -306,14 +307,14 @@ export function ContextWindowMeter({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
containerWithLabel: {
|
||||
height: 28,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CONTROL_HEIGHTS, type ControlSize } from "@/components/ui/control-geometry";
|
||||
import { CONTROL_HEIGHTS, mapLayout, type ControlSize } from "@/components/ui/control-geometry";
|
||||
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
@@ -49,7 +49,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// trailing button line up.
|
||||
nestedRow: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: CONTROL_HEIGHTS.sm + theme.spacing[2] * 2,
|
||||
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[2] * 2),
|
||||
},
|
||||
nestedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -154,6 +154,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { useHostBadges } from "@/hosts/use-host-badges";
|
||||
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
|
||||
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
|
||||
import { bySizing } from "@/components/ui/control-geometry";
|
||||
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
|
||||
|
||||
@@ -2737,12 +2738,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginRight: -6,
|
||||
},
|
||||
projectKebabButton: {
|
||||
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
|
||||
// dots on the same rail as the 24px desktop box and the row at its 44px height.
|
||||
width: { xs: 32, md: 24 },
|
||||
height: { xs: 32, md: 24 },
|
||||
marginRight: { xs: -4, md: 0 },
|
||||
marginVertical: { xs: -4, md: 0 },
|
||||
// Larger on touch so the kebab is an easy tap target. The negative margins keep its
|
||||
// dots on the same rail as the 24px pointer box and the row at its 44px height.
|
||||
width: bySizing({ touch: 32, pointer: 24 }),
|
||||
height: bySizing({ touch: 32, pointer: 24 }),
|
||||
marginRight: bySizing({ touch: -4, pointer: 0 }),
|
||||
marginVertical: bySizing({ touch: -4, pointer: 0 }),
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
|
||||
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
import { bySizing } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
workspaceServiceLabelKey,
|
||||
type WorkspaceServiceSummary,
|
||||
@@ -438,18 +439,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
// Larger on compact so the kebab is an easy tap target.
|
||||
width: { xs: 32, md: 18 },
|
||||
height: { xs: 32, md: 18 },
|
||||
// Larger on touch so the kebab is an easy tap target.
|
||||
width: bySizing({ touch: 32, pointer: 18 }),
|
||||
height: bySizing({ touch: 32, pointer: 18 }),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
// Center the compact box on the 20px title line it is anchored to.
|
||||
marginTop: { xs: -6, md: 0 },
|
||||
// Center the touch box on the 20px title line it is anchored to.
|
||||
marginTop: bySizing({ touch: -6, pointer: 0 }),
|
||||
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
||||
// pull the painted dots through that unused view-box space onto the trailing-content rail.
|
||||
marginRight: { xs: -14, md: -7 },
|
||||
marginRight: bySizing({ touch: -14, pointer: -7 }),
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { density, densityIconBox } from "@/styles/density";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||
|
||||
export { buildDesktopFrameStyle } from "./combobox-frame-style";
|
||||
@@ -203,7 +205,7 @@ export function SearchInput({
|
||||
|
||||
return (
|
||||
<View style={styles.searchInputContainer}>
|
||||
<Search size={16} color={theme.colors.foregroundMuted} />
|
||||
<DensityIcon icon={Search} size="md" color="foregroundMuted" />
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
@@ -250,21 +252,19 @@ export function ComboboxItem({
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
let leadingContent: ReactElement | null = null;
|
||||
if (leadingSlot) {
|
||||
leadingContent = <View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>;
|
||||
} else if (kind === "directory") {
|
||||
leadingContent = (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||
<DensityIcon icon={Folder} size="md" color="foregroundMuted" />
|
||||
</View>
|
||||
);
|
||||
} else if (kind === "file") {
|
||||
leadingContent = (
|
||||
<View style={styles.comboboxItemLeadingSlot}>
|
||||
<File size={16} color={theme.colors.foregroundMuted} />
|
||||
<DensityIcon icon={File} size="md" color="foregroundMuted" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -308,7 +308,7 @@ export function ComboboxItem({
|
||||
{selected || trailingSlot ? (
|
||||
<View style={styles.comboboxItemTrailingContainer}>
|
||||
<View style={styles.comboboxItemTrailingSlot}>
|
||||
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
|
||||
{selected ? <DensityIcon icon={Check} size="md" color="foregroundMuted" /> : null}
|
||||
</View>
|
||||
{trailingSlot}
|
||||
</View>
|
||||
@@ -1682,7 +1682,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
@@ -1713,7 +1713,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.55,
|
||||
},
|
||||
comboboxItemTrailingSlot: {
|
||||
width: 16,
|
||||
width: densityIconBox("md"),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
@@ -1733,7 +1733,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
comboboxItemLeadingSlot: {
|
||||
width: 16,
|
||||
width: densityIconBox("md"),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ALERT_ICON_SIZE,
|
||||
bySizing,
|
||||
CONTROL_HEIGHTS,
|
||||
controlHeightForLayout,
|
||||
controlSizingForLayout,
|
||||
createControlGeometry,
|
||||
HEADER_CONTROL_HEIGHT,
|
||||
ICON_BUTTON_SIZES,
|
||||
iconSizeFor,
|
||||
resolveControlSizing,
|
||||
switchGeometryForLayout,
|
||||
getControlInteractionPhase,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
TOOLBAR_CONTROL_SIZES,
|
||||
TOUCH_TARGET,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -47,6 +56,52 @@ const theme = {
|
||||
},
|
||||
} as unknown as Theme;
|
||||
|
||||
describe("control sizing gate", () => {
|
||||
it("is touch-sized in the compact layout or on a touch screen, and pointer-sized otherwise", () => {
|
||||
expect(resolveControlSizing({ compactLayout: true, touchScreen: true })).toBe("touch");
|
||||
expect(resolveControlSizing({ compactLayout: true, touchScreen: false })).toBe("touch");
|
||||
expect(resolveControlSizing({ compactLayout: false, touchScreen: true })).toBe("touch");
|
||||
expect(resolveControlSizing({ compactLayout: false, touchScreen: false })).toBe("pointer");
|
||||
});
|
||||
|
||||
it("gives a touch screen its touch sizes at every width", () => {
|
||||
const sizes = { touch: 44, pointer: 32 };
|
||||
|
||||
// An iPad: touch-sized in the docked desktop layout too.
|
||||
expect(bySizing(sizes, true)).toEqual({ xs: 44, md: 44 });
|
||||
// A mouse-driven window: touch-sized only once it narrows into the compact layout.
|
||||
expect(bySizing(sizes, false)).toEqual({ xs: 44, md: 32 });
|
||||
});
|
||||
|
||||
it("keeps a device without a touch screen on pointer sizes from md up", () => {
|
||||
// The unit runtime has no touch screen, which is the desktop that must not change.
|
||||
expect(controlSizingForLayout(false)).toBe("pointer");
|
||||
expect(controlSizingForLayout(true)).toBe("touch");
|
||||
expect(CONTROL_HEIGHTS.md).toEqual({ xs: 44, md: 32 });
|
||||
expect(CONTROL_HEIGHTS.sm).toEqual({ xs: 32, md: 28 });
|
||||
expect(HEADER_CONTROL_HEIGHT).toEqual({ xs: 32, md: 26 });
|
||||
});
|
||||
|
||||
it("follows the HIG on touch: a 44pt target, no control under 28pt, larger glyphs", () => {
|
||||
expect(TOUCH_TARGET).toBe(44);
|
||||
expect(controlHeightForLayout("md", true)).toBe(TOUCH_TARGET);
|
||||
expect(ICON_BUTTON_SIZES.large.touch).toBe(TOUCH_TARGET);
|
||||
expect(ICON_BUTTON_SIZES.small.touch).toBe(TOUCH_TARGET);
|
||||
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
|
||||
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
|
||||
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
|
||||
for (const size of ["sm", "md"] as const) {
|
||||
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
|
||||
controlHeightForLayout(size, false),
|
||||
);
|
||||
expect(controlHeightForLayout(size, false)).toBeGreaterThanOrEqual(28);
|
||||
}
|
||||
for (const size of ["xs", "sm", "md", "lg"] as const) {
|
||||
expect(iconSizeFor(size).touch).toBeGreaterThan(iconSizeFor(size).pointer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("control geometry", () => {
|
||||
it("keeps resting control borders transparent while preserving border geometry", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
@@ -98,18 +153,17 @@ describe("control geometry", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
const borderWidth = theme.borderWidth[1];
|
||||
|
||||
expect(geometry.fieldControlSm.minHeight).toBe(28);
|
||||
expect(
|
||||
geometry.fieldTextSm.lineHeight +
|
||||
geometry.fieldControlSm.paddingVertical * 2 +
|
||||
borderWidth * 2,
|
||||
).toBe(geometry.fieldControlSm.minHeight);
|
||||
|
||||
expect(geometry.fieldControlSm.minHeight).toEqual({ xs: 32, md: 28 });
|
||||
// The default keeps a 44 touch target in the compact layout and is 32 from the md
|
||||
// breakpoint up.
|
||||
expect(geometry.fieldControlMd.minHeight).toEqual({ xs: 44, md: 32 });
|
||||
expect(geometry.fieldControlMd.paddingVertical).toEqual({ xs: 11, md: 5 });
|
||||
for (const layout of ["xs", "md"] as const) {
|
||||
expect(
|
||||
geometry.fieldTextSm.lineHeight +
|
||||
geometry.fieldControlSm.paddingVertical[layout] * 2 +
|
||||
borderWidth * 2,
|
||||
).toBe(geometry.fieldControlSm.minHeight[layout]);
|
||||
expect(
|
||||
geometry.fieldTextMd.lineHeight +
|
||||
geometry.fieldControlMd.paddingVertical[layout] * 2 +
|
||||
@@ -125,24 +179,25 @@ describe("control geometry", () => {
|
||||
expect(geometry.segmentedContainerMd.padding).toBe(0);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(geometry.buttonSm.borderRadius);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(geometry.buttonMd.borderRadius);
|
||||
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
|
||||
expect(geometry.segmentedContainerSm.minHeight).toEqual(geometry.buttonSm.minHeight);
|
||||
expect(geometry.segmentedContainerMd.minHeight).toEqual(geometry.buttonMd.minHeight);
|
||||
expect(geometry.segmentedSegmentSm.minHeight).toBe(24);
|
||||
expect(geometry.segmentedSegmentSm.minHeight).toEqual({ xs: 28, md: 24 });
|
||||
expect(geometry.segmentedSegmentMd.minHeight).toEqual({ xs: 40, md: 28 });
|
||||
});
|
||||
|
||||
it("keeps one size contract across buttons, fields, and segmented controls", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
expect(geometry.buttonSm.minHeight).toBe(28);
|
||||
expect(geometry.buttonSm.minHeight).toEqual({ xs: 32, md: 28 });
|
||||
expect(geometry.buttonMd.minHeight).toEqual({ xs: 44, md: 32 });
|
||||
expect(geometry.buttonSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
|
||||
expect(geometry.buttonSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
|
||||
expect(geometry.buttonMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
|
||||
expect(geometry.fieldControlSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
|
||||
expect(geometry.fieldControlSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
|
||||
expect(geometry.fieldControlMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
|
||||
expect(controlHeightForLayout("md", true)).toBe(44);
|
||||
expect(controlHeightForLayout("md", false)).toBe(32);
|
||||
expect(controlHeightForLayout("sm", true)).toBe(28);
|
||||
expect(controlHeightForLayout("sm", true)).toBe(32);
|
||||
expect(controlHeightForLayout("sm", false)).toBe(28);
|
||||
|
||||
// Same size name means the same label size on every control kind.
|
||||
expect(geometry.segmentedLabelSm.fontSize).toBe(12);
|
||||
@@ -177,13 +232,15 @@ describe("control geometry", () => {
|
||||
thumbTravel: 18,
|
||||
reservedHeight: 44,
|
||||
});
|
||||
// Tight chrome keeps the pointer-sized switch on every layout.
|
||||
for (const isCompact of [true, false]) {
|
||||
expect(switchGeometryForLayout("sm", isCompact)).toMatchObject({
|
||||
trackWidth: 34,
|
||||
reservedHeight: 28,
|
||||
});
|
||||
}
|
||||
// Tight chrome keeps the pointer-sized track on every layout and reserves its row's height.
|
||||
expect(switchGeometryForLayout("sm", true)).toMatchObject({
|
||||
trackWidth: 34,
|
||||
reservedHeight: 32,
|
||||
});
|
||||
expect(switchGeometryForLayout("sm", false)).toMatchObject({
|
||||
trackWidth: 34,
|
||||
reservedHeight: 28,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives alert title and description one font size and a tight gap per size", () => {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
bySizing,
|
||||
controlSizingForLayout,
|
||||
mapLayout,
|
||||
TOUCH_TARGET,
|
||||
type ControlSizing,
|
||||
type LayoutValue,
|
||||
type SizingValue,
|
||||
} from "./control-sizing";
|
||||
|
||||
/**
|
||||
* Controls come in two sizes. `md` is the default and the right size almost everywhere;
|
||||
@@ -26,46 +35,78 @@ export interface ControlInteractionStyleMap {
|
||||
controlDisabled?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const SMALL_CONTROL_HEIGHT = 28;
|
||||
// The default control keeps a full touch target in the compact layout and tightens where a
|
||||
// pointer drives it.
|
||||
const DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT = 44;
|
||||
const DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT = 32;
|
||||
export const HEADER_CONTROL_HEIGHT = 26;
|
||||
export {
|
||||
bySizing,
|
||||
controlSizingForLayout,
|
||||
mapLayout,
|
||||
resolveControlSizing,
|
||||
TOUCH_TARGET,
|
||||
type ControlSizing,
|
||||
type ControlSizingFacts,
|
||||
type LayoutValue,
|
||||
type SizingValue,
|
||||
} from "./control-sizing";
|
||||
|
||||
// Touch `md` is the iOS HIG's 44pt default control; touch `sm` stays above its 28pt minimum.
|
||||
const CONTROL_SIZES = {
|
||||
sm: { touch: 32, pointer: 28 },
|
||||
md: { touch: TOUCH_TARGET, pointer: 32 },
|
||||
} satisfies Record<ControlSize, SizingValue>;
|
||||
|
||||
/** Pointer-sized header controls run tighter than `sm` so they fit the 36pt header. */
|
||||
const POINTER_HEADER_CONTROL_HEIGHT = 26;
|
||||
const SEGMENTED_INSET = 2;
|
||||
const CONTROL_FOCUS_RING_WIDTH = 2;
|
||||
const CONTROL_FOCUS_RING_OFFSET = 1;
|
||||
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
||||
|
||||
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
|
||||
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
|
||||
// signature, which interfaces never satisfy.
|
||||
export type LayoutValue = Record<"xs" | "md", number>;
|
||||
|
||||
function byLayout(compact: number, wide: number): LayoutValue {
|
||||
return { xs: compact, md: wide };
|
||||
}
|
||||
|
||||
function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
|
||||
return byLayout(map(value.xs), map(value.md));
|
||||
}
|
||||
|
||||
/**
|
||||
* The height of every button, field, and segmented control of a size. Exported so a row that
|
||||
* hosts one of those controls can size itself from the same numbers instead of guessing a
|
||||
* height the control then outgrows. `md` depends on the layout, so it only works inside a
|
||||
* Unistyles stylesheet.
|
||||
* height the control then outgrows. Breakpoint values, so they only work inside a Unistyles
|
||||
* stylesheet.
|
||||
*/
|
||||
export const CONTROL_HEIGHTS = {
|
||||
sm: SMALL_CONTROL_HEIGHT,
|
||||
md: byLayout(DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT, DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT),
|
||||
} satisfies Record<ControlSize, number | LayoutValue>;
|
||||
sm: bySizing(CONTROL_SIZES.sm),
|
||||
md: bySizing(CONTROL_SIZES.md),
|
||||
} satisfies Record<ControlSize, LayoutValue>;
|
||||
|
||||
/** A framed control in a header row, such as a split button. Touch-sized it is an `sm` control. */
|
||||
export const HEADER_CONTROL_HEIGHT = bySizing({
|
||||
touch: CONTROL_SIZES.sm.touch,
|
||||
pointer: POINTER_HEADER_CONTROL_HEIGHT,
|
||||
});
|
||||
|
||||
/**
|
||||
* Square icon-only buttons. Touch-sized, every icon button is one touch target; pointer-sized,
|
||||
* header buttons are larger than toolbar buttons.
|
||||
*/
|
||||
export const ICON_BUTTON_SIZES = {
|
||||
large: { touch: TOUCH_TARGET, pointer: POINTER_HEADER_CONTROL_HEIGHT },
|
||||
small: { touch: TOUCH_TARGET, pointer: 20 },
|
||||
} satisfies Record<"large" | "small", SizingValue>;
|
||||
|
||||
/**
|
||||
* A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled
|
||||
* pill. Touch-sized it is one touch target.
|
||||
*/
|
||||
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
|
||||
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
|
||||
|
||||
/**
|
||||
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
|
||||
* text, so each step is larger.
|
||||
*/
|
||||
export type IconSize = keyof typeof ICON_SIZE;
|
||||
export const TOUCH_ICON_SIZE: Record<IconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
|
||||
|
||||
export function iconSizeFor(size: IconSize): SizingValue {
|
||||
return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] };
|
||||
}
|
||||
|
||||
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
|
||||
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
|
||||
const height = CONTROL_HEIGHTS[size];
|
||||
if (typeof height === "number") return height;
|
||||
return isCompact ? height.xs : height.md;
|
||||
return CONTROL_SIZES[size][controlSizingForLayout(isCompact)];
|
||||
}
|
||||
|
||||
export const controlIconSize: Record<ControlSize, number> = {
|
||||
@@ -96,7 +137,8 @@ const SWITCH_TOUCH = switchSize(44, 26, 22);
|
||||
* animation, so the size is resolved to plain numbers here instead of a stylesheet breakpoint.
|
||||
*/
|
||||
export function switchGeometryForLayout(size: ControlSize, isCompact: boolean): SwitchGeometry {
|
||||
const track = size === "md" && isCompact ? SWITCH_TOUCH : SWITCH_POINTER;
|
||||
const touchSized = controlSizingForLayout(isCompact) === "touch";
|
||||
const track = size === "md" && touchSized ? SWITCH_TOUCH : SWITCH_POINTER;
|
||||
return { ...track, reservedHeight: controlHeightForLayout(size, isCompact) };
|
||||
}
|
||||
|
||||
@@ -225,10 +267,8 @@ export function createControlGeometry(theme: Theme) {
|
||||
const fieldControlSm = {
|
||||
minHeight: CONTROL_HEIGHTS.sm,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: fieldVerticalPadding(
|
||||
CONTROL_HEIGHTS.sm,
|
||||
fieldTextSmLineHeight,
|
||||
controlBorderWidth,
|
||||
paddingVertical: mapLayout(CONTROL_HEIGHTS.sm, (height) =>
|
||||
fieldVerticalPadding(height, fieldTextSmLineHeight, controlBorderWidth),
|
||||
),
|
||||
borderRadius: theme.borderRadius.md,
|
||||
};
|
||||
@@ -258,7 +298,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
},
|
||||
buttonMd: {
|
||||
minHeight: CONTROL_HEIGHTS.md,
|
||||
paddingHorizontal: byLayout(theme.spacing[6], theme.spacing[4]),
|
||||
paddingHorizontal: bySizing({ touch: theme.spacing[6], pointer: theme.spacing[4] }),
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
// A button with only an icon is a circle of its size's height.
|
||||
@@ -314,7 +354,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
padding: 0,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: CONTROL_HEIGHTS.sm - SEGMENTED_INSET * 2,
|
||||
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height - SEGMENTED_INSET * 2),
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { hasTouchScreen } from "@/hooks/use-fine-pointer";
|
||||
|
||||
// The sizing gate, apart from the control sizes so that low-level modules such as
|
||||
// `constants/layout` can size by it without loading the theme. Import it through
|
||||
// `@/components/ui/control-geometry`, which re-exports it.
|
||||
|
||||
/**
|
||||
* Every size comes in a touch value and a pointer value. Touch values follow Apple's Human
|
||||
* Interface Guidelines for iOS and iPadOS: a 44pt default control and hit region, nothing
|
||||
* smaller than its 28pt minimum. Pointer values sit one step down the same button ladder.
|
||||
*/
|
||||
export type ControlSizing = "touch" | "pointer";
|
||||
|
||||
export interface ControlSizingFacts {
|
||||
/** The layout is below the `md` breakpoint. */
|
||||
compactLayout: boolean;
|
||||
/** The device has a touch screen; see `hasTouchScreen`. */
|
||||
touchScreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one sizing gate: touch-sized in the compact layout or on any device with a touch screen,
|
||||
* pointer-sized otherwise. A touch screen keeps touch sizing when a mouse or keyboard is attached.
|
||||
*/
|
||||
export function resolveControlSizing({
|
||||
compactLayout,
|
||||
touchScreen,
|
||||
}: ControlSizingFacts): ControlSizing {
|
||||
if (compactLayout || touchScreen) return "touch";
|
||||
return "pointer";
|
||||
}
|
||||
|
||||
// Read once, so attaching a mouse or keyboard later never flips the sizing.
|
||||
const DEVICE_HAS_TOUCH_SCREEN = hasTouchScreen();
|
||||
|
||||
/** The sizing on this device for a layout, for code that sizes outside a stylesheet. */
|
||||
export function controlSizingForLayout(compactLayout: boolean): ControlSizing {
|
||||
return resolveControlSizing({ compactLayout, touchScreen: DEVICE_HAS_TOUCH_SCREEN });
|
||||
}
|
||||
|
||||
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
|
||||
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
|
||||
// signature, which interfaces never satisfy.
|
||||
export type LayoutValue = Record<"xs" | "md", number>;
|
||||
|
||||
export type SizingValue = Record<ControlSizing, number>;
|
||||
|
||||
/**
|
||||
* A size as a Unistyles breakpoint value. The compact layout always takes the touch value; from
|
||||
* `md` up the device decides.
|
||||
*/
|
||||
export function bySizing(
|
||||
sizes: SizingValue,
|
||||
touchScreen: boolean = DEVICE_HAS_TOUCH_SCREEN,
|
||||
): LayoutValue {
|
||||
return {
|
||||
xs: sizes[resolveControlSizing({ compactLayout: true, touchScreen })],
|
||||
md: sizes[resolveControlSizing({ compactLayout: false, touchScreen })],
|
||||
};
|
||||
}
|
||||
|
||||
/** Derives a breakpoint value from another, such as a row as tall as its control plus padding. */
|
||||
export function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
|
||||
return { xs: map(value.xs), md: map(value.md) };
|
||||
}
|
||||
|
||||
/** The smallest touch target, and the size of every touch-sized icon button, row, and bar. */
|
||||
export const TOUCH_TARGET = 44;
|
||||
@@ -1,17 +1,24 @@
|
||||
import { createContext, useCallback, useContext } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
bySizing,
|
||||
controlSizingForLayout,
|
||||
ICON_BUTTON_SIZES,
|
||||
TOUCH_ICON_SIZE,
|
||||
type SizingValue,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
|
||||
export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
|
||||
export type IconButtonChromeSize = "large" | "small";
|
||||
|
||||
const SMALL_ICON_BUTTON_SIZE = 20;
|
||||
/** Width and height of a large icon button on compact layouts. */
|
||||
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
|
||||
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
|
||||
// Touch-sized, every icon button is one target with one glyph size.
|
||||
const GLYPH_SIZES = {
|
||||
large: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.md },
|
||||
small: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.sm },
|
||||
} satisfies Record<IconButtonChromeSize, SizingValue>;
|
||||
|
||||
export interface IconButtonChromeState {
|
||||
hovered?: boolean;
|
||||
@@ -22,7 +29,7 @@ export interface IconButtonChromeState {
|
||||
|
||||
function resolveIconButtonFrame(size: IconButtonChromeSize, compact: boolean) {
|
||||
if (size === "large") return styles.large;
|
||||
return compact ? styles.smallCompact : styles.small;
|
||||
return controlSizingForLayout(compact) === "touch" ? styles.smallTouch : styles.small;
|
||||
}
|
||||
|
||||
interface IconButtonChromeOptions {
|
||||
@@ -92,24 +99,17 @@ export function iconButtonChromeFrameStyle(
|
||||
}
|
||||
|
||||
export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number {
|
||||
if (size === "large") return ICON_SIZE.md;
|
||||
return compact ? 18 : 14;
|
||||
return GLYPH_SIZES[size][controlSizingForLayout(compact)];
|
||||
}
|
||||
|
||||
export function smallIconButtonChromeFrameSize(compact = false): number {
|
||||
return compact ? COMPACT_SMALL_ICON_BUTTON_SIZE : SMALL_ICON_BUTTON_SIZE;
|
||||
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
large: {
|
||||
width: {
|
||||
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
height: {
|
||||
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
width: bySizing(ICON_BUTTON_SIZES.large),
|
||||
height: bySizing(ICON_BUTTON_SIZES.large),
|
||||
padding: 0,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
@@ -119,8 +119,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
outlineColor: "transparent",
|
||||
},
|
||||
small: {
|
||||
width: SMALL_ICON_BUTTON_SIZE,
|
||||
height: SMALL_ICON_BUTTON_SIZE,
|
||||
width: ICON_BUTTON_SIZES.small.pointer,
|
||||
height: ICON_BUTTON_SIZES.small.pointer,
|
||||
padding: 0,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
@@ -129,9 +129,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
},
|
||||
smallCompact: {
|
||||
width: COMPACT_SMALL_ICON_BUTTON_SIZE,
|
||||
height: COMPACT_SMALL_ICON_BUTTON_SIZE,
|
||||
smallTouch: {
|
||||
width: ICON_BUTTON_SIZES.small.touch,
|
||||
height: ICON_BUTTON_SIZES.small.touch,
|
||||
padding: 0,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
/** Shared geometry for menu rows and controls that align with them. */
|
||||
export const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
|
||||
import { density } from "@/styles/density";
|
||||
|
||||
/** A menu row is a tight row: touch-sized wherever controls are (see `resolveControlSizing`). */
|
||||
export const MENU_ITEM_HEIGHT = density.tightRowHeight;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
|
||||
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
|
||||
import type { AgentControlIcon } from "@/agent-controls/icons";
|
||||
@@ -96,7 +97,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
toolbarControl: {
|
||||
height: 28,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
@@ -107,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
toolbarIconOnly: {
|
||||
width: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
flexShrink: 0,
|
||||
paddingHorizontal: 0,
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StyleSheet, View } from "react-native";
|
||||
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
|
||||
return (
|
||||
<View
|
||||
style={size >= 20 ? styles.native : styles.web}
|
||||
style={size >= 20 ? styles.touch : styles.pointer}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
@@ -15,14 +15,14 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
web: {
|
||||
pointer: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
native: {
|
||||
touch: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
|
||||
@@ -61,6 +61,8 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from "@/composer/agent-controls/utils";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { readMeasuredWidth } from "@/hooks/use-container-width";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -70,11 +72,10 @@ import {
|
||||
type AgentControlCommandCenterSource,
|
||||
} from "@/command-center/agent-control-registration";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
composerToolbarGeometry,
|
||||
resolveComposerControlDensity,
|
||||
resolveComposerControlPresentation,
|
||||
resolveComposerToolbarGlyphSize,
|
||||
type ComposerControlDensity,
|
||||
type ComposerControlPresentation,
|
||||
} from "@/composer/agent-controls/layout";
|
||||
@@ -460,6 +461,10 @@ function buildAgentProviderModels(
|
||||
return map;
|
||||
}
|
||||
|
||||
function densityFolds(density: ComposerControlDensity): boolean {
|
||||
return resolveComposerControlPresentation(density).foldControls;
|
||||
}
|
||||
|
||||
function buildOpenChangeHandler(
|
||||
selector: AgentControlSelector,
|
||||
setOpenSelector: (next: AgentControlSelector | null) => void,
|
||||
@@ -503,14 +508,20 @@ function ControlledAgentControls({
|
||||
modelSelectorServerId = null,
|
||||
isCompactLayout,
|
||||
}: ControlledAgentControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
// Control sizes follow the window layout, as the toolbar's stylesheet does. A narrow pane in a
|
||||
// wide window (`isCompactLayout`) changes which labels show, never how large a control is.
|
||||
const toolbarSizing = controlSizingForLayout(isCompactFormFactor);
|
||||
const toolbarGeometry = useMemo(() => composerToolbarGeometry(toolbarSizing), [toolbarSizing]);
|
||||
const { fontScale } = useWindowDimensions();
|
||||
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
|
||||
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
|
||||
const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full";
|
||||
// A compact composer always folds its controls into the model sheet; the width budget only
|
||||
// decides whether the model keeps its label.
|
||||
const minimumDensity: ComposerControlDensity = isCompact ? "compact" : "full";
|
||||
const initialDensity = minimumDensity;
|
||||
const [density, setDensity] = useState<ComposerControlDensity>(initialDensity);
|
||||
const densityRef = useRef<ComposerControlDensity>(initialDensity);
|
||||
const availableWidthRef = useRef(0);
|
||||
@@ -574,10 +585,10 @@ function ControlledAgentControls({
|
||||
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
|
||||
const layoutContextValue = useMemo(
|
||||
() => ({
|
||||
glyphSize: resolveComposerToolbarGlyphSize(isNative ? "native" : "web"),
|
||||
glyphSize: toolbarGeometry.glyphSize,
|
||||
presentation,
|
||||
}),
|
||||
[presentation],
|
||||
[presentation, toolbarGeometry],
|
||||
);
|
||||
|
||||
const updateDensityForWidth = useCallback(
|
||||
@@ -586,12 +597,20 @@ function ControlledAgentControls({
|
||||
availableWidth,
|
||||
currentDensity: densityRef.current,
|
||||
controls: controlPresence,
|
||||
geometry: toolbarGeometry,
|
||||
minimumDensity,
|
||||
});
|
||||
if (nextDensity === densityRef.current) return;
|
||||
// Folding swaps the toolbar controls for the model sheet and back, so whatever menu or
|
||||
// sheet was open belonged to controls that are gone.
|
||||
if (densityFolds(nextDensity) !== densityFolds(densityRef.current)) {
|
||||
setOpenSelector(null);
|
||||
setActiveSheet(null);
|
||||
}
|
||||
densityRef.current = nextDensity;
|
||||
setDensity(nextDensity);
|
||||
},
|
||||
[controlPresence],
|
||||
[controlPresence, minimumDensity, toolbarGeometry],
|
||||
);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
@@ -633,10 +652,9 @@ function ControlledAgentControls({
|
||||
selected={args.selected}
|
||||
active={args.active}
|
||||
onPress={args.onPress}
|
||||
iconColor={theme.colors.foreground}
|
||||
/>
|
||||
),
|
||||
[theme.colors.foreground],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
@@ -702,8 +720,8 @@ function ControlledAgentControls({
|
||||
|
||||
const handleCloseSheet = useCallback(() => {
|
||||
setActiveSheet(null);
|
||||
if (!isCompact) onDropdownClose?.();
|
||||
}, [isCompact, onDropdownClose]);
|
||||
if (!presentation.foldControls) onDropdownClose?.();
|
||||
}, [onDropdownClose, presentation.foldControls]);
|
||||
|
||||
const handleSelectThinkingAndClose = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
@@ -734,7 +752,7 @@ function ControlledAgentControls({
|
||||
return (
|
||||
<ComposerControlLayoutProvider value={layoutContextValue}>
|
||||
<View style={styles.container} onLayout={handleLayout}>
|
||||
{!isCompact ? (
|
||||
{!presentation.foldControls ? (
|
||||
<DesktopAgentControlsContent
|
||||
provider={provider}
|
||||
providerOptions={providerOptions}
|
||||
@@ -821,6 +839,7 @@ function ControlledAgentControls({
|
||||
renderThinkingOption={renderThinkingOption}
|
||||
modeControl={modeControl}
|
||||
glyphSize={layoutContextValue.glyphSize}
|
||||
showLabels={presentation.showModelLabel}
|
||||
modelSelectorServerId={modelSelectorServerId}
|
||||
canSwitchProvider={Boolean(onSelectProviderAndModel)}
|
||||
/>
|
||||
@@ -1144,6 +1163,7 @@ interface SheetAgentControlsContentProps {
|
||||
}) => ReactElement;
|
||||
modeControl?: AgentModeControlValue | null;
|
||||
glyphSize: number;
|
||||
showLabels: boolean;
|
||||
modelSelectorServerId: string | null;
|
||||
canSwitchProvider: boolean;
|
||||
}
|
||||
@@ -1183,6 +1203,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
renderThinkingOption,
|
||||
modeControl,
|
||||
glyphSize,
|
||||
showLabels,
|
||||
modelSelectorServerId,
|
||||
canSwitchProvider,
|
||||
} = props;
|
||||
@@ -1271,6 +1292,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
isRetryingProvider={isRetryingModelProvider}
|
||||
serverId={modelSelectorServerId}
|
||||
glyphSize={glyphSize}
|
||||
showLabels={showLabels}
|
||||
canSwitchProvider={canSwitchProvider}
|
||||
>
|
||||
{sheetControls}
|
||||
@@ -1529,15 +1551,16 @@ function ThinkingComboboxOption({
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
iconColor,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
iconColor: string;
|
||||
}) {
|
||||
const leadingSlot = useMemo(() => <ThinkingIcon size={16} color={iconColor} />, [iconColor]);
|
||||
const leadingSlot = useMemo(
|
||||
() => <DensityIcon icon={ThinkingIcon} size="md" color="foreground" />,
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
@@ -1953,7 +1976,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
},
|
||||
modeBadge: {
|
||||
height: 28,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
@@ -1971,8 +1994,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
modeIconBadge: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 0,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
|
||||
import {
|
||||
resolveComposerControlPresentation,
|
||||
type ComposerControlPresentation,
|
||||
} from "@/composer/agent-controls/layout";
|
||||
|
||||
interface ComposerControlLayoutValue {
|
||||
glyphSize: number;
|
||||
@@ -8,12 +11,7 @@ interface ComposerControlLayoutValue {
|
||||
|
||||
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
|
||||
glyphSize: 16,
|
||||
presentation: {
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
},
|
||||
presentation: resolveComposerControlPresentation("full"),
|
||||
};
|
||||
|
||||
const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT);
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COMPOSER_TOOLBAR_GEOMETRY,
|
||||
composerToolbarGeometry,
|
||||
resolveComposerControlDensity,
|
||||
resolveComposerControlPresentation,
|
||||
resolveComposerToolbarGlyphSize,
|
||||
} from "./layout";
|
||||
|
||||
const POINTER = composerToolbarGeometry("pointer");
|
||||
const TOUCH = composerToolbarGeometry("touch");
|
||||
|
||||
describe("composer control layout", () => {
|
||||
it("removes labels in priority order as the toolbar narrows", () => {
|
||||
it("removes labels, then folds controls, then drops the model label as the toolbar narrows", () => {
|
||||
expect(resolveComposerControlPresentation("full")).toEqual({
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
foldControls: false,
|
||||
showModelLabel: true,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("condensed")).toEqual({
|
||||
expect(resolveComposerControlPresentation("condensed")).toMatchObject({
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: true,
|
||||
foldControls: false,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("tight")).toEqual({
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
expect(resolveComposerControlPresentation("tight")).toMatchObject({
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
foldControls: false,
|
||||
showModelLabel: true,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("compact")).toMatchObject({
|
||||
foldControls: true,
|
||||
showModelLabel: true,
|
||||
});
|
||||
expect(resolveComposerControlPresentation("iconOnly")).toMatchObject({
|
||||
foldControls: true,
|
||||
showModelLabel: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +53,7 @@ describe("composer control layout", () => {
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 420,
|
||||
currentDensity: "full",
|
||||
geometry: POINTER,
|
||||
controls,
|
||||
}),
|
||||
).toBe("full");
|
||||
@@ -48,6 +61,7 @@ describe("composer control layout", () => {
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 380,
|
||||
currentDensity: "full",
|
||||
geometry: POINTER,
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
@@ -55,6 +69,7 @@ describe("composer control layout", () => {
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 290,
|
||||
currentDensity: "condensed",
|
||||
geometry: POINTER,
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
@@ -62,6 +77,7 @@ describe("composer control layout", () => {
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 280,
|
||||
currentDensity: "condensed",
|
||||
geometry: POINTER,
|
||||
controls,
|
||||
}),
|
||||
).toBe("tight");
|
||||
@@ -69,6 +85,7 @@ describe("composer control layout", () => {
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 300,
|
||||
currentDensity: "tight",
|
||||
geometry: POINTER,
|
||||
controls,
|
||||
}),
|
||||
).toBe("tight");
|
||||
@@ -76,6 +93,7 @@ describe("composer control layout", () => {
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 312,
|
||||
currentDensity: "tight",
|
||||
geometry: POINTER,
|
||||
controls,
|
||||
}),
|
||||
).toBe("condensed");
|
||||
@@ -85,6 +103,7 @@ describe("composer control layout", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "condensed" as const,
|
||||
geometry: POINTER,
|
||||
};
|
||||
|
||||
expect(
|
||||
@@ -129,6 +148,7 @@ describe("composer control layout", () => {
|
||||
const base = {
|
||||
availableWidth: 430,
|
||||
currentDensity: "full" as const,
|
||||
geometry: POINTER,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
@@ -154,15 +174,97 @@ describe("composer control layout", () => {
|
||||
).toBe("condensed");
|
||||
});
|
||||
|
||||
it("gives every toolbar control one shell and one platform glyph envelope", () => {
|
||||
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
|
||||
it("gives every toolbar control one shell and one glyph per control sizing", () => {
|
||||
expect(POINTER).toEqual({
|
||||
controlSize: 28,
|
||||
glyphSize: 16,
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
});
|
||||
expect(resolveComposerToolbarGlyphSize("web")).toBe(16);
|
||||
expect(resolveComposerToolbarGlyphSize("native")).toBe(20);
|
||||
expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 });
|
||||
});
|
||||
|
||||
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
|
||||
const input = {
|
||||
availableWidth: 420,
|
||||
currentDensity: "full" as const,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }],
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveComposerControlDensity({ ...input, geometry: POINTER })).toBe("full");
|
||||
expect(resolveComposerControlDensity({ ...input, geometry: TOUCH })).toBe("condensed");
|
||||
});
|
||||
|
||||
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
|
||||
// Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide
|
||||
// with an empty draft and 224 once an attachment brings in the send button.
|
||||
const controls = {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
|
||||
fontScale: 1,
|
||||
};
|
||||
|
||||
it("keeps the model family label with icon-only controls on an empty draft", () => {
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 272,
|
||||
currentDensity: "full",
|
||||
geometry: TOUCH,
|
||||
controls,
|
||||
}),
|
||||
).toBe("tight");
|
||||
});
|
||||
|
||||
it("folds thinking, mode and features into the model control once an attachment narrows it", () => {
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 224,
|
||||
currentDensity: "tight",
|
||||
geometry: TOUCH,
|
||||
controls,
|
||||
}),
|
||||
).toBe("compact");
|
||||
});
|
||||
|
||||
it("drops the model label only after the controls are folded", () => {
|
||||
expect(
|
||||
resolveComposerControlDensity({
|
||||
availableWidth: 120,
|
||||
currentDensity: "compact",
|
||||
geometry: TOUCH,
|
||||
controls,
|
||||
}),
|
||||
).toBe("iconOnly");
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a compact composer at the folded toolbar however wide it is", () => {
|
||||
const input = {
|
||||
currentDensity: "compact" as const,
|
||||
geometry: TOUCH,
|
||||
minimumDensity: "compact" as const,
|
||||
controls: {
|
||||
hasModel: true,
|
||||
hasThinking: true,
|
||||
hasMode: true,
|
||||
features: [],
|
||||
fontScale: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact");
|
||||
// The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits.
|
||||
expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact");
|
||||
expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
export type ComposerControlDensity = "full" | "condensed" | "tight";
|
||||
import {
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_SIZES,
|
||||
type ControlSizing,
|
||||
} from "@/components/ui/control-geometry";
|
||||
|
||||
/**
|
||||
* How much of the toolbar shows, widest first. Each step drops something before the next:
|
||||
* carets and labels, then the mode label, then the thinking, mode and feature controls fold into
|
||||
* the model control's sheet (`compact`, the phone toolbar), then the model's own label.
|
||||
*/
|
||||
export type ComposerControlDensity = "full" | "condensed" | "tight" | "compact" | "iconOnly";
|
||||
|
||||
const DENSITY_LADDER: readonly ComposerControlDensity[] = [
|
||||
"full",
|
||||
"condensed",
|
||||
"tight",
|
||||
"compact",
|
||||
"iconOnly",
|
||||
];
|
||||
|
||||
export interface ComposerControlPresence {
|
||||
hasModel: boolean;
|
||||
@@ -15,27 +34,46 @@ export interface ComposerControlPresentation {
|
||||
showThinkingLabel: boolean;
|
||||
showModeLabel: boolean;
|
||||
aggregateFeatures: boolean;
|
||||
/** Thinking, mode and features live in the model control's sheet. */
|
||||
foldControls: boolean;
|
||||
showModelLabel: boolean;
|
||||
}
|
||||
|
||||
export const COMPOSER_TOOLBAR_GEOMETRY = {
|
||||
controlSize: 28,
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
} as const;
|
||||
export interface ComposerToolbarGeometry {
|
||||
controlSize: number;
|
||||
glyphSize: number;
|
||||
controlGap: number;
|
||||
iconLabelGap: number;
|
||||
labelPadding: number;
|
||||
caretSize: number;
|
||||
}
|
||||
|
||||
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
|
||||
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
|
||||
return {
|
||||
controlSize: TOOLBAR_CONTROL_SIZES[sizing],
|
||||
glyphSize: iconSizeFor("md")[sizing],
|
||||
controlGap: 4,
|
||||
iconLabelGap: 4,
|
||||
labelPadding: 8,
|
||||
caretSize: 14,
|
||||
};
|
||||
}
|
||||
|
||||
const DENSITY_HYSTERESIS = 12;
|
||||
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
|
||||
const MODEL_FAMILY_LABEL_WIDTH = 56;
|
||||
/** A thinking label beside the folded model label, such as "Medium". */
|
||||
const THINKING_LABEL_WIDTH = 48;
|
||||
|
||||
function normalizedFontScale(fontScale: number): number {
|
||||
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
|
||||
}
|
||||
|
||||
function sumControlWidths(widths: number[]): number {
|
||||
function sumControlWidths(widths: number[], geometry: ComposerToolbarGeometry): number {
|
||||
if (widths.length === 0) return 0;
|
||||
return (
|
||||
widths.reduce((total, width) => total + width, 0) +
|
||||
(widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap
|
||||
widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * geometry.controlGap
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,92 +81,173 @@ function estimateLabelWidth(label: string, fontScale: number): number {
|
||||
return Array.from(label).length * 7 * fontScale;
|
||||
}
|
||||
|
||||
/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */
|
||||
function labelledChrome(geometry: ComposerToolbarGeometry): number {
|
||||
return geometry.labelPadding * 2 + geometry.glyphSize + geometry.iconLabelGap;
|
||||
}
|
||||
|
||||
function resolveFeatureControlWidth(
|
||||
feature: ComposerFeatureControlPresence,
|
||||
fontScale: number,
|
||||
geometry: ComposerToolbarGeometry,
|
||||
): number {
|
||||
if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize;
|
||||
if (feature.type === "toggle") return geometry.controlSize;
|
||||
return (
|
||||
COMPOSER_TOOLBAR_GEOMETRY.controlSize +
|
||||
COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap +
|
||||
COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 +
|
||||
geometry.controlSize +
|
||||
geometry.iconLabelGap +
|
||||
geometry.labelPadding * 2 +
|
||||
estimateLabelWidth(feature.label, fontScale)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCondensedFloor(controls: ComposerControlPresence): number {
|
||||
function resolveCondensedFloor(
|
||||
controls: ComposerControlPresence,
|
||||
geometry: ComposerToolbarGeometry,
|
||||
): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const chrome = labelledChrome(geometry);
|
||||
const widths: number[] = [];
|
||||
if (controls.hasModel) widths.push(36 + 60 * fontScale);
|
||||
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
if (controls.hasMode) widths.push(36 + 96 * fontScale);
|
||||
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
|
||||
return sumControlWidths(widths);
|
||||
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
|
||||
if (controls.hasThinking) widths.push(geometry.controlSize);
|
||||
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
|
||||
if (controls.features.length > 0) widths.push(geometry.controlSize);
|
||||
return sumControlWidths(widths, geometry);
|
||||
}
|
||||
|
||||
function resolveFullFloor(controls: ComposerControlPresence): number {
|
||||
function resolveFullFloor(
|
||||
controls: ComposerControlPresence,
|
||||
geometry: ComposerToolbarGeometry,
|
||||
): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const chrome = labelledChrome(geometry);
|
||||
const caret = geometry.caretSize;
|
||||
const widths: number[] = [];
|
||||
if (controls.hasModel) widths.push(50 + 70 * fontScale);
|
||||
if (controls.hasThinking) widths.push(54 + 48 * fontScale);
|
||||
if (controls.hasMode) widths.push(54 + 96 * fontScale);
|
||||
if (controls.hasModel) widths.push(chrome + caret + 70 * fontScale);
|
||||
if (controls.hasThinking) widths.push(chrome + geometry.iconLabelGap + caret + 48 * fontScale);
|
||||
if (controls.hasMode) widths.push(chrome + geometry.iconLabelGap + caret + 96 * fontScale);
|
||||
for (const feature of controls.features) {
|
||||
widths.push(resolveFeatureControlWidth(feature, fontScale));
|
||||
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
|
||||
}
|
||||
return sumControlWidths(widths);
|
||||
return sumControlWidths(widths, geometry);
|
||||
}
|
||||
|
||||
/** Model at its family label, with thinking, mode and features as icon-only squares. */
|
||||
function resolveTightFloor(
|
||||
controls: ComposerControlPresence,
|
||||
geometry: ComposerToolbarGeometry,
|
||||
): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const widths: number[] = [];
|
||||
if (controls.hasModel) {
|
||||
widths.push(labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale);
|
||||
}
|
||||
if (controls.hasThinking) widths.push(geometry.controlSize);
|
||||
if (controls.hasMode) widths.push(geometry.controlSize);
|
||||
if (controls.features.length > 0) widths.push(geometry.controlSize);
|
||||
return sumControlWidths(widths, geometry);
|
||||
}
|
||||
|
||||
/** One model control carrying the model family label and the thinking label. */
|
||||
function resolveCompactFloor(
|
||||
controls: ComposerControlPresence,
|
||||
geometry: ComposerToolbarGeometry,
|
||||
): number {
|
||||
const fontScale = normalizedFontScale(controls.fontScale);
|
||||
const thinkingLabel = controls.hasThinking
|
||||
? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale
|
||||
: 0;
|
||||
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel;
|
||||
}
|
||||
|
||||
function resolveDensityFloor(
|
||||
density: ComposerControlDensity,
|
||||
controls: ComposerControlPresence,
|
||||
geometry: ComposerToolbarGeometry,
|
||||
): number {
|
||||
switch (density) {
|
||||
case "full":
|
||||
return resolveFullFloor(controls, geometry);
|
||||
case "condensed":
|
||||
return resolveCondensedFloor(controls, geometry);
|
||||
case "tight":
|
||||
return resolveTightFloor(controls, geometry);
|
||||
case "compact":
|
||||
return resolveCompactFloor(controls, geometry);
|
||||
case "iconOnly":
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest density whose floor fits `availableWidth`. Widening past the current density needs
|
||||
* the floor plus the hysteresis and keeping it tolerates the floor minus it, so a width near a
|
||||
* floor does not flip back and forth. `minimumDensity` is the widest density allowed.
|
||||
*/
|
||||
export function resolveComposerControlDensity(input: {
|
||||
availableWidth: number;
|
||||
currentDensity: ComposerControlDensity;
|
||||
controls: ComposerControlPresence;
|
||||
geometry: ComposerToolbarGeometry;
|
||||
minimumDensity?: ComposerControlDensity;
|
||||
}): ComposerControlDensity {
|
||||
const fullFloor = resolveFullFloor(input.controls);
|
||||
const condensedFloor = resolveCondensedFloor(input.controls);
|
||||
|
||||
if (input.currentDensity === "full") {
|
||||
if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full";
|
||||
return input.availableWidth >= condensedFloor ? "condensed" : "tight";
|
||||
const current = DENSITY_LADDER.indexOf(input.currentDensity);
|
||||
const first = DENSITY_LADDER.indexOf(input.minimumDensity ?? "full");
|
||||
for (let step = first; step < DENSITY_LADDER.length; step += 1) {
|
||||
const density = DENSITY_LADDER[step];
|
||||
const floor = resolveDensityFloor(density, input.controls, input.geometry);
|
||||
let required = floor;
|
||||
if (step < current) required = floor + DENSITY_HYSTERESIS;
|
||||
if (step === current) required = floor - DENSITY_HYSTERESIS;
|
||||
if (input.availableWidth >= required) return density;
|
||||
}
|
||||
|
||||
if (input.currentDensity === "condensed") {
|
||||
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
|
||||
if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight";
|
||||
return "condensed";
|
||||
}
|
||||
|
||||
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
|
||||
if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed";
|
||||
return "tight";
|
||||
return "iconOnly";
|
||||
}
|
||||
|
||||
export function resolveComposerControlPresentation(
|
||||
density: ComposerControlDensity,
|
||||
): ComposerControlPresentation {
|
||||
if (density === "full") {
|
||||
return {
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
};
|
||||
}
|
||||
if (density === "condensed") {
|
||||
return {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
const PRESENTATIONS: Record<ComposerControlDensity, ComposerControlPresentation> = {
|
||||
full: {
|
||||
showCarets: true,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: false,
|
||||
foldControls: false,
|
||||
showModelLabel: true,
|
||||
},
|
||||
condensed: {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: true,
|
||||
aggregateFeatures: true,
|
||||
foldControls: false,
|
||||
showModelLabel: true,
|
||||
},
|
||||
tight: {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
};
|
||||
}
|
||||
foldControls: false,
|
||||
showModelLabel: true,
|
||||
},
|
||||
compact: {
|
||||
showCarets: false,
|
||||
showThinkingLabel: true,
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
foldControls: true,
|
||||
showModelLabel: true,
|
||||
},
|
||||
iconOnly: {
|
||||
showCarets: false,
|
||||
showThinkingLabel: false,
|
||||
showModeLabel: false,
|
||||
aggregateFeatures: true,
|
||||
foldControls: true,
|
||||
showModelLabel: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number {
|
||||
return platform === "native" ? 20 : 16;
|
||||
export function resolveComposerControlPresentation(
|
||||
density: ComposerControlDensity,
|
||||
): ComposerControlPresentation {
|
||||
return PRESENTATIONS[density];
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import { getAgentModeIcon, getAgentModeOptionIcon } from "@/agent-controls/icons";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
interface ModeComboboxOptionProps {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
@@ -34,7 +35,6 @@ interface ModeComboboxOptionProps {
|
||||
onPress: () => void;
|
||||
provider: string;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
function ModeComboboxOption({
|
||||
@@ -44,12 +44,12 @@ function ModeComboboxOption({
|
||||
onPress,
|
||||
provider,
|
||||
providerDefinitions,
|
||||
iconColor,
|
||||
}: ModeComboboxOptionProps) {
|
||||
const IconComponent = getAgentModeOptionIcon(provider, option.id, providerDefinitions);
|
||||
const leadingSlot = useMemo(
|
||||
() => (IconComponent ? <IconComponent size={16} color={iconColor} /> : null),
|
||||
[IconComponent, iconColor],
|
||||
() =>
|
||||
IconComponent ? <DensityIcon icon={IconComponent} size="md" color="foreground" /> : null,
|
||||
[IconComponent],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
@@ -171,10 +171,9 @@ export function AgentModeControl({
|
||||
onPress={args.onPress}
|
||||
provider={provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
iconColor={theme.colors.foreground}
|
||||
/>
|
||||
),
|
||||
[provider, providerDefinitions, theme.colors.foreground],
|
||||
[provider, providerDefinitions],
|
||||
);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
|
||||
const SNAP_POINTS = ["80%", "90%"];
|
||||
const MODEL_LIST_TOP_INSET = 4;
|
||||
@@ -42,6 +43,8 @@ interface CompactModelSheetProps {
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
glyphSize: number;
|
||||
/** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */
|
||||
showLabels: boolean;
|
||||
canSwitchProvider: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -94,6 +97,7 @@ export function CompactModelSheet({
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
glyphSize,
|
||||
showLabels,
|
||||
canSwitchProvider,
|
||||
children,
|
||||
}: CompactModelSheetProps) {
|
||||
@@ -229,14 +233,16 @@ export function CompactModelSheet({
|
||||
open();
|
||||
}, [close, isOpen, open]);
|
||||
|
||||
const iconOnly = !showLabels && hasSelectedProvider;
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType) => [
|
||||
styles.trigger,
|
||||
iconOnly && styles.triggerIconOnly,
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
],
|
||||
[disabled, isOpen],
|
||||
[disabled, iconOnly, isOpen],
|
||||
);
|
||||
const mobileRootContent = useMemo(
|
||||
() => (
|
||||
@@ -280,16 +286,18 @@ export function CompactModelSheet({
|
||||
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
|
||||
</ComposerToolbarGlyph>
|
||||
) : null}
|
||||
<View style={styles.triggerLabels}>
|
||||
<Text style={styles.triggerText} numberOfLines={1}>
|
||||
{shortModelLabel(rootBrowser.triggerLabel)}
|
||||
</Text>
|
||||
{thinkingLabel ? (
|
||||
<Text style={styles.triggerThinking} numberOfLines={1}>
|
||||
{thinkingLabel}
|
||||
{!iconOnly ? (
|
||||
<View style={styles.triggerLabels}>
|
||||
<Text style={styles.triggerText} numberOfLines={1}>
|
||||
{shortModelLabel(rootBrowser.triggerLabel)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{thinkingLabel ? (
|
||||
<Text style={styles.triggerThinking} numberOfLines={1}>
|
||||
{thinkingLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</ComboboxTrigger>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
@@ -374,7 +382,7 @@ export function CompactModelSheet({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
@@ -384,6 +392,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
triggerIconOnly: {
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
flexShrink: 0,
|
||||
paddingHorizontal: 0,
|
||||
justifyContent: "center",
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
@@ -193,10 +198,6 @@ const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
|
||||
function noop() {}
|
||||
const noopCallback = () => {};
|
||||
|
||||
function resolveComposerButtonIconSize(): number {
|
||||
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||
}
|
||||
|
||||
function resolveIsComposerLocked(
|
||||
submitBehavior: "clear" | "preserve-and-lock",
|
||||
isSubmitLoading: boolean,
|
||||
@@ -1281,7 +1282,6 @@ function ComposerContentImpl({
|
||||
}: ComposerContentProps) {
|
||||
const mode = resolveComposerInputMode(inputMode);
|
||||
const { t } = useTranslation();
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
|
||||
@@ -1310,6 +1310,7 @@ function ComposerContentImpl({
|
||||
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const hasFinePointer = useHasFinePointer();
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
@@ -2081,7 +2082,6 @@ function ComposerContentImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
|
||||
const beforeVoiceContent = useMemo(
|
||||
() =>
|
||||
hasAgent ? (
|
||||
@@ -2092,7 +2092,7 @@ function ComposerContentImpl({
|
||||
maxTokens={agentState.contextWindowMaxTokens}
|
||||
usedTokens={agentState.contextWindowUsedTokens}
|
||||
totalCostUsd={agentState.totalCostUsd}
|
||||
glyphSize={contextWindowMeterGlyphSize}
|
||||
glyphSize={buttonIconSize}
|
||||
/>
|
||||
</View>
|
||||
) : null,
|
||||
@@ -2103,7 +2103,7 @@ function ComposerContentImpl({
|
||||
agentState.contextWindowMaxTokens,
|
||||
agentState.contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
contextWindowMeterGlyphSize,
|
||||
buttonIconSize,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2598,8 +2598,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
cancelButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
alignItems: "center",
|
||||
@@ -2612,15 +2612,15 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
} from "react";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
controlSizingForLayout,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
@@ -1282,7 +1287,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const isFullscreen = useComposerFullscreen(editingSession);
|
||||
const [renderedLines, setRenderedLines] = useState(1);
|
||||
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
|
||||
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||
const controlSizing = controlSizingForLayout(isCompact);
|
||||
const buttonIconSize = iconSizeFor("md")[controlSizing];
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||
@@ -2235,6 +2241,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
/>
|
||||
<ComposerInputPresentation
|
||||
fullscreen={isFullscreen}
|
||||
controlSizing={controlSizing}
|
||||
sendIconSize={buttonIconSize}
|
||||
surface={textSurface}
|
||||
toolbar={toolbar}
|
||||
@@ -2384,21 +2391,21 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
attachButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
attachButtonAnchor: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
voiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -2407,8 +2414,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.destructive,
|
||||
},
|
||||
sendButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
@@ -2417,7 +2424,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
sendButtonLabeled: {
|
||||
width: "auto",
|
||||
minWidth: 28,
|
||||
minWidth: TOOLBAR_CONTROL_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
|
||||
@@ -6,14 +6,16 @@ import { Button } from "@/components/ui/button";
|
||||
import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import {
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
TOOLBAR_CONTROL_SIZES,
|
||||
type ControlSizing,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useComposerFullscreenExit } from "./fullscreen-exit";
|
||||
import { resolveGlyphRailOffset } from "./state";
|
||||
|
||||
const CONTROL_SIZE = 28;
|
||||
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
|
||||
|
||||
function keepEditorFocused(event: import("react-native").PointerEvent) {
|
||||
if (isWeb) event.preventDefault();
|
||||
}
|
||||
@@ -27,6 +29,7 @@ function keepEditorFocused(event: import("react-native").PointerEvent) {
|
||||
*/
|
||||
export function ComposerInputPresentation({
|
||||
fullscreen,
|
||||
controlSizing,
|
||||
sendIconSize,
|
||||
surface,
|
||||
toolbar,
|
||||
@@ -42,6 +45,8 @@ export function ComposerInputPresentation({
|
||||
overlay,
|
||||
}: {
|
||||
fullscreen: boolean;
|
||||
/** The sizing the toolbar's stylesheet resolves to; see `resolveControlSizing`. */
|
||||
controlSizing: ControlSizing;
|
||||
sendIconSize: number;
|
||||
surface: ReactNode;
|
||||
toolbar: ReactNode;
|
||||
@@ -61,18 +66,19 @@ export function ComposerInputPresentation({
|
||||
// action (Escape, hardware back) belongs only to the pane the user is in.
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit);
|
||||
const glyphSize = iconSizeFor("md")[controlSizing];
|
||||
const railOffset = resolveGlyphRailOffset({
|
||||
controlSize: CONTROL_SIZE,
|
||||
controlSize: TOOLBAR_CONTROL_SIZES[controlSizing],
|
||||
sendIconSize,
|
||||
glyphIconSize: FULLSCREEN_GLYPH_SIZE,
|
||||
glyphIconSize: glyphSize,
|
||||
});
|
||||
const maximizeIcon = useCallback(
|
||||
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
|
||||
[],
|
||||
(color: string) => <Maximize2 color={color} size={glyphSize} />,
|
||||
[glyphSize],
|
||||
);
|
||||
const minimizeIcon = useCallback(
|
||||
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
|
||||
[],
|
||||
(color: string) => <Minimize2 color={color} size={glyphSize} />,
|
||||
[glyphSize],
|
||||
);
|
||||
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
|
||||
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
|
||||
@@ -141,8 +147,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
inline: { flexShrink: 1, position: "relative" },
|
||||
inlineTextSurface: { flexShrink: 1, position: "relative" },
|
||||
iconButton: {
|
||||
width: CONTROL_SIZE,
|
||||
height: CONTROL_SIZE,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
@@ -150,8 +156,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
enterButton: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: CONTROL_SIZE,
|
||||
height: CONTROL_SIZE,
|
||||
width: TOOLBAR_CONTROL_HEIGHT,
|
||||
height: TOOLBAR_CONTROL_HEIGHT,
|
||||
padding: 0,
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { bySizing } from "@/components/ui/control-geometry";
|
||||
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
@@ -343,16 +344,17 @@ const styles = StyleSheet.create((theme) => {
|
||||
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
|
||||
// inside it, and tall enough that revealing an action button cannot resize the row.
|
||||
//
|
||||
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
|
||||
// on compact; a pointer does not, and the popover keeps its density.
|
||||
// Touch and pointer sizing differ here and only here. A finger needs the row a menu row gets
|
||||
// on touch (`MENU_ITEM_HEIGHT` is always its touch value at `xs`); a pointer does not, and the
|
||||
// popover keeps its density.
|
||||
const trackRow = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
|
||||
minHeight: bySizing({ touch: MENU_ITEM_HEIGHT.xs, pointer: 32 }),
|
||||
marginHorizontal: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
|
||||
paddingVertical: bySizing({ touch: theme.spacing[2], pointer: theme.spacing[1] }),
|
||||
borderRadius: theme.borderRadius.md,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { bySizing, TOUCH_TARGET } from "@/components/ui/control-sizing";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
|
||||
// Shared header inner height (excluding safe area insets and border)
|
||||
// A bar of icon buttons: touch-sized it is one touch target tall, like an iOS navigation bar.
|
||||
const BAR_HEIGHT = { touch: TOUCH_TARGET, pointer: 36 };
|
||||
|
||||
// Shared header inner height from `md` up (excluding safe area insets and border).
|
||||
// Used by both agent header (ScreenHeader) and explorer sidebar header
|
||||
// This ensures both headers have the same visual height
|
||||
export const HEADER_INNER_HEIGHT = 36;
|
||||
export const HEADER_INNER_HEIGHT = bySizing(BAR_HEIGHT).md;
|
||||
export const HEADER_INNER_HEIGHT_MOBILE = 56;
|
||||
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
|
||||
/** The tab rail and pane toolbars under the header. A breakpoint value for stylesheets. */
|
||||
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = bySizing(BAR_HEIGHT);
|
||||
export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
|
||||
// Measured composer width; independent of the window's compact/wide breakpoint.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
|
||||
import { useGitActionRunner } from "@/git/use-actions";
|
||||
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
@@ -272,10 +272,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
height: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -296,10 +293,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
xs: 48,
|
||||
md: 42,
|
||||
},
|
||||
height: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -330,24 +324,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
transform: [{ scale: 0.8 }],
|
||||
},
|
||||
splitButtonCaret: {
|
||||
width: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
width: HEADER_CONTROL_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
borderLeftColor: theme.colors.borderAccent,
|
||||
},
|
||||
iconButton: {
|
||||
width: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
height: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
width: HEADER_CONTROL_HEIGHT,
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { CountedCheckPresentation } from "@/git/check-presentation";
|
||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
CheckPresentationIcon,
|
||||
getCheckPresentationTone,
|
||||
@@ -164,10 +164,10 @@ export const sectionKitStyles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
// Fixed, not minHeight plus padding. A row's trailing slot holds an sm Button
|
||||
// (28pt), which stacked on the vertical padding and made rows with an
|
||||
// , which stacked on the vertical padding and made rows with an
|
||||
// "Add to chat" button taller than rows without one. The row is single-line by
|
||||
// construction, so it declares a height the button fits inside instead.
|
||||
height: CONTROL_HEIGHTS.sm + theme.spacing[1],
|
||||
height: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[1]),
|
||||
},
|
||||
checkName: {
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -18,6 +18,16 @@ export function hasFinePointer(): boolean {
|
||||
return getFinePointerQueryList()?.matches ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the device has a touch screen, even while a mouse or keyboard is attached to it:
|
||||
* the primary pointer is coarse, or the screen reports touch points. Always true on native.
|
||||
*/
|
||||
export function hasTouchScreen(): boolean {
|
||||
if (!isWeb) return true;
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia("(pointer: coarse)").matches || window.navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive `hasFinePointer()` — re-renders when the user switches between a
|
||||
* mouse and touch (detachable keyboards, tablet mode).
|
||||
|
||||
@@ -656,8 +656,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: { xs: theme.spacing[1], md: theme.spacing[2] },
|
||||
},
|
||||
headerButton: {
|
||||
height: { xs: 32, md: HEADER_CONTROL_HEIGHT },
|
||||
minWidth: { xs: 32, md: HEADER_CONTROL_HEIGHT },
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
minWidth: HEADER_CONTROL_HEIGHT,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -125,7 +125,7 @@ import {
|
||||
SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { densityIconBox, densityIconSize } from "@/styles/density";
|
||||
import { density, densityIconBox, densityIconSize } from "@/styles/density";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
type EnableBuiltInDaemonOption,
|
||||
@@ -1523,7 +1523,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: { xs: theme.spacing[3], md: theme.spacing[2] },
|
||||
minHeight: { xs: 52, md: 28 },
|
||||
minHeight: { xs: 52, md: density.tightRowHeight.md },
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] },
|
||||
borderRadius: { xs: 0, md: theme.borderRadius.lg },
|
||||
@@ -1549,7 +1549,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: { xs: theme.spacing[3], md: theme.spacing[2] },
|
||||
minHeight: { xs: 52, md: 28 },
|
||||
minHeight: { xs: 52, md: density.tightRowHeight.md },
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] },
|
||||
borderRadius: { xs: 0, md: theme.borderRadius.lg },
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
WorkspaceScriptLinkTarget,
|
||||
} from "@/utils/workspace-script-links";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-color";
|
||||
import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks";
|
||||
import { scriptToneColorMapping } from "./script-status";
|
||||
@@ -531,10 +531,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
height: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
|
||||
@@ -82,6 +82,7 @@ import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
|
||||
import {
|
||||
paneContentToolbarIconSize,
|
||||
paneContentToolbarTrailingPadding,
|
||||
ToolbarButton,
|
||||
ToolbarControls,
|
||||
@@ -115,6 +116,8 @@ const TAB_ICON_WIDTH = 14;
|
||||
const TAB_CONTENT_GAP = 4;
|
||||
const TAB_DROP_INDICATOR_WIDTH = 4;
|
||||
const TAB_MODIFIED_DOT_SIZE = 8;
|
||||
// The tab rail only renders in the wide layout, so the device alone picks its glyph size.
|
||||
const TAB_RAIL_GLYPH_SIZE = paneContentToolbarIconSize(false);
|
||||
const TAB_MIN_WIDTH = 64;
|
||||
const TAB_MAX_WIDTH = 160;
|
||||
const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0;
|
||||
@@ -249,7 +252,7 @@ function WorkspaceNewTabButton({
|
||||
testID="workspace-new-tab-button"
|
||||
style={placement === "inline" ? styles.inlineNewTabButton : undefined}
|
||||
>
|
||||
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
|
||||
<ThemedPlus size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
|
||||
</ToolbarButton>
|
||||
<WorkspaceNewTabMenuContent
|
||||
serverId={serverId}
|
||||
@@ -298,11 +301,11 @@ function WorkspacePaneToolbarActions({
|
||||
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
||||
const splitActionsVisible = showSplitActions && Boolean(onSplitRight && onSplitDown);
|
||||
const splitRightLeading = useMemo(
|
||||
() => <ThemedColumns2 size={14} uniProps={extraMutedColorMapping} />,
|
||||
() => <ThemedColumns2 size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />,
|
||||
[],
|
||||
);
|
||||
const splitDownLeading = useMemo(
|
||||
() => <ThemedRows2 size={14} uniProps={extraMutedColorMapping} />,
|
||||
() => <ThemedRows2 size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />,
|
||||
[],
|
||||
);
|
||||
const splitRightTrailing = useMemo(
|
||||
@@ -341,9 +344,9 @@ function WorkspacePaneToolbarActions({
|
||||
onPress={onTogglePaneMaximized}
|
||||
>
|
||||
{paneMaximized ? (
|
||||
<ThemedMinimize size={14} uniProps={extraMutedColorMapping} />
|
||||
<ThemedMinimize size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedMaximize size={14} uniProps={extraMutedColorMapping} />
|
||||
<ThemedMaximize size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
|
||||
)}
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
@@ -354,7 +357,7 @@ function WorkspacePaneToolbarActions({
|
||||
label={t("workspace.git.actions.moreActions")}
|
||||
testID="workspace-split-pane-menu"
|
||||
>
|
||||
<ThemedEllipsis size={14} uniProps={extraMutedColorMapping} />
|
||||
<ThemedEllipsis size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
|
||||
</ToolbarButton>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
@@ -409,7 +412,7 @@ function WorkspaceExitFocusModeButton({
|
||||
testID="workspace-exit-focus-mode"
|
||||
onPress={onPress}
|
||||
>
|
||||
<ThemedX size={14} uniProps={mutedColorMapping} />
|
||||
<ThemedX size={TAB_RAIL_GLYPH_SIZE} uniProps={mutedColorMapping} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import {
|
||||
bySizing,
|
||||
iconSizeFor,
|
||||
TOOLBAR_CONTROL_HEIGHT,
|
||||
TOUCH_TARGET,
|
||||
type IconSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import { isCompactBreakpoint } from "@/constants/layout";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
* Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact
|
||||
* layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay
|
||||
* desktop-sized everywhere, so a surface migrates by swapping its sizes for these.
|
||||
* Density sizes for surfaces that opt into touch sizing: rows and icons take their touch sizes
|
||||
* where controls do (see `resolveControlSizing`) and their pointer sizes elsewhere. Plain theme
|
||||
* tokens stay pointer-sized everywhere, so a surface migrates by swapping its sizes for these.
|
||||
*
|
||||
* Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes
|
||||
* are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping.
|
||||
*/
|
||||
|
||||
export type DensityIconSize = keyof typeof ICON_SIZE;
|
||||
|
||||
const COMPACT_ICON_SIZE: Record<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
|
||||
|
||||
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
|
||||
function byLayout<T>(compact: T, desktop: T) {
|
||||
return { xs: compact, md: desktop };
|
||||
}
|
||||
export type DensityIconSize = IconSize;
|
||||
|
||||
export const density = {
|
||||
/** A list row such as a sidebar entry. */
|
||||
rowHeight: byLayout(44, 36),
|
||||
/** A row in a tight group, such as the sidebar's navigation rows. */
|
||||
tightRowHeight: byLayout(36, 28),
|
||||
rowHeight: bySizing({ touch: TOUCH_TARGET, pointer: 36 }),
|
||||
/** A row in a tight group, such as the sidebar's navigation rows. Touch-sized it is a full row. */
|
||||
tightRowHeight: bySizing({ touch: TOUCH_TARGET, pointer: 28 }),
|
||||
/** A square icon-only button that sits in a row or toolbar. */
|
||||
iconButton: byLayout(44, 28),
|
||||
/** The space between two blocks of rich text, such as Markdown paragraphs, code and images. */
|
||||
blockGap: byLayout(12, 16),
|
||||
iconButton: TOOLBAR_CONTROL_HEIGHT,
|
||||
/**
|
||||
* The space between two blocks of rich text, such as Markdown paragraphs, code and images.
|
||||
* Reading rhythm, not a touch target, so it follows the layout alone.
|
||||
*/
|
||||
blockGap: { xs: 12, md: 16 },
|
||||
};
|
||||
|
||||
/** Resolves a density value for a prop, which Unistyles cannot resolve on its own. */
|
||||
@@ -37,7 +40,7 @@ export function resolveDensity<T>(rt: { breakpoint?: string }, value: { xs: T; m
|
||||
|
||||
/** The width or height of a slot that holds an icon of `size`. */
|
||||
export function densityIconBox(size: DensityIconSize) {
|
||||
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
|
||||
return bySizing(iconSizeFor(size));
|
||||
}
|
||||
|
||||
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { getForgePresentation } from "@/git/forge";
|
||||
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
|
||||
interface WorkspaceOpenInEditorButtonProps {
|
||||
@@ -297,10 +297,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
splitButton: {
|
||||
height: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
height: HEADER_CONTROL_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -317,10 +314,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
splitButtonPrimaryIconOnly: {
|
||||
width: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
width: HEADER_CONTROL_HEIGHT,
|
||||
paddingHorizontal: 0,
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
@@ -348,10 +342,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
transform: [{ scale: 0.8 }],
|
||||
},
|
||||
splitButtonCaret: {
|
||||
width: {
|
||||
xs: CONTROL_HEIGHTS.sm,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
width: HEADER_CONTROL_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderLeftWidth: theme.borderWidth[1],
|
||||
|
||||
Reference in New Issue
Block a user