fix project

This commit is contained in:
sfja 2026-06-10 21:33:15 +02:00
parent ae14bc3724
commit c1e72f9ae2
10 changed files with 325 additions and 60 deletions

View File

@ -11,13 +11,12 @@ function App(): ReactElement {
return ( return (
<> <>
<h1>nandsim</h1>
<div className="Editor"> <div className="Editor">
<div> <div>
<Toolbar editor={editor} canvasRef={canvasRef} /> <Toolbar editor={editor} canvasRef={canvasRef} />
</div> </div>
<main> <main>
<Tabbar editor={editor} /> <Tabbar editor={editor} canvasRef={canvasRef} />
<Canvas editor={editor} canvasRef={canvasRef} /> <Canvas editor={editor} canvasRef={canvasRef} />
</main> </main>
</div> </div>

View File

@ -9,9 +9,28 @@ type Props = {
function Canvas({ editor, canvasRef }: Props): ReactElement { function Canvas({ editor, canvasRef }: Props): ReactElement {
useEffect(() => { 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 ( return (
@ -24,30 +43,24 @@ function Canvas({ editor, canvasRef }: Props): ReactElement {
onMouseDown={(ev) => { onMouseDown={(ev) => {
const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY);
editor.events.send({ tag: "MouseDown", pos }); editor.events.send({ tag: "MouseDown", pos });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}} }}
onMouseUp={(ev) => { onMouseUp={(ev) => {
const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY);
editor.events.send({ tag: "MouseUp", pos }); editor.events.send({ tag: "MouseUp", pos });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}} }}
onMouseMove={(ev) => { onMouseMove={(ev) => {
const deltaPos = v2(ev.movementX, ev.movementY); const deltaPos = v2(ev.movementX, ev.movementY);
const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY); const pos = v2(ev.nativeEvent.offsetX, ev.nativeEvent.offsetY);
editor.events.send({ tag: "MouseMove", pos, deltaPos }); editor.events.send({ tag: "MouseMove", pos, deltaPos });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}} }}
onMouseLeave={(ev) => { onMouseLeave={(_ev) => {
editor.events.send({ tag: "MouseLeave" }); editor.events.send({ tag: "MouseLeave" });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}} }}
onKeyDown={(ev) => { onKeyDown={(ev) => {
editor.events.send({ tag: "KeyDown", key: ev.key }); editor.events.send({ tag: "KeyDown", key: ev.key });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}} }}
onKeyUp={(ev) => { onKeyUp={(ev) => {
editor.events.send({ tag: "KeyUp", key: ev.key }); editor.events.send({ tag: "KeyUp", key: ev.key });
editor.renderIfNeeded(ev.target as HTMLCanvasElement);
}} }}
/> />
</div> </div>

View File

@ -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"; import type { Editor } from "./editor/Editor";
type Props = { editor: Editor }; type Props = { editor: Editor; canvasRef: RefObject<HTMLCanvasElement | null> };
function Tabbar({ editor }: Props): ReactElement { function Tabbar({ editor, canvasRef }: Props): ReactElement {
const [selectedTool, setSelectedTool] = useState("select"); const [updateId, update] = useState(0);
const [selectedTab, setSelectedTab] = useState(0);
useEffect(() =>
editor.events.subscribe(["ShowSelectedTab"], (ev) => {
setSelectedTab(ev.idx);
update(updateId + 1);
}),
);
return ( return (
<> <>
<div className="Tabbar"> <div className="Tabbar">
<button className="active">&lt;unnamed&gt;</button> <div>
<button>Component one</button> {editor.availableBoardEditors().map((tab, idx) => (
<button>Another components</button> <button
key={`${idx}${updateId}`}
className={selectedTab === idx ? "active" : ""}
onClick={() => {
editor.events.send({ tag: "SelectTab", idx });
}}
>
{tab}
</button>
))}
<button
className="add"
onClick={() => {
editor.events.send({ tag: "CreateTab" });
canvasRef.current?.focus();
}}
>
+
</button>
</div>
<div>
<button
onClick={() => {
editor.events.send({ tag: "SaveComponent" });
}}
>
Save
</button>
<button
onClick={() => {
const name = prompt("New component name:");
if (!name) return;
editor.events.send({ tag: "RenameComponent", newName: name });
}}
>
Rename
</button>
<button onClick={() => {}}>Close</button>
</div>
</div> </div>
</> </>
); );

View File

@ -17,9 +17,9 @@ function Toolbar({ editor, canvasRef }: Props): ReactElement {
<div className="Toolbar"> <div className="Toolbar">
<h2>Toolbar</h2> <h2>Toolbar</h2>
<div> <div>
{editor.tools().map((tool, key) => ( {editor.availableTools().map((tool, key) => (
<button <button
key={`${key}`} key={key}
className={selectedTool === tool ? "active" : ""} className={selectedTool === tool ? "active" : ""}
onClick={() => { onClick={() => {
editor.events.send({ tag: "SelectTool", tool }); editor.events.send({ tag: "SelectTool", tool });
@ -30,9 +30,7 @@ function Toolbar({ editor, canvasRef }: Props): ReactElement {
</button> </button>
))} ))}
</div> </div>
<div> <div></div>
<button className="add">+</button>
</div>
</div> </div>
</> </>
); );

View File

@ -21,6 +21,27 @@ export class Board {
constructor() {} 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 { canPlaceComponent(kind: ComponentKind, pos: V2): boolean {
return !this.components.some((comp) => return !this.components.some((comp) =>
rectsCollide(comp.pos, comp.kind.size, pos, kind.size), 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)); 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<null>(inputCount).fill(null),
new Array<null>(outputCount).fill(null),
);
}
toIr(): ir.Component { toIr(): ir.Component {
console.log("Lowering to IR"); console.log("Lowering to IR");
@ -337,6 +374,10 @@ export class ComponentRepo {
return repo; return repo;
} }
available(): string[] {
return [...this.defs.keys()];
}
add(ident: string, kind: ComponentKind) { add(ident: string, kind: ComponentKind) {
this.defs.set(ident, kind); this.defs.set(ident, kind);
} }

View File

@ -10,31 +10,39 @@ import { v2, type V2 } from "./V2";
import { ViewPos } from "./ViewPos"; import { ViewPos } from "./ViewPos";
import { type ComponentKind } from "./Board"; import { type ComponentKind } from "./Board";
import type { EventUnsub } from "./events"; import type { EventUnsub } from "./events";
import { Project } from "./Project";
export class Editor { export class Editor {
public events = new EventBus(); public events = new EventBus();
public viewpos = new ViewPos(this.events); 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; public selectionBox: SelectionBox | null = null;
private componentPlacer: ComponentPlacer | null = null; private componentPlacer: ComponentPlacer | null = null;
public selection: Selection | null = null; public selection: Selection | null = null;
public connectingWire: ConnectingWire | null = null; public connectingWire: ConnectingWire | null = null;
public board = new Board();
public componentRepo = ComponentRepo.withDefaults();
public keysPressed = new Set<string>(); public keysPressed = new Set<string>();
public mouse = new Mouse(this.events); private state: State = new Normal(this);
constructor() { constructor() {
this.state.enter();
this.events.subscribe( this.events.subscribe(
["MouseDown", "MouseUp", "MouseMove", "KeyDown", "KeyUp", "SelectTool"], [
"MouseDown",
"MouseUp",
"MouseMove",
"KeyDown",
"KeyUp",
"SelectTool",
"CreateTab",
"SelectTab",
"SaveComponent",
"RenameComponent",
],
(ev) => { (ev) => {
switch (ev.tag) { switch (ev.tag) {
case "KeyDown": case "KeyDown":
@ -45,10 +53,30 @@ export class Editor {
break; break;
case "SelectTool": case "SelectTool":
this.onSelectTool(ev.tool); this.onSelectTool(ev.tool);
break;
case "CreateTab": {
const idx = this.project.newTab();
this.switchTab(idx);
break;
} }
this.renderNeeded = true; case "SelectTab": {
this.switchTab(ev.idx);
break;
}
case "SaveComponent": {
this.project.saveComponent();
break;
}
case "RenameComponent": {
this.project.renameComponent(ev.newName);
break;
}
}
this.events.send({ tag: "RenderRequest" });
}, },
); );
this.state.enter();
} }
render(canvas: HTMLCanvasElement) { render(canvas: HTMLCanvasElement) {
@ -62,29 +90,12 @@ export class Editor {
this.connectingWire?.render(r); this.connectingWire?.render(r);
} }
renderIfNeeded(canvas: HTMLCanvasElement) { availableBoardEditors(): string[] {
if (this.renderNeeded) { return this.project.availableBoardEditors();
this.render(canvas);
this.renderNeeded = false;
}
} }
private onSelectTool(tool: string) { availableTools(): string[] {
switch (tool) { return this.project.availableTools();
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 });
} }
transitionTo(newState: State) { transitionTo(newState: State) {
@ -118,9 +129,34 @@ export class Editor {
// const sim = new Sim(comp, [], []); // const sim = new Sim(comp, [], []);
// sim.simulate(); // 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[] { private switchTab(idx: number) {
return ["select", "pan", "input", "output", "and", "or", "not"]; 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 { enter(): void {
this.unsubscribe = this.cx.events.subscribe( this.unsubscribe = this.cx.events.subscribe(
["MouseDownOffset", "MouseMoveOffset", "MouseDragBegin", "KeyDown"], [
"MouseDownOffset",
"MouseMoveOffset",
"MouseDragBegin",
"KeyDown",
"MouseDoubleClick",
],
(ev) => { (ev) => {
switch (ev.tag) { switch (ev.tag) {
case "MouseDownOffset": case "MouseDownOffset":
@ -157,6 +199,15 @@ class Normal implements State {
} }
break; 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 cx: Editor,
private tool: string, private tool: string,
) { ) {
this.compDef = this.cx.componentRepo.get(this.tool); this.compDef = this.cx.project.componentRepo.get(this.tool);
} }
enter(): void { enter(): void {

View File

@ -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;
};

View File

@ -25,6 +25,11 @@ export class V2 {
return new V2(Math.abs(this.x), Math.abs(this.y)); return new V2(Math.abs(this.x), Math.abs(this.y));
} }
assign(rhs: V2) {
this.x = rhs.x;
this.y = rhs.y;
}
toString(): string { toString(): string {
return `V2(${this.x}, ${this.y})`; return `V2(${this.x}, ${this.y})`;
} }

View File

@ -16,8 +16,13 @@ export type Event =
} }
| { tag: "KeyDown" | "KeyUp"; key: string } | { tag: "KeyDown" | "KeyUp"; key: string }
| { tag: "SelectTool" | "ShowSelectedTool"; tool: string } | { tag: "SelectTool" | "ShowSelectedTool"; tool: string }
| { tag: "CreateTab" }
| { tag: "SelectTab" | "ShowSelectedTab"; idx: number }
| { tag: "MouseDownOffset"; pos: V2; absPos: V2 } | { 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<Tag extends Event["tag"]> = Event & { tag: Tag }; export type EventOf<Tag extends Event["tag"]> = Event & { tag: Tag };

View File

@ -45,7 +45,14 @@
.Tabbar { .Tabbar {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 2px; justify-content: space-between;
> div {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 5px;
}
button { button {
padding-left: 5px; padding-left: 5px;