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
160 changes: 160 additions & 0 deletions st3/sublime_lib/settings_listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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 callable, then the derived value is ``selector(self)``.
If `selector` is a :class:`str`,
then the derived value is ``self.get(selector, None)``.
Otherwise, the derived value is ``projection(self, selector)``.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the "selector" concept being reused now, it might be sensible to add a section to our docs with more details and examples.


The handler should accept two arguments:
the new derived value and the previous derived value.
"""
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()