Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ serde.workspace = true
serde_json.workspace = true
base64 = "0.22"
tauri-plugin-dialog = "2"
fontdb = "0.24"
rusqlite = { version = "0.40.2", features = ["bundled"], default-features = false }
ureq = { version = "2.12.1", default-features = false, features = ["tls", "gzip"] }
tauri-plugin-process = "2"
Expand Down
125 changes: 125 additions & 0 deletions src-tauri/src/fonts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use std::collections::BTreeMap;

#[derive(Debug, Clone, serde::Serialize)]
pub struct SystemFont {
pub family: String,
pub monospace: bool,
}

fn collect_system_fonts_sync() -> Vec<SystemFont> {
let mut db = fontdb::Database::new();
db.load_system_fonts();

// Family -> monospace. A family counts as monospace if any of its faces
// is marked monospaced (covers regular/bold/italic splits).
let mut by_family: BTreeMap<String, bool> = BTreeMap::new();
// Case-insensitive dedupe while preserving the first-seen display name.
let mut seen_lower: std::collections::HashMap<String, String> =
std::collections::HashMap::new();

for face in db.faces() {
for (name, _) in face.families.iter() {
let trimmed = name.trim();
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_lowercase();
let display = seen_lower
.entry(lower.clone())
.or_insert_with(|| trimmed.to_string())
.clone();
let entry = by_family.entry(display).or_insert(false);
if face.monospaced {
*entry = true;
}
}
}

let mut out: Vec<SystemFont> = by_family
.into_iter()
.map(|(family, monospace)| SystemFont { family, monospace })
.collect();
out.sort_by(|a, b| {
a.family
.to_lowercase()
.cmp(&b.family.to_lowercase())
.then_with(|| a.family.cmp(&b.family))
});
out
}

#[tauri::command]
pub async fn list_system_fonts() -> Result<Vec<SystemFont>, String> {
tauri::async_runtime::spawn_blocking(collect_system_fonts_sync)
.await
.map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn dedupes_families_case_insensitively_and_sorts() {
// Simulate the merge logic without touching the real system fonts.
let mut by_family: BTreeMap<String, bool> = BTreeMap::new();
let mut seen_lower: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for (name, mono) in [
("JetBrains Mono", true),
("jetbrains mono", true),
("Inter", false),
(" Inter ", false),
("", false),
] {
let trimmed = name.trim();
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_lowercase();
let display = seen_lower
.entry(lower)
.or_insert_with(|| trimmed.to_string())
.clone();
let entry = by_family.entry(display).or_insert(false);
if mono {
*entry = true;
}
}
let mut out: Vec<SystemFont> = by_family
.into_iter()
.map(|(family, monospace)| SystemFont { family, monospace })
.collect();
out.sort_by(|a, b| {
a.family
.to_lowercase()
.cmp(&b.family.to_lowercase())
.then_with(|| a.family.cmp(&b.family))
});
assert_eq!(out.len(), 2);
assert_eq!(out[0].family, "Inter");
assert!(!out[0].monospace);
assert_eq!(out[1].family, "JetBrains Mono");
assert!(out[1].monospace);
}

#[test]
fn system_scan_returns_sorted_unique_families() {
let fonts = collect_system_fonts_sync();
// An empty result is valid (minimal containers may have no fonts);
// uniqueness and ordering must hold regardless.
let mut last = String::new();
let mut seen = std::collections::HashSet::new();
for font in &fonts {
assert!(!font.family.trim().is_empty());
assert!(seen.insert(font.family.to_lowercase()), "duplicate family");
assert!(
font.family.to_lowercase() >= last,
"fonts not sorted: {} after {}",
font.family,
last
);
last = font.family.to_lowercase();
}
}
}
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use tauri::Manager;
mod chat_background;
mod checkpoint;
mod cursor_store;
mod fonts;
mod fs;
mod gitlab;
mod harness;
Expand Down Expand Up @@ -338,6 +339,7 @@ pub fn run() {
project_logo::save_project_logo,
project_logo::remove_project_logo,
project_logo::forget_logo_file,
fonts::list_system_fonts,
])
.build(tauri::generate_context!())
.expect("error while building MonoCode");
Expand Down
12 changes: 12 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--font-mono-size: 13px;
--font-mono-weight: 400;
--font-sans-weight: 400;
}

:root {
Expand All @@ -38,6 +41,15 @@

html {
color-scheme: dark;
font-weight: var(--font-sans-weight);
}

/* A picked interface font applies on every platform. Without it, macOS
keeps its native text rendering (see the `html:not(.is-mac)` rule below). */
html.has-custom-ui-font,
html.has-custom-ui-font body,
html.has-custom-ui-font #root {
font-family: var(--font-sans);
}

html.theme-light {
Expand Down
115 changes: 115 additions & 0 deletions src/lib/fonts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
CODE_FONT_SIZE_DEFAULT,
CODE_FONT_SIZE_MAX,
CODE_FONT_SIZE_MIN,
UI_FONT_WEIGHT_DEFAULT,
codeFontStack,
loadCodeFontSize,
loadCodeFontFamily,
loadCodeFontWeight,
loadUiFontFamily,
loadUiFontWeight,
normalizeCodeFontSize,
normalizeFontFamily,
normalizeFontWeight,
saveCodeFontFamily,
saveCodeFontSize,
saveCodeFontWeight,
saveUiFontFamily,
saveUiFontWeight,
uiFontStack,
} from "./fonts";

function mockLocalStorage() {
const data = new Map<string, string>();
const storage = {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value);
},
removeItem: (key: string) => {
data.delete(key);
},
clear: () => {
data.clear();
},
key: (index: number) => [...data.keys()][index] ?? null,
get length() {
return data.size;
},
};
Object.defineProperty(globalThis, "localStorage", {
value: storage,
configurable: true,
});
}

describe("font family settings", () => {
beforeEach(mockLocalStorage);

it("defaults to the system stacks", () => {
expect(loadUiFontFamily()).toBe("");
expect(loadCodeFontFamily()).toBe("");
expect(uiFontStack("")).toContain("system-ui");
expect(codeFontStack("")).toContain("ui-monospace");
});

it("prefixes a picked family ahead of the fallback", () => {
expect(uiFontStack("Inter")).toBe(
`"Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif`,
);
expect(codeFontStack("JetBrains Mono")).toContain('"JetBrains Mono"');
expect(codeFontStack("JetBrains Mono")).toContain("ui-monospace");
});

it("escapes quotes in family names for the CSS stack", () => {
const stack = codeFontStack('Evil "Font"');
expect(stack).toContain('Evil \\"Font\\"');
});

it("trims and drops control characters", () => {
expect(normalizeFontFamily(" Inter\n")).toBe("Inter");
expect(normalizeFontFamily(42)).toBe("");
});

it("persists and clears the picked families", () => {
saveUiFontFamily("Inter");
expect(loadUiFontFamily()).toBe("Inter");
saveUiFontFamily("");
expect(loadUiFontFamily()).toBe("");
saveCodeFontFamily("JetBrains Mono");
expect(loadCodeFontFamily()).toBe("JetBrains Mono");
saveCodeFontFamily(" ");
expect(loadCodeFontFamily()).toBe("");
});
});

describe("code size / weight settings", () => {
beforeEach(mockLocalStorage);

it("defaults to the current editor metrics", () => {
expect(loadCodeFontSize()).toBe(CODE_FONT_SIZE_DEFAULT);
expect(loadUiFontWeight()).toBe(UI_FONT_WEIGHT_DEFAULT);
expect(loadCodeFontWeight()).toBe(400);
});

it("clamps size into range", () => {
expect(normalizeCodeFontSize(4)).toBe(CODE_FONT_SIZE_MIN);
expect(normalizeCodeFontSize(99)).toBe(CODE_FONT_SIZE_MAX);
expect(normalizeCodeFontSize("junk")).toBe(CODE_FONT_SIZE_DEFAULT);
saveCodeFontSize(15);
expect(loadCodeFontSize()).toBe(15);
});

it("snaps weights to hundreds inside 400-700", () => {
expect(normalizeFontWeight(450, 400)).toBe(500);
expect(normalizeFontWeight(100, 400)).toBe(400);
expect(normalizeFontWeight(900, 400)).toBe(700);
expect(normalizeFontWeight("junk", 400)).toBe(400);
saveUiFontWeight(600);
expect(loadUiFontWeight()).toBe(600);
saveCodeFontWeight(500);
expect(loadCodeFontWeight()).toBe(500);
});
});
Loading
Loading