From 58e0fdcece0d5c9234bf3816c96ee4fcaba28e9c Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 25 Jun 2026 19:42:30 +0530 Subject: [PATCH 1/2] feat(lsp): redesign parameter hints popup, fix occlusion and flicker Cap the function-hint popup to the editor width and keep the signature on a single scrollable line, so a long TS signature can no longer run off-screen underneath the central control bar. Clamp it into the editor area and raise its z-index above the CCB. Theme the surface to match the other LSP popups, use a monospace font, and highlight the active parameter clearly. When the signature overflows, scroll the active parameter into view and fade the clipped edge(s). Stop the popup flickering on every caret move: cursorActivity no longer dismisses (hides) the popup before firing the async request. It now updates in place - skipping the redraw entirely when the same signature and active parameter are already shown, and keeping the popup anchored while only the highlight moves. A monotonic request id drops stale responses and prevents a late response from re-showing a dismissed popup. --- src/features/ParameterHintsManager.js | 134 ++++++++++++++++--- src/htmlContent/parameter-hint-template.html | 4 +- src/styles/brackets.less | 75 +++++++++-- src/styles/brackets_variables.less | 4 +- 4 files changed, 181 insertions(+), 36 deletions(-) diff --git a/src/features/ParameterHintsManager.js b/src/features/ParameterHintsManager.js index 65a15f5f47..24c004da3a 100644 --- a/src/features/ParameterHintsManager.js +++ b/src/features/ParameterHintsManager.js @@ -53,6 +53,7 @@ define(function (require, exports, module) { }; let $hintContainer, // function hint container + $hintScroll, // single-line clipping/scrolling layer $hintContent, // function hint content holder hintState = {}, lastChar = null, @@ -66,12 +67,51 @@ define(function (require, exports, module) { // keep jslint from complaining about handleCursorActivity being used before // it was defined. let handleCursorActivity, - popupShown = false; + popupShown = false, + // Monotonic id so a slow LSP response from an earlier caret position can be ignored + // once a newer cursor move has fired a fresh request. + pendingRequestId = 0; + + /** + * A stable identity for the function being called (its parameter list), independent of which + * parameter the caret is currently in. Used to keep the popup anchored while only the active + * parameter changes. + * @param {{parameters: Array}} hint + * @return {string} + */ + function _signatureKey(hint) { + return (hint.parameters || []).map(function (p) { + return p.label || p.type || ""; + }).join(","); + } let _providerRegistrationHandler = new ProviderRegistrationHandler(), registerHintProvider = _providerRegistrationHandler.registerProvider.bind(_providerRegistrationHandler), removeHintProvider = _providerRegistrationHandler.removeProvider.bind(_providerRegistrationHandler); + /** + * Keep the active parameter visible inside the single-line, width-capped popup: scroll it + * into view (centered) when the signature overflows, and fade whichever edge is clipped so + * it's clear there's more of the signature off-screen. + */ + function _revealCurrentParameter() { + let el = $hintScroll && $hintScroll[0]; + if (!el) { + return; + } + let maxScroll = el.scrollWidth - el.clientWidth; + let $cur = $hintContent.find(".current-parameter"); + if (maxScroll > 0 && $cur.length) { + let elRect = el.getBoundingClientRect(), + curRect = $cur[0].getBoundingClientRect(), + curLeftInContent = (curRect.left - elRect.left) + el.scrollLeft, + target = curLeftInContent - (el.clientWidth - curRect.width) / 2; + el.scrollLeft = Math.max(0, Math.min(target, maxScroll)); + } + $hintScroll.toggleClass("fade-left", el.scrollLeft > 1); + $hintScroll.toggleClass("fade-right", el.scrollLeft < maxScroll - 1); + } + /** * Position a function hint. * @@ -80,11 +120,7 @@ define(function (require, exports, module) { * @param {number} ybot */ function positionHint(xpos, ypos, ybot) { - let hintWidth = $hintContainer.width(), - hintHeight = $hintContainer.height(), - top = ypos - hintHeight - POINTER_TOP_OFFSET, - left = xpos, - $editorHolder = $("#editor-holder"), + let $editorHolder = $("#editor-holder"), editorLeft; if ($editorHolder.offset() === undefined) { @@ -94,8 +130,22 @@ define(function (require, exports, module) { } editorLeft = $editorHolder.offset().left; - left = Math.max(left, editorLeft); + + // Cap the popup to the editor width so a long signature can never run off-screen (and + // underneath the central control bar). The signature stays on one line and scrolls. + let maxWidth = Math.min(700, $editorHolder.width() - 24); + $hintContainer.css("max-width", maxWidth + "px"); + _revealCurrentParameter(); + + let hintWidth = $hintContainer.outerWidth(), + hintHeight = $hintContainer.outerHeight(), + top = ypos - hintHeight - POINTER_TOP_OFFSET, + left = xpos; + + // Clamp within the editor area: never left of the editor (keeps it clear of the CCB), + // never past the right edge. left = Math.min(left, editorLeft + $editorHolder.width() - hintWidth); + left = Math.max(left, editorLeft); if (top < 0) { $hintContainer.removeClass("preview-bubble-above"); @@ -232,6 +282,8 @@ define(function (require, exports, module) { */ function dismissHint(editor) { popupShown = false; + // Invalidate any in-flight request so a late response can't re-show a dismissed popup. + pendingRequestId++; if (hintState.visible) { $hintContainer.hide(); $hintContent.empty(); @@ -262,7 +314,6 @@ define(function (require, exports, module) { let $deferredPopUp = $.Deferred(); let sessionProvider = null; - dismissHint(editor); popupShown = true; // Find a suitable provider, if any let language = editor.getLanguageForSelection(), @@ -279,25 +330,67 @@ define(function (require, exports, module) { request = sessionProvider.getParameterHints(explicit, onCursorActivity); } - if (request) { - request.done(function (parameterHint) { + // No hint at the caret (no provider, or none available here) - take down any existing popup. + if (!request) { + dismissHint(editor); + return $deferredPopUp; + } + + let requestId = ++pendingRequestId; + + request.done(function (parameterHint) { + // A newer cursor move already fired a fresh request; drop this stale response. + if (requestId !== pendingRequestId) { + return; + } + + let signature = _signatureKey(parameterHint), + renderKey = parameterHint.currentIndex + "|" + signature; + + // Already showing this exact signature with this exact active parameter: leave the + // popup untouched. Moving the caret within one parameter must not dismiss+redraw it + // (that is what made arrow-key presses flicker). + if (hintState.visible && hintState.renderKey === renderKey) { + $deferredPopUp.resolveWith(null); + return; + } + + _formatHint(editor, parameterHint); + $hintContainer.show(); // no-op when already visible -> content updates in place, no blink + + if (hintState.visible && hintState.signature === signature && hintState.anchor) { + // Same call, just a different active parameter: keep the popup anchored where it + // is and only let the highlight move (positionHint re-reveals the active param). + positionHint(hintState.anchor.left, hintState.anchor.top, hintState.anchor.bottom); + } else { let cm = editor._codeMirror, pos = parameterHint.functionCallPos || editor.getCursorPos(); - pos = cm.charCoords(pos); - _formatHint(editor, parameterHint); - - $hintContainer.show(); positionHint(pos.left, pos.top, pos.bottom); - hintState.visible = true; + hintState.anchor = pos; + hintState.signature = signature; + } + + hintState.visible = true; + hintState.renderKey = renderKey; + // Attach the cursor-tracking listener once per editor (not on every refresh). + if (sessionEditor !== editor) { + if (sessionEditor) { + sessionEditor.off("cursorActivity.ParameterHinting", handleCursorActivity); + } sessionEditor = editor; + editor.off("cursorActivity.ParameterHinting", handleCursorActivity); editor.on("cursorActivity.ParameterHinting", handleCursorActivity); - $deferredPopUp.resolveWith(null); - }).fail(function () { - hintState = {}; - }); - } + } + $deferredPopUp.resolveWith(null); + }).fail(function () { + // The caret moved off the call (or the request failed) - dismiss, unless a newer + // request has since taken over. + if (requestId === pendingRequestId) { + dismissHint(editor); + } + }); return $deferredPopUp; } @@ -407,6 +500,7 @@ define(function (require, exports, module) { } // Create the function hint container $hintContainer = $(hintContainerHTML).appendTo($("body")); + $hintScroll = $hintContainer.find(".function-hint-scroll"); $hintContent = $hintContainer.find(".function-hint-content-new"); activeEditorChangeHandler(null, EditorManager.getActiveEditor(), null); diff --git a/src/htmlContent/parameter-hint-template.html b/src/htmlContent/parameter-hint-template.html index ffdd53d620..8a52da4052 100644 --- a/src/htmlContent/parameter-hint-template.html +++ b/src/htmlContent/parameter-hint-template.html @@ -1,4 +1,6 @@
-
+
+
+
diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 1dbd2dd20a..38b97b074f 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -2667,33 +2667,80 @@ span.brackets-hints-with-type-details { #function-hint-container-new { display: none; - background: #fff; position: absolute; z-index: var(--z-index-parameter-hints); - left: 400px; - top: 40px; + box-sizing: border-box; + // Never wider than the editor (JS caps to the editor width too); the signature + // stays on one line and scrolls so it can never run off-screen under the CCB. + max-width: 700px; height: auto; - width: auto; - overflow: scroll; - padding: 1px 6px; - text-align: center; - border-radius: 3px; - box-shadow: 0 3px 9px rgba(0, 0, 0, 0.24); + overflow: hidden; + padding: 5px 11px; + text-align: left; + border-radius: 6px; + color: @bc-text; + background: @bc-panel-bg; + border: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 15px @bc-shadow-large; .dark & { - background: #000; - border: 1px solid rgba(255, 255, 255, 0.15); - color: #fff; - box-shadow: 0 3px 9px rgba(0, 0, 0, 0.24); + color: @dark-bc-text; + background: @dark-bc-panel-bg; + border-color: rgba(255, 255, 255, 0.12); + box-shadow: 0 4px 15px @dark-bc-shadow-large; + } +} + +// Clipping/scrolling layer: the (potentially very long) signature lives here on a single +// line. When it overflows we scroll the active parameter into view and fade the clipped +// edge(s) to signal there is more - see positionHint() in ParameterHintsManager. +#function-hint-container-new .function-hint-scroll { + overflow: hidden; + white-space: nowrap; + + &.fade-left { + -webkit-mask-image: linear-gradient(to right, transparent 0, #000 22px); + mask-image: linear-gradient(to right, transparent 0, #000 22px); + } + &.fade-right { + -webkit-mask-image: linear-gradient(to left, transparent 0, #000 22px); + mask-image: linear-gradient(to left, transparent 0, #000 22px); + } + &.fade-left.fade-right { + -webkit-mask-image: linear-gradient(to right, transparent 0, #000 22px, #000 calc(100% - 22px), transparent 100%); + mask-image: linear-gradient(to right, transparent 0, #000 22px, #000 calc(100% - 22px), transparent 100%); } } #function-hint-container-new .function-hint-content-new { + display: inline-block; text-align: left; + font-family: 'SourceCodePro', 'SF Mono', Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.6; + white-space: pre; } +// Non-active parameters and the function name / separators read as muted code. +.brackets-hints .parameter { + color: @bc-text; + .dark & { + color: @dark-bc-text; + } +} + +// The parameter the caret is currently inside: clearly emphasized so it's easy to track. .brackets-hints .current-parameter { - font-weight: 500; + font-weight: 700; + color: @accent-keyword; + background: fade(@accent-keyword, 12%); + border-radius: 3px; + padding: 1px 2px; + + .dark & { + color: #6cb6ff; + background: rgba(108, 182, 255, 0.16); + } } // selection view diff --git a/src/styles/brackets_variables.less b/src/styles/brackets_variables.less index 32061a5c86..74a9311aa2 100644 --- a/src/styles/brackets_variables.less +++ b/src/styles/brackets_variables.less @@ -22,7 +22,9 @@ /* Brackets Variables */ :root { - --z-index-parameter-hints: 19; + /* Above the central control bar (@z-index-brackets-main-content + 1 = 20) so the + parameter hint is never occluded by it when it sits near the editor's left edge. */ + --z-index-parameter-hints: 21; --editor-line-height: 1.5; } From e651c7b3937c6a32932036d78e6246683006b1ea Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 25 Jun 2026 20:03:29 +0530 Subject: [PATCH 2/2] fix(lsp): keep code-hint doc popup glued beside the list, never overlapping The documentation popup was positioned once, when a hint was highlighted, from the list's rect at that instant. The hint list keeps reflowing afterward - it flips above/below near a screen edge, shifts as the caret moves, and moves on scroll - so the popup drifted on top of the list and its position looked random. Track the list's live position with a requestAnimationFrame loop while the popup is visible, re-deriving placement from the list's current rect every frame: to the right, flipping to the left when there's no room, clamped to stay on screen. The css is only rewritten when the position actually changes, the loop stops on hide/close, and it self-terminates if the hint menu is torn down. --- src/languageTools/DefaultProviders.js | 76 ++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js index af3614a365..548c598b70 100644 --- a/src/languageTools/DefaultProviders.js +++ b/src/languageTools/DefaultProviders.js @@ -50,13 +50,68 @@ define(function (require, exports, module) { // is shown in a separate popup beside the hint list so the list itself never reflows while // navigating with the arrow keys. var $lspDocPopup = null; + // The hint list reflows after _showDocPopup runs - it flips above/below the caret near a screen + // edge, shifts as the cursor moves, and moves on scroll. To keep the doc popup glued beside it + // (and never overlapping it), we re-derive its position from the list's *current* rect on every + // animation frame while it is visible, instead of positioning it once. + var _docTrackRAF = null, // active requestAnimationFrame handle, or null when not tracking + _docTrackList = null, // the ul.dropdown-menu the popup is anchored to + _docLastPos = ""; // last applied "left,top" - skip the css write when unchanged function _hideDocPopup() { + if (_docTrackRAF) { + cancelAnimationFrame(_docTrackRAF); + _docTrackRAF = null; + } + _docTrackList = null; + _docLastPos = ""; if ($lspDocPopup) { $lspDocPopup.hide().empty(); } } + /** + * Place the doc popup flush beside the hint list's current position: to the right, flipping to + * the left when there isn't room, and clamped vertically to stay on screen. Reads the list's + * live rect so it stays correct no matter how the list has since moved. + */ + function _positionDocPopup() { + if (!$lspDocPopup || !_docTrackList || !_docTrackList.length) { + return; + } + var listEl = _docTrackList[0]; + if (!listEl.isConnected) { + // The hint menu (and this popup, its child) was torn down - stop tracking. + _hideDocPopup(); + return; + } + var anchor = listEl.getBoundingClientRect(); + if (anchor.width === 0 && anchor.height === 0) { + return; // list not laid out yet (mid-reflow) - try again next frame + } + var GAP = 6, + winW = $(window).width(), + winH = $(window).height(), + pw = $lspDocPopup.outerWidth(), + ph = $lspDocPopup.outerHeight(), + left = anchor.right + GAP; + if (left + pw > winW - 8) { + left = anchor.left - pw - GAP; // not enough room on the right - flip to the left + } + left = Math.max(8, left); + var top = Math.max(8, Math.min(anchor.top, winH - ph - 8)); + var pos = Math.round(left) + "," + Math.round(top); + if (pos !== _docLastPos) { + _docLastPos = pos; + $lspDocPopup.css({ left: left, top: top }); + } + } + + function _trackDocPopup() { + _positionDocPopup(); + _docTrackRAF = requestAnimationFrame(_trackDocPopup); + } + // Syntax-highlight fenced code blocks (the signature/examples in completion docs) with the // globally available highlight.js, so they read like code instead of flat monospace. Theme-aware // token colours live in src/styles/brackets.less (.lsp-hint-doc-popup, shared with the hover). @@ -117,21 +172,16 @@ define(function (require, exports, module) { if (!$list.length) { $list = $menu; } - var anchor = $list[0].getBoundingClientRect(); - var GAP = 6; - // Measure, then place to the right of the hint list - flipping to the left when there - // isn't enough room. - $lspDocPopup.css({ display: "block", visibility: "hidden", left: 0, top: 0 }); - var winW = $(window).width(), winH = $(window).height(), - pw = $lspDocPopup.outerWidth(), ph = $lspDocPopup.outerHeight(), - left = anchor.right + GAP; - if (left + pw > winW - 8) { - left = anchor.left - pw - GAP; // not enough room on the right - flip to the left + // Position now, then keep re-positioning every frame so the popup follows the list wherever + // it reflows to (and never ends up overlapping it). + _docTrackList = $list; + _docLastPos = ""; + $lspDocPopup.css({ display: "block" }); + _positionDocPopup(); + if (!_docTrackRAF) { + _trackDocPopup(); } - left = Math.max(8, left); - var top = Math.min(anchor.top, Math.max(8, winH - ph - 8)); - $lspDocPopup.css({ left: left, top: top, visibility: "visible" }); } function _injectInlineSignature($labelSpan, detail) {