From 797101a73bd5403139a8a33c4565318ebb33fa4c Mon Sep 17 00:00:00 2001 From: JohnDoe6345789 Date: Thu, 27 Nov 2025 15:11:03 +0000 Subject: [PATCH] Add PyQt6 UI skeleton with theming and setup scripts --- AGENTS.md | 6 ++ README.md | 47 ++++++++- install_all_python.sh | 8 ++ requirements.txt | 1 + run_app.sh | 13 +++ setup.sh | 18 ++++ wizardmerge/__init__.py | 3 + wizardmerge/algo/__init__.py | 5 + wizardmerge/algo/merge.py | 41 ++++++++ wizardmerge/app.py | 54 +++++++++++ wizardmerge/qml/main.qml | 116 +++++++++++++++++++++++ wizardmerge/themes/__init__.py | 6 ++ wizardmerge/themes/base.py | 17 ++++ wizardmerge/themes/dark_theme.py | 13 +++ wizardmerge/themes/light_theme.py | 13 +++ wizardmerge/themes/loader.py | 76 +++++++++++++++ wizardmerge/themes/plugins/__init__.py | 6 ++ wizardmerge/themes/plugins/warm_theme.py | 13 +++ 18 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100755 install_all_python.sh create mode 100644 requirements.txt create mode 100755 run_app.sh create mode 100755 setup.sh create mode 100644 wizardmerge/__init__.py create mode 100644 wizardmerge/algo/__init__.py create mode 100644 wizardmerge/algo/merge.py create mode 100644 wizardmerge/app.py create mode 100644 wizardmerge/qml/main.qml create mode 100644 wizardmerge/themes/__init__.py create mode 100644 wizardmerge/themes/base.py create mode 100644 wizardmerge/themes/dark_theme.py create mode 100644 wizardmerge/themes/light_theme.py create mode 100644 wizardmerge/themes/loader.py create mode 100644 wizardmerge/themes/plugins/__init__.py create mode 100644 wizardmerge/themes/plugins/warm_theme.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..139e114 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 893b7d1..33e3c67 100644 --- a/README.md +++ b/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 `_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. diff --git a/install_all_python.sh b/install_all_python.sh new file mode 100755 index 0000000..2e7a41e --- /dev/null +++ b/install_all_python.sh @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a2cbe7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyQt6>=6.6 diff --git a/run_app.sh b/run_app.sh new file mode 100755 index 0000000..c213d9e --- /dev/null +++ b/run_app.sh @@ -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 "$@" diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..f8fb351 --- /dev/null +++ b/setup.sh @@ -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" diff --git a/wizardmerge/__init__.py b/wizardmerge/__init__.py new file mode 100644 index 0000000..67e9edf --- /dev/null +++ b/wizardmerge/__init__.py @@ -0,0 +1,3 @@ +"""WizardMerge package entry point and metadata.""" + +__version__ = "0.1.0" diff --git a/wizardmerge/algo/__init__.py b/wizardmerge/algo/__init__.py new file mode 100644 index 0000000..a4592b6 --- /dev/null +++ b/wizardmerge/algo/__init__.py @@ -0,0 +1,5 @@ +"""Algorithmic utilities for WizardMerge.""" + +from .merge import MergeResult, merge_pairs + +__all__ = ["MergeResult", "merge_pairs"] diff --git a/wizardmerge/algo/merge.py b/wizardmerge/algo/merge.py new file mode 100644 index 0000000..82f5cec --- /dev/null +++ b/wizardmerge/algo/merge.py @@ -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) diff --git a/wizardmerge/app.py b/wizardmerge/app.py new file mode 100644 index 0000000..c7ea126 --- /dev/null +++ b/wizardmerge/app.py @@ -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() diff --git a/wizardmerge/qml/main.qml b/wizardmerge/qml/main.qml new file mode 100644 index 0000000..23fdc05 --- /dev/null +++ b/wizardmerge/qml/main.qml @@ -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 + } + } + } + } + } +} diff --git a/wizardmerge/themes/__init__.py b/wizardmerge/themes/__init__.py new file mode 100644 index 0000000..ae2a06b --- /dev/null +++ b/wizardmerge/themes/__init__.py @@ -0,0 +1,6 @@ +"""Theme helpers for WizardMerge.""" + +from .base import Theme +from .loader import ThemeManager + +__all__ = ["Theme", "ThemeManager"] diff --git a/wizardmerge/themes/base.py b/wizardmerge/themes/base.py new file mode 100644 index 0000000..713e24e --- /dev/null +++ b/wizardmerge/themes/base.py @@ -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} diff --git a/wizardmerge/themes/dark_theme.py b/wizardmerge/themes/dark_theme.py new file mode 100644 index 0000000..7dd40fc --- /dev/null +++ b/wizardmerge/themes/dark_theme.py @@ -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) diff --git a/wizardmerge/themes/light_theme.py b/wizardmerge/themes/light_theme.py new file mode 100644 index 0000000..9052e6f --- /dev/null +++ b/wizardmerge/themes/light_theme.py @@ -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) diff --git a/wizardmerge/themes/loader.py b/wizardmerge/themes/loader.py new file mode 100644 index 0000000..9e4632f --- /dev/null +++ b/wizardmerge/themes/loader.py @@ -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] diff --git a/wizardmerge/themes/plugins/__init__.py b/wizardmerge/themes/plugins/__init__.py new file mode 100644 index 0000000..4bc6ef0 --- /dev/null +++ b/wizardmerge/themes/plugins/__init__.py @@ -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"] diff --git a/wizardmerge/themes/plugins/warm_theme.py b/wizardmerge/themes/plugins/warm_theme.py new file mode 100644 index 0000000..d001fb3 --- /dev/null +++ b/wizardmerge/themes/plugins/warm_theme.py @@ -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)