diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 79f800b0e4..cfcc622689 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -58,9 +58,24 @@ class TestGeneral(BaseEvenniaCommandTest): rid = self.room1.id self.call(general.CmdLook(), "here", "Room(#{})\nroom_desc".format(rid)) + def test_look_no_location(self): + self.char1.location = None + self.call(general.CmdLook(), "", "You have no location to look at!") + + def test_look_nonexisting(self): + self.call(general.CmdLook(), "yellow sign", "Could not find 'yellow sign'.") + def test_home(self): self.call(general.CmdHome(), "", "You are already home") + def test_go_home(self): + self.call(building.CmdTeleport(), "/quiet Room2") + self.call(general.CmdHome(), "", "There's no place like home") + + def test_no_home(self): + self.char1.home = None + self.call(general.CmdHome(), "", "You have no home") + def test_inventory(self): self.call(general.CmdInventory(), "", "You are not carrying anything.") @@ -90,6 +105,12 @@ class TestGeneral(BaseEvenniaCommandTest): self.assertEqual(None, self.char1.account.nicks.get("testalias", category="account")) self.assertEqual("testaliasedstring3", self.char1.nicks.get("testalias", category="object")) + def test_nick_list(self): + self.call(general.CmdNick(), "/list", "No nicks defined.") + self.call(general.CmdNick(), "test1 = Hello", + "Inputline-nick 'test1' mapped to 'Hello'.") + self.call(general.CmdNick(), "/list", "Defined Nicks:") + def test_get_and_drop(self): self.call(general.CmdGet(), "Obj", "You pick up Obj.") self.call(general.CmdDrop(), "Obj", "You drop Obj.") diff --git a/evennia/contrib/base_systems/components/README.md b/evennia/contrib/base_systems/components/README.md index fc594aa036..8f30b03630 100644 --- a/evennia/contrib/base_systems/components/README.md +++ b/evennia/contrib/base_systems/components/README.md @@ -39,7 +39,7 @@ class Health(Component): Components may define DBFields or NDBFields at the class level. DBField will store its values in the host's DB with a prefixed key. NDBField will store its values in the host's NDB and will not persist. -The key used will be 'component_name__field_name'. +The key used will be 'component_name::field_name'. They use AttributeProperty under the hood. Example: diff --git a/evennia/contrib/base_systems/components/dbfield.py b/evennia/contrib/base_systems/components/dbfield.py index 7e2d16edee..9adbf8197f 100644 --- a/evennia/contrib/base_systems/components/dbfield.py +++ b/evennia/contrib/base_systems/components/dbfield.py @@ -21,7 +21,7 @@ class DBField(AttributeProperty): owner (object): The component classF on which this is set name (str): The name that was used to set the DBField. """ - key = f"{owner.name}__{name}" + key = f"{owner.name}::{name}" self._key = key db_fields = getattr(owner, "_db_fields", None) if db_fields is None: @@ -45,7 +45,7 @@ class NDBField(NAttributeProperty): owner (object): The component class on which this is set name (str): The name that was used to set the DBField. """ - key = f"{owner.name}__{name}" + key = f"{owner.name}::{name}" self._key = key ndb_fields = getattr(owner, "_ndb_fields", None) if ndb_fields is None: diff --git a/evennia/contrib/base_systems/components/holder.py b/evennia/contrib/base_systems/components/holder.py index ddd606151d..d5112083cd 100644 --- a/evennia/contrib/base_systems/components/holder.py +++ b/evennia/contrib/base_systems/components/holder.py @@ -5,6 +5,7 @@ This file contains the classes that allow a typeclass to use components. """ from evennia.contrib.base_systems import components +from evennia.contrib.base_systems.components import signals class ComponentProperty: @@ -66,6 +67,7 @@ class ComponentHandler: self.db_names.append(component.name) self._add_component_tags(component) component.at_added(self.host) + self.host.signals.add_object_listeners_and_responders(component) def add_default(self, name): """ @@ -87,6 +89,7 @@ class ComponentHandler: self.db_names.append(name) self._add_component_tags(new_component) new_component.at_added(self.host) + self.host.signals.add_object_listeners_and_responders(new_component) def _add_component_tags(self, component): """ @@ -118,6 +121,7 @@ class ComponentHandler: self._remove_component_tags(component) component.at_removed(self.host) self.db_names.remove(component_name) + self.host.signals.remove_object_listeners_and_responders(component) del self._loaded_components[component_name] else: message = f"Cannot remove {component_name} from {self.host.name} as it is not registered." @@ -140,6 +144,7 @@ class ComponentHandler: self._remove_component_tags(instance) instance.at_removed(self.host) + self.host.signals.remove_object_listeners_and_responders(instance) self.db_names.remove(name) del self._loaded_components[name] @@ -192,6 +197,7 @@ class ComponentHandler: if component: component_instance = component.load(self.host) self._set_component(component_instance) + self.host.signals.add_object_listeners_and_responders(component_instance) else: message = f"Could not initialize runtime component {component_name} of {self.host.name}" raise ComponentDoesNotExist(message) @@ -214,7 +220,7 @@ class ComponentHandler: return self.get(name) -class ComponentHolderMixin(object): +class ComponentHolderMixin: """ Mixin to add component support to a typeclass @@ -229,7 +235,17 @@ class ComponentHolderMixin(object): """ super(ComponentHolderMixin, self).at_init() setattr(self, "_component_handler", ComponentHandler(self)) + setattr(self, "_signal_handler", signals.SignalsHandler(self)) self.components.initialize() + self.signals.trigger("at_after_init") + + def at_post_puppet(self, *args, **kwargs): + super().at_post_puppet(*args, **kwargs) + self.signals.trigger("at_post_puppet", *args, **kwargs) + + def at_post_unpuppet(self, *args, **kwargs): + super().at_post_unpuppet(*args, **kwargs) + self.signals.trigger("at_post_unpuppet", *args, **kwargs) def basetype_setup(self): """ @@ -239,14 +255,17 @@ class ComponentHolderMixin(object): super().basetype_setup() component_names = [] setattr(self, "_component_handler", ComponentHandler(self)) + setattr(self, "_signal_handler", signals.SignalsHandler(self)) class_components = getattr(self, "_class_components", ()) for component_name, values in class_components: component_class = components.get_component_class(component_name) component = component_class.create(self, **values) component_names.append(component_name) self.components._loaded_components[component_name] = component + self.signals.add_object_listeners_and_responders(component) self.db.component_names = component_names + self.signals.trigger("at_basetype_setup") def basetype_posthook_setup(self): """ @@ -274,6 +293,10 @@ class ComponentHolderMixin(object): """ return self.components + @property + def signals(self) -> signals.SignalsHandler: + return getattr(self, "_signal_handler", None) + class ComponentDoesNotExist(Exception): pass diff --git a/evennia/contrib/base_systems/components/signals.py b/evennia/contrib/base_systems/components/signals.py new file mode 100644 index 0000000000..23ace839dd --- /dev/null +++ b/evennia/contrib/base_systems/components/signals.py @@ -0,0 +1,207 @@ +""" +Components - ChrisLR 2022 + +This file contains classes functions related to signals. +""" + + +def as_listener(func=None, signal_name=None): + """ + Decorator style function that marks a method to be connected as listener. + It will use the provided signal name and default to the decorated function name. + + Args: + func (callable): The method to mark as listener + signal_name (str): The name of the signal to listen to, defaults to function name. + """ + if not func and signal_name: + def wrapper(func): + func._listener_signal_name = signal_name + return func + return wrapper + + signal_name = func.__name__ + func._listener_signal_name = signal_name + return func + + +def as_responder(func=None, signal_name=None): + """ + Decorator style function that marks a method to be connected as responder. + It will use the provided signal name and default to the decorated function name. + + Args: + func (callable): The method to mark as responder + signal_name (str): The name of the signal to respond to, defaults to function name. + """ + if not func and signal_name: + def wrapper(func): + func._responder_signal_name = signal_name + return func + return wrapper + + signal_name = func.__name__ + func._responder_signal_name = signal_name + return func + + +class SignalsHandler(object): + """ + This object handles all about signals. + It holds the connected listeners and responders. + It allows triggering signals or querying responders. + """ + + def __init__(self, host): + self.host = host + self.listeners = {} + self.responders = {} + self.add_object_listeners_and_responders(host) + + def add_listener(self, signal_name, callback): + """ + Connect a listener to a specific signal. + + Args: + signal_name (str): The name of the signal to listen to + callback (callable): The callable that is called when the signal is triggered + """ + + signal_listeners = self.listeners.setdefault(signal_name, []) + if callback not in signal_listeners: + signal_listeners.append(callback) + + def add_responder(self, signal_name, callback): + """ + Connect a responder to a specific signal. + + Args: + signal_name (str): The name of the signal to respond to + callback (callable): The callable that is called when the signal is queried + """ + + signal_responders = self.responders.setdefault(signal_name, []) + if callback not in signal_responders: + signal_responders.append(callback) + + def remove_listener(self, signal_name, callback): + """ + Removes a listener for a specific signal. + + Args: + signal_name (str): The name of the signal to disconnect from + callback (callable): The callable that was used to connect + """ + + signal_listeners = self.listeners.get(signal_name) + if not signal_listeners: + return + + if callback in signal_listeners: + signal_listeners.remove(callback) + + def remove_responder(self, signal_name, callback): + """ + Removes a responder for a specific signal. + + Args: + signal_name (str): The name of the signal to disconnect from + callback (callable): The callable that was used to connect + """ + signal_responders = self.responders.get(signal_name) + if not signal_responders: + return + + if callback in signal_responders: + signal_responders.remove(callback) + + def trigger(self, signal_name, *args, **kwargs): + """ + Triggers a specific signal with specified args and kwargs + This method does not return anything + + Args: + signal_name (str): The name of the signal to trigger + """ + + callbacks = self.listeners.get(signal_name) + if not callbacks: + return + + for callback in callbacks: + callback(*args, **kwargs) + + def query(self, signal_name, *args, default=None, aggregate_func=None, **kwargs): + """ + Queries a specific signal with specified args and kwargs + This method will return the responses from its connected responders. + If an aggregate_func is specified, it is called with the responses + and its result is returned instead. + + Args: + signal_name (str): The name of the signal to trigger + default (any): The value to use when no responses are given + It will be passed to aggregate_func if it is also given. + aggregate_func (callable): The function to process the results before returning. + + Returns: + list: An iterable of the responses + OR the aggregated result when aggregate_func is specified. + + """ + callbacks = self.responders.get(signal_name) + + if not callbacks: + default = [] if default is None else default + if aggregate_func: + return aggregate_func(default) + return default + + responses = [] + for callback in callbacks: + response = callback(*args, **kwargs) + if response is not None: + responses.append(response) + + if aggregate_func and responses: + return aggregate_func(responses) + + return responses + + def add_object_listeners_and_responders(self, obj): + """ + This connects the methods marked as listener or responder from an object. + + Args: + obj (object): The instance of an object to connect to this handler. + """ + type_host = type(obj) + for att_name, att_obj in type_host.__dict__.items(): + listener_signal_name = getattr(att_obj, '_listener_signal_name', None) + if listener_signal_name: + callback = getattr(obj, att_name) + self.add_listener(signal_name=listener_signal_name, callback=callback) + + responder_signal_name = getattr(att_obj, '_responder_signal_name', None) + if responder_signal_name: + callback = getattr(obj, att_name) + self.add_responder(signal_name=responder_signal_name, callback=callback) + + def remove_object_listeners_and_responders(self, obj): + """ + This disconnects the methods marked as listener or responder from an object. + + Args: + obj (object): The instance of an object to disconnect from this handler. + """ + type_host = type(obj) + for att_name, att_obj in type_host.__dict__.items(): + listener_signal_name = getattr(att_obj, '_listener_signal_name', None) + if listener_signal_name: + callback = getattr(obj, att_name) + self.remove_listener(signal_name=listener_signal_name, callback=callback) + + responder_signal_name = getattr(att_obj, '_responder_signal_name', None) + if responder_signal_name: + callback = getattr(obj, att_name) + self.remove_responder(signal_name=responder_signal_name, callback=callback) diff --git a/evennia/contrib/base_systems/components/tests.py b/evennia/contrib/base_systems/components/tests.py index c374971e3f..413b964c18 100644 --- a/evennia/contrib/base_systems/components/tests.py +++ b/evennia/contrib/base_systems/components/tests.py @@ -1,7 +1,9 @@ -from evennia.contrib.base_systems.components import Component, DBField, TagField +from evennia.contrib.base_systems.components import Component, DBField, TagField, signals from evennia.contrib.base_systems.components.holder import ComponentProperty, ComponentHolderMixin +from evennia.contrib.base_systems.components.signals import as_listener from evennia.objects.objects import DefaultCharacter -from evennia.utils.test_resources import EvenniaTest +from evennia.utils import create +from evennia.utils.test_resources import EvenniaTest, BaseEvenniaTest class ComponentTestA(Component): @@ -186,3 +188,211 @@ class TestComponents(EvenniaTest): assert self.char1.tags.has(key="first value", category="test_b::multiple_tags") assert self.char1.tags.has(key="second value", category="test_b::multiple_tags") assert self.char1.tags.has(key="third value", category="test_b::multiple_tags") + + +class CharWithSignal(ComponentHolderMixin, DefaultCharacter): + @signals.as_listener + def my_signal(self): + setattr(self, 'my_signal_is_called', True) + + @signals.as_listener + def my_other_signal(self): + setattr(self, 'my_other_signal_is_called', True) + + @signals.as_responder + def my_response(self): + return 1 + + @signals.as_responder + def my_other_response(self): + return 2 + + +class ComponentWithSignal(Component): + name = "test_signal_a" + + @signals.as_listener + def my_signal(self): + setattr(self, 'my_signal_is_called', True) + + @signals.as_listener + def my_other_signal(self): + setattr(self, 'my_other_signal_is_called', True) + + @signals.as_responder + def my_response(self): + return 1 + + @signals.as_responder + def my_other_response(self): + return 2 + + @signals.as_responder + def my_component_response(self): + return 3 + + +class TestComponentSignals(BaseEvenniaTest): + def setUp(self): + super().setUp() + self.char1 = create.create_object( + CharWithSignal, key="Char", + ) + + def test_host_can_register_as_listener(self): + self.char1.signals.trigger("my_signal") + + assert self.char1.my_signal_is_called + assert not getattr(self.char1, 'my_other_signal_is_called', None) + + def test_host_can_register_as_responder(self): + responses = self.char1.signals.query("my_response") + + assert 1 in responses + assert 2 not in responses + + def test_component_can_register_as_listener(self): + char = self.char1 + char.components.add(ComponentWithSignal.create(char)) + char.signals.trigger("my_signal") + + component = char.cmp.test_signal_a + assert component.my_signal_is_called + assert not getattr(component, 'my_other_signal_is_called', None) + + def test_component_can_register_as_responder(self): + char = self.char1 + char.components.add(ComponentWithSignal.create(char)) + responses = char.signals.query("my_response") + + assert 1 in responses + assert 2 not in responses + + def test_signals_can_add_listener(self): + result = [] + + def my_fake_listener(): + result.append(True) + + self.char1.signals.add_listener("my_fake_signal", my_fake_listener) + self.char1.signals.trigger("my_fake_signal") + + assert result + + def test_signals_can_add_responder(self): + def my_fake_responder(): + return 1 + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response") + + assert 1 in responses + + def test_signals_can_remove_listener(self): + result = [] + + def my_fake_listener(): + result.append(True) + + self.char1.signals.add_listener("my_fake_signal", my_fake_listener) + self.char1.signals.remove_listener("my_fake_signal", my_fake_listener) + self.char1.signals.trigger("my_fake_signal") + + assert not result + + def test_signals_can_remove_responder(self): + def my_fake_responder(): + return 1 + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + self.char1.signals.remove_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response") + + assert not responses + + def test_signals_can_trigger_with_args(self): + result = [] + + def my_fake_listener(arg1, kwarg1): + result.append((arg1, kwarg1)) + + self.char1.signals.add_listener("my_fake_signal", my_fake_listener) + self.char1.signals.trigger("my_fake_signal", 1, kwarg1=2) + + assert (1, 2) in result + + def test_signals_can_query_with_args(self): + def my_fake_responder(arg1, kwarg1): + return (arg1, kwarg1) + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response", 1, kwarg1=2) + + assert (1, 2) in responses + + def test_signals_trigger_does_not_fail_without_listener(self): + self.char1.signals.trigger("some_unknown_signal") + + def test_signals_query_does_not_fail_wihout_responders(self): + self.char1.signals.query("no_responders_allowed") + + def test_signals_query_with_aggregate(self): + def my_fake_responder(arg1, kwarg1): + return (arg1, kwarg1) + + self.char1.signals.add_responder("my_fake_response", my_fake_responder) + responses = self.char1.signals.query("my_fake_response", 1, kwarg1=2) + + assert (1, 2) in responses + + def test_signals_can_add_object_listeners_and_responders(self): + result = [] + + class FakeObj: + @as_listener + def my_signal(self): + result.append(True) + + self.char1.signals.add_object_listeners_and_responders(FakeObj()) + self.char1.signals.trigger("my_signal") + + assert result + + def test_signals_can_remove_object_listeners_and_responders(self): + result = [] + + class FakeObj: + @as_listener + def my_signal(self): + result.append(True) + + obj = FakeObj() + self.char1.signals.add_object_listeners_and_responders(obj) + self.char1.signals.remove_object_listeners_and_responders(obj) + self.char1.signals.trigger("my_signal") + + assert not result + + def test_component_handler_signals_connected_when_adding_default_component(self): + char = self.char1 + char.components.add_default("test_signal_a") + responses = char.signals.query("my_component_response") + + assert 3 in responses + + def test_component_handler_signals_disconnected_when_removing_component(self): + char = self.char1 + comp = ComponentWithSignal.create(char) + char.components.add(comp) + char.components.remove(comp) + responses = char.signals.query("my_component_response") + + assert not responses + + def test_component_handler_signals_disconnected_when_removing_component_by_name(self): + char = self.char1 + char.components.add_default("test_signal_a") + char.components.remove_by_name("test_signal_a") + responses = char.signals.query("my_component_response") + + assert not responses diff --git a/evennia/contrib/rpg/rpsystem/rpsystem.py b/evennia/contrib/rpg/rpsystem/rpsystem.py index 6a34d39baf..2c431280c0 100644 --- a/evennia/contrib/rpg/rpsystem/rpsystem.py +++ b/evennia/contrib/rpg/rpsystem/rpsystem.py @@ -319,14 +319,15 @@ def regex_tuple_from_key_alias(obj): """ global _REGEX_TUPLE_CACHE permutation_string = " ".join([obj.key] + obj.aliases.all()) + cache_key = f"{obj.id} {permutation_string}" - if permutation_string not in _REGEX_TUPLE_CACHE: - _REGEX_TUPLE_CACHE[permutation_string] = ( + if cache_key not in _REGEX_TUPLE_CACHE: + _REGEX_TUPLE_CACHE[cache_key] = ( re.compile(ordered_permutation_regex(permutation_string), _RE_FLAGS), obj, obj.key, ) - return _REGEX_TUPLE_CACHE[permutation_string] + return _REGEX_TUPLE_CACHE[cache_key] def parse_language(speaker, emote): diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 6fe443cab8..a718d9b01b 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -232,20 +232,36 @@ class TestContentHandler(BaseEvenniaTest): self.assertEqual(self.room2.contents, [self.obj1, self.obj2]) +class SubAttributeProperty(AttributeProperty): + pass + + +class SubTagProperty(TagProperty): + pass + + class TestObjectPropertiesClass(DefaultObject): attr1 = AttributeProperty(default="attr1") attr2 = AttributeProperty(default="attr2", category="attrcategory") attr3 = AttributeProperty(default="attr3", autocreate=False) + attr4 = SubAttributeProperty(default="attr4") tag1 = TagProperty() tag2 = TagProperty(category="tagcategory") + tag3 = SubTagProperty() testalias = AliasProperty() testperm = PermissionProperty() + @property + def base_property(self): + self.property_initialized = True + + class TestProperties(EvenniaTestCase): """ Test Properties. """ + def setUp(self): self.obj = create.create_object(TestObjectPropertiesClass, key="testobj") @@ -270,13 +286,22 @@ class TestProperties(EvenniaTestCase): self.assertFalse(obj.attributes.has("attr3")) self.assertEqual(obj.attr3, "attr3") - obj.attr3 = "attr3b" # stores it in db! + self.assertEqual(obj.db.attr4, "attr4") + self.assertEqual(obj.attributes.get("attr4"), "attr4") + self.assertEqual(obj.attr4, "attr4") + + obj.attr3 = "attr3b" # stores it in db! self.assertEqual(obj.db.attr3, "attr3b") self.assertTrue(obj.attributes.has("attr3")) self.assertTrue(obj.tags.has("tag1")) self.assertTrue(obj.tags.has("tag2", category="tagcategory")) + self.assertTrue(obj.tags.has("tag3")) self.assertTrue(obj.aliases.has("testalias")) self.assertTrue(obj.permissions.has("testperm")) + + # Verify that regular properties do not get fetched in init_evennia_properties, + # only Attribute or TagProperties. + self.assertFalse(hasattr(obj, "property_initialized")) diff --git a/evennia/scripts/tickerhandler.py b/evennia/scripts/tickerhandler.py index 5af87f1445..3b2c95843a 100644 --- a/evennia/scripts/tickerhandler.py +++ b/evennia/scripts/tickerhandler.py @@ -585,9 +585,9 @@ class TickerHandler(object): self.ticker_pool.stop(interval) if interval: self.ticker_storage = dict( - (store_key, store_key) - for store_key in self.ticker_storage - if store_key[1] != interval + (store_key, store_value) + for store_key, store_value in self.ticker_storage.items() + if store_key[3] != interval ) else: self.ticker_storage = {} diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 2a71835e0c..44677aff05 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -39,11 +39,12 @@ from django.utils.text import slugify from evennia.typeclasses.attributes import ( Attribute, AttributeHandler, + AttributeProperty, ModelAttributeBackend, InMemoryAttributeBackend, ) from evennia.typeclasses.attributes import DbHolder -from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler +from evennia.typeclasses.tags import Tag, TagHandler, AliasHandler, PermissionHandler, TagProperty from evennia.utils.idmapper.models import SharedMemoryModel, SharedMemoryModelBase from evennia.server.signals import SIGNAL_TYPED_OBJECT_POST_RENAME @@ -331,7 +332,7 @@ class TypedObject(SharedMemoryModel): by fetching them once. """ for propkey, prop in self.__class__.__dict__.items(): - if hasattr(prop, "__set_name__"): + if isinstance(prop, (AttributeProperty, TagProperty)): try: getattr(self, propkey) except Exception: diff --git a/evennia/utils/containers.py b/evennia/utils/containers.py index 6b709fbf75..85678ee03e 100644 --- a/evennia/utils/containers.py +++ b/evennia/utils/containers.py @@ -167,7 +167,7 @@ class GlobalScriptContainer(Container): # store a hash representation of the setup script.attributes.add("_global_script_settings", compare_hash, category="settings_hash") - script.start() + script.start() return script diff --git a/evennia/utils/tests/test_text2html.py b/evennia/utils/tests/test_text2html.py index a1c45c06f4..3b67cd426e 100644 --- a/evennia/utils/tests/test_text2html.py +++ b/evennia/utils/tests/test_text2html.py @@ -120,13 +120,6 @@ class TestText2Html(TestCase): ) # TODO: doesn't URL encode correctly - def test_re_double_space(self): - parser = text2html.HTML_PARSER - self.assertEqual("foo", parser.re_double_space("foo")) - self.assertEqual( - "a  red    foo", parser.re_double_space("a red foo") - ) - def test_sub_mxp_links(self): parser = text2html.HTML_PARSER mocked_match = mock.Mock() @@ -156,7 +149,7 @@ class TestText2Html(TestCase): "tab": "\t", "space": "", } - self.assertEqual("  ", parser.sub_text(mocked_match)) + self.assertEqual(" ", parser.sub_text(mocked_match)) mocked_match.groupdict.return_value = { "htmlchars": "", @@ -165,7 +158,7 @@ class TestText2Html(TestCase): "space": " ", "spacestart": " ", } - self.assertEqual("    ", parser.sub_text(mocked_match)) + self.assertEqual(" ", parser.sub_text(mocked_match)) mocked_match.groupdict.return_value = { "htmlchars": "", @@ -181,24 +174,13 @@ class TestText2Html(TestCase): parser = text2html.HTML_PARSER parser.tabstop = 4 # single tab - self.assertEqual(parser.parse("foo|>foo"), "foo    foo") + self.assertEqual(parser.parse("foo|>foo"), "foo foo") # space and tab - self.assertEqual(parser.parse("foo |>foo"), "foo     foo") + self.assertEqual(parser.parse("foo |>foo"), "foo foo") # space, tab, space - self.assertEqual(parser.parse("foo |> foo"), "foo      foo") - - def test_parse_space_to_html(self): - """test space parsing - a single space should be kept, two or more - should get  """ - parser = text2html.HTML_PARSER - # single space - self.assertEqual(parser.parse("foo foo"), "foo foo") - # double space - self.assertEqual(parser.parse("foo foo"), "foo  foo") - # triple space - self.assertEqual(parser.parse("foo foo"), "foo   foo") + self.assertEqual(parser.parse("foo |> foo"), "foo foo") def test_parse_html(self): self.assertEqual("foo", text2html.parse_html("foo")) diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index 91a627f33b..be8f459c87 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -79,11 +79,11 @@ class TextToHTMLparser(object): # create stop markers fgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m|\033\[0m|$" bgstop = "(?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m|\033\[0m|$" - bgfgstop = bgstop[:-2] + r"(\s*)" + fgstop + bgfgstop = bgstop[:-2] + fgstop fgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[3[0-8].*?m)" bgstart = "((?:\033\[1m|\033\[22m){0,1}\033\[4[0-8].*?m)" - bgfgstart = bgstart + r"(\s*)" + "((?:\033\[1m|\033\[22m){0,1}\033\[[3-4][0-8].*?m){0,1}" + bgfgstart = bgstart + r"((?:\033\[1m|\033\[22m){0,1}\033\[[3-4][0-8].*?m){0,1}" # extract color markers, tagging the start marker and the text marked re_fgs = re.compile(fgstart + "(.*?)(?=" + fgstop + ")") @@ -97,12 +97,9 @@ class TextToHTMLparser(object): re_blink = re.compile("(?:%s)(.*?)(?=%s|%s)" % (blink.replace("[", r"\["), fgstop, bgstop)) re_inverse = re.compile("(?:%s)(.*?)(?=%s|%s)" % (inverse.replace("[", r"\["), fgstop, bgstop)) re_string = re.compile( - r"(?P[<&>])|(?P[\t]+)|(?P +)|" - r"(?P^ )|(?P\r\n|\r|\n)", + r"(?P[<&>])|(?P[\t]+)|(?P\r\n|\r|\n)", re.S | re.M | re.I, ) - re_dblspace = re.compile(r" {2,}", re.M) - re_invisiblespace = re.compile(r"( <.*?>)( )") re_url = re.compile( r'(?\[\]\s])+)(\.(?:\s|$)|&\w+;|)' ) @@ -111,20 +108,16 @@ class TextToHTMLparser(object): def _sub_bgfg(self, colormatch): # print("colormatch.groups()", colormatch.groups()) - bgcode, prespace, fgcode, text, postspace = colormatch.groups() + bgcode, fgcode, text = colormatch.groups() if not fgcode: - ret = r"""%s%s%s""" % ( + ret = r"""%s""" % ( self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), - prespace and " " * len(prespace) or "", - postspace and " " * len(postspace) or "", text, ) else: - ret = r"""%s%s%s""" % ( + ret = r"""%s""" % ( self.bg_colormap.get(bgcode, self.fg_colormap.get(bgcode, "err")), self.fg_colormap.get(fgcode, self.bg_colormap.get(fgcode, "err")), - prespace and " " * len(prespace) or "", - postspace and " " * len(postspace) or "", text, ) return ret @@ -265,20 +258,6 @@ class TextToHTMLparser(object): # change pages (and losing our webclient session). return self.re_url.sub(r'\1\2', text) - def re_double_space(self, text): - """ - HTML will swallow any normal space after the first, so if any slipped - through we must make sure to replace them with "  " - """ - return self.re_dblspace.sub(self.sub_dblspace, text) - - def re_invisible_space(self, text): - """ - If two spaces are separated by an invisble html element, they act as a - hidden double-space and the last of them should be replaced by   - """ - return self.re_invisiblespace.sub(self.sub_invisiblespace, text) - def sub_mxp_links(self, match): """ Helper method to be passed to re.sub, @@ -332,28 +311,10 @@ class TextToHTMLparser(object): elif cdict["lineend"]: return "
" elif cdict["tab"]: - text = cdict["tab"].replace("\t", " " + " " * (self.tabstop - 1)) - return text - elif cdict["space"] or cdict["spacestart"]: - text = cdict["space"] - text = " " if len(text) == 1 else " " + text[1:].replace(" ", " ") + text = cdict["tab"].replace("\t", " " * (self.tabstop)) return text return None - def sub_dblspace(self, match): - "clean up double-spaces" - return " " + " " * (len(match.group()) - 1) - - def sub_invisiblespace(self, match): - "clean up invisible spaces" - return match.group(1) + " " - - def handle_single_first_space(self, text): - "Don't swallow an initial lone space" - if text.startswith(" "): - return " " + text[1:] - return text - def parse(self, text, strip_ansi=False): """ Main access function, converts a text containing ANSI codes @@ -383,9 +344,6 @@ class TextToHTMLparser(object): result = self.convert_linebreaks(result) result = self.remove_backspaces(result) result = self.convert_urls(result) - result = self.re_double_space(result) - result = self.re_invisible_space(result) - result = self.handle_single_first_space(result) # clean out eventual ansi that was missed ## result = parse_ansi(result, strip_ansi=True) diff --git a/evennia/web/static/webclient/css/webclient.css b/evennia/web/static/webclient/css/webclient.css index bc94b84ae8..55135acc60 100644 --- a/evennia/web/static/webclient/css/webclient.css +++ b/evennia/web/static/webclient/css/webclient.css @@ -49,6 +49,7 @@ div {margin:0px;} .out { color: #aaa; background-color: #000; + white-space: pre-wrap; } /* Error messages (red) */