Skip to content
Open
7 changes: 7 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------------------

Expand Down
19 changes: 19 additions & 0 deletions docs/source/mocks/sublime_plugin.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions st3/sublime_lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
176 changes: 176 additions & 0 deletions st3/sublime_lib/settings_listener.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion stubs/sublime_plugin.pyi
Original file line number Diff line number Diff line change
@@ -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])
Expand Down
24 changes: 24 additions & 0 deletions tests/settings_listener_package/settings_listener_plugin.py
Original file line number Diff line number Diff line change
@@ -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]])
88 changes: 88 additions & 0 deletions tests/test_settings_listener.py
Original file line number Diff line number Diff line change
@@ -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()