From 172b4f79372320b9041c42f83c8d5ed45f2c138b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 23 Sep 2026 02:49:03 -0400 Subject: [PATCH] fix(ipynb): keep cells pixel-stable when they switch to editing The preview and the live editor disagreed on four things, measured over CDP: - Font: the excerpt painted the bare "SF Mono" name (or --font-mono via its row class), while Monaco appended its own fallbacks and landed on Menlo. Both now use resolveEditorFontStack, the editor font plus the terminal fallback chain. - Line height: 20px rows vs Monaco's 21px. Both read CODE_EXCERPT_LAYOUT. - Gutter: a 48px line-number column plus 12px inset vs Monaco's 25px gutter. Notebook cells drop line numbers (the Jupyter and VS Code notebook default) and Monaco's decorations lane is the same 12px inset. - Rows: colorized blank lines collapsed to 0px, and a trailing newline had no preview row. Rows are fixed-height and a trailing newline opens an empty last line, matching the Monaco model. The [n] prompt and run icon now share one grid cell, so the hover swap keeps the label's box and centre. The commented-line tint moves to a theme token. --- .../src/components/editor/IpynbCellEditor.tsx | 21 +++++----- .../components/editor/IpynbCellToolbar.tsx | 25 ++++++++---- .../components/editor/MonacoCodeExcerpt.tsx | 40 ++++++++++++++----- .../editor/ipynb-code-cell-lines.test.ts | 5 ++- .../editor/ipynb-code-cell-lines.ts | 8 ++-- src/renderer/src/lib/editor-font-zoom.ts | 10 +++++ 6 files changed, 74 insertions(+), 35 deletions(-) diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx index 13ef6cbcb57..7ea7db9cba2 100644 --- a/src/renderer/src/components/editor/IpynbCellEditor.tsx +++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import Editor, { type OnMount } from '@monaco-editor/react' import type { Components } from 'react-markdown' import { monaco } from '@/lib/monaco-setup' -import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' +import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom' import { useAppStore } from '@/store' import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' import { @@ -11,13 +11,10 @@ import { } from './ipynb-code-cell-lines' import type { IpynbCell } from './ipynb-parse' import { MarkdownPreviewBody } from './MarkdownPreviewBody' -import MonacoCodeExcerpt from './MonacoCodeExcerpt' +import MonacoCodeExcerpt, { CODE_EXCERPT_LAYOUT } from './MonacoCodeExcerpt' import { useDocumentDarkTheme } from './use-document-dark-theme' const NO_MARKDOWN_COMPONENTS: Components = {} -// Matches MonacoCodeExcerpt's `leading-5 py-1`, so activating a cell does not shift the layout. -const SOURCE_LINE_HEIGHT_PX = 20 -const SOURCE_VERTICAL_PADDING_PX = 4 export function IpynbMarkdownCell({ source }: { source: string }): React.JSX.Element { const isDark = useDocumentDarkTheme() @@ -88,6 +85,7 @@ export function IpynbCellSource(props: IpynbCellSourceProps): React.JSX.Element highlightedStartLine={-1} highlightedEndLine={-1} language={cell.language} + showLineNumbers={false} /> )} @@ -112,11 +110,11 @@ function IpynbSourceEditor({ onSaveRequestRef.current = onSaveRequest }, [onDeactivate, onSaveRequest]) const fontSize = computeEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel) - const lineHeight = Math.max(SOURCE_LINE_HEIGHT_PX, Math.ceil(fontSize * 1.5)) + const { lineHeight, paddingX, paddingY } = CODE_EXCERPT_LAYOUT const maxHeight = IPYNB_CODE_CELL_PREVIEW_MAX_LINES * lineHeight // Seeds the first frame only; Monaco reports the real content height after mount. const [contentHeight, setContentHeight] = useState( - () => getIpynbCodeCellPreviewLines(source).length * lineHeight + 2 * SOURCE_VERTICAL_PADDING_PX + () => getIpynbCodeCellPreviewLines(source).length * lineHeight + 2 * paddingY ) const handleMount: OnMount = useCallback((editorInstance, monacoInstance) => { editorInstance.focus() @@ -159,12 +157,15 @@ function IpynbSourceEditor({ onChange={(value) => onChange(value ?? '')} options={{ automaticLayout: true, - fontFamily: resolveEditorFontFamily(settings), + fontFamily: resolveEditorFontStack(settings), fontSize, + // Why: same box as the excerpt it replaces. No gutter, so the decorations lane is the inset. lineHeight, - padding: { top: SOURCE_VERTICAL_PADDING_PX, bottom: SOURCE_VERTICAL_PADDING_PX }, + padding: { top: paddingY, bottom: paddingY }, + lineNumbers: 'off', glyphMargin: false, - lineNumbersMinChars: 3, + folding: false, + lineDecorationsWidth: paddingX, minimap: { enabled: false }, overviewRulerLanes: 0, renderLineHighlight: 'none', diff --git a/src/renderer/src/components/editor/IpynbCellToolbar.tsx b/src/renderer/src/components/editor/IpynbCellToolbar.tsx index d975d3b1969..737a74cc8b8 100644 --- a/src/renderer/src/components/editor/IpynbCellToolbar.tsx +++ b/src/renderer/src/components/editor/IpynbCellToolbar.tsx @@ -24,6 +24,7 @@ import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import type { ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' import type { IpynbCellKind } from './ipynb-parse' const CELL_KINDS: readonly IpynbCellKind[] = ['code', 'markdown', 'raw'] @@ -77,7 +78,7 @@ export function IpynbToolbarButton({ ) } -/** Jupyter-style `[n]` prompt that turns into a run button on hover or focus. */ +/** Jupyter-style `[n]` prompt that turns into the run button on hover or focus. */ export function IpynbRunPrompt({ executionCount, running, @@ -87,19 +88,29 @@ export function IpynbRunPrompt({ running: boolean onRun: () => void }): React.JSX.Element { - if (running) { - return - } return ( - - [{executionCount ?? ' '}] + {/* Both states share one grid cell, so the slot keeps the label's width and centre. */} + + + [{executionCount ?? ' '}] + + {running ? ( + + ) : ( + + )} - ) } diff --git a/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx b/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx index a13ed13e1b7..c30424e7b92 100644 --- a/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx +++ b/src/renderer/src/components/editor/MonacoCodeExcerpt.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react' import { monaco } from '@/lib/monaco-setup' -import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom' +import { computeEditorFontSize, resolveEditorFontStack } from '@/lib/editor-font-zoom' import { resolveDocumentTheme } from '@/lib/document-theme' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' @@ -30,12 +30,18 @@ async function ensureColorizationLanguage(language: string): Promise { await pythonLanguageRegistrationPromise } +/** Box metrics a live Monaco editor must reuse to swap in for an excerpt without shifting. */ +export const CODE_EXCERPT_LAYOUT = { lineHeight: 20, paddingY: 4, paddingX: 12 } as const +// Why: preflight gives its own mono stack; inherit so the editor font setting applies. +const CODE_STYLE = { paddingInline: CODE_EXCERPT_LAYOUT.paddingX, fontFamily: 'inherit' } as const + type MonacoCodeExcerptProps = { lines: string[] firstLineNumber: number highlightedStartLine: number highlightedEndLine: number language: string + showLineNumbers?: boolean } export default function MonacoCodeExcerpt({ @@ -43,7 +49,8 @@ export default function MonacoCodeExcerpt({ firstLineNumber, highlightedStartLine, highlightedEndLine, - language + language, + showLineNumbers = true }: MonacoCodeExcerptProps): React.JSX.Element { const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) @@ -51,7 +58,7 @@ export default function MonacoCodeExcerpt({ settings?.terminalFontSize ?? 13, editorFontZoomLevel ) - const fontFamily = resolveEditorFontFamily(settings) + const fontFamily = resolveEditorFontStack(settings) const isDark = resolveDocumentTheme(settings?.theme ?? 'system') const code = useMemo(() => lines.join('\n'), [lines]) const [htmlLines, setHtmlLines] = useState(() => lines.map(() => '')) @@ -88,8 +95,14 @@ export default function MonacoCodeExcerpt({ return (
{lines.map((codeLine, index) => { const lineNumber = firstLineNumber + index @@ -99,18 +112,23 @@ export default function MonacoCodeExcerpt({ return (
- - {lineNumber} - + {showLineNumbers ? ( + + {lineNumber} + + ) : null} {html ? ( ) : ( - + {codeLine || ' '} )} diff --git a/src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts b/src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts index 992794d236c..2e29fd53c4c 100644 --- a/src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts +++ b/src/renderer/src/components/editor/ipynb-code-cell-lines.test.ts @@ -13,8 +13,9 @@ afterEach(() => { describe('notebook code cell line derivation', () => { it('derives preview lines including CRLF content', () => { expect(getIpynbCodeCellPreviewLines('')).toEqual(['']) - expect(getIpynbCodeCellPreviewLines('one\ntwo\n')).toEqual(['one', 'two']) - expect(getIpynbCodeCellPreviewLines('one\r\ntwo\r\n')).toEqual(['one', 'two']) + expect(getIpynbCodeCellPreviewLines('one\ntwo')).toEqual(['one', 'two']) + expect(getIpynbCodeCellPreviewLines('one\ntwo\n')).toEqual(['one', 'two', '']) + expect(getIpynbCodeCellPreviewLines('one\r\ntwo\r\n')).toEqual(['one', 'two', '']) }) it('caps newline-heavy cells without splitting or walking the full payload', () => { diff --git a/src/renderer/src/components/editor/ipynb-code-cell-lines.ts b/src/renderer/src/components/editor/ipynb-code-cell-lines.ts index 550ede43410..7ec1eea42ab 100644 --- a/src/renderer/src/components/editor/ipynb-code-cell-lines.ts +++ b/src/renderer/src/components/editor/ipynb-code-cell-lines.ts @@ -25,11 +25,9 @@ export function getIpynbCodeCellPreviewLines(source: string): string[] { lineStart = index + 1 } - if (lineStart < scanLength) { - lines.push(sliceIpynbCodeCellPreviewLine(source, lineStart, scanLength)) - } - - return lines.length > 0 ? lines : [''] + // A trailing newline still opens an empty last line, as it does in the Monaco model. + lines.push(sliceIpynbCodeCellPreviewLine(source, lineStart, scanLength)) + return lines } function sliceIpynbCodeCellPreviewLine(source: string, lineStart: number, lineEnd: number): string { diff --git a/src/renderer/src/lib/editor-font-zoom.ts b/src/renderer/src/lib/editor-font-zoom.ts index 3febc21269b..e3a8cd16985 100644 --- a/src/renderer/src/lib/editor-font-zoom.ts +++ b/src/renderer/src/lib/editor-font-zoom.ts @@ -1,3 +1,5 @@ +import { buildFontFamily } from '@/components/terminal-pane/layout-serialization' + const EDITOR_FONT_ZOOM_MIN = -6 const EDITOR_FONT_ZOOM_MAX = 18 const EDITOR_FONT_ZOOM_STEP = 1 @@ -43,3 +45,11 @@ export type EditorFontFamilySettings = { export function resolveEditorFontFamily(settings?: EditorFontFamilySettings | null): string { return settings?.editorFontFamily?.trim() || settings?.terminalFontFamily || 'monospace' } + +/** + * Fallback-backed stack for code painted outside Monaco. Monaco appends its own fallbacks to a + * bare name; a plain element does not, so an unresolvable name like "SF Mono" drops to serif. + */ +export function resolveEditorFontStack(settings?: EditorFontFamilySettings | null): string { + return buildFontFamily(settings?.editorFontFamily?.trim() || settings?.terminalFontFamily || '') +}