Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Checkboxes and summaries #129

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8e80df9
Readme Buttons (#1)
sipi Oct 5, 2018
2bd934a
Brought in checkboxes extension as a feature with initial test struct…
aboukirev Oct 9, 2018
0d76b67
Make summary update test pass.
aboukirev Oct 9, 2018
4618130
Added checkbox toggle test.
aboukirev Oct 9, 2018
36554f8
Make ticking checkbox test pass.
aboukirev Oct 9, 2018
c3937a6
Added test ticking parent checkbox ticks all children
aboukirev Oct 9, 2018
4e4e0a9
Added test for unticking last ticked child checkbox
aboukirev Oct 9, 2018
d17fbfd
Shortcicuit checking for parent nodes whenever possible
aboukirev Oct 9, 2018
b98f434
Added test for ticking all child checkboxes
aboukirev Oct 9, 2018
a394e28
Use undefined instead of null
aboukirev Oct 9, 2018
80aed16
Added tabs to spaces conversion with tests
aboukirev Oct 9, 2018
5143acd
One more null to undefined replacement
aboukirev Oct 9, 2018
de0030a
Refactor movement and selection in tests
aboukirev Oct 10, 2018
3e13344
Parameterized tab expansion function with optional tab size
aboukirev Oct 10, 2018
62f3111
Use string content instead of fixture in tests
aboukirev Oct 10, 2018
f379252
Refactor tests for correctness
aboukirev Oct 10, 2018
fb4a400
Added testing for expansion of 8-space tabs
aboukirev Oct 10, 2018
93cc884
Use async instead of promises in tests
aboukirev Oct 10, 2018
9245516
Added 3-level checkbox propagation test
aboukirev Oct 10, 2018
0289813
Fixes formatting, braces, comments, parameter names
aboukirev Oct 10, 2018
b3399a6
Replace null with undefined in comment
aboukirev Oct 10, 2018
8dd8880
Update for consistency
aboukirev Oct 10, 2018
dfb9106
Integrate checkboxes features with the extension
aboukirev Oct 10, 2018
5741818
Remove command registration from tests
aboukirev Oct 10, 2018
9a9c3b4
Added test for ticking parent and unticking one child
aboukirev Oct 11, 2018
c608c7e
Get document tab witdh from workspace configuration
aboukirev Oct 11, 2018
e606ced
Added key bindings for summary update and checkbox toggle
aboukirev Oct 11, 2018
729520b
Comply with camelCase standard for exported functions
aboukirev Oct 15, 2018
b8f9ea1
Updated keybinding and provided command contribution info for checkboxes
aboukirev Oct 31, 2018
3cb2c68
Merge branch 'develop' into feature/checkboxes
aboukirev Oct 31, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
[![Version](https://vsmarketplacebadge.apphb.com/version/tootone.org-mode.svg)](https://marketplace.visualstudio.com/items?itemName=tootone.org-mode)
[![Installs](https://vsmarketplacebadge.apphb.com/installs/tootone.org-mode.svg)](https://marketplace.visualstudio.com/items?itemName=tootone.org-mode)
[![Ratings](https://vsmarketplacebadge.apphb.com/rating/tootone.org-mode.svg)](https://marketplace.visualstudio.com/items?itemName=tootone.org-mode)

# VS Code Org Mode
[![Version](https://vsmarketplacebadge.apphb.com/version/tootone.org-mode.svg)](https://marketplace.visualstudio.com/items?itemName=tootone.org-mode)
[![Installs](https://vsmarketplacebadge.apphb.com/installs/tootone.org-mode.svg)](https://marketplace.visualstudio.com/items?itemName=tootone.org-mode)
Expand Down
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@
{
"command": "org.butterfly",
"title": "Org: Butterfly"
},
{
"command": "org.updateSummary",
"title": "Org: Update Summary",
"description": "Update ratio or percentage summary cookie based on number of ticked checkboxes."
},
{
"command": "org.toggleCheckbox",
"title": "Org: ToggleCheckbox",
"description": "Toggle checkbox status cascading the change up and down levels and update relevant summaries."
}
],
"languages": [
Expand Down Expand Up @@ -245,6 +255,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'"
}
]
},
Expand Down
203 changes: 203 additions & 0 deletions src/checkboxes.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link

@mrkeuz mrkeuz Feb 11, 2021

Choose a reason for hiding this comment

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

Is there a reason to store orgTabSize globally. It can be changed on fly. And potentially break traversing via trees. I think it potentially can have corner-cases here. If I understand your logic correctly (sorry if I'm wrong, just made fast review yet and not JS developer).


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;
}
5 changes: 5 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
decrementContext
} from './modify-context';
import * as PascuaneseFunctions from './pascuanese-functions';
import * as Checkboxes from './checkboxes';
import { OrgFoldingAndOutlineProvider } from './org-folding-and-outline-provider';

export function activate(context: vscode.ExtensionContext) {
Expand All @@ -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);
Expand All @@ -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);

const provider = new OrgFoldingAndOutlineProvider();
vscode.languages.registerFoldingRangeProvider('org', provider);
Expand Down