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
+88 -1
View File
@@ -67,6 +67,59 @@ class Cut:
return min(y for _, y in self.points)
# noteman's enum, verbatim. Real coupling between two repos: adding a type
# means changing both. Order is the order they appear in the editor's picker.
MARKER_TYPES = (
"rehearsal_letter",
"section_label",
"segno",
"coda",
"fine",
"repeat_start",
"repeat_end",
"volta",
"to_coda",
"ds_al_coda",
"ds_al_fine",
"dc_al_coda",
"dc_al_fine",
"generic_jump",
)
# The types that carry free text.
LABELLED_TYPES = frozenset({"rehearsal_letter", "section_label", "volta"})
# The types that send the reader elsewhere. Every one stores its target
# explicitly rather than resolving by type at read time, so the bundle is
# self-describing and a score with two codas simply works.
JUMP_TYPES = frozenset(
{"to_coda", "ds_al_coda", "ds_al_fine", "dc_al_coda", "dc_al_fine", "generic_jump"}
)
@dataclass
class Marker:
"""A semantic tag on a slice, used by noteman's navigation."""
type: str
label: str | None = None
# (page, slot) of the target slice, for jump sources. Positional like the
# slices themselves; resolved to a bundle index at export.
destination: tuple[int, int] | None = None
@property
def is_jump(self) -> bool:
return self.type in JUMP_TYPES
def describe(self) -> str:
text = self.type
if self.label:
text += f"{self.label}"
if self.destination:
text += f" → p{self.destination[0] + 1}s{self.destination[1] + 1}"
return text
@dataclass
class Page:
"""One page's decisions. `cuts` are ordered top to bottom."""
@@ -74,6 +127,8 @@ class Page:
skew: float = 0.0
cuts: list[Cut] = field(default_factory=list)
discards: list[bool] = field(default_factory=lambda: [False])
# One list per slice, parallel to `discards`.
markers: list[list[Marker]] = field(default_factory=lambda: [[]])
content_rect: tuple[float, float, float, float] | None = None
levels: tuple[int, int] | None = None
@@ -92,8 +147,12 @@ class Page:
y = cut.points[0][1]
index = sum(1 for c in self.cuts if c.points[0][1] < y)
self.cuts.insert(index, cut)
# The split slice keeps its flag on both halves.
# The split slice keeps its flag on both halves. Its markers stay with
# the upper half: a marker sits on a printed symbol, and splitting a
# slice cannot say which side that symbol landed on — leaving them put
# is at least predictable, and moving one is a click.
self.discards.insert(index, self.discards[index])
self.markers.insert(index + 1, [])
return index
def remove_cut(self, index: int) -> None:
@@ -102,6 +161,7 @@ class Page:
merged = self.discards[index] and self.discards[index + 1]
self.discards.pop(index + 1)
self.discards[index] = merged
self.markers[index].extend(self.markers.pop(index + 1))
@dataclass
@@ -175,6 +235,7 @@ class Project:
skew=detection.skew,
cuts=[Cut.straight(y / height) for y in ys],
discards=discards,
markers=[[] for _ in discards],
# Per page, not per song: scans drift, so the margin junk
# sits in a different place on each one.
content_rect=detection.content,
@@ -198,6 +259,21 @@ class Project:
"skew": page.skew,
"cuts": [[list(p) for p in cut.points] for cut in page.cuts],
"discards": page.discards,
"markers": [
[
{
"type": m.type,
**({"label": m.label} if m.label else {}),
**(
{"destination": list(m.destination)}
if m.destination
else {}
),
}
for m in slot
]
for slot in page.markers
],
"content_rect": list(page.content_rect) if page.content_rect else None,
"levels": list(page.levels) if page.levels else None,
}
@@ -222,6 +298,17 @@ class Project:
skew=page["skew"],
cuts=[Cut([tuple(p) for p in cut]) for cut in page["cuts"]],
discards=page["discards"],
markers=[
[
Marker(
type=m["type"],
label=m.get("label"),
destination=tuple(m["destination"]) if m.get("destination") else None,
)
for m in slot
]
for slot in page.get("markers", [[] for _ in page["discards"]])
],
content_rect=tuple(page["content_rect"]) if page["content_rect"] else None,
levels=tuple(page["levels"]) if page["levels"] else None,
)