Skip to content

feat: add FiraCode Nerd Font and custom font (TTF) support#57

Open
MADE-ADI wants to merge 1 commit intognoviawan:devfrom
MADE-ADI:feat/custom-font
Open

feat: add FiraCode Nerd Font and custom font (TTF) support#57
MADE-ADI wants to merge 1 commit intognoviawan:devfrom
MADE-ADI:feat/custom-font

Conversation

@MADE-ADI
Copy link

@MADE-ADI MADE-ADI commented Mar 8, 2026

  • Bundle FiraCode Nerd Font (Regular + Bold) in public/fonts/ with @font-face
  • Add 'FiraCode Nerd Font' as built-in terminal font option
  • New custom font feature: upload TTF files via AppPreferences UI
  • Custom fonts registered dynamically via FontFace API
  • Custom fonts appear in font family dropdown alongside built-in options
  • Persist custom fonts in app settings (base64 encoded)
  • Auto-load all custom fonts on app startup
  • Support up to 5 custom fonts (max 5MB each)
  • New files: font-loader.ts, custom-font-store.ts, use-custom-fonts.ts

Summary by CodeRabbit

  • New Features
    • Custom font management: upload, install, and select custom TTF fonts in preferences.
    • New FiraCode Nerd Font is now available for terminal use.
    • Remove installed custom fonts with a single click.
    • Maximum of 5 custom fonts per project with 5MB file size limit.

- Bundle FiraCode Nerd Font (Regular + Bold) in public/fonts/ with @font-face
- Add 'FiraCode Nerd Font' as built-in terminal font option
- New custom font feature: upload TTF files via AppPreferences UI
- Custom fonts registered dynamically via FontFace API
- Custom fonts appear in font family dropdown alongside built-in options
- Persist custom fonts in app settings (base64 encoded)
- Auto-load all custom fonts on app startup
- Support up to 5 custom fonts (max 5MB each)
- New files: font-loader.ts, custom-font-store.ts, use-custom-fonts.ts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive custom font system to the Tauri renderer application. It adds type definitions, a Zustand store for state management, font loading utilities, React hooks for font lifecycle operations (load, add, remove), CSS font declarations, app initialization logic, and a complete UI for managing custom fonts in the preferences panel.

Changes

Cohort / File(s) Summary
Core Infrastructure
src/renderer/types/settings.ts, src/renderer/stores/custom-font-store.ts
Introduced CustomFont interface, constants for limits (MAX_CUSTOM_FONTS, MAX_FONT_FILE_SIZE), persistence key, and a Zustand store managing font state with setFonts, addFont, removeFont actions and selectors.
Font Utilities
src/renderer/lib/font-loader.ts
New utility module exporting functions: registerFont (loads font from base64), unregisterFont (removes from document.fonts), isFontRegistered, getRegisteredFonts, readFontFile (converts File to base64), and extractFontName (normalizes filename to font name).
Font Hooks
src/renderer/hooks/use-custom-fonts.ts
Three hooks: useCustomFontsLoader (loads persisted fonts on startup), useAddCustomFont (adds new TTF files with validation, registration, store update, debounced persistence), useRemoveCustomFont (removes font, unregisters, updates store and persistence).
App Initialization
src/renderer/App.tsx, src/renderer/TauriApp.tsx
Both files invoke useCustomFontsLoader hook within AppEffects to load custom fonts during app startup.
Styling & Fonts
src/renderer/index.css
Added two @font-face declarations for "FiraCode Nerd Font" (Regular weight 400, Bold weight 700) with font-display swap.
UI Integration
src/renderer/pages/AppPreferences.tsx, src/renderer/types/settings.ts
Extended preferences UI with custom font management section including upload button, error/success messaging, list of installed fonts with removal action, and fallback empty state. Added FiraCode Nerd Font option to FONT_FAMILY_OPTIONS.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as AppPreferences UI
    participant Hook as useAddCustomFont
    participant Loader as font-loader
    participant Store as custom-font-store
    participant API as persistenceApi
    
    User->>UI: Select TTF file
    UI->>Hook: Call addCustomFont(file)
    Hook->>Hook: Validate .ttf extension
    Hook->>Hook: Validate file size ≤ 5MB
    Hook->>Hook: Validate max 5 fonts
    Hook->>Loader: readFontFile(file)
    Loader-->>Hook: base64 data
    Hook->>Hook: Extract fontFamily name
    Hook->>Hook: Check for duplicates
    Hook->>Loader: registerFont(fontFamily, base64)
    Loader->>Loader: Convert to ArrayBuffer
    Loader->>Loader: Create FontFace & load
    Loader-->>Hook: true/false
    Hook->>Store: addFont(CustomFont)
    Store-->>Hook: updated state
    Hook->>API: writeDebounced(CUSTOM_FONTS_KEY, fonts)
    API-->>Hook: persisted
    Hook-->>UI: {success: true, font}
    UI->>UI: Display success message
Loading
sequenceDiagram
    participant App
    participant Hook as useCustomFontsLoader
    participant API as persistenceApi
    participant Store as custom-font-store
    participant Loader as font-loader
    
    App->>Hook: Invoke on mount
    Hook->>API: Read CUSTOM_FONTS_KEY
    API-->>Hook: CustomFont[]
    Hook->>Store: setFonts(fonts)
    Store-->>Hook: isLoaded = true
    loop For each font
        Hook->>Loader: registerFont(fontFamily, base64)
        Loader-->>Hook: registration status
    end
    Hook-->>App: Fonts loaded & registered
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Custom fonts now hop and dance,
From files they get their chance,
Validated, registered with care,
Persisted safely everywhere! 📝✨
The rabbit grins—what a delightful feature fair!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: adding FiraCode Nerd Font support and implementing custom TTF font upload/management functionality.
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/renderer/pages/AppPreferences.tsx (1)

110-121: Minor: Consider a more robust font selection check.

The check fontFamily.includes(font.fontFamily) on Line 116 works correctly for the current format but could be fragile if the font family string format changes. Consider an exact match against the constructed value.

♻️ Proposed improvement
   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)) {
+    const selectedValue = font ? `"${font.fontFamily}", monospace` : ''
+    if (font && fontFamily === selectedValue) {
       handleFontFamilyChange('Menlo, Monaco, "Courier New", monospace')
     }
 
     await removeCustomFont(id)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/pages/AppPreferences.tsx` around lines 110 - 121, The removal
handler handleRemoveCustomFont uses a fragile substring check
(fontFamily.includes(font.fontFamily)); change it to a precise comparison:
compute the exact font-family string that the app sets for custom fonts (the
constructed value used elsewhere for selections) or normalize both sides (trim
quotes/whitespace and compare the primary family token) and then check equality
before calling handleFontFamilyChange; reference customFonts, font.fontFamily,
fontFamily, and handleFontFamilyChange to locate and update the comparison
logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/renderer/hooks/use-custom-fonts.ts`:
- Around line 101-117: The callback returned by useRemoveCustomFont captures the
stale fonts variable; update the callback to read the latest fonts from
useCustomFontStore.getState().fonts inside the async function (instead of the
captured fonts) so unregisterFont and persistenceApi.writeDebounced use the
current list, and adjust the useCallback dependency array to only include
removeFont; reference functions/values: useRemoveCustomFont,
useCustomFontStore.getState().fonts, removeFont, unregisterFont,
persistenceApi.writeDebounced, CUSTOM_FONTS_KEY, and useCallback.
- Around line 37-95: The callback returned by useAddCustomFont captures the
stale fonts variable; instead, inside the async callback read the live state
from the store (e.g., const currentFonts = useCustomFontStore.getState().fonts)
and replace all uses of fonts with currentFonts (checks for length >=
MAX_CUSTOM_FONTS, duplicate fontFamily, and composing allFonts for persistence).
Keep addFont from useCustomFontStore for updating, and update the useCallback
dependency array to omit fonts (e.g., [addFont]) so the closure no longer relies
on a stale array; ensure persistenceApi.writeDebounced(CUSTOM_FONTS_KEY,
allFonts) uses the computed allFonts based on currentFonts.

---

Nitpick comments:
In `@src/renderer/pages/AppPreferences.tsx`:
- Around line 110-121: The removal handler handleRemoveCustomFont uses a fragile
substring check (fontFamily.includes(font.fontFamily)); change it to a precise
comparison: compute the exact font-family string that the app sets for custom
fonts (the constructed value used elsewhere for selections) or normalize both
sides (trim quotes/whitespace and compare the primary family token) and then
check equality before calling handleFontFamilyChange; reference customFonts,
font.fontFamily, fontFamily, and handleFontFamilyChange to locate and update the
comparison logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93a3a08d-e25c-4451-b39c-97de168ec24d

📥 Commits

Reviewing files that changed from the base of the PR and between bde8122 and 1dc70cc.

⛔ Files ignored due to path filters (3)
  • package-lock.json is excluded by !**/package-lock.json
  • public/fonts/FiraCodeNerdFont-Bold.ttf is excluded by !**/*.ttf
  • public/fonts/FiraCodeNerdFont-Regular.ttf is excluded by !**/*.ttf
📒 Files selected for processing (8)
  • src/renderer/App.tsx
  • src/renderer/TauriApp.tsx
  • src/renderer/hooks/use-custom-fonts.ts
  • src/renderer/index.css
  • src/renderer/lib/font-loader.ts
  • src/renderer/pages/AppPreferences.tsx
  • src/renderer/stores/custom-font-store.ts
  • src/renderer/types/settings.ts

Comment on lines +37 to +95
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]
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stale closure issue with fonts reference.

The fonts array is captured in the useCallback closure, but it can become stale between renders. If a user adds fonts quickly in succession, the callback may operate on an outdated fonts array, potentially allowing duplicate fonts or exceeding the limit.

Consider reading the current state directly from the store within the callback.

🐛 Proposed fix
 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) => {
+      // Read current fonts from store to avoid stale closure
+      const fonts = useCustomFontStore.getState().fonts
+
       // Validate file type
       if (!file.name.toLowerCase().endsWith('.ttf')) {
         return { success: false, error: 'Only TTF font files are supported.' }
       }
       // ... rest of validation using fresh fonts ...
 
       // Update store and persist
       addFont(customFont)
-      const allFonts = [...fonts, customFont]
+      const allFonts = useCustomFontStore.getState().fonts
       await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, allFonts)

       return { success: true, font: customFont }
     },
-    [addFont, fonts]
+    [addFont]
   )
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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]
)
export function useAddCustomFont(): (file: File) => Promise<{ success: true; font: CustomFont } | { success: false; error: string }> {
const addFont = useCustomFontStore((state) => state.addFont)
return useCallback(
async (file: File) => {
// Read current fonts from store to avoid stale closure
const fonts = useCustomFontStore.getState().fonts
// 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 = useCustomFontStore.getState().fonts
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]
)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/use-custom-fonts.ts` around lines 37 - 95, The callback
returned by useAddCustomFont captures the stale fonts variable; instead, inside
the async callback read the live state from the store (e.g., const currentFonts
= useCustomFontStore.getState().fonts) and replace all uses of fonts with
currentFonts (checks for length >= MAX_CUSTOM_FONTS, duplicate fontFamily, and
composing allFonts for persistence). Keep addFont from useCustomFontStore for
updating, and update the useCallback dependency array to omit fonts (e.g.,
[addFont]) so the closure no longer relies on a stale array; ensure
persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, allFonts) uses the computed
allFonts based on currentFonts.

Comment on lines +101 to +117
export function useRemoveCustomFont(): (id: string) => Promise<void> {
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]
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same stale closure issue in useRemoveCustomFont.

The fonts reference captured in the closure can become stale. Use useCustomFontStore.getState().fonts within the callback for consistency.

🐛 Proposed fix
 export function useRemoveCustomFont(): (id: string) => Promise<void> {
   const removeFont = useCustomFontStore((state) => state.removeFont)
-  const fonts = useCustomFontStore((state) => state.fonts)

   return useCallback(
     async (id: string) => {
+      const fonts = useCustomFontStore.getState().fonts
       const font = fonts.find((f) => f.id === id)
       if (font) {
         unregisterFont(font.fontFamily)
       }

       removeFont(id)
-      const remaining = fonts.filter((f) => f.id !== id)
+      const remaining = useCustomFontStore.getState().fonts
       await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, remaining)
     },
-    [removeFont, fonts]
+    [removeFont]
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/use-custom-fonts.ts` around lines 101 - 117, The callback
returned by useRemoveCustomFont captures the stale fonts variable; update the
callback to read the latest fonts from useCustomFontStore.getState().fonts
inside the async function (instead of the captured fonts) so unregisterFont and
persistenceApi.writeDebounced use the current list, and adjust the useCallback
dependency array to only include removeFont; reference functions/values:
useRemoveCustomFont, useCustomFontStore.getState().fonts, removeFont,
unregisterFont, persistenceApi.writeDebounced, CUSTOM_FONTS_KEY, and
useCallback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant