diff --git a/CHANGELOG.md b/CHANGELOG.md index 705e44f..8a89f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0-beta] - 2020-11-15 + +### Added + +- Support configuration +- Add modes "emacs" and "vim" as predefined key mappings + +### Fixed + +- Focus line jumping around when moving up / below ## [0.1.0-alpha.3] - 2020-10-18 @@ -35,7 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial commit with pyfx -[unreleased]: https://github.com/cielong/pyfx/compare/v0.1.0-alpha.3...HEAD +[unreleased]: https://github.com/cielong/pyfx/compare/v0.1.0-beta...HEAD +[0.1.0-beta]: https://github.com/cielong/pyfx/compare/v0.1.0-alpha.3...v0.1.0-beta [0.1.0-alpha.3]: https://github.com/cielong/pyfx/compare/v0.1.0-alpha.2...v0.1.0-alpha.3 [0.1.0-alpha.2]: https://github.com/cielong/pyfx/compare/v0.1.0-alpha.1...v0.1.0-alpha.2 [0.1.0-alpha.1]: https://github.com/cielong/pyfx/compare/v0.1.0-alpha...v0.1.0-alpha.1 diff --git a/MANIFEST.in b/MANIFEST.in index dadbeb1..19df4fc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -21,3 +21,7 @@ include tox.ini # include Trave CI config include .travis.yml + +# include pyfx config +include src/pyfx/config/*.yml +include src/pyfx/view/keymapper/modes/*.yml diff --git a/Pipfile b/Pipfile index db494b1..6bee708 100644 --- a/Pipfile +++ b/Pipfile @@ -19,6 +19,10 @@ sphinx-click = "*" pytest-cov = "*" loguru = "*" jsonpath-ng = "*" +yamale = "*" +dacite = "*" +dataclasses = "*" +first = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 20fc37e..90707d4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "26e7f45540799eeabec65f5ec5100eedccd1dc9023337185b45d410a7acab643" + "sha256": "349db7f961b5e0d26a38f6646e97a937c403a4054563a073537e9ac4f7004d2d" }, "pipfile-spec": 6, "requires": { @@ -32,34 +32,34 @@ }, "asciimatics": { "hashes": [ - "sha256:1d0871133c95fa15c603d471ebb77e39b3389877e2ff2ad5ab3bc906d81b5e8c", - "sha256:6c7ad130a0663ecb1b3a731b82c51d6662f1d2c4760de2b8ffc9c1faba775360" + "sha256:4120461a3fb345638dee4fe0f8a3d3f9b6d2d2e003f95c5f914523f94463158d", + "sha256:83c8ead386cdcc5fd4cebb4289b0d6da61164e44dfdfff3776b66e6bcfef2e3d" ], "index": "pypi", - "version": "==1.11.0" + "version": "==1.12.0" }, "attrs": { "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.0" + "version": "==20.3.0" }, "babel": { "hashes": [ - "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", - "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" + "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5", + "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.0" + "version": "==2.9.0" }, "certifi": { "hashes": [ - "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", - "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" + "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", + "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" ], - "version": "==2020.6.20" + "version": "==2020.11.8" }, "chardet": { "hashes": [ @@ -116,6 +116,22 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==5.3" }, + "dacite": { + "hashes": [ + "sha256:764c96e0304cb189628686689a163a6a3a8ce7bf3465f0a2d882a8b42f88108f", + "sha256:f7f269647ede90f8702728eb7dcb972051511c81b853a93c962fbd31f1753b9f" + ], + "index": "pypi", + "version": "==1.5.1" + }, + "dataclasses": { + "hashes": [ + "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f", + "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84" + ], + "index": "pypi", + "version": "==0.6" + }, "decorator": { "hashes": [ "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760", @@ -145,10 +161,18 @@ ], "version": "==3.0.12" }, + "first": { + "hashes": [ + "sha256:8d8e46e115ea8ac652c76123c0865e3ff18372aef6f03c22809ceefcea9dec86", + "sha256:ff285b08c55f8c97ce4ea7012743af2495c9f1291785f163722bd36f6af6d3bf" + ], + "index": "pypi", + "version": "==2.0.2" + }, "future": { "hashes": [ - "sha256:0962b60de4ca66f0b0dbe01cbcb83fe5606b1bbf329f4bdb5bccc122cc555386", - "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" + "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d", + "sha256:0962b60de4ca66f0b0dbe01cbcb83fe5606b1bbf329f4bdb5bccc122cc555386" ], "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.18.2" @@ -373,13 +397,29 @@ ], "version": "==2020.4" }, + "pyyaml": { + "hashes": [ + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" + ], + "version": "==5.3.1" + }, "requests": { "hashes": [ - "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", - "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" + "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", + "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.24.0" + "version": "==2.25.0" }, "six": { "hashes": [ @@ -398,11 +438,11 @@ }, "sphinx": { "hashes": [ - "sha256:321d6d9b16fa381a5306e5a0b76cd48ffbc588e6340059a729c6fdd66087e0e8", - "sha256:ce6fd7ff5b215af39e2fcd44d4a321f6694b4530b6f2b2109b64d120773faea0" + "sha256:1e8d592225447104d1172be415bc2972bd1357e3e12fdc76edf2261105db4300", + "sha256:d4e59ad4ea55efbb3c05cde3bfc83bfc14f0c95aa95c3d75346fcce186a47960" ], "index": "pypi", - "version": "==3.2.1" + "version": "==3.3.1" }, "sphinx-click": { "hashes": [ @@ -478,11 +518,11 @@ }, "urllib3": { "hashes": [ - "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2", - "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e" + "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", + "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.25.11" + "version": "==1.26.2" }, "urwid": { "hashes": [ @@ -505,6 +545,13 @@ "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" ], "version": "==0.2.5" + }, + "yamale": { + "hashes": [ + "sha256:bec53aa08e29c26a9a47c0f5d5cb5632e7ac8aff619a4727acfe490042c83a5f" + ], + "index": "pypi", + "version": "==3.0.4" } }, "develop": {} diff --git a/README.md b/README.md index 324201e..1c9b457 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ from pyfx import Controller Controller().run_with_data(data) ``` #### Import *pyfx*'s Native JSON Library and Integrate with Your Own TUI -You can also import *pyfx* native JSON lib to integrate it into your own urwid TUI, e.g. [view_window.py](https://github.com/cielong/pyfx/blob/master/src/pyfx/view/components/view_window.py). +You can also import *pyfx* native JSON lib to integrate it into your own urwid TUI, e.g. [json_browser.py](https://github.com/cielong/pyfx/blob/master/src/pyfx/view/components/json_browser/json_browser.py). ```python from pyfx.view.json_lib import JSONListBox, JSONListWalker, NodeFactory @@ -55,20 +55,71 @@ listbox = JSONListBox(JSONListWalker(top_node)) # use listbox in your own TUI ... ``` +## Configuration +*pyfx* can be configured using YAML, the config file is either passed directly through CLI option or automatically +loaded in predefined config folderq. -### Key Mappings +If *pyfx* is invoked without `-c / --config` option, it will search config file in with the following order: +1. ~/.config/pyfx/config.yml +2. PYTHON_DIR/site-packages/pyfx/config/config.yml + +### Predefined Key Mappings +Key mapping is configured with the following configuration schema +``` +keymap: + mode: string, accepted_options = ["basic" (The default) | "emacs" | "vim"] +``` +#### Basic Mode | Key | Function | |------------------|---------------------------------------------------| -| **Main Window** | -| q | exit pyfx | -| **View Window** | +| q | exit pyfx (except in Query Bar) | +| **JSON Browser** | +| up | move cursor up one line | +| down | move cursor down one line | | enter | toggle expansion | -| up/ctrl p | move cursor up one line | -| down/ctrl n | move cursor down one line | -| **Query Window** | | . | enter query window (used to input JSONPath query) | -| enter | apply JSONPath query and switch to View Window | -| esc | apply JSONPath query and exit Query Window | +| **Query Bar** | +| enter | apply JSONPath query and switch to JSON Browser | +| esc | cancel query and restore to state before query | + +#### Emacs Mode +To enable, add the following configuration in your config file: +```yaml +keymap: + mode: "emacs" +``` +##### Mapped Keys +| Key | Function | +|------------------|---------------------------------------------------| +| q | exit pyfx (except in Query Bar) | +| **JSON Browser** | +| up / ctrl p | move cursor up one line | +| down / ctrl n | move cursor down one line | +| enter | toggle expansion | +| . / meta x | enter query window (used to input JSONPath query) | +| **Query Bar** | +| enter | apply JSONPath query and switch to JSON Browser | +| ctrl g | cancel query and restore to state before query | + +#### Vim Mode +To enable, add the following configuration in your config file: +```yaml +keymap: + mode: "vim" +``` +##### Mapped Keys +| Key | Function | +|------------------|---------------------------------------------------| +| q | exit pyfx (except in Query Bar) | +| **JSON Browser** | +| up / k | move cursor up one line | +| down / j | move cursor down one line | +| enter | toggle expansion | +| . / : | enter query window (used to input JSONPath query) | +| **Query Bar** | +| enter | apply JSONPath query and switch to JSON Browser | +| esc | cancel query and restore to state before query | + ## Full Documentation Please visit [Documentation](https://python-fx.readthedocs.io/en/latest/) diff --git a/VERSION b/VERSION index d594bc9..a67658d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0-alpha.3 +0.1.0-beta diff --git a/docs/requirements.txt b/docs/requirements.txt index 4664bc9..8136220 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -11,3 +11,7 @@ sphinxcontrib-qthelp==1.0.3 sphinxcontrib-serializinghtml==1.1.4 urwid==2.1.2 loguru==0.5.3 +yamale==3.0.4 +dataclasses==0.6 +dacite==1.5.1 +first==2.0.2 diff --git a/setup.py b/setup.py index d2679c3..5c3e989 100644 --- a/setup.py +++ b/setup.py @@ -8,10 +8,10 @@ here = pathlib.Path(__file__).parent.resolve() -# Get the version from the VERSION file +# get the version from the VERSION file version = (here / 'VERSION').read_text(encoding='utf-8') -# Get the long description from the README file +# get the long description from the README file long_description = (here / 'README.md').read_text(encoding='utf-8') setup( @@ -27,13 +27,19 @@ keywords="fx, pyfx, json viewer, tui", packages=find_packages('src'), package_dir={'': 'src'}, + package_data={'pyfx': ['config/*.yml', 'view/keymapper/modes/*.yml']}, + include_package_data=True, py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], install_requires=[ 'click', 'urwid', 'overrides', 'jsonpath-ng', - 'loguru' + 'loguru', + 'yamale', + 'dataclasses', + 'dacite', + 'first' ], setup_requires=[ 'pytest-runner', @@ -42,7 +48,7 @@ "console_scripts": ["pyfx=pyfx.cli:main"] }, classifiers=[ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Programming Language :: Python :: 3 :: Only", "Environment :: Console", "Operating System :: POSIX", diff --git a/src/pyfx/cli.py b/src/pyfx/cli.py index dc0f7a5..3663914 100644 --- a/src/pyfx/cli.py +++ b/src/pyfx/cli.py @@ -1,25 +1,29 @@ import click +from .config.config_parser import parse from .core import Controller -from .logging import log_config +from .logging import setup_logger STDIN = 'stdin' @click.command(name="pyfx") +@click.option("-c", "--config-file", type=click.Path(exists=True)) @click.argument("file", type=click.Path(exists=True), nargs=-1) -def main(file): +def main(file, config_file): """ pyfx command line entry point. It loads data from a JSON file FILE and opens pyfx UI for browsing. """ - log_config() + setup_logger() + config = parse(config_file) if len(file) > 1: - raise ValueError("pyfx does not support multi JSON files.") + raise click.BadArgumentUsage("pyfx does not support multi JSON files.") + controller = Controller(config) if len(file) == 1: - Controller().run_with_file(file[0]) + controller.run_with_file(file[0]) else: text_stream = click.get_text_stream(STDIN) - Controller().run_with_text_stream(text_stream) + controller.run_with_text_stream(text_stream) diff --git a/src/pyfx/cli_utils.py b/src/pyfx/cli_utils.py new file mode 100644 index 0000000..6c0a3c9 --- /dev/null +++ b/src/pyfx/cli_utils.py @@ -0,0 +1,18 @@ +import functools + +import click + + +def exit_on_exception(func): + """ + A decorator which exit the current click application when there's unexpected error + and print the error message to the stderr. + """ + # noinspection PyBroadException + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + raise click.ClickException(e) + return wrapper diff --git a/src/pyfx/config/__init__.py b/src/pyfx/config/__init__.py new file mode 100644 index 0000000..088b2c2 --- /dev/null +++ b/src/pyfx/config/__init__.py @@ -0,0 +1,2 @@ +from .config import Configuration +from .config_parser import ConfigurationParser diff --git a/src/pyfx/config/config.py b/src/pyfx/config/config.py new file mode 100644 index 0000000..9acaa41 --- /dev/null +++ b/src/pyfx/config/config.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from ..view.keymapper import KeyMapperConfiguration + + +@dataclass +class Configuration: + keymap: KeyMapperConfiguration = KeyMapperConfiguration() diff --git a/src/pyfx/config/config.yml b/src/pyfx/config/config.yml new file mode 100644 index 0000000..7ef129b --- /dev/null +++ b/src/pyfx/config/config.yml @@ -0,0 +1,2 @@ +keymap: + mode: "basic" diff --git a/src/pyfx/config/config_parser.py b/src/pyfx/config/config_parser.py new file mode 100644 index 0000000..1b3a76c --- /dev/null +++ b/src/pyfx/config/config_parser.py @@ -0,0 +1,44 @@ +import pathlib + +import dacite +import yamale +from first import first + +from .config import Configuration +from ..cli_utils import exit_on_exception + + +@exit_on_exception +def parse(config_file): + return ConfigurationParser().parse(config_file) + + +class ConfigurationParser: + + __CLASS_DIR = pathlib.Path(__file__).parent.resolve() + __SCHEMA_PATH = __CLASS_DIR / "schema.yml" + + __CONFIG_PATHS = [ + pathlib.Path.home() / ".config" / "pyfx" / "config.yml", + __CLASS_DIR / "config.yml" + ] + + def __init__(self): + self._schema = self.__load_schema() + + def parse(self, config_file=None): + config = self.__load_config(config_file) + yamale.validate(self._schema, config) + # config is composed as [(config, path)...] + return dacite.from_dict(data_class=Configuration, data=config[0][0]) + + @staticmethod + def __load_schema(): + return yamale.make_schema(ConfigurationParser.__SCHEMA_PATH) + + # noinspection PyBroadException + @staticmethod + def __load_config(config_file): + if config_file is None: + config_file = first(ConfigurationParser.__CONFIG_PATHS, key=lambda path: path.exists()) + return yamale.make_data(config_file) diff --git a/src/pyfx/config/schema.yml b/src/pyfx/config/schema.yml new file mode 100644 index 0000000..c99b268 --- /dev/null +++ b/src/pyfx/config/schema.yml @@ -0,0 +1,2 @@ +keymap: + mode: str() diff --git a/src/pyfx/core.py b/src/pyfx/core.py index 4796505..bad5fcc 100644 --- a/src/pyfx/core.py +++ b/src/pyfx/core.py @@ -1,3 +1,4 @@ +from .config import Configuration from .model import Model from .view import View @@ -7,9 +8,9 @@ class Controller: *pyfx* controller, the main entry point of pyfx library. """ - def __init__(self, config_file: str = None): - self._config = config_file - self._view = View(self) + def __init__(self, config=Configuration()): + self._config = config + self._view = View(self, config) self._model = Model(self) def run_with_file(self, filename): diff --git a/src/pyfx/logging.py b/src/pyfx/logging.py index 0e49060..07149d6 100644 --- a/src/pyfx/logging.py +++ b/src/pyfx/logging.py @@ -4,7 +4,7 @@ from loguru import logger -def log_config(): +def setup_logger(): logger.remove() logger.add("/tmp/pyfx.log", level='DEBUG', rotation='5MB', retention="10 days", format="{time} {module}.{function} {message}") diff --git a/src/pyfx/view/components/__init__.py b/src/pyfx/view/components/__init__.py index aad8fc0..246b7b7 100644 --- a/src/pyfx/view/components/__init__.py +++ b/src/pyfx/view/components/__init__.py @@ -4,6 +4,6 @@ Each component is a single unit rendered in *pyfx*. """ from .autocomplete_popup import AutoCompletePopUp -from .help_window import HelpWindow -from .query_window import QueryWindow -from .view_window import ViewWindow +from .help_bar import HelpBar +from .query_bar import QueryBar +from .json_browser import JSONBrowser diff --git a/src/pyfx/view/components/autocomplete_popup/__init__.py b/src/pyfx/view/components/autocomplete_popup/__init__.py new file mode 100644 index 0000000..a553dc4 --- /dev/null +++ b/src/pyfx/view/components/autocomplete_popup/__init__.py @@ -0,0 +1 @@ +from .autocomplete_popup import AutoCompletePopUp diff --git a/src/pyfx/view/components/autocomplete_popup.py b/src/pyfx/view/components/autocomplete_popup/autocomplete_popup.py similarity index 82% rename from src/pyfx/view/components/autocomplete_popup.py rename to src/pyfx/view/components/autocomplete_popup/autocomplete_popup.py index 3632885..1402fc2 100644 --- a/src/pyfx/view/components/autocomplete_popup.py +++ b/src/pyfx/view/components/autocomplete_popup/autocomplete_popup.py @@ -1,7 +1,16 @@ +from enum import Enum + import urwid from overrides import overrides -from ..common import SelectableText +from ...common import SelectableText + + +class AutoCompletePopUpKeys(Enum): + CURSOR_UP = "up" + CURSOR_DOWN = "down" + SELECT = "enter" + CANCEL = "esc" class AutoCompletePopUp(urwid.WidgetWrap): @@ -12,10 +21,12 @@ class AutoCompletePopUp(urwid.WidgetWrap): # predefined constants to constrain pop up window size MAX_HEIGHT = 5 - def __init__(self, popup_launcher, controller, query_window, prefix, options): + def __init__(self, controller, keymapper, popup_launcher, query_window, prefix, options): self._popup_launcher = popup_launcher self._query_window = query_window self._controller = controller + self._keymapper = keymapper + self._prefix = prefix self._options = options super().__init__(self._load_widget()) @@ -55,17 +66,22 @@ def _update_query(self): @overrides def keypress(self, size, key): + key = self._keymapper.key(key) key = super().keypress(size, key) - if key == 'enter': + + if key == AutoCompletePopUpKeys.SELECT.value: self._update_query() self._popup_launcher.close_pop_up() self._controller.query(self._query_window.get_text()) return None - elif key in ('esc', 'ctrl g'): + + elif key == AutoCompletePopUpKeys.CANCEL.value: self._popup_launcher.close_pop_up() return None + elif key is not None: # forward key to the query window if not handled by auto-complete self._popup_launcher.close_pop_up() key = self._query_window.keypress_internal(key) + return key diff --git a/src/pyfx/view/components/autocomplete_popup/autocomplete_popup_keymapper.py b/src/pyfx/view/components/autocomplete_popup/autocomplete_popup_keymapper.py new file mode 100644 index 0000000..0230eb6 --- /dev/null +++ b/src/pyfx/view/components/autocomplete_popup/autocomplete_popup_keymapper.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +from overrides import overrides + +from .autocomplete_popup import AutoCompletePopUpKeys +from ...keymapper import AbstractComponentKeyMapper + + +@dataclass(frozen=True) +class AutoCompletePopUpKeyMapper(AbstractComponentKeyMapper): + + cursor_up: str = "up" + cursor_down: str = "down" + select: str = "enter" + cancel: str = "esc" + + @property + @overrides + def mapped_key(self): + return { + self.cursor_up: AutoCompletePopUpKeys.CURSOR_UP, + self.cursor_down: AutoCompletePopUpKeys.CURSOR_DOWN, + self.select: AutoCompletePopUpKeys.SELECT, + self.cancel: AutoCompletePopUpKeys.CANCEL + } diff --git a/src/pyfx/view/components/help_bar/__init__.py b/src/pyfx/view/components/help_bar/__init__.py new file mode 100644 index 0000000..85a6091 --- /dev/null +++ b/src/pyfx/view/components/help_bar/__init__.py @@ -0,0 +1 @@ +from .help_bar import HelpBar diff --git a/src/pyfx/view/components/help_window.py b/src/pyfx/view/components/help_bar/help_bar.py similarity index 71% rename from src/pyfx/view/components/help_window.py rename to src/pyfx/view/components/help_bar/help_bar.py index e4cd8b0..9c0ff7a 100644 --- a/src/pyfx/view/components/help_window.py +++ b/src/pyfx/view/components/help_bar/help_bar.py @@ -1,12 +1,12 @@ import urwid -class HelpWindow(urwid.WidgetWrap): +class HelpBar(urwid.WidgetWrap): HELP_TEXT = [ ('title', "Pyfx"), " ", "UP, DOWN, ENTER, Q", ] def __init__(self, manager): self._manager = manager - self._text_widget = urwid.Text(HelpWindow.HELP_TEXT) + self._text_widget = urwid.Text(HelpBar.HELP_TEXT) super().__init__(urwid.AttrWrap(self._text_widget, "foot")) diff --git a/src/pyfx/view/components/help_page/__init__.py b/src/pyfx/view/components/help_page/__init__.py new file mode 100644 index 0000000..bb12a6e --- /dev/null +++ b/src/pyfx/view/components/help_page/__init__.py @@ -0,0 +1 @@ +from .detailed_help_page import DetailedHelpPage diff --git a/src/pyfx/view/components/help_details_window.py b/src/pyfx/view/components/help_page/detailed_help_page.py similarity index 56% rename from src/pyfx/view/components/help_details_window.py rename to src/pyfx/view/components/help_page/detailed_help_page.py index 1454937..b392c51 100644 --- a/src/pyfx/view/components/help_details_window.py +++ b/src/pyfx/view/components/help_page/detailed_help_page.py @@ -1,12 +1,12 @@ import urwid -class HelpDetailsWindow(urwid.WidgetWrap): +class DetailedHelpPage(urwid.WidgetWrap): HELP_TEXT = "" def __init__(self): self._text_widget = urwid.AttrWrap( - urwid.ListBox(urwid.SimpleListWalker([urwid.Text(HelpDetailsWindow.HELP_TEXT)])), + urwid.ListBox(urwid.SimpleListWalker([urwid.Text(DetailedHelpPage.HELP_TEXT)])), "body" ) super().__init__(self._text_widget) diff --git a/src/pyfx/view/components/json_browser/__init__.py b/src/pyfx/view/components/json_browser/__init__.py new file mode 100644 index 0000000..ef353f7 --- /dev/null +++ b/src/pyfx/view/components/json_browser/__init__.py @@ -0,0 +1 @@ +from .json_browser import JSONBrowser diff --git a/src/pyfx/view/components/view_window.py b/src/pyfx/view/components/json_browser/json_browser.py similarity index 55% rename from src/pyfx/view/components/view_window.py rename to src/pyfx/view/components/json_browser/json_browser.py index b813eb9..3fb1cbc 100644 --- a/src/pyfx/view/components/view_window.py +++ b/src/pyfx/view/components/json_browser/json_browser.py @@ -1,24 +1,34 @@ +from enum import Enum + import urwid from overrides import overrides -from ..json_lib import JSONListBox -from ..json_lib import JSONListWalker -from ..json_lib import NodeFactory +from ...json_lib import JSONListBox +from ...json_lib import JSONListWalker +from ...json_lib import NodeFactory + + +class JSONBrowserKeys(Enum): + # keys for json lib + CURSOR_UP = "up" + CURSOR_DOWN = "down" + TOGGLE_EXPANSION = "enter" + # keys for switching window + OPEN_QUERY_BAR = "." -class ViewWindow(urwid.WidgetWrap): +class JSONBrowser(urwid.WidgetWrap): """ Window to display JSON contents. """ - def __init__(self, manager, data=""): + def __init__(self, manager, keymapper, data=""): + self._keymapper = keymapper self._manager = manager - data = ViewWindow._validate(data) self._top_node = NodeFactory.create_node("", data, display_key=False) super().__init__(self._load_widget()) def set_top_node(self, data): - data = ViewWindow._validate(data) self._top_node = NodeFactory.create_node("", data, display_key=False) self._refresh() @@ -29,18 +39,12 @@ def _load_widget(self): def _refresh(self): self._w = self._load_widget() - @staticmethod - def _validate(data): - """ - Validates input data and reset it into empty string if it is None. - :param data: JSON valid data, could be `dict`, `list`, `int`, `str`, `float` etc. - :return: original data, or empty string if None - """ - return data if data else "" - @overrides def keypress(self, size, key): + key = self._keymapper.key(key) key = super().keypress(size, key) - if key == '.': + + if key == JSONBrowserKeys.OPEN_QUERY_BAR.value: self._manager.enter_query_window() + return key diff --git a/src/pyfx/view/components/json_browser/json_browser_keymapper.py b/src/pyfx/view/components/json_browser/json_browser_keymapper.py new file mode 100644 index 0000000..c53a22f --- /dev/null +++ b/src/pyfx/view/components/json_browser/json_browser_keymapper.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + +from overrides import overrides + +from .json_browser import JSONBrowserKeys +from ...keymapper import AbstractComponentKeyMapper + + +@dataclass(frozen=True) +class JSONBrowserKeyMapper(AbstractComponentKeyMapper): + cursor_up: str = "up" + cursor_down: str = "down" + toggle_expansion: str = "enter" + open_query_bar: str = "." + + @property + @overrides + def mapped_key(self): + return { + self.cursor_up: JSONBrowserKeys.CURSOR_UP, + self.cursor_down: JSONBrowserKeys.CURSOR_DOWN, + self.toggle_expansion: JSONBrowserKeys.TOGGLE_EXPANSION, + self.open_query_bar: JSONBrowserKeys.OPEN_QUERY_BAR + } diff --git a/src/pyfx/view/components/query_bar/__init__.py b/src/pyfx/view/components/query_bar/__init__.py new file mode 100644 index 0000000..6960073 --- /dev/null +++ b/src/pyfx/view/components/query_bar/__init__.py @@ -0,0 +1 @@ +from .query_bar import QueryBar diff --git a/src/pyfx/view/components/query_window.py b/src/pyfx/view/components/query_bar/query_bar.py similarity index 72% rename from src/pyfx/view/components/query_window.py rename to src/pyfx/view/components/query_bar/query_bar.py index 95fc3a9..23fd6d9 100644 --- a/src/pyfx/view/components/query_window.py +++ b/src/pyfx/view/components/query_bar/query_bar.py @@ -1,19 +1,27 @@ +from enum import Enum + import urwid from overrides import overrides -class QueryWindow(urwid.WidgetWrap): +class QueryBarKeys(Enum): + QUERY = "enter" + CANCEL = "esc" + + +class QueryBar(urwid.WidgetWrap): """ Query window for `pyfx` to input JSONPath query """ JSONPATH_START = "$" - def __init__(self, manager, controller): + def __init__(self, manager, controller, keymapper): self._manager = manager self._controller = controller + self._keymapper = keymapper self._edit_widget = urwid.Edit() - self._edit_widget.insert_text(QueryWindow.JSONPATH_START) + self._edit_widget.insert_text(QueryBar.JSONPATH_START) super().__init__(urwid.AttrWrap(self._edit_widget, None, "focus")) def setup(self): @@ -36,11 +44,15 @@ def keypress_internal(self, key): @overrides def keypress(self, size, key): + key = self._keymapper.key(key) key = super().keypress(size, key) - if key == 'enter': + + if key == QueryBarKeys.QUERY.value: self._controller.query(self.get_text()) self._manager.enter_view_window() - elif key == 'esc': + + if key == QueryBarKeys.CANCEL.value: self._controller.query(self.get_text()) - self._manager.exit_query_window() + self._manager.enter_view_window() + return key diff --git a/src/pyfx/view/components/query_bar/query_bar_keymapper.py b/src/pyfx/view/components/query_bar/query_bar_keymapper.py new file mode 100644 index 0000000..e055703 --- /dev/null +++ b/src/pyfx/view/components/query_bar/query_bar_keymapper.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + +from overrides import overrides + +from .query_bar import QueryBarKeys +from ...keymapper import AbstractComponentKeyMapper + + +@dataclass(frozen=True) +class QueryBarKeyMapper(AbstractComponentKeyMapper): + + query: str = "enter" + cancel: str = "esc" + + @property + @overrides + def mapped_key(self): + return { + self.query: QueryBarKeys.QUERY, + self.cancel: QueryBarKeys.CANCEL + } diff --git a/src/pyfx/view/json_lib/__init__.py b/src/pyfx/view/json_lib/__init__.py index b01d3ff..37ebb2b 100644 --- a/src/pyfx/view/json_lib/__init__.py +++ b/src/pyfx/view/json_lib/__init__.py @@ -41,5 +41,5 @@ For each leaf node, it implements :py:class:`.json_simple_node.JSONSimpleNode`. """ from .json_listbox import JSONListBox -from .json_listbox import JSONListWalker +from .json_listwalker import JSONListWalker from .node_factory import NodeFactory diff --git a/src/pyfx/view/json_lib/json_listbox.py b/src/pyfx/view/json_lib/json_listbox.py index 717ac24..8f2471d 100644 --- a/src/pyfx/view/json_lib/json_listbox.py +++ b/src/pyfx/view/json_lib/json_listbox.py @@ -1,19 +1,13 @@ import urwid from loguru import logger from overrides import overrides -from urwid import CURSOR_UP, CURSOR_DOWN, ACTIVATE - -from .json_listwalker import JSONListWalker class JSONListBox(urwid.ListBox): """ a ListBox with special handling for navigation and collapsing of JSONWidgets """ - - def __init__(self, - walker: JSONListWalker - ): + def __init__(self, walker): # set body to JSONListWalker super().__init__(walker) @@ -35,13 +29,13 @@ def keypress(self, size, key): self.make_cursor_visible((maxcol, maxrow)) return None - if self._command_map[key] == CURSOR_UP: + if key == "up": self.move_focus_to_prev_line(size) - elif self._command_map[key] == CURSOR_DOWN: + elif key == "down": self.move_focus_to_next_line(size) - elif self._command_map[key] == ACTIVATE: + elif key == "enter": self.toggle_collapse_on_focused_parent(size) return key @@ -55,6 +49,8 @@ def toggle_collapse_on_focused_parent(self, size): if not widget.is_expandable(): return + position.toggle_expanded() + if position.is_end_node() and (not position.is_expanded()): # switch to unexpanded widget when collapse on end widget self.change_focus(size, position.get_start_node()) diff --git a/src/pyfx/view/json_lib/json_widget.py b/src/pyfx/view/json_lib/json_widget.py index df3a994..8381acd 100644 --- a/src/pyfx/view/json_lib/json_widget.py +++ b/src/pyfx/view/json_lib/json_widget.py @@ -1,8 +1,5 @@ import urwid from overrides import overrides -from urwid import ACTIVATE - -from ..common import SelectableText class JSONWidget(urwid.WidgetWrap): @@ -168,17 +165,9 @@ def selectable(self): @overrides def keypress(self, size, key): """ - Handle expand & collapse requests (non-leaf nodes) + Delegate keypress into inner widget """ - if not self._expandable: - return key - if self._w.selectable(): key = self._w.keypress(size, key) - if key is None: - return None - if self._command_map[key] == ACTIVATE: - # toggle expanded - self._node.toggle_expanded() return key diff --git a/src/pyfx/view/keymapper/__init__.py b/src/pyfx/view/keymapper/__init__.py new file mode 100644 index 0000000..1f81839 --- /dev/null +++ b/src/pyfx/view/keymapper/__init__.py @@ -0,0 +1,3 @@ +from .abstract_component_keymapper import AbstractComponentKeyMapper +from .keymapper_config_parser import KeyMapperConfigurationParser +from .keymapper_config import KeyMapperConfiguration diff --git a/src/pyfx/view/keymapper/abstract_component_keymapper.py b/src/pyfx/view/keymapper/abstract_component_keymapper.py new file mode 100644 index 0000000..c359071 --- /dev/null +++ b/src/pyfx/view/keymapper/abstract_component_keymapper.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AbstractComponentKeyMapper(ABC): + """ + Base Key Mapping + """ + + @property + @abstractmethod + def mapped_key(self): + raise NotImplementedError(f"mapped_key is not implemented in {type(self)}") + + def key(self, key): + if key in self.mapped_key: + return self.mapped_key[key].value + return key diff --git a/src/pyfx/view/keymapper/keymapper.py b/src/pyfx/view/keymapper/keymapper.py new file mode 100644 index 0000000..868dd41 --- /dev/null +++ b/src/pyfx/view/keymapper/keymapper.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from ..components.autocomplete_popup.autocomplete_popup_keymapper import AutoCompletePopUpKeyMapper +from ..components.json_browser.json_browser_keymapper import JSONBrowserKeyMapper +from ..components.query_bar.query_bar_keymapper import QueryBarKeyMapper + + +@dataclass(frozen=True) +class KeyMapper: + json_browser: JSONBrowserKeyMapper = JSONBrowserKeyMapper() + query_bar: QueryBarKeyMapper = QueryBarKeyMapper() + autocomplete_popup: AutoCompletePopUpKeyMapper = AutoCompletePopUpKeyMapper() diff --git a/src/pyfx/view/keymapper/keymapper_config.py b/src/pyfx/view/keymapper/keymapper_config.py new file mode 100644 index 0000000..ace1e36 --- /dev/null +++ b/src/pyfx/view/keymapper/keymapper_config.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class KeyMapperConfiguration: + mode: str = "basic" diff --git a/src/pyfx/view/keymapper/keymapper_config_parser.py b/src/pyfx/view/keymapper/keymapper_config_parser.py new file mode 100644 index 0000000..186489f --- /dev/null +++ b/src/pyfx/view/keymapper/keymapper_config_parser.py @@ -0,0 +1,38 @@ +import pathlib + +import dacite +import yaml + +try: + from yaml import CLoader as Loader, CDumper as Dumper +except ImportError: + from yaml import Loader, Dumper + +from .keymapper import KeyMapper + + +HERE = pathlib.Path(__file__).parent.resolve() + + +class KeyMapperConfigurationParser: + """ Configuration parser that create keymappers from configuration """ + modes = { + "basic": None, + "emacs": HERE / "modes" / "emacs.yml", + "vim": HERE / "modes" / "vim.yml" + } + + @staticmethod + def create_keymapper(config): + mode_config = KeyMapperConfigurationParser.modes[config.mode] + if mode_config is None: + return KeyMapper() + + keymapper = KeyMapperConfigurationParser._load_yaml(mode_config) + return dacite.from_dict(data_class=KeyMapper, data=keymapper) + + @staticmethod + def _load_yaml(mode_config): + with mode_config.open() as f: + return yaml.load(f, Loader=Loader) + diff --git a/src/pyfx/view/keymapper/modes/emacs.yml b/src/pyfx/view/keymapper/modes/emacs.yml new file mode 100644 index 0000000..0394fee --- /dev/null +++ b/src/pyfx/view/keymapper/modes/emacs.yml @@ -0,0 +1,15 @@ +autocomplete_popup: + cursor_down: "meta n" + cursor_up: "meta p" + select: "enter" + cancel: "ctrl g" + +json_browser: + cursor_down: "ctrl n" + cursor_up: "ctrl p" + toggle_expansion: "enter" + open_query_bar: "meta x" + +query_bar: + query: "enter" + cancel: "ctrl g" diff --git a/src/pyfx/view/keymapper/modes/vim.yml b/src/pyfx/view/keymapper/modes/vim.yml new file mode 100644 index 0000000..c18fa5a --- /dev/null +++ b/src/pyfx/view/keymapper/modes/vim.yml @@ -0,0 +1,15 @@ +autocomplete_popup: + cursor_down: "j" + cursor_up: "k" + select: "enter" + cancel: "esc" + +json_browser: + cursor_down: "j" + cursor_up: "k" + toggle_expansion: "enter" + open_query_bar: ":" + +query_bar: + query: "enter" + cancel: "esc" \ No newline at end of file diff --git a/src/pyfx/view/view_frame.py b/src/pyfx/view/view_frame.py index e509d1f..bf88ddd 100644 --- a/src/pyfx/view/view_frame.py +++ b/src/pyfx/view/view_frame.py @@ -5,7 +5,6 @@ from overrides import overrides from .common import PopUpLauncher -from .components import AutoCompletePopUp class ViewFrame(PopUpLauncher): @@ -13,8 +12,8 @@ class ViewFrame(PopUpLauncher): A wrapper of the frame as the main UI of `pyfx`. """ - def __init__(self, controller, body, footer): - self._controller = controller + def __init__(self, body, footer, popup_factory): + self.popup_factory = popup_factory super().__init__(urwid.Frame(body, footer=footer)) def change_widget(self, widget, area): @@ -31,7 +30,7 @@ def change_focus(self, area): @overrides def create_pop_up(self, widget, prefix, options): - return AutoCompletePopUp(self, self._controller, widget, prefix, options) + return self.popup_factory(self, widget, prefix, options) @overrides def get_pop_up_parameters(self, size): diff --git a/src/pyfx/view/view_manager.py b/src/pyfx/view/view_manager.py index 00c98fc..9a64fdd 100644 --- a/src/pyfx/view/view_manager.py +++ b/src/pyfx/view/view_manager.py @@ -1,9 +1,11 @@ import urwid from loguru import logger -from .components import HelpWindow -from .components import QueryWindow -from .components import ViewWindow +from .components import AutoCompletePopUp +from .components import HelpBar +from .components import JSONBrowser +from .components import QueryBar +from .keymapper import KeyMapperConfigurationParser from .view_frame import FocusArea from .view_frame import ViewFrame @@ -28,21 +30,27 @@ class View: ('focus', 'light gray', 'dark blue', 'standout') ] - def __init__(self, controller): + def __init__(self, controller, config): """ :param controller: The controller/presenter used in `pyfx` to initialize data change. :type controller: :py:class:`pyfx.core.Controller` """ self._controller = controller + self._config = config + self._data = None + self._keymapper = KeyMapperConfigurationParser.create_keymapper(self._config.keymap) # different window components - self._view_window = ViewWindow(self, self._data) - self._query_window = QueryWindow(self, controller) - self._help_window = HelpWindow(self) + self._view_window = JSONBrowser(self, self._keymapper.json_browser, self._data) + self._query_window = QueryBar(self, controller, self._keymapper.query_bar) + self._help_window = HelpBar(self) # view frame - self._frame = ViewFrame(self._controller, self._view_window, self._help_window) + def popup_factory(popup_launcher, query_bar, prefix, options): + return AutoCompletePopUp(controller, self._keymapper.autocomplete_popup, popup_launcher, query_bar, + prefix, options) + self._frame = ViewFrame(self._view_window, self._help_window, popup_factory) self._screen = None self._loop = None diff --git a/tests/view/component/test_view_window.py b/tests/view/component/test_json_browser.py similarity index 70% rename from tests/view/component/test_view_window.py rename to tests/view/component/test_json_browser.py index 8bd779e..c8e2554 100644 --- a/tests/view/component/test_view_window.py +++ b/tests/view/component/test_json_browser.py @@ -1,10 +1,11 @@ import unittest -from pyfx import Controller -from pyfx.view import View -from pyfx.view.components import ViewWindow from urwid.compat import B +from pyfx import Controller +from pyfx.config import ConfigurationParser +from pyfx.view.components import JSONBrowser + class ViewWindowTest(unittest.TestCase): """ @@ -18,20 +19,23 @@ def test_view_window_refresh(self): } ] - controller = Controller() - view_manager = View(controller) - view_window = ViewWindow(view_manager, data) + config = ConfigurationParser().parse() + + controller = Controller(config) + view_manager = controller._view + keymapper = view_manager._keymapper.json_browser + json_browser = JSONBrowser(view_manager, keymapper, data) # expand the first line - content = view_window.render((18, 3)).content() + content = json_browser.render((18, 3)).content() texts_before_refresh = [[t[2] for t in row] for row in content] # refresh view window new_data = { "key": "value" } - view_window.set_top_node(new_data) - content = view_window.render((18, 3)).content() + json_browser.set_top_node(new_data) + content = json_browser.render((18, 3)).content() texts_after_refresh = [[t[2] for t in row] for row in content] # verify diff --git a/tests/view/component/test_query_window.py b/tests/view/component/test_query_bar.py similarity index 68% rename from tests/view/component/test_query_window.py rename to tests/view/component/test_query_bar.py index 02e8581..a2124a0 100644 --- a/tests/view/component/test_query_window.py +++ b/tests/view/component/test_query_bar.py @@ -1,9 +1,9 @@ import unittest from unittest.mock import MagicMock +from pyfx.config import ConfigurationParser from pyfx.core import Controller -from pyfx.view import View -from pyfx.view.components import QueryWindow +from pyfx.view.components import QueryBar class QueryWindowTest(unittest.TestCase): @@ -15,12 +15,15 @@ def test_query_on_enter(self): """ test query window submit query to controller """ - controller = Controller() + config = ConfigurationParser().parse() + + controller = Controller(config) controller.query = MagicMock() controller.complete = MagicMock(return_value=None) - view_manager = View(controller) - query_window = QueryWindow(view_manager, controller) + view_manager = controller._view + keymapper = view_manager._keymapper.query_bar + query_window = QueryBar(view_manager, controller, keymapper) query_window.setup() # act @@ -36,12 +39,15 @@ def test_query_on_esc(self): """ test query window submit query to controller """ - controller = Controller() + config = ConfigurationParser().parse() + + controller = Controller(config) controller.query = MagicMock() controller.complete = MagicMock(return_value=None) - view_manager = View(controller) - query_window = QueryWindow(view_manager, controller) + view_manager = controller._view + keymapper = view_manager._keymapper.query_bar + query_window = QueryBar(view_manager, controller, keymapper) query_window.setup() # act diff --git a/tests/view/json-lib/array/test_array_node.py b/tests/view/json-lib/array/test_array_node.py index 2586e85..05dc56a 100644 --- a/tests/view/json-lib/array/test_array_node.py +++ b/tests/view/json-lib/array/test_array_node.py @@ -21,7 +21,7 @@ def test_empty_list(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder() @@ -66,7 +66,7 @@ def test_simple_array(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder() @@ -106,7 +106,7 @@ def test_nested_array(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder() @@ -148,7 +148,7 @@ def test_array_with_object_child(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder() diff --git a/tests/view/json-lib/object/test_object_node.py b/tests/view/json-lib/object/test_object_node.py index b579f67..5e02b5b 100644 --- a/tests/view/json-lib/object/test_object_node.py +++ b/tests/view/json-lib/object/test_object_node.py @@ -21,7 +21,7 @@ def test_empty_object(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder() @@ -62,7 +62,7 @@ def test_simple_object(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder() @@ -98,7 +98,7 @@ def test_nested_object(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder() @@ -135,7 +135,7 @@ def test_object_with_list_child(self): while widget is not None: node = widget.get_node() if not node.is_expanded(): - widget.keypress((18,), "enter") + node.toggle_expanded() widget = node.get_widget() contents.append(widget.render((18,)).content()) widget = widget.next_inorder()