@effect-tui/react 0.1.5 → 0.1.7

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 (72) hide show
  1. package/dist/src/dev.js +2 -2
  2. package/dist/src/dev.js.map +1 -1
  3. package/dist/src/hosts/base.d.ts +5 -0
  4. package/dist/src/hosts/base.d.ts.map +1 -1
  5. package/dist/src/hosts/base.js +21 -7
  6. package/dist/src/hosts/base.js.map +1 -1
  7. package/dist/src/hosts/box.d.ts.map +1 -1
  8. package/dist/src/hosts/box.js +3 -5
  9. package/dist/src/hosts/box.js.map +1 -1
  10. package/dist/src/hosts/overlay-item.d.ts +2 -2
  11. package/dist/src/hosts/overlay-item.d.ts.map +1 -1
  12. package/dist/src/hosts/overlay-item.js +7 -22
  13. package/dist/src/hosts/overlay-item.js.map +1 -1
  14. package/dist/src/hosts/overlay.d.ts.map +1 -1
  15. package/dist/src/hosts/overlay.js +3 -35
  16. package/dist/src/hosts/overlay.js.map +1 -1
  17. package/dist/src/hosts/scroll.d.ts.map +1 -1
  18. package/dist/src/hosts/scroll.js +3 -5
  19. package/dist/src/hosts/scroll.js.map +1 -1
  20. package/dist/src/hosts/text.js +2 -2
  21. package/dist/src/hosts/text.js.map +1 -1
  22. package/dist/src/hosts/zstack.d.ts +3 -2
  23. package/dist/src/hosts/zstack.d.ts.map +1 -1
  24. package/dist/src/hosts/zstack.js +3 -18
  25. package/dist/src/hosts/zstack.js.map +1 -1
  26. package/dist/src/remote/Procedures.d.ts +4 -0
  27. package/dist/src/remote/Procedures.d.ts.map +1 -1
  28. package/dist/src/remote/Procedures.js +4 -0
  29. package/dist/src/remote/Procedures.js.map +1 -1
  30. package/dist/src/remote/Router.d.ts +2 -0
  31. package/dist/src/remote/Router.d.ts.map +1 -1
  32. package/dist/src/remote/Router.js.map +1 -1
  33. package/dist/src/remote/index.d.ts +8 -2
  34. package/dist/src/remote/index.d.ts.map +1 -1
  35. package/dist/src/remote/index.js +31 -3
  36. package/dist/src/remote/index.js.map +1 -1
  37. package/dist/src/renderer/modes/FullscreenRenderer.d.ts.map +1 -1
  38. package/dist/src/renderer/modes/FullscreenRenderer.js +8 -6
  39. package/dist/src/renderer/modes/FullscreenRenderer.js.map +1 -1
  40. package/dist/src/renderer/modes/InlineRenderer.d.ts.map +1 -1
  41. package/dist/src/renderer/modes/InlineRenderer.js +9 -7
  42. package/dist/src/renderer/modes/InlineRenderer.js.map +1 -1
  43. package/dist/src/utils/alignment.d.ts +15 -0
  44. package/dist/src/utils/alignment.d.ts.map +1 -0
  45. package/dist/src/utils/alignment.js +37 -0
  46. package/dist/src/utils/alignment.js.map +1 -0
  47. package/dist/src/utils/index.d.ts +2 -1
  48. package/dist/src/utils/index.d.ts.map +1 -1
  49. package/dist/src/utils/index.js +2 -1
  50. package/dist/src/utils/index.js.map +1 -1
  51. package/dist/src/utils/styles.d.ts +9 -0
  52. package/dist/src/utils/styles.d.ts.map +1 -1
  53. package/dist/src/utils/styles.js +9 -0
  54. package/dist/src/utils/styles.js.map +1 -1
  55. package/dist/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +2 -2
  57. package/src/dev.tsx +2 -2
  58. package/src/hosts/base.ts +21 -7
  59. package/src/hosts/box.ts +3 -6
  60. package/src/hosts/overlay-item.ts +7 -22
  61. package/src/hosts/overlay.ts +3 -37
  62. package/src/hosts/scroll.ts +3 -5
  63. package/src/hosts/text.ts +3 -3
  64. package/src/hosts/zstack.ts +5 -18
  65. package/src/remote/Procedures.ts +4 -0
  66. package/src/remote/Router.ts +7 -1
  67. package/src/remote/index.ts +41 -3
  68. package/src/renderer/modes/FullscreenRenderer.ts +8 -6
  69. package/src/renderer/modes/InlineRenderer.ts +9 -7
  70. package/src/utils/alignment.ts +50 -0
  71. package/src/utils/index.ts +10 -1
  72. package/src/utils/styles.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "React bindings for @effect-tui/core",
5
5
  "type": "module",
6
6
  "files": [
@@ -83,7 +83,7 @@
83
83
  "prepublishOnly": "bun run typecheck && bun run build"
84
84
  },
85
85
  "dependencies": {
86
- "@effect-tui/core": "^0.1.5",
86
+ "@effect-tui/core": "^0.1.7",
87
87
  "@effect/platform": "^0.94.0",
88
88
  "@effect/platform-bun": "^0.87.0",
89
89
  "@effect/rpc": "^0.73.0",
package/src/dev.tsx CHANGED
@@ -395,8 +395,8 @@ export async function devRender(entryPath: string, options?: DevRenderOptions):
395
395
  const renderer = createRenderer(options)
396
396
  const root = createRoot(renderer)
397
397
 
398
- // Dev mode always enables remote control
399
- const stopRemote = enableRemote(renderer)
398
+ // Dev mode always enables remote control with entry path for identification
399
+ const stopRemote = enableRemote(renderer, { entryPath })
400
400
 
401
401
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
402
402
  let version = 0
package/src/hosts/base.ts CHANGED
@@ -147,13 +147,13 @@ export abstract class BaseHost implements HostInstance {
147
147
  this.flexGrow = (props.flexGrow as number | undefined) ?? 0
148
148
  this.flexShrink = (props.flexShrink as number | undefined) ?? 1
149
149
 
150
- // Frame constraints
151
- this.frameWidth = props.width as number | undefined
152
- this.frameHeight = props.height as number | undefined
153
- this.frameMinWidth = props.minWidth as number | undefined
154
- this.frameMaxWidth = props.maxWidth as number | undefined
155
- this.frameMinHeight = props.minHeight as number | undefined
156
- this.frameMaxHeight = props.maxHeight as number | undefined
150
+ // Frame constraints - only accept valid numbers (ignore strings like "100%")
151
+ this.frameWidth = typeof props.width === "number" ? props.width : undefined
152
+ this.frameHeight = typeof props.height === "number" ? props.height : undefined
153
+ this.frameMinWidth = typeof props.minWidth === "number" ? props.minWidth : undefined
154
+ this.frameMaxWidth = typeof props.maxWidth === "number" ? props.maxWidth : undefined
155
+ this.frameMinHeight = typeof props.minHeight === "number" ? props.minHeight : undefined
156
+ this.frameMaxHeight = typeof props.maxHeight === "number" ? props.maxHeight : undefined
157
157
  }
158
158
 
159
159
  destroy(): void {
@@ -183,4 +183,18 @@ export abstract class BaseHost implements HostInstance {
183
183
  }
184
184
  child.parent = this
185
185
  }
186
+
187
+ /**
188
+ * Render a child with optional clipping to its rect.
189
+ * Common pattern used by containers (ZStack, Overlay, etc.)
190
+ */
191
+ protected renderChildWithClip(child: HostInstance, buffer: CellBuffer, palette: Palette): void {
192
+ if (child.rect) {
193
+ buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
194
+ child.render(buffer, palette)
195
+ })
196
+ } else {
197
+ child.render(buffer, palette)
198
+ }
199
+ }
186
200
  }
package/src/hosts/box.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { CellBuffer, Palette, Color } from "@effect-tui/core"
2
2
  import { Colors } from "@effect-tui/core"
3
3
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
4
- import { getInheritedBg } from "./base.js"
5
4
  import { SingleChildHost } from "./single-child.js"
6
5
  import {
7
6
  type BorderKind,
@@ -10,7 +9,7 @@ import {
10
9
  type Padding,
11
10
  type PaddingInput,
12
11
  resolvePadding,
13
- resolveBgStyle,
12
+ resolveInheritedBgStyle,
14
13
  toColorValue,
15
14
  } from "../utils/index.js"
16
15
 
@@ -94,11 +93,9 @@ export class BoxHost extends SingleChildHost {
94
93
  if (!this.rect) return
95
94
  const { x, y, w, h } = this.rect
96
95
 
97
- // If no explicit bg, inherit from parent (so child boxes are transparent over parents)
98
- const rawBg: Color | undefined = this.bg ?? getInheritedBg(this.parent)
99
-
100
96
  // Always clear our rect to ensure stale backgrounds are removed.
101
- const { styleId: bgStyleId } = resolveBgStyle(palette, rawBg)
97
+ // If no explicit bg, inherit from parent (so child boxes are transparent over parents)
98
+ const { styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
102
99
  buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
103
100
 
104
101
  // Draw border
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { CellBuffer, Palette, Rect, Size } from "@effect-tui/core"
9
9
  import type { HostContext, CommonProps } from "../reconciler/types.js"
10
- import { BaseHost } from "./base.js"
10
+ import { SingleChildHost } from "./single-child.js"
11
11
 
12
12
  type HAlign = "left" | "center" | "right"
13
13
  type VAlign = "top" | "center" | "bottom"
@@ -17,7 +17,7 @@ export interface OverlayItemProps extends CommonProps {
17
17
  alignment?: { h?: HAlign; v?: VAlign }
18
18
  }
19
19
 
20
- export class OverlayItemHost extends BaseHost {
20
+ export class OverlayItemHost extends SingleChildHost {
21
21
  /** Stored alignment for parent Overlay to read */
22
22
  alignment: { h?: HAlign; v?: VAlign } = {}
23
23
 
@@ -29,37 +29,22 @@ export class OverlayItemHost extends BaseHost {
29
29
  override measure(maxW: number, maxH: number): Size {
30
30
  const constrained = this.constrainProposal(maxW, maxH)
31
31
 
32
- // Size is determined by single child
33
- const child = this.children[0]
34
- if (!child) {
32
+ if (!this.child) {
35
33
  return this.constrainResult({ w: 0, h: 0 })
36
34
  }
37
35
 
38
- const childSize = child.measure(constrained.w, constrained.h)
36
+ const childSize = this.child.measure(constrained.w, constrained.h)
39
37
  return this.constrainResult(childSize)
40
38
  }
41
39
 
42
40
  override layout(rect: Rect): void {
43
41
  this.rect = rect
44
-
45
- // Pass rect to child
46
- const child = this.children[0]
47
- if (child) {
48
- child.layout(rect)
49
- }
42
+ this.child?.layout(rect)
50
43
  }
51
44
 
52
45
  override render(buffer: CellBuffer, palette: Palette): void {
53
- // Render child
54
- const child = this.children[0]
55
- if (child) {
56
- if (child.rect) {
57
- buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
58
- child.render(buffer, palette)
59
- })
60
- } else {
61
- child.render(buffer, palette)
62
- }
46
+ if (this.child) {
47
+ this.renderChildWithClip(this.child, buffer, palette)
63
48
  }
64
49
  }
65
50
 
@@ -19,6 +19,7 @@
19
19
  import type { CellBuffer, Palette, Rect, Size } from "@effect-tui/core"
20
20
  import type { HostContext, CommonProps } from "../reconciler/types.js"
21
21
  import { BaseHost } from "./base.js"
22
+ import { alignInRect } from "../utils/index.js"
22
23
  import type { OverlayItemHost } from "./overlay-item.js"
23
24
 
24
25
  export interface OverlayProps extends CommonProps {}
@@ -70,36 +71,7 @@ export class OverlayHost extends BaseHost {
70
71
 
71
72
  // Read alignment from OverlayItemHost (use type check, not instanceof, for bundler compatibility)
72
73
  const alignment = child.type === "overlayItem" ? (child as OverlayItemHost).alignment : {}
73
- const hAlign = alignment.h ?? "center"
74
- const vAlign = alignment.v ?? "center"
75
-
76
- // Calculate position based on alignment
77
- let x = rect.x
78
- let y = rect.y
79
-
80
- switch (hAlign) {
81
- case "left":
82
- x = rect.x
83
- break
84
- case "center":
85
- x = rect.x + Math.floor((rect.w - size.w) / 2)
86
- break
87
- case "right":
88
- x = rect.x + rect.w - size.w
89
- break
90
- }
91
-
92
- switch (vAlign) {
93
- case "top":
94
- y = rect.y
95
- break
96
- case "center":
97
- y = rect.y + Math.floor((rect.h - size.h) / 2)
98
- break
99
- case "bottom":
100
- y = rect.y + rect.h - size.h
101
- break
102
- }
74
+ const { x, y } = alignInRect(rect, size, alignment.h ?? "center", alignment.v ?? "center")
103
75
 
104
76
  child.layout({
105
77
  x,
@@ -113,13 +85,7 @@ export class OverlayHost extends BaseHost {
113
85
  override render(buffer: CellBuffer, palette: Palette): void {
114
86
  // Render all children in order (base first, then overlays)
115
87
  for (const child of this.children) {
116
- if (child.rect) {
117
- buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
118
- child.render(buffer, palette)
119
- })
120
- } else {
121
- child.render(buffer, palette)
122
- }
88
+ this.renderChildWithClip(child, buffer, palette)
123
89
  }
124
90
  }
125
91
  }
@@ -1,9 +1,8 @@
1
1
  // scroll.ts — Scrollable container host
2
2
  import type { CellBuffer, Palette, Color } from "@effect-tui/core"
3
3
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
4
- import { getInheritedBg } from "./base.js"
5
4
  import { SingleChildHost } from "./single-child.js"
6
- import { resolveBgStyle } from "../utils/index.js"
5
+ import { resolveInheritedBgStyle } from "../utils/index.js"
7
6
 
8
7
  export interface ScrollProps extends CommonProps {
9
8
  /** Scroll axis: "vertical" (default), "horizontal", or "both" */
@@ -175,9 +174,8 @@ export class ScrollHost extends SingleChildHost {
175
174
  if (!this.rect) return
176
175
  const { x, y, w, h } = this.rect
177
176
 
178
- // Fill background
179
- const rawBg: Color | undefined = this.bg ?? getInheritedBg(this.parent)
180
- const { value: bgValue, styleId: bgStyleId } = resolveBgStyle(palette, rawBg)
177
+ // Fill background (inherit from parent if not explicitly set)
178
+ const { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
181
179
  if (bgValue !== undefined) {
182
180
  buffer.fillRect(x, y, w, h, " ".codePointAt(0)!, bgStyleId)
183
181
  }
package/src/hosts/text.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { displayWidth, type CellBuffer, type Palette, type Color } from "@effect-tui/core"
2
2
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
3
3
  import { BaseHost, getInheritedBg } from "./base.js"
4
- import { resolveBgStyle, styleIdFromProps } from "../utils/index.js"
4
+ import { resolveInheritedBgStyle, styleIdFromProps } from "../utils/index.js"
5
5
 
6
6
  export interface TextProps extends CommonProps {
7
7
  fg?: Color
@@ -114,8 +114,8 @@ export class TextHost extends BaseHost {
114
114
  if (!this.rect) return
115
115
 
116
116
  // If text has no bg, inherit from parent box for proper highlight rendering
117
- const inheritedBg: Color | undefined = this.bg ?? getInheritedBg(this.parent)
118
- const { value: bgValue, styleId: bgStyleId } = resolveBgStyle(palette, inheritedBg)
117
+ const { value: bgValue, styleId: bgStyleId } = resolveInheritedBgStyle(palette, this.bg, this.parent)
118
+ const inheritedBg = this.bg ?? getInheritedBg(this.parent)
119
119
 
120
120
  const styleId = styleIdFromProps(palette, {
121
121
  fg: this.fg,
@@ -1,6 +1,7 @@
1
1
  import type { CellBuffer, Palette } from "@effect-tui/core"
2
2
  import type { HostContext, Rect, Size, CommonProps } from "../reconciler/types.js"
3
3
  import { BaseHost } from "./base.js"
4
+ import { alignInRect, type HAlign, type VAlign } from "../utils/index.js"
4
5
 
5
6
  export interface ZStackProps extends CommonProps {
6
7
  alignment?: { h?: "leading" | "center" | "trailing"; v?: "top" | "center" | "bottom" }
@@ -8,8 +9,8 @@ export interface ZStackProps extends CommonProps {
8
9
 
9
10
  // Overlay children in the same rect, honoring alignment for each child.
10
11
  export class ZStackHost extends BaseHost {
11
- alignmentH: "leading" | "center" | "trailing" = "center"
12
- alignmentV: "top" | "center" | "bottom" = "center"
12
+ alignmentH: HAlign = "center"
13
+ alignmentV: VAlign = "center"
13
14
  private cachedSizes: Size[] = []
14
15
 
15
16
  constructor(props: ZStackProps, ctx: HostContext) {
@@ -47,15 +48,7 @@ export class ZStackHost extends BaseHost {
47
48
  for (let i = 0; i < this.children.length; i++) {
48
49
  const child = this.children[i]
49
50
  const size = this.cachedSizes[i] ?? child.measure(rect.w, rect.h)
50
-
51
- let x = rect.x
52
- let y = rect.y
53
-
54
- if (this.alignmentH === "center") x += Math.floor((rect.w - size.w) / 2)
55
- else if (this.alignmentH === "trailing") x += Math.max(0, rect.w - size.w)
56
-
57
- if (this.alignmentV === "center") y += Math.floor((rect.h - size.h) / 2)
58
- else if (this.alignmentV === "bottom") y += Math.max(0, rect.h - size.h)
51
+ const { x, y } = alignInRect(rect, size, this.alignmentH, this.alignmentV)
59
52
 
60
53
  child.layout({
61
54
  x,
@@ -68,13 +61,7 @@ export class ZStackHost extends BaseHost {
68
61
 
69
62
  render(buffer: CellBuffer, palette: Palette): void {
70
63
  for (const child of this.children) {
71
- if (child.rect) {
72
- buffer.withClip(child.rect.x, child.rect.y, child.rect.w, child.rect.h, () => {
73
- child.render(buffer, palette)
74
- })
75
- } else {
76
- child.render(buffer, palette)
77
- }
64
+ this.renderChildWithClip(child, buffer, palette)
78
65
  }
79
66
  }
80
67
 
@@ -39,6 +39,10 @@ const Info = Rpc.make("Info", {
39
39
  pid: Schema.Number,
40
40
  width: Schema.Number,
41
41
  height: Schema.Number,
42
+ /** Entry file path (e.g., /Users/kit/code/my-app/src/tui.tsx) */
43
+ entryPath: Schema.optional(Schema.String),
44
+ /** Short name derived from entry path (e.g., my-app/src/tui.tsx) */
45
+ name: Schema.optional(Schema.String),
42
46
  }),
43
47
  })
44
48
 
@@ -10,7 +10,13 @@ export interface TuiSessionImpl {
10
10
  readonly dispatchKey: (key: KeyMsg) => void
11
11
  readonly dispatchPaste: (text: string) => void
12
12
  readonly dispatchResize: (width: number, height: number) => void
13
- readonly getInfo: () => { pid: number; width: number; height: number }
13
+ readonly getInfo: () => {
14
+ pid: number
15
+ width: number
16
+ height: number
17
+ entryPath?: string
18
+ name?: string
19
+ }
14
20
  }
15
21
 
16
22
  export class TuiSession extends Context.Tag("TuiSession")<
@@ -17,19 +17,55 @@ import type { TuiRenderer } from "../renderer-types.js"
17
17
  import type { TuiSessionImpl } from "./Router.js"
18
18
  import { makeServerLayer, getSocketPath } from "./Server.js"
19
19
 
20
+ export interface EnableRemoteOptions {
21
+ /** Custom socket path (defaults to /tmp/effect-tui-sessions/<pid>.sock) */
22
+ socketPath?: string
23
+ /** Entry file path for identification (e.g., import.meta.path) */
24
+ entryPath?: string
25
+ }
26
+
27
+ /**
28
+ * Derive a short name from the entry path.
29
+ * Uses the last directory + filename (e.g., "/Users/kit/code/my-app/src/tui.tsx" -> "my-app/src/tui.tsx")
30
+ */
31
+ function deriveSessionName(entryPath: string): string {
32
+ const parts = entryPath.split("/")
33
+ // Find the last directory that looks like a project name (not src, lib, etc)
34
+ const commonDirs = ["src", "lib", "dist", "build", "packages"]
35
+ let srcIdx = -1
36
+ for (let i = parts.length - 1; i >= 0; i--) {
37
+ if (commonDirs.includes(parts[i])) {
38
+ srcIdx = i
39
+ break
40
+ }
41
+ }
42
+ if (srcIdx > 0) {
43
+ // Include project name + relative path from there
44
+ return parts.slice(srcIdx - 1).join("/")
45
+ }
46
+ // Fallback: last 3 path segments
47
+ return parts.slice(-3).join("/")
48
+ }
49
+
20
50
  /**
21
51
  * Enable remote control for a TUI renderer.
22
52
  * Starts an RPC server on a Unix socket at /tmp/effect-tui-sessions/<pid>.sock
23
53
  *
24
54
  * @param renderer - The TUI renderer to control
25
- * @param socketPath - Optional custom socket path
55
+ * @param options - Socket path and entry path for session identification
26
56
  * @returns A cleanup function to stop the server
27
57
  */
28
58
  export function enableRemote(
29
59
  renderer: TuiRenderer,
30
- socketPath?: string,
60
+ options?: EnableRemoteOptions | string,
31
61
  ): () => void {
32
- const actualPath = socketPath ?? getSocketPath()
62
+ // Support old API: enableRemote(renderer, socketPath?)
63
+ const opts: EnableRemoteOptions =
64
+ typeof options === "string" ? { socketPath: options } : options ?? {}
65
+ const actualPath = opts.socketPath ?? getSocketPath()
66
+
67
+ // Derive session name from entry path
68
+ const name = opts.entryPath ? deriveSessionName(opts.entryPath) : undefined
33
69
 
34
70
  // Create session implementation from renderer
35
71
  const session: TuiSessionImpl = {
@@ -41,6 +77,8 @@ export function enableRemote(
41
77
  pid: process.pid,
42
78
  width: renderer.width,
43
79
  height: renderer.height,
80
+ entryPath: opts.entryPath,
81
+ name,
44
82
  }),
45
83
  }
46
84
 
@@ -1,4 +1,4 @@
1
- import { ANSI, emitRowWithReset, findChangeWindow } from "@effect-tui/core"
1
+ import { ANSI, emitRowWithReset, rowChanged } from "@effect-tui/core"
2
2
  import type { RendererMode, RenderContext, RenderOutput } from "./RendererMode.js"
3
3
 
4
4
  /**
@@ -15,20 +15,22 @@ export class FullscreenRenderer implements RendererMode {
15
15
  // If a full clear was requested (e.g., on resize), prepend it to output
16
16
  // so clear + content are written atomically (no visible flash)
17
17
  if (this.needsFullClear) {
18
- output += ANSI.cursor.to(1, 1)
18
+ output += ANSI.screen.clear + ANSI.cursor.to(1, 1)
19
19
  this.needsFullClear = false
20
20
  // After clear, we must redraw everything - can't rely on diff
21
21
  for (let y = 0; y < frameHeight; y++) {
22
22
  if (y > 0) output += ANSI.cursor.to(1, y + 1)
23
+ output += palette.sgr(0) + ANSI.line.clear
23
24
  output += emitRowWithReset(nextBuffer, palette, y, frameWidth)
24
25
  }
25
26
  } else if (enableDiff && prevBuffer) {
26
27
  // Diff per line for minimal writes
27
28
  for (let y = 0; y < frameHeight; y++) {
28
- const change = findChangeWindow(prevBuffer, nextBuffer, y, frameWidth)
29
- if (!change) continue
30
- output += ANSI.cursor.to(change.left + 1, y + 1)
31
- output += emitRowWithReset(nextBuffer, palette, y, frameWidth, change.left, change.right + 1)
29
+ if (!rowChanged(prevBuffer, nextBuffer, y, frameWidth)) continue
30
+ output += ANSI.cursor.to(1, y + 1)
31
+ // Clear the row using default style to avoid carrying stale backgrounds
32
+ output += palette.sgr(0) + ANSI.line.clear
33
+ output += emitRowWithReset(nextBuffer, palette, y, frameWidth)
32
34
  }
33
35
  } else {
34
36
  // Full redraw (tests/manual mode)
@@ -32,6 +32,7 @@ export class InlineRenderer implements RendererMode {
32
32
  output += ANSI.cursor.up(this.previousHeight)
33
33
  }
34
34
  output += ANSI.cursor.startOfLine
35
+ output += ANSI.screen.clearToEnd
35
36
  this.previousStartRow = startRow
36
37
  this.printedWidths = []
37
38
  } else if (this.previousHeight > 0) {
@@ -80,7 +81,7 @@ export class InlineRenderer implements RendererMode {
80
81
  const prevW = this.printedWidths[screenY] ?? 0
81
82
  if (prevW > 0) {
82
83
  moveToRow(screenY)
83
- output += ANSI.cursor.toCol(1) + palette.sgr(0) + " ".repeat(prevW)
84
+ output += ANSI.cursor.toCol(1) + palette.sgr(0) + ANSI.line.clear
84
85
  this.printedWidths[screenY] = 0
85
86
  }
86
87
  continue
@@ -94,7 +95,7 @@ export class InlineRenderer implements RendererMode {
94
95
  // No change; maybe need to clear tail if content shrunk
95
96
  if (prevW > newW) {
96
97
  moveToRow(screenY)
97
- output += ANSI.cursor.toCol(newW + 1) + palette.sgr(0) + " ".repeat(prevW - newW)
98
+ output += ANSI.cursor.toCol(newW + 1) + palette.sgr(0) + ANSI.line.clearToEnd
98
99
  this.printedWidths[screenY] = newW
99
100
  }
100
101
  continue
@@ -108,9 +109,9 @@ export class InlineRenderer implements RendererMode {
108
109
  // Clear tail if shrunk
109
110
  const effectiveW = Math.max(newW, change.right + 1)
110
111
  if (prevW > effectiveW) {
111
- output += ANSI.cursor.toCol(effectiveW + 1) + palette.sgr(0) + " ".repeat(prevW - effectiveW)
112
+ output += ANSI.cursor.toCol(effectiveW + 1) + ANSI.line.clearToEnd
112
113
  }
113
- this.printedWidths[screenY] = newW
114
+ this.printedWidths[screenY] = effectiveW
114
115
  }
115
116
 
116
117
  // Ensure cursor ends just after the dynamic block for next frame positioning
@@ -128,8 +129,9 @@ export class InlineRenderer implements RendererMode {
128
129
  for (let bufferY = startRow; bufferY < endRow; bufferY++) {
129
130
  const screenY = bufferY - startRow
130
131
  const trimmedWidth = rowContentWidth(nextBuffer, bufferY, frameWidth)
131
- // Full-width emit avoids reliance on line clears.
132
- output += emitRowWithReset(nextBuffer, palette, bufferY, frameWidth, 0, frameWidth)
132
+ // Clear ENTIRE line first to handle resize artifacts
133
+ output += ANSI.line.clear
134
+ output += emitRowWithReset(nextBuffer, palette, bufferY, frameWidth, 0, trimmedWidth)
133
135
  output += "\r\n"
134
136
  // Track line widths for resize reflow calculation
135
137
  this.printedWidths[screenY] = trimmedWidth
@@ -137,7 +139,7 @@ export class InlineRenderer implements RendererMode {
137
139
 
138
140
  // Clear any extra lines if content shrank
139
141
  for (let screenY = rowCount; screenY < this.previousHeight; screenY++) {
140
- output += palette.sgr(0) + " ".repeat(frameWidth) + "\r\n"
142
+ output += palette.sgr(0) + ANSI.line.clear + "\r\n"
141
143
  this.printedWidths[screenY] = 0
142
144
  }
143
145
 
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Alignment utilities for layout positioning.
3
+ */
4
+
5
+ import type { Rect, Size } from "../reconciler/types.js"
6
+
7
+ export type HAlign = "left" | "center" | "right" | "leading" | "trailing"
8
+ export type VAlign = "top" | "center" | "bottom"
9
+
10
+ /**
11
+ * Calculate position to align a child size within a container rect.
12
+ * Returns the x,y coordinates for the child's top-left corner.
13
+ */
14
+ export function alignInRect(
15
+ rect: Rect,
16
+ size: Size,
17
+ hAlign: HAlign = "center",
18
+ vAlign: VAlign = "center",
19
+ ): { x: number; y: number } {
20
+ let x = rect.x
21
+ let y = rect.y
22
+
23
+ switch (hAlign) {
24
+ case "left":
25
+ case "leading":
26
+ x = rect.x
27
+ break
28
+ case "center":
29
+ x = rect.x + Math.floor((rect.w - size.w) / 2)
30
+ break
31
+ case "right":
32
+ case "trailing":
33
+ x = rect.x + Math.max(0, rect.w - size.w)
34
+ break
35
+ }
36
+
37
+ switch (vAlign) {
38
+ case "top":
39
+ y = rect.y
40
+ break
41
+ case "center":
42
+ y = rect.y + Math.floor((rect.h - size.h) / 2)
43
+ break
44
+ case "bottom":
45
+ y = rect.y + Math.max(0, rect.h - size.h)
46
+ break
47
+ }
48
+
49
+ return { x, y }
50
+ }
@@ -1,4 +1,13 @@
1
+ export { type HAlign, type VAlign, alignInRect } from "./alignment.js"
1
2
  export { type BorderKind, type BorderChars, borderChars, type ClipRect, drawBorder } from "./border.js"
2
3
  export { type Padding, type PaddingInput, resolvePadding } from "./padding.js"
3
- export { type StyleOptions, type StyleInput, toColorValue, styleSpecFromProps, styleIdFromProps, resolveBgStyle } from "./styles.js"
4
+ export {
5
+ type StyleOptions,
6
+ type StyleInput,
7
+ toColorValue,
8
+ styleSpecFromProps,
9
+ styleIdFromProps,
10
+ resolveBgStyle,
11
+ resolveInheritedBgStyle,
12
+ } from "./styles.js"
4
13
  export { type FlexAxis, type FlexAlignment, type FlexMeasureResult, measureFlex, layoutFlex } from "./flex-layout.js"
@@ -55,3 +55,19 @@ export function resolveBgStyle(palette: Palette, bg?: Color): { value?: ColorVal
55
55
  const styleId = value === undefined ? 0 : palette.id({ bg: value })
56
56
  return { value, styleId }
57
57
  }
58
+
59
+ import type { HostInstance } from "../reconciler/types.js"
60
+ import { getInheritedBg } from "../hosts/base.js"
61
+
62
+ /**
63
+ * Resolve background style, inheriting from parent if not explicitly set.
64
+ * Common pattern used by hosts that need background color inheritance.
65
+ */
66
+ export function resolveInheritedBgStyle(
67
+ palette: Palette,
68
+ explicitBg: Color | undefined,
69
+ parent: HostInstance | null,
70
+ ): { value?: ColorValue; styleId: number } {
71
+ const bg = explicitBg ?? getInheritedBg(parent)
72
+ return resolveBgStyle(palette, bg)
73
+ }