Add markers: placement, labels and click-to-pick jump targets
Markers are stored per (page, slot), parallel to the discard flags, so adding or removing a cut keeps them aligned with their slices. On a split they stay with the upper half: a marker sits on a printed symbol and nothing can say which side that symbol landed on, so predictable beats clever. Jump targets are chosen by clicking the slice rather than from the thumbnail strip the plan called for. Less code, and it reads the score instead of a list of thumbnails - which is what you want when hunting for the Coda sign. Any page; PageUp/PageDown while picking. Export resolves (page, slot) to the bundle's array index, the only cross-reference the format has. A jump whose target was discarded or re-cut away is dropped rather than exported dangling, since noteman would have nothing to resolve it to. tests/test_markers.py covers the enum size - that is the coupling between two repos - along with cut-edit alignment, index resolution, the dangling-target drop, and round-trips through both the project file and a real bundle. Closes #28 Closes #29 Closes #30
This commit is contained in:
+126
-2
@@ -32,10 +32,12 @@ from PySide6.QtWidgets import (
|
||||
QFormLayout,
|
||||
QGraphicsScene,
|
||||
QGraphicsView,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
@@ -48,7 +50,15 @@ from . import bundle
|
||||
from .bundle import METADATA_FIELDS
|
||||
from .detect import deskew, detect_page
|
||||
from .pdf import Source, open_source, page_raster
|
||||
from .project import Cut, Project, open_project
|
||||
from .project import (
|
||||
JUMP_TYPES,
|
||||
LABELLED_TYPES,
|
||||
MARKER_TYPES,
|
||||
Cut,
|
||||
Marker,
|
||||
Project,
|
||||
open_project,
|
||||
)
|
||||
from .render import apply_levels
|
||||
|
||||
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
|
||||
@@ -60,6 +70,7 @@ _CUT_ACTIVE = QColor(255, 120, 0)
|
||||
_VERTEX = QColor(255, 200, 0)
|
||||
_DISCARD = QColor(120, 120, 140, 90)
|
||||
_RECT = QColor(40, 140, 220)
|
||||
_MARKER = QColor(150, 60, 190)
|
||||
|
||||
|
||||
class PageView(QGraphicsView):
|
||||
@@ -67,6 +78,7 @@ class PageView(QGraphicsView):
|
||||
|
||||
changed = Signal()
|
||||
selection_changed = Signal()
|
||||
picked = Signal(int, int) # page, slot — a jump target chosen by clicking
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
@@ -80,6 +92,7 @@ class PageView(QGraphicsView):
|
||||
self.pixmap: QPixmap | None = None
|
||||
self.selected_cut: int | None = None
|
||||
self.selected_slice = 0
|
||||
self.picking = False
|
||||
self._drag: tuple[str, int, int] | None = None
|
||||
|
||||
# -- state ------------------------------------------------------------
|
||||
@@ -124,6 +137,17 @@ class PageView(QGraphicsView):
|
||||
pen.setCosmetic(True)
|
||||
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
|
||||
|
||||
for slot in range(self.page.slice_count):
|
||||
markers = self.page.markers[slot]
|
||||
if not markers:
|
||||
continue
|
||||
above, _ = self.page.bounds(slot)
|
||||
top = 0 if above is None else int(above.lowest * h)
|
||||
text = scene.addText(" · ".join(m.describe() for m in markers))
|
||||
text.setDefaultTextColor(_MARKER)
|
||||
text.setScale(max(1.0, w / 900))
|
||||
text.setPos(w * 0.02, top + h * 0.004)
|
||||
|
||||
for i, cut in enumerate(self.page.cuts):
|
||||
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
|
||||
pen = QPen(colour, 2)
|
||||
@@ -189,6 +213,15 @@ class PageView(QGraphicsView):
|
||||
return super().mousePressEvent(event)
|
||||
x, y = self._norm(event.position().toPoint())
|
||||
|
||||
if self.picking:
|
||||
# Choosing a jump's target: click the slice it lands on. Cheaper
|
||||
# than a thumbnail picker and it reads the score rather than a list.
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.picked.emit(self.page_index, self._slice_at(x, y))
|
||||
self.picking = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
return
|
||||
|
||||
if event.button() == Qt.RightButton:
|
||||
hit = self._hit_cut(x, y)
|
||||
if hit:
|
||||
@@ -299,9 +332,11 @@ class Editor(QMainWindow):
|
||||
self._raw: dict[int, np.ndarray] = {}
|
||||
|
||||
self.setWindowTitle(f"noteman-slicer — {source.path.name}")
|
||||
self._targeting = 0
|
||||
self.view = PageView()
|
||||
self.view.changed.connect(self._touched)
|
||||
self.view.selection_changed.connect(self._sync)
|
||||
self.view.picked.connect(self._target_picked)
|
||||
|
||||
self.autosave = QTimer(self)
|
||||
self.autosave.setSingleShot(True)
|
||||
@@ -362,6 +397,36 @@ class Editor(QMainWindow):
|
||||
form.addRow(reset)
|
||||
box.addWidget(page_box)
|
||||
|
||||
marker_box = QGroupBox("Markers on this slice")
|
||||
marker_layout = QVBoxLayout(marker_box)
|
||||
self.marker_list = QListWidget()
|
||||
self.marker_list.setMaximumHeight(110)
|
||||
marker_layout.addWidget(self.marker_list)
|
||||
|
||||
add_row = QHBoxLayout()
|
||||
self.marker_type = QComboBox()
|
||||
self.marker_type.addItems(MARKER_TYPES)
|
||||
self.marker_type.currentTextChanged.connect(self._marker_type_changed)
|
||||
add_row.addWidget(self.marker_type, 1)
|
||||
self.marker_label = QLineEdit()
|
||||
self.marker_label.setPlaceholderText("label")
|
||||
self.marker_label.setFixedWidth(70)
|
||||
add_row.addWidget(self.marker_label)
|
||||
marker_layout.addLayout(add_row)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
add = QPushButton("Add")
|
||||
add.clicked.connect(self._add_marker)
|
||||
remove = QPushButton("Remove")
|
||||
remove.clicked.connect(self._remove_marker)
|
||||
self.retarget = QPushButton("Set target…")
|
||||
self.retarget.clicked.connect(self._pick_target)
|
||||
for button in (add, remove, self.retarget):
|
||||
button_row.addWidget(button)
|
||||
marker_layout.addLayout(button_row)
|
||||
box.addWidget(marker_box)
|
||||
self._marker_type_changed(self.marker_type.currentText())
|
||||
|
||||
meta_box = QGroupBox("Song")
|
||||
meta_form = QFormLayout(meta_box)
|
||||
self.metadata: dict[str, QLineEdit] = {}
|
||||
@@ -386,7 +451,8 @@ class Editor(QMainWindow):
|
||||
"Drag: move cut · Ctrl-click: add vertex\n"
|
||||
"Right-click: delete cut or vertex\n"
|
||||
"Click a slice, then D to discard\n"
|
||||
"Drag the blue edges: content rectangle"
|
||||
"Drag the blue edges: content rectangle\n"
|
||||
"Jump markers: add, then click the target slice"
|
||||
)
|
||||
help_text.setStyleSheet("color: palette(mid);")
|
||||
box.addWidget(help_text)
|
||||
@@ -444,6 +510,7 @@ class Editor(QMainWindow):
|
||||
widget.blockSignals(True)
|
||||
widget.setValue(value)
|
||||
widget.blockSignals(False)
|
||||
self._sync_markers()
|
||||
kept = len(self.project.kept_slices())
|
||||
total = sum(p.slice_count for p in self.project.pages)
|
||||
state = "discarded" if page.discards[self.view.selected_slice] else "kept"
|
||||
@@ -469,6 +536,63 @@ class Editor(QMainWindow):
|
||||
self.view.show_page(self.project, self.index, self._preview(self.index))
|
||||
self._touched()
|
||||
|
||||
# -- markers ----------------------------------------------------------
|
||||
|
||||
def _slot_markers(self) -> list[Marker]:
|
||||
return self.project.pages[self.index].markers[self.view.selected_slice]
|
||||
|
||||
def _marker_type_changed(self, kind: str) -> None:
|
||||
self.marker_label.setEnabled(kind in LABELLED_TYPES)
|
||||
self.retarget.setEnabled(kind in JUMP_TYPES)
|
||||
|
||||
def _add_marker(self) -> None:
|
||||
kind = self.marker_type.currentText()
|
||||
label = self.marker_label.text().strip() or None
|
||||
marker = Marker(type=kind, label=label if kind in LABELLED_TYPES else None)
|
||||
self._slot_markers().append(marker)
|
||||
self.marker_label.clear()
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
if marker.is_jump:
|
||||
# A jump is useless without a target, so ask for it immediately
|
||||
# rather than leaving it to be noticed at export.
|
||||
self._pick_target()
|
||||
|
||||
def _remove_marker(self) -> None:
|
||||
row = self.marker_list.currentRow()
|
||||
markers = self._slot_markers()
|
||||
if 0 <= row < len(markers):
|
||||
markers.pop(row)
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
|
||||
def _pick_target(self) -> None:
|
||||
"""Arm click-to-pick for the selected jump marker."""
|
||||
markers = self._slot_markers()
|
||||
row = self.marker_list.currentRow()
|
||||
candidates = [i for i, m in enumerate(markers) if m.is_jump]
|
||||
if not candidates:
|
||||
return
|
||||
self._targeting = row if row in candidates else candidates[-1]
|
||||
self.view.picking = True
|
||||
self.view.setCursor(Qt.CrossCursor)
|
||||
self.statusBar().showMessage(
|
||||
"Click the slice this jump goes to — any page, PageUp/PageDown to move"
|
||||
)
|
||||
|
||||
def _target_picked(self, page: int, slot: int) -> None:
|
||||
markers = self._slot_markers()
|
||||
if 0 <= self._targeting < len(markers):
|
||||
markers[self._targeting].destination = (page, slot)
|
||||
self.view.redraw()
|
||||
self._touched()
|
||||
self.statusBar().showMessage(f"target set to p{page + 1} slice {slot + 1}", 2000)
|
||||
|
||||
def _sync_markers(self) -> None:
|
||||
self.marker_list.clear()
|
||||
for marker in self._slot_markers():
|
||||
self.marker_list.addItem(marker.describe())
|
||||
|
||||
def _metadata_changed(self) -> None:
|
||||
self.project.metadata = {
|
||||
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
|
||||
|
||||
Reference in New Issue
Block a user