feat: allow users to specify a custom font (closes #176)
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m57s
CI / Lint & Test (pull_request) Successful in 24m12s
CI / Build Linux (pull_request) Successful in 23m48s
CI / Build Windows (cross-compile) (pull_request) Successful in 37m48s

Adds support for loading a custom font from either a remote URL or a
local file path, and applying it to the terminal and input bar.

- Rust: adds `custom_font_path` and `custom_font_family` fields to
  `HikariConfig` with `#[serde(default)]` for backwards compatibility
- TypeScript: extends `HikariConfig` interface and `defaultConfig`;
  exports `applyCustomFont()` which injects an `@import` for CSS
  stylesheet URLs, a `@font-face` rule for direct font file URLs, or a
  base64 data URL `@font-face` for local files via Tauri `readFile`;
  adds `setCustomFont()` to the config store
- Terminal.svelte and InputBar.svelte now use
  `--terminal-font-family` CSS variable (falls back to `monospace`)
- ConfigSidebar.svelte: new "Custom Font" section with URL/path input,
  family name input, Apply + Reset buttons, and inline status feedback
- `+page.svelte`: applies saved font on startup alongside theme/size
- 14 new tests for `applyCustomFont` (all code paths) + 2 for
  `setCustomFont`
This commit is contained in:
2026-03-03 18:07:14 -08:00
committed by Naomi Carrigan
parent 9061098b0f
commit 5bfff9d5e0
8 changed files with 353 additions and 3 deletions
+13
View File
@@ -144,6 +144,13 @@ pub struct HikariConfig {
#[serde(default)]
pub show_thinking_blocks: bool,
// Custom font settings
#[serde(default)]
pub custom_font_path: Option<String>,
#[serde(default)]
pub custom_font_family: Option<String>,
}
impl Default for HikariConfig {
@@ -183,6 +190,8 @@ impl Default for HikariConfig {
background_image_path: None,
background_image_opacity: 0.3,
show_thinking_blocks: false,
custom_font_path: None,
custom_font_family: None,
}
}
}
@@ -298,6 +307,8 @@ mod tests {
assert!(!config.disable_1m_context);
assert!(config.trusted_workspaces.is_empty());
assert!(!config.show_thinking_blocks);
assert!(config.custom_font_path.is_none());
assert!(config.custom_font_family.is_none());
}
#[test]
@@ -337,6 +348,8 @@ mod tests {
background_image_path: Some("/home/naomi/bg.png".to_string()),
background_image_opacity: 0.25,
show_thinking_blocks: true,
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
custom_font_family: Some("MyFont".to_string()),
};
let json = serde_json::to_string(&config).unwrap();
+70
View File
@@ -5,6 +5,7 @@
type Theme,
type CustomThemeColors,
applyFontSize,
applyCustomFont,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
@@ -60,9 +61,14 @@
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
});
let showCustomThemeEditor = $state(false);
let customFontPathInput = $state("");
let customFontFamilyInput = $state("");
let customFontStatus: string | null = $state(null);
interface AuthStatus {
is_logged_in: boolean;
@@ -88,6 +94,8 @@
configStore.config.subscribe((c) => {
config = { ...c };
customFontPathInput = c.custom_font_path ?? "";
customFontFamilyInput = c.custom_font_family ?? "";
});
configStore.isSidebarOpen.subscribe((open) => {
@@ -937,6 +945,68 @@
</p>
</div>
<!-- Custom Font -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Font</span>
<div class="flex flex-col gap-2">
<input
type="text"
bind:value={customFontPathInput}
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<input
type="text"
bind:value={customFontFamilyInput}
placeholder="Font family name (e.g. FiraCode)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<div class="flex gap-2">
<button
onclick={async () => {
customFontStatus = null;
try {
await configStore.setCustomFont(
customFontPathInput || null,
customFontFamilyInput || null
);
customFontStatus = "Font applied!";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
Apply
</button>
<button
onclick={async () => {
customFontStatus = null;
customFontPathInput = "";
customFontFamilyInput = "";
try {
await configStore.setCustomFont(null, null);
await applyCustomFont(null, null);
customFontStatus = "Font reset to default.";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
>
Reset
</button>
</div>
{#if customFontStatus}
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
required to apply the font.
</p>
</div>
<!-- Show Thinking Blocks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
+1 -1
View File
@@ -1011,7 +1011,7 @@ User: ${formattedMessage}`;
: "Connect to Claude first..."}
disabled={isSubmitting}
rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
+2
View File
@@ -111,6 +111,8 @@
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
});
let streamerModeActive = $state(false);
+1 -1
View File
@@ -231,7 +231,7 @@
bind:this={terminalElement}
onscroll={handleScroll}
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
style="font-size: var(--terminal-font-size, 14px);"
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
>
{#if lines.length === 0}
<div class="terminal-waiting italic">
+184
View File
@@ -5,6 +5,7 @@ import {
maskPaths,
clampFontSize,
applyFontSize,
applyCustomFont,
applyTheme,
applyCustomThemeColors,
clearCustomThemeColors,
@@ -21,12 +22,17 @@ import {
type CustomThemeColors,
} from "./config";
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
// Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
readFile: vi.fn(),
}));
describe("config store", () => {
describe("font size constants", () => {
it("has correct MIN_FONT_SIZE", () => {
@@ -206,6 +212,8 @@ describe("config store", () => {
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -258,6 +266,8 @@ describe("config store", () => {
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
};
expect(config.model).toBeNull();
@@ -809,6 +819,8 @@ describe("config store", () => {
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
};
const mockInvokeImpl = vi.mocked(invoke);
@@ -1133,4 +1145,176 @@ describe("config store", () => {
expect(get(showThinkingBlocks)).toBe(false);
});
});
describe("applyCustomFont", () => {
const readFileMock = vi.mocked(readFile);
beforeEach(() => {
// Remove any style element left by previous tests
document.getElementById("hikari-custom-font")?.remove();
document.documentElement.style.removeProperty("--terminal-font-family");
readFileMock.mockReset();
});
it("removes CSS variable when both path and family are null", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await applyCustomFont(null, null);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
});
it("removes CSS variable when both path and family are empty strings", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await applyCustomFont("", "");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
expect(document.getElementById("hikari-custom-font")).toBeNull();
});
it("injects @import for a CSS stylesheet URL", async () => {
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain(
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'Fira Code', monospace"
);
});
it("injects @font-face for a direct font file URL (.woff2)", async () => {
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
expect(style?.textContent).toContain("'MyFont'");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'MyFont', monospace"
);
});
it("injects @font-face for a direct font file URL (.ttf)", async () => {
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
expect(style?.textContent).toContain("'MyTtfFont'");
});
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("'HikariCustomFont'");
});
it("reads local file and injects @font-face with data URL", async () => {
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("data:font/ttf;base64,");
expect(style?.textContent).toContain("'MyFont'");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'MyFont', monospace"
);
});
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
const fakeData = new Uint8Array([1, 2, 3]);
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("'HikariCustomFont'");
expect(style?.textContent).toContain("font/woff2");
});
it("sets CSS variable when only family is provided (no path)", async () => {
await applyCustomFont("", "SystemFont");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'SystemFont', monospace"
);
expect(document.getElementById("hikari-custom-font")).toBeNull();
});
it("replaces a previously injected style element", async () => {
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
const styles = document.querySelectorAll("#hikari-custom-font");
expect(styles.length).toBe(1);
expect(styles[0].textContent).toContain("Roboto+Mono");
});
it("uses correct MIME type for .otf local files", async () => {
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("font/otf");
});
it("falls back to font/ttf MIME for unknown extension local files", async () => {
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("font/ttf");
});
});
describe("setCustomFont", () => {
const readFileMock = vi.mocked(readFile);
const invokeMock = vi.mocked(invoke);
beforeEach(() => {
document.getElementById("hikari-custom-font")?.remove();
document.documentElement.style.removeProperty("--terminal-font-family");
readFileMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
it("saves config and applies the font", async () => {
await configStore.setCustomFont(null, null);
await configStore.setCustomFont(
"https://fonts.googleapis.com/css2?family=Fira+Code",
"Fira Code"
);
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
custom_font_family: "Fira Code",
}),
})
);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'Fira Code', monospace"
);
});
it("clears font when called with nulls", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await configStore.setCustomFont(null, null);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_font_path: null,
custom_font_family: null,
}),
})
);
});
});
});
+74
View File
@@ -1,5 +1,6 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
export type Theme = "dark" | "light" | "high-contrast" | "custom";
export type BudgetAction = "warn" | "block";
@@ -58,6 +59,9 @@ export interface HikariConfig {
// Background image settings
background_image_path: string | null;
background_image_opacity: number;
// Custom font settings
custom_font_path: string | null;
custom_font_family: string | null;
}
const defaultConfig: HikariConfig = {
@@ -104,6 +108,8 @@ const defaultConfig: HikariConfig = {
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
};
function createConfigStore() {
@@ -240,6 +246,11 @@ function createConfigStore() {
setCompactMode: async (enabled: boolean) => {
await updateConfig({ compact_mode: enabled });
},
setCustomFont: async (path: string | null, family: string | null) => {
await updateConfig({ custom_font_path: path, custom_font_family: family });
await applyCustomFont(path, family);
},
};
}
@@ -305,6 +316,69 @@ export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
}
const FONT_STYLE_ID = "hikari-custom-font";
const FONT_CSS_VAR = "--terminal-font-family";
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
if (typeof document === "undefined") return;
// Remove any previously injected font style
document.getElementById(FONT_STYLE_ID)?.remove();
const trimmedPath = path?.trim() ?? "";
const trimmedFamily = family?.trim() ?? "";
if (!trimmedPath && !trimmedFamily) {
document.documentElement.style.removeProperty(FONT_CSS_VAR);
return;
}
if (trimmedPath) {
const style = document.createElement("style");
style.id = FONT_STYLE_ID;
if (trimmedPath.startsWith("http://") || trimmedPath.startsWith("https://")) {
const ext = trimmedPath.split(".").pop()?.toLowerCase() ?? "";
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
// Direct font file URL — inject via @font-face
const fontFamily = trimmedFamily || "HikariCustomFont";
style.textContent = `@font-face { font-family: '${fontFamily}'; src: url('${trimmedPath}'); }`;
} else {
// CSS stylesheet URL (e.g. Google Fonts) — inject as @import
style.textContent = `@import url('${trimmedPath}');`;
}
} else {
// Local file path — read via Tauri and embed as data URL
const data = await readFile(trimmedPath);
const chunks: string[] = [];
const chunkSize = 8192;
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
}
const ext = trimmedPath.split(".").pop()?.toLowerCase() ?? "ttf";
const mimeMap: Record<string, string> = {
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
eot: "application/vnd.ms-fontobject",
};
const mime = mimeMap[ext] ?? "font/ttf";
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
const fontFamily = trimmedFamily || "HikariCustomFont";
style.textContent = `@font-face { font-family: '${fontFamily}'; src: url('${dataUrl}'); }`;
}
document.head.appendChild(style);
}
if (trimmedFamily) {
document.documentElement.style.setProperty(FONT_CSS_VAR, `'${trimmedFamily}', monospace`);
}
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore();
+8 -1
View File
@@ -11,7 +11,13 @@
updateDiscordRpc,
setSkipNextGreeting,
} from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config";
import {
configStore,
applyTheme,
applyFontSize,
applyCustomFont,
isCompactMode,
} from "$lib/stores/config";
import { readFile } from "@tauri-apps/plugin-fs";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations";
@@ -454,6 +460,7 @@
const config = configStore.getConfig();
applyTheme(config.theme, config.custom_theme_colors);
applyFontSize(config.font_size);
await applyCustomFont(config.custom_font_path, config.custom_font_family);
// Apply always-on-top setting
if (config.always_on_top) {