11import { assert , describe , test } from "@rezi-ui/testkit" ;
2+ import type { DrawlistBuilder } from "../../drawlist/types.js" ;
3+ import { layout } from "../../layout/layout.js" ;
4+ import { renderToDrawlist } from "../../renderer/renderToDrawlist.js" ;
5+ import { type RuntimeInstance , commitVNodeTree } from "../../runtime/commit.js" ;
6+ import { createInstanceIdAllocator } from "../../runtime/instance.js" ;
27import { defaultTheme } from "../../theme/defaultTheme.js" ;
38import { darkTheme } from "../../theme/presets.js" ;
49import { compileTheme } from "../../theme/theme.js" ;
10+ import type { TextStyle } from "../../widgets/style.js" ;
511import { ui } from "../../widgets/ui.js" ;
612import { type DrawOp , renderOps } from "./recipeRendering.test-utils.js" ;
713
@@ -14,6 +20,114 @@ function firstDrawText(
1420 return ops . find ( ( op ) => op . kind === "drawText" && match ( op . text ) ) ;
1521}
1622
23+ class CursorRecordingBuilder implements DrawlistBuilder {
24+ readonly ops : DrawOp [ ] = [ ] ;
25+ cursor : Parameters < DrawlistBuilder [ "setCursor" ] > [ 0 ] | null = null ;
26+
27+ clear ( ) : void { }
28+ clearTo ( ) : void { }
29+ fillRect ( x : number , y : number , w : number , h : number , style ?: TextStyle ) : void {
30+ this . ops . push (
31+ style ? { kind : "fillRect" , x, y, w, h, style } : { kind : "fillRect" , x, y, w, h } ,
32+ ) ;
33+ }
34+ drawText ( x : number , y : number , text : string , style ?: TextStyle ) : void {
35+ this . ops . push (
36+ style ? { kind : "drawText" , x, y, text, style } : { kind : "drawText" , x, y, text } ,
37+ ) ;
38+ }
39+ pushClip ( x : number , y : number , w : number , h : number ) : void {
40+ this . ops . push ( { kind : "pushClip" , x, y, w, h } ) ;
41+ }
42+ popClip ( ) : void {
43+ this . ops . push ( { kind : "popClip" } ) ;
44+ }
45+ addBlob ( ) : number | null {
46+ return null ;
47+ }
48+ addTextRunBlob ( ) : number | null {
49+ return null ;
50+ }
51+ drawTextRun ( ) : void { }
52+ setCursor ( state : Parameters < DrawlistBuilder [ "setCursor" ] > [ 0 ] ) : void {
53+ this . cursor = state ;
54+ }
55+ hideCursor ( ) : void { }
56+ setLink ( ) : void { }
57+ drawCanvas ( ) : void { }
58+ drawImage ( ) : void { }
59+ blitRect ( ) : void { }
60+ build ( ) {
61+ return { ok : true , bytes : new Uint8Array ( 0 ) } as const ;
62+ }
63+ buildInto ( _dst : Uint8Array ) : ReturnType < DrawlistBuilder [ "buildInto" ] > {
64+ return this . build ( ) ;
65+ }
66+ reset ( ) : void {
67+ this . ops . length = 0 ;
68+ this . cursor = null ;
69+ }
70+ }
71+
72+ function findInstanceIdById ( node : RuntimeInstance , id : string ) : number | null {
73+ const props = node . vnode . props as { id ?: unknown } | undefined ;
74+ if ( props ?. id === id ) return node . instanceId ;
75+ for ( const child of node . children ) {
76+ if ( ! child ) continue ;
77+ const found = findInstanceIdById ( child , id ) ;
78+ if ( found !== null ) return found ;
79+ }
80+ return null ;
81+ }
82+
83+ function renderTextareaWithCursor (
84+ value : string ,
85+ cursor : number ,
86+ ) : Readonly < {
87+ ops : readonly DrawOp [ ] ;
88+ cursor : Parameters < DrawlistBuilder [ "setCursor" ] > [ 0 ] | null ;
89+ } > {
90+ const vnode = ui . row ( { height : 5 , items : "stretch" } , [
91+ ui . textarea ( { id : "ta" , value, rows : 3 , wordWrap : false } ) ,
92+ ] ) ;
93+ const committed = commitVNodeTree ( null , vnode , { allocator : createInstanceIdAllocator ( 1 ) } ) ;
94+ assert . equal ( committed . ok , true ) ;
95+ if ( ! committed . ok ) {
96+ return Object . freeze ( { ops : Object . freeze ( [ ] ) , cursor : null } ) ;
97+ }
98+
99+ const textareaInstanceId = findInstanceIdById ( committed . value . root , "ta" ) ;
100+ assert . ok ( textareaInstanceId !== null , "textarea instance should exist" ) ;
101+ if ( textareaInstanceId === null ) {
102+ return Object . freeze ( { ops : Object . freeze ( [ ] ) , cursor : null } ) ;
103+ }
104+
105+ const laidOut = layout ( committed . value . root . vnode , 0 , 0 , 16 , 6 , "column" ) ;
106+ assert . equal ( laidOut . ok , true ) ;
107+ if ( ! laidOut . ok ) {
108+ return Object . freeze ( { ops : Object . freeze ( [ ] ) , cursor : null } ) ;
109+ }
110+
111+ const builder = new CursorRecordingBuilder ( ) ;
112+ renderToDrawlist ( {
113+ tree : committed . value . root ,
114+ layout : laidOut . value ,
115+ viewport : { cols : 16 , rows : 6 } ,
116+ focusState : Object . freeze ( { focusedId : "ta" } ) ,
117+ cursorInfo : Object . freeze ( {
118+ cursorByInstanceId : new Map ( [ [ textareaInstanceId , cursor ] ] ) ,
119+ shape : 0 ,
120+ blink : true ,
121+ } ) ,
122+ builder,
123+ } ) ;
124+
125+ return Object . freeze ( {
126+ ops : Object . freeze ( builder . ops . slice ( ) ) ,
127+ cursor : builder . cursor ,
128+ } ) ;
129+ }
130+
17131describe ( "input recipe rendering" , ( ) => {
18132 test ( "uses recipe colors with semantic-token themes" , ( ) => {
19133 const ops = renderOps (
@@ -63,6 +177,51 @@ describe("input recipe rendering", () => {
63177 assert . equal ( text . text . includes ( "Enter text..." ) , true ) ;
64178 } ) ;
65179
180+ test ( "focused textarea keeps the tail of a long no-wrap line visible" , ( ) => {
181+ const ops = renderOps (
182+ ui . row ( { height : 5 , items : "stretch" } , [
183+ ui . textarea ( {
184+ id : "ta" ,
185+ value : "abcdefghijklmnopqrstuvwxyz" ,
186+ rows : 3 ,
187+ wordWrap : false ,
188+ } ) ,
189+ ] ) ,
190+ { viewport : { cols : 16 , rows : 6 } , theme : defaultTheme , focusedId : "ta" } ,
191+ ) ;
192+
193+ assert . equal (
194+ ops . some ( ( op ) => op . kind === "drawText" && op . text . includes ( "uvwxyz" ) ) ,
195+ true ,
196+ ) ;
197+ assert . equal (
198+ ops . some ( ( op ) => op . kind === "drawText" && op . text . includes ( "abcdef" ) ) ,
199+ false ,
200+ ) ;
201+ } ) ;
202+
203+ test ( "no-wrap textarea viewport follows cursor movement on long lines" , ( ) => {
204+ const start = renderTextareaWithCursor ( "abcdefghijklmnopqrstuvwxyz" , 4 ) ;
205+ const end = renderTextareaWithCursor ( "abcdefghijklmnopqrstuvwxyz" , 26 ) ;
206+
207+ assert . ok ( start . cursor , "early cursor should resolve" ) ;
208+ assert . ok ( end . cursor , "late cursor should resolve" ) ;
209+ if ( ! start . cursor || ! end . cursor ) return ;
210+
211+ const startText = start . ops
212+ . filter ( ( op ) : op is Extract < DrawOp , { kind : "drawText" } > => op . kind === "drawText" )
213+ . map ( ( op ) => op . text )
214+ . join ( "\n" ) ;
215+ const endText = end . ops
216+ . filter ( ( op ) : op is Extract < DrawOp , { kind : "drawText" } > => op . kind === "drawText" )
217+ . map ( ( op ) => op . text )
218+ . join ( "\n" ) ;
219+
220+ assert . equal ( startText . includes ( "abcdef" ) , true ) ;
221+ assert . equal ( endText . includes ( "uvwxyz" ) , true ) ;
222+ assert . ok ( end . cursor . x > start . cursor . x , "cursor should remain visible after viewport shift" ) ;
223+ } ) ;
224+
66225 test ( "increases left padding when dsSize is lg" , ( ) => {
67226 const mdOps = renderOps (
68227 ui . column ( { width : 20 , items : "stretch" } , [
0 commit comments