← Commit history

Merge PR #166: Refresh exact linked PCB editor and 3D viewer through native menus (astra/linked-view-refresh -> master) merge

John Lauer ·56522ef4f8 ·1mo ago ·parents 6e873f2, fb8c848

Showing changes introduced by the merge (against its first parent).

2 files changed +119
docs/linked-view-refresh.mdadded+7
@@ -0,0 +1,7 @@+# Refresh the linked board views++After native edits, call `kicad_refresh_board_views {pcbHwnd}` with the current exact PCB editor HWND. The viewer must already be open and uniquely associated with that editor. The bridge uses its editor/viewer binding logic and refuses missing or ambiguous identities instead of picking the first viewer.++It discovers both exact `View > Refresh` menu actions before invoking either, then requests both redraws using window-targeted native menu calls. It does not save, open windows, move the cursor, change camera pose or take focus. This is a rendering request, not rendering proof: the response says `refreshRequested:true`, `renderVerified:false`. Inspect the subsequent `kicad_state` screenshots for both surfaces. A failure after one menu invocation lists `refreshRequestedFor`; do not assume neither ran.++Native acceptance: two boards with separate viewers; refresh each exact pair without touching the other; reject stale PCB handles and ambiguous viewer bindings; verify visible changes appear in both 2D and 3D after a native silk edit; preserve camera and foreground; no substitute windows. Source tests cover exact menu labels and retain the existing viewer-binding tests, but do not establish those Windows results.
rust/crates/kicad-bridge/src/verbs_windows.rs+112
@@ -47,6 +47,15 @@ use kicad_platform::{native, unsupported, Placement, WindowInfo}; // ── Catalog ───────────────────────────────────────────────────────────────────  pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_refresh_board_views",+        summary: "Refresh the exact PCB editor and its already-open linked 3D viewer without focus or mouse.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 30,+        input: "{pcbHwnd}", example: "kicad_refresh_board_views {pcbHwnd:12345}",+        hint: "Requires an existing uniquely bound viewer. Discovers the exact View > Refresh menu on both windows before invoking either. Returns refreshRequested, not rendering proof; inspect kicad_state screenshots after redraw.",+        related: &["kicad_state", "kicad_open_3d_viewer", "kicad_silk_text_batch"],+        pitfalls: &["Does not open windows, substitute another editor/viewer, save the board, or claim that the 3D model has finished rebuilding."],+    },     Verb {         name: "kicad_launch",         summary: "Cold-start KiCad (project manager) when nothing is running.",@@ -304,6 +313,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value>         "kicad_open_footprint_editor" => open_footprint_editor(state, args),         "kicad_open_3d_viewer" => open_3d_viewer(args),         "kicad_3d_view" => view_3d(args),+        "kicad_refresh_board_views" => refresh_board_views(args),         "kicad_menu_items" => menu_items(args),         "kicad_open_editors" => open_editors(),         "kicad_close" => close_kicad(state, args),@@ -2023,6 +2033,92 @@ fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<O /// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.  /// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.+fn is_view_refresh(path: &[String]) -> bool {+    let clean = |s: &str| {+        s.replace('&', "")+            .split('\t')+            .next()+            .unwrap_or("")+            .trim()+            .to_lowercase()+    };+    path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"+}++pub(crate) fn refresh_board_views(args: &Value) -> Value {+    let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {+        return fail(+            "source_editor_required",+            "Pass the exact pcbHwnd.",+            "Read kicad_open_editors first.",+        );+    };+    invalidate();+    let windows = kicad_windows();+    let Some(source) = windows+        .iter()+        .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)+    else {+        return fail(+            "source_editor_missing",+            "Requested PCB editor is not open.",+            "No substitute was used.",+        );+    };+    let viewers = windows_of_kind(WindowKind::Viewer3d);+    let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();+    let count = windows+        .iter()+        .filter(|w| {+            w.pid == source.pid+                && matches!(+                    w.kind(),+                    WindowKind::PcbEditor | WindowKind::FootprintEditor+                )+        })+        .count();+    let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {+        m.get(&frame)+            .filter(|(pid, _)| *pid == source.pid)+            .map(|(_, h)| *h)+    });+    let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {+        return fail(+            "viewer_binding_ambiguous",+            "No uniquely linked open 3D viewer.",+            "Open the viewer from this exact PCB editor first; nothing was refreshed.",+        );+    };+    let mut commands = Vec::new();+    for hwnd in [frame, viewer.hwnd] {+        let items = match native().menu_tree(hwnd) {+            Ok(items) => items,+            Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),+        };+        let matches: Vec<_> = items+            .iter()+            .filter(|m| m.id != 0 && is_view_refresh(&m.path))+            .collect();+        if matches.len() != 1 {+            return fail(+                "refresh_action_missing",+                "Exact View > Refresh menu not uniquely available.",+                "No fallback menu or keyboard action was invoked.",+            );+        }+        commands.push((hwnd, matches[0].id));+    }+    let mut invoked = Vec::new();+    for (hwnd, id) in commands {+        if let Err(e) = native().menu_invoke(hwnd, id) {+            return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});+        }+        invoked.push(hwnd);+    }+    invalidate();+    json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})+}+ pub(crate) fn menu_items(args: &Value) -> Value {     let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {         Some(h) => h,@@ -3171,3 +3267,19 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value {         "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."}) } ++#[cfg(test)]+mod linked_refresh_tests {+    #[test]+    fn exact_view_refresh_excludes_plugins_and_partial_labels() {+        let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();+        assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));+        for a in [+            vec!["Tools", "Refresh Plugins"],+            vec!["View", "Refresh All"],+            vec!["Refresh"],+        ] {+            assert!(!super::is_view_refresh(&path(&a)));+        }+    }+}