This commit is contained in:
sfja 2025-02-06 02:21:37 +01:00
commit fe9e2c99a7
7 changed files with 615 additions and 0 deletions

15
action.ts Normal file
View File

@ -0,0 +1,15 @@
export type Action = {
kind: ActionKind;
chsm: number;
};
export type ActionKind =
| { tag: "init"; title: string }
| { tag: "addColumn"; idx: number; title: string }
| { tag: "editColumn"; idx: number; title: string }
| { tag: "deleteColumn"; idx: number }
| { tag: "moveColumn"; idx: number; delta: number }
| { tag: "addTask"; path: number[]; idx: number; content: string }
| { tag: "editTask"; path: number[]; content: string }
| { tag: "deleteTask"; path: number[] }
| { tag: "moveTask"; srcPath: number[]; destPath: number[]; idx: number };

54
board.ts Normal file
View File

@ -0,0 +1,54 @@
import { Action, ActionKind } from "./action.ts";
import * as rasterizer from "./rasterizer.ts";
export class Board {
private actions: Action[] = [];
public constructor(
title: string,
) {
this.pushAction({ tag: "init", title });
}
public addColumn(idx: number, title: string) {
this.pushAction({ tag: "addColumn", title, idx });
}
public editColumn(idx: number, title: string) {
this.pushAction({ tag: "editColumn", title, idx });
}
public deleteColumn(idx: number) {
this.pushAction({ tag: "deleteColumn", idx });
}
public moveColumn(idx: number, delta: number) {
this.pushAction({ tag: "moveColumn", idx, delta });
}
public addTask(path: number[], idx: number, content: string) {
this.pushAction({ tag: "addTask", path, idx, content });
}
public editTask(path: number[], content: string) {
this.pushAction({ tag: "editTask", path, content });
}
public deleteTask(path: number[]) {
this.pushAction({ tag: "deleteTask", path });
}
public moveTask(srcPath: number[], destPath: number[], idx: number) {
this.pushAction({ tag: "moveTask", srcPath, destPath, idx });
}
private pushAction(kind: ActionKind) {
const chsm = this.actions.at(-1)?.chsm ??
0 + JSON.stringify(kind).length;
this.actions.push({ kind, chsm });
}
public rasterize(): rasterizer.Board {
return new rasterizer.Rasterizer(this.actions).rasterize();
}
}

5
deno.jsonc Normal file
View File

@ -0,0 +1,5 @@
{
"fmt": {
"indentWidth": 4,
}
}

127
main.ts Normal file
View File

@ -0,0 +1,127 @@
import { Board } from "./board.ts";
import { Renderer } from "./renderer.ts";
import { Resolver } from "./resolver.ts";
const help_message = `
prown - simwle prowek mawawger
commands:
general:
help
tasks:
task-add #ID TITLE [INDEX]
task-edit #ID TITLE
task-delete #ID
task-move src:#ID dest:#ID [INDEX]
columns:
column-add TITLE [IDX]
column-edit IDX TITLE
column-delete IDX
column-move IDX DELTA
`.trim();
const board = new Board("Skill issues");
while (true) {
const raster = board.rasterize();
const resolver = new Resolver(raster);
const text = new Renderer(raster).render();
console.log(text);
const line = prompt("> ");
if (!line) {
break;
}
const [cmd, ...args] = line
.split("")
.reduce<[string[], string]>(
([acc, strCh], tok) =>
strCh === ""
? tok === "'" || tok === '"'
? [acc, tok]
: tok === " "
? [[tok.trim(), ...acc], strCh]
: [[acc[0] + tok, ...acc.slice(1)], strCh]
: tok === strCh
? [acc, ""]
: [[acc[0] + tok, ...acc.slice(1)], strCh],
[[""], ""],
)[0]
.toReversed();
switch (cmd) {
case "help":
console.log(help_message);
break;
case "add":
case "task-add": {
const path = parsePath(args[0], resolver);
const content = args[1];
const idx = args[2] && parseInt(args[2]) || 0;
board.addTask(path, idx, content);
break;
}
case "edit":
case "task-edit": {
const path = parsePath(args[0], resolver);
const content = args[1];
board.editTask(path, content);
break;
}
case "delete":
case "task-delete": {
const path = parsePath(args[0], resolver);
board.deleteTask(path);
break;
}
case "move":
case "task-move": {
const srcPath = parsePath(args[0], resolver);
const destPath = parsePath(args[1], resolver);
const idx = args[2] && parseInt(args[2]) || 0;
board.moveTask(srcPath, destPath, idx);
break;
}
case "cadd":
case "column-add": {
const title = args[0];
const idx = args[1] && parseInt(args[1]) || 0;
board.addColumn(idx, title);
break;
}
case "cedit":
case "column-edit": {
const idx = parseInt(args[0]);
const title = args[1];
board.editColumn(idx, title);
break;
}
case "cdelete":
case "column-delete": {
const idx = parseInt(args[0]);
board.deleteColumn(idx);
break;
}
case "cmove":
case "column-move": {
const idx = parseInt(args[0]);
const delta = parseInt(args[1]);
board.moveColumn(idx, delta);
break;
}
default:
console.log(`unknown command '${cmd}'`);
}
}
function parsePath(text: string, resolver: Resolver): number[] {
if (text.startsWith(".")) {
return [parseInt(text.slice(1))];
}
if (text.startsWith("#")) {
return resolver.taskIdPath(parseInt(text.slice(1)));
}
return resolver.taskIdPath(parseInt(text));
}

124
rasterizer.ts Normal file
View File

@ -0,0 +1,124 @@
import { Action } from "./action.ts";
export type Board = {
title: string;
columns: Column[];
};
export type Column = {
id: number;
title: string;
tasks: Task[];
};
export type Task = {
id: number;
content: string;
subTasks: Task[];
};
export class Rasterizer {
private board: Board = { title: "", columns: [] };
private columnIds = 0;
private taskIds = 0;
public constructor(
private actions: Action[],
) {}
public rasterize(): Board {
this.rasterizeAction(this.actions.length - 1);
return this.board;
}
private rasterizeAction(idx: number) {
const action = this.actions[idx];
const k = action.kind;
if (k.tag === "init") {
this.board.title = k.title;
return;
}
this.rasterizeAction(idx - 1);
switch (k.tag) {
case "addColumn":
this.board.columns
.splice(k.idx, 0, {
id: this.columnIds++,
title: k.title,
tasks: [],
});
return;
case "editColumn":
this.board.columns[k.idx].title = k.title;
return;
case "deleteColumn":
this.board.columns.splice(k.idx, 1);
return;
case "moveColumn": {
const column = this.board.columns[k.idx];
this.board.columns
.splice(k.idx, 1);
this.board.columns
.splice(k.idx + k.delta, 0, column);
return;
}
case "addTask": {
const column = this.board.columns[k.path[0]];
if (k.path.length === 1) {
column.tasks.splice(idx, 0, {
id: this.taskIds++,
content: k.content,
subTasks: [],
});
return;
}
this.taskAt(column.tasks, k.path.slice(1))
.subTasks.splice(idx, 0, {
id: this.taskIds++,
content: k.content,
subTasks: [],
});
return;
}
case "editTask": {
const column = this.board.columns[k.path[0]];
this.taskAt(column.tasks, k.path.slice(1))
.content = k.content;
return;
}
case "deleteTask": {
const column = this.board.columns[k.path[0]];
this.taskAt(column.tasks, k.path.slice(1, k.path.length - 1))
.subTasks.splice(k.path.at(-1)!, 1);
return;
}
case "moveTask": {
const srcColumn = this.board.columns[k.srcPath[0]];
const destColumn = this.board.columns[k.destPath[0]];
const task = this.taskAt(srcColumn.tasks, k.srcPath.slice(1));
if (k.srcPath.length === 2) {
srcColumn.tasks.splice(k.srcPath.at(-1)!, 1);
} else {
this.taskAt(
srcColumn.tasks,
k.srcPath.slice(1, k.srcPath.length - 1),
).subTasks.splice(k.srcPath.at(-1)!, 1);
}
if (k.destPath.length === 1) {
destColumn.tasks.splice(idx, 0, task);
return;
}
this.taskAt(destColumn.tasks, k.destPath.slice(1))
.subTasks.splice(idx, 0, task);
return;
}
}
const _: never = k;
}
private taskAt(tasks: Task[], path: number[]): Task {
if (path.length === 1) {
return tasks[path[0]];
}
return this.taskAt(tasks[path[0]].subTasks, path.slice(1));
}
}

252
renderer.ts Normal file
View File

@ -0,0 +1,252 @@
import { Board, Column, Task } from "./rasterizer.ts";
export class Renderer {
public constructor(
private board: Board,
) {}
public render(): string {
const layout = new Layouter(this.board);
return new TextPlotter(this.board, layout).plot();
}
}
export class TextPlotter {
private plotStr: string[];
private width: number;
public constructor(
private board: Board,
private layout: Layouter,
) {
const { w, h } = layout.boardBox();
this.plotStr = new Array(w * h).fill(" ");
this.width = w;
}
public plot(): string {
this.plotBoard();
const box = this.layout.boardBox();
let result = "";
for (let y = 0; y < box.h; ++y) {
for (let x = 0; x < box.w; ++x) {
result += this.plotStr[y * this.width + x];
}
result += "\n";
}
return result.slice(0, result.length - 1);
}
private plotBoard() {
const box = this.layout.boardBox();
this.plotBox(box);
const { x, y } = box;
this.plotText(this.board.title, Pos(x + 1, y + 1));
for (const [idx, column] of this.board.columns.entries()) {
this.plotColumn(column, idx);
}
}
private plotColumn(column: Column, idx: number) {
const box = this.layout.columnBox(column);
this.plotBox(box);
const { x, y } = box;
this.plotText("." + idx.toString(), Pos(x + 2, y + 1));
this.plotText(column.title, Pos(x + 2, y + 2));
for (const task of column.tasks) {
this.plotTask(task);
}
}
private plotTask(task: Task) {
const box = this.layout.taskBox(task);
this.plotBox(box);
const { x, y } = box;
this.plotText("#" + task.id.toString(), Pos(x + 2, y + 1));
this.plotText(task.content, Pos(x + 2, y + 2));
for (const subTask of task.subTasks) {
this.plotTask(subTask);
}
}
private plotText(text: string, { x, y }: Pos) {
for (let i = 0; i < text.length; ++i) {
this.set(text[i], Pos(x + i, y));
}
}
private plotBox({ x, y, w, h }: Box) {
this.set("+", Pos(x, y));
this.set("+", Pos(x + w - 1, y));
this.set("+", Pos(x, y + h - 1));
this.set("+", Pos(x + w - 1, y + h - 1));
for (let i = 1; i < w - 1; ++i) {
this.set("-", Pos(x + i, y));
this.set("-", Pos(x + i, y + h - 1));
}
for (let i = 1; i < h - 1; ++i) {
this.set("|", Pos(x, y + i));
this.set("|", Pos(x + w - 1, y + i));
}
}
private set(ch: string, { x, y }: Pos) {
this.plotStr[y * this.width + x] = ch;
}
}
type Size = { w: number; h: number };
const Size = (w: number, h: number): Size => ({ w, h });
type Pos = { x: number; y: number };
const Pos = (x: number, y: number): Pos => ({ x, y });
type Box = { x: number; y: number; w: number; h: number };
const Box = (
x: number,
y: number,
w: number,
h: number,
): Box => ({ x, y, w, h });
export class Layouter {
private _boardBox: Box;
private columnBoxes = new Map<number, Box>();
private taskBoxes = new Map<number, Box>();
private sizer: Sizer;
public constructor(
private board: Board,
) {
this.sizer = new Sizer(this.board);
this._boardBox = this.layoutBoard();
}
public boardBox(): Box {
return this._boardBox;
}
public columnBox(column: Column): Box {
return this.columnBoxes.get(column.id)!;
}
public taskBox(task: Task): Box {
return this.taskBoxes.get(task.id)!;
}
private layoutBoard(): Box {
const size = this.sizer.boardSize();
this.board.columns
.reduce(
({ x }, column) => this.layoutColumn(column, Pos(x + 1, 2)),
Pos(1, 0),
);
return Box(0, 0, size.w, size.h);
}
private layoutColumn(column: Column, { x, y }: Pos): Pos {
const { w, h } = this.sizer.columnSize(column);
column.tasks
.reduce(
({ y }, task) => this.layoutTask(task, Pos(2 + x, y)),
Pos(2 + x, 3 + y),
);
this.columnBoxes.set(column.id, Box(x, y, w, h));
return Pos(x + w, y + h);
}
private layoutTask(task: Task, { x, y }: Pos): Pos {
const { w, h } = this.sizer.taskSize(task);
task.subTasks
.reduce(
({ y }, task) => this.layoutTask(task, Pos(2 + x, y)),
Pos(2 + x, 3 + y),
);
this.taskBoxes.set(task.id, Box(x, y, w, h));
return Pos(x + w, y + h);
}
}
class Sizer {
private _boardSize: Size;
private columnSizes = new Map<number, Size>();
private taskSizes = new Map<number, Size>();
public constructor(
private board: Board,
) {
this._boardSize = this.sizeBoard();
}
public boardSize(): Size {
return this._boardSize;
}
public columnSize(column: Column): Size {
return this.columnSizes.get(column.id)!;
}
public taskSize(task: Task): Size {
return this.taskSizes.get(task.id)!;
}
private sizeBoard(): Size {
const size = this.board.columns
.map((column) => this.sizeColumn(column))
.reduce(
(board, column) =>
Size(
board.w + column.w + 1,
Math.max(board.h, 3 + column.h),
),
Size(3, 3),
);
for (const column of this.board.columns) {
this.columnSizes.get(column.id)!.h = size.h - 3;
}
return Size(Math.max(size.w, 2 + this.board.title.length), size.h);
}
private sizeColumn(column: Column): Size {
const tasks = column.tasks
.map((task) => this.sizeTask(task))
.reduce(
(acc, task) => Size(Math.max(acc.w, task.w), acc.h + task.h),
Size(0, 0),
);
const size = Size(
4 + Math.max(column.title.length + 1, tasks.w),
4 + tasks.h,
);
this.columnSizes.set(column.id, size);
for (const task of column.tasks) {
this.expandTask(task, size.w - 4);
}
return size;
}
private sizeTask(task: Task): Size {
const subTasks = task.subTasks
.map((task) => this.sizeTask(task))
.reduce(
(acc, task) => Size(Math.max(acc.w, task.w), acc.h + task.h),
Size(0, 0),
);
const size = Size(
4 + Math.max(task.content.length + 1, subTasks.w),
4 + subTasks.h,
);
this.taskSizes.set(task.id, size);
return size;
}
private expandTask(task: Task, w: number) {
this.taskSizes.get(task.id)!.w = w;
for (const subTask of task.subTasks) {
this.expandTask(subTask, w - 4);
}
}
}

38
resolver.ts Normal file
View File

@ -0,0 +1,38 @@
import { Board, Column, Task } from "./rasterizer.ts";
export class Resolver {
private taskPaths = new Map<number, number[]>();
public constructor(
private board: Board,
) {
this.resolveBoard();
}
public taskPath(task: Task): number[] {
return this.taskPaths.get(task.id)!;
}
public taskIdPath(id: number): number[] {
return this.taskPaths.get(id)!;
}
private resolveBoard() {
for (const [idx, column] of this.board.columns.entries()) {
this.resolveColumn(column, idx);
}
}
private resolveColumn(column: Column, columnIdx: number) {
for (const [taskIdx, task] of column.tasks.entries()) {
this.resolveTask(task, [columnIdx, taskIdx]);
}
}
private resolveTask(task: Task, path: number[]) {
this.taskPaths.set(task.id, path);
for (const [idx, subTask] of task.subTasks.entries()) {
this.resolveTask(subTask, [...path, idx]);
}
}
}