mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
Merge pull request #320 from comzine/feat/custom-terminal-font
feat: Nerd Font prompt icons out of the box + configurable terminal font
This commit is contained in:
@@ -527,6 +527,41 @@ function sortSessionsByActivity(rows) {
|
|||||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal font stack — the single source for every xterm surface (the main
|
||||||
|
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||||
|
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||||
|
// @font-face in styles.css): browsers fall back PER GLYPH, so Nerd Font
|
||||||
|
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
|
||||||
|
// oh-my-posh) render even though the text fonts carry no private-use-area
|
||||||
|
// symbols — while all readable text keeps coming from the text fonts.
|
||||||
|
const TERMINAL_FONT_DEFAULT_STACK =
|
||||||
|
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the xterm fontFamily from the per-device `terminalFontFamily`
|
||||||
|
* setting. A user-set family (or comma-separated list) is PREPENDED to the
|
||||||
|
* built-in stack, never a replacement — the symbols fallback and a final
|
||||||
|
* `monospace` must survive whatever the user types. Blank input yields the
|
||||||
|
* default. Unquoted names that need quoting for CSS (spaces, digits leading,
|
||||||
|
* etc.) are quoted; embedded quotes are stripped rather than escaped, since
|
||||||
|
* a font name cannot contain them anyway.
|
||||||
|
*/
|
||||||
|
function resolveTerminalFontFamily(custom) {
|
||||||
|
const raw = typeof custom === 'string' ? custom.trim() : '';
|
||||||
|
if (!raw) return TERMINAL_FONT_DEFAULT_STACK;
|
||||||
|
const families = raw
|
||||||
|
.split(',')
|
||||||
|
.map((f) => f.trim().replace(/^["']|["']$/g, '').replace(/["']/g, '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
// Drop generic families the user may append — the default stack already
|
||||||
|
// ends in `monospace`, and a duplicate earlier entry would shadow the
|
||||||
|
// symbols fallback behind it.
|
||||||
|
.filter((f) => !/^(monospace|serif|sans-serif|system-ui)$/i.test(f))
|
||||||
|
.map((f) => (/^[A-Za-z][A-Za-z0-9-]*$/.test(f) ? f : `"${f}"`));
|
||||||
|
if (!families.length) return TERMINAL_FONT_DEFAULT_STACK;
|
||||||
|
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
@@ -560,6 +595,10 @@ if (typeof window !== 'undefined') {
|
|||||||
compare: compareSessionActivity,
|
compare: compareSessionActivity,
|
||||||
sort: sortSessionsByActivity,
|
sort: sortSessionsByActivity,
|
||||||
};
|
};
|
||||||
|
window.CodemanTerminalFont = {
|
||||||
|
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||||||
|
resolve: resolveTerminalFontFamily,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Ryan L McIntyre
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Binary file not shown.
@@ -326,6 +326,10 @@
|
|||||||
|
|
||||||
// Input settings
|
// Input settings
|
||||||
Input: '输入',
|
Input: '输入',
|
||||||
|
Font: '字体',
|
||||||
|
'Terminal font': '终端字体',
|
||||||
|
'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.':
|
||||||
|
'置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。',
|
||||||
'Local Echo': '本地回显',
|
'Local Echo': '本地回显',
|
||||||
'CJK Input': '中日韩输入',
|
'CJK Input': '中日韩输入',
|
||||||
'Extended Keyboard Bar': '扩展键盘栏',
|
'Extended Keyboard Bar': '扩展键盘栏',
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<!-- Preload critical resources — lets browser discover these during HTML parse
|
<!-- Preload critical resources — lets browser discover these during HTML parse
|
||||||
instead of waiting until <script> tags at bottom-of-body are reached. -->
|
instead of waiting until <script> tags at bottom-of-body are reached. -->
|
||||||
<link rel="preload" href="vendor/xterm.min.js" as="script">
|
<link rel="preload" href="vendor/xterm.min.js" as="script">
|
||||||
|
<!-- Symbols font before the terminal first paints — a late-loading icon font
|
||||||
|
leaves tofu in xterm's glyph atlas until something forces a re-render. -->
|
||||||
|
<link rel="preload" href="fonts/symbols-nerd-font-mono.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="preload" href="constants.js" as="script">
|
<link rel="preload" href="constants.js" as="script">
|
||||||
<link rel="preload" href="app.js" as="script">
|
<link rel="preload" href="app.js" as="script">
|
||||||
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
|
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
|
||||||
@@ -1624,6 +1627,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="set-group">
|
||||||
|
<div class="set-group-head"><h4>Font</h4><span class="set-scope">device</span></div>
|
||||||
|
<div class="set-group-body">
|
||||||
|
<div class="set-row has-field" data-search="terminal font family nerd custom typeface">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Terminal font</span>
|
||||||
|
<span class="set-row-desc">Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-head"><h4>Scrolling & rendering</h4></div>
|
<div class="set-group-head"><h4>Scrolling & rendering</h4></div>
|
||||||
<div class="set-group-body">
|
<div class="set-group-body">
|
||||||
|
|||||||
@@ -2279,7 +2279,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
theme: { ...window.codemanCurrentXtermTheme() },
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||||
this.loadTunnelStatus();
|
this.loadTunnelStatus();
|
||||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||||
|
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
|
||||||
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
||||||
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
||||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||||
@@ -2006,6 +2007,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||||
|
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
||||||
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
||||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||||
@@ -2052,6 +2054,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
this.saveAppSettingsToStorage(settings);
|
this.saveAppSettingsToStorage(settings);
|
||||||
this._updateLocalEchoState();
|
this._updateLocalEchoState();
|
||||||
|
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
|
||||||
|
|
||||||
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
||||||
// marker so the next reload actually re-tries WebGL. Only the transition
|
// marker so the next reload actually re-tries WebGL. Only the transition
|
||||||
@@ -2200,6 +2203,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showFileViewerButton: _fvb,
|
showFileViewerButton: _fvb,
|
||||||
webglRendererEnabled: _wgl,
|
webglRendererEnabled: _wgl,
|
||||||
terminalWheelLocalScrollback: _twls,
|
terminalWheelLocalScrollback: _twls,
|
||||||
|
// Per-device by nature (the font must exist on the device) and absent
|
||||||
|
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
|
||||||
|
terminalFontFamily: _tff,
|
||||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||||
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
||||||
showSessionButton: _ssb,
|
showSessionButton: _ssb,
|
||||||
@@ -2874,6 +2880,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||||
|
'terminalFontFamily',
|
||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||||
|
|||||||
@@ -16,6 +16,19 @@
|
|||||||
font-weight: 400 700;
|
font-weight: 400 700;
|
||||||
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
|
||||||
|
fonts/LICENSE-nerd-fonts.txt). Sits BEHIND the text fonts in the xterm stack
|
||||||
|
(constants.js: TERMINAL_FONT_DEFAULT_STACK), so it only ever supplies the
|
||||||
|
private-use-area glyphs shell prompts draw (powerline, p10k/starship folder
|
||||||
|
and git icons) — text rendering is untouched. `block` display, not `swap`:
|
||||||
|
there is no fallback that CAN render these glyphs, so swapping in tofu first
|
||||||
|
would poison xterm's glyph atlas until the next re-render. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Symbols Nerd Font Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
src: url('fonts/symbols-nerd-font-mono.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dark is the safe fallback. Light skins override this so native selects,
|
/* Dark is the safe fallback. Light skins override this so native selects,
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
theme: { ...window.codemanCurrentXtermTheme() },
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
||||||
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
||||||
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
@@ -4122,6 +4122,22 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the per-device `terminalFontFamily` setting to the live terminal.
|
||||||
|
* The custom family is resolved against the built-in stack (constants.js),
|
||||||
|
* so passing '' / undefined restores the default. Mirrors setFontSize():
|
||||||
|
* refit for the new cell metrics, then refresh the echo overlays' cached
|
||||||
|
* font so predictions keep landing on the right cells.
|
||||||
|
*/
|
||||||
|
applyTerminalFontFamily(custom) {
|
||||||
|
const resolved = window.CodemanTerminalFont.resolve(custom);
|
||||||
|
if (!this.terminal || this.terminal.options.fontFamily === resolved) return;
|
||||||
|
this.terminal.options.fontFamily = resolved;
|
||||||
|
this.fitAddon?.fit();
|
||||||
|
this._localEchoOverlay?.refreshFont();
|
||||||
|
this._predictiveEcho?.refreshFont();
|
||||||
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
const saved = localStorage.getItem('codeman-font-size');
|
const saved = localStorage.getItem('codeman-font-size');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function loadFontHelper() {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (
|
||||||
|
context.window as {
|
||||||
|
CodemanTerminalFont: {
|
||||||
|
DEFAULT_STACK: string;
|
||||||
|
resolve: (custom?: unknown) => string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).CodemanTerminalFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = loadFontHelper();
|
||||||
|
|
||||||
|
describe('CodemanTerminalFont', () => {
|
||||||
|
it('returns the default stack for empty or missing input', () => {
|
||||||
|
expect(font.resolve(undefined)).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve('')).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve(' ')).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve(42)).toBe(font.DEFAULT_STACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the symbols fallback ahead of monospace in the default stack', () => {
|
||||||
|
const symbolsAt = font.DEFAULT_STACK.indexOf('"Symbols Nerd Font Mono"');
|
||||||
|
const monoAt = font.DEFAULT_STACK.lastIndexOf('monospace');
|
||||||
|
expect(symbolsAt).toBeGreaterThan(-1);
|
||||||
|
expect(monoAt).toBeGreaterThan(symbolsAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends a custom family and preserves the full default stack', () => {
|
||||||
|
expect(font.resolve('Menlo')).toBe(`Menlo, ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quotes names that need quoting for CSS', () => {
|
||||||
|
expect(font.resolve('JetBrainsMono Nerd Font')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes already-quoted input instead of double-quoting', () => {
|
||||||
|
expect(font.resolve('"JetBrainsMono Nerd Font"')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
||||||
|
expect(font.resolve("'Iosevka Term'")).toBe(`"Iosevka Term", ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a comma-separated list', () => {
|
||||||
|
expect(font.resolve('Iosevka, MesloLGS NF')).toBe(`Iosevka, "MesloLGS NF", ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops generic families so they cannot shadow the symbols fallback', () => {
|
||||||
|
expect(font.resolve('monospace')).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user