diff --git a/package-lock.json b/package-lock.json index 0c08ae9..b6252dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termul-manager", - "version": "0.3.0-2", + "version": "0.3.0-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termul-manager", - "version": "0.3.0-2", + "version": "0.3.0-4", "license": "MIT", "dependencies": { "@blocknote/code-block": "^0.46.2", @@ -4478,7 +4478,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", diff --git a/public/fonts/FiraCodeNerdFont-Bold.ttf b/public/fonts/FiraCodeNerdFont-Bold.ttf new file mode 100644 index 0000000..bd7fbd3 Binary files /dev/null and b/public/fonts/FiraCodeNerdFont-Bold.ttf differ diff --git a/public/fonts/FiraCodeNerdFont-Regular.ttf b/public/fonts/FiraCodeNerdFont-Regular.ttf new file mode 100644 index 0000000..7abb9fc Binary files /dev/null and b/public/fonts/FiraCodeNerdFont-Regular.ttf differ diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 39bf25b..a29344a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -24,6 +24,7 @@ import { useMenuUpdaterListener } from './hooks/use-menu-updater-listener' import { useUpdateCheck } from './hooks/use-updater' import { useUpdateToast } from './components/UpdateAvailableToast' import { useVisibilityState } from './hooks/use-visibility-state' +import { useCustomFontsLoader } from './hooks/use-custom-fonts' // Hook to prevent Alt key from showing the default browser menu bar function usePreventAltMenu(): void { @@ -65,6 +66,7 @@ function AppEffects(): null { useExitCode() useContextBarSettings() useAppSettingsLoader() + useCustomFontsLoader() useKeyboardShortcutsLoader() useProjectsLoader() useProjectsAutoSave() diff --git a/src/renderer/TauriApp.tsx b/src/renderer/TauriApp.tsx index cbadcad..4fa5f21 100644 --- a/src/renderer/TauriApp.tsx +++ b/src/renderer/TauriApp.tsx @@ -26,6 +26,7 @@ import { useMenuUpdaterListener } from './hooks/use-menu-updater-listener' import { useUpdateCheck } from './hooks/use-updater' import { useUpdateToast } from './components/UpdateAvailableToast' import { useVisibilityState } from './hooks/use-visibility-state' +import { useCustomFontsLoader } from './hooks/use-custom-fonts' const queryClient = new QueryClient() @@ -39,6 +40,7 @@ function AppEffects(): null { useExitCode() useContextBarSettings() useAppSettingsLoader() + useCustomFontsLoader() useKeyboardShortcutsLoader() useProjectsLoader() useProjectsAutoSave() diff --git a/src/renderer/hooks/use-custom-fonts.ts b/src/renderer/hooks/use-custom-fonts.ts new file mode 100644 index 0000000..c21829f --- /dev/null +++ b/src/renderer/hooks/use-custom-fonts.ts @@ -0,0 +1,118 @@ +import { useEffect, useCallback } from 'react' +import { useCustomFontStore } from '@/stores/custom-font-store' +import { persistenceApi } from '@/lib/api' +import { registerFont, unregisterFont, readFontFile, extractFontName } from '@/lib/font-loader' +import type { CustomFont } from '@/types/settings' +import { CUSTOM_FONTS_KEY, MAX_CUSTOM_FONTS, MAX_FONT_FILE_SIZE } from '@/types/settings' + +/** + * Load and register all custom fonts from persistence on app startup + */ +export function useCustomFontsLoader(): void { + const setFonts = useCustomFontStore((state) => state.setFonts) + + useEffect(() => { + async function load(): Promise { + const result = await persistenceApi.read(CUSTOM_FONTS_KEY) + if (result.success && result.data) { + const fonts = result.data + setFonts(fonts) + + // Register all fonts with the browser + for (const font of fonts) { + await registerFont(font.fontFamily, font.data) + } + } else { + setFonts([]) + } + } + load() + }, [setFonts]) +} + +/** + * Add a custom font from a file input + * Returns the added font on success, or an error message on failure + */ +export function useAddCustomFont(): (file: File) => Promise<{ success: true; font: CustomFont } | { success: false; error: string }> { + const addFont = useCustomFontStore((state) => state.addFont) + const fonts = useCustomFontStore((state) => state.fonts) + + return useCallback( + async (file: File) => { + // Validate file type + if (!file.name.toLowerCase().endsWith('.ttf')) { + return { success: false, error: 'Only TTF font files are supported.' } + } + + // Validate file size + if (file.size > MAX_FONT_FILE_SIZE) { + const sizeMB = (MAX_FONT_FILE_SIZE / 1024 / 1024).toFixed(0) + return { success: false, error: `Font file is too large. Maximum size is ${sizeMB}MB.` } + } + + // Check limit + if (fonts.length >= MAX_CUSTOM_FONTS) { + return { success: false, error: `Maximum of ${MAX_CUSTOM_FONTS} custom fonts reached. Remove one first.` } + } + + try { + // Read file as base64 + const base64Data = await readFontFile(file) + const name = extractFontName(file.name) + const fontFamily = `Custom-${name.replace(/\s+/g, '-')}` + + // Check for duplicate name + if (fonts.some((f) => f.fontFamily === fontFamily)) { + return { success: false, error: `A custom font with the name "${name}" already exists.` } + } + + // Register with browser + const registered = await registerFont(fontFamily, base64Data) + if (!registered) { + return { success: false, error: 'Failed to load font. The file may be corrupted.' } + } + + const customFont: CustomFont = { + id: crypto.randomUUID(), + name, + fontFamily, + data: base64Data, + addedAt: Date.now() + } + + // Update store and persist + addFont(customFont) + const allFonts = [...fonts, customFont] + await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, allFonts) + + return { success: true, font: customFont } + } catch (err) { + return { success: false, error: `Failed to add font: ${String(err)}` } + } + }, + [addFont, fonts] + ) +} + +/** + * Remove a custom font by ID + */ +export function useRemoveCustomFont(): (id: string) => Promise { + const removeFont = useCustomFontStore((state) => state.removeFont) + const fonts = useCustomFontStore((state) => state.fonts) + + return useCallback( + async (id: string) => { + const font = fonts.find((f) => f.id === id) + if (font) { + unregisterFont(font.fontFamily) + } + + removeFont(id) + const remaining = fonts.filter((f) => f.id !== id) + await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, remaining) + }, + [removeFont, fonts] + ) +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 0b7d8a0..d9aa47e 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -1,5 +1,22 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); +/* Bundled FiraCode Nerd Font */ +@font-face { + font-family: 'FiraCode Nerd Font'; + src: url('/fonts/FiraCodeNerdFont-Regular.ttf') format('truetype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'FiraCode Nerd Font'; + src: url('/fonts/FiraCodeNerdFont-Bold.ttf') format('truetype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/src/renderer/lib/font-loader.ts b/src/renderer/lib/font-loader.ts new file mode 100644 index 0000000..d8885f8 --- /dev/null +++ b/src/renderer/lib/font-loader.ts @@ -0,0 +1,108 @@ +/** + * Font Loader Utility + * + * Handles dynamic registration and unregistration of custom fonts + * using the CSS FontFace API. Works in both web and Tauri contexts. + */ + +const registeredFonts = new Map() + +/** + * Register a custom font from base64-encoded TTF data + */ +export async function registerFont(fontFamily: string, base64Data: string): Promise { + try { + // Unregister existing font with same name first + if (registeredFonts.has(fontFamily)) { + unregisterFont(fontFamily) + } + + const binaryData = base64ToArrayBuffer(base64Data) + const fontFace = new FontFace(fontFamily, binaryData, { + style: 'normal', + weight: '400', + display: 'swap' + }) + + await fontFace.load() + document.fonts.add(fontFace) + registeredFonts.set(fontFamily, fontFace) + + return true + } catch (err) { + console.error(`Failed to register font "${fontFamily}":`, err) + return false + } +} + +/** + * Unregister a previously registered custom font + */ +export function unregisterFont(fontFamily: string): boolean { + const fontFace = registeredFonts.get(fontFamily) + if (fontFace) { + document.fonts.delete(fontFace) + registeredFonts.delete(fontFamily) + return true + } + return false +} + +/** + * Check if a custom font is currently registered + */ +export function isFontRegistered(fontFamily: string): boolean { + return registeredFonts.has(fontFamily) +} + +/** + * Get all registered custom font family names + */ +export function getRegisteredFonts(): string[] { + return Array.from(registeredFonts.keys()) +} + +/** + * Read a TTF file and return base64-encoded data + */ +export function readFontFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result as string + // Strip the data URL prefix to get pure base64 + const base64 = result.split(',')[1] + if (!base64) { + reject(new Error('Failed to read font file as base64')) + return + } + resolve(base64) + } + reader.onerror = () => reject(new Error('Failed to read font file')) + reader.readAsDataURL(file) + }) +} + +/** + * Extract a clean font name from a filename + * e.g. "MyFont-Regular.ttf" → "MyFont" + */ +export function extractFontName(filename: string): string { + return filename + .replace(/\.ttf$/i, '') + .replace(/-(Regular|Bold|Italic|Light|Medium|SemiBold|ExtraBold|Thin|Black)$/i, '') + .replace(/([a-z])([A-Z])/g, '$1 $2') // CamelCase → spaces + .trim() +} + +/** + * Convert base64 string to ArrayBuffer + */ +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes.buffer +} diff --git a/src/renderer/pages/AppPreferences.tsx b/src/renderer/pages/AppPreferences.tsx index 8ad590b..6f14d73 100644 --- a/src/renderer/pages/AppPreferences.tsx +++ b/src/renderer/pages/AppPreferences.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { RotateCcw, Keyboard, Download, CheckCircle2, AlertCircle } from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { RotateCcw, Keyboard, Download, CheckCircle2, AlertCircle, Upload, Trash2, Type } from 'lucide-react' import { useTerminalFontFamily, useTerminalFontSize, @@ -11,7 +11,9 @@ import { useOrphanDetectionTimeout } from '@/stores/app-settings-store' import { useUpdateAppSetting, useResetAppSettings } from '@/hooks/use-app-settings' -import { FONT_FAMILY_OPTIONS, BUFFER_SIZE_OPTIONS, MAX_TERMINALS_OPTIONS, ORPHAN_TIMEOUT_OPTIONS } from '@/types/settings' +import { FONT_FAMILY_OPTIONS, BUFFER_SIZE_OPTIONS, MAX_TERMINALS_OPTIONS, ORPHAN_TIMEOUT_OPTIONS, MAX_CUSTOM_FONTS } from '@/types/settings' +import { useCustomFonts } from '@/stores/custom-font-store' +import { useAddCustomFont, useRemoveCustomFont } from '@/hooks/use-custom-fonts' import type { DetectedShells } from '@shared/types/ipc.types' import type { ProjectColor } from '@/types/project' import { availableColors, getColorClasses } from '@/lib/colors' @@ -40,6 +42,14 @@ export default function AppPreferences(): React.JSX.Element { const updateSetting = useUpdateAppSetting() const resetSettings = useResetAppSettings() + // Custom fonts + const customFonts = useCustomFonts() + const addCustomFont = useAddCustomFont() + const removeCustomFont = useRemoveCustomFont() + const fontFileInputRef = useRef(null) + const [fontError, setFontError] = useState(null) + const [fontSuccess, setFontSuccess] = useState(null) + const [availableShells, setAvailableShells] = useState(null) const [isResetDialogOpen, setIsResetDialogOpen] = useState(false) const [isResetShortcutsDialogOpen, setIsResetShortcutsDialogOpen] = useState(false) @@ -76,6 +86,40 @@ export default function AppPreferences(): React.JSX.Element { updateSetting('terminalFontSize', value) } + const handleCustomFontUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + setFontError(null) + setFontSuccess(null) + + const result = await addCustomFont(file) + if (result.success) { + setFontSuccess(`"${result.font.name}" added successfully.`) + setTimeout(() => setFontSuccess(null), 3000) + } else { + setFontError(result.error) + } + + // Reset input so the same file can be selected again + if (fontFileInputRef.current) { + fontFileInputRef.current.value = '' + } + } + + const handleRemoveCustomFont = async (id: string) => { + setFontError(null) + setFontSuccess(null) + + // If the removed font is currently selected, switch to default + const font = customFonts.find((f) => f.id === id) + if (font && fontFamily.includes(font.fontFamily)) { + handleFontFamilyChange('Menlo, Monaco, "Courier New", monospace') + } + + await removeCustomFont(id) + } + const handleBufferSizeChange = (value: number) => { updateSetting('terminalBufferSize', value) } @@ -173,11 +217,22 @@ export default function AppPreferences(): React.JSX.Element { onChange={(e) => handleFontFamilyChange(e.target.value)} className="w-full bg-secondary/50 border border-border rounded-md px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-shadow" > - {FONT_FAMILY_OPTIONS.map((option) => ( - - ))} + + {FONT_FAMILY_OPTIONS.map((option) => ( + + ))} + + {customFonts.length > 0 && ( + + {customFonts.map((font) => ( + + ))} + + )}

Choose a monospace font for terminal text. @@ -272,6 +327,100 @@ export default function AppPreferences(): React.JSX.Element { + {/* Custom Fonts Section */} +

+
+
+

Custom Fonts

+

+ Upload custom TTF font files for use in the terminal. +

+
+
+ {/* Upload Button */} +
+ + +

+ Upload a TrueType font file. Maximum {MAX_CUSTOM_FONTS} custom fonts, 5MB each. +

+
+ + {/* Status Messages */} + {fontError && ( +
+ + {fontError} +
+ )} + {fontSuccess && ( +
+ + {fontSuccess} +
+ )} + + {/* Custom Font List */} + {customFonts.length > 0 ? ( +
+ + {customFonts.map((font) => ( +
+
+ +
+

+ {font.name} +

+

+ The quick brown fox jumps over the lazy dog +

+
+
+ +
+ ))} +
+ ) : ( +
+ +

+ No custom fonts installed. Upload a .ttf file to get started. +

+
+ )} +
+
+
+ {/* Default Shell Section */}
diff --git a/src/renderer/stores/custom-font-store.ts b/src/renderer/stores/custom-font-store.ts new file mode 100644 index 0000000..bd8634e --- /dev/null +++ b/src/renderer/stores/custom-font-store.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand' +import type { CustomFont } from '@/types/settings' + +interface CustomFontState { + fonts: CustomFont[] + isLoaded: boolean + setFonts: (fonts: CustomFont[]) => void + addFont: (font: CustomFont) => void + removeFont: (id: string) => void +} + +export const useCustomFontStore = create((set) => ({ + fonts: [], + isLoaded: false, + + setFonts: (fonts) => set({ fonts, isLoaded: true }), + + addFont: (font) => + set((state) => ({ + fonts: [...state.fonts, font] + })), + + removeFont: (id) => + set((state) => ({ + fonts: state.fonts.filter((f) => f.id !== id) + })) +})) + +// Selectors +export const useCustomFonts = () => useCustomFontStore((state) => state.fonts) +export const useCustomFontsLoaded = () => useCustomFontStore((state) => state.isLoaded) diff --git a/src/renderer/types/settings.ts b/src/renderer/types/settings.ts index f55f684..0440891 100644 --- a/src/renderer/types/settings.ts +++ b/src/renderer/types/settings.ts @@ -46,9 +46,28 @@ export const FONT_FAMILY_OPTIONS = [ { value: '"Courier New", Courier, monospace', label: 'Courier New' }, { value: '"Source Code Pro", Menlo, monospace', label: 'Source Code Pro' }, { value: '"JetBrains Mono", Menlo, monospace', label: 'JetBrains Mono' }, - { value: '"Fira Code", Menlo, monospace', label: 'Fira Code' } + { value: '"Fira Code", Menlo, monospace', label: 'Fira Code' }, + { value: '"FiraCode Nerd Font", "Fira Code", monospace', label: 'FiraCode Nerd Font ⚡' } ] +// Custom font entry stored in persistence +export interface CustomFont { + id: string + name: string + fontFamily: string + data: string // base64-encoded TTF + addedAt: number +} + +// Maximum custom fonts allowed +export const MAX_CUSTOM_FONTS = 5 + +// Maximum file size for custom fonts (5MB) +export const MAX_FONT_FILE_SIZE = 5 * 1024 * 1024 + +// Persistence key for custom fonts +export const CUSTOM_FONTS_KEY = 'settings/custom-fonts' + // Max terminals per project options export const MAX_TERMINALS_OPTIONS = [ { value: 5, label: '5 terminals' },