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">
+ placeholder="diagonal stripes with a thin gap">
@@ -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)