"""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"{keycap(k)}" f"{v}" for k, v in SHORTCUTS ) return f"{rows}
" 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()