Give the editor a visual identity and a real levels control

The panel now uses the same colours the page is drawn with, on dark
chrome so the scan is the brightest thing on screen. Levels move from two
anonymous sliders to the scan's own histogram with draggable black and
white points and a tone strip, since getting them wrong is the one
mistake that only shows up on the tablet. A page rail replaces the
stepper and carries each page's slice count; export is pinned below the
scroll instead of below the fold; marker types read as prose.
This commit is contained in:
Esa Kataja
2026-07-29 15:39:24 +03:00
parent cdf37302d7
commit 68e41c2470
6 changed files with 493 additions and 100 deletions
+87 -82
View File
@@ -44,14 +44,13 @@ from PySide6.QtWidgets import (
QPushButton,
QScrollArea,
QSizePolicy,
QSlider,
QSplitter,
QToolButton,
QVBoxLayout,
QWidget,
)
from . import bundle, lilypond
from . import bundle, lilypond, panel as ui
from .bundle import METADATA_FIELDS, NUMERIC_FIELDS
from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster
@@ -89,28 +88,19 @@ def section(title: str, box: QVBoxLayout, *, expanded: bool = True) -> QVBoxLayo
A disclosure arrow, not a checkable QGroupBox: a checkbox in a group
header reads as "enable this feature" rather than "expand this", and a
column of framed boxes with checkboxes is hard to scan.
column of framed boxes with checkboxes is hard to scan. A hairline above
each one does the separating that the frames used to.
"""
box.addWidget(ui.Rule())
header = QToolButton()
header.setText(title)
header.setText(title.upper())
header.setCheckable(True)
header.setChecked(expanded)
header.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow)
header.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
header.setAutoRaise(True)
header.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Bold and greyed: a mid grey reads as a heading against both light and
# dark palettes without needing a second stylesheet.
header.setStyleSheet(
"QToolButton {"
" border: none;"
" font-weight: 700;"
" color: #808080;"
" padding: 7px 0 4px 0;"
" text-align: left;"
"}"
"QToolButton:hover { color: #a0a0a0; }"
)
header.setStyleSheet(ui.HEADING)
body = QWidget()
layout = QVBoxLayout(body)
@@ -472,20 +462,16 @@ class Editor(QMainWindow):
def _panel(self) -> QWidget:
inner = QWidget()
box = QVBoxLayout(inner)
panel = QScrollArea()
panel.setWidget(inner)
panel.setWidgetResizable(True)
panel.setMinimumWidth(260)
box.setContentsMargins(14, 12, 14, 14)
box.setSpacing(0)
scroller = QScrollArea()
scroller.setWidget(inner)
scroller.setWidgetResizable(True)
scroller.setMinimumWidth(280)
nav = QHBoxLayout()
self.page_label = QLabel()
prev, nxt = QPushButton(""), QPushButton("")
prev.clicked.connect(lambda: self._load_page(self.index - 1))
nxt.clicked.connect(lambda: self._load_page(self.index + 1))
nav.addWidget(prev)
nav.addWidget(self.page_label, 1)
nav.addWidget(nxt)
box.addLayout(nav)
self.rail = ui.PageRail()
self.rail.picked.connect(self._load_page)
box.addWidget(self.rail)
page_section = section("Page", box)
form = QFormLayout()
@@ -498,23 +484,24 @@ class Editor(QMainWindow):
self.skew.valueChanged.connect(self._skew_changed)
form.addRow("Skew", self.skew)
self.black = QSlider(Qt.Horizontal)
self.black.setRange(0, 255)
self.white = QSlider(Qt.Horizontal)
self.white.setRange(0, 255)
self.white.setValue(255)
for s in (self.black, self.white):
s.valueChanged.connect(self._levels_changed)
form.addRow("Black point", self.black)
form.addRow("White point", self.white)
self.levels = ui.LevelsBar()
self.levels.changed.connect(self._levels_changed)
self.levels.setToolTip(
"Drag the white dot to the foot of the paper hump and the light one "
"to the foot of the ink hump. The strip below is the resulting tone."
)
page_section.addWidget(self.levels)
discard = QPushButton("Toggle discard (D)")
buttons = QHBoxLayout()
discard = QPushButton("Discard slice")
discard.setToolTip("Or press D. Discarded slices never reach the tablet.")
discard.clicked.connect(self.view.toggle_discard)
form.addRow(discard)
reset = QPushButton("Reset content rectangle")
reset.setToolTip("Back to the rectangle detection proposed for this page")
reset = QPushButton("Reset crop")
reset.setToolTip("Back to the content rectangle detection proposed for this page")
reset.clicked.connect(self._reset_rect)
form.addRow(reset)
buttons.addWidget(discard)
buttons.addWidget(reset)
page_section.addLayout(buttons)
slice_layout = section("This slice", box)
slice_form = QFormLayout()
@@ -524,6 +511,7 @@ class Editor(QMainWindow):
# able to say "from bar 33" about either.
self.bar = QLineEdit()
self.bar.setValidator(QIntValidator(1, 9999, self.bar))
self.bar.setProperty("role", "number")
self.bar.setFixedWidth(90)
self.bar.setPlaceholderText("none")
self.bar.setToolTip("The measure this slice starts at, as printed in the score")
@@ -537,8 +525,13 @@ class Editor(QMainWindow):
add_row = QHBoxLayout()
self.marker_type = QComboBox()
self.marker_type.addItems(MARKER_TYPES)
self.marker_type.currentTextChanged.connect(self._marker_type_changed)
# Shown as prose, sent as the enum: "D.S. al coda" is what a musician
# reads off the page, `ds_al_coda` is what noteman parses.
for kind in MARKER_TYPES:
self.marker_type.addItem(kind.replace("_", " ").capitalize(), kind)
self.marker_type.currentIndexChanged.connect(
lambda: self._marker_type_changed(self.marker_type.currentData())
)
add_row.addWidget(self.marker_type, 1)
self.marker_label = QLineEdit()
self.marker_label.setPlaceholderText("label")
@@ -556,7 +549,7 @@ class Editor(QMainWindow):
for button in (add, remove, self.retarget):
button_row.addWidget(button)
marker_layout.addLayout(button_row)
self._marker_type_changed(self.marker_type.currentText())
self._marker_type_changed(self.marker_type.currentData())
# Optional feature: without LilyPond installed the pane never appears,
# and nothing else about the tool changes. Collapsed by default — most
@@ -570,7 +563,7 @@ class Editor(QMainWindow):
ly_layout.addWidget(open_engrave)
self.ly_status = QLabel()
self.ly_status.setWordWrap(True)
self.ly_status.setStyleSheet("color: #808080;")
self.ly_status.setProperty("role", "hint")
ly_layout.addWidget(self.ly_status)
meta_layout = section("Song", box)
@@ -589,6 +582,7 @@ class Editor(QMainWindow):
# metronome where "Andante" cannot.
edit.setValidator(QIntValidator(20, 400, edit))
edit.setPlaceholderText("BPM")
edit.setProperty("role", "number")
edit.setFixedWidth(90)
meta_form.addRow(f"{field.replace('_', ' ').title()}{' *' if required else ''}", edit)
@@ -597,27 +591,39 @@ class Editor(QMainWindow):
self.optimise.clicked.connect(self._optimise_pdf)
meta_layout.addWidget(self.optimise)
self.summary = QLabel()
self.summary.setWordWrap(True)
box.addWidget(self.summary)
export = QPushButton("Export bundle…")
export.clicked.connect(self._export)
box.addWidget(export)
# Open by default: the first thing a new user needs is to know that a
# double-click adds a cut, and a collapsed section does not tell them.
keys_layout = section("Keys and mouse", box)
keys = QLabel(ui.shortcut_html())
keys.setTextFormat(Qt.RichText)
keys_layout.addWidget(keys)
box.addStretch(1)
help_text = QLabel(
"Double-click: add cut\n"
"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\n"
"Jump markers: add, then click the target slice\n"
"Shift-double-click a slice: re-engrave it"
)
help_text.setStyleSheet("color: palette(mid);")
box.addWidget(help_text)
return panel
# Where you are and the way out, pinned below the scroll. Export is the
# one thing that must never be hidden by however far the panel is
# scrolled, and the count beside it is what says whether it is ready.
footer = QWidget()
column = QVBoxLayout(footer)
column.setContentsMargins(14, 0, 14, 12)
column.addWidget(ui.Rule())
self.summary = QLabel()
self.summary.setWordWrap(True)
self.summary.setProperty("role", "reading")
self.summary.setContentsMargins(0, 10, 0, 6)
column.addWidget(self.summary)
export = QPushButton("Export bundle…")
export.setProperty("role", "primary")
export.clicked.connect(self._export)
column.addWidget(export)
holder = QWidget()
stack = QVBoxLayout(holder)
stack.setContentsMargins(0, 0, 0, 0)
stack.setSpacing(0)
stack.addWidget(scroller, 1)
stack.addWidget(footer)
holder.setMinimumWidth(300)
return holder
def _shortcuts(self) -> None:
for key, slot in (
@@ -657,20 +663,18 @@ class Editor(QMainWindow):
return
self.index = index
self.view.show_page(self.project, index, self._preview(index))
# The histogram is of the raw scan, not the levelled preview: it has to
# keep showing where the ink is while you drag the points over it.
self.levels.set_page(self._raster(index))
self._sync()
def _sync(self) -> None:
page = self.project.pages[self.index]
self.page_label.setText(f"Page {self.index + 1} / {len(self.project.pages)}")
for widget, value in ((self.skew, page.skew),):
widget.blockSignals(True)
widget.setValue(value)
widget.blockSignals(False)
black, white = self.project.page_levels(self.index)
for widget, value in ((self.black, black), (self.white, white)):
widget.blockSignals(True)
widget.setValue(value)
widget.blockSignals(False)
self.rail.build([p.slice_count for p in self.project.pages], self.index)
self.skew.blockSignals(True)
self.skew.setValue(page.skew)
self.skew.blockSignals(False)
self.levels.set_levels(*self.project.page_levels(self.index))
bar = page.bars[self.view.selected_slice]
self.bar.blockSignals(True)
self.bar.setText("" if bar is None else str(bar))
@@ -681,9 +685,9 @@ class Editor(QMainWindow):
total = sum(p.slice_count for p in self.project.pages)
state = "discarded" if page.discards[self.view.selected_slice] else "kept"
self.summary.setText(
f"{page.slice_count} slices on this page · slice "
f"{self.view.selected_slice + 1} is {state}\n"
f"{kept} of {total} slices kept in the song"
f"page {self.index + 1}/{len(self.project.pages)} · "
f"slice {self.view.selected_slice + 1}/{page.slice_count} is {state}\n"
f"{kept} of {total} slices ship"
)
# -- edits ------------------------------------------------------------
@@ -702,8 +706,8 @@ class Editor(QMainWindow):
self.view.show_page(self.project, self.index, self._preview(self.index))
self._touched()
def _levels_changed(self) -> None:
self.project.pages[self.index].levels = (self.black.value(), self.white.value())
def _levels_changed(self, black: int, white: int) -> None:
self.project.pages[self.index].levels = (black, white)
self.view.show_page(self.project, self.index, self._preview(self.index))
self._touched()
@@ -717,7 +721,7 @@ class Editor(QMainWindow):
self.retarget.setEnabled(kind in JUMP_TYPES)
def _add_marker(self) -> None:
kind = self.marker_type.currentText()
kind = self.marker_type.currentData()
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)
@@ -913,6 +917,7 @@ class Editor(QMainWindow):
def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
app = QApplication(sys.argv[:1])
app.setStyleSheet(ui.STYLESHEET)
source = open_source(pdf, source_type)
# An exported project is spent: this opens a fresh session from detection