diff --git a/README.md b/README.md
index 0cd1c9f..99cf33f 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,51 @@
- 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]