Skip to content
3 changes: 3 additions & 0 deletions st3/sublime_lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@
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
)
88 changes: 88 additions & 0 deletions st3/sublime_lib/settings_listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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


__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):
SETTINGS_NAME = ''

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(sublime.load_settings(self.SETTINGS_NAME), *args, **kwargs)

def _on_settings_changed(self) -> None:
if self in sublime_plugin.all_callbacks['on_new']:
super()._on_settings_changed()
else:
self.__del__()

def on_new(self, view: sublime.View) -> None:
pass


class ViewSettingsListener(BaseSettingsListener, sublime_plugin.ViewEventListener):
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]:
def decorator(function: OnChangeListener) -> OnChangeListener:
setattr(function, OPTIONS_ATTRIBUTE, OnChangedOptions(get_selector(selector)))
return function

return decorator
3 changes: 3 additions & 0 deletions stubs/sublime_plugin.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import sublime
from typing import Optional, Union, List, Dict, Tuple


all_callbacks: Dict[str, List['EventListener']]


class CommandInputHandler():
def name(self) -> str: ...
def next_input(self, args: dict) -> Optional[CommandInputHandler]: ...
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]])
74 changes: 74 additions & 0 deletions tests/test_settings_listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import sublime
from sublime_lib import ResourcePath, new_view, close_view
from .temporary_package import TemporaryPackage

from unittesting import DeferrableTestCase


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):
# pass
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'],
])