diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 9442625029..ea84a68bcc 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -421,17 +421,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): kwargs["options"] = options - if not (isinstance(text, basestring) or isinstance(text, tuple)): - # sanitize text before sending across the wire - try: - text = to_str(text, force_string=True) - except Exception: - text = repr(text) + if text is not None: + if not (isinstance(text, basestring) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text, force_string=True) + except Exception: + text = repr(text) + kwargs['text'] = text # session relay sessions = make_iter(session) if session else self.sessions.all() for session in sessions: - session.data_out(text=text, **kwargs) + session.data_out(**kwargs) def execute_cmd(self, raw_string, session=None, **kwargs): """ diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index c593a6376d..8616b7dafa 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2324,11 +2324,12 @@ class CmdFind(COMMAND_DEFAULT_CLASS): @locate - this is a shorthand for using the /loc switch. Switches: - room - only look for rooms (location=None) - exit - only look for exits (destination!=None) - char - only look for characters (BASE_CHARACTER_TYPECLASS) - exact- only exact matches are returned. - loc - display object location if exists and match has one result + room - only look for rooms (location=None) + exit - only look for exits (destination!=None) + char - only look for characters (BASE_CHARACTER_TYPECLASS) + exact - only exact matches are returned. + loc - display object location if exists and match has one result + startswith - search for names starting with the string, rather than containing Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2339,7 +2340,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" - switch_options = ("room", "exit", "char", "exact", "loc") + switch_options = ("room", "exit", "char", "exact", "loc", "startswith") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" @@ -2413,10 +2414,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS): keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__iexact=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) - else: + elif "startswith" in switches: keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__istartswith=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) + else: + keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) + aliasquery = Q(db_tags__db_key__icontains=searchstring, + db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() nresults = results.count() diff --git a/evennia/commands/default/cmdset_unloggedin.py b/evennia/commands/default/cmdset_unloggedin.py index 5e43d5e8c8..1c45d908aa 100644 --- a/evennia/commands/default/cmdset_unloggedin.py +++ b/evennia/commands/default/cmdset_unloggedin.py @@ -23,3 +23,4 @@ class UnloggedinCmdSet(CmdSet): self.add(unloggedin.CmdUnconnectedHelp()) self.add(unloggedin.CmdUnconnectedEncoding()) self.add(unloggedin.CmdUnconnectedScreenreader()) + self.add(unloggedin.CmdUnconnectedInfo()) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index eafb7670b0..d72a2006b9 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -71,7 +71,7 @@ class CmdLook(COMMAND_DEFAULT_CLASS): target = caller.search(self.args) if not target: return - self.msg(caller.at_look(target)) + self.msg((caller.at_look(target), {'type':'look'}), options=None) class CmdNick(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index fe738a3e07..950b934125 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -14,16 +14,17 @@ main test suite started with import re import types +import datetime from django.conf import settings from mock import Mock, mock from evennia.commands.default.cmdset_character import CharacterCmdSet from evennia.utils.test_resources import EvenniaTest -from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms +from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.command import Command, InterruptCommand -from evennia.utils import ansi, utils +from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter @@ -76,7 +77,8 @@ class CommandTest(EvenniaTest): old_msg = receiver.msg try: receiver.msg = Mock() - cmdobj.at_pre_cmd() + if cmdobj.at_pre_cmd(): + return cmdobj.parse() ret = cmdobj.func() if isinstance(ret, types.GeneratorType): @@ -328,7 +330,7 @@ class TestBuilding(CommandTest): self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.") def test_find(self): - self.call(building.CmdFind(), "Room2", "One Match") + self.call(building.CmdFind(), "oom2", "One Match") expect = "One Match(#1#7, loc):\n " +\ "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" self.call(building.CmdFind(), "Char2", expect, cmdstring="locate") @@ -338,6 +340,7 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate") self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") + self.call(building.CmdFind(), "/startswith Room2", "One Match") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") @@ -500,3 +503,12 @@ class TestInterruptCommand(CommandTest): def test_interrupt_command(self): ret = self.call(CmdInterrupt(), "") self.assertEqual(ret, "") + + +class TestUnconnectedCommand(CommandTest): + def test_info_command(self): + expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( + settings.SERVERNAME, + datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), + SESSIONS.account_count(), utils.get_evennia_version().replace("-", "")) + self.call(unloggedin.CmdUnconnectedInfo(), "", expected) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 67e15dfd38..bc7e69934f 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -3,6 +3,7 @@ Commands that are available from the connect screen. """ import re import time +import datetime from collections import defaultdict from random import getrandbits from django.conf import settings @@ -11,8 +12,9 @@ from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.server.models import ServerConfig from evennia.comms.models import ChannelDB +from evennia.server.sessionhandler import SESSIONS -from evennia.utils import create, logger, utils +from evennia.utils import create, logger, utils, gametime from evennia.commands.cmdhandler import CMD_LOGINSTART COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -516,6 +518,24 @@ class CmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS): self.session.sessionhandler.session_portal_sync(self.session) +class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS): + """ + Provides MUDINFO output, so that Evennia games can be added to Mudconnector + and Mudstats. Sadly, the MUDINFO specification seems to have dropped off the + face of the net, but it is still used by some crawlers. This implementation + was created by looking at the MUDINFO implementation in MUX2, TinyMUSH, Rhost, + and PennMUSH. + """ + key = "info" + locks = "cmd:all()" + + def func(self): + self.caller.msg("## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( + settings.SERVERNAME, + datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), + SESSIONS.account_count(), utils.get_evennia_version())) + + def _create_account(session, accountname, password, permissions, typeclass=None, email=None): """ Helper function, creates an account of the specified typeclass. diff --git a/evennia/contrib/mail.py b/evennia/contrib/mail.py index 6e8585136d..dbecb62fcd 100644 --- a/evennia/contrib/mail.py +++ b/evennia/contrib/mail.py @@ -253,7 +253,7 @@ class CmdMail(default_cmds.MuxCommand): index += 1 table.reformat_column(0, width=6) - table.reformat_column(1, width=17) + table.reformat_column(1, width=18) table.reformat_column(2, width=34) table.reformat_column(3, width=13) table.reformat_column(4, width=7) diff --git a/evennia/contrib/rpsystem.py b/evennia/contrib/rpsystem.py index a56d2de731..efba0fe7fd 100644 --- a/evennia/contrib/rpsystem.py +++ b/evennia/contrib/rpsystem.py @@ -1088,7 +1088,7 @@ class CmdMask(RPCommand): if self.cmdstring == "mask": # wear a mask if not self.args: - caller.msg("Usage: (un)wearmask sdesc") + caller.msg("Usage: (un)mask sdesc") return if caller.db.unmasked_sdesc: caller.msg("You are already wearing a mask.") @@ -1111,7 +1111,7 @@ class CmdMask(RPCommand): del caller.db.unmasked_sdesc caller.locks.remove("enable_recog") caller.sdesc.add(old_sdesc) - caller.msg("You remove your mask and is again '%s'." % old_sdesc) + caller.msg("You remove your mask and are again '%s'." % old_sdesc) class RPSystemCmdSet(CmdSet): diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 30bf71dcc8..60ac50b64d 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -670,7 +670,7 @@ class TestGenderSub(CommandTest): char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) txt = "Test |p gender" self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender") - + # test health bar contrib from evennia.contrib import health_bar @@ -697,7 +697,7 @@ class TestMail(CommandTest): "You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2) - self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account) + self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account) self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account) self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account) self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account) @@ -798,7 +798,7 @@ from evennia.contrib import talking_npc class TestTalkingNPC(CommandTest): def test_talkingnpc(self): npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1) - self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)|") + self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)") npc.delete() @@ -966,7 +966,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") - + # Test equipment commands def test_turnbattleequipcmd(self): # Start with equip module specific commands. @@ -984,7 +984,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") - + # Test range commands def test_turnbattlerangecmd(self): # Start with range module specific commands. @@ -998,7 +998,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") - + class TestTurnBattleFunc(EvenniaTest): @@ -1080,7 +1080,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") @@ -1159,7 +1159,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test combat functions in tb_range too. def test_tbrangefunc(self): testroom = create_object(DefaultRoom, key="Test Room") @@ -1264,7 +1264,7 @@ Bar -Qux""" class TestTreeSelectFunc(EvenniaTest): - + def test_tree_functions(self): # Dash counter self.assertTrue(tree_select.dashcount("--test") == 2) @@ -1279,7 +1279,7 @@ class TestTreeSelectFunc(EvenniaTest): # Option list to menu options test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'}, - {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, + {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, {'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}] self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 20eb117e42..c65b30c131 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -607,6 +607,46 @@ class LockHandler(object): accessing_obj, locks, access_type) for access_type in locks) +# convenience access function + +# dummy to be able to call check_lockstring from the outside + +class _ObjDummy: + lock_storage = '' + +_LOCK_HANDLER = LockHandler(_ObjDummy()) + + +def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, + default=False, access_type=None): + """ + Do a direct check against a lockstring ('atype:func()..'), + without any intermediary storage on the accessed object. + + Args: + accessing_obj (object or None): The object seeking access. + Importantly, this can be left unset if the lock functions + don't access it, no updating or storage of locks are made + against this object in this method. + lockstring (str): Lock string to check, on the form + `"access_type:lock_definition"` where the `access_type` + part can potentially be set to a dummy value to just check + a lock condition. + no_superuser_bypass (bool, optional): Force superusers to heed lock. + default (bool, optional): Fallback result to use if `access_type` is set + but no such `access_type` is found in the given `lockstring`. + access_type (str, bool): If set, only this access_type will be looked up + among the locks defined by `lockstring`. + + Return: + access (bool): If check is passed or not. + + """ + return _LOCK_HANDLER.check_lockstring( + accessing_obj, lockstring, no_superuser_bypass=no_superuser_bypass, + default=default, access_type=access_type) + + def _test(): # testing diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 1c9b2dcafd..8ec1433dcd 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -569,17 +569,19 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): except Exception: logger.log_trace() - if not (isinstance(text, basestring) or isinstance(text, tuple)): - # sanitize text before sending across the wire - try: - text = to_str(text, force_string=True) - except Exception: - text = repr(text) + if text is not None: + if not (isinstance(text, basestring) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text, force_string=True) + except Exception: + text = repr(text) + kwargs['text'] = text # relay to session(s) sessions = make_iter(session) if session else self.sessions.all() for session in sessions: - session.data_out(text=text, **kwargs) + session.data_out(**kwargs) def for_contents(self, func, exclude=None, **kwargs): @@ -1873,7 +1875,7 @@ class DefaultCharacter(DefaultObject): """ self.msg("\nYou become |c%s|n.\n" % self.name) - self.msg(self.at_look(self.location)) + self.msg((self.at_look(self.location), {'type':'look'}), options = None) def message(obj, from_obj): obj.msg("%s has entered the game." % self.get_display_name(obj), from_obj=from_obj) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a2a92f5e34..0de33de348 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -976,7 +976,11 @@ class EvMenu(object): node (str): The formatted node to display. """ - screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0] + if self._session: + screen_width = self._session.protocol_flags.get( + "SCREENWIDTH", {0: _MAX_TEXT_WIDTH})[0] + else: + screen_width = _MAX_TEXT_WIDTH nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n")) @@ -1001,10 +1005,12 @@ def list_node(option_generator, select=None, pagesize=10): Args: option_generator (callable or list): A list of strings indicating the options, or a callable that is called as option_generator(caller) to produce such a list. - select (callable, option): Will be called as select(caller, menuchoice) - where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection (or None to repeat the list-node). Note that if this is not - given, the decorated node must itself provide a way to continue from the node! + select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will + contain the `available_choices` list and `selection` will hold one of the elements in + that list. If a callable, it will be called as select(caller, menuchoice) where + menuchoice is the chosen option as a string. Should return the target node to goto after + this selection (or None to repeat the list-node). Note that if this is not given, the + decorated node must itself provide a way to continue from the node! pagesize (int): How many options to show per page. Example: @@ -1034,11 +1040,17 @@ def list_node(option_generator, select=None, pagesize=10): except Exception: caller.msg("|rInvalid choice.|n") else: - if select: + if callable(select): try: return select(caller, selection) except Exception: logger.log_trace() + elif select: + # we assume a string was given, we inject the result into the kwargs + # to pass on to the next node + kwargs['selection'] = selection + return str(select) + # this means the previous node will be re-run with these same kwargs return None def _list_node(caller, raw_string, **kwargs): diff --git a/evennia/utils/evtable.py b/evennia/utils/evtable.py index 31218189ab..ffb29873c4 100644 --- a/evennia/utils/evtable.py +++ b/evennia/utils/evtable.py @@ -893,6 +893,9 @@ class EvColumn(object): """ col = self.column + # fixed options for the column will override those requested in the call! + # this is particularly relevant to things like width/height, to avoid + # fixed-widths columns from being auto-balanced kwargs.update(self.options) # use fixed width or adjust to the largest cell if "width" not in kwargs: @@ -1283,25 +1286,59 @@ class EvTable(object): cwidths_min = [max(cell.get_min_width() for cell in col) for col in self.worktable] cwmin = sum(cwidths_min) - if cwmin > width: - # we cannot shrink any more - raise Exception("Cannot shrink table width to %s. Minimum size is %s." % (self.width, cwmin)) + # get which cols have separately set widths - these should be locked + # note that we need to remove cwidths_min for each lock to avoid counting + # it twice (in cwmin and in locked_cols) + locked_cols = {icol: col.options['width'] - cwidths_min[icol] + for icol, col in enumerate(self.worktable) if 'width' in col.options} + locked_width = sum(locked_cols.values()) + + excess = width - cwmin - locked_width + + if len(locked_cols) >= ncols and excess: + # we can't adjust the width at all - all columns are locked + raise Exception("Cannot balance table to width %s - " + "all columns have a set, fixed width summing to %s!" % ( + self.width, sum(cwidths))) + + if excess < 0: + # the locked cols makes it impossible + raise Exception("Cannot shrink table width to %s. " + "Minimum size (and/or fixed-width columns) " + "sets minimum at %s." % (self.width, cwmin + locked_width)) - excess = width - cwmin if self.evenwidth: # make each column of equal width - for _ in range(excess): + # use cwidths as a work-array to track weights + cwidths = copy(cwidths_min) + correction = 0 + while correction < excess: # flood-fill the minimum table starting with the smallest columns - ci = cwidths_min.index(min(cwidths_min)) - cwidths_min[ci] += 1 + ci = cwidths.index(min(cwidths)) + if ci in locked_cols: + # locked column, make sure it's not picked again + cwidths[ci] += 9999 + cwidths_min[ci] = locked_cols[ci] + else: + cwidths_min[ci] += 1 + correction += 1 cwidths = cwidths_min else: # make each column expand more proportional to their data size - for _ in range(excess): + # we use cwidth as a work-array to track weights + correction = 0 + while correction < excess: # fill wider columns first ci = cwidths.index(max(cwidths)) - cwidths_min[ci] += 1 - cwidths[ci] -= 3 + if ci in locked_cols: + # locked column, make sure it's not picked again + cwidths[ci] -= 9999 + cwidths_min[ci] = locked_cols[ci] + else: + cwidths_min[ci] += 1 + correction += 1 + # give a just changed col less prio next run + cwidths[ci] -= 3 cwidths = cwidths_min # reformat worktable (for width align) @@ -1323,28 +1360,46 @@ class EvTable(object): for cell in (col[iy] for col in self.worktable)) for iy in range(nrowmax)] chmin = sum(cheights_min) + # get which cols have separately set heights - these should be locked + # note that we need to remove cheights_min for each lock to avoid counting + # it twice (in chmin and in locked_cols) + locked_cols = {icol: col.options['height'] - cheights_min[icol] + for icol, col in enumerate(self.worktable) if 'height' in col.options} + locked_height = sum(locked_cols.values()) + + excess = self.height - chmin - locked_height + if chmin > self.height: # we cannot shrink any more - raise Exception("Cannot shrink table height to %s. Minimum size is %s." % (self.height, chmin)) + raise Exception("Cannot shrink table height to %s. Minimum " + "size (and/or fixed-height rows) sets minimum at %s." % ( + self.height, chmin + locked_height)) # now we add all the extra height up to the desired table-height. # We do this so that the tallest cells gets expanded first (and # thus avoid getting cropped) - excess = self.height - chmin even = self.height % 2 == 0 - for position in range(excess): + correction = 0 + while correction < excess: # expand the cells with the most rows first - if 0 <= position < nrowmax and nrowmax > 1: + if 0 <= correction < nrowmax and nrowmax > 1: # avoid adding to header first round (looks bad on very small tables) ci = cheights[1:].index(max(cheights[1:])) + 1 else: ci = cheights.index(max(cheights)) - cheights_min[ci] += 1 - if ci == 0 and self.header: - # it doesn't look very good if header expands too fast - cheights[ci] -= 2 if even else 3 - cheights[ci] -= 2 if even else 1 + if ci in locked_cols: + # locked row, make sure it's not picked again + cheights[ci] -= 9999 + cheights_min[ci] = locked_cols[ci] + else: + cheights_min[ci] += 1 + # change balance + if ci == 0 and self.header: + # it doesn't look very good if header expands too fast + cheights[ci] -= 2 if even else 3 + cheights[ci] -= 2 if even else 1 + correction += 1 cheights = cheights_min # we must tell cells to crop instead of expanding @@ -1554,6 +1609,8 @@ class EvTable(object): """ if index > len(self.table): raise Exception("Not a valid column index") + # we update the columns' options which means eventual width/height + # will be 'locked in' and withstand auto-balancing width/height from the table later self.table[index].options.update(kwargs) self.table[index].reformat(**kwargs) @@ -1569,6 +1626,7 @@ class EvTable(object): def __str__(self): """print table (this also balances it)""" + # h = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" return str(unicode(ANSIString("\n").join([line for line in self._generate_lines()]))) def __unicode__(self): diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 1c94a1f9fd..7a33cfa207 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -8,10 +8,11 @@ --- */ /* Overall element look */ -html, body, #clientwrapper { height: 100% } +html, body { + height: 100%; + width: 100%; +} body { - margin: 0; - padding: 0; background: #000; color: #ccc; font-size: .9em; @@ -19,6 +20,12 @@ body { line-height: 1.6em; overflow: hidden; } +@media screen and (max-width: 480px) { + body { + font-size: .5rem; + line-height: .7rem; + } +} a:link, a:visited { color: inherit; } @@ -74,93 +81,109 @@ div {margin:0px;} } /* Style specific classes corresponding to formatted, narative text. */ - +.wrapper { + height: 100%; +} /* Container surrounding entire client */ -#wrapper { - position: relative; - height: 100% +#clientwrapper { + height: 100%; } /* Main scrolling message area */ + #messagewindow { - position: absolute; - overflow: auto; - padding: 1em; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - top: 0; - left: 0; - right: 0; - bottom: 70px; + overflow-y: auto; + overflow-x: hidden; + overflow-wrap: break-word; } -/* Input area containing input field and button */ -#inputform { - position: absolute; - width: 100%; - padding: 0; - bottom: 0; - margin: 0; - padding-bottom: 10px; - border-top: 1px solid #555; -} - -#inputcontrol { - width: 100%; - padding: 0; +#messagewindow { + overflow-y: auto; + overflow-x: hidden; + overflow-wrap: break-word; } /* Input field */ -#inputfield, #inputsend, #inputsizer { - display: block; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - height: 50px; +#inputfield, #inputsizer { + height: 100%; background: #000; color: #fff; - padding: 0 .45em; - font-size: 1.1em; + padding: 0 .45rem; + font-size: 1.1rem; font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace; -} - -#inputfield, #inputsizer { - float: left; - width: 95%; - border: 0; resize: none; - line-height: normal; +} +#inputsend { + height: 100%; +} +#inputcontrol { + height: 100%; } #inputfield:focus { - outline: 0; -} - -#inputsizer { - margin-left: -9999px; -} - -/* Input 'send' button */ -#inputsend { - float: right; - width: 3%; - max-width: 25px; - margin-right: 10px; - border: 0; - background: #555; } /* prompt area above input field */ -#prompt { - margin-top: 10px; - padding: 0 .45em; +.prompt { + max-height: 3rem; +} + +#splitbutton { + width: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +#splitbutton:hover { + color: white; + cursor: pointer; +} + +#panebutton { + width: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +#panebutton:hover { + color: white; + cursor: pointer; +} + +#undobutton { + width: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +#undobutton:hover { + color: white; + cursor: pointer; +} + +.button { + width: fit-content; + padding: 1em; + color: black; + border: 1px solid black; + background-color: darkgray; + margin: 0 auto; +} + +.splitbutton:hover { + cursor: pointer; } #optionsbutton { - width: 40px; - font-size: 20px; + width: 2rem; + font-size: 2rem; color: #a6a6a6; background-color: transparent; border: 0px; @@ -173,8 +196,8 @@ div {margin:0px;} #toolbar { position: fixed; - top: 0; - right: 5px; + top: .5rem; + right: .5rem; z-index: 1; } @@ -248,6 +271,52 @@ div {margin:0px;} text-decoration: none; cursor: pointer; } +.gutter.gutter-vertical { + cursor: row-resize; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=') +} + +.gutter.gutter-horizontal { + cursor: col-resize; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==') +} + +.split { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + overflow-y: auto; + overflow-x: hidden; +} + +.split-sub { + padding: .5rem; +} + +.content { + border: 1px solid #C0C0C0; + box-shadow: inset 0 1px 2px #e4e4e4; + background-color: black; + padding: 1rem; +} +@media screen and (max-width: 480px) { + .content { + padding: .5rem; + } +} + +.gutter { + background-color: grey; + + background-repeat: no-repeat; + background-position: 50%; +} + +.split.split-horizontal, .gutter.gutter-horizontal { + height: 100%; + float: left; +} /* XTERM256 colors */ diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js new file mode 100644 index 0000000000..81210df854 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -0,0 +1,145 @@ +// Use split.js to create a basic ui +var SplitHandler = (function () { + var split_panes = {}; + var backout_list = new Array; + + var set_pane_types = function(splitpane, types) { + split_panes[splitpane]['types'] = types; + } + + + var dynamic_split = function(splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) { + // find the sub-div of the pane we are being asked to split + splitpanesub = splitpane + '-sub'; + + // create the new div stack to replace the sub-div with. + var first_div = $( '
' ) + var first_sub = $( '
' ) + var second_div = $( '
' ) + var second_sub = $( '
' ) + + // check to see if this sub-pane contains anything + contents = $('#'+splitpanesub).contents(); + if( contents ) { + // it does, so move it to the first new div-sub (TODO -- selectable between first/second?) + contents.appendTo(first_sub); + } + first_div.append( first_sub ); + second_div.append( second_sub ); + + // update the split_panes array to remove this pane name, but store it for the backout stack + var backout_settings = split_panes[splitpane]; + delete( split_panes[splitpane] ); + + // now vaporize the current split_N-sub placeholder and create two new panes. + $('#'+splitpane).append(first_div); + $('#'+splitpane).append(second_div); + $('#'+splitpane+'-sub').remove(); + + // And split + Split(['#'+pane_name1,'#'+pane_name2], { + direction: direction, + sizes: sizes, + gutterSize: 4, + minSize: [50,50], + }); + + // store our new split sub-divs for future splits/uses by the main UI. + split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; + split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; + + // add our new split to the backout stack + backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} ); + } + + + var undo_split = function() { + // pop off the last split pair + var back = backout_list.pop(); + if( !back ) { + return; + } + + // Collect all the divs/subs in play + var pane1 = back['pane1']; + var pane2 = back['pane2']; + var pane1_sub = $('#'+pane1+'-sub'); + var pane2_sub = $('#'+pane2+'-sub'); + var pane1_parent = $('#'+pane1).parent(); + var pane2_parent = $('#'+pane2).parent(); + + if( pane1_parent.attr('id') != pane2_parent.attr('id') ) { + // sanity check failed...somebody did something weird...bail out + console.log( pane1 ); + console.log( pane2 ); + console.log( pane1_parent ); + console.log( pane2_parent ); + return; + } + + // create a new sub-pane in the panes parent + var parent_sub = $( '
' ) + + // check to see if the special #messagewindow is in either of our sub-panes. + var msgwindow = pane1_sub.find('#messagewindow') + if( !msgwindow ) { + //didn't find it in pane 1, try pane 2 + msgwindow = pane2_sub.find('#messagewindow') + } + if( msgwindow ) { + // It is, so collect all contents into it instead of our parent_sub div + // then move it to parent sub div, this allows future #messagewindow divs to flow properly + msgwindow.append( pane1_sub.contents() ); + msgwindow.append( pane2_sub.contents() ); + parent_sub.append( msgwindow ); + } else { + //didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane + parent_sub.append( pane1_sub.contents() ); + parent_sub.append( pane2_sub.contents() ); + } + + // clear the parent + pane1_parent.empty(); + + // add the new sub-pane back to the parent div + pane1_parent.append(parent_sub); + + // pull the sub-div's from split_panes + delete split_panes[pane1]; + delete split_panes[pane2]; + + // add our parent pane back into the split_panes list for future splitting + split_panes[pane1_parent.attr('id')] = back['undo']; + } + + + var init = function(settings) { + //change Mustache tags to ruby-style (Django gets mad otherwise) + var customTags = [ '<%', '%>' ]; + Mustache.tags = customTags; + + var input_template = $('#input-template').html(); + Mustache.parse(input_template); + + Split(['#main','#input'], { + direction: 'vertical', + sizes: [90,10], + gutterSize: 4, + minSize: [50,50], + }); + + split_panes['main'] = { 'types': [], 'update_method': 'append' }; + + var input_render = Mustache.render(input_template); + $('[data-role-input]').html(input_render); + console.log("SplitHandler initialized"); + } + + return { + init: init, + set_pane_types: set_pane_types, + dynamic_split: dynamic_split, + split_panes: split_panes, + undo_split: undo_split, + } +})(); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 9dde7da801..e1ed4d31fd 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -15,8 +15,13 @@ (function () { "use strict" +var num_splits = 0; //unique id counter for default split-panel names var options = {}; + +var known_types = new Array(); + known_types.push('help'); + // // GUI Elements // @@ -106,6 +111,7 @@ function togglePopup(dialogname, content) { // Grab text from inputline and send to Evennia function doSendText() { + console.log("sending text"); if (!Evennia.isConnected()) { var reconnect = confirm("Not currently connected. Reconnect?"); if (reconnect) { @@ -158,7 +164,11 @@ function onKeydown (event) { var code = event.which; var history_entry = null; var inputfield = $("#inputfield"); - inputfield.focus(); + if (code === 9) { + return; + } + + //inputfield.focus(); if (code === 13) { // Enter key sends text doSendText(); @@ -205,74 +215,68 @@ function onKeyPress (event) { } var resizeInputField = function () { - var min_height = 50; - var max_height = 300; - var prev_text_len = 0; + return function() { + var wrapper = $("#inputform") + var input = $("#inputcontrol") + var prompt = $("#prompt") - // Check to see if we should change the height of the input area - return function () { - var inputfield = $("#inputfield"); - var scrollh = inputfield.prop("scrollHeight"); - var clienth = inputfield.prop("clientHeight"); - var newh = 0; - var curr_text_len = inputfield.val().length; - - if (scrollh > clienth && scrollh <= max_height) { - // Need to make it bigger - newh = scrollh; - } - else if (curr_text_len < prev_text_len) { - // There is less text in the field; try to make it smaller - // To avoid repaints, we draw the text in an offscreen element and - // determine its dimensions. - var sizer = $('#inputsizer') - .css("width", inputfield.prop("clientWidth")) - .text(inputfield.val()); - newh = sizer.prop("scrollHeight"); - } - - if (newh != 0) { - newh = Math.min(newh, max_height); - if (clienth != newh) { - inputfield.css("height", newh + "px"); - doWindowResize(); - } - } - prev_text_len = curr_text_len; + input.height(wrapper.height() - (input.offset().top - wrapper.offset().top)); } }(); // Handle resizing of client function doWindowResize() { - var formh = $('#inputform').outerHeight(true); - var message_scrollh = $("#messagewindow").prop("scrollHeight"); - $("#messagewindow") - .css({"bottom": formh}) // leave space for the input form - .scrollTop(message_scrollh); // keep the output window scrolled to the bottom + resizeInputField(); + var resizable = $("[data-update-append]"); + var parents = resizable.closest(".split") + parents.animate({ + scrollTop: parents.prop("scrollHeight") + }, 0); } // Handle text coming from the server function onText(args, kwargs) { - // append message to previous ones, then scroll so latest is at - // the bottom. Send 'cls' kwarg to modify the output class. - var renderto = "main"; - if (kwargs["type"] == "help") { - if (("helppopup" in options) && (options["helppopup"])) { - renderto = "#helpdialog"; + var use_default_pane = true; + + if ( kwargs && 'type' in kwargs ) { + var msgtype = kwargs['type']; + if ( ! known_types.includes(msgtype) ) { + // this is a new output type that can be mapped to panes + console.log('detected new output type: ' + msgtype) + known_types.push(msgtype); + } + + // pass this message to each pane that has this msgtype mapped + if( SplitHandler ) { + for ( var key in SplitHandler.split_panes) { + var pane = SplitHandler.split_panes[key]; + // is this message type mapped to this pane? + if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { + // yes, so append/replace this pane's inner div with this message + var text_div = $('#'+key+'-sub'); + if ( pane['update_method'] == 'replace' ) { + text_div.html(args[0]) + } else { + text_div.append(args[0]); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); + } + // record sending this message to a pane, no need to update the default div + use_default_pane = false; + } + } } } - if (renderto == "main") { + // append message to default pane, then scroll so latest is at the bottom. + if(use_default_pane) { var mwin = $("#messagewindow"); var cls = kwargs == null ? 'out' : kwargs['cls']; mwin.append("
" + args[0] + "
"); - mwin.animate({ - scrollTop: document.getElementById("messagewindow").scrollHeight - }, 0); + var scrollHeight = mwin.parent().parent().prop("scrollHeight"); + mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0); onNewLine(args[0], null); - } else { - openPopup(renderto, args[0]); } } @@ -430,6 +434,105 @@ function doStartDragDialog(event) { $(document).bind("mouseup", undrag); } +function onSplitDialogClose() { + var pane = $("input[name=pane]:checked").attr("value"); + var direction = $("input[name=direction]:checked").attr("value"); + var new_pane1 = $("input[name=new_pane1]").val(); + var new_pane2 = $("input[name=new_pane2]").val(); + var flow1 = $("input[name=flow1]:checked").attr("value"); + var flow2 = $("input[name=flow2]:checked").attr("value"); + + if( new_pane1 == "" ) { + new_pane1 = 'pane_'+num_splits; + num_splits++; + } + + if( new_pane2 == "" ) { + new_pane2 = 'pane_'+num_splits; + num_splits++; + } + + if( document.getElementById(new_pane1) ) { + alert('An element: "' + new_pane1 + '" already exists'); + return; + } + + if( document.getElementById(new_pane2) ) { + alert('An element: "' + new_pane2 + '" already exists'); + return; + } + + SplitHandler.dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] ); + + closePopup("#splitdialog"); +} + +function onSplitDialog() { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Split?

"); + dialog.append(' top/bottom
'); + dialog.append(' side-by-side
'); + + dialog.append("

Split Which Pane?

"); + for ( var pane in SplitHandler.split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

New Pane Names

"); + dialog.append(''); + dialog.append(''); + + dialog.append("

New First Pane

"); + dialog.append('append new incoming messages
'); + dialog.append('replace old messages with new ones
'); + + dialog.append("

New Second Pane

"); + dialog.append('append new incoming messages
'); + dialog.append('replace old messages with new ones
'); + + dialog.append('
Split It
'); + + $("#splitclose").bind("click", onSplitDialogClose); + + togglePopup("#splitdialog"); +} + +function onPaneControlDialogClose() { + var pane = $("input[name=pane]:checked").attr("value"); + + var types = new Array; + $('#splitdialogcontent input[type=checkbox]:checked').each(function() { + types.push( $(this).attr('value') ); + }); + + SplitHandler.set_pane_types( pane, types ); + + closePopup("#splitdialog"); +} + +function onPaneControlDialog() { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Set Which Pane?

"); + for ( var pane in SplitHandler.split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

Which content types?

"); + for ( var type in known_types ) { + dialog.append(''+ known_types[type] +'
'); + } + + dialog.append('
Make It So
'); + + $("#paneclose").bind("click", onPaneControlDialogClose); + + togglePopup("#splitdialog"); +} + // // Register Events // @@ -437,6 +540,18 @@ function doStartDragDialog(event) { // Event when client finishes loading $(document).ready(function() { + if( SplitHandler ) { + SplitHandler.init(); + $("#splitbutton").bind("click", onSplitDialog); + $("#panebutton").bind("click", onPaneControlDialog); + $("#undobutton").bind("click", SplitHandler.undo_split); + $("#optionsbutton").hide(); + } else { + $("#splitbutton").hide(); + $("#panebutton").hide(); + $("#undobutton").hide(); + } + if ("Notification" in window) { Notification.requestPermission(); } @@ -453,7 +568,7 @@ $(document).ready(function() { //$(document).on("visibilitychange", onVisibilityChange); - $("#inputfield").bind("resize", doWindowResize) + $("[data-role-input]").bind("resize", doWindowResize) .keypress(onKeyPress) .bind("paste", resizeInputField) .bind("cut", resizeInputField); @@ -506,6 +621,7 @@ $(document).ready(function() { }, 60000*3 ); + console.log("Completed GUI setup"); }); diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index 863a90ba11..a5c65fad2c 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -13,6 +13,10 @@ JQuery available. + + + + @@ -20,7 +24,7 @@ JQuery available. {% block jquery_import %} - + {% endblock %} + + + + + + + + + {% block guilib_import %} @@ -63,7 +81,11 @@ JQuery available. } - + + + + {% block scripts %} + {% endblock %} @@ -86,10 +108,9 @@ JQuery available.
-
+
{% block client %} {% endblock %}
- diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index 1c641bffb0..74bef631cf 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -8,20 +8,29 @@ {% block client %} +
+ + + + +
-
-
- -
-
-
-
-
- - + +
+
+
+
+
+ +
+ + +
+
Split Pane×
+
+
-
@@ -47,4 +56,29 @@
+ + + + + + +{% endblock %} +{% block scripts %} {% endblock %}