diff --git a/README.md b/README.md
index a7f6cf5e..7c579458 100644
--- a/README.md
+++ b/README.md
@@ -83,12 +83,20 @@ To reveal the LaTeX syntax, move your cursor over it. You can also choose to del

-### Tabout
+### Tabout & Reverse Tabout
+#### Tabout
To make it easier to navigate and exit equations,
- Pressing Tab while the cursor is at the end of an equation will move the cursor outside the `$` symbols.
-- Otherwise, pressing Tab will advance the cursor to the next closing bracket: `)`, `]`, `}`, `>`, or `|`.
+- If the cursor is inside a `\left ... \right` pair, pressing Tab will jump to after the `\right` command and its corresponding delimiter.
+- Otherwise, pressing Tab will advance the cursor to the next closing bracket: `)`, `]`, `}`, `\rangle`, or `\rvert`.
+#### Reverse Tabout
+To navigate equations in the opposite direction:
+
+- Pressing Shift + Tab while the cursor is at the beginning of an equation will move the cursor before the opening `$` symbol.
+- If the cursor is inside a `\left ... \right` pair, pressing Shift + Tab will jump to before the `\left` command.
+- Otherwise, pressing Shift + Tab will move the cursor to the previous opening bracket: `(`, `[`, `{`, `\langle`, or `\lvert`.
### Preview inline math
When your cursor is inside inline math, a popup window showing the rendered math will be displayed.
diff --git a/src/features/tabout.ts b/src/features/tabout.ts
index 0b83ea11..852c58c0 100644
--- a/src/features/tabout.ts
+++ b/src/features/tabout.ts
@@ -1,33 +1,157 @@
import { EditorView } from "@codemirror/view";
import { replaceRange, setCursor, getCharacterAtPos } from "src/utils/editor_utils";
import { Context } from "src/utils/context";
+import { getLatexSuiteConfig } from "src/snippets/codemirror/config";
-export const tabout = (view: EditorView, ctx: Context):boolean => {
- if (!ctx.mode.inMath()) return false;
+let sortedLeftCommands: string[] = [];
+let sortedRightCommands: string[] = [];
+let sortedDelimiters: string[] = [];
+let sortedOpeningSymbols: string[] = [];
+let sortedClosingSymbols: string[] = [];
+
+
+const isCommandEnd = (str: string): boolean => {
+ return /\\[a-zA-Z]+\\*?$/.test(str);
+}
+
+
+const isMatchingCommand = (text: string, command: string, startIndex: number): boolean => {
+ if (!text.startsWith(command, startIndex)) {
+ return false;
+ }
+
+ const nextChar = text.charAt(startIndex + command.length);
+ const isEndOfCommand = !/[a-zA-Z]/.test(nextChar);
+
+ return isEndOfCommand;
+}
+
+
+const isMatchingToken = (text: string, token: string, startIndex: number): boolean => {
+ if (isCommandEnd(token)) {
+ return isMatchingCommand(text, token, startIndex);
+ }
+ else {
+ return text.startsWith(token, startIndex);
+ }
+}
+
+
+const findTokenLength = (sortedTokens: string[], text: string, startIndex: number): number => {
+ const matchedToken = sortedTokens.find((token) => isMatchingToken(text, token, startIndex));
+
+ if (matchedToken) {
+ return matchedToken.length;
+ }
+
+ return 0;
+}
+
+
+const findCommandWithDelimiterLength = (sortedCommands: string[], text: string, startIndex: number): number => {
+ const matchedCommand = sortedCommands.find((command) => isMatchingCommand(text, command, startIndex));
+
+ if (!matchedCommand) {
+ return 0;
+ }
+
+ const afterCommandIndex = startIndex + matchedCommand.length;
+
+ let whitespaceCount = 0;
+ while (/\s/.test(text.charAt(afterCommandIndex + whitespaceCount))) {
+ whitespaceCount++;
+ }
+ const delimiterStartIndex = afterCommandIndex + whitespaceCount;
+
+ const matchedDelimiter = sortedDelimiters.find((delimiter) => isMatchingToken(text, delimiter, delimiterStartIndex));
+
+ if (!matchedDelimiter) {
+ return 0;
+ }
+
+ return matchedCommand.length + whitespaceCount + matchedDelimiter.length;
+}
+
+
+const findLeftDelimiterLength = (text: string, startIndex: number): number => {
+ const leftDelimiterLength = findCommandWithDelimiterLength(sortedLeftCommands, text, startIndex);
+ if (leftDelimiterLength) return leftDelimiterLength;
+
+ const openingSymbolLength = findTokenLength(sortedOpeningSymbols, text, startIndex);
+ if (openingSymbolLength) return openingSymbolLength;
+
+ return 0;
+}
+
+
+const findRightDelimiterLength = (text: string, startIndex: number): number => {
+ const rightDelimiterLength = findCommandWithDelimiterLength(sortedRightCommands, text, startIndex);
+ if (rightDelimiterLength) return rightDelimiterLength;
+
+ const closingSymbolLength = findTokenLength(sortedClosingSymbols, text, startIndex);
+ if (closingSymbolLength) return closingSymbolLength;
+
+ return 0;
+}
+
+
+export const tabout = (view: EditorView, ctx: Context): boolean => {
+ if (!ctx.mode.inMath()) return false;
const result = ctx.getBounds();
if (!result) return false;
+
+ const start = result.start;
const end = result.end;
const pos = view.state.selection.main.to;
+
const d = view.state.doc;
const text = d.toString();
- // Move to the next closing bracket: }, ), ], >, |, or \\rangle
- const rangle = "\\rangle";
+ sortedLeftCommands = getLatexSuiteConfig(view).sortedTaboutLeftCommands;
+ sortedRightCommands = getLatexSuiteConfig(view).sortedTaboutRightCommands;
+ sortedDelimiters = getLatexSuiteConfig(view).sortedTaboutDelimiters;
+ sortedClosingSymbols = getLatexSuiteConfig(view).sortedTaboutClosingSymbols;
+
+ // Move to the next closing bracket
+ let i = start;
+ while (i < end) {
+ const rightDelimiterLength = findRightDelimiterLength(text, i);
+ if (rightDelimiterLength > 0) {
+ i += rightDelimiterLength;
- for (let i = pos; i < end; i++) {
- if (["}", ")", "]", ">", "|", "$"].contains(text.charAt(i))) {
- setCursor(view, i+1);
+ if (i > pos) {
+ setCursor(view, i);
+ return true;
+ }
- return true;
+ continue;
}
- else if (text.slice(i, i + rangle.length) === rangle) {
- setCursor(view, i + rangle.length);
- return true;
+ // Attempt to match only the right command if matching right command + delimiter fails
+ const rightCommandLength = findTokenLength(sortedRightCommands, text, i);
+ if (rightCommandLength > 0) {
+ i += rightCommandLength;
+
+ if (i > pos) {
+ setCursor(view, i);
+ return true;
+ }
+
+ continue;
}
+
+ // Skip left command + delimiter
+ const leftDelimiterLength = findCommandWithDelimiterLength(sortedLeftCommands, text, i);
+ if (leftDelimiterLength > 0) {
+ i += leftDelimiterLength;
+
+ continue;
+ }
+
+ i++;
}
@@ -47,7 +171,7 @@ export const tabout = (view: EditorView, ctx: Context):boolean => {
}
else {
// First, locate the $$ symbol
- const dollarLine = d.lineAt(end+2);
+ const dollarLine = d.lineAt(end + 2);
// If there's no line after the equation, create one
@@ -69,6 +193,115 @@ export const tabout = (view: EditorView, ctx: Context):boolean => {
}
+export const reverseTabout = (view: EditorView, ctx: Context): boolean => {
+ if (!ctx.mode.inMath()) return false;
+
+ const result = ctx.getBounds();
+ if (!result) return false;
+
+ const start = result.start;
+ const end = result.end;
+
+ const pos = view.state.selection.main.to;
+
+ const d = view.state.doc;
+ const text = d.toString();
+
+ sortedLeftCommands = getLatexSuiteConfig(view).sortedTaboutLeftCommands;
+ sortedRightCommands = getLatexSuiteConfig(view).sortedTaboutRightCommands;
+ sortedDelimiters = getLatexSuiteConfig(view).sortedTaboutDelimiters;
+ sortedOpeningSymbols = getLatexSuiteConfig(view).sortedTaboutOpeningSymbols;
+
+ const textBtwnStartAndCursor = d.sliceString(start, pos);
+ const isAtStart = textBtwnStartAndCursor.trim().length === 0;
+
+ // Move out of the equation.
+ if (isAtStart) {
+ if (ctx.mode.inlineMath || ctx.mode.codeMath) {
+ setCursor(view, start - 1);
+ }
+ else {
+ let whitespaceCount = 0;
+ while (/[ ]/.test(text.charAt(start - 2 - whitespaceCount - 1))) {
+ whitespaceCount++;
+ }
+ if (text.charAt(start - 2 - whitespaceCount - 1) == "\n") {
+ setCursor(view, start - 2 - whitespaceCount - 1);
+ }
+ else {
+ setCursor(view, start - 2);
+ }
+ }
+
+ return true;
+ }
+
+ // Move to the previous openinging bracket
+ let previous_i = start;
+ let i = start;
+ while (i < end) {
+ const leftDelimiterLength = findLeftDelimiterLength(text, i);
+ if (leftDelimiterLength > 0) {
+ if (i >= pos) {
+ setCursor(view, previous_i);
+
+ return true;
+ }
+
+ previous_i = i;
+ i += leftDelimiterLength;
+
+ if (i >= pos) {
+ setCursor(view, previous_i);
+
+ return true;
+ }
+
+ continue;
+ }
+
+ // Attempt to match only the left command if matching left command + delimiter fails
+ const leftCommandLength = findTokenLength(sortedLeftCommands, text, i);
+ if (leftCommandLength > 0) {
+ if (i >= pos) {
+ setCursor(view, previous_i);
+
+ return true;
+ }
+
+ previous_i = i;
+ i += leftCommandLength;
+
+ if (i >= pos) {
+ setCursor(view, previous_i);
+
+ return true;
+ }
+
+ // This helps users easily identify and correct missing delimiters.
+ // Set cursor to the next to the left coomand
+ previous_i = i;
+
+ continue;
+ }
+
+ // Skip right command + delimiter
+ const rightDelimiterLength = findCommandWithDelimiterLength(sortedRightCommands, text, i);
+ if (rightDelimiterLength > 0) {
+ i += rightDelimiterLength;
+
+ continue;
+ }
+
+ i++;
+ }
+
+ setCursor(view, previous_i);
+
+ return true;
+}
+
+
export const shouldTaboutByCloseBracket = (view: EditorView, keyPressed: string) => {
const sel = view.state.selection.main;
if (!sel.empty) return;
@@ -83,4 +316,4 @@ export const shouldTaboutByCloseBracket = (view: EditorView, keyPressed: string)
else {
return false;
}
-}
\ No newline at end of file
+}
diff --git a/src/latex_suite.ts b/src/latex_suite.ts
index 0f7f01fa..decb929d 100644
--- a/src/latex_suite.ts
+++ b/src/latex_suite.ts
@@ -2,7 +2,7 @@ import { EditorView, ViewUpdate } from "@codemirror/view";
import { runSnippets } from "./features/run_snippets";
import { runAutoFraction } from "./features/autofraction";
-import { tabout, shouldTaboutByCloseBracket } from "./features/tabout";
+import { tabout, reverseTabout, shouldTaboutByCloseBracket } from "./features/tabout";
import { runMatrixShortcuts } from "./features/matrix_shortcuts";
import { Context } from "./utils/context";
@@ -98,9 +98,17 @@ export const handleKeydown = (key: string, shiftKey: boolean, ctrlKey: boolean,
}
if (settings.taboutEnabled) {
- if (key === "Tab" || shouldTaboutByCloseBracket(view, key)) {
- success = tabout(view, ctx);
+ if (key === "Tab") {
+ if (settings.reverseTaboutEnabled && shiftKey) {
+ success = reverseTabout(view, ctx);
+ }
+ else {
+ success = tabout(view, ctx);
+ }
+ if (success) return true;
+ }
+ if (shouldTaboutByCloseBracket(view, key)) {
if (success) return true;
}
}
diff --git a/src/settings/settings.ts b/src/settings/settings.ts
index 7c9adecb..8a0c00ee 100644
--- a/src/settings/settings.ts
+++ b/src/settings/settings.ts
@@ -24,6 +24,7 @@ interface LatexSuiteBasicSettings {
autofractionBreakingChars: string;
matrixShortcutsEnabled: boolean;
taboutEnabled: boolean;
+ reverseTaboutEnabled: boolean;
autoEnlargeBrackets: boolean;
wordDelimiters: string;
}
@@ -34,6 +35,11 @@ interface LatexSuiteBasicSettings {
interface LatexSuiteRawSettings {
autofractionExcludedEnvs: string;
matrixShortcutsEnvNames: string;
+ taboutOpeningSymbols: string;
+ taboutClosingSymbols: string;
+ taboutLeftCommands: string,
+ taboutRightCommands: string,
+ taboutDelimiters: string;
autoEnlargeBracketsTriggers: string;
forceMathLanguages: string;
}
@@ -41,6 +47,11 @@ interface LatexSuiteRawSettings {
interface LatexSuiteParsedSettings {
autofractionExcludedEnvs: Environment[];
matrixShortcutsEnvNames: string[];
+ sortedTaboutOpeningSymbols: string[];
+ sortedTaboutClosingSymbols: string[];
+ sortedTaboutLeftCommands: string[];
+ sortedTaboutRightCommands: string[];
+ sortedTaboutDelimiters: string[];
autoEnlargeBracketsTriggers: string[];
forceMathLanguages: string[];
}
@@ -73,6 +84,7 @@ export const DEFAULT_SETTINGS: LatexSuitePluginSettings = {
autofractionBreakingChars: "+-=\t",
matrixShortcutsEnabled: true,
taboutEnabled: true,
+ reverseTaboutEnabled: true,
autoEnlargeBrackets: true,
wordDelimiters: "., +-\\n\t:;!?\\/{}[]()=~$",
@@ -83,6 +95,11 @@ export const DEFAULT_SETTINGS: LatexSuitePluginSettings = {
["\\\\pu{", "}"]
]`,
matrixShortcutsEnvNames: "pmatrix, cases, align, gather, bmatrix, Bmatrix, vmatrix, Vmatrix, array, matrix",
+ taboutOpeningSymbols: "(, [, \\lbrack, \\{, \\lbrace, \\langle, \\lvert, \\lVert, \\lfloor, \\lceil, \\ulcorner, {",
+ taboutClosingSymbols: "), ], \\rbrack, \\}, \\rbrace, \\rangle, \\rvert, \\rVert, \\rfloor, \\rceil, \\urcorner, }",
+ taboutLeftCommands: "\\left, \\bigl, \\Bigl, \\biggl, \\Biggl",
+ taboutRightCommands: "\\right, \\bigr, \\Bigr, \\biggr, \\Biggr",
+ taboutDelimiters: "(, ), [, ], \\lbrack, \\rbrack, \\{, \\}, \\lbrace, \\rbrace, <, >, \\langle, \\rangle, \\lt, \\gt, |, \\vert, \\lvert, \\rvert, \\|, \\Vert, \\lVert, \\rVert, \\lfloor, \\rfloor, \\lceil, \\rceil, \\ulcorner, \\urcorner, /, \\\\, \\backslash, \\uparrow, \\downarrow, \\Uparrow, \\Downarrow, .",
autoEnlargeBracketsTriggers: "sum, int, frac, prod, bigcup, bigcap",
forceMathLanguages: "math",
}
@@ -116,6 +133,11 @@ export function processLatexSuiteSettings(snippets: Snippet[], settings: LatexSu
snippets: snippets,
autofractionExcludedEnvs: getAutofractionExcludedEnvs(settings.autofractionExcludedEnvs),
matrixShortcutsEnvNames: strToArray(settings.matrixShortcutsEnvNames),
+ sortedTaboutOpeningSymbols: strToArray(settings.taboutOpeningSymbols).sort((a, b) => b.length - a.length),
+ sortedTaboutClosingSymbols: strToArray(settings.taboutClosingSymbols).sort((a, b) => b.length - a.length),
+ sortedTaboutLeftCommands: strToArray(settings.taboutLeftCommands).sort((a, b) => b.length - a.length),
+ sortedTaboutRightCommands: strToArray(settings.taboutRightCommands).sort((a, b) => b.length - a.length),
+ sortedTaboutDelimiters: strToArray(settings.taboutDelimiters).sort((a, b) => b.length - a.length),
autoEnlargeBracketsTriggers: strToArray(settings.autoEnlargeBracketsTriggers),
forceMathLanguages: strToArray(settings.forceMathLanguages),
}
diff --git a/src/settings/settings_tab.ts b/src/settings/settings_tab.ts
index fa825915..7ca8f3f5 100644
--- a/src/settings/settings_tab.ts
+++ b/src/settings/settings_tab.ts
@@ -340,8 +340,85 @@ export class LatexSuiteSettingTab extends PluginSettingTab {
.setValue(this.plugin.settings.taboutEnabled)
.onChange(async (value) => {
this.plugin.settings.taboutEnabled = value;
+
+ reverseTaboutSetting.settingEl.toggleClass("hidden", !value);
+
+ await this.plugin.saveSettings();
+ }));
+
+ const reverseTaboutSetting = new Setting(containerEl)
+ .setName("Reverse Tabout")
+ .setDesc("Whether reverse tabout is enabled.")
+ .addToggle(toggle => toggle
+ .setValue(this.plugin.settings.reverseTaboutEnabled)
+ .onChange(async (value) => {
+ this.plugin.settings.reverseTaboutEnabled = value;
+
+ await this.plugin.saveSettings();
+ }));
+
+ new Setting(containerEl)
+ .setName("Opening brackets")
+ .setDesc("A list of opening brackets for reverse tabout, separated by commas.")
+ .addText(text => text
+ .setPlaceholder(DEFAULT_SETTINGS.taboutOpeningSymbols)
+ .setValue(this.plugin.settings.taboutOpeningSymbols)
+ .onChange(async (value) => {
+ this.plugin.settings.taboutOpeningSymbols = value;
+
+ await this.plugin.saveSettings();
+ }));
+
+ new Setting(containerEl)
+ .setName("Closing brackets")
+ .setDesc("A list of closing brackets for tabout, separated by commas.")
+ .addText(text => text
+ .setPlaceholder(DEFAULT_SETTINGS.taboutClosingSymbols)
+ .setValue(this.plugin.settings.taboutClosingSymbols)
+ .onChange(async (value) => {
+ this.plugin.settings.taboutClosingSymbols = value;
+
await this.plugin.saveSettings();
}));
+
+ new Setting(containerEl)
+ .setName("Left Commands")
+ .setDesc("A list of left-side LaTeX delimiter commands, separated by commas.")
+ .addText(text => text
+ .setPlaceholder(DEFAULT_SETTINGS.taboutLeftCommands)
+ .setValue(this.plugin.settings.taboutLeftCommands)
+ .onChange(async (value) => {
+ this.plugin.settings.taboutLeftCommands = value;
+
+ await this.plugin.saveSettings();
+ }));
+
+ new Setting(containerEl)
+ .setName("Right Commands")
+ .setDesc("A list of right-side LaTeX delimiter commands, separated by commas.")
+ .addText(text => text
+ .setPlaceholder(DEFAULT_SETTINGS.taboutRightCommands)
+ .setValue(this.plugin.settings.taboutRightCommands)
+ .onChange(async (value) => {
+ this.plugin.settings.taboutRightCommands = value;
+
+ await this.plugin.saveSettings();
+ }));
+
+ new Setting(containerEl)
+ .setName("Delimiters")
+ .setDesc("A list of valid delimiters that can follow left-side or right-side LaTeX commands, separated by commas.")
+ .addText(text => text
+ .setPlaceholder(DEFAULT_SETTINGS.taboutDelimiters)
+ .setValue(this.plugin.settings.taboutDelimiters)
+ .onChange(async (value) => {
+ this.plugin.settings.taboutDelimiters = value;
+
+ await this.plugin.saveSettings();
+ }));
+
+ const taboutEnabled = this.plugin.settings.taboutEnabled;
+ reverseTaboutSetting.settingEl.toggleClass("hidden", !taboutEnabled);
}
private displayAutoEnlargeBracketsSettings() {