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.
374 lines
14 KiB
Python
374 lines
14 KiB
Python
"""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()
|