feat: 🎸 wxWidgets dialog/frame header/drag/resize

This commit is contained in:
Istvan Matejcsok 2026-06-30 15:42:32 +02:00
commit 3628a090dd
5 changed files with 222 additions and 11 deletions

View file

@ -202,10 +202,14 @@ void DialogTestFrame::OnInputDialog(wxCommandEvent& WXUNUSED(evt))
}
}
// Custom dialog implementation
// Custom dialog implementation. wxRESIZE_BORDER makes it resizable (like KiCad's
// DIALOG_SHIM dialogs, e.g. Print) so the resize-repaint behaviour can be tested:
// a modal dialog whose 2D canvas is cleared by a resize must repaint synchronously
// (its Asyncify pump otherwise defers the paint until the next click → black bg).
CustomTestDialog::CustomTestDialog(wxWindow* parent)
: wxDialog(parent, wxID_ANY, "Custom Test Dialog",
wxDefaultPosition, wxSize(300, 200))
wxDefaultPosition, wxSize(300, 200),
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER)
{
wxBoxSizer* mainSizer = new wxBoxSizer(wxVERTICAL);

View file

@ -128,7 +128,8 @@ public:
};
// Known-working baseline: a modeless wxDialog (same chrome path as Preferences /
// Print, which the user reports ARE draggable and X-closable).
// Print, which the user reports ARE draggable and X-closable). NO wxRESIZE_BORDER
// in its style -> the NEGATIVE case for edge-resize: it must NOT get resize handles.
class BaselineDialog : public wxDialog
{
public:
@ -141,6 +142,22 @@ public:
}
};
// Resizable dialog: carries wxRESIZE_BORDER (like KiCad's DIALOG_SHIM dialogs, whose
// base defaults to wxDEFAULT_FRAME_STYLE | wxRESIZE_BORDER). The POSITIVE dialog case
// for edge-resize: it must get resize handles and be resizable by dragging its edges.
class ResizableDialog : public wxDialog
{
public:
explicit ResizableDialog( wxWindow* parent )
: wxDialog( parent, wxID_ANY, "Resizable Dialog", wxPoint( 560, 300 ),
wxSize( 360, 280 ), wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER )
{
new wxStaticText( this, wxID_ANY, "Dialog: drag edges to resize", wxPoint( 16, 16 ) );
LogWindow( "ResizableDialog", this );
}
};
class MainFrame : public wxFrame
{
public:
@ -162,6 +179,7 @@ public:
addButton( "Open Rich GL Frame", &MainFrame::OnRichGL );
addButton( "Open Small Frame", &MainFrame::OnSmall );
addButton( "Open Modeless Dialog", &MainFrame::OnDialog );
addButton( "Open Resizable Dialog", &MainFrame::OnResizableDialog );
// A GL canvas in the MAIN frame too, so a secondary GL canvas is created
// "while another GL canvas is already visible" — the condition that lifts
@ -177,6 +195,7 @@ private:
void OnRichGL( wxCommandEvent& WXUNUSED( evt ) ) { ( new RichGLFrame() )->Show( true ); }
void OnSmall( wxCommandEvent& WXUNUSED( evt ) ) { ( new SmallFrame() )->Show( true ); }
void OnDialog( wxCommandEvent& WXUNUSED( evt ) ) { ( new BaselineDialog( this ) )->Show( true ); }
void OnResizableDialog( wxCommandEvent& WXUNUSED( evt ) ) { ( new ResizableDialog( this ) )->Show( true ); }
};
class SecondaryFrameChromeApp : public wxApp

View file

@ -220,4 +220,81 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
`modal canvas went transparent/black during drag (minOpaque=${minOpaque}, lowFrames=${lowFrames})`
).toBeGreaterThan(0.8);
});
test('modal background stays painted (not black) after resize', async ({ page, testLogger }) => {
await page.goto(DIALOG_APP);
expect(await tryLoadApp(page), 'App should load').toBe(true);
await waitForRegistry(page);
// As above: the bare shell lays out #window-container below the fold, so overlay
// it at the origin to make the modal's DOM resize handles reachable by the pointer.
await page.evaluate(() => {
const wc = document.getElementById('window-container');
if (wc) {
wc.style.position = 'absolute';
wc.style.top = '0';
wc.style.left = '0';
}
});
await clickByLabel(page, 'Custom Dialog');
await waitForModalRect(page);
await page.waitForTimeout(400);
// The Custom dialog now carries wxRESIZE_BORDER, so it has DOM resize handles.
const handle = page.locator(`${MODAL_SEL} .window-resize-se`);
const hbox = await handle.boundingBox();
expect(hbox, 'resizable modal should have a se resize handle').not.toBeNull();
const beforeStats = await sampleModalCanvas(page);
testLogger.consoleLogs.push(`[MODAL_RESIZE] before=${JSON.stringify(beforeStats)}`);
await page.screenshot({ path: 'test-results/modal-04-before-resize.png', fullPage: true });
// Grab the bottom-right corner and grow the dialog in small steps, sampling the
// modal canvas immediately after each move. A resize legitimately reassigns
// canvas.width/height (clears it); inside the modal's Asyncify pump the repaint
// that should refill it is deferred until the next input event, so with the bug
// the canvas stays transparent and the black .window div shows through.
const startX = hbox!.x + hbox!.width / 2;
const startY = hbox!.y + hbox!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.waitForTimeout(120);
let minOpaque = 1;
let lowFrames = 0;
const STEPS = 16;
for (let i = 1; i <= STEPS; i++) {
await page.mouse.move(startX + i * 5, startY + i * 4);
const s = await sampleModalCanvas(page);
if (s) {
if (s.opaqueFrac < minOpaque) minOpaque = s.opaqueFrac;
if (s.opaqueFrac < 0.5) lowFrames++;
}
}
await page.mouse.up();
await page.waitForTimeout(400);
const afterStats = await sampleModalCanvas(page);
testLogger.consoleLogs.push(
`[MODAL_RESIZE] minOpaque=${minOpaque} lowFrames=${lowFrames}/${STEPS} after=${JSON.stringify(afterStats)}`
);
await page.screenshot({ path: 'test-results/modal-05-after-resize.png', fullPage: true });
// Sanity: the resize actually grew the modal canvas (otherwise the assertion
// below is meaningless — the corner grab must have taken effect).
expect(
afterStats && beforeStats && afterStats.canvasW > beforeStats.canvasW,
`resize did not grow the modal canvas (before=${beforeStats?.canvasW}, after=${afterStats?.canvasW})`
).toBe(true);
// With the bug: the canvas is cleared on each resize and shows black until the
// deferred modal repaint flushes (only on the next click) → minOpaque ≈ 0.
// After the fix (wx_window_resize forces a synchronous repaint): it stays painted.
expect(
minOpaque,
`modal canvas went transparent/black during resize (minOpaque=${minOpaque}, lowFrames=${lowFrames})`
).toBeGreaterThan(0.8);
});
});

View file

@ -76,18 +76,58 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => {
}, winId);
}
// All non-main top-level windows — secondary frames AND dialogs — must
// have a DOM title bar, be draggable by it, and close via its × button.
for (const label of [
'Open Full GL Frame',
'Open Rich GL Frame',
'Open Small Frame',
'Open Modeless Dialog',
]) {
// Returns true if dragging the bottom-right (se) resize handle grew the window.
async function resizeViaCorner(winId: string): Promise<boolean> {
const handle = page.locator(`#${winId} .window-resize-se`);
const box = await handle.boundingBox();
if (!box) return false;
const before = await styleRect(winId);
const sx = box.x + box.width / 2;
const sy = box.y + box.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
await page.mouse.move(sx + 60, sy + 60, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(250);
const after = await styleRect(winId);
return !!before && !!after
&& (after.width - before.width > 20) && (after.height - before.height > 20);
}
const countResizeHandles = (winId: string) =>
page.locator(`#${winId} .window-resize-handle`).count();
// Every non-main top-level window — frame or dialog — gets a DOM title bar
// (drag + close ×). Edge-resize handles are added ONLY to windows whose wx
// style carries wxRESIZE_BORDER: all wxFrames + dialogs that opt in (the
// resizable dialog), but NOT the plain fixed dialog.
// resizable — expects 5 resize handles (e/w/s/se/sw); fixed expects 0
// resizeDraggable — its se corner is on-screen, so assert a real resize drag
const windows = [
{ label: 'Open Full GL Frame', resizable: true, resizeDraggable: false },
{ label: 'Open Rich GL Frame', resizable: true, resizeDraggable: false },
{ label: 'Open Small Frame', resizable: true, resizeDraggable: true },
{ label: 'Open Resizable Dialog', resizable: true, resizeDraggable: true },
{ label: 'Open Modeless Dialog', resizable: false, resizeDraggable: false },
];
for (const { label, resizable, resizeDraggable } of windows) {
const id = await openWindow(label);
const hasBar = await page.locator(`#${id} .window-titlebar`).count();
expect(hasBar, `${label} should have a DOM title bar`).toBe(1);
// Resize-handle gate: present (5) iff the window is wxRESIZE_BORDER.
const handles = await countResizeHandles(id);
if (resizable) {
expect(handles, `${label} should have edge-resize handles`).toBe(5);
if (resizeDraggable) {
const resized = await resizeViaCorner(id);
expect(resized, `${label} should resize by dragging its se corner`).toBe(true);
}
} else {
expect(handles, `${label} (no wxRESIZE_BORDER) must NOT be resizable`).toBe(0);
}
// Root-cause check: even with main-frame DOM controls present, the title
// bar is the top hit-test element at its own location.
const moved = await dragViaTitlebar(id);

View file

@ -360,4 +360,75 @@ test.describe('3D viewer from pcbnew', () => {
const aborts = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted during the title-bar test:\n${aborts.join('\n\n')}`).toEqual([]);
});
/*
* Edge-resize gate for the real 3D viewer.
*
* The viewer (a wxFrame with wxRESIZE_BORDER) now gets DOM edge-resize handles.
* Dragging an edge calls wx_window_resize wxWindow::SetSize, whose wxSizeEvent
* relays out the frame and resizes the embedded EDA_3D_CANVAS (wxGLCanvas
* setGLCanvasRect). We drag the RIGHT edge (full-screen frame: its se corner sits
* at/over the viewport edge and is unreliable to grab; the right edge is not).
* Assert the frame AND its GL canvas both shrink in width.
*/
test('real 3D viewer can be edge-resized (frame + GL canvas track)', async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForPcbnew(page);
await loadBoard(page, testLogger);
const winsBefore = await page.evaluate(() =>
Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id));
const glBefore = await countGlCanvases(page);
await openThreeDViewer(page, glBefore);
await page.waitForTimeout(1500);
const winId = await page.evaluate((before: string[]) => {
const all = Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id);
return all.find((id) => !before.includes(id)) ?? all[all.length - 1] ?? null;
}, winsBefore);
expect(winId, 'the 3D viewer should open a new top-level window').toBeTruthy();
// It is wxRESIZE_BORDER → exactly the 5 edge/corner handles.
const handles = await page.locator(`#${winId} .window-resize-handle`).count();
expect(handles, 'the 3D viewer (wxRESIZE_BORDER) should have edge-resize handles').toBe(5);
// Frame width from its style; GL canvas width from the newest glcanvas-*.
const frameWidth = (wid: string) =>
page.evaluate((id) => {
const el = document.getElementById(id) as HTMLElement | null;
return el ? (parseInt(el.style.width || '0', 10) || 0) : 0;
}, wid);
const glWidth = () =>
page.evaluate(() => {
const all = Array.from(document.querySelectorAll('canvas[id^="glcanvas-"]')) as HTMLCanvasElement[];
const c = all[all.length - 1];
return c ? (parseInt(c.style.width || '0', 10) || 0) : 0;
});
const beforeFrame = await frameWidth(winId as string);
const beforeGl = await glWidth();
expect(beforeFrame, 'frame should have a width').toBeGreaterThan(0);
// Drag the right edge inward (left) to shrink the frame width.
const edge = page.locator(`#${winId} .window-resize-e`);
const box = await edge.boundingBox();
expect(box, 'the 3D viewer should have a right-edge resize handle with a layout box').not.toBeNull();
const sx = box!.x + box!.width / 2;
const sy = box!.y + box!.height / 2;
await page.mouse.move(sx, sy);
await page.mouse.down();
await page.mouse.move(sx - 220, sy, { steps: 12 });
await page.mouse.up();
await page.waitForTimeout(500);
const afterFrame = await frameWidth(winId as string);
const afterGl = await glWidth();
expect(afterFrame, `frame width should shrink (was ${beforeFrame}, now ${afterFrame})`)
.toBeLessThan(beforeFrame - 100);
expect(afterGl, `3D viewer GL canvas should shrink with the frame (was ${beforeGl}, now ${afterGl})`)
.toBeLessThan(beforeGl);
const aborts = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted during the resize test:\n${aborts.join('\n\n')}`).toEqual([]);
});
});