riched 0.1.0__py3-none-any.whl

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/keybindings.py ADDED
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from textual.binding import Binding
8
+ import yaml
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class KeyBindingHelpGroup:
13
+ title: str
14
+ rows: tuple[tuple[str, str], ...]
15
+
16
+
17
+ def load_binding_spec() -> dict[str, Any]:
18
+ text = resources.files("riched").joinpath("bindings.yaml").read_text()
19
+ data = yaml.safe_load(text)
20
+ if not isinstance(data, dict):
21
+ raise ValueError("bindings.yaml must contain a mapping")
22
+ return data
23
+
24
+
25
+ BINDING_SPEC = load_binding_spec()
26
+ APP_COMMANDS = BINDING_SPEC["app"]["commands"]
27
+ COMMANDS: list[tuple[str, str, str]] = [
28
+ (item["name"], item["description"], item["key"]) for item in APP_COMMANDS
29
+ ]
30
+ DEFAULT_BINDINGS: dict[str, str] = {name: key for name, _, key in COMMANDS}
31
+
32
+
33
+ def build_bindings() -> list[Binding]:
34
+ # priority=True so configured shortcuts override TextArea built-ins.
35
+ return [
36
+ Binding(default, name, desc, priority=True)
37
+ for name, desc, default in COMMANDS
38
+ ]
39
+
40
+
41
+ def build_static_bindings(section: str) -> list[Binding]:
42
+ items = BINDING_SPEC[section]
43
+ return [
44
+ Binding(
45
+ item["key"],
46
+ item["action"],
47
+ item.get("description", ""),
48
+ show=item.get("show", True),
49
+ )
50
+ for item in items
51
+ ]
52
+
53
+
54
+ def build_screen_bindings(screen: str) -> list[Binding]:
55
+ items = BINDING_SPEC["screens"][screen]
56
+ return [
57
+ Binding(
58
+ item["key"],
59
+ item["action"],
60
+ item.get("description", ""),
61
+ show=item.get("show", True),
62
+ )
63
+ for item in items
64
+ ]
65
+
66
+
67
+ def binding_help_groups() -> list[KeyBindingHelpGroup]:
68
+ groups: list[KeyBindingHelpGroup] = []
69
+
70
+ app_rows = tuple(
71
+ (_display_key(item["key"]), item.get("description") or item["name"])
72
+ for item in BINDING_SPEC["app"]["commands"]
73
+ )
74
+ groups.append(KeyBindingHelpGroup("App", app_rows))
75
+
76
+ editor_rows = tuple(
77
+ (_display_key(item["key"]), item.get("description") or item["action"])
78
+ for item in BINDING_SPEC["editor"]
79
+ )
80
+ groups.append(KeyBindingHelpGroup("Editor", editor_rows))
81
+
82
+ for screen, items in BINDING_SPEC["screens"].items():
83
+ rows = tuple(
84
+ (_display_key(item["key"]), item.get("description") or item["action"])
85
+ for item in items
86
+ )
87
+ groups.append(KeyBindingHelpGroup(f"Screens / {screen}", rows))
88
+
89
+ return groups
90
+
91
+
92
+ def _display_key(key: str) -> str:
93
+ return display_key(key)
94
+
95
+
96
+ def display_key(key: str) -> str:
97
+ keys = [part.strip() for part in key.split(",") if part.strip()]
98
+ if not keys:
99
+ return key
100
+
101
+ without_super = [candidate for candidate in keys if "super" not in candidate]
102
+ candidates = without_super or keys
103
+
104
+ def score(candidate: str) -> tuple[int, int, int]:
105
+ parts = candidate.split("+")
106
+ has_cmd = "cmd" in parts
107
+ preferred_cmd_order = not (
108
+ "cmd" in parts
109
+ and "shift" in parts
110
+ and parts.index("shift") < parts.index("cmd")
111
+ )
112
+ preferred_alt_order = not (
113
+ "alt" in parts
114
+ and "shift" in parts
115
+ and parts.index("shift") < parts.index("alt")
116
+ )
117
+ return (
118
+ 0 if has_cmd else 1,
119
+ 0 if preferred_cmd_order else 1,
120
+ 0 if preferred_alt_order else 1,
121
+ )
122
+
123
+ return min(candidates, key=score)
riched/screens.py ADDED
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from textual import events
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Horizontal, Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Input, Label, OptionList, Static
10
+ from textual.widgets.option_list import Option
11
+
12
+ from .keybindings import binding_help_groups, build_screen_bindings
13
+
14
+ MAX_QUICK_OPEN_RESULTS = 100
15
+
16
+
17
+ class UnsavedChangesScreen(ModalScreen[str]):
18
+ """Prompt asking whether to save, discard, or cancel."""
19
+
20
+ BINDINGS = build_screen_bindings("unsaved_changes")
21
+
22
+ CSS = """
23
+ UnsavedChangesScreen {
24
+ align: center middle;
25
+ }
26
+ UnsavedChangesScreen > #dialog {
27
+ width: 64;
28
+ height: auto;
29
+ padding: 1 2;
30
+ background: $panel;
31
+ border: tall $accent;
32
+ }
33
+ UnsavedChangesScreen Label {
34
+ width: 100%;
35
+ content-align: center middle;
36
+ padding-bottom: 1;
37
+ }
38
+ UnsavedChangesScreen #buttons {
39
+ height: auto;
40
+ align: center middle;
41
+ }
42
+ UnsavedChangesScreen Button {
43
+ margin: 0 1;
44
+ }
45
+ """
46
+
47
+ def compose(self) -> ComposeResult:
48
+ with Vertical(id="dialog"):
49
+ yield Label("Unsaved changes.\nSave before continuing?")
50
+ with Horizontal(id="buttons"):
51
+ yield Button("Save", variant="primary", id="save")
52
+ yield Button("Discard", variant="error", id="discard")
53
+ yield Button("Cancel", id="cancel")
54
+
55
+ def on_button_pressed(self, event: Button.Pressed) -> None:
56
+ self.dismiss(event.button.id or "cancel")
57
+
58
+ def on_key(self, event: events.Key) -> None:
59
+ focused = self.focused
60
+ if event.key == "space" and isinstance(focused, Button):
61
+ event.stop()
62
+ event.prevent_default()
63
+ focused.action_press()
64
+
65
+ def action_cancel(self) -> None:
66
+ self.dismiss("cancel")
67
+
68
+
69
+ class QuickOpenScreen(ModalScreen[Path | None]):
70
+ """Fuzzy file picker."""
71
+
72
+ BINDINGS = build_screen_bindings("quick_open")
73
+
74
+ CSS = """
75
+ QuickOpenScreen {
76
+ align: center top;
77
+ }
78
+ QuickOpenScreen > #dialog {
79
+ offset-y: 2;
80
+ width: 76;
81
+ height: auto;
82
+ max-height: 24;
83
+ padding: 1 2;
84
+ background: $panel;
85
+ border: tall $accent;
86
+ }
87
+ QuickOpenScreen #query {
88
+ margin-bottom: 1;
89
+ }
90
+ QuickOpenScreen #results {
91
+ height: auto;
92
+ max-height: 18;
93
+ }
94
+ """
95
+
96
+ def __init__(self, root: Path, files: list[Path]) -> None:
97
+ super().__init__()
98
+ self.root = root
99
+ self.files = files
100
+ self._relative_paths = {
101
+ path: path.relative_to(root).as_posix() for path in files
102
+ }
103
+ self._option_paths: dict[str, Path] = {}
104
+
105
+ def compose(self) -> ComposeResult:
106
+ with Vertical(id="dialog"):
107
+ yield Input(placeholder="Quick open", id="query")
108
+ yield OptionList(id="results")
109
+
110
+ def on_mount(self) -> None:
111
+ self._refresh_results("")
112
+ self.query_one("#query", Input).focus()
113
+
114
+ def on_input_changed(self, event: Input.Changed) -> None:
115
+ if event.input.id == "query":
116
+ self._refresh_results(event.value)
117
+
118
+ def on_input_submitted(self, event: Input.Submitted) -> None:
119
+ if event.input.id == "query":
120
+ self._open_highlighted()
121
+
122
+ def on_option_list_option_selected(
123
+ self, event: OptionList.OptionSelected
124
+ ) -> None:
125
+ path = self._option_paths.get(event.option.id or "")
126
+ if path is not None:
127
+ self.dismiss(path)
128
+
129
+ def on_key(self, event: events.Key) -> None:
130
+ results = self.query_one("#results", OptionList)
131
+ if event.key == "up":
132
+ event.stop()
133
+ event.prevent_default()
134
+ results.action_cursor_up()
135
+ elif event.key == "down":
136
+ event.stop()
137
+ event.prevent_default()
138
+ results.action_cursor_down()
139
+ elif event.key == "enter":
140
+ event.stop()
141
+ event.prevent_default()
142
+ self._open_highlighted()
143
+
144
+ def action_close(self) -> None:
145
+ self.dismiss(None)
146
+
147
+ def _refresh_results(self, query: str) -> None:
148
+ results = self.query_one("#results", OptionList)
149
+ results.clear_options()
150
+ self._option_paths.clear()
151
+
152
+ matches = self._ranked_matches(query)
153
+ if not matches:
154
+ results.add_option(Option("No matches", id="no-matches", disabled=True))
155
+ return
156
+
157
+ options: list[Option] = []
158
+ for index, path in enumerate(matches[:MAX_QUICK_OPEN_RESULTS]):
159
+ option_id = str(index)
160
+ self._option_paths[option_id] = path
161
+ options.append(Option(self._relative_paths[path], id=option_id))
162
+ results.add_options(options)
163
+ results.highlighted = 0
164
+
165
+ def _ranked_matches(self, query: str) -> list[Path]:
166
+ stripped_query = query.strip()
167
+ if not stripped_query:
168
+ return sorted(self.files, key=lambda path: self._relative_paths[path].lower())
169
+
170
+ scored: list[tuple[tuple[int, int, int, int, str], Path]] = []
171
+ for path in self.files:
172
+ relative_path = self._relative_paths[path]
173
+ score = _fuzzy_score(stripped_query, relative_path)
174
+ if score is not None:
175
+ scored.append((score, path))
176
+ scored.sort(key=lambda item: item[0])
177
+ return [path for _score, path in scored]
178
+
179
+ def _open_highlighted(self) -> None:
180
+ results = self.query_one("#results", OptionList)
181
+ highlighted = results.highlighted
182
+ if highlighted is None:
183
+ return
184
+ try:
185
+ option = results.get_option_at_index(highlighted)
186
+ except Exception:
187
+ return
188
+ path = self._option_paths.get(option.id or "")
189
+ if path is not None:
190
+ self.dismiss(path)
191
+
192
+
193
+ class KeysHelpScreen(ModalScreen[None]):
194
+ """Popup key binding help."""
195
+
196
+ BINDINGS = build_screen_bindings("keys_help")
197
+
198
+ CSS = """
199
+ KeysHelpScreen {
200
+ align: center middle;
201
+ }
202
+ KeysHelpScreen > #dialog {
203
+ width: 80%;
204
+ height: 80%;
205
+ max-width: 96;
206
+ padding: 1 2;
207
+ background: $panel;
208
+ border: tall $accent;
209
+ }
210
+ KeysHelpScreen Label {
211
+ height: auto;
212
+ margin-bottom: 1;
213
+ text-style: bold;
214
+ }
215
+ KeysHelpScreen #bindings-list {
216
+ width: 1fr;
217
+ height: 1fr;
218
+ padding: 0;
219
+ overflow-y: auto;
220
+ }
221
+ KeysHelpScreen .binding-group {
222
+ height: auto;
223
+ margin-bottom: 1;
224
+ }
225
+ KeysHelpScreen .binding-title {
226
+ height: auto;
227
+ color: $text-primary;
228
+ text-style: underline bold;
229
+ }
230
+ KeysHelpScreen .binding-row {
231
+ height: auto;
232
+ layout: horizontal;
233
+ }
234
+ KeysHelpScreen .binding-key {
235
+ width: 38;
236
+ height: auto;
237
+ color: $text-accent;
238
+ text-style: bold;
239
+ padding-right: 1;
240
+ }
241
+ KeysHelpScreen .binding-description {
242
+ width: 1fr;
243
+ height: auto;
244
+ color: $foreground;
245
+ }
246
+ """
247
+
248
+ def compose(self) -> ComposeResult:
249
+ with Vertical(id="dialog"):
250
+ yield Label("Key Bindings")
251
+ with Vertical(id="bindings-list"):
252
+ for group in binding_help_groups():
253
+ with Vertical(classes="binding-group"):
254
+ yield Static(group.title, classes="binding-title")
255
+ for key, description in group.rows:
256
+ with Horizontal(classes="binding-row"):
257
+ yield Static(key, classes="binding-key")
258
+ yield Static(description, classes="binding-description")
259
+
260
+ def action_close(self) -> None:
261
+ self.dismiss(None)
262
+
263
+
264
+ def _fuzzy_score(query: str, candidate: str) -> tuple[int, int, int, int, str] | None:
265
+ query_lower = query.lower()
266
+ candidate_lower = candidate.lower()
267
+ positions: list[int] = []
268
+ search_from = 0
269
+ for char in query_lower:
270
+ index = candidate_lower.find(char, search_from)
271
+ if index == -1:
272
+ return None
273
+ positions.append(index)
274
+ search_from = index + 1
275
+
276
+ first = positions[0]
277
+ last = positions[-1]
278
+ spread = last - first + 1
279
+ gaps = spread - len(query_lower)
280
+ basename = Path(candidate_lower).name
281
+ basename_index = candidate_lower.rfind(basename)
282
+ basename_bonus = 0 if first >= basename_index else 1
283
+ contiguous_bonus = 0 if query_lower in candidate_lower else 1
284
+ return (basename_bonus, contiguous_bonus, gaps, len(candidate_lower), candidate_lower)
riched/settings.py ADDED
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ SETTINGS_FILENAME = "settings.yaml"
11
+
12
+
13
+ def config_dir() -> Path:
14
+ if sys.platform == "darwin":
15
+ return Path("~/Library/Application Support/riched").expanduser()
16
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
17
+ if xdg_config_home:
18
+ return Path(xdg_config_home).expanduser() / "riched"
19
+ return Path("~/.config/riched").expanduser()
20
+
21
+
22
+ def settings_path() -> Path:
23
+ return config_dir() / SETTINGS_FILENAME
24
+
25
+
26
+ def load_settings() -> dict[str, Any]:
27
+ path = settings_path()
28
+ try:
29
+ data = yaml.safe_load(path.read_text())
30
+ except Exception:
31
+ return {}
32
+ if not isinstance(data, dict):
33
+ return {}
34
+ return data
35
+
36
+
37
+ def load_theme() -> str | None:
38
+ theme = load_settings().get("theme")
39
+ return theme if isinstance(theme, str) and theme else None
40
+
41
+
42
+ def save_theme(theme: str) -> None:
43
+ path = settings_path()
44
+ settings = load_settings()
45
+ settings["theme"] = theme
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ path.write_text(yaml.safe_dump(settings, sort_keys=False))
riched/syntax.py ADDED
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.widgets import TextArea
4
+
5
+ EXT_LANGUAGE = {
6
+ ".py": "python",
7
+ ".js": "javascript",
8
+ ".mjs": "javascript",
9
+ ".cjs": "javascript",
10
+ ".ts": "typescript",
11
+ ".tsx": "tsx",
12
+ ".sh": "bash",
13
+ ".bash": "bash",
14
+ ".zsh": "bash",
15
+ ".html": "html",
16
+ ".htm": "html",
17
+ ".css": "css",
18
+ ".json": "json",
19
+ ".md": "markdown",
20
+ ".markdown": "markdown",
21
+ ".yaml": "yaml",
22
+ ".yml": "yaml",
23
+ ".toml": "toml",
24
+ ".xml": "xml",
25
+ ".sql": "sql",
26
+ ".java": "java",
27
+ ".go": "go",
28
+ ".rs": "rust",
29
+ }
30
+
31
+ _TS_FAMILY_REGISTERED = False
32
+
33
+
34
+ def reset_ts_registration() -> None:
35
+ global _TS_FAMILY_REGISTERED
36
+ _TS_FAMILY_REGISTERED = False
37
+
38
+
39
+ def apply_language(editor: TextArea, suffix: str) -> None:
40
+ editor.language = None
41
+ lang = EXT_LANGUAGE.get(suffix.lower())
42
+ if lang is None:
43
+ return
44
+ if lang in ("typescript", "tsx") and not _register_ts_family(editor):
45
+ lang = "javascript"
46
+ editor.language = lang
47
+
48
+
49
+ def _register_ts_family(editor: TextArea) -> bool:
50
+ """Register both ``typescript`` and ``tsx`` grammars on the editor."""
51
+ global _TS_FAMILY_REGISTERED
52
+ if _TS_FAMILY_REGISTERED:
53
+ return True
54
+ try:
55
+ import tree_sitter_typescript as tsts # type: ignore
56
+ from tree_sitter import Language # type: ignore
57
+ except Exception:
58
+ return False
59
+ try:
60
+ ts_lang = Language(tsts.language_typescript())
61
+ tsx_lang = Language(tsts.language_tsx())
62
+ except Exception:
63
+ return False
64
+ try:
65
+ editor.register_language("typescript", ts_lang, _TS_HIGHLIGHTS)
66
+ editor.register_language("tsx", tsx_lang, _TS_HIGHLIGHTS + _JSX_HIGHLIGHTS)
67
+ except Exception:
68
+ return False
69
+ _TS_FAMILY_REGISTERED = True
70
+ return True
71
+
72
+
73
+ _TS_HIGHLIGHTS = r"""
74
+ ; --- JavaScript base ---------------------------------------------------------
75
+
76
+ (identifier) @variable
77
+ (property_identifier) @property
78
+
79
+ (function_expression
80
+ name: (identifier) @function)
81
+ (function_declaration
82
+ name: (identifier) @function)
83
+ (method_definition
84
+ name: (property_identifier) @function.method)
85
+
86
+ (pair
87
+ key: (property_identifier) @function.method
88
+ value: [(function_expression) (arrow_function)])
89
+
90
+ (assignment_expression
91
+ left: (member_expression
92
+ property: (property_identifier) @function.method)
93
+ right: [(function_expression) (arrow_function)])
94
+
95
+ (variable_declarator
96
+ name: (identifier) @function
97
+ value: [(function_expression) (arrow_function)])
98
+
99
+ (assignment_expression
100
+ left: (identifier) @function
101
+ right: [(function_expression) (arrow_function)])
102
+
103
+ (call_expression
104
+ function: (identifier) @function)
105
+
106
+ (call_expression
107
+ function: (member_expression
108
+ property: (property_identifier) @function.method))
109
+
110
+ ((identifier) @constructor
111
+ (#match? @constructor "^[A-Z]"))
112
+
113
+ ([
114
+ (identifier)
115
+ (shorthand_property_identifier)
116
+ (shorthand_property_identifier_pattern)
117
+ ] @constant
118
+ (#match? @constant "^[A-Z_][A-Z\\d_]+$"))
119
+
120
+ ((identifier) @variable.builtin
121
+ (#match? @variable.builtin "^(arguments|module|console|window|document)$")
122
+ (#is-not? local))
123
+
124
+ ((identifier) @function.builtin
125
+ (#eq? @function.builtin "require")
126
+ (#is-not? local))
127
+
128
+ (this) @variable.builtin
129
+ (super) @variable.builtin
130
+
131
+ [
132
+ (true)
133
+ (false)
134
+ (null)
135
+ (undefined)
136
+ ] @constant.builtin
137
+
138
+ (comment) @comment
139
+
140
+ [
141
+ (string)
142
+ (template_string)
143
+ ] @string
144
+
145
+ (regex) @string.special
146
+ (number) @number
147
+
148
+ [
149
+ ";"
150
+ (optional_chain)
151
+ "."
152
+ ","
153
+ ] @punctuation.delimiter
154
+
155
+ [
156
+ "-" "--" "-=" "+" "++" "+=" "*" "*=" "**" "**=" "/" "/=" "%" "%="
157
+ "<" "<=" "<<" "<<=" "=" "==" "===" "!" "!=" "!==" "=>" ">" ">="
158
+ ">>" ">>=" ">>>" ">>>=" "~" "^" "&" "|" "^=" "&=" "|=" "&&" "||"
159
+ "??" "&&=" "||=" "??="
160
+ ] @operator
161
+
162
+ [
163
+ "(" ")" "[" "]" "{" "}"
164
+ ] @punctuation.bracket
165
+
166
+ (template_substitution
167
+ "${" @punctuation.special
168
+ "}" @punctuation.special) @embedded
169
+
170
+ [
171
+ "as" "async" "await" "break" "case" "catch" "class" "const" "continue"
172
+ "debugger" "default" "delete" "do" "else" "export" "extends" "finally"
173
+ "for" "from" "function" "get" "if" "import" "in" "instanceof" "let"
174
+ "new" "of" "return" "set" "static" "switch" "target" "throw" "try"
175
+ "typeof" "var" "void" "while" "with" "yield"
176
+ ] @keyword
177
+
178
+ ; --- TypeScript additions ----------------------------------------------------
179
+
180
+ (type_identifier) @type
181
+ (predefined_type) @type.builtin
182
+
183
+ ((identifier) @type
184
+ (#match? @type "^[A-Z]"))
185
+
186
+ (type_arguments
187
+ "<" @punctuation.bracket
188
+ ">" @punctuation.bracket)
189
+
190
+ (required_parameter (identifier) @variable.parameter)
191
+ (optional_parameter (identifier) @variable.parameter)
192
+
193
+ [ "abstract" "declare" "enum" "implements" "interface" "keyof"
194
+ "namespace" "private" "protected" "public" "type" "readonly"
195
+ "override" "satisfies"
196
+ ] @keyword
197
+ """
198
+
199
+
200
+ _JSX_HIGHLIGHTS = r"""
201
+ ; --- JSX ---------------------------------------------------------------------
202
+
203
+ (jsx_opening_element
204
+ ["<" ">"] @punctuation.bracket)
205
+
206
+ (jsx_closing_element
207
+ ["</" ">"] @punctuation.bracket)
208
+
209
+ (jsx_self_closing_element
210
+ ["<" "/>"] @punctuation.bracket)
211
+
212
+ (jsx_opening_element
213
+ name: (identifier) @tag)
214
+
215
+ (jsx_closing_element
216
+ name: (identifier) @tag)
217
+
218
+ (jsx_self_closing_element
219
+ name: (identifier) @tag)
220
+
221
+ (jsx_opening_element
222
+ (member_expression
223
+ object: (identifier) @tag
224
+ property: (property_identifier) @tag))
225
+
226
+ (jsx_closing_element
227
+ (member_expression
228
+ object: (identifier) @tag
229
+ property: (property_identifier) @tag))
230
+
231
+ (jsx_self_closing_element
232
+ (member_expression
233
+ object: (identifier) @tag
234
+ property: (property_identifier) @tag))
235
+
236
+ (jsx_attribute
237
+ (property_identifier) @property)
238
+
239
+ (jsx_text) @string
240
+ """
241
+