Release 1.4.2: load-only dialog when nothing is selected
tests / fedora:latest (push) Successful in 1m15s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m1s
tests / archlinux:latest (push) Successful in 53s
tests / debian:12 (push) Successful in 51s
tests / ubuntu:24.04 (push) Successful in 1m8s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 10s
tests / fedora:latest (push) Successful in 1m15s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m1s
tests / archlinux:latest (push) Successful in 53s
tests / debian:12 (push) Successful in 51s
tests / ubuntu:24.04 (push) Successful in 1m8s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 10s
With neither mode derivable (nothing selected, no marker rectangles, no config) the dialog now opens anyway instead of failing with an error figure: both radios disabled with their reasons, OK/Save disabled, Load config… live - a saved config can bootstrap the run. README: note that macOS/NixOS field tests last ran with plugin v1.3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjSRLWQ8ywBBYyakvr3YTx
This commit is contained in:
@@ -45,8 +45,8 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
| Platform | Status | Verified by |
|
||||
|-----------------------------|:------:|-------------|
|
||||
| Windows | ✅ | development platform, full suite before every release |
|
||||
| macOS | ✅ | field-tested in KiCad 10 |
|
||||
| NixOS | ✅ | field-tested in KiCad 10 ([setup](docs/NIXOS.md)) |
|
||||
| macOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3) |
|
||||
| NixOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3, [setup](docs/NIXOS.md)) |
|
||||
| Debian 12 | ✅ | CI test suite in container |
|
||||
| Ubuntu 24.04 | ✅ | CI test suite in container |
|
||||
| Fedora (latest) | ✅ | CI test suite in container |
|
||||
@@ -57,16 +57,17 @@ rasterizer, and the platform-fallback regressions — headless against
|
||||
the real pip wheels of each Linux row, including the
|
||||
`PySide6.QtWidgets` import probe that decides the matplotlib backend.
|
||||
What CI *cannot* do is launch KiCad itself, so "runs inside KiCad"
|
||||
remains field-tested (Windows continuously, macOS and NixOS per
|
||||
release).
|
||||
remains field-tested (Windows continuously; macOS and NixOS last
|
||||
field-tested with plugin v1.3 — later versions are covered there by
|
||||
the test suites only).
|
||||
|
||||
## Setup (one-time)
|
||||
|
||||
The plugin is developed and tested on **Windows**; **macOS works**
|
||||
(field-tested on KiCad 10 after a round of mac-specific fixes), and
|
||||
**Linux works** (field-tested on NixOS — the hardest Linux to run pip
|
||||
wheels on; mainstream FHS distributions should be no harder, reports
|
||||
welcome). KiCad builds the plugin a private Python venv from
|
||||
(field-tested on KiCad 10 after a round of mac-specific fixes, last
|
||||
with plugin v1.3), and **Linux works** (field-tested on NixOS — the
|
||||
hardest Linux to run pip wheels on, last with plugin v1.3; mainstream
|
||||
FHS distributions should be no harder, reports welcome). KiCad builds the plugin a private Python venv from
|
||||
`requirements.txt` on every platform, from pre-built wheels only, no
|
||||
compiler needed. Steps 1–4 are the same everywhere; OS specifics are
|
||||
spelled out per step and in *Platform notes* below.
|
||||
@@ -110,7 +111,8 @@ spelled out per step and in *Platform notes* below.
|
||||
- **Windows** is the development and test platform — everything in
|
||||
this README was exercised here. KiCad's bundled Python is 3.13, so
|
||||
the venv gets the current dependency stack.
|
||||
- **macOS** — **works** (field-tested on KiCad 10). Requires
|
||||
- **macOS** — **works** (field-tested on KiCad 10, last with plugin
|
||||
v1.3). Requires
|
||||
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
|
||||
is universal). KiCad's bundled Python is **3.9**, so pip resolves
|
||||
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
|
||||
@@ -119,8 +121,9 @@ spelled out per step and in *Platform notes* below.
|
||||
Plot and dialog windows may open **behind** the KiCad window (they
|
||||
are raised best-effort) — check the Dock if nothing seems to appear
|
||||
after a solve.
|
||||
- **Linux** — **works** (field-tested on NixOS, KiCad 10; mainstream
|
||||
distributions are audited but not yet field-tested). The venv uses
|
||||
- **Linux** — **works** (field-tested on NixOS, KiCad 10, last with
|
||||
plugin v1.3; mainstream distributions are audited but not yet
|
||||
field-tested). The venv uses
|
||||
the system Python (3.9+), so the stack matches your distribution.
|
||||
On **ARM64 (aarch64)** there are no pyamg wheels —
|
||||
`requirements.txt` skips pyamg there and the solver falls back to
|
||||
@@ -236,7 +239,10 @@ the picked file. **Save config…** asks for the target file name each
|
||||
time (pre-filled with the loaded config), so writing back and saving
|
||||
a variant under a new `fill_res_config.<name>.json` are both one
|
||||
click — a name outside the auto-load set prints a reminder that it
|
||||
needs Load config….
|
||||
needs Load config…. The dialog opens even when nothing on the board
|
||||
yields a setup (nothing selected, no marker rectangles, no config):
|
||||
load-only, with both mode radios disabled and their reasons shown, so
|
||||
**Load config…** can bootstrap the run.
|
||||
|
||||
**Precedence**: `config.py` constants < config file < dialog edits.
|
||||
The file pre-fills the dialog; what the dialog shows is what runs. A
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
Launching with nothing usable on the board no longer dead-ends: when
|
||||
neither mode can be derived (nothing selected, no marker rectangles,
|
||||
no config), the dialog now opens anyway - load-only - instead of
|
||||
failing with an error figure.
|
||||
|
||||
- Both mode radios are unchecked and disabled with their reasons
|
||||
shown; OK and Save config... are disabled too. Load config... (and
|
||||
Cancel) stay live, so a saved config can bootstrap the run without
|
||||
first selecting pads or drawing marker rectangles.
|
||||
- A loaded file re-derives everything exactly as if it had been
|
||||
present at launch - mode, net, terminals, values.
|
||||
@@ -4,4 +4,4 @@ __version__ is the runtime source of truth (metadata.json and
|
||||
pyproject.toml are not deployed with the plugin); a test keeps the
|
||||
three in sync.
|
||||
"""
|
||||
__version__ = "1.4.1"
|
||||
__version__ = "1.4.2"
|
||||
|
||||
+47
-19
@@ -26,7 +26,11 @@ Two run modes share the dialog, chosen by a radio at the top:
|
||||
|
||||
"Load config…" swaps the whole setup for another config file: ask()
|
||||
then returns a LoadRequest instead of a Selection and main re-derives
|
||||
everything from that file and reopens the dialog.
|
||||
everything from that file and reopens the dialog. The dialog even
|
||||
opens with NO runnable mode at all (nothing selected, no marker
|
||||
rectangles, no config) - load-only: both radios unchecked and
|
||||
disabled with their reasons, OK/Save disabled - so a saved config can
|
||||
bootstrap the run.
|
||||
|
||||
Row identity is POSITIONAL: the tables never sort or reorder, so
|
||||
main.py zips Selection.pdn_rows with its own parallel terminal list.
|
||||
@@ -205,16 +209,28 @@ class _Dialog(QDialog):
|
||||
self.mode_pdn = QRadioButton("PDN")
|
||||
self.mode_classic.setEnabled(self._classic_ok)
|
||||
self.mode_pdn.setEnabled(self._pdn_ok)
|
||||
start_pdn = self._pdn_ok and (not self._classic_ok
|
||||
or start_mode == "pdn")
|
||||
(self.mode_pdn if start_pdn else self.mode_classic).setChecked(True)
|
||||
reason = None
|
||||
# with NEITHER mode derivable (nothing selected, no marker
|
||||
# rectangles, no config terminals) the dialog still opens
|
||||
# LOAD-ONLY: no radio checked, OK/Save disabled below - so
|
||||
# "Load config…" can pull in a saved setup instead of the
|
||||
# launch dying with an error figure
|
||||
self._no_mode = not (self._classic_ok or self._pdn_ok)
|
||||
if not self._no_mode:
|
||||
start_pdn = self._pdn_ok and (not self._classic_ok
|
||||
or start_mode == "pdn")
|
||||
(self.mode_pdn if start_pdn
|
||||
else self.mode_classic).setChecked(True)
|
||||
reasons = []
|
||||
if not self._classic_ok and classic_reason:
|
||||
reason = f"Classic unavailable: {classic_reason}"
|
||||
reasons.append(f"Classic unavailable: {classic_reason}")
|
||||
self.mode_classic.setToolTip(classic_reason)
|
||||
elif not self._pdn_ok and pdn_reason:
|
||||
reason = f"PDN unavailable: {pdn_reason}"
|
||||
if not self._pdn_ok and pdn_reason:
|
||||
reasons.append(f"PDN unavailable: {pdn_reason}")
|
||||
self.mode_pdn.setToolTip(pdn_reason)
|
||||
if self._no_mode and load_dir is not None:
|
||||
reasons.append("Load config… below can still set up a run "
|
||||
"from a saved config file.")
|
||||
reason = "\n".join(reasons) if reasons else None
|
||||
|
||||
# --- shared form #1 --------------------------------------------
|
||||
form1 = QFormLayout()
|
||||
@@ -387,14 +403,23 @@ class _Dialog(QDialog):
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self._try_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
self.ok_button = buttons.button(QDialogButtonBox.Ok)
|
||||
self.load_button = None
|
||||
self.save_button = None
|
||||
if load_dir is not None:
|
||||
load_btn = buttons.addButton("Load config…",
|
||||
QDialogButtonBox.ActionRole)
|
||||
load_btn.clicked.connect(self._load_config)
|
||||
self.load_button = buttons.addButton(
|
||||
"Load config…", QDialogButtonBox.ActionRole)
|
||||
self.load_button.clicked.connect(self._load_config)
|
||||
if save_callback is not None:
|
||||
save_btn = buttons.addButton("Save config…",
|
||||
QDialogButtonBox.ActionRole)
|
||||
save_btn.clicked.connect(self._save_config)
|
||||
self.save_button = buttons.addButton(
|
||||
"Save config…", QDialogButtonBox.ActionRole)
|
||||
self.save_button.clicked.connect(self._save_config)
|
||||
if self._no_mode:
|
||||
# nothing to run or to save until a load re-derives; Load
|
||||
# config… and Cancel stay live
|
||||
self.ok_button.setEnabled(False)
|
||||
if self.save_button is not None:
|
||||
self.save_button.setEnabled(False)
|
||||
|
||||
content = QWidget()
|
||||
lay = QVBoxLayout(content)
|
||||
@@ -405,11 +430,12 @@ class _Dialog(QDialog):
|
||||
mode_row.addWidget(self.mode_pdn)
|
||||
mode_row.addStretch(1)
|
||||
lay.addLayout(mode_row)
|
||||
self.reason_label = None
|
||||
if reason is not None:
|
||||
rl = QLabel(reason)
|
||||
rl.setWordWrap(True)
|
||||
rl.setStyleSheet("color: gray; font-size: 10px;")
|
||||
lay.addWidget(rl)
|
||||
self.reason_label = QLabel(reason)
|
||||
self.reason_label.setWordWrap(True)
|
||||
self.reason_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||
lay.addWidget(self.reason_label)
|
||||
lay.addLayout(form1)
|
||||
if self.classic_section is not None:
|
||||
lay.addWidget(self.classic_section)
|
||||
@@ -997,7 +1023,9 @@ def ask(candidates: dict[str, list[str]], layer_order: list[str],
|
||||
config file); pdn: the PDN terminal setup (editable tables);
|
||||
pdn_candidates: net candidates for PDN mode (classic uses
|
||||
`candidates`); classic_reason / pdn_reason: why a mode is
|
||||
unavailable (its radio is disabled with the reason shown);
|
||||
unavailable (its radio is disabled with the reason shown; with
|
||||
BOTH unavailable the dialog opens load-only - OK and Save disabled
|
||||
until Load config… returns a LoadRequest);
|
||||
save_callback(selection, target_path) -> saved name string enables
|
||||
the "Save config…" button in both modes (the file name is asked
|
||||
per save, seeded with save_target); load_dir (the board directory)
|
||||
|
||||
+15
-6
@@ -7,7 +7,10 @@ config's terminal set) -> gather fills -> dialog with a Classic/PDN
|
||||
mode selector (classic: the two-contact form; PDN: editable per-role
|
||||
terminal tables) -> extract vias -> solve -> figures + report. The
|
||||
dialog's "Load config…" button loops back to the derivation with the
|
||||
picked file, so a run can be set up from any saved config.
|
||||
picked file, so a run can be set up from any saved config - and the
|
||||
dialog opens even when NEITHER mode is derivable (nothing selected, no
|
||||
marker rectangles, no config): load-only, both radios disabled with
|
||||
their reasons, so a saved config can bootstrap the run.
|
||||
|
||||
Every failure is reported twice: on stdout (lands in the KiCad status-bar
|
||||
warning list) and as a matplotlib error figure, so it cannot be missed.
|
||||
@@ -101,8 +104,10 @@ def main() -> None:
|
||||
configfile.apply_physics(cfg)
|
||||
|
||||
# BOTH terminal derivations always run; a failure only
|
||||
# disables that mode's radio (with the reason shown) - the
|
||||
# launch dies only when neither mode is possible
|
||||
# disables that mode's radio (with the reason shown).
|
||||
# Even with neither mode possible the dialog still
|
||||
# opens - load-only - so "Load config…" works without
|
||||
# first selecting pads or drawing marker rectangles
|
||||
classic_reason = pdn_reason = None
|
||||
es1: list = []
|
||||
es2: list = []
|
||||
@@ -210,9 +215,13 @@ def main() -> None:
|
||||
f"workable supply+load copper; PDN "
|
||||
f"candidates: {', '.join(pdn_nets)}")
|
||||
if classic_reason is not None and pdn_reason is not None:
|
||||
raise SelectionError(
|
||||
f"{classic_reason}\n(PDN mode is also "
|
||||
f"unavailable: {pdn_reason})")
|
||||
# nothing runnable right now: the dialog opens with
|
||||
# both radios and OK/Save disabled, offering only
|
||||
# Load config… (and Cancel) - a loaded file then
|
||||
# re-derives everything through the loop below
|
||||
print(f"note: classic mode unavailable: "
|
||||
f"{classic_reason}")
|
||||
print(f"note: PDN mode unavailable: {pdn_reason}")
|
||||
buildups = board_io.gather_mask_buildups(board)
|
||||
except ApiError as e:
|
||||
raise UserFacingError(
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"status": "stable",
|
||||
"kicad_version": "10.0",
|
||||
"runtime": "ipc"
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
# the dependency list there in sync with [project.dependencies].
|
||||
[project]
|
||||
name = "fill-resistance"
|
||||
version = "1.4.1"
|
||||
version = "1.4.2"
|
||||
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
||||
license = "GPL-3.0-or-later"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -590,6 +590,55 @@ def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
|
||||
assert not dlg.error_label.isVisible()
|
||||
|
||||
|
||||
# --- no runnable mode: the load-only dialog ----------------------------------
|
||||
|
||||
def _no_mode_dlg(app, load_dir=None, save_callback=None):
|
||||
"""Neither mode derivable (nothing selected, no marker rects, no
|
||||
config): main opens the dialog anyway so a config can be loaded."""
|
||||
return _Dialog({}, ORDER, "", "", "", "auto", "auto",
|
||||
buildup_layers=[], pdn=None, pdn_candidates={},
|
||||
classic_reason="nothing selected",
|
||||
pdn_reason="no rectangles found",
|
||||
save_callback=save_callback, load_dir=load_dir)
|
||||
|
||||
|
||||
def test_no_mode_opens_load_only(app, tmp_path):
|
||||
dlg = _no_mode_dlg(app, load_dir=tmp_path,
|
||||
save_callback=lambda *_a: "x")
|
||||
assert not dlg.mode_classic.isEnabled()
|
||||
assert not dlg.mode_pdn.isEnabled()
|
||||
# no mode is even checked - there is nothing to run
|
||||
assert not dlg.mode_classic.isChecked()
|
||||
assert not dlg.mode_pdn.isChecked()
|
||||
assert not dlg.ok_button.isEnabled()
|
||||
assert not dlg.save_button.isEnabled()
|
||||
assert dlg.load_button.isEnabled()
|
||||
# both reasons and the load hint are shown together
|
||||
text = dlg.reason_label.text()
|
||||
assert "nothing selected" in text
|
||||
assert "no rectangles found" in text
|
||||
assert "Load config…" in text
|
||||
|
||||
|
||||
def test_no_mode_load_config_still_works(app, tmp_path, monkeypatch):
|
||||
path = tmp_path / "fill_res_config.saved.json"
|
||||
path.write_text('{"version": 1}', encoding="utf-8")
|
||||
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||
_FakePicker.result = (str(path), "json")
|
||||
dlg = _no_mode_dlg(app, load_dir=tmp_path)
|
||||
dlg._load_config()
|
||||
assert dlg._load_request == path
|
||||
assert dlg.result() == QDialog.Accepted
|
||||
|
||||
|
||||
def test_available_modes_keep_ok_and_save_enabled(app, tmp_path):
|
||||
dlg = _dlg(app, pdn=_setup(), save_callback=lambda *_a: "x",
|
||||
load_dir=tmp_path)
|
||||
assert dlg.ok_button.isEnabled()
|
||||
assert dlg.save_button.isEnabled()
|
||||
assert dlg.load_button.isEnabled()
|
||||
|
||||
|
||||
# --- save button -------------------------------------------------------------
|
||||
|
||||
def _patch_save(monkeypatch, name):
|
||||
|
||||
Reference in New Issue
Block a user