diff --git a/README.md b/README.md index 0cd1c9f..99cf33f 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,51 @@
ICRA
- Interactive Color Range Analyzer is a Tkinter-based desktop tool for highlighting customised colour ranges in images.
- Load a single photo or an entire folder, fine-tune hue/saturation/value sliders, and export overlays complete with quick statistics. + Interactive Color Range Analyzer is being reimagined with a PySide6 user interface.
+ This branch focuses on building a native desktop shell with modern window controls before porting the colour-analysis features.
-## Features -- Two synced previews (original + overlay) -- Hue/Sat/Value sliders with presets and image colour picker -- Exclusion rectangles or freehand polygons that persist while browsing -- Theme toggle (light/dark) with rounded toolbar buttons and accent-aware highlights -- Folder support with wrap-around previous/next navigation -- Quick overlay export (PNG) with configurable defaults and language settings via `config.toml` +## Current prototype +- Custom frameless window with minimise / maximise / close controls that hook into Windows natively +- Dark themed layout and basic image preview powered by Qt +- “Open image” workflow that displays the selected asset scaled to the viewport + +> ⚠️ Legacy Tk features (sliders, exclusions, folder navigation, stats) are not wired up yet. The goal here is to validate the PySide6 shell first. ## Requirements -- Python 3.11+ (3.10 works with `tomli`) +- Python 3.11+ - [uv](https://github.com/astral-sh/uv) for dependency management -- Tkinter (install separately on some Linux distros) +- Windows 10/11 recommended (PySide6 build included; Linux/macOS should work but are untested in this branch) -## Setup with uv (Windows PowerShell) +## Setup with uv (PowerShell example) ```bash git clone https://git.lukasmahler.de/lm/ICRA.git cd ICRA uv venv -source .venv/Scripts/activate +source .venv/Scripts/activate # macOS/Linux: source .venv/bin/activate uv pip install . uv run icra ``` -The launcher copies Tcl/Tk resources into the virtualenv on first run, so no manual environment tweaks are needed. -On macOS/Linux activate with `source .venv/bin/activate` instead. -## Workflow -1. Load an image (`🖼`) or a folder (`📂`). -2. Pick a colour (`🎨` dialog, `🖱️` image click, or preset swatch). -3. Fine‑tune sliders; watch the overlay update on the right. -4. Toggle freehand mode (`△`) or stick with rectangles and mark areas to exclude (right mouse drag). -5. Move through folder images with `⬅️` / `➡️`; exclusions stay put unless you opt into automatic resets. -6. Save an overlay (`💾`) when ready. +The app launches directly as a PySide6 GUI—no browser or local web server involved. Use the “Open Image…” button to load a file and test resizing/snap behaviour. -## Project Layout +## Roadmap (branch scope) +1. Port hue/saturation/value controls to Qt widgets +2. Re-implement exclusion drawing using QPainter overlays +3. Integrate existing image-processing logic (`app/logic`) with the new UI + +## Project layout ``` app/ - app.py # main app assembly - gui/ # UI, theme, picker mixins - logic/ # image ops, defaults, config helpers - lang/ # localisation TOML files -config.toml # optional defaults -main.py # entry point + assets/ # Shared branding + gui/, logic/ # Legacy Tk code kept for reference + qt/ # New PySide6 implementation (main_window, app bootstrap) +config.toml # Historical defaults (unused in the prototype) +main.py # Entry point -> PySide6 launcher ``` -## Localisation -- English and German translations ship in `app/lang`. Set the desired language via the top-level `language` key in `config.toml`. - -## Development -- Quick check: `uv run python -m compileall app main.py` -- Contributions welcome; include screenshots for UI tweaks. +## Development notes +- Quick syntax check: `uv run python -m compileall app main.py` +- Uploaded images are not persisted; the preview uses Qt pixmaps only. +- Contributions welcome—please target this branch with PySide6-specific improvements. diff --git a/app/__init__.py b/app/__init__.py index c6cbc33..f3c94c3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,15 @@ -"""Application package.""" +"""Application package exposing the PySide6 entry points.""" -from .app import ICRAApp, start_app +from __future__ import annotations -__all__ = ["ICRAApp", "start_app"] +try: # Legacy Tk support remains optional + from .app import ICRAApp, start_app as start_tk_app # type: ignore[attr-defined] +except Exception: # pragma: no cover + ICRAApp = None # type: ignore[assignment] + start_tk_app = None # type: ignore[assignment] + +from .qt import create_application as create_qt_app, run as run_qt_app + +start_app = run_qt_app + +__all__ = ["ICRAApp", "start_tk_app", "create_qt_app", "run_qt_app", "start_app"] diff --git a/app/launcher.py b/app/launcher.py index 290f4f5..71138b8 100644 --- a/app/launcher.py +++ b/app/launcher.py @@ -1,49 +1,12 @@ -"""Launcher ensuring Tcl/Tk resources are available before starting ICRA.""" +"""Launcher for the PySide6 ICRA application.""" from __future__ import annotations -import os -import shutil -import subprocess -import sys -from pathlib import Path - - -def _copy_tcl_runtime(venv_root: Path) -> tuple[Path, Path] | None: - """Copy Tcl/Tk directories from the base interpreter into the venv if needed.""" - - base_prefix = Path(getattr(sys, "base_prefix", sys.prefix)) - base_tcl_dir = base_prefix / "tcl" - if not base_tcl_dir.exists(): - return None - - tcl_src = base_tcl_dir / "tcl8.6" - tk_src = base_tcl_dir / "tk8.6" - if not tcl_src.exists() or not tk_src.exists(): - return None - - target_root = venv_root / "tcl" - tcl_dest = target_root / "tcl8.6" - tk_dest = target_root / "tk8.6" - - if not tcl_dest.exists(): - shutil.copytree(tcl_src, tcl_dest, dirs_exist_ok=True) - if not tk_dest.exists(): - shutil.copytree(tk_src, tk_dest, dirs_exist_ok=True) - - return tcl_dest, tk_dest +from .qt import run def main() -> int: - venv_root = Path(sys.prefix) - tcl_paths = _copy_tcl_runtime(venv_root) - - env = os.environ.copy() - if tcl_paths: - env.setdefault("TCL_LIBRARY", str(tcl_paths[0])) - env.setdefault("TK_LIBRARY", str(tcl_paths[1])) - - return subprocess.call([sys.executable, "main.py"], env=env) + return run() if __name__ == "__main__": diff --git a/app/qt/__init__.py b/app/qt/__init__.py new file mode 100644 index 0000000..86cac02 --- /dev/null +++ b/app/qt/__init__.py @@ -0,0 +1,7 @@ +"""PySide6 application entry points.""" + +from __future__ import annotations + +from .app import create_application, run + +__all__ = ["create_application", "run"] diff --git a/app/qt/app.py b/app/qt/app.py new file mode 100644 index 0000000..b8cdb95 --- /dev/null +++ b/app/qt/app.py @@ -0,0 +1,50 @@ +"""Application bootstrap for the PySide6 GUI.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6 import QtGui, QtWidgets + +from .main_window import MainWindow + + +def create_application() -> QtWidgets.QApplication: + """Create the Qt application instance with customised styling.""" + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication(sys.argv) + + app.setOrganizationName("ICRA") + app.setApplicationName("Interactive Color Range Analyzer") + app.setApplicationDisplayName("ICRA") + + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#111216")) + palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor("#f5f5f5")) + palette.setColor(QtGui.QPalette.Base, QtGui.QColor("#1a1b21")) + palette.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor("#20212a")) + palette.setColor(QtGui.QPalette.Button, QtGui.QColor("#20212a")) + palette.setColor(QtGui.QPalette.ButtonText, QtGui.QColor("#f5f5f5")) + palette.setColor(QtGui.QPalette.Text, QtGui.QColor("#f5f5f5")) + palette.setColor(QtGui.QPalette.Highlight, QtGui.QColor("#5168ff")) + palette.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor("#ffffff")) + app.setPalette(palette) + + font = QtGui.QFont("Segoe UI", 10) + app.setFont(font) + + logo_path = Path(__file__).resolve().parents[1] / "assets" / "logo.png" + if logo_path.exists(): + app.setWindowIcon(QtGui.QIcon(str(logo_path))) + + return app + + +def run() -> int: + """Run the PySide6 GUI.""" + app = create_application() + window = MainWindow() + window.show() + return app.exec() diff --git a/app/qt/main_window.py b/app/qt/main_window.py new file mode 100644 index 0000000..b591585 --- /dev/null +++ b/app/qt/main_window.py @@ -0,0 +1,223 @@ +"""Main PySide6 window with custom title bar.""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6 import QtCore, QtGui, QtWidgets + + +class TitleBar(QtWidgets.QWidget): + """Custom title bar mimicking modern Windows applications.""" + + 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(24, 24, 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-color: 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.12); + } + """ + ) + 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 ImageView(QtWidgets.QLabel): + """Simple image display widget that keeps aspect ratio.""" + + 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.clear() + self.setText("") + self.setStyleSheet("color: rgba(255,255,255,0.5); font-size: 14px;") + return + target = self._pixmap.scaled( + self.size(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation, + ) + self.setPixmap(target) + self.setStyleSheet("") + + +class MainWindow(QtWidgets.QMainWindow): + """Main application window containing custom chrome and content area.""" + + 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(900, 600) + + 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) + + toolbar = QtWidgets.QHBoxLayout() + toolbar.setSpacing(12) + + self.open_button = QtWidgets.QPushButton("Open Image…") + self.open_button.setCursor(QtCore.Qt.PointingHandCursor) + self.open_button.setStyleSheet( + """ + QPushButton { + background: linear-gradient(135deg, #5168ff, #9a4dff); + border: none; + color: #ffffff; + font-weight: 600; + padding: 10px 16px; + border-radius: 10px; + } + QPushButton:hover { + filter: brightness(1.1); + } + """ + ) + self.open_button.clicked.connect(self.open_image) + toolbar.addWidget(self.open_button) + + toolbar.addStretch(1) + + self.status_label = QtWidgets.QLabel("No image loaded") + self.status_label.setStyleSheet("color: rgba(255,255,255,0.7); font-weight: 500;") + toolbar.addWidget(self.status_label) + + content_layout.addLayout(toolbar) + + self.image_view = ImageView() + self.image_view.setStyleSheet("border: 1px solid rgba(255,255,255,0.08); border-radius: 12px;") + content_layout.addWidget(self.image_view, 1) + + container_layout.addWidget(self.content, 1) + self.setCentralWidget(container) + + self._is_maximised = False + self._current_image_path: Path | None = None + + # 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() + + # 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) + pixmap = QtGui.QPixmap(str(path)) + if pixmap.isNull(): + QtWidgets.QMessageBox.warning(self, "ICRA", "Unable to open the selected image.") + return + self.image_view.set_image(pixmap) + self._current_image_path = path + self.status_label.setText(f"{path.name} · {pixmap.width()}×{pixmap.height()}") diff --git a/pyproject.toml b/pyproject.toml index adedbd6..da38ed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,14 @@ [project] name = "icra" version = "0.1.0" -description = "Interactive Color Range Analyzer (ICRA) for Tkinter" +description = "Interactive Color Range Analyzer (ICRA) desktop app (PySide6)" readme = "README.md" authors = [{ name = "ICRA contributors" }] license = "MIT" requires-python = ">=3.10" dependencies = [ "pillow>=10.0.0", + "PySide6>=6.7", ] [project.scripts]