dazscript-framework 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -299,6 +299,16 @@ Custom action icons are selected from `action(...)` metadata and sibling image f
299
299
 
300
300
  `scriptname.action.png` is the installed custom action icon. Daz Studio uses the same action icon for menu and toolbar placements. `scriptname.png` is the preferred script/content icon fallback. `scriptname.dsa.png` is a legacy fallback kept for older projects and will be removed in a future breaking release.
301
301
 
302
+ Setup dialog header assets are optional and are discovered beside `src/Setup.dsa.ts`:
303
+
304
+ 1. `src/Setup.header.png`
305
+ 2. `src/Setup.png`
306
+ 3. `src/Setup.dsa.png` legacy fallback
307
+
308
+ Header text can be placed in `src/Setup.header.html`, `src/Setup.header.md`, or `src/Setup.header.txt`, with `src/Setup.html`, `src/Setup.md`, and `src/Setup.txt` as script-named fallbacks. The installer generator embeds that text into `Setup.dsa.ts`, so Daz Studio does not need to read the text file at setup time. The setup dialog renders the header body with `DzTextBrowser` rich text support; no Markdown conversion is performed. The image remains a deployed PNG asset and is resolved relative to the generated setup script at runtime.
309
+
310
+ The same layout is available to custom dialogs through `add.header({ imagePath, html, text, height, imageWidth }).build()`. Use `html` for rich text, or `text` for escaped plain text.
311
+
302
312
  This replaces the older `Install.dsa.ts` / `Uninstall.dsa.ts` pattern. The installer generator removes those legacy files if they exist.
303
313
 
304
314
  ### Setup Keyboard Shortcuts
@@ -276,6 +276,48 @@ function toPosix(filePath) {
276
276
  return filePath.replace(/\\/g, '/');
277
277
  }
278
278
 
279
+ function resolveSetupHeaderImage(workdir) {
280
+ const candidates = [
281
+ path.join(workdir, 'src', 'Setup.header.png'),
282
+ path.join(workdir, 'src', 'Setup.png'),
283
+ path.join(workdir, 'src', 'Setup.dsa.png'),
284
+ ];
285
+ const match = candidates.find((candidate) => fs.existsSync(candidate));
286
+ return match ? `./${path.basename(match)}` : undefined;
287
+ }
288
+
289
+ function resolveSetupHeaderTextFile(workdir) {
290
+ const candidates = [
291
+ path.join(workdir, 'src', 'Setup.header.html'),
292
+ path.join(workdir, 'src', 'Setup.header.md'),
293
+ path.join(workdir, 'src', 'Setup.header.txt'),
294
+ path.join(workdir, 'src', 'Setup.html'),
295
+ path.join(workdir, 'src', 'Setup.md'),
296
+ path.join(workdir, 'src', 'Setup.txt'),
297
+ ];
298
+
299
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
300
+ }
301
+
302
+ function loadSetupHeader(workdir) {
303
+ const header = {};
304
+ const imagePath = resolveSetupHeaderImage(workdir);
305
+ const textFile = resolveSetupHeaderTextFile(workdir);
306
+
307
+ if (imagePath) {
308
+ header.headerImagePath = imagePath;
309
+ }
310
+
311
+ if (textFile) {
312
+ const text = fs.readFileSync(textFile, 'utf8').trim();
313
+ if (text) {
314
+ header.headerText = text;
315
+ }
316
+ }
317
+
318
+ return header;
319
+ }
320
+
279
321
  function resolveShortcutFile(workdir, config) {
280
322
  const configuredPath =
281
323
  config.keyboardShortcutsPath ||
@@ -340,6 +382,7 @@ function generateInstallerFiles(workdir, options) {
340
382
  settingsPath,
341
383
  bundleName,
342
384
  shortcutBackupPath: `${appDataPath}/Installer/keyboard-shortcuts-backup.json`,
385
+ ...loadSetupHeader(workdir),
343
386
  };
344
387
  if (shortcutData.shortcuts && shortcutData.shortcuts.length > 0) {
345
388
  setupOptions.shortcuts = shortcutData.shortcuts;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dazscript-framework",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "author": "Freddy Diaz",
5
5
  "license": "MPL-2.0",
6
6
  "description": "",
@@ -0,0 +1,72 @@
1
+ import LayoutBuilder from './layout-builder'
2
+ import { createWidget } from './widget-builder'
3
+ import { WidgetBuilderContext } from './widgets-builder'
4
+
5
+ export type DialogHeaderOptions = {
6
+ image?: Pixmap
7
+ imagePath?: string
8
+ imageWidth?: number
9
+ height?: number
10
+ html?: string
11
+ text?: string
12
+ }
13
+
14
+ const escapeHtml = (value: string): string =>
15
+ value
16
+ .replace(/&/g, '&')
17
+ .replace(/</g, '&lt;')
18
+ .replace(/>/g, '&gt;')
19
+ .replace(/"/g, '&quot;')
20
+ .replace(/'/g, '&#39;')
21
+ .replace(/\r?\n/g, '<br>')
22
+
23
+ export class DialogHeaderBuilder {
24
+ constructor(
25
+ private readonly context: WidgetBuilderContext,
26
+ private readonly options: DialogHeaderOptions
27
+ ) { }
28
+
29
+ build(): DzHBoxLayout {
30
+ const height = this.options.height ?? 96
31
+ const imageWidth = this.options.imageWidth ?? height
32
+ const html = this.options.html ?? (
33
+ typeof this.options.text === 'string'
34
+ ? escapeHtml(this.options.text)
35
+ : ''
36
+ )
37
+
38
+ return LayoutBuilder
39
+ .create(this.context)
40
+ .direction('horizontal')
41
+ .build(() => {
42
+ this.buildImage(height, imageWidth)
43
+ this.buildText(html, height)
44
+ }) as DzHBoxLayout
45
+ }
46
+
47
+ private buildImage(height: number, imageWidth: number): void {
48
+ const pixmap = this.options.image ?? (
49
+ this.options.imagePath ? new Pixmap(this.options.imagePath) : null
50
+ )
51
+ if (!pixmap) return
52
+
53
+ createWidget(this.context).build(DzLabel, (label) => {
54
+ label.pixmap = pixmap
55
+ label.scaledContents = true
56
+ label.setFixedWidth(imageWidth)
57
+ label.setFixedHeight(height)
58
+ })
59
+ }
60
+
61
+ private buildText(html: string, height: number): void {
62
+ if (!html) return
63
+
64
+ createWidget(this.context).build(DzTextBrowser, (textBrowser) => {
65
+ textBrowser.html = html
66
+ textBrowser.readOnly = true
67
+ textBrowser.lineWrapMode = DzTextEdit.WidgetWidth
68
+ textBrowser.wordWrapMode = DzTextEdit.WordWrap
69
+ textBrowser.setFixedHeight(height)
70
+ })
71
+ }
72
+ }
@@ -4,6 +4,7 @@ import CheckBoxBuilder from './checkbox-builder'
4
4
  import ColorPickerBuilder from './color-picker-builder'
5
5
  import { ComboBoxBuilder } from './combo-box-builder'
6
6
  import { ComboEditBuilder } from './combo-edit-builder'
7
+ import { DialogHeaderBuilder, DialogHeaderOptions } from './dialog-header-builder'
7
8
  import GroupBoxBuilder from './groupbox-builder'
8
9
  import LabelBuilder from './label-builder'
9
10
  import LayoutBuilder, { LayoutOrientation } from './layout-builder'
@@ -63,6 +64,10 @@ export class WidgetsBuilder {
63
64
  return new LabelBuilder(this.context).text(text)
64
65
  }
65
66
 
67
+ header(options: DialogHeaderOptions): DialogHeaderBuilder {
68
+ return new DialogHeaderBuilder(this.context, options)
69
+ }
70
+
66
71
  button(text?: string): ButtonBuilder {
67
72
  return new ButtonBuilder(this.context).text(text)
68
73
  }
@@ -1,58 +1,11 @@
1
1
  import { debug, warn } from '@dsf/common/log'
2
2
  import { mainWindow } from '@dsf/core/global'
3
3
  import { isGUID } from './string-helper'
4
+ export { normalizeShortcut } from './shortcut-helper'
5
+ import { normalizeShortcut } from './shortcut-helper'
4
6
 
5
7
  const actionMgr = mainWindow.getActionMgr()
6
8
 
7
- const shortcutTokenMap: { [key: string]: string } = {
8
- 'CTRL': 'Ctrl',
9
- 'CONTROL': 'Ctrl',
10
- 'SHIFT': 'Shift',
11
- 'ALT': 'Alt',
12
- 'OPTION': 'Alt',
13
- 'WIN': 'Win',
14
- 'WINDOWS': 'Win',
15
- 'CMD': 'Win',
16
- 'COMMAND': 'Win',
17
- 'SPACE': 'Space',
18
- 'HOME': 'Home',
19
- 'END': 'End',
20
- 'INS': 'Ins',
21
- 'INSERT': 'Ins',
22
- 'DEL': 'Del',
23
- 'DELETE': 'Del',
24
- 'TAB': 'Tab',
25
- 'BACKSPACE': 'Backspace',
26
- 'COMMA': 'Comma',
27
- 'PERIOD': 'Period',
28
- 'PLUS': 'Plus',
29
- 'MINUS': 'Minus',
30
- 'PGUP': 'PgUp',
31
- 'PAGEUP': 'PgUp',
32
- 'PGDOWN': 'PgDn',
33
- 'PGDN': 'PgDn',
34
- 'LEFT': 'Left',
35
- 'RIGHT': 'Right',
36
- 'UP': 'Up',
37
- 'DOWN': 'Down',
38
- }
39
-
40
- export const normalizeShortcut = (shortcut: string): string => {
41
- if (!shortcut) return ''
42
-
43
- return shortcut
44
- .split('+')
45
- .map(part => part.trim())
46
- .filter(Boolean)
47
- .map(part => {
48
- if (part.length === 1) return part.toUpperCase()
49
-
50
- const key = part.toUpperCase()
51
- return shortcutTokenMap[key] || part
52
- })
53
- .join('+')
54
- }
55
-
56
9
  export const getActionShortcut = (name: string): string => {
57
10
  const action = findAction(name)
58
11
  if (!action) return ''
@@ -184,7 +137,7 @@ export const findActionsForShortcut = (shortcut: string): DzAction[] => {
184
137
  return result
185
138
  }
186
139
 
187
- export const clearActionShorcut = (name: string) => {
140
+ export const clearActionShortcut = (name: string) => {
188
141
  let action = findAction(name)
189
142
  if (!action) return
190
143
  if (isCustomAction(action)) {
@@ -195,6 +148,8 @@ export const clearActionShorcut = (name: string) => {
195
148
  }
196
149
  }
197
150
 
151
+ export const clearActionShorcut = clearActionShortcut
152
+
198
153
  export const getActionPixmap = (action: string, icon: string, maxSize?: number): Pixmap | null => {
199
154
  try {
200
155
  if (!action) return null;
@@ -280,6 +280,12 @@ const removeUnderlyingCustomAction = (actionName: string | null | undefined) =>
280
280
  const index = actionMgr.findCustomAction(actionName)
281
281
  if (index < 0) return
282
282
 
283
+ const shortcut = String(actionMgr.getCustomActionShortcut(index) ?? '').trim()
284
+ if (shortcut) {
285
+ actionMgr.setCustomActionShortcut(index, '')
286
+ debug(`Action ${actionName} shortcut cleared before removal`)
287
+ }
288
+
283
289
  actionMgr.removeCustomAction(index)
284
290
  debug(`Action ${actionName} removed`)
285
291
  }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getCanonicalInstallerEntry, toActionKey } from './custom-action-installer-entries'
3
+
4
+ const entries = [
5
+ { action: { text: 'Power Menu - Main Actions', filePath: './Power Menu Actions.dsa' }, effectiveShortcut: '' },
6
+ { action: { text: 'Power Menu', filePath: './Power Menu.dsa' }, effectiveShortcut: '' },
7
+ ]
8
+
9
+ describe('setup installer entries', () => {
10
+ it('builds an empty key for missing action data instead of throwing', () => {
11
+ expect(toActionKey(undefined)).toBe('')
12
+ })
13
+
14
+ it('resolves row data back to the canonical installer entry', () => {
15
+ const rowCopy = {
16
+ action: { text: 'Power Menu', filePath: './Power Menu.dsa' },
17
+ effectiveShortcut: 'F12'
18
+ }
19
+
20
+ const canonical = getCanonicalInstallerEntry(entries, rowCopy)
21
+
22
+ expect(canonical).toBe(entries[1])
23
+ })
24
+
25
+ it('falls back to the displayed action text when row data is not an installer entry', () => {
26
+ const canonical = getCanonicalInstallerEntry(entries, { value: null }, 'Power Menu')
27
+
28
+ expect(canonical).toBe(entries[1])
29
+ })
30
+
31
+ it('ignores row data with an invalid action property before falling back to displayed text', () => {
32
+ const canonical = getCanonicalInstallerEntry(entries, { action: true }, 'Power Menu')
33
+
34
+ expect(canonical).toBe(entries[1])
35
+ })
36
+
37
+ it('prefers displayed row text over attached row data', () => {
38
+ const staleRowData = {
39
+ action: { text: 'Power Menu - Main Actions', filePath: './Power Menu Actions.dsa' },
40
+ effectiveShortcut: ''
41
+ }
42
+
43
+ const canonical = getCanonicalInstallerEntry(entries, staleRowData, 'Power Menu')
44
+
45
+ expect(canonical).toBe(entries[1])
46
+ })
47
+
48
+ it('returns null when neither row data nor displayed text identify an installer entry', () => {
49
+ const canonical = getCanonicalInstallerEntry(entries, { action: true }, 'Missing')
50
+
51
+ expect(canonical).toBeNull()
52
+ })
53
+ })
@@ -0,0 +1,58 @@
1
+ import type { CustomAction } from '@dsf/core/custom-action'
2
+
3
+ export type InstallerActionEntry = {
4
+ action: CustomAction
5
+ }
6
+
7
+ const isActionLike = (value: any): value is CustomAction =>
8
+ Boolean(value && (typeof value.filePath !== 'undefined' || typeof value.text !== 'undefined'))
9
+
10
+ export const toActionKey = (action?: CustomAction | null): string =>
11
+ String(action?.filePath ?? action?.text ?? '')
12
+
13
+ const findByDisplayedText = <TEntry extends InstallerActionEntry>(
14
+ entries: TEntry[],
15
+ displayedText: string
16
+ ): TEntry | null => {
17
+ for (let i = 0; i < entries.length; i++) {
18
+ const entry = entries[i]
19
+ if (String(entry.action.text ?? '') === displayedText) return entry
20
+ }
21
+
22
+ return null
23
+ }
24
+
25
+ const findByActionKey = <TEntry extends InstallerActionEntry>(
26
+ entries: TEntry[],
27
+ key: string
28
+ ): TEntry | null => {
29
+ for (let i = 0; i < entries.length; i++) {
30
+ const entry = entries[i]
31
+ if (toActionKey(entry.action) === key) return entry
32
+ }
33
+
34
+ return null
35
+ }
36
+
37
+ export const asInstallerActionEntry = <TEntry extends InstallerActionEntry>(value: any): TEntry | null => {
38
+ if (isActionLike(value?.action)) return value as TEntry
39
+ if (isActionLike(value?.value?.action)) return value.value as TEntry
40
+ return null
41
+ }
42
+
43
+ export const getCanonicalInstallerEntry = <TEntry extends InstallerActionEntry>(
44
+ entries: TEntry[],
45
+ value: any,
46
+ displayedActionText?: string
47
+ ): TEntry | null => {
48
+ const displayedText = String(displayedActionText ?? '')
49
+ const displayedMatch = findByDisplayedText(entries, displayedText)
50
+ if (displayedMatch) return displayedMatch
51
+
52
+ const candidate = asInstallerActionEntry<TEntry>(value)
53
+
54
+ if (!candidate || !isActionLike(candidate.action)) return null
55
+
56
+ const key = toActionKey(candidate.action)
57
+ return findByActionKey(entries, key) ?? candidate
58
+ }
@@ -11,6 +11,9 @@ import CustomSet from '@dsf/lib/set'
11
11
  import { TreeNode } from '@dsf/lib/tree-node'
12
12
  import { promptKeyboardShortcut } from '@dsf/shared/set-keyboard-shortcut'
13
13
  import { readFromFile, saveToFile } from './file-helper'
14
+ import { getCanonicalInstallerEntry, toActionKey } from './custom-action-installer-entries'
15
+ import { canResetSetupShortcut, getDisplayedSetupShortcut, resetSetupShortcut, setSetupShortcut, updateShortcutOverrideState } from './custom-action-installer-shortcuts'
16
+ import { getScriptPath } from './script-helper'
14
17
 
15
18
  type InstallerEntry = {
16
19
  action: CustomAction
@@ -29,6 +32,10 @@ type InstallerEntry = {
29
32
  type SetupDialogOptions = {
30
33
  settingsPath: string
31
34
  bundleName?: string
35
+ headerImagePath?: string
36
+ headerImageWidth?: number
37
+ headerHeight?: number
38
+ headerText?: string
32
39
  shortcuts?: ActionAccelerator[]
33
40
  shortcutsSourcePath?: string
34
41
  shortcutBackupPath?: string
@@ -70,9 +77,7 @@ type SetupSelection = {
70
77
  shortcuts: ShortcutEntry[]
71
78
  }
72
79
 
73
- const OVERRIDE_MARKER = '[ovr]'
74
-
75
- const toKey = (action: CustomAction): string => String(action.filePath ?? action.text ?? '')
80
+ const toKey = toActionKey
76
81
 
77
82
  const toTreeNode = (entry: InstallerEntry): TreeNode<InstallerEntry> =>
78
83
  new TreeNode(String(entry.action.text), toKey(entry.action), entry)
@@ -96,16 +101,15 @@ const getSetupDialogOptions = (options: string | SetupDialogOptions): SetupDialo
96
101
  const getShortcutBackupPath = (options: SetupDialogOptions): string =>
97
102
  `${App.getAppDataPath()}/${options.shortcutBackupPath ?? `${options.settingsPath}/keyboard-shortcuts-backup.json`}`
98
103
 
99
- const getDisplayedShortcut = (entry: InstallerEntry): string => {
100
- if (!entry.isShortcutOverridden) return entry.effectiveShortcut
101
- return entry.effectiveShortcut
102
- ? `${entry.effectiveShortcut} ${OVERRIDE_MARKER}`
103
- : OVERRIDE_MARKER
104
- }
104
+ const hasSetupHeader = (options: SetupDialogOptions): boolean =>
105
+ Boolean(options.headerImagePath || options.headerText)
106
+
107
+ const resolveSetupAssetPath = (filePath: string | null | undefined): string => {
108
+ const value = String(filePath ?? '').replace(/\\/g, '/')
109
+ if (!value) return ''
110
+ if (value.indexOf(':') >= 0 || value.charAt(0) === '/') return value
105
111
 
106
- const updateOverrideState = (entry: InstallerEntry) => {
107
- entry.isShortcutOverridden = Boolean(entry.installedActionName) &&
108
- normalizeShortcut(entry.installedShortcut) !== normalizeShortcut(entry.defaultShortcut)
112
+ return `${getScriptPath()}/${value.replace(/^\.\//, '')}`
109
113
  }
110
114
 
111
115
  const buildEntries = (actions: CustomAction[]): InstallerEntry[] => {
@@ -132,7 +136,7 @@ const buildEntries = (actions: CustomAction[]): InstallerEntry[] => {
132
136
  installedActionName: String(installed.customAction?.name ?? '')
133
137
  }
134
138
 
135
- updateOverrideState(entry)
139
+ updateShortcutOverrideState(entry)
136
140
  return entry
137
141
  })
138
142
  }
@@ -192,9 +196,9 @@ class InstallerSelectionDialog extends BasicDialog {
192
196
  constructor(
193
197
  private readonly entries: InstallerEntry[],
194
198
  private readonly shortcutEntries: ShortcutEntry[],
195
- bundleName?: string
199
+ private readonly options: SetupDialogOptions
196
200
  ) {
197
- super(bundleName ? `${bundleName} Setup` : 'Setup Scripts', 'dsfSetupScripts')
201
+ super(options.bundleName ? `${options.bundleName} Setup` : 'Setup Scripts', 'dsfSetupScripts')
198
202
  this.items$ = new Observable(entries.map(toTreeNode))
199
203
  this.shortcutItems$ = new Observable(shortcutEntries.map(toShortcutTreeNode))
200
204
  }
@@ -226,17 +230,20 @@ class InstallerSelectionDialog extends BasicDialog {
226
230
  private buildScriptsTab(): void {
227
231
  const add = this.add
228
232
 
229
- add.label('Choose which scripts to install, then use the columns to review their shortcut, menu, and toolbar targets.')
230
- .wordWrap()
231
- .build()
233
+ if (hasSetupHeader(this.options)) {
234
+ this.buildHeader()
235
+ } else {
236
+ add.label('Choose which scripts to install, then use the columns to review their shortcut, menu, and toolbar targets.')
237
+ .wordWrap()
238
+ .build()
239
+ }
232
240
  add.group('Search').horizontal().build(() => {
233
241
  add.edit()
234
242
  .value(this.keywords$)
235
243
  .focus()
236
244
  .placeholder('Search actions...')
237
245
  .toolTip('Search by action name, description, path, shortcut, or available targets.')
238
- add.button('Select All').clicked(() => this.setVisibleSelections(true))
239
- add.button('Deselect All').clicked(() => this.setVisibleSelections(false))
246
+ add.button('Clear').clicked(() => this.keywords$.value = '')
240
247
  })
241
248
 
242
249
  add.group('Scripts')
@@ -269,7 +276,7 @@ class InstallerSelectionDialog extends BasicDialog {
269
276
  return [
270
277
  '',
271
278
  String(entry.action.text ?? ''),
272
- getDisplayedShortcut(entry),
279
+ getDisplayedSetupShortcut(entry),
273
280
  String(entry.action.description ?? ''),
274
281
  String(entry.action.menuPath ?? ''),
275
282
  getDisplayedToolbar(entry),
@@ -291,6 +298,25 @@ class InstallerSelectionDialog extends BasicDialog {
291
298
  listView.allColumnsShowFocus = true
292
299
  })
293
300
  })
301
+ add.group().horizontal().build((layout) => {
302
+ layout.addStretch()
303
+ add.button('Select All').clicked(() => this.setVisibleSelections(true))
304
+ add.button('Deselect All').clicked(() => this.setVisibleSelections(false))
305
+ })
306
+ }
307
+
308
+ private buildHeader(): void {
309
+ const add = this.add
310
+ const height = this.options.headerHeight ?? 96
311
+ const imageWidth = this.options.headerImageWidth ?? height
312
+ const headerText = String(this.options.headerText ?? 'Choose which scripts to install, then use the columns to review their shortcut, menu, and toolbar targets.')
313
+
314
+ add.header({
315
+ imagePath: resolveSetupAssetPath(this.options.headerImagePath),
316
+ imageWidth,
317
+ height,
318
+ html: headerText
319
+ }).build()
294
320
  }
295
321
 
296
322
  private buildShortcutsTab(): void {
@@ -304,8 +330,7 @@ class InstallerSelectionDialog extends BasicDialog {
304
330
  .value(this.shortcutKeywords$)
305
331
  .placeholder('Search shortcuts...')
306
332
  .toolTip('Search by action name, current shortcut, new shortcut, status, or conflicts.')
307
- add.button('Select All').clicked(() => this.setVisibleShortcutSelections(true))
308
- add.button('Deselect All').clicked(() => this.setVisibleShortcutSelections(false))
333
+ add.button('Clear').clicked(() => this.shortcutKeywords$.value = '')
309
334
  })
310
335
 
311
336
  add.group('Keyboard Shortcuts')
@@ -358,12 +383,20 @@ class InstallerSelectionDialog extends BasicDialog {
358
383
  listView.allColumnsShowFocus = true
359
384
  })
360
385
  })
386
+ add.group().horizontal().build((layout) => {
387
+ layout.addStretch()
388
+ add.button('Select All').clicked(() => this.setVisibleShortcutSelections(true))
389
+ add.button('Deselect All').clicked(() => this.setVisibleShortcutSelections(false))
390
+ })
361
391
  }
362
392
 
363
393
  private buildContextMenu(listView: DzListView, listItem: DzListViewItem | null): DzPopupMenu {
364
- const entry = listItem ? getDataItem<InstallerEntry>(listItem) : null
394
+ debug(`[Setup] context menu row input ${this.describeListItem('item', listItem)} ${this.describeListItem('selected', listView.selectedItem())} ${this.describeListItem('current', listView.currentItem())}`)
395
+ const contextItem = this.getContextListItem(listView, listItem)
396
+ const entry = contextItem ? this.getCanonicalEntry(getDataItem<InstallerEntry>(contextItem), contextItem) : null
397
+ debug(`[Setup] context menu row resolved item="${this.safeListItemText(contextItem, 1)}" entry="${entry?.action?.text ?? ''}"`)
365
398
  const canSetShortcut = Boolean(entry)
366
- const canResetShortcut = Boolean(entry?.installedActionName && entry?.isShortcutOverridden)
399
+ const canResetShortcut = Boolean(entry && canResetSetupShortcut(entry))
367
400
 
368
401
  const items = [
369
402
  {
@@ -391,31 +424,77 @@ class InstallerSelectionDialog extends BasicDialog {
391
424
  })
392
425
  }
393
426
 
427
+ private getContextListItem(listView: DzListView, listItem: any): DzListViewItem | null {
428
+ if (this.isListViewItem(listItem)) return listItem
429
+
430
+ const selectedItem = listView.selectedItem()
431
+ if (this.isListViewItem(selectedItem)) return selectedItem
432
+
433
+ const currentItem = listView.currentItem()
434
+ if (this.isListViewItem(currentItem)) return currentItem
435
+
436
+ return null
437
+ }
438
+
439
+ private isListViewItem(value: any): value is DzListViewItem {
440
+ return Boolean(value && typeof value.text === 'function')
441
+ }
442
+
443
+ private describeListItem(label: string, value: any): string {
444
+ if (!value) return `${label}=null`
445
+
446
+ const hasTextFunction = typeof value.text === 'function'
447
+ const text0 = this.safeListItemText(value, 0)
448
+ const text1 = this.safeListItemText(value, 1)
449
+ const text2 = this.safeListItemText(value, 2)
450
+ const hasDataFunction = typeof value.getDataItem === 'function'
451
+ let dataText = ''
452
+
453
+ if (hasDataFunction) {
454
+ const data = getDataItem<any>(value)
455
+ dataText = String(data?.action?.text ?? data?.value?.action?.text ?? '')
456
+ }
457
+
458
+ return `${label} type=${typeof value} textFn=${hasTextFunction} text0="${text0}" text1="${text1}" text2="${text2}" dataFn=${hasDataFunction} data="${dataText}"`
459
+ }
460
+
461
+ private safeListItemText(value: any, column: number): string {
462
+ try {
463
+ if (!value || typeof value.text !== 'function') return ''
464
+ return String(value.text(column) ?? '')
465
+ } catch (error) {
466
+ return `[error:${String(error)}]`
467
+ }
468
+ }
469
+
470
+ private getCanonicalEntry(entry: InstallerEntry | null, listItem?: DzListViewItem | null): InstallerEntry | null {
471
+ return getCanonicalInstallerEntry(this.entries, entry, listItem?.text(1))
472
+ }
473
+
394
474
  private setShortcut(entry: InstallerEntry) {
475
+ if (!entry?.action) {
476
+ debug('[Setup] shortcut set ignored because the selected row did not resolve to an installer entry')
477
+ return
478
+ }
479
+
395
480
  const actionName = entry.installedActionName || toKey(entry.action)
396
481
  const actionLabel = String(entry.action.text ?? entry.action.filePath ?? actionName)
397
482
  const shortcut = promptKeyboardShortcut(actionLabel, actionName, entry.effectiveShortcut)
398
483
  if (shortcut == null) return
399
484
 
400
- if (entry.installedActionName) {
401
- setActionShortcut(entry.installedActionName, shortcut)
402
- entry.installedShortcut = normalizeShortcut(shortcut)
403
- entry.effectiveShortcut = entry.installedShortcut
404
- updateOverrideState(entry)
405
- } else {
406
- entry.effectiveShortcut = normalizeShortcut(shortcut)
407
- }
485
+ const previousShortcut = entry.effectiveShortcut
486
+ setSetupShortcut(entry, shortcut)
487
+ debug(`[Setup] shortcut queued action="${entry.action.text}" installed=${Boolean(entry.installedActionName)} previous="${previousShortcut}" next="${entry.effectiveShortcut}" custom=${entry.isShortcutOverridden}`)
408
488
 
409
489
  this.refreshListEvent$.trigger()
410
490
  }
411
491
 
412
492
  private resetDefaultShortcut(entry: InstallerEntry) {
413
- if (!entry.installedActionName || !entry.isShortcutOverridden) return
493
+ if (!canResetSetupShortcut(entry)) return
414
494
 
415
- setActionShortcut(entry.installedActionName, entry.defaultShortcut)
416
- entry.installedShortcut = normalizeShortcut(entry.defaultShortcut)
417
- entry.effectiveShortcut = entry.installedShortcut
418
- updateOverrideState(entry)
495
+ const previousShortcut = entry.effectiveShortcut
496
+ resetSetupShortcut(entry)
497
+ debug(`[Setup] shortcut reset queued action="${entry.action.text}" previous="${previousShortcut}" default="${entry.defaultShortcut}"`)
419
498
  this.refreshListEvent$.trigger()
420
499
  }
421
500
 
@@ -514,7 +593,7 @@ class InstallerSelectionDialog extends BasicDialog {
514
593
  const runDialog = (actions: CustomAction[], options: SetupDialogOptions): SetupSelection | null => {
515
594
  const entries = buildEntries(actions)
516
595
  const shortcutEntries = buildShortcutEntries(options.shortcuts)
517
- const dialog = new InstallerSelectionDialog(entries, shortcutEntries, options.bundleName)
596
+ const dialog = new InstallerSelectionDialog(entries, shortcutEntries, options)
518
597
  return dialog.ok() ? dialog.getSelections() : null
519
598
  }
520
599
 
@@ -565,7 +644,7 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
565
644
  const selectedToolbarActions: CustomAction[] = []
566
645
 
567
646
  progress('Setting Up Scripts', selections.actions, (selection) => {
568
- debug(`[Setup] ${selection.selected ? 'install' : 'remove'} "${selection.action.text}"`)
647
+ debug(`[Setup] ${selection.selected ? 'install' : 'remove'} "${selection.action.text}" shortcut="${selection.effectiveShortcut}" custom=${selection.isShortcutOverridden}`)
569
648
 
570
649
  if (selection.supportsToolbar && selection.action.toolbar) {
571
650
  touchedToolbarNames.add(selection.action.toolbar)
@@ -574,7 +653,7 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
574
653
  if (selection.selected) {
575
654
  const customAction = applyCustomActionTargets({
576
655
  ...selection.action,
577
- shortcut: selection.effectiveShortcut
656
+ shortcut: ''
578
657
  }, {
579
658
  menu: selection.supportsMenu,
580
659
  toolbar: selection.supportsToolbar
@@ -582,6 +661,12 @@ export const showSetupCustomActionsDialog = (actions: CustomAction[], options: s
582
661
  deferToolbar: true
583
662
  })
584
663
 
664
+ if (customAction) {
665
+ setActionShortcut(String(customAction.name), selection.effectiveShortcut)
666
+ const appliedShortcut = getActionShortcut(String(customAction.name))
667
+ debug(`[Setup] applied shortcut "${appliedShortcut}" to "${selection.action.text}" after install`)
668
+ }
669
+
585
670
  if (customAction && selection.supportsToolbar && selection.action.toolbar) {
586
671
  selectedToolbarActions.push(customAction)
587
672
  }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ canResetSetupShortcut,
4
+ getDisplayedSetupShortcut,
5
+ resetSetupShortcut,
6
+ setSetupShortcut,
7
+ updateShortcutOverrideState
8
+ } from './custom-action-installer-shortcuts'
9
+
10
+ const shortcutState = (overrides: Partial<ReturnType<typeof baseState>> = {}) => ({
11
+ ...baseState(),
12
+ ...overrides
13
+ })
14
+
15
+ const baseState = () => ({
16
+ installedActionName: '',
17
+ installedShortcut: '',
18
+ defaultShortcut: '',
19
+ effectiveShortcut: '',
20
+ isShortcutOverridden: false
21
+ })
22
+
23
+ describe('setup shortcut state', () => {
24
+ it('stores pending shortcuts for actions that are not installed yet', () => {
25
+ const entry = shortcutState()
26
+
27
+ setSetupShortcut(entry, 'F12')
28
+
29
+ expect(entry.effectiveShortcut).toBe('F12')
30
+ expect(entry.installedShortcut).toBe('')
31
+ expect(entry.isShortcutOverridden).toBe(false)
32
+ expect(canResetSetupShortcut(entry)).toBe(true)
33
+ })
34
+
35
+ it('marks installed shortcuts that differ from the default as custom', () => {
36
+ const entry = shortcutState({
37
+ installedActionName: 'installed-guid',
38
+ defaultShortcut: '',
39
+ installedShortcut: ''
40
+ })
41
+
42
+ setSetupShortcut(entry, 'F12')
43
+
44
+ expect(entry.installedShortcut).toBe('F12')
45
+ expect(entry.effectiveShortcut).toBe('F12')
46
+ expect(entry.isShortcutOverridden).toBe(true)
47
+ expect(getDisplayedSetupShortcut(entry)).toBe('F12 [custom]')
48
+ expect(canResetSetupShortcut(entry)).toBe(true)
49
+ })
50
+
51
+ it('resets installed custom shortcuts to the default', () => {
52
+ const entry = shortcutState({
53
+ installedActionName: 'installed-guid',
54
+ defaultShortcut: 'Ctrl+P',
55
+ installedShortcut: 'F12',
56
+ effectiveShortcut: 'F12',
57
+ isShortcutOverridden: true
58
+ })
59
+
60
+ resetSetupShortcut(entry)
61
+
62
+ expect(entry.installedShortcut).toBe('Ctrl+P')
63
+ expect(entry.effectiveShortcut).toBe('Ctrl+P')
64
+ expect(entry.isShortcutOverridden).toBe(false)
65
+ expect(canResetSetupShortcut(entry)).toBe(false)
66
+ })
67
+
68
+ it('resets pending shortcuts without inventing an installed shortcut', () => {
69
+ const entry = shortcutState({
70
+ defaultShortcut: '',
71
+ installedShortcut: '',
72
+ effectiveShortcut: 'F12'
73
+ })
74
+
75
+ resetSetupShortcut(entry)
76
+
77
+ expect(entry.installedShortcut).toBe('')
78
+ expect(entry.effectiveShortcut).toBe('')
79
+ expect(entry.isShortcutOverridden).toBe(false)
80
+ expect(canResetSetupShortcut(entry)).toBe(false)
81
+ })
82
+
83
+ it('displays custom marker when installed shortcut was changed outside setup', () => {
84
+ const entry = shortcutState({
85
+ installedActionName: 'installed-guid',
86
+ defaultShortcut: '',
87
+ installedShortcut: 'F12',
88
+ effectiveShortcut: 'F12'
89
+ })
90
+
91
+ updateShortcutOverrideState(entry)
92
+
93
+ expect(getDisplayedSetupShortcut(entry)).toBe('F12 [custom]')
94
+ })
95
+ })
@@ -0,0 +1,49 @@
1
+ import { normalizeShortcut } from '@dsf/helpers/shortcut-helper'
2
+
3
+ export const CUSTOM_SHORTCUT_MARKER = '[custom]'
4
+
5
+ export type InstallerShortcutState = {
6
+ installedActionName: string
7
+ installedShortcut: string
8
+ defaultShortcut: string
9
+ effectiveShortcut: string
10
+ isShortcutOverridden: boolean
11
+ }
12
+
13
+ export const updateShortcutOverrideState = (entry: InstallerShortcutState): void => {
14
+ entry.isShortcutOverridden = Boolean(entry.installedActionName) &&
15
+ normalizeShortcut(entry.installedShortcut) !== normalizeShortcut(entry.defaultShortcut)
16
+ }
17
+
18
+ export const getDisplayedSetupShortcut = (entry: InstallerShortcutState): string => {
19
+ if (!entry.isShortcutOverridden) return entry.effectiveShortcut
20
+
21
+ return entry.effectiveShortcut
22
+ ? `${entry.effectiveShortcut} ${CUSTOM_SHORTCUT_MARKER}`
23
+ : CUSTOM_SHORTCUT_MARKER
24
+ }
25
+
26
+ export const canResetSetupShortcut = (entry: InstallerShortcutState): boolean =>
27
+ normalizeShortcut(entry.effectiveShortcut) !== normalizeShortcut(entry.defaultShortcut)
28
+
29
+ export const setSetupShortcut = (entry: InstallerShortcutState, shortcut: string): void => {
30
+ const normalizedShortcut = normalizeShortcut(shortcut)
31
+
32
+ if (entry.installedActionName) {
33
+ entry.installedShortcut = normalizedShortcut
34
+ }
35
+
36
+ entry.effectiveShortcut = normalizedShortcut
37
+ updateShortcutOverrideState(entry)
38
+ }
39
+
40
+ export const resetSetupShortcut = (entry: InstallerShortcutState): void => {
41
+ const defaultShortcut = normalizeShortcut(entry.defaultShortcut)
42
+
43
+ if (entry.installedActionName) {
44
+ entry.installedShortcut = defaultShortcut
45
+ }
46
+
47
+ entry.effectiveShortcut = defaultShortcut
48
+ updateShortcutOverrideState(entry)
49
+ }
@@ -0,0 +1,48 @@
1
+ const shortcutTokenMap: { [key: string]: string } = {
2
+ 'CTRL': 'Ctrl',
3
+ 'CONTROL': 'Ctrl',
4
+ 'SHIFT': 'Shift',
5
+ 'ALT': 'Alt',
6
+ 'OPTION': 'Alt',
7
+ 'WIN': 'Win',
8
+ 'WINDOWS': 'Win',
9
+ 'CMD': 'Win',
10
+ 'COMMAND': 'Win',
11
+ 'SPACE': 'Space',
12
+ 'HOME': 'Home',
13
+ 'END': 'End',
14
+ 'INS': 'Ins',
15
+ 'INSERT': 'Ins',
16
+ 'DEL': 'Del',
17
+ 'DELETE': 'Del',
18
+ 'TAB': 'Tab',
19
+ 'BACKSPACE': 'Backspace',
20
+ 'COMMA': 'Comma',
21
+ 'PERIOD': 'Period',
22
+ 'PLUS': 'Plus',
23
+ 'MINUS': 'Minus',
24
+ 'PGUP': 'PgUp',
25
+ 'PAGEUP': 'PgUp',
26
+ 'PGDOWN': 'PgDn',
27
+ 'PGDN': 'PgDn',
28
+ 'LEFT': 'Left',
29
+ 'RIGHT': 'Right',
30
+ 'UP': 'Up',
31
+ 'DOWN': 'Down',
32
+ }
33
+
34
+ export const normalizeShortcut = (shortcut: string): string => {
35
+ if (!shortcut) return ''
36
+
37
+ return shortcut
38
+ .split('+')
39
+ .map(part => part.trim())
40
+ .filter(Boolean)
41
+ .map(part => {
42
+ if (part.length === 1) return part.toUpperCase()
43
+
44
+ const key = part.toUpperCase()
45
+ return shortcutTokenMap[key] || part
46
+ })
47
+ .join('+')
48
+ }
@@ -29,6 +29,10 @@ const writePng = (projectDir: string, fileName: string): void => {
29
29
  fs.writeFileSync(path.join(projectDir, 'src', fileName), 'png')
30
30
  }
31
31
 
32
+ const writeText = (projectDir: string, fileName: string, content: string): void => {
33
+ fs.writeFileSync(path.join(projectDir, 'src', fileName), content)
34
+ }
35
+
32
36
  const generateSetup = (projectDir: string): string => {
33
37
  const previousCwd = process.cwd()
34
38
  process.chdir(projectDir)
@@ -103,3 +107,69 @@ describe('install generator action icons', () => {
103
107
  expect(setup).not.toContain('"icon": "custom-icon.dsa.png"')
104
108
  })
105
109
  })
110
+
111
+ describe('install generator setup header', () => {
112
+ it('uses the explicit setup header image before the generic setup image', () => {
113
+ const projectDir = makeProject()
114
+ writeScript(projectDir, 'render-tools')
115
+ writePng(projectDir, 'Setup.header.png')
116
+ writePng(projectDir, 'Setup.png')
117
+
118
+ const setup = generateSetup(projectDir)
119
+
120
+ expect(setup).toContain('"headerImagePath":"./Setup.header.png"')
121
+ expect(setup).not.toContain('"headerImagePath":"./Setup.png"')
122
+ })
123
+
124
+ it('falls back to the generic setup image when no explicit header image exists', () => {
125
+ const projectDir = makeProject()
126
+ writeScript(projectDir, 'render-tools')
127
+ writePng(projectDir, 'Setup.png')
128
+
129
+ const setup = generateSetup(projectDir)
130
+
131
+ expect(setup).toContain('"headerImagePath":"./Setup.png"')
132
+ })
133
+
134
+ it('falls back to the setup script image when no header image exists', () => {
135
+ const projectDir = makeProject()
136
+ writeScript(projectDir, 'render-tools')
137
+ writePng(projectDir, 'Setup.dsa.png')
138
+
139
+ const setup = generateSetup(projectDir)
140
+
141
+ expect(setup).toContain('"headerImagePath":"./Setup.dsa.png"')
142
+ })
143
+
144
+ it('embeds setup header markdown text into generated setup options', () => {
145
+ const projectDir = makeProject()
146
+ writeScript(projectDir, 'render-tools')
147
+ writeText(projectDir, 'Setup.header.md', 'Header line\n\nSecond line\n')
148
+
149
+ const setup = generateSetup(projectDir)
150
+
151
+ expect(setup).toContain('"headerText":"Header line\\n\\nSecond line"')
152
+ })
153
+
154
+ it('prefers setup header html over markdown text', () => {
155
+ const projectDir = makeProject()
156
+ writeScript(projectDir, 'render-tools')
157
+ writeText(projectDir, 'Setup.header.html', '<h2>HTML Header</h2>')
158
+ writeText(projectDir, 'Setup.header.md', '# Markdown Header')
159
+
160
+ const setup = generateSetup(projectDir)
161
+
162
+ expect(setup).toContain('"headerText":"<h2>HTML Header</h2>"')
163
+ expect(setup).not.toContain('# Markdown Header')
164
+ })
165
+
166
+ it('falls back to setup script markdown when no header text file exists', () => {
167
+ const projectDir = makeProject()
168
+ writeScript(projectDir, 'render-tools')
169
+ writeText(projectDir, 'Setup.md', 'Setup markdown fallback')
170
+
171
+ const setup = generateSetup(projectDir)
172
+
173
+ expect(setup).toContain('"headerText":"Setup markdown fallback"')
174
+ })
175
+ })
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { clearShortcutConflicts } from './set-keyboard-shortcut-conflicts'
3
+
4
+ describe('clearShortcutConflicts', () => {
5
+ it('clears conflicting actions without clearing the target action', () => {
6
+ const cleared: string[] = []
7
+
8
+ clearShortcutConflicts('target-action', [
9
+ { name: 'target-action', text: 'Target' } as DzAction,
10
+ { name: 'old-owner', text: 'Old Owner' } as DzAction,
11
+ { name: 'other-owner', text: 'Other Owner' } as DzAction
12
+ ], (name) => {
13
+ cleared.push(name)
14
+ })
15
+
16
+ expect(cleared).toEqual(['old-owner', 'other-owner'])
17
+ })
18
+ })
@@ -0,0 +1,14 @@
1
+ export type ShortcutConflict = {
2
+ name: string
3
+ }
4
+
5
+ export const clearShortcutConflicts = (
6
+ actionName: string,
7
+ conflicts: ShortcutConflict[],
8
+ clearShortcut: (name: string) => void
9
+ ): void => {
10
+ conflicts.forEach((action) => {
11
+ if (!action || action.name === actionName) return
12
+ clearShortcut(action.name)
13
+ })
14
+ }
@@ -1,9 +1,10 @@
1
1
  import { BasicDialog } from '@dsf/dialog/basic-dialog';
2
2
  import { debug } from '@dsf/common/log';
3
- import { findAction, findActionsForShortcut, getActionShortcut, normalizeShortcut, setActionShortcut } from '@dsf/helpers/action-helper';
3
+ import { clearActionShortcut, findActionsForShortcut, getActionShortcut, normalizeShortcut, setActionShortcut } from '@dsf/helpers/action-helper';
4
4
  import { contains } from '@dsf/helpers/array-helper';
5
5
  import { confirm } from '@dsf/helpers/message-box-helper';
6
6
  import { Observable } from '@dsf/lib/observable';
7
+ import { clearShortcutConflicts } from './set-keyboard-shortcut-conflicts';
7
8
 
8
9
  class KeyboardShortcutModel {
9
10
  actionLabel: string
@@ -157,6 +158,7 @@ export const promptKeyboardShortcut = (actionLabel: string, actionName: string,
157
158
  const conflictText = conflicts.map(action => action.text || action.name).join(', ')
158
159
  const response = confirm(`"${model.shortcut.value}" is already assigned to: ${conflictText}\n\nReplace it?`)
159
160
  if (!response.ok) return null
161
+ clearShortcutConflicts(actionName, conflicts, clearActionShortcut)
160
162
  }
161
163
 
162
164
  return model.shortcut.value