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

Hat snapshots #318

Merged
merged 55 commits into from
Dec 6, 2021
Merged
Show file tree
Hide file tree
Changes from 48 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
d3edc76
Initial attempt to at snapshots
pokey Nov 7, 2021
4ff14be
Switch to unnamed snapshots
pokey Nov 7, 2021
134685c
A kind of workign attempt
pokey Nov 7, 2021
3626b29
A kind of workign attempt
pokey Nov 7, 2021
79f5759
Use command server signal API
pokey Nov 8, 2021
e81cf2d
More error robustness
pokey Nov 8, 2021
a72d6b7
Working version
pokey Nov 8, 2021
cb60d9d
Have navigation map return snapshot
pokey Nov 8, 2021
9c813da
Attempt at big refactor
pokey Nov 9, 2021
87b5e1f
Fixes to get it running
pokey Nov 9, 2021
b063d7c
Rename
pokey Nov 9, 2021
4c71e5e
Bind function
pokey Nov 9, 2021
6b69e22
Remove unnecessary field
pokey Nov 9, 2021
56bb24f
Add docstring
pokey Nov 9, 2021
aeb1786
snapshot => prePhraseSnapshot
pokey Nov 9, 2021
55ce1c4
Clean yaml
pokey Nov 9, 2021
759aa3c
Do disposal in hat allocator
pokey Nov 9, 2021
0b1b9ab
navigationMap => hatTokenMap
pokey Nov 9, 2021
94bb3d8
Add tests
pokey Nov 9, 2021
f1c60ee
Make isTesting into function
pokey Nov 9, 2021
ac4df71
Set testing env var
pokey Nov 9, 2021
ea5760d
Try to change env var
pokey Nov 9, 2021
5e98e7f
Initial cleanup work for edits outside viewport
pokey Nov 10, 2021
1717dec
More cleanup
pokey Nov 10, 2021
e5c80ee
Fix yarn lockfile
pokey Nov 10, 2021
ddd3022
Fix yarn
pokey Nov 10, 2021
fc4dd8c
refactoring
pokey Nov 10, 2021
8a7ae4b
Fixes; add tests
pokey Nov 12, 2021
f2c2e11
File rename
pokey Nov 12, 2021
c6062c5
Finish merging
pokey Nov 27, 2021
6c85e37
Some cleanup
pokey Nov 29, 2021
95ac8f9
Create command runner class
pokey Nov 29, 2021
ef8624e
Working backwards compatible command runner
pokey Nov 29, 2021
e2440fc
More backward compatibility fixes
pokey Nov 30, 2021
e888bbf
Rejects stale snapshots
pokey Nov 30, 2021
a17d90d
Add link
pokey Nov 30, 2021
65bc4b9
A bunch of refactoring
pokey Dec 1, 2021
12694fb
Test fixes
pokey Dec 2, 2021
e5de4da
Revert change
pokey Dec 2, 2021
7358f3b
Improved canonicalization
pokey Dec 2, 2021
0f9cfd6
Add comment
pokey Dec 2, 2021
c01bd09
Fix ci
pokey Dec 2, 2021
f609c7c
Rollback decoration test change
pokey Dec 2, 2021
006681b
Attempt to fix decorations
pokey Dec 3, 2021
4b13d06
Normalize hat enablement during testing
pokey Dec 2, 2021
c90e810
Fix recorded tests
pokey Dec 3, 2021
66c8195
Fix tests
pokey Dec 3, 2021
f98ae49
Cleanup test recording
pokey Dec 3, 2021
fbd8531
Add docs
pokey Dec 4, 2021
3e75772
Fix creating nested recorded test directories
pokey Dec 5, 2021
30d60bc
Cleanup test case bulk transformer
pokey Dec 6, 2021
c577831
More transform script fixes
pokey Dec 6, 2021
4b56a1d
More transform stuff
pokey Dec 6, 2021
333e02a
Merge branch 'main' into hat-snapshots
pokey Dec 6, 2021
b74cf8d
Upgrade a test
pokey Dec 6, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"url": "https://github.com/pokey/cursorless-vscode.git"
},
"engines": {
"vscode": "^1.53.0"
"vscode": "^1.61.0"
},
"extensionKind": [
"workspace"
Expand Down Expand Up @@ -427,15 +427,15 @@
"watch": "tsc -watch -p ./",
"pretest": "yarn run compile && yarn run lint && yarn run esbuild",
"lint": "eslint src --ext ts",
"test": "node ./out/test/runTest.js"
"test": "env CURSORLESS_TEST=true node ./out/test/runTest.js"
Copy link
Member Author

Choose a reason for hiding this comment

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

For consistency with local unit testing, where this variable is set by launch.json

},
"devDependencies": {
"@types/glob": "^7.1.3",
"@types/js-yaml": "^4.0.2",
"@types/mocha": "^8.0.4",
"@types/node": "^16.11.3",
"@types/sinon": "^10.0.2",
"@types/vscode": "^1.53.0",
"@types/vscode": "^1.61.0",
"@typescript-eslint/eslint-plugin": "^4.9.0",
"@typescript-eslint/parser": "^4.9.0",
"esbuild": "^0.11.12",
Expand Down
62 changes: 54 additions & 8 deletions src/core/Decorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "./constants";
import { readFileSync } from "fs";
import FontMeasurements from "./FontMeasurements";
import { sortBy } from "lodash";
import { pull, sortBy } from "lodash";
import getHatThemeColors from "./getHatThemeColors";
import {
IndividualHatAdjustmentMap,
Expand All @@ -20,6 +20,7 @@ import {
DEFAULT_VERTICAL_OFFSET_EM,
} from "./shapeAdjustments";
import { Graph } from "../typings/Types";
import isTesting from "../testUtil/isTesting";

export type DecorationMap = {
[k in HatStyleName]?: vscode.TextEditorDecorationType;
Expand All @@ -30,24 +31,66 @@ export interface NamedDecoration {
decoration: vscode.TextEditorDecorationType;
}

type DecorationChangeListener = () => void;

export default class Decorations {
decorations!: NamedDecoration[];
decorationMap!: DecorationMap;
hatStyleMap!: Record<HatStyleName, HatStyle>;
private hatStyleMap!: Record<HatStyleName, HatStyle>;
hatStyleNames!: HatStyleName[];
private decorationChangeListeners: DecorationChangeListener[] = [];
private disposables: vscode.Disposable[] = [];

constructor(private graph: Graph) {
this.constructDecorations(graph.fontMeasurements);
graph.extensionContext.subscriptions.push(this);

this.recomputeDecorationStyles = this.recomputeDecorationStyles.bind(this);

this.disposables.push(
Copy link
Member Author

Choose a reason for hiding this comment

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

Note how graph components do their own registration now

Copy link
Member

Choose a reason for hiding this comment

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

Looks good really clean things up

vscode.commands.registerCommand(
"cursorless.recomputeDecorationStyles",
() => {
graph.fontMeasurements.clearCache();
this.recomputeDecorationStyles();
}
),

vscode.workspace.onDidChangeConfiguration(this.recomputeDecorationStyles)
);
}

async init() {
await this.graph.fontMeasurements.calculate();
this.constructDecorations(this.graph.fontMeasurements);
}

destroyDecorations() {
/**
* Register to be notified when decoration styles are updated, for example if
* the user enables a new hat style
* @param listener A function to be called when decoration styles are updated
* @returns A function that can be called to unsubscribe from notifications
*/
registerDecorationChangeListener(listener: DecorationChangeListener) {
this.decorationChangeListeners.push(listener);

return () => {
pull(this.decorationChangeListeners, listener);
};
}

private destroyDecorations() {
this.decorations.forEach(({ decoration }) => {
decoration.dispose();
});
}

constructDecorations(fontMeasurements: FontMeasurements) {
private async recomputeDecorationStyles() {
this.destroyDecorations();
await this.graph.fontMeasurements.calculate();
this.constructDecorations(this.graph.fontMeasurements);
}

private constructDecorations(fontMeasurements: FontMeasurements) {
this.constructHatStyleMap();

const userSizeAdjustment = vscode.workspace
Expand Down Expand Up @@ -125,6 +168,8 @@ export default class Decorations {
this.decorationMap = Object.fromEntries(
this.decorations.map(({ name, decoration }) => [name, decoration])
);

this.decorationChangeListeners.forEach((listener) => listener());
}

private constructHatStyleMap() {
Expand All @@ -146,9 +191,9 @@ export default class Decorations {
shapePenalties.default = 0;
colorPenalties.default = 0;

const activeHatColors = HAT_COLORS.filter(
(color) => colorEnablement[color]
);
const activeHatColors = isTesting()
? HAT_COLORS
: HAT_COLORS.filter((color) => colorEnablement[color]);
pokey marked this conversation as resolved.
Show resolved Hide resolved
const activeNonDefaultHatShapes = HAT_NON_DEFAULT_SHAPES.filter(
(shape) => shapeEnablement[shape]
);
Expand Down Expand Up @@ -279,5 +324,6 @@ export default class Decorations {

dispose() {
this.destroyDecorations();
this.disposables.forEach(({ dispose }) => dispose());
}
}
95 changes: 95 additions & 0 deletions src/core/HatAllocator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import * as vscode from "vscode";
import { addDecorationsToEditors } from "../util/addDecorationsToEditor";
import { DECORATION_DEBOUNCE_DELAY } from "../core/constants";
import { Graph } from "../typings/Types";
import { Disposable } from "vscode";
import { IndividualHatMap } from "./IndividualHatMap";

interface Context {
getActiveMap(): Promise<IndividualHatMap>;
}

export class HatAllocator {
private timeoutHandle: NodeJS.Timeout | null = null;
private isActive: boolean;
private disposables: Disposable[] = [];
private disposalFunctions: (() => void)[] = [];

constructor(private graph: Graph, private context: Context) {
graph.extensionContext.subscriptions.push(this);

this.isActive = vscode.workspace
.getConfiguration("cursorless")
.get<boolean>("showOnStart")!;

this.addDecorationsDebounced = this.addDecorationsDebounced.bind(this);
this.toggleDecorations = this.toggleDecorations.bind(this);
this.clearEditorDecorations = this.clearEditorDecorations.bind(this);

this.disposalFunctions.push(
graph.decorations.registerDecorationChangeListener(
this.addDecorationsDebounced
)
);

this.disposables.push(
vscode.commands.registerCommand(
"cursorless.toggleDecorations",
this.toggleDecorations
),

vscode.window.onDidChangeTextEditorVisibleRanges(
this.addDecorationsDebounced
),
vscode.window.onDidChangeActiveTextEditor(this.addDecorationsDebounced),
vscode.window.onDidChangeVisibleTextEditors(this.addDecorationsDebounced),
vscode.window.onDidChangeTextEditorSelection(
this.addDecorationsDebounced
),
vscode.workspace.onDidChangeTextDocument(this.addDecorationsDebounced)
);
}

private clearEditorDecorations(editor: vscode.TextEditor) {
this.graph.decorations.decorations.forEach(({ decoration }) => {
editor.setDecorations(decoration, []);
});
}

async addDecorations() {
const activeMap = await this.context.getActiveMap();

if (this.isActive) {
addDecorationsToEditors(activeMap, this.graph.decorations);
} else {
vscode.window.visibleTextEditors.forEach(this.clearEditorDecorations);
activeMap.clear();
}
}

addDecorationsDebounced() {
if (this.timeoutHandle != null) {
clearTimeout(this.timeoutHandle);
}

this.timeoutHandle = setTimeout(() => {
this.addDecorations();

this.timeoutHandle = null;
}, DECORATION_DEBOUNCE_DELAY);
}

private toggleDecorations() {
this.isActive = !this.isActive;
this.addDecorationsDebounced();
}

dispose() {
this.disposables.forEach(({ dispose }) => dispose());
this.disposalFunctions.forEach((dispose) => dispose());

if (this.timeoutHandle != null) {
clearTimeout(this.timeoutHandle);
}
}
}
154 changes: 154 additions & 0 deletions src/core/HatTokenMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { HatStyleName } from "./constants";
import { Graph } from "../typings/Types";
import { IndividualHatMap, ReadOnlyHatMap } from "./IndividualHatMap";
import { HatAllocator } from "./HatAllocator";
import { hrtime } from "process";
import { abs } from "../util/bigint";

/**
* Maximum age for the pre-phrase snapshot before we consider it to be stale
*/
const PRE_PHRASE_SNAPSHOT_MAX_AGE_NS = BigInt(6e10); // 60 seconds

/**
* Maps from (hatStyle, character) pairs to tokens
*/
export default class HatTokenMap {
/**
* This is the active map the changes every time we reallocate hats. It is
* liable to change in the middle of a phrase.
*/
private activeMap: IndividualHatMap;

/**
* This is a snapshot of the hat map that remains stable over the course of a
* phrase. Ranges will be updated to account for changes to the document, but a
* hat with the same color and shape will refer to the same logical range.
*/
private prePhraseMapSnapshot?: IndividualHatMap;
private prePhraseMapsSnapshotTimestamp: bigint | null = null;

private lastSignalVersion: string | null = null;
private hatAllocator: HatAllocator;

constructor(private graph: Graph) {
graph.extensionContext.subscriptions.push(this);
this.activeMap = new IndividualHatMap(graph);

this.getActiveMap = this.getActiveMap.bind(this);

this.hatAllocator = new HatAllocator(graph, {
getActiveMap: this.getActiveMap,
});
}

init() {
return this.hatAllocator.addDecorations();
}

addDecorations() {
return this.hatAllocator.addDecorations();
}

static getKey(hatStyle: HatStyleName, character: string) {
return `${hatStyle}.${character}`;
}

static splitKey(key: string) {
let [hatStyle, character] = key.split(".");
if (character.length === 0) {
// If the character is `.` then it will appear as a zero length string
// due to the way the split on `.` works
character = ".";
}
return { hatStyle: hatStyle as HatStyleName, character };
}

private async getActiveMap() {
// NB: We need to take a snapshot of the hat map before we make any
// modifications if it is the beginning of the phrase
await this.maybeTakePrePhraseSnapshot();

return this.activeMap;
}

/**
* Returns a transient, read-only hat map for use during the course of a
* single command.
*
* Please do not hold onto this copy beyond the lifetime of a single command,
* because it will get stale.
* @param usePrePhraseSnapshot Whether to use pre-phrase snapshot
* @returns A readable snapshot of the map
*/
async getReadableMap(usePrePhraseSnapshot: boolean): Promise<ReadOnlyHatMap> {
// NB: Take a snapshot before we return the map if it is the beginning of
// the phrase so all commands will get the same map over the course of the
// phrase
await this.maybeTakePrePhraseSnapshot();

if (usePrePhraseSnapshot) {
if (this.lastSignalVersion == null) {
console.error(
"Pre phrase snapshot requested but no signal was present; please upgrade command client"
);
return this.activeMap;
}

if (this.prePhraseMapSnapshot == null) {
console.error(
"Navigation map pre-phrase snapshot requested, but no snapshot has been taken"
);
return this.activeMap;
}

if (
abs(hrtime.bigint() - this.prePhraseMapsSnapshotTimestamp!) >
PRE_PHRASE_SNAPSHOT_MAX_AGE_NS
) {
console.error(
"Navigation map pre-phrase snapshot requested, but snapshot is more than a minute old"
);
return this.activeMap;
}

return this.prePhraseMapSnapshot;
}

return this.activeMap;
}

public dispose() {
this.activeMap.dispose();

if (this.prePhraseMapSnapshot != null) {
this.prePhraseMapSnapshot.dispose();
}
}

private async maybeTakePrePhraseSnapshot() {
const phraseStartSignal = this.graph.commandServerApi?.signals?.prePhrase;

if (phraseStartSignal != null) {
const newSignalVersion = await phraseStartSignal.getVersion();

if (newSignalVersion !== this.lastSignalVersion) {
console.debug("taking snapshot");
this.lastSignalVersion = newSignalVersion;

if (newSignalVersion != null) {
this.takePrePhraseSnapshot();
}
}
}
}

private takePrePhraseSnapshot() {
if (this.prePhraseMapSnapshot != null) {
this.prePhraseMapSnapshot.dispose();
}

this.prePhraseMapSnapshot = this.activeMap.clone();
this.prePhraseMapsSnapshotTimestamp = hrtime.bigint();
}
}
Loading