generated from nhcarrigan/template
feat: allow users to specify a custom font (closes #176)
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:
@@ -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,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user