init
This commit is contained in:
commit
fe9e2c99a7
15
action.ts
Normal file
15
action.ts
Normal 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
54
board.ts
Normal 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
5
deno.jsonc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"fmt": {
|
||||
"indentWidth": 4,
|
||||
}
|
||||
}
|
127
main.ts
Normal file
127
main.ts
Normal 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
124
rasterizer.ts
Normal 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
252
renderer.ts
Normal 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
38
resolver.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user