diff --git a/src/ide/views/editors.ts b/src/ide/views/editors.ts index deb682c2..ca4c5e08 100644 --- a/src/ide/views/editors.ts +++ b/src/ide/views/editors.ts @@ -25,6 +25,7 @@ import { disassemblyTheme } from "../../themes/disassemblyTheme"; import { editorTheme } from "../../themes/editorTheme"; import { mbo } from "../../themes/mbo"; import { loadSettings, registerEditor, settingsExtensions } from "../settings"; +import { asmSpacesKeymap } from "./tabs"; import { clearBreakpoint, current_project, lastDebugState, openHeaderFile, platform, qs, runToPC } from "../ui"; import { createAssetHeaderPlugin } from "./assetdecorations"; import { createIncludeLinkPlugin } from "./includedecorations"; @@ -230,6 +231,10 @@ export class SourceEditor implements ProjectView { return 0; }) : [], + // Asm files: tab/backspace work on inferred tab stops when + // "insert spaces when pressing tab" is enabled. Placed before the + // settings keymap so it takes precedence over indentMore/insertTab. + ...(isAsm ? [keymap.of(asmSpacesKeymap(() => loadSettings().tabsToSpaces))] : []), // Keybindings from settings must appear before default keymap. ...settingsExtensions(loadSettings()), // https://codemirror.net/docs/ref/#commands.defaultKeymap includes diff --git a/src/ide/views/tabs.ts b/src/ide/views/tabs.ts index c173e924..58fd5750 100644 --- a/src/ide/views/tabs.ts +++ b/src/ide/views/tabs.ts @@ -1,5 +1,6 @@ import { indentLess, indentMore, insertTab } from "@codemirror/commands"; -import { KeyBinding } from "@codemirror/view"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView, KeyBinding } from "@codemirror/view"; export const smartIndentKeymap: KeyBinding[] = [ { key: "Tab", run: indentMore }, @@ -10,3 +11,216 @@ export const insertTabKeymap: KeyBinding[] = [ { key: "Tab", run: insertTab }, { key: "Shift-Tab", run: indentLess }, ]; + +// Number of lines scanned in each direction when inferring tab stops. +const ASM_TABSTOP_SCAN_LINES = 50; +// Existing tab characters are assumed to advance to the next multiple of this column. +const TAB_EXPAND_WIDTH = 8; +// Print tab stop inference results to the browser console (for debugging). +const ASM_TAB_DEBUG = true; + +function dbgTab(msg: string) { + if (ASM_TAB_DEBUG) console.log("[asm-tab] " + msg); +} + +// Compute the visual column of character position 'index' within a line, +// expanding tab characters to the next multiple of TAB_EXPAND_WIDTH. +function visualCol(text: string, index: number): number { + let col = 0; + for (let i = 0; i < index; i++) { + col = text.charCodeAt(i) == 9 ? (Math.floor(col / TAB_EXPAND_WIDTH) + 1) * TAB_EXPAND_WIDTH : col + 1; + } + return col; +} + +// Add a candidate stop column to the frequency tally. +function addStop(col: number, freq: Map) { + if (col > 0) freq.set(col, (freq.get(col) || 0) + 1); +} + +// Collect candidate tab stops from one asm line: the opcode start, operand +// start, and comment start columns (in visual columns, tabs expanded to 8). +// Lines starting at column 0 are assumed to have a label, so the opcode is the +// second word on those lines. +function collectStopsFromLine(text: string, freq: Map) { + // Visual columns of word starts, stopping at the comment. + const words: number[] = []; + const re = /\S+/g; + let m; + while ((m = re.exec(text)) !== null) { + if (text[m.index] == ';') break; + words.push(visualCol(text, m.index)); + } + // Comment start column (';' preceded by whitespace or line start). + const cm = /(^|\s);/.exec(text); + if (cm) addStop(visualCol(text, cm.index + cm[1].length), freq); + // Skip the label if the line starts in column 0. + const fields = words.length && words[0] == 0 ? words.slice(1) : words; + if (fields.length >= 1) addStop(fields[0], freq); // opcode column + if (fields.length >= 2) addStop(fields[1], freq); // operand column +} + +// Minimum number of non-empty lines scanned after the cursor before we also +// merge in lines from before it (e.g. when the cursor is near the end of file). +const ASM_TABSTOP_MIN_LINES = 3; + +// Infer tab stops for asm files: scan the lines after the current line first; +// if there aren't many of them (or they yield no stops), also merge in the lines +// before it. Returns a frequency tally of stop columns (in visual columns, +// with tabs expanded to 8). +export function inferAsmTabStops(state: EditorState, pos: number): Map { + const cur = state.doc.lineAt(pos); + const total = state.doc.lines; + const scan = (from: number, to: number, step: number, freq: Map): number => { + let lines = 0; + for (let n = from; n != to && lines < ASM_TABSTOP_SCAN_LINES; n += step) { + const line = state.doc.line(n); + if (line.text.trim()) { + collectStopsFromLine(line.text, freq); + lines++; + } + } + return lines; + }; + const freq: Map = new Map(); + // Prefer lines after the current line, but merge in lines before it when + // there are too few lines below (cursor near end of file). + const after = scan(cur.number + 1, Math.min(total, cur.number + ASM_TABSTOP_SCAN_LINES) + 1, 1, freq); + if (after < ASM_TABSTOP_MIN_LINES) { + scan(cur.number - 1, Math.max(1, cur.number - ASM_TABSTOP_SCAN_LINES) - 1, -1, freq); + } + return freq; +} + +// Most frequent stop greater than col (ties: smallest column), +// or the next grid stop as a fallback. +function nextStop(freq: Map, col: number, grid: number): number { + let best = -1, bestFreq = 0; + for (const [s, f] of freq) { + if (s > col && (f > bestFreq || (f == bestFreq && s < best))) { + best = s; bestFreq = f; + } + } + if (best >= 0) return best; + return (Math.floor(col / grid) + 1) * grid; +} + +// Most frequent stop less than col (ties: largest column), +// or the previous grid stop as a fallback. +function prevStop(freq: Map, col: number, grid: number): number { + let best = -1, bestFreq = 0; + for (const [s, f] of freq) { + if (s < col && (f > bestFreq || (f == bestFreq && s > best))) { + best = s; bestFreq = f; + } + } + if (best >= 0) return best; + return Math.max(0, Math.min(Math.floor((col - 1) / grid) * grid, col - 1)); +} + +// True if the document region near pos contains literal tab characters; +// in that case tab stops fall back to a hard grid of 8 columns. +function docHasTabs(state: EditorState, pos: number): boolean { + const cur = state.doc.lineAt(pos); + const fromLine = Math.max(1, cur.number - ASM_TABSTOP_SCAN_LINES); + const toLine = Math.min(state.doc.lines, cur.number + ASM_TABSTOP_SCAN_LINES); + for (let n = fromLine; n <= toLine; n++) { + if (state.doc.line(n).text.includes('\t')) return true; + } + return false; +} + +// Compute the insertion needed for pressing Tab at document position 'head': +// spaces from the cursor up to the next inferred tab stop. +function fmtStops(freq: Map): string { + return "[" + [...freq.entries()].sort((a, b) => a[0] - b[0]).map(([s, f]) => `${s}:${f}`).join(" ") + "]"; +} + +export function asmTabChange(state: EditorState, head: number): { from: number, insert: string } | null { + const line = state.doc.lineAt(head); + const col = visualCol(line.text, head - line.from); + const hasTabs = docHasTabs(state, head); + const grid = hasTabs ? TAB_EXPAND_WIDTH : state.tabSize; + const stops = inferAsmTabStops(state, head); + const target = nextStop(stops, col, grid); + dbgTab(`Tab @ line ${line.number} col ${col}: stops(col:freq)=${fmtStops(stops)} ${hasTabs ? "grid=8 (file has tabs)" : `grid=${state.tabSize}`} -> target=${target}` + + ` | line=${JSON.stringify(line.text.slice(0, col))}+CURSOR`); + if (target <= col) return null; + return { from: head, insert: " ".repeat(target - col) }; +} + +// Compute the deletion range for Backspace at document position 'head' +// (spaces removed back to the previous inferred tab stop, or a single tab char), +// or null if default backspace behavior should apply. +export function asmBackspaceRange(state: EditorState, head: number): { from: number, to: number } | null { + const line = state.doc.lineAt(head); + const index = head - line.from; + if (index == 0) { + dbgTab(`Backspace @ line ${line.number} col 0: start of line, using default`); + return null; + } + // If the character before the cursor is a tab, just delete it. + if (line.text[index - 1] == '\t') { + dbgTab(`Backspace @ line ${line.number} col ${visualCol(line.text, index)}: deleting one tab char`); + return { from: head - 1, to: head }; + } + // Only handle cursors inside a pure-whitespace indent region. + if (!/^[ \t]+$/.test(line.text.slice(0, index))) { + dbgTab(`Backspace @ line ${line.number} col ${visualCol(line.text, index)}: cursor not in leading whitespace, using default`); + return null; + } + const hasTabs = docHasTabs(state, head); + const grid = hasTabs ? TAB_EXPAND_WIDTH : state.tabSize; + const col = visualCol(line.text, index); + const stops = inferAsmTabStops(state, head); + const target = prevStop(stops, col, grid); + if (target >= col) { + dbgTab(`Backspace @ line ${line.number} col ${col}: no stop before cursor, using default`); + return null; + } + // Convert target visual column back to a character index (only spaces ahead of us). + const from = head - (col - target); + dbgTab(`Backspace @ line ${line.number} col ${col}: stops(col:freq)=${fmtStops(stops)} ${hasTabs ? "grid=8 (file has tabs)" : `grid=${state.tabSize}`} -> deleting cols ${target}..${col}` + + ` | line=${JSON.stringify(line.text.slice(0, index))}+CURSOR`); + return { from, to: head }; +} + +// Insert spaces from the cursor up to the next inferred tab stop. +function asmInsertSpaces(view: EditorView): boolean { + const state = view.state; + if (state.selection.ranges.some(r => !r.empty)) return false; // let indentMore handle selections + const tr = state.changeByRange(range => { + const change = asmTabChange(state, range.head); + if (!change) return { range }; + return { + changes: change, + range: EditorSelection.cursor(range.head + change.insert.length), + }; + }); + view.dispatch(tr); + return true; +} + +// Delete spaces from the cursor back to the previous inferred tab stop, +// but only when the cursor sits in leading whitespace. +function asmDeleteSpacesBackward(view: EditorView): boolean { + const state = view.state; + const range = state.selection.main; + if (!range.empty) return false; + const del = asmBackspaceRange(state, range.head); + if (!del) return false; + view.dispatch(state.update({ + changes: del, + selection: EditorSelection.cursor(del.from), + })); + return true; +} + +// Keymap for asm editors when "insert spaces when pressing tab" is enabled. +// The callback lets the caller gate the behavior on current settings. +export function asmSpacesKeymap(tabsToSpaces: () => boolean): KeyBinding[] { + return [ + { key: "Tab", run: v => tabsToSpaces() && asmInsertSpaces(v) }, + { key: "Backspace", run: v => tabsToSpaces() && asmDeleteSpacesBackward(v) }, + ]; +}