mirror of
https://github.com/johndoe6345789/WizardMerge.git
synced 2026-04-24 13:44:55 +00:00
Add PyQt6 UI skeleton with theming and setup scripts
This commit is contained in:
6
AGENTS.md
Normal file
6
AGENTS.md
Normal 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.
|
||||
47
README.md
47
README.md
@@ -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
8
install_all_python.sh
Executable 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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
PyQt6>=6.6
|
||||
13
run_app.sh
Executable file
13
run_app.sh
Executable 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
18
setup.sh
Executable 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
3
wizardmerge/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""WizardMerge package entry point and metadata."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
wizardmerge/algo/__init__.py
Normal file
5
wizardmerge/algo/__init__.py
Normal 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
41
wizardmerge/algo/merge.py
Normal 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
54
wizardmerge/app.py
Normal 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
116
wizardmerge/qml/main.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
wizardmerge/themes/__init__.py
Normal file
6
wizardmerge/themes/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Theme helpers for WizardMerge."""
|
||||
|
||||
from .base import Theme
|
||||
from .loader import ThemeManager
|
||||
|
||||
__all__ = ["Theme", "ThemeManager"]
|
||||
17
wizardmerge/themes/base.py
Normal file
17
wizardmerge/themes/base.py
Normal 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}
|
||||
13
wizardmerge/themes/dark_theme.py
Normal file
13
wizardmerge/themes/dark_theme.py
Normal 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)
|
||||
13
wizardmerge/themes/light_theme.py
Normal file
13
wizardmerge/themes/light_theme.py
Normal 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)
|
||||
76
wizardmerge/themes/loader.py
Normal file
76
wizardmerge/themes/loader.py
Normal 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]
|
||||
6
wizardmerge/themes/plugins/__init__.py
Normal file
6
wizardmerge/themes/plugins/__init__.py
Normal 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"]
|
||||
13
wizardmerge/themes/plugins/warm_theme.py
Normal file
13
wizardmerge/themes/plugins/warm_theme.py
Normal 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)
|
||||
Reference in New Issue
Block a user