From fdad031f12b40f48072d0b9d5939c3e4ed9b84a3 Mon Sep 17 00:00:00 2001 From: Kuba Orlik Date: Fri, 1 Jan 2021 17:04:11 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + .prettierrc | 14 ++++ package-lock.json | 19 +++++ package.json | 19 +++++ src/dialog.ts | 183 ++++++++++++++++++++++++++++++++++++++++++ src/entry.ts | 22 +++++ src/file-selection.ts | 20 +++++ src/index.ts | 8 ++ src/info.ts | 18 +++++ src/list.ts | 103 ++++++++++++++++++++++++ src/simple-spawn.ts | 14 ++++ src/sleep.ts | 8 ++ tsconfig.json | 20 +++++ 13 files changed, 451 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/dialog.ts create mode 100644 src/entry.ts create mode 100644 src/file-selection.ts create mode 100644 src/index.ts create mode 100644 src/info.ts create mode 100644 src/list.ts create mode 100644 src/simple-spawn.ts create mode 100644 src/sleep.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1af6435 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/@types/ +/lib/ +/node_modules/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..886619a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + useTabs: true, + tabWidth: 4, + trailingComma: "es5", + "overrides": [ + { + "files": "*.yml", + "options": { + "tabWidth": 2, + "useTabs": false + } + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4c2f26c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "node-zenity", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/node": { + "version": "14.14.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.17.tgz", + "integrity": "sha512-G0lD1/7qD60TJ/mZmhog76k7NcpLWkPVGgzkRy3CTlnFu4LUQh5v2Wa661z6vnXmD8EQrnALUyf0VRtrACYztw==" + }, + "typescript": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", + "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d55730 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "node-zenity", + "description": "Node zenity bindings with typescript support", + "version": "1.0.0", + "author": "Kuba Orlik ", + "license": "MIT", + "main": "./lib/index.js", + "types": "./@types/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "npm run build -- --watch" + }, + "dependencies": { + "@types/node": "^14.14.17" + }, + "devDependencies": { + "typescript": "^4.1.3" + } +} diff --git a/src/dialog.ts b/src/dialog.ts new file mode 100644 index 0000000..a702208 --- /dev/null +++ b/src/dialog.ts @@ -0,0 +1,183 @@ +import { ChildProcessWithoutNullStreams, spawn } from "child_process"; +import { EventEmitter } from "events"; +import { promisify } from "util"; +import simpleSpawn from "./simple-spawn"; +import sleep from "./sleep"; + +export type DialogOptions = Record< + string, + true | string | undefined | string[] +>; // array value means use this flag this many times + +let last_position: { x: number; y: number } | null = null; + +async function get_geometry() { + const result = (await simpleSpawn("bash", [ + "-c", + 'wmctrl -l -x node-zenity -G | grep node-zenity | awk \'{print $3 " " $4 " " $5 " " $6}\'', + ])) as string; + const [x, y, width, height] = result + .split("\n")[0] + .split(" ") + .map((s) => parseInt(s)); + return { x, y, width, height }; +} + +async function set_geometry({ + x, + y, + width, + height, +}: { + x: number; + y: number; + width?: number; + height?: number; +}) { + if (width === undefined) { + width = -1; + } + if (height === undefined) { + height = -1; + } + await simpleSpawn("wmctrl", [ + "-x", + "-a", + "node-zenity", + "-e", + `0,${Math.round(x)},${Math.round(y)},${Math.round(width)},${Math.round( + height + )}`, + ]); +} + +type DialogResponse = { + code: number; + output: string; + error: string; +}; + +export default class Dialog extends EventEmitter { + shown = false; + process: ChildProcessWithoutNullStreams; + output: string = ""; + error: string = ""; + constructor( + public type_option: string, + public args: DialogOptions, + public positional_args: string[] = [] + ) { + super(); + } + + private prepareArguments(): string[] { + const ret = [`--${this.type_option}`]; + for (const arg in this.args) { + const value = this.args[arg]; + if (typeof value === "boolean") { + ret.push(`--${arg}`); + } else if (typeof value === "string") { + ret.push(`--${arg}=${value}`); + } else if (Array.isArray(value)) { + value.forEach((element) => ret.push(`--${arg}=${element}`)); + } else if (value === undefined) { + //void + } else { + console.log("unknown value:", value); + throw new Error("uknown arg value type"); + } + } + return ret; + } + + async show(): Promise { + const args: string[] = [ + "--class=node-zenity", + ...this.prepareArguments(), + ...this.positional_args, + ]; + console.log("spawning process!", args.join(" ")); + this.process = spawn("zenity", args); + let position_listener_interval = setInterval(async () => { + const { x, y, width, height } = await get_geometry(); + console.log( + { x, y, width, height }, + { x: Math.round(x + width / 2), y: Math.round(y + height / 2) } + ); + last_position = { + x: Math.round(x + width / 2), + y: Math.round(y + height / 2), + }; + }, 2000); + + let spawned = false; + return new Promise((resolve, reject) => { + this.process.on("spawn", async () => { + spawned = true; + resolve(); + let tries = 0; + let succeded = false; + while (!succeded && tries < 15) { + try { + tries++; + await sleep(50); + await simpleSpawn("wmctrl", [ + "-x", + "-r", + "node-zenity", + "-b", + "add,above", + ]); // to pin to top + await simpleSpawn("wmctrl", [ + "-x", + "-a", + "node-zenity", + ]); // to activate it + if (last_position) { + const { width, height } = await get_geometry(); + await set_geometry({ + x: last_position.x - width / 2 - 11, + y: last_position.y - height / 2 - 82, + }); // to put it where it last was + } + succeded = true; + console.log("WMCTRL SUCCEEDED!"); + } catch (e) { + console.error(e); + continue; + } + } + }); + this.process.stdout.on("data", (chunk) => { + this.output += chunk; + }); + this.process.stderr.on("data", (chunk) => { + this.error += chunk; + }); + this.process.on("close", (code) => { + clearInterval(position_listener_interval); + console.log("process closed. output:", this.output); + if (code !== 0) { + if (!spawned) { + reject({ code, message: this.error }); + } else { + this.emit("error", code); + } + } else { + this.emit("done"); + } + }); + }); + } + + getAnswer(): Promise { + return new Promise((resolve, reject) => { + this.on("done", () => + resolve({ code: 0, output: this.output, error: this.error }) + ); + this.on("error", (code) => { + resolve({ code, output: this.output, error: this.error }); + }); + }); + } +} diff --git a/src/entry.ts b/src/entry.ts new file mode 100644 index 0000000..2086008 --- /dev/null +++ b/src/entry.ts @@ -0,0 +1,22 @@ +import Dialog from "./dialog"; + +export default class Entry { + static async show( + options?: Partial<{ + text: string; + "entry-text": string; + "hide-text": true; + title: string; + width: number; + height: number; + }> + ): Promise { + const dialog = new Dialog("entry", { + ...options, + width: ((options?.width as unknown) as string)?.toString(), + height: ((options?.height as unknown) as string)?.toString(), + }); + await dialog.show(); + return dialog.output.split("\n")[0]; + } +} diff --git a/src/file-selection.ts b/src/file-selection.ts new file mode 100644 index 0000000..35f7055 --- /dev/null +++ b/src/file-selection.ts @@ -0,0 +1,20 @@ +import Dialog from "./dialog"; + +export default class FileSelection { + static async getFile( + options: Partial<{ + multiple?: true; + directory?: true; + save?: true; + separator: string; + "confirm-overwrite"?: true; + "file-filter": string; + title: string; + }> + ): Promise { + const dialog = new Dialog("file-selection", options); + await dialog.show(); + await dialog.getAnswer(); + return dialog.output.split("\n"); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..227fea0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +export { default as FileSelection } from "./file-selection"; +export { default as Info } from "./info"; +export { + default as List, + responseIsCustomButton, + ItemsSelectedResponse, +} from "./list"; +export { default as Entry } from "./entry"; diff --git a/src/info.ts b/src/info.ts new file mode 100644 index 0000000..6212fc0 --- /dev/null +++ b/src/info.ts @@ -0,0 +1,18 @@ +import Dialog from "./dialog"; + +export default class Info { + static async show( + text: string, + options?: Partial<{ + "icon-name": string; + "no-wrap": true; + "no-markup": true; + ellipsize: true; + title: string; + }> + ): Promise { + const dialog = new Dialog("info", { ...options, text }); + await dialog.show(); + await dialog.getAnswer(); + } +} diff --git a/src/list.ts b/src/list.ts new file mode 100644 index 0000000..e12a22c --- /dev/null +++ b/src/list.ts @@ -0,0 +1,103 @@ +import Dialog, { DialogOptions } from "./dialog"; +import sleep from "./sleep"; + +export type ListOptions = { + mode: "checklist" | "radiolist" | "imagelist" | "list"; + columns: string[]; +} & Partial<{ + title: string; + text: string; + rows: string[][]; + multiple: true; + separator: string; + editable: true; + "print-column": number; + extraButtons: string[]; + height: number; + width: number; +}>; + +type CustomButtonPressed = { _type: "custom_button"; button_pressed: string }; +export type ItemsSelectedResponse = { + _type: "items_selected"; + items_selected: string[]; +}; + +export type ListResponse = CustomButtonPressed | ItemsSelectedResponse; + +export function responseIsCustomButton( + response: ListResponse +): response is CustomButtonPressed { + return response._type === "custom_button"; +} + +export default class List { + private dialog: Dialog; + constructor(private options: ListOptions) {} + + async show(): Promise { + const dialog = new Dialog( + "list", + List.prepareOptions(this.options), + List.preparePositionalArguments(this.options) + ); + this.dialog = dialog; + await dialog.show(); + } + + async getAnswer(): Promise { + const response = await this.dialog.getAnswer(); + if (response.code === 1) { + return { + _type: "custom_button", + button_pressed: response.output.split("\n")[0], + }; + } + return { + _type: "items_selected", + items_selected: response.output + .split("\n")[0] + .split(this.options.separator || "|"), + }; + } + + static async showAndGetAnswer(options: ListOptions) { + const list = new List(options); + await list.show(); + return list.getAnswer(); + } + + static prepareOptions(options: ListOptions): DialogOptions { + const ret: DialogOptions = {}; + if (options.mode !== "list") { + ret[options.mode] = true; + } + ret.title = options.title; + ret.text = options.text; + if (options.columns) { + ret.column = options.columns; + } + ret.multiple = options.multiple; + ret.separator = options.separator; + ret.editable = options.editable; + ret["print-column"] = options["print-column"]?.toString(); + ret["extra-button"] = options.extraButtons; + ret.height = options.height?.toString(); + ret.width = options.width?.toString(); + return ret; + } + + static preparePositionalArguments(options: ListOptions): string[] { + const ret: string[] = []; + for (const row of options.rows || []) { + for (const element of row) { + ret.push(element); + } + } + return ret; + } + + hide() { + this.dialog.process.kill(); + } +} diff --git a/src/simple-spawn.ts b/src/simple-spawn.ts new file mode 100644 index 0000000..31e89d6 --- /dev/null +++ b/src/simple-spawn.ts @@ -0,0 +1,14 @@ +import { spawn } from "child_process"; + +export default function simpleSpawn(cmd: string, arg: string[]) { + const process = spawn(cmd, arg); + let output = ""; + let err = ""; + return new Promise((resolve, reject) => { + process.on("close", (code) => { + code === 0 ? resolve(output) : reject(err); + }); + process.stdout.on("data", (data) => (output += data.toString("utf-8"))); + process.stderr.on("data", (data) => (err += data.toString("utf-8"))); + }); +} diff --git a/src/sleep.ts b/src/sleep.ts new file mode 100644 index 0000000..8c5f341 --- /dev/null +++ b/src/sleep.ts @@ -0,0 +1,8 @@ +import { promisify } from "util"; + +const sleep = (timeout: number) => + promisify((time: number, cb: (...args: any[]) => void) => + setTimeout(cb, time) + )(timeout); + +export default sleep; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..294107e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "noImplicitAny": true, + "noImplicitThis": true, + "strictNullChecks": true, + "target": "ES2020", + "declaration": true, + "esModuleInterop": true, + "lib": ["es6", "esnext"], + "outDir": "lib", + "checkJs": true, + "allowJs": true, + "declarationDir": "@types", + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"] +}