Skip to content

Commit d9174e8

Browse files
authored
fix: integrate selection highlighting into cell rendering (#87)
Fix glyph clipping issue where complex scripts (like Devanagari कवि) would render on top of selection highlights. This happened because selection was drawn as a semi-transparent overlay after all text, causing z-order issues when adjacent rows were re-rendered for glyph overflow handling.
1 parent e27776c commit d9174e8

File tree

4 files changed

+114
-66
lines changed

4 files changed

+114
-66
lines changed

lib/renderer.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ describe('CanvasRenderer', () => {
4444
});
4545

4646
test('has selection colors', () => {
47-
expect(DEFAULT_THEME.selectionBackground).toBe('rgba(255, 255, 255, 0.3)');
48-
expect(DEFAULT_THEME.selectionForeground).toBe('#d4d4d4');
47+
// Selection colors are now solid (not semi-transparent overlay)
48+
// Ghostty-style: selection bg = foreground color, selection fg = background color
49+
expect(DEFAULT_THEME.selectionBackground).toBe('#d4d4d4');
50+
expect(DEFAULT_THEME.selectionForeground).toBe('#1e1e1e');
4951
});
5052
});
5153

lib/renderer.ts

Lines changed: 93 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ export const DEFAULT_THEME: Required<ITheme> = {
6565
background: '#1e1e1e',
6666
cursor: '#ffffff',
6767
cursorAccent: '#1e1e1e',
68-
selectionBackground: 'rgba(255, 255, 255, 0.3)',
69-
selectionForeground: '#d4d4d4',
68+
// Selection colors: solid colors that replace cell bg/fg when selected
69+
// Using Ghostty's approach: selection bg = default fg, selection fg = default bg
70+
selectionBackground: '#d4d4d4',
71+
selectionForeground: '#1e1e1e',
7072
black: '#000000',
7173
red: '#cd3131',
7274
green: '#0dbc79',
@@ -112,8 +114,15 @@ export class CanvasRenderer {
112114
// Current buffer being rendered (for grapheme lookups)
113115
private currentBuffer: IRenderable | null = null;
114116

115-
// Selection manager (for rendering selection overlay)
117+
// Selection manager (for rendering selection)
116118
private selectionManager?: SelectionManager;
119+
// Cached selection coordinates for current render pass (viewport-relative)
120+
private currentSelectionCoords: {
121+
startCol: number;
122+
startRow: number;
123+
endCol: number;
124+
endRow: number;
125+
} | null = null;
117126

118127
// Link rendering state
119128
private hoveredHyperlinkId: number = 0;
@@ -319,13 +328,15 @@ export class CanvasRenderer {
319328
const hasSelection = this.selectionManager && this.selectionManager.hasSelection();
320329
const selectionRows = new Set<number>();
321330

331+
// Cache selection coordinates for use during cell rendering
332+
// This is used by isInSelection() to determine if a cell needs selection colors
333+
this.currentSelectionCoords = hasSelection ? this.selectionManager!.getSelectionCoords() : null;
334+
322335
// Mark current selection rows for redraw (includes programmatic selections)
323-
if (hasSelection) {
324-
const coords = this.selectionManager!.getSelectionCoords();
325-
if (coords) {
326-
for (let row = coords.startRow; row <= coords.endRow; row++) {
327-
selectionRows.add(row);
328-
}
336+
if (this.currentSelectionCoords) {
337+
const coords = this.currentSelectionCoords;
338+
for (let row = coords.startRow; row <= coords.endRow; row++) {
339+
selectionRows.add(row);
329340
}
330341
}
331342

@@ -467,12 +478,8 @@ export class CanvasRenderer {
467478
}
468479
}
469480

470-
// Render selection highlight AFTER all text (so it overlays)
471-
// Only render if we actually rendered some lines
472-
if (hasSelection && anyLinesRendered) {
473-
// Draw selection overlay - only when we've redrawn the underlying text
474-
this.renderSelection(dims.cols);
475-
}
481+
// Selection highlighting is now integrated into renderCellBackground/renderCellText
482+
// No separate overlay pass needed - this fixes z-order issues with complex glyphs
476483

477484
// Link underlines are drawn during cell rendering (see renderCell)
478485

@@ -535,12 +542,24 @@ export class CanvasRenderer {
535542

536543
/**
537544
* Render a cell's background only (Pass 1 of two-pass rendering)
545+
* Selection highlighting is integrated here to avoid z-order issues with
546+
* complex glyphs (like Devanagari) that extend outside their cell bounds.
538547
*/
539548
private renderCellBackground(cell: GhosttyCell, x: number, y: number): void {
540549
const cellX = x * this.metrics.width;
541550
const cellY = y * this.metrics.height;
542551
const cellWidth = this.metrics.width * cell.width;
543552

553+
// Check if this cell is selected
554+
const isSelected = this.isInSelection(x, y);
555+
556+
if (isSelected) {
557+
// Draw selection background (solid color, not overlay)
558+
this.ctx.fillStyle = this.theme.selectionBackground;
559+
this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height);
560+
return; // Selection background replaces cell background
561+
}
562+
544563
// Extract background color and handle inverse
545564
let bg_r = cell.bg_r,
546565
bg_g = cell.bg_g,
@@ -564,6 +583,7 @@ export class CanvasRenderer {
564583

565584
/**
566585
* Render a cell's text and decorations (Pass 2 of two-pass rendering)
586+
* Selection foreground color is applied here to match the selection background.
567587
*/
568588
private renderCellText(cell: GhosttyCell, x: number, y: number): void {
569589
const cellX = x * this.metrics.width;
@@ -575,26 +595,33 @@ export class CanvasRenderer {
575595
return;
576596
}
577597

578-
// Extract colors and handle inverse
579-
let fg_r = cell.fg_r,
580-
fg_g = cell.fg_g,
581-
fg_b = cell.fg_b;
582-
583-
if (cell.flags & CellFlags.INVERSE) {
584-
// When inverted, foreground becomes background
585-
fg_r = cell.bg_r;
586-
fg_g = cell.bg_g;
587-
fg_b = cell.bg_b;
588-
}
598+
// Check if this cell is selected
599+
const isSelected = this.isInSelection(x, y);
589600

590601
// Set text style
591602
let fontStyle = '';
592603
if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic ';
593604
if (cell.flags & CellFlags.BOLD) fontStyle += 'bold ';
594605
this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`;
595606

596-
// Set text color
597-
this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b);
607+
// Set text color - use selection foreground if selected
608+
if (isSelected) {
609+
this.ctx.fillStyle = this.theme.selectionForeground;
610+
} else {
611+
// Extract colors and handle inverse
612+
let fg_r = cell.fg_r,
613+
fg_g = cell.fg_g,
614+
fg_b = cell.fg_b;
615+
616+
if (cell.flags & CellFlags.INVERSE) {
617+
// When inverted, foreground becomes background
618+
fg_r = cell.bg_r;
619+
fg_g = cell.bg_g;
620+
fg_b = cell.bg_b;
621+
}
622+
623+
this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b);
624+
}
598625

599626
// Apply faint effect
600627
if (cell.flags & CellFlags.FAINT) {
@@ -816,9 +843,6 @@ export class CanvasRenderer {
816843
visibleRows: number,
817844
opacity: number = 1
818845
): void {
819-
// Don't render if fully transparent or no scrollback
820-
if (opacity <= 0 || scrollbackLength === 0) return;
821-
822846
const ctx = this.ctx;
823847
const canvasHeight = this.canvas.height / this.devicePixelRatio;
824848
const canvasWidth = this.canvas.width / this.devicePixelRatio;
@@ -829,6 +853,13 @@ export class CanvasRenderer {
829853
const scrollbarPadding = 4;
830854
const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2;
831855

856+
// Always clear the scrollbar area first (fixes ghosting when fading out)
857+
ctx.fillStyle = this.theme.background;
858+
ctx.fillRect(scrollbarX - 2, 0, scrollbarWidth + 6, canvasHeight);
859+
860+
// Don't draw scrollbar if fully transparent or no scrollback
861+
if (opacity <= 0 || scrollbackLength === 0) return;
862+
832863
// Calculate scrollbar thumb size and position
833864
const totalLines = scrollbackLength + visibleRows;
834865
const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight);
@@ -859,12 +890,42 @@ export class CanvasRenderer {
859890
}
860891

861892
/**
862-
* Set selection manager (for rendering selection overlay)
893+
* Set selection manager (for rendering selection)
863894
*/
864895
public setSelectionManager(manager: SelectionManager): void {
865896
this.selectionManager = manager;
866897
}
867898

899+
/**
900+
* Check if a cell at (x, y) is within the current selection.
901+
* Uses cached selection coordinates for performance.
902+
*/
903+
private isInSelection(x: number, y: number): boolean {
904+
const sel = this.currentSelectionCoords;
905+
if (!sel) return false;
906+
907+
const { startCol, startRow, endCol, endRow } = sel;
908+
909+
// Single line selection
910+
if (startRow === endRow) {
911+
return y === startRow && x >= startCol && x <= endCol;
912+
}
913+
914+
// Multi-line selection
915+
if (y === startRow) {
916+
// First line: from startCol to end of line
917+
return x >= startCol;
918+
} else if (y === endRow) {
919+
// Last line: from start of line to endCol
920+
return x <= endCol;
921+
} else if (y > startRow && y < endRow) {
922+
// Middle lines: entire line is selected
923+
return true;
924+
}
925+
926+
return false;
927+
}
928+
868929
/**
869930
* Set the currently hovered hyperlink ID for rendering underlines
870931
*/
@@ -909,35 +970,6 @@ export class CanvasRenderer {
909970
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
910971
}
911972

912-
/**
913-
* Render selection overlay
914-
*/
915-
private renderSelection(cols: number): void {
916-
const coords = this.selectionManager!.getSelectionCoords();
917-
if (!coords) return;
918-
919-
const { startCol, startRow, endCol, endRow } = coords;
920-
921-
// Use semi-transparent fill for selection
922-
this.ctx.save();
923-
this.ctx.fillStyle = this.theme.selectionBackground;
924-
this.ctx.globalAlpha = 0.5; // Make it semi-transparent so text is visible
925-
926-
for (let row = startRow; row <= endRow; row++) {
927-
const colStart = row === startRow ? startCol : 0;
928-
const colEnd = row === endRow ? endCol : cols - 1;
929-
930-
const x = colStart * this.metrics.width;
931-
const y = row * this.metrics.height;
932-
const width = (colEnd - colStart + 1) * this.metrics.width;
933-
const height = this.metrics.height;
934-
935-
this.ctx.fillRect(x, y, width, height);
936-
}
937-
938-
this.ctx.restore();
939-
}
940-
941973
/**
942974
* Cleanup resources
943975
*/

lib/selection-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* - Double-click word selection
77
* - Text extraction from terminal buffer
88
* - Automatic clipboard copy
9-
* - Visual selection overlay (rendered by CanvasRenderer)
9+
* - Visual selection highlighting (integrated into CanvasRenderer cell rendering)
1010
* - Auto-scroll during drag selection
1111
*/
1212

lib/terminal.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export class Terminal implements ITerminalCore {
143143
cursorBlink: options.cursorBlink ?? false,
144144
cursorStyle: options.cursorStyle ?? 'block',
145145
theme: options.theme ?? {},
146-
scrollback: options.scrollback ?? 1000,
146+
scrollback: options.scrollback ?? 10000,
147147
fontSize: options.fontSize ?? 15,
148148
fontFamily: options.fontFamily ?? 'monospace',
149149
allowTransparency: options.allowTransparency ?? false,
@@ -290,7 +290,7 @@ export class Terminal implements ITerminalCore {
290290
const scrollback = this.options.scrollback;
291291

292292
// If no theme and default scrollback, use defaults
293-
if (!theme && scrollback === 1000) {
293+
if (!theme && scrollback === 10000) {
294294
return undefined;
295295
}
296296

@@ -1697,6 +1697,11 @@ export class Terminal implements ITerminalCore {
16971697
const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1);
16981698
this.scrollbarOpacity = progress;
16991699

1700+
// Trigger render to show updated opacity
1701+
if (this.renderer && this.wasmTerm) {
1702+
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1703+
}
1704+
17001705
if (progress < 1) {
17011706
requestAnimationFrame(animate);
17021707
}
@@ -1715,11 +1720,20 @@ export class Terminal implements ITerminalCore {
17151720
const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1);
17161721
this.scrollbarOpacity = startOpacity * (1 - progress);
17171722

1723+
// Trigger render to show updated opacity
1724+
if (this.renderer && this.wasmTerm) {
1725+
this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity);
1726+
}
1727+
17181728
if (progress < 1) {
17191729
requestAnimationFrame(animate);
17201730
} else {
17211731
this.scrollbarVisible = false;
17221732
this.scrollbarOpacity = 0;
1733+
// Final render to clear scrollbar completely
1734+
if (this.renderer && this.wasmTerm) {
1735+
this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0);
1736+
}
17231737
}
17241738
};
17251739
animate();

0 commit comments

Comments
 (0)