From 28e440d672f76deed1ba01db6b7d2b649699b956 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 19:32:12 -0400 Subject: [PATCH 01/26] python 3.11 testing --- .github/workflows/autofix.yml | 2 +- .github/workflows/check.yml | 9 +++++---- .github/workflows/release-builds.yml | 2 +- tests/test_jump_to_definition_plugin.py | 8 ++++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index f06d095ff..45a56cd42 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -19,7 +19,7 @@ jobs: path: ./pr - uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" cache: pip - run: pip install wheel - run: pip install -r requirements-dev.txt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 40f03e58f..6e1bc2f26 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" cache: pip - run: pip install wheel - run: pip install -r requirements.txt -r requirements-dev.txt @@ -26,13 +26,14 @@ jobs: time mypy --platform darwin --python-version 3.8 porcupine docs/extensions.py time mypy --platform darwin --python-version 3.9 porcupine docs/extensions.py time mypy --platform darwin --python-version 3.10 porcupine docs/extensions.py + time mypy --platform darwin --python-version 3.11 porcupine docs/extensions.py pytest: timeout-minutes: 10 strategy: matrix: os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 @@ -60,7 +61,7 @@ jobs: runs-on: macos-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] env: # TODO: how to install tkdnd on mac? add instructions to README or make mac app that bundles it TCLLIBPATH: ./lib @@ -82,7 +83,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" cache: pip # TODO: adding these to requirements-dev.txt breaks pip install - run: pip install flake8==5.0.4 flake8-tkinter==0.5.0 diff --git a/.github/workflows/release-builds.yml b/.github/workflows/release-builds.yml index c55d76781..177f405a2 100644 --- a/.github/workflows/release-builds.yml +++ b/.github/workflows/release-builds.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" cache: pip - run: pip install wheel - run: pip install -r requirements.txt -r requirements-dev.txt diff --git a/tests/test_jump_to_definition_plugin.py b/tests/test_jump_to_definition_plugin.py index b0c6c8692..9b37744f7 100644 --- a/tests/test_jump_to_definition_plugin.py +++ b/tests/test_jump_to_definition_plugin.py @@ -1,11 +1,19 @@ # TODO: create much more tests for langserver +import platform import time +import pytest from sansio_lsp_client import ClientState from porcupine import get_main_window from porcupine.plugins.langserver import langservers +pytestmark = pytest.mark.xfail( + platform.python_version_tuple()[:2] == ("3", "11"), + run=False, + reason="https://github.com/Akuli/porcupine/issues/1300", +) + def langserver_started(filetab): return lambda: any( From 6fe65ebeaa12ef359409c883b6e8921ac32fe6ce Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 19:32:21 -0400 Subject: [PATCH 02/26] improve headless testing --- tests/test_fullscreen_plugin.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_fullscreen_plugin.py b/tests/test_fullscreen_plugin.py index 56f9fec34..95bd6c750 100644 --- a/tests/test_fullscreen_plugin.py +++ b/tests/test_fullscreen_plugin.py @@ -5,8 +5,13 @@ from porcupine import get_main_window from porcupine.menubar import get_menu +try: + xvfb_status = "xvfb" in os.environ["XAUTHORITY"] +except KeyError: + xvfb = False -@pytest.mark.skipif(os.getenv("GITHUB_ACTIONS") == "true", reason="fails CI on all platforms") + +@pytest.mark.skipif(xvfb_status, reason="fails CI on all platforms") def test_basic(wait_until): assert not get_main_window().attributes("-fullscreen") @@ -18,7 +23,7 @@ def test_basic(wait_until): # Window managers can toggle full-screen-ness without going through our menubar -@pytest.mark.skipif(os.getenv("GITHUB_ACTIONS") == "true", reason="fails CI on all platforms") +@pytest.mark.skipif(xvfb_status, reason="fails CI on all platforms") def test_toggled_without_menu_bar(wait_until): get_main_window().attributes("-fullscreen", 1) wait_until(lambda: bool(get_main_window().attributes("-fullscreen"))) From 1c41dbe378e2242592a94feeb162ec12c39e905d Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 19:50:12 -0400 Subject: [PATCH 03/26] fix headless detection --- tests/test_fullscreen_plugin.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_fullscreen_plugin.py b/tests/test_fullscreen_plugin.py index 95bd6c750..407a016ac 100644 --- a/tests/test_fullscreen_plugin.py +++ b/tests/test_fullscreen_plugin.py @@ -5,13 +5,20 @@ from porcupine import get_main_window from porcupine.menubar import get_menu -try: - xvfb_status = "xvfb" in os.environ["XAUTHORITY"] -except KeyError: - xvfb = False +github_actions = os.getenv("GITHUB_ACTIONS") +xauthority = os.getenv("XAUTHORITY") + +headless = False + +if github_actions is not None: + headless = github_actions == "true" +elif xauthority is not None: + headless = "xvfb" in xauthority + + +pytestmark = pytest.mark.skipif(headless, reason="Does not work in headless environments") -@pytest.mark.skipif(xvfb_status, reason="fails CI on all platforms") def test_basic(wait_until): assert not get_main_window().attributes("-fullscreen") @@ -23,7 +30,6 @@ def test_basic(wait_until): # Window managers can toggle full-screen-ness without going through our menubar -@pytest.mark.skipif(xvfb_status, reason="fails CI on all platforms") def test_toggled_without_menu_bar(wait_until): get_main_window().attributes("-fullscreen", 1) wait_until(lambda: bool(get_main_window().attributes("-fullscreen"))) From e844415f59f4ed2fbed7107e66aee656a4951724 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Thu, 29 Jun 2023 22:34:43 -0400 Subject: [PATCH 04/26] add .venv to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cb4a5e0a5..553779059 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ celerybeat-schedule .env # virtualenv +.venv/ venv/ ENV/ env*/ From 16bf54eae72fac62ab0dea7b157e1e873ee6f142 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Fri, 30 Jun 2023 19:02:56 -0400 Subject: [PATCH 05/26] markdown plugin --- porcupine/plugins/markdown.py | 56 +++++++++++ tests/test_markdown_plugin.py | 183 ++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 porcupine/plugins/markdown.py create mode 100644 tests/test_markdown_plugin.py diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py new file mode 100644 index 000000000..f51fe3cf8 --- /dev/null +++ b/porcupine/plugins/markdown.py @@ -0,0 +1,56 @@ +"""If configuration says so, insert spaces when the tab key is pressed.""" + +from __future__ import annotations + +import logging +import re +import tkinter +from functools import partial + +from porcupine import get_tab_manager, tabs, textutils, utils + +log = logging.getLogger(__name__) + + +setup_before = ["tabs2spaces"] + + +def _is_list_item(line: str) -> bool: + """Detect if the line that is passed is a markdown list item + + According to: + - https://spec.commonmark.org/0.30/#lists + - https://pandoc.org/MANUAL.html#lists + """ + assert len(line.splitlines()) == 1 + pattern = r"^\s*\d{1,9}[.)]|^\s*[-+*]|^\s*#\)|^\s*#\." + regex = re.compile(pattern) + match = regex.search(line) + return bool(match) + + +def on_tab_key( + tab: tabs.FileTab, + event: tkinter.Event[textutils.MainText], + shift_pressed: bool, +) -> str: + """Indenting and dedenting list items""" + if tab.settings.get("filetype_name", str) == "Markdown": + line = event.widget.get("insert linestart", "insert lineend") + list_item_status = _is_list_item(line) + + if shift_pressed and list_item_status: + event.widget.dedent("insert linestart") + return "break" + + if list_item_status: + event.widget.indent("insert linestart") + return "break" + + +def on_new_filetab(tab: tabs.FileTab) -> None: + utils.bind_tab_key(tab.textwidget, partial(on_tab_key, tab), add=True) + + +def setup() -> None: + get_tab_manager().add_filetab_callback(on_new_filetab) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py new file mode 100644 index 000000000..d5d8cce0e --- /dev/null +++ b/tests/test_markdown_plugin.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import itertools +from contextlib import nullcontext as does_not_raise +from typing import NamedTuple + +import pytest + +from porcupine.plugins import markdown + + +class IsListItemCase(NamedTuple): + id: str + line: str + expected: bool + marks: list[pytest.MarkDecorator] = [] + raises: Exception | None = None + + +IS_LIST_ITEM_CASES = [ + IsListItemCase(id="# with no separator", line="# item 1", expected=False), + IsListItemCase(id="# bad separator |", line="#| item 1", expected=False), + IsListItemCase(id="# bad separator /", line="#/ item 1", expected=False), + IsListItemCase(id="# bad separator \\", line="#\\ item 1", expected=False), + IsListItemCase(id="ol bad separator |", line="8| item 1", expected=False), + IsListItemCase(id="ol bad separator /", line="8/ item 1", expected=False), + IsListItemCase( + id="ol bad separator \\", line="8\\ item 1", expected=False + ), + IsListItemCase(id="not a list 1", line="item 1", expected=False), + IsListItemCase(id="not a list 2", line=" item 1", expected=False), + IsListItemCase(id="not a list 3", line=" item 1", expected=False), + IsListItemCase(id="not a list 4", line="& item 1", expected=False), + IsListItemCase(id="not a list 5", line="^ item 1", expected=False), + IsListItemCase(id="duplicate token 1", line="-- item 1", expected=True), + IsListItemCase(id="duplicate token 2", line="--- item 1", expected=True), + IsListItemCase(id="duplicate token 3", line="- - - item 1", expected=True), + IsListItemCase( + id="duplicate token 4", line=" - item -- 1 -", expected=True + ), + IsListItemCase( + id="duplicate token 5", line=" -#) item -- 1 -", expected=True + ), + IsListItemCase( + id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=True + ), +] + +# test `#` and 0 to 99 numbered lists +# tests ol with `.` and `)` +IS_LIST_ITEM_CASES.extend( + [ + IsListItemCase( + id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True + ) + for i, sep in itertools.product( + itertools.chain(range(100), "#"), (".", ")") + ) + ] +) + +# test numbered list with whitespace following and preceding +IS_LIST_ITEM_CASES.extend( + [ + IsListItemCase( + id=f"numbered {preceding=} {following=} space", + line=f"{' ' * preceding}{i}{sep}{' ' * following} item 1", + expected=True, + ) + for i, sep, preceding, following in itertools.product( + ("7", "#"), (".", ")"), range(11), range(11) + ) + ] +) + +# test with whitespace following and preceding +IS_LIST_ITEM_CASES.extend( + [ + IsListItemCase( + id=f"bullet {preceding=} {following=} space", + line=f"{' ' * preceding}{bullet} {' ' * following} item 1", + expected=True, + ) + for bullet, preceding, following in itertools.product( + ("-", "*", "+"), range(11), range(11) + ) + ] +) + + +@pytest.mark.parametrize( + "line, expected, raises", + [ + pytest.param( + case.line, + case.expected, + pytest.raises(case.raises) if case.raises else does_not_raise(), + marks=case.marks, + id=case.id, + ) + for case in IS_LIST_ITEM_CASES + ], +) +def test_is_list(line: str, expected: bool, raises): + with raises: + assert markdown._is_list_item(line) == expected + + +@pytest.mark.parametrize( + "li", + [ + "1. item 1", + "1) item 1", + "#) item 1", + "- item 1", + "* item 1", + "+ item 1", + "++++++ weird", + "1)))))) still weird", + "- [ ] unchecked task", + "- [X] checked task", + ], +) +def test_filetype_switching(li: str, filetab, tmp_path): + assert filetab.settings.get("filetype_name", object) == "Python" + + filetab.textwidget.insert("1.0", li) + filetab.textwidget.event_generate("") + filetab.update() + assert ( + filetab.textwidget.get("1.0", "end - 1 char") == li + ), "should not effect list items unless using markdown filetype" + filetab.textwidget.event_generate("") # close the autocomplete + + # switch to Markdown filetype format + filetab.save_as(tmp_path / "asdf.md") + assert filetab.settings.get("filetype_name", object) == "Markdown" + + filetab.textwidget.event_generate("") + filetab.update() + assert ( + filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n" + ), "should indent" + filetab.textwidget.event_generate("") + filetab.update() + assert ( + filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n" + ), "should dedent" + + +@pytest.mark.parametrize( + "line", + [ + "# H1 Heading", + "## H2 Heading", + " ### H3 Heading with whitespace preceding", + "| Markdown | Table |", + "| :---------------- | :------: | ----: |", + "```python", + '

My Great Heading

', + ": This is the definition", + "~~The world is flat.~~", + "==very important words==", + "X^2^", + "http://www.example.com", + "`http://www.example.com`", + ], +) +def test_non_list(line: str, filetab, tmp_path): + import time + + # switch to Markdown filetype format + filetab.save_as(tmp_path / "asdf.md") + assert filetab.settings.get("filetype_name", object) == "Markdown" + + filetab.textwidget.insert("1.0", line) + filetab.textwidget.event_generate("") + filetab.textwidget.event_generate("") # close the autocomplete + filetab.update() + # time.sleep(3) + assert ( + filetab.textwidget.get("1.0", "end - 1 char") == f"{line}\n" + ), "should not change, just open autocomplete" From b5de9eb46b8c2b3aad418148577686ebc4972c29 Mon Sep 17 00:00:00 2001 From: benjamin-kirkbride Date: Fri, 30 Jun 2023 23:10:47 +0000 Subject: [PATCH 06/26] Run pycln, black and isort --- porcupine/plugins/markdown.py | 4 +--- tests/test_markdown_plugin.py | 38 +++++++++-------------------------- 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index f51fe3cf8..446f4dbe3 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -30,9 +30,7 @@ def _is_list_item(line: str) -> bool: def on_tab_key( - tab: tabs.FileTab, - event: tkinter.Event[textutils.MainText], - shift_pressed: bool, + tab: tabs.FileTab, event: tkinter.Event[textutils.MainText], shift_pressed: bool ) -> str: """Indenting and dedenting list items""" if tab.settings.get("filetype_name", str) == "Markdown": diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index d5d8cce0e..4d3c6de01 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -24,9 +24,7 @@ class IsListItemCase(NamedTuple): IsListItemCase(id="# bad separator \\", line="#\\ item 1", expected=False), IsListItemCase(id="ol bad separator |", line="8| item 1", expected=False), IsListItemCase(id="ol bad separator /", line="8/ item 1", expected=False), - IsListItemCase( - id="ol bad separator \\", line="8\\ item 1", expected=False - ), + IsListItemCase(id="ol bad separator \\", line="8\\ item 1", expected=False), IsListItemCase(id="not a list 1", line="item 1", expected=False), IsListItemCase(id="not a list 2", line=" item 1", expected=False), IsListItemCase(id="not a list 3", line=" item 1", expected=False), @@ -35,27 +33,17 @@ class IsListItemCase(NamedTuple): IsListItemCase(id="duplicate token 1", line="-- item 1", expected=True), IsListItemCase(id="duplicate token 2", line="--- item 1", expected=True), IsListItemCase(id="duplicate token 3", line="- - - item 1", expected=True), - IsListItemCase( - id="duplicate token 4", line=" - item -- 1 -", expected=True - ), - IsListItemCase( - id="duplicate token 5", line=" -#) item -- 1 -", expected=True - ), - IsListItemCase( - id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=True - ), + IsListItemCase(id="duplicate token 4", line=" - item -- 1 -", expected=True), + IsListItemCase(id="duplicate token 5", line=" -#) item -- 1 -", expected=True), + IsListItemCase(id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=True), ] # test `#` and 0 to 99 numbered lists # tests ol with `.` and `)` IS_LIST_ITEM_CASES.extend( [ - IsListItemCase( - id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True - ) - for i, sep in itertools.product( - itertools.chain(range(100), "#"), (".", ")") - ) + IsListItemCase(id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True) + for i, sep in itertools.product(itertools.chain(range(100), "#"), (".", ")")) ] ) @@ -81,9 +69,7 @@ class IsListItemCase(NamedTuple): line=f"{' ' * preceding}{bullet} {' ' * following} item 1", expected=True, ) - for bullet, preceding, following in itertools.product( - ("-", "*", "+"), range(11), range(11) - ) + for bullet, preceding, following in itertools.product(("-", "*", "+"), range(11), range(11)) ] ) @@ -138,14 +124,10 @@ def test_filetype_switching(li: str, filetab, tmp_path): filetab.textwidget.event_generate("") filetab.update() - assert ( - filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n" - ), "should indent" + assert filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n", "should indent" filetab.textwidget.event_generate("") filetab.update() - assert ( - filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n" - ), "should dedent" + assert filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n", "should dedent" @pytest.mark.parametrize( @@ -167,8 +149,6 @@ def test_filetype_switching(li: str, filetab, tmp_path): ], ) def test_non_list(line: str, filetab, tmp_path): - import time - # switch to Markdown filetype format filetab.save_as(tmp_path / "asdf.md") assert filetab.settings.get("filetype_name", object) == "Markdown" From 5c81b9782583983cc65b267335ca4cdfc36718f0 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Fri, 30 Jun 2023 21:04:26 -0400 Subject: [PATCH 07/26] add explicit return --- porcupine/plugins/markdown.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 446f4dbe3..2dcba8362 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -30,8 +30,10 @@ def _is_list_item(line: str) -> bool: def on_tab_key( - tab: tabs.FileTab, event: tkinter.Event[textutils.MainText], shift_pressed: bool -) -> str: + tab: tabs.FileTab, + event: tkinter.Event[textutils.MainText], + shift_pressed: bool, +) -> str | None: """Indenting and dedenting list items""" if tab.settings.get("filetype_name", str) == "Markdown": line = event.widget.get("insert linestart", "insert lineend") @@ -45,6 +47,8 @@ def on_tab_key( event.widget.indent("insert linestart") return "break" + return None + def on_new_filetab(tab: tabs.FileTab) -> None: utils.bind_tab_key(tab.textwidget, partial(on_tab_key, tab), add=True) From 0a14f13ca301bfbe7f21defbcbfe24590a413593 Mon Sep 17 00:00:00 2001 From: benjamin-kirkbride Date: Sat, 1 Jul 2023 01:06:21 +0000 Subject: [PATCH 08/26] Run pycln, black and isort --- porcupine/plugins/markdown.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 2dcba8362..271b582e1 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -30,9 +30,7 @@ def _is_list_item(line: str) -> bool: def on_tab_key( - tab: tabs.FileTab, - event: tkinter.Event[textutils.MainText], - shift_pressed: bool, + tab: tabs.FileTab, event: tkinter.Event[textutils.MainText], shift_pressed: bool ) -> str | None: """Indenting and dedenting list items""" if tab.settings.get("filetype_name", str) == "Markdown": From 333688a9576f916307a315b60c0c3d65ed805902 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Fri, 30 Jun 2023 21:23:31 -0400 Subject: [PATCH 09/26] improve coverage and regex --- porcupine/plugins/markdown.py | 2 +- tests/test_markdown_plugin.py | 58 +++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 271b582e1..5bee4f566 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -23,7 +23,7 @@ def _is_list_item(line: str) -> bool: - https://pandoc.org/MANUAL.html#lists """ assert len(line.splitlines()) == 1 - pattern = r"^\s*\d{1,9}[.)]|^\s*[-+*]|^\s*#\)|^\s*#\." + pattern = r"(^\s*\d{1,9}[.)]|^\s*[-+*]|^\s*#\)|^\s*#\.) .*" regex = re.compile(pattern) match = regex.search(line) return bool(match) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index 4d3c6de01..682c3e1ab 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -24,26 +24,52 @@ class IsListItemCase(NamedTuple): IsListItemCase(id="# bad separator \\", line="#\\ item 1", expected=False), IsListItemCase(id="ol bad separator |", line="8| item 1", expected=False), IsListItemCase(id="ol bad separator /", line="8/ item 1", expected=False), - IsListItemCase(id="ol bad separator \\", line="8\\ item 1", expected=False), + IsListItemCase( + id="ol bad separator \\", line="8\\ item 1", expected=False + ), IsListItemCase(id="not a list 1", line="item 1", expected=False), IsListItemCase(id="not a list 2", line=" item 1", expected=False), IsListItemCase(id="not a list 3", line=" item 1", expected=False), IsListItemCase(id="not a list 4", line="& item 1", expected=False), IsListItemCase(id="not a list 5", line="^ item 1", expected=False), - IsListItemCase(id="duplicate token 1", line="-- item 1", expected=True), - IsListItemCase(id="duplicate token 2", line="--- item 1", expected=True), + IsListItemCase(id="duplicate token 1", line="-- item 1", expected=False), + IsListItemCase(id="duplicate token 2", line="--- item 1", expected=False), IsListItemCase(id="duplicate token 3", line="- - - item 1", expected=True), - IsListItemCase(id="duplicate token 4", line=" - item -- 1 -", expected=True), - IsListItemCase(id="duplicate token 5", line=" -#) item -- 1 -", expected=True), - IsListItemCase(id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=True), + IsListItemCase( + id="duplicate token 4", line=" - item -- 1 -", expected=True + ), + IsListItemCase( + id="duplicate token 5", line=" -#) item -- 1 -", expected=False + ), + IsListItemCase( + id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=False + ), ] # test `#` and 0 to 99 numbered lists # tests ol with `.` and `)` IS_LIST_ITEM_CASES.extend( [ - IsListItemCase(id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True) - for i, sep in itertools.product(itertools.chain(range(100), "#"), (".", ")")) + IsListItemCase( + id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True + ) + for i, sep in itertools.product( + itertools.chain(range(100), "#"), (".", ")") + ) + ] +) + +# test raw li prefixes with and without space +IS_LIST_ITEM_CASES.extend( + [ + IsListItemCase( + id=f"raw prexix {prefix} no space", + line=f"{prefix}{' ' if space else ''}", + expected=space, + ) + for prefix, space in itertools.product( + ["1.", "1)", "#.", "#)", "-", "*", "+"], [True, False] + ) ] ) @@ -69,7 +95,9 @@ class IsListItemCase(NamedTuple): line=f"{' ' * preceding}{bullet} {' ' * following} item 1", expected=True, ) - for bullet, preceding, following in itertools.product(("-", "*", "+"), range(11), range(11)) + for bullet, preceding, following in itertools.product( + ("-", "*", "+"), range(11), range(11) + ) ] ) @@ -101,8 +129,8 @@ def test_is_list(line: str, expected: bool, raises): "- item 1", "* item 1", "+ item 1", - "++++++ weird", - "1)))))) still weird", + "+ +++++ weird", + "1) ))))) still weird", "- [ ] unchecked task", "- [X] checked task", ], @@ -124,10 +152,14 @@ def test_filetype_switching(li: str, filetab, tmp_path): filetab.textwidget.event_generate("") filetab.update() - assert filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n", "should indent" + assert ( + filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n" + ), "should indent" filetab.textwidget.event_generate("") filetab.update() - assert filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n", "should dedent" + assert ( + filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n" + ), "should dedent" @pytest.mark.parametrize( From c87644229e1a9506191c386f43ead9fc4187b6bc Mon Sep 17 00:00:00 2001 From: benjamin-kirkbride Date: Sat, 1 Jul 2023 01:24:11 +0000 Subject: [PATCH 10/26] Run pycln, black and isort --- tests/test_markdown_plugin.py | 36 +++++++++-------------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index 682c3e1ab..222802859 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -24,9 +24,7 @@ class IsListItemCase(NamedTuple): IsListItemCase(id="# bad separator \\", line="#\\ item 1", expected=False), IsListItemCase(id="ol bad separator |", line="8| item 1", expected=False), IsListItemCase(id="ol bad separator /", line="8/ item 1", expected=False), - IsListItemCase( - id="ol bad separator \\", line="8\\ item 1", expected=False - ), + IsListItemCase(id="ol bad separator \\", line="8\\ item 1", expected=False), IsListItemCase(id="not a list 1", line="item 1", expected=False), IsListItemCase(id="not a list 2", line=" item 1", expected=False), IsListItemCase(id="not a list 3", line=" item 1", expected=False), @@ -35,27 +33,17 @@ class IsListItemCase(NamedTuple): IsListItemCase(id="duplicate token 1", line="-- item 1", expected=False), IsListItemCase(id="duplicate token 2", line="--- item 1", expected=False), IsListItemCase(id="duplicate token 3", line="- - - item 1", expected=True), - IsListItemCase( - id="duplicate token 4", line=" - item -- 1 -", expected=True - ), - IsListItemCase( - id="duplicate token 5", line=" -#) item -- 1 -", expected=False - ), - IsListItemCase( - id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=False - ), + IsListItemCase(id="duplicate token 4", line=" - item -- 1 -", expected=True), + IsListItemCase(id="duplicate token 5", line=" -#) item -- 1 -", expected=False), + IsListItemCase(id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=False), ] # test `#` and 0 to 99 numbered lists # tests ol with `.` and `)` IS_LIST_ITEM_CASES.extend( [ - IsListItemCase( - id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True - ) - for i, sep in itertools.product( - itertools.chain(range(100), "#"), (".", ")") - ) + IsListItemCase(id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True) + for i, sep in itertools.product(itertools.chain(range(100), "#"), (".", ")")) ] ) @@ -95,9 +83,7 @@ class IsListItemCase(NamedTuple): line=f"{' ' * preceding}{bullet} {' ' * following} item 1", expected=True, ) - for bullet, preceding, following in itertools.product( - ("-", "*", "+"), range(11), range(11) - ) + for bullet, preceding, following in itertools.product(("-", "*", "+"), range(11), range(11)) ] ) @@ -152,14 +138,10 @@ def test_filetype_switching(li: str, filetab, tmp_path): filetab.textwidget.event_generate("") filetab.update() - assert ( - filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n" - ), "should indent" + assert filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n", "should indent" filetab.textwidget.event_generate("") filetab.update() - assert ( - filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n" - ), "should dedent" + assert filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n", "should dedent" @pytest.mark.parametrize( From c525b06ba0d636ff181f739812bb61d5d13bf0a8 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Sat, 1 Jul 2023 16:56:00 -0400 Subject: [PATCH 11/26] remove autoindent regexes for markdown --- porcupine/default_filetypes.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/porcupine/default_filetypes.toml b/porcupine/default_filetypes.toml index 83631142a..b3bbaf0e3 100644 --- a/porcupine/default_filetypes.toml +++ b/porcupine/default_filetypes.toml @@ -377,7 +377,6 @@ filename_patterns = ["*.md", "*.markdown"] pygments_lexer = "pygments.lexers.MarkdownLexer" syntax_highlighter = "tree_sitter" tree_sitter_language_name = "markdown" -autoindent_regexes = {dedent = '.*\.', indent = '^([0-9]+\.|-) .*'} [YAML] filename_patterns = ["*.yml", "*.yaml"] From eae3e71feb4e83d8f965c4c992a88a9c53a80c20 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Sat, 1 Jul 2023 16:56:28 -0400 Subject: [PATCH 12/26] remove extranious sleep --- tests/test_markdown_plugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index 222802859..89bb9851d 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -171,7 +171,6 @@ def test_non_list(line: str, filetab, tmp_path): filetab.textwidget.event_generate("") filetab.textwidget.event_generate("") # close the autocomplete filetab.update() - # time.sleep(3) assert ( filetab.textwidget.get("1.0", "end - 1 char") == f"{line}\n" ), "should not change, just open autocomplete" From d9266950b2dd8295af827030276b627dc456a7fa Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Sat, 1 Jul 2023 16:58:35 -0400 Subject: [PATCH 13/26] update module docstring --- porcupine/plugins/markdown.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 5bee4f566..5f1f392ee 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -1,4 +1,7 @@ -"""If configuration says so, insert spaces when the tab key is pressed.""" +"""Features for working with Markdown Files. + +- Indenting and dedenting lists +""" from __future__ import annotations From 6c751440c1024c43cb0cc7fe4f43120021293713 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Sat, 1 Jul 2023 17:55:11 -0400 Subject: [PATCH 14/26] fix autocomplete --- porcupine/plugins/markdown.py | 7 ++++++- tests/test_markdown_plugin.py | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 5f1f392ee..7e5b61ef2 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -40,11 +40,16 @@ def on_tab_key( line = event.widget.get("insert linestart", "insert lineend") list_item_status = _is_list_item(line) + # shift-tab if shift_pressed and list_item_status: event.widget.dedent("insert linestart") return "break" - if list_item_status: + # if it isn't, we want tab to trigger autocomplete instead + char_before_cursor_is_space = tab.textwidget.get("insert - 1 char", "insert") == " " + + # tab + if list_item_status and char_before_cursor_is_space: event.widget.indent("insert linestart") return "break" diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index 89bb9851d..dfe226730 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -109,6 +109,8 @@ def test_is_list(line: str, expected: bool, raises): @pytest.mark.parametrize( "li", [ + "-", + "1.", "1. item 1", "1) item 1", "#) item 1", @@ -127,8 +129,9 @@ def test_filetype_switching(li: str, filetab, tmp_path): filetab.textwidget.insert("1.0", li) filetab.textwidget.event_generate("") filetab.update() + assert ( - filetab.textwidget.get("1.0", "end - 1 char") == li + filetab.textwidget.get("1.0", "insert") == li ), "should not effect list items unless using markdown filetype" filetab.textwidget.event_generate("") # close the autocomplete @@ -138,10 +141,17 @@ def test_filetype_switching(li: str, filetab, tmp_path): filetab.textwidget.event_generate("") filetab.update() - assert filetab.textwidget.get("1.0", "end - 1 char") == f" {li}\n", "should indent" + # no change to text, should open autocomplete menu + assert filetab.textwidget.get("1.0", "insert") == li + filetab.textwidget.event_generate("") # close the autocomplete + + # add a space + filetab.textwidget.insert("insert", " ") + filetab.textwidget.event_generate("") + assert filetab.textwidget.get("1.0", "insert") == f" {li} ", "should be indented" filetab.textwidget.event_generate("") filetab.update() - assert filetab.textwidget.get("1.0", "end - 1 char") == f"{li}\n", "should dedent" + assert filetab.textwidget.get("1.0", "insert") == f"{li} ", "should be back to normal" @pytest.mark.parametrize( From c86cd39e89a8ce910a86b5f5a2c917efe6b27931 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Sat, 1 Jul 2023 22:01:32 -0400 Subject: [PATCH 15/26] fix assert --- porcupine/plugins/markdown.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 7e5b61ef2..01a1e8697 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -25,7 +25,13 @@ def _is_list_item(line: str) -> bool: - https://spec.commonmark.org/0.30/#lists - https://pandoc.org/MANUAL.html#lists """ + assert isinstance(line, str) + if not line: + # empty string + return False + assert len(line.splitlines()) == 1 + pattern = r"(^\s*\d{1,9}[.)]|^\s*[-+*]|^\s*#\)|^\s*#\.) .*" regex = re.compile(pattern) match = regex.search(line) From dd54e3f142c83a5e6deef5d0cd8f232dbf6ac2b0 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Sat, 1 Jul 2023 22:05:00 -0400 Subject: [PATCH 16/26] fix flaking (?) tests --- tests/test_markdown_plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index dfe226730..4e5f8d9fb 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -148,6 +148,7 @@ def test_filetype_switching(li: str, filetab, tmp_path): # add a space filetab.textwidget.insert("insert", " ") filetab.textwidget.event_generate("") + filetab.update() assert filetab.textwidget.get("1.0", "insert") == f" {li} ", "should be indented" filetab.textwidget.event_generate("") filetab.update() From d086ad9f6e6d85f8597fcaeaf59b6c998006f098 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Sun, 2 Jul 2023 21:04:17 -0400 Subject: [PATCH 17/26] add list continuation --- porcupine/plugins/autoindent.py | 2 + porcupine/plugins/markdown.py | 38 +++++++++++---- tests/test_markdown_plugin.py | 84 +++++++++++++++++++++++---------- 3 files changed, 92 insertions(+), 32 deletions(-) diff --git a/porcupine/plugins/autoindent.py b/porcupine/plugins/autoindent.py index 34ec70baf..4d67fb325 100644 --- a/porcupine/plugins/autoindent.py +++ b/porcupine/plugins/autoindent.py @@ -90,6 +90,8 @@ def after_enter(tab: tabs.FileTab, alt_pressed: bool) -> None: if dedent_prev_line: tab.textwidget.dedent("insert - 1 line") + tab.textwidget.event_generate("<>") + def on_enter_press( tab: tabs.FileTab, alt_pressed: bool, event: tkinter.Event[tkinter.Text] diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 01a1e8697..a73b3c5fc 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -18,24 +18,28 @@ setup_before = ["tabs2spaces"] -def _is_list_item(line: str) -> bool: - """Detect if the line that is passed is a markdown list item +def _list_item(line: str) -> re.Match[str] | None: + """Regex for markdown list item + + First group is the whitespace (if any) preceding the item + Second group is the list item prefix (ex `-`, `+`, `6.`, `#.`) According to: - https://spec.commonmark.org/0.30/#lists - https://pandoc.org/MANUAL.html#lists + Technically `#)` is not in either spec, but I won't tell if you won't """ + print(f"{line=}") assert isinstance(line, str) if not line: # empty string - return False + return None assert len(line.splitlines()) == 1 - pattern = r"(^\s*\d{1,9}[.)]|^\s*[-+*]|^\s*#\)|^\s*#\.) .*" - regex = re.compile(pattern) - match = regex.search(line) - return bool(match) + list_item_regex = re.compile(r"(^[\t ]*)(\d{1,9}[.)]|[-+*]|#\)|#\.) .*") + match = list_item_regex.search(line) + return match if match else None def on_tab_key( @@ -44,7 +48,7 @@ def on_tab_key( """Indenting and dedenting list items""" if tab.settings.get("filetype_name", str) == "Markdown": line = event.widget.get("insert linestart", "insert lineend") - list_item_status = _is_list_item(line) + list_item_status = _list_item(line) # shift-tab if shift_pressed and list_item_status: @@ -62,8 +66,26 @@ def on_tab_key( return None +def continue_list(tab: tabs.FileTab, event: tkinter.Event[tkinter.Text]) -> str | None: + """Automatically continue lists + + This happens after the `autoindent` plugin automatically handles indentation + """ + if tab.settings.get("filetype_name", str) == "Markdown": + current_line = event.widget.get("insert - 1l linestart", "insert -1l lineend") + list_item_match = _list_item(current_line) + if list_item_match: + indentation, prefix = list_item_match.groups() + + tab.textwidget.insert("insert", prefix + " ") + tab.update() + + return None + + def on_new_filetab(tab: tabs.FileTab) -> None: utils.bind_tab_key(tab.textwidget, partial(on_tab_key, tab), add=True) + tab.textwidget.bind("<>", partial(continue_list, tab), add=True) def setup() -> None: diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index 4e5f8d9fb..ed98cc23c 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -9,7 +9,7 @@ from porcupine.plugins import markdown -class IsListItemCase(NamedTuple): +class ListItemCase(NamedTuple): id: str line: str expected: bool @@ -18,31 +18,31 @@ class IsListItemCase(NamedTuple): IS_LIST_ITEM_CASES = [ - IsListItemCase(id="# with no separator", line="# item 1", expected=False), - IsListItemCase(id="# bad separator |", line="#| item 1", expected=False), - IsListItemCase(id="# bad separator /", line="#/ item 1", expected=False), - IsListItemCase(id="# bad separator \\", line="#\\ item 1", expected=False), - IsListItemCase(id="ol bad separator |", line="8| item 1", expected=False), - IsListItemCase(id="ol bad separator /", line="8/ item 1", expected=False), - IsListItemCase(id="ol bad separator \\", line="8\\ item 1", expected=False), - IsListItemCase(id="not a list 1", line="item 1", expected=False), - IsListItemCase(id="not a list 2", line=" item 1", expected=False), - IsListItemCase(id="not a list 3", line=" item 1", expected=False), - IsListItemCase(id="not a list 4", line="& item 1", expected=False), - IsListItemCase(id="not a list 5", line="^ item 1", expected=False), - IsListItemCase(id="duplicate token 1", line="-- item 1", expected=False), - IsListItemCase(id="duplicate token 2", line="--- item 1", expected=False), - IsListItemCase(id="duplicate token 3", line="- - - item 1", expected=True), - IsListItemCase(id="duplicate token 4", line=" - item -- 1 -", expected=True), - IsListItemCase(id="duplicate token 5", line=" -#) item -- 1 -", expected=False), - IsListItemCase(id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=False), + ListItemCase(id="# with no separator", line="# item 1", expected=False), + ListItemCase(id="# bad separator |", line="#| item 1", expected=False), + ListItemCase(id="# bad separator /", line="#/ item 1", expected=False), + ListItemCase(id="# bad separator \\", line="#\\ item 1", expected=False), + ListItemCase(id="ol bad separator |", line="8| item 1", expected=False), + ListItemCase(id="ol bad separator /", line="8/ item 1", expected=False), + ListItemCase(id="ol bad separator \\", line="8\\ item 1", expected=False), + ListItemCase(id="not a list 1", line="item 1", expected=False), + ListItemCase(id="not a list 2", line=" item 1", expected=False), + ListItemCase(id="not a list 3", line=" item 1", expected=False), + ListItemCase(id="not a list 4", line="& item 1", expected=False), + ListItemCase(id="not a list 5", line="^ item 1", expected=False), + ListItemCase(id="duplicate token 1", line="-- item 1", expected=False), + ListItemCase(id="duplicate token 2", line="--- item 1", expected=False), + ListItemCase(id="duplicate token 3", line="- - - item 1", expected=True), + ListItemCase(id="duplicate token 4", line=" - item -- 1 -", expected=True), + ListItemCase(id="duplicate token 5", line=" -#) item -- 1 -", expected=False), + ListItemCase(id="duplicate token 6", line=" *-#)1. item -- 1 -", expected=False), ] # test `#` and 0 to 99 numbered lists # tests ol with `.` and `)` IS_LIST_ITEM_CASES.extend( [ - IsListItemCase(id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True) + ListItemCase(id=f"numbered {i}", line=f"{i}{sep} item 1", expected=True) for i, sep in itertools.product(itertools.chain(range(100), "#"), (".", ")")) ] ) @@ -50,7 +50,7 @@ class IsListItemCase(NamedTuple): # test raw li prefixes with and without space IS_LIST_ITEM_CASES.extend( [ - IsListItemCase( + ListItemCase( id=f"raw prexix {prefix} no space", line=f"{prefix}{' ' if space else ''}", expected=space, @@ -64,7 +64,7 @@ class IsListItemCase(NamedTuple): # test numbered list with whitespace following and preceding IS_LIST_ITEM_CASES.extend( [ - IsListItemCase( + ListItemCase( id=f"numbered {preceding=} {following=} space", line=f"{' ' * preceding}{i}{sep}{' ' * following} item 1", expected=True, @@ -78,7 +78,7 @@ class IsListItemCase(NamedTuple): # test with whitespace following and preceding IS_LIST_ITEM_CASES.extend( [ - IsListItemCase( + ListItemCase( id=f"bullet {preceding=} {following=} space", line=f"{' ' * preceding}{bullet} {' ' * following} item 1", expected=True, @@ -103,7 +103,11 @@ class IsListItemCase(NamedTuple): ) def test_is_list(line: str, expected: bool, raises): with raises: - assert markdown._is_list_item(line) == expected + result = markdown._list_item(line) + if expected: + assert result + if not expected: + assert not result @pytest.mark.parametrize( @@ -185,3 +189,35 @@ def test_non_list(line: str, filetab, tmp_path): assert ( filetab.textwidget.get("1.0", "end - 1 char") == f"{line}\n" ), "should not change, just open autocomplete" + + +@pytest.mark.parametrize( + "li", + [ + "- ", # note the space + "1. ", # note the space + "1. item 1", + "1) item 1", + "#) item 1", + "- item 1", + "* item 1", + "+ item 1", + "+ +++++ weird", + "1) ))))) still weird", + "- [ ] unchecked task", + "- [X] checked task", + ], +) +def test_list_continuation(li: str, filetab, tmp_path): + filetab.textwidget.insert("1.0", li) + filetab.update() + + # switch to Markdown filetype format + filetab.save_as(tmp_path / "asdf.md") + assert filetab.settings.get("filetype_name", object) == "Markdown" + + # new line + filetab.textwidget.event_generate("") + filetab.update() + current_line = filetab.textwidget.get("insert linestart", "insert") + assert markdown._list_item(current_line) From 17d5f203167ec98fc2f5a4a0f7dde0a31ea004b1 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Tue, 4 Jul 2023 10:52:12 -0400 Subject: [PATCH 18/26] add update() before all events --- tests/test_markdown_plugin.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index ed98cc23c..c58f23991 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -131,29 +131,32 @@ def test_filetype_switching(li: str, filetab, tmp_path): assert filetab.settings.get("filetype_name", object) == "Python" filetab.textwidget.insert("1.0", li) - filetab.textwidget.event_generate("") filetab.update() + filetab.textwidget.event_generate("") assert ( filetab.textwidget.get("1.0", "insert") == li ), "should not effect list items unless using markdown filetype" + filetab.update() filetab.textwidget.event_generate("") # close the autocomplete # switch to Markdown filetype format filetab.save_as(tmp_path / "asdf.md") assert filetab.settings.get("filetype_name", object) == "Markdown" - filetab.textwidget.event_generate("") filetab.update() + filetab.textwidget.event_generate("") # no change to text, should open autocomplete menu assert filetab.textwidget.get("1.0", "insert") == li + filetab.update() filetab.textwidget.event_generate("") # close the autocomplete # add a space filetab.textwidget.insert("insert", " ") - filetab.textwidget.event_generate("") filetab.update() + filetab.textwidget.event_generate("") assert filetab.textwidget.get("1.0", "insert") == f" {li} ", "should be indented" + filetab.update() filetab.textwidget.event_generate("") filetab.update() assert filetab.textwidget.get("1.0", "insert") == f"{li} ", "should be back to normal" @@ -183,9 +186,10 @@ def test_non_list(line: str, filetab, tmp_path): assert filetab.settings.get("filetype_name", object) == "Markdown" filetab.textwidget.insert("1.0", line) + filetab.update() filetab.textwidget.event_generate("") - filetab.textwidget.event_generate("") # close the autocomplete filetab.update() + filetab.textwidget.event_generate("") # close the autocomplete assert ( filetab.textwidget.get("1.0", "end - 1 char") == f"{line}\n" ), "should not change, just open autocomplete" @@ -217,7 +221,7 @@ def test_list_continuation(li: str, filetab, tmp_path): assert filetab.settings.get("filetype_name", object) == "Markdown" # new line - filetab.textwidget.event_generate("") filetab.update() + filetab.textwidget.event_generate("") current_line = filetab.textwidget.get("insert linestart", "insert") assert markdown._list_item(current_line) From dd4a0aae6de5f8bd634e9695966d7b642df898bc Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 18:58:47 -0400 Subject: [PATCH 19/26] remove print --- porcupine/plugins/markdown.py | 1 - 1 file changed, 1 deletion(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index a73b3c5fc..5fca22300 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -29,7 +29,6 @@ def _list_item(line: str) -> re.Match[str] | None: - https://pandoc.org/MANUAL.html#lists Technically `#)` is not in either spec, but I won't tell if you won't """ - print(f"{line=}") assert isinstance(line, str) if not line: # empty string From c6caf775caff1e51c2cde6c2bb257f17cb1f9b80 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 19:14:04 -0400 Subject: [PATCH 20/26] remove bad test --- tests/test_indent_dedent.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/tests/test_indent_dedent.py b/tests/test_indent_dedent.py index 7eef3d871..733abb7f2 100644 --- a/tests/test_indent_dedent.py +++ b/tests/test_indent_dedent.py @@ -198,28 +198,6 @@ def check(filename, input_commands, output): return check -def test_markdown_autoindent(check_autoindents): - check_autoindents( - "hello.md", - """ -1. Lol and -wat. -- Foo and -bar and -baz. -End of list -""", - """ -1. Lol and - wat. -- Foo and - bar and - baz. -End of list -""", - ) - - def test_shell_autoindent(check_autoindents): check_autoindents( "loll.sh", From 68e7fcb97b8761e49ab4608596653a974d1ee0b6 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 19:16:20 -0400 Subject: [PATCH 21/26] fix crash in case no filetype --- porcupine/plugins/markdown.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 5fca22300..0fe987229 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -45,7 +45,7 @@ def on_tab_key( tab: tabs.FileTab, event: tkinter.Event[textutils.MainText], shift_pressed: bool ) -> str | None: """Indenting and dedenting list items""" - if tab.settings.get("filetype_name", str) == "Markdown": + if tab.settings.get("filetype_name", object) == "Markdown": line = event.widget.get("insert linestart", "insert lineend") list_item_status = _list_item(line) @@ -70,7 +70,7 @@ def continue_list(tab: tabs.FileTab, event: tkinter.Event[tkinter.Text]) -> str This happens after the `autoindent` plugin automatically handles indentation """ - if tab.settings.get("filetype_name", str) == "Markdown": + if tab.settings.get("filetype_name", object) == "Markdown": current_line = event.widget.get("insert - 1l linestart", "insert -1l lineend") list_item_match = _list_item(current_line) if list_item_match: From 2c24d1b25881d3dee4b496958f006867b4ad60e0 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 19:56:33 -0400 Subject: [PATCH 22/26] fix list continuation tests really not sure why this was broken, I'm 100% sure I had this working yesterday.......... --- tests/test_markdown_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index c58f23991..44d9b0232 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -223,5 +223,5 @@ def test_list_continuation(li: str, filetab, tmp_path): # new line filetab.update() filetab.textwidget.event_generate("") - current_line = filetab.textwidget.get("insert linestart", "insert") + current_line = filetab.textwidget.get("insert - 1l linestart", "insert - 1l lineend") assert markdown._list_item(current_line) From 7809f5b0b34c5f9b06a525c9854a9d10f2a33e65 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 21:46:34 -0400 Subject: [PATCH 23/26] actually fix the list continuation test hopefully --- tests/test_markdown_plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index 44d9b0232..fce232691 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -221,7 +221,7 @@ def test_list_continuation(li: str, filetab, tmp_path): assert filetab.settings.get("filetype_name", object) == "Markdown" # new line - filetab.update() filetab.textwidget.event_generate("") - current_line = filetab.textwidget.get("insert - 1l linestart", "insert - 1l lineend") + filetab.update() + current_line = filetab.textwidget.get("insert linestart", "insert lineend") assert markdown._list_item(current_line) From 8065b37979dd87f7e87dea792aa75a3f47c3a838 Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Mon, 3 Jul 2023 22:37:04 -0400 Subject: [PATCH 24/26] clear empty list item on return --- porcupine/plugins/markdown.py | 28 +++++++++++++++++++++++----- tests/test_markdown_plugin.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/porcupine/plugins/markdown.py b/porcupine/plugins/markdown.py index 0fe987229..4e316ec63 100644 --- a/porcupine/plugins/markdown.py +++ b/porcupine/plugins/markdown.py @@ -15,14 +15,15 @@ log = logging.getLogger(__name__) -setup_before = ["tabs2spaces"] +setup_before = ["tabs2spaces", "autoindent"] def _list_item(line: str) -> re.Match[str] | None: """Regex for markdown list item - First group is the whitespace (if any) preceding the item - Second group is the list item prefix (ex `-`, `+`, `6.`, `#.`) + 1st group is the whitespace (if any) preceding the item + 2nd group is the list item prefix (ex `-`, `+`, `6.`, `#.`) + 3rd group is the item text According to: - https://spec.commonmark.org/0.30/#lists @@ -36,7 +37,7 @@ def _list_item(line: str) -> re.Match[str] | None: assert len(line.splitlines()) == 1 - list_item_regex = re.compile(r"(^[\t ]*)(\d{1,9}[.)]|[-+*]|#\)|#\.) .*") + list_item_regex = re.compile(r"(^[\t ]*)(\d{1,9}[.)]|[-+*]|#\)|#\.) (.*)") match = list_item_regex.search(line) return match if match else None @@ -74,7 +75,7 @@ def continue_list(tab: tabs.FileTab, event: tkinter.Event[tkinter.Text]) -> str current_line = event.widget.get("insert - 1l linestart", "insert -1l lineend") list_item_match = _list_item(current_line) if list_item_match: - indentation, prefix = list_item_match.groups() + indentation, prefix, item_text = list_item_match.groups() tab.textwidget.insert("insert", prefix + " ") tab.update() @@ -82,9 +83,26 @@ def continue_list(tab: tabs.FileTab, event: tkinter.Event[tkinter.Text]) -> str return None +def on_enter_press(tab: tabs.FileTab, event: tkinter.Event[tkinter.Text]) -> str | None: + if tab.settings.get("filetype_name", object) == "Markdown": + current_line = event.widget.get("insert linestart", "insert lineend") + list_item_match = _list_item(current_line) + if list_item_match: + indentation, prefix, item_text = list_item_match.groups() + if item_text: + # there is item text, so we are done here + return None + + event.widget.delete("insert linestart", "insert lineend") + return "break" + + return None + + def on_new_filetab(tab: tabs.FileTab) -> None: utils.bind_tab_key(tab.textwidget, partial(on_tab_key, tab), add=True) tab.textwidget.bind("<>", partial(continue_list, tab), add=True) + tab.textwidget.bind("", partial(on_enter_press, tab), add=True) def setup() -> None: diff --git a/tests/test_markdown_plugin.py b/tests/test_markdown_plugin.py index fce232691..b9f1eaeb7 100644 --- a/tests/test_markdown_plugin.py +++ b/tests/test_markdown_plugin.py @@ -198,8 +198,6 @@ def test_non_list(line: str, filetab, tmp_path): @pytest.mark.parametrize( "li", [ - "- ", # note the space - "1. ", # note the space "1. item 1", "1) item 1", "#) item 1", @@ -225,3 +223,35 @@ def test_list_continuation(li: str, filetab, tmp_path): filetab.update() current_line = filetab.textwidget.get("insert linestart", "insert lineend") assert markdown._list_item(current_line) + + +@pytest.mark.parametrize("prefix", ["-", "+", "*", "#.", "#)", "1.", "1)", "88)", "88."]) +def test_return_remove_empty_item(prefix: str, filetab, tmp_path): + """Pressing 'return' on an empty item should remove it""" + filetab.textwidget.insert("1.0", prefix + " item 1") + filetab.update() + + # switch to Markdown filetype format + filetab.save_as(tmp_path / "asdf.md") + assert filetab.settings.get("filetype_name", object) == "Markdown" + + # new line + filetab.textwidget.event_generate("") + filetab.update() + previous_line = filetab.textwidget.get("insert - 1l linestart", "insert - 1l lineend") + current_line = filetab.textwidget.get("insert linestart", "insert lineend") + assert previous_line == f"{prefix} item 1" + assert markdown._list_item(previous_line) + assert current_line == f"{prefix} " + assert markdown._list_item(current_line) + + # new line + filetab.textwidget.event_generate("") + filetab.update() + previous_line = filetab.textwidget.get("insert - 1l linestart", "insert - 1l lineend") + current_line = filetab.textwidget.get("insert linestart", "insert lineend") + assert previous_line == f"{prefix} item 1" + assert markdown._list_item(previous_line) + # current line should now be empty + assert current_line == "" + assert not markdown._list_item(current_line) From 8775d89fc4acce367c11cfdecdfeb3ebc6749dcb Mon Sep 17 00:00:00 2001 From: Benjamin Kirkbride Date: Tue, 4 Jul 2023 22:25:13 -0400 Subject: [PATCH 25/26] first pass implementation --- porcupine/plugins/python_tools.py | 12 +++- porcupine/plugins/toolbar.py | 110 ++++++++++++++++++++++++++++++ tests/test_toolbar_plugin.py | 39 +++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 porcupine/plugins/toolbar.py create mode 100644 tests/test_toolbar_plugin.py diff --git a/porcupine/plugins/python_tools.py b/porcupine/plugins/python_tools.py index 3c62bd8b8..5d292a9c8 100644 --- a/porcupine/plugins/python_tools.py +++ b/porcupine/plugins/python_tools.py @@ -3,7 +3,6 @@ Available in Tools/Python/Black and Tools/Python/Isort. """ - from __future__ import annotations import logging @@ -14,7 +13,7 @@ from tkinter import messagebox from porcupine import menubar, tabs, textutils, utils -from porcupine.plugins import python_venv +from porcupine.plugins import python_venv, toolbar log = logging.getLogger(__name__) @@ -66,3 +65,12 @@ def format_code_in_textwidget(tool: str, tab: tabs.FileTab) -> None: def setup() -> None: menubar.add_filetab_command("Tools/Python/Black", partial(format_code_in_textwidget, "black")) menubar.add_filetab_command("Tools/Python/Isort", partial(format_code_in_textwidget, "isort")) + + buttons = [] + buttons.append( + toolbar.Button(text="Black", command=partial(format_code_in_textwidget, "black")) + ) + buttons.append( + toolbar.Button(text="isort", command=partial(format_code_in_textwidget, "isort")) + ) + toolbar.add_button_group(filetype_name="Python", name="Python Tools", buttons=buttons) diff --git a/porcupine/plugins/toolbar.py b/porcupine/plugins/toolbar.py new file mode 100644 index 000000000..8445943e2 --- /dev/null +++ b/porcupine/plugins/toolbar.py @@ -0,0 +1,110 @@ +"""Display a toolbar in each file tab.""" +from __future__ import annotations + +import dataclasses +import logging +import tkinter +from functools import partial +from tkinter import ttk +from typing import Any, Callable, Iterable + +from porcupine import get_tab_manager, tabs + +log = logging.getLogger(__name__) + +setup_after = ["filetypes"] + + +# TODO: add icon (make text optional?) +@dataclasses.dataclass(kw_only=True) +class Button: + text: str + description: str | None = None + command: Callable + + +@dataclasses.dataclass(kw_only=True) +class ButtonGroup: + name: str + priority: int # 0 is highest + buttons: list[Button] + separator: bool = True + + +class SortedButtonGroupList(list[ButtonGroup]): + """A of button groups that sorts itself automatically""" + + # no this wasn't necessary, but I am in too deep to stop now + # but seriously why isn't there a sorted list type in the stdlib? + @classmethod + def _key_func(cls, group: ButtonGroup) -> int: + return group.priority + + def __init__(self, *args: Iterable[ButtonGroup], **kwargs: ButtonGroup) -> None: + super().__init__(*args, **kwargs) + self.sort(key=self._key_func) + + def append(self, __item: ButtonGroup) -> None: + super().append(__item) + self.sort(key=self._key_func) + + def extend(self, __iterable: Iterable[ButtonGroup]) -> None: + super().extend(__iterable) + self.sort(key=self._key_func) + + +filetype_button_groups_mapping: dict[str, SortedButtonGroupList] = {} + + +def add_button_group( + *, filetype_name: str, name: str, buttons: list[Button], priority: int = 0, separator=True +) -> None: + button_group = ButtonGroup(name=name, priority=priority, buttons=buttons, separator=separator) + if filetype_button_groups_mapping.get(filetype_name): + filetype_button_groups_mapping[filetype_name].append(button_group) + else: + filetype_button_groups_mapping[filetype_name] = SortedButtonGroupList([button_group]) + + +class ToolBar(ttk.Frame): + def __init__(self, tab: tabs.FileTab): + super().__init__(tab.top_frame, name="toolbar", border=1, relief="raised") + self._tab = tab + + def update_buttons(self, tab: tabs.FileTab, junk: object = None) -> None: + """Different filetypes have different buttons associated with them.""" + filetype_name = tab.settings.get("filetype_name", object) + button_groups = filetype_button_groups_mapping.get(filetype_name) + if not button_groups: + return + + for button_group in button_groups: + for button in button_group.buttons: + ttk.Button( + self, + command=partial(button.command, tab), + style="Statusbar.TButton", + text=button.text, + ).pack(side="left", padx=10, pady=5) + + +def on_new_filetab(tab: tabs.FileTab) -> None: + toolbar = ToolBar(tab) + toolbar.pack(side="bottom", fill="x") + + tab.bind("<>", partial(toolbar.update_buttons, tab), add=True) + toolbar.update_buttons(tab) + + +def update_button_style(junk_event: object = None) -> None: + # https://tkdocs.com/tutorial/styles.html + # tkinter's style stuff sucks + get_tab_manager().tk.eval( + "ttk::style configure Statusbar.TButton -padding {10 0} -anchor center" + ) + + +def setup() -> None: + get_tab_manager().add_filetab_callback(on_new_filetab) + get_tab_manager().bind("<>", update_button_style, add=True) + update_button_style() diff --git a/tests/test_toolbar_plugin.py b/tests/test_toolbar_plugin.py new file mode 100644 index 000000000..2d7108e11 --- /dev/null +++ b/tests/test_toolbar_plugin.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from porcupine.plugins import toolbar + + +def _gen_button_group(priority: int, name: str | None = None) -> toolbar.ButtonGroup: + return toolbar.ButtonGroup( + name=f"priority = {priority}" if not name else name, priority=priority, buttons=[] + ) + + +def test_manual_sorted_button_group_list_with_append_and_extend(): + # reverse order + sorted_button_group_list = toolbar.SortedButtonGroupList( + [_gen_button_group(i) for i in [100, 0, 50, 25, 2, 1, 99]] + ) + + sorted_button_group_list.append(_gen_button_group(33)) + sorted_button_group_list.append(_gen_button_group(5)) + + sorted_button_group_list.extend( + [_gen_button_group(9), _gen_button_group(8), _gen_button_group(7)] + ) + + for i, button_group in zip( + [0, 1, 2, 5, 7, 8, 9, 25, 33, 50, 99, 100], sorted_button_group_list + ): + assert i == button_group.priority + + +def test_big_reversed_sorted_button_group_list(): + qty = 100 + # reverse order + sorted_button_group_list = toolbar.SortedButtonGroupList( + [_gen_button_group(i) for i in reversed(range(qty))] + ) + + for i, button_group in zip(range(qty), sorted_button_group_list): + assert i == button_group.priority From 544723acd8928d01d54c2dcae0f7d17955d69359 Mon Sep 17 00:00:00 2001 From: benjamin-kirkbride Date: Wed, 5 Jul 2023 02:42:33 +0000 Subject: [PATCH 26/26] Run pycln, black and isort --- porcupine/plugins/toolbar.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/porcupine/plugins/toolbar.py b/porcupine/plugins/toolbar.py index 8445943e2..13a77fedb 100644 --- a/porcupine/plugins/toolbar.py +++ b/porcupine/plugins/toolbar.py @@ -3,10 +3,9 @@ import dataclasses import logging -import tkinter from functools import partial from tkinter import ttk -from typing import Any, Callable, Iterable +from typing import Callable, Iterable from porcupine import get_tab_manager, tabs