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:
Esa Kataja
2026-07-29 00:05:48 +03:00
parent 15f64e4131
commit ff1cc6740e
4 changed files with 357 additions and 4 deletions
+126 -2
View File
@@ -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()