Introduce PySide6 desktop shell
This commit is contained in:
parent
f09da5018f
commit
9ded332269
63
README.md
63
README.md
|
|
@ -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. 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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
"""PySide6 application entry points."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .app import create_application, run
|
||||
|
||||
__all__ = ["create_application", "run"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()}")
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue