|
1 | | -import { describe, test, expect } from 'bun:test' |
| 1 | +import { beforeAll, describe, expect, test } from 'bun:test' |
| 2 | +import { createTestRenderer } from '@opentui/core/testing' |
| 3 | +import { createRoot, flushSync } from '@opentui/react' |
| 4 | +import React from 'react' |
2 | 5 |
|
3 | 6 | import { |
4 | 7 | getKeypadPrintableSequence, |
5 | 8 | isKeypadEnter, |
6 | 9 | } from '../../utils/keypad-keys' |
| 10 | +import { initializeThemeStore } from '../../hooks/use-theme' |
| 11 | +import { |
| 12 | + calculateMultilineInputCursorPosition, |
| 13 | + MultilineInput, |
| 14 | +} from '../multiline-input' |
| 15 | + |
| 16 | +beforeAll(() => { |
| 17 | + initializeThemeStore() |
| 18 | +}) |
7 | 19 |
|
8 | 20 | /** |
9 | 21 | * Tests for tab character cursor rendering in MultilineInput component. |
@@ -242,6 +254,187 @@ describe('MultilineInput - tab character handling', () => { |
242 | 254 | }) |
243 | 255 | }) |
244 | 256 |
|
| 257 | +describe('MultilineInput - hardware cursor coordinates', () => { |
| 258 | + const viewportX = 10 |
| 259 | + const viewportY = 20 |
| 260 | + const lineInfo = (lineStartCols: number[]) => ({ lineStartCols }) |
| 261 | + |
| 262 | + test('places the ASCII caret using terminal cell columns', () => { |
| 263 | + expect( |
| 264 | + calculateMultilineInputCursorPosition({ |
| 265 | + text: 'hello', |
| 266 | + cursorPosition: 2, |
| 267 | + lineInfo: lineInfo([0]), |
| 268 | + viewportX, |
| 269 | + viewportY, |
| 270 | + verticalScrollPosition: 0, |
| 271 | + }), |
| 272 | + ).toEqual({ x: 13, y: 21 }) |
| 273 | + }) |
| 274 | + |
| 275 | + test('advances two terminal cells after a CJK wide character', () => { |
| 276 | + expect( |
| 277 | + calculateMultilineInputCursorPosition({ |
| 278 | + text: '你a', |
| 279 | + cursorPosition: 1, |
| 280 | + lineInfo: lineInfo([0]), |
| 281 | + viewportX, |
| 282 | + viewportY, |
| 283 | + verticalScrollPosition: 0, |
| 284 | + }), |
| 285 | + ).toEqual({ x: 13, y: 21 }) |
| 286 | + }) |
| 287 | + |
| 288 | + test('handles mixed ASCII and CJK widths', () => { |
| 289 | + expect( |
| 290 | + calculateMultilineInputCursorPosition({ |
| 291 | + text: 'a你b', |
| 292 | + cursorPosition: 2, |
| 293 | + lineInfo: lineInfo([0]), |
| 294 | + viewportX, |
| 295 | + viewportY, |
| 296 | + verticalScrollPosition: 0, |
| 297 | + }), |
| 298 | + ).toEqual({ x: 14, y: 21 }) |
| 299 | + }) |
| 300 | + |
| 301 | + test('uses the existing four-cell tab expansion', () => { |
| 302 | + expect( |
| 303 | + calculateMultilineInputCursorPosition({ |
| 304 | + text: '\ta', |
| 305 | + cursorPosition: 1, |
| 306 | + lineInfo: lineInfo([0]), |
| 307 | + viewportX, |
| 308 | + viewportY, |
| 309 | + verticalScrollPosition: 0, |
| 310 | + }), |
| 311 | + ).toEqual({ x: 15, y: 21 }) |
| 312 | + }) |
| 313 | + |
| 314 | + test('uses cumulative visual-line offsets for wrapped text', () => { |
| 315 | + expect( |
| 316 | + calculateMultilineInputCursorPosition({ |
| 317 | + text: 'abcdefghij', |
| 318 | + cursorPosition: 7, |
| 319 | + lineInfo: lineInfo([0, 5]), |
| 320 | + viewportX, |
| 321 | + viewportY, |
| 322 | + verticalScrollPosition: 0, |
| 323 | + }), |
| 324 | + ).toEqual({ x: 13, y: 22 }) |
| 325 | + }) |
| 326 | + |
| 327 | + test('applies the viewport offset and vertical scroll position', () => { |
| 328 | + expect( |
| 329 | + calculateMultilineInputCursorPosition({ |
| 330 | + text: 'a\nb\nc', |
| 331 | + cursorPosition: 4, |
| 332 | + lineInfo: lineInfo([0, 2, 4]), |
| 333 | + viewportX, |
| 334 | + viewportY, |
| 335 | + verticalScrollPosition: 1, |
| 336 | + }), |
| 337 | + ).toEqual({ x: 11, y: 22 }) |
| 338 | + }) |
| 339 | +}) |
| 340 | + |
| 341 | +describe('MultilineInput - hardware cursor lifecycle', () => { |
| 342 | + test('moves the renderer cursor two cells after a CJK character', async () => { |
| 343 | + const setup = await createTestRenderer({ width: 30, height: 8 }) |
| 344 | + const root = createRoot(setup.renderer) |
| 345 | + |
| 346 | + try { |
| 347 | + flushSync(() => |
| 348 | + root.render( |
| 349 | + <MultilineInput |
| 350 | + value="你a" |
| 351 | + onChange={() => {}} |
| 352 | + onSubmit={() => {}} |
| 353 | + onPaste={() => {}} |
| 354 | + cursorPosition={1} |
| 355 | + maxHeight={3} |
| 356 | + shouldBlinkCursor={false} |
| 357 | + focused |
| 358 | + />, |
| 359 | + ), |
| 360 | + ) |
| 361 | + await setup.renderOnce() |
| 362 | + |
| 363 | + expect(setup.renderer.getCursorState()).toMatchObject({ |
| 364 | + x: 4, |
| 365 | + y: 1, |
| 366 | + visible: true, |
| 367 | + }) |
| 368 | + } finally { |
| 369 | + flushSync(() => root.unmount()) |
| 370 | + setup.renderer.destroy() |
| 371 | + } |
| 372 | + }) |
| 373 | + |
| 374 | + test('places the renderer cursor on the wrapped visual row', async () => { |
| 375 | + const setup = await createTestRenderer({ width: 12, height: 8 }) |
| 376 | + const root = createRoot(setup.renderer) |
| 377 | + const props = { |
| 378 | + value: 'one two three', |
| 379 | + onChange: () => {}, |
| 380 | + onSubmit: () => {}, |
| 381 | + onPaste: () => {}, |
| 382 | + cursorPosition: 13, |
| 383 | + maxHeight: 5, |
| 384 | + shouldBlinkCursor: false, |
| 385 | + focused: true, |
| 386 | + } |
| 387 | + |
| 388 | + try { |
| 389 | + flushSync(() => root.render(<MultilineInput {...props} />)) |
| 390 | + await setup.renderOnce() |
| 391 | + |
| 392 | + // The wrapped second line is scrolled into the top viewport row. |
| 393 | + expect(setup.renderer.getCursorState()).toMatchObject({ |
| 394 | + x: 7, |
| 395 | + y: 1, |
| 396 | + visible: true, |
| 397 | + }) |
| 398 | + } finally { |
| 399 | + flushSync(() => root.unmount()) |
| 400 | + setup.renderer.destroy() |
| 401 | + } |
| 402 | + }) |
| 403 | + |
| 404 | + test('hides the hardware cursor when unfocused and on unmount', async () => { |
| 405 | + const setup = await createTestRenderer({ width: 30, height: 8 }) |
| 406 | + const root = createRoot(setup.renderer) |
| 407 | + const props = { |
| 408 | + value: 'input', |
| 409 | + onChange: () => {}, |
| 410 | + onSubmit: () => {}, |
| 411 | + onPaste: () => {}, |
| 412 | + cursorPosition: 2, |
| 413 | + maxHeight: 3, |
| 414 | + shouldBlinkCursor: false, |
| 415 | + } |
| 416 | + |
| 417 | + try { |
| 418 | + flushSync(() => root.render(<MultilineInput {...props} focused />)) |
| 419 | + await setup.renderOnce() |
| 420 | + expect(setup.renderer.getCursorState().visible).toBe(true) |
| 421 | + |
| 422 | + flushSync(() => root.render(<MultilineInput {...props} focused={false} />)) |
| 423 | + await setup.renderOnce() |
| 424 | + expect(setup.renderer.getCursorState().visible).toBe(false) |
| 425 | + |
| 426 | + flushSync(() => root.render(<MultilineInput {...props} focused />)) |
| 427 | + await setup.renderOnce() |
| 428 | + expect(setup.renderer.getCursorState().visible).toBe(true) |
| 429 | + |
| 430 | + flushSync(() => root.unmount()) |
| 431 | + expect(setup.renderer.getCursorState().visible).toBe(false) |
| 432 | + } finally { |
| 433 | + setup.renderer.destroy() |
| 434 | + } |
| 435 | + }) |
| 436 | +}) |
| 437 | + |
245 | 438 | /** |
246 | 439 | * Tests for Chinese/IME character input handling in MultilineInput component. |
247 | 440 | * |
|
0 commit comments