Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { activateBackgroundHighlighter } from "./providers/background";
import { activateYamlLinks } from "./providers/yaml-links";
import { activateYamlFilepathCompletions } from "./providers/yaml-filepath-completions";
import { activateContextKeySetter } from "./providers/context-keys";
import { activateDivBracketDecorations } from "./providers/div-brackets";
import { CommandManager } from "./core/command";
import { createQuartoExtensionApi, QuartoExtensionApi } from "./api";

Expand Down Expand Up @@ -221,6 +222,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
// context setter
activateContextKeySetter(context, engine);

// div bracket decorations
activateDivBracketDecorations(context);

// commands
const commandManager = new CommandManager();
for (const cmd of commands) {
Expand Down
173 changes: 173 additions & 0 deletions apps/vscode/src/providers/div-brackets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* div-brackets.ts
*
* Copyright (C) 2025 by Posit Software, PBC
Comment thread
vezwork marked this conversation as resolved.
Outdated
*
* Unless you have received this program directly from Posit Software pursuant
* to the terms of a commercial license agreement with Posit Software, then
* this program is licensed to you under the terms of version 3 of the
* GNU Affero General Public License. This program is distributed WITHOUT
* ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
* MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
* AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
*
*/

import * as vscode from 'vscode';
import { markdownitParser, Token } from 'quarto-core';

/**
* Provides colored decorations for div bracket pairs (:::)
*
* This gives visual feedback similar to bracket pair colorization,
* but works for Quarto's context-sensitive div syntax.
*/
export function activateDivBracketDecorations(context: vscode.ExtensionContext) {
const parser = markdownitParser();

// Cache for parsed tokens
const parseCache = new Map<string, {
Comment thread
vezwork marked this conversation as resolved.
version: number;
divTokens: Token[];
}>();

// Define decoration types for different nesting levels (rotating colors)
const decorationTypes = [
vscode.window.createTextEditorDecorationType({
color: new vscode.ThemeColor('editorBracketHighlight.foreground1'),
}),
vscode.window.createTextEditorDecorationType({
color: new vscode.ThemeColor('editorBracketHighlight.foreground2'),
}),
vscode.window.createTextEditorDecorationType({
color: new vscode.ThemeColor('editorBracketHighlight.foreground3'),
}),
];

// Decoration type for matching pairs when cursor is on a bracket
const matchHighlightDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: new vscode.ThemeColor('editor.wordHighlightBackground'),
border: '1px solid',
borderRadius: '6px',
borderColor: new vscode.ThemeColor('editor.wordHighlightBorder'),
});

// Helper to extract ::: range from a line
const getDivMarkerRange = (editor: vscode.TextEditor, line: number): vscode.Range | null => {
const lineText = editor.document.lineAt(line).text;
const match = lineText.match(/^(:::+)/);
return match ? new vscode.Range(line, 0, line, match[1].length) : null;
};

function updateDecorations(editor: vscode.TextEditor) {
if (editor.document.languageId !== 'quarto') return;

const docUri = editor.document.uri.toString();
const docVersion = editor.document.version;

// Check cache
let divTokens: Token[];
const cached = parseCache.get(docUri);
if (cached && cached.version === docVersion) {
divTokens = cached.divTokens;
} else {
// Parse the document
const doc = {
getText: () => editor.document.getText(),
uri: docUri,
version: docVersion,
lineCount: editor.document.lineCount,
};

divTokens = parser(doc as any).filter(t => t.type === 'Div');
parseCache.set(docUri, { version: docVersion, divTokens });
}

// Group decorations by nesting level
const decorationsByLevel = decorationTypes.map(() => [] as vscode.Range[]);
const matchHighlights: vscode.Range[] = [];

// Calculate nesting depth for all divs in a single pass using a stack
const divDepth = new Map<Token, number>();
const stack: Token[] = [];
for (const divToken of divTokens) {
// Pop divs from stack that have ended before this div starts
while (stack.length > 0 && stack.at(-1)!.range.end.line < divToken.range.start.line) {
stack.pop();
}
divDepth.set(divToken, stack.length);
stack.push(divToken);
}

// Apply decorations
for (const divToken of divTokens) {
const openLine = divToken.range.start.line;
const closeLine = divToken.range.end.line;
const depth = divDepth.get(divToken)!;
const colorIndex = depth % decorationTypes.length;
const cursorLine = editor.selection.active.line;
const isCursorOver = cursorLine === openLine || cursorLine === closeLine;

const openRange = getDivMarkerRange(editor, openLine);
const closeRange = getDivMarkerRange(editor, closeLine);

const targetList = isCursorOver ?
matchHighlights :
decorationsByLevel[colorIndex];
if (openRange) targetList.push(openRange);
if (closeRange) targetList.push(closeRange);
}

decorationTypes.forEach((decorationType, i) =>
editor.setDecorations(decorationType, decorationsByLevel[i])
);
editor.setDecorations(matchHighlightDecorationType, matchHighlights);
}

function triggerUpdateDecorations(editor: vscode.TextEditor | undefined) {

if (editor) {
updateDecorations(editor);
}
}

// Update decorations when active editor changes
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(editor => {
if (editor) {
triggerUpdateDecorations(editor);
}
})
);

// Update decorations when document changes
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument(event => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we debounce this the same way we do the highlighting decorations in background.ts?

Copy link
Copy Markdown
Member Author

@vezwork vezwork May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went down a bit of a rabbit hole.

  • Yes we should not be calling updateDecorations too often because that could cause lag.
  • BUT! background.ts's debounce was implemented incorrectly -- a new debounced function was getting created on every call of the trigger function (we have to call the same debounced function multiple times for the debounce to take effect).
  • Also BUT! I think throttling should be a nicer user experience than debouncing. With throttling, the editor will update as the user is typing, but at a reduced rate, and it will definitely have an update based on the user's last input.

so I made both div-brackets.ts and background.ts use throttling for their updates.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we can't (easily) use lodash's throttle/debounce because it has a fixed delay, whereas we want the delay to be from the config.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm reconsidering my decision to use throttle instead of debounce. I am consulting the #frontend channel in Slack to see if anyone has any thoughts.

Copy link
Copy Markdown
Member Author

@vezwork vezwork May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks to @dhruvisompura and @dotNomad for very helpful discussion and resources on Slack around throttling v.s. debouncing.

I tested a 32000 line qmd and did not experience any input lag when creating and removing divs with throttled bracket highlighting. That only says so much, because my work laptop is powerful, but at least there isn't an egregious perf issue here.

In summary, I think throttling is a better user experience than debouncing here!

const editor = vscode.window.activeTextEditor;
if (editor && event.document === editor.document) {
triggerUpdateDecorations(editor);
}
})
);

// Update decorations when cursor moves
context.subscriptions.push(
vscode.window.onDidChangeTextEditorSelection(event => {
if (event.textEditor === vscode.window.activeTextEditor) {
triggerUpdateDecorations(event.textEditor);
}
})
);

// Update decorations for the active editor now
if (vscode.window.activeTextEditor) {
triggerUpdateDecorations(vscode.window.activeTextEditor);
}

// Clean up decoration types on deactivation
context.subscriptions.push({
dispose: () => {
decorationTypes.forEach(type => type.dispose());
}
});
}
2 changes: 1 addition & 1 deletion packages/core/src/markdownit/divs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const divPlugin = (md: MarkdownIt) => {
}

// Three or more colons followed by a an optional brace with attributes
const divBraceRegex = /^(:::+)\s*(?:(\{[\s\S]+?\}))?$/;
const divBraceRegex = /^(:::+)\s*(?:(\{[\s\S]*?\}))?$/;

// Three or more colons followed by a string with no braces
const divNoBraceRegex = /^(:::+)\s*(?:([^{}\s]+?))?$/;
Expand Down
Loading