diff --git a/scripts/editorOptionWiring.test.ts b/scripts/editorOptionWiring.test.ts index f0e3e064..f80d096e 100644 --- a/scripts/editorOptionWiring.test.ts +++ b/scripts/editorOptionWiring.test.ts @@ -752,3 +752,25 @@ test('space-delimited languages are left exactly as they were', () => { ); } }); + +test('turning line numbers off keeps the gutter the TOC button stands on', () => { + // #810: the floating table-of-contents button is positioned against the + // pane, and what it floats over is the line-number gutter. `'off'` is the + // one `lineNumbers` value that collapses that gutter, which dropped the + // button onto the text; a render function validates as `Custom` instead, so + // Monaco keeps the width and this draws nothing in it. The assertion is + // therefore that 'off' does NOT reach Monaco as 'off'. + const off = editorOptionsFromSettings({ ...SETTINGS, lineNumbers: 'off' }, 100).lineNumbers; + assert.equal(typeof off, 'function'); + assert.equal((off as (lineNumber: number) => string)(7), ''); + + // One value, not a closure per call: `editorOptionsFromSettings` runs in an + // effect, and a fresh arrow would be a changed option on every + // `updateOptions`. + assert.equal(off, editorOptionsFromSettings({ ...SETTINGS, lineNumbers: 'off' }, 100).lineNumbers); + + // The modes that already reserved the gutter are passed through untouched. + for (const lineNumbers of ['on', 'relative', 'interval']) { + assert.equal(editorOptionsFromSettings({ ...SETTINGS, lineNumbers }, 100).lineNumbers, lineNumbers); + } +}); diff --git a/src/lib/utils/editorOptions.ts b/src/lib/utils/editorOptions.ts index 7aa9f848..00103082 100644 --- a/src/lib/utils/editorOptions.ts +++ b/src/lib/utils/editorOptions.ts @@ -2,6 +2,21 @@ import type { editor as MonacoEditor } from "monaco-editor"; import { animatesCursor, animatesJumpScroll } from "./motion.js"; +/** + * "Line Numbers: off" without giving up the gutter they sit in. + * + * Monaco reserves that gutter on `renderType !== Off`, and a render function + * validates as `Custom` — so an empty string keeps the width and draws nothing + * in it. `'off'` is the one value that collapses it, and the floating table of + * contents button stands on what it collapsed (#810). + * + * A module constant rather than an inline arrow because + * `editorOptionsFromSettings` runs inside an effect: a new closure per call is + * a new option value, and Monaco would see this option change on every + * `updateOptions`. + */ +const BLANK_LINE_NUMBER = () => ""; + /** * The Monaco options derived from the settings store, in one place. * @@ -41,7 +56,10 @@ export function editorOptionsFromSettings( // of the window edge (#758). 'advanced' measures in the DOM and is slow // on large files, so only a proportional font pays for it. wrappingStrategy: fontIsMonospace ? "simple" : "advanced", - lineNumbers: settings.lineNumbers as "on" | "off" | "relative" | "interval", + lineNumbers: + settings.lineNumbers === "off" + ? BLANK_LINE_NUMBER + : (settings.lineNumbers as "on" | "relative" | "interval"), // A Monaco string enum, not a flag. Any non-empty string is truthy, so // a ternary on it can only ever produce "line" — which defeats both the // line-highlight toggle and Zen mode, whose whole effect is 'none'.