generated from nhcarrigan/template
feat: add custom UI font support
Allow users to specify a custom font for the entire app interface (menus, labels, buttons) separately from the terminal font. Supports Google Fonts URLs, direct font file URLs, and local file paths. - Add custom_ui_font_path and custom_ui_font_family to Rust config - Refactor applyCustomFont into shared applyFontFromSource helper - Add applyCustomUiFont function using --ui-font-family CSS variable - Update app.css to use --ui-font-family with fallback - Apply custom UI font on startup in +page.svelte - Add Custom UI Font section to ConfigSidebar settings panel - Add tests for applyCustomUiFont and setCustomUiFont
This commit is contained in:
+14
-1
@@ -145,12 +145,19 @@ pub struct HikariConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub show_thinking_blocks: bool,
|
pub show_thinking_blocks: bool,
|
||||||
|
|
||||||
// Custom font settings
|
// Custom terminal font settings
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_font_path: Option<String>,
|
pub custom_font_path: Option<String>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_font_family: Option<String>,
|
pub custom_font_family: Option<String>,
|
||||||
|
|
||||||
|
// Custom UI font settings
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_ui_font_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_ui_font_family: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -192,6 +199,8 @@ impl Default for HikariConfig {
|
|||||||
show_thinking_blocks: false,
|
show_thinking_blocks: false,
|
||||||
custom_font_path: None,
|
custom_font_path: None,
|
||||||
custom_font_family: None,
|
custom_font_family: None,
|
||||||
|
custom_ui_font_path: None,
|
||||||
|
custom_ui_font_family: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +318,8 @@ mod tests {
|
|||||||
assert!(!config.show_thinking_blocks);
|
assert!(!config.show_thinking_blocks);
|
||||||
assert!(config.custom_font_path.is_none());
|
assert!(config.custom_font_path.is_none());
|
||||||
assert!(config.custom_font_family.is_none());
|
assert!(config.custom_font_family.is_none());
|
||||||
|
assert!(config.custom_ui_font_path.is_none());
|
||||||
|
assert!(config.custom_ui_font_family.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -350,6 +361,8 @@ mod tests {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
|
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
|
||||||
custom_font_family: Some("MyFont".to_string()),
|
custom_font_family: Some("MyFont".to_string()),
|
||||||
|
custom_ui_font_path: None,
|
||||||
|
custom_ui_font_family: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
+1
-5
@@ -154,11 +154,7 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family:
|
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
||||||
"Segoe UI",
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
applyCustomFont,
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
@@ -63,12 +64,17 @@
|
|||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
custom_font_path: null,
|
||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
let customFontPathInput = $state("");
|
let customFontPathInput = $state("");
|
||||||
let customFontFamilyInput = $state("");
|
let customFontFamilyInput = $state("");
|
||||||
let customFontStatus: string | null = $state(null);
|
let customFontStatus: string | null = $state(null);
|
||||||
|
let customUiFontPathInput = $state("");
|
||||||
|
let customUiFontFamilyInput = $state("");
|
||||||
|
let customUiFontStatus: string | null = $state(null);
|
||||||
|
|
||||||
interface AuthStatus {
|
interface AuthStatus {
|
||||||
is_logged_in: boolean;
|
is_logged_in: boolean;
|
||||||
@@ -96,6 +102,8 @@
|
|||||||
config = { ...c };
|
config = { ...c };
|
||||||
customFontPathInput = c.custom_font_path ?? "";
|
customFontPathInput = c.custom_font_path ?? "";
|
||||||
customFontFamilyInput = c.custom_font_family ?? "";
|
customFontFamilyInput = c.custom_font_family ?? "";
|
||||||
|
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
||||||
|
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
@@ -1007,6 +1015,68 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom UI Font -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUiFontPathInput}
|
||||||
|
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={customUiFontFamilyInput}
|
||||||
|
placeholder="Font family name (e.g. Inter)"
|
||||||
|
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 () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(
|
||||||
|
customUiFontPathInput || null,
|
||||||
|
customUiFontFamilyInput || null
|
||||||
|
);
|
||||||
|
customUiFontStatus = "Font applied!";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `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 UI Font
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
customUiFontPathInput = "";
|
||||||
|
customUiFontFamilyInput = "";
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
await applyCustomUiFont(null, null);
|
||||||
|
customUiFontStatus = "Font reset to default.";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `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 customUiFontStatus}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
|
||||||
|
direct font file URLs, or local file paths.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Show Thinking Blocks Toggle -->
|
<!-- Show Thinking Blocks Toggle -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
|||||||
@@ -113,6 +113,8 @@
|
|||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
custom_font_path: null,
|
||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
clampFontSize,
|
clampFontSize,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
applyCustomFont,
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
clearCustomThemeColors,
|
clearCustomThemeColors,
|
||||||
@@ -214,6 +215,8 @@ describe("config store", () => {
|
|||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
custom_font_path: null,
|
||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -268,6 +271,8 @@ describe("config store", () => {
|
|||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
custom_font_path: null,
|
||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -821,6 +826,8 @@ describe("config store", () => {
|
|||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
custom_font_path: null,
|
||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
@@ -1317,4 +1324,133 @@ describe("config store", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("applyCustomUiFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--ui-font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable when both path and family are null", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
await applyCustomUiFont(null, null);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable when both path and family are empty strings", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
await applyCustomUiFont("", "");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @import for a CSS stylesheet URL", async () => {
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain(
|
||||||
|
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @font-face for a direct font file URL (.woff2)", async () => {
|
||||||
|
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses HikariCustomUiFont as fallback family for direct font URLs when family is empty", async () => {
|
||||||
|
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style?.textContent).toContain("'HikariCustomUiFont'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads local file and embeds as data URL", async () => {
|
||||||
|
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
|
||||||
|
readFileMock.mockResolvedValueOnce(fakeData);
|
||||||
|
|
||||||
|
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CSS variable when only family is provided (no path)", async () => {
|
||||||
|
await applyCustomUiFont("", "SystemUiFont");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'SystemUiFont', sans-serif"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a previously injected style element", async () => {
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
|
||||||
|
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
|
||||||
|
const styles = document.querySelectorAll("#hikari-custom-ui-font");
|
||||||
|
expect(styles.length).toBe(1);
|
||||||
|
expect(styles[0].textContent).toContain("Roboto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCustomUiFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
const invokeMock = vi.mocked(invoke);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--ui-font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves config and applies the UI font", async () => {
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
|
||||||
|
custom_ui_font_family: "Inter",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears UI font when called with nulls", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+81
-44
@@ -59,9 +59,12 @@ export interface HikariConfig {
|
|||||||
// Background image settings
|
// Background image settings
|
||||||
background_image_path: string | null;
|
background_image_path: string | null;
|
||||||
background_image_opacity: number;
|
background_image_opacity: number;
|
||||||
// Custom font settings
|
// Custom terminal font settings
|
||||||
custom_font_path: string | null;
|
custom_font_path: string | null;
|
||||||
custom_font_family: string | null;
|
custom_font_family: string | null;
|
||||||
|
// Custom UI font settings
|
||||||
|
custom_ui_font_path: string | null;
|
||||||
|
custom_ui_font_family: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -110,6 +113,8 @@ const defaultConfig: HikariConfig = {
|
|||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
custom_font_path: null,
|
custom_font_path: null,
|
||||||
custom_font_family: null,
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -251,6 +256,11 @@ function createConfigStore() {
|
|||||||
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
||||||
await applyCustomFont(path, family);
|
await applyCustomFont(path, family);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCustomUiFont: async (path: string | null, family: string | null) => {
|
||||||
|
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
|
||||||
|
await applyCustomUiFont(path, family);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,66 +326,93 @@ export function clampFontSize(size: number): number {
|
|||||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
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"]);
|
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
|
||||||
|
|
||||||
|
const FONT_MIME_MAP: Record<string, string> = {
|
||||||
|
woff: "font/woff",
|
||||||
|
woff2: "font/woff2",
|
||||||
|
ttf: "font/ttf",
|
||||||
|
otf: "font/otf",
|
||||||
|
eot: "application/vnd.ms-fontobject",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = styleId;
|
||||||
|
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
|
||||||
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
|
||||||
|
} else {
|
||||||
|
style.textContent = `@import url('${path}');`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await readFile(path);
|
||||||
|
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 = path.split(".").pop()?.toLowerCase() ?? "ttf";
|
||||||
|
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
|
||||||
|
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
||||||
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
// Remove any previously injected font style
|
const styleId = "hikari-custom-font";
|
||||||
document.getElementById(FONT_STYLE_ID)?.remove();
|
const cssVar = "--terminal-font-family";
|
||||||
|
const fallbackFamily = "HikariCustomFont";
|
||||||
|
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
|
||||||
const trimmedPath = path?.trim() ?? "";
|
const trimmedPath = path?.trim() ?? "";
|
||||||
const trimmedFamily = family?.trim() ?? "";
|
const trimmedFamily = family?.trim() ?? "";
|
||||||
|
|
||||||
if (!trimmedPath && !trimmedFamily) {
|
if (!trimmedPath && !trimmedFamily) {
|
||||||
document.documentElement.style.removeProperty(FONT_CSS_VAR);
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedPath) {
|
if (trimmedPath) {
|
||||||
const style = document.createElement("style");
|
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
||||||
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) {
|
if (trimmedFamily) {
|
||||||
document.documentElement.style.setProperty(FONT_CSS_VAR, `'${trimmedFamily}', monospace`);
|
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const styleId = "hikari-custom-ui-font";
|
||||||
|
const cssVar = "--ui-font-family";
|
||||||
|
const fallbackFamily = "HikariCustomUiFont";
|
||||||
|
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
|
||||||
|
const trimmedPath = path?.trim() ?? "";
|
||||||
|
const trimmedFamily = family?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!trimmedPath && !trimmedFamily) {
|
||||||
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedPath) {
|
||||||
|
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedFamily) {
|
||||||
|
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', sans-serif`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
applyTheme,
|
applyTheme,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
applyCustomFont,
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
isCompactMode,
|
isCompactMode,
|
||||||
} from "$lib/stores/config";
|
} from "$lib/stores/config";
|
||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
@@ -461,6 +462,7 @@
|
|||||||
applyTheme(config.theme, config.custom_theme_colors);
|
applyTheme(config.theme, config.custom_theme_colors);
|
||||||
applyFontSize(config.font_size);
|
applyFontSize(config.font_size);
|
||||||
await applyCustomFont(config.custom_font_path, config.custom_font_family);
|
await applyCustomFont(config.custom_font_path, config.custom_font_family);
|
||||||
|
await applyCustomUiFont(config.custom_ui_font_path, config.custom_ui_font_family);
|
||||||
|
|
||||||
// Apply always-on-top setting
|
// Apply always-on-top setting
|
||||||
if (config.always_on_top) {
|
if (config.always_on_top) {
|
||||||
|
|||||||
Reference in New Issue
Block a user