symtest-cli 1.3.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.
Files changed (63) hide show
  1. symtest/__init__.py +45 -0
  2. symtest/cli.py +549 -0
  3. symtest/commands/__init__.py +9 -0
  4. symtest/commands/compare.py +221 -0
  5. symtest/config/__init__.py +7 -0
  6. symtest/config/config_io.py +346 -0
  7. symtest/config/config_schema.py +330 -0
  8. symtest/config/import_expander.py +149 -0
  9. symtest/config/inheritance_expander.py +197 -0
  10. symtest/core/__init__.py +15 -0
  11. symtest/core/assertions.py +253 -0
  12. symtest/core/base_runner.py +299 -0
  13. symtest/core/config_loader.py +536 -0
  14. symtest/core/execution.py +498 -0
  15. symtest/core/history_store.py +96 -0
  16. symtest/core/last_run_store.py +109 -0
  17. symtest/core/parallel_runner.py +251 -0
  18. symtest/core/process_worker.py +93 -0
  19. symtest/core/sequence_state.py +143 -0
  20. symtest/core/setup.py +137 -0
  21. symtest/core/test_case.py +76 -0
  22. symtest/core/types.py +92 -0
  23. symtest/file_comparator/__init__.py +10 -0
  24. symtest/file_comparator/base_comparator.py +109 -0
  25. symtest/file_comparator/binary_comparator.py +399 -0
  26. symtest/file_comparator/csv_comparator.py +241 -0
  27. symtest/file_comparator/factory.py +191 -0
  28. symtest/file_comparator/h5_comparator.py +777 -0
  29. symtest/file_comparator/json_comparator.py +323 -0
  30. symtest/file_comparator/result.py +213 -0
  31. symtest/file_comparator/script_comparator.py +182 -0
  32. symtest/file_comparator/text_comparator.py +182 -0
  33. symtest/file_comparator/xml_comparator.py +150 -0
  34. symtest/logging_config.py +66 -0
  35. symtest/runners/__init__.py +15 -0
  36. symtest/runners/config_runner.py +96 -0
  37. symtest/runners/json_runner.py +21 -0
  38. symtest/runners/parallel_config_runner.py +278 -0
  39. symtest/runners/parallel_json_runner.py +26 -0
  40. symtest/runners/parallel_yaml_runner.py +31 -0
  41. symtest/runners/yaml_runner.py +26 -0
  42. symtest/tui/__init__.py +11 -0
  43. symtest/tui/app.py +90 -0
  44. symtest/tui/controllers/__init__.py +0 -0
  45. symtest/tui/controllers/case_controller.py +322 -0
  46. symtest/tui/screens/__init__.py +0 -0
  47. symtest/tui/screens/case_editor.py +244 -0
  48. symtest/tui/screens/case_list.py +255 -0
  49. symtest/tui/widgets/__init__.py +0 -0
  50. symtest/tui/widgets/case_table.py +113 -0
  51. symtest/tui/widgets/expected_editor.py +159 -0
  52. symtest/tui/widgets/search_bar.py +160 -0
  53. symtest/tui/widgets/steps_editor.py +243 -0
  54. symtest/utils/__init__.py +21 -0
  55. symtest/utils/junit_xml_writer.py +137 -0
  56. symtest/utils/path_resolver.py +124 -0
  57. symtest/utils/report_generator.py +208 -0
  58. symtest_cli-1.3.0.dist-info/METADATA +316 -0
  59. symtest_cli-1.3.0.dist-info/RECORD +63 -0
  60. symtest_cli-1.3.0.dist-info/WHEEL +5 -0
  61. symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
  62. symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
  63. symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,255 @@
1
+ """Main screen: case table + search bar + status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.screen import Screen
6
+ from textual.widgets import Header, Footer, Static
7
+ from textual.containers import Horizontal, Container
8
+ from textual.binding import Binding
9
+
10
+ from ..controllers.case_controller import CaseController
11
+ from ..widgets.case_table import CaseTable
12
+ from ..widgets.search_bar import SearchBar
13
+ from .case_editor import CaseEditorScreen
14
+
15
+
16
+ class CaseListScreen(Screen):
17
+ """Primary TUI screen showing the test case table."""
18
+
19
+ BINDINGS = [
20
+ Binding("/", "focus_search", "Search", show=True),
21
+ Binding("escape", "clear_search", "Clear search", show=True),
22
+ Binding("a", "add_case", "Add", show=True),
23
+ Binding("e", "edit_case", "Edit", show=True),
24
+ Binding("d", "delete_case", "Delete", show=True),
25
+ Binding("u", "duplicate_case", "Dup", show=True),
26
+ Binding("r", "run_case", "Run", show=True),
27
+ Binding("f6", "save_file", "Save", show=True),
28
+ Binding("ctrl+s", "save_file", "Save", show=False),
29
+ Binding("ctrl+q", "quit", "Quit", show=True),
30
+ Binding("q", "quit", "Quit", show=True),
31
+ Binding("n", "next_match", "Next", show=False),
32
+ Binding("shift+enter", "prev_match", "Prev", show=False),
33
+ Binding("up", "cursor_up", "", show=False),
34
+ Binding("down", "cursor_down", "", show=False),
35
+ Binding("alt+f", "toggle_fuzzy", "", show=False),
36
+ Binding("alt+r", "toggle_regex", "", show=False),
37
+ Binding("alt+s", "toggle_substring", "", show=False),
38
+ ]
39
+
40
+ DEFAULT_CSS = """
41
+ CaseListScreen {
42
+ align: center middle;
43
+ }
44
+ CaseListScreen #status-bar {
45
+ height: 1;
46
+ padding: 0 1;
47
+ background: $panel;
48
+ }
49
+ CaseListScreen #status-bar Static {
50
+ width: 1fr;
51
+ }
52
+ """
53
+
54
+ def __init__(self, controller: CaseController):
55
+ super().__init__()
56
+ self._ctrl = controller
57
+ self._filtered_indices: list[int] = list(range(len(controller.cases)))
58
+ self._cur_match: int = 0
59
+ self._current_tag: str | None = None
60
+
61
+ def compose(self):
62
+ yield Header()
63
+ yield SearchBar(id="main-search")
64
+ yield CaseTable(id="main-table")
65
+ yield Container(
66
+ Horizontal(
67
+ Static(f"File: {self._ctrl.file_name}", id="status-file"),
68
+ Static(f"{self._ctrl.case_count} cases", id="status-count"),
69
+ Static("Tag: All", id="status-tag"),
70
+ Static("", id="status-dirty"),
71
+ id="status-inner",
72
+ ),
73
+ id="status-bar",
74
+ )
75
+ yield Footer()
76
+
77
+ def on_mount(self) -> None:
78
+ self._refresh_table()
79
+ self._update_status()
80
+
81
+ # -- table refresh -------------------------------------------------------
82
+
83
+ def _refresh_table(self) -> None:
84
+ table = self.query_one("#main-table", CaseTable)
85
+ table.refresh_rows(self._ctrl.cases, self._filtered_indices)
86
+
87
+ def _update_status(self) -> None:
88
+ self.query_one("#status-count", Static).update(
89
+ f"{len(self._filtered_indices)}/{self._ctrl.case_count} cases"
90
+ )
91
+ tag_label = self.query_one("#status-tag", Static)
92
+ tag_label.update(f"Tag: {self._current_tag or 'All'}")
93
+ dirty = self._ctrl.dirty
94
+ self.query_one("#status-dirty", Static).update(
95
+ "[MODIFIED]" if dirty else ""
96
+ )
97
+
98
+ # -- search handling -----------------------------------------------------
99
+
100
+ def on_search_bar_search_changed(self, event: SearchBar.SearchChanged) -> None:
101
+ sb = self.query_one("#main-search", SearchBar)
102
+ self._filtered_indices = self._ctrl.search(
103
+ event.query, event.mode, self._current_tag
104
+ )
105
+ self._cur_match = 0
106
+ sb.set_match_count(len(self._filtered_indices), 0)
107
+ self._refresh_table()
108
+ self._update_status()
109
+ # Select first match
110
+ if self._filtered_indices:
111
+ table = self.query_one("#main-table", CaseTable)
112
+ table.select_row_by_index(self._filtered_indices[0])
113
+ self._cur_match = 1
114
+ sb.set_match_count(len(self._filtered_indices), 1)
115
+
116
+ def on_search_bar_search_navigated(self, event: SearchBar.SearchNavigated) -> None:
117
+ sb = self.query_one("#main-search", SearchBar)
118
+ table = self.query_one("#main-table", CaseTable)
119
+ if event.delta > 0:
120
+ table.select_next_match()
121
+ else:
122
+ table.select_prev_match()
123
+ # Update current match display
124
+ if self._filtered_indices and table.cursor_coordinate.row < len(self._filtered_indices):
125
+ self._cur_match = table.cursor_coordinate.row + 1
126
+ sb.set_match_count(len(self._filtered_indices), self._cur_match)
127
+
128
+ # -- actions -------------------------------------------------------------
129
+
130
+ def action_focus_search(self) -> None:
131
+ sb = self.query_one("#main-search", SearchBar)
132
+ sb.query_one("#search-input").focus()
133
+
134
+ def action_clear_search(self) -> None:
135
+ self.query_one("#main-search", SearchBar).clear()
136
+ self._filtered_indices = list(range(len(self._ctrl.cases)))
137
+ self._refresh_table()
138
+ self._update_status()
139
+
140
+ def action_add_case(self) -> None:
141
+ self.app.push_screen(
142
+ CaseEditorScreen(),
143
+ callback=self._on_editor_done,
144
+ )
145
+
146
+ def action_edit_case(self) -> None:
147
+ idx = self._get_current_case_index()
148
+ if idx is None:
149
+ self.notify("No case selected", severity="warning")
150
+ return
151
+ case = self._ctrl.get_case(idx)
152
+ self.app.push_screen(
153
+ CaseEditorScreen(case),
154
+ callback=lambda msg: self._on_editor_done(msg, original_idx=idx),
155
+ )
156
+
157
+ def action_delete_case(self) -> None:
158
+ idx = self._get_current_case_index()
159
+ if idx is None:
160
+ self.notify("No case selected", severity="warning")
161
+ return
162
+ case_name = self._ctrl.get_case(idx).name
163
+ self._ctrl.delete_case(idx)
164
+ self.notify(f"Deleted: {case_name}")
165
+ self._refresh_filtered()
166
+ self._update_status()
167
+
168
+ def action_duplicate_case(self) -> None:
169
+ idx = self._get_current_case_index()
170
+ if idx is None:
171
+ self.notify("No case selected", severity="warning")
172
+ return
173
+ new_idx = self._ctrl.duplicate_case(idx)
174
+ self.notify(f"Duplicated as: {self._ctrl.get_case(new_idx).name}")
175
+ self._refresh_filtered()
176
+ self._update_status()
177
+
178
+ def action_run_case(self) -> None:
179
+ idx = self._get_current_case_index()
180
+ if idx is None:
181
+ self.notify("No case selected", severity="warning")
182
+ return
183
+ case = self._ctrl.get_case(idx)
184
+ self.notify(f"Running: {case.name}...")
185
+ try:
186
+ result = self._ctrl.run_case(idx)
187
+ status_icon = "[OK]" if result["status"] == "passed" else "[FAIL]"
188
+ # Build rich status line with new result fields
189
+ parts = [f"{status_icon} {case.name}"]
190
+ parts.append(f"rc={result.get('return_code')}")
191
+ parts.append(f"{result.get('duration', 0):.2f}s")
192
+ # Failure kind
193
+ if result.get("failure_kind"):
194
+ parts.append(f"kind={result['failure_kind']}")
195
+ # Flaky / attempts
196
+ if result.get("flaky"):
197
+ parts.append(f"flaky ({result.get('attempts', 1)} attempts)")
198
+ elif result.get("attempts", 1) > 1:
199
+ parts.append(f"{result['attempts']} attempts")
200
+ # Failed step (sequence)
201
+ if result.get("failed_step"):
202
+ parts.append(f"step={result['failed_step']}")
203
+ self.notify(
204
+ " | ".join(parts),
205
+ severity="information" if result["status"] == "passed" else "error",
206
+ )
207
+ # Show error message separately for failed cases
208
+ if result["status"] != "passed" and result.get("message"):
209
+ self.notify(f"[ERROR] {result['message']}", severity="error")
210
+ # Show baseline updates
211
+ if result.get("baseline_updated"):
212
+ for bu in result["baseline_updated"]:
213
+ self.notify(f"[BASELINE UPDATED] {bu}", severity="information")
214
+ except Exception as e:
215
+ self.notify(f"Run error: {e}", severity="error")
216
+
217
+ def action_save_file(self) -> None:
218
+ try:
219
+ self._ctrl.save()
220
+ self.notify(f"Saved to {self._ctrl.file_name}")
221
+ self._update_status()
222
+ except Exception as e:
223
+ self.notify(f"Save error: {e}", severity="error")
224
+
225
+ def action_toggle_fuzzy(self) -> None:
226
+ self.query_one("#main-search", SearchBar).action_toggle_fuzzy()
227
+
228
+ def action_toggle_regex(self) -> None:
229
+ self.query_one("#main-search", SearchBar).action_toggle_regex()
230
+
231
+ def action_toggle_substring(self) -> None:
232
+ self.query_one("#main-search", SearchBar).action_toggle_substring()
233
+
234
+ # -- helpers -------------------------------------------------------------
235
+
236
+ def _get_current_case_index(self) -> int | None:
237
+ table = self.query_one("#main-table", CaseTable)
238
+ return table.get_selected_index()
239
+
240
+ def _refresh_filtered(self) -> None:
241
+ sb = self.query_one("#main-search", SearchBar)
242
+ self._filtered_indices = self._ctrl.search(
243
+ sb.query, sb.mode, self._current_tag
244
+ )
245
+ self._refresh_table()
246
+ self._update_status()
247
+
248
+ def _on_editor_done(self, msg, original_idx=None) -> None:
249
+ if isinstance(msg, CaseEditorScreen.Saved):
250
+ if original_idx is not None:
251
+ self._ctrl.update_case(original_idx, msg.case)
252
+ else:
253
+ self._ctrl.add_case(msg.case)
254
+ self._refresh_filtered()
255
+ self._update_status()
File without changes
@@ -0,0 +1,113 @@
1
+ """DataTable wrapper for displaying test cases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.widgets import DataTable
6
+ from textual.coordinate import Coordinate
7
+
8
+ from ...core.test_case import TestCase
9
+
10
+
11
+ class CaseTable(DataTable):
12
+ """DataTable pre-configured for test case display."""
13
+
14
+ DEFAULT_CSS = """
15
+ CaseTable {
16
+ height: 1fr;
17
+ min-height: 5;
18
+ }
19
+ """
20
+
21
+ COLUMNS = ("#", "Name", "Command", "Tags", "Timeout", "Mode")
22
+
23
+ def __init__(self, *args, **kwargs):
24
+ super().__init__(cursor_type="row", zebra_stripes=True, *args, **kwargs)
25
+ self._case_indices: list[int] = []
26
+ self._all_tags: list[str] = []
27
+ self._filtered_tag: str | None = None
28
+
29
+ def on_mount(self) -> None:
30
+ for col in self.COLUMNS:
31
+ self.add_column(col, key=col.lower())
32
+
33
+ # -- public API ----------------------------------------------------------
34
+
35
+ def refresh_rows(self, cases: list[TestCase], indices: list[int]) -> None:
36
+ """Clear table and repopulate with the cases at *indices*."""
37
+ self.clear()
38
+ self._case_indices = list(indices)
39
+ for i, ci in enumerate(indices):
40
+ tc = cases[ci]
41
+ mode = "seq" if tc.steps else "cmd"
42
+ tags = ",".join(tc.tags) if tc.tags else "-"
43
+ timeout = str(tc.timeout) if tc.timeout else "-"
44
+ self.add_row(
45
+ str(i + 1),
46
+ tc.name,
47
+ self._truncate(tc.command, 30),
48
+ tags,
49
+ timeout,
50
+ mode,
51
+ key=str(ci),
52
+ )
53
+
54
+ def get_selected_index(self) -> int | None:
55
+ """Return the case index of the currently selected row."""
56
+ if self.row_count == 0:
57
+ return None
58
+ row_key = self.coordinate_to_cell_key(self.cursor_coordinate)
59
+ if row_key is not None:
60
+ return int(str(row_key.row_key.value))
61
+ return None
62
+
63
+ def select_row_by_index(self, case_index: int) -> None:
64
+ """Move cursor to the row corresponding to *case_index*."""
65
+ try:
66
+ row_key = self._row_key_for_case(case_index)
67
+ if row_key is not None:
68
+ self.move_cursor(row=row_key)
69
+ except Exception:
70
+ pass
71
+
72
+ def select_next_match(self) -> int | None:
73
+ """Move down one row; return new selected case index."""
74
+ if self.row_count == 0:
75
+ return None
76
+ cr = self.cursor_coordinate
77
+ new_row = min(cr.row + 1, self.row_count - 1)
78
+ self.move_cursor(row=new_row)
79
+ return self.get_selected_index()
80
+
81
+ def select_prev_match(self) -> int | None:
82
+ """Move up one row; return new selected case index."""
83
+ if self.row_count == 0:
84
+ return None
85
+ cr = self.cursor_coordinate
86
+ new_row = max(cr.row - 1, 0)
87
+ self.move_cursor(row=new_row)
88
+ return self.get_selected_index()
89
+
90
+ # -- tag filter ----------------------------------------------------------
91
+
92
+ def set_all_tags(self, tags: list[str]) -> None:
93
+ self._all_tags = list(tags)
94
+
95
+ @property
96
+ def filtered_tag(self) -> str | None:
97
+ return self._filtered_tag
98
+
99
+ # -- helpers -------------------------------------------------------------
100
+
101
+ @staticmethod
102
+ def _truncate(text: str, max_len: int) -> str:
103
+ if len(text) <= max_len:
104
+ return text
105
+ return text[: max_len - 2] + ".."
106
+
107
+ def _row_key_for_case(self, case_index: int) -> object | None:
108
+ """Find the row key for a given *case_index*."""
109
+ for i in range(self.row_count):
110
+ key = self.coordinate_to_cell_key(Coordinate(i, 0))
111
+ if key is not None and str(key.row_key.value) == str(case_index):
112
+ return key.row_key
113
+ return None
@@ -0,0 +1,159 @@
1
+ """Widget for editing a test case's ``expected`` dict.
2
+
3
+ Provides structured inputs for known keys (return_code, output_contains,
4
+ output_matches, compare_files) and a free-form area for custom key-value pairs.
5
+
6
+ Uses an internal ``_data`` dict so that ``load()`` / ``to_dict()`` work
7
+ even when the widget is not mounted (e.g. in tests).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import json
14
+ from typing import Any, Dict
15
+
16
+ from textual.widgets import Input, TextArea, Static
17
+ from textual.containers import Vertical
18
+ from textual.message import Message
19
+
20
+
21
+ class ExpectedEditor(Vertical):
22
+ """Nested-dict editor for the ``expected`` field of a test case."""
23
+
24
+ DEFAULT_CSS = """
25
+ ExpectedEditor {
26
+ height: auto;
27
+ border: solid $primary;
28
+ padding: 1;
29
+ }
30
+ ExpectedEditor Static.label {
31
+ color: $text-muted;
32
+ height: 1;
33
+ margin-top: 1;
34
+ }
35
+ ExpectedEditor #expected-return-code {
36
+ width: 10;
37
+ }
38
+ ExpectedEditor #expected-output-contains {
39
+ height: 3;
40
+ }
41
+ ExpectedEditor #expected-custom {
42
+ height: 4;
43
+ }
44
+ """
45
+
46
+ class Changed(Message):
47
+ """Emitted whenever the expected dict is modified."""
48
+
49
+ _KNOWN_KEYS = {"return_code", "output_contains", "output_matches", "compare_files"}
50
+
51
+ def __init__(self, *args, **kwargs):
52
+ super().__init__(*args, **kwargs)
53
+ self._data: Dict[str, Any] = {}
54
+
55
+ def compose(self):
56
+ yield Static("─ Expected ─", classes="label")
57
+ yield Static("return_code:", classes="label")
58
+ yield Input(placeholder="0", id="expected-return-code")
59
+ yield Static("output_contains (one per line):", classes="label")
60
+ yield TextArea("", id="expected-output-contains")
61
+ yield Static("output_matches (regex):", classes="label")
62
+ yield Input(placeholder=".*", id="expected-output-matches")
63
+ yield Static(
64
+ "compare_files (one JSON per line: {actual, baseline, type, start_line?, end_line?, start_column?, end_column?}):",
65
+ classes="label",
66
+ )
67
+ yield TextArea("", id="expected-compare-files")
68
+ yield Static("Custom key-value pairs (key=value, one per line):", classes="label")
69
+ yield TextArea("", id="expected-custom")
70
+
71
+ def on_mount(self) -> None:
72
+ """Populate UI from internal data on mount."""
73
+ self._sync_to_ui()
74
+
75
+ # -- public API ----------------------------------------------------------
76
+
77
+ def load(self, expected: Dict[str, Any]) -> None:
78
+ """Populate the editor from an *expected* dict."""
79
+ self._data = copy.deepcopy(expected)
80
+ if self.is_mounted:
81
+ self._sync_to_ui()
82
+
83
+ def to_dict(self) -> Dict[str, Any]:
84
+ """Reconstruct the expected dict from the current widget values."""
85
+ if self.is_mounted:
86
+ self._sync_from_ui()
87
+ return copy.deepcopy(self._data)
88
+
89
+ # -- internal sync -------------------------------------------------------
90
+
91
+ def _sync_to_ui(self) -> None:
92
+ """Push internal ``_data`` into the widget children."""
93
+ rc = self._data.get("return_code")
94
+ self.query_one("#expected-return-code", Input).value = str(rc) if rc is not None else ""
95
+
96
+ oc = self._data.get("output_contains", [])
97
+ self.query_one("#expected-output-contains", TextArea).text = "\n".join(oc)
98
+
99
+ om = self._data.get("output_matches", "")
100
+ self.query_one("#expected-output-matches", Input).value = om
101
+
102
+ cf = self._data.get("compare_files", [])
103
+ cf_lines = [json.dumps(item, ensure_ascii=False) for item in cf]
104
+ self.query_one("#expected-compare-files", TextArea).text = "\n".join(cf_lines)
105
+
106
+ custom_lines = [
107
+ f"{k}={json.dumps(v, ensure_ascii=False)}"
108
+ for k, v in self._data.items()
109
+ if k not in self._KNOWN_KEYS
110
+ ]
111
+ self.query_one("#expected-custom", TextArea).text = "\n".join(custom_lines)
112
+
113
+ def _sync_from_ui(self) -> None:
114
+ """Pull widget children values into ``_data``."""
115
+ self._data = {}
116
+
117
+ rc_text = self.query_one("#expected-return-code", Input).value.strip()
118
+ if rc_text:
119
+ try:
120
+ self._data["return_code"] = int(rc_text)
121
+ except ValueError:
122
+ self._data["return_code"] = rc_text
123
+
124
+ oc_text = self.query_one("#expected-output-contains", TextArea).text.strip()
125
+ if oc_text:
126
+ self._data["output_contains"] = [
127
+ line for line in oc_text.splitlines() if line.strip()
128
+ ]
129
+
130
+ om_text = self.query_one("#expected-output-matches", Input).value.strip()
131
+ if om_text:
132
+ self._data["output_matches"] = om_text
133
+
134
+ cf_text = self.query_one("#expected-compare-files", TextArea).text.strip()
135
+ if cf_text:
136
+ cf_list = []
137
+ for line in cf_text.splitlines():
138
+ line = line.strip()
139
+ if not line:
140
+ continue
141
+ try:
142
+ cf_list.append(json.loads(line))
143
+ except json.JSONDecodeError:
144
+ pass
145
+ if cf_list:
146
+ self._data["compare_files"] = cf_list
147
+
148
+ custom_text = self.query_one("#expected-custom", TextArea).text.strip()
149
+ if custom_text:
150
+ for line in custom_text.splitlines():
151
+ line = line.strip()
152
+ if "=" not in line:
153
+ continue
154
+ key, _, value = line.partition("=")
155
+ key = key.strip()
156
+ try:
157
+ self._data[key] = json.loads(value.strip())
158
+ except (json.JSONDecodeError, ValueError):
159
+ self._data[key] = value.strip()
@@ -0,0 +1,160 @@
1
+ """Search bar widget with multi-mode search (substring / fuzzy / regex).
2
+
3
+ Emits :class:`SearchChanged` when the query text or mode changes, and
4
+ :class:`SearchNavigated` when the user cycles through matches.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from textual import on
10
+ from textual.widgets import Input, Static
11
+ from textual.containers import Horizontal
12
+ from textual.message import Message
13
+
14
+
15
+ class SearchBar(Horizontal):
16
+ """A search bar with mode toggle and result count."""
17
+
18
+ DEFAULT_CSS = """
19
+ SearchBar {
20
+ height: auto;
21
+ padding: 0 1;
22
+ margin-bottom: 1;
23
+ }
24
+ SearchBar #search-input {
25
+ width: 40;
26
+ }
27
+ SearchBar #search-mode {
28
+ width: 12;
29
+ padding: 0 1;
30
+ color: $text-muted;
31
+ }
32
+ SearchBar #search-count {
33
+ width: auto;
34
+ padding: 0 1;
35
+ color: $text-muted;
36
+ }
37
+ """
38
+
39
+ # Maps keyboard modifiers to modes
40
+ _MODES = {
41
+ 0: "substring",
42
+ 2: "regex", # Alt pressed
43
+ 4: "fuzzy", # Ctrl pressed → fuzzy mode
44
+ }
45
+
46
+ class SearchChanged(Message):
47
+ """Emitted when search query or mode changes."""
48
+
49
+ def __init__(self, query: str, mode: str):
50
+ super().__init__()
51
+ self.query = query
52
+ self.mode = mode
53
+
54
+ class SearchNavigated(Message):
55
+ """Emitted to jump to match N."""
56
+
57
+ def __init__(self, delta: int):
58
+ super().__init__()
59
+ self.delta = delta
60
+
61
+ def __init__(self, *args, **kwargs):
62
+ super().__init__(*args, **kwargs)
63
+ self._query = ""
64
+ self._mode: str = "substring"
65
+ self._match_count = 0
66
+ self._history: list[str] = []
67
+ self._history_idx = -1
68
+
69
+ def compose(self):
70
+ yield Input(placeholder="Search...", id="search-input")
71
+ yield Static("[子串]", id="search-mode")
72
+ yield Static("", id="search-count")
73
+
74
+ # -- mode toggle via key bindings ----------------------------------------
75
+
76
+ def _handle_mode_toggle(self, mode: str, label: str) -> None:
77
+ if self._mode != mode:
78
+ self._mode = mode
79
+ self.query_one("#search-mode", Static).update(label)
80
+ self.post_message(self.SearchChanged(self._query, self._mode))
81
+
82
+ def action_toggle_substring(self) -> None:
83
+ self._handle_mode_toggle("substring", "[子串]")
84
+
85
+ def action_toggle_fuzzy(self) -> None:
86
+ self._handle_mode_toggle("fuzzy", "[模糊]")
87
+
88
+ def action_toggle_regex(self) -> None:
89
+ self._handle_mode_toggle("regex", "[正则]")
90
+
91
+ # -- input handling ------------------------------------------------------
92
+
93
+ @on(Input.Changed, "#search-input")
94
+ def _on_input_changed(self, event: Input.Changed) -> None:
95
+ self._query = event.value.strip()
96
+ self.post_message(self.SearchChanged(self._query, self._mode))
97
+
98
+ @on(Input.Submitted, "#search-input")
99
+ def _on_input_submitted(self, _event: Input.Submitted) -> None:
100
+ # Add to history (dedup, keep last 10)
101
+ if self._query and (not self._history or self._history[-1] != self._query):
102
+ self._history.append(self._query)
103
+ if len(self._history) > 10:
104
+ self._history.pop(0)
105
+ self._history_idx = -1
106
+ self.post_message(self.SearchNavigated(1))
107
+
108
+ # -- public API ----------------------------------------------------------
109
+
110
+ def set_match_count(self, total: int, current: int = 0) -> None:
111
+ """Update the match counter display."""
112
+ self._match_count = total
113
+ label = f"{current}/{total}" if total > 0 else ""
114
+ self.query_one("#search-count", Static).update(label)
115
+
116
+ def clear(self) -> None:
117
+ """Reset search bar."""
118
+ inp = self.query_one("#search-input", Input)
119
+ inp.clear()
120
+ self._query = ""
121
+ self._match_count = 0
122
+ self.query_one("#search-count", Static).update("")
123
+
124
+ def history_previous(self) -> None:
125
+ """Cycle to previous history entry."""
126
+ if not self._history:
127
+ return
128
+ if self._history_idx == -1:
129
+ self._history_idx = len(self._history) - 1
130
+ elif self._history_idx > 0:
131
+ self._history_idx -= 1
132
+ text = self._history[self._history_idx]
133
+ inp = self.query_one("#search-input", Input)
134
+ inp.value = text
135
+ self._query = text
136
+ self.post_message(self.SearchChanged(self._query, self._mode))
137
+
138
+ def history_next(self) -> None:
139
+ if not self._history or self._history_idx == -1:
140
+ return
141
+ self._history_idx += 1
142
+ if self._history_idx >= len(self._history):
143
+ self._history_idx = -1
144
+ inp = self.query_one("#search-input", Input)
145
+ inp.value = ""
146
+ self._query = ""
147
+ else:
148
+ text = self._history[self._history_idx]
149
+ inp = self.query_one("#search-input", Input)
150
+ inp.value = text
151
+ self._query = text
152
+ self.post_message(self.SearchChanged(self._query, self._mode))
153
+
154
+ @property
155
+ def query(self) -> str:
156
+ return self._query
157
+
158
+ @property
159
+ def mode(self) -> str:
160
+ return self._mode