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.
Files changed (31) hide show
  1. {riched-0.2.2 → riched-0.4.0}/PKG-INFO +28 -21
  2. {riched-0.2.2 → riched-0.4.0}/README.md +26 -20
  3. {riched-0.2.2 → riched-0.4.0}/pyproject.toml +4 -1
  4. {riched-0.2.2 → riched-0.4.0}/src/riched/app.py +30 -6
  5. {riched-0.2.2 → riched-0.4.0}/src/riched/bindings.yaml +31 -6
  6. {riched-0.2.2 → riched-0.4.0}/src/riched/cli.py +12 -3
  7. riched-0.4.0/src/riched/editor.py +254 -0
  8. {riched-0.2.2 → riched-0.4.0}/src/riched/quick_open.py +0 -1
  9. {riched-0.2.2 → riched-0.4.0}/src/riched/screens.py +51 -0
  10. {riched-0.2.2 → riched-0.4.0}/tests/helpers.py +2 -0
  11. {riched-0.2.2 → riched-0.4.0}/tests/runner.py +74 -7
  12. {riched-0.2.2 → riched-0.4.0}/tests/test_cli.py +18 -0
  13. riched-0.4.0/tests/test_command_palette.py +71 -0
  14. {riched-0.2.2 → riched-0.4.0}/tests/test_dirty_buffers.py +63 -9
  15. {riched-0.2.2 → riched-0.4.0}/tests/test_editor_shortcuts.py +257 -1
  16. {riched-0.2.2 → riched-0.4.0}/tests/test_file_io.py +1 -2
  17. {riched-0.2.2 → riched-0.4.0}/tests/test_footer.py +3 -2
  18. {riched-0.2.2 → riched-0.4.0}/tests/test_quick_open.py +11 -3
  19. {riched-0.2.2 → riched-0.4.0}/uv.lock +1 -1
  20. riched-0.2.2/src/riched/editor.py +0 -99
  21. riched-0.2.2/tests/test_command_palette.py +0 -27
  22. {riched-0.2.2 → riched-0.4.0}/.gitignore +0 -0
  23. {riched-0.2.2 → riched-0.4.0}/.python-version +0 -0
  24. {riched-0.2.2 → riched-0.4.0}/AGENTS.md +0 -0
  25. {riched-0.2.2 → riched-0.4.0}/src/riched/__init__.py +0 -0
  26. {riched-0.2.2 → riched-0.4.0}/src/riched/keybindings.py +0 -0
  27. {riched-0.2.2 → riched-0.4.0}/src/riched/settings.py +0 -0
  28. {riched-0.2.2 → riched-0.4.0}/src/riched/syntax.py +0 -0
  29. {riched-0.2.2 → riched-0.4.0}/tests/__init__.py +0 -0
  30. {riched-0.2.2 → riched-0.4.0}/tests/test_file_tree.py +0 -0
  31. {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.2.2
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
- > TUI editor with VSCode keybindigs
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` targets Ghostty on macOS. Some key bindings, especially shortcuts
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
- - VS Code-style text editing key bindings
25
- - Syntax highlighting
26
- - Resizable project file tree
27
- - Quick open with git-aware indexing, bounded large-folder scanning
28
- - fuzzy matching, symlink traversal
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
- | `⌃S` | Save |
51
+ | `⌘S` | Save |
58
52
  | `⌃Q` | Quit |
59
- | `⌃W` | Close buffer |
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
- ## Won't Implemented
94
-
95
- `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.
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
- > TUI editor with VSCode keybindigs
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` targets Ghostty on macOS. Some key bindings, especially shortcuts
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
- - VS Code-style text editing key bindings
15
- - Syntax highlighting
16
- - Resizable project file tree
17
- - Quick open with git-aware indexing, bounded large-folder scanning
18
- - fuzzy matching, symlink traversal
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
- | `⌃S` | Save |
40
+ | `⌘S` | Save |
48
41
  | `⌃Q` | Quit |
49
- | `⌃W` | Close buffer |
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
- ## Won't Implemented
84
-
85
- `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.
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.2.2"
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 KeysHelpScreen, QuickOpenScreen, UnsavedChangesScreen
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: ctrl+s
5
+ key: cmd+s,super+s
6
6
  - name: quit_check
7
7
  description: Quit
8
8
  key: ctrl+q
9
- - name: close_buffer
10
- description: Close buffer
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="version",
18
- version=f"%(prog)s {version('riched')}",
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}"
@@ -48,7 +48,6 @@ def git_quick_open_entries(
48
48
  "ls-files",
49
49
  "--cached",
50
50
  "--others",
51
- "--exclude-standard",
52
51
  ],
53
52
  stdout=subprocess.PIPE,
54
53
  stderr=subprocess.DEVNULL,
@@ -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