workwatch
Version:
A Linux terminal program for honest worktime tracking and billing.
395 lines (380 loc) • 14.7 kB
JavaScript
/**
* 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;