riched 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.
- riched-0.1.0/.gitignore +75 -0
- riched-0.1.0/.python-version +1 -0
- riched-0.1.0/AGENTS.md +31 -0
- riched-0.1.0/PKG-INFO +72 -0
- riched-0.1.0/README.md +62 -0
- riched-0.1.0/pyproject.toml +21 -0
- riched-0.1.0/src/riched/__init__.py +2 -0
- riched-0.1.0/src/riched/app.py +433 -0
- riched-0.1.0/src/riched/bindings.yaml +76 -0
- riched-0.1.0/src/riched/cli.py +24 -0
- riched-0.1.0/src/riched/editor.py +96 -0
- riched-0.1.0/src/riched/keybindings.py +123 -0
- riched-0.1.0/src/riched/screens.py +284 -0
- riched-0.1.0/src/riched/settings.py +47 -0
- riched-0.1.0/src/riched/syntax.py +241 -0
- riched-0.1.0/tests/__init__.py +0 -0
- riched-0.1.0/tests/helpers.py +49 -0
- riched-0.1.0/tests/runner.py +146 -0
- riched-0.1.0/tests/test_command_palette.py +27 -0
- riched-0.1.0/tests/test_dirty_buffers.py +135 -0
- riched-0.1.0/tests/test_editor_shortcuts.py +312 -0
- riched-0.1.0/tests/test_file_io.py +64 -0
- riched-0.1.0/tests/test_file_tree.py +59 -0
- riched-0.1.0/tests/test_footer.py +24 -0
- riched-0.1.0/tests/test_syntax.py +53 -0
- riched-0.1.0/uv.lock +447 -0
riched-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
### JavaScript ###
|
|
2
|
+
.DS_Store
|
|
3
|
+
.env
|
|
4
|
+
.env.*
|
|
5
|
+
*.tsbuildinfo
|
|
6
|
+
/build
|
|
7
|
+
/coverage
|
|
8
|
+
/dist
|
|
9
|
+
/node_modules
|
|
10
|
+
/out
|
|
11
|
+
/tmp
|
|
12
|
+
|
|
13
|
+
### Python ###
|
|
14
|
+
# Byte-compiled / optimized / DLL files
|
|
15
|
+
__pycache__/
|
|
16
|
+
*.py[cod]
|
|
17
|
+
*$py.class
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
|
|
20
|
+
# C extensions
|
|
21
|
+
*.so
|
|
22
|
+
|
|
23
|
+
# Distribution / packaging
|
|
24
|
+
.Python
|
|
25
|
+
build/
|
|
26
|
+
develop-eggs/
|
|
27
|
+
downloads/
|
|
28
|
+
eggs/
|
|
29
|
+
.eggs/
|
|
30
|
+
lib/
|
|
31
|
+
lib64/
|
|
32
|
+
parts/
|
|
33
|
+
sdist/
|
|
34
|
+
var/
|
|
35
|
+
wheels/
|
|
36
|
+
share/python-wheels/
|
|
37
|
+
*.egg-info/
|
|
38
|
+
.installed.cfg
|
|
39
|
+
*.egg
|
|
40
|
+
MANIFEST
|
|
41
|
+
|
|
42
|
+
# Unit test / coverage reports
|
|
43
|
+
htmlcov/
|
|
44
|
+
.tox/
|
|
45
|
+
.nox/
|
|
46
|
+
.coverage
|
|
47
|
+
.coverage.*
|
|
48
|
+
.cache
|
|
49
|
+
nosetests.xml
|
|
50
|
+
coverage.xml
|
|
51
|
+
*.cover
|
|
52
|
+
*.py,cover
|
|
53
|
+
.hypothesis/
|
|
54
|
+
.pytest_cache/
|
|
55
|
+
cover/
|
|
56
|
+
|
|
57
|
+
# Jupyter Notebook
|
|
58
|
+
.ipynb_checkpoints
|
|
59
|
+
|
|
60
|
+
# Environments
|
|
61
|
+
.venv
|
|
62
|
+
env/
|
|
63
|
+
venv/
|
|
64
|
+
ENV/
|
|
65
|
+
env.bak/
|
|
66
|
+
venv.bak/
|
|
67
|
+
.env.local
|
|
68
|
+
|
|
69
|
+
# mkdocs output
|
|
70
|
+
site/
|
|
71
|
+
|
|
72
|
+
# custom
|
|
73
|
+
try*
|
|
74
|
+
secrets.toml
|
|
75
|
+
keys.log
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
riched-0.1.0/AGENTS.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Repository Guidelines
|
|
2
|
+
|
|
3
|
+
## Project Structure & Module Organization
|
|
4
|
+
|
|
5
|
+
This repository is a uv-managed Python project for `riched`, a Textual TUI editor. Application code lives in `src/riched/`: `cli.py` owns argument parsing, `app.py` owns the Textual app and file tree, `editor.py` customizes text editing, `screens.py` contains modal screens, `keybindings.py` loads shipped shortcut config and popup display rows, `bindings.yaml` defines shipped bindings, `settings.py` persists user settings such as theme, and `syntax.py` handles language detection/highlighting. End-to-end tests live in `tests/`. Root-level `foo.*` and `hello.txt` files are test fixtures/sample content. Avoid committing generated files such as `.venv/`, `__pycache__/`, `keys.log`, or build artifacts.
|
|
6
|
+
|
|
7
|
+
## Build, Test, and Development Commands
|
|
8
|
+
|
|
9
|
+
- `uv sync`: install project dependencies from `pyproject.toml` and `uv.lock`.
|
|
10
|
+
- `uv run riched path/to/file.txt`: open or create a file in the editor.
|
|
11
|
+
- `uv run python -m tests.runner`: run the Textual Pilot end-to-end suite.
|
|
12
|
+
- `uv add <package>`: add runtime dependencies; do not use `pip`.
|
|
13
|
+
|
|
14
|
+
## Coding Style & Naming Conventions
|
|
15
|
+
|
|
16
|
+
Use Python 3.14-compatible code with type hints, `from __future__ import annotations`, `Path`, and small focused helpers. Use 4-space indentation. Keep constants in `UPPER_SNAKE_CASE`, classes in `PascalCase`, functions and methods in `snake_case`, and private helpers prefixed with `_`. Prefer Textual widgets and events over manual terminal control. Keep comments brief and only for non-obvious behavior.
|
|
17
|
+
|
|
18
|
+
Keep key bindings in `src/riched/bindings.yaml`, not hardcoded in Python. The F1 hotkey popup is generated from this YAML and displays one user-facing key per binding, preferring `cmd` aliases over `super`.
|
|
19
|
+
|
|
20
|
+
## Testing Guidelines
|
|
21
|
+
|
|
22
|
+
Only add end-to-end tests in `tests/`; do not create unit tests. Tests use Textual's Pilot harness and should be named `test_<behavior>`. Keep each test focused on user-visible behavior such as file open/save, dirty prompts, file tree switching, or syntax highlighting. Use temporary files as existing tests do.
|
|
23
|
+
|
|
24
|
+
## Commit & Pull Request Guidelines
|
|
25
|
+
|
|
26
|
+
Use concise imperative commit messages, for example `Add file tree` or `Refactor editor package`. Pull requests should include a short summary, manual/e2e verification steps, related issue links if any, and terminal screenshots or recordings for visible TUI changes. When merging pull requests, use rebase and merge.
|
|
27
|
+
|
|
28
|
+
## Agent-Specific Instructions
|
|
29
|
+
|
|
30
|
+
Prefer `uv` for Python tasks. Keep changes scoped to requested behavior. Update `AGENTS.md` when commands, structure, or testing policy changes.
|
|
31
|
+
Never change Ghostty settings or configuration, even when investigating Command-key behavior; only document suggested user-side settings when explicitly asked.
|
riched-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: riched
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Textual TUI editor
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
7
|
+
Requires-Dist: textual[syntax]>=8.2.4
|
|
8
|
+
Requires-Dist: tree-sitter-typescript>=0.23.2
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Riched
|
|
12
|
+
|
|
13
|
+
`riched` is a TUI editor powered by the [Rich](https://github.com/Textualize/rich) package and the [Textual](https://github.com/Textualize/textual) framework, the name is a nod to Rich.
|
|
14
|
+
|
|
15
|
+
> [!NOTE]
|
|
16
|
+
> `riched` targets Ghostty on macOS. Some key bindings, especially shortcuts
|
|
17
|
+
> that use the Command key, may not work in other terminals or on other
|
|
18
|
+
> operating systems.
|
|
19
|
+
|
|
20
|
+
## Key Features
|
|
21
|
+
|
|
22
|
+
- Syntax highlighting
|
|
23
|
+
- Resizable project file tree
|
|
24
|
+
- VS Code-style text editing key bindings
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uv sync
|
|
30
|
+
uv run riched .
|
|
31
|
+
uv run python -m tests.runner
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Key Bindings
|
|
35
|
+
|
|
36
|
+
Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
|
|
37
|
+
|
|
38
|
+
App shortcuts:
|
|
39
|
+
|
|
40
|
+
| Shortcut | Action |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `Ctrl+S` | Save |
|
|
43
|
+
| `Ctrl+Q` | Quit |
|
|
44
|
+
| `Ctrl+W` | Close buffer |
|
|
45
|
+
| `F1` | Key bindings |
|
|
46
|
+
| `Cmd+B` / `Super+B` | Toggle file tree |
|
|
47
|
+
| `Cmd+P` / `Super+P` | Quick open |
|
|
48
|
+
|
|
49
|
+
Editor shortcuts:
|
|
50
|
+
|
|
51
|
+
| Shortcut | Action |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `Alt+Up` | Move line up |
|
|
54
|
+
| `Alt+Down` | Move line down |
|
|
55
|
+
| `Alt+Shift+Up` / `Shift+Alt+Up` | Copy line up |
|
|
56
|
+
| `Alt+Shift+Down` / `Shift+Alt+Down` | Copy line down |
|
|
57
|
+
| `Alt+Backspace` | Delete word left |
|
|
58
|
+
| `Alt+Shift+Left` / `Shift+Alt+Left` | Select word left |
|
|
59
|
+
| `Alt+Shift+Right` / `Shift+Alt+Right` | Select word right |
|
|
60
|
+
| `Super+L` / `Cmd+L` | Select line |
|
|
61
|
+
| `Shift+Super+Left` / `Super+Shift+Left` / `Shift+Cmd+Left` / `Cmd+Shift+Left` | Select to line start |
|
|
62
|
+
| `Shift+Super+Right` / `Super+Shift+Right` / `Shift+Cmd+Right` / `Cmd+Shift+Right` | Select to line end |
|
|
63
|
+
|
|
64
|
+
## Settings
|
|
65
|
+
|
|
66
|
+
`riched` persists the selected Textual theme in `settings.yaml` under the user
|
|
67
|
+
config directory: `~/Library/Application Support/riched/settings.yaml`
|
|
68
|
+
|
|
69
|
+
## Won't Implemented
|
|
70
|
+
|
|
71
|
+
`riched` will not implement key bindings that require users to modify the Ghostty terminal configuration, since terminal-specific configuration makes shortcuts harder to document, test, and support consistently across environments.
|
|
72
|
+
|
riched-0.1.0/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Riched
|
|
2
|
+
|
|
3
|
+
`riched` is a TUI editor powered by the [Rich](https://github.com/Textualize/rich) package and the [Textual](https://github.com/Textualize/textual) framework, the name is a nod to Rich.
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> `riched` targets Ghostty on macOS. Some key bindings, especially shortcuts
|
|
7
|
+
> that use the Command key, may not work in other terminals or on other
|
|
8
|
+
> operating systems.
|
|
9
|
+
|
|
10
|
+
## Key Features
|
|
11
|
+
|
|
12
|
+
- Syntax highlighting
|
|
13
|
+
- Resizable project file tree
|
|
14
|
+
- VS Code-style text editing key bindings
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv sync
|
|
20
|
+
uv run riched .
|
|
21
|
+
uv run python -m tests.runner
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Key Bindings
|
|
25
|
+
|
|
26
|
+
Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
|
|
27
|
+
|
|
28
|
+
App shortcuts:
|
|
29
|
+
|
|
30
|
+
| Shortcut | Action |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `Ctrl+S` | Save |
|
|
33
|
+
| `Ctrl+Q` | Quit |
|
|
34
|
+
| `Ctrl+W` | Close buffer |
|
|
35
|
+
| `F1` | Key bindings |
|
|
36
|
+
| `Cmd+B` / `Super+B` | Toggle file tree |
|
|
37
|
+
| `Cmd+P` / `Super+P` | Quick open |
|
|
38
|
+
|
|
39
|
+
Editor shortcuts:
|
|
40
|
+
|
|
41
|
+
| Shortcut | Action |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `Alt+Up` | Move line up |
|
|
44
|
+
| `Alt+Down` | Move line down |
|
|
45
|
+
| `Alt+Shift+Up` / `Shift+Alt+Up` | Copy line up |
|
|
46
|
+
| `Alt+Shift+Down` / `Shift+Alt+Down` | Copy line down |
|
|
47
|
+
| `Alt+Backspace` | Delete word left |
|
|
48
|
+
| `Alt+Shift+Left` / `Shift+Alt+Left` | Select word left |
|
|
49
|
+
| `Alt+Shift+Right` / `Shift+Alt+Right` | Select word right |
|
|
50
|
+
| `Super+L` / `Cmd+L` | Select line |
|
|
51
|
+
| `Shift+Super+Left` / `Super+Shift+Left` / `Shift+Cmd+Left` / `Cmd+Shift+Left` | Select to line start |
|
|
52
|
+
| `Shift+Super+Right` / `Super+Shift+Right` / `Shift+Cmd+Right` / `Cmd+Shift+Right` | Select to line end |
|
|
53
|
+
|
|
54
|
+
## Settings
|
|
55
|
+
|
|
56
|
+
`riched` persists the selected Textual theme in `settings.yaml` under the user
|
|
57
|
+
config directory: `~/Library/Application Support/riched/settings.yaml`
|
|
58
|
+
|
|
59
|
+
## Won't Implemented
|
|
60
|
+
|
|
61
|
+
`riched` will not implement key bindings that require users to modify the Ghostty terminal configuration, since terminal-specific configuration makes shortcuts harder to document, test, and support consistently across environments.
|
|
62
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "riched"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Textual TUI editor"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pyyaml>=6.0.3",
|
|
9
|
+
"textual[syntax]>=8.2.4",
|
|
10
|
+
"tree-sitter-typescript>=0.23.2",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
riched = "riched.cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/riched"]
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from textual import events
|
|
8
|
+
from textual.app import App, ComposeResult, SystemCommand
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.command import CommandPalette
|
|
11
|
+
from textual.containers import Container, Horizontal
|
|
12
|
+
from textual.keys import format_key
|
|
13
|
+
from textual.screen import Screen
|
|
14
|
+
from textual.widgets import DirectoryTree, Footer, Header, Static, TextArea
|
|
15
|
+
|
|
16
|
+
from .editor import RichedTextArea
|
|
17
|
+
from .keybindings import display_key
|
|
18
|
+
from .screens import KeysHelpScreen, QuickOpenScreen, UnsavedChangesScreen
|
|
19
|
+
from .settings import load_theme, save_theme
|
|
20
|
+
from .syntax import apply_language
|
|
21
|
+
|
|
22
|
+
FILE_TREE_DEFAULT_WIDTH = 30
|
|
23
|
+
FILE_TREE_MIN_WIDTH = 18
|
|
24
|
+
FILE_TREE_MAX_WIDTH = 44
|
|
25
|
+
QUICK_OPEN_SKIP_DIRS = {
|
|
26
|
+
".git",
|
|
27
|
+
".venv",
|
|
28
|
+
"__pycache__",
|
|
29
|
+
"build",
|
|
30
|
+
"dist",
|
|
31
|
+
"node_modules",
|
|
32
|
+
}
|
|
33
|
+
KEY_MODIFIER_SYMBOLS = {
|
|
34
|
+
"cmd": "⌘",
|
|
35
|
+
"super": "⌘",
|
|
36
|
+
"ctrl": "⌃",
|
|
37
|
+
"control": "⌃",
|
|
38
|
+
"alt": "⌥",
|
|
39
|
+
"option": "⌥",
|
|
40
|
+
"shift": "⇧",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RichedDirectoryTree(DirectoryTree):
|
|
45
|
+
"""Directory tree with editor-style keyboard affordances."""
|
|
46
|
+
|
|
47
|
+
BINDINGS = [
|
|
48
|
+
*(binding for binding in DirectoryTree.BINDINGS if binding.key != "space"),
|
|
49
|
+
Binding("left", "collapse_cursor", "Collapse folder", show=False),
|
|
50
|
+
Binding("right", "expand_cursor", "Expand folder", show=False),
|
|
51
|
+
Binding("space", "activate_cursor", "Open file or toggle folder", show=False),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
def _cursor_dir_node(self) -> Any | None:
|
|
55
|
+
node = self.cursor_node
|
|
56
|
+
if node is None or node.data is None or not node.allow_expand:
|
|
57
|
+
return None
|
|
58
|
+
return node
|
|
59
|
+
|
|
60
|
+
def action_collapse_cursor(self) -> None:
|
|
61
|
+
node = self._cursor_dir_node()
|
|
62
|
+
if node is not None:
|
|
63
|
+
node.collapse()
|
|
64
|
+
|
|
65
|
+
def action_expand_cursor(self) -> None:
|
|
66
|
+
node = self._cursor_dir_node()
|
|
67
|
+
if node is not None:
|
|
68
|
+
node.expand()
|
|
69
|
+
|
|
70
|
+
def action_activate_cursor(self) -> None:
|
|
71
|
+
node = self.cursor_node
|
|
72
|
+
if node is None:
|
|
73
|
+
return
|
|
74
|
+
if node.allow_expand:
|
|
75
|
+
node.toggle()
|
|
76
|
+
return
|
|
77
|
+
self.action_select_cursor()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FileTreeResizeHandle(Static):
|
|
81
|
+
"""Mouse handle for resizing the file tree."""
|
|
82
|
+
|
|
83
|
+
can_focus = False
|
|
84
|
+
|
|
85
|
+
def __init__(self) -> None:
|
|
86
|
+
super().__init__("", id="file-tree-resize-handle")
|
|
87
|
+
self._drag_start_x: int | None = None
|
|
88
|
+
self._drag_start_width: int | None = None
|
|
89
|
+
|
|
90
|
+
def on_mouse_down(self, event: events.MouseDown) -> None:
|
|
91
|
+
if event.button != 1 or event.screen_x is None:
|
|
92
|
+
return
|
|
93
|
+
tree = self.app.query_one("#file-tree", DirectoryTree)
|
|
94
|
+
self._drag_start_x = int(event.screen_x)
|
|
95
|
+
self._drag_start_width = tree.region.width
|
|
96
|
+
self.capture_mouse()
|
|
97
|
+
self.add_class("dragging")
|
|
98
|
+
event.stop()
|
|
99
|
+
event.prevent_default()
|
|
100
|
+
|
|
101
|
+
def on_mouse_move(self, event: events.MouseMove) -> None:
|
|
102
|
+
if self._drag_start_x is None or self._drag_start_width is None:
|
|
103
|
+
return
|
|
104
|
+
if event.screen_x is None:
|
|
105
|
+
return
|
|
106
|
+
delta = int(event.screen_x) - self._drag_start_x
|
|
107
|
+
app = self.app
|
|
108
|
+
if isinstance(app, RichedApp):
|
|
109
|
+
app.set_file_tree_width(self._drag_start_width + delta)
|
|
110
|
+
event.stop()
|
|
111
|
+
event.prevent_default()
|
|
112
|
+
|
|
113
|
+
def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
114
|
+
if self._drag_start_x is None:
|
|
115
|
+
return
|
|
116
|
+
self._drag_start_x = None
|
|
117
|
+
self._drag_start_width = None
|
|
118
|
+
self.release_mouse()
|
|
119
|
+
self.remove_class("dragging")
|
|
120
|
+
event.stop()
|
|
121
|
+
event.prevent_default()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class RichedApp(App):
|
|
125
|
+
"""TUI text editor with a project file tree."""
|
|
126
|
+
|
|
127
|
+
CSS = """
|
|
128
|
+
#workspace {
|
|
129
|
+
height: 1fr;
|
|
130
|
+
}
|
|
131
|
+
#file-tree {
|
|
132
|
+
width: 30;
|
|
133
|
+
min-width: 18;
|
|
134
|
+
max-width: 44;
|
|
135
|
+
}
|
|
136
|
+
#file-tree-resize-handle {
|
|
137
|
+
width: 1;
|
|
138
|
+
min-width: 1;
|
|
139
|
+
max-width: 1;
|
|
140
|
+
height: 1fr;
|
|
141
|
+
background: $panel;
|
|
142
|
+
}
|
|
143
|
+
#file-tree-resize-handle:hover,
|
|
144
|
+
#file-tree-resize-handle.dragging {
|
|
145
|
+
background: $accent;
|
|
146
|
+
}
|
|
147
|
+
#editor {
|
|
148
|
+
height: 1fr;
|
|
149
|
+
width: 1fr;
|
|
150
|
+
}
|
|
151
|
+
#editor-slot {
|
|
152
|
+
height: 1fr;
|
|
153
|
+
width: 1fr;
|
|
154
|
+
}
|
|
155
|
+
HeaderIcon {
|
|
156
|
+
display: none;
|
|
157
|
+
}
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
BINDINGS: list[Binding] = []
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
path: Path,
|
|
165
|
+
root: Path | None = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
super().__init__()
|
|
168
|
+
saved_theme = load_theme()
|
|
169
|
+
if saved_theme in self.available_themes:
|
|
170
|
+
self.theme = saved_theme
|
|
171
|
+
self.watch(self, "theme", self._save_theme, init=False)
|
|
172
|
+
initial_path = path.expanduser()
|
|
173
|
+
self._initial_path = None if initial_path.is_dir() else initial_path
|
|
174
|
+
self.path: Path | None = self._initial_path
|
|
175
|
+
self.root = root or Path.cwd()
|
|
176
|
+
self._saved_text = ""
|
|
177
|
+
|
|
178
|
+
def _save_theme(self, theme: str) -> None:
|
|
179
|
+
try:
|
|
180
|
+
save_theme(theme)
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
self.notify(f"Could not save theme: {exc}", severity="warning")
|
|
183
|
+
|
|
184
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
185
|
+
for command in super().get_system_commands(screen):
|
|
186
|
+
if command.title == "Maximize":
|
|
187
|
+
continue
|
|
188
|
+
if command.title == "Keys":
|
|
189
|
+
yield SystemCommand(
|
|
190
|
+
"Keys",
|
|
191
|
+
"Show key bindings",
|
|
192
|
+
self.action_show_keys_popup,
|
|
193
|
+
)
|
|
194
|
+
continue
|
|
195
|
+
yield command
|
|
196
|
+
|
|
197
|
+
def get_key_display(self, binding: Binding) -> str:
|
|
198
|
+
if binding.key_display:
|
|
199
|
+
return binding.key_display
|
|
200
|
+
|
|
201
|
+
key = display_key(binding.key)
|
|
202
|
+
parts = key.split("+")
|
|
203
|
+
base_key = format_key(parts[-1])
|
|
204
|
+
if len(base_key) == 1:
|
|
205
|
+
base_key = base_key.upper()
|
|
206
|
+
else:
|
|
207
|
+
base_key = base_key.title()
|
|
208
|
+
modifiers = "".join(
|
|
209
|
+
KEY_MODIFIER_SYMBOLS.get(part, part) for part in parts[:-1]
|
|
210
|
+
)
|
|
211
|
+
return f"{modifiers}{base_key}"
|
|
212
|
+
|
|
213
|
+
def compose(self) -> ComposeResult:
|
|
214
|
+
yield Header()
|
|
215
|
+
with Horizontal(id="workspace"):
|
|
216
|
+
yield RichedDirectoryTree(str(self.root), id="file-tree")
|
|
217
|
+
yield FileTreeResizeHandle()
|
|
218
|
+
yield Container(id="editor-slot")
|
|
219
|
+
yield Footer()
|
|
220
|
+
|
|
221
|
+
def on_mount(self) -> None:
|
|
222
|
+
self.title = "riched"
|
|
223
|
+
self.set_file_tree_width(FILE_TREE_DEFAULT_WIDTH)
|
|
224
|
+
if self._initial_path is None:
|
|
225
|
+
self.sub_title = ""
|
|
226
|
+
self.query_one("#file-tree", DirectoryTree).focus()
|
|
227
|
+
return
|
|
228
|
+
self._open_path(self._initial_path)
|
|
229
|
+
|
|
230
|
+
def _editor_or_none(self) -> TextArea | None:
|
|
231
|
+
editors = list(self.query("#editor"))
|
|
232
|
+
return editors[0] if editors else None
|
|
233
|
+
|
|
234
|
+
def _file_tree(self) -> DirectoryTree:
|
|
235
|
+
return self.query_one("#file-tree", DirectoryTree)
|
|
236
|
+
|
|
237
|
+
def _file_tree_resize_handle(self) -> Static:
|
|
238
|
+
return self.query_one("#file-tree-resize-handle", Static)
|
|
239
|
+
|
|
240
|
+
def _is_file_tree_visible(self) -> bool:
|
|
241
|
+
return self._file_tree().styles.display != "none"
|
|
242
|
+
|
|
243
|
+
def _show_file_tree(self) -> None:
|
|
244
|
+
self._file_tree().styles.display = "block"
|
|
245
|
+
self._file_tree_resize_handle().styles.display = "block"
|
|
246
|
+
|
|
247
|
+
def _hide_file_tree(self) -> None:
|
|
248
|
+
self._file_tree().styles.display = "none"
|
|
249
|
+
self._file_tree_resize_handle().styles.display = "none"
|
|
250
|
+
|
|
251
|
+
def set_file_tree_width(self, width: int) -> None:
|
|
252
|
+
clamped_width = max(FILE_TREE_MIN_WIDTH, min(FILE_TREE_MAX_WIDTH, width))
|
|
253
|
+
tree = self._file_tree()
|
|
254
|
+
tree.styles.width = clamped_width
|
|
255
|
+
tree.refresh(layout=True)
|
|
256
|
+
|
|
257
|
+
def _get_or_create_editor(self) -> TextArea:
|
|
258
|
+
editor = self._editor_or_none()
|
|
259
|
+
if editor is not None:
|
|
260
|
+
return editor
|
|
261
|
+
editor = RichedTextArea.code_editor(id="editor", theme="css")
|
|
262
|
+
self.query_one("#editor-slot", Container).mount(editor)
|
|
263
|
+
return editor
|
|
264
|
+
|
|
265
|
+
def _close_buffer(self) -> None:
|
|
266
|
+
self.query_one("#editor-slot", Container).remove_children()
|
|
267
|
+
self.path = None
|
|
268
|
+
self.sub_title = ""
|
|
269
|
+
self._saved_text = ""
|
|
270
|
+
self._show_file_tree()
|
|
271
|
+
self._file_tree().focus()
|
|
272
|
+
|
|
273
|
+
def _open_path(self, path: Path) -> None:
|
|
274
|
+
self.path = path.expanduser()
|
|
275
|
+
self.sub_title = str(self.path)
|
|
276
|
+
editor = self._get_or_create_editor()
|
|
277
|
+
if self.path.exists():
|
|
278
|
+
try:
|
|
279
|
+
content = self.path.read_text()
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
self.notify(f"Could not read: {exc}", severity="error")
|
|
282
|
+
content = ""
|
|
283
|
+
else:
|
|
284
|
+
content = ""
|
|
285
|
+
editor.load_text(content)
|
|
286
|
+
self._saved_text = content
|
|
287
|
+
try:
|
|
288
|
+
apply_language(editor, self.path.suffix)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
self.notify(f"Syntax highlight off: {exc}", severity="warning")
|
|
291
|
+
editor.focus()
|
|
292
|
+
|
|
293
|
+
def _is_dirty(self) -> bool:
|
|
294
|
+
editor = self._editor_or_none()
|
|
295
|
+
return self.path is not None and editor is not None and editor.text != self._saved_text
|
|
296
|
+
|
|
297
|
+
def on_directory_tree_file_selected(
|
|
298
|
+
self, event: DirectoryTree.FileSelected
|
|
299
|
+
) -> None:
|
|
300
|
+
event.stop()
|
|
301
|
+
selected = Path(event.path)
|
|
302
|
+
if selected == self.path:
|
|
303
|
+
editor = self._editor_or_none()
|
|
304
|
+
if editor is not None:
|
|
305
|
+
editor.focus()
|
|
306
|
+
return
|
|
307
|
+
self._switch_path(selected)
|
|
308
|
+
|
|
309
|
+
def _switch_path(self, selected: Path) -> None:
|
|
310
|
+
if not self._is_dirty():
|
|
311
|
+
self._open_path(selected)
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
def handle(choice: str) -> None:
|
|
315
|
+
if choice == "save":
|
|
316
|
+
self.action_save()
|
|
317
|
+
if not self._is_dirty():
|
|
318
|
+
self._open_path(selected)
|
|
319
|
+
elif choice == "discard":
|
|
320
|
+
self._open_path(selected)
|
|
321
|
+
|
|
322
|
+
self.push_screen(UnsavedChangesScreen(), handle)
|
|
323
|
+
|
|
324
|
+
def _quick_open_files(self) -> list[Path]:
|
|
325
|
+
root = self.root.expanduser()
|
|
326
|
+
files: list[Path] = []
|
|
327
|
+
stack = [root]
|
|
328
|
+
while stack:
|
|
329
|
+
directory = stack.pop()
|
|
330
|
+
try:
|
|
331
|
+
entries = sorted(directory.iterdir(), key=lambda path: path.name.lower())
|
|
332
|
+
except OSError:
|
|
333
|
+
continue
|
|
334
|
+
for entry in entries:
|
|
335
|
+
try:
|
|
336
|
+
if entry.is_dir():
|
|
337
|
+
if entry.name not in QUICK_OPEN_SKIP_DIRS and not entry.is_symlink():
|
|
338
|
+
stack.append(entry)
|
|
339
|
+
elif entry.is_file():
|
|
340
|
+
files.append(entry)
|
|
341
|
+
except OSError:
|
|
342
|
+
continue
|
|
343
|
+
return sorted(files, key=lambda path: path.relative_to(root).as_posix().lower())
|
|
344
|
+
|
|
345
|
+
def action_quick_open(self) -> None:
|
|
346
|
+
def handle(selected: Path | None) -> None:
|
|
347
|
+
if selected is not None:
|
|
348
|
+
self._switch_path(selected)
|
|
349
|
+
|
|
350
|
+
self.push_screen(
|
|
351
|
+
QuickOpenScreen(self.root.expanduser(), self._quick_open_files()),
|
|
352
|
+
handle,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def action_toggle_command_palette(self) -> None:
|
|
356
|
+
if CommandPalette.is_open(self):
|
|
357
|
+
self.pop_screen()
|
|
358
|
+
return
|
|
359
|
+
self.action_command_palette()
|
|
360
|
+
|
|
361
|
+
def action_show_keys_popup(self) -> None:
|
|
362
|
+
self.push_screen(KeysHelpScreen())
|
|
363
|
+
|
|
364
|
+
def action_save(self) -> None:
|
|
365
|
+
if self.path is None:
|
|
366
|
+
self.notify("No file open", severity="warning")
|
|
367
|
+
return
|
|
368
|
+
editor = self._editor_or_none()
|
|
369
|
+
if editor is None:
|
|
370
|
+
self.notify("No file open", severity="warning")
|
|
371
|
+
return
|
|
372
|
+
try:
|
|
373
|
+
self.path.write_text(editor.text)
|
|
374
|
+
except Exception as exc:
|
|
375
|
+
self.notify(f"Save failed: {exc}", severity="error")
|
|
376
|
+
return
|
|
377
|
+
self._saved_text = editor.text
|
|
378
|
+
self.notify(f"Saved {self.path}")
|
|
379
|
+
|
|
380
|
+
def action_quit_check(self) -> None:
|
|
381
|
+
if not self._is_dirty():
|
|
382
|
+
self.exit()
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
def handle(choice: str) -> None:
|
|
386
|
+
if choice == "save":
|
|
387
|
+
self.action_save()
|
|
388
|
+
if not self._is_dirty():
|
|
389
|
+
self.exit()
|
|
390
|
+
elif choice == "discard":
|
|
391
|
+
self.exit()
|
|
392
|
+
|
|
393
|
+
self.push_screen(UnsavedChangesScreen(), handle)
|
|
394
|
+
|
|
395
|
+
def action_close_buffer(self) -> None:
|
|
396
|
+
if self.path is None:
|
|
397
|
+
return
|
|
398
|
+
if not self._is_dirty():
|
|
399
|
+
self._close_buffer()
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
def handle(choice: str) -> None:
|
|
403
|
+
if choice == "save":
|
|
404
|
+
self.action_save()
|
|
405
|
+
if not self._is_dirty():
|
|
406
|
+
self._close_buffer()
|
|
407
|
+
elif choice == "discard":
|
|
408
|
+
self._close_buffer()
|
|
409
|
+
|
|
410
|
+
self.push_screen(UnsavedChangesScreen(), handle)
|
|
411
|
+
|
|
412
|
+
def action_toggle_file_tree(self) -> None:
|
|
413
|
+
editor = self._editor_or_none()
|
|
414
|
+
if self._is_file_tree_visible():
|
|
415
|
+
if editor is None:
|
|
416
|
+
return
|
|
417
|
+
self._hide_file_tree()
|
|
418
|
+
editor.focus()
|
|
419
|
+
return
|
|
420
|
+
self._show_file_tree()
|
|
421
|
+
|
|
422
|
+
def action_toggle_file_tree_focus(self) -> None:
|
|
423
|
+
tree = self._file_tree()
|
|
424
|
+
if not self._is_file_tree_visible():
|
|
425
|
+
self._show_file_tree()
|
|
426
|
+
tree.focus()
|
|
427
|
+
return
|
|
428
|
+
editor = self._editor_or_none()
|
|
429
|
+
if tree.has_focus:
|
|
430
|
+
if editor is not None:
|
|
431
|
+
editor.focus()
|
|
432
|
+
return
|
|
433
|
+
tree.focus()
|