Add PyQt6 UI skeleton with theming and setup scripts

This commit is contained in:
2025-11-27 15:11:03 +00:00
parent 35a8056aec
commit 797101a73b
18 changed files with 455 additions and 1 deletions

6
AGENTS.md Normal file
View File

@@ -0,0 +1,6 @@
# WizardMerge AGENT Notes
- Favor clear module-level docstrings and type hints when practical.
- Keep shell scripts POSIX compatible.
- Prefer descriptive function and variable names for readability.
- When editing QML files, maintain consistent indentation of two spaces.

View File

@@ -1,2 +1,47 @@
# WizardMerge
Python GIT PyQT6 Wizard Merge
PyQt6 + QML demo application that showcases a themed UI shell alongside simple
merge algorithm helpers. The project ships with a theming plugin system so you
can extend the UI palette without touching the core code.
## Features
- PyQt6 application bootstrapped from `wizardmerge.app`
- QML front-end that reads theme colors from the Python context
- Built-in light and dark themes plus an example warm plugin theme
- Simple merge algorithm utilities in `wizardmerge.algo`
- Helper scripts for environment setup and running the app
## Getting Started
1. Create a virtual environment and install dependencies:
```sh
./setup.sh
```
2. Launch the GUI (activates `.venv` automatically when present):
```sh
./run_app.sh
```
3. To install requirements into an existing environment instead of creating a
new one:
```sh
./install_all_python.sh
```
## Theming
Themes live under `wizardmerge/themes`. Built-ins follow the `<name>_theme.py`
pattern. Plugin themes can be placed in `wizardmerge/themes/plugins` or in any
folder passed to `ThemeManager(extra_plugin_paths=[Path("/path/to/themes")])`.
Each theme module must expose a `Theme` instance named `theme` (or the
`warm_theme` example) with a palette mapping of color keys used by the QML UI.
## QML
The UI entry point is `wizardmerge/qml/main.qml`. It binds to a `theme` context
property injected from Python so you can use colors consistently across QML
components. Maintain two-space indentation when updating QML files.
## Algorithms
`wizardmerge/algo/merge.py` offers a deterministic `merge_pairs` function for
interleaving two sequences of lines and reporting their origins. The GUI can be
extended to call these helpers when you add inputs to the placeholder area in
the QML layout.

8
install_all_python.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env sh
# Install Python dependencies system-wide or in the active environment.
set -eu
PYTHON_BIN=${PYTHON_BIN:-python3}
"$PYTHON_BIN" -m pip install --upgrade pip
"$PYTHON_BIN" -m pip install -r requirements.txt

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
PyQt6>=6.6

13
run_app.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env sh
# Launch the WizardMerge GUI using the local virtual environment when present.
set -eu
VENV_DIR=${VENV_DIR:-.venv}
PYTHON_BIN=${PYTHON_BIN:-python3}
if [ -d "$VENV_DIR" ]; then
# shellcheck disable=SC1090
. "$VENV_DIR/bin/activate"
fi
exec "$PYTHON_BIN" -m wizardmerge.app "$@"

18
setup.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env sh
# Prepare a local virtual environment and install dependencies.
set -eu
VENV_DIR=${VENV_DIR:-.venv}
PYTHON_BIN=${PYTHON_BIN:-python3}
if [ ! -d "$VENV_DIR" ]; then
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# shellcheck disable=SC1090
. "$VENV_DIR/bin/activate"
pip install --upgrade pip
pip install -r requirements.txt
echo "Environment ready. Activate with: . $VENV_DIR/bin/activate"

3
wizardmerge/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""WizardMerge package entry point and metadata."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,5 @@
"""Algorithmic utilities for WizardMerge."""
from .merge import MergeResult, merge_pairs
__all__ = ["MergeResult", "merge_pairs"]

41
wizardmerge/algo/merge.py Normal file
View File

@@ -0,0 +1,41 @@
"""Toy merge utilities to accompany the GUI."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, List, Sequence
@dataclass
class MergeResult:
"""Hold the combined payload and an audit trail of sources."""
merged: str
sources: List[str]
def merge_pairs(lines_a: Sequence[str], lines_b: Sequence[str]) -> MergeResult:
"""Return interleaved lines and capture their origin.
This function is intentionally simple, providing a deterministic merge
strategy useful for demonstration in the GUI layer.
"""
merged_lines: List[str] = []
sources: List[str] = []
for index, (line_a, line_b) in enumerate(zip(lines_a, lines_b)):
merged_lines.append(line_a)
merged_lines.append(line_b)
sources.append(f"A{index}")
sources.append(f"B{index}")
if len(lines_a) > len(lines_b):
for tail_index, line in enumerate(lines_a[len(lines_b) :], start=len(lines_b)):
merged_lines.append(line)
sources.append(f"A{tail_index}")
elif len(lines_b) > len(lines_a):
for tail_index, line in enumerate(lines_b[len(lines_a) :], start=len(lines_a)):
merged_lines.append(line)
sources.append(f"B{tail_index}")
return MergeResult(merged="\n".join(merged_lines), sources=sources)

54
wizardmerge/app.py Normal file
View File

@@ -0,0 +1,54 @@
"""Application bootstrap for the WizardMerge PyQt6 + QML UI."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtQml import QQmlApplicationEngine
from wizardmerge.themes.loader import ThemeManager
def _resolve_qml_path() -> Path:
"""Return the absolute path to the main QML entry file."""
qml_path = Path(__file__).parent / "qml" / "main.qml"
if not qml_path.exists():
raise FileNotFoundError("Unable to locate main.qml; ensure resources are installed.")
return qml_path
def run(preferred_theme: Optional[str] = None) -> int:
"""Run the WizardMerge UI.
Args:
preferred_theme: Optional theme name to prioritize when loading themes.
Returns:
Exit code to propagate to the caller.
"""
app = QGuiApplication(sys.argv)
theme_manager = ThemeManager()
theme = theme_manager.select_theme(preferred_theme)
engine = QQmlApplicationEngine()
engine.rootContext().setContextProperty("theme", theme.as_dict())
qml_path = _resolve_qml_path()
engine.load(QUrl.fromLocalFile(qml_path.as_posix()))
if not engine.rootObjects():
return 1
return app.exec()
def main() -> None:
"""Entry-point wrapper for console scripts."""
sys.exit(run())
if __name__ == "__main__":
main()

116
wizardmerge/qml/main.qml Normal file
View File

@@ -0,0 +1,116 @@
import QtQuick
import QtQuick.Controls
ApplicationWindow {
width: 720
height: 480
visible: true
title: "WizardMerge"
color: theme.background
Column {
anchors.fill: parent
spacing: 12
padding: 16
Rectangle {
width: parent.width
height: 64
color: theme.surface
radius: 8
border.color: theme.border
border.width: 1
Row {
anchors.fill: parent
anchors.margins: 12
spacing: 12
Rectangle {
width: 36
height: 36
radius: 18
color: theme.accent
}
Column {
spacing: 4
Text {
text: "WizardMerge"
font.bold: true
color: theme.text
font.pointSize: 18
}
Text {
text: "PyQt6 + QML theming demo"
color: theme.text
opacity: 0.7
}
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: 40
color: theme.border
}
Text {
text: `Current theme: ${theme.name}`
color: theme.text
anchors.verticalCenter: parent.verticalCenter
}
}
}
Rectangle {
width: parent.width
height: 320
radius: 8
color: theme.surface
border.color: theme.border
border.width: 1
Column {
anchors.fill: parent
anchors.margins: 16
spacing: 12
Text {
text: "Algorithm preview"
font.bold: true
color: theme.text
font.pointSize: 14
}
Rectangle {
height: 1
width: parent.width
color: theme.border
}
Text {
text: "Drop your merge data here. The algorithm preview uses a simple interleaving strategy from wizardmerge.algo.merge.merge_pairs."
wrapMode: Text.Wrap
color: theme.text
opacity: 0.8
}
Rectangle {
width: parent.width
height: 180
color: theme.background
radius: 6
border.color: theme.border
border.width: 1
Text {
anchors.centerIn: parent
text: "Future input widgets will live here."
color: theme.text
opacity: 0.6
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
"""Theme helpers for WizardMerge."""
from .base import Theme
from .loader import ThemeManager
__all__ = ["Theme", "ThemeManager"]

View File

@@ -0,0 +1,17 @@
"""Core theme definitions."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict
@dataclass
class Theme:
"""Simple theme container for color palette values."""
name: str
palette: Dict[str, str]
def as_dict(self) -> Dict[str, str]:
"""Return a dictionary representation usable by QML contexts."""
return {"name": self.name, **self.palette}

View File

@@ -0,0 +1,13 @@
"""Built-in dark theme."""
from wizardmerge.themes.base import Theme
palette = {
"background": "#0d1117",
"surface": "#161b22",
"text": "#e6edf3",
"accent": "#7c9aff",
"border": "#30363d",
}
theme = Theme(name="Dark", palette=palette)

View File

@@ -0,0 +1,13 @@
"""Built-in light theme."""
from wizardmerge.themes.base import Theme
palette = {
"background": "#f5f5f5",
"surface": "#ffffff",
"text": "#1f2933",
"accent": "#0f7ada",
"border": "#d8d8d8",
}
theme = Theme(name="Light", palette=palette)

View File

@@ -0,0 +1,76 @@
"""Theme loading and plugin discovery helpers."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from pkgutil import iter_modules
from typing import Iterable, List, Sequence
from wizardmerge.themes.base import Theme
class ThemeManager:
"""Manage built-in and plugin-based themes."""
def __init__(self, extra_plugin_paths: Sequence[Path] | None = None) -> None:
self._builtin_modules = self._discover_builtin_modules()
self._plugin_modules = self._discover_plugin_modules(extra_plugin_paths)
@staticmethod
def _discover_builtin_modules() -> List[str]:
"""Return module names for bundled themes."""
module_path = Path(__file__).parent
modules = []
for module in iter_modules([str(module_path)]):
if module.name.endswith("_theme"):
modules.append(f"{__package__}.{module.name}")
return modules
@staticmethod
def _discover_plugin_modules(extra_paths: Sequence[Path] | None) -> List[str]:
"""Return module names for shipped plugin examples and user-defined themes."""
modules: List[str] = []
plugin_package = f"{__package__}.plugins"
plugin_path = Path(__file__).parent / "plugins"
modules.extend(
f"{plugin_package}.{module.name}" for module in iter_modules([str(plugin_path)]) if module.ispkg is False
)
if extra_paths:
for path in extra_paths:
if not path.exists():
continue
sys.path.append(str(path))
modules.extend(module.name for module in iter_modules([str(path)]))
return modules
def _load_theme_from_module(self, module_name: str) -> Theme | None:
module = importlib.import_module(module_name)
theme = getattr(module, "theme", None) or getattr(module, "warm_theme", None)
if isinstance(theme, Theme):
return theme
return None
def available_themes(self) -> List[Theme]:
"""Return a list of all themes that could be loaded."""
themes: List[Theme] = []
for module in [*self._builtin_modules, *self._plugin_modules]:
theme = self._load_theme_from_module(module)
if theme:
themes.append(theme)
return themes
def select_theme(self, preferred_name: str | None = None) -> Theme:
"""Return the preferred theme or fall back to the first available one."""
themes = self.available_themes()
if not themes:
raise RuntimeError("No themes could be loaded.")
if preferred_name:
for theme in themes:
if theme.name.lower() == preferred_name.lower():
return theme
return themes[0]

View File

@@ -0,0 +1,6 @@
"""Example plugin themes shipped with the project."""
from wizardmerge.themes.base import Theme
from .warm_theme import warm_theme
__all__ = ["warm_theme", "Theme"]

View File

@@ -0,0 +1,13 @@
"""Sample theme plugin distributed separately from the built-ins."""
from wizardmerge.themes.base import Theme
palette = {
"background": "#fdf1e5",
"surface": "#fde7d3",
"text": "#3b2f2f",
"accent": "#d67b4d",
"border": "#f6c4a3",
}
warm_theme = Theme(name="Warm", palette=palette)