Introduce PySide6 desktop shell

This commit is contained in:
lm 2025-10-19 19:10:11 +02:00
parent f09da5018f
commit 9ded332269
7 changed files with 326 additions and 79 deletions

View File

@ -1,58 +1,51 @@
<div style="display:flex; gap:16px; align-items:center;">
<img src="app/assets/logo.png" alt="ICRA" width="140"/>
<div>
<strong>Interactive Color Range Analyzer</strong> is a Tkinter-based desktop tool for highlighting customised colour ranges in images.<br/>
Load a single photo or an entire folder, fine-tune hue/saturation/value sliders, and export overlays complete with quick statistics.
<strong>Interactive Color Range Analyzer</strong> is being reimagined with a <em>PySide6</em> user interface.<br/>
This branch focuses on building a native desktop shell with modern window controls before porting the colour-analysis features.
</div>
</div>
## 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. Finetune 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.

View File

@ -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"]

View File

@ -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__":

7
app/qt/__init__.py Normal file
View File

@ -0,0 +1,7 @@
"""PySide6 application entry points."""
from __future__ import annotations
from .app import create_application, run
__all__ = ["create_application", "run"]

50
app/qt/app.py Normal file
View File

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

223
app/qt/main_window.py Normal file
View File

@ -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("<No image loaded>")
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()}")

View File

@ -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]