Files
openchamber/packages/ui/src/lib/codemirror/flexokiTheme.ts
T
e0229917f8 feat(settings): editor font size for chat input and code editor (#1325) (#2065)
* feat(settings): add editor font size setting for chat input and code editor

Adds an 'Editor font size' control in Settings > Appearance that sets an
absolute px font size for the chat input textarea and the in-app
CodeMirror editor. Mirrors the existing terminalFontSize lifecycle.

- New store field editorFontSize (default 13, clamp 9-32, step 1) in
  useUIStore with narrow selectors at each consumer.
- Persistence wired through appearanceAutoSave, desktop + runtime API
  types, and persistence.ts read/normalize.
- Settings UI row (NumberInput) with reset to 13, VisibleSetting union
  entry, OpenChamberPage registration, and search index entry
  appearance.editor-font-size.
- Applied as a post-zoom absolute override on the chat input textarea
  and on the CodeMirror theme's content rule, leaving gutter/line-number
  chrome at its existing hardcoded sizes (matches terminal scope).
- All 10 locales translated (en, es, fr, ja, ko, pl, pt-BR, uk, zh-CN,
  zh-TW); no English placeholders in non-English dictionaries.

Refs #1325

* fix(codemirror): use unitless lineHeight so it scales with editor font size

The & rule in the CodeMirror theme set lineHeight to 1.5rem (~24px),
which does not scale when editorFontSize is increased (e.g., 28-32px).
This causes overlapping lines at larger font sizes.

Change to unitless 1.5, which scales proportionally with whatever fontSize
resolves to (dynamic prop or --text-code fallback). Matches browser best
practice for proportional leading.

Review comment: https://github.com/openchamber/openchamber/pull/2065

---------

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-07-11 14:45:27 +03:00

577 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { HighlightStyle, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { classHighlighter, tags as t } from '@lezer/highlight';
import type { Theme } from '@/types/theme';
export function createFlexokiCodeMirrorTheme(
theme: Theme,
// When syntax colors are provided elsewhere (e.g. the Shiki decoration
// extension), pass `{ syntaxColors: false }` to keep only the editor UI theme
// (gutters, selection, cursor) and avoid a competing token color source.
options?: { syntaxColors?: boolean; fontSize?: number },
): Extension {
const isDark = theme.metadata.variant === 'dark';
const monoFont = theme.config?.fonts?.mono || 'monospace';
const highlights = theme.colors.syntax.highlights || {};
const tokens = theme.colors.syntax.tokens || {};
const contentFontSize = options?.fontSize ? `${options.fontSize}px` : 'var(--text-code)';
const ui = EditorView.theme({
'&': {
backgroundColor: 'var(--background)',
color: theme.colors.syntax.base.foreground,
fontSize: contentFontSize,
lineHeight: '1.5',
position: 'relative' as const,
},
'.cm-scroller': {
fontFamily: monoFont,
backgroundColor: 'var(--background)',
},
/* StreamLanguage/legacy-modes tokens (class-based) */
'.cm-comment': {
color: theme.colors.syntax.base.comment,
},
'.cm-keyword': {
color: theme.colors.syntax.base.keyword,
},
'.cm-string': {
color: theme.colors.syntax.base.string,
},
'.cm-string-2': {
color: tokens.stringEscape || theme.colors.syntax.base.string,
},
'.cm-number': {
color: theme.colors.syntax.base.number,
},
'.cm-operator': {
color: theme.colors.syntax.base.operator,
},
'.cm-punctuation': {
color: tokens.punctuation || theme.colors.syntax.base.comment,
},
'.cm-atom': {
color: tokens.boolean || theme.colors.syntax.base.number,
},
'.cm-builtin': {
color: tokens.functionCall || theme.colors.syntax.base.function,
},
'.cm-def': {
color: tokens.variableGlobal || theme.colors.syntax.base.variable,
},
// Legacy shell flags (--foo, -bar)
'.cm-attribute': {
color: tokens.variableOther || tokens.variableProperty || theme.colors.syntax.base.operator,
},
'.cm-meta': {
color: theme.colors.syntax.base.comment,
},
'.cm-property': {
color: tokens.variableProperty || theme.colors.syntax.base.keyword,
},
'.cm-variable': {
color: theme.colors.syntax.base.variable,
},
'.cm-variable-2': {
color: tokens.variableOther || theme.colors.syntax.base.function,
},
'.cm-variable-3': {
color: tokens.variableGlobal || theme.colors.syntax.base.type,
},
'.cm-tag': {
color: tokens.tag || theme.colors.syntax.base.keyword,
},
'.cm-link': {
color: tokens.url || theme.colors.syntax.base.keyword,
textDecoration: 'underline',
},
'.cm-content': {
caretColor: theme.colors.interactive.cursor,
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: theme.colors.interactive.cursor,
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
backgroundColor: theme.colors.interactive.selection,
},
'.cm-gutters': {
backgroundColor: 'var(--background)',
color: highlights.lineNumber || theme.colors.syntax.base.comment,
borderRight: `1px solid ${theme.colors.interactive.border}`,
position: 'sticky',
paddingRight: '8px',
left: 0,
zIndex: 2,
boxShadow: `0 0 0 var(--background)`,
},
'.cm-gutter': {
backgroundColor: 'var(--background)',
},
'.cm-gutterElement': {
backgroundColor: 'var(--background)',
},
'.cm-lineNumbers': {
backgroundColor: 'var(--background)',
},
'.cm-lineNumbers .cm-gutterElement': {
paddingLeft: '8px',
paddingRight: '8px',
minWidth: '42px',
},
'.cm-activeLineGutter': {
color: highlights.lineNumberActive || theme.colors.syntax.base.foreground,
},
'.cm-activeLine': {
backgroundColor: theme.colors.surface.overlay,
},
/* ── Floating search: panels container ── */
'.cm-panels': {
backgroundColor: 'transparent',
color: theme.colors.surface.foreground,
position: 'absolute' as const,
top: '0',
right: '0',
left: 'auto',
width: 'auto',
zIndex: 10,
pointerEvents: 'none',
},
'.cm-panels-top': {
borderBottom: 'none',
position: 'absolute' as const,
top: '6px',
right: '14px',
left: 'auto',
width: 'auto',
},
'.cm-panels-bottom': {
position: 'static' as const,
width: '100%',
pointerEvents: 'auto',
},
'.cm-panels-bottom .cm-vim-panel': {
borderTop: `1px solid ${theme.colors.interactive.border}`,
backgroundColor: theme.colors.surface.elevated,
color: theme.colors.surface.mutedForeground,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
minHeight: '22px',
padding: '2px 8px',
},
/* ── Floating search panel ── */
'.cm-panel.cm-search': {
pointerEvents: 'auto',
padding: '4px',
backgroundColor: `color-mix(in srgb, ${theme.colors.surface.elevated} 85%, transparent)`,
border: `1px solid ${theme.colors.interactive.border}`,
borderRadius: '10px',
boxShadow: `0 2px 8px color-mix(in srgb, ${theme.colors.surface.background} 60%, transparent)`,
width: 'auto',
minWidth: '360px',
maxWidth: '480px',
},
/* ── Search inner layout: flexbox rows ── */
'.cm-search': {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0',
width: '100%',
color: theme.colors.surface.foreground,
fontFamily: 'inherit',
fontSize: '13px',
lineHeight: '1',
},
/* ── Hide <br>; we force row break via replace input's flex-basis ── */
'.cm-search br': {
display: 'none',
},
/* ──────────────────────────────────────────────────
ROW 1: [Find input] [Aa] [ab] [.*] [match count] [↑] [↓] [≡] [×]
────────────────────────────────────────────────── */
/* ── Text input base: border-radius matches action buttons (4px) ── */
'.cm-search .cm-textfield': {
appearance: 'none',
height: '26px',
margin: 0,
padding: '0 8px',
borderRadius: '6px !important',
border: `1px solid ${theme.colors.interactive.border}`,
backgroundColor: theme.colors.surface.background,
color: theme.colors.surface.foreground,
fontFamily: 'inherit',
fontSize: '12px',
lineHeight: '1',
outline: 'none',
transition: 'border-color 150ms ease',
},
'.cm-search .cm-textfield::placeholder': {
color: theme.colors.surface.mutedForeground,
opacity: 1,
},
'.cm-search .cm-textfield:focus': {
borderColor: theme.colors.primary.base,
},
/* Find input: row 1, max-width matches replace input for alignment */
'.cm-search .cm-textfield[name="search"]': {
order: 1,
flex: '1 1 120px',
maxWidth: 'calc(100% - 142px)',
minWidth: '100px',
},
/* Replace input is styled below in the row-2 section */
/* ────────────────────────────────────────────────────
ICON-ONLY TOGGLE BUTTONS (checkbox labels → icon toggles)
Labels contain: [checkbox] "match case" / "regexp" / "by word"
We hide text, hide the checkbox, and show icon via label::after
──────────────────────────────────────────────────── */
'.cm-search label': {
order: 2,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '26px',
height: '26px',
minWidth: '26px',
flexShrink: '0',
margin: 0,
marginLeft: '1px',
padding: 0,
borderRadius: '4px',
border: `1px solid transparent`,
backgroundColor: 'transparent',
color: 'transparent', /* hide label text */
cursor: 'pointer',
userSelect: 'none' as const,
fontSize: '1px', /* near-zero to collapse text nodes */
lineHeight: '0',
overflow: 'hidden',
position: 'relative' as const,
transition: 'background-color 120ms ease, border-color 120ms ease',
},
'.cm-search label:hover': {
backgroundColor: theme.colors.interactive.hover,
},
'.cm-search label:has(input[type="checkbox"]:checked)': {
backgroundColor: theme.colors.interactive.hover,
borderColor: theme.colors.interactive.border,
},
/* Hide the actual checkbox input inside labels */
'.cm-search [type="checkbox"]': {
appearance: 'none',
WebkitAppearance: 'none',
width: '0',
height: '0',
margin: '0',
padding: '0',
border: 'none',
position: 'absolute' as const,
opacity: '0',
pointerEvents: 'none' as const,
},
/* Icon via ::after on each label - absolutely positioned so text-hiding doesn't affect it */
'.cm-search label::after': {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '13px',
fontWeight: '600',
lineHeight: '1',
fontFamily: 'inherit',
color: theme.colors.surface.mutedForeground,
pointerEvents: 'none' as const,
},
'.cm-search label:hover::after': {
color: theme.colors.surface.foreground,
},
'.cm-search label:has(input[type="checkbox"]:checked)::after': {
color: theme.colors.surface.foreground,
},
/* Case sensitive: "Aa" */
'.cm-search label:has(input[name="case"])::after': {
content: '"Aa"',
fontSize: '12px',
letterSpacing: '-0.5px',
},
/* Regex: ".*" */
'.cm-search label:has(input[name="re"])::after': {
content: '".*"',
fontSize: '13px',
fontFamily: 'monospace',
},
/* Whole word: "ab" with underline */
'.cm-search label:has(input[name="word"])::after': {
content: '"ab"',
fontSize: '12px',
textDecoration: 'underline',
textUnderlineOffset: '2px',
},
/* ────────────────────────────────────────────────────
MATCH COUNT (search message - "X of Y")
──────────────────────────────────────────────────── */
'.cm-search .cm-search-message': {
order: 3,
color: theme.colors.surface.mutedForeground,
backgroundColor: 'transparent',
border: 'none',
borderRadius: '0',
padding: '0 6px',
fontSize: '11px',
whiteSpace: 'nowrap' as const,
lineHeight: '26px',
},
/* ────────────────────────────────────────────────────
ICON-ONLY ACTION BUTTONS
Hide button text with font-size:0, show icon via ::after
──────────────────────────────────────────────────── */
'.cm-search .cm-button': {
appearance: 'none',
WebkitAppearance: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '26px',
height: '26px',
minWidth: '26px',
flexShrink: '0',
margin: 0,
marginLeft: '1px',
padding: 0,
borderRadius: '4px',
border: 'none',
backgroundColor: 'transparent',
backgroundImage: 'none',
color: 'transparent', /* hide button text */
cursor: 'pointer',
fontSize: '1px', /* near-zero to collapse text */
fontWeight: '400',
lineHeight: '0',
overflow: 'hidden',
position: 'relative' as const,
boxShadow: 'none',
transition: 'background-color 120ms ease',
},
'.cm-search .cm-button::after': {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '15px',
lineHeight: '1',
color: theme.colors.surface.mutedForeground,
pointerEvents: 'none' as const,
},
'.cm-search .cm-button:hover': {
backgroundColor: theme.colors.interactive.hover,
},
'.cm-search .cm-button:hover::after': {
color: theme.colors.surface.foreground,
},
'.cm-search .cm-button:active': {
backgroundColor: theme.colors.interactive.active,
},
'.cm-search .cm-button:focus-visible': {
outline: 'none',
boxShadow: `0 0 0 1px ${theme.colors.interactive.focusRing}`,
},
'.cm-search .cm-button:disabled': {
opacity: 0.4,
cursor: 'default',
pointerEvents: 'none' as const,
},
/* prev → ↑ arrow (row 2) */
'.cm-search .cm-button[name="prev"]': {
order: 21,
marginTop: '2px',
},
'.cm-search .cm-button[name="prev"]::after': {
content: '"↑"',
},
/* next → ↓ arrow (row 2) */
'.cm-search .cm-button[name="next"]': {
order: 22,
marginTop: '2px',
},
'.cm-search .cm-button[name="next"]::after': {
content: '"↓"',
},
/* select all → ≡ (row 2) */
'.cm-search .cm-button[name="select"]': {
order: 23,
marginTop: '2px',
},
'.cm-search .cm-button[name="select"]::after': {
content: '"≡"',
fontSize: '17px',
},
/* ── Close button (×) - not a .cm-button, separate selector ── */
'.cm-panel.cm-search button[name="close"]': {
order: 5,
appearance: 'none',
WebkitAppearance: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '26px',
height: '26px',
minWidth: '26px',
flexShrink: '0',
margin: '0 0 0 auto',
padding: '0',
borderRadius: '4px',
border: 'none',
backgroundColor: 'transparent',
backgroundImage: 'none',
color: theme.colors.surface.mutedForeground,
cursor: 'pointer',
fontSize: '16px',
lineHeight: '1',
boxShadow: 'none',
position: 'relative' as const,
overflow: 'visible',
transition: 'background-color 120ms ease, color 120ms ease',
},
'.cm-panel.cm-search button[name="close"]:hover': {
backgroundColor: theme.colors.interactive.hover,
color: theme.colors.surface.foreground,
},
/* ──────────────────────────────────────────────────
ROW 2: [Replace input] [replace icon] [replace-all icon]
────────────────────────────────────────────────── */
/* replace → swap icon (row 2) */
'.cm-search .cm-button[name="replace"]': {
order: 24,
marginTop: '2px',
},
'.cm-search .cm-button[name="replace"]::after': {
content: '"⇄"',
},
/* replace all → double arrow icon (row 2) */
'.cm-search .cm-button[name="replaceAll"], .cm-search .cm-button[name="replace-all"]': {
order: 25,
marginTop: '2px',
},
'.cm-search .cm-button[name="replaceAll"]::after, .cm-search .cm-button[name="replace-all"]::after': {
content: '"⇉"',
},
/* ── Replace input: forces wrap to row 2, same width as find input ── */
'.cm-search .cm-textfield[name="replace"]': {
order: 20,
flex: '1 1 100%',
maxWidth: 'calc(100% - 142px)', /* room for ↑↓≡⇄⇉ (5 × 27px + gaps) */
minWidth: '80px',
marginTop: '2px',
},
'.cm-searchMatch': {
backgroundColor: theme.colors.status.infoBackground,
boxShadow: `inset 0 0 0 1px ${theme.colors.status.infoBorder}`,
borderRadius: '2px',
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: theme.colors.interactive.selection,
color: theme.colors.interactive.selectionForeground,
boxShadow: `inset 0 0 0 1px ${theme.colors.interactive.borderFocus}`,
},
'&.cm-focused': {
outline: 'none',
},
}, { dark: isDark });
const syntax = HighlightStyle.define([
{ tag: [t.comment, t.docComment, t.meta, t.documentMeta], class: 'cm-comment' },
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.definitionKeyword, t.modifier], class: 'cm-keyword' },
{ tag: [t.operatorKeyword, t.operator, t.derefOperator, t.updateOperator, t.definitionOperator, t.typeOperator, t.controlOperator, t.logicOperator, t.bitwiseOperator, t.arithmeticOperator, t.compareOperator], class: 'cm-operator' },
{ tag: [t.punctuation, t.separator, t.bracket, t.paren, t.brace, t.squareBracket, t.angleBracket], class: 'cm-punctuation' },
{ tag: [t.string, t.regexp, t.attributeValue, t.special(t.string), t.monospace], class: 'cm-string' },
{ tag: t.escape, class: 'cm-string-2' },
{ tag: [t.number, t.bool, t.atom, t.null, t.self], class: 'cm-number' },
{ tag: [t.function(t.variableName), t.function(t.definition(t.variableName)), t.function(t.propertyName), t.standard(t.variableName), t.special(t.variableName)], class: 'cm-builtin' },
{ tag: t.definition(t.variableName), class: 'cm-def' },
{ tag: [t.variableName, t.local(t.variableName), t.constant(t.variableName), t.literal], class: 'cm-variable' },
{ tag: t.propertyName, class: 'cm-property' },
{ tag: t.attributeName, class: 'cm-attribute' },
{ tag: [t.className, t.typeName, t.namespace], class: 'cm-variable-3' },
{ tag: [t.tagName, t.labelName, t.annotation, t.macroName], class: 'cm-tag' },
{ tag: t.link, class: 'cm-link' },
{ tag: [t.heading, t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], class: 'cm-keyword' },
]);
const directSyntax = HighlightStyle.define([
{ tag: [t.comment, t.docComment, t.meta, t.documentMeta], color: theme.colors.syntax.base.comment },
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.definitionKeyword, t.modifier], color: theme.colors.syntax.base.keyword },
{
tag: [
t.operatorKeyword,
t.operator,
t.derefOperator,
t.updateOperator,
t.definitionOperator,
t.typeOperator,
t.controlOperator,
t.logicOperator,
t.bitwiseOperator,
t.arithmeticOperator,
t.compareOperator,
],
color: theme.colors.syntax.base.operator,
},
{ tag: [t.string, t.regexp, t.attributeValue, t.special(t.string), t.monospace], color: theme.colors.syntax.base.string },
{ tag: t.escape, color: tokens.stringEscape || theme.colors.syntax.base.string },
{ tag: [t.number, t.bool, t.atom, t.null, t.self], color: theme.colors.syntax.base.number },
{ tag: [t.function(t.variableName), t.function(t.definition(t.variableName)), t.function(t.propertyName), t.standard(t.variableName), t.special(t.variableName)], color: theme.colors.syntax.base.function },
{ tag: t.definition(t.variableName), color: tokens.variableGlobal || theme.colors.syntax.base.variable },
{ tag: [t.variableName, t.local(t.variableName), t.constant(t.variableName), t.literal], color: theme.colors.syntax.base.variable },
{ tag: t.propertyName, color: tokens.variableProperty || theme.colors.syntax.base.variable },
{ tag: t.attributeName, color: tokens.variableOther || theme.colors.syntax.base.variable },
{ tag: [t.className, t.typeName, t.namespace], color: theme.colors.syntax.base.type },
{ tag: [t.tagName, t.labelName, t.annotation, t.macroName], color: tokens.tag || theme.colors.syntax.base.keyword },
{ tag: t.link, color: tokens.url || theme.colors.syntax.base.function, textDecoration: 'underline' },
{
tag: [t.punctuation, t.separator, t.bracket, t.paren, t.brace, t.squareBracket, t.angleBracket],
color: tokens.punctuation || theme.colors.syntax.base.comment,
},
]);
if (options?.syntaxColors === false) {
return [ui];
}
return [
ui,
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
syntaxHighlighting(classHighlighter),
syntaxHighlighting(syntax),
syntaxHighlighting(directSyntax),
];
}