Make it so you can now write text anywhere using solid (#42)

Add "ghost" text nodes if the text parent is not a text renderable
Handle cleanup of text nodes
Handle anchoring to text nodes properly
Fix bug where text is not updating properly

Let me know if you want me to clean up this code, its a bit messy
This commit is contained in:
Ben Chidlow
2025-08-19 18:26:16 +08:00
committed by GitHub
parent 65737a2b6d
commit b66ab3ba5b
4 changed files with 138 additions and 65 deletions
+16 -16
View File
@@ -6,7 +6,7 @@
},
"packages/core": {
"name": "@opentui/core",
"version": "0.1.5",
"version": "0.1.6",
"dependencies": {
"jimp": "1.6.0",
"yoga-layout": "3.2.1",
@@ -19,12 +19,12 @@
},
"optionalDependencies": {
"@dimforge/rapier2d-simd-compat": "^0.17.3",
"@opentui/core-darwin-arm64": "0.1.5",
"@opentui/core-darwin-x64": "0.1.5",
"@opentui/core-linux-arm64": "0.1.5",
"@opentui/core-linux-x64": "0.1.5",
"@opentui/core-win32-arm64": "0.1.5",
"@opentui/core-win32-x64": "0.1.5",
"@opentui/core-darwin-arm64": "0.1.6",
"@opentui/core-darwin-x64": "0.1.6",
"@opentui/core-linux-arm64": "0.1.6",
"@opentui/core-linux-x64": "0.1.6",
"@opentui/core-win32-arm64": "0.1.6",
"@opentui/core-win32-x64": "0.1.6",
"bun-webgpu": "0.1.3",
"planck": "^1.4.2",
"three": "0.177.0",
@@ -35,8 +35,9 @@
},
"packages/react": {
"name": "@opentui/react",
"version": "0.1.5",
"version": "0.1.6",
"dependencies": {
"@opentui/core": "workspace:*",
"react-reconciler": "^0.32.0",
},
"devDependencies": {
@@ -45,14 +46,13 @@
"prettier": "3.6.2",
},
"peerDependencies": {
"@opentui/core": "workspace:*",
"react": ">=19.0.0",
"typescript": "^5",
},
},
"packages/solid": {
"name": "@opentui/solid",
"version": "0.1.5",
"version": "0.1.6",
"dependencies": {
"@opentui/core": "workspace:*",
},
@@ -199,17 +199,17 @@
"@opentui/core": ["@opentui/core@workspace:packages/core"],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5/JQdjeko52fjyrJ9SRRz9W0aCUPC9okGFUJkVEoIxmypC5qsOpG+ExVz5HdtX4iEC9HI2GvLPd5FwLquABUeA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H1B1smKYfr1E1ovCKJz3N+uYBX4ZK37FOqrpmwgtjdCzp3V+1lHP/dmM+tX6MWgFUP2qyMJ4CfRPpGJrW2jakQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1GTjLSsuP3rXhJJf9eDk4gMBtt1FSchfjsudfL4mlUQwRSXuJUTdtiJnl6frZZ0Q9quLraigLnQ0FsBU5VgKFg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-CNqu/UGpciPh95OKboE5V9V2zkWXJRcfkPOgw5+Y+FLyf4z3v1+2vztXZJihz+FcufVDARy3uLS9kYispX0MFg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-j0TFLhDY3NJ6C6Dua3sVHXFC4dP2s4HI1k3j2lYaSIYF99wp7PJxv8dJchBGFeb88yRuAmxnTJRmptj5POk9oA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-s8c40QcFZlTiFRMYGhEMXzO5D8SH2GaNtmFkDDPIKn58ZxTTG2rOinbEv96fCMaKNgMEfZ70MLc2evyeL3vW8A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-/rSgJmPJqxvCS1zQyEdgQgJeWhZ35e5yBsRjsrGajUoFGMUqnttCT/rSqNlpmWlmE8KEYkDdpQ+o8nPT9aUHYg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kvhuvYejVp7p+H4TyMPz5izKafh7qHOQhoEEtBGQDpbzMH/9rLiRKKFCRH3bpP9VWGVb6QTEipoGwdTjGzOSqQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-2GYlTfxpPJifSSFX51q4qiigb0K5Fz+lKvV2H5jbnktowkaCto1d1UnTFnagx3fiahTNuV3AB4bilVYMj3hWyg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-yeoV/KYLipJLbdRn3IU39Zwcv4Y7CJyrm9oAjUKoocpzImH8jZJW663qmTW06yTi7NMVkwfTnRV2YmTMjxF+gQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-l7o4rFZ/UKhdzOceEAa6hsIEYDXqQRFlklTSWbzDCPnAKbiQVTHjOc11f+meiaKBHN5lE7EOpQDJxm2V4u1Bcw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-1emGMn880UbnaU2n4Dv78iuLpUSc8kAstImFN8k5zgBOIsOOkelyvCA8Y8A5aB2otPsNm5o6ueAJpucfiWA0ng=="],
"@opentui/react": ["@opentui/react@workspace:packages/react"],
+1 -1
View File
@@ -142,8 +142,8 @@ export class TextRenderable extends Renderable {
const changed = this.selectionHelper.reevaluateSelection(this.width, this.height)
if (changed) {
this.syncSelectionToTextBuffer()
this.needsUpdate()
}
this.needsUpdate()
}
private setupMeasureFunc(): void {
@@ -4,7 +4,7 @@ import { createSignal, onCleanup, onMount } from "solid-js";
export default function TextStyleScene() {
const [counter, setCounter] = createSignal(0);
let interval: NodeJS.Timer;
let interval: NodeJS.Timeout;
onMount(() => {
interval = setInterval(() => {
@@ -24,6 +24,10 @@ export default function TextStyleScene() {
<text>
And {bold("chunk arrays")} work {fg("#ff8c00")("as welll")}!! {italic(underline(`${counter()}`))}
</text>
You do not need to have a text node {counter()} as a parent when dealing with text {counter()}
<box>
{counter()} Mix in some {bold("more text")} {counter()}
</box>
</group>
);
}
+116 -47
View File
@@ -1,6 +1,5 @@
/* @refresh skip */
import {
GroupRenderable,
InputRenderable,
InputRenderableEvents,
Renderable,
@@ -16,16 +15,20 @@ import { createRenderer } from "solid-js/universal";
import { elements, type Element } from "./elements";
import { getNextId } from "./utils/id-counter";
const GHOST_NODE_TAG = "text-ghost" as const;
class TextNode {
id: string;
chunk: TextChunk;
parent?: Renderable;
textParent?: TextRenderable;
constructor(chunk: TextChunk) {
this.id = getNextId("text-node");
this.chunk = chunk;
}
}
const ChunkToTextNodeMap = new WeakMap<TextChunk, TextNode>();
type DomNode = Renderable | TextNode;
@@ -34,43 +37,106 @@ const log = (...args: any[]) => {
console.log("[Reconciler]", ...args);
};
function getOrCreateTextGhostNode(parent: Renderable, anchor?: DomNode | null): TextRenderable {
if (anchor instanceof TextNode && anchor.textParent) {
// prepend text to anchor
return anchor.textParent;
}
const children = parent.getChildren();
if (anchor instanceof Renderable) {
const anchorIndex = children.findIndex((el) => el.id === anchor.id);
const beforeAnchor = children[anchorIndex - 1];
if (beforeAnchor instanceof TextRenderable && beforeAnchor.id.startsWith(GHOST_NODE_TAG)) {
// append text to previous
return beforeAnchor;
}
}
const lastChild = children.at(-1);
if (lastChild instanceof TextRenderable && lastChild.id.startsWith(GHOST_NODE_TAG)) {
// Append text to last child if exists
return lastChild;
}
// Create a new ghost node
const ghostNode = new TextRenderable(getNextId(GHOST_NODE_TAG), {});
_insertNode(parent, ghostNode, anchor);
return ghostNode;
}
function insertTextNode(parent: DomNode, node: TextNode, anchor?: DomNode | null): void {
if (!(parent instanceof Renderable)) {
console.warn("Attaching text node to parent text node, impossible");
return;
}
log("Inserting text node:", node.id, "into parent:", parent.id, "with anchor:", anchor?.id);
let textParent: TextRenderable;
// get parent text renderable
if (!(parent instanceof TextRenderable)) {
textParent = getOrCreateTextGhostNode(parent, anchor);
} else {
textParent = parent;
}
node.textParent = textParent;
const styledText = textParent.content;
if (anchor && anchor instanceof TextNode) {
const anchorIndex = styledText.chunks.indexOf(anchor.chunk);
if (anchorIndex == -1) {
console.log("anchor not found");
return;
}
styledText.insert(node.chunk, anchorIndex);
} else {
const firstChunk = textParent.content.chunks[0];
// Handles the default unlinked chunk
if (firstChunk && !ChunkToTextNodeMap.has(firstChunk)) {
styledText.replace(node.chunk, firstChunk);
} else {
styledText.insert(node.chunk);
}
}
textParent.content = styledText;
node.parent = parent;
return;
}
function removeTextNode(parent: DomNode, node: TextNode): void {
if (!(parent instanceof Renderable)) {
// cleanup orphaned node
ChunkToTextNodeMap.delete(node.chunk);
return;
}
if (parent === node.textParent && parent instanceof TextRenderable) {
ChunkToTextNodeMap.delete(node.chunk);
const styledText = parent.content;
styledText.remove(node.chunk);
parent.content = styledText;
} else if (node.textParent) {
// check to remove ghost node
ChunkToTextNodeMap.delete(node.chunk);
const styledText = node.textParent.content;
styledText.remove(node.chunk);
if (styledText.chunks.length > 0) {
node.textParent.content = styledText;
} else {
node.parent?.remove(node.textParent.id);
node.textParent.destroyRecursively();
}
}
}
function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode | null): void {
log("Inserting node:", node.id, "into parent:", parent.id, "with anchor:", anchor?.id);
if (node instanceof TextNode) {
// Text nodes
if (!(parent instanceof TextRenderable)) {
console.warn(`Cannot insert text:"${node.chunk.plainText}" unless wrapped with a <text> element.`);
const ghostNode = new GroupRenderable(getNextId("ghost-group"), {});
// Sets display to contents so that it doesn't affect layout
ghostNode.getLayoutNode().yogaNode.setDisplay(2);
if (parent instanceof Renderable) {
parent.add(ghostNode);
}
return;
}
const styledText = parent.content;
if (anchor && anchor instanceof TextNode) {
const anchorIndex = styledText.chunks.indexOf(anchor.chunk);
if (anchorIndex == -1) {
console.log("anchor not found");
return;
}
styledText.insert(node.chunk, anchorIndex);
} else {
const firstChunk = parent.content.chunks[0];
// Handles the default unlinked chunk
if (firstChunk && !ChunkToTextNodeMap.has(firstChunk)) {
styledText.replace(node.chunk, firstChunk);
} else {
styledText.insert(node.chunk);
}
}
parent.content = styledText;
node.parent = parent;
return;
return insertTextNode(parent, node, anchor);
}
// Renderable nodes
@@ -79,7 +145,12 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode | null): v
}
if (anchor) {
const anchorIndex = parent.getChildren().findIndex((el) => el.id === anchor.id);
const anchorIndex = parent.getChildren().findIndex((el) => {
if (anchor instanceof TextNode) {
return el.id === anchor.textParent?.id;
}
return el.id === anchor.id;
});
parent.add(node, anchorIndex);
} else {
parent.add(node);
@@ -88,14 +159,12 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode | null): v
function _removeNode(parent: DomNode, node: DomNode): void {
log("Removing node:", node.id, "from parent:", parent.id);
if (parent instanceof TextRenderable && node instanceof TextNode) {
ChunkToTextNodeMap.delete(node.chunk);
const styledText = parent.content;
styledText.remove(node.chunk);
parent.content = styledText;
} else if (parent instanceof Renderable && node instanceof Renderable) {
node.destroyRecursively();
if (node instanceof TextNode) {
return removeTextNode(parent, node);
}
if (parent instanceof Renderable && node instanceof Renderable) {
parent.remove(node.id);
node.destroyRecursively();
}
}
@@ -145,15 +214,15 @@ export const {
plainText: value,
};
const parent = textNode.parent;
if (!parent) {
const textParent = textNode.textParent;
if (!textParent) {
log("No parent found for text node:", textNode.id);
return;
}
if (parent instanceof TextRenderable) {
const styledText = parent.content;
if (textParent instanceof TextRenderable) {
const styledText = textParent.content;
styledText.replace(newChunk, textNode.chunk);
parent.content = styledText;
textParent.content = styledText;
textNode.chunk = newChunk;
ChunkToTextNodeMap.set(newChunk, textNode);