Skip to content

Commit d021a4a

Browse files
committed
fix: scroll, selection
1 parent 1bd8a93 commit d021a4a

3 files changed

Lines changed: 139 additions & 56 deletions

File tree

‎src/components/terminal/terminal.js‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default class TerminalComponent {
7373
this.parsedAppKeybindings = [];
7474
this.parsedAppKeybindingsVersion = -1;
7575
this.boundNativeSelectionMenuHandler = null;
76+
this.visibleScrollbarWidth = undefined;
7677

7778
this.init();
7879
}
@@ -1006,10 +1007,33 @@ export default class TerminalComponent {
10061007
* @param {boolean} visible Whether the scrollbar should be shown
10071008
*/
10081009
updateScrollbarVisibility(visible) {
1009-
this.terminal?.element?.classList.toggle(
1010+
if (!this.terminal) return;
1011+
1012+
const overviewRuler = { ...this.terminal.options.overviewRuler };
1013+
if (visible === false) {
1014+
if (
1015+
!this.terminal.element?.classList.contains("terminal-scrollbar-hidden")
1016+
) {
1017+
this.visibleScrollbarWidth = overviewRuler.width;
1018+
}
1019+
// xterm 6 and FitAddon fall back to 14px when width is zero. A tiny,
1020+
// truthy width removes the gutter while CSS hides the remaining fraction.
1021+
overviewRuler.width = 0.001;
1022+
} else if (this.visibleScrollbarWidth === undefined) {
1023+
delete overviewRuler.width;
1024+
} else {
1025+
overviewRuler.width = this.visibleScrollbarWidth;
1026+
}
1027+
this.terminal.options.overviewRuler = overviewRuler;
1028+
this.terminal.element?.classList.toggle(
10101029
"terminal-scrollbar-hidden",
10111030
visible === false,
10121031
);
1032+
1033+
requestAnimationFrame(() => {
1034+
if (!this.terminal) return;
1035+
this.fitAddon?.fit();
1036+
});
10131037
}
10141038

10151039
/**

‎src/components/terminal/terminalTouchScrolling.js‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,19 @@ export default class TerminalTouchScrolling {
1717
this.element = null;
1818

1919
this.touchStartY = 0;
20+
this.lastTouchX = 0;
2021
this.lastTouchY = 0;
2122
this.lastTouchTime = 0;
2223
this.isTouching = false;
2324
this.didScroll = false;
25+
this.totalMovement = 0;
2426

2527
this.velocitySamples = [];
2628
this.velocity = 0;
2729
this.scrollRemainder = 0;
28-
this.maxVelocity = 35;
29-
this.friction = 0.95;
30-
this.minVelocity = 0.1;
31-
this.velocityThreshold = 1;
30+
this.friction = 0.92;
31+
this.minVelocity = 0.5;
32+
this.scrollConfirmPixels = 6;
3233

3334
this.animationId = null;
3435
this.boundHandlers = {};
@@ -58,7 +59,23 @@ export default class TerminalTouchScrolling {
5859
(this.terminal.options.lineHeight || 1);
5960
}
6061

61-
scrollByPixels(deltaY) {
62+
scrollByPixels(deltaY, clientX = this.lastTouchX, clientY = this.lastTouchY) {
63+
if (this.terminal.buffer.active.type === "alternate") {
64+
const target =
65+
this.element?.querySelector(".xterm-screen") || this.element;
66+
target?.dispatchEvent(
67+
new WheelEvent("wheel", {
68+
bubbles: true,
69+
cancelable: true,
70+
clientX,
71+
clientY,
72+
deltaY,
73+
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
74+
}),
75+
);
76+
return;
77+
}
78+
6279
const cellHeight = this.getCellHeight();
6380
if (!Number.isFinite(cellHeight) || cellHeight <= 0) return;
6481

@@ -76,7 +93,7 @@ export default class TerminalTouchScrolling {
7693
this.boundHandlers.touchStart = this.onTouchStart.bind(this);
7794
this.boundHandlers.touchMove = this.onTouchMove.bind(this);
7895
this.boundHandlers.touchEnd = this.onTouchEnd.bind(this);
79-
this.boundHandlers.touchCancel = this.onTouchEnd.bind(this);
96+
this.boundHandlers.touchCancel = this.onTouchCancel.bind(this);
8097

8198
this.element.addEventListener("touchstart", this.boundHandlers.touchStart, {
8299
passive: false,
@@ -110,10 +127,12 @@ export default class TerminalTouchScrolling {
110127

111128
const touch = event.touches[0];
112129
this.touchStartY = touch.clientY;
130+
this.lastTouchX = touch.clientX;
113131
this.lastTouchY = touch.clientY;
114132
this.lastTouchTime = performance.now();
115133
this.isTouching = true;
116134
this.didScroll = false;
135+
this.totalMovement = 0;
117136
this.velocity = 0;
118137
this.velocitySamples = [];
119138
this.scrollRemainder = 0;
@@ -135,22 +154,25 @@ export default class TerminalTouchScrolling {
135154
const touch = event.touches[0];
136155
const deltaY = this.lastTouchY - touch.clientY;
137156
const deltaTime = performance.now() - this.lastTouchTime;
157+
this.totalMovement += Math.abs(deltaY);
138158

139159
if (deltaTime > 0) {
140160
const instantVelocity = (deltaY / deltaTime) * 16.67;
141161
this.velocitySamples.push(instantVelocity);
142-
if (this.velocitySamples.length > 6) {
162+
if (this.velocitySamples.length > 5) {
143163
this.velocitySamples.shift();
144164
}
145165
}
146166

147167
if (Math.abs(deltaY) > 0.5) {
148-
event.preventDefault();
149-
this.didScroll = true;
150-
151-
this.scrollByPixels(deltaY);
168+
this.scrollByPixels(deltaY, touch.clientX, touch.clientY);
169+
if (this.totalMovement > this.scrollConfirmPixels) {
170+
event.preventDefault();
171+
this.didScroll = true;
172+
}
152173
}
153174

175+
this.lastTouchX = touch.clientX;
154176
this.lastTouchY = touch.clientY;
155177
this.lastTouchTime = performance.now();
156178
}
@@ -171,22 +193,26 @@ export default class TerminalTouchScrolling {
171193

172194
if (this.velocitySamples.length > 0) {
173195
this.velocity =
174-
this.velocitySamples.reduce((a, b) => a + b, 0) /
175-
this.velocitySamples.length;
176-
177-
this.velocity = Math.max(
178-
-this.maxVelocity,
179-
Math.min(this.maxVelocity, this.velocity),
180-
);
196+
(this.velocitySamples.reduce((a, b) => a + b, 0) /
197+
this.velocitySamples.length) *
198+
1.1;
181199
}
182200

183-
if (Math.abs(this.velocity) > this.velocityThreshold) {
201+
if (Math.abs(this.velocity) >= this.minVelocity) {
184202
this.startMomentum();
185203
}
186204

187205
this.velocitySamples = [];
188206
}
189207

208+
onTouchCancel() {
209+
this.isTouching = false;
210+
this.didScroll = false;
211+
this.velocitySamples = [];
212+
this.scrollRemainder = 0;
213+
this.stopMomentum();
214+
}
215+
190216
startMomentum() {
191217
const animate = () => {
192218
if (this.isTouching) {
@@ -199,8 +225,8 @@ export default class TerminalTouchScrolling {
199225
return;
200226
}
201227

202-
this.scrollByPixels(this.velocity);
203228
this.velocity *= this.friction;
229+
this.scrollByPixels(this.velocity);
204230

205231
this.animationId = requestAnimationFrame(animate);
206232
};

‎src/components/terminal/terminalTouchSelection.js‎

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ export default class TerminalTouchSelection {
194194
this.isTerminalScrolling = false;
195195
this.scrollEndTimeout = null;
196196
this.scrollEndDelay = 100;
197+
this.selectionRenderFrame = null;
198+
this.selectionResizeTimeout = null;
197199

198200
this.init();
199201
}
@@ -466,6 +468,13 @@ export default class TerminalTouchSelection {
466468
return;
467469
}
468470

471+
// A long press can otherwise produce a compatibility mouse/click event
472+
// after touchend. xterm 6 handles that event as a new selection gesture,
473+
// which clears the range created by startSelection.
474+
if (this.isSelecting && event.cancelable) {
475+
event.preventDefault();
476+
}
477+
469478
if (this.tapHoldTimeout) {
470479
clearTimeout(this.tapHoldTimeout);
471480
this.tapHoldTimeout = null;
@@ -687,42 +696,42 @@ export default class TerminalTouchSelection {
687696
}
688697
}
689698

690-
onTerminalResize(size) {
691-
// Handle terminal resize (keyboard open/close on Android)
692-
setTimeout(() => {
699+
onTerminalResize() {
700+
// xterm 6 clears its selection on vertical resize. Debounce the keyboard's
701+
// intermediate sizes, then restore our range using absolute buffer rows.
702+
if (this.selectionResizeTimeout) {
703+
clearTimeout(this.selectionResizeTimeout);
704+
}
705+
this.selectionResizeTimeout = setTimeout(() => {
706+
this.selectionResizeTimeout = null;
693707
this.updateCellDimensions();
694-
if (this.isSelecting) {
695-
// Don't clear selection if it's protected (during keyboard events)
696-
if (this.selectionProtected) {
697-
// Just update handle positions during protected period
698-
this.updateHandlePositions();
699-
return;
700-
}
708+
if (!this.isSelecting) return;
701709

702-
// Only clear selection if it becomes invalid due to actual content resize
703-
// Don't clear selection for keyboard-related resizes
704-
if (
705-
this.selectionStart &&
706-
this.selectionEnd &&
707-
(this.selectionStart.row >= size.rows ||
708-
this.selectionEnd.row >= size.rows)
709-
) {
710-
this.clearSelection();
711-
} else if (this.isSelecting) {
712-
// Maintain selection and update handle positions
713-
this.updateHandlePositions();
714-
// Temporarily hide context menu during resize but keep selection
715-
if (this.contextMenu && this.contextMenu.style.display === "flex") {
716-
this.hideContextMenu(true);
717-
}
718-
// Re-show context menu after resize if selection is still active
719-
setTimeout(() => {
720-
if (this.isSelecting && this.options.showContextMenu) {
721-
this.showContextMenu();
722-
}
723-
}, 100);
724-
}
710+
const start = this.selectionStart;
711+
const end = this.selectionEnd;
712+
const buffer = this.terminal.buffer.active;
713+
if (
714+
!start ||
715+
!end ||
716+
start.row < 0 ||
717+
end.row < 0 ||
718+
start.row >= buffer.length ||
719+
end.row >= buffer.length ||
720+
!buffer.getLine(start.row) ||
721+
!buffer.getLine(end.row)
722+
) {
723+
// Invalid buffer coordinates must also remove our overlay handles,
724+
// even while the selection is protected from keyboard side effects.
725+
this.forceClearSelection();
726+
return;
725727
}
728+
729+
const lastColumn = Math.max(0, this.terminal.cols - 1);
730+
start.col = Math.min(start.col, lastColumn);
731+
end.col = Math.min(end.col, lastColumn);
732+
733+
this.hideContextMenu(true);
734+
this.finalizeSelection();
726735
}, 50);
727736
}
728737

@@ -847,9 +856,25 @@ export default class TerminalTouchSelection {
847856
}
848857

849858
finalizeSelection() {
850-
if (this.options.showContextMenu && this.currentSelection) {
851-
this.showContextMenu();
859+
if (!this.isSelecting) return;
860+
861+
this.updateSelection();
862+
this.currentSelection = this.terminal.getSelection();
863+
864+
if (this.selectionRenderFrame) {
865+
cancelAnimationFrame(this.selectionRenderFrame);
852866
}
867+
this.selectionRenderFrame = requestAnimationFrame(() => {
868+
this.selectionRenderFrame = null;
869+
if (!this.isSelecting || !this.terminal) return;
870+
871+
// Reapply after any compatibility mouse events dispatched by WebView.
872+
this.updateSelection();
873+
this.currentSelection = this.terminal.getSelection();
874+
if (this.options.showContextMenu && this.currentSelection) {
875+
this.showContextMenu();
876+
}
877+
});
853878
}
854879

855880
showHandles() {
@@ -1255,6 +1280,14 @@ export default class TerminalTouchSelection {
12551280
clearTimeout(this.scrollEndTimeout);
12561281
this.scrollEndTimeout = null;
12571282
}
1283+
if (this.selectionRenderFrame) {
1284+
cancelAnimationFrame(this.selectionRenderFrame);
1285+
this.selectionRenderFrame = null;
1286+
}
1287+
if (this.selectionResizeTimeout) {
1288+
clearTimeout(this.selectionResizeTimeout);
1289+
this.selectionResizeTimeout = null;
1290+
}
12581291

12591292
// Clear protection timeout
12601293
if (this.protectionTimeout) {

0 commit comments

Comments
 (0)