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.
@@ -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
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .mypy_cache/
12
+ .coverage
13
+ htmlcov/
14
+ *.log
15
+ .DS_Store
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,3 @@
1
+ """mdedit — a terminal Markdown viewer and editor built with Textual."""
2
+
3
+ __version__ = "0.1.0"
@@ -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())
@@ -0,0 +1,6 @@
1
+ /* Global styles for mdedit. Most widgets rely on Textual's defaults; this
2
+ file only overrides what needs project-specific tweaks. */
3
+
4
+ Screen {
5
+ background: $surface;
6
+ }
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,9 @@
1
+ import pytest
2
+
3
+
4
+ @pytest.fixture
5
+ def sample_md_path(tmp_path):
6
+ """A temporary Markdown file with some content."""
7
+ path = tmp_path / "sample.md"
8
+ path.write_text("# Title\n\nSome **bold** text.\n", encoding="utf-8")
9
+ return path
@@ -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"