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()
|
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]:
|
def get_scan_models(self, filename: str, profile: str) -> list[str]:
|
||||||
"""Return model names that have scan results for this file."""
|
"""Return model names that have scan results for this file."""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
|
|||||||
@@ -1057,16 +1057,96 @@ class ScanResultsPanel(QWidget):
|
|||||||
continue
|
continue
|
||||||
if self._is_row_exported(float(start), float(end)):
|
if self._is_row_exported(float(start), float(end)):
|
||||||
ranges.append((float(start), float(end)))
|
ranges.append((float(start), float(end)))
|
||||||
if not ranges:
|
|
||||||
return
|
|
||||||
from PyQt6.QtWidgets import QMenu
|
from PyQt6.QtWidgets import QMenu
|
||||||
menu = QMenu(table)
|
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)
|
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))
|
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)
|
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:
|
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."""
|
"""Click Time → seek to start; click End → seek to last 3s of clip."""
|
||||||
if col == 1:
|
if col == 1:
|
||||||
@@ -1433,8 +1513,6 @@ class ScanResultsPanel(QWidget):
|
|||||||
return
|
return
|
||||||
add_back: list[float] = []
|
add_back: list[float] = []
|
||||||
remove_back: list[float] = []
|
remove_back: list[float] = []
|
||||||
gray = QColor(100, 100, 100)
|
|
||||||
red = QColor(220, 60, 60)
|
|
||||||
default_fg = table.palette().color(table.foregroundRole())
|
default_fg = table.palette().color(table.foregroundRole())
|
||||||
for row, t_val, was_in_neg in was_neg:
|
for row, t_val, was_in_neg in was_neg:
|
||||||
if row >= table.rowCount():
|
if row >= table.rowCount():
|
||||||
@@ -1444,13 +1522,12 @@ class ScanResultsPanel(QWidget):
|
|||||||
if was_in_neg and t not in self._neg_times:
|
if was_in_neg and t not in self._neg_times:
|
||||||
self._neg_times.add(t)
|
self._neg_times.add(t)
|
||||||
add_back.append(t)
|
add_back.append(t)
|
||||||
fg = gray if disabled else red
|
|
||||||
elif not was_in_neg and t in self._neg_times:
|
elif not was_in_neg and t in self._neg_times:
|
||||||
self._neg_times.discard(t)
|
self._neg_times.discard(t)
|
||||||
remove_back.append(t)
|
remove_back.append(t)
|
||||||
fg = gray if disabled else default_fg
|
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
fg = self._row_fg(table, row, disabled, default_fg)
|
||||||
for col in range(3):
|
for col in range(3):
|
||||||
table.item(row, col).setForeground(fg)
|
table.item(row, col).setForeground(fg)
|
||||||
if add_back:
|
if add_back:
|
||||||
@@ -1458,10 +1535,60 @@ class ScanResultsPanel(QWidget):
|
|||||||
if remove_back:
|
if remove_back:
|
||||||
self.negatives_removed.emit(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):
|
def keyPressEvent(self, event):
|
||||||
if event.key() == Qt.Key.Key_Z and event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||||||
self.undo()
|
|
||||||
elif event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
|
||||||
self.toggle_disable_selected()
|
self.toggle_disable_selected()
|
||||||
elif event.key() == Qt.Key.Key_N:
|
elif event.key() == Qt.Key.Key_N:
|
||||||
self._on_add_negatives()
|
self._on_add_negatives()
|
||||||
@@ -3371,6 +3498,7 @@ class MainWindow(QMainWindow):
|
|||||||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
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("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
|
||||||
QShortcut(QKeySequence("A"), self, context=ctx).activated.connect(self._autoclip)
|
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"):
|
for key in ("?", "F1"):
|
||||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user