diff --git a/editor/src/App.tsx b/editor/src/App.tsx index b198aa4..cefdb12 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -11,13 +11,12 @@ function App(): ReactElement { return ( <> -

nandsim

- +
diff --git a/editor/src/Canvas.tsx b/editor/src/Canvas.tsx index e5c07ec..7999cbb 100644 --- a/editor/src/Canvas.tsx +++ b/editor/src/Canvas.tsx @@ -9,9 +9,28 @@ type Props = { function Canvas({ editor, canvasRef }: Props): ReactElement { useEffect(() => { - if (!canvasRef.current) return; + if (canvasRef.current) { + editor.render(canvasRef.current); + } - editor.render(canvasRef.current); + const unsubscribe = editor.events.subscribe(["RenderRequest"], (ev) => { + if (canvasRef.current) { + editor.render(canvasRef.current); + } + }); + + function onResize() { + if (canvasRef.current) { + editor.render(canvasRef.current); + } + } + + window.addEventListener("resize", onResize); + + return () => { + window.removeEventListener("resize", onResize); + unsubscribe(); + }; }); return ( @@ -24,30 +43,24 @@ function Canvas({ editor, canvasRef }: Props): ReactElement { onMouseDown={(ev) => { const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); editor.events.send({ tag: "MouseDown", pos }); - editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} onMouseUp={(ev) => { const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); editor.events.send({ tag: "MouseUp", pos }); - editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} onMouseMove={(ev) => { const deltaPos = v2(ev.movementX, ev.movementY); const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); editor.events.send({ tag: "MouseMove", pos, deltaPos }); - editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} - onMouseLeave={(ev) => { + onMouseLeave={(_ev) => { editor.events.send({ tag: "MouseLeave" }); - editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} onKeyDown={(ev) => { editor.events.send({ tag: "KeyDown", key: ev.key }); - editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} onKeyUp={(ev) => { editor.events.send({ tag: "KeyUp", key: ev.key }); - editor.renderIfNeeded(ev.target as HTMLCanvasElement); }} /> diff --git a/editor/src/Tabbar.tsx b/editor/src/Tabbar.tsx index 2fdf3d5..ecbef66 100644 --- a/editor/src/Tabbar.tsx +++ b/editor/src/Tabbar.tsx @@ -1,17 +1,63 @@ -import { useEffect, useState, type ReactElement } from "react"; +import { useEffect, useState, type ReactElement, type RefObject } from "react"; import type { Editor } from "./editor/Editor"; -type Props = { editor: Editor }; +type Props = { editor: Editor; canvasRef: RefObject }; -function Tabbar({ editor }: Props): ReactElement { - const [selectedTool, setSelectedTool] = useState("select"); +function Tabbar({ editor, canvasRef }: Props): ReactElement { + const [updateId, update] = useState(0); + const [selectedTab, setSelectedTab] = useState(0); + + useEffect(() => + editor.events.subscribe(["ShowSelectedTab"], (ev) => { + setSelectedTab(ev.idx); + update(updateId + 1); + }), + ); return ( <>
- - - +
+ {editor.availableBoardEditors().map((tab, idx) => ( + + ))} + +
+
+ + + +
); diff --git a/editor/src/Toolbar.tsx b/editor/src/Toolbar.tsx index 8afe452..b15f3c1 100644 --- a/editor/src/Toolbar.tsx +++ b/editor/src/Toolbar.tsx @@ -17,9 +17,9 @@ function Toolbar({ editor, canvasRef }: Props): ReactElement {

Toolbar

- {editor.tools().map((tool, key) => ( + {editor.availableTools().map((tool, key) => ( ))}
-
- -
+
); diff --git a/editor/src/editor/Board.ts b/editor/src/editor/Board.ts index 8218fb5..e3edd99 100644 --- a/editor/src/editor/Board.ts +++ b/editor/src/editor/Board.ts @@ -21,6 +21,27 @@ export class Board { constructor() {} + static withExample(repo: ComponentRepo): Board { + const board = new Board(); + board.placeComponent(repo.get("input"), v2(100, 100)); + board.placeComponent(repo.get("input"), v2(100, 200)); + board.placeComponent(repo.get("and"), v2(300, 150)); + board.placeComponent(repo.get("output"), v2(500, 150)); + board.addWire( + { tag: "OutputPin", comp: board.components[0], i: 0 }, + { tag: "InputPin", comp: board.components[2], i: 0 }, + ); + board.addWire( + { tag: "OutputPin", comp: board.components[1], i: 0 }, + { tag: "InputPin", comp: board.components[2], i: 1 }, + ); + board.addWire( + { tag: "OutputPin", comp: board.components[2], i: 0 }, + { tag: "InputPin", comp: board.components[3], i: 0 }, + ); + return board; + } + canPlaceComponent(kind: ComponentKind, pos: V2): boolean { return !this.components.some((comp) => rectsCollide(comp.pos, comp.kind.size, pos, kind.size), @@ -194,6 +215,22 @@ export class Board { this.wires = this.wires.filter((wire) => !wire.isSelected(selection)); } + toComponentKind(name: string): ComponentKind { + const inputCount = this.components.filter( + (comp) => comp.kind.label === "input", + ).length; + const outputCount = this.components.filter( + (comp) => comp.kind.label === "output", + ).length; + const pinMax = Math.max(inputCount, outputCount); + return new ComponentKind( + v2(60 + name.length * 5, 40 + 10 * pinMax), + name, + new Array(inputCount).fill(null), + new Array(outputCount).fill(null), + ); + } + toIr(): ir.Component { console.log("Lowering to IR"); @@ -337,6 +374,10 @@ export class ComponentRepo { return repo; } + available(): string[] { + return [...this.defs.keys()]; + } + add(ident: string, kind: ComponentKind) { this.defs.set(ident, kind); } diff --git a/editor/src/editor/Editor.ts b/editor/src/editor/Editor.ts index 89778bc..2b206c6 100644 --- a/editor/src/editor/Editor.ts +++ b/editor/src/editor/Editor.ts @@ -10,31 +10,39 @@ import { v2, type V2 } from "./V2"; import { ViewPos } from "./ViewPos"; import { type ComponentKind } from "./Board"; import type { EventUnsub } from "./events"; +import { Project } from "./Project"; export class Editor { public events = new EventBus(); public viewpos = new ViewPos(this.events); - private renderNeeded = false; + public mouse = new Mouse(this.events); - private state: State = new Normal(this); + public project = Project.loadLocalStoreOrInitNew(this.events); + public board = this.project.currentBoard(); public selectionBox: SelectionBox | null = null; private componentPlacer: ComponentPlacer | null = null; public selection: Selection | null = null; public connectingWire: ConnectingWire | null = null; - public board = new Board(); - public componentRepo = ComponentRepo.withDefaults(); - public keysPressed = new Set(); - public mouse = new Mouse(this.events); + private state: State = new Normal(this); constructor() { - this.state.enter(); - this.events.subscribe( - ["MouseDown", "MouseUp", "MouseMove", "KeyDown", "KeyUp", "SelectTool"], + [ + "MouseDown", + "MouseUp", + "MouseMove", + "KeyDown", + "KeyUp", + "SelectTool", + "CreateTab", + "SelectTab", + "SaveComponent", + "RenameComponent", + ], (ev) => { switch (ev.tag) { case "KeyDown": @@ -45,10 +53,30 @@ export class Editor { break; case "SelectTool": this.onSelectTool(ev.tool); + break; + case "CreateTab": { + const idx = this.project.newTab(); + this.switchTab(idx); + break; + } + case "SelectTab": { + this.switchTab(ev.idx); + break; + } + case "SaveComponent": { + this.project.saveComponent(); + break; + } + case "RenameComponent": { + this.project.renameComponent(ev.newName); + break; + } } - this.renderNeeded = true; + this.events.send({ tag: "RenderRequest" }); }, ); + + this.state.enter(); } render(canvas: HTMLCanvasElement) { @@ -62,29 +90,12 @@ export class Editor { this.connectingWire?.render(r); } - renderIfNeeded(canvas: HTMLCanvasElement) { - if (this.renderNeeded) { - this.render(canvas); - this.renderNeeded = false; - } + availableBoardEditors(): string[] { + return this.project.availableBoardEditors(); } - private onSelectTool(tool: string) { - switch (tool) { - case "pan": - this.transitionTo(new Panning(this)); - break; - case "input": - case "output": - case "and": - case "or": - case "not": - this.transitionTo(new Placing(this, tool)); - break; - default: - this.transitionTo(new Normal(this)); - } - this.events.send({ tag: "ShowSelectedTool", tool }); + availableTools(): string[] { + return this.project.availableTools(); } transitionTo(newState: State) { @@ -118,9 +129,34 @@ export class Editor { // const sim = new Sim(comp, [], []); // sim.simulate(); } + private onSelectTool(tool: string) { + switch (tool) { + case "pan": + this.transitionTo(new Panning(this)); + break; + case "input": + case "output": + case "and": + case "or": + case "not": + this.transitionTo(new Placing(this, tool)); + break; + default: + this.transitionTo(new Normal(this)); + } + this.events.send({ tag: "ShowSelectedTool", tool }); + } - tools(): string[] { - return ["select", "pan", "input", "output", "and", "or", "not"]; + private switchTab(idx: number) { + this.project.switchTab(idx); + this.events.send({ tag: "ShowSelectedTab", idx }); + this.selectionBox = null; + this.componentPlacer = null; + this.selection = null; + this.connectingWire = null; + this.viewpos.offset.assign(v2(0, 0)); + this.board = this.project.currentBoard(); + this.transitionTo(new Normal(this)); } } @@ -136,7 +172,13 @@ class Normal implements State { enter(): void { this.unsubscribe = this.cx.events.subscribe( - ["MouseDownOffset", "MouseMoveOffset", "MouseDragBegin", "KeyDown"], + [ + "MouseDownOffset", + "MouseMoveOffset", + "MouseDragBegin", + "KeyDown", + "MouseDoubleClick", + ], (ev) => { switch (ev.tag) { case "MouseDownOffset": @@ -157,6 +199,15 @@ class Normal implements State { } break; } + case "MouseDoubleClick": { + this.cx.board.handleMouseClick(ev.pos, { + onComponentClicked: (comp) => { + if (comp.kind.label === "input") { + } + }, + }); + break; + } } }, ); @@ -256,7 +307,7 @@ class Placing implements State { private cx: Editor, private tool: string, ) { - this.compDef = this.cx.componentRepo.get(this.tool); + this.compDef = this.cx.project.componentRepo.get(this.tool); } enter(): void { diff --git a/editor/src/editor/Project.ts b/editor/src/editor/Project.ts new file mode 100644 index 0000000..9b83f6b --- /dev/null +++ b/editor/src/editor/Project.ts @@ -0,0 +1,100 @@ +import { Board, ComponentRepo, type Component } from "./Board"; +import { type EventBus } from "./events"; + +export class Project { + private current: BoardEditor; + private selectedIdx = 0; + + private constructor( + private events: EventBus, + private boardEditors: BoardEditor[], + private components: Component[], + public componentRepo: ComponentRepo, + ) { + this.current = boardEditors[this.selectedIdx]; + } + + static loadLocalStoreOrInitNew(events: EventBus): Project { + if (globalThis.localStorage.getItem("nandsim")) { + return this.loadLocalStorage(); + } else { + return this.initNew(events); + } + } + static initNew(events: EventBus): Project { + const repo = ComponentRepo.withDefaults(); + return new Project( + events, + [ + { + name: "(Unnamed)", + board: Board.withExample(repo), + }, + ], + [], + repo, + ); + } + + static loadLocalStorage(): Project { + throw new Error("not implemented"); + } + + currentBoard(): Board { + return this.current.board; + } + + availableBoardEditors(): string[] { + return this.boardEditors.map((e) => e.name); + } + + availableTools(): string[] { + return this.componentRepo + .available() + .filter((e) => e !== this.current.name); + } + + newTab(): number { + this.boardEditors.push({ + name: `(Unnamed ${this.boardEditors.length})`, + board: new Board(), + }); + this.events.send({ tag: "ShowSelectedTab", idx: this.selectedIdx }); + return this.boardEditors.length - 1; + } + + switchTab(idx: number) { + this.selectedIdx = idx; + this.current = this.boardEditors[this.selectedIdx]; + this.events.send({ tag: "ShowSelectedTab", idx: this.selectedIdx }); + this.events.send({ tag: "ShowSelectedTool", tool: this.current.name }); + } + + closeTab(idx: number) { + this.boardEditors.splice(idx, 1); + if (this.boardEditors.length === 0) { + this.newTab(); + } + this.selectedIdx = 0; + this.current = this.boardEditors[this.selectedIdx]; + this.events.send({ tag: "ShowSelectedTab", idx: this.selectedIdx }); + } + + renameComponent(newName: string) { + this.current.name = newName; + this.events.send({ tag: "ShowSelectedTab", idx: this.selectedIdx }); + } + + saveComponent() { + this.componentRepo.add( + this.current.name, + this.current.board.toComponentKind(this.current.name), + ); + this.events.send({ tag: "ShowSelectedTool", tool: this.current.name }); + } +} + +type BoardEditor = { + name: string; + board: Board; +}; diff --git a/editor/src/editor/V2.ts b/editor/src/editor/V2.ts index 2972eff..78facdf 100644 --- a/editor/src/editor/V2.ts +++ b/editor/src/editor/V2.ts @@ -25,6 +25,11 @@ export class V2 { return new V2(Math.abs(this.x), Math.abs(this.y)); } + assign(rhs: V2) { + this.x = rhs.x; + this.y = rhs.y; + } + toString(): string { return `V2(${this.x}, ${this.y})`; } diff --git a/editor/src/editor/events.ts b/editor/src/editor/events.ts index e4f1a46..9d61094 100644 --- a/editor/src/editor/events.ts +++ b/editor/src/editor/events.ts @@ -16,8 +16,13 @@ export type Event = } | { tag: "KeyDown" | "KeyUp"; key: string } | { tag: "SelectTool" | "ShowSelectedTool"; tool: string } + | { tag: "CreateTab" } + | { tag: "SelectTab" | "ShowSelectedTab"; idx: number } | { tag: "MouseDownOffset"; pos: V2; absPos: V2 } - | { tag: "MouseMoveOffset"; pos: V2; deltaPos: V2 }; + | { tag: "MouseMoveOffset"; pos: V2; deltaPos: V2 } + | { tag: "RenderRequest" } + | { tag: "SaveComponent" | "CloseComponent" } + | { tag: "RenameComponent"; newName: string }; export type EventOf = Event & { tag: Tag }; diff --git a/editor/src/style.css b/editor/src/style.css index 4ecab00..9980017 100644 --- a/editor/src/style.css +++ b/editor/src/style.css @@ -45,7 +45,14 @@ .Tabbar { display: flex; flex-direction: row; - gap: 2px; + justify-content: space-between; + + > div { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 5px; + } button { padding-left: 5px;