riched 0.2.2__tar.gz → 0.4.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.2.2 → riched-0.4.0}/PKG-INFO +28 -21
- {riched-0.2.2 → riched-0.4.0}/README.md +26 -20
- {riched-0.2.2 → riched-0.4.0}/pyproject.toml +4 -1
- {riched-0.2.2 → riched-0.4.0}/src/riched/app.py +30 -6
- {riched-0.2.2 → riched-0.4.0}/src/riched/bindings.yaml +31 -6
- {riched-0.2.2 → riched-0.4.0}/src/riched/cli.py +12 -3
- riched-0.4.0/src/riched/editor.py +254 -0
- {riched-0.2.2 → riched-0.4.0}/src/riched/quick_open.py +0 -1
- {riched-0.2.2 → riched-0.4.0}/src/riched/screens.py +51 -0
- {riched-0.2.2 → riched-0.4.0}/tests/helpers.py +2 -0
- {riched-0.2.2 → riched-0.4.0}/tests/runner.py +74 -7
- {riched-0.2.2 → riched-0.4.0}/tests/test_cli.py +18 -0
- riched-0.4.0/tests/test_command_palette.py +71 -0
- {riched-0.2.2 → riched-0.4.0}/tests/test_dirty_buffers.py +63 -9
- {riched-0.2.2 → riched-0.4.0}/tests/test_editor_shortcuts.py +257 -1
- {riched-0.2.2 → riched-0.4.0}/tests/test_file_io.py +1 -2
- {riched-0.2.2 → riched-0.4.0}/tests/test_footer.py +3 -2
- {riched-0.2.2 → riched-0.4.0}/tests/test_quick_open.py +11 -3
- {riched-0.2.2 → riched-0.4.0}/uv.lock +1 -1
- riched-0.2.2/src/riched/editor.py +0 -99
- riched-0.2.2/tests/test_command_palette.py +0 -27
- {riched-0.2.2 → riched-0.4.0}/.gitignore +0 -0
- {riched-0.2.2 → riched-0.4.0}/.python-version +0 -0
- {riched-0.2.2 → riched-0.4.0}/AGENTS.md +0 -0
- {riched-0.2.2 → riched-0.4.0}/src/riched/__init__.py +0 -0
- {riched-0.2.2 → riched-0.4.0}/src/riched/keybindings.py +0 -0
- {riched-0.2.2 → riched-0.4.0}/src/riched/settings.py +0 -0
- {riched-0.2.2 → riched-0.4.0}/src/riched/syntax.py +0 -0
- {riched-0.2.2 → riched-0.4.0}/tests/__init__.py +0 -0
- {riched-0.2.2 → riched-0.4.0}/tests/test_file_tree.py +0 -0
- {riched-0.2.2 → riched-0.4.0}/tests/test_syntax.py +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riched
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: TUI editor with VSCode keybindigs
|
|
5
|
+
Project-URL: Repository, https://github.com/hoishing/riched
|
|
5
6
|
Requires-Python: >=3.12
|
|
6
7
|
Requires-Dist: pyyaml>=6.0.3
|
|
7
8
|
Requires-Dist: textual[syntax]>=8.2.4
|
|
@@ -10,22 +11,18 @@ Description-Content-Type: text/markdown
|
|
|
10
11
|
|
|
11
12
|
# Riched
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
`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
|
+
[Rich](https://github.com/Textualize/rich) powered editor, so the name `riched`, that implement vscode keybinding in TUI with best effort.
|
|
16
15
|
|
|
17
16
|
> [!NOTE]
|
|
18
|
-
> `riched`
|
|
19
|
-
> that use the Command key, may not work in other terminals or on other
|
|
20
|
-
> operating systems.
|
|
17
|
+
> `riched` is designed to work with [ghostty](https://ghostty.org) in macOS only
|
|
21
18
|
|
|
22
19
|
## Key Features
|
|
23
20
|
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
21
|
+
- vscode text editing key bindings
|
|
22
|
+
- macOS style key bindings work in ssh session to linux host also
|
|
23
|
+
- multiple built-in theme(atom, drcula, nordic ...etc)
|
|
24
|
+
- syntax highlighting for major file formats
|
|
25
|
+
- quick file open with fuzzy search
|
|
29
26
|
|
|
30
27
|
## Usage
|
|
31
28
|
|
|
@@ -41,23 +38,19 @@ riched --version
|
|
|
41
38
|
uv sync
|
|
42
39
|
uv run riched .
|
|
43
40
|
uv run python -m tests.runner
|
|
44
|
-
uv build
|
|
45
|
-
uv publish
|
|
46
41
|
```
|
|
47
42
|
|
|
48
43
|
## Key Bindings
|
|
49
44
|
|
|
50
45
|
Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
|
|
51
|
-
Symbols: `⌘` = Command, `⌃` = Control, `⌥` = Option, `⇧` = Shift.
|
|
52
46
|
|
|
53
47
|
App shortcuts:
|
|
54
48
|
|
|
55
49
|
| Shortcut | Action |
|
|
56
50
|
| --- | --- |
|
|
57
|
-
|
|
|
51
|
+
| `⌘S` | Save |
|
|
58
52
|
| `⌃Q` | Quit |
|
|
59
|
-
|
|
|
60
|
-
| `F1` | Key bindings |
|
|
53
|
+
| `F1` | Command palette |
|
|
61
54
|
| `⌘B` | Toggle file tree |
|
|
62
55
|
| `⌘P` | Quick open |
|
|
63
56
|
|
|
@@ -70,6 +63,12 @@ Editor shortcuts:
|
|
|
70
63
|
| `⌥⇧↑` | Copy line up |
|
|
71
64
|
| `⌥⇧↓` | Copy line down |
|
|
72
65
|
| `⌥⌫` | Delete word left |
|
|
66
|
+
| `⌘⌫` | Delete to line start |
|
|
67
|
+
| `⌘Z` | Undo |
|
|
68
|
+
| `⌘⇧Z` | Redo |
|
|
69
|
+
| `⌘X` | Cut |
|
|
70
|
+
| `⌘/` | Toggle line comment |
|
|
71
|
+
| `⌥Z` | Toggle word wrap |
|
|
73
72
|
| `⌥⇧←` | Select word left |
|
|
74
73
|
| `⌥⇧→` | Select word right |
|
|
75
74
|
| `⌘L` | Select line |
|
|
@@ -90,6 +89,14 @@ config directory: `~/Library/Application Support/riched/settings.yaml`
|
|
|
90
89
|
- File search prefers git-indexed files when available, follows symlinked files and directories, skips cache/build dependency folders, and caps indexing at 10,000 files.
|
|
91
90
|
- Fuzzy ranking now prioritizes exact path and filename matches such as `.zshrc`.
|
|
92
91
|
|
|
93
|
-
##
|
|
94
|
-
|
|
95
|
-
`riched`
|
|
92
|
+
## Limitations
|
|
93
|
+
|
|
94
|
+
- `riched` does not support multi-cursor editing because Textual `TextArea`
|
|
95
|
+
currently models one active cursor/selection.
|
|
96
|
+
- Some Command-key shortcuts can conflict with Ghostty or terminal-level
|
|
97
|
+
bindings, for example `⌘C`, `⌘Enter`, `⌘⇧Enter`, `⌘[`, `⌘]`, `⌘⌫`,
|
|
98
|
+
`⌘Z`, `⌘⇧Z`, `⌘⇧P`, `⌘W`, and `⌘Q`.
|
|
99
|
+
- `riched` will not implement key bindings that require users to modify the
|
|
100
|
+
Ghostty terminal configuration, since terminal-specific configuration makes
|
|
101
|
+
shortcuts harder to document, test, and support consistently across
|
|
102
|
+
environments.
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
# Riched
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
`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.
|
|
3
|
+
[Rich](https://github.com/Textualize/rich) powered editor, so the name `riched`, that implement vscode keybinding in TUI with best effort.
|
|
6
4
|
|
|
7
5
|
> [!NOTE]
|
|
8
|
-
> `riched`
|
|
9
|
-
> that use the Command key, may not work in other terminals or on other
|
|
10
|
-
> operating systems.
|
|
6
|
+
> `riched` is designed to work with [ghostty](https://ghostty.org) in macOS only
|
|
11
7
|
|
|
12
8
|
## Key Features
|
|
13
9
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
10
|
+
- vscode text editing key bindings
|
|
11
|
+
- macOS style key bindings work in ssh session to linux host also
|
|
12
|
+
- multiple built-in theme(atom, drcula, nordic ...etc)
|
|
13
|
+
- syntax highlighting for major file formats
|
|
14
|
+
- quick file open with fuzzy search
|
|
19
15
|
|
|
20
16
|
## Usage
|
|
21
17
|
|
|
@@ -31,23 +27,19 @@ riched --version
|
|
|
31
27
|
uv sync
|
|
32
28
|
uv run riched .
|
|
33
29
|
uv run python -m tests.runner
|
|
34
|
-
uv build
|
|
35
|
-
uv publish
|
|
36
30
|
```
|
|
37
31
|
|
|
38
32
|
## Key Bindings
|
|
39
33
|
|
|
40
34
|
Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
|
|
41
|
-
Symbols: `⌘` = Command, `⌃` = Control, `⌥` = Option, `⇧` = Shift.
|
|
42
35
|
|
|
43
36
|
App shortcuts:
|
|
44
37
|
|
|
45
38
|
| Shortcut | Action |
|
|
46
39
|
| --- | --- |
|
|
47
|
-
|
|
|
40
|
+
| `⌘S` | Save |
|
|
48
41
|
| `⌃Q` | Quit |
|
|
49
|
-
|
|
|
50
|
-
| `F1` | Key bindings |
|
|
42
|
+
| `F1` | Command palette |
|
|
51
43
|
| `⌘B` | Toggle file tree |
|
|
52
44
|
| `⌘P` | Quick open |
|
|
53
45
|
|
|
@@ -60,6 +52,12 @@ Editor shortcuts:
|
|
|
60
52
|
| `⌥⇧↑` | Copy line up |
|
|
61
53
|
| `⌥⇧↓` | Copy line down |
|
|
62
54
|
| `⌥⌫` | Delete word left |
|
|
55
|
+
| `⌘⌫` | Delete to line start |
|
|
56
|
+
| `⌘Z` | Undo |
|
|
57
|
+
| `⌘⇧Z` | Redo |
|
|
58
|
+
| `⌘X` | Cut |
|
|
59
|
+
| `⌘/` | Toggle line comment |
|
|
60
|
+
| `⌥Z` | Toggle word wrap |
|
|
63
61
|
| `⌥⇧←` | Select word left |
|
|
64
62
|
| `⌥⇧→` | Select word right |
|
|
65
63
|
| `⌘L` | Select line |
|
|
@@ -80,6 +78,14 @@ config directory: `~/Library/Application Support/riched/settings.yaml`
|
|
|
80
78
|
- File search prefers git-indexed files when available, follows symlinked files and directories, skips cache/build dependency folders, and caps indexing at 10,000 files.
|
|
81
79
|
- Fuzzy ranking now prioritizes exact path and filename matches such as `.zshrc`.
|
|
82
80
|
|
|
83
|
-
##
|
|
84
|
-
|
|
85
|
-
`riched`
|
|
81
|
+
## Limitations
|
|
82
|
+
|
|
83
|
+
- `riched` does not support multi-cursor editing because Textual `TextArea`
|
|
84
|
+
currently models one active cursor/selection.
|
|
85
|
+
- Some Command-key shortcuts can conflict with Ghostty or terminal-level
|
|
86
|
+
bindings, for example `⌘C`, `⌘Enter`, `⌘⇧Enter`, `⌘[`, `⌘]`, `⌘⌫`,
|
|
87
|
+
`⌘Z`, `⌘⇧Z`, `⌘⇧P`, `⌘W`, and `⌘Q`.
|
|
88
|
+
- `riched` will not implement key bindings that require users to modify the
|
|
89
|
+
Ghostty terminal configuration, since terminal-specific configuration makes
|
|
90
|
+
shortcuts harder to document, test, and support consistently across
|
|
91
|
+
environments.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "riched"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "TUI editor with VSCode keybindigs"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -10,6 +10,9 @@ dependencies = [
|
|
|
10
10
|
"tree-sitter-typescript>=0.23.2",
|
|
11
11
|
]
|
|
12
12
|
|
|
13
|
+
[project.urls]
|
|
14
|
+
Repository = "https://github.com/hoishing/riched"
|
|
15
|
+
|
|
13
16
|
[project.scripts]
|
|
14
17
|
riched = "riched.cli:main"
|
|
15
18
|
|
|
@@ -23,7 +23,12 @@ from .quick_open import (
|
|
|
23
23
|
git_quick_open_entries,
|
|
24
24
|
scan_quick_open_entries,
|
|
25
25
|
)
|
|
26
|
-
from .screens import
|
|
26
|
+
from .screens import (
|
|
27
|
+
KeysHelpScreen,
|
|
28
|
+
QuickOpenScreen,
|
|
29
|
+
QuitConfirmationScreen,
|
|
30
|
+
UnsavedChangesScreen,
|
|
31
|
+
)
|
|
27
32
|
from .settings import load_theme, save_theme
|
|
28
33
|
from .syntax import apply_language
|
|
29
34
|
|
|
@@ -49,6 +54,7 @@ class RichedDirectoryTree(DirectoryTree):
|
|
|
49
54
|
Binding("left", "collapse_cursor", "Collapse folder", show=False),
|
|
50
55
|
Binding("right", "expand_cursor", "Expand folder", show=False),
|
|
51
56
|
Binding("space", "activate_cursor", "Open file or toggle folder", show=False),
|
|
57
|
+
Binding("escape", "quit_check", "Quit", show=False),
|
|
52
58
|
]
|
|
53
59
|
|
|
54
60
|
def _cursor_dir_node(self) -> Any | None:
|
|
@@ -76,6 +82,11 @@ class RichedDirectoryTree(DirectoryTree):
|
|
|
76
82
|
return
|
|
77
83
|
self.action_select_cursor()
|
|
78
84
|
|
|
85
|
+
def action_quit_check(self) -> None:
|
|
86
|
+
app = self.app
|
|
87
|
+
if isinstance(app, RichedApp):
|
|
88
|
+
app.action_file_tree_quit_check()
|
|
89
|
+
|
|
79
90
|
|
|
80
91
|
class FileTreeResizeHandle(Static):
|
|
81
92
|
"""Mouse handle for resizing the file tree."""
|
|
@@ -124,6 +135,8 @@ class FileTreeResizeHandle(Static):
|
|
|
124
135
|
class RichedApp(App):
|
|
125
136
|
"""TUI text editor with a project file tree."""
|
|
126
137
|
|
|
138
|
+
COMMAND_PALETTE_BINDING = "f1"
|
|
139
|
+
|
|
127
140
|
CSS = """
|
|
128
141
|
#workspace {
|
|
129
142
|
height: 1fr;
|
|
@@ -192,13 +205,13 @@ class RichedApp(App):
|
|
|
192
205
|
if command.title == "Maximize":
|
|
193
206
|
continue
|
|
194
207
|
if command.title == "Keys":
|
|
195
|
-
yield SystemCommand(
|
|
196
|
-
"Keys",
|
|
197
|
-
"Show key bindings",
|
|
198
|
-
self.action_show_keys_popup,
|
|
199
|
-
)
|
|
200
208
|
continue
|
|
201
209
|
yield command
|
|
210
|
+
yield SystemCommand(
|
|
211
|
+
"Show key bindings",
|
|
212
|
+
"Open the key bindings reference",
|
|
213
|
+
self.action_show_keys_popup,
|
|
214
|
+
)
|
|
202
215
|
|
|
203
216
|
def get_key_display(self, binding: Binding) -> str:
|
|
204
217
|
if binding.key_display:
|
|
@@ -501,6 +514,17 @@ class RichedApp(App):
|
|
|
501
514
|
|
|
502
515
|
self.push_screen(UnsavedChangesScreen(), handle)
|
|
503
516
|
|
|
517
|
+
def action_file_tree_quit_check(self) -> None:
|
|
518
|
+
if self._is_dirty():
|
|
519
|
+
self.action_quit_check()
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
def handle(choice: str) -> None:
|
|
523
|
+
if choice == "quit":
|
|
524
|
+
self.exit()
|
|
525
|
+
|
|
526
|
+
self.push_screen(QuitConfirmationScreen(), handle)
|
|
527
|
+
|
|
504
528
|
def action_close_buffer(self) -> None:
|
|
505
529
|
if self.path is None:
|
|
506
530
|
return
|
|
@@ -2,15 +2,12 @@ app:
|
|
|
2
2
|
commands:
|
|
3
3
|
- name: save
|
|
4
4
|
description: Save
|
|
5
|
-
key:
|
|
5
|
+
key: cmd+s,super+s
|
|
6
6
|
- name: quit_check
|
|
7
7
|
description: Quit
|
|
8
8
|
key: ctrl+q
|
|
9
|
-
- name:
|
|
10
|
-
description:
|
|
11
|
-
key: ctrl+w
|
|
12
|
-
- name: show_keys_popup
|
|
13
|
-
description: Key bindings
|
|
9
|
+
- name: command_palette
|
|
10
|
+
description: Command palette
|
|
14
11
|
key: f1
|
|
15
12
|
- name: toggle_file_tree
|
|
16
13
|
description: Toggle file tree
|
|
@@ -40,6 +37,30 @@ editor:
|
|
|
40
37
|
action: delete_word_left
|
|
41
38
|
description: Delete word left
|
|
42
39
|
show: false
|
|
40
|
+
- key: cmd+backspace,super+backspace,ctrl+u
|
|
41
|
+
action: delete_to_start_of_line_or_delete_left
|
|
42
|
+
description: Delete to line start
|
|
43
|
+
show: false
|
|
44
|
+
- key: cmd+z,super+z
|
|
45
|
+
action: undo
|
|
46
|
+
description: Undo
|
|
47
|
+
show: false
|
|
48
|
+
- key: cmd+shift+z,shift+cmd+z,super+shift+z,shift+super+z
|
|
49
|
+
action: redo
|
|
50
|
+
description: Redo
|
|
51
|
+
show: false
|
|
52
|
+
- key: cmd+x,super+x
|
|
53
|
+
action: cut
|
|
54
|
+
description: Cut
|
|
55
|
+
show: false
|
|
56
|
+
- key: cmd+/,super+/
|
|
57
|
+
action: toggle_line_comment
|
|
58
|
+
description: Toggle line comment
|
|
59
|
+
show: false
|
|
60
|
+
- key: alt+z
|
|
61
|
+
action: toggle_word_wrap
|
|
62
|
+
description: Toggle word wrap
|
|
63
|
+
show: false
|
|
43
64
|
- key: alt+shift+left,shift+alt+left
|
|
44
65
|
action: cursor_word_left(True)
|
|
45
66
|
description: Select word left
|
|
@@ -70,6 +91,10 @@ editor:
|
|
|
70
91
|
show: false
|
|
71
92
|
|
|
72
93
|
screens:
|
|
94
|
+
quit_confirmation:
|
|
95
|
+
- key: escape
|
|
96
|
+
action: cancel
|
|
97
|
+
description: Cancel
|
|
73
98
|
unsaved_changes:
|
|
74
99
|
- key: escape
|
|
75
100
|
action: cancel
|
|
@@ -14,12 +14,21 @@ def main() -> int:
|
|
|
14
14
|
)
|
|
15
15
|
parser.add_argument(
|
|
16
16
|
"--version",
|
|
17
|
-
action="
|
|
18
|
-
|
|
17
|
+
action="store_true",
|
|
18
|
+
help="Show the version and exit",
|
|
19
19
|
)
|
|
20
|
-
parser.add_argument("filename", help="File to open (created on save if missing)")
|
|
20
|
+
parser.add_argument("filename", nargs="?", help="File to open (created on save if missing)")
|
|
21
21
|
args = parser.parse_args()
|
|
22
22
|
|
|
23
|
+
if args.version:
|
|
24
|
+
if args.filename is not None:
|
|
25
|
+
parser.error("riched --version does not take arguments")
|
|
26
|
+
print(f"riched {version('riched')}")
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
if args.filename is None:
|
|
30
|
+
parser.error("the following arguments are required: filename")
|
|
31
|
+
|
|
23
32
|
path = Path(args.filename).expanduser()
|
|
24
33
|
root = path if path.is_dir() else Path.cwd()
|
|
25
34
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.widgets import TextArea
|
|
4
|
+
|
|
5
|
+
from .keybindings import build_static_bindings
|
|
6
|
+
|
|
7
|
+
LINE_COMMENT_MARKERS = {
|
|
8
|
+
"bash": "#",
|
|
9
|
+
"go": "//",
|
|
10
|
+
"java": "//",
|
|
11
|
+
"javascript": "//",
|
|
12
|
+
"python": "#",
|
|
13
|
+
"rust": "//",
|
|
14
|
+
"toml": "#",
|
|
15
|
+
"tsx": "//",
|
|
16
|
+
"typescript": "//",
|
|
17
|
+
"yaml": "#",
|
|
18
|
+
}
|
|
19
|
+
BLOCK_COMMENT_MARKERS = {
|
|
20
|
+
"css": ("/*", "*/"),
|
|
21
|
+
"html": ("<!--", "-->"),
|
|
22
|
+
"markdown": ("<!--", "-->"),
|
|
23
|
+
"xml": ("<!--", "-->"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RichedTextArea(TextArea):
|
|
28
|
+
"""TextArea with VS Code-style line-edit shortcuts."""
|
|
29
|
+
|
|
30
|
+
BINDINGS = build_static_bindings("editor")
|
|
31
|
+
|
|
32
|
+
def _clamp_location(self, location: tuple[int, int]) -> tuple[int, int]:
|
|
33
|
+
row, column = location
|
|
34
|
+
row = max(0, min(row, self.document.line_count - 1))
|
|
35
|
+
column = max(0, min(column, len(self.document.get_line(row))))
|
|
36
|
+
return row, column
|
|
37
|
+
|
|
38
|
+
def _clamp_selection(self) -> None:
|
|
39
|
+
start, end = self.selection
|
|
40
|
+
clamped_start = self._clamp_location(start)
|
|
41
|
+
clamped_end = self._clamp_location(end)
|
|
42
|
+
if (clamped_start, clamped_end) != (start, end):
|
|
43
|
+
self.selection = type(self.selection)(clamped_start, clamped_end)
|
|
44
|
+
|
|
45
|
+
def _refresh_size(self) -> None:
|
|
46
|
+
self._clamp_selection()
|
|
47
|
+
super()._refresh_size()
|
|
48
|
+
|
|
49
|
+
def _line_selection_end(self, row: int) -> tuple[int, int]:
|
|
50
|
+
if row + 1 < self.document.line_count:
|
|
51
|
+
return (row + 1, 0)
|
|
52
|
+
return (row, len(self.document.get_line(row)))
|
|
53
|
+
|
|
54
|
+
def _has_whole_line_selection(self) -> bool:
|
|
55
|
+
start, end = self.selection
|
|
56
|
+
if start > end:
|
|
57
|
+
return False
|
|
58
|
+
if start[1] != 0:
|
|
59
|
+
return False
|
|
60
|
+
if end[1] == 0 and end[0] > start[0]:
|
|
61
|
+
return True
|
|
62
|
+
last_row = self.document.line_count - 1
|
|
63
|
+
return end[0] == last_row and end[1] == len(self.document.get_line(last_row))
|
|
64
|
+
|
|
65
|
+
def action_select_line(self) -> None:
|
|
66
|
+
start, end = self.selection
|
|
67
|
+
if self._has_whole_line_selection():
|
|
68
|
+
selection_start = start
|
|
69
|
+
row = min(end[0], self.document.line_count - 1)
|
|
70
|
+
else:
|
|
71
|
+
row, _column = self.cursor_location
|
|
72
|
+
selection_start = (row, 0)
|
|
73
|
+
|
|
74
|
+
selection_end = self._line_selection_end(row)
|
|
75
|
+
self.selection = type(self.selection)(selection_start, selection_end)
|
|
76
|
+
|
|
77
|
+
def action_move_line_down(self) -> None:
|
|
78
|
+
row, col = self.cursor_location
|
|
79
|
+
if row + 1 >= self.document.line_count:
|
|
80
|
+
return
|
|
81
|
+
line_a = self.document.get_line(row)
|
|
82
|
+
line_b = self.document.get_line(row + 1)
|
|
83
|
+
self.replace(
|
|
84
|
+
line_b + "\n" + line_a,
|
|
85
|
+
(row, 0),
|
|
86
|
+
(row + 1, len(line_b)),
|
|
87
|
+
maintain_selection_offset=False,
|
|
88
|
+
)
|
|
89
|
+
self.move_cursor((row + 1, col))
|
|
90
|
+
|
|
91
|
+
def action_move_line_up(self) -> None:
|
|
92
|
+
row, col = self.cursor_location
|
|
93
|
+
if row == 0:
|
|
94
|
+
return
|
|
95
|
+
line_a = self.document.get_line(row - 1)
|
|
96
|
+
line_b = self.document.get_line(row)
|
|
97
|
+
self.replace(
|
|
98
|
+
line_b + "\n" + line_a,
|
|
99
|
+
(row - 1, 0),
|
|
100
|
+
(row, len(line_b)),
|
|
101
|
+
maintain_selection_offset=False,
|
|
102
|
+
)
|
|
103
|
+
self.move_cursor((row - 1, col))
|
|
104
|
+
|
|
105
|
+
def action_copy_line_down(self) -> None:
|
|
106
|
+
row, col = self.cursor_location
|
|
107
|
+
line = self.document.get_line(row)
|
|
108
|
+
self.insert("\n" + line, (row, len(line)), maintain_selection_offset=False)
|
|
109
|
+
self.move_cursor((row + 1, col))
|
|
110
|
+
|
|
111
|
+
def action_copy_line_up(self) -> None:
|
|
112
|
+
row, col = self.cursor_location
|
|
113
|
+
line = self.document.get_line(row)
|
|
114
|
+
self.insert(line + "\n", (row, 0), maintain_selection_offset=False)
|
|
115
|
+
self.move_cursor((row, col))
|
|
116
|
+
|
|
117
|
+
def action_delete_to_start_of_line_or_delete_left(self) -> None:
|
|
118
|
+
if not self.selection.is_empty or self.cursor_location[1] == 0:
|
|
119
|
+
self.action_delete_left()
|
|
120
|
+
return
|
|
121
|
+
self.action_delete_to_start_of_line()
|
|
122
|
+
|
|
123
|
+
def action_toggle_word_wrap(self) -> None:
|
|
124
|
+
self.soft_wrap = not self.soft_wrap
|
|
125
|
+
|
|
126
|
+
def action_toggle_line_comment(self) -> None:
|
|
127
|
+
language = self.language
|
|
128
|
+
if language in LINE_COMMENT_MARKERS:
|
|
129
|
+
self._toggle_line_comment(LINE_COMMENT_MARKERS[language])
|
|
130
|
+
return
|
|
131
|
+
if language in BLOCK_COMMENT_MARKERS:
|
|
132
|
+
self._toggle_block_comment(*BLOCK_COMMENT_MARKERS[language])
|
|
133
|
+
return
|
|
134
|
+
self.app.notify(
|
|
135
|
+
"Line comments are not supported for this file type.",
|
|
136
|
+
severity="warning",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _selected_row_range(self) -> tuple[int, int]:
|
|
140
|
+
if self.selection.is_empty:
|
|
141
|
+
row, _column = self.cursor_location
|
|
142
|
+
return row, row
|
|
143
|
+
|
|
144
|
+
start, end = sorted(self.selection)
|
|
145
|
+
start_row = start[0]
|
|
146
|
+
end_row = end[0]
|
|
147
|
+
if end[1] == 0 and end_row > start_row:
|
|
148
|
+
end_row -= 1
|
|
149
|
+
return start_row, end_row
|
|
150
|
+
|
|
151
|
+
def _target_lines(self) -> tuple[int, int, list[str]]:
|
|
152
|
+
start_row, end_row = self._selected_row_range()
|
|
153
|
+
lines = [
|
|
154
|
+
self.document.get_line(row)
|
|
155
|
+
for row in range(start_row, end_row + 1)
|
|
156
|
+
]
|
|
157
|
+
return start_row, end_row, lines
|
|
158
|
+
|
|
159
|
+
def _replace_target_lines(
|
|
160
|
+
self,
|
|
161
|
+
start_row: int,
|
|
162
|
+
end_row: int,
|
|
163
|
+
lines: list[str],
|
|
164
|
+
) -> None:
|
|
165
|
+
text = "\n".join(lines)
|
|
166
|
+
self.replace(
|
|
167
|
+
text,
|
|
168
|
+
(start_row, 0),
|
|
169
|
+
(end_row, len(self.document.get_line(end_row))),
|
|
170
|
+
maintain_selection_offset=False,
|
|
171
|
+
)
|
|
172
|
+
self.move_cursor((start_row, 0))
|
|
173
|
+
|
|
174
|
+
def _toggle_line_comment(self, marker: str) -> None:
|
|
175
|
+
start_row, end_row, lines = self._target_lines()
|
|
176
|
+
non_blank = [line for line in lines if line.strip()]
|
|
177
|
+
if not non_blank:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
should_uncomment = all(
|
|
181
|
+
line.lstrip().startswith(marker)
|
|
182
|
+
for line in non_blank
|
|
183
|
+
)
|
|
184
|
+
updated = [
|
|
185
|
+
_uncomment_line(line, marker)
|
|
186
|
+
if should_uncomment
|
|
187
|
+
else _comment_line(line, marker)
|
|
188
|
+
for line in lines
|
|
189
|
+
]
|
|
190
|
+
self._replace_target_lines(start_row, end_row, updated)
|
|
191
|
+
|
|
192
|
+
def _toggle_block_comment(self, open_marker: str, close_marker: str) -> None:
|
|
193
|
+
start_row, end_row, lines = self._target_lines()
|
|
194
|
+
non_blank = [line for line in lines if line.strip()]
|
|
195
|
+
if not non_blank:
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
should_uncomment = all(
|
|
199
|
+
line.lstrip().startswith(open_marker)
|
|
200
|
+
and line.rstrip().endswith(close_marker)
|
|
201
|
+
for line in non_blank
|
|
202
|
+
)
|
|
203
|
+
updated = [
|
|
204
|
+
_uncomment_block_line(line, open_marker, close_marker)
|
|
205
|
+
if should_uncomment
|
|
206
|
+
else _comment_block_line(line, open_marker, close_marker)
|
|
207
|
+
for line in lines
|
|
208
|
+
]
|
|
209
|
+
self._replace_target_lines(start_row, end_row, updated)
|
|
210
|
+
|
|
211
|
+
def action_ignore(self) -> None:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _leading_spaces(line: str) -> str:
|
|
216
|
+
return line[: len(line) - len(line.lstrip())]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _comment_line(line: str, marker: str) -> str:
|
|
220
|
+
if not line.strip():
|
|
221
|
+
return line
|
|
222
|
+
indent = _leading_spaces(line)
|
|
223
|
+
return f"{indent}{marker} {line[len(indent):]}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _uncomment_line(line: str, marker: str) -> str:
|
|
227
|
+
if not line.strip():
|
|
228
|
+
return line
|
|
229
|
+
indent = _leading_spaces(line)
|
|
230
|
+
rest = line[len(indent):]
|
|
231
|
+
if not rest.startswith(marker):
|
|
232
|
+
return line
|
|
233
|
+
rest = rest[len(marker):]
|
|
234
|
+
if rest.startswith(" "):
|
|
235
|
+
rest = rest[1:]
|
|
236
|
+
return f"{indent}{rest}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _comment_block_line(line: str, open_marker: str, close_marker: str) -> str:
|
|
240
|
+
if not line.strip():
|
|
241
|
+
return line
|
|
242
|
+
indent = _leading_spaces(line)
|
|
243
|
+
return f"{indent}{open_marker} {line[len(indent):]} {close_marker}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _uncomment_block_line(line: str, open_marker: str, close_marker: str) -> str:
|
|
247
|
+
if not line.strip():
|
|
248
|
+
return line
|
|
249
|
+
indent = _leading_spaces(line)
|
|
250
|
+
rest = line[len(indent):].strip()
|
|
251
|
+
if not rest.startswith(open_marker) or not rest.endswith(close_marker):
|
|
252
|
+
return line
|
|
253
|
+
rest = rest[len(open_marker): -len(close_marker)].strip()
|
|
254
|
+
return f"{indent}{rest}"
|
|
@@ -68,6 +68,57 @@ class UnsavedChangesScreen(ModalScreen[str]):
|
|
|
68
68
|
self.dismiss("cancel")
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
class QuitConfirmationScreen(ModalScreen[str]):
|
|
72
|
+
"""Prompt asking whether to quit a clean session."""
|
|
73
|
+
|
|
74
|
+
BINDINGS = build_screen_bindings("quit_confirmation")
|
|
75
|
+
|
|
76
|
+
CSS = """
|
|
77
|
+
QuitConfirmationScreen {
|
|
78
|
+
align: center middle;
|
|
79
|
+
}
|
|
80
|
+
QuitConfirmationScreen > #dialog {
|
|
81
|
+
width: 44;
|
|
82
|
+
height: auto;
|
|
83
|
+
padding: 1 2;
|
|
84
|
+
background: $panel;
|
|
85
|
+
border: tall $accent;
|
|
86
|
+
}
|
|
87
|
+
QuitConfirmationScreen Label {
|
|
88
|
+
width: 100%;
|
|
89
|
+
content-align: center middle;
|
|
90
|
+
padding-bottom: 1;
|
|
91
|
+
}
|
|
92
|
+
QuitConfirmationScreen #buttons {
|
|
93
|
+
height: auto;
|
|
94
|
+
align: center middle;
|
|
95
|
+
}
|
|
96
|
+
QuitConfirmationScreen Button {
|
|
97
|
+
margin: 0 1;
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def compose(self) -> ComposeResult:
|
|
102
|
+
with Vertical(id="dialog"):
|
|
103
|
+
yield Label("Quit riched?")
|
|
104
|
+
with Horizontal(id="buttons"):
|
|
105
|
+
yield Button("Quit", variant="error", id="quit")
|
|
106
|
+
yield Button("Cancel", id="cancel")
|
|
107
|
+
|
|
108
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
109
|
+
self.dismiss(event.button.id or "cancel")
|
|
110
|
+
|
|
111
|
+
def on_key(self, event: events.Key) -> None:
|
|
112
|
+
focused = self.focused
|
|
113
|
+
if event.key == "space" and isinstance(focused, Button):
|
|
114
|
+
event.stop()
|
|
115
|
+
event.prevent_default()
|
|
116
|
+
focused.action_press()
|
|
117
|
+
|
|
118
|
+
def action_cancel(self) -> None:
|
|
119
|
+
self.dismiss("cancel")
|
|
120
|
+
|
|
121
|
+
|
|
71
122
|
class QuickOpenScreen(ModalScreen[Path | None]):
|
|
72
123
|
"""Fuzzy file picker."""
|
|
73
124
|
|