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:
+87
-82
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user