Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 38 additions & 19 deletions app/patternfront.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 <input> 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}
Expand Down Expand Up @@ -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.</span>
<div class="r"><input id="aiKey" type="password" placeholder="Anthropic API key" class="wide"
autocomplete="off" spellcheck="false"
style="background:var(--well);color:var(--text);border:0.1rem solid var(--edge-dim);
font:inherit;padding:0 0.4rem">
autocomplete="off" spellcheck="false">
<button class="b" id="aiSave" style="flex:0 0 auto">Save</button>
<button class="b" id="aiForget" style="flex:0 0 auto">Forget</button></div>
<div class="r" id="aiAsk" hidden><input id="aiWhat" class="wide"
placeholder="diagonal stripes with a thin gap"
style="background:var(--well);color:var(--text);border:0.1rem solid var(--edge-dim);
font:inherit;padding:0 0.4rem">
placeholder="diagonal stripes with a thin gap">
<button class="b go" id="aiGo" style="flex:0 0 auto">Write it</button></div>
<span class="note" id="aiStat"></span>
</div>
Expand Down Expand Up @@ -594,9 +602,7 @@
<button class="b" id="ofJson">Copy JSON</button>
</div>
<span class="note" id="ofStat"></span>
<div class="r"><input id="ofName" placeholder="pattern name" class="wide"
style="background:var(--well);color:var(--text);border:0.1rem solid var(--edge-dim);
font:inherit;padding:0 0.4rem">
<div class="r"><input id="ofName" placeholder="pattern name" class="wide">
<button class="b" id="ofEntry" style="flex:0 0 auto">cosmetics.json entry</button></div>
<div class="data" id="ofEntryOut" hidden></div>
<div class="r" style="margin-top:0.4rem">
Expand Down Expand Up @@ -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(),
Expand All @@ -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;
Expand All @@ -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)=>{
Expand Down
26 changes: 25 additions & 1 deletion electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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; });

Expand Down
44 changes: 39 additions & 5 deletions electron/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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'),
],
},

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 22 additions & 0 deletions tools/verify-electron.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions tools/verify-ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <input> 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)
Expand Down
Loading