riched 0.3.0__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.3.0 → riched-0.4.0}/PKG-INFO +17 -18
  2. {riched-0.3.0 → riched-0.4.0}/README.md +16 -17
  3. {riched-0.3.0 → riched-0.4.0}/pyproject.toml +1 -1
  4. {riched-0.3.0 → riched-0.4.0}/src/riched/app.py +30 -6
  5. {riched-0.3.0 → riched-0.4.0}/src/riched/bindings.yaml +30 -5
  6. riched-0.4.0/src/riched/editor.py +254 -0
  7. {riched-0.3.0 → riched-0.4.0}/src/riched/quick_open.py +0 -1
  8. {riched-0.3.0 → riched-0.4.0}/src/riched/screens.py +51 -0
  9. {riched-0.3.0 → riched-0.4.0}/tests/helpers.py +2 -0
  10. {riched-0.3.0 → riched-0.4.0}/tests/runner.py +72 -6
  11. riched-0.4.0/tests/test_command_palette.py +71 -0
  12. {riched-0.3.0 → riched-0.4.0}/tests/test_dirty_buffers.py +63 -9
  13. {riched-0.3.0 → riched-0.4.0}/tests/test_editor_shortcuts.py +257 -1
  14. {riched-0.3.0 → riched-0.4.0}/tests/test_footer.py +2 -1
  15. {riched-0.3.0 → riched-0.4.0}/tests/test_quick_open.py +11 -3
  16. {riched-0.3.0 → riched-0.4.0}/uv.lock +1 -1
  17. riched-0.3.0/src/riched/editor.py +0 -99
  18. riched-0.3.0/tests/test_command_palette.py +0 -27
  19. {riched-0.3.0 → riched-0.4.0}/.gitignore +0 -0
  20. {riched-0.3.0 → riched-0.4.0}/.python-version +0 -0
  21. {riched-0.3.0 → riched-0.4.0}/AGENTS.md +0 -0
  22. {riched-0.3.0 → riched-0.4.0}/src/riched/__init__.py +0 -0
  23. {riched-0.3.0 → riched-0.4.0}/src/riched/cli.py +0 -0
  24. {riched-0.3.0 → riched-0.4.0}/src/riched/keybindings.py +0 -0
  25. {riched-0.3.0 → riched-0.4.0}/src/riched/settings.py +0 -0
  26. {riched-0.3.0 → riched-0.4.0}/src/riched/syntax.py +0 -0
  27. {riched-0.3.0 → riched-0.4.0}/tests/__init__.py +0 -0
  28. {riched-0.3.0 → riched-0.4.0}/tests/test_cli.py +0 -0
  29. {riched-0.3.0 → riched-0.4.0}/tests/test_file_io.py +0 -0
  30. {riched-0.3.0 → riched-0.4.0}/tests/test_file_tree.py +0 -0
  31. {riched-0.3.0 → riched-0.4.0}/tests/test_syntax.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riched
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: TUI editor with VSCode keybindigs
5
5
  Project-URL: Repository, https://github.com/hoishing/riched
6
6
  Requires-Python: >=3.12
@@ -11,22 +11,18 @@ Description-Content-Type: text/markdown
11
11
 
12
12
  # Riched
13
13
 
14
- > TUI editor with VSCode keybindigs
15
-
16
- `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.
17
15
 
18
16
  > [!NOTE]
19
- > `riched` targets Ghostty on macOS. Some key bindings, especially shortcuts
20
- > that use the Command key, may not work in other terminals or on other
21
- > operating systems.
17
+ > `riched` is designed to work with [ghostty](https://ghostty.org) in macOS only
22
18
 
23
19
  ## Key Features
24
20
 
25
- - VS Code-style text editing key bindings
26
- - Syntax highlighting
27
- - Resizable project file tree
28
- - Quick open with git-aware indexing, bounded large-folder scanning
29
- - 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
30
26
 
31
27
  ## Usage
32
28
 
@@ -42,14 +38,11 @@ riched --version
42
38
  uv sync
43
39
  uv run riched .
44
40
  uv run python -m tests.runner
45
- uv build
46
- uv publish
47
41
  ```
48
42
 
49
43
  ## Key Bindings
50
44
 
51
45
  Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
52
- Symbols: `⌘` = Command, `⌃` = Control, `⌥` = Option, `⇧` = Shift.
53
46
 
54
47
  App shortcuts:
55
48
 
@@ -57,8 +50,7 @@ App shortcuts:
57
50
  | --- | --- |
58
51
  | `⌘S` | Save |
59
52
  | `⌃Q` | Quit |
60
- | `⌃W` | Close buffer |
61
- | `F1` | Key bindings |
53
+ | `F1` | Command palette |
62
54
  | `⌘B` | Toggle file tree |
63
55
  | `⌘P` | Quick open |
64
56
 
@@ -71,6 +63,12 @@ Editor shortcuts:
71
63
  | `⌥⇧↑` | Copy line up |
72
64
  | `⌥⇧↓` | Copy line down |
73
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 |
74
72
  | `⌥⇧←` | Select word left |
75
73
  | `⌥⇧→` | Select word right |
76
74
  | `⌘L` | Select line |
@@ -96,7 +94,8 @@ config directory: `~/Library/Application Support/riched/settings.yaml`
96
94
  - `riched` does not support multi-cursor editing because Textual `TextArea`
97
95
  currently models one active cursor/selection.
98
96
  - Some Command-key shortcuts can conflict with Ghostty or terminal-level
99
- bindings, for example `⌘⇧P`, `⌘W`, and `⌘Q`.
97
+ bindings, for example `⌘C`, `⌘Enter`, `⌘⇧Enter`, `⌘[`, `⌘]`, `⌘⌫`,
98
+ `⌘Z`, `⌘⇧Z`, `⌘⇧P`, `⌘W`, and `⌘Q`.
100
99
  - `riched` will not implement key bindings that require users to modify the
101
100
  Ghostty terminal configuration, since terminal-specific configuration makes
102
101
  shortcuts harder to document, test, and support consistently across
@@ -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,14 +27,11 @@ 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
 
@@ -46,8 +39,7 @@ App shortcuts:
46
39
  | --- | --- |
47
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 |
@@ -85,7 +83,8 @@ config directory: `~/Library/Application Support/riched/settings.yaml`
85
83
  - `riched` does not support multi-cursor editing because Textual `TextArea`
86
84
  currently models one active cursor/selection.
87
85
  - Some Command-key shortcuts can conflict with Ghostty or terminal-level
88
- bindings, for example `⌘⇧P`, `⌘W`, and `⌘Q`.
86
+ bindings, for example `⌘C`, `⌘Enter`, `⌘⇧Enter`, `⌘[`, `⌘]`, `⌘⌫`,
87
+ `⌘Z`, `⌘⇧Z`, `⌘⇧P`, `⌘W`, and `⌘Q`.
89
88
  - `riched` will not implement key bindings that require users to modify the
90
89
  Ghostty terminal configuration, since terminal-specific configuration makes
91
90
  shortcuts harder to document, test, and support consistently across
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "riched"
3
- version = "0.3.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"
@@ -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
@@ -6,11 +6,8 @@ app:
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
@@ -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
 
@@ -16,6 +16,7 @@ from riched.keybindings import ( # noqa: E402
16
16
  build_bindings,
17
17
  )
18
18
  from riched.screens import ( # noqa: E402
19
+ QuitConfirmationScreen,
19
20
  UnsavedChangesScreen,
20
21
  )
21
22
 
@@ -23,6 +24,7 @@ mod = SimpleNamespace(
23
24
  COMMANDS=COMMANDS,
24
25
  DEFAULT_BINDINGS=DEFAULT_BINDINGS,
25
26
  RichedApp=RichedApp,
27
+ QuitConfirmationScreen=QuitConfirmationScreen,
26
28
  UnsavedChangesScreen=UnsavedChangesScreen,
27
29
  build_bindings=build_bindings,
28
30
  )