mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 00:02:16 +00:00
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { indentLess, indentMore, insertTab } from "@codemirror/commands";
|
|
import { getIndentUnit } from "@codemirror/language";
|
|
import { countColumn, EditorSelection, EditorState } from "@codemirror/state";
|
|
import { EditorView, KeyBinding } from "@codemirror/view";
|
|
|
|
// Spaces to insert when pressing Tab at document position 'head': up to the
|
|
// next multiple of the indent unit in leading whitespace, otherwise up to the
|
|
// next multiple of the tab size.
|
|
export function spacesToTabStop(state: EditorState, head: number): string {
|
|
const line = state.doc.lineAt(head);
|
|
const before = line.text.slice(0, head - line.from);
|
|
const col = countColumn(before, state.tabSize);
|
|
const stop = /^\s*$/.test(before) ? getIndentUnit(state) : state.tabSize;
|
|
return " ".repeat(stop - (col % stop));
|
|
}
|
|
|
|
// Insert spaces from the cursor up to the next tab stop.
|
|
// A non-empty selection indents the selected lines instead.
|
|
function insertSpacesToTabStop(view: EditorView): boolean {
|
|
const state = view.state;
|
|
if (state.selection.ranges.some(r => !r.empty)) return indentMore(view);
|
|
view.dispatch(state.changeByRange(range => {
|
|
const insert = spacesToTabStop(state, range.head);
|
|
return {
|
|
changes: { from: range.head, insert },
|
|
range: EditorSelection.cursor(range.head + insert.length),
|
|
};
|
|
}), { scrollIntoView: true, userEvent: "input" });
|
|
return true;
|
|
}
|
|
|
|
export const insertSpacesKeymap: KeyBinding[] = [
|
|
{ key: "Tab", run: insertSpacesToTabStop },
|
|
{ key: "Shift-Tab", run: indentLess },
|
|
];
|
|
|
|
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<number, number>) {
|
|
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<number, number>) {
|
|
// 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<number, number> {
|
|
const cur = state.doc.lineAt(pos);
|
|
const total = state.doc.lines;
|
|
const scan = (from: number, to: number, step: number, freq: Map<number, number>): 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<number, number> = 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<number, number>, 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<number, number>, 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<number, number>): 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) },
|
|
];
|
|
}
|