diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c06f6a8 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,33 @@ +[run] +branch = True +parallel = True +source = . +omit = + .tox/* + /usr/* + setup.py + # Don't complain if non-runnable code isn't run + */__main__.py + +[report] +show_missing = True +skip_covered = True +exclude_lines = + # Have to re-enable the standard pragma + \#\s*pragma: no cover + # We optionally substitute this + ${COVERAGE_IGNORE_WINDOWS} + + # Don't complain if tests don't hit defensive assertion code: + ^\s*raise AssertionError\b + ^\s*raise NotImplementedError\b + ^\s*return NotImplemented\b + ^\s*raise$ + + # Don't complain if non-runnable code isn't run: + ^if __name__ == ['"]__main__['"]:$ + +[html] +directory = coverage-html + +# vim:ft=dosini diff --git a/.gitignore b/.gitignore index 020de35..9513297 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ +*.egg-info +*.pyc +/.coverage +/.coverage.* /.mypy_cache +/.pytest_cache +/.tox +/venv* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a4128d --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Anthony Sottile + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f16d44 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +babi +==== + +a text editor, eventually... + +### why is it called babi? + +I usually use the text editor `nano`, frequently I typo this. on a qwerty +keyboard, when the right hand is shifted left by one, `nano` becomes `babi`. diff --git a/babi.py b/babi.py index 368a3ae..d5f52d3 100644 --- a/babi.py +++ b/babi.py @@ -4,6 +4,8 @@ import curses from typing import Dict from typing import Tuple +VERSION_STR = 'babi v0' + def _get_color_pair_mapping() -> Dict[Tuple[int, int], int]: ret = {} @@ -63,18 +65,78 @@ def _color_test(stdscr: '_curses._CursesWindow') -> None: x += 4 y += 1 x = 0 + stdscr.get_wch() + + +def _write_header( + stdscr: '_curses._CursesWindow', + filename: str, + *, + modified: bool, +) -> None: + filename = filename or '<>' + if modified: + filename += ' *' + centered_filename = filename.center(curses.COLS)[len(VERSION_STR) + 2:] + s = f' {VERSION_STR} {centered_filename}' + stdscr.addstr(0, 0, s, curses.A_REVERSE) + + +def _write_status(stdscr: '_curses._CursesWindow', status: str) -> None: + stdscr.addstr(curses.LINES - 1, 0, ' ' * (curses.COLS - 1)) + if status: + status = f' {status} ' + offset = (curses.COLS - len(status)) // 2 + stdscr.addstr(curses.LINES - 1, offset, status, curses.A_REVERSE) def c_main(stdscr: '_curses._CursesWindow', args: argparse.Namespace) -> None: _init_colors(stdscr) + if args.color_test: - _color_test(stdscr) - stdscr.getch() + return _color_test(stdscr) + + filename = args.filename + status = '' + status_action_counter = -1 + position_y, position_x = 0, 0 + + def _set_status(s: str) -> None: + nonlocal status, status_action_counter + status = s + status_action_counter = 25 + + while True: + if status_action_counter == 0: + status = '' + status_action_counter -= 1 + + _write_header(stdscr, filename, modified=False) + _write_status(stdscr, status) + stdscr.move(position_y + 1, position_x) + + wch = stdscr.get_wch() + key = wch if isinstance(wch, int) else ord(wch) + keyname = curses.keyname(key) + + if key == curses.KEY_DOWN: + position_y = min(position_y + 1, curses.LINES - 2) + elif key == curses.KEY_UP: + position_y = max(position_y - 1, 0) + elif key == curses.KEY_RIGHT: + position_x = min(position_x + 1, curses.COLS - 1) + elif key == curses.KEY_LEFT: + position_x = max(position_x - 1, 0) + elif keyname == b'^X': + return + else: + _set_status(f'unknown key: {keyname} ({key})') def main() -> int: parser = argparse.ArgumentParser() parser.add_argument('--color-test', action='store_true') + parser.add_argument('filename', nargs='?') args = parser.parse_args() curses.wrapper(c_main, args) return 0 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b0200a8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +coverage +git+https://github.com/ionelmc/python-remote-pdb@a5469c2d +git+https://github.com/mjsir911/hecate@092f811 +pytest diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..d9ecaa6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,43 @@ +[metadata] +name = babi +version = 0.0.0 +description = a text editor +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/asottile/babi +author = Anthony Sottile +author_email = asottile@umich.edu +license = MIT +license_file = LICENSE +classifiers = + License :: OSI Approved :: MIT License + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: Implementation :: CPython + Programming Language :: Python :: Implementation :: PyPy + +[options] +py_modules = babi +python_requires = >=3.6 + +[options.entry_points] +console_scripts = + babi = babi:main + +[bdist_wheel] +universal = True + +[mypy] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true + +[mypy-testing.*] +disallow_untyped_defs = false + +[mypy-tests.*] +disallow_untyped_defs = false diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..8bf1ba9 --- /dev/null +++ b/setup.py @@ -0,0 +1,2 @@ +from setuptools import setup +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/babi_test.py b/tests/babi_test.py new file mode 100644 index 0000000..542fb28 --- /dev/null +++ b/tests/babi_test.py @@ -0,0 +1,53 @@ +import contextlib +import sys + +from hecate import Runner + +import babi + + +@contextlib.contextmanager +def run(*args, **kwargs): + cmd = (sys.executable, '-mcoverage', 'run', '-m', 'babi', *args) + with Runner(*cmd, **kwargs) as h: + h.await_text(babi.VERSION_STR) + yield h + + +def await_text_missing(h, text): + """largely based on await_text""" + for _ in h.poll_until_timeout(): + screen = h.screenshot() + munged = screen.replace('\n', '') + if text not in munged: # pragma: no branch + return + raise AssertionError(f'Timeout while waiting for text {text!r} to appear') + + +def test_window_bounds(tmpdir): + f = tmpdir.join('f.txt') + f.write(f'{"x" * 40}\n' * 40) + + with run(str(f), width=30, height=30) as h: + # make sure we don't go off the top left of the screen + h.press('LEFT') + h.press('UP') + # make sure we don't go off the bottom of the screen + for i in range(32): + h.press('RIGHT') + h.press('DOWN') + h.press('C-x') + h.await_exit() + + +def test_status_clearing_behaviour(): + with run() as h: + h.press('C-j') + h.await_text('unknown key') + for i in range(24): + h.press('LEFT') + h.await_text('unknown key') + h.press('LEFT') + await_text_missing(h, 'unknown key') + h.press('C-x') + h.await_exit() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..2645b6e --- /dev/null +++ b/tox.ini @@ -0,0 +1,15 @@ +[tox] +envlist = pypy3,py36,py37,pre-commit + +[testenv] +deps = -rrequirements-dev.txt +commands = + coverage erase + coverage run -m pytest {posargs:tests} + coverage combine + coverage report + +[testenv:pre-commit] +skip_install = true +deps = pre-commit +commands = pre-commit run --all-files --show-diff-on-failure