mdedit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdedit-0.1.0/.github/workflows/publish.yml +57 -0
- mdedit-0.1.0/.gitignore +15 -0
- mdedit-0.1.0/LICENSE +21 -0
- mdedit-0.1.0/PKG-INFO +102 -0
- mdedit-0.1.0/README.md +71 -0
- mdedit-0.1.0/pyproject.toml +63 -0
- mdedit-0.1.0/src/mdedit/__init__.py +3 -0
- mdedit-0.1.0/src/mdedit/__main__.py +40 -0
- mdedit-0.1.0/src/mdedit/app.py +55 -0
- mdedit-0.1.0/src/mdedit/document.py +105 -0
- mdedit-0.1.0/src/mdedit/screens/__init__.py +0 -0
- mdedit-0.1.0/src/mdedit/screens/editor.py +102 -0
- mdedit-0.1.0/src/mdedit/screens/quit_confirm.py +54 -0
- mdedit-0.1.0/src/mdedit/screens/welcome.py +47 -0
- mdedit-0.1.0/src/mdedit/styles/mdedit.tcss +6 -0
- mdedit-0.1.0/src/mdedit/widgets/__init__.py +0 -0
- mdedit-0.1.0/src/mdedit/widgets/source_editor.py +25 -0
- mdedit-0.1.0/src/mdedit/widgets/status_bar.py +33 -0
- mdedit-0.1.0/src/mdedit/widgets/viewer.py +15 -0
- mdedit-0.1.0/tests/__init__.py +0 -0
- mdedit-0.1.0/tests/conftest.py +9 -0
- mdedit-0.1.0/tests/test_app_smoke.py +115 -0
- mdedit-0.1.0/tests/test_document.py +93 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Triggered by pushing a version tag, e.g.:
|
|
4
|
+
# git tag v0.1.0 && git push origin v0.1.0
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags:
|
|
8
|
+
- "v*.*.*"
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
name: Build distribution
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.x"
|
|
23
|
+
|
|
24
|
+
- name: Verify tag matches package version
|
|
25
|
+
run: |
|
|
26
|
+
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
27
|
+
PKG_VERSION=$(grep -m1 '__version__' src/mdedit/__init__.py | sed -E 's/.*"([^"]+)".*/\1/')
|
|
28
|
+
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
|
|
29
|
+
echo "Tag $GITHUB_REF_NAME (version $TAG_VERSION) does not match" \
|
|
30
|
+
"src/mdedit/__init__.py's __version__ ($PKG_VERSION)."
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
- name: Build sdist and wheel
|
|
35
|
+
run: |
|
|
36
|
+
python -m pip install --upgrade build
|
|
37
|
+
python -m build
|
|
38
|
+
|
|
39
|
+
- uses: actions/upload-artifact@v4
|
|
40
|
+
with:
|
|
41
|
+
name: dist
|
|
42
|
+
path: dist/
|
|
43
|
+
|
|
44
|
+
publish:
|
|
45
|
+
name: Publish to PyPI
|
|
46
|
+
needs: build
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
environment: pypi
|
|
49
|
+
permissions:
|
|
50
|
+
id-token: write # required for PyPI trusted publishing (OIDC)
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/download-artifact@v4
|
|
53
|
+
with:
|
|
54
|
+
name: dist
|
|
55
|
+
path: dist/
|
|
56
|
+
|
|
57
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
mdedit-0.1.0/.gitignore
ADDED
mdedit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 matplo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mdedit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mdedit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal Markdown viewer and editor built with Textual
|
|
5
|
+
Project-URL: Homepage, https://github.com/matplo/mdedit
|
|
6
|
+
Project-URL: Repository, https://github.com/matplo/mdedit
|
|
7
|
+
Author-email: matplo <ploskon@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: editor,markdown,terminal,textual,tui
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Terminals
|
|
21
|
+
Classifier: Topic :: Text Editors
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: rich>=13.7
|
|
24
|
+
Requires-Dist: textual[syntax]<9,>=8.2
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
29
|
+
Requires-Dist: textual-dev; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# mdedit
|
|
33
|
+
|
|
34
|
+
A terminal Markdown viewer and editor, built with
|
|
35
|
+
[Textual](https://github.com/Textualize/textual) and
|
|
36
|
+
[Rich](https://github.com/Textualize/rich).
|
|
37
|
+
|
|
38
|
+
Open a `.md` file to a fully rendered view (headings, tables, code blocks,
|
|
39
|
+
lists, block quotes...) and press a single key to toggle into a
|
|
40
|
+
syntax-highlighted source editor for the same file — then toggle back to see
|
|
41
|
+
your changes rendered.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install git+https://github.com/matplo/mdedit
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
mdedit path/to/file.md # open (or create) a file
|
|
53
|
+
mdedit # start with a prompt to open/create a file
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Key bindings
|
|
57
|
+
|
|
58
|
+
| Key | Action |
|
|
59
|
+
|----------|----------------------------------|
|
|
60
|
+
| `ctrl+t` | Toggle between view and edit mode |
|
|
61
|
+
| `ctrl+s` | Save |
|
|
62
|
+
| `ctrl+r` | Reload from disk |
|
|
63
|
+
| `ctrl+q` | Quit (prompts if unsaved changes) |
|
|
64
|
+
|
|
65
|
+
> **Note:** if `ctrl+s` doesn't seem to do anything in your terminal, it may
|
|
66
|
+
> be intercepted by terminal flow control (XON/XOFF). Run `stty -ixon` in
|
|
67
|
+
> your shell first, or use the Save option from the command palette
|
|
68
|
+
> (`ctrl+p`).
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install -e ".[dev]"
|
|
74
|
+
pytest
|
|
75
|
+
ruff check .
|
|
76
|
+
ruff format --check .
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Releasing to PyPI
|
|
80
|
+
|
|
81
|
+
Releases publish automatically via GitHub Actions using
|
|
82
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API
|
|
83
|
+
token stored anywhere). To cut a release:
|
|
84
|
+
|
|
85
|
+
1. Bump `__version__` in `src/mdedit/__init__.py`, commit it.
|
|
86
|
+
2. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`
|
|
87
|
+
3. The `Publish to PyPI` workflow builds the sdist/wheel and uploads them.
|
|
88
|
+
|
|
89
|
+
## Roadmap
|
|
90
|
+
|
|
91
|
+
v1 is intentionally scoped to a single-file, toggle-based view/edit
|
|
92
|
+
experience. Not yet included (candidates for a future version):
|
|
93
|
+
|
|
94
|
+
- Always-rendered, true WYSIWYG editing (edit directly on rendered text,
|
|
95
|
+
no separate source view)
|
|
96
|
+
- Split-pane side-by-side edit + preview
|
|
97
|
+
- Browser-style navigation history, bookmarks, remote/URL loading
|
|
98
|
+
- Multiple open files / tabs
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
mdedit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# mdedit
|
|
2
|
+
|
|
3
|
+
A terminal Markdown viewer and editor, built with
|
|
4
|
+
[Textual](https://github.com/Textualize/textual) and
|
|
5
|
+
[Rich](https://github.com/Textualize/rich).
|
|
6
|
+
|
|
7
|
+
Open a `.md` file to a fully rendered view (headings, tables, code blocks,
|
|
8
|
+
lists, block quotes...) and press a single key to toggle into a
|
|
9
|
+
syntax-highlighted source editor for the same file — then toggle back to see
|
|
10
|
+
your changes rendered.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install git+https://github.com/matplo/mdedit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
mdedit path/to/file.md # open (or create) a file
|
|
22
|
+
mdedit # start with a prompt to open/create a file
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Key bindings
|
|
26
|
+
|
|
27
|
+
| Key | Action |
|
|
28
|
+
|----------|----------------------------------|
|
|
29
|
+
| `ctrl+t` | Toggle between view and edit mode |
|
|
30
|
+
| `ctrl+s` | Save |
|
|
31
|
+
| `ctrl+r` | Reload from disk |
|
|
32
|
+
| `ctrl+q` | Quit (prompts if unsaved changes) |
|
|
33
|
+
|
|
34
|
+
> **Note:** if `ctrl+s` doesn't seem to do anything in your terminal, it may
|
|
35
|
+
> be intercepted by terminal flow control (XON/XOFF). Run `stty -ixon` in
|
|
36
|
+
> your shell first, or use the Save option from the command palette
|
|
37
|
+
> (`ctrl+p`).
|
|
38
|
+
|
|
39
|
+
## Development
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install -e ".[dev]"
|
|
43
|
+
pytest
|
|
44
|
+
ruff check .
|
|
45
|
+
ruff format --check .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Releasing to PyPI
|
|
49
|
+
|
|
50
|
+
Releases publish automatically via GitHub Actions using
|
|
51
|
+
[trusted publishing](https://docs.pypi.org/trusted-publishers/) (no API
|
|
52
|
+
token stored anywhere). To cut a release:
|
|
53
|
+
|
|
54
|
+
1. Bump `__version__` in `src/mdedit/__init__.py`, commit it.
|
|
55
|
+
2. Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z`
|
|
56
|
+
3. The `Publish to PyPI` workflow builds the sdist/wheel and uploads them.
|
|
57
|
+
|
|
58
|
+
## Roadmap
|
|
59
|
+
|
|
60
|
+
v1 is intentionally scoped to a single-file, toggle-based view/edit
|
|
61
|
+
experience. Not yet included (candidates for a future version):
|
|
62
|
+
|
|
63
|
+
- Always-rendered, true WYSIWYG editing (edit directly on rendered text,
|
|
64
|
+
no separate source view)
|
|
65
|
+
- Split-pane side-by-side edit + preview
|
|
66
|
+
- Browser-style navigation history, bookmarks, remote/URL loading
|
|
67
|
+
- Multiple open files / tabs
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mdedit"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A terminal Markdown viewer and editor built with Textual"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "matplo", email = "ploskon@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["markdown", "tui", "textual", "editor", "terminal"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Text Editors",
|
|
27
|
+
"Topic :: Terminals",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"textual[syntax]>=8.2,<9",
|
|
31
|
+
"rich>=13.7",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest",
|
|
37
|
+
"pytest-asyncio",
|
|
38
|
+
"textual-dev",
|
|
39
|
+
"ruff",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
mdedit = "mdedit.__main__:main"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/matplo/mdedit"
|
|
47
|
+
Repository = "https://github.com/matplo/mdedit"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.version]
|
|
50
|
+
path = "src/mdedit/__init__.py"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/mdedit"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
asyncio_mode = "auto"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Command-line entry point for mdedit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from mdedit import __version__
|
|
9
|
+
from mdedit.app import MDEditApp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="mdedit",
|
|
15
|
+
description="A terminal Markdown viewer and editor.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"file",
|
|
19
|
+
nargs="?",
|
|
20
|
+
default=None,
|
|
21
|
+
help="Markdown file to open (created on first save if it doesn't exist yet)",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--version",
|
|
25
|
+
action="version",
|
|
26
|
+
version=f"mdedit {__version__}",
|
|
27
|
+
)
|
|
28
|
+
return parser
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> None:
|
|
32
|
+
parser = build_parser()
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
file_path = Path(args.file).expanduser() if args.file else None
|
|
35
|
+
app = MDEditApp(file_path=file_path)
|
|
36
|
+
app.run()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The mdedit Textual application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual import work
|
|
8
|
+
from textual.app import App
|
|
9
|
+
|
|
10
|
+
from mdedit.document import Document, DocumentError
|
|
11
|
+
from mdedit.screens.editor import EditorScreen
|
|
12
|
+
from mdedit.screens.quit_confirm import QuitConfirmScreen
|
|
13
|
+
from mdedit.screens.welcome import WelcomeScreen
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MDEditApp(App[None]):
|
|
17
|
+
"""A terminal Markdown viewer/editor."""
|
|
18
|
+
|
|
19
|
+
TITLE = "mdedit"
|
|
20
|
+
CSS_PATH = "styles/mdedit.tcss"
|
|
21
|
+
|
|
22
|
+
def __init__(self, file_path: Path | None = None) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self._initial_path = file_path
|
|
25
|
+
|
|
26
|
+
def on_mount(self) -> None:
|
|
27
|
+
if self._initial_path is not None:
|
|
28
|
+
self.open_path(self._initial_path)
|
|
29
|
+
else:
|
|
30
|
+
self.push_screen(WelcomeScreen())
|
|
31
|
+
|
|
32
|
+
def open_path(self, path: Path) -> None:
|
|
33
|
+
"""Load ``path`` as a Document and switch to the editor screen."""
|
|
34
|
+
try:
|
|
35
|
+
document = Document.from_path(path)
|
|
36
|
+
except DocumentError as exc:
|
|
37
|
+
self.notify(str(exc), severity="error", timeout=6)
|
|
38
|
+
return
|
|
39
|
+
self.push_screen(EditorScreen(document))
|
|
40
|
+
|
|
41
|
+
@work
|
|
42
|
+
async def action_quit(self) -> None:
|
|
43
|
+
screen = self.screen
|
|
44
|
+
document = getattr(screen, "document", None)
|
|
45
|
+
if document is not None and document.dirty:
|
|
46
|
+
choice = await self.push_screen_wait(QuitConfirmScreen())
|
|
47
|
+
if choice == "cancel":
|
|
48
|
+
return
|
|
49
|
+
if choice == "save":
|
|
50
|
+
try:
|
|
51
|
+
document.save()
|
|
52
|
+
except DocumentError as exc:
|
|
53
|
+
self.notify(str(exc), severity="error", timeout=6)
|
|
54
|
+
return
|
|
55
|
+
self.exit()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Core document model.
|
|
2
|
+
|
|
3
|
+
This module is intentionally free of any Textual (or other UI-framework)
|
|
4
|
+
imports. It is the seam between the on-disk file and whichever widget is
|
|
5
|
+
currently displaying it (rendered view or source editor), so that a future
|
|
6
|
+
"always-rendered" WYSIWYG editing mode could be layered in later behind the
|
|
7
|
+
same narrow interface (``.text`` / ``.set_text()`` / ``.dirty``) without
|
|
8
|
+
rewriting the screens or widgets that consume it today.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DocumentError(Exception):
|
|
17
|
+
"""Raised when a document fails to load or save."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Document:
|
|
21
|
+
"""An in-memory Markdown document, optionally backed by a file on disk."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, path: Path | None = None, text: str = "") -> None:
|
|
24
|
+
self.path = path
|
|
25
|
+
self._text = text
|
|
26
|
+
self._saved_text = text
|
|
27
|
+
self._dirty = False
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
return f"Document(path={self.path!r}, dirty={self._dirty!r})"
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_path(cls, path: Path) -> Document:
|
|
34
|
+
"""Load a document from ``path``.
|
|
35
|
+
|
|
36
|
+
If ``path`` does not exist, an empty new document is returned with
|
|
37
|
+
``path`` pre-set (matching common editor UX: the file is created on
|
|
38
|
+
first save). Genuine I/O errors (permission denied, path is a
|
|
39
|
+
directory, etc.) raise :class:`DocumentError`.
|
|
40
|
+
"""
|
|
41
|
+
path = Path(path)
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return cls(path=path, text="")
|
|
44
|
+
try:
|
|
45
|
+
text = path.read_text(encoding="utf-8")
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
raise DocumentError(f"Could not read {path}: {exc}") from exc
|
|
48
|
+
return cls(path=path, text=text)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def text(self) -> str:
|
|
52
|
+
"""The current in-memory content."""
|
|
53
|
+
return self._text
|
|
54
|
+
|
|
55
|
+
def set_text(self, new_text: str) -> None:
|
|
56
|
+
"""Replace the in-memory content, updating the dirty flag."""
|
|
57
|
+
self._text = new_text
|
|
58
|
+
self._dirty = self._text != self._saved_text
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def dirty(self) -> bool:
|
|
62
|
+
"""Whether the in-memory content differs from what's on disk."""
|
|
63
|
+
return self._dirty
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_new(self) -> bool:
|
|
67
|
+
"""Whether this document has no backing file yet."""
|
|
68
|
+
return self.path is None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def display_name(self) -> str:
|
|
72
|
+
"""A short, human-readable name for status/title display."""
|
|
73
|
+
return self.path.name if self.path is not None else "untitled.md"
|
|
74
|
+
|
|
75
|
+
def save(self, path: Path | None = None) -> None:
|
|
76
|
+
"""Write the current content to disk.
|
|
77
|
+
|
|
78
|
+
Pass ``path`` to save-as; otherwise saves to the document's existing
|
|
79
|
+
path. Raises :class:`DocumentError` if there is no path to save to,
|
|
80
|
+
or if the write fails.
|
|
81
|
+
"""
|
|
82
|
+
target = path if path is not None else self.path
|
|
83
|
+
if target is None:
|
|
84
|
+
raise DocumentError("No path to save to")
|
|
85
|
+
target = Path(target)
|
|
86
|
+
try:
|
|
87
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
target.write_text(self._text, encoding="utf-8")
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
raise DocumentError(f"Could not write {target}: {exc}") from exc
|
|
91
|
+
self.path = target
|
|
92
|
+
self._saved_text = self._text
|
|
93
|
+
self._dirty = False
|
|
94
|
+
|
|
95
|
+
def reload(self) -> None:
|
|
96
|
+
"""Discard in-memory changes and re-read the file from disk."""
|
|
97
|
+
if self.path is None:
|
|
98
|
+
raise DocumentError("Cannot reload a document with no path")
|
|
99
|
+
try:
|
|
100
|
+
text = self.path.read_text(encoding="utf-8")
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
raise DocumentError(f"Could not read {self.path}: {exc}") from exc
|
|
103
|
+
self._text = text
|
|
104
|
+
self._saved_text = text
|
|
105
|
+
self._dirty = False
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The main screen: a view/edit toggle over a single Document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widgets import ContentSwitcher, Footer, Header
|
|
9
|
+
|
|
10
|
+
from mdedit.document import Document, DocumentError
|
|
11
|
+
from mdedit.widgets.source_editor import SourceEditor
|
|
12
|
+
from mdedit.widgets.status_bar import StatusBar
|
|
13
|
+
from mdedit.widgets.viewer import DocumentViewer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EditorScreen(Screen):
|
|
17
|
+
"""Displays a Document in either rendered (view) or source (edit) mode."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("ctrl+t", "toggle_mode", "Toggle view/edit"),
|
|
21
|
+
("ctrl+s", "save", "Save"),
|
|
22
|
+
("ctrl+r", "reload", "Reload"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
VIEW_ID = "viewer"
|
|
26
|
+
EDIT_ID = "editor"
|
|
27
|
+
|
|
28
|
+
DEFAULT_CSS = """
|
|
29
|
+
EditorScreen > Vertical {
|
|
30
|
+
height: 1fr;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
EditorScreen ContentSwitcher {
|
|
34
|
+
height: 1fr;
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, document: Document) -> None:
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.document = document
|
|
41
|
+
|
|
42
|
+
def compose(self) -> ComposeResult:
|
|
43
|
+
yield Header()
|
|
44
|
+
with Vertical():
|
|
45
|
+
with ContentSwitcher(initial=self.VIEW_ID):
|
|
46
|
+
yield DocumentViewer(id=self.VIEW_ID, show_table_of_contents=True)
|
|
47
|
+
yield SourceEditor.for_document(self.document, id=self.EDIT_ID)
|
|
48
|
+
yield StatusBar()
|
|
49
|
+
yield Footer()
|
|
50
|
+
|
|
51
|
+
async def on_mount(self) -> None:
|
|
52
|
+
await self.query_one(DocumentViewer).load_document(self.document)
|
|
53
|
+
self._refresh_status()
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def mode(self) -> str:
|
|
57
|
+
switcher = self.query_one(ContentSwitcher)
|
|
58
|
+
return "EDIT" if switcher.current == self.EDIT_ID else "VIEW"
|
|
59
|
+
|
|
60
|
+
def _refresh_status(self) -> None:
|
|
61
|
+
self.query_one(StatusBar).update_status(self.document, self.mode)
|
|
62
|
+
self.title = self.document.display_name
|
|
63
|
+
|
|
64
|
+
async def action_toggle_mode(self) -> None:
|
|
65
|
+
switcher = self.query_one(ContentSwitcher)
|
|
66
|
+
if switcher.current == self.EDIT_ID:
|
|
67
|
+
# Leaving edit mode: sync the editor's text back into the
|
|
68
|
+
# Document before switching, so view mode always reflects the
|
|
69
|
+
# latest edits.
|
|
70
|
+
source = self.query_one(SourceEditor).text
|
|
71
|
+
self.document.set_text(source)
|
|
72
|
+
await self.query_one(DocumentViewer).load_document(self.document)
|
|
73
|
+
switcher.current = self.VIEW_ID
|
|
74
|
+
else:
|
|
75
|
+
switcher.current = self.EDIT_ID
|
|
76
|
+
self.query_one(SourceEditor).focus()
|
|
77
|
+
self._refresh_status()
|
|
78
|
+
|
|
79
|
+
def action_save(self) -> None:
|
|
80
|
+
# If currently in edit mode, make sure in-flight edits are captured
|
|
81
|
+
# before writing to disk.
|
|
82
|
+
if self.mode == "EDIT":
|
|
83
|
+
self.document.set_text(self.query_one(SourceEditor).text)
|
|
84
|
+
try:
|
|
85
|
+
self.document.save()
|
|
86
|
+
except DocumentError as exc:
|
|
87
|
+
self.notify(str(exc), severity="error")
|
|
88
|
+
else:
|
|
89
|
+
self.notify(f"Saved {self.document.display_name}")
|
|
90
|
+
self._refresh_status()
|
|
91
|
+
|
|
92
|
+
async def action_reload(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
self.document.reload()
|
|
95
|
+
except DocumentError as exc:
|
|
96
|
+
self.notify(str(exc), severity="error")
|
|
97
|
+
return
|
|
98
|
+
self.query_one(SourceEditor).text = self.document.text
|
|
99
|
+
if self.mode == "VIEW":
|
|
100
|
+
await self.query_one(DocumentViewer).load_document(self.document)
|
|
101
|
+
self.notify(f"Reloaded {self.document.display_name}")
|
|
102
|
+
self._refresh_status()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Modal shown when quitting with unsaved changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class QuitConfirmScreen(ModalScreen[str]):
|
|
12
|
+
"""Ask the user whether to save, discard, or cancel before quitting.
|
|
13
|
+
|
|
14
|
+
Dismisses with one of ``"save"``, ``"discard"``, or ``"cancel"``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
QuitConfirmScreen {
|
|
19
|
+
align: center middle;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
QuitConfirmScreen > Vertical {
|
|
23
|
+
width: auto;
|
|
24
|
+
height: auto;
|
|
25
|
+
border: thick $panel;
|
|
26
|
+
padding: 1 2;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
QuitConfirmScreen Label {
|
|
30
|
+
width: 100%;
|
|
31
|
+
content-align: center middle;
|
|
32
|
+
padding-bottom: 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
QuitConfirmScreen Horizontal {
|
|
36
|
+
width: auto;
|
|
37
|
+
height: auto;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
QuitConfirmScreen Button {
|
|
41
|
+
margin: 0 1;
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def compose(self) -> ComposeResult:
|
|
46
|
+
with Vertical():
|
|
47
|
+
yield Label("You have unsaved changes. Save before quitting?")
|
|
48
|
+
with Horizontal():
|
|
49
|
+
yield Button("Save", id="save", variant="success")
|
|
50
|
+
yield Button("Discard", id="discard", variant="error")
|
|
51
|
+
yield Button("Cancel", id="cancel")
|
|
52
|
+
|
|
53
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
54
|
+
self.dismiss(event.button.id)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shown on startup when no file was given on the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Footer, Header, Input, Static
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WelcomeScreen(Screen):
|
|
14
|
+
"""Prompt for a Markdown file path to open (or create)."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
WelcomeScreen {
|
|
18
|
+
align: center middle;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
WelcomeScreen > Vertical {
|
|
22
|
+
width: 60;
|
|
23
|
+
height: auto;
|
|
24
|
+
border: thick $panel;
|
|
25
|
+
padding: 1 2;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
WelcomeScreen Static {
|
|
29
|
+
padding-bottom: 1;
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def compose(self) -> ComposeResult:
|
|
34
|
+
yield Header()
|
|
35
|
+
with Vertical():
|
|
36
|
+
yield Static("mdedit — open or create a Markdown file")
|
|
37
|
+
yield Input(placeholder="path/to/file.md", id="path-input")
|
|
38
|
+
yield Footer()
|
|
39
|
+
|
|
40
|
+
def on_mount(self) -> None:
|
|
41
|
+
self.query_one(Input).focus()
|
|
42
|
+
|
|
43
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
44
|
+
value = event.value.strip()
|
|
45
|
+
if not value:
|
|
46
|
+
return
|
|
47
|
+
self.app.open_path(Path(value).expanduser())
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The editable, syntax-highlighted source view of a Document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.widgets import TextArea
|
|
6
|
+
|
|
7
|
+
from mdedit.document import Document
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SourceEditor(TextArea):
|
|
11
|
+
"""A TextArea configured for editing Markdown source."""
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def for_document(cls, document: Document, id: str | None = None) -> SourceEditor:
|
|
15
|
+
"""Build a SourceEditor pre-populated with ``document``'s text."""
|
|
16
|
+
editor = cls.code_editor(
|
|
17
|
+
document.text,
|
|
18
|
+
language="markdown",
|
|
19
|
+
theme="monokai",
|
|
20
|
+
soft_wrap=True,
|
|
21
|
+
tab_behavior="indent",
|
|
22
|
+
id=id,
|
|
23
|
+
)
|
|
24
|
+
editor.show_line_numbers = False
|
|
25
|
+
return editor
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A small status bar: filename, dirty indicator, current mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from mdedit.document import Document
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StatusBar(Static):
|
|
12
|
+
"""Shows the open file's name, dirty state, and view/edit mode."""
|
|
13
|
+
|
|
14
|
+
# Deliberately NOT docked: EditorScreen's Footer already docks to the
|
|
15
|
+
# bottom of the screen, and a second bottom-docked widget in the same
|
|
16
|
+
# container overlaps it exactly rather than stacking above it. Instead
|
|
17
|
+
# StatusBar is a normal flow child placed just above the Footer, inside
|
|
18
|
+
# a wrapping Vertical (see EditorScreen).
|
|
19
|
+
DEFAULT_CSS = """
|
|
20
|
+
StatusBar {
|
|
21
|
+
height: 1;
|
|
22
|
+
background: $panel;
|
|
23
|
+
color: $text-muted;
|
|
24
|
+
padding: 0 1;
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
mode: reactive[str] = reactive("VIEW")
|
|
29
|
+
|
|
30
|
+
def update_status(self, document: Document, mode: str) -> None:
|
|
31
|
+
self.mode = mode
|
|
32
|
+
dirty = " •" if document.dirty else ""
|
|
33
|
+
self.update(f"{document.display_name}{dirty} — {mode}")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The read-only rendered view of a Document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.widgets import MarkdownViewer
|
|
6
|
+
|
|
7
|
+
from mdedit.document import Document
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DocumentViewer(MarkdownViewer):
|
|
11
|
+
"""A MarkdownViewer bound to a :class:`~mdedit.document.Document`."""
|
|
12
|
+
|
|
13
|
+
async def load_document(self, document: Document) -> None:
|
|
14
|
+
"""Render ``document``'s current text."""
|
|
15
|
+
await self.document.update(document.text)
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from textual.widgets import Footer
|
|
5
|
+
|
|
6
|
+
from mdedit.app import MDEditApp
|
|
7
|
+
from mdedit.screens.editor import EditorScreen
|
|
8
|
+
from mdedit.screens.welcome import WelcomeScreen
|
|
9
|
+
from mdedit.widgets.source_editor import SourceEditor
|
|
10
|
+
from mdedit.widgets.status_bar import StatusBar
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def sample_file(tmp_path: Path) -> Path:
|
|
15
|
+
path = tmp_path / "sample.md"
|
|
16
|
+
path.write_text("# Hello\n\nSome **bold** text.\n", encoding="utf-8")
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def test_opens_directly_to_editor_screen_with_file_arg(sample_file):
|
|
21
|
+
app = MDEditApp(file_path=sample_file)
|
|
22
|
+
async with app.run_test() as pilot:
|
|
23
|
+
await pilot.pause()
|
|
24
|
+
assert isinstance(app.screen, EditorScreen)
|
|
25
|
+
assert app.screen.document.text == sample_file.read_text(encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def test_no_file_arg_shows_welcome_screen():
|
|
29
|
+
app = MDEditApp()
|
|
30
|
+
async with app.run_test() as pilot:
|
|
31
|
+
await pilot.pause()
|
|
32
|
+
assert isinstance(app.screen, WelcomeScreen)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_toggle_mode_switches_between_view_and_edit(sample_file):
|
|
36
|
+
app = MDEditApp(file_path=sample_file)
|
|
37
|
+
async with app.run_test() as pilot:
|
|
38
|
+
await pilot.pause()
|
|
39
|
+
screen = app.screen
|
|
40
|
+
assert screen.mode == "VIEW"
|
|
41
|
+
await pilot.press("ctrl+t")
|
|
42
|
+
await pilot.pause()
|
|
43
|
+
assert screen.mode == "EDIT"
|
|
44
|
+
await pilot.press("ctrl+t")
|
|
45
|
+
await pilot.pause()
|
|
46
|
+
assert screen.mode == "VIEW"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def test_edit_and_save_writes_to_disk(sample_file):
|
|
50
|
+
app = MDEditApp(file_path=sample_file)
|
|
51
|
+
async with app.run_test() as pilot:
|
|
52
|
+
await pilot.pause()
|
|
53
|
+
screen = app.screen
|
|
54
|
+
await pilot.press("ctrl+t")
|
|
55
|
+
await pilot.pause()
|
|
56
|
+
editor = screen.query_one(SourceEditor)
|
|
57
|
+
editor.text = "# Changed\n"
|
|
58
|
+
await pilot.press("ctrl+s")
|
|
59
|
+
await pilot.pause()
|
|
60
|
+
assert not screen.document.dirty
|
|
61
|
+
assert sample_file.read_text(encoding="utf-8") == "# Changed\n"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def test_toggle_syncs_edits_into_document(sample_file):
|
|
65
|
+
app = MDEditApp(file_path=sample_file)
|
|
66
|
+
async with app.run_test() as pilot:
|
|
67
|
+
await pilot.pause()
|
|
68
|
+
screen = app.screen
|
|
69
|
+
await pilot.press("ctrl+t")
|
|
70
|
+
await pilot.pause()
|
|
71
|
+
editor = screen.query_one(SourceEditor)
|
|
72
|
+
editor.text = "# Edited in place\n"
|
|
73
|
+
await pilot.press("ctrl+t") # back to view mode
|
|
74
|
+
await pilot.pause()
|
|
75
|
+
assert screen.document.text == "# Edited in place\n"
|
|
76
|
+
assert screen.document.dirty
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def test_quit_confirm_appears_on_dirty_document(sample_file):
|
|
80
|
+
app = MDEditApp(file_path=sample_file)
|
|
81
|
+
async with app.run_test() as pilot:
|
|
82
|
+
await pilot.pause()
|
|
83
|
+
screen = app.screen
|
|
84
|
+
screen.document.set_text("# dirty\n")
|
|
85
|
+
await pilot.press("ctrl+q")
|
|
86
|
+
await pilot.pause()
|
|
87
|
+
# A modal should now be on top of the screen stack, and the app
|
|
88
|
+
# should not have exited.
|
|
89
|
+
assert app.is_running
|
|
90
|
+
assert len(app.screen_stack) >= 2
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def test_status_bar_does_not_overlap_footer(sample_file):
|
|
94
|
+
# Regression test: StatusBar and Footer both used `dock: bottom` at one
|
|
95
|
+
# point, which made them occupy the exact same region (the status bar
|
|
96
|
+
# was silently hidden underneath the footer).
|
|
97
|
+
app = MDEditApp(file_path=sample_file)
|
|
98
|
+
async with app.run_test(size=(100, 35)) as pilot:
|
|
99
|
+
await pilot.pause()
|
|
100
|
+
status_bar = app.screen.query_one(StatusBar)
|
|
101
|
+
footer = app.screen.query_one(Footer)
|
|
102
|
+
assert status_bar.region.y != footer.region.y
|
|
103
|
+
assert not status_bar.region.overlaps(footer.region)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def test_welcome_screen_opens_new_file(tmp_path):
|
|
107
|
+
app = MDEditApp()
|
|
108
|
+
async with app.run_test() as pilot:
|
|
109
|
+
await pilot.pause()
|
|
110
|
+
target = tmp_path / "new-from-welcome.md"
|
|
111
|
+
app.open_path(target)
|
|
112
|
+
await pilot.pause()
|
|
113
|
+
assert isinstance(app.screen, EditorScreen)
|
|
114
|
+
assert app.screen.document.is_new is False # path is set
|
|
115
|
+
assert not target.exists() # not written until save
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from mdedit.document import Document, DocumentError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_from_path_loads_existing_file(sample_md_path):
|
|
9
|
+
doc = Document.from_path(sample_md_path)
|
|
10
|
+
assert doc.text == "# Title\n\nSome **bold** text.\n"
|
|
11
|
+
assert doc.path == sample_md_path
|
|
12
|
+
assert not doc.dirty
|
|
13
|
+
assert not doc.is_new
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_from_path_missing_file_is_new_document(tmp_path):
|
|
17
|
+
missing = tmp_path / "does-not-exist.md"
|
|
18
|
+
doc = Document.from_path(missing)
|
|
19
|
+
assert doc.text == ""
|
|
20
|
+
assert doc.path == missing
|
|
21
|
+
assert not doc.dirty
|
|
22
|
+
# path is set, so it's not "is_new" in the no-path sense, but nothing
|
|
23
|
+
# exists on disk yet until save() is called.
|
|
24
|
+
assert not missing.exists()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_from_path_directory_raises(tmp_path):
|
|
28
|
+
with pytest.raises(DocumentError):
|
|
29
|
+
Document.from_path(tmp_path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_set_text_marks_dirty(sample_md_path):
|
|
33
|
+
doc = Document.from_path(sample_md_path)
|
|
34
|
+
doc.set_text(doc.text + "\nMore.\n")
|
|
35
|
+
assert doc.dirty
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_set_text_same_content_not_dirty(sample_md_path):
|
|
39
|
+
doc = Document.from_path(sample_md_path)
|
|
40
|
+
doc.set_text(doc.text)
|
|
41
|
+
assert not doc.dirty
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_save_writes_to_disk_and_clears_dirty(sample_md_path):
|
|
45
|
+
doc = Document.from_path(sample_md_path)
|
|
46
|
+
doc.set_text("# Changed\n")
|
|
47
|
+
doc.save()
|
|
48
|
+
assert not doc.dirty
|
|
49
|
+
assert sample_md_path.read_text(encoding="utf-8") == "# Changed\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_save_new_document_creates_file(tmp_path):
|
|
53
|
+
target = tmp_path / "new.md"
|
|
54
|
+
doc = Document()
|
|
55
|
+
doc.set_text("# New\n")
|
|
56
|
+
doc.save(target)
|
|
57
|
+
assert doc.path == target
|
|
58
|
+
assert not doc.dirty
|
|
59
|
+
assert target.read_text(encoding="utf-8") == "# New\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_save_without_path_raises():
|
|
63
|
+
doc = Document()
|
|
64
|
+
doc.set_text("content")
|
|
65
|
+
with pytest.raises(DocumentError):
|
|
66
|
+
doc.save()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_reload_discards_in_memory_changes(sample_md_path):
|
|
70
|
+
doc = Document.from_path(sample_md_path)
|
|
71
|
+
doc.set_text("# Overwritten locally\n")
|
|
72
|
+
assert doc.dirty
|
|
73
|
+
doc.reload()
|
|
74
|
+
assert not doc.dirty
|
|
75
|
+
assert doc.text == "# Title\n\nSome **bold** text.\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_reload_without_path_raises():
|
|
79
|
+
doc = Document()
|
|
80
|
+
with pytest.raises(DocumentError):
|
|
81
|
+
doc.reload()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_is_new_true_without_path():
|
|
85
|
+
doc = Document()
|
|
86
|
+
assert doc.is_new
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_display_name():
|
|
90
|
+
doc = Document()
|
|
91
|
+
assert doc.display_name == "untitled.md"
|
|
92
|
+
doc2 = Document(path=Path("/tmp/foo.md"))
|
|
93
|
+
assert doc2.display_name == "foo.md"
|