Skip to content

Commit

Permalink
feat(keymap-upgrader): Upgrade encoder resolution
Browse files Browse the repository at this point in the history
Added an upgrade function to the keymap upgrader to replace the encoder
"resolution" property with "steps" and (if it is not already present)
"triggers-per-rotation".
  • Loading branch information
joelspadin committed Feb 4, 2024
1 parent be75da0 commit 3a4cf18
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 6 deletions.
101 changes: 101 additions & 0 deletions docs/src/keymap-upgrade/encoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { SyntaxNode, Tree } from "web-tree-sitter";

import {
getContainingDevicetreeNode,
getDevicetreeNodePath,
findDevicetreeProperty,
} from "./parser";
import { TextEdit } from "./textedit";

const ALPS_EC11_COMPATIBLE = '"alps,ec11"';
const DEFAULT_RESOLUTION = 4;
const TRIGGERS_PER_ROTATION = 20;
const TRIGGERS_PER_ROTATION_DT = `
&sensors {
// Change this to your encoder's number of detents.
// If you have multiple encoders with different detents, see
// https://zmk.dev/docs/config/encoders#keymap-sensor-config
triggers-per-rotation = <${TRIGGERS_PER_ROTATION}>;
};`;

export function upgradeEncoderResolution(tree: Tree) {
const edits: TextEdit[] = [];

const resolutionProps = findEncoderResolution(tree);
edits.push(...resolutionProps.flatMap(upgradeResolutionProperty));

if (resolutionProps.length > 0) {
edits.push(...addTriggersPerRotation(tree));
}

return edits;
}

function findEncoderResolution(tree: Tree): SyntaxNode[] {
const props = findDevicetreeProperty(tree.rootNode, "resolution", {
recursive: true,
});

return props.filter((prop) => {
const node = getContainingDevicetreeNode(prop);
return node && isEncoderNode(node);
});
}

function isEncoderNode(node: SyntaxNode) {
// If a compatible property is set, then we know for sure if this is an encoder.
const compatible = findDevicetreeProperty(node, "compatible");
if (compatible) {
return compatible.childForFieldName("value")?.text === ALPS_EC11_COMPATIBLE;
}

// Compatible properties rarely appear in a keymap though, so just guess based
// on the node path/reference otherwise.
return getDevicetreeNodePath(node).toLowerCase().includes("encoder");
}

function upgradeResolutionProperty(prop: SyntaxNode): TextEdit[] {
const name = prop.childForFieldName("name");
const value = prop.childForFieldName("value");

if (!name || !value) {
return [];
}

// Try to set the new steps to be triggers-per-rotation * resolution, but fall
// back to a default if the value is something more complex than a single int.
const resolution = value.text.trim().replaceAll(/^<|>$/g, "");
const steps =
(parseInt(resolution) || DEFAULT_RESOLUTION) * TRIGGERS_PER_ROTATION;

const hint = `/* Change this to your encoder's number of detents times ${resolution} */`;

return [
TextEdit.fromNode(name, "steps"),
TextEdit.fromNode(value, `<${steps}> ${hint}`),
];
}

function addTriggersPerRotation(tree: Tree): TextEdit[] {
// The keymap might already contain "triggers-per-rotation" for example if the
// user already upgraded some but not all "resolution" properties. Don't add
// another one if it already exists.
if (keymapHasTriggersPerRotation(tree)) {
return [];
}

// Inserting a new property into an existing node while keeping the code
// readable in all cases is hard, so just append a new &sensors node to the
// end of the keymap.
const end = tree.rootNode.endIndex;
return [new TextEdit(end, end, TRIGGERS_PER_ROTATION_DT)];
}

function keymapHasTriggersPerRotation(tree: Tree) {
const props = findDevicetreeProperty(tree.rootNode, "triggers-per-rotation", {
recursive: true,
});

return props.length > 0;
}
2 changes: 2 additions & 0 deletions docs/src/keymap-upgrade/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createParser } from "./parser";
import { applyEdits, Range } from "./textedit";

import { upgradeBehaviors } from "./behaviors";
import { upgradeEncoderResolution } from "./encoder";
import { upgradeHeaders } from "./headers";
import { upgradeKeycodes } from "./keycodes";
import { upgradeNodeNames } from "./nodes";
Expand All @@ -11,6 +12,7 @@ export { initParser } from "./parser";

const upgradeFunctions = [
upgradeBehaviors,
upgradeEncoderResolution,
upgradeHeaders,
upgradeKeycodes,
upgradeNodeNames,
Expand Down
74 changes: 68 additions & 6 deletions docs/src/keymap-upgrade/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ export function captureHasText(

/**
* Get a list of SyntaxNodes representing a devicetree node with the given path.
* (The same node may be listed multiple times within a file.)
* The same node may be listed multiple times within a file.
*
* @param path Absolute path to the node (must start with "/")
* This function does not evaluate which node a reference points to, so given
* a file containing "/ { foo: bar {}; }; &foo {};" searching for "&foo" will
* return the "&foo {}" node but not "foo: bar {}".
*
* @param path Path to the node to find. May be an absolute path such as
* "/foo/bar", a node reference such as "&foo", or a node reference followed by
* a relative path such as "&foo/bar".
*/
export function findDevicetreeNode(
tree: Parser.Tree,
Expand All @@ -81,6 +87,64 @@ export function findDevicetreeNode(
return result;
}

export interface FindPropertyOptions {
/** Search in children of the given node as well */
recursive?: boolean;
}

/**
* Find all instances of a devicetree property with the given name which are
* descendants of the given syntax node.
*
* @param node Any syntax node
*/
export function findDevicetreeProperty(
node: Parser.SyntaxNode,
name: string,
options: FindPropertyOptions & { recursive: true }
): Parser.SyntaxNode[];

/**
* Find a devicetree node's property with the given name, or null if it doesn't
* have one.
*
* @note If the node contains multiple instances of the same property, this
* returns the last once, since that is the one that will actually be applied.
*
* @param node A syntax node for a devicetree node
*/
export function findDevicetreeProperty(
node: Parser.SyntaxNode,
name: string,
options?: FindPropertyOptions
): Parser.SyntaxNode | null;

export function findDevicetreeProperty(
node: Parser.SyntaxNode,
name: string,
options?: FindPropertyOptions
): Parser.SyntaxNode[] | Parser.SyntaxNode | null {
const query = Devicetree.query(
`(property name: (identifier) @name (#eq? @name "${name}")) @prop`
);
const matches = query.matches(node);
const props = matches.map(({ captures }) => findCapture("prop", captures)!);

if (options?.recursive) {
return props;
}

// The query finds all descendants. Filter to just the properties that belong
// to the given devicetree node.
const childProps = props.filter((prop) =>
getContainingDevicetreeNode(prop)?.equals(node)
);

// Sort in descending order to select the last instance of the property.
childProps.sort((a, b) => b.startIndex - a.startIndex);
return childProps[0] ?? null;
}

export function getDevicetreeNodePath(node: Parser.SyntaxNode | null) {
const parts = getDevicetreeNodePathParts(node);

Expand All @@ -99,9 +163,7 @@ export function getDevicetreeNodePath(node: Parser.SyntaxNode | null) {
return parts[0] === "/" ? path.substring(1) : path;
}

export function getDevicetreeNodePathParts(
node: Parser.SyntaxNode | null
): string[] {
function getDevicetreeNodePathParts(node: Parser.SyntaxNode | null): string[] {
// There may be intermediate syntax nodes between devicetree nodes, such as
// #if blocks, so if we aren't currently on a "node" node, traverse up the
// tree until we find one.
Expand All @@ -115,7 +177,7 @@ export function getDevicetreeNodePathParts(
return [...getDevicetreeNodePathParts(dtnode.parent), name];
}

function getContainingDevicetreeNode(node: Parser.SyntaxNode | null) {
export function getContainingDevicetreeNode(node: Parser.SyntaxNode | null) {
while (node && node.type !== "node") {
node = node.parent;
}
Expand Down

0 comments on commit 3a4cf18

Please sign in to comment.