Files
termix-mobile/app/tabs/settings/TerminalCustomization.tsx
Luke Gustafson 6c40d2f0cc v1.4.0 (#31)
* fix: register physical Tab key on iPadOS/iOS hardware keyboards (#14)

The native module only registered Shift+Tab but not bare Tab, so iOS
intercepted it for UI focus navigation. Register an unmodified Tab
UIKeyCommand with wantsPriorityOverSystemBehavior and handle both
Tab and Shift+Tab in the same JS branch.

Fixes Termix-SSH/Support#557

* fix: pass jumpHosts, forceKeyboardInteractive, and overrideCredentialUsername to terminal connection (#15)

These fields were defined on SSHHost but omitted from the terminal
hostConfig passthrough. Without jumpHosts the backend cannot route
through jump servers; without forceKeyboardInteractive hosts that
require keyboard-interactive auth fail silently; without
overrideCredentialUsername shared credentials use the wrong username.

Fixes Termix-SSH/Support#422
Fixes Termix-SSH/Support#638

* fix: improve mobile terminal scroll recovery (#17)

* Add mobile remote desktop sessions (#18)

* feat: add mobile remote desktop sessions

* feat: add remote desktop input controls

* feat: polish remote desktop controls

* feat: add mobile terminal font selection (#19)

* feat: add mobile shift tab hotkey (#20)

* feat: initial UI redesign

* feat: revamp server login system

* chore: update app icon/background color

* fix: remove unused fields in the settings tab

* feat: improve the user pin system and update all settings modals to use new ui

* fix: active server not updating unless app restarts

* feat: add version in settings

* feat: improve host page UI (updated host form and added credential management)

* fix: preserve composed iOS terminal input

* ci: restore mobile checks

* feat: rewrite of connection system for all types

* fix: preserve mobile jump hosts (#30)

* fix: guard android user ca trust (#29)

* fix: capture ios hardware tab key (#27)

* fix: reset terminal input after special keys (#28)

* fix: open oidc in system browser (#26)

* fix: preserve websocket auth context (#25)

* fix: keep none-auth terminal prompts alive (#23)

* fix: allow local network SSL in Android app (#21)

* feat: allow oidc passkeys

* feat: bundeled xterm into app and added docker loading screen

* feat: add 2fa, api key, and active sessions to settings and addressed multiple bug fixes and inconsistencies with termix web

* chore: update repo images and readme

* chore: update readme to split table for screenshots

* chore: rename ios to apple in build workflow

* fix: android build failure

* feat: add snippet management and fix several bugs

* fix: web login and oidc incorrect logic

* chore: run linter

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:49:50 -05:00

420 lines
14 KiB
TypeScript

import React, { useState } from "react";
import { View, ScrollView, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ArrowLeft, Type, AlignLeft, Rows3 } from "lucide-react-native";
import { useTerminalCustomization } from "@/app/contexts/TerminalCustomizationContext";
import { toast } from "@/app/utils/toast";
import { TERMINAL_FONTS } from "@/constants/terminal-themes";
import { Text, Button, Input, Dialog } from "@/app/components/ui";
import { useThemeColor } from "@/app/contexts/ThemeContext";
const FONT_SIZE_OPTIONS = [
{ label: "Extra Small", value: 12 },
{ label: "Small", value: 14 },
{ label: "Medium", value: 16 },
{ label: "Large", value: 18 },
{ label: "Extra Large", value: 20 },
{ label: "Huge", value: 24 },
];
const LETTER_SPACING_OPTIONS = [
{ label: "Default", value: 0 },
{ label: "Relaxed", value: 0.5 },
{ label: "Wide", value: 1 },
{ label: "Wider", value: 1.5 },
{ label: "Widest", value: 2 },
];
const LINE_HEIGHT_OPTIONS = [
{ label: "Compact", value: 1.0 },
{ label: "Default", value: 1.2 },
{ label: "Relaxed", value: 1.4 },
{ label: "Spacious", value: 1.6 },
];
export default function TerminalCustomization() {
const router = useRouter();
const insets = useSafeAreaInsets();
const color = useThemeColor();
const {
config,
updateFontFamily,
updateFontSize,
updateLetterSpacing,
updateLineHeight,
resetToDefault,
} = useTerminalCustomization();
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [customFontSize, setCustomFontSize] = useState("");
const [showCustomInput, setShowCustomInput] = useState(false);
const isCustomFontSize = !FONT_SIZE_OPTIONS.some(
(option) => option.value === config.fontSize,
);
const handleFontSizeChange = async (fontSize: number) => {
try {
await updateFontSize(fontSize);
toast.success(`Font size updated to ${fontSize}px`);
} catch {
toast.error("Failed to update font size");
}
};
const handleFontFamilyChange = async (fontFamily: string, label: string) => {
try {
await updateFontFamily(fontFamily);
toast.success(`Font updated to ${label}`);
} catch {
toast.error("Failed to update font");
}
};
const handleLetterSpacingChange = async (value: number) => {
try {
await updateLetterSpacing(value);
} catch {
toast.error("Failed to update letter spacing");
}
};
const handleLineHeightChange = async (value: number) => {
try {
await updateLineHeight(value);
} catch {
toast.error("Failed to update line height");
}
};
const handleReset = async () => {
try {
await resetToDefault();
toast.success("Terminal settings reset to default");
setShowResetConfirm(false);
} catch {
toast.error("Failed to reset settings");
}
};
const handleCustomFontSize = async () => {
const fontSize = parseInt(customFontSize);
if (isNaN(fontSize) || fontSize <= 0) {
toast.error("Please enter a valid font size");
return;
}
try {
await updateFontSize(fontSize);
toast.success(`Font size updated to ${fontSize}px`);
setShowCustomInput(false);
setCustomFontSize("");
} catch {
toast.error("Failed to update font size");
}
};
return (
<View className="flex-1 bg-background" style={{ paddingTop: insets.top }}>
{/* Header */}
<View className="flex-row items-center gap-3 border-b border-border px-4 pb-3 pt-3">
<Pressable
onPress={() => router.back()}
hitSlop={8}
className="shrink-0"
>
<ArrowLeft size={18} color={color("foreground")} />
</Pressable>
<Text weight="bold" className="flex-1 text-xl text-foreground">
Terminal
</Text>
</View>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, gap: 16, paddingBottom: 40 }}
>
{/* Font Family */}
<View className="border border-border bg-card">
<View className="flex-row items-center gap-2 border-b border-border px-3 py-3">
<Type size={14} color={color("muted-foreground")} />
<Text
weight="bold"
className="text-[11px] uppercase tracking-[2px] text-foreground"
>
Font Family
</Text>
</View>
<View className="gap-1.5 px-3 pb-3 pt-2">
<Text className="mb-1 text-[11px] text-muted-foreground">
Nerd Font support depends on the selected font being available to
the WebView.
</Text>
{TERMINAL_FONTS.map((option, i) => {
const isActive = config.fontFamily === option.value;
const isLast = i === TERMINAL_FONTS.length - 1;
return (
<Pressable
key={option.value}
onPress={() =>
handleFontFamilyChange(option.value, option.label)
}
className={`flex-row items-center justify-between py-2.5 ${!isLast ? "border-b border-border" : ""}`}
>
<View className="min-w-0 flex-1">
<Text
weight="medium"
className={`text-sm ${isActive ? "text-accent-brand" : "text-foreground"}`}
>
{option.label}
</Text>
<Text
className="mt-0.5 text-[10px] text-muted-foreground"
style={{ fontFamily: option.fallback }}
>
Aa Bb Cc 123
</Text>
</View>
{isActive ? (
<View className="shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5">
<Text
weight="bold"
className="text-[8px] uppercase tracking-wider text-accent-brand"
>
Active
</Text>
</View>
) : null}
</Pressable>
);
})}
</View>
</View>
{/* Font Size */}
<View className="border border-border bg-card">
<View className="flex-row items-center gap-2 border-b border-border px-3 py-3">
<AlignLeft size={14} color={color("muted-foreground")} />
<Text
weight="bold"
className="text-[11px] uppercase tracking-[2px] text-foreground"
>
Font Size
</Text>
</View>
<View className="gap-1.5 px-3 pb-3 pt-2">
<Text className="mb-1 text-[11px] text-muted-foreground">
Base size for terminal text. Overrides the font size configured on
the host in Termix Web UI.
</Text>
{FONT_SIZE_OPTIONS.map((option, i) => {
const isActive = config.fontSize === option.value;
return (
<Pressable
key={option.value}
onPress={() => handleFontSizeChange(option.value)}
className={`flex-row items-center justify-between py-2.5 ${i < FONT_SIZE_OPTIONS.length - 1 || !isCustomFontSize ? "border-b border-border" : ""}`}
>
<View>
<Text
weight="medium"
className={`text-sm ${isActive ? "text-accent-brand" : "text-foreground"}`}
>
{option.label}
</Text>
<Text className="mt-0.5 text-[10px] text-muted-foreground">
{option.value}px
</Text>
</View>
{isActive ? (
<View className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5">
<Text
weight="bold"
className="text-[8px] uppercase tracking-wider text-accent-brand"
>
Active
</Text>
</View>
) : null}
</Pressable>
);
})}
{/* Custom size row */}
<Pressable
onPress={() => setShowCustomInput(true)}
className="flex-row items-center justify-between py-2.5"
>
<View>
<Text
weight="medium"
className={`text-sm ${isCustomFontSize ? "text-accent-brand" : "text-foreground"}`}
>
Custom
</Text>
<Text className="mt-0.5 text-[10px] text-muted-foreground">
{isCustomFontSize ? `${config.fontSize}px` : "Enter any size"}
</Text>
</View>
{isCustomFontSize ? (
<View className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5">
<Text
weight="bold"
className="text-[8px] uppercase tracking-wider text-accent-brand"
>
Active
</Text>
</View>
) : null}
</Pressable>
</View>
</View>
{/* Spacing */}
<View className="border border-border bg-card">
<View className="flex-row items-center gap-2 border-b border-border px-3 py-3">
<Rows3 size={14} color={color("muted-foreground")} />
<Text
weight="bold"
className="text-[11px] uppercase tracking-[2px] text-foreground"
>
Spacing
</Text>
</View>
<View className="px-3 pb-3 pt-2">
<Text className="mb-3 text-[11px] text-muted-foreground">
Letter Spacing
</Text>
<View className="gap-1.5">
{LETTER_SPACING_OPTIONS.map((option, i) => {
const isActive =
(config.letterSpacing ?? 0) === option.value;
return (
<Pressable
key={option.value}
onPress={() => handleLetterSpacingChange(option.value)}
className={`flex-row items-center justify-between py-2.5 ${i < LETTER_SPACING_OPTIONS.length - 1 ? "border-b border-border" : ""}`}
>
<Text
weight="medium"
className={`text-sm ${isActive ? "text-accent-brand" : "text-foreground"}`}
>
{option.label}
</Text>
{isActive ? (
<View className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5">
<Text
weight="bold"
className="text-[8px] uppercase tracking-wider text-accent-brand"
>
Active
</Text>
</View>
) : null}
</Pressable>
);
})}
</View>
<Text className="mb-3 mt-4 text-[11px] text-muted-foreground">
Line Height
</Text>
<View className="gap-1.5">
{LINE_HEIGHT_OPTIONS.map((option, i) => {
const isActive =
(config.lineHeight ?? 1.2) === option.value;
return (
<Pressable
key={option.value}
onPress={() => handleLineHeightChange(option.value)}
className={`flex-row items-center justify-between py-2.5 ${i < LINE_HEIGHT_OPTIONS.length - 1 ? "border-b border-border" : ""}`}
>
<Text
weight="medium"
className={`text-sm ${isActive ? "text-accent-brand" : "text-foreground"}`}
>
{option.label}
</Text>
{isActive ? (
<View className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5">
<Text
weight="bold"
className="text-[8px] uppercase tracking-wider text-accent-brand"
>
Active
</Text>
</View>
) : null}
</Pressable>
);
})}
</View>
</View>
</View>
{/* Reset */}
<Button variant="destructive" onPress={() => setShowResetConfirm(true)}>
Reset to Default
</Button>
</ScrollView>
{/* Custom font size dialog */}
<Dialog
visible={showCustomInput}
onClose={() => {
setShowCustomInput(false);
setCustomFontSize("");
}}
title="Custom Font Size"
description="Enter your preferred font size for the terminal."
footer={
<>
<Button
variant="ghost"
size="sm"
onPress={() => {
setShowCustomInput(false);
setCustomFontSize("");
}}
>
Cancel
</Button>
<Button variant="accent" size="sm" onPress={handleCustomFontSize}>
Apply
</Button>
</>
}
>
<Input
value={customFontSize}
onChangeText={setCustomFontSize}
placeholder="e.g. 15"
keyboardType="number-pad"
autoFocus
/>
</Dialog>
{/* Reset confirm dialog */}
<Dialog
visible={showResetConfirm}
onClose={() => setShowResetConfirm(false)}
title="Confirm Reset"
description="This will reset all terminal customizations to default settings."
footer={
<>
<Button
variant="ghost"
size="sm"
onPress={() => setShowResetConfirm(false)}
>
Cancel
</Button>
<Button variant="destructive" size="sm" onPress={handleReset}>
Reset
</Button>
</>
}
/>
</View>
);
}