Skip to content

Commit b1cc03e

Browse files
authored
feat: add native ghostty alternate screen and line wrapping (#29)
- Add ghostty_terminal_is_alternate_screen() to detect alternate buffer mode - Add ghostty_terminal_is_row_wrapped() to detect soft-wrapped lines
1 parent f21d317 commit b1cc03e

5 files changed

Lines changed: 359 additions & 53 deletions

File tree

demo/index.html

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,23 @@ <h3>📜 Scrolling Features</h3>
301301
</div>
302302
</div>
303303

304+
<div class="feature-panel">
305+
<h3>🔍 Buffer Access API (NEW!)</h3>
306+
<div class="button-grid">
307+
<button class="test-button" id="btn-testAlternateScreen">🔄 Test Alternate Screen</button>
308+
<button class="test-button" id="btn-testWrapping">📏 Test Line Wrapping</button>
309+
<button class="test-button" id="btn-testEdgeCases">⚠️ Test Edge Cases</button>
310+
<button class="test-button" id="btn-showBufferInfo">ℹ️ Show Buffer Info</button>
311+
</div>
312+
<div>
313+
<strong style="display: block; margin-bottom: 8px">API Test Results:</strong>
314+
<div class="event-log" id="buffer-api-log">
315+
<div style="opacity: 0.5; text-align: center">Test results will appear here...</div>
316+
</div>
317+
<button class="clear-log" id="btn-clear-buffer-log">Clear Results</button>
318+
</div>
319+
</div>
320+
304321
<div class="warning">
305322
<strong>⚠️ Warning: Full Filesystem Access</strong>
306323
This demo has unrestricted access to your entire filesystem. It's meant for local
@@ -692,6 +709,167 @@ <h3>📜 Scrolling Features</h3>
692709
}, 100);
693710
});
694711

712+
// =========================================================================
713+
// Buffer Access API Handlers (NEW!)
714+
// =========================================================================
715+
716+
function logBufferEvent(message, isSuccess = true) {
717+
const bufferLog = document.getElementById('buffer-api-log');
718+
const eventDiv = document.createElement('div');
719+
eventDiv.className = 'event';
720+
eventDiv.innerHTML = `
721+
<span class="event-type" style="color: ${isSuccess ? '#4caf50' : '#ff9800'}">${isSuccess ? '✓' : '→'}</span>
722+
<span class="event-data">${message}</span>
723+
`;
724+
bufferLog.appendChild(eventDiv);
725+
bufferLog.scrollTop = bufferLog.scrollHeight;
726+
}
727+
728+
document.getElementById('btn-clear-buffer-log').addEventListener('click', () => {
729+
const bufferLog = document.getElementById('buffer-api-log');
730+
bufferLog.innerHTML =
731+
'<div style="opacity: 0.5; text-align: center">Test results will appear here...</div>';
732+
});
733+
734+
document.getElementById('btn-testAlternateScreen').addEventListener('click', async () => {
735+
const bufferLog = document.getElementById('buffer-api-log');
736+
bufferLog.innerHTML = ''; // Clear log
737+
738+
logBufferEvent('Testing alternate screen detection...', false);
739+
740+
// Check initial state
741+
const initial = term.wasmTerm?.isAlternateScreen();
742+
logBufferEvent(
743+
`Initial (normal mode): ${initial === false ? '✓ PASS' : '✗ FAIL'} (${initial})`
744+
);
745+
746+
// Enter alternate screen
747+
term.write('\x1b[?1049h');
748+
term.write('\x1b[2J\x1b[H'); // Clear and home
749+
term.write('\x1b[1;32m╔════════════════════════════════════════╗\r\n');
750+
term.write('║ ALTERNATE SCREEN MODE ACTIVE ║\r\n');
751+
term.write('║ (Like vim/less/htop) ║\r\n');
752+
term.write('║ ║\r\n');
753+
term.write('║ Normal buffer is preserved! ║\r\n');
754+
term.write('║ Exiting in 3 seconds... ║\r\n');
755+
term.write('╚════════════════════════════════════════╝\x1b[0m\r\n');
756+
757+
const inAlt = term.wasmTerm?.isAlternateScreen();
758+
logBufferEvent(`In alternate mode: ${inAlt === true ? '✓ PASS' : '✗ FAIL'} (${inAlt})`);
759+
760+
// Exit after delay
761+
await new Promise((r) => setTimeout(r, 3000));
762+
term.write('\x1b[?1049l');
763+
764+
const afterExit = term.wasmTerm?.isAlternateScreen();
765+
logBufferEvent(
766+
`After exit (normal mode): ${afterExit === false ? '✓ PASS' : '✗ FAIL'} (${afterExit})`
767+
);
768+
769+
if (initial === false && inAlt === true && afterExit === false) {
770+
logBufferEvent('🎉 All alternate screen tests PASSED!', true);
771+
}
772+
});
773+
774+
document.getElementById('btn-testWrapping').addEventListener('click', () => {
775+
const bufferLog = document.getElementById('buffer-api-log');
776+
bufferLog.innerHTML = ''; // Clear log
777+
778+
logBufferEvent('Testing line wrapping detection...', false);
779+
780+
term.write('\x1b[2J\x1b[H'); // Clear screen
781+
782+
// Write short lines (no wrap)
783+
term.write('Short line 1\r\n');
784+
term.write('Short line 2\r\n');
785+
786+
const row0 = term.wasmTerm?.isRowWrapped(0);
787+
const row1 = term.wasmTerm?.isRowWrapped(1);
788+
logBufferEvent(
789+
`Row 0 (short line): ${row0 === false ? '✓ PASS' : '✗ FAIL'} (wrapped=${row0})`
790+
);
791+
logBufferEvent(
792+
`Row 1 (short line): ${row1 === false ? '✓ PASS' : '✗ FAIL'} (wrapped=${row1})`
793+
);
794+
795+
// Write long line that wraps
796+
term.write(
797+
'\x1b[33mThis is a very long line that will definitely wrap to the next line because it is much longer than the terminal width allows for a single line of text!\x1b[0m\r\n'
798+
);
799+
800+
// Check wrapping (row 2 starts long line, row 3+ are continuations)
801+
const results = [];
802+
for (let i = 2; i < 6; i++) {
803+
const wrapped = term.wasmTerm?.isRowWrapped(i);
804+
results.push({ row: i, wrapped });
805+
logBufferEvent(
806+
`Row ${i}: ${wrapped ? 'WRAPPED ↪' : 'NEW LINE ↓'} (wrapped=${wrapped})`
807+
);
808+
}
809+
810+
const row2correct = results[0].wrapped === false; // Start of line
811+
const row3correct = results[1].wrapped === true; // Continuation
812+
813+
if (row0 === false && row1 === false && row2correct && row3correct) {
814+
logBufferEvent('🎉 Line wrapping tests PASSED!', true);
815+
}
816+
});
817+
818+
document.getElementById('btn-testEdgeCases').addEventListener('click', () => {
819+
const bufferLog = document.getElementById('buffer-api-log');
820+
bufferLog.innerHTML = ''; // Clear log
821+
822+
logBufferEvent('Testing edge cases...', false);
823+
824+
// Row 0 can never be wrapped
825+
const row0 = term.wasmTerm?.isRowWrapped(0);
826+
logBufferEvent(`Row 0 (never wraps): ${row0 === false ? '✓ PASS' : '✗ FAIL'} (${row0})`);
827+
828+
// Negative row
829+
const negRow = term.wasmTerm?.isRowWrapped(-1);
830+
logBufferEvent(`Row -1 (invalid): ${negRow === false ? '✓ PASS' : '✗ FAIL'} (${negRow})`);
831+
832+
// Out of bounds
833+
const largeRow = term.wasmTerm?.isRowWrapped(9999);
834+
logBufferEvent(
835+
`Row 9999 (out of bounds): ${largeRow === false ? '✓ PASS' : '✗ FAIL'} (${largeRow})`
836+
);
837+
838+
if (row0 === false && negRow === false && largeRow === false) {
839+
logBufferEvent('🎉 All edge case tests PASSED!', true);
840+
}
841+
});
842+
843+
document.getElementById('btn-showBufferInfo').addEventListener('click', () => {
844+
const bufferLog = document.getElementById('buffer-api-log');
845+
bufferLog.innerHTML = ''; // Clear log
846+
847+
logBufferEvent('📊 Current Buffer Information:', false);
848+
849+
const isAlt = term.wasmTerm?.isAlternateScreen();
850+
const mode = isAlt
851+
? 'Alternate Screen (vim/less/htop mode)'
852+
: 'Normal Screen (shell mode)';
853+
logBufferEvent(`Screen Mode: ${mode}`, true);
854+
855+
logBufferEvent(`Terminal Size: ${term.cols}x${term.rows}`, true);
856+
857+
// Check wrapping for visible rows
858+
let wrappedCount = 0;
859+
for (let i = 0; i < term.rows; i++) {
860+
if (term.wasmTerm?.isRowWrapped(i)) {
861+
wrappedCount++;
862+
}
863+
}
864+
logBufferEvent(`Wrapped Rows: ${wrappedCount}/${term.rows} visible rows`, true);
865+
866+
// Show first 10 rows wrap status
867+
logBufferEvent('First 10 rows wrap status:', false);
868+
for (let i = 0; i < Math.min(10, term.rows); i++) {
869+
const wrapped = term.wasmTerm?.isRowWrapped(i);
870+
logBufferEvent(` Row ${i}: ${wrapped ? '↪ wrapped' : '↓ new line'}`, true);
871+
}
872+
});
695873
// Expose terminal to console for debugging
696874
window.term = term;
697875
}

lib/ghostty.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,55 @@ export class GhosttyTerminal {
394394
return this.exports.ghostty_terminal_get_scrollback_length(this.handle);
395395
}
396396

397+
/**
398+
* Check if terminal is in alternate screen buffer mode.
399+
*
400+
* The alternate screen is used by vim, less, htop, etc.
401+
* When active, normal buffer is preserved and restored when the app exits.
402+
*
403+
* @returns true if in alternate screen, false if in normal screen
404+
*
405+
* @example
406+
* ```typescript
407+
* // Detect if vim is running
408+
* if (term.isAlternateScreen()) {
409+
* console.log('Full-screen app is active');
410+
* }
411+
* ```
412+
*/
413+
isAlternateScreen(): boolean {
414+
return Boolean(this.exports.ghostty_terminal_is_alternate_screen(this.handle));
415+
}
416+
417+
/**
418+
* Check if a row is wrapped from the previous row.
419+
*
420+
* Wrapped rows are continuations of long lines that exceeded terminal width.
421+
* Used for text selection to treat wrapped lines as single logical lines.
422+
*
423+
* @param row Row index (0 = top visible line)
424+
* @returns true if row continues from previous line, false otherwise
425+
*
426+
* @example
427+
* ```typescript
428+
* // Get full logical line including wraps
429+
* let text = '';
430+
* for (let row = 0; row < term.rows; row++) {
431+
* const line = term.getLine(row);
432+
* text += lineToString(line);
433+
*
434+
* // Only add newline if NOT wrapped
435+
* if (!term.isRowWrapped(row + 1)) {
436+
* text += '\n';
437+
* }
438+
* }
439+
* ```
440+
*/
441+
isRowWrapped(row: number): boolean {
442+
if (row < 0 || row >= this._rows) return false;
443+
return Boolean(this.exports.ghostty_terminal_is_row_wrapped(this.handle, row));
444+
}
445+
397446
/**
398447
* Get a line of cells from the visible screen.
399448
*

lib/terminal.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,3 +1093,89 @@ describe('Terminal Options', () => {
10931093
});
10941094
});
10951095
});
1096+
1097+
describe('Buffer Access API', () => {
1098+
let term: Terminal;
1099+
let container: HTMLElement;
1100+
1101+
beforeEach(() => {
1102+
term = new Terminal();
1103+
if (typeof document !== 'undefined') {
1104+
container = document.createElement('div');
1105+
document.body.appendChild(container);
1106+
}
1107+
});
1108+
1109+
afterEach(() => {
1110+
term.dispose();
1111+
if (container && container.parentNode) {
1112+
container.parentNode.removeChild(container);
1113+
}
1114+
});
1115+
1116+
test('isAlternateScreen() starts false', async () => {
1117+
if (!container) return; // Skip if no DOM
1118+
1119+
await term.open(container);
1120+
expect(term.wasmTerm?.isAlternateScreen()).toBe(false);
1121+
});
1122+
1123+
test('isAlternateScreen() detects alternate screen mode', async () => {
1124+
if (!container) return; // Skip if no DOM
1125+
1126+
await term.open(container);
1127+
1128+
// Enter alternate screen (DEC Private Mode 1049 - like vim does)
1129+
term.write('\x1b[?1049h');
1130+
expect(term.wasmTerm?.isAlternateScreen()).toBe(true);
1131+
1132+
// Exit alternate screen
1133+
term.write('\x1b[?1049l');
1134+
expect(term.wasmTerm?.isAlternateScreen()).toBe(false);
1135+
});
1136+
1137+
test('isRowWrapped() returns false for normal line breaks', async () => {
1138+
if (!container) return; // Skip if no DOM
1139+
1140+
await term.open(container);
1141+
term.write('Line 1\r\nLine 2\r\n');
1142+
1143+
expect(term.wasmTerm?.isRowWrapped(0)).toBe(false);
1144+
expect(term.wasmTerm?.isRowWrapped(1)).toBe(false);
1145+
});
1146+
1147+
test('isRowWrapped() detects wrapped lines', async () => {
1148+
if (typeof document === 'undefined') return; // Skip if no DOM
1149+
1150+
// Create narrow terminal to force wrapping
1151+
const narrowTerm = new Terminal({ cols: 20, rows: 10 });
1152+
const narrowContainer = document.createElement('div');
1153+
await narrowTerm.open(narrowContainer);
1154+
1155+
try {
1156+
// Write text longer than terminal width (no newline)
1157+
narrowTerm.write('This is a very long line that will definitely wrap');
1158+
1159+
// First line should not be wrapped (start of line)
1160+
expect(narrowTerm.wasmTerm?.isRowWrapped(0)).toBe(false);
1161+
1162+
// Second line should be wrapped (continuation)
1163+
expect(narrowTerm.wasmTerm?.isRowWrapped(1)).toBe(true);
1164+
} finally {
1165+
narrowTerm.dispose();
1166+
}
1167+
});
1168+
1169+
test('isRowWrapped() handles edge cases', async () => {
1170+
if (!container) return; // Skip if no DOM
1171+
1172+
await term.open(container);
1173+
1174+
// Row 0 can never be wrapped (nothing to wrap from)
1175+
expect(term.wasmTerm?.isRowWrapped(0)).toBe(false);
1176+
1177+
// Out of bounds returns false
1178+
expect(term.wasmTerm?.isRowWrapped(-1)).toBe(false);
1179+
expect(term.wasmTerm?.isRowWrapped(999)).toBe(false);
1180+
});
1181+
});

lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,8 @@ export interface GhosttyWasmExports extends WebAssembly.Exports {
353353
ghostty_terminal_get_cursor_x(terminal: TerminalHandle): number;
354354
ghostty_terminal_get_cursor_y(terminal: TerminalHandle): number;
355355
ghostty_terminal_get_cursor_visible(terminal: TerminalHandle): boolean;
356+
ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean;
357+
ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): boolean;
356358
ghostty_terminal_is_dirty(terminal: TerminalHandle): boolean;
357359
ghostty_terminal_is_row_dirty(terminal: TerminalHandle, row: number): boolean;
358360
ghostty_terminal_clear_dirty(terminal: TerminalHandle): void;

0 commit comments

Comments
 (0)