UNPKG

workwatch

Version:

A Linux terminal program for honest worktime tracking and billing.

395 lines (380 loc) 14.7 kB
/** * This file is part of the WorkWatch, a Linux terminal program for honest * worktime tracking and billing. * * Copyright (C) 2020-2025 by Artur Rutkowski * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * WorkWatch is beeing developped and maintained by Artur (locust) Rutkwoski * <locust@mailbox.org> */ /** * This is a module responsible for full-screen program's interface. It * creates interactive and non-interactive sessions, dialogs and displays * tables. */ const process = require("node:process"); function Terminal() { // Variables storing data for most important getters of this object. let [ titleText, statusText, appText, appAreaPageNumber, interactiveInterface ] = ["", "", "", 1, false]; Object.defineProperty(this, "clearScreen", { value: () => { // It works only on true tty. It is used many times by interactive parts // so that they are required to run on tty. if (process.stdout.isTTY) { process.stdout.cursorTo(0, 0); process.stdout.clearScreenDown(); } else { const error = new Error("WorkWatch has to run on terminal."); error.code = "ERR_NO_TTY"; throw error; } }, writeable: false, enumerable: false, configurable: false }); // Gets and sets the title of "window" - the first line of the screen. // Works also in non-interactive mode. Object.defineProperty(this, "title", { get: () => { return titleText; }, set: (text) => { titleText = text; if (this.interactive) { if (titleText.length <= process.stdout.columns) { process.stdout.cursorTo(0, 0); process.stdout.clearLine(0); } else { const error = new Error("Title exceeds the terminal width."); error.code = "ERR_TTY_TITLE_TOO_LONG"; throw error; } } process.stdout.write(`${titleText}\n`); }, enumerable: true, configurable: false }); // Gets and sets the status bar - the last line of the screen. // Works only in interactive mode. Object.defineProperty(this, "status", { get: () => { return statusText; }, set: (text) => { statusText = text; if (this.interactive) { if (statusText.length <= process.stdout.columns) { process.stdout.cursorTo(0, process.stdout.rows); process.stdout.clearLine(0); process.stdout.write(`${statusText}`); } else { const error = new Error("Status exceeds the terminal width."); error.code = "ERR_TTY_STATUS_TOO_LONG"; throw error; } } }, enumerable: true, configurable: false }); // Gets and sets the content for the rest of the screen. // Works also in non-interactive mode. Object.defineProperty(this, "appArea", { get: () => { return appText; }, set: (text) => { // Used for appending text on the screen. const previousAppTextLength = appText.length; // Devides text and wraps the whole words. text.split("\n").forEach(line => { appText += divideText(line) + "\n"; }); if (this.interactive) { // In interactive mode sets the content page so that below getter prints // the respective amount of text. this.pageNumber = 1; } else { // In non-interactive mode it appends text. process.stdout.write(appText.substring(previousAppTextLength, appText.length)); } }, enumerable: true, configurable: false }); // Devides text to fit the screen width with word wrapping. function divideText(text) { const maxLineSize = process.stdout.columns; if (maxLineSize >= text.length) { return text; } let fragment = text.substring(0, maxLineSize); let lineEnd = (text[maxLineSize] === " ")? maxLineSize : fragment.lastIndexOf(" "); return fragment.substring(0, lineEnd) + "\n" + divideText(text.replace(text.substring(0, lineEnd + 1), "")); } // Application area text pagination getter/setter. Object.defineProperty(this, "pageNumber", { get: () => { return appAreaPageNumber; }, set: (number) => { if (this.interactive) { appAreaPageNumber = (number < 1)? 1 : (number > this.pages)? this.pages : number; const screenVerticalSize = process.stdout.rows - 2; let cursorVerticalPosition = 1; appText.split("\n").slice( (appAreaPageNumber - 1) * screenVerticalSize, (appAreaPageNumber * screenVerticalSize) ) .forEach(line => { process.stdout.cursorTo(0, cursorVerticalPosition); process.stdout.clearLine(0); process.stdout.write(line); cursorVerticalPosition = ( cursorVerticalPosition === screenVerticalSize )? 1 : cursorVerticalPosition + 1; }); // In interactive mode put cursor on the status line after printing. process.stdout.cursorTo(this.status.length, process.stdout.rows); } }, enumerable: true, configurable: false }); // Gets amount of application area pages used further for pagination. Object.defineProperty(this, "pages", { get: () => { return Math.ceil(appText.split("\n").length / (process.stdout.rows - 2)); }, enumerable: true, configurable: false }); // Turns the interactive mode on and off. It also gets its state. // Used for interactive non-dialogic sessions. Handles page scrolling // and listens for interrupt signal. Object.defineProperty(this, "interactive", { get: () => { return interactiveInterface; }, set: (isInteractive) => { interactiveInterface = isInteractive; if (interactiveInterface) { // Due to below line it has to be set before textual terminal properties. this.clearScreen(); process.stdin.setRawMode(true); process.stdin.on("data", (dataBuffer) => { const key = dataBuffer.toString("hex"); // Handle PageUp and PageDown keys. if (key === "1b5b357e") { this.pageNumber--; } else if (key === "1b5b367e") { this.pageNumber++; } else if (key === "03") { process.stdin.unref(); process.emit("SIGINT"); } }); } else { process.stdin.unref(); } }, enumerable: true, configurable: false }); // Creates a dialog screen. It doesn't use the interactive property because // it must handle open and predefined answers. // It accepts a callback which gets an answer. Predefined answers are // passed as an object with keys defining the answer and a value containing // an array with a single element defining a hotkey to the answer and // optional second element specifying if the answer is default. Object.defineProperty(this, "ask", { value: (title, question, answers, callback) => { this.clearScreen(); this.title = title; this.appArea = `${question}\n` + Object.keys(answers).map( answer => `${answers[answer][0].toUpperCase()}\t${answer} ${(answers[answer][1] === true)? "(default)" : ""}` ).join("\n"); // Used for sequence of dialogs. setImmediate(() => { process.stdin.ref(); process.stdin.setRawMode((Object.keys(answers).length > 0)? true : false); if (Object.keys(answers).length > 0) { process.stdin.on("data", (answer) => handleAnswer(answers, answer, callback)); } else { process.stdin.once("data", (answer) => handleAnswer(answers, answer, callback)); } }); }, writeable: false, enumerable: false, configurable: false }); // Handler for answers. No matter which type. function handleAnswer(answers, answer, callback) { if (Object.keys(answers).length > 0 ) { // Predefined. // Used to pass the answer to callback. let answerText = ""; // Handle keys: enter, ^C and answers hotkeys. if (answer.toString("hex") === "0d") { answerText = Object.keys(answers).find( // Find the default answer - when pressed enter. key => answers[key].length === 2 && answers[key][1] === true ); if (answerText === undefined) return; } else if (answer.toString("hex") === "03") { process.stdin.unref(); return; } else if ( // Handle non-existent hotkey. Object.keys(answers).find( key => answers[key][0].toLowerCase() === answer.toString().toLowerCase() ) === undefined ) { return; } else { answer = answer.toString().toLowerCase(); let answerLetter = Object.values(answers).find( val => val[0].toLowerCase() === answer ); answerText = Object.keys(answers).find(key => answers[key] === answerLetter); } callback(undefined, answerText); } else { callback(undefined, answer.toString()); } process.stdin.unref(); } // Non-interactive tty-independent method used in worklog. Object.defineProperty(this, "displayTable", { value: (tableContent, terminalWidth = (process.stdout.columns || 80)) => { // Headers of columns printed as a first row of table. const tableHeaders = Object.keys(tableContent[0]); // Last column isn't taken into a count because it is a descriptive // aggremental field. const tableColumnsWidth = tableHeaders.slice(0, tableHeaders.length - 1) .map(columnName => { let columnWidth = 0; tableContent.forEach(row => { if (typeof row[columnName] === "string") { // Counts screen's width for a normal string column comparing // column name and its content in order to choose the greatest one. const width = ( columnName.length > row[columnName].length )? columnName.length : row[columnName].length; columnWidth = (width > columnWidth)? width : columnWidth; } else if (Array.isArray(row[columnName])) { // For columns which are an arrays of strings the algorithm // is similar but considers each array element. row[columnName].forEach(rowPart => { const width = ( columnName.length > rowPart.length )? columnName.length : rowPart.length; columnWidth = (width > columnWidth)? width : columnWidth; }); } }); return columnWidth; }); // How many spaces will be used to separate columns. const separatorSize = Math.floor( terminalWidth / tableColumnsWidth.reduce( (rowLength, columnLength) => rowLength + columnLength, 0 ) ); // Needed for last descriptive column. const tableRowSize = tableColumnsWidth.reduce( (rowSize, columnSize) => rowSize + columnSize + separatorSize, 0 ); // Column headers to print on screen. let headers = ""; tableHeaders.forEach((header, headerIndex) => { // Capitalisation and counting spaces to add for appropriate separation. headers += header.replace(header[0], header[0].toUpperCase()) + " ".repeat((tableColumnsWidth[headerIndex] - header.length) + separatorSize); }); // Full table content. let rows = []; tableContent.forEach(rowContent => { let row = ""; Object.keys(rowContent).forEach((column, columnIndex) => { if (typeof rowContent[column] === "string") { // The last column has a rest of screen width and is wrapped at // screen edge. if (columnIndex === tableHeaders.length - 1) { const textSize = terminalWidth - tableRowSize; let [textBegin, textEnd] = [0, textSize]; // How many lines of last column's content to add. const lines = Math.ceil(rowContent[column].length / textSize); row = row.split("\n").map(item => { // Add last column's content at the end of the row. // Splitting by newline is neccesarry because array column // adds it. item += ( rowContent[column].substring(textBegin, textEnd).length !== 0 )? " ".repeat(separatorSize) + rowContent[column].substring(textBegin, textEnd) : ""; textBegin = textEnd; textEnd += textSize; return item; }); // But last column's content can be longer so... if (lines > row.length) { while ( rowContent[column].substring(textBegin, textEnd).length !== 0 ) { row.push( " ".repeat(tableRowSize) + rowContent[column].substring(textBegin, textEnd) ); textBegin = textEnd; textEnd += textSize; } } } else { // For other string columns... row += rowContent[column] + " ".repeat( (tableColumnsWidth[columnIndex] - rowContent[column].length) + separatorSize ); } } else if (Array.isArray(rowContent[column])) { // There are some string array columns, especially hours ranges in worklog. rowContent[column].forEach((item, itemIndex) => { row += item; if (itemIndex !== rowContent[column].length - 1) { row += "\n" + " ".repeat( tableRowSize - (tableColumnsWidth[columnIndex] + separatorSize) ); } }); } }); rows.push(row.join("\n")); }); process.stdout.write(`${headers}\n${rows.join("\n")}\n`); }, writeable: false, enumerable: false, configurable: false }); Object.preventExtensions(this); } module.exports = Terminal;