diff --git a/src/docs/editor.md b/src/docs/editor.md index ef528398..ec24ac43 100644 --- a/src/docs/editor.md +++ b/src/docs/editor.md @@ -38,8 +38,8 @@ For some platforms, the gutter shows additional information next to source lines | Shortcut | Action | | --- | --- | -| `Tab` | Insert a tab or indent the selected line(s) | -| `Shift+Tab` | Outdent the line(s) or selected range(s) | +| `Tab` | Insert spaces or a tab character (per settings) or indent selection | +| `Shift+Tab` | Unindent the current line or selection | | `mod+Z` | Undo | | `mod+Shift+Z` | Redo | | `mod+F` | Find | diff --git a/src/ide/settings.ts b/src/ide/settings.ts index 5a1e5757..fe66fc80 100644 --- a/src/ide/settings.ts +++ b/src/ide/settings.ts @@ -5,7 +5,7 @@ import { EditorView, highlightSpecialChars, highlightTrailingWhitespace, highlig import { isProductionHost } from "../common/util"; import { setBarVisible } from "./shortcutbar"; import { debugHighlightTagsTooltip } from "./views/debug"; -import { insertTabKeymap, smartIndentKeymap } from "./views/tabs"; +import { insertSpacesKeymap, insertTabKeymap } from "./views/tabs"; declare var bootbox: BootboxStatic; declare var $: JQueryStatic; @@ -61,7 +61,7 @@ export function saveSettings(settings: EditorSettings) { const compartmentValues: [Compartment, (s: EditorSettings) => Extension][] = [ [tabSizeCompartment, s => [EditorState.tabSize.of(s.tabSize), indentUnit.of(" ".repeat(s.tabSize))]], - [tabsToSpacesCompartment, s => keymap.of(s.tabsToSpaces ? smartIndentKeymap : insertTabKeymap)], + [tabsToSpacesCompartment, s => keymap.of(s.tabsToSpaces ? insertSpacesKeymap : insertTabKeymap)], [highlightSpecialCharsCompartment, s => s.highlightSpecialChars ? highlightSpecialChars() : []], [highlightWhitespaceCompartment, s => s.highlightWhitespace ? highlightWhitespace() : []], [highlightTrailingWhitespaceCompartment, s => s.highlightTrailingWhitespace ? highlightTrailingWhitespace() : []], diff --git a/src/ide/views/tabs.ts b/src/ide/views/tabs.ts index 58fd5750..84237dac 100644 --- a/src/ide/views/tabs.ts +++ b/src/ide/views/tabs.ts @@ -1,9 +1,36 @@ import { indentLess, indentMore, insertTab } from "@codemirror/commands"; -import { EditorSelection, EditorState } from "@codemirror/state"; +import { getIndentUnit } from "@codemirror/language"; +import { countColumn, EditorSelection, EditorState } from "@codemirror/state"; import { EditorView, KeyBinding } from "@codemirror/view"; -export const smartIndentKeymap: KeyBinding[] = [ - { key: "Tab", run: indentMore }, +// 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 }, ]; diff --git a/test/unit/testtabs.ts b/test/unit/testtabs.ts new file mode 100644 index 00000000..fddb4944 --- /dev/null +++ b/test/unit/testtabs.ts @@ -0,0 +1,44 @@ +import assert from "assert"; +import { describe } from "mocha"; +import { indentUnit } from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { spacesToTabStop } from "../../src/ide/views/tabs"; + +// Build a state from text with '|' marking the cursor; returns the spaces Tab would insert. +function tabAt(textWithCursor: string, tabSize: number, unit: number): string { + const head = textWithCursor.indexOf('|'); + const state = EditorState.create({ + doc: textWithCursor.replace('|', ''), + extensions: [EditorState.tabSize.of(tabSize), indentUnit.of(" ".repeat(unit))], + }); + return spacesToTabStop(state, head); +} + +describe('spacesToTabStop', () => { + it('should advance by indent unit in leading whitespace', () => { + assert.strictEqual(tabAt('|', 8, 2).length, 2); + assert.strictEqual(tabAt(' |', 8, 2).length, 2); + assert.strictEqual(tabAt(' |x', 8, 2).length, 2); + assert.strictEqual(tabAt(' |x', 8, 2).length, 1); // 3 -> 4 + }); + it('should advance to tab size stops after text', () => { + assert.strictEqual(tabAt('int x;|', 8, 2).length, 2); // 6 -> 8 + assert.strictEqual(tabAt('int x; |', 8, 2).length, 8); // 8 -> 16 + assert.strictEqual(tabAt('x|', 8, 2).length, 7); + }); + it('should use the tab size stops when indent unit equals it', () => { + assert.strictEqual(tabAt('|', 8, 8).length, 8); + assert.strictEqual(tabAt(' |', 8, 8).length, 6); + }); + it('should count literal tab characters as tab size columns', () => { + assert.strictEqual(tabAt('\t|', 8, 2).length, 2); // col 8 -> 10 + assert.strictEqual(tabAt('\tx|', 8, 2).length, 7); // col 9 -> 16 + }); + it('should use the cursor line only', () => { + assert.strictEqual(tabAt(' foo\n|', 8, 2).length, 2); + assert.strictEqual(tabAt('a\n b|\nc', 4, 2).length, 1); // col 3 -> 4 + }); + it('should insert spaces only', () => { + assert.strictEqual(tabAt('a|', 4, 2), ' '); + }); +});