feat: 🎸 wxWidgets dialog/frame header/drag/resize
This commit is contained in:
parent
cf1fdf5c3d
commit
3628a090dd
5 changed files with 222 additions and 11 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue