Skip to content

Commit ca68a30

Browse files
Merge pull request #37 from NEURASCOPE/feature/gui-v1.5
feat: macro recorder, advanced features, light theme fix (#31 #32)
2 parents 880c026 + 2260053 commit ca68a30

20 files changed

Lines changed: 2463 additions & 16 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Advanced features: statistics, diff, selector validator, autosave."""
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Autosave and recovery for scenario editing."""
2+
3+
import json
4+
import logging
5+
import time
6+
from pathlib import Path
7+
8+
from PySide6.QtCore import QTimer
9+
from PySide6.QtWidgets import QMessageBox
10+
11+
logger = logging.getLogger("neurascreen.gui")
12+
13+
# Autosave directory
14+
AUTOSAVE_DIR = Path.home() / ".neurascreen" / "autosave"
15+
16+
# Autosave interval in milliseconds
17+
AUTOSAVE_INTERVAL_MS = 60_000 # 60 seconds
18+
19+
20+
def autosave_path() -> Path:
21+
"""Return the autosave file path."""
22+
return AUTOSAVE_DIR / "autosave_scenario.json"
23+
24+
25+
def has_recovery() -> bool:
26+
"""Check if an autosave file exists for recovery."""
27+
return autosave_path().exists()
28+
29+
30+
def save_autosave(scenario: dict) -> None:
31+
"""Write scenario data to the autosave file."""
32+
AUTOSAVE_DIR.mkdir(parents=True, exist_ok=True)
33+
path = autosave_path()
34+
data = {
35+
"timestamp": int(time.time()),
36+
"scenario": scenario,
37+
}
38+
with open(path, "w", encoding="utf-8") as f:
39+
json.dump(data, f, indent=2, ensure_ascii=False)
40+
logger.debug("Autosaved to %s", path)
41+
42+
43+
def load_autosave() -> dict | None:
44+
"""Load the autosaved scenario, or None if not found."""
45+
path = autosave_path()
46+
if not path.exists():
47+
return None
48+
try:
49+
with open(path, "r", encoding="utf-8") as f:
50+
data = json.load(f)
51+
return data.get("scenario")
52+
except (json.JSONDecodeError, OSError) as e:
53+
logger.warning("Failed to load autosave: %s", e)
54+
return None
55+
56+
57+
def autosave_timestamp() -> int:
58+
"""Return the timestamp of the autosave file, or 0."""
59+
path = autosave_path()
60+
if not path.exists():
61+
return 0
62+
try:
63+
with open(path, "r", encoding="utf-8") as f:
64+
data = json.load(f)
65+
return data.get("timestamp", 0)
66+
except (json.JSONDecodeError, OSError):
67+
return 0
68+
69+
70+
def clear_autosave() -> None:
71+
"""Delete the autosave file."""
72+
path = autosave_path()
73+
if path.exists():
74+
path.unlink()
75+
logger.debug("Autosave cleared")
76+
77+
78+
class AutosaveManager:
79+
"""Manages periodic autosave via QTimer.
80+
81+
Usage:
82+
manager = AutosaveManager()
83+
manager.start(get_scenario_fn) # starts periodic saving
84+
manager.stop() # stops the timer
85+
"""
86+
87+
def __init__(self, interval_ms: int = AUTOSAVE_INTERVAL_MS):
88+
self._timer = QTimer()
89+
self._timer.setInterval(interval_ms)
90+
self._timer.timeout.connect(self._on_timeout)
91+
self._get_scenario = None
92+
93+
def start(self, get_scenario_fn) -> None:
94+
"""Start autosaving. get_scenario_fn returns a dict or None."""
95+
self._get_scenario = get_scenario_fn
96+
self._timer.start()
97+
logger.info("Autosave started (every %ds)", self._timer.interval() // 1000)
98+
99+
def stop(self) -> None:
100+
"""Stop autosaving."""
101+
self._timer.stop()
102+
self._get_scenario = None
103+
104+
def _on_timeout(self) -> None:
105+
if self._get_scenario is None:
106+
return
107+
scenario = self._get_scenario()
108+
if scenario and scenario.get("steps"):
109+
save_autosave(scenario)
110+
111+
112+
def prompt_recovery(parent=None) -> dict | None:
113+
"""Show recovery dialog if autosave exists. Returns scenario or None."""
114+
if not has_recovery():
115+
return None
116+
117+
ts = autosave_timestamp()
118+
if ts:
119+
from datetime import datetime
120+
time_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
121+
msg = f"An autosaved scenario was found from {time_str}.\n\nRecover it?"
122+
else:
123+
msg = "An autosaved scenario was found.\n\nRecover it?"
124+
125+
result = QMessageBox.question(
126+
parent, "Recover Autosave",
127+
msg,
128+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
129+
)
130+
131+
if result == QMessageBox.StandardButton.Yes:
132+
scenario = load_autosave()
133+
clear_autosave()
134+
return scenario
135+
else:
136+
clear_autosave()
137+
return None
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"""Scenario diff — compare two scenarios side by side."""
2+
3+
import json
4+
import logging
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
8+
from PySide6.QtCore import Qt
9+
from PySide6.QtGui import QColor
10+
from PySide6.QtWidgets import (
11+
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
12+
QTableWidget, QTableWidgetItem, QHeaderView,
13+
QPushButton, QFileDialog, QGroupBox,
14+
)
15+
16+
logger = logging.getLogger("neurascreen.gui")
17+
18+
19+
@dataclass
20+
class DiffEntry:
21+
"""A single difference between two scenarios."""
22+
23+
index: int
24+
kind: str # "added", "removed", "modified", "unchanged"
25+
left: dict | None # step from scenario A
26+
right: dict | None # step from scenario B
27+
changes: list[str] # list of changed fields
28+
29+
30+
def diff_steps(steps_a: list[dict], steps_b: list[dict]) -> list[DiffEntry]:
31+
"""Compute a simple positional diff between two step lists.
32+
33+
This is a positional diff (not LCS-based) — it compares steps
34+
at the same index and reports additions/removals at the end.
35+
"""
36+
entries = []
37+
max_len = max(len(steps_a), len(steps_b))
38+
39+
for i in range(max_len):
40+
a = steps_a[i] if i < len(steps_a) else None
41+
b = steps_b[i] if i < len(steps_b) else None
42+
43+
if a is None:
44+
entries.append(DiffEntry(i, "added", None, b, []))
45+
elif b is None:
46+
entries.append(DiffEntry(i, "removed", a, None, []))
47+
elif a == b:
48+
entries.append(DiffEntry(i, "unchanged", a, b, []))
49+
else:
50+
changes = _find_changes(a, b)
51+
entries.append(DiffEntry(i, "modified", a, b, changes))
52+
53+
return entries
54+
55+
56+
def diff_summary(entries: list[DiffEntry]) -> dict:
57+
"""Summarize diff entries into counts."""
58+
counts = {"added": 0, "removed": 0, "modified": 0, "unchanged": 0}
59+
for e in entries:
60+
counts[e.kind] += 1
61+
return counts
62+
63+
64+
def _find_changes(a: dict, b: dict) -> list[str]:
65+
"""List the fields that differ between two step dicts."""
66+
all_keys = set(a.keys()) | set(b.keys())
67+
changes = []
68+
for key in sorted(all_keys):
69+
val_a = a.get(key)
70+
val_b = b.get(key)
71+
if val_a != val_b:
72+
changes.append(key)
73+
return changes
74+
75+
76+
DIFF_COLORS = {
77+
"added": "#22C55E",
78+
"removed": "#EF4444",
79+
"modified": "#F59E0B",
80+
"unchanged": None,
81+
}
82+
83+
84+
class DiffDialog(QDialog):
85+
"""Dialog showing side-by-side scenario diff."""
86+
87+
def __init__(self, parent=None):
88+
super().__init__(parent)
89+
self.setWindowTitle("Compare Scenarios")
90+
self.setMinimumSize(800, 500)
91+
self.resize(900, 600)
92+
93+
self._path_a: str = ""
94+
self._path_b: str = ""
95+
self._setup_ui()
96+
97+
def _setup_ui(self) -> None:
98+
layout = QVBoxLayout(self)
99+
layout.setSpacing(12)
100+
101+
# File selection
102+
files_group = QGroupBox("Files")
103+
files_layout = QVBoxLayout(files_group)
104+
105+
row_a = QHBoxLayout()
106+
self._label_a = QLabel("File A: (none)")
107+
btn_a = QPushButton("Browse...")
108+
btn_a.clicked.connect(lambda: self._browse("a"))
109+
row_a.addWidget(self._label_a, 1)
110+
row_a.addWidget(btn_a)
111+
files_layout.addLayout(row_a)
112+
113+
row_b = QHBoxLayout()
114+
self._label_b = QLabel("File B: (none)")
115+
btn_b = QPushButton("Browse...")
116+
btn_b.clicked.connect(lambda: self._browse("b"))
117+
row_b.addWidget(self._label_b, 1)
118+
row_b.addWidget(btn_b)
119+
files_layout.addLayout(row_b)
120+
121+
layout.addWidget(files_group)
122+
123+
# Compare button
124+
btn_row = QHBoxLayout()
125+
self._btn_compare = QPushButton("Compare")
126+
self._btn_compare.setProperty("primary", True)
127+
self._btn_compare.setEnabled(False)
128+
self._btn_compare.clicked.connect(self._on_compare)
129+
btn_row.addStretch()
130+
btn_row.addWidget(self._btn_compare)
131+
btn_row.addStretch()
132+
layout.addLayout(btn_row)
133+
134+
# Summary
135+
self._summary_label = QLabel("")
136+
self._summary_label.setProperty("muted", True)
137+
layout.addWidget(self._summary_label)
138+
139+
# Diff table
140+
self._table = QTableWidget(0, 5)
141+
self._table.setHorizontalHeaderLabels(["#", "Status", "Action A", "Action B", "Changes"])
142+
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
143+
self._table.horizontalHeader().resizeSection(0, 40)
144+
self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
145+
self._table.horizontalHeader().resizeSection(1, 80)
146+
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive)
147+
self._table.horizontalHeader().resizeSection(2, 180)
148+
self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive)
149+
self._table.horizontalHeader().resizeSection(3, 180)
150+
self._table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
151+
self._table.verticalHeader().setVisible(False)
152+
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
153+
self._table.setAlternatingRowColors(True)
154+
layout.addWidget(self._table, 1)
155+
156+
# Close
157+
btn_close = QPushButton("Close")
158+
btn_close.clicked.connect(self.accept)
159+
close_row = QHBoxLayout()
160+
close_row.addStretch()
161+
close_row.addWidget(btn_close)
162+
layout.addLayout(close_row)
163+
164+
def _browse(self, which: str) -> None:
165+
filepath, _ = QFileDialog.getOpenFileName(
166+
self, f"Select Scenario {which.upper()}",
167+
"", "JSON Scenarios (*.json);;All Files (*)",
168+
)
169+
if not filepath:
170+
return
171+
if which == "a":
172+
self._path_a = filepath
173+
self._label_a.setText(f"File A: {Path(filepath).name}")
174+
else:
175+
self._path_b = filepath
176+
self._label_b.setText(f"File B: {Path(filepath).name}")
177+
self._btn_compare.setEnabled(bool(self._path_a and self._path_b))
178+
179+
def _on_compare(self) -> None:
180+
try:
181+
with open(self._path_a, "r", encoding="utf-8") as f:
182+
data_a = json.load(f)
183+
with open(self._path_b, "r", encoding="utf-8") as f:
184+
data_b = json.load(f)
185+
except (json.JSONDecodeError, OSError) as e:
186+
self._summary_label.setText(f"Error: {e}")
187+
return
188+
189+
steps_a = data_a.get("steps", [])
190+
steps_b = data_b.get("steps", [])
191+
entries = diff_steps(steps_a, steps_b)
192+
summary = diff_summary(entries)
193+
194+
self._summary_label.setText(
195+
f"{summary['unchanged']} unchanged, "
196+
f"{summary['modified']} modified, "
197+
f"{summary['added']} added, "
198+
f"{summary['removed']} removed"
199+
)
200+
201+
self._table.setRowCount(len(entries))
202+
for row, entry in enumerate(entries):
203+
# Index
204+
idx_item = QTableWidgetItem(str(entry.index + 1))
205+
idx_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
206+
self._table.setItem(row, 0, idx_item)
207+
208+
# Status
209+
status_item = QTableWidgetItem(entry.kind)
210+
color = DIFF_COLORS.get(entry.kind)
211+
if color:
212+
status_item.setForeground(QColor(color))
213+
self._table.setItem(row, 1, status_item)
214+
215+
# Action A
216+
action_a = entry.left.get("action", "") if entry.left else ""
217+
title_a = entry.left.get("title", "") if entry.left else ""
218+
text_a = f"{action_a}: {title_a}" if title_a else action_a
219+
self._table.setItem(row, 2, QTableWidgetItem(text_a))
220+
221+
# Action B
222+
action_b = entry.right.get("action", "") if entry.right else ""
223+
title_b = entry.right.get("title", "") if entry.right else ""
224+
text_b = f"{action_b}: {title_b}" if title_b else action_b
225+
self._table.setItem(row, 3, QTableWidgetItem(text_b))
226+
227+
# Changes
228+
changes_text = ", ".join(entry.changes) if entry.changes else ""
229+
self._table.setItem(row, 4, QTableWidgetItem(changes_text))
230+
231+
def load_files(self, path_a: str, path_b: str) -> None:
232+
"""Pre-load file paths programmatically."""
233+
self._path_a = path_a
234+
self._path_b = path_b
235+
self._label_a.setText(f"File A: {Path(path_a).name}")
236+
self._label_b.setText(f"File B: {Path(path_b).name}")
237+
self._btn_compare.setEnabled(True)

0 commit comments

Comments
 (0)