From 634df87fe040d03ff7e3c4fb349bb840f6054869 Mon Sep 17 00:00:00 2001 From: janik Date: Mon, 31 Aug 2026 11:41:49 +0700 Subject: [PATCH] Release 1.4.2: load-only dialog when nothing is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HjSRLWQ8ywBBYyakvr3YTx --- README.md | 30 +++++++++------- docs/release-notes/v1.4.2.md | 11 ++++++ fill_resistance/__init__.py | 2 +- fill_resistance/dialog.py | 66 +++++++++++++++++++++++++----------- fill_resistance/main.py | 21 ++++++++---- metadata.json | 2 +- pyproject.toml | 2 +- tests/test_dialog.py | 49 ++++++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 144 insertions(+), 41 deletions(-) create mode 100644 docs/release-notes/v1.4.2.md diff --git a/README.md b/README.md index b34cbfa..ac9ced5 100644 --- a/README.md +++ b/README.md @@ -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..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 diff --git a/docs/release-notes/v1.4.2.md b/docs/release-notes/v1.4.2.md new file mode 100644 index 0000000..97e3ab7 --- /dev/null +++ b/docs/release-notes/v1.4.2.md @@ -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. diff --git a/fill_resistance/__init__.py b/fill_resistance/__init__.py index 6020de0..71dee0b 100644 --- a/fill_resistance/__init__.py +++ b/fill_resistance/__init__.py @@ -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" diff --git a/fill_resistance/dialog.py b/fill_resistance/dialog.py index 69f1ea1..af9692d 100644 --- a/fill_resistance/dialog.py +++ b/fill_resistance/dialog.py @@ -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) diff --git a/fill_resistance/main.py b/fill_resistance/main.py index 5533d11..b9eb876 100644 --- a/fill_resistance/main.py +++ b/fill_resistance/main.py @@ -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( diff --git a/metadata.json b/metadata.json index 5ce102c..2df93b3 100644 --- a/metadata.json +++ b/metadata.json @@ -17,7 +17,7 @@ }, "versions": [ { - "version": "1.4.1", + "version": "1.4.2", "status": "stable", "kicad_version": "10.0", "runtime": "ipc" diff --git a/pyproject.toml b/pyproject.toml index 616fa11..a72e45f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_dialog.py b/tests/test_dialog.py index efbb89b..ccbd8b9 100644 --- a/tests/test_dialog.py +++ b/tests/test_dialog.py @@ -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): diff --git a/uv.lock b/uv.lock index 313187d..fdeaac5 100644 --- a/uv.lock +++ b/uv.lock @@ -216,7 +216,7 @@ wheels = [ [[package]] name = "fill-resistance" -version = "1.4.1" +version = "1.4.2" source = { virtual = "." } dependencies = [ { name = "kicad-python" },