mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-26 20:04:12 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6132cc0503 | |||
| d7052e396b | |||
| e123c55754 | |||
| 268aba60eb | |||
| 313f9533a8 | |||
| 3d1cb671c1 | |||
| c0fc093b4e | |||
| 46c35d2297 | |||
| 2adc58c1c0 | |||
| 82262c5980 | |||
| 6dcb6a2a8f | |||
| c740cdf1ac | |||
| aab0a18506 | |||
| ebab58e4b3 | |||
| 45ca53c7a8 | |||
| 2efce1752d | |||
| bf7bff595e | |||
| 81449faffd |
@@ -1,67 +0,0 @@
|
||||
name: Auto-build doxygen docs
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '**'
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
doxygen:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
show-progress: ''
|
||||
- name: Setup and run doxygen
|
||||
run: sudo apt update && sudo apt install doxygen graphviz qhelpgenerator-qt5 -y
|
||||
- name: Set up Git LFS
|
||||
run: |
|
||||
git lfs install
|
||||
git lfs track "*.qch"
|
||||
- name: Run doxygen
|
||||
run: doxygen Doxyfile
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.MR_TOKEN }}
|
||||
commit-message: update QCH file
|
||||
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
title: Update QCH Help file
|
||||
body: |
|
||||
- Updating QT Help file following commit ${{ github.sha }}.
|
||||
- Auto-generated by [create-pull-request][1]
|
||||
|
||||
[1]: https://github.com/peter-evans/create-pull-request
|
||||
branch: update-qch
|
||||
labels: |
|
||||
qch
|
||||
cicd
|
||||
delete-branch: true
|
||||
add-paths: doc/*.qch
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ${{ github.workspace }}/doc/html/
|
||||
deploy:
|
||||
# Add a dependency to the build job
|
||||
needs: doxygen
|
||||
|
||||
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
|
||||
permissions:
|
||||
pages: write # to deploy to Pages
|
||||
id-token: write # to verify the deployment originates from an appropriate source
|
||||
|
||||
# Deploy to the github-pages environment
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
# Specify runner + deployment step
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4 # or specific "vX.X.X" version tag for this action
|
||||
@@ -707,6 +707,8 @@ set(QET_SRC_FILES
|
||||
${QET_DIR}/sources/ui/conductorpropertiesdialog.h
|
||||
${QET_DIR}/sources/ui/conductorcolortoolbutton.cpp
|
||||
${QET_DIR}/sources/ui/conductorcolortoolbutton.h
|
||||
${QET_DIR}/sources/ui/diagrambgcolorbutton.cpp
|
||||
${QET_DIR}/sources/ui/diagrambgcolorbutton.h
|
||||
${QET_DIR}/sources/ui/conductorpropertieswidget.cpp
|
||||
${QET_DIR}/sources/ui/conductorpropertieswidget.h
|
||||
${QET_DIR}/sources/ui/configsaveloaderwidget.cpp
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# qet-mcp — a Model Context Protocol server for QElectroTech projects
|
||||
|
||||
A small stdio MCP server that lets an AI assistant read and verify
|
||||
QElectroTech projects: what is in a project, what an edit actually
|
||||
changed, and what a whole corpus of projects contains.
|
||||
|
||||
It has **no third-party dependencies** — Python 3.9+ and the standard
|
||||
library only. The MCP SDK is not required.
|
||||
|
||||
## Why
|
||||
|
||||
Verifying a change by screenshot is unreliable, and this tool exists
|
||||
because that unreliability produced two wrong conclusions in one review
|
||||
session:
|
||||
|
||||
- A drag of a multi-element selection *looked* like it had left the
|
||||
symbols behind and detached their labels. Diffing the saved file showed
|
||||
all four elements had moved by an identical `(0, -80)` and **no label
|
||||
had moved at all**. A bug report was one step away from being filed.
|
||||
- An "Apply" button *looked* like it did nothing. It was disabled,
|
||||
because a required field was empty.
|
||||
|
||||
Both times the pixels misled and the model told the truth. So the tools
|
||||
here read the model.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it answers |
|
||||
|---|---|
|
||||
| `qet_project_info` | title, format version, folios, element and conductor counts |
|
||||
| `qet_elements` | placed elements: uuid, type, position, label, information bag |
|
||||
| `qet_conductors` | conductors and their documentation fields; filter by attribute |
|
||||
| `qet_diff` | **what an edit actually changed** — moves with deltas, adds, removes, relabels, conductor field changes |
|
||||
| `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute |
|
||||
| `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts |
|
||||
| `qet_export` | run a headless export (pdf, png, svg, bom, cables, wires, wiring, nets, links, info) |
|
||||
|
||||
Only `qet_export` launches QElectroTech. Everything else parses the file
|
||||
directly, which is faster, needs no display, and cannot be confused by a
|
||||
dialog.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
# list the tools and exit
|
||||
misc/qet-mcp/qet_mcp.py --list
|
||||
|
||||
# speak MCP on stdin/stdout
|
||||
misc/qet-mcp/qet_mcp.py
|
||||
```
|
||||
|
||||
Register it with an MCP client, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"qet": {
|
||||
"command": "python3",
|
||||
"args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Worked examples
|
||||
|
||||
**What did that edit change?**
|
||||
|
||||
```json
|
||||
{"name": "qet_diff", "arguments": {"before": "a.qet", "after": "b.qet"}}
|
||||
```
|
||||
|
||||
```json
|
||||
"elements": { "moved_count": 4,
|
||||
"distinct_move_deltas": [[0.0, -80.0]],
|
||||
"relabelled": [], "info_changed": [] }
|
||||
```
|
||||
|
||||
Four elements moved by one uniform delta; nothing was relabelled. That is
|
||||
the answer a screenshot gave wrongly.
|
||||
|
||||
**How much of a corpus uses a field?**
|
||||
|
||||
```json
|
||||
{"name": "qet_scan",
|
||||
"arguments": {"directory": "examples", "tag": "conductor", "attribute": "cable"}}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "files": 24, "total": 3190, "non_empty": 0, "distinct_values": [] }
|
||||
```
|
||||
|
||||
Across the shipped examples: 3190 conductors, not one with a cable value.
|
||||
|
||||
## Notes and limits
|
||||
|
||||
- **The project database is not reachable from outside the application.**
|
||||
`projectDataBase::newQuery()` and `isReadOnlySelect()` are C++-internal
|
||||
and the JavaScript scripting API exposes no SQL binding, so structural
|
||||
queries here are done over the XML. A `--query` CLI verb, or a scripting
|
||||
binding, would let this server expose the guarded read-only SQL surface
|
||||
instead, and would be a better foundation.
|
||||
- **`qet_export` isolates its launch.** SingleApplication keys its socket
|
||||
on `applicationFilePath()`, so a second launch of the same binary path
|
||||
forwards its request to an already-running instance and returns *that*
|
||||
process's answer with no error. The tool copies the binary to a unique
|
||||
temporary path, gives it a private `HOME`, and runs it on the offscreen
|
||||
platform. A symlink would not work: `applicationFilePath()` resolves it
|
||||
back to the real path.
|
||||
- **The CLI matches its flags exactly.** `--export-bom out.csv` is the
|
||||
supported form; `--export-bom=out.csv` is not recognised as an export
|
||||
at all, so the application starts its interface instead and a headless
|
||||
run hangs. The tool uses the positional form.
|
||||
- **Conductor identity is the hard part of `qet_diff`.** A conductor names
|
||||
its ends with `terminal1`/`terminal2`, and the project format has two
|
||||
schemes: folio-scoped integer ids in older files, terminal-definition
|
||||
uuids plus `element1`/`element2` in newer ones. The integer ids are
|
||||
**renumbered on every save**, so keying on them made all 47 conductors of
|
||||
an untouched `ArduinoLCD.qet` read as 29 removed and 29 re-added the
|
||||
moment the other side had been through QElectroTech. Ends are now keyed
|
||||
by owning element uuid plus terminal, which is stable across a save:
|
||||
measured at 0 colliding keys over 3190 conductors in the 24 shipped
|
||||
examples, and 0 churn on a re-saved but otherwise untouched project.
|
||||
Where an element predates persisted uuids the end cannot be resolved and
|
||||
keeps a `#`-marked unstable key; the diff then reports `unstable_keys`
|
||||
and says so rather than pretending to be comparable.
|
||||
- **Elements** written before persisted uuids fall back to a positional key,
|
||||
which makes a move in such a file read as a remove plus an add rather
|
||||
than as a move.
|
||||
- Read-only by design. Nothing here writes to a project.
|
||||
Binary file not shown.
Executable
+721
@@ -0,0 +1,721 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2006-2026 The QElectroTech Team
|
||||
# This file is part of QElectroTech.
|
||||
#
|
||||
# QElectroTech is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# QElectroTech is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""
|
||||
qet-mcp — a Model Context Protocol server over QElectroTech projects.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
Verifying a QET change by screenshot is unreliable. Twice in one review
|
||||
session a screenshot was read as showing a defect that the saved file
|
||||
proved had not happened: once "dragging a multi-selection leaves the
|
||||
symbols behind and detaches their labels" (the XML showed all four
|
||||
elements moved and no label moved), and once "Apply does nothing" (Apply
|
||||
was disabled because a required field was empty). Both times the pixels
|
||||
misled and the model told the truth.
|
||||
|
||||
So the primary tools here read the *model*, not the screen, and the
|
||||
primary tool is qet_diff: do the thing, then ask what actually changed.
|
||||
|
||||
DESIGN
|
||||
|
||||
Most tools parse the .qet XML directly and never launch QElectroTech.
|
||||
That is deliberate: it is fast, deterministic, needs no display, and
|
||||
cannot be confused by a dialog. Only qet_export shells out to the
|
||||
binary, and it carries the launch traps with it (see _run_qet).
|
||||
|
||||
The project database would be a better query surface than XML, but it is
|
||||
not reachable from outside the application: projectDataBase::newQuery()
|
||||
and isReadOnlySelect() are C++-internal and the JavaScript scripting API
|
||||
exposes no SQL binding. Until it does, structure lives here.
|
||||
|
||||
PROTOCOL
|
||||
|
||||
Line-delimited JSON-RPC 2.0 on stdin/stdout, per MCP's stdio transport.
|
||||
Nothing but protocol goes to stdout; diagnostics go to stderr.
|
||||
No third-party dependencies — the MCP SDK is not assumed to be present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
SERVER_NAME = "qet-mcp"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
DEFAULT_PROTOCOL = "2025-06-18"
|
||||
|
||||
EXPORT_FORMATS = {
|
||||
"pdf": "--export-pdf",
|
||||
"png": "--export-png",
|
||||
"svg": "--export-svg",
|
||||
"bom": "--export-bom",
|
||||
"cables": "--export-cables",
|
||||
"wires": "--export-wires",
|
||||
"wiring": "--export-wiring",
|
||||
"nets": "--export-nets",
|
||||
"links": "--export-links",
|
||||
"info": "--info",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# model reading
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _root(path: str) -> ET.Element:
|
||||
p = Path(path).expanduser()
|
||||
if not p.is_file():
|
||||
raise ValueError(f"no such file: {p}")
|
||||
try:
|
||||
return ET.parse(p).getroot()
|
||||
except ET.ParseError as exc:
|
||||
raise ValueError(f"{p.name} is not parseable XML: {exc}") from exc
|
||||
|
||||
|
||||
def _element_info(el: ET.Element) -> dict:
|
||||
"""The <elementInformations> bag, as a plain dict."""
|
||||
out = {}
|
||||
bag = el.find("elementInformations")
|
||||
if bag is not None:
|
||||
for info in bag.findall("elementInformation"):
|
||||
name = info.get("name")
|
||||
if name:
|
||||
out[name] = (info.text or "").strip()
|
||||
return out
|
||||
|
||||
|
||||
def _folios(root: ET.Element):
|
||||
"""Yield (index, diagram) for each folio, 1-based as the UI numbers them."""
|
||||
for i, d in enumerate(root.iter("diagram"), start=1):
|
||||
yield i, d
|
||||
|
||||
|
||||
def _elements(root: ET.Element):
|
||||
for i, d in _folios(root):
|
||||
for el in d.iter("element"):
|
||||
yield i, el
|
||||
|
||||
|
||||
def _conductors(root: ET.Element):
|
||||
for i, d in _folios(root):
|
||||
index = _terminal_index(d)
|
||||
for c in d.iter("conductor"):
|
||||
yield i, c, index
|
||||
|
||||
|
||||
def _element_row(folio: int, el: ET.Element) -> dict:
|
||||
info = _element_info(el)
|
||||
etype = el.get("type", "")
|
||||
return {
|
||||
"folio": folio,
|
||||
"uuid": el.get("uuid", ""),
|
||||
"type": etype,
|
||||
"name": etype.rsplit("/", 1)[-1].removesuffix(".elmt"),
|
||||
"x": el.get("x"),
|
||||
"y": el.get("y"),
|
||||
"label": info.get("label", ""),
|
||||
"info": info,
|
||||
}
|
||||
|
||||
|
||||
def _terminal_index(diagram: ET.Element) -> dict:
|
||||
"""Map a folio's terminal ids to an identity that survives a save.
|
||||
|
||||
A conductor names its ends with terminal1/terminal2, which are plain
|
||||
integers scoped to the folio -- and QElectroTech reassigns them on every
|
||||
write, in whatever order it happens to serialise the elements. The same
|
||||
untouched conductor comes back as terminal1="1" terminal2="16" before a
|
||||
save and terminal1="34" terminal2="15" after one. Keying a conductor on
|
||||
that pair, which this tool used to do, made every conductor in the file
|
||||
read as removed-and-re-added whenever the "after" side had been through
|
||||
QElectroTech -- which is the common case for "what did that edit change",
|
||||
so the conductor half of the diff was noise precisely when it was needed.
|
||||
|
||||
So resolve each id to (owning element uuid, terminal position and
|
||||
orientation inside that element). Element uuids are persisted and
|
||||
stable; the terminal's local geometry comes from the element definition
|
||||
and does not move when the element moves. That pair is the same basis
|
||||
QET's own Terminal::stableUuid() uses for terminals with no uuid of
|
||||
their own, and it is stable for the same reasons.
|
||||
|
||||
Conductors in the corpus carry no element1/element2 attribute -- 0 of
|
||||
47 in ArduinoLCD.qet, 0 of 67 in 741.qet -- so this mapping has to be
|
||||
built from the elements rather than read off the conductor.
|
||||
"""
|
||||
index = {}
|
||||
for el in diagram.iter("element"):
|
||||
uuid = el.get("uuid", "")
|
||||
if not uuid:
|
||||
# Old enough to predate persisted element uuids. Leaving these
|
||||
# ids unresolved is deliberate: keyed on terminal geometry
|
||||
# alone, every element of the same type collapses together --
|
||||
# in schema_indus.qet that merged nine distinct conductors onto
|
||||
# one key, which is worse than the instability it was meant to
|
||||
# fix. An unresolved end keeps them apart and stays visibly
|
||||
# marked with a "#" so the caller can see the diff is on the
|
||||
# unstable footing that file forces.
|
||||
continue
|
||||
for t in el.iter("terminal"):
|
||||
tid = t.get("id")
|
||||
if tid is None:
|
||||
continue
|
||||
index[tid] = (f"{uuid}@{t.get('x','?')},{t.get('y','?')}"
|
||||
f",{t.get('orientation','?')}")
|
||||
return index
|
||||
|
||||
|
||||
def _conductor_key(folio: int, c: ET.Element, index: dict) -> str:
|
||||
"""Identify a conductor by its two ends, in whichever scheme it uses.
|
||||
|
||||
The project format has two, and a file can hold both at once -- the
|
||||
same folio, after an edit, carries legacy conductors and new ones:
|
||||
|
||||
- legacy: terminal1/terminal2 are the folio-scoped integer ids, and
|
||||
there is no element1/element2. Resolve them through index.
|
||||
- current: terminal1/terminal2 are terminal uuids from the element
|
||||
*definition*, with element1/element2 naming the placed instances.
|
||||
The terminal uuid alone is not an identity -- two coils of the same
|
||||
type have the same one on both ends, so a conductor between them
|
||||
would key as a self-loop -- so it is the (instance, terminal) pair
|
||||
that identifies an end.
|
||||
"""
|
||||
ends = []
|
||||
for elem_attr, term_attr, name_attr in (("element1", "terminal1", "terminalname1"),
|
||||
("element2", "terminal2", "terminalname2")):
|
||||
tid = c.get(term_attr, "?")
|
||||
owner = c.get(elem_attr)
|
||||
if owner:
|
||||
ends.append(f"{owner}/{tid or c.get(name_attr, '?')}")
|
||||
else:
|
||||
# An id with no element behind it stays visible as itself
|
||||
# rather than silently collapsing conductors onto one key.
|
||||
ends.append(index.get(tid, f"#{tid}"))
|
||||
# A conductor is undirected: whichever end QET happens to write first,
|
||||
# it is the same connection.
|
||||
return f"{folio}:" + "--".join(sorted(ends))
|
||||
|
||||
|
||||
def _conductor_row(folio: int, c: ET.Element, index: dict | None = None) -> dict:
|
||||
return {
|
||||
"folio": folio,
|
||||
"uuid": c.get("uuid", ""),
|
||||
"key": _conductor_key(folio, c, index or {}),
|
||||
"num": c.get("num", ""),
|
||||
"formula": c.get("formula", ""),
|
||||
"cable": c.get("cable", ""),
|
||||
"bus": c.get("bus", ""),
|
||||
"function": c.get("function", ""),
|
||||
"color": c.get("conductor_color", ""),
|
||||
"section": c.get("conductor_section", ""),
|
||||
"type": c.get("type", ""),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# tools
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def tool_project_info(path: str) -> dict:
|
||||
root = _root(path)
|
||||
folios = []
|
||||
for i, d in _folios(root):
|
||||
folios.append({
|
||||
"index": i,
|
||||
"title": d.get("title", ""),
|
||||
"elements": sum(1 for _ in d.iter("element")),
|
||||
"conductors": sum(1 for _ in d.iter("conductor")),
|
||||
})
|
||||
return {
|
||||
"file": str(Path(path).expanduser()),
|
||||
"title": root.get("title", ""),
|
||||
"version": root.get("version", ""),
|
||||
"folio_count": len(folios),
|
||||
"element_count": sum(f["elements"] for f in folios),
|
||||
"conductor_count": sum(f["conductors"] for f in folios),
|
||||
"folios": folios,
|
||||
}
|
||||
|
||||
|
||||
def tool_elements(path: str, folio: int | None = None,
|
||||
name_contains: str | None = None, limit: int = 200) -> dict:
|
||||
rows = []
|
||||
for i, el in _elements(_root(path)):
|
||||
if folio is not None and i != folio:
|
||||
continue
|
||||
row = _element_row(i, el)
|
||||
if name_contains and name_contains.lower() not in row["name"].lower():
|
||||
continue
|
||||
rows.append(row)
|
||||
return {"count": len(rows), "truncated": len(rows) > limit,
|
||||
"elements": rows[:limit]}
|
||||
|
||||
|
||||
def tool_conductors(path: str, folio: int | None = None,
|
||||
attribute: str | None = None,
|
||||
non_empty: bool = False, limit: int = 200) -> dict:
|
||||
rows = []
|
||||
for i, c, ix in _conductors(_root(path)):
|
||||
if folio is not None and i != folio:
|
||||
continue
|
||||
row = _conductor_row(i, c, ix)
|
||||
if attribute is not None:
|
||||
value = c.get(attribute, "")
|
||||
if non_empty and not value.strip():
|
||||
continue
|
||||
row["value"] = value
|
||||
rows.append(row)
|
||||
return {"count": len(rows), "truncated": len(rows) > limit,
|
||||
"conductors": rows[:limit]}
|
||||
|
||||
|
||||
def tool_diff(before: str, after: str) -> dict:
|
||||
"""Structural diff of two .qet files.
|
||||
|
||||
This is the tool that answers "what did that edit actually change",
|
||||
which is the question a screenshot answers badly.
|
||||
"""
|
||||
# Key on uuid where there is one. Files written before conductors and
|
||||
# elements carried persisted uuids fall back to a positional key, which
|
||||
# is why a move in such a file reads as remove+add rather than a move.
|
||||
a_el, b_el = {}, {}
|
||||
for i, e in _elements(_root(before)):
|
||||
r = _element_row(i, e)
|
||||
a_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
|
||||
for i, e in _elements(_root(after)):
|
||||
r = _element_row(i, e)
|
||||
b_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r
|
||||
|
||||
moved, relabelled, changed_info = [], [], []
|
||||
for k, a in a_el.items():
|
||||
b = b_el.get(k)
|
||||
if b is None:
|
||||
continue
|
||||
if (a["x"], a["y"]) != (b["x"], b["y"]):
|
||||
moved.append({
|
||||
"uuid": k, "name": a["name"], "folio": a["folio"],
|
||||
"from": [a["x"], a["y"]], "to": [b["x"], b["y"]],
|
||||
"delta": [_num(b["x"]) - _num(a["x"]),
|
||||
_num(b["y"]) - _num(a["y"])],
|
||||
})
|
||||
if a["label"] != b["label"]:
|
||||
relabelled.append({"uuid": k, "name": a["name"],
|
||||
"from": a["label"], "to": b["label"]})
|
||||
if a["info"] != b["info"]:
|
||||
changed_info.append({"uuid": k, "name": a["name"],
|
||||
"from": a["info"], "to": b["info"]})
|
||||
|
||||
a_co = {r["key"]: r for i, c, ix in _conductors(_root(before))
|
||||
for r in [_conductor_row(i, c, ix)]}
|
||||
b_co = {r["key"]: r for i, c, ix in _conductors(_root(after))
|
||||
for r in [_conductor_row(i, c, ix)]}
|
||||
# An end that could not be resolved to an element is keyed on the
|
||||
# folio-scoped integer id, which QElectroTech reassigns on every write.
|
||||
# Say so rather than presenting the result as if it were comparable:
|
||||
# in such a file an untouched conductor can read as removed and re-added.
|
||||
shaky = sum(1 for k in set(a_co) | set(b_co) if "#" in k)
|
||||
unstable = {} if not shaky else {
|
||||
"unstable_keys": shaky,
|
||||
"warning": "some conductors sit on elements with no persisted uuid, so "
|
||||
"they are keyed on folio-scoped terminal ids that "
|
||||
"QElectroTech renumbers on save; added/removed entries "
|
||||
"marked with # may be the same conductor, not a change",
|
||||
}
|
||||
conductor_changes = []
|
||||
for k, a in a_co.items():
|
||||
b = b_co.get(k)
|
||||
if b is None:
|
||||
continue
|
||||
fields = {f: [a[f], b[f]] for f in
|
||||
("num", "formula", "cable", "bus", "color", "section",
|
||||
"function", "type")
|
||||
if a[f] != b[f]}
|
||||
if fields:
|
||||
conductor_changes.append({"key": k, "changed": fields})
|
||||
|
||||
deltas = sorted({tuple(m["delta"]) for m in moved})
|
||||
return {
|
||||
"elements": {
|
||||
"before": len(a_el), "after": len(b_el),
|
||||
"added": sorted(set(b_el) - set(a_el))[:50],
|
||||
"removed": sorted(set(a_el) - set(b_el))[:50],
|
||||
"moved": moved[:100],
|
||||
"moved_count": len(moved),
|
||||
"distinct_move_deltas": [list(d) for d in deltas],
|
||||
"relabelled": relabelled[:50],
|
||||
"info_changed": changed_info[:50],
|
||||
},
|
||||
"conductors": {
|
||||
"before": len(a_co), "after": len(b_co),
|
||||
"added": sorted(set(b_co) - set(a_co))[:50],
|
||||
"removed": sorted(set(a_co) - set(b_co))[:50],
|
||||
"changed": conductor_changes[:100],
|
||||
"changed_count": len(conductor_changes),
|
||||
**unstable,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _num(v) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def tool_scan(directory: str, tag: str = "conductor",
|
||||
attribute: str = "cable", recursive: bool = True) -> dict:
|
||||
"""Sweep every .qet in a directory, counting how many <tag> carry a
|
||||
non-empty `attribute`.
|
||||
|
||||
This is the corpus question: "3190 conductors, 0 cable values across
|
||||
25 projects" is exactly one call to this tool.
|
||||
"""
|
||||
d = Path(directory).expanduser()
|
||||
if not d.is_dir():
|
||||
raise ValueError(f"not a directory: {d}")
|
||||
files = sorted(d.rglob("*.qet") if recursive else d.glob("*.qet"))
|
||||
total = non_empty = 0
|
||||
per_file, values, unreadable = [], {}, []
|
||||
for f in files:
|
||||
try:
|
||||
root = ET.parse(f).getroot()
|
||||
except ET.ParseError as exc:
|
||||
unreadable.append({"file": f.name, "error": str(exc)})
|
||||
continue
|
||||
n = ne = 0
|
||||
for node in root.iter(tag):
|
||||
n += 1
|
||||
v = (node.get(attribute) or "").strip()
|
||||
if v:
|
||||
ne += 1
|
||||
values[v] = values.get(v, 0) + 1
|
||||
total += n
|
||||
non_empty += ne
|
||||
per_file.append({"file": f.name, tag: n, "non_empty": ne})
|
||||
return {
|
||||
"directory": str(d), "files": len(files), "unreadable": unreadable,
|
||||
"tag": tag, "attribute": attribute,
|
||||
"total": total, "non_empty": non_empty,
|
||||
"distinct_values": sorted(values.items(), key=lambda kv: -kv[1])[:25],
|
||||
"per_file": per_file,
|
||||
}
|
||||
|
||||
|
||||
def tool_element_info(path: str) -> dict:
|
||||
"""Introspect a .elmt: names, terminals, and which info fields it carries."""
|
||||
root = _root(path)
|
||||
names = {n.get("lang"): (n.text or "") for n in root.iter("name")}
|
||||
terminals = [{"x": t.get("x"), "y": t.get("y"),
|
||||
"orientation": t.get("orientation"),
|
||||
"name": t.get("name", ""), "type": t.get("type", "")}
|
||||
for t in root.iter("terminal")]
|
||||
info_fields = sorted({(i.text or "").strip()
|
||||
for i in root.iter("info_name") if (i.text or "").strip()})
|
||||
parts = {}
|
||||
desc = root.find("description")
|
||||
for child in (desc if desc is not None else []):
|
||||
parts[child.tag] = parts.get(child.tag, 0) + 1
|
||||
return {
|
||||
"file": str(Path(path).expanduser()),
|
||||
"type": root.get("type", ""), "link_type": root.get("link_type", ""),
|
||||
"width": root.get("width"), "height": root.get("height"),
|
||||
"names": names,
|
||||
"terminal_count": len(terminals), "terminals": terminals,
|
||||
"info_fields": info_fields,
|
||||
"parts": parts,
|
||||
}
|
||||
|
||||
|
||||
def _run_qet(binary: str, args: list[str], timeout: int = 180) -> dict:
|
||||
"""Launch QElectroTech headlessly, carrying the known launch traps.
|
||||
|
||||
SingleApplication keys its socket on applicationFilePath(), so a second
|
||||
launch of the same path forwards its request to an already-running
|
||||
instance and returns THAT process's answer with no error. Copying the
|
||||
binary to a unique path gives this run its own socket. A symlink will
|
||||
not do: applicationFilePath() resolves it back.
|
||||
"""
|
||||
src = Path(binary).expanduser()
|
||||
if not src.is_file() or not os.access(src, os.X_OK):
|
||||
raise ValueError(f"not an executable: {src}")
|
||||
with tempfile.TemporaryDirectory(prefix="qet-mcp-") as tmp:
|
||||
sandbox = Path(tmp)
|
||||
exe = sandbox / f"qet-mcp-{os.getpid()}"
|
||||
shutil.copy2(src, exe)
|
||||
home = sandbox / "home"
|
||||
(home / ".config").mkdir(parents=True)
|
||||
(home / ".local" / "share").mkdir(parents=True)
|
||||
env = dict(os.environ,
|
||||
HOME=str(home),
|
||||
XDG_CONFIG_HOME=str(home / ".config"),
|
||||
XDG_DATA_HOME=str(home / ".local" / "share"),
|
||||
QT_QPA_PLATFORM="offscreen")
|
||||
try:
|
||||
p = subprocess.run([str(exe), *args], env=env, timeout=timeout,
|
||||
capture_output=True, text=True)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "timed_out": True, "timeout_s": timeout,
|
||||
"hint": "a modal dialog during load will hang a headless "
|
||||
"run; check the project's format version"}
|
||||
return {"ok": p.returncode == 0, "exit_code": p.returncode,
|
||||
"stdout": p.stdout[-4000:], "stderr": p.stderr[-4000:]}
|
||||
|
||||
|
||||
def tool_export(binary: str, project: str, format: str, output: str,
|
||||
timeout: int = 180) -> dict:
|
||||
if format not in EXPORT_FORMATS:
|
||||
raise ValueError(f"unknown format {format!r}; "
|
||||
f"expected one of {', '.join(sorted(EXPORT_FORMATS))}")
|
||||
proj = Path(project).expanduser()
|
||||
if not proj.is_file():
|
||||
raise ValueError(f"no such project: {proj}")
|
||||
# The CLI matches its flags by exact string (cli_export.cpp:828) and takes
|
||||
# the project and output as the two positional arguments after the flag
|
||||
# (:862, :882). A "--export-bom=out.csv" form is NOT recognised: it fails
|
||||
# the flag test, so the run is not treated as an export at all and the
|
||||
# application starts its GUI instead, which then hangs on an offscreen
|
||||
# platform. Order matters here.
|
||||
flag = EXPORT_FORMATS[format]
|
||||
result = _run_qet(binary, [flag, str(proj), output], timeout)
|
||||
out = Path(output).expanduser()
|
||||
result["output"] = str(out)
|
||||
result["output_exists"] = out.exists()
|
||||
if out.exists() and out.is_file():
|
||||
result["output_bytes"] = out.stat().st_size
|
||||
return result
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "qet_project_info",
|
||||
"description": "Summarise a .qet project: title, format version, folios, "
|
||||
"and element/conductor counts per folio. Reads the file "
|
||||
"directly; does not launch QElectroTech.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "path to a .qet file"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
"handler": lambda a: tool_project_info(a["path"]),
|
||||
},
|
||||
{
|
||||
"name": "qet_elements",
|
||||
"description": "List placed elements with uuid, type, position, label and "
|
||||
"their elementInformations bag. Optionally filter by folio "
|
||||
"or by element name substring.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"folio": {"type": "integer", "description": "1-based folio number"},
|
||||
"name_contains": {"type": "string"},
|
||||
"limit": {"type": "integer", "default": 200},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
"handler": lambda a: tool_elements(a["path"], a.get("folio"),
|
||||
a.get("name_contains"),
|
||||
a.get("limit", 200)),
|
||||
},
|
||||
{
|
||||
"name": "qet_conductors",
|
||||
"description": "List conductors with their documentation fields (num, "
|
||||
"formula, cable, bus, function, colour, section). Set "
|
||||
"attribute+non_empty to find only conductors that carry a "
|
||||
"value for one attribute.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"folio": {"type": "integer"},
|
||||
"attribute": {"type": "string",
|
||||
"description": "an XML attribute of <conductor>, e.g. cable"},
|
||||
"non_empty": {"type": "boolean", "default": False},
|
||||
"limit": {"type": "integer", "default": 200},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
"handler": lambda a: tool_conductors(a["path"], a.get("folio"),
|
||||
a.get("attribute"),
|
||||
a.get("non_empty", False),
|
||||
a.get("limit", 200)),
|
||||
},
|
||||
{
|
||||
"name": "qet_diff",
|
||||
"description": "Structurally diff two .qet files: which elements moved and "
|
||||
"by what delta, which were added, removed or relabelled, and "
|
||||
"which conductor fields changed. Use this to verify what an "
|
||||
"edit actually did, rather than reading a screenshot.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"before": {"type": "string"},
|
||||
"after": {"type": "string"},
|
||||
},
|
||||
"required": ["before", "after"],
|
||||
},
|
||||
"handler": lambda a: tool_diff(a["before"], a["after"]),
|
||||
},
|
||||
{
|
||||
"name": "qet_scan",
|
||||
"description": "Sweep every .qet in a directory and count how many nodes of "
|
||||
"a given tag carry a non-empty attribute, with the distinct "
|
||||
"values found. For corpus questions such as how many "
|
||||
"conductors in the shipped examples have a cable value.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {"type": "string"},
|
||||
"tag": {"type": "string", "default": "conductor"},
|
||||
"attribute": {"type": "string", "default": "cable"},
|
||||
"recursive": {"type": "boolean", "default": True},
|
||||
},
|
||||
"required": ["directory"],
|
||||
},
|
||||
"handler": lambda a: tool_scan(a["directory"], a.get("tag", "conductor"),
|
||||
a.get("attribute", "cable"),
|
||||
a.get("recursive", True)),
|
||||
},
|
||||
{
|
||||
"name": "qet_element_info",
|
||||
"description": "Introspect a .elmt element definition: translated names, "
|
||||
"terminals, which dynamic-text info fields it carries, and a "
|
||||
"count of its drawing parts.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string", "description": "path to a .elmt file"}},
|
||||
"required": ["path"],
|
||||
},
|
||||
"handler": lambda a: tool_element_info(a["path"]),
|
||||
},
|
||||
{
|
||||
"name": "qet_export",
|
||||
"description": "Run a QElectroTech export headlessly (pdf, png, svg, bom, "
|
||||
"cables, wires, wiring, nets, links, info). Launches the "
|
||||
"binary in an isolated sandbox so it cannot be captured by, "
|
||||
"or capture, a running QElectroTech.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"binary": {"type": "string", "description": "path to the qelectrotech executable"},
|
||||
"project": {"type": "string"},
|
||||
"format": {"type": "string", "enum": sorted(EXPORT_FORMATS)},
|
||||
"output": {"type": "string"},
|
||||
"timeout": {"type": "integer", "default": 180},
|
||||
},
|
||||
"required": ["binary", "project", "format", "output"],
|
||||
},
|
||||
"handler": lambda a: tool_export(a["binary"], a["project"], a["format"],
|
||||
a["output"], a.get("timeout", 180)),
|
||||
},
|
||||
]
|
||||
|
||||
_BY_NAME = {t["name"]: t for t in TOOLS}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# JSON-RPC / MCP plumbing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _public(tool: dict) -> dict:
|
||||
return {k: v for k, v in tool.items() if k != "handler"}
|
||||
|
||||
|
||||
def handle(msg: dict) -> dict | None:
|
||||
method = msg.get("method")
|
||||
mid = msg.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
want = (msg.get("params") or {}).get("protocolVersion")
|
||||
return _ok(mid, {
|
||||
"protocolVersion": want or DEFAULT_PROTOCOL,
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
||||
})
|
||||
|
||||
if method in ("notifications/initialized", "initialized"):
|
||||
return None # notification: no reply
|
||||
|
||||
if method == "ping":
|
||||
return _ok(mid, {})
|
||||
|
||||
if method == "tools/list":
|
||||
return _ok(mid, {"tools": [_public(t) for t in TOOLS]})
|
||||
|
||||
if method == "tools/call":
|
||||
params = msg.get("params") or {}
|
||||
name = params.get("name")
|
||||
tool = _BY_NAME.get(name)
|
||||
if tool is None:
|
||||
return _err(mid, -32602, f"unknown tool: {name}")
|
||||
try:
|
||||
result = tool["handler"](params.get("arguments") or {})
|
||||
text = json.dumps(result, indent=2, ensure_ascii=False)
|
||||
return _ok(mid, {"content": [{"type": "text", "text": text}]})
|
||||
except Exception as exc: # surfaced to the model, not the transport
|
||||
return _ok(mid, {
|
||||
"isError": True,
|
||||
"content": [{"type": "text",
|
||||
"text": f"{type(exc).__name__}: {exc}"}],
|
||||
})
|
||||
|
||||
if mid is None:
|
||||
return None
|
||||
return _err(mid, -32601, f"method not found: {method}")
|
||||
|
||||
|
||||
def _ok(mid, result):
|
||||
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
||||
|
||||
|
||||
def _err(mid, code, message):
|
||||
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def serve(stdin=sys.stdin, stdout=sys.stdout) -> None:
|
||||
for line in stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
print(json.dumps(_err(None, -32700, f"parse error: {exc}")),
|
||||
file=stdout, flush=True)
|
||||
continue
|
||||
reply = handle(msg)
|
||||
if reply is not None:
|
||||
print(json.dumps(reply, ensure_ascii=False), file=stdout, flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("--list", "-l"):
|
||||
for t in TOOLS:
|
||||
print(f"{t['name']}\n {t['description']}\n")
|
||||
return 0
|
||||
serve()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -505,7 +505,10 @@ void BorderTitleBlock::draw(QPainter *painter)
|
||||
{
|
||||
//Set the QPainter
|
||||
painter -> save();
|
||||
QPen pen(Qt::black);
|
||||
//Use a pen color that contrasts with the background
|
||||
QColor border_color = Diagram::background_color.lightness() < 128
|
||||
? QColor(Qt::white) : QColor(Qt::black);
|
||||
QPen pen(border_color);
|
||||
painter -> setPen(pen);
|
||||
painter -> setBrush(Qt::NoBrush);
|
||||
|
||||
|
||||
@@ -499,6 +499,7 @@ void ConductorProperties::applyForEqualAttributes(QList<ConductorProperties> lis
|
||||
horiz_rotate_text = cp.horiz_rotate_text;
|
||||
m_vertical_alignment = cp.m_vertical_alignment;
|
||||
m_horizontal_alignment = cp.m_horizontal_alignment;
|
||||
style = cp.style;
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -555,6 +556,18 @@ void ConductorProperties::applyForEqualAttributes(QList<ConductorProperties> lis
|
||||
m_dash_size = i_value;
|
||||
equal = true;
|
||||
|
||||
//style
|
||||
Qt::PenStyle pen_style;
|
||||
pen_style = clist.first().style;
|
||||
for(ConductorProperties cp : clist)
|
||||
{
|
||||
if (cp.style != pen_style)
|
||||
equal = false;
|
||||
}
|
||||
if (equal)
|
||||
style = pen_style;
|
||||
equal = true;
|
||||
|
||||
//text
|
||||
s_value = clist.first().text;
|
||||
for(ConductorProperties cp : clist)
|
||||
|
||||
+6
-5
@@ -1709,10 +1709,7 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
//Get the top left corner of the rectangle that contain all added items
|
||||
QRectF items_rect;
|
||||
for (auto item : added_items) {
|
||||
items_rect = items_rect.united(
|
||||
item->mapToScene(
|
||||
item->boundingRect()
|
||||
).boundingRect());
|
||||
items_rect = items_rect.united(item->mapToScene(item->boundingRect()).boundingRect());
|
||||
}
|
||||
|
||||
QPointF point_ = items_rect.topLeft();
|
||||
@@ -1720,8 +1717,12 @@ bool Diagram::fromXml(QDomElement &document,
|
||||
position.y() - point_.y()));
|
||||
|
||||
//Translate all added items
|
||||
for (auto qgi : added_items)
|
||||
for (auto qgi : added_items) {
|
||||
qgi->setPos(qgi->pos() += pos_);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
}
|
||||
|
||||
// Load conductor
|
||||
|
||||
@@ -67,7 +67,19 @@ m_preview_item(nullptr)
|
||||
dummy_diagram->setDisplayGrid(false);
|
||||
dummy_diagram->fromXml(diagram_node, QPointF(0, 0), false, nullptr);
|
||||
|
||||
// Compute bounding rect of TOP-LEVEL items only (matching fromXml's added_items logic)
|
||||
// Child items (DynamicElementTextItem, Terminal) are NOT included - they move with parents
|
||||
QRectF top_level_rect;
|
||||
for (auto *item : dummy_diagram->items()) {
|
||||
if (!item->parentItem()) {
|
||||
top_level_rect = top_level_rect.united(
|
||||
item->mapToScene(item->boundingRect()).boundingRect());
|
||||
}
|
||||
}
|
||||
m_items_top_left = top_level_rect.topLeft();
|
||||
|
||||
QRectF scene_rect = dummy_diagram->itemsBoundingRect();
|
||||
|
||||
if (!scene_rect.isEmpty()) {
|
||||
QPixmap pixmap(scene_rect.toAlignedRect().size());
|
||||
pixmap.fill(Qt::transparent);
|
||||
@@ -80,10 +92,11 @@ m_preview_item(nullptr)
|
||||
}
|
||||
}
|
||||
|
||||
if (m_preview_item) {
|
||||
m_preview_item->setPos(Diagram::snapToGrid(pos));
|
||||
m_preview_item->setOpacity(0.6);
|
||||
m_diagram->addItem(m_preview_item);
|
||||
if (m_preview_item) {
|
||||
QPointF snapped = Diagram::snapToGrid(pos);
|
||||
m_preview_item->setPos(snapped);
|
||||
m_preview_item->setOpacity(0.6);
|
||||
m_diagram->addItem(m_preview_item);
|
||||
m_running = true;
|
||||
}
|
||||
|
||||
@@ -117,6 +130,7 @@ void DiagramEventAddMacro::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
if (m_preview_item) {
|
||||
const auto pos_{Diagram::snapToGrid(event->scenePos())};
|
||||
|
||||
m_preview_item->setPos(pos_);
|
||||
|
||||
if (m_status_bar) {
|
||||
@@ -141,7 +155,8 @@ void DiagramEventAddMacro::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||
emit finish();
|
||||
}
|
||||
else if (event->button() == Qt::LeftButton) {
|
||||
addMacro(Diagram::snapToGrid(event->scenePos()));
|
||||
QPointF snapped = Diagram::snapToGrid(event->scenePos());
|
||||
addMacro(snapped);
|
||||
}
|
||||
}
|
||||
event->setAccepted(true);
|
||||
@@ -238,10 +253,9 @@ void DiagramEventAddMacro::addMacro(QPointF final_pos)
|
||||
|
||||
if (!diagram_node.isNull()) {
|
||||
QDomElement cloned_node = diagram_node.cloneNode(true).toElement();
|
||||
|
||||
DiagramContent pasted_content;
|
||||
|
||||
m_diagram->fromXml(cloned_node, final_pos, false, &pasted_content);
|
||||
m_diagram->fromXml(cloned_node, final_pos + m_items_top_left, false, &pasted_content);
|
||||
m_diagram->refreshContents();
|
||||
|
||||
// Prevent PasteDiagramCommand from erasing labels (BMK)
|
||||
|
||||
@@ -40,6 +40,7 @@ private:
|
||||
QDomDocument m_macro_doc;
|
||||
QGraphicsPixmapItem *m_preview_item;
|
||||
QPointer<QStatusBar> m_status_bar;
|
||||
QPointF m_items_top_left; // top-left of bounding rect of top-level items in the macro (for correct placement offset)
|
||||
};
|
||||
|
||||
#endif // DIAGRAMEVENTADDMACRO_H
|
||||
|
||||
@@ -99,7 +99,7 @@ bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event)
|
||||
*/
|
||||
void PaletteGraphicsView::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
if (invertsLightness())
|
||||
if (invertsLightness() && !customBackgroundColor())
|
||||
{
|
||||
paintInverted(event);
|
||||
return;
|
||||
|
||||
@@ -63,6 +63,9 @@ class PaletteGraphicsView : public QGraphicsView
|
||||
|
||||
bool invertsLightness() const;
|
||||
|
||||
static void setCustomBackgroundColor(bool custom) { s_custom_bg = custom; }
|
||||
static bool customBackgroundColor() { return s_custom_bg; }
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
@@ -90,6 +93,7 @@ class PaletteGraphicsView : public QGraphicsView
|
||||
QPainter m_buffer_painter;
|
||||
/// True while paintEvent() paints for an inverted display.
|
||||
bool m_inverting = false;
|
||||
static inline bool s_custom_bg = false;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
#include "shortcutmanager.h"
|
||||
#include "ui/bomexportdialog.h"
|
||||
#include "ui/conductorcolortoolbutton.h"
|
||||
#include "ui/diagrambgcolorbutton.h"
|
||||
#include "ui/jumptoelementdialog.h"
|
||||
#include "ui/diagrampropertieseditordockwidget.h"
|
||||
#include "ui/backupdialog.h"
|
||||
@@ -413,15 +414,8 @@ void QETDiagramEditor::setUpActions()
|
||||
pv->project()->setAutoBreakConductor(abc);
|
||||
});
|
||||
|
||||
//Switch background color
|
||||
m_grey_background = new QAction (QET::Icons::DiagramBg, tr("Couleur de fond blanc/gris","Tool tip of white/grey background button"), this);
|
||||
m_grey_background -> setStatusTip (tr("Affiche la couleur de fond du folio en blanc ou en gris", "Status tip of white/grey background button"));
|
||||
m_grey_background -> setCheckable (true);
|
||||
connect (m_grey_background, &QAction::triggered, [this](bool checked) {
|
||||
Diagram::background_color = checked ? Qt::darkGray : Qt::white;
|
||||
if (this->currentDiagramView() && this->currentDiagramView()->diagram())
|
||||
this->currentDiagramView()->diagram()->update();
|
||||
});
|
||||
//Diagram background color picker
|
||||
m_background_color_button = new DiagramBgColorToolButton(this, this);
|
||||
|
||||
//Draw or not the background grid
|
||||
m_draw_grid = new QAction ( QET::Icons::Grid, tr("Afficher la grille"), this);
|
||||
@@ -912,7 +906,7 @@ void QETDiagramEditor::setUpToolBar()
|
||||
view_tool_bar -> addSeparator();
|
||||
view_tool_bar -> addAction(m_draw_grid);
|
||||
view_tool_bar -> addAction(m_draw_guides);
|
||||
view_tool_bar -> addAction (m_grey_background);
|
||||
view_tool_bar -> addWidget(m_background_color_button);
|
||||
view_tool_bar -> addSeparator();
|
||||
view_tool_bar -> addActions(m_zoom_action_toolBar);
|
||||
|
||||
@@ -1063,7 +1057,7 @@ void QETDiagramEditor::setUpMenu()
|
||||
menu_affichage -> addSeparator();
|
||||
menu_affichage -> addAction(m_draw_grid);
|
||||
menu_affichage -> addAction(m_draw_guides);
|
||||
menu_affichage -> addAction(m_grey_background);
|
||||
menu_affichage -> addMenu(m_background_color_button->menu());
|
||||
menu_affichage -> addSeparator();
|
||||
menu_affichage -> addActions(m_zoom_actions_group.actions());
|
||||
|
||||
@@ -1880,7 +1874,7 @@ void QETDiagramEditor::slot_updateActions()
|
||||
m_select_actions_group. setEnabled(opened_diagram);
|
||||
m_add_item_actions_group. setEnabled(editable_project);
|
||||
m_row_column_actions_group. setEnabled(editable_project);
|
||||
m_grey_background-> setEnabled(opened_diagram);
|
||||
m_background_color_button-> setEnabled(opened_diagram);
|
||||
m_draw_grid-> setEnabled(opened_diagram);
|
||||
m_draw_guides-> setEnabled(opened_diagram);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class QETResult;
|
||||
class ProjectView;
|
||||
class ConductorColorToolButton;
|
||||
class CustomElement;
|
||||
class DiagramBgColorToolButton;
|
||||
class Diagram;
|
||||
class DiagramView;
|
||||
class Element;
|
||||
@@ -200,7 +201,6 @@ class QETDiagramEditor : public QETMainWindow
|
||||
*m_paste, ///< Paste clipboard content on the current diagram
|
||||
*m_auto_conductor, ///< Enable/Disable the use of auto conductor
|
||||
*m_auto_break_conductor, ///< Enable/Disable the use of auto break conductor
|
||||
*m_grey_background, ///< Switch the background color in white or grey
|
||||
*m_draw_grid, ///< Switch the background grid display or not
|
||||
*m_draw_guides = nullptr, ///< Switch the custom guides display or not
|
||||
*m_project_edit_properties, ///< Edit the properties of the current project.
|
||||
@@ -240,6 +240,8 @@ class QETDiagramEditor : public QETMainWindow
|
||||
|
||||
///< One-click conductor colour, in the "Schéma" toolbar
|
||||
ConductorColorToolButton *m_conductor_color_button = nullptr;
|
||||
///< Diagram background color picker, in the "Affichage" toolbar
|
||||
DiagramBgColorToolButton *m_background_color_button = nullptr;
|
||||
|
||||
QList <QAction *> m_zoom_action_toolBar; ///Only zoom action must displayed in the toolbar
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "../qetgraphicsitem/terminal.h"
|
||||
#include "../qetinformation.h"
|
||||
#include "../utils/qetutils.h"
|
||||
#include "../QetGraphicsItemModeler/qetgraphicshandleritem.h"
|
||||
#include "crossrefitem.h"
|
||||
#include "element.h"
|
||||
#include "elementtextitemgroup.h"
|
||||
@@ -67,7 +68,9 @@ DynamicElementTextItem::DynamicElementTextItem(Element *parent_element) :
|
||||
}
|
||||
|
||||
DynamicElementTextItem::~DynamicElementTextItem()
|
||||
{}
|
||||
{
|
||||
removeResizeHandles();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DynamicElementTextItem::textFromMetaEnum
|
||||
@@ -728,7 +731,10 @@ void DynamicElementTextItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event)
|
||||
void DynamicElementTextItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
|
||||
{
|
||||
DiagramTextItem::paint(painter, option, widget);
|
||||
|
||||
|
||||
if (m_left_resize_handle || m_right_resize_handle)
|
||||
updateResizeHandlesPos();
|
||||
|
||||
if (m_frame)
|
||||
{
|
||||
painter->save();
|
||||
@@ -818,15 +824,44 @@ QVariant DynamicElementTextItem::itemChange(QGraphicsItem::GraphicsItemChange ch
|
||||
updateXref();
|
||||
updateXref();
|
||||
}
|
||||
|
||||
else if (change == QGraphicsItem::ItemSelectedHasChanged)
|
||||
{
|
||||
if (value.toBool())
|
||||
addResizeHandles();
|
||||
else
|
||||
removeResizeHandles();
|
||||
}
|
||||
else if (change == QGraphicsItem::ItemSceneHasChanged && !scene())
|
||||
{
|
||||
removeResizeHandles();
|
||||
}
|
||||
|
||||
return QGraphicsObject::itemChange(change, value);
|
||||
}
|
||||
|
||||
bool DynamicElementTextItem::sceneEventFilter(QGraphicsItem *watched, QEvent *event)
|
||||
{
|
||||
if (watched == m_left_resize_handle || watched == m_right_resize_handle)
|
||||
{
|
||||
auto *handle = static_cast<QetGraphicsHandlerItem *>(watched);
|
||||
if (event->type() == QEvent::GraphicsSceneMousePress) {
|
||||
handlerMousePressEvent(handle, static_cast<QGraphicsSceneMouseEvent *>(event));
|
||||
return true;
|
||||
}
|
||||
else if (event->type() == QEvent::GraphicsSceneMouseMove) {
|
||||
handlerMouseMoveEvent(handle, static_cast<QGraphicsSceneMouseEvent *>(event));
|
||||
return true;
|
||||
}
|
||||
else if (event->type() == QEvent::GraphicsSceneMouseRelease) {
|
||||
handlerMouseReleaseEvent(handle, static_cast<QGraphicsSceneMouseEvent *>(event));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if(watched != m_slave_Xref_item)
|
||||
return false;
|
||||
|
||||
|
||||
if(event->type() == QEvent::GraphicsSceneHoverEnter) {
|
||||
m_slave_Xref_item->setDefaultTextColor(Qt::blue);
|
||||
return true;
|
||||
@@ -839,10 +874,129 @@ bool DynamicElementTextItem::sceneEventFilter(QGraphicsItem *watched, QEvent *ev
|
||||
zoomToLinkedElement();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DynamicElementTextItem::addResizeHandles
|
||||
Create and show the two width-resize handles (left/right edge of
|
||||
frameRect()), reusing QetGraphicsHandlerItem the same way QetShapeItem
|
||||
does for its own resize handles.
|
||||
*/
|
||||
void DynamicElementTextItem::addResizeHandles()
|
||||
{
|
||||
if (m_left_resize_handle || !scene())
|
||||
return;
|
||||
|
||||
qreal size = QETUtils::graphicsHandlerSize(this);
|
||||
m_left_resize_handle = new QetGraphicsHandlerItem(size);
|
||||
m_right_resize_handle = new QetGraphicsHandlerItem(size);
|
||||
|
||||
for (QetGraphicsHandlerItem *handle : {m_left_resize_handle, m_right_resize_handle})
|
||||
{
|
||||
scene()->addItem(handle);
|
||||
handle->setColor(Qt::darkGreen);
|
||||
handle->setZValue(zValue() + 1);
|
||||
handle->installSceneEventFilter(this);
|
||||
}
|
||||
|
||||
updateResizeHandlesPos();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DynamicElementTextItem::removeResizeHandles
|
||||
*/
|
||||
void DynamicElementTextItem::removeResizeHandles()
|
||||
{
|
||||
delete m_left_resize_handle;
|
||||
delete m_right_resize_handle;
|
||||
m_left_resize_handle = nullptr;
|
||||
m_right_resize_handle = nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DynamicElementTextItem::updateResizeHandlesPos
|
||||
Keep the two resize handles at the vertical middle of frameRect()'s left
|
||||
and right edges, in scene coordinates -- called on every paint() so it
|
||||
stays correct across every kind of change that can move this item or
|
||||
change its size (position, rotation, font, text, textWidth...) without
|
||||
needing a dedicated hook for each one.
|
||||
*/
|
||||
void DynamicElementTextItem::updateResizeHandlesPos()
|
||||
{
|
||||
if (!m_left_resize_handle || !m_right_resize_handle)
|
||||
return;
|
||||
|
||||
QRectF fr = frameRect();
|
||||
m_left_resize_handle->setPos(mapToScene(QPointF(fr.left(), fr.center().y())));
|
||||
m_right_resize_handle->setPos(mapToScene(QPointF(fr.right(), fr.center().y())));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DynamicElementTextItem::handlerMousePressEvent
|
||||
@param handle
|
||||
@param event
|
||||
*/
|
||||
void DynamicElementTextItem::handlerMousePressEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
Q_UNUSED(handle)
|
||||
|
||||
//The actual property value, kept as-is (possibly -1, meaning "auto")
|
||||
//so a later undo restores the exact original state rather than a
|
||||
//synthesized fixed width.
|
||||
m_resize_original_width = textWidth();
|
||||
//A concrete baseline for the live drag's delta math, which can't
|
||||
//start from -1.
|
||||
m_resize_baseline_width = (m_resize_original_width < 0) ? frameRect().width() : m_resize_original_width;
|
||||
m_resize_start_local_x = mapFromScene(event->scenePos()).x();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DynamicElementTextItem::handlerMouseMoveEvent
|
||||
Live-resize the text while dragging, exactly like the element editor's
|
||||
resize handles live-update geometry during a drag (undo is only pushed
|
||||
on release). The drag delta is resolved in this item's own local
|
||||
coordinates (not scene coordinates) so a rotated text box still resizes
|
||||
along its own baseline.
|
||||
@param handle
|
||||
@param event
|
||||
*/
|
||||
void DynamicElementTextItem::handlerMouseMoveEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
qreal local_x = mapFromScene(event->scenePos()).x();
|
||||
qreal delta = local_x - m_resize_start_local_x;
|
||||
if (handle == m_left_resize_handle)
|
||||
delta = -delta;
|
||||
|
||||
qreal new_width = qMax(m_resize_baseline_width + delta, qreal(10));
|
||||
setTextWidth(new_width);
|
||||
updateResizeHandlesPos();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DynamicElementTextItem::handlerMouseReleaseEvent
|
||||
Push the same QPropertyUndoCommand the properties-panel width spinbox
|
||||
already pushes (sources/ui/dynamicelementtextmodel.cpp) -- the value is
|
||||
already applied live from the drag, so this only makes it undoable.
|
||||
@param handle
|
||||
@param event
|
||||
*/
|
||||
void DynamicElementTextItem::handlerMouseReleaseEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event)
|
||||
{
|
||||
Q_UNUSED(handle)
|
||||
Q_UNUSED(event)
|
||||
|
||||
qreal new_width = textWidth();
|
||||
if (!qFuzzyCompare(m_resize_original_width, new_width) && m_parent_element && m_parent_element->diagram())
|
||||
{
|
||||
auto *undo = new QPropertyUndoCommand(this, "textWidth", QVariant(m_resize_original_width), QVariant(new_width));
|
||||
undo->setAnimated(true, false);
|
||||
undo->setText(tr("Redimensionner un texte d'élément"));
|
||||
m_parent_element->diagram()->undoStack().push(undo);
|
||||
}
|
||||
}
|
||||
|
||||
void DynamicElementTextItem::elementInfoChanged()
|
||||
{
|
||||
DiagramContext dc;
|
||||
|
||||
@@ -29,6 +29,7 @@ class Element;
|
||||
class Conductor;
|
||||
class ElementTextItemGroup;
|
||||
class CrossRefItem;
|
||||
class QetGraphicsHandlerItem;
|
||||
|
||||
/**
|
||||
@brief The DynamicElementTextItem class
|
||||
@@ -151,6 +152,12 @@ class DynamicElementTextItem : public DiagramTextItem
|
||||
void zoomToLinkedElement();
|
||||
void parentElementRotationChanged();
|
||||
void thisRotationChanged();
|
||||
void addResizeHandles();
|
||||
void removeResizeHandles();
|
||||
void updateResizeHandlesPos();
|
||||
void handlerMousePressEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
|
||||
void handlerMouseMoveEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
|
||||
void handlerMouseReleaseEvent(QetGraphicsHandlerItem *handle, QGraphicsSceneMouseEvent *event);
|
||||
|
||||
private:
|
||||
QPointer <Element>
|
||||
@@ -182,6 +189,11 @@ class DynamicElementTextItem : public DiagramTextItem
|
||||
bool m_rotation_point_center = false;
|
||||
qreal m_visual_rotation_ref = 0;
|
||||
bool m_move_parent = true;
|
||||
QetGraphicsHandlerItem *m_left_resize_handle = nullptr;
|
||||
QetGraphicsHandlerItem *m_right_resize_handle = nullptr;
|
||||
qreal m_resize_original_width = -1;
|
||||
qreal m_resize_baseline_width = -1;
|
||||
qreal m_resize_start_local_x = 0;
|
||||
};
|
||||
|
||||
#endif // DYNAMICELEMENTTEXTITEM_H
|
||||
|
||||
@@ -27,11 +27,25 @@
|
||||
#include "../qet.h"
|
||||
#include "../qetgraphicsitem/element.h"
|
||||
#include "../qetmessagebox.h"
|
||||
#include "../dataBase/projectdatabase.h"
|
||||
#include "../qetproject.h"
|
||||
#include "../qetresult.h"
|
||||
#include "../qetgraphicsitem/conductor.h"
|
||||
#include "../qetgraphicsitem/independenttextitem.h"
|
||||
#include "../qetgraphicsitem/qetshapeitem.h"
|
||||
#include "../qetgraphicsitem/terminal.h"
|
||||
#include "../qetinformation.h"
|
||||
#include "../titleblockproperties.h"
|
||||
#include "../undocommand/addgraphicsobjectcommand.h"
|
||||
#include "../undocommand/changeelementinformationcommand.h"
|
||||
#include "../undocommand/changetitleblockcommand.h"
|
||||
#include "../undocommand/deleteqgraphicsitemcommand.h"
|
||||
#include "../undocommand/linkelementcommand.h"
|
||||
#include "../utils/conductorcreator.h"
|
||||
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QSqlRecord>
|
||||
#include <QTextStream>
|
||||
#include <QUndoCommand>
|
||||
|
||||
@@ -268,6 +282,137 @@ Element *QetScriptApi::findElement(int folioIndex, const QString &elementUuid) c
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
Terminal *QetScriptApi::findTerminal(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, const QString &caller)
|
||||
{
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) {
|
||||
log(QStringLiteral("qet.%1: no element %2 on folio %3").arg(caller, elementUuid).arg(folioIndex));
|
||||
return nullptr;
|
||||
}
|
||||
const QList<Terminal *> terminals = element->terminals();
|
||||
if (terminalIndex < 0 || terminalIndex >= terminals.count()) {
|
||||
log(QStringLiteral("qet.%1: %2 has %3 terminal(s), no index %4")
|
||||
.arg(caller, element->name()).arg(terminals.count()).arg(terminalIndex));
|
||||
return nullptr;
|
||||
}
|
||||
return terminals.at(terminalIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::findConductor
|
||||
The single conductor attached to a terminal, or nullptr.
|
||||
|
||||
Conductors carry no persisted uuid, and the terminal1/terminal2 ids the
|
||||
file uses for their ends are folio-scoped integers QElectroTech
|
||||
renumbers on every save, so a conductor has no name that survives a
|
||||
save/load cycle. Naming one by a terminal it is attached to does, and
|
||||
it reads the way the question is usually asked ("the wire on A1 of
|
||||
KM1"). A terminal with several conductors on it does not name one, so
|
||||
refuse rather than silently take the first.
|
||||
*/
|
||||
Conductor *QetScriptApi::findConductor(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, const QString &caller)
|
||||
{
|
||||
Terminal *terminal = findTerminal(folioIndex, elementUuid, terminalIndex, caller);
|
||||
if (!terminal) return nullptr;
|
||||
const QList<Conductor *> conductors = terminal->conductors();
|
||||
if (conductors.isEmpty()) {
|
||||
log(QStringLiteral("qet.%1: terminal %2 of %3 has no conductor on it")
|
||||
.arg(caller).arg(terminalIndex).arg(elementUuid));
|
||||
return nullptr;
|
||||
}
|
||||
if (conductors.count() > 1) {
|
||||
log(QStringLiteral("qet.%1: terminal %2 of %3 carries %4 conductors, so it does "
|
||||
"not name one -- use a terminal with a single conductor")
|
||||
.arg(caller).arg(terminalIndex).arg(elementUuid).arg(conductors.count()));
|
||||
return nullptr;
|
||||
}
|
||||
return conductors.first();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/**
|
||||
Read or write one named conductor property. The names are the ones the
|
||||
project file uses for the same fields (ConductorProperties::toXml), so
|
||||
that what a script sets is what a reader of the .qet sees, rather than
|
||||
a third spelling invented here.
|
||||
*/
|
||||
QString conductorPropertyValue(const ConductorProperties &p, const QString &name)
|
||||
{
|
||||
if (name == QLatin1String("num")) return p.text;
|
||||
if (name == QLatin1String("formula")) return p.m_formula;
|
||||
if (name == QLatin1String("function")) return p.m_function;
|
||||
if (name == QLatin1String("bus")) return p.m_bus;
|
||||
if (name == QLatin1String("cable")) return p.m_cable;
|
||||
if (name == QLatin1String("tension_protocol")) return p.m_tension_protocol;
|
||||
if (name == QLatin1String("conductor_color")) return p.m_wire_color;
|
||||
if (name == QLatin1String("conductor_section")) return p.m_wire_section;
|
||||
if (name == QLatin1String("color")) return p.color.name();
|
||||
if (name == QLatin1String("text_color")) return p.text_color.name();
|
||||
return QString();
|
||||
}
|
||||
|
||||
bool setConductorPropertyValue(ConductorProperties &p, const QString &name, const QString &value)
|
||||
{
|
||||
if (name == QLatin1String("num")) { p.text = value; return true; }
|
||||
if (name == QLatin1String("formula")) { p.m_formula = value; return true; }
|
||||
if (name == QLatin1String("function")) { p.m_function = value; return true; }
|
||||
if (name == QLatin1String("bus")) { p.m_bus = value; return true; }
|
||||
if (name == QLatin1String("cable")) { p.m_cable = value; return true; }
|
||||
if (name == QLatin1String("tension_protocol")) { p.m_tension_protocol = value; return true; }
|
||||
if (name == QLatin1String("conductor_color")) { p.m_wire_color = value; return true; }
|
||||
if (name == QLatin1String("conductor_section")) { p.m_wire_section = value; return true; }
|
||||
// The two real colours are QColor, not free text: an unparseable name
|
||||
// would otherwise be stored as an invalid colour and drawn as black.
|
||||
if (name == QLatin1String("color") || name == QLatin1String("text_color"))
|
||||
{
|
||||
const QColor c(value);
|
||||
if (!c.isValid()) return false;
|
||||
if (name == QLatin1String("color")) p.color = c; else p.text_color = c;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList &conductorPropertyNames()
|
||||
{
|
||||
static const QStringList names {
|
||||
QStringLiteral("num"), QStringLiteral("formula"), QStringLiteral("function"),
|
||||
QStringLiteral("bus"), QStringLiteral("cable"), QStringLiteral("tension_protocol"),
|
||||
QStringLiteral("conductor_color"), QStringLiteral("conductor_section"),
|
||||
QStringLiteral("color"), QStringLiteral("text_color")};
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool QetScriptApi::setInfoKey(int folioIndex, const QString &elementUuid,
|
||||
const QString &key, const QString &value, const QString &caller)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.%1: project is read-only").arg(caller));
|
||||
return false;
|
||||
}
|
||||
if (key.isEmpty()) {
|
||||
log(QStringLiteral("qet.%1: empty information key").arg(caller));
|
||||
return false;
|
||||
}
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) return false;
|
||||
|
||||
const DiagramContext old_info = element->elementInformations();
|
||||
if (old_info.value(key).toString() == value) return true; // nothing to push
|
||||
DiagramContext new_info = old_info;
|
||||
new_info.addValue(key, value);
|
||||
|
||||
auto *cmd = new ChangeElementInformationCommand(element, old_info, new_info);
|
||||
m_project->undoStack()->push(cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::addElement
|
||||
Place a new element on a folio, through the same AddGraphicsObjectCommand
|
||||
@@ -417,6 +562,737 @@ bool QetScriptApi::deleteElement(int folioIndex, const QString &elementUuid)
|
||||
return true;
|
||||
}
|
||||
|
||||
bool QetScriptApi::rotateElement(int folioIndex, const QString &elementUuid, double angle)
|
||||
{
|
||||
if (m_project && m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.rotateElement: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) return false;
|
||||
|
||||
// The same property command RotateSelectionCommand pushes for an
|
||||
// Element -- deliberately not RotateSelectionCommand itself, which
|
||||
// works on diagram->selectedItems() and would mean quietly rewriting
|
||||
// the user's selection to rotate one element by uuid. For a single
|
||||
// element the two are mechanically identical: that class special-cases
|
||||
// Element::Type to exactly this one command, and only adds a second,
|
||||
// positional one when rotating a multi-item selection as a group.
|
||||
auto *cmd = new QPropertyUndoCommand(element, "rotation",
|
||||
QVariant(element->rotation()),
|
||||
QVariant(element->rotation() + angle));
|
||||
cmd->setText(QObject::tr("Pivoter %1").arg(element->name()));
|
||||
m_project->undoStack()->push(cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
QStringList QetScriptApi::elementUuids(int folioIndex) const
|
||||
{
|
||||
QStringList uuids;
|
||||
if (!m_project) return uuids;
|
||||
const QList<Diagram *> diagrams = m_project->diagrams();
|
||||
if (folioIndex < 0 || folioIndex >= diagrams.count()) return uuids;
|
||||
DiagramContent content(diagrams.at(folioIndex), false);
|
||||
for (Element *elmt : std::as_const(content.m_elements)) {
|
||||
uuids << elmt->uuid().toString();
|
||||
}
|
||||
return uuids;
|
||||
}
|
||||
|
||||
QString QetScriptApi::elementName(int folioIndex, const QString &elementUuid) const
|
||||
{
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
return element ? element->name() : QString();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::elementTerminals
|
||||
The element's terminals, in the order addConductor() indexes them: one
|
||||
entry per terminal, "<index>: <name> (<n> conductor(s))". Descriptive
|
||||
rather than structured because its only job is to let a script -- or a
|
||||
human reading a script's output -- see which index is which before
|
||||
wiring anything to it.
|
||||
|
||||
Indexes, not uuids, because a terminal uuid does not address a terminal
|
||||
on a folio. Terminal::uuid() comes from the catalog .elmt definition
|
||||
(see Terminal::stableUuid()), so it is empty for most of the installed
|
||||
base, and where it is not, every instance of that same element carries
|
||||
the same one -- two coils of one type placed side by side have
|
||||
byte-identical terminal uuids, which is plainly visible in the saved
|
||||
file of any project written through this API. The order of
|
||||
Element::terminals() also comes from the definition, but it is at least
|
||||
unambiguous within the element the caller has already named by uuid.
|
||||
*/
|
||||
QStringList QetScriptApi::elementTerminals(int folioIndex, const QString &elementUuid) const
|
||||
{
|
||||
QStringList list;
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) return list;
|
||||
const QList<Terminal *> terminals = element->terminals();
|
||||
for (int i = 0 ; i < terminals.count() ; ++i)
|
||||
{
|
||||
Terminal *t = terminals.at(i);
|
||||
list << QStringLiteral("%1: %2 (%3 conductor(s))")
|
||||
.arg(i)
|
||||
.arg(t->name().isEmpty() ? QStringLiteral("-") : t->name())
|
||||
.arg(t->conductorsCount());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
QString QetScriptApi::elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const
|
||||
{
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) return QString();
|
||||
return element->elementInformations().value(key).toString();
|
||||
}
|
||||
|
||||
bool QetScriptApi::setElementInfo(int folioIndex, const QString &elementUuid,
|
||||
const QString &key, const QString &value)
|
||||
{
|
||||
return setInfoKey(folioIndex, elementUuid, key, value, QStringLiteral("setElementInfo"));
|
||||
}
|
||||
|
||||
QString QetScriptApi::elementLabel(int folioIndex, const QString &elementUuid) const
|
||||
{
|
||||
return elementInfo(folioIndex, elementUuid, QETInformation::ELMT_LABEL);
|
||||
}
|
||||
|
||||
bool QetScriptApi::setElementLabel(int folioIndex, const QString &elementUuid, const QString &label)
|
||||
{
|
||||
return setInfoKey(folioIndex, elementUuid, QETInformation::ELMT_LABEL, label,
|
||||
QStringLiteral("setElementLabel"));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::addConductor
|
||||
Wire terminal terminalIndexA of one element to terminalIndexB of
|
||||
another, on the same folio, through ConductorCreator -- the same class
|
||||
the "draw a selection rectangle over terminals" GUI path uses. Going
|
||||
through it rather than constructing a Conductor directly is what makes
|
||||
the new conductor inherit an existing potential's properties and take
|
||||
part in conductor auto-numbering; a hand-built one would be silently
|
||||
outside both.
|
||||
|
||||
Refuses, rather than creating anything, when the two terminals sit on
|
||||
two different existing potentials: ConductorCreator then has to ask
|
||||
which one's properties the new conductor should inherit, and it asks
|
||||
with a plain modal QDialog that QET::QetMessageBox's non-interactive
|
||||
mode does not cover -- so under headless --run there would be nobody to
|
||||
answer it and the script would hang forever. Same reasoning, and the
|
||||
same choice, as addElement() makes about the import-conflict dialog.
|
||||
@return true if a conductor was created
|
||||
*/
|
||||
bool QetScriptApi::addConductor(int folioIndex,
|
||||
const QString &elementUuidA, int terminalIndexA,
|
||||
const QString &elementUuidB, int terminalIndexB)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.addConductor: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
const QString caller = QStringLiteral("addConductor");
|
||||
Terminal *t1 = findTerminal(folioIndex, elementUuidA, terminalIndexA, caller);
|
||||
Terminal *t2 = findTerminal(folioIndex, elementUuidB, terminalIndexB, caller);
|
||||
if (!t1 || !t2) return false;
|
||||
|
||||
if (t1 == t2) {
|
||||
log(QStringLiteral("qet.addConductor: both ends are the same terminal"));
|
||||
return false;
|
||||
}
|
||||
if (t1->isLinkedTo(t2)) {
|
||||
log(QStringLiteral("qet.addConductor: those two terminals are already wired together"));
|
||||
return false;
|
||||
}
|
||||
if (!t1->canBeLinkedTo(t2)) {
|
||||
log(QStringLiteral("qet.addConductor: those two terminals cannot be linked"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const QList<Terminal *> terminals {t1, t2};
|
||||
if (ConductorCreator::needsPotentialChoice(terminals)) {
|
||||
log(QStringLiteral("qet.addConductor: those terminals are on two different existing "
|
||||
"potentials, so creating a conductor would ask which one to inherit "
|
||||
"-- refusing rather than open a dialog no script can answer"));
|
||||
return false;
|
||||
}
|
||||
|
||||
Diagram *diagram = m_project->diagrams().at(folioIndex);
|
||||
ConductorCreator creator(diagram, terminals);
|
||||
Q_UNUSED(creator)
|
||||
|
||||
// ConductorCreator has no return value and several ways to decline
|
||||
// quietly, so report what actually happened rather than that it ran.
|
||||
return t1->isLinkedTo(t2);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::conductors
|
||||
One line per conductor on the folio: which terminals it joins and its
|
||||
number, in the form setConductorProperty() addresses them. Descriptive
|
||||
rather than structured for the same reason elementTerminals() is -- it
|
||||
exists so a script, or a person reading its output, can see what is
|
||||
there before changing it.
|
||||
*/
|
||||
QStringList QetScriptApi::conductors(int folioIndex) const
|
||||
{
|
||||
QStringList list;
|
||||
if (!m_project) return list;
|
||||
const QList<Diagram *> diagrams = m_project->diagrams();
|
||||
if (folioIndex < 0 || folioIndex >= diagrams.count()) return list;
|
||||
|
||||
auto describe = [](Terminal *t) -> QString {
|
||||
if (!t || !t->parentElement()) return QStringLiteral("?");
|
||||
return QStringLiteral("%1 terminal %2")
|
||||
.arg(t->parentElement()->uuid().toString())
|
||||
.arg(t->parentElement()->terminals().indexOf(t));
|
||||
};
|
||||
|
||||
DiagramContent content(diagrams.at(folioIndex), false);
|
||||
const QList<Conductor *> all = content.conductors(DiagramContent::AnyConductor);
|
||||
for (Conductor *c : all)
|
||||
{
|
||||
list << QStringLiteral("%1 -- %2 : num='%3'")
|
||||
.arg(describe(c->terminal1), describe(c->terminal2), c->properties().text);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
QString QetScriptApi::conductorProperty(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, const QString &property) const
|
||||
{
|
||||
// const_cast: findConductor logs, and log() writes to stderr, which is
|
||||
// not a const operation on this object. The lookup itself changes
|
||||
// nothing.
|
||||
auto *self = const_cast<QetScriptApi *>(this);
|
||||
Conductor *conductor = self->findConductor(folioIndex, elementUuid, terminalIndex,
|
||||
QStringLiteral("conductorProperty"));
|
||||
if (!conductor) return QString();
|
||||
return conductorPropertyValue(conductor->properties(), property);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::setConductorProperty
|
||||
Set one property on the conductor attached to a terminal -- and on
|
||||
every other conductor of the same electrical potential.
|
||||
|
||||
That is not a convenience, it is the rule the application already
|
||||
follows: SearchAndReplaceWorker does exactly this, pushing one
|
||||
QPropertyUndoCommand per conductor of relatedPotentialConductors()
|
||||
inside a single macro, because a wire number, colour or section
|
||||
describes a potential and not one drawn segment. Setting it on one
|
||||
conductor and leaving the rest of the potential disagreeing would
|
||||
produce a file no GUI action could have produced.
|
||||
@return true if anything was changed, or if it already held that value
|
||||
*/
|
||||
bool QetScriptApi::setConductorProperty(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, const QString &property,
|
||||
const QString &value)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
const QString caller = QStringLiteral("setConductorProperty");
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.%1: project is read-only").arg(caller));
|
||||
return false;
|
||||
}
|
||||
if (!conductorPropertyNames().contains(property)) {
|
||||
log(QStringLiteral("qet.%1: unknown property '%2'; expected one of %3")
|
||||
.arg(caller, property, conductorPropertyNames().join(QStringLiteral(", "))));
|
||||
return false;
|
||||
}
|
||||
Conductor *conductor = findConductor(folioIndex, elementUuid, terminalIndex, caller);
|
||||
if (!conductor) return false;
|
||||
|
||||
ConductorProperties properties = conductor->properties();
|
||||
if (!setConductorPropertyValue(properties, property, value)) {
|
||||
log(QStringLiteral("qet.%1: '%2' is not a valid value for %3")
|
||||
.arg(caller, value, property));
|
||||
return false;
|
||||
}
|
||||
if (properties == conductor->properties()) return true; // already so
|
||||
|
||||
QSet<Conductor *> potential = conductor->relatedPotentialConductors(true);
|
||||
potential << conductor;
|
||||
|
||||
m_project->undoStack()->beginMacro(QObject::tr("Modifier les propriétés du conducteur"));
|
||||
for (Conductor *c : std::as_const(potential))
|
||||
{
|
||||
QVariant old_value, new_value;
|
||||
old_value.setValue(c->properties());
|
||||
new_value.setValue(properties);
|
||||
m_project->undoStack()->push(new QPropertyUndoCommand(c, "properties", old_value, new_value));
|
||||
}
|
||||
m_project->undoStack()->endMacro();
|
||||
return true;
|
||||
}
|
||||
|
||||
QString QetScriptApi::elementLinkType(int folioIndex, const QString &elementUuid) const
|
||||
{
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) return QString();
|
||||
switch (element->linkType())
|
||||
{
|
||||
case Element::Simple: return QStringLiteral("simple");
|
||||
case Element::NextReport: return QStringLiteral("next_report");
|
||||
case Element::PreviousReport: return QStringLiteral("previous_report");
|
||||
case Element::Master: return QStringLiteral("master");
|
||||
case Element::Slave: return QStringLiteral("slave");
|
||||
case Element::Terminale: return QStringLiteral("terminal");
|
||||
default: return QStringLiteral("unknown");
|
||||
}
|
||||
}
|
||||
|
||||
QStringList QetScriptApi::linkedElements(int folioIndex, const QString &elementUuid) const
|
||||
{
|
||||
QStringList list;
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) return list;
|
||||
const QList<Element *> linked = element->linkedElements();
|
||||
for (Element *e : linked) {
|
||||
list << e->uuid().toString();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::linkElements
|
||||
Link two elements -- a master to a slave, or one report to its
|
||||
counterpart. Two folio indices because a master and its slave normally
|
||||
sit on different folios; that is the usual case, not the exception.
|
||||
|
||||
Whether a given pair may be linked is not decided here.
|
||||
LinkElementCommand::isLinkable() already holds those rules -- that a
|
||||
master takes a slave and not another master, that a PLC master pairs
|
||||
only with a PLC slave, that a next-report pairs only with a
|
||||
previous-report, and that the target is free -- and asking it rather
|
||||
than re-deriving them is what keeps a script from producing a link the
|
||||
GUI would refuse to make.
|
||||
*/
|
||||
bool QetScriptApi::linkElements(int folioIndexA, const QString &elementUuidA,
|
||||
int folioIndexB, const QString &elementUuidB)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.linkElements: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
Element *a = findElement(folioIndexA, elementUuidA);
|
||||
Element *b = findElement(folioIndexB, elementUuidB);
|
||||
if (!a || !b) {
|
||||
log(QStringLiteral("qet.linkElements: %1 does not resolve to an element")
|
||||
.arg(a ? elementUuidB : elementUuidA));
|
||||
return false;
|
||||
}
|
||||
if (a == b) {
|
||||
log(QStringLiteral("qet.linkElements: an element cannot be linked to itself"));
|
||||
return false;
|
||||
}
|
||||
if (!LinkElementCommand::isLinkable(a, b)) {
|
||||
log(QStringLiteral("qet.linkElements: %1 (%2) cannot be linked to %3 (%4) -- "
|
||||
"check the two link types, and that the target is still free")
|
||||
.arg(elementUuidA, elementLinkType(folioIndexA, elementUuidA),
|
||||
elementUuidB, elementLinkType(folioIndexB, elementUuidB)));
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *cmd = new LinkElementCommand(a);
|
||||
cmd->setLink(b);
|
||||
m_project->undoStack()->push(cmd);
|
||||
return a->linkedElements().contains(b);
|
||||
}
|
||||
|
||||
bool QetScriptApi::unlinkElement(int folioIndex, const QString &elementUuid)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.unlinkElement: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
Element *element = findElement(folioIndex, elementUuid);
|
||||
if (!element) return false;
|
||||
if (element->linkedElements().isEmpty()) return true; // nothing to undo
|
||||
|
||||
auto *cmd = new LinkElementCommand(element);
|
||||
cmd->unlinkAll();
|
||||
m_project->undoStack()->push(cmd);
|
||||
return element->linkedElements().isEmpty();
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
/**
|
||||
Reading order for items that have no identity but their position:
|
||||
top to bottom, then left to right.
|
||||
|
||||
On sceneBoundingRect(), not pos(): a QetShapeItem keeps its geometry in
|
||||
its line/rect/polygon, and its pos() stays at the origin, so three
|
||||
shapes drawn in different places all sort as (0, 0) and the ordering
|
||||
collapses -- which is exactly what the first version of this did, and
|
||||
it made every shape index refer to whichever one the set happened to
|
||||
yield first. The scene bounding rect reflects where the item actually
|
||||
is for both kinds.
|
||||
*/
|
||||
template <typename T>
|
||||
QList<T *> sortedByPosition(const QSet<T *> &items)
|
||||
{
|
||||
QList<T *> list(items.cbegin(), items.cend());
|
||||
std::sort(list.begin(), list.end(), [](T *a, T *b) {
|
||||
const QPointF pa = a->sceneBoundingRect().topLeft();
|
||||
const QPointF pb = b->sceneBoundingRect().topLeft();
|
||||
if (pa.y() != pb.y()) return pa.y() < pb.y();
|
||||
if (pa.x() != pb.x()) return pa.x() < pb.x();
|
||||
// Two items genuinely at the same point still need a total order,
|
||||
// or std::sort's result depends on the set's iteration order.
|
||||
return a < b;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QList<IndependentTextItem *> QetScriptApi::sortedTexts(int folioIndex) const
|
||||
{
|
||||
if (!m_project) return {};
|
||||
const QList<Diagram *> diagrams = m_project->diagrams();
|
||||
if (folioIndex < 0 || folioIndex >= diagrams.count()) return {};
|
||||
DiagramContent content(diagrams.at(folioIndex), false);
|
||||
return sortedByPosition(content.m_text_fields);
|
||||
}
|
||||
|
||||
QList<QetShapeItem *> QetScriptApi::sortedShapes(int folioIndex) const
|
||||
{
|
||||
if (!m_project) return {};
|
||||
const QList<Diagram *> diagrams = m_project->diagrams();
|
||||
if (folioIndex < 0 || folioIndex >= diagrams.count()) return {};
|
||||
DiagramContent content(diagrams.at(folioIndex), false);
|
||||
return sortedByPosition(content.m_shapes);
|
||||
}
|
||||
|
||||
IndependentTextItem *QetScriptApi::findText(int folioIndex, int textIndex, const QString &caller)
|
||||
{
|
||||
const QList<IndependentTextItem *> list = sortedTexts(folioIndex);
|
||||
if (textIndex < 0 || textIndex >= list.count()) {
|
||||
log(QStringLiteral("qet.%1: folio %2 has %3 independent text(s), no index %4")
|
||||
.arg(caller).arg(folioIndex).arg(list.count()).arg(textIndex));
|
||||
return nullptr;
|
||||
}
|
||||
return list.at(textIndex);
|
||||
}
|
||||
|
||||
QStringList QetScriptApi::texts(int folioIndex) const
|
||||
{
|
||||
QStringList out;
|
||||
const QList<IndependentTextItem *> list = sortedTexts(folioIndex);
|
||||
for (int i = 0 ; i < list.count() ; ++i)
|
||||
{
|
||||
IndependentTextItem *t = list.at(i);
|
||||
const QPointF at = t->sceneBoundingRect().topLeft();
|
||||
out << QStringLiteral("%1: '%2' at (%3, %4)")
|
||||
.arg(i)
|
||||
.arg(t->toPlainText())
|
||||
.arg(at.x())
|
||||
.arg(at.y());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::addText
|
||||
Place a free-standing text, as the "add text" tool does.
|
||||
@return its index in texts(), or -1
|
||||
*/
|
||||
int QetScriptApi::addText(int folioIndex, const QString &text, double x, double y)
|
||||
{
|
||||
if (!m_project) return -1;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.addText: project is read-only"));
|
||||
return -1;
|
||||
}
|
||||
const QList<Diagram *> diagrams = m_project->diagrams();
|
||||
if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1;
|
||||
Diagram *diagram = diagrams.at(folioIndex);
|
||||
|
||||
auto *item = new IndependentTextItem();
|
||||
item->setPlainText(text);
|
||||
diagram->undoStack().push(new AddGraphicsObjectCommand(item, diagram, QPointF(x, y)));
|
||||
return sortedTexts(folioIndex).indexOf(item);
|
||||
}
|
||||
|
||||
bool QetScriptApi::setTextContent(int folioIndex, int textIndex, const QString &text)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.setTextContent: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("setTextContent"));
|
||||
if (!item) return false;
|
||||
if (item->toPlainText() == text) return true;
|
||||
|
||||
auto *cmd = new QPropertyUndoCommand(item, "plainText",
|
||||
QVariant(item->toPlainText()), QVariant(text));
|
||||
cmd->setText(QObject::tr("Modifier un texte"));
|
||||
m_project->undoStack()->push(cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool QetScriptApi::setTextColor(int folioIndex, int textIndex, const QString &color)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.setTextColor: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
const QColor new_color(color);
|
||||
if (!new_color.isValid()) {
|
||||
log(QStringLiteral("qet.setTextColor: '%1' is not a valid colour").arg(color));
|
||||
return false;
|
||||
}
|
||||
IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("setTextColor"));
|
||||
if (!item) return false;
|
||||
if (item->color() == new_color) return true;
|
||||
|
||||
auto *cmd = new QPropertyUndoCommand(item, "color",
|
||||
QVariant(item->color()), QVariant(new_color));
|
||||
cmd->setText(QObject::tr("Modifier la couleur d'un texte"));
|
||||
m_project->undoStack()->push(cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool QetScriptApi::setTextRotation(int folioIndex, int textIndex, double angle)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.setTextRotation: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("setTextRotation"));
|
||||
if (!item) return false;
|
||||
|
||||
auto *cmd = new QPropertyUndoCommand(item, "rotation",
|
||||
QVariant(item->rotation()),
|
||||
QVariant(item->rotation() + angle));
|
||||
cmd->setText(QObject::tr("Pivoter un texte"));
|
||||
m_project->undoStack()->push(cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool QetScriptApi::deleteText(int folioIndex, int textIndex)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.deleteText: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
IndependentTextItem *item = findText(folioIndex, textIndex, QStringLiteral("deleteText"));
|
||||
if (!item) return false;
|
||||
Diagram *diagram = m_project->diagrams().at(folioIndex);
|
||||
|
||||
DiagramContent content;
|
||||
content.m_text_fields << item;
|
||||
diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, content));
|
||||
return true;
|
||||
}
|
||||
|
||||
QStringList QetScriptApi::shapes(int folioIndex) const
|
||||
{
|
||||
QStringList out;
|
||||
const QList<QetShapeItem *> list = sortedShapes(folioIndex);
|
||||
for (int i = 0 ; i < list.count() ; ++i)
|
||||
{
|
||||
QetShapeItem *shape = list.at(i);
|
||||
const QRectF r = shape->sceneBoundingRect();
|
||||
out << QStringLiteral("%1: %2 (%3, %4) to (%5, %6)")
|
||||
.arg(i)
|
||||
.arg(shape->name())
|
||||
.arg(r.left()).arg(r.top()).arg(r.right()).arg(r.bottom());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::addShape
|
||||
Draw a line, rectangle, ellipse or polygon, as the shape tools do.
|
||||
Path is deliberately absent: it is built by successive clicks and has
|
||||
no two-point form to give here.
|
||||
@return the shape's index in shapes(), or -1
|
||||
*/
|
||||
int QetScriptApi::addShape(int folioIndex, const QString &type,
|
||||
double x1, double y1, double x2, double y2)
|
||||
{
|
||||
if (!m_project) return -1;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.addShape: project is read-only"));
|
||||
return -1;
|
||||
}
|
||||
const QList<Diagram *> diagrams = m_project->diagrams();
|
||||
if (folioIndex < 0 || folioIndex >= diagrams.count()) return -1;
|
||||
|
||||
QetShapeItem::ShapeType shape_type;
|
||||
const QString t = type.toLower();
|
||||
if (t == QLatin1String("line")) shape_type = QetShapeItem::Line;
|
||||
else if (t == QLatin1String("rectangle")) shape_type = QetShapeItem::Rectangle;
|
||||
else if (t == QLatin1String("ellipse")) shape_type = QetShapeItem::Ellipse;
|
||||
else if (t == QLatin1String("polygon")) shape_type = QetShapeItem::Polygon;
|
||||
else {
|
||||
log(QStringLiteral("qet.addShape: unknown shape '%1'; expected line, "
|
||||
"rectangle, ellipse or polygon").arg(type));
|
||||
return -1;
|
||||
}
|
||||
|
||||
Diagram *diagram = diagrams.at(folioIndex);
|
||||
auto *shape = new QetShapeItem(QPointF(x1, y1), QPointF(x2, y2), shape_type);
|
||||
diagram->undoStack().push(new AddGraphicsObjectCommand(shape, diagram, QPointF(0, 0)));
|
||||
return sortedShapes(folioIndex).indexOf(shape);
|
||||
}
|
||||
|
||||
bool QetScriptApi::deleteShape(int folioIndex, int shapeIndex)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.deleteShape: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
const QList<QetShapeItem *> list = sortedShapes(folioIndex);
|
||||
if (shapeIndex < 0 || shapeIndex >= list.count()) {
|
||||
log(QStringLiteral("qet.deleteShape: folio %1 has %2 shape(s), no index %3")
|
||||
.arg(folioIndex).arg(list.count()).arg(shapeIndex));
|
||||
return false;
|
||||
}
|
||||
Diagram *diagram = m_project->diagrams().at(folioIndex);
|
||||
|
||||
DiagramContent content;
|
||||
content.m_shapes << list.at(shapeIndex);
|
||||
diagram->undoStack().push(new DeleteQGraphicsItemCommand(diagram, content));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::tables
|
||||
The tables and views the project database holds, as "name (type)".
|
||||
|
||||
Worth reading before writing a query against them: the three *_view
|
||||
entries are the queryable surface and are named for it; the tables are
|
||||
how the cache is arranged today.
|
||||
*/
|
||||
QStringList QetScriptApi::tables() const
|
||||
{
|
||||
QStringList list;
|
||||
if (!m_project || !m_project->dataBase()) return list;
|
||||
|
||||
QSqlQuery q = m_project->dataBase()->newQuery(QStringLiteral(
|
||||
"SELECT name, type FROM sqlite_master WHERE type IN ('table','view') "
|
||||
"ORDER BY type, name"));
|
||||
while (q.next()) {
|
||||
list << QStringLiteral("%1 (%2)").arg(q.value(0).toString(), q.value(1).toString());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QetScriptApi::query
|
||||
Run a read-only SELECT against the project database and return its rows
|
||||
as objects, one property per column.
|
||||
|
||||
Goes through projectDataBase::newQuery(), which applies
|
||||
isReadOnlySelect() itself -- the same rule, and the same rejection
|
||||
message, that the "Requête SQL personnalisée" box in the element-query
|
||||
dialog shows a user. Nothing here can write: a statement that is not a
|
||||
single SELECT or WITH...SELECT is refused before it reaches SQLite.
|
||||
|
||||
No updateDB() first, deliberately. A script that has just edited
|
||||
something is the expected caller, so querying a stale cache was the
|
||||
obvious hazard -- but projectDataBase maintains itself incrementally
|
||||
through addElement()/elementInfoChanged()/addConductor() and the rest,
|
||||
which the undo commands behind every edit here already call. Tested
|
||||
both ways on the cases most likely to be stale: an element added and
|
||||
labelled, and a conductor property changed, each queried immediately
|
||||
afterwards through both the table and the view. The counts are the
|
||||
same with the rebuild and without it. Since updateDB() is a full
|
||||
repopulation of every table, calling it per query would have been a
|
||||
real cost for no observable benefit -- so it is not called, and this
|
||||
note exists so it is not added back on the assumption that it must be
|
||||
needed.
|
||||
|
||||
@return the rows; empty on refusal or SQL error, with queryError()
|
||||
saying which. An empty result and a failure are not the same thing.
|
||||
*/
|
||||
QVariantList QetScriptApi::query(const QString &sql)
|
||||
{
|
||||
m_query_error.clear();
|
||||
QVariantList rows;
|
||||
if (!m_project || !m_project->dataBase()) {
|
||||
m_query_error = QStringLiteral("no project database");
|
||||
return rows;
|
||||
}
|
||||
|
||||
QString rejection;
|
||||
QSqlQuery q = m_project->dataBase()->newQuery(sql, &rejection);
|
||||
if (!rejection.isEmpty()) {
|
||||
m_query_error = rejection;
|
||||
log(QStringLiteral("qet.query: %1").arg(rejection));
|
||||
return rows;
|
||||
}
|
||||
if (q.lastError().isValid()) {
|
||||
m_query_error = q.lastError().text();
|
||||
log(QStringLiteral("qet.query: %1").arg(m_query_error));
|
||||
return rows;
|
||||
}
|
||||
|
||||
const QSqlRecord record = q.record();
|
||||
while (q.next())
|
||||
{
|
||||
QVariantMap row;
|
||||
for (int i = 0 ; i < record.count() ; ++i) {
|
||||
row.insert(record.fieldName(i), q.value(i));
|
||||
}
|
||||
rows << row;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
QString QetScriptApi::queryError() const
|
||||
{
|
||||
return m_query_error;
|
||||
}
|
||||
|
||||
int QetScriptApi::addFolio()
|
||||
{
|
||||
if (!m_project) return -1;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.addFolio: project is read-only"));
|
||||
return -1;
|
||||
}
|
||||
Diagram *diagram = m_project->addNewDiagram();
|
||||
if (!diagram) return -1;
|
||||
return m_project->diagrams().indexOf(diagram);
|
||||
}
|
||||
|
||||
bool QetScriptApi::setFolioTitle(int folioIndex, const QString &title)
|
||||
{
|
||||
if (!m_project) return false;
|
||||
if (m_project->isReadOnly()) {
|
||||
log(QStringLiteral("qet.setFolioTitle: project is read-only"));
|
||||
return false;
|
||||
}
|
||||
const QList<Diagram *> diagrams = m_project->diagrams();
|
||||
if (folioIndex < 0 || folioIndex >= diagrams.count()) return false;
|
||||
Diagram *diagram = diagrams.at(folioIndex);
|
||||
|
||||
// The folio title is one field of the title block properties, so it
|
||||
// changes the way the title block dialog changes it: read the whole
|
||||
// struct, set one member, push the command with both versions.
|
||||
const TitleBlockProperties old_properties = diagram->border_and_titleblock.exportTitleBlock();
|
||||
if (old_properties.title == title) return true;
|
||||
TitleBlockProperties new_properties = old_properties;
|
||||
new_properties.title = title;
|
||||
|
||||
auto *cmd = new ChangeTitleBlockCommand(diagram, old_properties, new_properties);
|
||||
m_project->undoStack()->push(cmd);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool QetScriptApi::undo()
|
||||
{
|
||||
if (!m_project || !m_project->undoStack()->canUndo()) return false;
|
||||
|
||||
@@ -21,10 +21,15 @@
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantList>
|
||||
|
||||
class QETProject;
|
||||
class DiagramView;
|
||||
class Element;
|
||||
class Terminal;
|
||||
class Conductor;
|
||||
class IndependentTextItem;
|
||||
class QetShapeItem;
|
||||
|
||||
/**
|
||||
@brief The QetScriptApi class
|
||||
@@ -65,12 +70,86 @@ class Element;
|
||||
QPropertyUndoCommand merges consecutive commands on the same
|
||||
object+property when their text() also matches
|
||||
(QPropertyUndoCommand::mergeWith(), pre-existing), and
|
||||
setElementPosition()/moveElement() always use the same text for a
|
||||
given element -- so several position changes to the same element in a
|
||||
row collapse into one undo step, the same way dragging an element
|
||||
does, not one step per call. Verified against exactly that: two
|
||||
setElementPosition()/moveElement()/rotateElement() always use the
|
||||
same text for a given element -- so several position changes, or
|
||||
several rotations, of the same element in a row collapse into one
|
||||
undo step, the same way dragging or repeatedly rotating an element
|
||||
does, not one step per call. setElementInfo()/setElementLabel()
|
||||
behave the same way for the same reason, through
|
||||
ChangeElementInformationCommand::mergeWith(). Verified against exactly that: two
|
||||
consecutive calls on one element, then undo/undo/redo/redo, land
|
||||
where a merge predicts, not where two independent steps would.
|
||||
- @b Wiring, @b labelling and @b folios: create a conductor between two
|
||||
terminals (ConductorCreator, the same class the GUI's
|
||||
drag-a-rectangle-over-terminals path uses, so the result inherits an
|
||||
existing potential's properties and joins conductor auto-numbering),
|
||||
change an element's label or any other information key
|
||||
(ChangeElementInformationCommand, which also tells the project
|
||||
database what changed), add a folio (QETProject::addNewDiagram(),
|
||||
already undoable) and set its title (ChangeTitleBlockCommand). With
|
||||
addElement() these are what make a script able to draw rather than
|
||||
only rearrange: before them a script could place two symbols and had
|
||||
no way to connect them.
|
||||
|
||||
Terminals are addressed by their @b index in Element::terminals(),
|
||||
not by uuid, and elementTerminals() prints that indexing so a script
|
||||
can see what it is about to wire. Terminal uuids look like the
|
||||
obvious key and are not one: Terminal::uuid() is a property of the
|
||||
catalog .elmt definition, empty for most of the installed base and,
|
||||
where present, identical across every instance of that element -- so
|
||||
it does not distinguish one placed coil's A1 from another's.
|
||||
- @b Conductor properties and @b cross-references: set a conductor's
|
||||
number, formula, colour or section, and link a master to a slave or
|
||||
one report to another. Both follow the application's own rules rather
|
||||
than writing the field: a conductor property is applied to every
|
||||
conductor of the same electrical potential, which is what the GUI and
|
||||
search-and-replace both do -- a wire number belongs to a potential,
|
||||
not to one drawn segment -- and a link is refused unless
|
||||
LinkElementCommand::isLinkable() allows it, which is where the
|
||||
master/slave, PLC-pairing and report-direction rules already live.
|
||||
linkElements() takes a folio index for each end because a master and
|
||||
its slave are usually on different ones.
|
||||
|
||||
A conductor is addressed as "the conductor on terminal i of element
|
||||
U", not by an identity of its own: conductors have no persisted uuid,
|
||||
and the folio-scoped integer ids the file uses for their ends are
|
||||
renumbered on every save, so there is nothing stable to name one by.
|
||||
Since the change is potential-wide anyway, any terminal of the
|
||||
potential names it equally well. A terminal carrying more than one
|
||||
conductor is ambiguous and is refused rather than guessed at -- which
|
||||
in practice means a potential is addressed from one of its leaf
|
||||
terminals, not from the hub several conductors meet at.
|
||||
- @b Text and @b shapes: the drawing furniture a folio carries beside
|
||||
its circuit -- a free-standing note, a line, a rectangle, an ellipse
|
||||
-- added with the same AddGraphicsObjectCommand the corresponding GUI
|
||||
tools use, and changed through the plainText/color/rotation
|
||||
properties those items already publish.
|
||||
|
||||
These are addressed by @b index into a listing sorted by position
|
||||
(top to bottom, then left to right), because unlike an element they
|
||||
carry no uuid and unlike a conductor they have no terminal to be
|
||||
named by. Position is the only identity they have, and it persists,
|
||||
so the ordering is the same after a save and reload -- verified
|
||||
against exactly that. What it is @b not stable against is adding or
|
||||
deleting one: indexes after the affected position shift, the way a
|
||||
list's do. Call texts() or shapes() again rather than holding an
|
||||
index across an edit that adds or removes one.
|
||||
- @b Querying the project database: run a read-only SELECT against the
|
||||
SQLite database QElectroTech builds from the project, and get rows
|
||||
back as objects. This is not a new door. QET already ships a
|
||||
"Requête SQL personnalisée" box in the element-query dialog where a
|
||||
user types arbitrary SQL, and it is guarded by the same
|
||||
projectDataBase::isReadOnlySelect() this calls through
|
||||
projectDataBase::newQuery(). A script gets what a user already has,
|
||||
under the same rule, and neither can write.
|
||||
|
||||
What is worth knowing is what the database @b is: a cache, rebuilt
|
||||
from the XML on every load and never written to disk. The three
|
||||
views -- element_nomenclature_view, project_summary_view and
|
||||
wiring_list_view -- exist to be queried and are the surface to
|
||||
depend on. The underlying tables are how the cache happens to be
|
||||
arranged today, and a column may move. tables() lists both so a
|
||||
script can see what it is querying rather than guess.
|
||||
- @b Navigating and @b messaging: select an element, zoom the active
|
||||
view, and show the user a message. Deliberately narrow: selection and
|
||||
messaging work with no view at all (headless `--run`); zoom is a no-op
|
||||
@@ -87,7 +166,11 @@ class Element;
|
||||
import-collision case that would otherwise reach
|
||||
QETProject::importElement()'s own ImportElementDialog::exec() and
|
||||
refuses instead, rather than let a plain QDialog (not routed through
|
||||
QetMessageBox) block a script the same way.
|
||||
QetMessageBox) block a script the same way. addConductor() declines the
|
||||
same way, for the same reason, when the two terminals belong to two
|
||||
different existing potentials and ConductorCreator would therefore ask
|
||||
which one's properties to inherit -- measured: with that check removed,
|
||||
exactly that call never returns.
|
||||
*/
|
||||
class QetScriptApi : public QObject
|
||||
{
|
||||
@@ -128,7 +211,62 @@ class QetScriptApi : public QObject
|
||||
Q_INVOKABLE QString addElement(int folioIndex, const QString &locationPath, double x, double y);
|
||||
Q_INVOKABLE bool setElementPosition(int folioIndex, const QString &elementUuid, double x, double y);
|
||||
Q_INVOKABLE bool moveElement(int folioIndex, const QString &elementUuid, double dx, double dy);
|
||||
Q_INVOKABLE bool rotateElement(int folioIndex, const QString &elementUuid, double angle);
|
||||
Q_INVOKABLE bool deleteElement(int folioIndex, const QString &elementUuid);
|
||||
|
||||
// -- address what is already there --
|
||||
Q_INVOKABLE QStringList elementUuids(int folioIndex) const;
|
||||
Q_INVOKABLE QString elementName(int folioIndex, const QString &elementUuid) const;
|
||||
Q_INVOKABLE QStringList elementTerminals(int folioIndex, const QString &elementUuid) const;
|
||||
|
||||
// -- element information, through ChangeElementInformationCommand --
|
||||
Q_INVOKABLE QString elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const;
|
||||
Q_INVOKABLE bool setElementInfo(int folioIndex, const QString &elementUuid, const QString &key, const QString &value);
|
||||
Q_INVOKABLE QString elementLabel(int folioIndex, const QString &elementUuid) const;
|
||||
Q_INVOKABLE bool setElementLabel(int folioIndex, const QString &elementUuid, const QString &label);
|
||||
|
||||
// -- wire two terminals together --
|
||||
Q_INVOKABLE bool addConductor(int folioIndex,
|
||||
const QString &elementUuidA, int terminalIndexA,
|
||||
const QString &elementUuidB, int terminalIndexB);
|
||||
|
||||
// -- conductor properties, applied to the whole potential --
|
||||
Q_INVOKABLE QStringList conductors(int folioIndex) const;
|
||||
Q_INVOKABLE QString conductorProperty(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, const QString &property) const;
|
||||
Q_INVOKABLE bool setConductorProperty(int folioIndex, const QString &elementUuid,
|
||||
int terminalIndex, const QString &property,
|
||||
const QString &value);
|
||||
|
||||
// -- cross-references: master/slave and report links --
|
||||
Q_INVOKABLE QString elementLinkType(int folioIndex, const QString &elementUuid) const;
|
||||
Q_INVOKABLE QStringList linkedElements(int folioIndex, const QString &elementUuid) const;
|
||||
Q_INVOKABLE bool linkElements(int folioIndexA, const QString &elementUuidA,
|
||||
int folioIndexB, const QString &elementUuidB);
|
||||
Q_INVOKABLE bool unlinkElement(int folioIndex, const QString &elementUuid);
|
||||
|
||||
// -- independent text and drawing shapes --
|
||||
Q_INVOKABLE QStringList texts(int folioIndex) const;
|
||||
Q_INVOKABLE int addText(int folioIndex, const QString &text, double x, double y);
|
||||
Q_INVOKABLE bool setTextContent(int folioIndex, int textIndex, const QString &text);
|
||||
Q_INVOKABLE bool setTextColor(int folioIndex, int textIndex, const QString &color);
|
||||
Q_INVOKABLE bool setTextRotation(int folioIndex, int textIndex, double angle);
|
||||
Q_INVOKABLE bool deleteText(int folioIndex, int textIndex);
|
||||
|
||||
Q_INVOKABLE QStringList shapes(int folioIndex) const;
|
||||
Q_INVOKABLE int addShape(int folioIndex, const QString &type,
|
||||
double x1, double y1, double x2, double y2);
|
||||
Q_INVOKABLE bool deleteShape(int folioIndex, int shapeIndex);
|
||||
|
||||
// -- query the project database --
|
||||
Q_INVOKABLE QStringList tables() const;
|
||||
Q_INVOKABLE QVariantList query(const QString &sql);
|
||||
Q_INVOKABLE QString queryError() const;
|
||||
|
||||
// -- folios --
|
||||
Q_INVOKABLE int addFolio();
|
||||
Q_INVOKABLE bool setFolioTitle(int folioIndex, const QString &title);
|
||||
|
||||
Q_INVOKABLE bool undo();
|
||||
Q_INVOKABLE bool redo();
|
||||
Q_INVOKABLE bool canUndo() const;
|
||||
@@ -148,9 +286,19 @@ class QetScriptApi : public QObject
|
||||
private:
|
||||
bool runFlag(const QString &flag, const QStringList &args);
|
||||
Element *findElement(int folioIndex, const QString &elementUuid) const;
|
||||
Terminal *findTerminal(int folioIndex, const QString &elementUuid, int terminalIndex,
|
||||
const QString &caller);
|
||||
Conductor *findConductor(int folioIndex, const QString &elementUuid, int terminalIndex,
|
||||
const QString &caller);
|
||||
QList<IndependentTextItem *> sortedTexts(int folioIndex) const;
|
||||
QList<QetShapeItem *> sortedShapes(int folioIndex) const;
|
||||
IndependentTextItem *findText(int folioIndex, int textIndex, const QString &caller);
|
||||
bool setInfoKey(int folioIndex, const QString &elementUuid,
|
||||
const QString &key, const QString &value, const QString &caller);
|
||||
|
||||
QETProject *m_project;
|
||||
DiagramView *m_view;
|
||||
QString m_query_error;
|
||||
};
|
||||
|
||||
#endif // QET_SCRIPT_API_H
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#include "NameList/nameslist.h"
|
||||
#include "createdxf.h"
|
||||
#include "diagram.h"
|
||||
#include "qet.h"
|
||||
#include "qetapp.h"
|
||||
// uncomment the line below to get more debug information
|
||||
@@ -1589,8 +1590,10 @@ void TitleBlockTemplate::render(QPainter &painter,
|
||||
int titleblock_height = height();
|
||||
|
||||
painter.save();
|
||||
//Setup the QPainter
|
||||
QPen pen(Qt::black);
|
||||
//Setup the QPainter - use a color that contrasts with the background
|
||||
QColor ink = Diagram::background_color.lightness() < 128
|
||||
? QColor(Qt::white) : QColor(Qt::black);
|
||||
QPen pen(ink);
|
||||
painter.setPen(pen);
|
||||
|
||||
// draw the titleblock border
|
||||
@@ -1737,7 +1740,9 @@ void TitleBlockTemplate::renderCell(QPainter &painter,
|
||||
{
|
||||
// draw the border rect of the current cell
|
||||
QPen pen(QBrush(), 1, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin);
|
||||
pen.setColor(Qt::black);
|
||||
QColor ink = Diagram::background_color.lightness() < 128
|
||||
? QColor(Qt::white) : QColor(Qt::black);
|
||||
pen.setColor(ink);
|
||||
painter.setPen(pen);
|
||||
painter.drawRect(cell_rect);
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "diagrambgcolorbutton.h"
|
||||
|
||||
#include "../diagram.h"
|
||||
#include "../diagramview.h"
|
||||
#include "../palettegraphicsview.h"
|
||||
#include "../qetdiagrameditor.h"
|
||||
#include "../projectview.h"
|
||||
#include "../qetproject.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QColorDialog>
|
||||
#include <QMenu>
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
|
||||
namespace {
|
||||
struct NamedColor { const char *context_name; QColor color; };
|
||||
|
||||
QList<NamedColor> standardColors()
|
||||
{
|
||||
return {
|
||||
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Blanc"), QColor(0xFF, 0xFF, 0xFF)},
|
||||
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Blanc cassé"), QColor(0xFD, 0xFB, 0xF5)},
|
||||
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Gris clair"), QColor(0xE0, 0xE0, 0xE0)},
|
||||
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Gris"), QColor(0x80, 0x80, 0x80)},
|
||||
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Gris foncé"), QColor(0x40, 0x40, 0x40)},
|
||||
{QT_TRANSLATE_NOOP("DiagramBgColorToolButton", "Noir"), QColor(0x00, 0x00, 0x00)},
|
||||
};
|
||||
}
|
||||
|
||||
const int MAX_RECENT = 6;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::DiagramBgColorToolButton
|
||||
@param editor : the diagram editor this button acts on
|
||||
@param parent
|
||||
*/
|
||||
DiagramBgColorToolButton::DiagramBgColorToolButton(QETDiagramEditor *editor, QWidget *parent) :
|
||||
QToolButton(parent),
|
||||
m_editor(editor)
|
||||
{
|
||||
setPopupMode(QToolButton::InstantPopup);
|
||||
setToolTip(tr("Couleur de fond du folio"));
|
||||
setStatusTip(tr("Choisir la couleur de fond du folio",
|
||||
"status bar tip"));
|
||||
|
||||
m_is_system_color = true;
|
||||
m_current = QApplication::palette().color(QPalette::Base);
|
||||
setMenu(new QMenu(this));
|
||||
rebuildMenu();
|
||||
setSwatch(m_current);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::rebuildMenu
|
||||
*/
|
||||
void DiagramBgColorToolButton::rebuildMenu()
|
||||
{
|
||||
QMenu *m = menu();
|
||||
m->clear();
|
||||
|
||||
QAction *sys = m->addAction(tr("Couleur système"));
|
||||
connect(sys, &QAction::triggered, this, &DiagramBgColorToolButton::applySystemColor);
|
||||
m->addSeparator();
|
||||
|
||||
for (const auto &nc : standardColors())
|
||||
{
|
||||
const QColor c = nc.color;
|
||||
QAction *a = m->addAction(swatchIcon(c),
|
||||
tr(nc.context_name));
|
||||
connect(a, &QAction::triggered, this, [this, c]() { applyColor(c); });
|
||||
}
|
||||
|
||||
if (!m_recent.isEmpty())
|
||||
{
|
||||
m->addSeparator();
|
||||
QAction *title = m->addAction(tr("Récemment utilisées"));
|
||||
title->setEnabled(false);
|
||||
for (const QColor &c : std::as_const(m_recent))
|
||||
{
|
||||
QAction *a = m->addAction(swatchIcon(c), c.name());
|
||||
connect(a, &QAction::triggered, this, [this, c]() { applyColor(c); });
|
||||
}
|
||||
}
|
||||
|
||||
m->addSeparator();
|
||||
QAction *other = m->addAction(tr("Autre couleur…"));
|
||||
connect(other, &QAction::triggered, this, &DiagramBgColorToolButton::chooseOtherColor);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::applyColor
|
||||
Set a custom background color on all open diagrams.
|
||||
@param color
|
||||
*/
|
||||
void DiagramBgColorToolButton::applyColor(const QColor &color)
|
||||
{
|
||||
if (!color.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_is_system_color = false;
|
||||
rememberRecent(color);
|
||||
setSwatch(color);
|
||||
|
||||
PaletteGraphicsView::setCustomBackgroundColor(true);
|
||||
Diagram::background_color = color;
|
||||
|
||||
QETDiagramEditor *editor = m_editor;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
for (ProjectView *pv : editor->openedProjects())
|
||||
for (Diagram *d : pv->project()->diagrams())
|
||||
d->update();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::applySystemColor
|
||||
Restore the system-default background and re-enable dark-mode
|
||||
inversion.
|
||||
*/
|
||||
void DiagramBgColorToolButton::applySystemColor()
|
||||
{
|
||||
m_is_system_color = true;
|
||||
m_current = QApplication::palette().color(QPalette::Base);
|
||||
setSwatch(m_current);
|
||||
|
||||
PaletteGraphicsView::setCustomBackgroundColor(false);
|
||||
Diagram::background_color = Qt::white;
|
||||
|
||||
QETDiagramEditor *editor = m_editor;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
for (ProjectView *pv : editor->openedProjects())
|
||||
for (Diagram *d : pv->project()->diagrams())
|
||||
d->update();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::chooseOtherColor
|
||||
*/
|
||||
void DiagramBgColorToolButton::chooseOtherColor()
|
||||
{
|
||||
const QColor c = QColorDialog::getColor(m_current, this,
|
||||
tr("Choisir une couleur de fond"));
|
||||
if (c.isValid()) {
|
||||
applyColor(c);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::rememberRecent
|
||||
Most recent first, no duplicates, capped.
|
||||
@param color
|
||||
*/
|
||||
void DiagramBgColorToolButton::rememberRecent(const QColor &color)
|
||||
{
|
||||
for (const auto &nc : standardColors()) {
|
||||
if (nc.color == color) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
m_recent.removeAll(color);
|
||||
m_recent.prepend(color);
|
||||
while (m_recent.size() > MAX_RECENT) {
|
||||
m_recent.removeLast();
|
||||
}
|
||||
rebuildMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::setSwatch
|
||||
@param color
|
||||
*/
|
||||
void DiagramBgColorToolButton::setSwatch(const QColor &color)
|
||||
{
|
||||
m_current = color;
|
||||
setIcon(swatchIcon(color));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::syncFromDiagram
|
||||
Sync the button swatch to whatever Diagram::background_color is
|
||||
currently set to. Called when the active folio changes.
|
||||
*/
|
||||
void DiagramBgColorToolButton::syncFromDiagram()
|
||||
{
|
||||
if (m_is_system_color) {
|
||||
m_current = QApplication::palette().color(QPalette::Base);
|
||||
} else {
|
||||
m_current = Diagram::background_color;
|
||||
}
|
||||
setSwatch(m_current);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::updateEnabledState
|
||||
*/
|
||||
void DiagramBgColorToolButton::updateEnabledState()
|
||||
{
|
||||
setEnabled(m_editor && m_editor->currentProjectView());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramBgColorToolButton::swatchIcon
|
||||
@param color
|
||||
@return a plain square of that colour, outlined so that white and very
|
||||
light colours are still visible against the toolbar.
|
||||
*/
|
||||
QIcon DiagramBgColorToolButton::swatchIcon(const QColor &color)
|
||||
{
|
||||
QPixmap pix(16, 16);
|
||||
pix.fill(Qt::transparent);
|
||||
QPainter p(&pix);
|
||||
p.setRenderHint(QPainter::Antialiasing, false);
|
||||
p.setBrush(color);
|
||||
p.setPen(QPen(QColor(0x40, 0x40, 0x40), 1));
|
||||
p.drawRect(0, 0, 15, 15);
|
||||
p.end();
|
||||
return QIcon(pix);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Copyright 2006-2026 The QElectroTech Team
|
||||
This file is part of QElectroTech.
|
||||
|
||||
QElectroTech is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
QElectroTech is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#ifndef DIAGRAMBGCOLORTOOLBUTTON_H
|
||||
#define DIAGRAMBGCOLORTOOLBUTTON_H
|
||||
|
||||
#include <QColor>
|
||||
#include <QList>
|
||||
#include <QToolButton>
|
||||
|
||||
class QETDiagramEditor;
|
||||
|
||||
/**
|
||||
@brief The DiagramBgColorToolButton class
|
||||
Color picker for the diagram sheet background, placed in the
|
||||
"Affichage" toolbar. Mirrors the ConductorColorToolButton UX:
|
||||
preset colors in the dropdown, "Autre couleur..." at the bottom,
|
||||
and a swatch icon on the button itself.
|
||||
|
||||
Picking "Couleur système" clears any custom colour and lets the
|
||||
dark-mode inversion handle the background as before.
|
||||
*/
|
||||
class DiagramBgColorToolButton : public QToolButton
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DiagramBgColorToolButton(QETDiagramEditor *editor,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
public slots:
|
||||
void updateEnabledState();
|
||||
void syncFromDiagram();
|
||||
|
||||
private:
|
||||
void rebuildMenu();
|
||||
void applyColor(const QColor &color);
|
||||
void applySystemColor();
|
||||
void chooseOtherColor();
|
||||
void rememberRecent(const QColor &color);
|
||||
void setSwatch(const QColor &color);
|
||||
static QIcon swatchIcon(const QColor &color);
|
||||
|
||||
QETDiagramEditor *m_editor = nullptr;
|
||||
QList<QColor> m_recent;
|
||||
QColor m_current;
|
||||
bool m_is_system_color = false;
|
||||
};
|
||||
|
||||
#endif // DIAGRAMBGCOLORTOOLBUTTON_H
|
||||
@@ -95,6 +95,29 @@ void ConductorCreator::create(Diagram *d, const QPolygonF &polygon)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorCreator::needsPotentialChoice
|
||||
Whether creating a potential between these terminals would ask the user
|
||||
to choose which of several existing potentials to inherit from -- that
|
||||
is, whether the constructor would reach PotentialSelectorDialog.
|
||||
|
||||
This exists for callers with nobody there to answer: the dialog is a
|
||||
plain QDialog::exec(), not routed through QET::QetMessageBox, so its
|
||||
non-interactive mode does not cover it and a headless caller would hang
|
||||
on it indefinitely. Such a caller can check this first and decline.
|
||||
Exposed here, rather than reimplemented by the caller, so the condition
|
||||
cannot drift away from the one setUpPropertieToUse() actually applies.
|
||||
@param terminals_list the terminals a potential would be created between
|
||||
@return true if the constructor would open the dialog
|
||||
*/
|
||||
bool ConductorCreator::needsPotentialChoice(const QList<Terminal *> &terminals_list)
|
||||
{
|
||||
if (terminals_list.size() <= 1) {
|
||||
return false;
|
||||
}
|
||||
return existingPotential(terminals_list).size() >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
@brief ConductorCreator::propertieToUse
|
||||
@return true if the caller should proceed with conductor creation,
|
||||
@@ -104,7 +127,7 @@ void ConductorCreator::create(Diagram *d, const QPolygonF &polygon)
|
||||
*/
|
||||
bool ConductorCreator::setUpPropertieToUse()
|
||||
{
|
||||
QList<Conductor *> potentials = existingPotential();
|
||||
QList<Conductor *> potentials = existingPotential(m_terminals_list);
|
||||
|
||||
//There is an existing potential
|
||||
//we get one of them
|
||||
@@ -145,14 +168,15 @@ bool ConductorCreator::setUpPropertieToUse()
|
||||
@brief ConductorCreator::existingPotential
|
||||
Return the list of existing potential of
|
||||
the terminal list
|
||||
@param terminals_list the terminals to inspect
|
||||
@return c_list QList<Conductor *>
|
||||
*/
|
||||
QList<Conductor *> ConductorCreator::existingPotential()
|
||||
QList<Conductor *> ConductorCreator::existingPotential(const QList<Terminal *> &terminals_list)
|
||||
{
|
||||
QList<Conductor *> c_list;
|
||||
QList<Terminal *> t_exclude;
|
||||
|
||||
for (Terminal *t : m_terminals_list)
|
||||
for (Terminal *t : terminals_list)
|
||||
{
|
||||
if (t_exclude.contains(t)) {
|
||||
continue;
|
||||
@@ -166,9 +190,9 @@ QList<Conductor *> ConductorCreator::existingPotential()
|
||||
//in the same potential of c, and if true, exclude this terminal from the search.
|
||||
for (Conductor *c : t->conductors().first()->relatedPotentialConductors(false))
|
||||
{
|
||||
if (m_terminals_list.contains(c->terminal1)) {
|
||||
if (terminals_list.contains(c->terminal1)) {
|
||||
t_exclude.append(c->terminal1);
|
||||
} else if (m_terminals_list.contains(c->terminal2)) {
|
||||
} else if (terminals_list.contains(c->terminal2)) {
|
||||
t_exclude.append(c->terminal2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,11 @@ class ConductorCreator
|
||||
public:
|
||||
ConductorCreator(Diagram *d, QList<Terminal *> terminals_list);
|
||||
static void create(Diagram *d, const QPolygonF &polygon);
|
||||
|
||||
static bool needsPotentialChoice(const QList<Terminal *> &terminals_list);
|
||||
|
||||
private:
|
||||
static QList<Conductor *> existingPotential(const QList<Terminal *> &terminals_list);
|
||||
bool setUpPropertieToUse();
|
||||
QList<Conductor *> existingPotential();
|
||||
Terminal *hubTerminal();
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user