feat: merge scan rows and strengthen Ctrl+Z undo
Add "Merge N rows" context-menu option that combines selected scan rows into one (min start, max end, max score), with full undo support. Ctrl+Z is now an application-wide shortcut so it works regardless of which widget has focus. Negatives undo now respects the exported-green row color instead of reverting to default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+35
@@ -803,6 +803,41 @@ class ProcessedDB:
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def insert_scan_result(self, filename: str, profile: str, model: str,
|
||||
start: float, end: float, score: float,
|
||||
disabled: bool, orig_start: float, orig_end: float,
|
||||
scan_timestamp: str = "") -> int:
|
||||
"""Insert a single scan result row; returns its new id."""
|
||||
if not self._enabled:
|
||||
return -1
|
||||
with self._lock:
|
||||
cur = self._con.execute(
|
||||
"INSERT INTO scan_results"
|
||||
" (filename, profile, model, start_time, end_time, score,"
|
||||
" disabled, orig_start_time, orig_end_time, scan_timestamp)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(filename, profile, model, start, end, score,
|
||||
1 if disabled else 0, orig_start, orig_end, scan_timestamp),
|
||||
)
|
||||
self._con.commit()
|
||||
return int(cur.lastrowid or -1)
|
||||
|
||||
def update_scan_result_full(self, row_id: int, start: float, end: float,
|
||||
score: float, orig_start: float,
|
||||
orig_end: float) -> None:
|
||||
"""Update bounds, score and orig_* fields — used after merging rows."""
|
||||
if not self._enabled:
|
||||
return
|
||||
with self._lock:
|
||||
self._con.execute(
|
||||
"UPDATE scan_results"
|
||||
" SET start_time = ?, end_time = ?, score = ?,"
|
||||
" orig_start_time = ?, orig_end_time = ?"
|
||||
" WHERE id = ?",
|
||||
(start, end, score, orig_start, orig_end, row_id),
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
def get_scan_models(self, filename: str, profile: str) -> list[str]:
|
||||
"""Return model names that have scan results for this file."""
|
||||
if not self._enabled:
|
||||
|
||||
@@ -1057,16 +1057,96 @@ class ScanResultsPanel(QWidget):
|
||||
continue
|
||||
if self._is_row_exported(float(start), float(end)):
|
||||
ranges.append((float(start), float(end)))
|
||||
if not ranges:
|
||||
return
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
menu = QMenu(table)
|
||||
act_merge = None
|
||||
act_delete = None
|
||||
if len(selected_rows) >= 2:
|
||||
act_merge = menu.addAction(f"Merge {len(selected_rows)} rows")
|
||||
if ranges:
|
||||
n = len(ranges)
|
||||
act = menu.addAction(f"Delete export{'s' if n > 1 else ''} for {n} row{'s' if n > 1 else ''}")
|
||||
act_delete = menu.addAction(
|
||||
f"Delete export{'s' if n > 1 else ''} for {n} row{'s' if n > 1 else ''}")
|
||||
if menu.isEmpty():
|
||||
return
|
||||
chosen = menu.exec(table.viewport().mapToGlobal(pos))
|
||||
if chosen == act:
|
||||
if chosen is None:
|
||||
return
|
||||
if chosen == act_merge:
|
||||
self._merge_rows(table, selected_rows)
|
||||
elif chosen == act_delete:
|
||||
self.delete_exports_requested.emit(ranges)
|
||||
|
||||
def _merge_rows(self, table: QTableWidget, rows: list[int]) -> None:
|
||||
"""Merge selected rows into the first — min start, max end, max score."""
|
||||
if len(rows) < 2:
|
||||
return
|
||||
data = []
|
||||
for r in rows:
|
||||
item0 = table.item(r, 0)
|
||||
item1 = table.item(r, 1)
|
||||
item2 = table.item(r, 2)
|
||||
if item0 is None or item1 is None or item2 is None:
|
||||
continue
|
||||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||||
end = item1.data(Qt.ItemDataRole.UserRole)
|
||||
os_ = item0.data(Qt.ItemDataRole.UserRole + 3)
|
||||
oe = item0.data(Qt.ItemDataRole.UserRole + 4)
|
||||
try:
|
||||
score = float(item2.text())
|
||||
except ValueError:
|
||||
score = 0.0
|
||||
if start is None or end is None or row_id is None:
|
||||
continue
|
||||
data.append((r, row_id, float(start), float(end), score,
|
||||
float(os_) if os_ is not None else float(start),
|
||||
float(oe) if oe is not None else float(end)))
|
||||
if len(data) < 2:
|
||||
return
|
||||
keeper_row, keeper_id, k_start, k_end, k_score, k_os, k_oe = data[0]
|
||||
new_start = min(d[2] for d in data)
|
||||
new_end = max(d[3] for d in data)
|
||||
new_score = max(d[4] for d in data)
|
||||
new_os = min(d[5] for d in data)
|
||||
new_oe = max(d[6] for d in data)
|
||||
|
||||
# Record undo: keeper's old state + data for rows we are about to delete
|
||||
tab_idx = self._tabs.currentIndex()
|
||||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||||
removed = []
|
||||
for r, rid, s, e, sc, os_, oe in data[1:]:
|
||||
disabled = table.item(r, 0).data(Qt.ItemDataRole.UserRole + 2) or False
|
||||
removed.append((s, e, sc, bool(disabled), os_, oe))
|
||||
self._undo_stack.append((
|
||||
"merge", tab_idx, model, keeper_id,
|
||||
(k_start, k_end, k_score, k_os, k_oe), removed,
|
||||
))
|
||||
|
||||
self._db.update_scan_result_full(keeper_id, new_start, new_end,
|
||||
new_score, new_os, new_oe)
|
||||
self._editing = True
|
||||
keeper_item0 = table.item(keeper_row, 0)
|
||||
keeper_item0.setText(format_time(new_start))
|
||||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 1, new_start)
|
||||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 3, new_os)
|
||||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 4, new_oe)
|
||||
keeper_item1 = table.item(keeper_row, 1)
|
||||
keeper_item1.setText(format_time(new_end))
|
||||
keeper_item1.setData(Qt.ItemDataRole.UserRole, new_end)
|
||||
table.item(keeper_row, 2).setText(f"{new_score:.2f}")
|
||||
self._editing = False
|
||||
|
||||
# Delete the other rows from DB and table (bottom-up)
|
||||
for r, row_id, *_ in sorted(data[1:], key=lambda d: d[0], reverse=True):
|
||||
self._db.delete_scan_result(row_id)
|
||||
table.removeRow(r)
|
||||
|
||||
tab_idx = self._tabs.currentIndex()
|
||||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||||
self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})")
|
||||
self.regions_edited.emit()
|
||||
|
||||
def _on_cell_clicked(self, table: QTableWidget, row: int, col: int) -> None:
|
||||
"""Click Time → seek to start; click End → seek to last 3s of clip."""
|
||||
if col == 1:
|
||||
@@ -1433,8 +1513,6 @@ class ScanResultsPanel(QWidget):
|
||||
return
|
||||
add_back: list[float] = []
|
||||
remove_back: list[float] = []
|
||||
gray = QColor(100, 100, 100)
|
||||
red = QColor(220, 60, 60)
|
||||
default_fg = table.palette().color(table.foregroundRole())
|
||||
for row, t_val, was_in_neg in was_neg:
|
||||
if row >= table.rowCount():
|
||||
@@ -1444,13 +1522,12 @@ class ScanResultsPanel(QWidget):
|
||||
if was_in_neg and t not in self._neg_times:
|
||||
self._neg_times.add(t)
|
||||
add_back.append(t)
|
||||
fg = gray if disabled else red
|
||||
elif not was_in_neg and t in self._neg_times:
|
||||
self._neg_times.discard(t)
|
||||
remove_back.append(t)
|
||||
fg = gray if disabled else default_fg
|
||||
else:
|
||||
continue
|
||||
fg = self._row_fg(table, row, disabled, default_fg)
|
||||
for col in range(3):
|
||||
table.item(row, col).setForeground(fg)
|
||||
if add_back:
|
||||
@@ -1458,10 +1535,60 @@ class ScanResultsPanel(QWidget):
|
||||
if remove_back:
|
||||
self.negatives_removed.emit(remove_back)
|
||||
|
||||
elif kind == "merge":
|
||||
_, tab_idx, model, keeper_id, keeper_old, removed = action
|
||||
table = self._tab_table(tab_idx)
|
||||
if table is None:
|
||||
return
|
||||
k_start, k_end, k_score, k_os, k_oe = keeper_old
|
||||
# Revert keeper to its previous state
|
||||
self._db.update_scan_result_full(
|
||||
keeper_id, k_start, k_end, k_score, k_os, k_oe)
|
||||
# Find keeper's row in the table and update cells
|
||||
self._editing = True
|
||||
for row in range(table.rowCount()):
|
||||
item0 = table.item(row, 0)
|
||||
if item0 and item0.data(Qt.ItemDataRole.UserRole) == keeper_id:
|
||||
item0.setText(format_time(k_start))
|
||||
item0.setData(Qt.ItemDataRole.UserRole + 1, k_start)
|
||||
item0.setData(Qt.ItemDataRole.UserRole + 3, k_os)
|
||||
item0.setData(Qt.ItemDataRole.UserRole + 4, k_oe)
|
||||
item1 = table.item(row, 1)
|
||||
item1.setText(format_time(k_end))
|
||||
item1.setData(Qt.ItemDataRole.UserRole, k_end)
|
||||
table.item(row, 2).setText(f"{k_score:.2f}")
|
||||
break
|
||||
# Re-insert deleted rows (new ids)
|
||||
default_fg = table.palette().color(table.foregroundRole())
|
||||
for (s, e, sc, disabled, os_, oe) in removed:
|
||||
new_id = self._db.insert_scan_result(
|
||||
self._filename, self._profile, model,
|
||||
s, e, sc, disabled, os_, oe)
|
||||
row = table.rowCount()
|
||||
table.insertRow(row)
|
||||
t_item = QTableWidgetItem(format_time(s))
|
||||
t_item.setData(Qt.ItemDataRole.UserRole, new_id)
|
||||
t_item.setData(Qt.ItemDataRole.UserRole + 1, s)
|
||||
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
|
||||
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_)
|
||||
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe)
|
||||
table.setItem(row, 0, t_item)
|
||||
e_item = QTableWidgetItem(format_time(e))
|
||||
e_item.setData(Qt.ItemDataRole.UserRole, e)
|
||||
table.setItem(row, 1, e_item)
|
||||
sc_item = QTableWidgetItem(f"{sc:.2f}")
|
||||
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row, 2, sc_item)
|
||||
fg = self._row_fg(table, row, disabled, default_fg)
|
||||
if fg != default_fg:
|
||||
for col in range(3):
|
||||
table.item(row, col).setForeground(fg)
|
||||
self._editing = False
|
||||
self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})")
|
||||
self.regions_edited.emit()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == Qt.Key.Key_Z and event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
self.undo()
|
||||
elif event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||||
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||||
self.toggle_disable_selected()
|
||||
elif event.key() == Qt.Key.Key_N:
|
||||
self._on_add_negatives()
|
||||
@@ -3371,6 +3498,7 @@ class MainWindow(QMainWindow):
|
||||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
||||
QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
|
||||
QShortcut(QKeySequence("A"), self, context=ctx).activated.connect(self._autoclip)
|
||||
QShortcut(QKeySequence("Ctrl+Z"), self, context=ctx).activated.connect(self._scan_panel.undo)
|
||||
for key in ("?", "F1"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user