ICRA/app/qt/main_window.py

590 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Main PySide6 window emulating the legacy Tk interface."""
from __future__ import annotations
from pathlib import Path
from typing import Callable, Dict, List, Tuple
from PySide6 import QtCore, QtGui, QtWidgets
from app.logic import SUPPORTED_IMAGE_EXTENSIONS
from .image_processor import QtImageProcessor
DEFAULT_COLOUR = "#763e92"
PRESET_COLOURS: List[Tuple[str, str]] = [
("Red", "#ff3b30"),
("Orange", "#ff9500"),
("Yellow", "#ffd60a"),
("Green", "#34c759"),
("Teal", "#5ac8fa"),
("Blue", "#0a84ff"),
("Violet", "#af52de"),
("Magenta", "#ff2d55"),
("White", "#ffffff"),
("Grey", "#8e8e93"),
("Black", "#000000"),
]
SLIDER_SPECS: List[Tuple[str, str, int, int, int]] = [
("Hue min", "hue_min", 0, 360, 0),
("Hue max", "hue_max", 0, 360, 360),
("Sat min", "sat_min", 0, 100, 25),
("Value min", "val_min", 0, 100, 15),
("Value max", "val_max", 0, 100, 100),
("Overlay alpha", "alpha", 0, 255, 120),
]
class ToolbarButton(QtWidgets.QPushButton):
"""Rounded toolbar button inspired by the legacy design."""
def __init__(self, icon_text: str, label: str, callback: Callable[[], None], parent: QtWidgets.QWidget | None = None):
super().__init__(f"{icon_text} {label}", parent)
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedHeight(34)
self.setStyleSheet(
"""
QPushButton {
padding: 8px 16px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(255, 255, 255, 0.04);
color: #f7f7fb;
font-weight: 600;
}
QPushButton:hover {
background-color: rgba(255, 255, 255, 0.12);
}
QPushButton:pressed {
background-color: rgba(255, 255, 255, 0.18);
}
"""
)
self.clicked.connect(callback)
class ColourSwatch(QtWidgets.QPushButton):
"""Clickable palette swatch."""
def __init__(self, name: str, hex_code: str, callback: Callable[[str, str], None], parent: QtWidgets.QWidget | None = None):
super().__init__(parent)
self.hex_code = hex_code
self.name = name
self.setToolTip(f"{name} ({hex_code})")
self.setCursor(QtCore.Qt.PointingHandCursor)
self.setFixedSize(28, 28)
self.setStyleSheet(
f"""
QPushButton {{
background-color: {hex_code};
border: 2px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
}}
QPushButton:hover {{
border-color: rgba(255, 255, 255, 0.45);
}}
"""
)
self.clicked.connect(lambda: callback(hex_code, name))
class SliderControl(QtWidgets.QWidget):
"""Slider with header and live value label."""
value_changed = QtCore.Signal(str, int)
def __init__(self, title: str, key: str, minimum: int, maximum: int, initial: int):
super().__init__()
self.key = key
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
header = QtWidgets.QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
title_label = QtWidgets.QLabel(title)
title_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 500;")
header.addWidget(title_label)
header.addStretch(1)
self.value_label = QtWidgets.QLabel(str(initial))
self.value_label.setStyleSheet("color: rgba(255,255,255,0.6);")
header.addWidget(self.value_label)
layout.addLayout(header)
self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
self.slider.setRange(minimum, maximum)
self.slider.setValue(initial)
self.slider.setCursor(QtCore.Qt.PointingHandCursor)
self.slider.setStyleSheet(
"""
QSlider::groove:horizontal {
border: 1px solid rgba(255,255,255,0.08);
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #9a4dff;
border: 1px solid rgba(255,255,255,0.2);
width: 14px;
margin: -5px 0;
border-radius: 7px;
}
"""
)
self.slider.valueChanged.connect(self._sync_value)
layout.addWidget(self.slider)
def _sync_value(self, value: int) -> None:
self.value_label.setText(str(value))
self.value_changed.emit(self.key, value)
def set_value(self, value: int) -> None:
self.slider.blockSignals(True)
self.slider.setValue(value)
self.slider.blockSignals(False)
self.value_label.setText(str(value))
class ImageView(QtWidgets.QLabel):
"""Aspect-ratio aware image view."""
def __init__(self) -> None:
super().__init__()
self.setAlignment(QtCore.Qt.AlignCenter)
self._pixmap: QtGui.QPixmap | None = None
def set_image(self, pixmap: QtGui.QPixmap | None) -> None:
self._pixmap = pixmap
self._rescale()
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
super().resizeEvent(event)
self._rescale()
def _rescale(self) -> None:
if self._pixmap is None:
self.setPixmap(QtGui.QPixmap())
self.setText("<No image loaded>")
self.setStyleSheet("color: rgba(255,255,255,0.45); font-size: 14px;")
return
target = self._pixmap.scaled(
self.size(),
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation,
)
self.setPixmap(target)
self.setStyleSheet("")
class TitleBar(QtWidgets.QWidget):
"""Custom title bar with native window controls."""
HEIGHT = 40
def __init__(self, window: "MainWindow") -> None:
super().__init__(window)
self.window = window
self.setFixedHeight(self.HEIGHT)
self.setCursor(QtCore.Qt.ArrowCursor)
self.setAutoFillBackground(True)
palette = self.palette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#16171d"))
self.setPalette(palette)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(8)
logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png"
if logo_path.exists():
pixmap = QtGui.QPixmap(str(logo_path))
logo_label = QtWidgets.QLabel()
logo_label.setPixmap(pixmap.scaled(26, 26, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation))
layout.addWidget(logo_label)
title_label = QtWidgets.QLabel("Interactive Color Range Analyzer")
title_label.setStyleSheet("color: #f7f7fb; font-weight: 600;")
layout.addWidget(title_label)
layout.addStretch(1)
self.min_btn = self._create_button("", "Minimise window")
self.min_btn.clicked.connect(window.showMinimized)
layout.addWidget(self.min_btn)
self.max_btn = self._create_button("", "Maximise / Restore")
self.max_btn.clicked.connect(window.toggle_maximise)
layout.addWidget(self.max_btn)
close_btn = self._create_button("", "Close")
close_btn.clicked.connect(window.close)
close_btn.setStyleSheet(
"""
QPushButton { background: transparent; color: #f7f7fb; border: none; padding: 4px 10px; }
QPushButton:hover { background-color: #d0342c; }
"""
)
layout.addWidget(close_btn)
def _create_button(self, text: str, tooltip: str) -> QtWidgets.QPushButton:
btn = QtWidgets.QPushButton(text)
btn.setToolTip(tooltip)
btn.setFixedSize(36, 24)
btn.setCursor(QtCore.Qt.ArrowCursor)
btn.setStyleSheet(
"""
QPushButton {
background-color: transparent;
color: #f7f7fb;
border: none;
padding: 4px 10px;
}
QPushButton:hover {
background-color: rgba(255, 255, 255, 0.1);
}
"""
)
return btn
def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent) -> None:
if event.button() == QtCore.Qt.LeftButton:
self.window.toggle_maximise()
event.accept()
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
if event.button() == QtCore.Qt.LeftButton:
self.window.start_system_move(event.globalPosition())
event.accept()
super().mousePressEvent(event)
class MainWindow(QtWidgets.QMainWindow):
"""Main application window containing all controls."""
def __init__(self) -> None:
super().__init__()
self.setWindowFlag(QtCore.Qt.FramelessWindowHint)
self.setWindowFlag(QtCore.Qt.Window)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground, False)
self.setMinimumSize(1100, 680)
container = QtWidgets.QWidget()
container_layout = QtWidgets.QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(0)
self.title_bar = TitleBar(self)
container_layout.addWidget(self.title_bar)
self.content = QtWidgets.QWidget()
self.content.setStyleSheet("background-color: #111216;")
content_layout = QtWidgets.QVBoxLayout(self.content)
content_layout.setContentsMargins(24, 24, 24, 24)
content_layout.setSpacing(18)
content_layout.addLayout(self._build_toolbar())
content_layout.addLayout(self._build_palette())
content_layout.addLayout(self._build_sliders())
content_layout.addWidget(self._build_previews(), 1)
content_layout.addLayout(self._build_status_section())
container_layout.addWidget(self.content, 1)
self.setCentralWidget(container)
self._is_maximised = False
self._current_image_path: Path | None = None
self._current_colour = DEFAULT_COLOUR
self._toolbar_actions: Dict[str, Callable[[], None]] = {}
self._slider_controls: Dict[str, SliderControl] = {}
self._register_default_actions()
self._update_colour_display(DEFAULT_COLOUR, "Default colour")
self.processor = QtImageProcessor()
# Window control helpers -------------------------------------------------
def toggle_maximise(self) -> None:
handle = self.windowHandle()
if handle is None:
return
if self._is_maximised:
self.showNormal()
self._is_maximised = False
self.title_bar.max_btn.setText("")
else:
self.showMaximized()
self._is_maximised = True
self.title_bar.max_btn.setText("")
def start_system_move(self, _global_position: QtCore.QPointF) -> None:
handle = self.windowHandle()
if handle:
handle.startSystemMove()
# UI builders ------------------------------------------------------------
def _build_toolbar(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(12)
buttons = [
("open_image", "🖼", "Open image…"),
("open_folder", "📂", "Open folder…"),
("choose_color", "🎨", "Choose colour"),
("pick_from_image", "🖱", "Pick from image"),
("save_overlay", "💾", "Save overlay"),
("toggle_free_draw", "", "Toggle free-draw"),
("clear_excludes", "🧹", "Clear exclusions"),
("undo_exclude", "", "Undo last exclusion"),
("reset_sliders", "🔄", "Reset sliders"),
("toggle_theme", "🌓", "Toggle theme"),
]
self._toolbar_buttons: Dict[str, ToolbarButton] = {}
for key, icon_txt, label in buttons:
button = ToolbarButton(icon_txt, label, lambda k=key: self._invoke_action(k))
layout.addWidget(button)
self._toolbar_buttons[key] = button
layout.addStretch(1)
self.status_label = QtWidgets.QLabel("No image loaded")
self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;")
layout.addWidget(self.status_label, 0, QtCore.Qt.AlignRight)
return layout
def _build_palette(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(16)
current_group = QtWidgets.QHBoxLayout()
current_group.setSpacing(8)
current_label = QtWidgets.QLabel("Current colour")
current_label.setStyleSheet("color: rgba(255,255,255,0.75); font-weight: 500;")
current_group.addWidget(current_label)
self.current_colour_swatch = QtWidgets.QLabel()
self.current_colour_swatch.setFixedSize(28, 28)
self.current_colour_swatch.setStyleSheet(f"background-color: {DEFAULT_COLOUR}; border-radius: 6px;")
current_group.addWidget(self.current_colour_swatch)
self.current_colour_label = QtWidgets.QLabel(f"({DEFAULT_COLOUR})")
self.current_colour_label.setStyleSheet("color: rgba(255,255,255,0.65);")
current_group.addWidget(self.current_colour_label)
layout.addLayout(current_group)
more_label = QtWidgets.QLabel("More colours")
more_label.setStyleSheet("color: rgba(255,255,255,0.65); font-weight: 500;")
layout.addWidget(more_label)
swatch_container = QtWidgets.QHBoxLayout()
swatch_container.setSpacing(8)
for name, hex_code in PRESET_COLOURS:
swatch = ColourSwatch(name, hex_code, self._update_colour_display)
swatch_container.addWidget(swatch)
layout.addLayout(swatch_container)
layout.addStretch(1)
return layout
def _build_sliders(self) -> QtWidgets.QHBoxLayout:
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(16)
self._slider_controls.clear()
for title, key, minimum, maximum, initial in SLIDER_SPECS:
control = SliderControl(title, key, minimum, maximum, initial)
control.value_changed.connect(self._on_slider_change)
layout.addWidget(control)
self._slider_controls[key] = control
return layout
def _build_previews(self) -> QtWidgets.QWidget:
container = QtWidgets.QWidget()
layout = QtWidgets.QGridLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setHorizontalSpacing(16)
layout.setVerticalSpacing(0)
self.prev_button = QtWidgets.QToolButton()
self.prev_button.setText("")
self.prev_button.setCursor(QtCore.Qt.PointingHandCursor)
self.prev_button.setFixedSize(44, 44)
self.prev_button.setStyleSheet(
"""
QToolButton {
border-radius: 22px;
background-color: rgba(255, 255, 255, 0.08);
color: #f7f7fb;
font-size: 18px;
}
QToolButton:hover {
background-color: rgba(255, 255, 255, 0.16);
}
"""
)
self.prev_button.clicked.connect(lambda: self._invoke_action("show_previous_image"))
layout.addWidget(self.prev_button, 0, 0, QtCore.Qt.AlignVCenter)
self.image_view = ImageView()
self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
layout.addWidget(self.image_view, 0, 1)
self.overlay_view = ImageView()
self.overlay_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;")
layout.addWidget(self.overlay_view, 0, 2)
self.next_button = QtWidgets.QToolButton()
self.next_button.setText("")
self.next_button.setCursor(QtCore.Qt.PointingHandCursor)
self.next_button.setFixedSize(44, 44)
self.next_button.setStyleSheet(self.prev_button.styleSheet())
self.next_button.clicked.connect(lambda: self._invoke_action("show_next_image"))
layout.addWidget(self.next_button, 0, 3, QtCore.Qt.AlignVCenter)
layout.setColumnStretch(1, 1)
layout.setColumnStretch(2, 1)
return container
def _build_status_section(self) -> QtWidgets.QVBoxLayout:
layout = QtWidgets.QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(0, 0, 0, 0)
self.filename_label = QtWidgets.QLabel("")
self.filename_label.setAlignment(QtCore.Qt.AlignCenter)
self.filename_label.setStyleSheet("color: rgba(255,255,255,0.85); font-weight: 600;")
layout.addWidget(self.filename_label)
self.ratio_label = QtWidgets.QLabel("Matches with exclusions: —")
self.ratio_label.setAlignment(QtCore.Qt.AlignCenter)
self.ratio_label.setStyleSheet("color: #e6b84b; font-weight: 600;")
layout.addWidget(self.ratio_label)
return layout
# Action wiring ----------------------------------------------------------
def _register_default_actions(self) -> None:
self._toolbar_actions = {
"open_image": self.open_image,
"open_folder": self.open_folder,
"choose_color": self._coming_soon,
"pick_from_image": self._coming_soon,
"save_overlay": self._coming_soon,
"toggle_free_draw": self._coming_soon,
"clear_excludes": self._coming_soon,
"undo_exclude": self._coming_soon,
"reset_sliders": self._reset_sliders,
"toggle_theme": self._coming_soon,
"show_previous_image": self.show_previous_image,
"show_next_image": self.show_next_image,
}
def _invoke_action(self, key: str) -> None:
action = self._toolbar_actions.get(key)
if action:
action()
# Image handling ---------------------------------------------------------
def open_image(self) -> None:
filters = "Images (*.png *.jpg *.jpeg *.bmp *.webp)"
path_str, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Select image", "", filters)
if not path_str:
return
path = Path(path_str)
try:
self.processor.load_single_image(path)
except Exception as exc:
QtWidgets.QMessageBox.warning(self, "ICRA", f"Unable to open the selected image.\n{exc}")
return
self._current_image_path = path
self._refresh_views()
def open_folder(self) -> None:
directory = QtWidgets.QFileDialog.getExistingDirectory(self, "Select image folder")
if not directory:
return
folder = Path(directory)
paths = sorted(
(p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS and p.is_file()),
key=lambda p: p.name.lower(),
)
if not paths:
QtWidgets.QMessageBox.information(self, "ICRA", "No supported image files found in the selected folder.")
return
try:
self.processor.load_folder(paths)
except ValueError as exc:
QtWidgets.QMessageBox.information(self, "ICRA", str(exc))
return
self._current_image_path = paths[0]
self._refresh_views()
def show_previous_image(self) -> None:
if not self.processor.preview_paths:
self._coming_soon()
return
self.processor.previous_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index]
self._refresh_views()
def show_next_image(self) -> None:
if not self.processor.preview_paths:
self._coming_soon()
return
self.processor.next_image()
self._current_image_path = self.processor.preview_paths[self.processor.current_index]
self._refresh_views()
# Helpers ----------------------------------------------------------------
def _update_colour_display(self, hex_code: str, label: str) -> None:
self._current_colour = hex_code
self.current_colour_swatch.setStyleSheet(f"background-color: {hex_code}; border-radius: 6px;")
self.current_colour_label.setText(f"({hex_code})")
self.status_label.setText(f"{label}: {hex_code}")
def _on_slider_change(self, key: str, value: int) -> None:
formatted = key.replace("_", " ").title()
self.processor.set_threshold(key, value)
self.status_label.setText(f"{formatted}{value}")
self._refresh_overlay_only()
def _reset_sliders(self) -> None:
for _, key, _, _, initial in SLIDER_SPECS:
control = self._slider_controls.get(key)
if control:
control.set_value(initial)
self.processor.set_threshold(key, initial)
self.status_label.setText("Sliders reset to defaults")
self._refresh_overlay_only()
def _coming_soon(self) -> None:
QtWidgets.QMessageBox.information(
self,
"Coming soon",
"This action is not yet wired up in the PySide6 migration prototype.",
)
# view refresh -----------------------------------------------------------
def _refresh_views(self) -> None:
preview_pix = self.processor.preview_pixmap()
overlay_pix = self.processor.overlay_pixmap()
self.image_view.set_image(preview_pix)
self.overlay_view.set_image(overlay_pix)
if self._current_image_path and self.processor.preview_img:
width, height = self.processor.preview_img.size
self.status_label.setText(
f"{self._current_image_path.name} · {width}×{height}"
)
self.filename_label.setText(
f"{self._current_image_path.name} ({width}×{height})"
)
self.ratio_label.setText(self.processor.stats.summary())
def _refresh_overlay_only(self) -> None:
if self.processor.preview_img is None:
return
self.overlay_view.set_image(self.processor.overlay_pixmap())
self.ratio_label.setText(self.processor.stats.summary())