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 || '') +}