Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 0 additions & 15 deletions .eslintrc.json

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules/
dist/
yarn-error.log
*.tsbuildinfo
3 changes: 2 additions & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 120
"printWidth": 80,
"arrowParens": "avoid"
}
21 changes: 21 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import eslint from '@eslint/js';
import tsEslint from 'typescript-eslint';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';

export default tsEslint.config(
eslint.configs.recommended,
...tsEslint.configs.recommendedTypeChecked,
{
languageOptions: {
parserOptions: {
project: ['tsconfig.build.json', 'tsconfig.test.json'],
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-unused-vars': ['warn', {argsIgnorePattern: '^_'}],
},
files: ['**/*.ts'],
},
eslintPluginPrettierRecommended
);
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { pathsToModuleNameMapper } from 'ts-jest';

/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: pathsToModuleNameMapper({}, { useESM: true }),
};
26 changes: 15 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
"description": "Create nested menus using dmenu and json files.",
"license": "MIT",
"scripts": {
"build": "tsc",
"prepublishOnly": "tsc"
"build": "tsc --build",
"test": "jest",
"prepublishOnly": "tsc --build"
},
"type": "module",
"keywords": [
"dmenu",
"json",
Expand All @@ -21,16 +23,18 @@
},
"bin": "dist/index.js",
"devDependencies": {
"@types/node": "^12.12.18",
"@typescript-eslint/eslint-plugin": "^2.11.0",
"@typescript-eslint/parser": "^2.11.0",
"eslint": "^6.7.2",
"eslint-config-prettier": "^6.7.0",
"eslint-plugin-prettier": "^3.1.2",
"prettier": "^1.19.1",
"typescript": "^3.7.3"
"@eslint/js": "^9.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.7",
"eslint": "^9.0.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"typescript": "^5.4.5",
"typescript-eslint": "^7.7.0"
},
"dependencies": {
"assert-never": "^1.2.0"
"assert-never": "^1.2.1"
}
}
4 changes: 2 additions & 2 deletions src/menu.ts → src/dmenu.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { exec } from 'child_process';

export class Menu {
export class DMenu {
public async choose(items: string[]): Promise<string> {
const stdout = await new Promise<string>((resolve, reject) => {
const child = exec('dmenu -l 15 ', (err, stdout) => {
Expand All @@ -10,7 +10,7 @@ export class Menu {
resolve(stdout);
}
});
if (!child.stdin) throw new Error();
if (!child.stdin) throw new Error('No stdin on dmenu process');
child.stdin.write(items.join('\n'));
child.stdin.end();
});
Expand Down
65 changes: 65 additions & 0 deletions src/hierarchical-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { HierarchicalMenu } from './hierarchical-menu.js';

describe('hierarchical-menu', () => {
const abc = {
type: 'node',
children: {
a: {
type: 'leaf',
value: 'a',
},
b: {
type: 'node',
children: {
c: {
type: 'leaf',
value: 'c',
},
},
},
},
} as const;

it('can choose a leaf node at the root', async () => {
const menu = {
choose: jest.fn().mockReturnValueOnce('a'),
};
const hm = new HierarchicalMenu(abc, menu);
expect(await hm.navigate()).toBe('a');
});

it('can choose a leaf node at a deeper level', async () => {
const menu = {
choose: jest.fn().mockReturnValueOnce('b').mockReturnValueOnce('c'),
};
const hm = new HierarchicalMenu(abc, menu);
expect(await hm.navigate()).toBe('c');
});

it('presents the back option when not at root level', async () => {
const menu = {
choose: jest
.fn()
.mockReturnValueOnce('b')
.mockReturnValueOnce('(back)')
.mockReturnValueOnce('a'),
};
const hm = new HierarchicalMenu(abc, menu);
expect(await hm.navigate()).toBe('a');
expect(menu.choose).toHaveBeenNthCalledWith(1, ['a', 'b']);
expect(menu.choose).toHaveBeenNthCalledWith(2, ['(back)', 'c']);
expect(menu.choose).toHaveBeenCalledTimes(3);
});

it('goes back when selecting the (back) option', async () => {
const menu = {
choose: jest
.fn()
.mockReturnValueOnce('b')
.mockReturnValueOnce('(back)')
.mockReturnValueOnce('a'),
};
const hm = new HierarchicalMenu(abc, menu);
expect(await hm.navigate()).toBe('a');
});
});
45 changes: 45 additions & 0 deletions src/hierarchical-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { DMenu } from './dmenu.js';
import { KeyValueTree } from './key-value-tree.js';

export class HierarchicalMenu {
private stateStack: KeyValueTree[];
private readonly backString = '(back)';

constructor(
tree: KeyValueTree,
private menu: DMenu,
) {
this.stateStack = [tree];
}

/** Opens a dialog and returns the chosen entry or undefined if back was chosen. */
private async choose(from: string[]): Promise<string | undefined> {
if (this.stateStack.length > 1) {
const value = await this.menu.choose([this.backString, ...from]);
if (value === this.backString) {
return undefined;
} else {
return value;
}
} else {
//p No back option if at the root
return this.menu.choose(from);
}
}

public async navigate(): Promise<string> {
let currentState = this.stateStack[this.stateStack.length - 1];
while (currentState.type !== 'leaf') {
const chosenKey = await this.choose(Object.keys(currentState.children));
if (chosenKey === undefined) {
// Back was chosen
this.stateStack.pop();
} else {
const value = currentState.children[chosenKey];
this.stateStack.push(value);
}
currentState = this.stateStack[this.stateStack.length - 1];
}
return currentState.value;
}
}
23 changes: 18 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
#!/usr/bin/env node
import { JSONTree } from './jsonTree';
import { DMenu } from './dmenu.js';
import { HierarchicalMenu } from './hierarchical-menu.js';
import { JSON } from './json.js';
import { createKeyValueTree } from './key-value-tree.js';

let data = '';
process.stdin.on('data', chunk => {
data += chunk;
data += chunk.toString();
});
process.stdin.on('end', () => {
const json = JSON.parse(data);
const tree = new JSONTree(json);
tree.navigate().then(x => console.log(x));
const json = JSON.parse(data) as JSON;
const tree = createKeyValueTree(json);
const menu = new DMenu();
const hMenu = new HierarchicalMenu(tree, menu);
hMenu
.navigate()
.then(output => {
console.log(output);
})
.catch(error => {
console.error(error);
process.exit(1);
});
});
46 changes: 46 additions & 0 deletions src/json-visitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { assertNever } from 'assert-never';
import { JSON, JSONObject } from './json.js';

export default abstract class JsonVisitor<T> {
visit(json: JSON): T {
// find the type of the tree, and call the appropriate method
if (json === null) {
return this.visitNull();
} else if (typeof json === 'string') {
return this.visitString(json);
} else if (typeof json === 'number') {
return this.visitNumber(json);
} else if (typeof json === 'boolean') {
return this.visitBoolean(json);
} else if (Array.isArray(json)) {
return this.visitArray(json);
} else if (typeof json === 'object') {
// This check will be true as long as the definition of JSON does not change.
// In case it changes incorrectly, the assertion in the else will fail at compile time.
return this.visitObject(json);
} else {
return assertNever(json);
}
}

/** To be used inside of visitObject */
mapObject(json: JSONObject): Record<string, T> {
const result: Record<string, T> = {};
for (const key in json) {
result[key] = this.visit(json[key]);
}
return result;
}

/** To be used inside of visitArray */
mapArray(json: JSON[]): T[] {
return json.map(item => this.visit(item));
}

abstract visitObject(json: JSONObject): T;
abstract visitArray(json: JSON[]): T;
abstract visitString(json: string): T;
abstract visitNumber(json: number): T;
abstract visitBoolean(json: boolean): T;
abstract visitNull(): T;
}
1 change: 0 additions & 1 deletion src/json.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// source: https://github.com/Microsoft/TypeScript/issues/15225#issuecomment-294718709
export type JSONObject = { [key: string]: JSON };
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface JSONArray extends Array<JSON> {}
export type JSON = null | string | number | boolean | JSONArray | JSONObject;
Loading