From fe8eba0ceded582398b2f2c0510a67b175c6567b Mon Sep 17 00:00:00 2001 From: Sean Boettger Date: Tue, 6 May 2025 11:46:41 +1000 Subject: [PATCH 1/4] Fix: make constants like COLOR_RED still show as suggestions even when already fully typed out, for consistency. Previous when typing COLOR_RED the suggestion for COLOR_RED would show, but once COLOR_RED was fully typed out the suggestion box would vanish. This was intended, but I think was the wrong choice - it's better to still allow the user to hit enter and not suddenly end up on a newline. --- Browser_IDE/splashkit-javascript-hint.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Browser_IDE/splashkit-javascript-hint.js b/Browser_IDE/splashkit-javascript-hint.js index bb11b3d..5dad0a1 100644 --- a/Browser_IDE/splashkit-javascript-hint.js +++ b/Browser_IDE/splashkit-javascript-hint.js @@ -114,8 +114,7 @@ loadSplashKitAutocompletes(); function getCompletions(token, context, keywords, options) { var found = [], start = token.string, global = options && options.globalScope || window; function maybeAdd(str) { - // Sean Edit: Skip matches that are identical to the token itself - if (str.lastIndexOf(start, 0) == 0 && !arrayContains(found, str) && str != start) found.push(str); + if (str.lastIndexOf(start, 0) == 0 && !arrayContains(found, str)) found.push(str); } function gatherCompletions(obj) { if (typeof obj == "string") forEach(stringProps, maybeAdd); From 19cf26d7e8f66674d5e06f92c6c836e20ae290ed Mon Sep 17 00:00:00 2001 From: Sean Boettger Date: Tue, 6 May 2025 12:18:27 +1000 Subject: [PATCH 2/4] Fix: Make cursor visible again --- Browser_IDE/css/theming/baseTheme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Browser_IDE/css/theming/baseTheme.css b/Browser_IDE/css/theming/baseTheme.css index b25515d..246413f 100644 --- a/Browser_IDE/css/theming/baseTheme.css +++ b/Browser_IDE/css/theming/baseTheme.css @@ -5,7 +5,7 @@ } .cm-s-dracula .CodeMirror-gutters { color: var(--editorGutterBackground); } - .cm-s-dracula .CodeMirror-cursor { border-left: solid thin var(--editorSelected); } + .cm-s-dracula .CodeMirror-cursor { border-left: solid thin var(--editorMeta); } .cm-s-dracula .CodeMirror-linenumber { color: var(--editorLineNumber); } .cm-s-dracula .CodeMirror-selected { background: var(--editorSelected); } .cm-s-dracula .CodeMirror-line::selection, .cm-s-dracula .CodeMirror-line > span::selection, .cm-s-dracula .CodeMirror-line > span > span::selection { background: var(--editorSelected); } From cd84fb273cc4fb99863bc862f701c1c848e6b9b9 Mon Sep 17 00:00:00 2001 From: Sean Boettger Date: Wed, 7 May 2025 10:11:27 +1000 Subject: [PATCH 3/4] New: Add code hinter window - Shows when typing - Ctrl-Space can trigger manually, Escape hides it - Some other small related UI adjustments --- Browser_IDE/codeHinter.js | 430 +++++++++++++++++++++++ Browser_IDE/editorMain.js | 13 +- Browser_IDE/index.html | 1 + Browser_IDE/splashkit-javascript-hint.js | 17 +- Browser_IDE/stylesheet.css | 18 + 5 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 Browser_IDE/codeHinter.js diff --git a/Browser_IDE/codeHinter.js b/Browser_IDE/codeHinter.js new file mode 100644 index 0000000..2d09063 --- /dev/null +++ b/Browser_IDE/codeHinter.js @@ -0,0 +1,430 @@ +// This file contains all the logic and visual handling for the code hint dialog. +// The logic for popping it up and hiding it is in editorMain.js in `setupCodeArea` + +////////////////////////////////// +// Utility functions for parsing the code and working out our context, +// and matching SplashKit Functions +////////////////////////////////// +// While CodeMirror does some code parsing already to handle syntax highlighting, +// it didn't seem possible at the time to get all the info needed out of it reliably, +// and there wasn't any relevant documentation to check against. +// So instead manual parsing was chosen instead. This is really just technical debt +// already haha, and I hope it can be removed in the future when we upgrade the +// code editor more. + + +// A bit of an tricky function that handles estimating the context around +// where the cursor is, without too involved parsing. +// +// First it scans backwards to figure out how many levels deep in brackets +// the cursor is, saving any function names as well. +// Then it scans forward to figure out how many arguments are in the +// current function. +// The result is a stack of functions, storing the name, argument count, +// and argument position of the cursor. +function getCodeContextAtCursor(cm){ + let currentToken = cm.getTokenAt(cm.getCursor()); + let currentLine = cm.getCursor(); + + let stack = []; // tracks brackets - we basically skip until we find the character in it + let context = []; + let arg_pos = -1; + let closedContextCount = 0; + + // This function scans backwards to work out the overall context + function scanBackward(){ + arg_pos = -1; // start with -1. Our position in a function with no args is -1, but first position in one _with_ args is 0. + + // loop upwards from the cursor's line - CodeMirror doesn't give us a single + // string, but rather each line seperately. + for(let i = cm.getCursor().line; i >= 0; i --){ + let line = cm.getLine(i); + + // find the start point + let startChar = line.length; // start from the end of the line + if (i == cm.getCursor().line) // unless it's the line the cursor is on + startChar = cm.getCursor().ch-1; // in which case start from the cursor + + // head backwards from that start point + for(let j = startChar; j >= 0; j --) { + // stop completely if we encounter ';' + if (line[j] == ';') + return; + // found a ')' - we need to skip until we find the matching '(' + else if (line[j] == ')') + stack.push('('); + // if we're inside things to skip (stack.length > 0) and we found the matching bracket, consume it + else if (stack.length > 0 && line[j] == stack[0]) + stack.pop(); + // we found the '(' that's the edge of our current stack! :D + else if (stack.length == 0 && line[j] == '(') { + // get the function name (whatever token is next to the '(') + let token = cm.getTokenAt({line: i, ch: j-1}); + // Push this into the context + context.push({token, arg_pos, i, j, arg_count:arg_pos}); + // reset the arg position + arg_pos = -1; + } + // found a ',' - this means there's at least two arguments + else if (stack.length == 0 && line[j] == ','){ + if (arg_pos == -1) arg_pos = 0; + arg_pos += 1; + } + // we found _something_, so there's at least one argument + else if (stack.length == 0 && arg_pos == -1){ + arg_pos = 0; + } + } + } + } + + // This function just finishes figuring out how many arguments are in the + // current function. It's fairly similar to the previous one. + function scanForward() { + arg_pos = -1; + for(let i = cm.getCursor().line; i < cm.lineCount(); i ++){ + let line = cm.getLine(i); + let startChar = 0; + if (i == cm.getCursor().line) startChar = cm.getCursor().ch; + if (closedContextCount >= context.length) + return; + + for(let j = startChar; j < line.length; j ++){ + if (line[j] == ';') + return; + else if (line[j] == '(') + stack.push(')'); + else if (stack.length > 0 && line[j] == stack[0]) + stack.pop(); + // we found the ')' that's at the edge of our current stack! :D + else if (stack.length == 0 && line[j] == ')'){ + arg_pos = -1; + if (context[closedContextCount].arg_count > 0 && context[closedContextCount].arg_pos == -1) + context[closedContextCount].arg_pos = 0; + closedContextCount += 1; + if (closedContextCount >= context.length) + return; + } + else if (stack.length == 0 && line[j] == ','){ + if (context[closedContextCount].arg_count == -1) context[closedContextCount].arg_count = 0; + context[closedContextCount].arg_count += 1; + } + else if (stack.length == 0 && context[closedContextCount].arg_count == -1){ + context[closedContextCount].arg_count = 0; + } + } + } + } + + // Just call each of the functions for their side-effects on `context`. + scanBackward(); + scanForward(); + + // Done! + return context; +}; + +// Returns the SplashKit API matches found for a single level of the context +// Also computes whether they are possible within that context based on +// the argument count. +// It also handles filtering out known 'non-functions' like while/if. +// Finally, it also has some logic to estimate 'optional' parameters +// to help keep the number of matches manageable and tidy. +function getMatches(context, ignore_impossible = false){ + // return null for known non-function. Otherwise we'll return an empty match list + if (["if", "while", "do", "for"].includes(context.token.string)){ + return null; + } + + let matches = []; + + // Loop over every possible function + // NOTE: These are stored in alphabetical order, and then by argument count. + // This is important for the algorithm. + for (let func of splashKitAutocompletes.functions){ + // Skip if the name is different + if (context.token.string != func.name) + continue; + // Skip if we have too many args already for the function and ignore_impossible is true + if (context.arg_count >= func.params.length && ignore_impossible) + continue; + + // Detect if this function is a superset of one we've already matched. + // If it is, we'll add the new params as 'optional' params of the old one. + let subSet = null; + for (let k = 0; k < matches.length; k ++){ + let funcB = matches[k]; + + let isSuperSet = function(){ + let totalParams = funcB.params.concat(funcB.optParams); + + // If we have less or the same number of params, we can't be a superset + if (func.length <= totalParams.length) + return false; + + // Let's keep zero-parameter functions as a special case + if (totalParams.length == 0) + return false; + + // Check all the param names within the matching subset match + for (let j = 0; j < totalParams.length; j ++) { + if (totalParams[j] != func.params[j]) + return false; + } + + return true; + }(); + + if (isSuperSet) { + subSet = funcB; + break; + } + } + // If we are a superset, add all the new params, then continue + if (subSet != null) { + let subSetSize = subSet.params.length + subSet.optParams.length; + + for (let j = subSetSize; j < func.params.length; j ++) { + subSet.optParams.push(func.params[j]); + } + + // recalculate if the expanded function is possible + subSet.possible = context.arg_count < func.params.length; + + // continue to next potential match + continue; + } + + // We are a new match - make a clone of the function, determine if we're possible + // based on the arg count in the context, and push the match. + let x = structuredClone(func); + x.possible = context.arg_count < func.params.length; + x.optParams = []; + matches.push(x); + } + + return matches; +} + +////////////////////////////////// +// Main function for creating the code hinter - is called for each code editor tab +////////////////////////////////// + +// Maybe this'd be better as a class? + +function createCodeHinter(editor){ + ////////////////////////////////// + // Create the elements + ////////////////////////////////// + let overloadCounter = elemFromText(`
1/2
`).children[0]; + let overloadNextButton = elemFromText(``).children[0]; + let overloadPrevButton = elemFromText(``).children[0]; + + let overloadSelector = elem("div", {style:{"text-align": "center", "font-size": "0.8em", "display": "flex", "justify-content":"end", "flex-direction":"column"}}, [ + overloadNextButton, overloadCounter, overloadPrevButton + ]); + + let functionHintInner = elem("div", {style:{ + display: "flex", + justifyContent: "center", + flexDirection: "column" + }}); + + let functionHint = elem("div", {class: "sk-contents sk-notification sk-function-hint sk-contents-focusable", tabindex: "10"}, [ + overloadSelector, + functionHintInner + ]); + + ////////////////////////////////// + // Setup instance variables + ////////////////////////////////// + let codeMirrorWidget = null; + + let isVisible = false; + + let currentMatches = null; + let overloadDivs = null; + let currentOverload = 0; + + + ////////////////////////////////// + // Setup the overload switching events/function + ////////////////////////////////// + function setOverload(overload){ + if (overloadDivs.length > currentOverload) + overloadDivs[currentOverload].style.display = "none"; + overloadDivs[overload].style.display = "initial"; + + currentOverload = overload; + + overloadNextButton.disabled = currentOverload+1 >= overloadDivs.length; + overloadPrevButton.disabled = currentOverload == 0; + + overloadCounter.innerText = (currentOverload+1) + "/" + overloadDivs.length; + } + + overloadNextButton.addEventListener("mousedown", function (e){ + setOverload(currentOverload + 1); + e.preventDefault(); // avoid taking focus from the editor + }); + + overloadPrevButton.addEventListener("mousedown", function (e){ + setOverload(currentOverload - 1); + e.preventDefault(); // avoid taking focus from the editor + }); + + + ////////////////////////////////// + // Core update logic for deciding the hints to show + ////////////////////////////////// + function update(cm){ + // no point updating if we're invisible + if (!isVisible) + return; + + // First fetch the context (stack of function calls at cursor) + let context = getCodeContextAtCursor(cm); + + // if no context, just close the dialog + if (context.length == 0){ + close(); + return; + } + + // get matches against SplashKit functions. + let matches = getMatches(context[0]); + + // if no matches, just close the dialog + if (matches == null || matches.length == 0){ + close(); + return; + } + + // If we ended up with matches, update the hint object. + // This involves adding an 'overload Div' for each match. + // We also find a decent default overload to show by picking + // the first 'possible' one (while still allowing the user + // to switch and view the other ones). + + // clear the existing overload divs + overloadDivs = []; + functionHintInner.innerHTML = ""; + + let bestOverload = null; + + // Loop over each overload + for (let k = 0; k < matches.length; k ++){ + let func = matches[k]; + + let paramList = ""; + // We'll loop over all params including the guessed 'optional' ones + let totalParams = func.params.concat(func.optParams); + + for (let i = 0; i < totalParams.length; i ++) { + let param = totalParams[i]; + + // group the ' ' parts together when wrapping + param = "
"+param+"
"; + + // if we're up to the optional params, give them lower opacity + if (i >= func.params.length) + param = ""+param+"" + + // If this is where the cursor is, highlight it + if (i == Math.max(0, context[0].arg_pos)) + param = ""+param+"" + + // Now add the param to the paramList + paramList += param+", " + } + + let sig = (func.return!="" ? func.return + " " : "") + func.name + "(" + paramList.slice(0, paramList.length - 2) + ")"; + let div = elemFromText("
"+sig+"
").children[0]; + + if (func.possible && bestOverload == null) bestOverload = k; + + overloadDivs.push(div); + functionHintInner.appendChild(div); + } + + // If we didn't find any possible overloads, default to the first (also the shortest) + if (bestOverload == null) bestOverload = 0; + + // If the current matches are different to what we were showing before, + // update the count display and switch to that bestOverload. + // Otherwise just ensure we're still on the same overload as before. + if (JSON.stringify(matches) != JSON.stringify(currentMatches)){ + currentMatches = matches; + overloadSelector.style.display = matches.length > 1 ? "flex" : "none"; + setOverload(bestOverload); + } else { + setOverload(currentOverload); + } + + // Now update the widget within the code editor + + // First remove the widget if it's already added + if (codeMirrorWidget) + codeMirrorWidget.clear(); + + // Position it as close to the cursor as we can while still fitting it + // First compute how large it'd be if it was all the way on the left + functionHint.style.left = "0px"; + functionHint.style.maxWidth = "30em"; + editor.getWrapperElement().appendChild(functionHint); + + // Compute sizes/positions + let functionHintBounding = functionHint.getBoundingClientRect(); + let gutterRect = cm.getGutterElement().getBoundingClientRect(); + let wrapperRect = cm.getWrapperElement().getBoundingClientRect(); + let cursorRect = cm.cursorCoords(); + editor.getWrapperElement().removeChild(functionHint); + + // Compute the minimum width needed with some padding + let minimumWidth = functionHintBounding.width + 16; + + // Also check if it'll fix vertically + let height = functionHintBounding.height; + let positionAbove = cursorRect.top > (height + wrapperRect.top); + + // re-add it above/below the current line + codeMirrorWidget = cm.addLineWidget(cm.getCursor().line, functionHint, {above: positionAbove}); + + // ensure it's positioned correctly vertically + functionHint.style.transform = positionAbove ? "translateY(-100%)" : "initial"; + + // Now position it at the cursor, or move it to the left if it'll go out of the editor + let maxX = wrapperRect.width - minimumWidth - gutterRect.width; + functionHint.style.left = Math.min(maxX, cursorRect.left - gutterRect.width - wrapperRect.left) + "px"; + + // trigger updating the widget fully in the editor + codeMirrorWidget.changed(); // does this do anything anymore? Used to be important... + } + + + ////////////////////////////////// + // The 'public' functions to be used elsewhere + ////////////////////////////////// + function show(cm){ + isVisible = true; + window.requestAnimationFrame(function(){ + functionHint.style.opacity = "0.9"; + functionHint.style.pointerEvents = "initial"; + }); + update(cm); + } + function close(){ + isVisible = false; + window.requestAnimationFrame(function(){ + functionHint.style.opacity = "0"; + functionHint.style.pointerEvents = "none"; + }); + } + + // return the object - the interface is just these functions + return { + show: show, + update: update, + close: close, + }; +} \ No newline at end of file diff --git a/Browser_IDE/editorMain.js b/Browser_IDE/editorMain.js index e394281..aff5091 100644 --- a/Browser_IDE/editorMain.js +++ b/Browser_IDE/editorMain.js @@ -89,7 +89,10 @@ class CodeViewer { lineNumbers: true, autoCloseBrackets: true, styleActiveLine: true, - extraKeys: {"Ctrl-Space": "autocomplete"}, + extraKeys: { + "Ctrl-Space": () => codeHinter.show(editor), + "Esc" : () => codeHinter.close() + }, hintOptions: { alignWithWord: false, completeSingle: false, @@ -99,11 +102,19 @@ class CodeViewer { gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"] }); + let codeHinter = createCodeHinter(editor); + editor.on('inputRead', (cm, change) => { if (!cm.state.completeActive) { cm.showHint(); } }); + editor.on('change', (cm, change) => { + codeHinter.show(cm); + }); + editor.on('cursorActivity', (cm, change) => { + codeHinter.update(cm); + }); editor.display.wrapper.classList.add("sk-contents"); return editor; diff --git a/Browser_IDE/index.html b/Browser_IDE/index.html index 3543c8e..0272559 100644 --- a/Browser_IDE/index.html +++ b/Browser_IDE/index.html @@ -145,5 +145,6 @@

Let's write some code!

+ diff --git a/Browser_IDE/splashkit-javascript-hint.js b/Browser_IDE/splashkit-javascript-hint.js index 5dad0a1..d0b12c6 100644 --- a/Browser_IDE/splashkit-javascript-hint.js +++ b/Browser_IDE/splashkit-javascript-hint.js @@ -13,6 +13,17 @@ function loadSplashKitAutocompletes() { if (xhr.readyState === 4) { if (xhr.status === 0 || xhr.status === 200) { splashKitAutocompletes = JSON.parse(xhr.responseText); + + // Sort the functions by name -> argument count + splashKitAutocompletes.functions.sort((a,b) => { + let nameComparison = a.name.localeCompare(b.name); + + if (nameComparison == 0) + return a.params.length - b.params.length; + + return nameComparison + }); + } else{ throw new Error("Couldn't load SplashKit Autocompletes!"); @@ -166,16 +177,12 @@ loadSplashKitAutocompletes(); // Sean Edit: Handle Splashkit Functions specially - // TODO: Show when writing parameters, and bold current parameter for (func of splashKitAutocompletes.functions){ if (func.name.lastIndexOf(start, 0) == 0 && !arrayContains(found, func.name)){ paramList = "" for (param of func.params) paramList += param+", " - found.push({ - text: func.name, - displayText: (func.return!="" ? func.return + " " : "") + func.name + "(" + paramList.slice(0, paramList.length - 2) + ")" - }); + found.push(func.name); } } diff --git a/Browser_IDE/stylesheet.css b/Browser_IDE/stylesheet.css index cbc0c67..f963e31 100644 --- a/Browser_IDE/stylesheet.css +++ b/Browser_IDE/stylesheet.css @@ -54,6 +54,8 @@ button { } button:disabled { color: var(--disabled) !important; +} +#runtimeContainer .sk-header button:disabled, #codeViewContainer .sk-header button:disabled { cursor: wait; } @@ -572,6 +574,10 @@ li.CodeMirror-hint-active { color: var(--primary); } +/* ensure widgets are above the text */ +.CodeMirror-linewidget { + z-index: 10; +} /* New changes for scrollbar in FILES explorer section */ @@ -887,3 +893,15 @@ li.CodeMirror-hint-active { margin-top: 0.64em; opacity: 0.5; } + +.sk-function-hint { + opacity:0.8; + z-index:100; + width: initial; + position: absolute; + transform-origin: bottom; + transform: translateY(-100%); + padding:2px 10px; + margin:0; + transition: opacity 0.1s; +} \ No newline at end of file From a73ce4d282a6316438d52f8c8b0e687ad30ae4b0 Mon Sep 17 00:00:00 2001 From: Sean Boettger Date: Wed, 14 May 2025 08:48:39 +1000 Subject: [PATCH 4/4] refactor: use `elem` to create elements safely + use stylesheet more --- Browser_IDE/codeHinter.js | 39 +++++++++++++++++++++----------------- Browser_IDE/stylesheet.css | 16 +++++++++++++++- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/Browser_IDE/codeHinter.js b/Browser_IDE/codeHinter.js index 2d09063..5b27363 100644 --- a/Browser_IDE/codeHinter.js +++ b/Browser_IDE/codeHinter.js @@ -220,15 +220,11 @@ function createCodeHinter(editor){ let overloadNextButton = elemFromText(``).children[0]; let overloadPrevButton = elemFromText(``).children[0]; - let overloadSelector = elem("div", {style:{"text-align": "center", "font-size": "0.8em", "display": "flex", "justify-content":"end", "flex-direction":"column"}}, [ + let overloadSelector = elem("div", {class: "sk-function-hint-overload-selector"}, [ overloadNextButton, overloadCounter, overloadPrevButton ]); - let functionHintInner = elem("div", {style:{ - display: "flex", - justifyContent: "center", - flexDirection: "column" - }}); + let functionHintInner = elem("div", {class: "sk-function-hint-inner"}); let functionHint = elem("div", {class: "sk-contents sk-notification sk-function-hint sk-contents-focusable", tabindex: "10"}, [ overloadSelector, @@ -264,13 +260,13 @@ function createCodeHinter(editor){ } overloadNextButton.addEventListener("mousedown", function (e){ - setOverload(currentOverload + 1); e.preventDefault(); // avoid taking focus from the editor + setOverload(currentOverload + 1); }); overloadPrevButton.addEventListener("mousedown", function (e){ - setOverload(currentOverload - 1); e.preventDefault(); // avoid taking focus from the editor + setOverload(currentOverload - 1); }); @@ -316,30 +312,39 @@ function createCodeHinter(editor){ for (let k = 0; k < matches.length; k ++){ let func = matches[k]; - let paramList = ""; + let paramList = []; + + if (func.return != "") + paramList.push(func.return + " "); + + paramList.push(func.name + "("); + // We'll loop over all params including the guessed 'optional' ones let totalParams = func.params.concat(func.optParams); for (let i = 0; i < totalParams.length; i ++) { - let param = totalParams[i]; + let paramText = totalParams[i]; // group the ' ' parts together when wrapping - param = "
"+param+"
"; + let param = elem("div", {style: {display: "inline-block"}}, [paramText]); // if we're up to the optional params, give them lower opacity if (i >= func.params.length) - param = ""+param+"" + param = elem("i", {style: {opacity: "0.7"}}, [param]); // If this is where the cursor is, highlight it if (i == Math.max(0, context[0].arg_pos)) - param = ""+param+"" + param = elem("b", {}, [elem("u", {style: {color: "var(--editorFunctionsAndObject)"}}, [param])]); // Now add the param to the paramList - paramList += param+", " + paramList.push(param); + if (i < totalParams.length - 1) + paramList.push(", "); } - let sig = (func.return!="" ? func.return + " " : "") + func.name + "(" + paramList.slice(0, paramList.length - 2) + ")"; - let div = elemFromText("
"+sig+"
").children[0]; + paramList.push(")"); + + let div = elem("div", {style:{display: "none"}}, paramList); if (func.possible && bestOverload == null) bestOverload = k; @@ -427,4 +432,4 @@ function createCodeHinter(editor){ update: update, close: close, }; -} \ No newline at end of file +} diff --git a/Browser_IDE/stylesheet.css b/Browser_IDE/stylesheet.css index f963e31..8a7121b 100644 --- a/Browser_IDE/stylesheet.css +++ b/Browser_IDE/stylesheet.css @@ -904,4 +904,18 @@ li.CodeMirror-hint-active { padding:2px 10px; margin:0; transition: opacity 0.1s; -} \ No newline at end of file +} + +.sk-function-hint-overload-selector { + text-align: center; + font-size: 0.8em; + display: flex; + justify-content: end; + flex-direction: column; +} + +.sk-function-hint-inner { + display: flex; + justify-content: center; + flex-direction: column; +}