diff --git a/docs/guide.md b/docs/guide.md index 80aa830..02fabf6 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -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 diff --git a/noteman_slicer/editor.py b/noteman_slicer/editor.py index 0126895..40ac810 100644 --- a/noteman_slicer/editor.py +++ b/noteman_slicer/editor.py @@ -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 diff --git a/noteman_slicer/engrave.py b/noteman_slicer/engrave.py index f06d8eb..7b61a67 100644 --- a/noteman_slicer/engrave.py +++ b/noteman_slicer/engrave.py @@ -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 diff --git a/noteman_slicer/panel.py b/noteman_slicer/panel.py new file mode 100644 index 0000000..175fd33 --- /dev/null +++ b/noteman_slicer/panel.py @@ -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'{text}' + ) + + +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"