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,322 @@
1
+ """Bridge between config I/O and the TUI: CRUD, search, run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import logging
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ from ...core.test_case import TestCase, TestCaseStep
12
+ from ...core.execution import execute_single_test_case
13
+ from ...core.config_loader import parse_test_cases, execute_sequence
14
+ from ...config.config_io import load_config, save_config
15
+
16
+ logger = logging.getLogger("symtest.tui.controller")
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Search helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ # Field weights for fuzzy scoring (higher = more important).
23
+ _FIELD_WEIGHTS: Dict[str, float] = {
24
+ "name": 2.0,
25
+ "command": 1.5,
26
+ "tags": 1.0,
27
+ "description": 1.0,
28
+ "args": 0.5,
29
+ }
30
+
31
+ # Default searchable fields and their extractors
32
+ _SEARCH_FIELDS = [
33
+ ("name", lambda tc: tc.name),
34
+ ("command", lambda tc: tc.command),
35
+ ("args", lambda tc: " ".join(tc.args)),
36
+ ("tags", lambda tc: ",".join(tc.tags)),
37
+ ("description", lambda tc: tc.description or ""),
38
+ ]
39
+
40
+
41
+ def _substring_match(query: str, case: TestCase) -> bool:
42
+ """Case-insensitive substring match across all searchable fields."""
43
+ q = query.lower()
44
+ for _field_name, extractor in _SEARCH_FIELDS:
45
+ if q in extractor(case).lower():
46
+ return True
47
+ return False
48
+
49
+
50
+ def _regex_match(query: str, case: TestCase) -> bool:
51
+ """Regex search across all searchable fields (case-insensitive)."""
52
+ try:
53
+ pattern = re.compile(query, re.IGNORECASE)
54
+ except re.error:
55
+ return False
56
+ for _field_name, extractor in _SEARCH_FIELDS:
57
+ if pattern.search(extractor(case)):
58
+ return True
59
+ return False
60
+
61
+
62
+ def _fuzzy_score(query: str, case: TestCase) -> float:
63
+ """N-gram overlap scorer; higher = better match."""
64
+ q = query.lower()
65
+ q_bigrams = {q[i : i + 2] for i in range(len(q) - 1)} if len(q) >= 2 else {q}
66
+ if not q_bigrams:
67
+ return 0.0
68
+
69
+ total = 0.0
70
+ for field_name, extractor in _SEARCH_FIELDS:
71
+ text = extractor(case).lower()
72
+ text_bigrams = {text[i : i + 2] for i in range(len(text) - 1)}
73
+ if not text_bigrams:
74
+ continue
75
+ overlap = len(q_bigrams & text_bigrams) / len(q_bigrams)
76
+ total += _FIELD_WEIGHTS.get(field_name, 0.5) * overlap
77
+ return total
78
+
79
+
80
+ def _fuzzy_match(query: str, cases: List[TestCase], threshold: float = 0.15) -> List[int]:
81
+ """Return indices of cases whose fuzzy score meets *threshold*."""
82
+ scored: List[Tuple[int, float]] = []
83
+ for i, tc in enumerate(cases):
84
+ s = _fuzzy_score(query, tc)
85
+ if s >= threshold:
86
+ scored.append((i, s))
87
+ scored.sort(key=lambda x: -x[1])
88
+ return [idx for idx, _ in scored]
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # CaseController
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ class CaseController:
97
+ """Holds test cases in memory and bridges config_io ↔ TUI."""
98
+
99
+ def __init__(self):
100
+ self._cases: List[TestCase] = []
101
+ self._file_path: Optional[Path] = None
102
+ self._workspace: Optional[str] = None
103
+ self._dirty = False
104
+ self._setup: Dict[str, Any] = {}
105
+ self._update_baseline: bool = False
106
+ self._history_dir: Optional[str] = None
107
+ self._update_history: bool = False
108
+
109
+ # -- properties ----------------------------------------------------------
110
+
111
+ @property
112
+ def cases(self) -> List[TestCase]:
113
+ return self._cases
114
+
115
+ @property
116
+ def file_path(self) -> Optional[Path]:
117
+ return self._file_path
118
+
119
+ @property
120
+ def file_name(self) -> str:
121
+ return self._file_path.name if self._file_path else "(untitled)"
122
+
123
+ @property
124
+ def workspace(self) -> Optional[str]:
125
+ return self._workspace
126
+
127
+ @property
128
+ def dirty(self) -> bool:
129
+ return self._dirty
130
+
131
+ @property
132
+ def case_count(self) -> int:
133
+ return len(self._cases)
134
+
135
+ @property
136
+ def update_baseline(self) -> bool:
137
+ return self._update_baseline
138
+
139
+ @update_baseline.setter
140
+ def update_baseline(self, value: bool) -> None:
141
+ self._update_baseline = value
142
+
143
+ @property
144
+ def history_dir(self) -> Optional[str]:
145
+ return self._history_dir
146
+
147
+ @history_dir.setter
148
+ def history_dir(self, value: Optional[str]) -> None:
149
+ self._history_dir = value
150
+
151
+ @property
152
+ def update_history(self) -> bool:
153
+ return self._update_history
154
+
155
+ @update_history.setter
156
+ def update_history(self, value: bool) -> None:
157
+ self._update_history = value
158
+
159
+ # -- load / save ---------------------------------------------------------
160
+
161
+ def load(self, file_path: str, workspace: Optional[str] = None) -> int:
162
+ """Load config from *file_path*, parse test cases, return case count."""
163
+ path = Path(file_path).resolve()
164
+ config = load_config(path)
165
+
166
+ # Store setup for later re-serialisation
167
+ self._setup = config.get("setup", {})
168
+ self._file_path = path
169
+ self._workspace = workspace
170
+
171
+ # Parse test cases via unified entry point (skip path resolution for TUI display)
172
+ self._cases = parse_test_cases(config)
173
+ self._dirty = False
174
+ return len(self._cases)
175
+
176
+ def save(self, file_path: Optional[str] = None) -> None:
177
+ """Persist cases to the current file (or *file_path* if given)."""
178
+ target = Path(file_path) if file_path else self._file_path
179
+ if target is None:
180
+ raise ValueError("No file path set for save.")
181
+
182
+ config: Dict[str, Any] = {"test_cases": []}
183
+ if self._setup:
184
+ config["setup"] = self._setup
185
+ config["test_cases"] = [tc.to_dict() for tc in self._cases]
186
+
187
+ save_config(config, target)
188
+
189
+ if file_path:
190
+ self._file_path = Path(file_path).resolve()
191
+
192
+ self._dirty = False
193
+
194
+ def save_as(self, file_path: str) -> None:
195
+ """Save to a different file."""
196
+ self.save(file_path=file_path)
197
+
198
+ # -- CRUD ----------------------------------------------------------------
199
+
200
+ def get_case(self, index: int) -> TestCase:
201
+ return self._cases[index]
202
+
203
+ def add_case(self, case: TestCase) -> int:
204
+ """Append *case*; return its index."""
205
+ self._cases.append(case)
206
+ self._dirty = True
207
+ return len(self._cases) - 1
208
+
209
+ def update_case(self, index: int, case: TestCase) -> None:
210
+ self._cases[index] = case
211
+ self._dirty = True
212
+
213
+ def delete_case(self, index: int) -> None:
214
+ del self._cases[index]
215
+ self._dirty = True
216
+
217
+ def duplicate_case(self, index: int) -> int:
218
+ """Deep-copy case at *index*, append '_copy' to name."""
219
+ original = self._cases[index]
220
+ new_case = copy.deepcopy(original)
221
+ new_case.name = original.name + "_copy"
222
+ return self.add_case(new_case)
223
+
224
+ def move_case(self, from_idx: int, to_idx: int) -> None:
225
+ """Move case from *from_idx* to *to_idx*."""
226
+ if from_idx == to_idx:
227
+ return
228
+ case = self._cases.pop(from_idx)
229
+ self._cases.insert(to_idx, case)
230
+ self._dirty = True
231
+
232
+ def swap_cases(self, i: int, j: int) -> None:
233
+ self._cases[i], self._cases[j] = self._cases[j], self._cases[i]
234
+ self._dirty = True
235
+
236
+ # -- search --------------------------------------------------------------
237
+
238
+ def search(
239
+ self,
240
+ query: str,
241
+ mode: str = "substring",
242
+ tag: Optional[str] = None,
243
+ ) -> List[int]:
244
+ """Return sorted list of indices for cases matching *query*.
245
+
246
+ Parameters
247
+ ----------
248
+ query:
249
+ Search term.
250
+ mode:
251
+ ``"substring"`` (default), ``"fuzzy"``, or ``"regex"``.
252
+ tag:
253
+ Optional tag filter (exact match).
254
+ """
255
+ if mode == "fuzzy":
256
+ indices = _fuzzy_match(query, self._cases)
257
+ elif mode == "regex":
258
+ indices = [i for i, tc in enumerate(self._cases) if _regex_match(query, tc)]
259
+ else: # substring (default)
260
+ indices = [i for i, tc in enumerate(self._cases) if _substring_match(query, tc)]
261
+
262
+ # Apply tag filter
263
+ if tag:
264
+ indices = [i for i in indices if tag in self._cases[i].tags]
265
+
266
+ return indices
267
+
268
+ def get_all_tags(self) -> List[str]:
269
+ """Collect unique tags across all cases, sorted."""
270
+ tags: set = set()
271
+ for tc in self._cases:
272
+ tags.update(tc.tags)
273
+ return sorted(tags)
274
+
275
+ # -- run ----------------------------------------------------------------
276
+
277
+ def run_case(self, index: int) -> Dict[str, Any]:
278
+ """Execute a single test case and return the result dict."""
279
+ case = self._cases[index]
280
+ if case.steps:
281
+ # Sequence mode — use execute_sequence (consistent with Runner)
282
+ result = execute_sequence(
283
+ case_name=case.name,
284
+ steps=case.steps,
285
+ workspace=self._workspace,
286
+ update_baseline=self._update_baseline,
287
+ )
288
+ else:
289
+ # Single-command mode — unified to_execution_dict()
290
+ result = execute_single_test_case(
291
+ case.to_execution_dict(),
292
+ self._workspace,
293
+ update_baseline=self._update_baseline,
294
+ )
295
+ self._record_history(case.name, result)
296
+ return result
297
+
298
+ def _record_history(self, case_name: str, result: Dict[str, Any]) -> None:
299
+ """Per-case history recording with optional reset support."""
300
+ if not self._history_dir:
301
+ return
302
+ from pathlib import Path as _Path
303
+ from ...core.history_store import load_history, save_history, reset_cases, update_case
304
+
305
+ history_dir = str((_Path(self._workspace or ".") / self._history_dir).resolve())
306
+ history = load_history(history_dir)
307
+
308
+ if self._update_history:
309
+ cleared = reset_cases(history, {case_name})
310
+ if cleared:
311
+ logger.info("History reset: cleared '%s'", case_name)
312
+
313
+ if result.get("status") == "passed":
314
+ update_case(history, case_name, result.get("duration", 0))
315
+ save_history(history_dir, history)
316
+
317
+ @staticmethod
318
+ def create_empty_case(mode: str = "single") -> TestCase:
319
+ """Create a blank TestCase."""
320
+ if mode == "sequence":
321
+ return TestCase(name="new_case", steps=[])
322
+ return TestCase(name="new_case", command="", args=[], expected={}, tags=[])
File without changes
@@ -0,0 +1,244 @@
1
+ """Screen for editing a single test case (both single-cmd and sequence modes)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual import on
6
+ from textual.screen import Screen
7
+ from textual.widgets import Input, TextArea, Static, Button, Header, Footer, Label
8
+ from textual.containers import Horizontal, Vertical, Container, ScrollableContainer
9
+ from textual.message import Message
10
+
11
+ from ...core.test_case import TestCase
12
+ from ..widgets.expected_editor import ExpectedEditor
13
+ from ..widgets.steps_editor import StepsEditor
14
+
15
+
16
+ class CaseEditorScreen(Screen):
17
+ """Full-screen form for creating / editing a test case."""
18
+
19
+ DEFAULT_CSS = """
20
+ CaseEditorScreen {
21
+ align: center middle;
22
+ }
23
+ CaseEditorScreen #editor-container {
24
+ width: 90%;
25
+ height: 90%;
26
+ border: solid $primary;
27
+ padding: 1 2;
28
+ }
29
+ CaseEditorScreen #editor-fields {
30
+ height: 1fr;
31
+ }
32
+ CaseEditorScreen #editor-footer {
33
+ height: auto;
34
+ margin-top: 1;
35
+ align-horizontal: right;
36
+ }
37
+ CaseEditorScreen Input {
38
+ width: 100%;
39
+ margin-bottom: 1;
40
+ }
41
+ CaseEditorScreen Label {
42
+ margin-top: 1;
43
+ }
44
+ CaseEditorScreen #case-tags {
45
+ width: 100%;
46
+ }
47
+ CaseEditorScreen #case-description {
48
+ height: 3;
49
+ margin-bottom: 1;
50
+ }
51
+ CaseEditorScreen #case-timeout {
52
+ width: 15;
53
+ }
54
+ CaseEditorScreen #mode-indicator {
55
+ color: $accent;
56
+ text-style: bold;
57
+ }
58
+ """
59
+
60
+ class Saved(Message):
61
+ """Emitted when user saves, carrying the TestCase."""
62
+
63
+ def __init__(self, case: TestCase):
64
+ super().__init__()
65
+ self.case = case
66
+
67
+ class Cancelled(Message):
68
+ """Emitted when user cancels editing."""
69
+
70
+ def __init__(self, case: TestCase | None = None):
71
+ super().__init__()
72
+ self._case: TestCase | None = case
73
+ self._is_sequence = case is not None and case.steps is not None
74
+
75
+ def compose(self):
76
+ yield Header()
77
+ yield Vertical(
78
+ ScrollableContainer(
79
+ Label("Name:", id="name-label"),
80
+ Input(placeholder="test_case_name", id="case-name"),
81
+ Label("Mode:", id="mode-label"),
82
+ Static("Single Command", id="mode-indicator"),
83
+ Label("Command:", id="cmd-label"),
84
+ Input(placeholder="python", id="case-command"),
85
+ Label("Args (space-separated):", id="args-label"),
86
+ Input(placeholder="./script.py --flag", id="case-args"),
87
+ Label("Tags (comma-separated):", id="tags-label"),
88
+ Input(placeholder="tag1, tag2", id="case-tags"),
89
+ Label("Description:", id="desc-label"),
90
+ TextArea("", id="case-description"),
91
+ Label("Timeout (seconds):", id="timeout-label"),
92
+ Input(placeholder="", id="case-timeout"),
93
+ Label("Retry count:", id="retry-label"),
94
+ Input(placeholder="0", id="case-retry-count"),
95
+ ExpectedEditor(id="expected-editor"),
96
+ StepsEditor(id="steps-editor"),
97
+ id="editor-fields",
98
+ ),
99
+ Horizontal(
100
+ Button("Save", id="btn-save", variant="primary"),
101
+ Button("Cancel", id="btn-cancel"),
102
+ Button("Switch to Sequence", id="btn-switch-mode"),
103
+ id="editor-footer",
104
+ ),
105
+ id="editor-container",
106
+ )
107
+ yield Footer()
108
+
109
+ def on_mount(self) -> None:
110
+ if self._case is None:
111
+ self._case = TestCase(name="new_case", command="", args=[], expected={}, tags=[])
112
+ self._is_sequence = False
113
+ else:
114
+ self._is_sequence = self._case.steps is not None
115
+
116
+ self._populate_form()
117
+ self._update_mode_visibility()
118
+
119
+ # -- populate / collect --------------------------------------------------
120
+
121
+ def _populate_form(self) -> None:
122
+ if self._case is None:
123
+ return
124
+ tc = self._case
125
+
126
+ self.query_one("#case-name", Input).value = tc.name
127
+ self.query_one("#case-command", Input).value = tc.command
128
+ self.query_one("#case-args", Input).value = " ".join(tc.args) if tc.args else ""
129
+ self.query_one("#case-tags", Input).value = ",".join(tc.tags) if tc.tags else ""
130
+ self.query_one("#case-description", TextArea).text = tc.description or ""
131
+ self.query_one("#case-timeout", Input).value = str(tc.timeout) if tc.timeout else ""
132
+ self.query_one("#case-retry-count", Input).value = str(tc.retry_count) if tc.retry_count else ""
133
+
134
+ self.query_one("#expected-editor", ExpectedEditor).load(tc.expected or {})
135
+
136
+ if self._is_sequence and tc.steps:
137
+ self.query_one("#steps-editor", StepsEditor).load(tc.steps)
138
+
139
+ self.query_one("#mode-indicator", Static).update(
140
+ "Sequence Mode" if self._is_sequence else "Single Command"
141
+ )
142
+ switch_btn = self.query_one("#btn-switch-mode", Button)
143
+ switch_btn.label = "Switch to Sequence" if not self._is_sequence else "Switch to Single"
144
+
145
+ def _collect_case(self) -> TestCase:
146
+ name = self.query_one("#case-name", Input).value.strip() or "unnamed"
147
+ tags_text = self.query_one("#case-tags", Input).value
148
+ tags = [t.strip() for t in tags_text.split(",") if t.strip()]
149
+
150
+ timeout_text = self.query_one("#case-timeout", Input).value.strip()
151
+ timeout = float(timeout_text) if timeout_text else None
152
+
153
+ retry_text = self.query_one("#case-retry-count", Input).value.strip()
154
+ retry_count = int(retry_text) if retry_text else 0
155
+
156
+ description = self.query_one("#case-description", TextArea).text.strip()
157
+
158
+ if self._is_sequence:
159
+ steps = self.query_one("#steps-editor", StepsEditor).to_steps()
160
+ expected = self.query_one("#expected-editor", ExpectedEditor).to_dict()
161
+ return TestCase(
162
+ name=name,
163
+ steps=steps,
164
+ expected=expected,
165
+ description=description or "",
166
+ timeout=timeout,
167
+ resources=self._case.resources if self._case else None,
168
+ tags=tags,
169
+ retry_count=retry_count,
170
+ )
171
+ else:
172
+ command = self.query_one("#case-command", Input).value.strip()
173
+ args_text = self.query_one("#case-args", Input).value.strip()
174
+ args = args_text.split() if args_text else []
175
+ expected = self.query_one("#expected-editor", ExpectedEditor).to_dict()
176
+
177
+ return TestCase(
178
+ name=name,
179
+ command=command,
180
+ args=args,
181
+ expected=expected,
182
+ description=description or "",
183
+ timeout=timeout,
184
+ resources=self._case.resources if self._case else None,
185
+ tags=tags,
186
+ retry_count=retry_count,
187
+ )
188
+
189
+ def _update_mode_visibility(self) -> None:
190
+ """Show/hide fields based on current mode."""
191
+ cmd_label = self.query_one("#cmd-label", Label)
192
+ args_label = self.query_one("#args-label", Label)
193
+ cmd_input = self.query_one("#case-command", Input)
194
+ args_input = self.query_one("#case-args", Input)
195
+ expected = self.query_one("#expected-editor", ExpectedEditor)
196
+ steps = self.query_one("#steps-editor", StepsEditor)
197
+ mode_label = self.query_one("#mode-label", Label)
198
+ mode_indicator = self.query_one("#mode-indicator", Static)
199
+ switch_btn = self.query_one("#btn-switch-mode", Button)
200
+
201
+ if self._is_sequence:
202
+ mode_label.display = True
203
+ mode_indicator.display = True
204
+ cmd_label.display = False
205
+ args_label.display = False
206
+ cmd_input.display = False
207
+ args_input.display = False
208
+ expected.display = True
209
+ steps.display = True
210
+ switch_btn.label = "Switch to Single"
211
+ else:
212
+ mode_label.display = False
213
+ mode_indicator.display = True
214
+ cmd_label.display = True
215
+ args_label.display = True
216
+ cmd_input.display = True
217
+ args_input.display = True
218
+ expected.display = True
219
+ steps.display = False
220
+ switch_btn.label = "Switch to Sequence"
221
+
222
+ # -- button handlers -----------------------------------------------------
223
+
224
+ @on(Button.Pressed, "#btn-save")
225
+ def _on_save(self) -> None:
226
+ try:
227
+ case = self._collect_case()
228
+ except Exception as e:
229
+ self.notify(f"Error: {e}", severity="error")
230
+ return
231
+ self.post_message(self.Saved(case))
232
+ self.dismiss()
233
+
234
+ @on(Button.Pressed, "#btn-cancel")
235
+ def _on_cancel(self) -> None:
236
+ self.post_message(self.Cancelled())
237
+ self.dismiss()
238
+
239
+ @on(Button.Pressed, "#btn-switch-mode")
240
+ def _on_switch_mode(self) -> None:
241
+ self._is_sequence = not self._is_sequence
242
+ mode_label = self.query_one("#mode-indicator", Static)
243
+ mode_label.update("Sequence Mode" if self._is_sequence else "Single Command")
244
+ self._update_mode_visibility()