diff --git a/docs/source/index.rst b/docs/source/index.rst index 3fb376c..d477d01 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -58,6 +58,13 @@ Region manager .. autoclass:: sublime_lib.RegionManager +Settings listeners +------------------ + +.. autoclass:: sublime_lib.GlobalSettingsListener +.. autoclass:: sublime_lib.ViewSettingsListener +.. autofunction:: sublime_lib.on_setting_changed + :mod:`~sublime_lib.encodings` submodule --------------------------------------- diff --git a/docs/source/mocks/sublime_plugin.py b/docs/source/mocks/sublime_plugin.py new file mode 100644 index 0000000..6e2a731 --- /dev/null +++ b/docs/source/mocks/sublime_plugin.py @@ -0,0 +1,19 @@ +from sphinx.ext.autodoc.mock import _MockObject + +import sys + + +class MockType(_MockObject): + def __init__(self, name): + self.name = name + + def __repr__(self): + return self.name + + +class SublimePluginMock: + EventListener = MockType('sublime_plugin.EventListener') + ViewEventListener = MockType('sublime_plugin.ViewEventListener') + + +sys.modules[__name__] = SublimePluginMock() diff --git a/st3/sublime_lib/__init__.py b/st3/sublime_lib/__init__.py index 0ef227d..42a7dc7 100644 --- a/st3/sublime_lib/__init__.py +++ b/st3/sublime_lib/__init__.py @@ -7,4 +7,7 @@ from .view_utils import new_view, close_view, LineEnding # noqa: F401 from .activity_indicator import ActivityIndicator # noqa: F401 from .window_utils import new_window, close_window # noqa: F401 +from .settings_listener import ( # noqa: F401 + GlobalSettingsListener, ViewSettingsListener, on_setting_changed +) from .region_manager import RegionManager # noqa: F401 diff --git a/st3/sublime_lib/settings_listener.py b/st3/sublime_lib/settings_listener.py new file mode 100644 index 0000000..29e6119 --- /dev/null +++ b/st3/sublime_lib/settings_listener.py @@ -0,0 +1,176 @@ +import sublime +import sublime_plugin + +from collections import namedtuple + +from ._util.weak_method import weak_method +from ._util.collections import get_selector + + +from ._compat.typing import Callable, Any, Iterator, Tuple, TypeVar, Union, Optional + + +__all__ = ['GlobalSettingsListener', 'ViewSettingsListener', 'on_setting_changed'] + + +OnChangedOptions = namedtuple('OnChangedOptions', ('selector')) +OPTIONS_ATTRIBUTE = '_sublime_lib_settings_listener_options' + + +class BaseSettingsListener: + def _handlers(self) -> Iterator[Tuple[str, Callable, OnChangedOptions]]: + for name in dir(self): + value = getattr(self, name) + options = getattr(value, OPTIONS_ATTRIBUTE, None) + + if not name.startswith('_') and options is not None: + yield name, value, options + + def __init__(self, settings: sublime.Settings, *args: Any, **kwargs: Any) -> None: + # Don't complain that object.__init__ doesn't take any args + super().__init__(*args, **kwargs) # type: ignore + self.settings = settings + + self.settings.add_on_change(str(id(self)), weak_method(self._on_settings_changed)) + + self._last_known_values = { + name: options.selector(self.settings) + for name, _, options in self._handlers() + } + + def __del__(self) -> None: + self.settings.clear_on_change(str(id(self))) + + def _on_settings_changed(self) -> None: + for name, handler, options in self._handlers(): + previous_value = self._last_known_values[name] + current_value = options.selector(self.settings) + + if current_value != previous_value: + self._last_known_values[name] = current_value + handler(current_value, previous_value) + + +class GlobalSettingsListener(BaseSettingsListener, sublime_plugin.EventListener): + """ + A subclass of :class:`sublime_plugin.EventListener` + that also listens for changes in a global settings object. + + Subclasses must set the `SETTINGS_NAME` class variable to the name of the global settings. + Otherwise, :exc:`RuntimeError` is raised. + + Example usage: + + .. code-block:: python + + from sublime_lib import GlobalSettingsListener, on_setting_changed + + class JsCustomConfigurationsListener(GlobalSettingsListener) + SETTINGS_NAME = 'JS Custom.sublime-settings' + + @on_setting_changed('configurations') + def configurations_changed(self, new_configuration, old_configuration): + if self.settings.get('auto_build', False): + sublime.active_window().run_command('build_js_custom_syntaxes') + """ + SETTINGS_NAME = None # type: Optional[str] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + if type(self) is GlobalSettingsListener: + # If someone accidentally exports GlobalSettingsListener itself, + # don't attach a listener. + return + elif self.SETTINGS_NAME is None: + listener_name = type(self).__name__ + raise RuntimeError('Must specify SETTINGS_NAME for listener {}.'.format(listener_name)) + + super().__init__(sublime.load_settings(self.SETTINGS_NAME), *args, **kwargs) + + def _on_settings_changed(self) -> None: + # Necessary because Sublime keeps EventListener objects alive unnecessarily. + # See https://github.com/sublimehq/sublime_text/issues/4078. + if self in sublime_plugin.all_callbacks['on_new']: + super()._on_settings_changed() + else: + self.settings.clear_on_change(str(id(self))) + + def on_new(self, view: sublime.View) -> None: + pass + + +class ViewSettingsListener(BaseSettingsListener, sublime_plugin.ViewEventListener): + """ + A subclass of :class:`sublime_plugin.ViewEventListener` + that also listens for changes in the view settings. + + Example: + + .. code-block:: python + + from sublime_lib import ViewSettingsListener, on_setting_changed + + class TabSizeListener(ViewSettingsListener): + @classmethod + def is_applicable(cls, settings): + return settings.get('translate_tabs_to_spaces', False) + + @classmethod + def applies_to_primary_view_only(cls): + return True + + @on_setting_changed('tab_size') + def tab_size_changed(self, new_value, old_value): + self.view.run_command('resize_existing_tabs', { + 'new_size': new_value, + 'old_size': old_value, + }) + """ + def __init__(self, *args: Any, **kwargs: Any) -> None: + view = args[0] + assert isinstance(view, sublime.View) + super().__init__(view.settings(), *args, **kwargs) + + +Selected = TypeVar('Selected') +OnChangeListener = Callable[[BaseSettingsListener, Selected, Selected], None] + + +def on_setting_changed( + selector: Union[str, Callable[[sublime.Settings], Selected]] +) -> Callable[[OnChangeListener], OnChangeListener]: + """ + A decorator function to mark a method + of a :class:`GlobalSettingsListener` or :class:`ViewSettingsListener` + as a setting change handler. + + The handler is not called every time the `settings` object changes, + but only when the value derived using the `selector` argument changes. + If `selector` is a :class:`str`, + then the derived value is ``settings.get(selector, None)``. + If `selector` is callable, then the derived value is ``selector(settings)``. + Otherwise, the derived value is ``projection(settings, selector)`` (as defined below). + + The handler should accept two arguments: + the new derived value and the previous derived value. + + ``projection(settings, keys)`` + Return a new :class:`dict` with keys of ``settings`` restricted to values in ``keys``. + + .. code-block:: python + + >>> projection({'a': 1, 'b': 2}, ['b']) + {'b': 2} + + If ``keys`` is a :class:`dict`, then it maps keys of the original dict to + keys of the result: + + .. code-block:: python + + >>> projection({'a': 1, 'b': 2}, {'b': 'c'}) + {'c': 2} + """ + def decorator(function: OnChangeListener) -> OnChangeListener: + setattr(function, OPTIONS_ATTRIBUTE, OnChangedOptions(get_selector(selector))) + return function + + return decorator diff --git a/stubs/sublime_plugin.pyi b/stubs/sublime_plugin.pyi index 5ad9e28..9310d52 100644 --- a/stubs/sublime_plugin.pyi +++ b/stubs/sublime_plugin.pyi @@ -1,6 +1,9 @@ import sublime -from typing import Callable, Generic, TypeVar, Optional, Union, List, Tuple, overload +from typing import Callable, Generic, TypeVar, Optional, Union, List, Tuple, Dict, overload + + +all_callbacks: Dict[str, List['EventListener']] InputType = TypeVar('InputType', bound=Union[str, int, float, list, dict, tuple, None]) diff --git a/tests/settings_listener_package/settings_listener_plugin.py b/tests/settings_listener_package/settings_listener_plugin.py new file mode 100644 index 0000000..d9aab4f --- /dev/null +++ b/tests/settings_listener_package/settings_listener_plugin.py @@ -0,0 +1,24 @@ +import sublime_lib +from sublime_lib import on_setting_changed, ResourcePath + + +__all__ = ['FooSettingListener', 'GlobalFooSettingListener'] + + +PACKAGE_NAME = ResourcePath.from_file_path(__file__).package + + +class FooSettingListener(sublime_lib.ViewSettingsListener): + @on_setting_changed('foo') + def foo_changed(self, new_value: object, old_value: object): + changes = self.settings.get('changes', []) + self.settings.set('changes', changes + [[new_value, old_value]]) + + +class GlobalFooSettingListener(sublime_lib.GlobalSettingsListener): + SETTINGS_NAME = PACKAGE_NAME + '.sublime-settings' + + @on_setting_changed('foo') + def foo_changed(self, new_value: object, old_value: object): + changes = self.settings.get('changes', []) + self.settings.set('changes', changes + [[new_value, old_value]]) diff --git a/tests/test_settings_listener.py b/tests/test_settings_listener.py new file mode 100644 index 0000000..9444198 --- /dev/null +++ b/tests/test_settings_listener.py @@ -0,0 +1,88 @@ +import sublime +from sublime_lib import ResourcePath, new_view, close_view +from .temporary_package import TemporaryPackage + +from unittest import TestCase +from unittesting import DeferrableTestCase + +from sublime_lib import GlobalSettingsListener + + +class TestViewSettingsListener(DeferrableTestCase): + def setUp(self): + self.view = new_view(sublime.active_window()) + self.temporary_package = TemporaryPackage( + 'sublime_lib_settings_listener_test', + ResourcePath("Packages/sublime_lib/tests/settings_listener_package") + ) + self.temporary_package.create() + yield self.temporary_package.exists + + def tearDown(self): + self.temporary_package.destroy() + if getattr(self, 'view', None): + try: + close_view(self.view, force=True) + except ValueError: + pass + + def test_view_listener(self): + self.view.settings().set('foo', 'A') + self.view.settings().set('foo', 'B') + + self.temporary_package.destroy() + yield lambda: not self.temporary_package.exists() + + self.view.settings().set('foo', 'C') + + self.assertEqual(self.view.settings().get('changes'), [ + ['A', None], + ['B', 'A'], + ]) + + +class TestGlobalSettingsListener(DeferrableTestCase): + def setUp(self): + name = 'TestGlobalSettingsListener:{}'.format(str(id(self))) + self.settings = sublime.load_settings(name + '.sublime-settings') + self.settings.set('changes', []) + self.settings.set('foo', None) + self.temporary_package = TemporaryPackage( + name, + ResourcePath("Packages/sublime_lib/tests/settings_listener_package") + ) + self.temporary_package.create() + yield self.temporary_package.exists + + def tearDown(self): + self.temporary_package.destroy() + + def test_global_listener(self): + self.settings.set('foo', 'A') + self.settings.set('foo', 'B') + + self.temporary_package.destroy() + yield lambda: not self.temporary_package.exists() + yield 100 + import gc + gc.collect() + yield 100 + + self.settings.set('foo', 'C') + + self.assertEqual(self.settings.get('changes'), [ + ['A', None], + ['B', 'A'], + ]) + + +class TestGlobalSettingsListenerErrors(TestCase): + def test_instantiate_base(self): + GlobalSettingsListener() + + def test_instantiate_without_name(self): + class TestListener(GlobalSettingsListener): + pass + + with self.assertRaises(RuntimeError): + TestListener()