Metadata-Version: 2.4
Name: qt-md3
Version: 0.1.0
Summary: Material Design 3 theming, controls and QML components for PySide6/PyQt6 applications.
Author: qt-md3 contributors
License: Apache-2.0
Project-URL: Homepage, https://github.com/example/qt-md3
Project-URL: Documentation, https://github.com/example/qt-md3#readme
Project-URL: Issues, https://github.com/example/qt-md3/issues
Keywords: qt,pyside6,pyqt6,material-design,material-3,qml,theming,ui
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: User Interfaces
Classifier: Topic :: Software Development :: Widget Sets
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: pyside6
Requires-Dist: PySide6>=6.5; extra == "pyside6"
Provides-Extra: pyqt6
Requires-Dist: PyQt6>=6.5; extra == "pyqt6"
Provides-Extra: hct
Requires-Dist: materialyoucolor>=2.0.8; extra == "hct"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: PySide6>=6.5; extra == "dev"
Dynamic: license-file

# qt-md3

Material Design 3 theming, controls and QML components for PySide6 (and,
architecturally, PyQt6) applications.

```bash
pip install "qt-md3[pyside6]"
```

```python
import sys
from PySide6.QtWidgets import QApplication, QPushButton
import qtmd3

app = QApplication(sys.argv)
theme = qtmd3.apply(app)          # <- the whole public API for widgets
QPushButton("Hello, Material 3").show()
app.exec()
```

> **Note on the package name.** `qt-md3` is the **PyPI** distribution
> name (hyphens are fine there). Python identifiers can't contain
> hyphens, so the importable module is `qtmd3`: `import qtmd3`, not
> `import qt-md3`. This mirrors how e.g. `pip install pillow` gives you
> `import PIL`.

For Qt Quick/QML apps:

```python
from PySide6.QtGui import QGuiApplication
from PySide6.QtQml import QQmlApplicationEngine
import qtmd3

app = QGuiApplication(sys.argv)
engine = QQmlApplicationEngine()
qtmd3.apply(app, qml_engine=engine, widgets=False)
engine.load("main.qml")
```

```qml
// main.qml
import QtQuick
import Material3 1.0

Rectangle {
    color: Material3Theme.colors.background
    M3Button { text: "Hello"; anchors.centerIn: parent }
}
```

---

## Table of contents

- [Why this package](#why-this-package)
- [Architecture](#architecture)
- [Widgets vs. QML — compatibility boundary](#widgets-vs-qml--compatibility-boundary)
- [Public API](#public-api)
- [Installation & packaging](#installation--packaging)
- [Examples](#examples)
- [Testing](#testing)
- [Binding support matrix](#binding-support-matrix)
- [What's honestly not done](#whats-honestly-not-done)

## Why this package

Material Design 3 is a complete, coherent design system (color roles,
type scale, shape scale, elevation, interaction states). Qt ships none
of it. Every team that wants M3 in a Qt app currently reinvents a
stylesheet generator, a handful of custom widgets, and usually gives up
on the QML half. qt-md3 is that shared implementation, done once:

- a **single theme engine** that derives a complete light/dark M3 color
  scheme from one seed color, plus the type/shape/elevation/state tokens
  that go with it;
- a **Qt Widgets integration** that reskins stock widgets via a generated
  QSS stylesheet — no UI rewrite required;
- a **Qt Quick/QML integration** with real, reusable M3 components
  (`M3Button`, `M3Switch`, `M3TextField`, ...) for the interaction
  patterns (ripples, animated state layers, floating labels) that QSS
  structurally cannot express;
- **packaging that actually ships the QML files and resources**, which
  is the part most from-scratch attempts get wrong.

## Architecture

```
qtmd3/
├── __init__.py            # apply() — the public entry point
├── backends/               # Qt-binding abstraction (PySide6 now, PyQt6 ready)
│   ├── base.py             #   QtBinding ABC: submodules + Signal/Slot/Property + helpers
│   ├── pyside6.py          #   concrete PySide6 implementation
│   ├── pyqt6.py            #   concrete PyQt6 implementation
│   └── __init__.py         #   get_backend() auto-detection
├── core/                   # binding-neutral theme engine (pure Python + Qt data types)
│   ├── scheme.py           #   tonal palettes + full M3 ColorScheme (~45 roles)
│   ├── theme.py            #   Typescale, ShapeScale, Elevation, StateOpacities, Theme
│   ├── manager.py          #   ThemeManager: owns the live Theme, pushes to widgets/QML
│   ├── qml_bridge.py        #   builds the QObject exposed to QML as Material3Theme
│   └── resources.py        #   locates the installed package's qml/ and fonts/ dirs
├── widgets/                 # Qt Widgets integration
│   ├── stylesheet.py        #   Theme -> QSS generator
│   └── controls.py          #   MD3Button / MD3Card / MD3TextField convenience classes
└── qml/Material3/           # Qt Quick/QML integration — the installable QML module
    ├── qmldir
    ├── M3Button.qml, M3Card.qml, M3TextField.qml, M3Switch.qml,
    │   M3TopAppBar.qml, M3FAB.qml, M3IconButton.qml
```

### Why a backend abstraction

Every other module (`core`, `widgets`) imports Qt only through a
`QtBinding` object returned by `backends.get_backend()` — never
`import PySide6` directly. A `QtBinding` exposes the live `QtCore`/
`QtGui`/`QtWidgets`/`QtQml`/`QtQuick` submodules plus the three
primitives whose *names* differ between bindings (`Signal`/`Slot`/
`Property` in PySide6 vs. `pyqtSignal`/`pyqtSlot`/`pyqtProperty` in
PyQt6) and a couple of methods whose *call shape* differs (QML global
exposure, import-path setup). `backends/pyqt6.py` is a complete second
implementation of that interface — adding a third binding is "write one
more file here," not "rewrite the engine."

### Why the theme is centralized

`ThemeManager` owns exactly one `Theme` (colors + type + shape +
elevation + state-layer opacities) at a time. `apply()`, `set_dark()`,
`set_seed_color()`, `toggle_dark()` all go through it, and it regenerates
and re-pushes to every surface it's installed on (the QApplication
stylesheet, any QML engines wired via `install_qml()`). There is no
per-screen or per-widget theme state to get out of sync.

### Why some components are QML, not QSS

QSS is a static stylesheet language: no property animation, no runtime
alpha-compositing of a "state layer" over an arbitrary container color,
no elevation shadows that grow on hover. Those are first-class parts of
the M3 spec (press ripples, the animated floating label on text fields,
a switch's thumb/track). Rather than fake them badly in QSS, qt-md3
implements that subset as real QML components and documents the line —
see [Compatibility boundary](#widgets-vs-qml--compatibility-boundary)
and `docs/COVERAGE.md` for exactly which is which, per component.

## Widgets vs. QML — compatibility boundary

**You never have to rewrite your UI to adopt qt-md3.** `qtmd3.apply(app)`
reskins every stock widget it can reach through Qt's stylesheet system —
buttons, fields, lists, menus, tabs, checkboxes, scrollbars, dialogs —
with zero code changes beyond the one `apply()` call.

What it does **not** do is pretend a `QCheckBox` can become an animated
M3 `Switch`, or that a `QLineEdit` can grow a floating label, purely via
QSS — because it can't. For those specific components:

1. Use the `qtmd3.widgets.MD3*` convenience class if one exists (adds an
   M3 *variant* on top of the same base widget — no behavior change, no
   new dependency).
2. Otherwise, embed the QML component for just that one control/screen
   via `QQuickWidget`, or build that screen in QML entirely.

The full per-component breakdown — automatic / needs-MD3-class /
QML-only / not-yet-covered — is **`docs/COVERAGE.md`**. Read it before
assuming any specific widget "just works."

## Public API

```python
theme = qtmd3.apply(
    app,
    seed_color="#6750A4",   # any #RRGGBB; derives the whole palette
    dark=None,               # True / False / None (= follow OS)
    follow_system=True,      # react to live OS theme changes when dark=None
    qml_engine=None,         # optionally wire up QML in the same call
    widgets=None,             # None = auto (True for QApplication)
)

theme.set_seed_color("#006A60")   # re-theme at runtime
theme.set_dark(True)              # force dark; None resumes following the OS
theme.toggle_dark()
theme.set_density("compact")      # "comfortable" (default) | "compact"
theme.install_qml(another_engine) # wire up more QML engines later
theme.changed.connect(my_slot)    # Qt signal, fires on every re-theme

theme.theme          # the current qtmd3.Theme (colors/typescale/shape/elevation/state)
theme.is_dark         # bool
```

`qtmd3.current_theme()` returns the most recently `apply()`-created
manager, for code that doesn't want to thread the object through.

## Installation & packaging

```bash
pip install "qt-md3[pyside6]"   # PySide6 is an extra, not a hard dependency
pip install "qt-md3[pyqt6]"     # or PyQt6
pip install "qt-md3[hct]"       # optional: perceptually-accurate HCT palettes
```

The QML module (`qml/Material3/qmldir` + every `.qml` file) and the
`resources/fonts/` directory are shipped as **package data** —
configured in `pyproject.toml` under `[tool.setuptools.package-data]`
and mirrored in `MANIFEST.in` for sdists. `core/resources.py` locates
them at runtime via `importlib.resources`, which works identically for a
normal install, an editable install, and a zipped wheel. You do not need
to vendor or copy any `.qml` file into your own project.

## Examples

```
examples/
├── widgets_demo/main.py   # Qt Widgets: buttons, cards, inputs, dark-mode/seed-color switcher
└── qml_demo/
    ├── main.py
    └── main.qml            # Qt Quick: every bundled M3 component on one screen
```

```bash
pip install -e ".[dev]"
python examples/widgets_demo/main.py
python examples/qml_demo/main.py
```

## Testing

```bash
pip install -e ".[dev]"
QT_QPA_PLATFORM=offscreen pytest tests/ -v
```

- `tests/test_theme_core.py` — pure-Python: tonal palettes, color scheme
  derivation, stylesheet generation. No Qt binding required.
- `tests/test_apply_pyside6.py` — the real public API against a live
  (offscreen) `QApplication`: applying, dark-mode toggling, re-theming,
  the `changed` signal, `MD3Button`.
- `tests/test_apply_qml.py` — the real public API against a live
  `QQmlApplicationEngine`: every bundled `M3*` component loads and
  resolves `Material3Theme`.

All 16 tests pass as shipped.

## Binding support matrix

| | PySide6 | PyQt6 |
|---|---|---|
| Theme engine (colors/type/shape/elevation/state) | ✅ | ✅ (binding-neutral, no Qt-specific code) |
| Widgets stylesheet | ✅ tested | ✅ implemented, same code path |
| QML integration | ✅ tested | ✅ implemented, same code path |
| `MD3Button`/`MD3Card`/`MD3TextField` | ✅ tested | ✅ implemented |
| Manual QA / CI | ✅ | ⚠️ not yet run in this environment — see `backends/pyqt6.py` docstring |

The PyQt6 backend is a complete implementation against the same
`QtBinding` interface PySide6 uses (same signal/property primitives,
same `expose_qml_global`/`add_qml_import_path` calls) — it just hasn't
had the PySide6 path's manual testing pass yet in this environment,
which didn't have PyQt6 installed. `QTMD3_FORCE_BACKEND=PyQt6` forces it
for testing once both bindings are available in one environment.

## What's honestly not done

- Chips, navigation rail/drawer, snackbar, circular progress — no
  component yet in either widgets or QML (see `docs/COVERAGE.md`).
- The default tonal-palette generator is an HSL approximation, not true
  HCT (install the `hct` extra for that) — see `docs/AUDIT.md`.
- No bundled Roboto font files (licensing/size) — `resources/fonts/`
  documents how to add your own.
- PyQt6 hasn't been run against a live `QApplication` in this particular
  build/test environment (only PySide6 has); the code path is identical
  for both.
