From 1d6964c3114484ff14ec97261634692c20d19bc5 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Tue, 10 Oct 2023 08:51:22 +0200 Subject: [PATCH 01/25] Experimental Workspace Symbols overhaul --- plugin/symbols.py | 287 +++++++++++++++++++++++++++++++++++----------- stubs/sublime.pyi | 3 + 2 files changed, 225 insertions(+), 65 deletions(-) diff --git a/plugin/symbols.py b/plugin/symbols.py index 2780405eb..965f08996 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -1,21 +1,26 @@ -import weakref from .core.protocol import DocumentSymbol from .core.protocol import DocumentSymbolParams from .core.protocol import Request from .core.protocol import SymbolInformation from .core.protocol import SymbolKind from .core.protocol import SymbolTag +from .core.protocol import WorkspaceSymbol from .core.registry import LspTextCommand +from .core.registry import LspWindowCommand from .core.sessions import print_to_status_bar -from .core.typing import Any, List, Optional, Tuple, Dict, Union, cast +from .core.typing import Any, Callable, List, Optional, Tuple, Dict, TypeVar, Union, cast from .core.views import range_to_region -from .core.views import SublimeKind from .core.views import SYMBOL_KINDS from .core.views import text_document_identifier from .goto_diagnostic import PreselectedListInputHandler +from abc import ABCMeta +from abc import abstractmethod +import functools import os import sublime import sublime_plugin +import threading +import weakref SUPPRESS_INPUT_SETTING_KEY = 'lsp_suppress_input' @@ -50,32 +55,6 @@ } # type: Dict[SymbolKind, str] -def unpack_lsp_kind(kind: SymbolKind) -> SublimeKind: - return SYMBOL_KINDS.get(kind, sublime.KIND_AMBIGUOUS) - - -def symbol_information_to_quick_panel_item( - item: SymbolInformation, - show_file_name: bool = True -) -> sublime.QuickPanelItem: - st_kind, st_icon, st_display_type = unpack_lsp_kind(item['kind']) - tags = item.get("tags") or [] - if SymbolTag.Deprecated in tags: - st_display_type = "⚠ {} - Deprecated".format(st_display_type) - container = item.get("containerName") or "" - details = [] # List[str] - if container: - details.append(container) - if show_file_name: - file_name = os.path.basename(item['location']['uri']) - details.append(file_name) - return sublime.QuickPanelItem( - trigger=item["name"], - details=details, - annotation=st_display_type, - kind=(st_kind, st_icon, st_display_type)) - - def symbol_to_list_input_item( view: sublime.View, item: Union[DocumentSymbol, SymbolInformation], hierarchy: str = '' ) -> sublime.ListInputItem: @@ -202,7 +181,7 @@ def handle_response_async(self, response: Union[List[DocumentSymbol], List[Symbo window = self.view.window() if window: self.cached = True - window.run_command('show_overlay', {'overlay': 'command_palette', 'command': 'lsp_document_symbols'}) + window.run_command('show_overlay', {'overlay': 'command_palette', 'command': self.name()}) def handle_response_error(self, error: Any) -> None: self.view.settings().erase(SUPPRESS_INPUT_SETTING_KEY) @@ -305,48 +284,226 @@ def cancel(self) -> None: self.view.show_at_center(self.old_selection[0].begin()) -class SymbolQueryInput(sublime_plugin.TextInputHandler): - def want_event(self) -> bool: - return False +class LspWorkspaceSymbolsCommand(LspWindowCommand): - def placeholder(self) -> str: - return "Enter symbol name" + capability = 'workspaceSymbolProvider' + def __init__(self, window: sublime.Window) -> None: + super().__init__(window) + self.items = [] # type: List[sublime.ListInputItem] + self.pending_request = False -class LspWorkspaceSymbolsCommand(LspTextCommand): + def run( + self, + symbol: Optional[Dict[str, Any]], + text: str = "" + ) -> None: + if not symbol: + return + session = self.session() + if session: + session.open_location_async(symbol['location'], sublime.ENCODED_POSITION) - capability = 'workspaceSymbolProvider' + def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandler]: + # TODO maybe send an initial request with empty query string when the command is invoked? + if 'symbol' not in args: + return WorkspaceSymbolsInputHandler(self, args.get('text', '')) + return None - def input(self, _args: Any) -> sublime_plugin.TextInputHandler: - return SymbolQueryInput() - def run(self, edit: sublime.Edit, symbol_query_input: str, event: Optional[Any] = None) -> None: - session = self.best_session(self.capability) - if session: - self.weaksession = weakref.ref(session) - session.send_request( - Request.workspaceSymbol({"query": symbol_query_input}), - lambda r: self._handle_response(symbol_query_input, r), - self._handle_error) +def symbol_to_list_input_item2(item: Union[SymbolInformation, WorkspaceSymbol]) -> sublime.ListInputItem: + # TODO merge this function with symbol_to_list_input_item + name = item['name'] + kind = item['kind'] + location = item['location'] + st_kind = SYMBOL_KINDS.get(kind, sublime.KIND_AMBIGUOUS) + details = [] + details.append(os.path.basename(location['uri'])) + container_name = item.get('containerName') + if container_name: + details.append(container_name) + deprecated = SymbolTag.Deprecated in (item.get('tags') or []) or item.get('deprecated', False) + return sublime.ListInputItem( + name, + {'kind': kind, 'location': location, 'deprecated': deprecated}, + details=" > ".join(details), + annotation=st_kind[2], + kind=st_kind + ) - def _open_file(self, symbols: List[SymbolInformation], index: int) -> None: - if index != -1: - session = self.weaksession() - if session: - session.open_location_async(symbols[index]['location'], sublime.ENCODED_POSITION) - def _handle_response(self, query: str, response: Union[List[SymbolInformation], None]) -> None: - if response: - matches = response - window = self.view.window() - if window: - window.show_quick_panel( - list(map(symbol_information_to_quick_panel_item, matches)), - lambda i: self._open_file(matches, i)) +class DynamicListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): + """ A ListInputHandler which can update its items while typing in the input field. + + Derive from this class and override the `get_list_items` method for the initial list items, but don't implement + `list_items`. Then you can call the `update` method with a list of `ListInputItem`s from within `on_modified`, + which will be called after changes have been made to the input (with a small delay). + + To create an instance of the derived class, pass the command instance and the `text` command argument to the + constructor, like this: + + def input(self, args): + return MyDynamicListInputHandler(self, args.get('text', '')) + + For now, the type of the command must be a WindowCommand, but maybe it can be generalized later if needed. + This class will set and modify an `_items` attribute of the command, so make sure that this attribute name is not + used in another way in the command's class. + """ + + def __init__(self, command: sublime_plugin.WindowCommand, text: str) -> None: + super().__init__() + self.command = command + self.text = text + self.listener = None # type: Optional[sublime_plugin.TextChangeListener] + self.input_view = None # type: Optional[sublime.View] + + def attach_listener(self) -> None: + window = sublime.active_window() + for buffer in sublime._buffers(): # type: ignore + view = buffer.primary_view() + # TODO what to do if there is another command palette open in the same window but in another group? + if view.element() == 'command_palette:input' and view.window() == window: + self.input_view = view + break else: - sublime.message_dialog("No matches found for query: '{}'".format(query)) + raise RuntimeError('Could not find the Command Palette input field view') + self.listener = WorkspaceSymbolsQueryListener(self) + self.listener.attach(buffer) + # --- Hack needed because the initial_selection method is not supported on Python 3.3 API + selection = self.input_view.sel() + selection.clear() + selection.add(len(self.text)) + # --- End of hack + + def list_items(self) -> List[sublime.ListInputItem]: + if not self.text: # Show initial items when the command was just invoked + return self.get_list_items() or [sublime.ListInputItem("No Results", "")] + else: # Items were updated after typing + return getattr(self.command, '_items', None) or [sublime.ListInputItem("No Results", "")] + + def initial_text(self) -> str: + sublime.set_timeout(self.attach_listener) + return self.text + + # Not supported on Python 3.3 API :-( + def initial_selection(self) -> List[Tuple[int, int]]: + pt = len(self.text) + return [(pt, pt)] + + def validate(self, text: str) -> bool: + return bool(text) + + def cancel(self) -> None: + if self.listener and self.listener.is_attached(): + self.listener.detach() + + def confirm(self, text: str) -> None: + if self.listener and self.listener.is_attached(): + self.listener.detach() + + def on_modified(self, text: str) -> None: + """ Called after changes have been made to the input, with the text of the input field passed as argument. """ + pass + + @abstractmethod + def get_list_items(self) -> List[sublime.ListInputItem]: + """ The list items which are initially shown. """ + raise NotImplementedError() + + def update(self, items: List[sublime.ListInputItem]) -> None: + """ Call this method to update the list items. """ + if not self.input_view: + return + setattr(self.command, '_items', items) + text = self.input_view.substr(sublime.Region(0, self.input_view.size())) + self.command.window.run_command('chain', { + 'commands': [ + # TODO is there a way to run the command again without having to close the overlay first, so that the + # command palette won't change its width? + ['hide_overlay', {}], + [self.command.name(), {'text': text}] + ] + }) + # self.command.window.run_command(self.command.name(), {'text': self.text}) + + +class WorkspaceSymbolsInputHandler(DynamicListInputHandler): + + def __init__(self, command: sublime_plugin.WindowCommand, text: str) -> None: + super().__init__(command, text) + + def name(self) -> str: + return 'symbol' + + def placeholder(self) -> str: + return "Start typing to search" + + def preview(self, text: Any) -> Union[str, sublime.Html, None]: + if isinstance(text, dict) and text.get('deprecated'): + return "⚠ Deprecated" + return "" + + def get_list_items(self) -> List[sublime.ListInputItem]: + return [] + + def on_modified(self, text: str) -> None: + self.command = cast(LspWindowCommand, self.command) + session = self.command.session() + if session and self.input_view: + change_count = self.input_view.change_count() + session.send_request( + Request.workspaceSymbol({"query": text}), + functools.partial(self._handle_response_async, change_count), + functools.partial(self._handle_response_error_async, change_count) + ) + + def _handle_response_async(self, change_count: int, response: Union[List[SymbolInformation], None]) -> None: + if self.input_view and self.input_view.change_count() == change_count: + self.update([symbol_to_list_input_item2(item) for item in response] if response else []) + + def _handle_response_error_async(self, change_count: int, error: Dict[str, Any]) -> None: + if self.input_view and self.input_view.change_count() == change_count: + self.update([]) + + +T_Callable = TypeVar('T_Callable', bound=Callable[..., Any]) + + +def debounced(user_function: T_Callable) -> T_Callable: + """ Yet another debounce implementation :-) """ + DEBOUNCE_TIME = 0.5 # seconds + @functools.wraps(user_function) + def wrapped_function(*args: Any, **kwargs: Any) -> None: + def call_function(): + if hasattr(wrapped_function, '_timer'): + delattr(wrapped_function, '_timer') + return user_function(*args, **kwargs) + timer = getattr(wrapped_function, '_timer', None) + if timer is not None: + timer.cancel() + timer = threading.Timer(DEBOUNCE_TIME, call_function) + timer.start() + setattr(wrapped_function, '_timer', timer) + setattr(wrapped_function, '_timer', None) + return cast(T_Callable, wrapped_function) + + +class WorkspaceSymbolsQueryListener(sublime_plugin.TextChangeListener): + + def __init__(self, handler: DynamicListInputHandler) -> None: + super().__init__() + self.weakhandler = weakref.ref(handler) + + @classmethod + def is_applicable(cls, buffer: sublime.Buffer) -> bool: + return False - def _handle_error(self, error: Dict[str, Any]) -> None: - reason = error.get("message", "none provided by server :(") - msg = "command 'workspace/symbol' failed. Reason: {}".format(reason) - sublime.error_message(msg) + @debounced + def on_text_changed(self, changes: List[sublime.TextChange]) -> None: + handler = self.weakhandler() + if not handler: + return + view = self.buffer.primary_view() + if not view: + return + handler.on_modified(view.substr(sublime.Region(0, view.size()))) diff --git a/stubs/sublime.pyi b/stubs/sublime.pyi index f031efdc2..e8dc878f5 100644 --- a/stubs/sublime.pyi +++ b/stubs/sublime.pyi @@ -1082,7 +1082,10 @@ class QuickPanelItem: class ListInputItem: + text = ... # type: str value = ... # type: Any + details = ... # type: Union[str, List[str], Tuple[str]] + annotation = ... # type: str kind = ... # type: Tuple[int, str, str] def __init__( self, From 7f72d5e8a1745b542ceb24a726f0e6ce386052c5 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 11 Oct 2023 17:29:04 +0200 Subject: [PATCH 02/25] Select topmost item in the list --- plugin/symbols.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugin/symbols.py b/plugin/symbols.py index 965f08996..a2a665403 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -379,7 +379,15 @@ def list_items(self) -> List[sublime.ListInputItem]: if not self.text: # Show initial items when the command was just invoked return self.get_list_items() or [sublime.ListInputItem("No Results", "")] else: # Items were updated after typing - return getattr(self.command, '_items', None) or [sublime.ListInputItem("No Results", "")] + items = getattr(self.command, '_items', None) + if items: + # Trick to select the topmost item; also see https://github.com/sublimehq/sublime_text/issues/6162 + sublime.set_timeout(self._select_first_item) + return [sublime.ListInputItem("", "")] + items + return [sublime.ListInputItem("No Results", "")] + + def _select_first_item(self) -> None: + self.command.window.run_command('move', {'by': 'lines', 'forward': True}) def initial_text(self) -> str: sublime.set_timeout(self.attach_listener) From aa23d735b08d8f0728f54936eea230fd6b547ddd Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 11 Oct 2023 18:58:30 +0200 Subject: [PATCH 03/25] Move abstract input handlers to separate file --- plugin/core/input_handlers.py | 195 ++++++++++++++++++++++++++++++++++ plugin/goto_diagnostic.py | 45 +------- plugin/symbols.py | 158 +-------------------------- 3 files changed, 200 insertions(+), 198 deletions(-) create mode 100644 plugin/core/input_handlers.py diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py new file mode 100644 index 000000000..ede6d1ebc --- /dev/null +++ b/plugin/core/input_handlers.py @@ -0,0 +1,195 @@ +from .typing import Any, Callable, List, Optional, Tuple, TypeVar, Union +from .typing import cast +from abc import ABCMeta +from abc import abstractmethod +import functools +import sublime +import sublime_plugin +import threading +import weakref + + +ListItemsReturn = Union[List[str], Tuple[List[str], int], List[Tuple[str, Any]], Tuple[List[Tuple[str, Any]], int], + List[sublime.ListInputItem], Tuple[List[sublime.ListInputItem], int]] + +T_Callable = TypeVar('T_Callable', bound=Callable[..., Any]) + + +def debounced(user_function: T_Callable) -> T_Callable: + """ Yet another debounce implementation :-) """ + DEBOUNCE_TIME = 0.5 # seconds + + @functools.wraps(user_function) + def wrapped_function(*args: Any, **kwargs: Any) -> None: + def call_function(): + if hasattr(wrapped_function, '_timer'): + delattr(wrapped_function, '_timer') + return user_function(*args, **kwargs) + timer = getattr(wrapped_function, '_timer', None) + if timer is not None: + timer.cancel() + timer = threading.Timer(DEBOUNCE_TIME, call_function) + timer.start() + setattr(wrapped_function, '_timer', timer) + setattr(wrapped_function, '_timer', None) + return cast(T_Callable, wrapped_function) + + +class PreselectedListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): + """ + Similar to ListInputHandler, but allows to preselect a value like some of the input overlays in Sublime Merge. + Inspired by https://github.com/sublimehq/sublime_text/issues/5507. + + Subclasses of PreselectedListInputHandler must not implement the `list_items` method, but instead `get_list_items`, + i.e. just prepend `get_` to the regular `list_items` method. + + When an instance of PreselectedListInputHandler is created, it must be given the window as an argument. + An optional second argument `initial_value` can be provided to preselect a value. + """ + + def __init__( + self, window: sublime.Window, initial_value: Optional[Union[str, sublime.ListInputItem]] = None + ) -> None: + super().__init__() + self._window = window + self._initial_value = initial_value + + def list_items(self) -> ListItemsReturn: + if self._initial_value is not None: + sublime.set_timeout(self._select_and_reset) + return [self._initial_value], 0 # pyright: ignore[reportGeneralTypeIssues] + else: + return self.get_list_items() + + def _select_and_reset(self) -> None: + self._initial_value = None + if self._window.is_valid(): + self._window.run_command('select') + + @abstractmethod + def get_list_items(self) -> ListItemsReturn: + raise NotImplementedError() + + +class DynamicListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): + """ A ListInputHandler which can update its items while typing in the input field. + + Derive from this class and override the `get_list_items` method for the initial list items, but don't implement + `list_items`. Then you can call the `update` method with a list of `ListInputItem`s from within `on_modified`, + which will be called after changes have been made to the input (with a small delay). + + To create an instance of the derived class, pass the command instance and the `text` command argument to the + constructor, like this: + + def input(self, args): + return MyDynamicListInputHandler(self, args.get('text', '')) + + For now, the type of the command must be a WindowCommand, but maybe it can be generalized later if needed. + This class will set and modify an `_items` attribute of the command, so make sure that this attribute name is not + used in another way in the command's class. + """ + + def __init__(self, command: sublime_plugin.WindowCommand, text: str) -> None: + super().__init__() + self.command = command + self.text = text + self.listener = None # type: Optional[sublime_plugin.TextChangeListener] + self.input_view = None # type: Optional[sublime.View] + + def attach_listener(self) -> None: + window = sublime.active_window() + for buffer in sublime._buffers(): # type: ignore + view = buffer.primary_view() + # TODO what to do if there is another command palette open in the same window but in another group? + if view.element() == 'command_palette:input' and view.window() == window: + self.input_view = view + break + else: + raise RuntimeError('Could not find the Command Palette input field view') + self.listener = WorkspaceSymbolsQueryListener(self) + self.listener.attach(buffer) + # --- Hack needed because the initial_selection method is not supported on Python 3.3 API + selection = self.input_view.sel() + selection.clear() + selection.add(len(self.text)) + # --- End of hack + + def list_items(self) -> List[sublime.ListInputItem]: + if not self.text: # Show initial items when the command was just invoked + return self.get_list_items() or [sublime.ListInputItem("No Results", "")] + else: # Items were updated after typing + items = getattr(self.command, '_items', None) + if items: + # Trick to select the topmost item; also see https://github.com/sublimehq/sublime_text/issues/6162 + sublime.set_timeout(self._select_first_item) + return [sublime.ListInputItem("", "")] + items + return [sublime.ListInputItem("No Results", "")] + + def _select_first_item(self) -> None: + self.command.window.run_command('move', {'by': 'lines', 'forward': True}) + + def initial_text(self) -> str: + sublime.set_timeout(self.attach_listener) + return self.text + + # Not supported on Python 3.3 API :-( + def initial_selection(self) -> List[Tuple[int, int]]: + pt = len(self.text) + return [(pt, pt)] + + def validate(self, text: str) -> bool: + return bool(text) + + def cancel(self) -> None: + if self.listener and self.listener.is_attached(): + self.listener.detach() + + def confirm(self, text: str) -> None: + if self.listener and self.listener.is_attached(): + self.listener.detach() + + def on_modified(self, text: str) -> None: + """ Called after changes have been made to the input, with the text of the input field passed as argument. """ + pass + + @abstractmethod + def get_list_items(self) -> List[sublime.ListInputItem]: + """ The list items which are initially shown. """ + raise NotImplementedError() + + def update(self, items: List[sublime.ListInputItem]) -> None: + """ Call this method to update the list items. """ + if not self.input_view: + return + setattr(self.command, '_items', items) + text = self.input_view.substr(sublime.Region(0, self.input_view.size())) + self.command.window.run_command('chain', { + 'commands': [ + # TODO is there a way to run the command again without having to close the overlay first, so that the + # command palette won't change its width? + ['hide_overlay', {}], + [self.command.name(), {'text': text}] + ] + }) + # self.command.window.run_command(self.command.name(), {'text': self.text}) + + +class WorkspaceSymbolsQueryListener(sublime_plugin.TextChangeListener): + + def __init__(self, handler: DynamicListInputHandler) -> None: + super().__init__() + self.weakhandler = weakref.ref(handler) + + @classmethod + def is_applicable(cls, buffer: sublime.Buffer) -> bool: + return False + + @debounced + def on_text_changed(self, changes: List[sublime.TextChange]) -> None: + handler = self.weakhandler() + if not handler: + return + view = self.buffer.primary_view() + if not view: + return + handler.on_modified(view.substr(sublime.Region(0, view.size()))) diff --git a/plugin/goto_diagnostic.py b/plugin/goto_diagnostic.py index 49b4aa918..c5abbfa8a 100644 --- a/plugin/goto_diagnostic.py +++ b/plugin/goto_diagnostic.py @@ -1,5 +1,6 @@ from .core.diagnostics_storage import is_severity_included from .core.diagnostics_storage import ParsedUri +from .core.input_handlers import PreselectedListInputHandler from .core.paths import project_base_dir from .core.paths import project_path from .core.paths import simple_project_path @@ -11,7 +12,7 @@ from .core.sessions import Session from .core.settings import userprefs from .core.types import ClientConfig -from .core.typing import Any, Dict, Iterator, List, Optional, Tuple, Union +from .core.typing import Dict, Iterator, List, Optional, Tuple, Union from .core.url import parse_uri, unparse_uri from .core.views import DIAGNOSTIC_KINDS from .core.views import diagnostic_severity @@ -22,8 +23,6 @@ from .core.views import MissingUriError from .core.views import to_encoded_filename from .core.views import uri_from_view -from abc import ABCMeta -from abc import abstractmethod from collections import Counter, OrderedDict from pathlib import Path import functools @@ -93,46 +92,6 @@ def input_description(self) -> str: return "Goto Diagnostic" -ListItemsReturn = Union[List[str], Tuple[List[str], int], List[Tuple[str, Any]], Tuple[List[Tuple[str, Any]], int], - List[sublime.ListInputItem], Tuple[List[sublime.ListInputItem], int]] - - -class PreselectedListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): - """ - Similar to ListInputHandler, but allows to preselect a value like some of the input overlays in Sublime Merge. - Inspired by https://github.com/sublimehq/sublime_text/issues/5507. - - Subclasses of PreselectedListInputHandler must not implement the `list_items` method, but instead `get_list_items`, - i.e. just prepend `get_` to the regular `list_items` method. - - When an instance of PreselectedListInputHandler is created, it must be given the window as an argument. - An optional second argument `initial_value` can be provided to preselect a value. - """ - - def __init__( - self, window: sublime.Window, initial_value: Optional[Union[str, sublime.ListInputItem]] = None - ) -> None: - super().__init__() - self._window = window - self._initial_value = initial_value - - def list_items(self) -> ListItemsReturn: - if self._initial_value is not None: - sublime.set_timeout(self._select_and_reset) - return [self._initial_value], 0 # pyright: ignore[reportGeneralTypeIssues] - else: - return self.get_list_items() - - def _select_and_reset(self) -> None: - self._initial_value = None - if self._window.is_valid(): - self._window.run_command('select') - - @abstractmethod - def get_list_items(self) -> ListItemsReturn: - raise NotImplementedError() - - class DiagnosticUriInputHandler(PreselectedListInputHandler): _preview = None # type: Optional[sublime.View] uri = None # Optional[DocumentUri] diff --git a/plugin/symbols.py b/plugin/symbols.py index a2a665403..8437fc10b 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -1,3 +1,5 @@ +from .core.input_handlers import DynamicListInputHandler +from .core.input_handlers import PreselectedListInputHandler from .core.protocol import DocumentSymbol from .core.protocol import DocumentSymbolParams from .core.protocol import Request @@ -8,19 +10,14 @@ from .core.registry import LspTextCommand from .core.registry import LspWindowCommand from .core.sessions import print_to_status_bar -from .core.typing import Any, Callable, List, Optional, Tuple, Dict, TypeVar, Union, cast +from .core.typing import Any, List, Optional, Tuple, Dict, Union, cast from .core.views import range_to_region from .core.views import SYMBOL_KINDS from .core.views import text_document_identifier -from .goto_diagnostic import PreselectedListInputHandler -from abc import ABCMeta -from abc import abstractmethod import functools import os import sublime import sublime_plugin -import threading -import weakref SUPPRESS_INPUT_SETTING_KEY = 'lsp_suppress_input' @@ -332,114 +329,8 @@ def symbol_to_list_input_item2(item: Union[SymbolInformation, WorkspaceSymbol]) ) -class DynamicListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): - """ A ListInputHandler which can update its items while typing in the input field. - - Derive from this class and override the `get_list_items` method for the initial list items, but don't implement - `list_items`. Then you can call the `update` method with a list of `ListInputItem`s from within `on_modified`, - which will be called after changes have been made to the input (with a small delay). - - To create an instance of the derived class, pass the command instance and the `text` command argument to the - constructor, like this: - - def input(self, args): - return MyDynamicListInputHandler(self, args.get('text', '')) - - For now, the type of the command must be a WindowCommand, but maybe it can be generalized later if needed. - This class will set and modify an `_items` attribute of the command, so make sure that this attribute name is not - used in another way in the command's class. - """ - - def __init__(self, command: sublime_plugin.WindowCommand, text: str) -> None: - super().__init__() - self.command = command - self.text = text - self.listener = None # type: Optional[sublime_plugin.TextChangeListener] - self.input_view = None # type: Optional[sublime.View] - - def attach_listener(self) -> None: - window = sublime.active_window() - for buffer in sublime._buffers(): # type: ignore - view = buffer.primary_view() - # TODO what to do if there is another command palette open in the same window but in another group? - if view.element() == 'command_palette:input' and view.window() == window: - self.input_view = view - break - else: - raise RuntimeError('Could not find the Command Palette input field view') - self.listener = WorkspaceSymbolsQueryListener(self) - self.listener.attach(buffer) - # --- Hack needed because the initial_selection method is not supported on Python 3.3 API - selection = self.input_view.sel() - selection.clear() - selection.add(len(self.text)) - # --- End of hack - - def list_items(self) -> List[sublime.ListInputItem]: - if not self.text: # Show initial items when the command was just invoked - return self.get_list_items() or [sublime.ListInputItem("No Results", "")] - else: # Items were updated after typing - items = getattr(self.command, '_items', None) - if items: - # Trick to select the topmost item; also see https://github.com/sublimehq/sublime_text/issues/6162 - sublime.set_timeout(self._select_first_item) - return [sublime.ListInputItem("", "")] + items - return [sublime.ListInputItem("No Results", "")] - - def _select_first_item(self) -> None: - self.command.window.run_command('move', {'by': 'lines', 'forward': True}) - - def initial_text(self) -> str: - sublime.set_timeout(self.attach_listener) - return self.text - - # Not supported on Python 3.3 API :-( - def initial_selection(self) -> List[Tuple[int, int]]: - pt = len(self.text) - return [(pt, pt)] - - def validate(self, text: str) -> bool: - return bool(text) - - def cancel(self) -> None: - if self.listener and self.listener.is_attached(): - self.listener.detach() - - def confirm(self, text: str) -> None: - if self.listener and self.listener.is_attached(): - self.listener.detach() - - def on_modified(self, text: str) -> None: - """ Called after changes have been made to the input, with the text of the input field passed as argument. """ - pass - - @abstractmethod - def get_list_items(self) -> List[sublime.ListInputItem]: - """ The list items which are initially shown. """ - raise NotImplementedError() - - def update(self, items: List[sublime.ListInputItem]) -> None: - """ Call this method to update the list items. """ - if not self.input_view: - return - setattr(self.command, '_items', items) - text = self.input_view.substr(sublime.Region(0, self.input_view.size())) - self.command.window.run_command('chain', { - 'commands': [ - # TODO is there a way to run the command again without having to close the overlay first, so that the - # command palette won't change its width? - ['hide_overlay', {}], - [self.command.name(), {'text': text}] - ] - }) - # self.command.window.run_command(self.command.name(), {'text': self.text}) - - class WorkspaceSymbolsInputHandler(DynamicListInputHandler): - def __init__(self, command: sublime_plugin.WindowCommand, text: str) -> None: - super().__init__(command, text) - def name(self) -> str: return 'symbol' @@ -472,46 +363,3 @@ def _handle_response_async(self, change_count: int, response: Union[List[SymbolI def _handle_response_error_async(self, change_count: int, error: Dict[str, Any]) -> None: if self.input_view and self.input_view.change_count() == change_count: self.update([]) - - -T_Callable = TypeVar('T_Callable', bound=Callable[..., Any]) - - -def debounced(user_function: T_Callable) -> T_Callable: - """ Yet another debounce implementation :-) """ - DEBOUNCE_TIME = 0.5 # seconds - @functools.wraps(user_function) - def wrapped_function(*args: Any, **kwargs: Any) -> None: - def call_function(): - if hasattr(wrapped_function, '_timer'): - delattr(wrapped_function, '_timer') - return user_function(*args, **kwargs) - timer = getattr(wrapped_function, '_timer', None) - if timer is not None: - timer.cancel() - timer = threading.Timer(DEBOUNCE_TIME, call_function) - timer.start() - setattr(wrapped_function, '_timer', timer) - setattr(wrapped_function, '_timer', None) - return cast(T_Callable, wrapped_function) - - -class WorkspaceSymbolsQueryListener(sublime_plugin.TextChangeListener): - - def __init__(self, handler: DynamicListInputHandler) -> None: - super().__init__() - self.weakhandler = weakref.ref(handler) - - @classmethod - def is_applicable(cls, buffer: sublime.Buffer) -> bool: - return False - - @debounced - def on_text_changed(self, changes: List[sublime.TextChange]) -> None: - handler = self.weakhandler() - if not handler: - return - view = self.buffer.primary_view() - if not view: - return - handler.on_modified(view.substr(sublime.Region(0, view.size()))) From 64106308c81a5d5a02efffee46d5036d005f54bd Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 11 Oct 2023 23:46:45 +0200 Subject: [PATCH 04/25] Merge functions and add support for workspaceSymbol/resolve --- plugin/core/protocol.py | 4 +++ plugin/core/sessions.py | 3 ++ plugin/symbols.py | 71 +++++++++++++++++++++++------------------ 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/plugin/core/protocol.py b/plugin/core/protocol.py index 372a53ae9..597e5d728 100644 --- a/plugin/core/protocol.py +++ b/plugin/core/protocol.py @@ -6144,6 +6144,10 @@ def foldingRange(cls, params: FoldingRangeParams, view: sublime.View) -> 'Reques def workspaceSymbol(cls, params: WorkspaceSymbolParams) -> 'Request': return Request("workspace/symbol", params, None, progress=True) + @classmethod + def resolveWorkspaceSymbol(cls, params: WorkspaceSymbol) -> 'Request': + return Request('workspaceSymbol/resolve', params) + @classmethod def documentDiagnostic(cls, params: DocumentDiagnosticParams, view: sublime.View) -> 'Request': return Request('textDocument/diagnostic', params, view) diff --git a/plugin/core/sessions.py b/plugin/core/sessions.py index f0080bc36..9f2140973 100644 --- a/plugin/core/sessions.py +++ b/plugin/core/sessions.py @@ -463,6 +463,9 @@ def get_initialize_params(variables: Dict[str, str], workspace_folders: List[Wor "workspaceFolders": True, "symbol": { "dynamicRegistration": True, # exceptional + "resolveSupport": { + "properties": ["location.range"] + }, "symbolKind": { "valueSet": symbol_kinds }, diff --git a/plugin/symbols.py b/plugin/symbols.py index 8437fc10b..009c3965a 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -2,6 +2,7 @@ from .core.input_handlers import PreselectedListInputHandler from .core.protocol import DocumentSymbol from .core.protocol import DocumentSymbolParams +from .core.protocol import Location from .core.protocol import Request from .core.protocol import SymbolInformation from .core.protocol import SymbolKind @@ -53,12 +54,17 @@ def symbol_to_list_input_item( - view: sublime.View, item: Union[DocumentSymbol, SymbolInformation], hierarchy: str = '' + item: Union[DocumentSymbol, WorkspaceSymbol, SymbolInformation], + view: Optional[sublime.View] = None, + hierarchy: str = '' ) -> sublime.ListInputItem: name = item['name'] kind = item['kind'] st_kind = SYMBOL_KINDS.get(kind, sublime.KIND_AMBIGUOUS) - details = [] + details = [] # type: List[str] + deprecated = SymbolTag.Deprecated in (item.get('tags') or []) or item.get('deprecated', False) + value = {'kind': kind, 'deprecated': deprecated} + details_separator = " • " selection_range = item.get('selectionRange') if selection_range: item = cast(DocumentSymbol, item) @@ -67,18 +73,32 @@ def symbol_to_list_input_item( details.append(detail) if hierarchy: details.append(hierarchy + " > " + name) - region = range_to_region(selection_range, view) - else: + if view: + region = range_to_region(selection_range, view) + value['region'] = [region.a, region.b] + elif view: item = cast(SymbolInformation, item) container_name = item.get('containerName') if container_name: details.append(container_name) region = range_to_region(item['location']['range'], view) - deprecated = SymbolTag.Deprecated in (item.get('tags') or []) or item.get('deprecated', False) + value['region'] = [region.a, region.b] + else: # Can be either WorkspaceSymbol or SymbolInformation + item = cast(WorkspaceSymbol, item) + details_separator = " > " + location = item['location'] + details.append(os.path.basename(location['uri'])) + container_name = item.get('containerName') + if container_name: + details.append(container_name) + if 'range' in location: + value['location'] = location + else: + value['workspaceSymbol'] = item return sublime.ListInputItem( name, - {'kind': kind, 'region': [region.a, region.b], 'deprecated': deprecated}, - details=" • ".join(details), + value, + details=details_separator.join(details), annotation=st_kind[2], kind=st_kind ) @@ -173,7 +193,7 @@ def handle_response_async(self, response: Union[List[DocumentSymbol], List[Symbo else: items = cast(List[SymbolInformation], response) for item in items: - self.items.append(symbol_to_list_input_item(self.view, item)) + self.items.append(symbol_to_list_input_item(item, self.view)) self.items.sort(key=lambda item: item.value['region']) window = self.view.window() if window: @@ -189,7 +209,7 @@ def process_document_symbol_recursive( ) -> List[sublime.ListInputItem]: name = item['name'] name_hierarchy = hierarchy + " > " + name if hierarchy else name - items = [symbol_to_list_input_item(self.view, item, hierarchy)] + items = [symbol_to_list_input_item(item, self.view, hierarchy)] for child in item.get('children') or []: items.extend(self.process_document_symbol_recursive(child, name_hierarchy)) return items @@ -299,7 +319,11 @@ def run( return session = self.session() if session: - session.open_location_async(symbol['location'], sublime.ENCODED_POSITION) + if 'location' in symbol: + session.open_location_async(symbol['location'], sublime.ENCODED_POSITION) + else: + session.send_request( + Request.resolveWorkspaceSymbol(symbol['workspaceSymbol']), self._on_resolved_symbol_async) def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandler]: # TODO maybe send an initial request with empty query string when the command is invoked? @@ -307,26 +331,11 @@ def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandle return WorkspaceSymbolsInputHandler(self, args.get('text', '')) return None - -def symbol_to_list_input_item2(item: Union[SymbolInformation, WorkspaceSymbol]) -> sublime.ListInputItem: - # TODO merge this function with symbol_to_list_input_item - name = item['name'] - kind = item['kind'] - location = item['location'] - st_kind = SYMBOL_KINDS.get(kind, sublime.KIND_AMBIGUOUS) - details = [] - details.append(os.path.basename(location['uri'])) - container_name = item.get('containerName') - if container_name: - details.append(container_name) - deprecated = SymbolTag.Deprecated in (item.get('tags') or []) or item.get('deprecated', False) - return sublime.ListInputItem( - name, - {'kind': kind, 'location': location, 'deprecated': deprecated}, - details=" > ".join(details), - annotation=st_kind[2], - kind=st_kind - ) + def _on_resolved_symbol_async(self, response: WorkspaceSymbol) -> None: + location = cast(Location, response['location']) + session = self.session() + if session: + session.open_location_async(location, sublime.ENCODED_POSITION) class WorkspaceSymbolsInputHandler(DynamicListInputHandler): @@ -358,7 +367,7 @@ def on_modified(self, text: str) -> None: def _handle_response_async(self, change_count: int, response: Union[List[SymbolInformation], None]) -> None: if self.input_view and self.input_view.change_count() == change_count: - self.update([symbol_to_list_input_item2(item) for item in response] if response else []) + self.update([symbol_to_list_input_item(item) for item in response] if response else []) def _handle_response_error_async(self, change_count: int, error: Dict[str, Any]) -> None: if self.input_view and self.input_view.change_count() == change_count: From 6569c620e5db8707b1bc04157eb0d9809e2105ca Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Fri, 13 Oct 2023 08:20:45 +0200 Subject: [PATCH 05/25] Send requests to all sessions --- plugin/core/input_handlers.py | 1 - plugin/symbols.py | 84 ++++++++++++++++++++--------------- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index ede6d1ebc..6058875c8 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -171,7 +171,6 @@ def update(self, items: List[sublime.ListInputItem]) -> None: [self.command.name(), {'text': text}] ] }) - # self.command.window.run_command(self.command.name(), {'text': self.text}) class WorkspaceSymbolsQueryListener(sublime_plugin.TextChangeListener): diff --git a/plugin/symbols.py b/plugin/symbols.py index 009c3965a..2689b5c97 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -1,5 +1,6 @@ from .core.input_handlers import DynamicListInputHandler from .core.input_handlers import PreselectedListInputHandler +from .core.promise import Promise from .core.protocol import DocumentSymbol from .core.protocol import DocumentSymbolParams from .core.protocol import Location @@ -56,7 +57,8 @@ def symbol_to_list_input_item( item: Union[DocumentSymbol, WorkspaceSymbol, SymbolInformation], view: Optional[sublime.View] = None, - hierarchy: str = '' + hierarchy: str = '', + session_name: Optional[str] = None ) -> sublime.ListInputItem: name = item['name'] kind = item['kind'] @@ -65,26 +67,26 @@ def symbol_to_list_input_item( deprecated = SymbolTag.Deprecated in (item.get('tags') or []) or item.get('deprecated', False) value = {'kind': kind, 'deprecated': deprecated} details_separator = " • " - selection_range = item.get('selectionRange') - if selection_range: - item = cast(DocumentSymbol, item) - detail = item.get('detail') - if detail: - details.append(detail) - if hierarchy: - details.append(hierarchy + " > " + name) - if view: + if view: # Response from textDocument/documentSymbol request + selection_range = item.get('selectionRange') + if selection_range: + item = cast(DocumentSymbol, item) + detail = item.get('detail') + if detail: + details.append(detail) + if hierarchy: + details.append(hierarchy + " > " + name) region = range_to_region(selection_range, view) value['region'] = [region.a, region.b] - elif view: - item = cast(SymbolInformation, item) - container_name = item.get('containerName') - if container_name: - details.append(container_name) - region = range_to_region(item['location']['range'], view) - value['region'] = [region.a, region.b] - else: # Can be either WorkspaceSymbol or SymbolInformation - item = cast(WorkspaceSymbol, item) + else: + item = cast(SymbolInformation, item) + container_name = item.get('containerName') + if container_name: + details.append(container_name) + region = range_to_region(item['location']['range'], view) + value['region'] = [region.a, region.b] + else: # Response from workspace/symbol request + item = cast(WorkspaceSymbol, item) # Either WorkspaceSymbol or SymbolInformation, but possibly undecidable details_separator = " > " location = item['location'] details.append(os.path.basename(location['uri'])) @@ -95,6 +97,7 @@ def symbol_to_list_input_item( value['location'] = location else: value['workspaceSymbol'] = item + value['session'] = session_name return sublime.ListInputItem( name, value, @@ -317,13 +320,15 @@ def run( ) -> None: if not symbol: return - session = self.session() + session_name = symbol['session'] + session = self.session_by_name(session_name) if session: if 'location' in symbol: session.open_location_async(symbol['location'], sublime.ENCODED_POSITION) else: session.send_request( - Request.resolveWorkspaceSymbol(symbol['workspaceSymbol']), self._on_resolved_symbol_async) + Request.resolveWorkspaceSymbol(symbol['workspaceSymbol']), + functools.partial(self._on_resolved_symbol_async, session_name)) def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandler]: # TODO maybe send an initial request with empty query string when the command is invoked? @@ -331,9 +336,9 @@ def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandle return WorkspaceSymbolsInputHandler(self, args.get('text', '')) return None - def _on_resolved_symbol_async(self, response: WorkspaceSymbol) -> None: + def _on_resolved_symbol_async(self, session_name: str, response: WorkspaceSymbol) -> None: location = cast(Location, response['location']) - session = self.session() + session = self.session_by_name(session_name) if session: session.open_location_async(location, sublime.ENCODED_POSITION) @@ -355,20 +360,25 @@ def get_list_items(self) -> List[sublime.ListInputItem]: return [] def on_modified(self, text: str) -> None: + if not self.input_view: + return + change_count = self.input_view.change_count() self.command = cast(LspWindowCommand, self.command) - session = self.command.session() - if session and self.input_view: - change_count = self.input_view.change_count() - session.send_request( - Request.workspaceSymbol({"query": text}), - functools.partial(self._handle_response_async, change_count), - functools.partial(self._handle_response_error_async, change_count) - ) - - def _handle_response_async(self, change_count: int, response: Union[List[SymbolInformation], None]) -> None: - if self.input_view and self.input_view.change_count() == change_count: - self.update([symbol_to_list_input_item(item) for item in response] if response else []) + promises = [] # type: List[Promise[List[sublime.ListInputItem]]] + for session in self.command.sessions(): + promises.append( + session.send_request_task(Request.workspaceSymbol({"query": text})) + .then(functools.partial(self._handle_response_async, session.config.name))) + Promise.all(promises).then(functools.partial(self._on_all_responses, change_count)) + + def _handle_response_async( + self, session_name: str, response: Union[List[SymbolInformation], List[WorkspaceSymbol], None] + ) -> List[sublime.ListInputItem]: + return [symbol_to_list_input_item(item, session_name=session_name) for item in response] if response else [] - def _handle_response_error_async(self, change_count: int, error: Dict[str, Any]) -> None: + def _on_all_responses(self, change_count: int, item_lists: List[List[sublime.ListInputItem]]) -> None: if self.input_view and self.input_view.change_count() == change_count: - self.update([]) + items = [] # type: List[sublime.ListInputItem] + for item_list in item_lists: + items.extend(item_list) + self.update(items) From cfde4aa926038f45505d9a01054592b1e29a394f Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Fri, 13 Oct 2023 08:59:22 +0200 Subject: [PATCH 06/25] Add @final decorator --- plugin/core/input_handlers.py | 3 +++ plugin/core/typing.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 6058875c8..90c23cc2d 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -1,5 +1,6 @@ from .typing import Any, Callable, List, Optional, Tuple, TypeVar, Union from .typing import cast +from .typing import final from abc import ABCMeta from abc import abstractmethod import functools @@ -54,6 +55,7 @@ def __init__( self._window = window self._initial_value = initial_value + @final def list_items(self) -> ListItemsReturn: if self._initial_value is not None: sublime.set_timeout(self._select_and_reset) @@ -114,6 +116,7 @@ def attach_listener(self) -> None: selection.add(len(self.text)) # --- End of hack + @final def list_items(self) -> List[sublime.ListInputItem]: if not self.text: # Show initial items when the command was just invoked return self.get_list_items() or [sublime.ListInputItem("No Results", "")] diff --git a/plugin/core/typing.py b/plugin/core/typing.py index bcb251a85..16ed4250f 100644 --- a/plugin/core/typing.py +++ b/plugin/core/typing.py @@ -8,6 +8,7 @@ from typing import cast from typing import Deque from typing import Dict + from typing import final from typing import Generator from typing import Generic from typing import IO @@ -34,6 +35,9 @@ def cast(typ, val): # type: ignore return val + def final(func): + return func + def _make_type(name: str) -> '_TypeMeta': return _TypeMeta(name, (Type,), {}) # type: ignore From d24b8ae602dc1e119c749b89be691c4d41974635 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 14 Oct 2023 20:11:59 +0200 Subject: [PATCH 07/25] Delete test file with tests for now removed function --- tests/test_document_symbols.py | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 tests/test_document_symbols.py diff --git a/tests/test_document_symbols.py b/tests/test_document_symbols.py deleted file mode 100644 index b86ba2572..000000000 --- a/tests/test_document_symbols.py +++ /dev/null @@ -1,24 +0,0 @@ -from setup import TextDocumentTestCase -from LSP.plugin.core.typing import Generator -from LSP.plugin.symbols import symbol_information_to_quick_panel_item -from LSP.plugin.core.protocol import SymbolTag - - -class DocumentSymbolTests(TextDocumentTestCase): - def test_show_deprecated_flag_for_symbol_information(self) -> 'Generator': - symbol_information = { - "name": 'Name', - "kind": 6, # Method - "tags": [SymbolTag.Deprecated], - } - formatted_symbol_information = symbol_information_to_quick_panel_item(symbol_information, show_file_name=False) - self.assertEqual('⚠ Method - Deprecated', formatted_symbol_information.annotation) - - def test_dont_show_deprecated_flag_for_symbol_information(self) -> 'Generator': - symbol_information = { - "name": 'Name', - "kind": 6, # Method - # to deprecated tags - } - formatted_symbol_information = symbol_information_to_quick_panel_item(symbol_information, show_file_name=False) - self.assertEqual('Method', formatted_symbol_information.annotation) From bb027368768b6a987d18c4c722265ca64ca79920 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sun, 15 Oct 2023 19:09:06 +0200 Subject: [PATCH 08/25] Simplifications --- plugin/core/input_handlers.py | 26 +++++++++++++------------- plugin/symbols.py | 10 ++-------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 90c23cc2d..c1de0a13b 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -80,21 +80,20 @@ class DynamicListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta `list_items`. Then you can call the `update` method with a list of `ListInputItem`s from within `on_modified`, which will be called after changes have been made to the input (with a small delay). - To create an instance of the derived class, pass the command instance and the `text` command argument to the - constructor, like this: + To create an instance of the derived class pass the command instance to the constructor, like this: def input(self, args): - return MyDynamicListInputHandler(self, args.get('text', '')) + return MyDynamicListInputHandler(self) For now, the type of the command must be a WindowCommand, but maybe it can be generalized later if needed. - This class will set and modify an `_items` attribute of the command, so make sure that this attribute name is not - used in another way in the command's class. + This class will set and modify `_items` and '_text' attributes of the command, so make sure that those attribute + names are not used in another way in the command's class. """ - def __init__(self, command: sublime_plugin.WindowCommand, text: str) -> None: + def __init__(self, command: sublime_plugin.WindowCommand) -> None: super().__init__() self.command = command - self.text = text + self.text = getattr(command, '_text', '') self.listener = None # type: Optional[sublime_plugin.TextChangeListener] self.input_view = None # type: Optional[sublime.View] @@ -124,14 +123,15 @@ def list_items(self) -> List[sublime.ListInputItem]: items = getattr(self.command, '_items', None) if items: # Trick to select the topmost item; also see https://github.com/sublimehq/sublime_text/issues/6162 - sublime.set_timeout(self._select_first_item) + sublime.set_timeout(self._select_first_row) return [sublime.ListInputItem("", "")] + items return [sublime.ListInputItem("No Results", "")] - def _select_first_item(self) -> None: + def _select_first_row(self) -> None: self.command.window.run_command('move', {'by': 'lines', 'forward': True}) def initial_text(self) -> str: + setattr(self.command, '_text', '') sublime.set_timeout(self.attach_listener) return self.text @@ -166,12 +166,13 @@ def update(self, items: List[sublime.ListInputItem]) -> None: return setattr(self.command, '_items', items) text = self.input_view.substr(sublime.Region(0, self.input_view.size())) + setattr(self.command, '_text', text) self.command.window.run_command('chain', { 'commands': [ # TODO is there a way to run the command again without having to close the overlay first, so that the # command palette won't change its width? ['hide_overlay', {}], - [self.command.name(), {'text': text}] + [self.command.name(), {}] ] }) @@ -192,6 +193,5 @@ def on_text_changed(self, changes: List[sublime.TextChange]) -> None: if not handler: return view = self.buffer.primary_view() - if not view: - return - handler.on_modified(view.substr(sublime.Region(0, view.size()))) + if view and view.id(): + handler.on_modified(view.substr(sublime.Region(0, view.size()))) diff --git a/plugin/symbols.py b/plugin/symbols.py index 2689b5c97..6006c53f4 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -308,15 +308,9 @@ class LspWorkspaceSymbolsCommand(LspWindowCommand): capability = 'workspaceSymbolProvider' - def __init__(self, window: sublime.Window) -> None: - super().__init__(window) - self.items = [] # type: List[sublime.ListInputItem] - self.pending_request = False - def run( self, - symbol: Optional[Dict[str, Any]], - text: str = "" + symbol: Dict[str, Any] ) -> None: if not symbol: return @@ -333,7 +327,7 @@ def run( def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandler]: # TODO maybe send an initial request with empty query string when the command is invoked? if 'symbol' not in args: - return WorkspaceSymbolsInputHandler(self, args.get('text', '')) + return WorkspaceSymbolsInputHandler(self) return None def _on_resolved_symbol_async(self, session_name: str, response: WorkspaceSymbol) -> None: From 7bfe49af7913bf4903d9b5168d16d9369f7f6405 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 18 Oct 2023 18:13:46 +0200 Subject: [PATCH 09/25] Display message item when there are no search results --- plugin/core/input_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index c1de0a13b..a24fec740 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -125,7 +125,7 @@ def list_items(self) -> List[sublime.ListInputItem]: # Trick to select the topmost item; also see https://github.com/sublimehq/sublime_text/issues/6162 sublime.set_timeout(self._select_first_row) return [sublime.ListInputItem("", "")] + items - return [sublime.ListInputItem("No Results", "")] + return [sublime.ListInputItem('No Symbol found: "{}"'.format(self.text), "")] def _select_first_row(self) -> None: self.command.window.run_command('move', {'by': 'lines', 'forward': True}) From e7f7ad094ae263e98c191a5b421e1f1e0940c991 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 19 Oct 2023 08:33:21 +0200 Subject: [PATCH 10/25] Avoid empty item if possible --- plugin/core/input_handlers.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index a24fec740..167cd3747 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -10,6 +10,8 @@ import weakref +ST_VERSION = int(sublime.version()) + ListItemsReturn = Union[List[str], Tuple[List[str], int], List[Tuple[str, Any]], Tuple[List[Tuple[str, Any]], int], List[sublime.ListInputItem], Tuple[List[sublime.ListInputItem], int]] @@ -98,11 +100,10 @@ def __init__(self, command: sublime_plugin.WindowCommand) -> None: self.input_view = None # type: Optional[sublime.View] def attach_listener(self) -> None: - window = sublime.active_window() for buffer in sublime._buffers(): # type: ignore view = buffer.primary_view() # TODO what to do if there is another command palette open in the same window but in another group? - if view.element() == 'command_palette:input' and view.window() == window: + if view.element() == 'command_palette:input' and view.window() == self.command.window: self.input_view = view break else: @@ -122,9 +123,12 @@ def list_items(self) -> List[sublime.ListInputItem]: else: # Items were updated after typing items = getattr(self.command, '_items', None) if items: - # Trick to select the topmost item; also see https://github.com/sublimehq/sublime_text/issues/6162 - sublime.set_timeout(self._select_first_row) - return [sublime.ListInputItem("", "")] + items + if ST_VERSION >= 4157: + return items + else: + # Trick to select the topmost item; see https://github.com/sublimehq/sublime_text/issues/6162 + sublime.set_timeout(self._select_first_row) + return [sublime.ListInputItem("", "")] + items return [sublime.ListInputItem('No Symbol found: "{}"'.format(self.text), "")] def _select_first_row(self) -> None: From e3ab4463d10aa913d2baacd3d47fc12b7a79adb2 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 21 Oct 2023 17:02:30 +0200 Subject: [PATCH 11/25] Keep original command arguments --- plugin/core/input_handlers.py | 16 +++++++++------- plugin/symbols.py | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 167cd3747..59473a8b1 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -1,4 +1,4 @@ -from .typing import Any, Callable, List, Optional, Tuple, TypeVar, Union +from .typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union from .typing import cast from .typing import final from abc import ABCMeta @@ -82,19 +82,21 @@ class DynamicListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta `list_items`. Then you can call the `update` method with a list of `ListInputItem`s from within `on_modified`, which will be called after changes have been made to the input (with a small delay). - To create an instance of the derived class pass the command instance to the constructor, like this: + To create an instance of the derived class pass the command instance and the command arguments to the constructor, + like this: def input(self, args): - return MyDynamicListInputHandler(self) + return MyDynamicListInputHandler(self, args) For now, the type of the command must be a WindowCommand, but maybe it can be generalized later if needed. This class will set and modify `_items` and '_text' attributes of the command, so make sure that those attribute names are not used in another way in the command's class. """ - def __init__(self, command: sublime_plugin.WindowCommand) -> None: + def __init__(self, command: sublime_plugin.WindowCommand, args: Dict[str, Any]) -> None: super().__init__() self.command = command + self.args = args self.text = getattr(command, '_text', '') self.listener = None # type: Optional[sublime_plugin.TextChangeListener] self.input_view = None # type: Optional[sublime.View] @@ -108,7 +110,7 @@ def attach_listener(self) -> None: break else: raise RuntimeError('Could not find the Command Palette input field view') - self.listener = WorkspaceSymbolsQueryListener(self) + self.listener = InputListener(self) self.listener.attach(buffer) # --- Hack needed because the initial_selection method is not supported on Python 3.3 API selection = self.input_view.sel() @@ -176,12 +178,12 @@ def update(self, items: List[sublime.ListInputItem]) -> None: # TODO is there a way to run the command again without having to close the overlay first, so that the # command palette won't change its width? ['hide_overlay', {}], - [self.command.name(), {}] + [self.command.name(), self.args] ] }) -class WorkspaceSymbolsQueryListener(sublime_plugin.TextChangeListener): +class InputListener(sublime_plugin.TextChangeListener): def __init__(self, handler: DynamicListInputHandler) -> None: super().__init__() diff --git a/plugin/symbols.py b/plugin/symbols.py index 6006c53f4..af131c282 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -327,7 +327,7 @@ def run( def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandler]: # TODO maybe send an initial request with empty query string when the command is invoked? if 'symbol' not in args: - return WorkspaceSymbolsInputHandler(self) + return WorkspaceSymbolsInputHandler(self, args) return None def _on_resolved_symbol_async(self, session_name: str, response: WorkspaceSymbol) -> None: From c4105a68fb7757875e16ba50b025c870153a72e5 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 21 Oct 2023 17:09:55 +0200 Subject: [PATCH 12/25] Make initial list items optional --- plugin/core/input_handlers.py | 3 +-- plugin/symbols.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 59473a8b1..10c65561b 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -161,10 +161,9 @@ def on_modified(self, text: str) -> None: """ Called after changes have been made to the input, with the text of the input field passed as argument. """ pass - @abstractmethod def get_list_items(self) -> List[sublime.ListInputItem]: """ The list items which are initially shown. """ - raise NotImplementedError() + return [] def update(self, items: List[sublime.ListInputItem]) -> None: """ Call this method to update the list items. """ diff --git a/plugin/symbols.py b/plugin/symbols.py index af131c282..de6ec854b 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -350,9 +350,6 @@ def preview(self, text: Any) -> Union[str, sublime.Html, None]: return "⚠ Deprecated" return "" - def get_list_items(self) -> List[sublime.ListInputItem]: - return [] - def on_modified(self, text: str) -> None: if not self.input_view: return From ad4613c03fd73910e1ddda7ca222b685c2fe859c Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 21 Oct 2023 17:36:31 +0200 Subject: [PATCH 13/25] Update docstrings --- plugin/core/input_handlers.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 10c65561b..1bfdf6b4a 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -19,7 +19,7 @@ def debounced(user_function: T_Callable) -> T_Callable: - """ Yet another debounce implementation :-) """ + """ Yet another debounce implementation, to be used as a decorator for the debounced function. """ DEBOUNCE_TIME = 0.5 # seconds @functools.wraps(user_function) @@ -39,15 +39,16 @@ def call_function(): class PreselectedListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): - """ - Similar to ListInputHandler, but allows to preselect a value like some of the input overlays in Sublime Merge. - Inspired by https://github.com/sublimehq/sublime_text/issues/5507. + """ A ListInputHandler which can preselect a value. Subclasses of PreselectedListInputHandler must not implement the `list_items` method, but instead `get_list_items`, i.e. just prepend `get_` to the regular `list_items` method. - When an instance of PreselectedListInputHandler is created, it must be given the window as an argument. - An optional second argument `initial_value` can be provided to preselect a value. + To create an instance of PreselectedListInputHandler pass the window to the constructor, and optionally a second + argument `initial_value` to preselect a value. Usually you then want to use the `next_input` method to push another + InputHandler onto the input stack. + + Inspired by https://github.com/sublimehq/sublime_text/issues/5507. """ def __init__( @@ -78,9 +79,10 @@ def get_list_items(self) -> ListItemsReturn: class DynamicListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): """ A ListInputHandler which can update its items while typing in the input field. - Derive from this class and override the `get_list_items` method for the initial list items, but don't implement - `list_items`. Then you can call the `update` method with a list of `ListInputItem`s from within `on_modified`, - which will be called after changes have been made to the input (with a small delay). + Subclasses of PreselectedListInputHandler must not implement the `list_items` method, but can override + `get_list_items` for the initial list items. The `on_modified` method will be called after a small delay (debounced) + whenever changes were made to the input text. You can use this to call the `update` method with a list of + `ListInputItem`s to update the list items. To create an instance of the derived class pass the command instance and the command arguments to the constructor, like this: From 024e4bab71ce17e5ac2e7583d4ae243e5e41bdfb Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 25 Oct 2023 06:41:10 +0200 Subject: [PATCH 14/25] Remove initial selection workaround --- plugin/core/input_handlers.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 1bfdf6b4a..3deaa7e64 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -114,11 +114,6 @@ def attach_listener(self) -> None: raise RuntimeError('Could not find the Command Palette input field view') self.listener = InputListener(self) self.listener.attach(buffer) - # --- Hack needed because the initial_selection method is not supported on Python 3.3 API - selection = self.input_view.sel() - selection.clear() - selection.add(len(self.text)) - # --- End of hack @final def list_items(self) -> List[sublime.ListInputItem]: @@ -143,7 +138,8 @@ def initial_text(self) -> str: sublime.set_timeout(self.attach_listener) return self.text - # Not supported on Python 3.3 API :-( + # This requires a fix for https://github.com/sublimehq/sublime_text/issues/6175 - it can manually be fixed in + # sublime_plugin.py def initial_selection(self) -> List[Tuple[int, int]]: pt = len(self.text) return [(pt, pt)] From 74d4c09211880ee64bda0d2e4b63c334b6dd748a Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 26 Oct 2023 07:08:11 +0200 Subject: [PATCH 15/25] Improve type annotations for decorator --- plugin/core/input_handlers.py | 17 ++++++++++------- plugin/core/typing.py | 9 ++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 3deaa7e64..a8a76e67e 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -1,5 +1,4 @@ -from .typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union -from .typing import cast +from .typing import Any, Callable, Dict, List, Optional, ParamSpec, Tuple, Union from .typing import final from abc import ABCMeta from abc import abstractmethod @@ -15,15 +14,19 @@ ListItemsReturn = Union[List[str], Tuple[List[str], int], List[Tuple[str, Any]], Tuple[List[Tuple[str, Any]], int], List[sublime.ListInputItem], Tuple[List[sublime.ListInputItem], int]] -T_Callable = TypeVar('T_Callable', bound=Callable[..., Any]) +P = ParamSpec('P') -def debounced(user_function: T_Callable) -> T_Callable: - """ Yet another debounce implementation, to be used as a decorator for the debounced function. """ +def debounced(user_function: Callable[P, Any]) -> Callable[P, None]: + """ A decorator which debounces the calls to a function. + + Note that the return value of the function will be discarded, so it only makes sense to use this decorator for + functions that return None. + """ DEBOUNCE_TIME = 0.5 # seconds @functools.wraps(user_function) - def wrapped_function(*args: Any, **kwargs: Any) -> None: + def wrapped_function(*args: P.args, **kwargs: P.kwargs) -> None: def call_function(): if hasattr(wrapped_function, '_timer'): delattr(wrapped_function, '_timer') @@ -35,7 +38,7 @@ def call_function(): timer.start() setattr(wrapped_function, '_timer', timer) setattr(wrapped_function, '_timer', None) - return cast(T_Callable, wrapped_function) + return wrapped_function class PreselectedListInputHandler(sublime_plugin.ListInputHandler, metaclass=ABCMeta): diff --git a/plugin/core/typing.py b/plugin/core/typing.py index 16ed4250f..1e14a51d9 100644 --- a/plugin/core/typing.py +++ b/plugin/core/typing.py @@ -19,6 +19,7 @@ from typing import Mapping from typing import NotRequired from typing import Optional + from typing import ParamSpec from typing import Protocol from typing import Required from typing import Sequence @@ -35,7 +36,7 @@ def cast(typ, val): # type: ignore return val - def final(func): + def final(func): # type: ignore return func def _make_type(name: str) -> '_TypeMeta': @@ -139,3 +140,9 @@ class NotRequired(Type): # type: ignore def TypeVar(*args, **kwargs) -> Any: # type: ignore return object + + class ParamSpec(Type): # type: ignore + args = ... + kwargs = ... + def __init__(*args, **kwargs) -> None: # type: ignore + pass From 8093cedc8673539f24f25e38770cff4a850ee0ef Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 26 Oct 2023 07:17:34 +0200 Subject: [PATCH 16/25] Add compatibility with all ST versions --- plugin/core/input_handlers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index a8a76e67e..6281c77e7 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -117,6 +117,11 @@ def attach_listener(self) -> None: raise RuntimeError('Could not find the Command Palette input field view') self.listener = InputListener(self) self.listener.attach(buffer) + if not hasattr(sublime_plugin.CommandInputHandler, 'initial_selection'): # TODO Replace with ST_VERSION < XXXX + # Workaround for initial_selection not working; see https://github.com/sublimehq/sublime_text/issues/6175 + selection = self.input_view.sel() + selection.clear() + selection.add(len(self.text)) @final def list_items(self) -> List[sublime.ListInputItem]: @@ -141,8 +146,6 @@ def initial_text(self) -> str: sublime.set_timeout(self.attach_listener) return self.text - # This requires a fix for https://github.com/sublimehq/sublime_text/issues/6175 - it can manually be fixed in - # sublime_plugin.py def initial_selection(self) -> List[Tuple[int, int]]: pt = len(self.text) return [(pt, pt)] From c0c477377eb726687baf9d776fe0624e743c052d Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Thu, 26 Oct 2023 20:03:47 +0200 Subject: [PATCH 17/25] Lint --- plugin/core/typing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/core/typing.py b/plugin/core/typing.py index 1e14a51d9..b4cf3a3aa 100644 --- a/plugin/core/typing.py +++ b/plugin/core/typing.py @@ -144,5 +144,6 @@ def TypeVar(*args, **kwargs) -> Any: # type: ignore class ParamSpec(Type): # type: ignore args = ... kwargs = ... + def __init__(*args, **kwargs) -> None: # type: ignore pass From d3ae8c2d5defd4bd365ab4f066e99c3c8c215c31 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Sat, 28 Oct 2023 19:52:14 +0200 Subject: [PATCH 18/25] Improve type annotations --- plugin/symbols.py | 50 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/plugin/symbols.py b/plugin/symbols.py index c24c0d0a0..7beeef3b9 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -5,6 +5,7 @@ from .core.protocol import DocumentSymbolParams from .core.protocol import Location from .core.protocol import Point +from .core.protocol import Range from .core.protocol import Request from .core.protocol import SymbolInformation from .core.protocol import SymbolKind @@ -13,7 +14,8 @@ from .core.registry import LspTextCommand from .core.registry import LspWindowCommand from .core.sessions import print_to_status_bar -from .core.typing import Any, List, Optional, Tuple, Dict, Union, cast +from .core.typing import Any, Dict, List, NotRequired, Optional, Tuple, TypedDict, TypeGuard, Union +from .core.typing import cast from .core.views import offset_to_point from .core.views import range_to_region from .core.views import SYMBOL_KINDS @@ -56,6 +58,25 @@ } # type: Dict[SymbolKind, str] +DocumentSymbolValue = TypedDict('DocumentSymbolValue', { + 'deprecated': bool, + 'kind': int, + 'range': Range +}) + +WorkspaceSymbolValue = TypedDict('WorkspaceSymbolValue', { + 'deprecated': bool, + 'kind': int, + 'location': NotRequired[Location], + 'session': str, + 'workspaceSymbol': NotRequired[WorkspaceSymbol] +}) + + +def is_document_symbol_value(val: Any) -> TypeGuard[DocumentSymbolValue]: + return isinstance(val, dict) and all(key in val for key in ('deprecated', 'kind', 'range')) + + def symbol_to_list_input_item( item: Union[DocumentSymbol, WorkspaceSymbol, SymbolInformation], hierarchy: str = '', @@ -292,14 +313,12 @@ def list_items(self) -> Tuple[List[sublime.ListInputItem], int]: break return items, selected_index - def preview(self, text: Any) -> Union[str, sublime.Html, None]: - if isinstance(text, dict): - r = text.get('range') - if r: - region = range_to_region(r, self.view) - self.view.run_command('lsp_selection_set', {'regions': [(region.a, region.b)]}) - self.view.show_at_center(region.a) - if text.get('deprecated'): + def preview(self, text: Optional[DocumentSymbolValue]) -> Union[str, sublime.Html, None]: + if is_document_symbol_value(text): + region = range_to_region(text['range'], self.view) + self.view.run_command('lsp_selection_set', {'regions': [(region.a, region.b)]}) + self.view.show_at_center(region.a) + if text['deprecated']: return "⚠ Deprecated" return "" @@ -315,18 +334,17 @@ class LspWorkspaceSymbolsCommand(LspWindowCommand): def run( self, - symbol: Dict[str, Any] + symbol: WorkspaceSymbolValue ) -> None: - if not symbol: - return session_name = symbol['session'] session = self.session_by_name(session_name) if session: - if 'location' in symbol: - session.open_location_async(symbol['location'], sublime.ENCODED_POSITION) + location = symbol.get('location') + if location: + session.open_location_async(location, sublime.ENCODED_POSITION) else: session.send_request( - Request.resolveWorkspaceSymbol(symbol['workspaceSymbol']), + Request.resolveWorkspaceSymbol(symbol['workspaceSymbol']), # type: ignore functools.partial(self._on_resolved_symbol_async, session_name)) def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandler]: @@ -350,7 +368,7 @@ def name(self) -> str: def placeholder(self) -> str: return "Start typing to search" - def preview(self, text: Any) -> Union[str, sublime.Html, None]: + def preview(self, text: Optional[WorkspaceSymbolValue]) -> Union[str, sublime.Html, None]: if isinstance(text, dict) and text.get('deprecated'): return "⚠ Deprecated" return "" From 4f0e2f96eb157d93367fcc7dd197bc6c5b3429fd Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 8 Nov 2023 13:45:13 +0100 Subject: [PATCH 19/25] Update condition for initial_selection workaround --- plugin/core/input_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 6281c77e7..85aa13eaa 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -117,7 +117,7 @@ def attach_listener(self) -> None: raise RuntimeError('Could not find the Command Palette input field view') self.listener = InputListener(self) self.listener.attach(buffer) - if not hasattr(sublime_plugin.CommandInputHandler, 'initial_selection'): # TODO Replace with ST_VERSION < XXXX + if ST_VERSION < 4161: # Workaround for initial_selection not working; see https://github.com/sublimehq/sublime_text/issues/6175 selection = self.input_view.sel() selection.clear() From 1a4b8a5fa27b9fe4593f53746ec1ab95291f5dda Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Fri, 10 Nov 2023 12:45:05 +0100 Subject: [PATCH 20/25] Move ST_VERSION to constants --- plugin/core/constants.py | 6 ++++-- plugin/core/input_handlers.py | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugin/core/constants.py b/plugin/core/constants.py index daa5b908d..28207be8e 100644 --- a/plugin/core/constants.py +++ b/plugin/core/constants.py @@ -1,5 +1,7 @@ -# TODO: Move all constants which are shared by multiple modules into this file, so that they can be imported without -# causing import loops +import sublime + + +ST_VERSION = int(sublime.version()) # Keys for View.add_regions HOVER_HIGHLIGHT_KEY = 'lsp_hover_highlight' diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 85aa13eaa..39dcd946f 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -1,3 +1,4 @@ +from .constants import ST_VERSION from .typing import Any, Callable, Dict, List, Optional, ParamSpec, Tuple, Union from .typing import final from abc import ABCMeta @@ -9,8 +10,6 @@ import weakref -ST_VERSION = int(sublime.version()) - ListItemsReturn = Union[List[str], Tuple[List[str], int], List[Tuple[str, Any]], Tuple[List[Tuple[str, Any]], int], List[sublime.ListInputItem], Tuple[List[sublime.ListInputItem], int]] From 408de8551e0c62b6c18fb13ef82d21b9b167c226 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Tue, 5 Dec 2023 12:09:53 +0100 Subject: [PATCH 21/25] Fix missed conflict --- plugin/symbols.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugin/symbols.py b/plugin/symbols.py index 8007b6504..fc150c847 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -1,4 +1,3 @@ -from .core.constants import SublimeKind from .core.constants import SYMBOL_KINDS from .core.input_handlers import DynamicListInputHandler from .core.input_handlers import PreselectedListInputHandler From 94933b995043aaa0fd4fcdd5ccc3cea873f774c4 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 13 Dec 2023 15:05:28 +0100 Subject: [PATCH 22/25] Cleanup --- plugin/core/input_handlers.py | 6 +++--- plugin/symbols.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 39dcd946f..4c6709bc8 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -108,7 +108,8 @@ def __init__(self, command: sublime_plugin.WindowCommand, args: Dict[str, Any]) def attach_listener(self) -> None: for buffer in sublime._buffers(): # type: ignore view = buffer.primary_view() - # TODO what to do if there is another command palette open in the same window but in another group? + # This condition to find the input field view might not be sufficient if there is another command palette + # open in another group in the same window if view.element() == 'command_palette:input' and view.window() == self.command.window: self.input_view = view break @@ -177,8 +178,7 @@ def update(self, items: List[sublime.ListInputItem]) -> None: setattr(self.command, '_text', text) self.command.window.run_command('chain', { 'commands': [ - # TODO is there a way to run the command again without having to close the overlay first, so that the - # command palette won't change its width? + # Note that the command palette changes its width after the update, due to the hide_overlay command ['hide_overlay', {}], [self.command.name(), self.args] ] diff --git a/plugin/symbols.py b/plugin/symbols.py index fc150c847..f9e6330f7 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -348,7 +348,6 @@ def run( functools.partial(self._on_resolved_symbol_async, session_name)) def input(self, args: Dict[str, Any]) -> Optional[sublime_plugin.ListInputHandler]: - # TODO maybe send an initial request with empty query string when the command is invoked? if 'symbol' not in args: return WorkspaceSymbolsInputHandler(self, args) return None From 7b65fb29eb4813d3bc9784f59c2e187aebd453a3 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 20 Dec 2023 09:31:58 +0100 Subject: [PATCH 23/25] Single line --- plugin/symbols.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/plugin/symbols.py b/plugin/symbols.py index f9e6330f7..deec5c4c4 100644 --- a/plugin/symbols.py +++ b/plugin/symbols.py @@ -332,10 +332,7 @@ class LspWorkspaceSymbolsCommand(LspWindowCommand): capability = 'workspaceSymbolProvider' - def run( - self, - symbol: WorkspaceSymbolValue - ) -> None: + def run(self, symbol: WorkspaceSymbolValue) -> None: session_name = symbol['session'] session = self.session_by_name(session_name) if session: From dff00506075754d9319f3008c181e646d1150bd5 Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Wed, 20 Dec 2023 11:37:13 +0100 Subject: [PATCH 24/25] Ensure debounced function runs on UI thread --- plugin/core/input_handlers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index 4c6709bc8..de07c7087 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -26,10 +26,10 @@ def debounced(user_function: Callable[P, Any]) -> Callable[P, None]: @functools.wraps(user_function) def wrapped_function(*args: P.args, **kwargs: P.kwargs) -> None: - def call_function(): + def call_function() -> None: if hasattr(wrapped_function, '_timer'): delattr(wrapped_function, '_timer') - return user_function(*args, **kwargs) + sublime.set_timeout(lambda: user_function(*args, **kwargs)) timer = getattr(wrapped_function, '_timer', None) if timer is not None: timer.cancel() From 2b36bf30bcab41863b67dd3b6be1a69bd2ed4c2b Mon Sep 17 00:00:00 2001 From: Janos Wortmann Date: Fri, 22 Dec 2023 18:46:35 +0100 Subject: [PATCH 25/25] Use ST API instead of custom thread for debounced decorator --- plugin/core/input_handlers.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/plugin/core/input_handlers.py b/plugin/core/input_handlers.py index de07c7087..cfd414c4d 100644 --- a/plugin/core/input_handlers.py +++ b/plugin/core/input_handlers.py @@ -6,7 +6,7 @@ import functools import sublime import sublime_plugin -import threading +import time import weakref @@ -20,23 +20,27 @@ def debounced(user_function: Callable[P, Any]) -> Callable[P, None]: """ A decorator which debounces the calls to a function. Note that the return value of the function will be discarded, so it only makes sense to use this decorator for - functions that return None. + functions that return None. The function will run on Sublime's main thread. """ DEBOUNCE_TIME = 0.5 # seconds @functools.wraps(user_function) def wrapped_function(*args: P.args, **kwargs: P.kwargs) -> None: - def call_function() -> None: - if hasattr(wrapped_function, '_timer'): - delattr(wrapped_function, '_timer') - sublime.set_timeout(lambda: user_function(*args, **kwargs)) - timer = getattr(wrapped_function, '_timer', None) - if timer is not None: - timer.cancel() - timer = threading.Timer(DEBOUNCE_TIME, call_function) - timer.start() - setattr(wrapped_function, '_timer', timer) - setattr(wrapped_function, '_timer', None) + def check_call_function() -> None: + target_time = getattr(wrapped_function, '_target_time', None) + if isinstance(target_time, float): + additional_delay = target_time - time.monotonic() + if additional_delay > 0: + setattr(wrapped_function, '_target_time', None) + sublime.set_timeout(check_call_function, int(additional_delay * 1000)) + return + delattr(wrapped_function, '_target_time') + user_function(*args, **kwargs) + if hasattr(wrapped_function, '_target_time'): + setattr(wrapped_function, '_target_time', time.monotonic() + DEBOUNCE_TIME) + return + setattr(wrapped_function, '_target_time', None) + sublime.set_timeout(check_call_function, int(DEBOUNCE_TIME * 1000)) return wrapped_function