diff --git a/package.json b/package.json index b2a2067..facccc0 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,16 @@ "command": "org.literal", "key": "ctrl+alt+o l", "when": "editorLangId == 'org'" + }, + { + "command": "org.updateSummary", + "key": "ctrl+alt+o #", + "when": "editorLangId == 'org'" + }, + { + "command": "org.toggleCheckbox", + "key": "ctrl+alt+o x", + "when": "editorLangId == 'org'" } ] }, diff --git a/src/checkboxes.ts b/src/checkboxes.ts new file mode 100644 index 0000000..e1fc9ae --- /dev/null +++ b/src/checkboxes.ts @@ -0,0 +1,203 @@ +'use strict'; + +import { window, workspace, TextLine, Range, TextEditor, TextEditorEdit } from 'vscode'; + +// Checkbox is represented by exactly one symbol between square brackets. Symbol indicates status: '-' undetermined, 'x' or 'X' checked, ' ' unchecked. +const checkboxRegex = /\[([-xX ])\]/; +// Summary is a cookie indicating the number of ticked checkboxes in the child list relative to the total number of checkboxes in the list. +const summaryRegex = /\[(\d*\/\d*)\]/; +// Percentage is a cookie indicating the percentage of ticked checkboxes in the child list relative to the total number of checkboxes in the list. +const percentRegex = /\[(\d*)%\]/; +const indentRegex = /^(\s*)\S/; +let orgTabSize: number = 4; + +export function orgTabsToSpaces(tabs: string, tabSize: number = 4): number { + if (!tabs) { + return 0; + } + let off = 1; + for (let i = 0; i < tabs.length; i++) { + if (tabs[i] == '\t') { + off += tabSize - off % tabSize; + } else { + off++; + } + } + return off; +} + +export function orgToggleCheckbox(editor: TextEditor, edit: TextEditorEdit) { + let doc = editor.document; + let line = doc.lineAt(editor.selection.active.line); + let checkbox = orgFindCookie(checkboxRegex, line); + if (checkbox) { + orgTabSize = workspace.getConfiguration('editor', doc.uri).get('tabSize'); + let text = doc.getText(checkbox).toLowerCase(); + let delta = orgCascadeCheckbox(edit, checkbox, line, text == 'x' ? ' ' : 'x'); + let parent = orgFindParent(editor, line); + // Since the updates as a result of toggle have not happened yet in the editor, + // counting checked children is going to use old value of the current checkbox. + // Hence the delta adjustment. + if (parent) { + orgUpdateParent(editor, edit, parent, delta); + } + } +} + +export function orgUpdateSummary(editor: TextEditor, edit: TextEditorEdit) { + let doc = editor.document; + let line = doc.lineAt(editor.selection.active.line); + orgTabSize = workspace.getConfiguration('editor', doc.uri).get('tabSize'); + orgUpdateParent(editor, edit, line, 0); +} + +// Pattern elements, like ratio summary, percent summary, checkbox, of the orgmode document are called cookies. +function orgFindCookie(cookie: RegExp, line: TextLine): Range | undefined { + let match = cookie.exec(line.text); + if (match) { + return new Range(line.lineNumber, match.index + 1, line.lineNumber, match.index + 1 + match[1].length); + } + return undefined; +} + +function orgTriStateToDelta(value: string): number { + switch (value) { + case 'x': return 1; + case ' ': return -1; + default: return 0; + } +} + +function orgGetTriState(checked, total: number): string { + return checked == 0 ? ' ' : (checked == total ? 'x' : '-'); +} + +// Calculate and return indentation level of the line. Used in traversing nested lists and locating parent item. +function orgGetIndent(line: TextLine): number { + let match = indentRegex.exec(line.text); + if (match) { + return orgTabsToSpaces(match[1], orgTabSize); + } + return 0; +} + +// Set checkbox to the desired state and perform necessary updates to child and parent elements (however many levels). +function orgCascadeCheckbox(edit: TextEditorEdit, checkbox: Range, line: TextLine, state: string): number { + if (!checkbox) { + return 0; + } + let editor = window.activeTextEditor; + let text = editor.document.getText(checkbox).toLowerCase(); + if (text == state) { + return 0; // Nothing to do. + } + edit.replace(checkbox, state); + if (!line) { + return orgTriStateToDelta(state); + } + let children = orgFindChildren(editor, line); + let child: TextLine = undefined; + for (child of children) { + orgCascadeCheckbox(edit, orgFindCookie(checkboxRegex, child), child, state); + } + // If there is a summary cookie on this line, update it to either [0/0] or [total/total] depending on target state. + let total = state ? children.length : 0; + let summary = orgFindCookie(summaryRegex, line); + if (summary) { + edit.replace(summary, total.toString() + '/' + total.toString()); + } + // If there is a percent cookie on this line, update it to either [0%] or [100%] depending on target state. + let percent = orgFindCookie(percentRegex, line); + if (percent) { + total = state == 'x' ? 100 : 0; + edit.replace(percent, total.toString()); + } + return orgTriStateToDelta(state); +} + +// Find parent item by walking lines up to the start of the file looking for a smaller indentation. +// Does not ignore blank lines (indentation 0). +function orgFindParent(editor: TextEditor, line: TextLine): TextLine | undefined { + let doc = editor.document; + let lnum = line.lineNumber; + let indent = orgGetIndent(line); + let parent = undefined; + let pindent = indent; + while (pindent >= indent) { + lnum--; + if (lnum < 0) { + return undefined; + } + + parent = doc.lineAt(lnum); + pindent = orgGetIndent(parent); + } + return parent; +} + +// Update checkbox and summary on this line. Adjust checked items count with an additional offset. +// That accounts for a checkbox that has just been toggled but text in the editor has not been updated yet. +function orgUpdateParent(editor: TextEditor, edit: TextEditorEdit, line: TextLine, adjust: number) { + if (!line) { + return; + } + let children = orgFindChildren(editor, line); + let total = children.length; + let checked = adjust; + let chk: Range = undefined; + let doc = editor.document; + for (let child of children) { + chk = orgFindCookie(checkboxRegex, child); + if (doc.getText(chk).toLowerCase() == 'x') { + checked++; + } + } + let summary = orgFindCookie(summaryRegex, line); + if (summary) { + edit.replace(summary, checked.toString() + '/' + total.toString()); + } + let percent = orgFindCookie(percentRegex, line); + if (percent) { + edit.replace(percent, total == 0 ? '0' : (checked * 100 / total).toString()); + } + // If there is a checkbox on this line, update it depending on (checked == total). + chk = orgFindCookie(checkboxRegex, line); + // Prevent propagation downstream by passing line = undefined. + let delta = orgCascadeCheckbox(edit, chk, undefined, orgGetTriState(checked, total)); + // Recursively update parent nodes + let parent = orgFindParent(editor, line); + // Since the updates as a result of toggle have not happened yet in the editor, + // counting checked children is going to use old value of the current checkbox. + // Hence the delta adjustment. + if (parent) { + orgUpdateParent(editor, edit, parent, delta); + } +} + +// Find parent item by walking lines up to the start of the file looking for a smaller indentation. +// Does not ignore blank lines (indentation 0). +function orgFindChildren(editor: TextEditor, line: TextLine): TextLine[] { + let children: TextLine[] = []; + let lnum = line.lineNumber; + let doc = editor.document; + let lmax = doc.lineCount - 1; + let indent = orgGetIndent(line); + let child: TextLine = undefined; + let cindent = indent; + let next_indent = -1; + while (lnum < lmax) { + lnum++; + child = doc.lineAt(lnum); + cindent = orgGetIndent(child); + if (cindent <= indent) { + break; + } + if (next_indent < 0) { + next_indent = cindent; + } + if (cindent <= next_indent) { + children.push(child); + } + } + return children; +} diff --git a/src/extension.ts b/src/extension.ts index 5f13fa4..b186a5a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,6 +9,7 @@ import { decrementContext } from './modify-context'; import * as PascuaneseFunctions from './pascuanese-functions'; +import * as Checkboxes from './checkboxes'; import { OrgFoldingProvider } from './org-folding-provider'; export function activate(context: vscode.ExtensionContext) { @@ -35,6 +36,8 @@ export function activate(context: vscode.ExtensionContext) { const verboseCmd = vscode.commands.registerTextEditorCommand('org.verbose', MarkupFunctions.verbose); const literalCmd = vscode.commands.registerTextEditorCommand('org.literal', MarkupFunctions.literal); const butterflyCmd = vscode.commands.registerTextEditorCommand('org.butterfly', PascuaneseFunctions.butterfly); + const updateSummaryCmd = vscode.commands.registerTextEditorCommand('org.updateSummary', Checkboxes.orgUpdateSummary); + const toggleCheckboxCmd = vscode.commands.registerTextEditorCommand('org.toggleCheckbox', Checkboxes.orgToggleCheckbox); context.subscriptions.push(insertHeadingRespectContentCmd); context.subscriptions.push(insertChildCmd); @@ -56,6 +59,8 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(verboseCmd); context.subscriptions.push(literalCmd); context.subscriptions.push(butterflyCmd); + context.subscriptions.push(updateSummaryCmd); + context.subscriptions.push(toggleCheckboxCmd); vscode.languages.registerFoldingRangeProvider('org', new OrgFoldingProvider()); } diff --git a/test/checkboxes.test.ts b/test/checkboxes.test.ts new file mode 100644 index 0000000..f9dec07 --- /dev/null +++ b/test/checkboxes.test.ts @@ -0,0 +1,171 @@ +'use strict'; + +import 'mocha'; +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as checkboxes from '../src/checkboxes'; + +const content2level: string = +`* TODO Organize party [/] + - [-] call people [/] + - [ ] Peter + - [X] Sarah + - [ ] Sam + - [X] order food + - [ ] think about what music to play + - [X] talk to the neighbors + +* TODO Implement tests [%] [/] + - [ ] updates summary cookie + - [ ] updates percent cookie + - [ ] toggling child checkbox [%] + - [ ] updates parent summary/percent cookie + - [ ] sets parent to on if all children are on + - [ ] sets parent to off when all children are off + - [ ] sets parent to undetermined when some children are on and some are off + - [-] toggling parent checkbox [/] + - [ ] updates summary/percent cookie + - [x] sets all children to on when parent is on + - [ ] sets all children to off when parent is off +`; + +const content3level: string = +`* TODO Organize party [/] + - [-] order food [%] + - [ ] appetizers + - [-] salads [/] + - [ ] ceasar salad + - [x] coleslaw + - [ ] avocado salad + - [ ] dessert [/] + - [ ] cake + - [ ] cookies + - [ ] icecream + - [x] order drinks +`; + +function closeAllEditors(): Thenable { + return vscode.commands.executeCommand('workbench.action.closeAllEditors'); +} + +function moveAndSelect(editor: vscode.TextEditor, line: number, col: number, lineTo?: number, colTo?: number) { + lineTo = lineTo ? lineTo : line; + colTo = colTo ? colTo : col; + editor.selection = new vscode.Selection(line, col, lineTo, colTo); +} + +function loadContent(content: string): Thenable { + return vscode.workspace.openTextDocument({ language: 'org', content: content }); +} + +suite('Checkboxes', () => { + teardown(closeAllEditors); + + test('Can convert tabs to spaces', done => { + let cases = [ + " \t \t ", + "\t\t", + "\t \t", + " \t \t " + ]; + let expected4 = [ + 9, + 8, + 8, + 14 + ]; + let expected8 = [ + 17, + 16, + 16, + 18 + ]; + + for (let i: number = 0; i < cases.length; i++) { + assert.equal(checkboxes.orgTabsToSpaces(cases[i], 4), expected4[i]); + assert.equal(checkboxes.orgTabsToSpaces(cases[i], 8), expected8[i]); + } + done(); + }); + test('Can update summary', async () => { + let expected = '* TODO Implement tests [0%] [0/4]'; + let document = await loadContent(content2level); + let editor = await vscode.window.showTextDocument(document); + moveAndSelect(editor, 9, 5); + await vscode.commands.executeCommand('org.updateSummary'); + let actual = document.lineAt(9).text; + assert.equal(actual, expected); + }); + test('Ticking checkbox updates parent', async () => { + let expected = ' - [-] toggling child checkbox [25%]'; + let document = await loadContent(content2level); + let editor = await vscode.window.showTextDocument(document); + moveAndSelect(editor, 14, 14); + await vscode.commands.executeCommand('org.toggleCheckbox'); + let actual = document.lineAt(12).text; + assert.equal(actual, expected); + }); + test('Ticking parent checkbox ticks all children', async () => { + const expected = [ + ' - [x] updates parent summary/percent cookie', + ' - [x] sets parent to on if all children are on', + ' - [x] sets parent to off when all children are off', + ' - [x] sets parent to undetermined when some children are on and some are off' + ]; + const lineNo = [13, 14, 15, 16]; + let document = await loadContent(content2level); + let editor = await vscode.window.showTextDocument(document); + moveAndSelect(editor, 12, 14); + await vscode.commands.executeCommand('org.toggleCheckbox'); + for (let i: number = 0; i < expected.length; i++) { + let actual = document.lineAt(lineNo[i]).text; + assert.equal(actual, expected[i]); + } + }); + test('Unticking last ticked child clears parent checkbox', async () => { + let expected = ' - [ ] call people [0/3]'; + let document = await loadContent(content2level); + let editor = await vscode.window.showTextDocument(document); + moveAndSelect(editor, 3, 14); + await vscode.commands.executeCommand('org.toggleCheckbox'); + let actual = document.lineAt(1).text; + assert.equal(actual, expected); + }); + test('Ticking all children ticks parent checkbox', async () => { + let expected = ' - [x] toggling parent checkbox [3/3]'; + let document = await loadContent(content2level); + let editor = await vscode.window.showTextDocument(document); + moveAndSelect(editor, 18, 5); + await vscode.commands.executeCommand('org.toggleCheckbox'); + moveAndSelect(editor, 20, 5); + await vscode.commands.executeCommand('org.toggleCheckbox'); + let actual = document.lineAt(17).text; + assert.equal(actual, expected); + }); + test('Ticking parent checkbox ticks all children (3 level)', async () => { + const expected = [ + ' - [x] salads [3/3]', + ' - [x] cookies' + ]; + const lineNo = [3, 9]; + let document = await loadContent(content3level); + let editor = await vscode.window.showTextDocument(document); + moveAndSelect(editor, 1, 7); + await vscode.commands.executeCommand('org.toggleCheckbox'); + for (let i: number = 0; i < expected.length; i++) { + let actual = document.lineAt(lineNo[i]).text; + assert.equal(actual, expected[i]); + } + }); + test('Unticking child checkbox makes parent untetermined (3 level)', async () => { + const expected = ' - [-] dessert [2/3]'; + let document = await loadContent(content3level); + let editor = await vscode.window.showTextDocument(document); + moveAndSelect(editor, 1, 7); + await vscode.commands.executeCommand('org.toggleCheckbox'); + moveAndSelect(editor, 8, 7); + await vscode.commands.executeCommand('org.toggleCheckbox'); + let actual = document.lineAt(7).text; + assert.equal(actual, expected); + }); +});