@effect-tui/react 0.16.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +9 -0
  2. package/dist/src/codeblock.d.ts +1 -1
  3. package/dist/src/codeblock.d.ts.map +1 -1
  4. package/dist/src/codeblock.js +2 -2
  5. package/dist/src/codeblock.js.map +1 -1
  6. package/dist/src/components/Markdown.js +3 -3
  7. package/dist/src/components/Markdown.js.map +1 -1
  8. package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
  9. package/dist/src/components/MultilineTextInput.js +133 -305
  10. package/dist/src/components/MultilineTextInput.js.map +1 -1
  11. package/dist/src/components/TextInput.d.ts.map +1 -1
  12. package/dist/src/components/TextInput.js +51 -98
  13. package/dist/src/components/TextInput.js.map +1 -1
  14. package/dist/src/components/text-editing.d.ts +61 -0
  15. package/dist/src/components/text-editing.d.ts.map +1 -1
  16. package/dist/src/components/text-editing.js +131 -0
  17. package/dist/src/components/text-editing.js.map +1 -1
  18. package/dist/src/hosts/base.d.ts +13 -2
  19. package/dist/src/hosts/base.d.ts.map +1 -1
  20. package/dist/src/hosts/base.js +74 -2
  21. package/dist/src/hosts/base.js.map +1 -1
  22. package/dist/src/hosts/box.d.ts +2 -2
  23. package/dist/src/hosts/box.d.ts.map +1 -1
  24. package/dist/src/hosts/box.js +29 -2
  25. package/dist/src/hosts/box.js.map +1 -1
  26. package/dist/src/hosts/canvas.d.ts +22 -2
  27. package/dist/src/hosts/canvas.d.ts.map +1 -1
  28. package/dist/src/hosts/canvas.js +99 -31
  29. package/dist/src/hosts/canvas.js.map +1 -1
  30. package/dist/src/hosts/codeblock.d.ts +8 -10
  31. package/dist/src/hosts/codeblock.d.ts.map +1 -1
  32. package/dist/src/hosts/codeblock.js +36 -33
  33. package/dist/src/hosts/codeblock.js.map +1 -1
  34. package/dist/src/hosts/flex-container.d.ts +2 -2
  35. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  36. package/dist/src/hosts/flex-container.js +17 -2
  37. package/dist/src/hosts/flex-container.js.map +1 -1
  38. package/dist/src/hosts/index.d.ts +1 -1
  39. package/dist/src/hosts/index.d.ts.map +1 -1
  40. package/dist/src/hosts/index.js.map +1 -1
  41. package/dist/src/hosts/overlay-item.d.ts +2 -2
  42. package/dist/src/hosts/overlay-item.d.ts.map +1 -1
  43. package/dist/src/hosts/overlay-item.js +7 -2
  44. package/dist/src/hosts/overlay-item.js.map +1 -1
  45. package/dist/src/hosts/overlay.d.ts +2 -2
  46. package/dist/src/hosts/overlay.d.ts.map +1 -1
  47. package/dist/src/hosts/overlay.js +2 -2
  48. package/dist/src/hosts/overlay.js.map +1 -1
  49. package/dist/src/hosts/scroll.d.ts +7 -2
  50. package/dist/src/hosts/scroll.d.ts.map +1 -1
  51. package/dist/src/hosts/scroll.js +126 -45
  52. package/dist/src/hosts/scroll.js.map +1 -1
  53. package/dist/src/hosts/single-child.d.ts.map +1 -1
  54. package/dist/src/hosts/single-child.js +2 -0
  55. package/dist/src/hosts/single-child.js.map +1 -1
  56. package/dist/src/hosts/spacer.d.ts +1 -1
  57. package/dist/src/hosts/spacer.d.ts.map +1 -1
  58. package/dist/src/hosts/spacer.js +6 -1
  59. package/dist/src/hosts/spacer.js.map +1 -1
  60. package/dist/src/hosts/text.d.ts +20 -15
  61. package/dist/src/hosts/text.d.ts.map +1 -1
  62. package/dist/src/hosts/text.js +104 -71
  63. package/dist/src/hosts/text.js.map +1 -1
  64. package/dist/src/hosts/zstack.d.ts +2 -2
  65. package/dist/src/hosts/zstack.d.ts.map +1 -1
  66. package/dist/src/hosts/zstack.js +7 -2
  67. package/dist/src/hosts/zstack.js.map +1 -1
  68. package/dist/src/index.d.ts +1 -1
  69. package/dist/src/index.d.ts.map +1 -1
  70. package/dist/src/internal/renderer/index.d.ts.map +1 -1
  71. package/dist/src/internal/renderer/index.js +41 -16
  72. package/dist/src/internal/renderer/index.js.map +1 -1
  73. package/dist/src/internal/renderer/types.d.ts +4 -0
  74. package/dist/src/internal/renderer/types.d.ts.map +1 -1
  75. package/dist/src/motion/hooks.d.ts +1 -1
  76. package/dist/src/motion/hooks.js +1 -1
  77. package/dist/src/reconciler/host-config.js +2 -2
  78. package/dist/src/reconciler/host-config.js.map +1 -1
  79. package/dist/src/reconciler/types.d.ts +5 -1
  80. package/dist/src/reconciler/types.d.ts.map +1 -1
  81. package/dist/src/utils/border.d.ts +1 -1
  82. package/dist/src/utils/border.d.ts.map +1 -1
  83. package/dist/src/utils/border.js +2 -0
  84. package/dist/src/utils/border.js.map +1 -1
  85. package/dist/src/utils/index.d.ts +2 -1
  86. package/dist/src/utils/index.d.ts.map +1 -1
  87. package/dist/src/utils/index.js +2 -1
  88. package/dist/src/utils/index.js.map +1 -1
  89. package/dist/src/utils/text-layout.d.ts +22 -0
  90. package/dist/src/utils/text-layout.d.ts.map +1 -0
  91. package/dist/src/utils/text-layout.js +37 -0
  92. package/dist/src/utils/text-layout.js.map +1 -0
  93. package/dist/src/utils/text-wrap.d.ts +26 -1
  94. package/dist/src/utils/text-wrap.d.ts.map +1 -1
  95. package/dist/src/utils/text-wrap.js +106 -11
  96. package/dist/src/utils/text-wrap.js.map +1 -1
  97. package/dist/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +2 -2
  99. package/src/codeblock.tsx +2 -2
  100. package/src/components/Markdown.tsx +3 -3
  101. package/src/components/MultilineTextInput.tsx +138 -344
  102. package/src/components/TextInput.tsx +54 -99
  103. package/src/components/text-editing.ts +180 -0
  104. package/src/hosts/base.ts +86 -3
  105. package/src/hosts/box.ts +37 -2
  106. package/src/hosts/canvas.ts +120 -31
  107. package/src/hosts/codeblock.ts +46 -33
  108. package/src/hosts/flex-container.ts +21 -2
  109. package/src/hosts/index.ts +1 -1
  110. package/src/hosts/overlay-item.ts +8 -2
  111. package/src/hosts/overlay.ts +2 -2
  112. package/src/hosts/scroll.ts +142 -45
  113. package/src/hosts/single-child.ts +2 -0
  114. package/src/hosts/spacer.ts +6 -1
  115. package/src/hosts/text.ts +122 -75
  116. package/src/hosts/zstack.ts +7 -2
  117. package/src/index.ts +1 -1
  118. package/src/internal/renderer/index.ts +53 -20
  119. package/src/internal/renderer/types.ts +4 -0
  120. package/src/motion/hooks.ts +1 -1
  121. package/src/reconciler/host-config.ts +2 -2
  122. package/src/reconciler/types.ts +7 -1
  123. package/src/utils/border.ts +11 -1
  124. package/src/utils/index.ts +15 -1
  125. package/src/utils/text-layout.ts +65 -0
  126. package/src/utils/text-wrap.ts +135 -13
@@ -7,12 +7,15 @@ import {
7
7
  deleteCharBackward,
8
8
  deleteCharForward,
9
9
  deleteWordBackward,
10
+ deleteWordForward,
10
11
  insertText,
11
12
  killToEnd,
12
13
  killToStart,
13
14
  matchNextWord,
14
15
  matchPrevWord,
16
+ resolveTextInputAction,
15
17
  type TextState,
18
+ type TextKeyEvent,
16
19
  transposeChars,
17
20
  } from "./text-editing.js"
18
21
 
@@ -97,17 +100,20 @@ export function TextInput({
97
100
  }, [value, cursorPos])
98
101
 
99
102
  const handleKey = useCallback(
100
- (key: { name: string; text?: string; ctrl?: boolean; meta?: boolean; shift?: boolean }) => {
103
+ (key: TextKeyEvent) => {
101
104
  if (!focused) return
102
105
 
106
+ const action = resolveTextInputAction(key)
107
+ if (!action) return
108
+
103
109
  const state: TextState = { text: value, cursor: cursorPos, killRing }
104
110
 
105
111
  // Helper to apply an edit result
106
- const applyEdit = (result: { state: TextState; changed: boolean }) => {
112
+ const applyEdit = (result: { state: TextState; changed: boolean }, options?: { keepKillRing?: boolean }) => {
107
113
  if (result.changed) {
108
114
  onChange(result.state.text)
109
115
  setCursorPos(result.state.cursor)
110
- if (result.state.killRing !== killRing) {
116
+ if (!options?.keepKillRing && result.state.killRing !== killRing) {
111
117
  setKillRing(result.state.killRing)
112
118
  }
113
119
  }
@@ -126,114 +132,63 @@ export function TextInput({
126
132
  setCursorPos(match ? cursorPos + match.length : value.length)
127
133
  }
128
134
 
129
- switch (key.name) {
130
- case "left":
131
- if (key.meta) {
132
- moveToPrevWord()
133
- } else {
134
- setCursorPos(Math.max(0, cursorPos - 1))
135
- }
135
+ switch (action.type) {
136
+ case "move-left":
137
+ setCursorPos(Math.max(0, cursorPos - 1))
136
138
  break
137
-
138
- case "right":
139
- if (key.meta) {
140
- moveToNextWord()
141
- } else {
142
- setCursorPos(Math.min(value.length, cursorPos + 1))
143
- }
139
+ case "move-right":
140
+ setCursorPos(Math.min(value.length, cursorPos + 1))
144
141
  break
145
-
146
- case "home":
142
+ case "move-word-left":
143
+ moveToPrevWord()
144
+ break
145
+ case "move-word-right":
146
+ moveToNextWord()
147
+ break
148
+ case "move-start":
149
+ case "move-doc-start":
147
150
  setCursorPos(0)
148
151
  break
149
-
150
- case "end":
152
+ case "move-end":
153
+ case "move-doc-end":
151
154
  setCursorPos(value.length)
152
155
  break
153
-
154
- case "backspace":
155
- if (key.meta) {
156
- // Option+Backspace: Delete to previous word boundary
157
- const beforeCursor = value.slice(0, cursorPos)
158
- const match = matchPrevWord(beforeCursor)
159
- if (match) {
160
- const newPos = cursorPos - match.length
161
- onChange(value.slice(0, newPos) + value.slice(cursorPos))
162
- setCursorPos(newPos)
163
- } else if (cursorPos > 0) {
164
- onChange(value.slice(cursorPos))
165
- setCursorPos(0)
166
- }
167
- } else {
168
- applyEdit(deleteCharBackward(state))
169
- }
156
+ case "delete-backward":
157
+ applyEdit(deleteCharBackward(state))
170
158
  break
171
-
172
- case "delete":
173
- if (key.meta) {
174
- // Option+Delete: Delete to next word boundary
175
- const afterCursor = value.slice(cursorPos)
176
- const match = matchNextWord(afterCursor)
177
- if (match) {
178
- onChange(value.slice(0, cursorPos) + value.slice(cursorPos + match.length))
179
- } else if (cursorPos < value.length) {
180
- onChange(value.slice(0, cursorPos))
181
- }
182
- } else {
183
- applyEdit(deleteCharForward(state))
184
- }
159
+ case "delete-forward":
160
+ applyEdit(deleteCharForward(state))
161
+ break
162
+ case "delete-word-backward":
163
+ applyEdit(deleteWordBackward(state), { keepKillRing: action.scope === "line" })
164
+ break
165
+ case "delete-word-forward":
166
+ applyEdit(deleteWordForward(state), { keepKillRing: action.scope === "line" })
167
+ break
168
+ case "kill-to-end":
169
+ applyEdit(killToEnd(state))
170
+ break
171
+ case "kill-to-start":
172
+ applyEdit(killToStart(state))
173
+ break
174
+ case "transpose":
175
+ applyEdit(transposeChars(state))
176
+ break
177
+ case "yank":
178
+ applyEdit(insertText(state, killRing))
179
+ break
180
+ case "insert":
181
+ applyEdit(insertText(state, action.text))
185
182
  break
186
-
187
183
  case "enter":
184
+ case "submit":
188
185
  onSubmit?.(value)
189
186
  break
190
-
191
- case "escape":
187
+ case "cancel":
192
188
  onCancel?.()
193
189
  break
194
-
195
- case "char":
196
- case "space":
197
- if (key.ctrl && key.text) {
198
- // Emacs-style keybindings
199
- switch (key.text) {
200
- case "a":
201
- setCursorPos(0)
202
- break
203
- case "e":
204
- setCursorPos(value.length)
205
- break
206
- case "b":
207
- setCursorPos(Math.max(0, cursorPos - 1))
208
- break
209
- case "f":
210
- setCursorPos(Math.min(value.length, cursorPos + 1))
211
- break
212
- case "d":
213
- applyEdit(deleteCharForward(state))
214
- break
215
- case "h":
216
- applyEdit(deleteCharBackward(state))
217
- break
218
- case "k":
219
- applyEdit(killToEnd(state))
220
- break
221
- case "u":
222
- applyEdit(killToStart(state))
223
- break
224
- case "w":
225
- applyEdit(deleteWordBackward(state))
226
- break
227
- case "t":
228
- applyEdit(transposeChars(state))
229
- break
230
- case "y":
231
- applyEdit(insertText(state, killRing))
232
- break
233
- }
234
- } else if (key.text && !key.meta) {
235
- applyEdit(insertText(state, key.text))
236
- }
190
+ case "move-up":
191
+ case "move-down":
237
192
  break
238
193
  }
239
194
  },
@@ -268,7 +223,7 @@ export function TextInput({
268
223
 
269
224
  // Clear the line with background color
270
225
  if (bg !== undefined) {
271
- ctx.fill(0, 0, ctx.width, 1, " ", { bg })
226
+ ctx.fillRect(0, 0, ctx.width, 1, " ", { bg })
272
227
  }
273
228
 
274
229
  // Calculate scroll offset to keep cursor visible
@@ -25,6 +25,40 @@ export interface MultilineState {
25
25
  killRing: string
26
26
  }
27
27
 
28
+ export interface TextKeyEvent {
29
+ name: string
30
+ text?: string
31
+ ctrl?: boolean
32
+ meta?: boolean
33
+ shift?: boolean
34
+ }
35
+
36
+ export type WordScope = "line" | "document"
37
+
38
+ export type TextInputAction =
39
+ | { type: "move-left" }
40
+ | { type: "move-right" }
41
+ | { type: "move-up" }
42
+ | { type: "move-down" }
43
+ | { type: "move-start" }
44
+ | { type: "move-end" }
45
+ | { type: "move-doc-start" }
46
+ | { type: "move-doc-end" }
47
+ | { type: "move-word-left" }
48
+ | { type: "move-word-right" }
49
+ | { type: "delete-backward" }
50
+ | { type: "delete-forward" }
51
+ | { type: "delete-word-backward"; scope: WordScope }
52
+ | { type: "delete-word-forward"; scope: WordScope }
53
+ | { type: "kill-to-end" }
54
+ | { type: "kill-to-start" }
55
+ | { type: "transpose" }
56
+ | { type: "yank" }
57
+ | { type: "insert"; text: string }
58
+ | { type: "enter" }
59
+ | { type: "submit" }
60
+ | { type: "cancel" }
61
+
28
62
  /** Result of an edit operation */
29
63
  export interface EditResult<T> {
30
64
  state: T
@@ -37,6 +71,79 @@ export interface EditResult<T> {
37
71
 
38
72
  export { matchNextWord, matchPrevWord }
39
73
 
74
+ // ============================================================================
75
+ // Keybinding resolution (shared by TextInput and MultilineTextInput)
76
+ // ============================================================================
77
+
78
+ export function resolveTextInputAction(key: TextKeyEvent): TextInputAction | null {
79
+ switch (key.name) {
80
+ case "left":
81
+ return key.meta ? { type: "move-word-left" } : { type: "move-left" }
82
+ case "right":
83
+ return key.meta ? { type: "move-word-right" } : { type: "move-right" }
84
+ case "up":
85
+ return key.meta ? { type: "move-doc-start" } : { type: "move-up" }
86
+ case "down":
87
+ return key.meta ? { type: "move-doc-end" } : { type: "move-down" }
88
+ case "home":
89
+ return { type: "move-start" }
90
+ case "end":
91
+ return { type: "move-end" }
92
+ case "backspace":
93
+ return key.meta
94
+ ? { type: "delete-word-backward", scope: "line" }
95
+ : { type: "delete-backward" }
96
+ case "delete":
97
+ return key.meta
98
+ ? { type: "delete-word-forward", scope: "line" }
99
+ : { type: "delete-forward" }
100
+ case "enter":
101
+ return key.ctrl || key.meta ? { type: "submit" } : { type: "enter" }
102
+ case "escape":
103
+ return { type: "cancel" }
104
+ case "char":
105
+ case "space": {
106
+ if (key.ctrl && key.text) {
107
+ switch (key.text) {
108
+ case "a":
109
+ return { type: "move-start" }
110
+ case "e":
111
+ return { type: "move-end" }
112
+ case "b":
113
+ return { type: "move-left" }
114
+ case "f":
115
+ return { type: "move-right" }
116
+ case "n":
117
+ return { type: "move-down" }
118
+ case "p":
119
+ return { type: "move-up" }
120
+ case "d":
121
+ return { type: "delete-forward" }
122
+ case "h":
123
+ return { type: "delete-backward" }
124
+ case "k":
125
+ return { type: "kill-to-end" }
126
+ case "u":
127
+ return { type: "kill-to-start" }
128
+ case "w":
129
+ return { type: "delete-word-backward", scope: "document" }
130
+ case "t":
131
+ return { type: "transpose" }
132
+ case "y":
133
+ return { type: "yank" }
134
+ }
135
+ }
136
+
137
+ if (key.text && !key.meta) {
138
+ return { type: "insert", text: key.text }
139
+ }
140
+ break
141
+ }
142
+ }
143
+
144
+ return null
145
+ }
146
+
40
147
  // ============================================================================
41
148
  // Single-line operations (for TextInput)
42
149
  // ============================================================================
@@ -64,6 +171,35 @@ export function deleteWordBackward(state: TextState): EditResult<TextState> {
64
171
  }
65
172
  }
66
173
 
174
+ /** Delete word forward */
175
+ export function deleteWordForward(state: TextState): EditResult<TextState> {
176
+ const { text, cursor } = state
177
+ if (cursor >= text.length) {
178
+ return { state, changed: false }
179
+ }
180
+
181
+ const afterCursor = text.slice(cursor)
182
+ const match = matchNextWord(afterCursor)
183
+
184
+ if (match) {
185
+ const newText = text.slice(0, cursor) + text.slice(cursor + match.length)
186
+ return {
187
+ state: { text: newText, cursor, killRing: match },
188
+ changed: true,
189
+ }
190
+ }
191
+
192
+ const killed = text.slice(cursor)
193
+ if (!killed) {
194
+ return { state, changed: false }
195
+ }
196
+
197
+ return {
198
+ state: { text: text.slice(0, cursor), cursor, killRing: killed },
199
+ changed: true,
200
+ }
201
+ }
202
+
67
203
  /** Kill to end of line */
68
204
  export function killToEnd(state: TextState): EditResult<TextState> {
69
205
  const { text, cursor } = state
@@ -247,6 +383,50 @@ export function deleteWordBackwardMultiline(state: MultilineState): EditResult<M
247
383
  return { state, changed: false }
248
384
  }
249
385
 
386
+ /** Delete word forward in multiline (line-scoped) */
387
+ export function deleteWordForwardMultiline(state: MultilineState): EditResult<MultilineState> {
388
+ const { lines, cursor } = state
389
+ const line = lines[cursor.row]
390
+ const lineLen = graphemes(line).length
391
+
392
+ if (cursor.col < lineLen) {
393
+ const charIdx = graphemeColToCharIdx(line, cursor.col)
394
+ const afterCursor = line.slice(charIdx)
395
+ const match = matchNextWord(afterCursor)
396
+ const newLines = [...lines]
397
+
398
+ if (match) {
399
+ const newLine = line.slice(0, charIdx) + line.slice(charIdx + match.length)
400
+ newLines[cursor.row] = newLine
401
+ return {
402
+ state: {
403
+ lines: newLines,
404
+ cursor,
405
+ killRing: match,
406
+ },
407
+ changed: true,
408
+ }
409
+ }
410
+
411
+ const killed = line.slice(charIdx)
412
+ if (!killed) {
413
+ return { state, changed: false }
414
+ }
415
+
416
+ newLines[cursor.row] = line.slice(0, charIdx)
417
+ return {
418
+ state: {
419
+ lines: newLines,
420
+ cursor,
421
+ killRing: killed,
422
+ },
423
+ changed: true,
424
+ }
425
+ }
426
+
427
+ return { state, changed: false }
428
+ }
429
+
250
430
  /** Kill to end of line in multiline */
251
431
  export function killToEndMultiline(state: MultilineState): EditResult<MultilineState> {
252
432
  const { lines, cursor } = state
package/src/hosts/base.ts CHANGED
@@ -67,6 +67,12 @@ export abstract class BaseHost implements HostInstance {
67
67
  parent: HostInstance | null = null
68
68
  children: HostInstance[] = []
69
69
  rect: Rect | null = null
70
+ private _layoutDirty = true
71
+ private _renderDirty = true
72
+ private _lastMeasureW = -1
73
+ private _lastMeasureH = -1
74
+ private _lastMeasuredSize: Size | null = null
75
+ private _lastLayoutRect: Rect | null = null
70
76
 
71
77
  // Greedy layout - expands to fill remaining space
72
78
  // undefined = not greedy (hug content)
@@ -117,17 +123,38 @@ export abstract class BaseHost implements HostInstance {
117
123
  * Optional pre-frame hook. BaseHost will call prepareSelf() and then recurse into children.
118
124
  */
119
125
  prepareFrame(): void {
120
- this.prepareSelf()
126
+ this.ensurePrepared()
121
127
  for (const child of this.children) {
122
128
  child.prepareFrame?.()
123
129
  }
124
130
  }
125
131
 
126
132
  /** Override in subclasses to precompute caches once per frame. */
127
- protected prepareSelf(): void {
133
+ protected prepareSelf(_layoutDirty: boolean, _renderDirty: boolean): void {
128
134
  // Default no-op
129
135
  }
130
136
 
137
+ protected ensurePrepared(): void {
138
+ if (!this._layoutDirty && !this._renderDirty) return
139
+ const layoutDirty = this._layoutDirty
140
+ const renderDirty = this._renderDirty
141
+ this._renderDirty = false
142
+ this.prepareSelf(layoutDirty, renderDirty)
143
+ }
144
+
145
+ invalidateLayout(): void {
146
+ this._layoutDirty = true
147
+ this._renderDirty = true
148
+ this._lastMeasuredSize = null
149
+ this.ctx.requestRender()
150
+ this.parent?.invalidateLayout?.()
151
+ }
152
+
153
+ invalidateRender(): void {
154
+ this._renderDirty = true
155
+ this.ctx.requestRender()
156
+ }
157
+
131
158
  /**
132
159
  * Resolve a prop that may be a MotionValue/ColorMotionValue.
133
160
  * If it's a spring, subscribes to changes and returns current value.
@@ -204,10 +231,39 @@ export abstract class BaseHost implements HostInstance {
204
231
  this._springSubscriptions.clear()
205
232
  }
206
233
 
207
- abstract measure(maxW: number, maxH: number): Size
234
+ measure(maxW: number, maxH: number): Size {
235
+ const constraintsUnchanged = this._lastMeasureW === maxW && this._lastMeasureH === maxH
236
+ if (!this._layoutDirty && constraintsUnchanged && this._lastMeasuredSize) {
237
+ return this._lastMeasuredSize
238
+ }
239
+
240
+ if (!constraintsUnchanged) {
241
+ this._layoutDirty = true
242
+ }
243
+
244
+ const size = this.measureSelf(maxW, maxH)
245
+ this._lastMeasureW = maxW
246
+ this._lastMeasureH = maxH
247
+ this._lastMeasuredSize = size
248
+ return size
249
+ }
250
+
251
+ protected abstract measureSelf(maxW: number, maxH: number): Size
208
252
  abstract render(buffer: CellBuffer, palette: Palette): void
209
253
 
210
254
  layout(rect: Rect): void {
255
+ const prev = this._lastLayoutRect
256
+ const rectUnchanged =
257
+ prev?.x === rect.x && prev?.y === rect.y && prev?.w === rect.w && prev?.h === rect.h
258
+
259
+ if (!this._layoutDirty && rectUnchanged) return
260
+
261
+ this.layoutSelf(rect)
262
+ this._layoutDirty = false
263
+ this._lastLayoutRect = this.rect ?? rect
264
+ }
265
+
266
+ protected layoutSelf(rect: Rect): void {
211
267
  this.layoutWithConstraints(rect)
212
268
  }
213
269
 
@@ -319,6 +375,7 @@ export abstract class BaseHost implements HostInstance {
319
375
  updateProps(props: Record<string, unknown>): void {
320
376
  // Greedy layout - reset to undefined unless explicitly set
321
377
  // Subclasses like Spacer/Scroll set their own default before calling super
378
+ const prevGreedy = this.greedy
322
379
  if ("greedy" in props) {
323
380
  const greedy = props.greedy
324
381
  if (greedy === true) {
@@ -336,6 +393,12 @@ export abstract class BaseHost implements HostInstance {
336
393
  }
337
394
 
338
395
  // Frame constraints - only accept valid numbers (ignore strings like "100%")
396
+ const prevFrameWidth = this.frameWidth
397
+ const prevFrameHeight = this.frameHeight
398
+ const prevFrameMinWidth = this.frameMinWidth
399
+ const prevFrameMaxWidth = this.frameMaxWidth
400
+ const prevFrameMinHeight = this.frameMinHeight
401
+ const prevFrameMaxHeight = this.frameMaxHeight
339
402
  this.frameWidth = typeof props.width === "number" ? props.width : undefined
340
403
  this.frameHeight = typeof props.height === "number" ? props.height : undefined
341
404
  this.frameMinWidth = typeof props.minWidth === "number" ? props.minWidth : undefined
@@ -345,6 +408,19 @@ export abstract class BaseHost implements HostInstance {
345
408
 
346
409
  // onLayout callback
347
410
  this.onLayout = typeof props.onLayout === "function" ? (props.onLayout as typeof this.onLayout) : undefined
411
+
412
+ const layoutChanged =
413
+ prevGreedy !== this.greedy ||
414
+ prevFrameWidth !== this.frameWidth ||
415
+ prevFrameHeight !== this.frameHeight ||
416
+ prevFrameMinWidth !== this.frameMinWidth ||
417
+ prevFrameMaxWidth !== this.frameMaxWidth ||
418
+ prevFrameMinHeight !== this.frameMinHeight ||
419
+ prevFrameMaxHeight !== this.frameMaxHeight
420
+
421
+ if (layoutChanged) {
422
+ this.invalidateLayout()
423
+ }
348
424
  }
349
425
 
350
426
  /**
@@ -353,7 +429,11 @@ export abstract class BaseHost implements HostInstance {
353
429
  */
354
430
  protected applyGreedyDefault(props: Record<string, unknown>, fallback: number): void {
355
431
  if (!("greedy" in props)) {
432
+ const prevGreedy = this.greedy
356
433
  this.greedy = fallback
434
+ if (prevGreedy !== this.greedy) {
435
+ this.invalidateLayout()
436
+ }
357
437
  }
358
438
  }
359
439
 
@@ -366,6 +446,7 @@ export abstract class BaseHost implements HostInstance {
366
446
  appendChild(child: HostInstance): void {
367
447
  this.children.push(child)
368
448
  child.parent = this
449
+ this.invalidateLayout()
369
450
  }
370
451
 
371
452
  removeChild(child: HostInstance): void {
@@ -374,6 +455,7 @@ export abstract class BaseHost implements HostInstance {
374
455
  this.children.splice(idx, 1)
375
456
  child.parent = null
376
457
  }
458
+ this.invalidateLayout()
377
459
  }
378
460
 
379
461
  insertBefore(child: HostInstance, before: HostInstance): void {
@@ -388,6 +470,7 @@ export abstract class BaseHost implements HostInstance {
388
470
  this.children.push(child)
389
471
  }
390
472
  child.parent = this
473
+ this.invalidateLayout()
391
474
  }
392
475
 
393
476
  /**
package/src/hosts/box.ts CHANGED
@@ -65,7 +65,7 @@ export class BoxHost extends SingleChildHost {
65
65
  return this.borderThickness + titleHeight + this.padding.top + this.padding.bottom + this.borderThickness
66
66
  }
67
67
 
68
- measure(maxW: number, maxH: number): Size {
68
+ protected measureSelf(maxW: number, maxH: number): Size {
69
69
  // Apply frame constraints first
70
70
  const constrained = this.constrainProposal(maxW, maxH)
71
71
 
@@ -91,7 +91,7 @@ export class BoxHost extends SingleChildHost {
91
91
  return this.constrainResult(naturalSize)
92
92
  }
93
93
 
94
- override layout(rect: Rect): void {
94
+ protected override layoutSelf(rect: Rect): void {
95
95
  const layoutRect = this.layoutWithConstraints(rect)
96
96
 
97
97
  const t = this.borderThickness
@@ -173,6 +173,15 @@ export class BoxHost extends SingleChildHost {
173
173
 
174
174
  override updateProps(props: Record<string, unknown>): void {
175
175
  super.updateProps(props)
176
+ const prevPadding = this.padding
177
+ const prevBorder = this.border
178
+ const prevBorderColor = this.borderColor
179
+ const prevBg = this.bg
180
+ const prevTitle = this.title
181
+ const prevTitleColor = this.titleColor
182
+ const prevTitleBold = this.titleBold
183
+ const prevTitleDivider = this.titleDivider
184
+
176
185
  this.padding = resolvePadding(props.padding as BoxProps["padding"])
177
186
  this.border = (props.border as BorderKind | undefined) ?? "none"
178
187
  // Color props support MotionValue/ColorMotionValue - auto-subscribe and animate
@@ -188,5 +197,31 @@ export class BoxHost extends SingleChildHost {
188
197
  }) as Color | undefined
189
198
  this.titleBold = Boolean(props.titleBold)
190
199
  this.titleDivider = Boolean(props.titleDivider)
200
+
201
+ const paddingChanged =
202
+ prevPadding.top !== this.padding.top ||
203
+ prevPadding.right !== this.padding.right ||
204
+ prevPadding.bottom !== this.padding.bottom ||
205
+ prevPadding.left !== this.padding.left
206
+ const layoutChanged =
207
+ paddingChanged ||
208
+ prevBorder !== this.border ||
209
+ prevTitle !== this.title ||
210
+ prevTitleDivider !== this.titleDivider
211
+
212
+ if (layoutChanged) {
213
+ this.invalidateLayout()
214
+ return
215
+ }
216
+
217
+ const renderChanged =
218
+ prevBorderColor !== this.borderColor ||
219
+ prevBg !== this.bg ||
220
+ prevTitleColor !== this.titleColor ||
221
+ prevTitleBold !== this.titleBold
222
+
223
+ if (renderChanged) {
224
+ this.invalidateRender()
225
+ }
191
226
  }
192
227
  }