diff --git a/src/common.ts b/src/common.ts
index abf9f90..c65575e 100644
--- a/src/common.ts
+++ b/src/common.ts
@@ -2,13 +2,8 @@
import { DocumentSymbol, Position, Range, SymbolKind } from 'vscode';
import { config } from './extension/config';
+import { SymbolKindStr } from './utils';
-type SymbolKindStr =
- 'File' | 'Module' | 'Namespace' | 'Package' | 'Class' | 'Method' |
- 'Property' | 'Field' | 'Constructor' | 'Enum' | 'Interface' |
- 'Function' | 'Variable' | 'Constant' | 'String' | 'Number' |
- 'Boolean' | 'Array' | 'Object' | 'Key' | 'Null' | 'EnumMember' |
- 'Struct' | 'Event' | 'Operator' | 'TypeParameter';
/**
* Node of a tree of symbols.
*/
diff --git a/src/utils.ts b/src/utils.ts
index 0099660..ace2374 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,5 +1,12 @@
-export const SymbolKindList = [
+export type SymbolKindStr =
+ 'File' | 'Module' | 'Namespace' | 'Package' | 'Class' | 'Method' |
+ 'Property' | 'Field' | 'Constructor' | 'Enum' | 'Interface' |
+ 'Function' | 'Variable' | 'Constant' | 'String' | 'Number' |
+ 'Boolean' | 'Array' | 'Object' | 'Key' | 'Null' | 'EnumMember' |
+ 'Struct' | 'Event' | 'Operator' | 'TypeParameter';
+
+export const SymbolKindList: SymbolKindStr[] = [
'File', 'Module', 'Namespace', 'Package', 'Class', 'Method',
'Property', 'Field', 'Constructor', 'Enum', 'Interface',
'Function', 'Variable', 'Constant', 'String', 'Number',
diff --git a/src/webview/components/inputArea.ts b/src/webview/components/inputArea.ts
index e557ed6..7afefe6 100644
--- a/src/webview/components/inputArea.ts
+++ b/src/webview/components/inputArea.ts
@@ -1,17 +1,52 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { SymbolKindList, SymbolKindStr, camelToDash } from '../../utils';
import { Mode } from '../input';
+// import { camelToDash } from '../../common';
export class InputArea extends HTMLElement {
private _textarea: HTMLTextAreaElement;
private _mode: Mode = Mode.Nav;
+
+ private _filtering = false;
+
+ private _filterEle: HTMLDivElement;
+
+ private _filterFocusing = 0;
+
+ private _filterList: HTMLDivElement;
+
+ private symbolElements: HTMLLIElement[] = [];
+
+ public filteredSymbol: SymbolKindStr | null = null;
+
private init() {
+ function symbolList() {
+ return SymbolKindList.map(k => {
+ const dashCase = camelToDash(k);
+ return /*html*/`
+
+
+ ${dashCase}
+
+ `;
+ });
+ }
const TEMPLATE = /*html*/`
+
+
+
+
+
+ ${symbolList().join('')}
+
+
`;
this.innerHTML = TEMPLATE;
@@ -23,12 +58,79 @@ export class InputArea extends HTMLElement {
this.init();
this._textarea = this.querySelector('textarea')!;
- this._textarea.addEventListener('input', ()=>{
+ this._filterEle = this.querySelector('#symbol-filter')!;
+ this._filterList = this.querySelector('#symbol-list')!;
+ this.symbolElements = Array.from(this.querySelectorAll('.symbol-item'));
+ this.symbolElements[0].classList.add('focused');
+ this.symbolElements.forEach(ele => {
+ ele.addEventListener('click', () => {
+ this.setFilteredSymbol(ele.dataset.symbol! as SymbolKindStr);
+ this._textarea.focus();
+ });
+ });
+
+ this._textarea.addEventListener('keydown', (e) => {
+ if (this.filtering) {
+ // prevent the input from being handled by the parent
+ e.stopPropagation();
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ this.updateFocusing(1);
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ this.updateFocusing(-1);
+ break;
+ case 'Enter':
+ e.preventDefault();
+ this.symbolElements[this._filterFocusing].click();
+ break;
+ }
+ }
+
+ if ((this.filtering || this.filteredSymbol) && this.searchText.length === 0
+ && ['Backspace', 'Delete'].includes(e.key)
+ ) {
+ this.exitFiltering();
+ }
+ });
+ this._textarea.addEventListener('input', (e)=>{
this.adjustHeight();
+ if (this.filtering) {
+ this.filter(this.searchText);
+ }
+ if (this.filtering || this._textarea.value[0] === '@') {
+ // prevent the input from being handled by the parent
+ e.stopPropagation();
+ e.preventDefault();
+ this.filtering = true;
+ return;
+ }
this.updateMode();
});
}
+ setFilteredSymbol(symbol: SymbolKindStr) {
+ this.filteredSymbol = symbol;
+ this._filterEle.querySelector('.icon')!.className =
+ `icon codicon codicon-symbol-${camelToDash(this.filteredSymbol)}`;
+ this._filterEle.title = `Search for ${this.filteredSymbol}`;
+ this.filtering = false;
+ this.filter('');
+ this._textarea.value = '';
+ }
+
+ exitFiltering() {
+ this.filtering = false;
+ this.filter('');
+ this._filterEle.querySelector('.icon')!.className =
+ 'icon codicon codicon-pencil';
+ this._textarea.value = this.mode as string;
+ this._filterEle.classList.toggle('active', false);
+ this.filteredSymbol = null;
+ }
+
/**
* pass the focus to the inner textarea
*/
@@ -41,22 +143,9 @@ export class InputArea extends HTMLElement {
*/
adjustHeight() {
this._textarea.style.height = '0px';
- this._textarea.style.height = this._textarea.scrollHeight + 'px';
- }
-
- set value(value: string) {
- this._textarea.value = value;
- this.adjustHeight();
- this.updateMode();
- }
-
- get value(): string {
- return this._textarea.value;
+ this._textarea.style.height = `calc(4px + ${this._textarea.scrollHeight}px)`;
}
- get searchText(): string {
- return this._textarea.value.slice(1);
- }
/**
* Update the mode according to the first character of the input.
@@ -76,6 +165,89 @@ export class InputArea extends HTMLElement {
console.log(this._mode);
}
+
+ /**
+ * clear the input area and set the mode to the given mode
+ * @param mode
+ */
+ clear(mode: Mode = Mode.Nav) {
+ this._textarea.value = mode as string;
+ this.adjustHeight();
+ this.mode = mode;
+ }
+
+ private filter(pattern: string) {
+ // fuzzy match
+ const reg = new RegExp(pattern.split('').join('.*'));
+ this.symbolElements.forEach(ele => {
+ const dash = camelToDash(ele.dataset.symbol!);
+ const hidden = !reg.test(dash);
+ ele.classList.toggle('hidden', hidden);
+ ele.dataset.hidden = hidden.toString();
+ });
+ this.updateFocusing();
+ }
+
+ /**
+ * update the index of the symbol that is being focused
+ * try to move the focus to the next visible symbol
+ * @param move the number of symbols to move, hidden symbols are ignored
+ * @param checkHidden whether to check if all symbols are hidden,
+ * avoid checking when calling recursively
+ */
+ private updateFocusing(move = 0, sign = 0, checkHidden = true) {
+ const len = this.symbolElements.length;
+ sign = move === 0 ? sign : Math.sign(move);
+ if (checkHidden && this.querySelector('.symbol-item:not(.hidden)') === null) {
+ return;
+ }
+ this.symbolElements[this._filterFocusing].classList.remove('focused');
+ if (this.symbolElements[this._filterFocusing].dataset.hidden === 'true') {
+ // (sign || 1) :
+ // automatically move to the "next" symbol if the current one is "turned to" hidden
+ this._filterFocusing = (this._filterFocusing + (sign || 1) + len) % len;
+ this.updateFocusing(move, sign, false);
+ return;
+ }
+ if (move !== 0) {
+ this._filterFocusing = (this._filterFocusing + sign + len) % len;
+ // this.updateFocusing(move > 0 ? move - 1 : move + 1, false);
+ this.updateFocusing(move - sign, sign, false);
+ return;
+ }
+ // moved to expected position
+ this.symbolElements[this._filterFocusing].classList.add('focused');
+ }
+
+
+ set value(value: string) {
+ this._textarea.value = value;
+ this.adjustHeight();
+ this.updateMode();
+ }
+
+ get value(): string {
+ return this._textarea.value;
+ }
+
+ get searchText(): string {
+ return this._textarea.value.slice(1);
+ }
+
+
+ private set filtering(value: boolean) {
+ this._filtering = value;
+ if (value) {
+ this._filterEle.classList.toggle('active', true);
+ }
+ this._filterList.classList.toggle('active', value);
+ }
+
+ private get filtering() {
+ return this._filtering;
+ }
+
+
set mode(mode: Mode) {
this._mode = mode;
this._textarea.value = mode as string + this._textarea.value.slice(1);
@@ -90,16 +262,6 @@ export class InputArea extends HTMLElement {
get mode(): Mode {
return this._mode;
}
-
- /**
- * clear the input area and set the mode to the given mode
- * @param mode
- */
- clear(mode: Mode = Mode.Nav) {
- this._textarea.value = mode as string;
- this.adjustHeight();
- this.mode = mode;
- }
}
@@ -108,12 +270,79 @@ const STYLE = /*css*/`
background: transparent;
color: var(--vscode-input-foreground);
padding: 3px 0 3px 6px;
- font-size: 14px;
+ font-size: inherit;
width: 100%;
resize: none;
+ line-break: anywhere;
}
#input-text:focus {
outline: none;
}
+
+div#symbol-list {
+ position: absolute;
+ display: none;
+ top: calc(11px + 1em);
+ min-width: 150px;
+ left: 1em;
+ background: var(--vscode-dropdown-background);
+ border: 1px solid var(--vscode-menu-border);
+ color: var(--vscode-dropdown-foreground);
+ border-radius: 3px;
+ box-shadow: 0 3px 7px 0 rgba(0, 0, 0, .13), 0 1px 2px 0 rgba(0, 0, 0, .11);
+}
+
+div#symbol-list.active {
+ display: block;
+}
+
+#symbol-list ul{
+ list-style-type: none;
+}
+
+#symbol-list .symbol-item {
+ display: flex;
+ padding: 2px 2px 2px 5px;
+ align-items: center;
+ gap: 5px;
+ font-size: var(--vscode-font-size);
+}
+
+#symbol-list .symbol-item.hidden {
+ display: none;
+}
+#symbol-list .symbol-item.focused {
+ background: var(--vscode-list-hoverBackground);
+}
+
+#symbol-list .symbol-item:hover {
+ background: var(--vscode-list-hoverBackground);
+}
+
+#symbol-list span.symbol-name {
+ font-family: var(--vscode-font-family);
+}
+
+div#symbol-filter {
+ position: absolute;
+ left: 13px;
+ top: 9px;
+ display: none;
+ align-items: center;
+ height: 1rem;
+ width: 1rem;
+ background: var(--vscode-inputOption-activeBackground);
+ padding: 1px;
+ justify-content: center;
+ border-radius: 3px;
+}
+
+div#symbol-filter.active {
+ display: flex;
+}
+
+div#symbol-filter.active ~ #input-text {
+ text-indent: calc(1rem + 5px);
+}
`;
\ No newline at end of file
diff --git a/src/webview/input.ts b/src/webview/input.ts
index 3c278cd..6663389 100644
--- a/src/webview/input.ts
+++ b/src/webview/input.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { SwitchButton } from './components/switchButton';
import { InputArea } from './components/inputArea';
-import { throttle } from '../utils';
+import { SymbolKindStr, throttle } from '../utils';
customElements.define('switch-button', SwitchButton);
customElements.define('input-area', InputArea);
@@ -21,7 +21,7 @@ const InputContainerHTML = /*html*/`
{
- Input.mode = this.inputArea.mode as Mode;
- });
}
/**
@@ -163,11 +160,16 @@ export class Input {
// As the most common case is adding at the end, this may improve performance a lot
// In regex mode, we can't reuse the last search result.
const reuse = value.startsWith(this.lastSearch) && this.inputArea.mode !== Mode.Regex;
+ const config = {
+ pattern,
+ mode: this.inputArea.mode || Mode.Normal,
+ filter: this.inputArea.filteredSymbol,
+ };
if (this.searcher && reuse) {
- this.searcher.search(pattern);
+ this.searcher.search(config);
}
else {
- this.searcher = new Searcher(outlineRoot, pattern);
+ this.searcher = new Searcher(outlineRoot, config);
}
this.lastSearch = value;
}
@@ -271,10 +273,6 @@ export class Input {
document.querySelector('#outline-root')?.classList.toggle('searching', false);
}
- static getMode() {
- return Input.mode;
- }
-
}
class TreeNode {
@@ -332,8 +330,13 @@ class TreeNode {
}
}
- match(search: RegExp | string) {
+ match(search: RegExp | string, symbol: SymbolKindStr | null = null) {
if (!this.matched) return false;
+ if (symbol && symbol !== this.element.dataset.kind) {
+ this.matched = false;
+ this.element.classList.toggle('matched', false);
+ return false;
+ }
const name = this.element.querySelector('.symbol-name')!;
if (search instanceof RegExp) {
const matched = name.textContent?.match(search);
@@ -365,44 +368,50 @@ class TreeNode {
}
+interface SearchConfig {
+ pattern: string,
+ mode: Mode,
+ filter: SymbolKindStr | null,
+}
+
class Searcher {
private tree: TreeNode;
private searchReg: RegExp | string | null = null;
- constructor(root: HTMLDivElement, searchInit: string) {
+ constructor(root: HTMLDivElement, init: SearchConfig) {
this.tree = new TreeNode(root);
- this.search(searchInit);
+ this.search(init);
}
- search(search: string) {
- switch (Input.getMode()) {
+ search(config: SearchConfig) {
+ switch (config.mode) {
case Mode.Normal:
- this.searchReg = search;
+ this.searchReg = config.pattern;
break;
case Mode.Regex:
try {
- this.searchReg = new RegExp(search);
+ this.searchReg = new RegExp(config.pattern);
} catch (e) {
this.searchReg = null;
}
break;
case Mode.Fuzzy:
- this.searchReg = new RegExp(search.split('').join('.*?'));
+ this.searchReg = new RegExp(config.pattern.split('').join('.*?'));
break;
default:
this.searchReg = null;
break;
}
if (this.searchReg) {
- this.searchTree(this.tree, this.searchReg);
+ this.searchTree(this.tree, this.searchReg, config.filter);
}
}
- private searchTree(node: TreeNode, search: RegExp | string): boolean {
- const matched = node.match(search);
+ private searchTree(node: TreeNode, search: RegExp | string, symbol: SymbolKindStr | null = null): boolean {
+ const matched = node.match(search, symbol);
let matchedChildren = false;
for (const child of node.children) {
- const matched = this.searchTree(child, search);
+ const matched = this.searchTree(child, search, symbol);
matchedChildren = matchedChildren || matched;
}
node.setMatchedChildren(matchedChildren);