Files
noteman-slicer/noteman_slicer/panel.py
T
Esa Kataja 68e41c2470 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.
2026-07-29 15:39:24 +03:00

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()