mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
Fix renderable.add with index (#222)
Now delegating to and using similar logic as `insertBefore`
This commit is contained in:
@@ -1042,40 +1042,38 @@ export abstract class Renderable extends BaseRenderable {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (this.renderableMapById.has(renderable.id)) {
|
||||
console.warn(`A renderable with id ${renderable.id} already exists in ${this.id}, removing it`)
|
||||
this.remove(renderable.id)
|
||||
const anchorRenderable = index !== undefined ? this._childrenInLayoutOrder[index] : undefined
|
||||
|
||||
if (anchorRenderable) {
|
||||
return this.insertBefore(renderable, anchorRenderable)
|
||||
}
|
||||
|
||||
this.replaceParent(renderable)
|
||||
|
||||
const childLayoutNode = renderable.getLayoutNode()
|
||||
let insertedIndex: number
|
||||
if (index !== undefined) {
|
||||
insertedIndex = Math.max(0, Math.min(index, this._childrenInLayoutOrder.length))
|
||||
this._childrenInLayoutOrder.splice(index, 0, renderable)
|
||||
this._forceLayoutUpdateFor = this._childrenInLayoutOrder.slice(index)
|
||||
this.yogaNode.insertChild(childLayoutNode, insertedIndex)
|
||||
if (renderable.parent === this) {
|
||||
this.yogaNode.removeChild(renderable.getLayoutNode())
|
||||
this._childrenInLayoutOrder.splice(this._childrenInLayoutOrder.indexOf(renderable), 1)
|
||||
} else {
|
||||
insertedIndex = this._childrenInLayoutOrder.length
|
||||
this._childrenInLayoutOrder.push(renderable)
|
||||
this.yogaNode.insertChild(childLayoutNode, insertedIndex)
|
||||
}
|
||||
this.replaceParent(renderable)
|
||||
this.needsZIndexSort = true
|
||||
this.renderableMapById.set(renderable.id, renderable)
|
||||
this._childrenInZIndexOrder.push(renderable)
|
||||
|
||||
this.needsZIndexSort = true
|
||||
this.childrenPrimarySortDirty = true
|
||||
this.renderableMapById.set(renderable.id, renderable)
|
||||
this._childrenInZIndexOrder.push(renderable)
|
||||
if (typeof renderable.onLifecyclePass === "function") {
|
||||
this._ctx.registerLifecyclePass(renderable)
|
||||
}
|
||||
|
||||
if (typeof renderable.onLifecyclePass === "function") {
|
||||
this._ctx.registerLifecyclePass(renderable)
|
||||
if (renderable._liveCount > 0) {
|
||||
this.propagateLiveCount(renderable._liveCount)
|
||||
}
|
||||
}
|
||||
|
||||
this._newChildren.push(renderable)
|
||||
|
||||
if (renderable._liveCount > 0) {
|
||||
this.propagateLiveCount(renderable._liveCount)
|
||||
}
|
||||
const childLayoutNode = renderable.getLayoutNode()
|
||||
const insertedIndex = this._childrenInLayoutOrder.length
|
||||
this._childrenInLayoutOrder.push(renderable)
|
||||
this.yogaNode.insertChild(childLayoutNode, insertedIndex)
|
||||
|
||||
this.childrenPrimarySortDirty = true
|
||||
|
||||
this.requestRender()
|
||||
|
||||
@@ -1147,6 +1145,8 @@ export abstract class Renderable extends BaseRenderable {
|
||||
this._childrenInLayoutOrder.splice(insertedIndex, 0, renderable)
|
||||
this.yogaNode.insertChild(renderable.getLayoutNode(), insertedIndex)
|
||||
|
||||
this.requestRender()
|
||||
|
||||
return insertedIndex
|
||||
}
|
||||
|
||||
@@ -1524,7 +1524,7 @@ export class RootRenderable extends Renderable {
|
||||
|
||||
public render(buffer: OptimizedBuffer, deltaTime: number): void {
|
||||
if (!this.visible) return
|
||||
// console.log("RootRenderable render", this.width, this.height)
|
||||
|
||||
// 0. Run lifecycle pass
|
||||
for (const renderable of this._ctx.getLifecyclePasses()) {
|
||||
renderable.onLifecyclePass?.call(renderable)
|
||||
|
||||
@@ -71,4 +71,456 @@ describe("Renderable - insertBefore", () => {
|
||||
const reorderedFrame = captureFrame()
|
||||
expect(reorderedFrame).toMatchSnapshot("insertBefore reordered state")
|
||||
})
|
||||
|
||||
test("ensure .add with index works correctly", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 20,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
// Create 5 text renderables in order
|
||||
const items = [
|
||||
new TextRenderable(testRenderer, { id: "order-1", content: "First" }),
|
||||
new TextRenderable(testRenderer, { id: "order-2", content: "Second" }),
|
||||
new TextRenderable(testRenderer, { id: "order-3", content: "Third" }),
|
||||
new TextRenderable(testRenderer, { id: "order-4", content: "Fourth" }),
|
||||
new TextRenderable(testRenderer, { id: "order-5", content: "Fifth" }),
|
||||
]
|
||||
|
||||
// Add items in initial order [1, 2, 3, 4, 5]
|
||||
for (const item of items) {
|
||||
container.add(item)
|
||||
}
|
||||
|
||||
testRenderer.root.add(container)
|
||||
await renderOnce()
|
||||
|
||||
let children = container.getChildren()
|
||||
|
||||
expect(children.length).toBe(5)
|
||||
expect(children[0]?.id).toBe("order-1")
|
||||
expect(children[1]?.id).toBe("order-2")
|
||||
expect(children[2]?.id).toBe("order-3")
|
||||
expect(children[3]?.id).toBe("order-4")
|
||||
expect(children[4]?.id).toBe("order-5")
|
||||
|
||||
// Reproduce the EXACT sequence from SolidJS reconciler output:
|
||||
container.add(items[4]!, 1) // order-5 at index 1
|
||||
container.add(items[0]!) // order-1 at index undefined
|
||||
container.add(items[3]!, 2) // order-4 at index 2
|
||||
container.add(items[1]!, 4) // order-2 at index 4
|
||||
|
||||
await renderOnce()
|
||||
|
||||
children = container.getChildren()
|
||||
|
||||
// Expected: [5, 4, 3, 2, 1]
|
||||
expect(children.length).toBe(5)
|
||||
expect(children[0]?.id).toBe("order-5")
|
||||
expect(children[1]?.id).toBe("order-4")
|
||||
expect(children[2]?.id).toBe("order-3")
|
||||
expect(children[3]?.id).toBe("order-2")
|
||||
expect(children[4]?.id).toBe("order-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Renderable - add method", () => {
|
||||
test("basic add appends to end", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.add(item3)
|
||||
|
||||
const children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-1")
|
||||
expect(children[1]?.id).toBe("item-2")
|
||||
expect(children[2]?.id).toBe("item-3")
|
||||
})
|
||||
|
||||
test("add with index 0 inserts at beginning", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.add(item3, 0) // Insert at beginning
|
||||
|
||||
const children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-3")
|
||||
expect(children[1]?.id).toBe("item-1")
|
||||
expect(children[2]?.id).toBe("item-2")
|
||||
})
|
||||
|
||||
test("add with middle index inserts correctly", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.add(item3, 1) // Insert in middle
|
||||
|
||||
const children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-1")
|
||||
expect(children[1]?.id).toBe("item-3")
|
||||
expect(children[2]?.id).toBe("item-2")
|
||||
})
|
||||
|
||||
test("add with large index appends to end", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.add(item3, 999) // Out of bounds index
|
||||
|
||||
const children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-1")
|
||||
expect(children[1]?.id).toBe("item-2")
|
||||
expect(children[2]?.id).toBe("item-3")
|
||||
})
|
||||
|
||||
test("add returns correct index", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
const idx1 = container.add(item1)
|
||||
const idx2 = container.add(item2)
|
||||
const idx3 = container.add(item3, 1)
|
||||
|
||||
expect(idx1).toBe(0)
|
||||
expect(idx2).toBe(1)
|
||||
expect(idx3).toBe(1) // Inserted at index 1
|
||||
})
|
||||
|
||||
test("add null/undefined returns -1", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const idx1 = container.add(null as any)
|
||||
const idx2 = container.add(undefined as any)
|
||||
|
||||
expect(idx1).toBe(-1)
|
||||
expect(idx2).toBe(-1)
|
||||
expect(container.getChildrenCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("re-adding existing child moves it", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.add(item3)
|
||||
|
||||
// Re-add item1 to end
|
||||
container.add(item1)
|
||||
|
||||
let children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-2")
|
||||
expect(children[1]?.id).toBe("item-3")
|
||||
expect(children[2]?.id).toBe("item-1")
|
||||
|
||||
// Re-add item3 to beginning
|
||||
container.add(item3, 0)
|
||||
|
||||
children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-3")
|
||||
expect(children[1]?.id).toBe("item-2")
|
||||
expect(children[2]?.id).toBe("item-1")
|
||||
})
|
||||
|
||||
test("adding child from another parent removes it from old parent", async () => {
|
||||
const container1 = new BoxRenderable(testRenderer, {
|
||||
id: "container-1",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const container2 = new BoxRenderable(testRenderer, {
|
||||
id: "container-2",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item = new TextRenderable(testRenderer, { id: "item", content: "A" })
|
||||
|
||||
container1.add(item)
|
||||
expect(container1.getChildrenCount()).toBe(1)
|
||||
expect(item.parent).toBe(container1)
|
||||
|
||||
container2.add(item)
|
||||
expect(container1.getChildrenCount()).toBe(0)
|
||||
expect(container2.getChildrenCount()).toBe(1)
|
||||
expect(item.parent).toBe(container2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Renderable - insertBefore method", () => {
|
||||
test("insertBefore with null anchor appends to end", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.insertBefore(item3, null as any)
|
||||
|
||||
const children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[2]?.id).toBe("item-3")
|
||||
})
|
||||
|
||||
test("insertBefore inserts at correct position", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item3)
|
||||
container.insertBefore(item2, item3) // Insert item2 before item3
|
||||
|
||||
const children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-1")
|
||||
expect(children[1]?.id).toBe("item-2")
|
||||
expect(children[2]?.id).toBe("item-3")
|
||||
})
|
||||
|
||||
test("insertBefore at beginning", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.insertBefore(item3, item1) // Insert before first item
|
||||
|
||||
const children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-3")
|
||||
expect(children[1]?.id).toBe("item-1")
|
||||
expect(children[2]?.id).toBe("item-2")
|
||||
})
|
||||
|
||||
test("insertBefore moves existing child", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item2)
|
||||
container.add(item3)
|
||||
|
||||
// Move item3 before item1
|
||||
container.insertBefore(item3, item1)
|
||||
|
||||
let children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-3")
|
||||
expect(children[1]?.id).toBe("item-1")
|
||||
expect(children[2]?.id).toBe("item-2")
|
||||
|
||||
// Move item1 before item2
|
||||
container.insertBefore(item1, item2)
|
||||
|
||||
children = container.getChildren()
|
||||
expect(children.length).toBe(3)
|
||||
expect(children[0]?.id).toBe("item-3")
|
||||
expect(children[1]?.id).toBe("item-1")
|
||||
expect(children[2]?.id).toBe("item-2")
|
||||
})
|
||||
|
||||
test("insertBefore with invalid anchor throws error", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const notAChild = new TextRenderable(testRenderer, { id: "not-child", content: "X" })
|
||||
|
||||
container.add(item1)
|
||||
|
||||
expect(() => {
|
||||
container.insertBefore(item2, notAChild)
|
||||
}).toThrow("Anchor does not exist")
|
||||
})
|
||||
|
||||
test("insertBefore returns correct index", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const item1 = new TextRenderable(testRenderer, { id: "item-1", content: "A" })
|
||||
const item2 = new TextRenderable(testRenderer, { id: "item-2", content: "B" })
|
||||
const item3 = new TextRenderable(testRenderer, { id: "item-3", content: "C" })
|
||||
|
||||
container.add(item1)
|
||||
container.add(item3)
|
||||
|
||||
const idx = container.insertBefore(item2, item3)
|
||||
expect(idx).toBe(1)
|
||||
})
|
||||
|
||||
test("insertBefore with null object returns -1", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const anchor = new TextRenderable(testRenderer, { id: "anchor", content: "A" })
|
||||
container.add(anchor)
|
||||
|
||||
const idx = container.insertBefore(null as any, anchor)
|
||||
expect(idx).toBe(-1)
|
||||
})
|
||||
|
||||
test("complex reordering scenario", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const items = [
|
||||
new TextRenderable(testRenderer, { id: "A", content: "A" }),
|
||||
new TextRenderable(testRenderer, { id: "B", content: "B" }),
|
||||
new TextRenderable(testRenderer, { id: "C", content: "C" }),
|
||||
new TextRenderable(testRenderer, { id: "D", content: "D" }),
|
||||
new TextRenderable(testRenderer, { id: "E", content: "E" }),
|
||||
]
|
||||
|
||||
// Initial: A, B, C, D, E
|
||||
items.forEach((item) => container.add(item))
|
||||
|
||||
let children = container.getChildren()
|
||||
expect(children.map((c) => c.id)).toEqual(["A", "B", "C", "D", "E"])
|
||||
|
||||
// Move E before B: A, E, B, C, D
|
||||
container.insertBefore(items[4]!, items[1]!)
|
||||
children = container.getChildren()
|
||||
expect(children.map((c) => c.id)).toEqual(["A", "E", "B", "C", "D"])
|
||||
|
||||
// Move A before D: E, B, C, A, D
|
||||
container.insertBefore(items[0]!, items[3]!)
|
||||
children = container.getChildren()
|
||||
expect(children.map((c) => c.id)).toEqual(["E", "B", "C", "A", "D"])
|
||||
|
||||
// Move C before E: C, E, B, A, D
|
||||
container.insertBefore(items[2]!, items[4]!)
|
||||
children = container.getChildren()
|
||||
expect(children.map((c) => c.id)).toEqual(["C", "E", "B", "A", "D"])
|
||||
})
|
||||
|
||||
test("multiple sequential adds and inserts", async () => {
|
||||
const container = new BoxRenderable(testRenderer, {
|
||||
id: "container",
|
||||
width: 10,
|
||||
height: 10,
|
||||
})
|
||||
|
||||
const items = [
|
||||
new TextRenderable(testRenderer, { id: "1", content: "1" }),
|
||||
new TextRenderable(testRenderer, { id: "2", content: "2" }),
|
||||
new TextRenderable(testRenderer, { id: "3", content: "3" }),
|
||||
new TextRenderable(testRenderer, { id: "4", content: "4" }),
|
||||
]
|
||||
|
||||
container.add(items[0]!)
|
||||
container.add(items[1]!)
|
||||
expect(container.getChildren().map((c) => c.id)).toEqual(["1", "2"])
|
||||
|
||||
container.insertBefore(items[2]!, items[1]!)
|
||||
expect(container.getChildren().map((c) => c.id)).toEqual(["1", "3", "2"])
|
||||
|
||||
container.add(items[3]!, 0)
|
||||
expect(container.getChildren().map((c) => c.id)).toEqual(["4", "1", "3", "2"])
|
||||
|
||||
// Move "2" before "4"
|
||||
container.insertBefore(items[1]!, items[3]!)
|
||||
expect(container.getChildren().map((c) => c.id)).toEqual(["2", "4", "1", "3"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, Match, onMount, Switch } from "solid-js"
|
||||
import { Session } from "../session.tsx"
|
||||
import { SplitModeDemo } from "./animation-demo.tsx"
|
||||
import AutocompleteDemo from "./autocomplete-demo.tsx"
|
||||
import { CodeDemo } from "./code-demo.tsx"
|
||||
import ExtendDemo from "./extend-demo.tsx"
|
||||
import InputScene from "./input-demo.tsx"
|
||||
@@ -29,6 +30,11 @@ const EXAMPLES = [
|
||||
description: "Interactive InputElement demo with validation and multiple fields",
|
||||
scene: "input-demo",
|
||||
},
|
||||
{
|
||||
name: "Autocomplete Demo",
|
||||
description: "@ mention autocomplete with keyboard navigation",
|
||||
scene: "autocomplete-demo",
|
||||
},
|
||||
{
|
||||
name: "Mouse demo",
|
||||
description: "Mouse interaction",
|
||||
@@ -135,6 +141,9 @@ const ExampleSelector = () => {
|
||||
<Match when={selectedScene() === "input-demo"}>
|
||||
<InputScene />
|
||||
</Match>
|
||||
<Match when={selectedScene() === "autocomplete-demo"}>
|
||||
<AutocompleteDemo />
|
||||
</Match>
|
||||
<Match when={selectedScene() === "mouse-demo"}>
|
||||
<MouseScene />
|
||||
</Match>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { type InputRenderable, type BoxRenderable, type KeyEvent, TextAttributes } from "@opentui/core"
|
||||
import { createSignal, createMemo, For, Show, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
|
||||
type AutocompleteOption = {
|
||||
display: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const SAMPLE_OPTIONS: AutocompleteOption[] = [
|
||||
{ display: "alice", description: "Alice Johnson" },
|
||||
{ display: "bob", description: "Bob Smith" },
|
||||
{ display: "charlie", description: "Charlie Brown" },
|
||||
{ display: "diana", description: "Diana Prince" },
|
||||
{ display: "eve", description: "Eve Anderson" },
|
||||
{ display: "frank", description: "Frank Miller" },
|
||||
{ display: "grace", description: "Grace Hopper" },
|
||||
{ display: "henry", description: "Henry Ford" },
|
||||
{ display: "iris", description: "Iris Chang" },
|
||||
{ display: "jack", description: "Jack Dorsey" },
|
||||
{ display: "karen", description: "Karen Walker" },
|
||||
{ display: "leo", description: "Leo Martinez" },
|
||||
{ display: "maria", description: "Maria Garcia" },
|
||||
{ display: "noah", description: "Noah Wilson" },
|
||||
{ display: "olivia", description: "Olivia Taylor" },
|
||||
{ display: "peter", description: "Peter Parker" },
|
||||
{ display: "quinn", description: "Quinn Roberts" },
|
||||
{ display: "rachel", description: "Rachel Green" },
|
||||
{ display: "sam", description: "Sam Anderson" },
|
||||
{ display: "tina", description: "Tina Turner" },
|
||||
{ display: "uma", description: "Uma Thurman" },
|
||||
{ display: "victor", description: "Victor Hugo" },
|
||||
{ display: "wendy", description: "Wendy Williams" },
|
||||
{ display: "xavier", description: "Xavier Thompson" },
|
||||
{ display: "yuki", description: "Yuki Tanaka" },
|
||||
{ display: "zoe", description: "Zoe Chen" },
|
||||
{ display: "adam", description: "Adam Davis" },
|
||||
{ display: "bella", description: "Bella Rodriguez" },
|
||||
{ display: "carlos", description: "Carlos Sanchez" },
|
||||
{ display: "derek", description: "Derek Lee" },
|
||||
{ display: "emma", description: "Emma Watson" },
|
||||
{ display: "felix", description: "Felix White" },
|
||||
{ display: "gina", description: "Gina Lopez" },
|
||||
{ display: "harry", description: "Harry Potter" },
|
||||
{ display: "isla", description: "Isla Fisher" },
|
||||
{ display: "james", description: "James Bond" },
|
||||
{ display: "kate", description: "Kate Middleton" },
|
||||
{ display: "luke", description: "Luke Skywalker" },
|
||||
{ display: "maya", description: "Maya Angelou" },
|
||||
{ display: "nick", description: "Nick Fury" },
|
||||
{ display: "oscar", description: "Oscar Wilde" },
|
||||
{ display: "paul", description: "Paul McCartney" },
|
||||
{ display: "queenie", description: "Queenie Goldstein" },
|
||||
{ display: "ryan", description: "Ryan Reynolds" },
|
||||
{ display: "sara", description: "Sara Connor" },
|
||||
{ display: "tony", description: "Tony Stark" },
|
||||
{ display: "ursula", description: "Ursula Le Guin" },
|
||||
{ display: "vera", description: "Vera Wang" },
|
||||
{ display: "will", description: "Will Smith" },
|
||||
{ display: "xena", description: "Xena Warrior" },
|
||||
{ display: "yasmin", description: "Yasmin Khan" },
|
||||
{ display: "zack", description: "Zack Morris" },
|
||||
{ display: "amber", description: "Amber Heard" },
|
||||
{ display: "blake", description: "Blake Lively" },
|
||||
{ display: "chris", description: "Chris Evans" },
|
||||
{ display: "donna", description: "Donna Noble" },
|
||||
{ display: "ethan", description: "Ethan Hunt" },
|
||||
{ display: "fiona", description: "Fiona Apple" },
|
||||
{ display: "george", description: "George Clooney" },
|
||||
{ display: "hannah", description: "Hannah Montana" },
|
||||
{ display: "ivan", description: "Ivan Drago" },
|
||||
{ display: "julia", description: "Julia Roberts" },
|
||||
{ display: "keith", description: "Keith Richards" },
|
||||
{ display: "linda", description: "Linda Hamilton" },
|
||||
{ display: "mark", description: "Mark Zuckerberg" },
|
||||
{ display: "nina", description: "Nina Simone" },
|
||||
{ display: "oliver", description: "Oliver Twist" },
|
||||
{ display: "penny", description: "Penny Lane" },
|
||||
{ display: "quincy", description: "Quincy Jones" },
|
||||
{ display: "rose", description: "Rose Tyler" },
|
||||
{ display: "steve", description: "Steve Jobs" },
|
||||
{ display: "tracy", description: "Tracy Chapman" },
|
||||
{ display: "umar", description: "Umar Johnson" },
|
||||
{ display: "violet", description: "Violet Baudelaire" },
|
||||
{ display: "wade", description: "Wade Wilson" },
|
||||
{ display: "xander", description: "Xander Harris" },
|
||||
{ display: "yvonne", description: "Yvonne Strahovski" },
|
||||
{ display: "zeus", description: "Zeus King" },
|
||||
]
|
||||
|
||||
const AutocompleteDemo = () => {
|
||||
const renderer = useRenderer()
|
||||
let input: InputRenderable
|
||||
let anchor: BoxRenderable
|
||||
|
||||
const [inputValue, setInputValue] = createSignal("")
|
||||
const [store, setStore] = createStore({
|
||||
visible: false,
|
||||
selected: 0,
|
||||
index: 0,
|
||||
position: { x: 0, y: 0, width: 0 },
|
||||
})
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (!store.visible) return ""
|
||||
return inputValue().substring(store.index + 1)
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const filterText = filter().toLowerCase()
|
||||
if (!filterText) return SAMPLE_OPTIONS.slice(0, 8)
|
||||
return SAMPLE_OPTIONS.filter(
|
||||
(opt) => opt.display.toLowerCase().includes(filterText) || opt.description?.toLowerCase().includes(filterText),
|
||||
).slice(0, 8)
|
||||
})
|
||||
|
||||
const height = createMemo(() => {
|
||||
if (options().length) return Math.min(8, options().length)
|
||||
return 1
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
if (!options().length) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = options().length - 1
|
||||
if (next >= options().length) next = 0
|
||||
setStore("selected", next)
|
||||
}
|
||||
|
||||
function select() {
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
const newValue = inputValue().slice(0, store.index) + "@" + selected.display + " "
|
||||
setInputValue(newValue)
|
||||
input.value = newValue
|
||||
input.cursorPosition = newValue.length
|
||||
hide()
|
||||
}
|
||||
|
||||
function show() {
|
||||
setStore({
|
||||
visible: true,
|
||||
selected: 0,
|
||||
index: input.cursorPosition,
|
||||
position: {
|
||||
x: anchor.x,
|
||||
y: anchor.y,
|
||||
width: anchor.width,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyEvent) {
|
||||
if (store.visible) {
|
||||
if (e.name === "up") {
|
||||
e.preventDefault()
|
||||
move(-1)
|
||||
}
|
||||
if (e.name === "down") {
|
||||
e.preventDefault()
|
||||
move(1)
|
||||
}
|
||||
if (e.name === "escape") {
|
||||
e.preventDefault()
|
||||
hide()
|
||||
}
|
||||
if (e.name === "return") {
|
||||
e.preventDefault()
|
||||
select()
|
||||
}
|
||||
} else {
|
||||
if (e.name === "@") {
|
||||
const last = inputValue().at(-1)
|
||||
if (last === " " || last === undefined) {
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(value: string) {
|
||||
setInputValue(value)
|
||||
if (store.visible && value.length <= store.index) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
renderer.setBackgroundColor("#1a1b26")
|
||||
input.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<box height="100%" width="100%" flexDirection="column" gap={1} padding={2}>
|
||||
<box>
|
||||
<text attributes={TextAttributes.BOLD} fg="#7aa2f7">
|
||||
Autocomplete Demo
|
||||
</text>
|
||||
<text attributes={TextAttributes.DIM} fg="#9aa5ce">
|
||||
Type @ to trigger autocomplete. Use arrow keys to navigate, Enter to select.
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box ref={(r) => (anchor = r)} flexDirection="column">
|
||||
<box border borderColor="#3b4261" padding={1}>
|
||||
<input
|
||||
ref={(r) => (input = r)}
|
||||
value={inputValue()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type @ to mention someone..."
|
||||
cursorColor="#7aa2f7"
|
||||
backgroundColor="#1a1b26"
|
||||
focusedBackgroundColor="#1a1b26"
|
||||
/>
|
||||
</box>
|
||||
|
||||
{/* Autocomplete popup */}
|
||||
<box
|
||||
visible={store.visible}
|
||||
position="absolute"
|
||||
top={store.position.y - height()}
|
||||
left={store.position.x}
|
||||
width={store.position.width}
|
||||
zIndex={100}
|
||||
border
|
||||
borderColor="#7aa2f7"
|
||||
>
|
||||
<box backgroundColor="#24283b" height={height()}>
|
||||
<For
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg="#9aa5ce">No matching items</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(option, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === store.selected ? "#7aa2f7" : undefined}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={index() === store.selected ? "#1a1b26" : "#c0caf5"}>@{option.display}</text>
|
||||
<Show when={option.description}>
|
||||
<text fg={index() === store.selected ? "#1a1b26" : "#9aa5ce"}> - {option.description}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box marginTop={2}>
|
||||
<text fg="#9aa5ce">Current input: </text>
|
||||
<text fg="#c0caf5">{inputValue() || "(empty)"}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export default AutocompleteDemo
|
||||
@@ -755,5 +755,76 @@ describe("SolidJS Renderer - Control Flow Components", () => {
|
||||
expect(anotherVisible).toBeDefined()
|
||||
expect(anotherVisible?.id).toBe("another-visible")
|
||||
})
|
||||
|
||||
it("REPRODUCE BUG: For component has incorrect ordering after array reordering", async () => {
|
||||
interface Option {
|
||||
id: string
|
||||
display: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const [options, setOptions] = createSignal<Option[]>([])
|
||||
|
||||
testSetup = await testRender(
|
||||
() => (
|
||||
<box id="container">
|
||||
<For each={options()}>
|
||||
{(option, index) => (
|
||||
<box id={`option-${option.id}`}>
|
||||
<text>
|
||||
{option.display}
|
||||
<Show when={option.description}>
|
||||
<span> - {option.description}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
),
|
||||
{ width: 50, height: 25 },
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
|
||||
// === BUG: Array reversal causes incorrect ordering ===
|
||||
const orderedItems = [
|
||||
{ id: "order-1", display: "First" },
|
||||
{ id: "order-2", display: "Second" },
|
||||
{ id: "order-3", display: "Third" },
|
||||
{ id: "order-4", display: "Fourth" },
|
||||
{ id: "order-5", display: "Fifth" },
|
||||
]
|
||||
|
||||
setOptions(orderedItems)
|
||||
await testSetup.renderOnce()
|
||||
|
||||
const container = testSetup.renderer.root.findDescendantById("container")!
|
||||
let children = container.getChildren()
|
||||
|
||||
// Verify initial order
|
||||
expect(children.length).toBe(5)
|
||||
expect(children[0]?.id).toBe("option-order-1")
|
||||
expect(children[1]?.id).toBe("option-order-2")
|
||||
expect(children[2]?.id).toBe("option-order-3")
|
||||
expect(children[3]?.id).toBe("option-order-4")
|
||||
expect(children[4]?.id).toBe("option-order-5")
|
||||
|
||||
// Reverse the array - THIS EXPOSES THE BUG
|
||||
setOptions([...orderedItems].reverse())
|
||||
await testSetup.renderOnce()
|
||||
|
||||
children = container.getChildren()
|
||||
|
||||
// BUG: The order is INCORRECT after reversing!
|
||||
// Expected: [order-5, order-4, order-3, order-2, order-1]
|
||||
// Actual might have swapped elements
|
||||
expect(children.length).toBe(5)
|
||||
expect(children[0]?.id).toBe("option-order-5")
|
||||
expect(children[1]?.id).toBe("option-order-4")
|
||||
expect(children[2]?.id).toBe("option-order-3")
|
||||
expect(children[3]?.id).toBe("option-order-2") // ← BUG: This might be order-1
|
||||
expect(children[4]?.id).toBe("option-order-1") // ← BUG: This might be order-2
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user