From 0bbae1710990563e345d775b9aa68536f29ed7d2 Mon Sep 17 00:00:00 2001 From: timbogdanov Date: Wed, 19 Aug 2026 12:52:53 -0500 Subject: [PATCH] =?UTF-8?q?Version=200.2.1=20=E2=80=94=20paste=20works,=20?= =?UTF-8?q?and=20fields=20fill=20their=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a custom application menu replaces macOS's default one wholesale, and on macOS the clipboard shortcuts inside web content are delivered by that menu. The Edit menu here had Undo, Redo, Select All, Deselect and Clear — and no Cut, Copy or Paste at all. Cmd+V had nowhere to go, so no text field anywhere in the app could be pasted into; the API-key box in the Program sheet was simply not fillable by anyone who keeps their key in a password manager. Right-click was dead too, because Electron ships no context menu and only the canvas had one. Each Edit item now does both halves: it asks the web contents to do the text-editing action, which is a no-op unless something editable has focus, then sends the canvas command, which the renderer drops while a field has focus. One key, whichever meaning the focus implies. Two more bugs fell out of the same root: Cmd+Z in a field undid a brush stroke, and Clear's bare `Delete` accelerator was swallowed application-wide, so the key stopped deleting characters everywhere — and fired alongside the renderer's own handler, clearing twice per press. The renderer owns Delete again; it already ignored text fields. The prompt and key boxes were narrow for an unrelated reason. `.wide` — the class meaning "fill the row" — was written `input[type=number].wide`, and an attribute selector matches the attribute, so it skipped `type=password` and skipped an written with no type at all. Both fields sat at 195px in a 491px row. They missed the shared text-input rule the same way, which is why three of them carried inline copies of it; those are gone. body{user-select:none} was inherited into every field, so you could not select what you had typed — form controls opt back in. Verified by driving the real window: a key on the clipboard, focus in the field, the actual Paste menu item fired, and the value lands. Both regressions are held by new checks in verify-electron.py and verify-ui.py, each of which fails on the code as it stood. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GRMJpT8yxY2Q33td1QeP7z --- app/patternfront.html | 57 ++++++++++++++++++++++++++-------------- electron/main.js | 26 +++++++++++++++++- electron/menu.js | 44 +++++++++++++++++++++++++++---- package-lock.json | 4 +-- package.json | 2 +- tools/verify-electron.py | 22 ++++++++++++++++ tools/verify-ui.py | 18 +++++++++++++ 7 files changed, 145 insertions(+), 28 deletions(-) diff --git a/app/patternfront.html b/app/patternfront.html index 09a86c4..ab962aa 100644 --- a/app/patternfront.html +++ b/app/patternfront.html @@ -154,14 +154,26 @@ border:0.1rem solid var(--edge-dim)} input[type=range]::-moz-range-thumb{width:0.8rem;height:1.4rem;border-radius:0; background:var(--accent);border:0.1rem solid var(--accent-ink)} -input[type=text]{height:1.8rem;background:var(--well);color:var(--text); +/* One box for every field you type into. An attribute selector matches the + ATTRIBUTE, not the resolved type, so `input[type=text]` on its own skips + `type=password` and skips an written without any type at all — both + of which are still text fields. Naming all three is what lets the markup + stop carrying inline copies of this rule. */ +input[type=text],input[type=password],input:not([type]), +input[type=number],select{height:1.8rem;background:var(--well);color:var(--text); border:0.1rem solid var(--edge-dim);padding:0 0.4rem; font:400 var(--lbl)/1 var(--mono);min-width:0} -input[type=number],select{height:1.8rem;background:var(--well);color:var(--text); - border:0.1rem solid var(--edge-dim);padding:0 0.4rem;font:400 var(--lbl)/1 var(--mono); - font-variant-numeric:tabular-nums} +input[type=number],select{font-variant-numeric:tabular-nums} input[type=number]{width:4.2rem;text-align:center} -input[type=number].wide{flex:1;width:auto;min-width:0} +/* "Fill the rest of the row." Deliberately type-agnostic: as + `input[type=number].wide` it quietly did nothing for the API-key, prompt and + pattern-name fields, which sat at the default 20-character width with half + their row empty beside them. */ +.wide{flex:1;width:auto;min-width:0} +/* body{user-select:none} stops a drag across the chrome from selecting labels, + but it is inherited into the form fields too — where it means you cannot + select the text you just typed, or double-click a pasted key to replace it. */ +input,textarea{-webkit-user-select:text;user-select:text} /* Flex children must be allowed to shrink, or a narrow column overflows instead of compressing. */ .r>.b,.duo>.b,.r>select{min-width:0} @@ -556,15 +568,11 @@ Anthropic, never to this project. The program it writes lands in the box above, where you can read and edit it before rendering.
+ autocomplete="off" spellcheck="false">
@@ -594,9 +602,7 @@ -
+
@@ -2853,6 +2859,13 @@ // The menu drives the same functions the buttons do — one implementation each, // so a menu item cannot drift from its on-screen twin. +// True while a text field has focus. The desktop Edit menu owns Cmd+Z/X/C/V/A +// for both the canvas and the fields; it has already asked the web contents to +// do the text-editing half, so the canvas half must stand down — otherwise +// fixing a typo in the key box undoes a brush stroke behind it. +const typing=()=>{const a=document.activeElement; + const t=((a&&a.tagName)||'').toLowerCase(); + return t==='input'||t==='textarea'||t==='select';}; const COMMANDS={ 'file.new': ()=>{startFresh();markClean();showPath(null);}, 'file.open': ()=>openDocument(), @@ -2868,12 +2881,15 @@ 'export.sheet': ()=>$('exSheet').click(), 'export.gif': ()=>$('exGIF').click(), 'export.pattern':()=>{openOv('ovExp');refreshOF();}, - 'edit.undo': ()=>undo(), - 'edit.redo': ()=>redo(), + 'edit.undo': ()=>{if(!typing())undo();}, + 'edit.redo': ()=>{if(!typing())redo();}, + 'edit.copy': ()=>{if(!typing()&&sel)copySel();}, + 'edit.paste': ()=>{if(!typing()&&clipboard)pasteSel();}, 'edit.clear': ()=>clearArt(false), 'edit.clearAll': ()=>clearArt(true), - 'edit.selectAll':()=>{sel={x:0,y:0,w:doc.w,h:doc.h};overlay();syncStatus();}, - 'edit.deselect': ()=>{sel=null;overlay();syncStatus();}, + 'edit.selectAll':()=>{if(typing())return; + sel={x:0,y:0,w:doc.w,h:doc.h};overlay();syncStatus();}, + 'edit.deselect': ()=>{if(typing())return;sel=null;overlay();syncStatus();}, 'view.zoomIn': ()=>{zoom=Math.min(48,zoom+1);$('zoom').value=zoom; $('zoomO').textContent=zoom;layout();}, 'view.zoomOut': ()=>{zoom=Math.max(1,zoom-1);$('zoom').value=zoom; @@ -2887,7 +2903,10 @@ // Keys the native menu owns. Electron fires the accelerator BEFORE the page // sees the keydown, so leaving these enabled here would undo twice per press. -const MENU_KEYS=new Set(['z','y','a','d','n','o','s']); +// x/c/v joined the list when the Edit menu gained Cut/Copy/Paste — without +// those items macOS had no responder for Cmd+V and no input in the app could +// be pasted into. +const MENU_KEYS=new Set(['z','y','a','d','n','o','s','x','c','v']); if(native()){ window.pfNative.onCommand((id,arg)=>{ diff --git a/electron/main.js b/electron/main.js index 3117f18..c69c419 100644 --- a/electron/main.js +++ b/electron/main.js @@ -13,7 +13,7 @@ const fs = require('fs/promises'); const path = require('path'); -const { app, BrowserWindow, dialog, ipcMain, protocol, net, shell } = require('electron'); +const { app, BrowserWindow, Menu, MenuItem, dialog, ipcMain, protocol, net, shell } = require('electron'); const docs = require('./documents'); const menu = require('./menu'); @@ -133,6 +133,30 @@ function createWindow() { }); win.webContents.on('will-attach-webview', e => e.preventDefault()); + // Electron ships no context menu of its own, and the canvas has its own. That + // leaves a text field with neither — right-clicking the API-key box offered + // nothing at all, which is half of why pasting a key felt impossible. Built + // per event from the flags Chromium reports, so the items are only ever + // offered when they would actually do something. + win.webContents.on('context-menu', (_e, params) => { + if (!params.isEditable) return; + const f = params.editFlags; + const m = new Menu(); + for (const [label, role, enabled] of [ + ['Undo', 'undo', f.canUndo], ['Redo', 'redo', f.canRedo], + [null, null, null], + ['Cut', 'cut', f.canCut], ['Copy', 'copy', f.canCopy], + ['Paste', 'paste', f.canPaste], + [null, null, null], + ['Select All', 'selectAll', f.canSelectAll], + ]) { + m.append(label + ? new MenuItem({ label, role, enabled: !!enabled }) + : new MenuItem({ type: 'separator' })); + } + m.popup({ window: win }); + }); + win.on('close', onClose); win.on('closed', () => { win = null; }); diff --git a/electron/menu.js b/electron/menu.js index ad975d8..81be9ec 100644 --- a/electron/menu.js +++ b/electron/menu.js @@ -23,6 +23,28 @@ function build({ send, recent, openRecent, quit }) { label, accelerator, click: () => send(id), ...extra, }); + // Undo, cut, copy, paste and select-all mean one thing over the canvas and + // another inside a text field, and a custom application menu is what decides + // which. Setting one replaces the default menu wholesale, and on macOS the + // clipboard shortcuts in web content are delivered by the menu's native + // first-responder items — so a menu without them leaves Cmd+V with nowhere to + // go and pasting into any input in the app silently does nothing. + // + // Each item here does both halves. `webContents[action]()` is the text-field + // half and is a no-op unless something editable has focus; `send(id)` is the + // canvas half, which the renderer drops while a field has focus. One key, + // whichever meaning the focus implies, and the menu item does the same thing + // as its accelerator. + const edit = (label, id, accelerator, action) => ({ + id: `edit-${action}`, + label, + accelerator, + click: (item, win) => { + if (win && !win.isDestroyed()) win.webContents[action](); + if (id) send(id); + }, + }); + const recentItems = recent.list.length ? [ ...recent.list.map(file => ({ @@ -76,14 +98,26 @@ function build({ send, recent, openRecent, quit }) { { label: '&Edit', submenu: [ - cmd('Undo', 'edit.undo', 'CmdOrCtrl+Z'), - cmd('Redo', 'edit.redo', isMac ? 'Cmd+Shift+Z' : 'Ctrl+Y'), + edit('Undo', 'edit.undo', 'CmdOrCtrl+Z', 'undo'), + edit('Redo', 'edit.redo', isMac ? 'Cmd+Shift+Z' : 'Ctrl+Y', 'redo'), + { type: 'separator' }, + // Cut has no canvas twin, so it stays text-only rather than inventing + // one; copy and paste carry the selection commands the editor already + // has. All three exist mainly so a text field behaves like a text field. + edit('Cut', null, 'CmdOrCtrl+X', 'cut'), + edit('Copy', 'edit.copy', 'CmdOrCtrl+C', 'copy'), + edit('Paste', 'edit.paste', 'CmdOrCtrl+V', 'paste'), { type: 'separator' }, - cmd('Select All', 'edit.selectAll', 'CmdOrCtrl+A'), + edit('Select All', 'edit.selectAll', 'CmdOrCtrl+A', 'selectAll'), cmd('Deselect', 'edit.deselect', 'CmdOrCtrl+D'), { type: 'separator' }, - cmd('Clear', 'edit.clear', 'Delete'), - cmd('Clear Every Layer', 'edit.clearAll', 'Shift+Delete'), + // No accelerator on purpose. A bare, unmodified key in the menu is + // swallowed application-wide, so `Delete` here made the key stop + // deleting characters in every text field — and fired alongside the + // renderer's own handler, clearing twice per press. The renderer owns + // Delete and Backspace, and already ignores them while a field has focus. + cmd('Clear', 'edit.clear'), + cmd('Clear Every Layer', 'edit.clearAll'), ], }, diff --git a/package-lock.json b/package-lock.json index 0eac301..ef9c15f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "patternfront", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patternfront", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "electron": "^43.4.0", diff --git a/package.json b/package.json index 2988132..613701c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "patternfront", "productName": "PatternFront", - "version": "0.2.0", + "version": "0.2.1", "description": "A 1-bit pattern editor for OpenFront territory patterns \u2014 desktop app for macOS and Windows.", "keywords": [ "pixel-art", diff --git a/tools/verify-electron.py b/tools/verify-electron.py index 98be760..a411099 100755 --- a/tools/verify-electron.py +++ b/tools/verify-electron.py @@ -110,6 +110,28 @@ def main() -> int: "fileAssociations" in read_root("electron-builder.yml") and "ext: patternfront" in read_root("electron-builder.yml")) + # Setting a custom application menu replaces the default one wholesale, and + # on macOS the clipboard shortcuts in web content are delivered by the + # menu. A menu with no Paste item leaves Cmd+V with nowhere to go and no + # input in the app can be pasted into — which is how an API key becomes + # untypeable. Every text-editing item must drive the web contents. + chk("Edit items drive the web contents, not just the canvas", + "win.webContents[action]()" in menu) + for label, accel in (("Undo", "CmdOrCtrl+Z"), ("Cut", "CmdOrCtrl+X"), + ("Copy", "CmdOrCtrl+C"), ("Paste", "CmdOrCtrl+V"), + ("Select All", "CmdOrCtrl+A")): + chk(f"{label} is in the Edit menu on {accel}", + re.search(rf"edit\('{re.escape(label)}',[^)]*{re.escape(accel)}", menu) is not None) + # A plain, unmodified key in the menu is swallowed application-wide, so a + # bare `Delete` accelerator stops the key deleting characters in every text + # field. The renderer's keydown owns it and already skips text fields. + chk("no bare Delete accelerator to eat the key in text fields", + "'Delete'" not in menu) + # Electron ships no context menu; the canvas has its own, so without this a + # text field gets neither and right-click offers no way to paste. + chk("right-click offers paste inside a text field", + "params.isEditable" in main_js and "'Paste', 'paste'" in main_js) + print("\n=== packaging ===") yml = read_root("electron-builder.yml") chk("mac builds both architectures", "arch: [arm64, x64]" in yml) diff --git a/tools/verify-ui.py b/tools/verify-ui.py index 046a3c5..d7fddc2 100644 --- a/tools/verify-ui.py +++ b/tools/verify-ui.py @@ -153,6 +153,24 @@ def main() -> int: chk("column widths default to 152 / 240 / 190 @1x", "--wL:15.2rem" in css and "--wP:24rem" in css and "--wR:19rem" in css) chk("24px bars @1x", "height:2.4rem" in css) + # `.wide` means "fill the row". It was written as `input[type=number].wide`, + # which silently skips every text-ish field: an attribute selector matches + # the attribute, so it misses `type=password` AND an with no type at + # all. The API-key and prompt fields wore the class and stayed 195px wide in + # a 491px row. Keep the selector type-agnostic. + wide_rule = re.search(r"(^|[,\s{}])\.wide\s*\{", css, re.M) + chk("`.wide` fills its row for every input type, not just numbers", + wide_rule is not None and "input[type=number].wide{" not in css) + # Text fields need the same box as the numeric ones. `input[type=text]` + # alone misses password fields and typeless inputs, which is why three of + # them carried duplicated inline styles instead. + chk("text, password and typeless inputs share one rule", + "input[type=password]" in css and "input:not([type])" in css) + # body{user-select:none} keeps drags from selecting the chrome, but it is + # inherited straight into every form field, so you cannot select what you + # just typed — or double-click a pasted key to replace it. + chk("form fields opt back in to text selection", + re.search(r"input[^{]*,?[^{]*textarea[^{]*\{[^}]*user-select:\s*text", css) is not None) print("\n=== interface scale ===") chk("root font-size drives the scale", "font-size:calc(10px * var(--scale))" in css)