mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add appearance settings for theme, fonts, and syntax highlighting (#1236)
* Add appearance settings for theme, fonts, and syntax highlighting New Appearance section in Settings to configure the app theme, UI and mono font family + size, and a syntax-highlighting theme chosen independently of light/dark. Font family fields default to empty and show the active system stack as the placeholder; a live split-diff preview reflects the choices as they change. The theme picker moves here from General. Preferences are client-only (AsyncStorage) and applied at runtime by patching every registered Unistyles theme via UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint with no per-component preference reads and no useUnistyles. No daemon/protocol change. * Expand syntax themes and move the preview under the chooser Replace the small fixed syntax-theme set with eight popular themes — GitHub, Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a compact per-theme role palette. The app theme and syntax theme stay separate; the only coupling is the light/dark axis: a theme with both variants uses its light palette on a light app and its dark palette on a dark app, while the code frame (gutter, line numbers, background) keeps following the app theme. Default is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match). Move the live preview directly under the syntax-theme chooser, drop the "preview.ts" filename header, and use a clearer code snippet for it. * Update settings e2e for the moved theme picker and Appearance section The theme picker moved from General to the new Appearance section, so the General-content assertion no longer finds "Theme" — point it at "Default send" (which stays in General) and add Appearance to the e2e section map plus an expectAppearanceContent check, and exercise the Appearance section in the sidebar-navigation test. * Render the appearance preview as a unified diff Switch the live preview from side-by-side columns to a single unified diff: unchanged context lines plus "-"/"+" rows for the change, matching how diffs read elsewhere in the app. Same snippet, mono font, syntax colors, and live code-font draft behavior. * Fix code font size change shrinking all UI text applyAppearance scaled the live (already-patched) theme ramp instead of the authored FONT_SIZE ramp, so it was not idempotent: every appearance change re-scaled the current sizes, compounding the whole-UI font scale. With any non-default UI size, changing the code size — or any setting — shrank (or grew) all app text, and it persisted into the theme. Build the ramp from the canonical FONT_SIZE ramp each apply (code size still set absolutely), so applies are idempotent and a code-size change never touches the UI ramp. * Apply the interface font across the app on web react-native-web stamps a default font onto every text element, so the interface font only reached the few components that set a font explicitly. Inject one rule that points all text at a CSS variable (updated live as the setting changes), with high specificity (1,2,0) so it deterministically beats RN-web's base font and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded), so they keep their code font. Web-only: React Native has no global font cascade, so native still applies the UI font only where components opt in. Inline `code` within chat prose is a known minor gap (its render paths aren't tagged yet). * Keep preview code in the code font * Apply code font settings to terminals * Fix settings host picker import after rebase * Format terminal webview bundle
This commit is contained in:
@@ -289,6 +289,20 @@ That brief transition is expected with the current storage model. It makes track
|
||||
|
||||
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
|
||||
|
||||
## Runtime Theme Patching For User Preferences
|
||||
|
||||
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||
|
||||
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
||||
|
||||
Gotchas:
|
||||
|
||||
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Debugging
|
||||
|
||||
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getServerId } from "./server-id";
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
@@ -180,7 +181,11 @@ export async function expectAboutContent(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectAppearanceContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Highlight theme", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
expectDiagnosticsContent,
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
expectAppearanceContent,
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
@@ -43,6 +44,10 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHeader(page, "General");
|
||||
await expectGeneralContent(page);
|
||||
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHeader(page, "Appearance");
|
||||
await expectAppearanceContent(page);
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -625,6 +626,27 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
// Apply font / size / syntax appearance settings on mount and when they change.
|
||||
// Sibling to the theme effect above; order is irrelevant because both patch all
|
||||
// six registered theme keys, so the active key is always current.
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
applyAppearance({
|
||||
uiFontFamily: settings.uiFontFamily,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
uiFontSize: settings.uiFontSize,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
syntaxTheme: settings.syntaxTheme,
|
||||
});
|
||||
}, [
|
||||
settingsLoading,
|
||||
settings.uiFontFamily,
|
||||
settings.monoFontFamily,
|
||||
settings.uiFontSize,
|
||||
settings.codeFontSize,
|
||||
settings.syntaxTheme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync enabled={!settingsLoading} />
|
||||
|
||||
@@ -2,13 +2,13 @@ import React from "react";
|
||||
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { diffLinePrefix } from "@/utils/diff-highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
@@ -165,7 +165,7 @@ export function DiffViewer({
|
||||
}
|
||||
|
||||
const lines = (
|
||||
<View style={linesContainerStyle}>
|
||||
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
|
||||
{keyedDiffLines.map(({ key, line }) => (
|
||||
<DiffLineRow key={key} line={line} />
|
||||
))}
|
||||
@@ -226,7 +226,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
...(isWeb
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
@@ -1157,7 +1156,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
codeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
@@ -19,6 +18,7 @@ import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
@@ -172,14 +172,14 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
gutterText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
opacity: 0.4,
|
||||
userSelect: "none",
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
flex: 1,
|
||||
@@ -296,7 +296,7 @@ function FilePreviewBody({
|
||||
lineNumber: index + 1,
|
||||
}));
|
||||
const codeLines = (
|
||||
<View>
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
||||
<CodeLine
|
||||
key={key}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
@@ -75,6 +76,7 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache";
|
||||
|
||||
@@ -56,7 +56,7 @@ const GutteredLine = React.memo(function GutteredLine({
|
||||
export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
|
||||
if (startLine === undefined) {
|
||||
return (
|
||||
<View>
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{lines.map((line) => (
|
||||
<ContentLine key={line.key} line={line} />
|
||||
))}
|
||||
@@ -67,7 +67,7 @@ export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
|
||||
const lastLineNumber = startLine + lines.length - 1;
|
||||
const digits = Math.max(2, String(lastLineNumber).length);
|
||||
return (
|
||||
<View>
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{lines.map((line, index) => (
|
||||
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
|
||||
))}
|
||||
@@ -81,7 +81,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: CODE_LINE_HEIGHT,
|
||||
},
|
||||
gutterText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -90,7 +90,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
|
||||
@@ -62,7 +62,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -1860,13 +1860,13 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
color: theme.colors.foreground,
|
||||
@@ -1976,7 +1976,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
metadataText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.code,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
lineHeight: 16,
|
||||
},
|
||||
}));
|
||||
@@ -2072,7 +2072,7 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
</View>
|
||||
</View>
|
||||
{isExpanded && metadata && (
|
||||
<View style={activityLogStylesheet.metadataContainer}>
|
||||
<View style={activityLogStylesheet.metadataContainer} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text style={activityLogStylesheet.metadataText}>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</Text>
|
||||
@@ -2108,7 +2108,7 @@ const compactionStylesheet = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
text: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: 13,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
@@ -52,7 +52,12 @@ function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
{model.description ? (
|
||||
@@ -89,7 +94,12 @@ function CustomModelRow({
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
<View style={sheetStyles.modelRowFiller} />
|
||||
@@ -307,7 +317,7 @@ function DiagnosticSubSheet({
|
||||
body = (
|
||||
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||
<Text style={sheetStyles.codeText} selectable>
|
||||
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -607,7 +617,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
monoHint: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
@@ -716,7 +726,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
|
||||
@@ -45,6 +45,8 @@ interface TerminalEmulatorProps {
|
||||
testId?: string;
|
||||
xtermTheme?: ITheme;
|
||||
scrollbackLines: number;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
@@ -81,6 +83,8 @@ type BridgeInboundMessage =
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
@@ -93,6 +97,7 @@ type BridgeInboundMessage =
|
||||
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
|
||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
||||
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
|
||||
| {
|
||||
@@ -160,6 +165,8 @@ function createMountMessage(input: {
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}): BridgeInboundMessage {
|
||||
@@ -169,6 +176,8 @@ function createMountMessage(input: {
|
||||
initialSnapshot: input.initialSnapshot,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
theme: input.theme,
|
||||
fontFamily: input.fontFamily,
|
||||
fontSize: input.fontSize,
|
||||
pendingModifiers: input.pendingModifiers,
|
||||
swipeGesturesEnabled: input.swipeGesturesEnabled,
|
||||
};
|
||||
@@ -184,6 +193,8 @@ export default function TerminalEmulator({
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
@@ -218,6 +229,8 @@ export default function TerminalEmulator({
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
});
|
||||
@@ -226,6 +239,8 @@ export default function TerminalEmulator({
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
};
|
||||
@@ -393,6 +408,11 @@ export default function TerminalEmulator({
|
||||
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
|
||||
}, [scrollbackLines, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
|
||||
}, [fontFamily, fontSize, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountedStreamKeyRef.current) return;
|
||||
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
|
||||
|
||||
@@ -134,6 +134,8 @@ interface TerminalEmulatorProps {
|
||||
testId?: string;
|
||||
xtermTheme?: ITheme;
|
||||
scrollbackLines: number;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
@@ -215,6 +217,8 @@ export default function TerminalEmulator({
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
@@ -236,8 +240,12 @@ export default function TerminalEmulator({
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||
const mountedThemeRef = useRef<ITheme>(xtermTheme);
|
||||
const fontFamilyRef = useRef(fontFamily);
|
||||
const fontSizeRef = useRef(fontSize);
|
||||
const scrollbackLinesRef = useRef(scrollbackLines);
|
||||
scrollbackLinesRef.current = scrollbackLines;
|
||||
fontFamilyRef.current = fontFamily;
|
||||
fontSizeRef.current = fontSize;
|
||||
const viewportRef = useRef<HTMLElement | null>(null);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
@@ -486,6 +494,8 @@ export default function TerminalEmulator({
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
scrollback: scrollbackLinesRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
fontFamily: fontFamilyRef.current,
|
||||
fontSize: fontSizeRef.current,
|
||||
});
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||
|
||||
@@ -525,6 +535,10 @@ export default function TerminalEmulator({
|
||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||
}, [pendingModifiers]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setFont({ fontFamily, fontSize });
|
||||
}, [fontFamily, fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) {
|
||||
return () => {};
|
||||
|
||||
@@ -173,6 +173,10 @@ export function TerminalPane({
|
||||
const { theme } = useUnistyles();
|
||||
const { settings } = useAppSettings();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||
const terminalFontFamily = useMemo(() => {
|
||||
const trimmed = settings.monoFontFamily.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}, [settings.monoFontFamily]);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
@@ -763,6 +767,8 @@ export function TerminalPane({
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
scrollbackLines={settings.terminalScrollbackLines}
|
||||
fontFamily={terminalFontFamily}
|
||||
fontSize={settings.codeFontSize}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
} from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { highlightDiffLines } from "@/utils/diff-highlight";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
|
||||
import { HighlightedLines } from "./highlighted-content";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
@@ -179,7 +179,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text style={styles.shellPrompt}>$ </Text>
|
||||
{normalizedCommand}
|
||||
@@ -224,7 +224,7 @@ function WorktreeSetupDetailSection({
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
|
||||
</Text>
|
||||
@@ -383,7 +383,7 @@ function SubAgentDetailSection({
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
{childSessionId ? (
|
||||
<Text selectable style={styles.subAgentSessionText}>
|
||||
session {childSessionId}
|
||||
@@ -466,7 +466,7 @@ function ScrollableTextSection({
|
||||
{keyedLines ? (
|
||||
<HighlightedLines lines={keyedLines} startLine={startLine} />
|
||||
) : (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{content}
|
||||
</Text>
|
||||
)}
|
||||
@@ -498,7 +498,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{result ? `${url}\n\n${result}` : url}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -542,7 +542,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -553,7 +553,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
if (detail.filePaths && detail.filePaths.length > 0) {
|
||||
out.push(
|
||||
<View key="search-files" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.filePaths.join("\n")}
|
||||
</Text>
|
||||
</View>,
|
||||
@@ -562,7 +562,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
if (detail.webResults && detail.webResults.length > 0) {
|
||||
out.push(
|
||||
<View key="search-web-results" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
|
||||
</Text>
|
||||
</View>,
|
||||
@@ -571,7 +571,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
if (detail.annotations && detail.annotations.length > 0) {
|
||||
out.push(
|
||||
<View key="search-annotations" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.annotations.join("\n\n")}
|
||||
</Text>
|
||||
</View>,
|
||||
@@ -638,7 +638,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{value}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -738,7 +738,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={SCROLL_TEXT_ERROR_STYLE}>
|
||||
<Text selectable style={SCROLL_TEXT_ERROR_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
||||
{errorText}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -823,7 +823,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
plainText: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 22,
|
||||
@@ -876,7 +876,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
padding: insets.padding,
|
||||
},
|
||||
scrollText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
@@ -891,7 +891,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
subAgentSessionText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
@@ -907,13 +907,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
subAgentActionTool: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
},
|
||||
subAgentActionSummary: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const tintColorLight = "#0a7ea4";
|
||||
const tintColorDark = "#fff";
|
||||
|
||||
@@ -26,28 +24,3 @@ export const Colors = {
|
||||
tabIconSelected: tintColorDark,
|
||||
},
|
||||
};
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: "system-ui",
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: "ui-serif",
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: "ui-rounded",
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: "ui-monospace",
|
||||
},
|
||||
default: {
|
||||
sans: "normal",
|
||||
serif: "serif",
|
||||
rounded: "normal",
|
||||
mono: "monospace",
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
|
||||
type DesktopDaemonSettings = DesktopSettings["daemon"];
|
||||
|
||||
@@ -133,7 +134,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -163,7 +164,7 @@ function DaemonCliStatusModal({
|
||||
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{cliStatusOutput ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
|
||||
@@ -57,8 +57,8 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { DiffScroll } from "@/components/diff-scroll";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
||||
import {
|
||||
buildSplitDiffRows,
|
||||
@@ -955,7 +955,7 @@ function DiffFileBody({
|
||||
if (layout === "split") {
|
||||
const rows = buildSplitDiffRows(file);
|
||||
return (
|
||||
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE}>
|
||||
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
||||
<SplitDiffColumn
|
||||
rows={rows}
|
||||
side="left"
|
||||
@@ -979,7 +979,7 @@ function DiffFileBody({
|
||||
|
||||
if (wrapLines) {
|
||||
return (
|
||||
<View style={styles.diffContent}>
|
||||
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
|
||||
<View style={styles.linesContainer}>
|
||||
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
|
||||
<View key={key}>
|
||||
@@ -1006,7 +1006,7 @@ function DiffFileBody({
|
||||
const textViewportWidth =
|
||||
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
|
||||
return (
|
||||
<View style={DIFF_CONTENT_ROW_STYLE}>
|
||||
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
||||
<View style={styles.gutterColumn}>
|
||||
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
|
||||
<View key={key}>
|
||||
@@ -2557,7 +2557,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.lineHeight.diff,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
userSelect: "none",
|
||||
},
|
||||
@@ -2572,7 +2572,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[3],
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.lineHeight.diff,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
color: theme.colors.foreground,
|
||||
userSelect: "text",
|
||||
},
|
||||
|
||||
@@ -14,12 +14,20 @@ import {
|
||||
APP_SETTINGS_QUERY_KEY,
|
||||
DEFAULT_APP_SETTINGS,
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
DEFAULT_CODE_FONT_SIZE,
|
||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
DEFAULT_UI_FONT_SIZE,
|
||||
MAX_CODE_FONT_SIZE,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_UI_FONT_SIZE,
|
||||
MIN_CODE_FONT_SIZE,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MIN_UI_FONT_SIZE,
|
||||
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
|
||||
loadSettingsFromStorage as loadSettingsFromStoragePure,
|
||||
parseClampedFontSize,
|
||||
parseTerminalScrollbackLines,
|
||||
sanitizeFontFamily,
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type DesktopSettingsBridge,
|
||||
@@ -35,10 +43,18 @@ export {
|
||||
APP_SETTINGS_KEY,
|
||||
DEFAULT_APP_SETTINGS,
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
DEFAULT_CODE_FONT_SIZE,
|
||||
DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
DEFAULT_UI_FONT_SIZE,
|
||||
MAX_CODE_FONT_SIZE,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
MAX_UI_FONT_SIZE,
|
||||
MIN_CODE_FONT_SIZE,
|
||||
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||
MIN_UI_FONT_SIZE,
|
||||
parseClampedFontSize,
|
||||
parseTerminalScrollbackLines,
|
||||
sanitizeFontFamily,
|
||||
};
|
||||
export type {
|
||||
AppSettings,
|
||||
@@ -136,6 +152,21 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (updates.terminalScrollbackLines !== undefined) {
|
||||
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
|
||||
}
|
||||
if (updates.uiFontFamily !== undefined) {
|
||||
appUpdates.uiFontFamily = updates.uiFontFamily;
|
||||
}
|
||||
if (updates.monoFontFamily !== undefined) {
|
||||
appUpdates.monoFontFamily = updates.monoFontFamily;
|
||||
}
|
||||
if (updates.uiFontSize !== undefined) {
|
||||
appUpdates.uiFontSize = updates.uiFontSize;
|
||||
}
|
||||
if (updates.codeFontSize !== undefined) {
|
||||
appUpdates.codeFontSize = updates.codeFontSize;
|
||||
}
|
||||
if (updates.syntaxTheme !== undefined) {
|
||||
appUpdates.syntaxTheme = updates.syntaxTheme;
|
||||
}
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
|
||||
@@ -4,8 +4,11 @@ import {
|
||||
APP_SETTINGS_KEY,
|
||||
DEFAULT_APP_SETTINGS,
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
DEFAULT_CODE_FONT_SIZE,
|
||||
DEFAULT_UI_FONT_SIZE,
|
||||
loadAppSettingsFromStorage,
|
||||
loadSettingsFromStorage,
|
||||
parseClampedFontSize,
|
||||
parseTerminalScrollbackLines,
|
||||
saveAppSettings,
|
||||
type SettingsDeps,
|
||||
@@ -87,10 +90,8 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result).toEqual({
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
});
|
||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
|
||||
});
|
||||
@@ -126,12 +127,8 @@ describe("loadSettingsFromStorage", () => {
|
||||
const result = await loadSettingsFromStorage(deps);
|
||||
|
||||
expect(result).toEqual({
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,10 +169,8 @@ describe("loadSettingsFromStorage", () => {
|
||||
{ manageBuiltInDaemon: false, releaseChannel: "beta" },
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -194,12 +189,8 @@ describe("loadSettingsFromStorage", () => {
|
||||
|
||||
expect(desktop.migrationsApplied).toEqual([]);
|
||||
expect(result).toEqual({
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
manageBuiltInDaemon: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -234,3 +225,146 @@ describe("parseTerminalScrollbackLines", () => {
|
||||
expect(parseTerminalScrollbackLines("abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("appearance settings", () => {
|
||||
it("defaults the appearance fields when an old blob omits them", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ theme: "dark" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.uiFontFamily).toBe("");
|
||||
expect(result.monoFontFamily).toBe("");
|
||||
expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
|
||||
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
|
||||
expect(result.syntaxTheme).toBe("github");
|
||||
});
|
||||
|
||||
it("clamps the UI font size into range and rejects non-numeric values", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 999 }),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(deps)).uiFontSize).toBe(24);
|
||||
|
||||
const low = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 8 }),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(low)).uiFontSize).toBe(11);
|
||||
|
||||
const bogus = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: "abc" }),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(bogus)).uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("clamps the code font size into range and rejects non-numeric values", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 999 }),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(deps)).codeFontSize).toBe(22);
|
||||
|
||||
const low = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 8 }),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(low)).codeFontSize).toBe(9);
|
||||
|
||||
const bogus = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: "abc" }),
|
||||
}),
|
||||
});
|
||||
expect((await loadAppSettingsFromStorage(bogus)).codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
|
||||
});
|
||||
|
||||
it("trims an accepted font family", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: " Menlo " }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("Menlo");
|
||||
});
|
||||
|
||||
it("keeps an explicit empty font family as the default sentinel", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
|
||||
});
|
||||
|
||||
it("rejects a font family containing CSS-breaking characters", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a;b{c}" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
|
||||
});
|
||||
|
||||
it("rejects an over-length font family", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a".repeat(201) }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
|
||||
});
|
||||
|
||||
it("accepts a known syntax theme id", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "dracula" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("dracula");
|
||||
});
|
||||
|
||||
it("drops a removed syntax theme id back to the default", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "auto" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github");
|
||||
});
|
||||
|
||||
it("drops an unknown syntax theme id back to the default", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "bogus" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("github");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClampedFontSize", () => {
|
||||
it("clamps to the bounds and rejects non-numeric strings", () => {
|
||||
expect(parseClampedFontSize(999, { min: 11, max: 24 })).toBe(24);
|
||||
expect(parseClampedFontSize(8, { min: 11, max: 24 })).toBe(11);
|
||||
expect(parseClampedFontSize("15", { min: 11, max: 24 })).toBe(15);
|
||||
expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
@@ -15,12 +16,24 @@ const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app"
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
export const DEFAULT_UI_FONT_SIZE = 16; // == FONT_SIZE.base
|
||||
export const MIN_UI_FONT_SIZE = 11;
|
||||
export const MAX_UI_FONT_SIZE = 24;
|
||||
export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code
|
||||
export const MIN_CODE_FONT_SIZE = 9;
|
||||
export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe
|
||||
export const MAX_FONT_FAMILY_LENGTH = 200;
|
||||
|
||||
export interface AppSettings {
|
||||
theme: ThemeName | "auto";
|
||||
sendBehavior: SendBehavior;
|
||||
serviceUrlBehavior: ServiceUrlBehavior;
|
||||
terminalScrollbackLines: number;
|
||||
uiFontFamily: string; // "" = platform default UI stack
|
||||
monoFontFamily: string; // "" = platform default mono stack
|
||||
uiFontSize: number; // clamped px, default 16
|
||||
codeFontSize: number; // clamped px, default 12
|
||||
syntaxTheme: SyntaxThemeId; // default "auto"
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
@@ -33,6 +46,11 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiFontSize: DEFAULT_UI_FONT_SIZE,
|
||||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||||
syntaxTheme: "github",
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
@@ -144,6 +162,31 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
if (terminalScrollbackLines !== null) {
|
||||
result.terminalScrollbackLines = terminalScrollbackLines;
|
||||
}
|
||||
const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily);
|
||||
if (uiFontFamily !== null) {
|
||||
result.uiFontFamily = uiFontFamily;
|
||||
}
|
||||
const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily);
|
||||
if (monoFontFamily !== null) {
|
||||
result.monoFontFamily = monoFontFamily;
|
||||
}
|
||||
const uiFontSize = parseClampedFontSize(stored.uiFontSize, {
|
||||
min: MIN_UI_FONT_SIZE,
|
||||
max: MAX_UI_FONT_SIZE,
|
||||
});
|
||||
if (uiFontSize !== null) {
|
||||
result.uiFontSize = uiFontSize;
|
||||
}
|
||||
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
|
||||
min: MIN_CODE_FONT_SIZE,
|
||||
max: MAX_CODE_FONT_SIZE,
|
||||
});
|
||||
if (codeFontSize !== null) {
|
||||
result.codeFontSize = codeFontSize;
|
||||
}
|
||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
||||
result.syntaxTheme = stored.syntaxTheme;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -171,6 +214,42 @@ export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||||
);
|
||||
}
|
||||
|
||||
export function parseClampedFontSize(
|
||||
value: unknown,
|
||||
bounds: { min: number; max: number },
|
||||
): number | null {
|
||||
let numericValue = NaN;
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string" && value.trim().length > 0) {
|
||||
numericValue = Number(value);
|
||||
}
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(bounds.max, Math.max(bounds.min, Math.floor(numericValue)));
|
||||
}
|
||||
|
||||
export function sanitizeFontFamily(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return ""; // explicit empty = default
|
||||
}
|
||||
if (trimmed.length > MAX_FONT_FAMILY_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
if (/[;{}<>]/.test(trimmed)) {
|
||||
return null; // would break the web CSS font-family declaration
|
||||
}
|
||||
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
|
||||
return null; // control chars would corrupt the font-family string
|
||||
}
|
||||
return trimmed; // quotes/commas are legit in stacks
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
} from "react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
useWorkspaceSetupStore,
|
||||
@@ -350,7 +350,7 @@ function SetupCommandRow({
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
>
|
||||
<Text selectable style={styles.logText}>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
|
||||
{processedLog}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -404,7 +404,7 @@ function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: s
|
||||
accessible
|
||||
accessibilityLabel="Workspace setup log"
|
||||
>
|
||||
<Text selectable style={styles.logText}>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
|
||||
{log}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -540,7 +540,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
logText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 20,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -24,11 +24,10 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Buffer } from "buffer";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Settings,
|
||||
Palette,
|
||||
Server,
|
||||
Network,
|
||||
Workflow,
|
||||
@@ -46,6 +45,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { AppearanceSection } from "@/screens/settings/appearance/appearance-section";
|
||||
import {
|
||||
useAppSettings,
|
||||
useSettings,
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
type ServiceUrlBehavior,
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { THEME_SWATCHES } from "@/styles/theme";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
@@ -80,7 +79,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
@@ -134,6 +132,7 @@ interface SidebarSectionItem {
|
||||
|
||||
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true },
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true },
|
||||
{ id: "permissions", label: "Permissions", icon: Shield, desktopOnly: true },
|
||||
@@ -155,45 +154,9 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme helpers (General section)
|
||||
// Trigger + sidebar style helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ThemeIcon({
|
||||
theme,
|
||||
size,
|
||||
color,
|
||||
}: {
|
||||
theme: AppSettings["theme"];
|
||||
size: number;
|
||||
color: string;
|
||||
}) {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <Sun size={size} color={color} />;
|
||||
case "dark":
|
||||
return <Moon size={size} color={color} />;
|
||||
case "auto":
|
||||
return <Monitor size={size} color={color} />;
|
||||
default:
|
||||
return <ThemeSwatch color={THEME_SWATCHES[theme]} size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
function ThemeSwatch({ color, size }: { color: string; size: number }) {
|
||||
const swatchStyle = useMemo(
|
||||
() => ({
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255,255,255,0.15)",
|
||||
}),
|
||||
[color, size],
|
||||
);
|
||||
return <View style={swatchStyle} />;
|
||||
}
|
||||
|
||||
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
|
||||
}
|
||||
@@ -210,16 +173,6 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
|
||||
];
|
||||
}
|
||||
|
||||
const THEME_LABELS: Record<AppSettings["theme"], string> = {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
zinc: "Zinc",
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
auto: "System",
|
||||
};
|
||||
|
||||
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
|
||||
|
||||
const SEND_BEHAVIOR_OPTIONS = [
|
||||
@@ -247,41 +200,11 @@ const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "ext
|
||||
interface GeneralSectionProps {
|
||||
settings: AppSettings;
|
||||
isDesktopApp: boolean;
|
||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
||||
}
|
||||
|
||||
interface ThemeMenuItemProps {
|
||||
themeValue: AppSettings["theme"];
|
||||
selected: boolean;
|
||||
iconSize: number;
|
||||
iconColor: string;
|
||||
onChange: (theme: AppSettings["theme"]) => void;
|
||||
}
|
||||
|
||||
function ThemeMenuItem({
|
||||
themeValue,
|
||||
selected,
|
||||
iconSize,
|
||||
iconColor,
|
||||
onChange,
|
||||
}: ThemeMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(themeValue);
|
||||
}, [onChange, themeValue]);
|
||||
const leading = useMemo(
|
||||
() => <ThemeIcon theme={themeValue} size={iconSize} color={iconColor} />,
|
||||
[themeValue, iconSize, iconColor],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
|
||||
{THEME_LABELS[themeValue]}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServiceUrlBehaviorMenuItemProps {
|
||||
value: ServiceUrlBehavior;
|
||||
selected: boolean;
|
||||
@@ -306,13 +229,11 @@ function ServiceUrlBehaviorMenuItem({
|
||||
function GeneralSection({
|
||||
settings,
|
||||
isDesktopApp,
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
handleServiceUrlBehaviorChange,
|
||||
handleTerminalScrollbackLinesChange,
|
||||
}: GeneralSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconSize = theme.iconSize.md;
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
|
||||
String(settings.terminalScrollbackLines),
|
||||
@@ -343,41 +264,6 @@ function GeneralSection({
|
||||
<SettingsSection title="General">
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Theme</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger style={themeTriggerStyle}>
|
||||
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
|
||||
<Text style={styles.themeTriggerText}>{THEME_LABELS[settings.theme]}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{(["light", "dark", "auto"] as const).map((t) => (
|
||||
<ThemeMenuItem
|
||||
key={t}
|
||||
themeValue={t}
|
||||
selected={settings.theme === t}
|
||||
iconSize={iconSize}
|
||||
iconColor={iconColor}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
|
||||
<ThemeMenuItem
|
||||
key={t}
|
||||
themeValue={t}
|
||||
selected={settings.theme === t}
|
||||
iconSize={iconSize}
|
||||
iconColor={iconColor}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
<View style={ROW_WITH_BORDER_STYLE}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Default send</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
@@ -1217,13 +1103,6 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
return localServerId ?? sortedHosts[0]?.serverId ?? null;
|
||||
}, [view, localServerId, sortedHosts]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(nextTheme: AppSettings["theme"]) => {
|
||||
void updateSettings({ theme: nextTheme });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleSendBehaviorChange = useCallback(
|
||||
(behavior: SendBehavior) => {
|
||||
void updateSettings({ sendBehavior: behavior });
|
||||
@@ -1446,12 +1325,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleThemeChange={handleThemeChange}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
);
|
||||
case "appearance":
|
||||
return <AppearanceSection />;
|
||||
case "shortcuts":
|
||||
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
|
||||
case "integrations":
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View, type TextStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { DEFAULT_MONO_FONT_STACK } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { tokenizeToLines } from "@/utils/highlight-cache";
|
||||
import { CHANGED_LINE_INDICES, PREVIEW_AFTER, PREVIEW_BEFORE } from "./preview-snippet";
|
||||
|
||||
// Snippets are TypeScript; the cache keys grammar selection off the extension.
|
||||
const PREVIEW_EXTENSION = "ts";
|
||||
|
||||
// GitHub diff tints, matching git/diff-pane.tsx (addLineContainer /
|
||||
// removeLineContainer). Hardcoded rgba is the documented diff exception to the
|
||||
// "no raw hex outside the palette" rule (docs/design.md §13).
|
||||
const REMOVED_TINT = "rgba(248, 81, 73, 0.1)";
|
||||
const ADDED_TINT = "rgba(46, 160, 67, 0.15)";
|
||||
|
||||
// Zero-width space keeps blank lines at full line height.
|
||||
const ZERO_WIDTH = "";
|
||||
|
||||
type RowType = "context" | "add" | "remove";
|
||||
|
||||
interface PreviewOverrides {
|
||||
monoFontFamily?: string;
|
||||
codeFontSize?: number;
|
||||
}
|
||||
|
||||
interface AppearancePreviewProps {
|
||||
// Live draft values for the code font applied as inline overrides on top of the
|
||||
// themed styles (the while-typing path). Absent/empty fields fall back to the
|
||||
// theme value; an explicitly-empty family resolves to the default stack.
|
||||
overrides?: PreviewOverrides;
|
||||
}
|
||||
|
||||
function resolveFamilyOverride(value: string | undefined, fallback: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length === 0 ? fallback : trimmed;
|
||||
}
|
||||
|
||||
function resolveSizeOverride(value: number | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function buildCodeOverride(overrides: PreviewOverrides | undefined): TextStyle {
|
||||
if (!overrides) return {};
|
||||
const style: TextStyle = {};
|
||||
const fontFamily = resolveFamilyOverride(overrides.monoFontFamily, DEFAULT_MONO_FONT_STACK);
|
||||
if (fontFamily !== undefined) style.fontFamily = fontFamily;
|
||||
const fontSize = resolveSizeOverride(overrides.codeFontSize);
|
||||
if (fontSize !== undefined) {
|
||||
style.fontSize = fontSize;
|
||||
// Mirror applyAppearance's code line-height coupling so a larger draft size
|
||||
// doesn't clip while the user is still typing it.
|
||||
style.lineHeight = Math.round(fontSize * 1.5);
|
||||
}
|
||||
// High-churn draft values bypass the Unistyles CSS registry (docs/unistyles.md).
|
||||
return inlineUnistylesStyle(style);
|
||||
}
|
||||
|
||||
interface KeyedToken {
|
||||
key: string;
|
||||
style: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface UnifiedRow {
|
||||
key: string;
|
||||
type: RowType;
|
||||
marker: string;
|
||||
tokens: KeyedToken[] | null;
|
||||
fallbackText: string;
|
||||
}
|
||||
|
||||
function makeRow(
|
||||
key: string,
|
||||
type: RowType,
|
||||
marker: string,
|
||||
text: string,
|
||||
raw: HighlightToken[] | null,
|
||||
): UnifiedRow {
|
||||
const tokens =
|
||||
raw && raw.length > 0
|
||||
? raw.map((token, index) => ({
|
||||
key: `${key}-${index}`,
|
||||
style: token.style,
|
||||
text: token.text,
|
||||
}))
|
||||
: null;
|
||||
return { key, type, marker, tokens, fallbackText: text.length > 0 ? text : ZERO_WIDTH };
|
||||
}
|
||||
|
||||
// Interleave the before/after snippet into a single unified diff: unchanged lines
|
||||
// appear once as context; a changed line emits a "-" removed row (from BEFORE)
|
||||
// followed by a "+" added row (from AFTER). Tokens are precomputed with stable
|
||||
// keys so the renderer never keys off an array index.
|
||||
function buildUnifiedRows(): UnifiedRow[] {
|
||||
const beforeLines = tokenizeToLines(PREVIEW_BEFORE.join("\n"), PREVIEW_EXTENSION);
|
||||
const afterLines = tokenizeToLines(PREVIEW_AFTER.join("\n"), PREVIEW_EXTENSION);
|
||||
const rows: UnifiedRow[] = [];
|
||||
for (let index = 0; index < PREVIEW_BEFORE.length; index += 1) {
|
||||
if (CHANGED_LINE_INDICES.has(index)) {
|
||||
rows.push(
|
||||
makeRow(`r-${index}`, "remove", "- ", PREVIEW_BEFORE[index], beforeLines?.[index] ?? null),
|
||||
);
|
||||
rows.push(
|
||||
makeRow(`a-${index}`, "add", "+ ", PREVIEW_AFTER[index], afterLines?.[index] ?? null),
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
makeRow(`c-${index}`, "context", " ", PREVIEW_BEFORE[index], beforeLines?.[index] ?? null),
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Marker color follows the diff stat tokens; a single style ref per type keeps it
|
||||
// off the new-array-as-prop path. The marker is a child of the code <Text>, so it
|
||||
// inherits the mono font + (draft) size and only overrides its color.
|
||||
function markerStyle(type: RowType) {
|
||||
if (type === "add") return styles.markerAdd;
|
||||
if (type === "remove") return styles.markerRemove;
|
||||
return styles.markerContext;
|
||||
}
|
||||
|
||||
// Self-contained live preview: a unified diff of a fixed TypeScript snippet in the
|
||||
// code (mono) font with the selected syntax colors. All themed styling flows
|
||||
// through StyleSheet.create((theme) => …) so it repaints when
|
||||
// UnistylesRuntime.updateTheme commits a setting; the optional `overrides` layer
|
||||
// inline styles for live-while-typing feedback on the code font.
|
||||
export function AppearancePreview({ overrides }: AppearancePreviewProps) {
|
||||
const rows = useMemo(() => buildUnifiedRows(), []);
|
||||
const codeOverride = useMemo(() => buildCodeOverride(overrides), [overrides]);
|
||||
const codeStyle = useMemo(() => [styles.codeLine, codeOverride], [codeOverride]);
|
||||
const addRowStyle = useMemo(() => [styles.row, styles.addRow], []);
|
||||
const removeRowStyle = useMemo(() => [styles.row, styles.removeRow], []);
|
||||
|
||||
function rowStyle(type: RowType) {
|
||||
if (type === "add") return addRowStyle;
|
||||
if (type === "remove") return removeRowStyle;
|
||||
return styles.row;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel="Live preview of the syntax theme and code font"
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
style={styles.card}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<View key={row.key} style={rowStyle(row.type)}>
|
||||
<Text style={codeStyle}>
|
||||
<Text style={markerStyle(row.type)}>{row.marker}</Text>
|
||||
{row.tokens
|
||||
? row.tokens.map((token) => (
|
||||
<Text
|
||||
key={token.key}
|
||||
style={token.style ? syntaxTokenStyleFor(token.style) : undefined}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: row.fallbackText}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
addRow: {
|
||||
backgroundColor: ADDED_TINT,
|
||||
},
|
||||
removeRow: {
|
||||
backgroundColor: REMOVED_TINT,
|
||||
},
|
||||
codeLine: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.lineHeight.diff,
|
||||
color: theme.colors.foreground,
|
||||
...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal" } : null),
|
||||
},
|
||||
markerContext: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
markerAdd: {
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
markerRemove: {
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,588 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
|
||||
import {
|
||||
SYNTAX_THEME_OPTIONS,
|
||||
type SyntaxThemeId,
|
||||
type SyntaxThemeOption,
|
||||
} from "@getpaseo/highlight";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import {
|
||||
MAX_CODE_FONT_SIZE,
|
||||
MAX_UI_FONT_SIZE,
|
||||
MIN_CODE_FONT_SIZE,
|
||||
MIN_UI_FONT_SIZE,
|
||||
parseClampedFontSize,
|
||||
sanitizeFontFamily,
|
||||
useAppSettings,
|
||||
type AppSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import {
|
||||
DEFAULT_MONO_FONT_STACK,
|
||||
DEFAULT_UI_FONT_STACK,
|
||||
ICON_SIZE,
|
||||
THEME_SWATCHES,
|
||||
type Theme,
|
||||
} from "@/styles/theme";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { AppearancePreview } from "./appearance-preview";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no
|
||||
// useUnistyles). Icon sizes read the static ICON_SIZE token; the appearance
|
||||
// feature does not scale icons.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ThemedSun = withUnistyles(Sun);
|
||||
const ThemedMoon = withUnistyles(Moon);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
// Stored value -> displayed label. `auto` reads as "System" for the app theme.
|
||||
const THEME_LABELS: Record<AppSettings["theme"], string> = {
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
zinc: "Zinc",
|
||||
midnight: "Midnight",
|
||||
claude: "Claude",
|
||||
ghostty: "Ghostty",
|
||||
auto: "System",
|
||||
};
|
||||
|
||||
const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"];
|
||||
const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
|
||||
"zinc",
|
||||
"midnight",
|
||||
"claude",
|
||||
"ghostty",
|
||||
];
|
||||
|
||||
// Platform default stacks can be the bare native tokens ("normal"/"monospace");
|
||||
// those read as a bug, so show a human label in the placeholder instead.
|
||||
const BARE_DEFAULT_STACKS: ReadonlySet<string> = new Set(["normal", "monospace"]);
|
||||
|
||||
function resolveDefaultStackPlaceholder(stack: string): string {
|
||||
return BARE_DEFAULT_STACKS.has(stack) ? "System default" : stack;
|
||||
}
|
||||
|
||||
const UI_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_UI_FONT_STACK);
|
||||
const MONO_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_MONO_FONT_STACK);
|
||||
|
||||
// Local size string (digits only) -> preview override number. Empty/invalid
|
||||
// yields undefined so the preview falls back to the committed theme value.
|
||||
function sizeDraftToOverride(value: string): number | undefined {
|
||||
if (value.length === 0) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.trigger, pressed ? styles.triggerPressed : null];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ThemeLeadingProps {
|
||||
themeValue: AppSettings["theme"];
|
||||
}
|
||||
|
||||
function ThemeLeading({ themeValue }: ThemeLeadingProps) {
|
||||
switch (themeValue) {
|
||||
case "light":
|
||||
return <ThemedSun size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
|
||||
case "dark":
|
||||
return <ThemedMoon size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
|
||||
case "auto":
|
||||
return <ThemedMonitor size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
|
||||
default:
|
||||
return <ThemeSwatch color={THEME_SWATCHES[themeValue]} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeSwatchProps {
|
||||
color: string;
|
||||
}
|
||||
|
||||
function ThemeSwatch({ color }: ThemeSwatchProps) {
|
||||
const swatchStyle = useMemo(() => [styles.swatch, { backgroundColor: color }], [color]);
|
||||
return <View style={swatchStyle} />;
|
||||
}
|
||||
|
||||
interface ThemeMenuItemProps {
|
||||
themeValue: AppSettings["theme"];
|
||||
selected: boolean;
|
||||
onChange: (theme: AppSettings["theme"]) => void;
|
||||
}
|
||||
|
||||
function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(themeValue);
|
||||
}, [onChange, themeValue]);
|
||||
const leading = useMemo(() => <ThemeLeading themeValue={themeValue} />, [themeValue]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
|
||||
{THEME_LABELS[themeValue]}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThemeRowProps {
|
||||
value: AppSettings["theme"];
|
||||
onChange: (theme: AppSettings["theme"]) => void;
|
||||
}
|
||||
|
||||
function ThemeRow({ value, onChange }: ThemeRowProps) {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Theme</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={`Theme: ${THEME_LABELS[value]}`}
|
||||
>
|
||||
<ThemeLeading themeValue={value} />
|
||||
<Text style={styles.triggerText}>{THEME_LABELS[value]}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{PRIMARY_THEMES.map((themeValue) => (
|
||||
<ThemeMenuItem
|
||||
key={themeValue}
|
||||
themeValue={themeValue}
|
||||
selected={value === themeValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{DARK_VARIANT_THEMES.map((themeValue) => (
|
||||
<ThemeMenuItem
|
||||
key={themeValue}
|
||||
themeValue={themeValue}
|
||||
selected={value === themeValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fonts: family text fields + numeric size fields (commit on blur/submit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FontFamilyRowProps {
|
||||
title: string;
|
||||
hint: string;
|
||||
accessibilityLabel: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
draft: string;
|
||||
withBorder: boolean;
|
||||
onChangeDraft: (value: string) => void;
|
||||
onCommit: (value: string) => void;
|
||||
}
|
||||
|
||||
function FontFamilyRow({
|
||||
title,
|
||||
hint,
|
||||
accessibilityLabel,
|
||||
placeholder,
|
||||
value,
|
||||
draft,
|
||||
withBorder,
|
||||
onChangeDraft,
|
||||
onCommit,
|
||||
}: FontFamilyRowProps) {
|
||||
const handleCommit = useCallback(() => {
|
||||
onCommit(draft);
|
||||
}, [draft, onCommit]);
|
||||
|
||||
// Resync from the committed value when it changes elsewhere.
|
||||
useEffect(() => {
|
||||
onChangeDraft(value);
|
||||
// Only resync on external value changes, not on local keystrokes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{title}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{hint}</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={onChangeDraft}
|
||||
onBlur={handleCommit}
|
||||
onSubmitEditing={handleCommit}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
style={styles.fontFamilyInput}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface FontSizeRowProps {
|
||||
title: string;
|
||||
accessibilityLabel: string;
|
||||
draft: string;
|
||||
onChangeDraft: (value: string) => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
function FontSizeRow({
|
||||
title,
|
||||
accessibilityLabel,
|
||||
draft,
|
||||
onChangeDraft,
|
||||
onCommit,
|
||||
}: FontSizeRowProps) {
|
||||
return (
|
||||
<View style={styles.rowWithBorder}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.sizeField}>
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={onChangeDraft}
|
||||
onBlur={onCommit}
|
||||
onSubmitEditing={onCommit}
|
||||
keyboardType="number-pad"
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.sizeInput}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
/>
|
||||
<Text style={styles.unit}>px</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Syntax highlight theme picker (commits immediately)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function syntaxLabelForId(id: SyntaxThemeId): string {
|
||||
const option = SYNTAX_THEME_OPTIONS.find((entry) => entry.id === id);
|
||||
return option ? option.label : id;
|
||||
}
|
||||
|
||||
interface SyntaxMenuItemProps {
|
||||
option: SyntaxThemeOption;
|
||||
selected: boolean;
|
||||
onChange: (id: SyntaxThemeId) => void;
|
||||
}
|
||||
|
||||
function SyntaxMenuItem({ option, selected, onChange }: SyntaxMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(option.id);
|
||||
}, [onChange, option.id]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface SyntaxRowProps {
|
||||
value: SyntaxThemeId;
|
||||
onChange: (id: SyntaxThemeId) => void;
|
||||
}
|
||||
|
||||
function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Highlight theme</Text>
|
||||
<Text style={settingsStyles.rowHint}>Colors for code, independent of the app theme</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={`Highlight theme: ${syntaxLabelForId(value)}`}
|
||||
>
|
||||
<Text style={styles.triggerText}>{syntaxLabelForId(value)}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{SYNTAX_THEME_OPTIONS.map((option) => (
|
||||
<SyntaxMenuItem
|
||||
key={option.id}
|
||||
option={option}
|
||||
selected={value === option.id}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AppearanceSection() {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily);
|
||||
const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily);
|
||||
const [uiSizeDraft, setUiSizeDraft] = useState(String(settings.uiFontSize));
|
||||
const [codeSizeDraft, setCodeSizeDraft] = useState(String(settings.codeFontSize));
|
||||
|
||||
// Resync numeric drafts when the committed value changes elsewhere.
|
||||
useEffect(() => {
|
||||
setUiSizeDraft(String(settings.uiFontSize));
|
||||
}, [settings.uiFontSize]);
|
||||
useEffect(() => {
|
||||
setCodeSizeDraft(String(settings.codeFontSize));
|
||||
}, [settings.codeFontSize]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(theme: AppSettings["theme"]) => {
|
||||
void updateSettings({ theme });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleSyntaxThemeChange = useCallback(
|
||||
(syntaxTheme: SyntaxThemeId) => {
|
||||
void updateSettings({ syntaxTheme });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const commitUiFontFamily = useCallback(
|
||||
(value: string) => {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
if (sanitized === null) {
|
||||
setUiFontDraft(settings.uiFontFamily);
|
||||
return;
|
||||
}
|
||||
setUiFontDraft(sanitized);
|
||||
if (sanitized !== settings.uiFontFamily) {
|
||||
void updateSettings({ uiFontFamily: sanitized });
|
||||
}
|
||||
},
|
||||
[settings.uiFontFamily, updateSettings],
|
||||
);
|
||||
|
||||
const commitMonoFontFamily = useCallback(
|
||||
(value: string) => {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
if (sanitized === null) {
|
||||
setMonoFontDraft(settings.monoFontFamily);
|
||||
return;
|
||||
}
|
||||
setMonoFontDraft(sanitized);
|
||||
if (sanitized !== settings.monoFontFamily) {
|
||||
void updateSettings({ monoFontFamily: sanitized });
|
||||
}
|
||||
},
|
||||
[settings.monoFontFamily, updateSettings],
|
||||
);
|
||||
|
||||
const handleUiSizeChange = useCallback((value: string) => {
|
||||
setUiSizeDraft(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const handleCodeSizeChange = useCallback((value: string) => {
|
||||
setCodeSizeDraft(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const commitUiSize = useCallback(() => {
|
||||
const parsed = parseClampedFontSize(uiSizeDraft, {
|
||||
min: MIN_UI_FONT_SIZE,
|
||||
max: MAX_UI_FONT_SIZE,
|
||||
});
|
||||
const next = parsed ?? settings.uiFontSize;
|
||||
setUiSizeDraft(String(next));
|
||||
if (next !== settings.uiFontSize) {
|
||||
void updateSettings({ uiFontSize: next });
|
||||
}
|
||||
}, [settings.uiFontSize, uiSizeDraft, updateSettings]);
|
||||
|
||||
const commitCodeSize = useCallback(() => {
|
||||
const parsed = parseClampedFontSize(codeSizeDraft, {
|
||||
min: MIN_CODE_FONT_SIZE,
|
||||
max: MAX_CODE_FONT_SIZE,
|
||||
});
|
||||
const next = parsed ?? settings.codeFontSize;
|
||||
setCodeSizeDraft(String(next));
|
||||
if (next !== settings.codeFontSize) {
|
||||
void updateSettings({ codeFontSize: next });
|
||||
}
|
||||
}, [codeSizeDraft, settings.codeFontSize, updateSettings]);
|
||||
|
||||
// Live-while-typing: the in-progress drafts drive the preview without
|
||||
// committing to the global theme. Empty/invalid fields fall back to the
|
||||
// theme value inside the preview.
|
||||
const previewOverrides = useMemo(
|
||||
() => ({
|
||||
monoFontFamily: monoFontDraft,
|
||||
codeFontSize: sizeDraftToOverride(codeSizeDraft),
|
||||
}),
|
||||
[codeSizeDraft, monoFontDraft],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title="Theme">
|
||||
<View style={settingsStyles.card}>
|
||||
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Fonts">
|
||||
<View style={settingsStyles.card}>
|
||||
<FontFamilyRow
|
||||
title="Interface font"
|
||||
hint="Used across the app. Leave empty for the system default"
|
||||
accessibilityLabel="Interface font family"
|
||||
placeholder={UI_FONT_PLACEHOLDER}
|
||||
value={settings.uiFontFamily}
|
||||
draft={uiFontDraft}
|
||||
withBorder={false}
|
||||
onChangeDraft={setUiFontDraft}
|
||||
onCommit={commitUiFontFamily}
|
||||
/>
|
||||
<FontSizeRow
|
||||
title="Interface size"
|
||||
accessibilityLabel="Interface font size"
|
||||
draft={uiSizeDraft}
|
||||
onChangeDraft={handleUiSizeChange}
|
||||
onCommit={commitUiSize}
|
||||
/>
|
||||
<FontFamilyRow
|
||||
title="Code font"
|
||||
hint="Used in code, diffs, and the terminal output. Leave empty for the system default"
|
||||
accessibilityLabel="Code font family"
|
||||
placeholder={MONO_FONT_PLACEHOLDER}
|
||||
value={settings.monoFontFamily}
|
||||
draft={monoFontDraft}
|
||||
withBorder
|
||||
onChangeDraft={setMonoFontDraft}
|
||||
onCommit={commitMonoFontFamily}
|
||||
/>
|
||||
<FontSizeRow
|
||||
title="Code size"
|
||||
accessibilityLabel="Code font size"
|
||||
draft={codeSizeDraft}
|
||||
onChangeDraft={handleCodeSizeChange}
|
||||
onCommit={commitCodeSize}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title="Syntax">
|
||||
<View style={settingsStyles.card}>
|
||||
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
|
||||
</View>
|
||||
<View style={styles.preview}>
|
||||
<AppearancePreview overrides={previewOverrides} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
preview: {
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
rowWithBorder: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
triggerPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
swatch: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
borderRadius: ICON_SIZE.md / 2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
fontFamilyInput: {
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
maxWidth: 280,
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "left",
|
||||
},
|
||||
sizeField: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sizeInput: {
|
||||
width: 64,
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "right",
|
||||
},
|
||||
unit: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
placeholderColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,176 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
|
||||
import { DEFAULT_UI_FONT_STACK } from "@/styles/theme";
|
||||
import { applyAppearance, type AppearanceInput } from "./apply-appearance";
|
||||
|
||||
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
|
||||
// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls.
|
||||
const { updateTheme } = vi.hoisted(() => ({ updateTheme: vi.fn() }));
|
||||
vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: { updateTheme } }));
|
||||
|
||||
// The six registered Unistyles theme keys, in the order applyAppearance patches them.
|
||||
const ALL_THEME_KEYS = [
|
||||
"light",
|
||||
"dark",
|
||||
"darkZinc",
|
||||
"darkMidnight",
|
||||
"darkClaude",
|
||||
"darkGhostty",
|
||||
] as const;
|
||||
|
||||
// The signature of the updater passed to UnistylesRuntime.updateTheme.
|
||||
type ThemeUpdater = (theme: FakeTheme) => FakeTheme;
|
||||
|
||||
// The subset of the theme shape the updater reads / spreads. The real Theme type
|
||||
// is a frozen `as const` literal; the updater only touches these fields. Casting a
|
||||
// fake of this shape through `unknown` to ThemeUpdater's param is test-only.
|
||||
interface FakeTheme {
|
||||
colorScheme: "light" | "dark";
|
||||
fontFamily: { ui: string; mono: string };
|
||||
fontSize: {
|
||||
xs: number;
|
||||
code: number;
|
||||
sm: number;
|
||||
base: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
"2xl": number;
|
||||
"3xl": number;
|
||||
"4xl": number;
|
||||
};
|
||||
lineHeight: { diff: number };
|
||||
colors: { foreground: string; syntax: Record<string, string> };
|
||||
}
|
||||
|
||||
function makeFakeTheme(): FakeTheme {
|
||||
return {
|
||||
colorScheme: "dark",
|
||||
fontFamily: { ui: "seed-ui-stack", mono: "seed-mono-stack" },
|
||||
fontSize: {
|
||||
xs: 12,
|
||||
code: 12,
|
||||
sm: 14,
|
||||
base: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
"2xl": 22,
|
||||
"3xl": 26,
|
||||
"4xl": 34,
|
||||
},
|
||||
lineHeight: { diff: 22 },
|
||||
colors: { foreground: "#fff", syntax: {} },
|
||||
};
|
||||
}
|
||||
|
||||
function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
|
||||
return {
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiFontSize: 16,
|
||||
codeFontSize: 12,
|
||||
syntaxTheme: "github",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Run a single captured updater (default the first) against a fresh fake theme.
|
||||
function runCapturedUpdater(call = 0): FakeTheme {
|
||||
const updater = updateTheme.mock.calls[call]?.[1] as unknown as ThemeUpdater;
|
||||
return updater(makeFakeTheme());
|
||||
}
|
||||
|
||||
describe("applyAppearance", () => {
|
||||
beforeEach(() => {
|
||||
updateTheme.mockClear();
|
||||
});
|
||||
|
||||
it("patches every registered Unistyles theme exactly once", () => {
|
||||
applyAppearance(makeInput());
|
||||
|
||||
expect(updateTheme).toHaveBeenCalledTimes(6);
|
||||
expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([...ALL_THEME_KEYS]);
|
||||
});
|
||||
|
||||
it("resolves an empty UI font family to the default stack", () => {
|
||||
applyAppearance(makeInput({ uiFontFamily: "" }));
|
||||
|
||||
expect(runCapturedUpdater().fontFamily.ui).toBe(DEFAULT_UI_FONT_STACK);
|
||||
});
|
||||
|
||||
it("passes a non-empty UI font family through trimmed", () => {
|
||||
applyAppearance(makeInput({ uiFontFamily: " Menlo " }));
|
||||
|
||||
expect(runCapturedUpdater().fontFamily.ui).toBe("Menlo");
|
||||
});
|
||||
|
||||
it("scales the whole UI ramp proportionally while preserving ratios", () => {
|
||||
applyAppearance(makeInput({ uiFontSize: 14 }));
|
||||
|
||||
const { fontSize } = runCapturedUpdater();
|
||||
// r = 14 / 16 = 0.875
|
||||
expect(fontSize.base).toBe(14); // round(16 * 0.875)
|
||||
expect(fontSize.lg).toBe(16); // round(18 * 0.875) = round(15.75)
|
||||
expect(fontSize.xs).toBe(11); // round(12 * 0.875) = round(10.5)
|
||||
expect(fontSize["4xl"]).toBe(30); // round(34 * 0.875) = round(29.75)
|
||||
});
|
||||
|
||||
it("derives the UI ramp from the canonical sizes, not the live theme (no compounding)", () => {
|
||||
applyAppearance(makeInput({ uiFontSize: 14 }));
|
||||
|
||||
// Simulate a theme whose fontSize was already scaled by a prior apply; the
|
||||
// updater must ignore it and rebuild from the authored FONT_SIZE ramp.
|
||||
const updater = updateTheme.mock.calls[0]?.[1] as unknown as ThemeUpdater;
|
||||
const alreadyScaled = makeFakeTheme();
|
||||
alreadyScaled.fontSize = {
|
||||
xs: 4,
|
||||
code: 4,
|
||||
sm: 4,
|
||||
base: 4,
|
||||
lg: 4,
|
||||
xl: 4,
|
||||
"2xl": 4,
|
||||
"3xl": 4,
|
||||
"4xl": 4,
|
||||
};
|
||||
|
||||
const { fontSize } = updater(alreadyScaled);
|
||||
expect(fontSize.base).toBe(14); // not 4 * 0.875 — rebuilt from FONT_SIZE
|
||||
expect(fontSize.lg).toBe(16);
|
||||
});
|
||||
|
||||
it("leaves the UI ramp at authored sizes when only the code size changes", () => {
|
||||
applyAppearance(makeInput({ uiFontSize: 16, codeFontSize: 10 }));
|
||||
|
||||
const { fontSize } = runCapturedUpdater();
|
||||
expect(fontSize.base).toBe(16);
|
||||
expect(fontSize.sm).toBe(14);
|
||||
expect(fontSize.code).toBe(10);
|
||||
});
|
||||
|
||||
it("sets fontSize.code to codeFontSize regardless of the UI font size", () => {
|
||||
applyAppearance(makeInput({ uiFontSize: 14, codeFontSize: 18 }));
|
||||
|
||||
expect(runCapturedUpdater().fontSize.code).toBe(18);
|
||||
});
|
||||
|
||||
it("couples lineHeight.diff to the code font size", () => {
|
||||
applyAppearance(makeInput({ codeFontSize: 18 }));
|
||||
|
||||
expect(runCapturedUpdater().lineHeight.diff).toBe(Math.round(18 * 1.5)); // 27
|
||||
});
|
||||
|
||||
it("swaps colors.syntax to the resolved palette for the named theme", () => {
|
||||
applyAppearance(makeInput({ syntaxTheme: "dracula" }));
|
||||
|
||||
const { colors } = runCapturedUpdater();
|
||||
expect(colors.syntax).toEqual(resolveSyntaxColors("dracula", "dark"));
|
||||
});
|
||||
|
||||
it("resolves a syntax theme using the theme's own color scheme", () => {
|
||||
applyAppearance(makeInput({ syntaxTheme: "github" }));
|
||||
|
||||
// makeFakeTheme().colorScheme === "dark" -> github resolves to the dark palette.
|
||||
expect(runCapturedUpdater().colors.syntax).toEqual(darkHighlightColors);
|
||||
expect(runCapturedUpdater().colors.syntax).toEqual(resolveSyntaxColors("github", "dark"));
|
||||
});
|
||||
});
|
||||
105
packages/app/src/screens/settings/appearance/apply-appearance.ts
Normal file
105
packages/app/src/screens/settings/appearance/apply-appearance.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { resolveSyntaxColors, type SyntaxThemeId } from "@getpaseo/highlight";
|
||||
import {
|
||||
DEFAULT_UI_FONT_STACK,
|
||||
DEFAULT_MONO_FONT_STACK,
|
||||
FONT_SIZE,
|
||||
type Theme,
|
||||
} from "@/styles/theme";
|
||||
import { applyRootUiFont } from "./apply-root-font";
|
||||
|
||||
// All six registered Unistyles keys — pinned literal (greppable, type-checked).
|
||||
// The `as const` element types are exactly `keyof UnistylesThemes`, so each key
|
||||
// is assignable to `UnistylesRuntime.updateTheme`'s first argument with no cast.
|
||||
const ALL_THEME_KEYS = [
|
||||
"light",
|
||||
"dark",
|
||||
"darkZinc",
|
||||
"darkMidnight",
|
||||
"darkClaude",
|
||||
"darkGhostty",
|
||||
] as const;
|
||||
|
||||
// The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor).
|
||||
const BASE_UI_REFERENCE = FONT_SIZE.base; // 16
|
||||
|
||||
export interface AppearanceInput {
|
||||
uiFontFamily: string; // "" -> default stack
|
||||
monoFontFamily: string; // "" -> default stack
|
||||
uiFontSize: number; // already clamped
|
||||
codeFontSize: number; // already clamped
|
||||
syntaxTheme: SyntaxThemeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the font-size ramp from the canonical `FONT_SIZE` ramp, scaled
|
||||
* proportionally by `uiSize / 16` so the type hierarchy is preserved at non-default
|
||||
* sizes. Deriving from the authored ramp — NOT the live (possibly already-scaled)
|
||||
* theme — makes `applyAppearance` idempotent: repeated applies never compound, and a
|
||||
* code-size change (uiSize unchanged) leaves the UI ramp at its authored values.
|
||||
* `code` is set absolutely to `codeSize`, never scaled by the UI factor — a separate
|
||||
* control on a separate semantic axis (mono/diff text).
|
||||
*/
|
||||
function scaleFontSize(uiSize: number, codeSize: number): Theme["fontSize"] {
|
||||
const r = uiSize / BASE_UI_REFERENCE;
|
||||
return {
|
||||
xs: Math.round(FONT_SIZE.xs * r),
|
||||
sm: Math.round(FONT_SIZE.sm * r),
|
||||
base: Math.round(FONT_SIZE.base * r),
|
||||
lg: Math.round(FONT_SIZE.lg * r),
|
||||
xl: Math.round(FONT_SIZE.xl * r),
|
||||
"2xl": Math.round(FONT_SIZE["2xl"] * r),
|
||||
"3xl": Math.round(FONT_SIZE["3xl"] * r),
|
||||
"4xl": Math.round(FONT_SIZE["4xl"] * r),
|
||||
code: codeSize, // absolute, NOT scaled
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch every registered Unistyles theme with the user's appearance choices.
|
||||
* All six keys are patched because the active theme can change and adaptive mode
|
||||
* can flip light/dark — patching all keys keeps the active key always current and
|
||||
* makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
|
||||
*/
|
||||
export function applyAppearance(input: AppearanceInput): void {
|
||||
const ui = input.uiFontFamily.trim() || DEFAULT_UI_FONT_STACK;
|
||||
const mono = input.monoFontFamily.trim() || DEFAULT_MONO_FONT_STACK;
|
||||
const diffLineHeight = Math.round(input.codeFontSize * 1.5); // couple to code size
|
||||
|
||||
for (const key of ALL_THEME_KEYS) {
|
||||
// Spread `...t` first — `updateTheme` replaces the stored theme, it does not
|
||||
// merge; an omitted key would be dropped. `syntax` follows the theme's own
|
||||
// scheme for `auto`; named palettes ignore it. `colors.base`/plain text stays
|
||||
// `theme.colors.foreground` (owned by `syntaxTokenStyles.base`, not patched).
|
||||
//
|
||||
// Narrow on the `colorScheme` discriminant before spreading: the updater must
|
||||
// return the theme union, and a spread of the union widens `colorScheme` to
|
||||
// `"light" | "dark"`, assignable to neither concrete member. Each branch spreads
|
||||
// a single narrowed theme type.
|
||||
UnistylesRuntime.updateTheme(key, (t) => {
|
||||
const fontFamily = { ui, mono };
|
||||
const fontSize = scaleFontSize(input.uiFontSize, input.codeFontSize);
|
||||
const lineHeight = { ...t.lineHeight, diff: diffLineHeight };
|
||||
if (t.colorScheme === "light") {
|
||||
return {
|
||||
...t,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
lineHeight,
|
||||
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Web: apply the UI font app-wide (RN-web stamps a default font on every text
|
||||
// element, so it can't be done through the theme alone). No-op on native.
|
||||
applyRootUiFont(ui);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Native (and default) no-op: React Native has no global font cascade, so the
|
||||
// interface font applies only where components read theme.fontFamily.ui. The web
|
||||
// build (apply-root-font.web.ts) overrides this to apply it app-wide.
|
||||
export function applyRootUiFont(_uiFontStack: string): void {}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Apply the interface (UI) font app-wide on web.
|
||||
//
|
||||
// react-native-web stamps a hardcoded default font onto every text element, so a
|
||||
// plain `body { font-family }` never cascades in — the element already has its own
|
||||
// font. Instead we inject ONE rule that points all text at a CSS variable and set
|
||||
// that variable live. The selector is high-specificity (1,2,0) so it deterministically
|
||||
// beats both RN-web's base font and Unistyles' generated classes (0,1,0) — no reliance
|
||||
// on stylesheet order. Code/diff/terminal surfaces carry `data-pmono` (and have their
|
||||
// subtree excluded via `:not([data-pmono] *)`) so they keep their monospace font.
|
||||
const STYLE_ID = "paseo-ui-font";
|
||||
const RULE = "#root *:not([data-pmono]):not([data-pmono] *){font-family:var(--paseo-ui-font);}";
|
||||
|
||||
export function applyRootUiFont(uiFontStack: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
// Strip anything that could break out of the CSS value; commas/quotes/spaces in a
|
||||
// font stack are fine.
|
||||
const value = uiFontStack
|
||||
.replace(/[<>{}();]/g, "")
|
||||
.replace(/[\r\n]/g, " ")
|
||||
.trim();
|
||||
if (value.length === 0) return;
|
||||
|
||||
document.documentElement.style.setProperty("--paseo-ui-font", value);
|
||||
|
||||
// The rule itself is static (references the variable); inject it once.
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = RULE;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// A small, syntax-rich TypeScript change rendered side-by-side in the appearance
|
||||
// preview. BEFORE and AFTER are aligned 1:1 (5 lines each); only the indices in
|
||||
// CHANGED_LINE_INDICES differ, so the diff tints land on matching rows.
|
||||
|
||||
export const PREVIEW_BEFORE: string[] = [
|
||||
"// Format a price for display",
|
||||
"export function formatPrice(cents: number) {",
|
||||
" const amount = cents / 100;",
|
||||
' return "$" + amount;',
|
||||
"}",
|
||||
];
|
||||
|
||||
export const PREVIEW_AFTER: string[] = [
|
||||
"// Format a price for display",
|
||||
"export function formatPrice(cents: number): string {",
|
||||
" const amount = cents / 100;",
|
||||
" return `$${amount.toFixed(2)}`;",
|
||||
"}",
|
||||
];
|
||||
|
||||
export const CHANGED_LINE_INDICES: ReadonlySet<number> = new Set([1, 3]);
|
||||
@@ -16,11 +16,11 @@ import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
|
||||
interface StartupSplashScreenProps {
|
||||
bootstrapState?: {
|
||||
@@ -236,7 +236,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: 20,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
logsMeta: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -257,7 +257,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
logsText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
@@ -417,7 +417,9 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
GitHub and include the logs below.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.errorMessage}>{bootstrapState.splashError}</Text>
|
||||
<Text dataSet={CODE_SURFACE_DATASET} style={styles.errorMessage}>
|
||||
{bootstrapState.splashError}
|
||||
</Text>
|
||||
|
||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||
|
||||
@@ -427,7 +429,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<Text selectable style={styles.logsText}>
|
||||
<Text dataSet={CODE_SURFACE_DATASET} selectable style={styles.logsText}>
|
||||
{logsText}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
7
packages/app/src/styles/code-surface.ts
Normal file
7
packages/app/src/styles/code-surface.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Marker for code / diff / monospace surfaces. On web, the app-wide interface-font
|
||||
// rule (see screens/settings/appearance/apply-root-font.web.ts) targets
|
||||
// `#root *:not([data-pmono]):not([data-pmono] *)`, so tagging a code container with
|
||||
// this dataSet excludes it and its subtree — they keep their monospace font. On
|
||||
// native it renders nothing and is harmless. Use a shared stable reference so it
|
||||
// doesn't trip the react-perf "new object as prop" rule.
|
||||
export const CODE_SURFACE_DATASET = { pmono: "" } as const;
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Theme } from "./theme";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
|
||||
@@ -21,7 +20,9 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
// Prose line-height scales with the UI ramp (≈22 at base 16), NOT the
|
||||
// code-size-coupled lineHeight.diff token used by code/diff surfaces.
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.4),
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
width: "100%" as const,
|
||||
@@ -169,8 +170,8 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 0,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.base - 3,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
|
||||
code_block: {
|
||||
@@ -179,7 +180,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
@@ -192,7 +193,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
marginVertical: theme.spacing[3],
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Platform } from "react-native";
|
||||
import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight";
|
||||
|
||||
export const baseColors = {
|
||||
@@ -485,16 +486,48 @@ export const OPACITY = {
|
||||
100: 1,
|
||||
} as const;
|
||||
|
||||
const commonTheme = {
|
||||
// Platform default font stacks — copied verbatim from constants/theme.ts `Fonts`
|
||||
// (sans -> ui, mono -> mono). These seed the dynamic `fontFamily` theme token and
|
||||
// are the fallback an empty user-supplied family resolves to at apply time.
|
||||
export const DEFAULT_UI_FONT_STACK: string = Platform.select({
|
||||
ios: "system-ui",
|
||||
default: "normal",
|
||||
web: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
});
|
||||
|
||||
export const DEFAULT_MONO_FONT_STACK: string = Platform.select({
|
||||
ios: "ui-monospace",
|
||||
default: "monospace",
|
||||
web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
});
|
||||
|
||||
// `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain
|
||||
// `number`/`string` (not narrowed by `as const`) so the appearance updater can patch
|
||||
// them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their
|
||||
// literal types.
|
||||
interface CommonTheme {
|
||||
spacing: typeof SPACING;
|
||||
fontSize: Record<keyof typeof FONT_SIZE, number>;
|
||||
fontFamily: { ui: string; mono: string };
|
||||
lineHeight: Record<keyof typeof LINE_HEIGHT, number>;
|
||||
iconSize: typeof ICON_SIZE;
|
||||
fontWeight: typeof FONT_WEIGHT;
|
||||
borderRadius: typeof BORDER_RADIUS;
|
||||
borderWidth: typeof BORDER_WIDTH;
|
||||
opacity: typeof OPACITY;
|
||||
}
|
||||
|
||||
const commonTheme: CommonTheme = {
|
||||
spacing: SPACING,
|
||||
fontSize: FONT_SIZE,
|
||||
fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK },
|
||||
lineHeight: LINE_HEIGHT,
|
||||
iconSize: ICON_SIZE,
|
||||
fontWeight: FONT_WEIGHT,
|
||||
borderRadius: BORDER_RADIUS,
|
||||
borderWidth: BORDER_WIDTH,
|
||||
opacity: OPACITY,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const darkShadow = {
|
||||
sm: {
|
||||
|
||||
@@ -86,7 +86,7 @@ interface StubTerminal {
|
||||
resize?: (cols: number, rows: number) => void;
|
||||
focus: () => void;
|
||||
refresh?: (start: number, end: number) => void;
|
||||
options?: { theme?: unknown; scrollback?: number };
|
||||
options?: { theme?: unknown; scrollback?: number; fontFamily?: string; fontSize?: number };
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}
|
||||
@@ -384,6 +384,31 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
it("updates terminal font without remounting", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const refresh = vi.fn();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
const terminal: StubTerminal = {
|
||||
write: () => {},
|
||||
reset: () => {},
|
||||
focus: () => {},
|
||||
refresh,
|
||||
options: { fontFamily: "before", fontSize: 13 },
|
||||
rows: 12,
|
||||
cols: 40,
|
||||
};
|
||||
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
|
||||
runtime.setFont({ fontFamily: " Menlo ", fontSize: 18 });
|
||||
|
||||
expect(terminal.options?.fontFamily).toBe("Menlo");
|
||||
expect(terminal.options?.fontSize).toBe(18);
|
||||
expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true });
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
it("passively refits when the page becomes visible again", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface TerminalEmulatorRuntimeMountInput {
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollback: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export interface TerminalEmulatorRuntimeCallbacks {
|
||||
@@ -107,6 +109,7 @@ const isAppleHandheld =
|
||||
});
|
||||
|
||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 13;
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
|
||||
@@ -147,6 +150,17 @@ const DEFAULT_TERMINAL_FONT_FAMILY = [
|
||||
"monospace",
|
||||
].join(", ");
|
||||
|
||||
function resolveTerminalFontFamily(fontFamily: string | undefined): string {
|
||||
const trimmed = fontFamily?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_TERMINAL_FONT_FAMILY;
|
||||
}
|
||||
|
||||
function resolveTerminalFontSize(fontSize: number | undefined): number {
|
||||
return typeof fontSize === "number" && Number.isFinite(fontSize) && fontSize > 0
|
||||
? fontSize
|
||||
: DEFAULT_TERMINAL_FONT_SIZE;
|
||||
}
|
||||
|
||||
function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
||||
return {
|
||||
...theme,
|
||||
@@ -210,8 +224,8 @@ export class TerminalEmulatorRuntime {
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
fontFamily: resolveTerminalFontFamily(input.fontFamily),
|
||||
fontSize: resolveTerminalFontSize(input.fontSize),
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
@@ -668,6 +682,24 @@ export class TerminalEmulatorRuntime {
|
||||
this.refreshVisibleRows();
|
||||
}
|
||||
|
||||
setFont(input: { fontFamily?: string; fontSize?: number }): void {
|
||||
const terminal = this.terminal;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.options.fontFamily = resolveTerminalFontFamily(input.fontFamily);
|
||||
terminal.options.fontSize = resolveTerminalFontSize(input.fontSize);
|
||||
} catch {
|
||||
// ignore
|
||||
return;
|
||||
}
|
||||
|
||||
this.fitAndEmitResize?.({ force: true });
|
||||
this.refreshVisibleRows();
|
||||
}
|
||||
|
||||
focus(input?: { forceRefocus?: boolean }): void {
|
||||
const terminal = this.terminal;
|
||||
if (!terminal) {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface MountMessage {
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
@@ -33,6 +35,7 @@ type InboundMessage =
|
||||
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
|
||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
||||
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
|
||||
| {
|
||||
@@ -227,6 +230,8 @@ class TerminalWebViewBridge {
|
||||
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
||||
>,
|
||||
): void {
|
||||
if (this.receiveConfigurationMessage(message)) return;
|
||||
|
||||
switch (message.type) {
|
||||
case "writeOutput":
|
||||
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
|
||||
@@ -246,19 +251,34 @@ class TerminalWebViewBridge {
|
||||
case "resize":
|
||||
this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private receiveConfigurationMessage(
|
||||
message: Exclude<
|
||||
InboundMessage,
|
||||
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
||||
>,
|
||||
): boolean {
|
||||
switch (message.type) {
|
||||
case "setTheme":
|
||||
this.applyThemeBackground(message.theme);
|
||||
this.runtime?.setTheme({ theme: message.theme });
|
||||
break;
|
||||
return true;
|
||||
case "setScrollback":
|
||||
this.runtime?.setScrollback({ lines: message.lines });
|
||||
break;
|
||||
return true;
|
||||
case "setFont":
|
||||
this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize });
|
||||
return true;
|
||||
case "setPendingModifiers":
|
||||
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
|
||||
break;
|
||||
return true;
|
||||
case "setSwipeGesturesEnabled":
|
||||
this.swipeGesturesEnabled = message.enabled;
|
||||
break;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +320,8 @@ class TerminalWebViewBridge {
|
||||
initialSnapshot: message.initialSnapshot,
|
||||
scrollback: message.scrollbackLines,
|
||||
theme: message.theme,
|
||||
fontFamily: message.fontFamily,
|
||||
fontSize: message.fontSize,
|
||||
});
|
||||
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
14
packages/app/src/types/react-native-dataset.d.ts
vendored
Normal file
14
packages/app/src/types/react-native-dataset.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// react-native-web renders the `dataSet` prop to `data-*` attributes on the DOM
|
||||
// node, but the bundled react-native types don't declare it. Augment View/Text
|
||||
// props so code surfaces can carry a marker for the web interface-font rule
|
||||
// (see styles/code-surface.ts and screens/settings/appearance/apply-root-font.web.ts).
|
||||
import "react-native";
|
||||
|
||||
declare module "react-native" {
|
||||
interface ViewProps {
|
||||
dataSet?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
interface TextProps {
|
||||
dataSet?: Record<string, string | number | boolean | undefined>;
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,7 @@ export function buildHostNewWorkspaceRoute(
|
||||
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"general",
|
||||
"appearance",
|
||||
"shortcuts",
|
||||
"integrations",
|
||||
"permissions",
|
||||
|
||||
81
packages/highlight/src/__tests__/themes.test.ts
Normal file
81
packages/highlight/src/__tests__/themes.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { darkHighlightColors, lightHighlightColors } from "../colors.js";
|
||||
import { SYNTAX_THEME_IDS, isSyntaxThemeId, resolveSyntaxColors } from "../themes.js";
|
||||
import type { HighlightStyle } from "../types.js";
|
||||
|
||||
const allStyles: HighlightStyle[] = [
|
||||
"keyword",
|
||||
"comment",
|
||||
"string",
|
||||
"number",
|
||||
"literal",
|
||||
"function",
|
||||
"definition",
|
||||
"class",
|
||||
"type",
|
||||
"tag",
|
||||
"attribute",
|
||||
"property",
|
||||
"variable",
|
||||
"operator",
|
||||
"punctuation",
|
||||
"regexp",
|
||||
"escape",
|
||||
"meta",
|
||||
"heading",
|
||||
"link",
|
||||
];
|
||||
|
||||
const colorSchemes: ("light" | "dark")[] = ["light", "dark"];
|
||||
|
||||
describe("resolveSyntaxColors", () => {
|
||||
for (const id of SYNTAX_THEME_IDS) {
|
||||
for (const colorScheme of colorSchemes) {
|
||||
describe(`${id} (${colorScheme})`, () => {
|
||||
const colors = resolveSyntaxColors(id, colorScheme);
|
||||
|
||||
it("covers all HighlightStyle values", () => {
|
||||
for (const style of allStyles) {
|
||||
expect(colors[style]).toBeDefined();
|
||||
expect(typeof colors[style]).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("has valid hex color values", () => {
|
||||
for (const style of allStyles) {
|
||||
expect(colors[style]).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("github + light deep-equals lightHighlightColors", () => {
|
||||
expect(resolveSyntaxColors("github", "light")).toEqual(lightHighlightColors);
|
||||
});
|
||||
|
||||
it("github + dark deep-equals darkHighlightColors", () => {
|
||||
expect(resolveSyntaxColors("github", "dark")).toEqual(darkHighlightColors);
|
||||
});
|
||||
|
||||
it("dark-only themes ignore the color scheme", () => {
|
||||
for (const id of ["dracula", "nord"] as const) {
|
||||
expect(resolveSyntaxColors(id, "light")).toEqual(resolveSyntaxColors(id, "dark"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyntaxThemeId", () => {
|
||||
it("accepts every id in SYNTAX_THEME_IDS", () => {
|
||||
for (const id of SYNTAX_THEME_IDS) {
|
||||
expect(isSyntaxThemeId(id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown ids", () => {
|
||||
expect(isSyntaxThemeId("auto")).toBe(false);
|
||||
expect(isSyntaxThemeId("github-light")).toBe(false);
|
||||
expect(isSyntaxThemeId("one-dark")).toBe(false);
|
||||
expect(isSyntaxThemeId("nope")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,10 @@ export type { HighlightStyle, HighlightToken } from "./types.js";
|
||||
export { getParserForFile, isLanguageSupported, getSupportedExtensions } from "./parsers.js";
|
||||
export { highlightCode, highlightLine } from "./highlighter.js";
|
||||
export { darkHighlightColors, lightHighlightColors } from "./colors.js";
|
||||
export type { SyntaxThemeId, SyntaxThemeOption, SyntaxColors } from "./themes.js";
|
||||
export {
|
||||
SYNTAX_THEME_IDS,
|
||||
SYNTAX_THEME_OPTIONS,
|
||||
isSyntaxThemeId,
|
||||
resolveSyntaxColors,
|
||||
} from "./themes.js";
|
||||
|
||||
278
packages/highlight/src/themes.ts
Normal file
278
packages/highlight/src/themes.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { darkHighlightColors, lightHighlightColors } from "./colors.js";
|
||||
import type { HighlightStyle } from "./types.js";
|
||||
|
||||
// Syntax-highlighting themes are chosen independently of the app's light/dark
|
||||
// theme. The ONLY coupling is the light/dark axis: a theme that ships both
|
||||
// variants uses its light palette on a light app and its dark palette on a dark
|
||||
// app (resolveSyntaxColors receives the active theme's colorScheme). Dark-only
|
||||
// themes (Dracula, Nord) apply their single palette regardless. The code frame
|
||||
// — gutter, line numbers, background — follows the app theme, not the palette.
|
||||
export type SyntaxThemeId =
|
||||
| "github"
|
||||
| "catppuccin"
|
||||
| "dracula"
|
||||
| "tokyo-night"
|
||||
| "one"
|
||||
| "nord"
|
||||
| "gruvbox"
|
||||
| "solarized";
|
||||
|
||||
export const SYNTAX_THEME_IDS: readonly SyntaxThemeId[] = [
|
||||
"github",
|
||||
"catppuccin",
|
||||
"dracula",
|
||||
"tokyo-night",
|
||||
"one",
|
||||
"nord",
|
||||
"gruvbox",
|
||||
"solarized",
|
||||
];
|
||||
|
||||
export interface SyntaxThemeOption {
|
||||
id: SyntaxThemeId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const SYNTAX_THEME_OPTIONS: readonly SyntaxThemeOption[] = [
|
||||
{ id: "github", label: "GitHub" },
|
||||
{ id: "catppuccin", label: "Catppuccin" },
|
||||
{ id: "dracula", label: "Dracula" },
|
||||
{ id: "tokyo-night", label: "Tokyo Night" },
|
||||
{ id: "one", label: "One" },
|
||||
{ id: "nord", label: "Nord" },
|
||||
{ id: "gruvbox", label: "Gruvbox" },
|
||||
{ id: "solarized", label: "Solarized" },
|
||||
];
|
||||
|
||||
export type SyntaxColors = Record<HighlightStyle, string>;
|
||||
|
||||
// A compact per-theme role palette. `expandRolePalette` maps these roles onto
|
||||
// all 20 HighlightStyle tokens, so every theme stays complete and internally
|
||||
// consistent. GitHub keeps its own hand-tuned maps (colors.ts) for exactness
|
||||
// and byte-for-byte back-compat with the previous default.
|
||||
interface RolePalette {
|
||||
base: string; // plain text: variables, punctuation
|
||||
keyword: string;
|
||||
comment: string; // comments, meta
|
||||
string: string; // strings, regexp, links
|
||||
number: string; // numbers, literals, escapes
|
||||
function: string; // functions, definitions, headings
|
||||
type: string; // types, classes
|
||||
tag: string;
|
||||
attribute: string; // attributes, properties
|
||||
operator: string;
|
||||
}
|
||||
|
||||
function expandRolePalette(r: RolePalette): SyntaxColors {
|
||||
return {
|
||||
keyword: r.keyword,
|
||||
comment: r.comment,
|
||||
string: r.string,
|
||||
number: r.number,
|
||||
literal: r.number,
|
||||
function: r.function,
|
||||
definition: r.function,
|
||||
class: r.type,
|
||||
type: r.type,
|
||||
tag: r.tag,
|
||||
attribute: r.attribute,
|
||||
property: r.attribute,
|
||||
variable: r.base,
|
||||
operator: r.operator,
|
||||
punctuation: r.base,
|
||||
regexp: r.string,
|
||||
escape: r.number,
|
||||
meta: r.comment,
|
||||
heading: r.function,
|
||||
link: r.string,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Catppuccin (Latte light / Mocha dark) -------------------------------
|
||||
const catppuccinLatte: RolePalette = {
|
||||
base: "#4c4f69",
|
||||
keyword: "#8839ef",
|
||||
comment: "#8c8fa1",
|
||||
string: "#40a02b",
|
||||
number: "#fe640b",
|
||||
function: "#1e66f5",
|
||||
type: "#df8e1d",
|
||||
tag: "#8839ef",
|
||||
attribute: "#df8e1d",
|
||||
operator: "#04a5e5",
|
||||
};
|
||||
const catppuccinMocha: RolePalette = {
|
||||
base: "#cdd6f4",
|
||||
keyword: "#cba6f7",
|
||||
comment: "#9399b2",
|
||||
string: "#a6e3a1",
|
||||
number: "#fab387",
|
||||
function: "#89b4fa",
|
||||
type: "#f9e2af",
|
||||
tag: "#cba6f7",
|
||||
attribute: "#f9e2af",
|
||||
operator: "#89dceb",
|
||||
};
|
||||
|
||||
// --- Dracula (dark only) -------------------------------------------------
|
||||
const dracula: RolePalette = {
|
||||
base: "#f8f8f2",
|
||||
keyword: "#ff79c6",
|
||||
comment: "#6272a4",
|
||||
string: "#f1fa8c",
|
||||
number: "#bd93f9",
|
||||
function: "#50fa7b",
|
||||
type: "#8be9fd",
|
||||
tag: "#ff79c6",
|
||||
attribute: "#50fa7b",
|
||||
operator: "#ff79c6",
|
||||
};
|
||||
|
||||
// --- Tokyo Night (Day light / Night dark) --------------------------------
|
||||
const tokyoDay: RolePalette = {
|
||||
base: "#3760bf",
|
||||
keyword: "#9854f1",
|
||||
comment: "#848cb5",
|
||||
string: "#587539",
|
||||
number: "#b15c00",
|
||||
function: "#2e7de9",
|
||||
type: "#007197",
|
||||
tag: "#f52a65",
|
||||
attribute: "#8c6c3e",
|
||||
operator: "#006a83",
|
||||
};
|
||||
const tokyoNight: RolePalette = {
|
||||
base: "#c0caf5",
|
||||
keyword: "#bb9af7",
|
||||
comment: "#565f89",
|
||||
string: "#9ece6a",
|
||||
number: "#ff9e64",
|
||||
function: "#7aa2f7",
|
||||
type: "#2ac3de",
|
||||
tag: "#f7768e",
|
||||
attribute: "#e0af68",
|
||||
operator: "#89ddff",
|
||||
};
|
||||
|
||||
// --- One (One Light / One Dark) ------------------------------------------
|
||||
const oneLight: RolePalette = {
|
||||
base: "#383a42",
|
||||
keyword: "#a626a4",
|
||||
comment: "#a0a1a7",
|
||||
string: "#50a14f",
|
||||
number: "#986801",
|
||||
function: "#4078f2",
|
||||
type: "#c18401",
|
||||
tag: "#e45649",
|
||||
attribute: "#986801",
|
||||
operator: "#0184bc",
|
||||
};
|
||||
const oneDark: RolePalette = {
|
||||
base: "#abb2bf",
|
||||
keyword: "#c678dd",
|
||||
comment: "#5c6370",
|
||||
string: "#98c379",
|
||||
number: "#d19a66",
|
||||
function: "#61afef",
|
||||
type: "#e5c07b",
|
||||
tag: "#e06c75",
|
||||
attribute: "#d19a66",
|
||||
operator: "#56b6c2",
|
||||
};
|
||||
|
||||
// --- Nord (dark only) ----------------------------------------------------
|
||||
const nord: RolePalette = {
|
||||
base: "#d8dee9",
|
||||
keyword: "#81a1c1",
|
||||
comment: "#616e88",
|
||||
string: "#a3be8c",
|
||||
number: "#b48ead",
|
||||
function: "#88c0d0",
|
||||
type: "#8fbcbb",
|
||||
tag: "#81a1c1",
|
||||
attribute: "#8fbcbb",
|
||||
operator: "#81a1c1",
|
||||
};
|
||||
|
||||
// --- Gruvbox (Light / Dark) ----------------------------------------------
|
||||
const gruvboxLight: RolePalette = {
|
||||
base: "#3c3836",
|
||||
keyword: "#9d0006",
|
||||
comment: "#928374",
|
||||
string: "#79740e",
|
||||
number: "#8f3f71",
|
||||
function: "#427b58",
|
||||
type: "#b57614",
|
||||
tag: "#076678",
|
||||
attribute: "#b57614",
|
||||
operator: "#af3a03",
|
||||
};
|
||||
const gruvboxDark: RolePalette = {
|
||||
base: "#ebdbb2",
|
||||
keyword: "#fb4934",
|
||||
comment: "#928374",
|
||||
string: "#b8bb26",
|
||||
number: "#d3869b",
|
||||
function: "#8ec07c",
|
||||
type: "#fabd2f",
|
||||
tag: "#83a598",
|
||||
attribute: "#fabd2f",
|
||||
operator: "#fe8019",
|
||||
};
|
||||
|
||||
// --- Solarized (Light / Dark — shared accents, different base/comment) ----
|
||||
const solarizedLight: RolePalette = {
|
||||
base: "#657b83",
|
||||
keyword: "#859900",
|
||||
comment: "#93a1a1",
|
||||
string: "#2aa198",
|
||||
number: "#d33682",
|
||||
function: "#268bd2",
|
||||
type: "#b58900",
|
||||
tag: "#268bd2",
|
||||
attribute: "#b58900",
|
||||
operator: "#859900",
|
||||
};
|
||||
const solarizedDark: RolePalette = {
|
||||
base: "#839496",
|
||||
keyword: "#859900",
|
||||
comment: "#586e75",
|
||||
string: "#2aa198",
|
||||
number: "#d33682",
|
||||
function: "#268bd2",
|
||||
type: "#b58900",
|
||||
tag: "#268bd2",
|
||||
attribute: "#b58900",
|
||||
operator: "#859900",
|
||||
};
|
||||
|
||||
export function isSyntaxThemeId(value: string): value is SyntaxThemeId {
|
||||
return (SYNTAX_THEME_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// Resolve a theme id + the app's color scheme to a full token palette. Only the
|
||||
// light/dark axis is coupled to the app; the theme brand is the user's choice.
|
||||
export function resolveSyntaxColors(
|
||||
id: SyntaxThemeId,
|
||||
colorScheme: "light" | "dark",
|
||||
): SyntaxColors {
|
||||
const dark = colorScheme === "dark";
|
||||
switch (id) {
|
||||
case "github":
|
||||
return dark ? darkHighlightColors : lightHighlightColors;
|
||||
case "catppuccin":
|
||||
return expandRolePalette(dark ? catppuccinMocha : catppuccinLatte);
|
||||
case "dracula":
|
||||
return expandRolePalette(dracula);
|
||||
case "tokyo-night":
|
||||
return expandRolePalette(dark ? tokyoNight : tokyoDay);
|
||||
case "one":
|
||||
return expandRolePalette(dark ? oneDark : oneLight);
|
||||
case "nord":
|
||||
return expandRolePalette(nord);
|
||||
case "gruvbox":
|
||||
return expandRolePalette(dark ? gruvboxDark : gruvboxLight);
|
||||
case "solarized":
|
||||
return expandRolePalette(dark ? solarizedDark : solarizedLight);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user