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:
+14
-9
@@ -44,14 +44,19 @@ off.
|
||||
one back.
|
||||
4. **Set the content rectangle.** Drag the blue edges so they hold the music and
|
||||
nothing else. This is the horizontal crop for every slice on the page.
|
||||
5. **Check black and white points.** These arrive proposed from the scan, like
|
||||
the cuts do. If the paper still shows texture, pull *White point* down; if the
|
||||
notes look grey rather than solid, pull *Black point* up. Getting this wrong
|
||||
is the one mistake you cannot see until the bundle is on the tablet — grey ink
|
||||
becomes half-transparent ink, and nothing downstream can rescue it.
|
||||
5. **Check black and white points.** Under *Page* is the scan's own histogram:
|
||||
a hump of ink on the left, a hump of paper on the right, and two handles you
|
||||
drag. Put the white handle at the foot of the paper hump and the black one at
|
||||
the foot of the ink hump. The strip underneath shows the tone that results.
|
||||
Getting this wrong is the one mistake you cannot see until the bundle is on
|
||||
the tablet — grey ink becomes half-transparent ink, and nothing downstream
|
||||
can rescue it. Arrow keys nudge the black point, Shift+arrows the white one.
|
||||
|
||||
Page Up / Page Down move between pages. Levels carry over from the previous
|
||||
page, so a consistent scan only needs setting once.
|
||||
The page rail across the top of the panel has one chip per page, with the number
|
||||
of slices on it underneath. Click to go there, or use Page Up / Page Down. A page
|
||||
whose count is far off its neighbours' is usually a page where detection missed
|
||||
a system. Levels carry over from the previous page, so a consistent scan only
|
||||
needs setting once.
|
||||
|
||||
## Bar numbers
|
||||
|
||||
@@ -163,8 +168,8 @@ substitutes for it.
|
||||
| Detection found no systems, or one giant one | The score has no bracket joining the staves; add the cuts by hand. |
|
||||
| "The PDF has changed since these cuts were made" | The file was edited or replaced under an existing project. The cuts probably no longer line up — re-cut. |
|
||||
| Export says a title is required | Fill in *Song → Title*. |
|
||||
| Slices look grey and washed out | The white point is too high. |
|
||||
| Notes have holes in them | The black point is too high. |
|
||||
| Slices look grey and washed out | The white point is too high — drag it down onto the paper hump. |
|
||||
| Notes have holes in them | The black point is too high — drag it left, off the ink hump. |
|
||||
|
||||
## The command line
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -31,7 +31,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from . import lilypond
|
||||
from . import lilypond, panel as ui
|
||||
from .detect import staff_height
|
||||
from .editor import section
|
||||
from .project import Project, Replacement, Voice
|
||||
@@ -141,7 +141,7 @@ class EngraveWindow(QDialog):
|
||||
box = QVBoxLayout(panel)
|
||||
box.setContentsMargins(0, 0, 0, 0)
|
||||
heading = QLabel(title)
|
||||
heading.setStyleSheet("font-weight: 700; color: #808080;")
|
||||
heading.setStyleSheet(ui.HEADING.replace("QToolButton", "QLabel"))
|
||||
box.addWidget(heading)
|
||||
|
||||
view = label or QLabel()
|
||||
@@ -196,7 +196,7 @@ class EngraveWindow(QDialog):
|
||||
box.addLayout(top)
|
||||
|
||||
voices_label = QLabel("Voices")
|
||||
voices_label.setStyleSheet("font-weight: 700; color: #808080;")
|
||||
voices_label.setStyleSheet(ui.HEADING.replace("QToolButton", "QLabel"))
|
||||
box.addWidget(voices_label)
|
||||
|
||||
self.voice_box = QVBoxLayout()
|
||||
@@ -211,6 +211,7 @@ class EngraveWindow(QDialog):
|
||||
remove = QPushButton("Remove last voice")
|
||||
remove.clicked.connect(self._remove_voice)
|
||||
render = QPushButton("Render (Ctrl+↵)")
|
||||
render.setProperty("role", "primary")
|
||||
render.clicked.connect(self.render)
|
||||
drop = QPushButton("Discard replacement")
|
||||
drop.clicked.connect(self._discard)
|
||||
@@ -229,7 +230,7 @@ class EngraveWindow(QDialog):
|
||||
self.generated = QPlainTextEdit()
|
||||
self.generated.setReadOnly(True)
|
||||
self.generated.setMaximumHeight(220)
|
||||
self.generated.setStyleSheet("color: #808080;")
|
||||
self.generated.setStyleSheet(f"color: {ui.GRAPHITE};")
|
||||
raw.addWidget(self.generated)
|
||||
self._refresh_source()
|
||||
return panel
|
||||
@@ -287,13 +288,13 @@ class EngraveWindow(QDialog):
|
||||
|
||||
def render(self) -> None:
|
||||
source = self._source()
|
||||
self.status.setStyleSheet("color: #808080;")
|
||||
self.status.setStyleSheet(f"color: {ui.GRAPHITE};")
|
||||
self.status.setText("rendering…")
|
||||
self.repaint()
|
||||
try:
|
||||
image = lilypond.render(source)
|
||||
except lilypond.LilypondError as error:
|
||||
self.status.setStyleSheet("color: #c0392b;")
|
||||
self.status.setStyleSheet(f"color: {ui.PROOF};")
|
||||
self.status.setText(str(error)[-600:])
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Look and feel for the editor: palette, chrome, and two custom controls.
|
||||
|
||||
The page already speaks a colour language — red cut lines, a blue content
|
||||
rectangle, purple marker chips, amber for a re-engraved system. The panel
|
||||
speaks the same one, from the same constants, so a colour means one thing in
|
||||
this window rather than two. Everything else is neutral, and the chrome is
|
||||
dark for the reason photo editors are: the scanned page should be the
|
||||
brightest object on screen, because it is the thing being judged.
|
||||
|
||||
Numbers are set in mono and prose is not. This is a measuring tool; skew,
|
||||
levels, bar and page numbers are measurements, and they line up in a column
|
||||
when they are monospaced.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QRectF, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QLinearGradient, QPainter, QPen
|
||||
from PySide6.QtWidgets import QGridLayout, QSizePolicy, QToolButton, QWidget
|
||||
|
||||
INK = "#14161a" # window chrome
|
||||
DESK = "#1d2026" # panel surface
|
||||
RAISED = "#262a33" # inputs, chips
|
||||
RULE = "#333844" # hairlines
|
||||
GRAPHITE = "#8b93a3" # secondary text and section headings
|
||||
PAPER = "#e6e9f0" # primary text, borrowed from the scan
|
||||
PROOF = "#dc2828" # cuts
|
||||
CROP = "#288cdc" # content rectangle, primary action
|
||||
MARK = "#9638be" # markers
|
||||
PLATE = "#c87800" # re-engraved
|
||||
|
||||
MONO = '"JetBrains Mono", "DejaVu Sans Mono", "Menlo", monospace'
|
||||
|
||||
STYLESHEET = f"""
|
||||
QMainWindow, QDialog {{ background: {INK}; }}
|
||||
QWidget {{ color: {PAPER}; font-size: 13px; }}
|
||||
QScrollArea, QScrollArea > QWidget > QWidget {{ background: {DESK}; border: none; }}
|
||||
QSplitter::handle {{ background: {RULE}; width: 1px; }}
|
||||
QGraphicsView {{ background: {INK}; border: none; }}
|
||||
|
||||
QLabel {{ background: transparent; }}
|
||||
QLabel[role="hint"] {{ color: {GRAPHITE}; font-size: 12px; }}
|
||||
QLabel[role="reading"] {{ color: {PAPER}; font-family: {MONO}; font-size: 12px; }}
|
||||
|
||||
QLineEdit, QComboBox, QDoubleSpinBox, QListWidget {{
|
||||
background: {RAISED}; border: 1px solid {RULE}; border-radius: 3px;
|
||||
padding: 4px 6px; selection-background-color: {CROP};
|
||||
}}
|
||||
QLineEdit:focus, QComboBox:focus, QDoubleSpinBox:focus, QListWidget:focus {{
|
||||
border-color: {CROP};
|
||||
}}
|
||||
QLineEdit[role="number"], QDoubleSpinBox {{ font-family: {MONO}; }}
|
||||
QComboBox::drop-down {{ border: none; width: 18px; }}
|
||||
QDoubleSpinBox::up-button, QDoubleSpinBox::down-button {{
|
||||
background: {RULE}; border: none; width: 16px;
|
||||
}}
|
||||
QDoubleSpinBox::up-arrow, QDoubleSpinBox::down-arrow {{ width: 7px; height: 7px; }}
|
||||
QComboBox QAbstractItemView {{
|
||||
background: {RAISED}; border: 1px solid {RULE}; selection-background-color: {CROP};
|
||||
}}
|
||||
QListWidget::item {{ padding: 2px 4px; }}
|
||||
QListWidget::item:selected {{ background: {MARK}; }}
|
||||
|
||||
QPushButton {{
|
||||
background: {RAISED}; border: 1px solid {RULE}; border-radius: 3px;
|
||||
padding: 6px 12px;
|
||||
}}
|
||||
QPushButton:hover {{ border-color: {GRAPHITE}; }}
|
||||
QPushButton:pressed {{ background: {RULE}; }}
|
||||
QPushButton:disabled {{ color: {RULE}; }}
|
||||
QPushButton:focus {{ border-color: {CROP}; }}
|
||||
QPushButton[role="primary"] {{
|
||||
background: {CROP}; border-color: {CROP}; color: #ffffff;
|
||||
font-weight: 600; padding: 9px 12px;
|
||||
}}
|
||||
QPushButton[role="primary"]:hover {{ background: #3a9de8; }}
|
||||
|
||||
QScrollBar:vertical {{ background: {DESK}; width: 10px; margin: 0; }}
|
||||
QScrollBar:horizontal {{ background: {DESK}; height: 10px; margin: 0; }}
|
||||
QScrollBar::handle {{ background: {RULE}; border-radius: 5px; min-height: 30px; }}
|
||||
QScrollBar::handle:hover {{ background: {GRAPHITE}; }}
|
||||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; }}
|
||||
QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }}
|
||||
|
||||
QCheckBox {{ spacing: 7px; }}
|
||||
QCheckBox::indicator {{
|
||||
width: 14px; height: 14px; border: 1px solid {RULE};
|
||||
border-radius: 3px; background: {RAISED};
|
||||
}}
|
||||
QCheckBox::indicator:checked {{ background: {CROP}; border-color: {CROP}; }}
|
||||
QCheckBox::indicator:hover {{ border-color: {GRAPHITE}; }}
|
||||
|
||||
QStatusBar {{ background: {INK}; color: {GRAPHITE}; }}
|
||||
QToolTip {{ background: {RAISED}; color: {PAPER}; border: 1px solid {RULE}; padding: 4px; }}
|
||||
"""
|
||||
|
||||
HEADING = f"""
|
||||
QToolButton {{
|
||||
border: none; background: transparent; text-align: left;
|
||||
color: {GRAPHITE}; font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 1.4px; padding: 10px 0 5px 0;
|
||||
}}
|
||||
QToolButton:hover {{ color: {PAPER}; }}
|
||||
"""
|
||||
|
||||
|
||||
def mono(size: int = 12, weight: int = QFont.Normal) -> QFont:
|
||||
font = QFont("JetBrains Mono", size, weight)
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
return font
|
||||
|
||||
|
||||
class Rule(QWidget):
|
||||
"""A hairline between sections. Structure the eye can follow without boxes."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setFixedHeight(1)
|
||||
self.setStyleSheet(f"background: {RULE};")
|
||||
|
||||
|
||||
class PageRail(QWidget):
|
||||
"""One chip per page, each carrying its slice count.
|
||||
|
||||
Replaces a ◀ 1/6 ▶ stepper. The song *is* a sequence of pages with a
|
||||
number of systems on each, and seeing that sequence is how you notice the
|
||||
page where detection found one slice where the others found three — the
|
||||
failure this tool actually produces.
|
||||
"""
|
||||
|
||||
picked = Signal(int)
|
||||
|
||||
COLUMNS = 7 # ponytail: fixed, sized for the panel's minimum width
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.buttons: list[QToolButton] = []
|
||||
self.grid = QGridLayout(self)
|
||||
self.grid.setContentsMargins(0, 0, 0, 0)
|
||||
self.grid.setSpacing(4)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
def build(self, counts: list[int], current: int) -> None:
|
||||
while self.buttons:
|
||||
chip = self.buttons.pop()
|
||||
self.grid.removeWidget(chip)
|
||||
chip.deleteLater()
|
||||
for i, count in enumerate(counts):
|
||||
chip = QToolButton()
|
||||
chip.setText(f"{i + 1}\n{count}")
|
||||
chip.setFont(mono(11))
|
||||
chip.setFixedSize(34, 38)
|
||||
chip.setCursor(Qt.PointingHandCursor)
|
||||
chip.setToolTip(f"Page {i + 1} — {count} slice{'s' if count != 1 else ''}")
|
||||
chip.setStyleSheet(self._chip_style(i == current))
|
||||
chip.clicked.connect(lambda _=False, n=i: self.picked.emit(n))
|
||||
self.grid.addWidget(chip, i // self.COLUMNS, i % self.COLUMNS)
|
||||
self.buttons.append(chip)
|
||||
self.grid.setColumnStretch(self.COLUMNS, 1)
|
||||
|
||||
@staticmethod
|
||||
def _chip_style(current: bool) -> str:
|
||||
return (
|
||||
f"QToolButton {{ background: {'#12405f' if current else RULE};"
|
||||
f" border: 1px solid {CROP if current else '#454b59'}; border-radius: 3px;"
|
||||
f" color: {PAPER if current else GRAPHITE}; }}"
|
||||
f"QToolButton:hover {{ border-color: {PAPER}; color: {PAPER}; }}"
|
||||
)
|
||||
|
||||
|
||||
class LevelsBar(QWidget):
|
||||
"""The scan's own ink distribution, with the black and white points on it.
|
||||
|
||||
The signature control of this window, and the one place worth spending
|
||||
pixels: getting levels wrong is the single mistake that cannot be seen
|
||||
until the bundle is on the tablet, and two anonymous sliders give no reason
|
||||
to move either one. Here the paper hump and the ink hump are visible, the
|
||||
handles sit on them, and the strip underneath shows the tone ramp that
|
||||
results — grey ink looks grey right there.
|
||||
"""
|
||||
|
||||
changed = Signal(int, int)
|
||||
|
||||
RAMP = 14 # height of the tone strip under the histogram
|
||||
GRAB = 7
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.hist = np.zeros(256)
|
||||
self.black, self.white = 0, 255
|
||||
self._drag: str | None = None
|
||||
self.setMinimumHeight(96)
|
||||
self.setMouseTracking(True)
|
||||
self.setCursor(Qt.SizeHorCursor)
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
|
||||
def set_page(self, gray: np.ndarray) -> None:
|
||||
counts = np.bincount(gray.ravel(), minlength=256).astype(float)
|
||||
# Square root, clipped to the tallest bin that is not the paper spike.
|
||||
# Linear buries the ink hump under a spike two orders of magnitude
|
||||
# taller; log flattens everything into one slab. This keeps both humps
|
||||
# shaped like humps, which is the whole point of showing them.
|
||||
scale = np.sqrt(counts)
|
||||
ceiling = np.partition(scale, -3)[-3] or scale.max() or 1.0
|
||||
self.hist = np.clip(scale / ceiling, 0, 1)
|
||||
self.update()
|
||||
|
||||
def set_levels(self, black: int, white: int) -> None:
|
||||
self.black, self.white = black, white
|
||||
self.update()
|
||||
|
||||
# -- painting ---------------------------------------------------------
|
||||
|
||||
def _x(self, value: int) -> float:
|
||||
return value / 255 * (self.width() - 1)
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
w, h = self.width(), self.height()
|
||||
top = h - self.RAMP - 10
|
||||
|
||||
p.fillRect(0, 0, w, top, QColor(INK))
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor("#5f7d99"))
|
||||
for value in range(256):
|
||||
bar = self.hist[value] * (top - 4)
|
||||
p.drawRect(QRectF(self._x(value), top - bar, max(w / 256, 1.0), bar))
|
||||
|
||||
# What is clipped away, dimmed at both ends.
|
||||
p.setBrush(QColor(20, 22, 26, 170))
|
||||
p.drawRect(QRectF(0, 0, self._x(self.black), top))
|
||||
p.drawRect(QRectF(self._x(self.white), 0, w - self._x(self.white), top))
|
||||
|
||||
ramp = QLinearGradient(self._x(self.black), 0, self._x(self.white), 0)
|
||||
ramp.setColorAt(0.0, QColor(0, 0, 0))
|
||||
ramp.setColorAt(1.0, QColor(255, 255, 255))
|
||||
p.setBrush(QBrush(ramp))
|
||||
p.drawRect(QRectF(0, h - self.RAMP, w, self.RAMP))
|
||||
p.fillRect(QRectF(0, h - self.RAMP, self._x(self.black), self.RAMP), QColor(0, 0, 0))
|
||||
p.fillRect(
|
||||
QRectF(self._x(self.white), h - self.RAMP, w - self._x(self.white), self.RAMP),
|
||||
QColor(255, 255, 255),
|
||||
)
|
||||
|
||||
for value, colour in ((self.black, QColor(PAPER)), (self.white, QColor(CROP))):
|
||||
x = self._x(value)
|
||||
p.setPen(QPen(colour, 2))
|
||||
p.drawLine(QRectF(x, 0, 0, h).topLeft(), QRectF(x, 0, 0, h).bottomLeft())
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(colour)
|
||||
p.drawEllipse(QRectF(x - 4, top + 1, 8, 8))
|
||||
|
||||
# Readouts inside the histogram, not on the tone strip: white text on
|
||||
# the pale end of that ramp is unreadable exactly when the white point
|
||||
# is where you most need to read it.
|
||||
p.setFont(mono(10))
|
||||
p.setPen(QColor(PAPER))
|
||||
p.drawText(
|
||||
QRectF(5, 2, w - 10, 16), Qt.AlignLeft | Qt.AlignVCenter, f"black {self.black}"
|
||||
)
|
||||
p.setPen(QColor(CROP))
|
||||
p.drawText(
|
||||
QRectF(5, 2, w - 10, 16), Qt.AlignRight | Qt.AlignVCenter, f"white {self.white}"
|
||||
)
|
||||
|
||||
# -- interaction ------------------------------------------------------
|
||||
|
||||
def _nearest(self, x: float) -> str:
|
||||
return "black" if abs(x - self._x(self.black)) <= abs(x - self._x(self.white)) else "white"
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
self._drag = self._nearest(event.position().x())
|
||||
self.mouseMoveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
if not self._drag:
|
||||
return
|
||||
value = int(round(event.position().x() / max(self.width() - 1, 1) * 255))
|
||||
value = min(255, max(0, value))
|
||||
if self._drag == "black":
|
||||
self.black = min(value, self.white - 1)
|
||||
else:
|
||||
self.white = max(value, self.black + 1)
|
||||
self.update()
|
||||
self.changed.emit(self.black, self.white)
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None:
|
||||
self._drag = None
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
step = {Qt.Key_Left: -1, Qt.Key_Right: 1}.get(event.key())
|
||||
if step is None:
|
||||
return super().keyPressEvent(event)
|
||||
# Shift picks the white point, so the whole control is reachable from
|
||||
# the keyboard without a second focus stop.
|
||||
if event.modifiers() & Qt.ShiftModifier:
|
||||
self.white = min(255, max(self.black + 1, self.white + step))
|
||||
else:
|
||||
self.black = max(0, min(self.white - 1, self.black + step))
|
||||
self.update()
|
||||
self.changed.emit(self.black, self.white)
|
||||
|
||||
|
||||
def keycap(text: str) -> str:
|
||||
"""A key name as inline HTML, for the shortcut list."""
|
||||
return (
|
||||
f'<span style="font-family:{MONO}; background:{RAISED}; color:{PAPER};'
|
||||
f' border:1px solid {RULE}; padding:1px 4px;">{text}</span>'
|
||||
)
|
||||
|
||||
|
||||
SHORTCUTS = [
|
||||
("Double-click", "add a cut"),
|
||||
("Drag", "move a cut"),
|
||||
("Ctrl-click", "add a vertex"),
|
||||
("Right-click", "delete a cut or vertex"),
|
||||
("D", "discard the selected slice"),
|
||||
("Shift-double-click", "re-engrave a slice"),
|
||||
("PgUp / PgDn", "change page"),
|
||||
("Ctrl+S", "save"),
|
||||
]
|
||||
|
||||
|
||||
def shortcut_html() -> str:
|
||||
rows = "".join(
|
||||
f"<tr><td style='padding:2px 10px 2px 0'>{keycap(k)}</td>"
|
||||
f"<td style='color:{GRAPHITE}'>{v}</td></tr>"
|
||||
for k, v in SHORTCUTS
|
||||
)
|
||||
return f"<table cellspacing='0'>{rows}</table>"
|
||||
|
||||
|
||||
def demo() -> None:
|
||||
"""Self-check: the histogram and handles behave without a real page."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
bar = LevelsBar()
|
||||
bar.resize(300, 96)
|
||||
bar.set_page(np.array([[10, 10, 250, 250, 250]], np.uint8))
|
||||
assert bar.hist[250] == 1.0 and 0 < bar.hist[10] <= 1.0, bar.hist[[10, 250]]
|
||||
assert bar.hist[128] == 0.0, "an empty bin draws nothing"
|
||||
bar.set_levels(40, 200)
|
||||
seen: list[tuple[int, int]] = []
|
||||
bar.changed.connect(lambda b, w: seen.append((b, w)))
|
||||
bar._drag = "white"
|
||||
bar.white = 30 # a drag past the black point must not invert the ramp
|
||||
bar.set_levels(40, 200)
|
||||
bar.keyPressEvent(_Key(Qt.Key_Left, Qt.NoModifier))
|
||||
assert bar.black == 39 and seen[-1] == (39, 200), (bar.black, seen)
|
||||
bar.keyPressEvent(_Key(Qt.Key_Right, Qt.ShiftModifier))
|
||||
assert bar.white == 201, bar.white
|
||||
bar.grab() # paints; raises if the painter path is wrong
|
||||
assert PageRail()._chip_style(True) != PageRail._chip_style(False)
|
||||
del app
|
||||
print("ok")
|
||||
|
||||
|
||||
class _Key:
|
||||
def __init__(self, key, modifiers):
|
||||
self._key, self._mod = key, modifiers
|
||||
|
||||
def key(self):
|
||||
return self._key
|
||||
|
||||
def modifiers(self):
|
||||
return self._mod
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
demo()
|
||||
@@ -113,7 +113,11 @@ class Marker:
|
||||
return self.type in JUMP_TYPES
|
||||
|
||||
def describe(self) -> str:
|
||||
text = self.type
|
||||
"""For the marker list and the badge drawn on the page — never the wire.
|
||||
|
||||
A musician reads "D.S. al coda" off the score, not `ds_al_coda`.
|
||||
"""
|
||||
text = self.type.replace("_", " ").capitalize()
|
||||
if self.label:
|
||||
text += f" “{self.label}”"
|
||||
if self.destination:
|
||||
|
||||
@@ -77,9 +77,14 @@ def main() -> int:
|
||||
# Skew and levels reach the model and re-render without raising.
|
||||
editor.skew.setValue(-1.4)
|
||||
assert abs(page.skew + 1.4) < 1e-6
|
||||
editor.black.setValue(40)
|
||||
editor.white.setValue(210)
|
||||
# The levels bar carries the page's own histogram and drives the model
|
||||
# directly — there are no sliders behind it to keep in step.
|
||||
assert editor.levels.hist.sum() > 0, "the histogram should hold the scan"
|
||||
editor.levels.set_levels(40, 210)
|
||||
editor._levels_changed(40, 210)
|
||||
assert project.page_levels(0) == (40, 210)
|
||||
editor.rail.picked.emit(0) # the page rail navigates
|
||||
assert editor.index == 0 and len(editor.rail.buttons) == len(project.pages)
|
||||
|
||||
# Metadata.
|
||||
editor.metadata["title"].setText("Ketun joululaulu")
|
||||
|
||||
Reference in New Issue
Block a user