dazscript-framework 1.0.41 → 1.0.42
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 +18 -9
- package/dist/scripts/icons.js +26 -1
- package/dist/scripts/install-generator.js +54 -13
- package/package.json +1 -1
- package/src/dialog/builders/list-view-builder.items.test.ts +51 -1
- package/src/dialog/builders/list-view-builder.ts +28 -2
- package/src/scripts/icons.test.ts +63 -0
- package/src/scripts/install-generator.test.ts +58 -5
- /package/src/{Setup.dsa.ts → Examples Setup.dsa.ts} +0 -0
package/README.md
CHANGED
|
@@ -256,11 +256,19 @@ export default defineConfig({
|
|
|
256
256
|
defaultMenuPath: '/MyScripts',
|
|
257
257
|
appDataPath: 'YourName/my-project', // required
|
|
258
258
|
bundleName: 'My Project', // optional — used in the setup dialog title
|
|
259
|
+
assets: [
|
|
260
|
+
{ from: './publishing/manual/User Guide.pdf', to: 'User Guide.pdf' },
|
|
261
|
+
{ from: './licenses/NOTICE.txt', to: 'docs/NOTICE.txt' },
|
|
262
|
+
],
|
|
259
263
|
});
|
|
260
264
|
```
|
|
261
265
|
|
|
262
266
|
`appDataPath` is required for builds that generate launcher shims and must be unique across your projects.
|
|
263
267
|
|
|
268
|
+
Each `assets` entry copies one completed file during the existing postbuild asset stage. `from` is relative to the project root; `to` is relative to `outDir`. Nested destination directories are created automatically. Mappings are explicit—directories and globs are not expanded—and every declared source file is required. A missing source fails the build with its configured path. Both paths must remain inside their respective roots.
|
|
269
|
+
|
|
270
|
+
Assets use the same stage as PNG icons, so `npm run build`, `npm run build:release`, and `npm run icons` copy them without product-specific scripts. The framework copies files only; it does not generate or convert PDFs.
|
|
271
|
+
|
|
264
272
|
---
|
|
265
273
|
|
|
266
274
|
### The `action(...)` Entrypoint
|
|
@@ -301,7 +309,7 @@ action({ text: 'My Script' }, MyScript);
|
|
|
301
309
|
| `group` | Grouping label for related actions in Daz Studio |
|
|
302
310
|
| `description` | Longer description for the action |
|
|
303
311
|
| `icon` | Image path used for the installed custom action. Overrides discovered icon files. |
|
|
304
|
-
| `bundle` | Generates a setup script beside the action. `true` →
|
|
312
|
+
| `bundle` | Generates a setup script beside the action. `true` → `<bundleName> Setup.dsa.ts`, a string → `<name> Setup.dsa.ts` |
|
|
305
313
|
|
|
306
314
|
---
|
|
307
315
|
|
|
@@ -334,7 +342,7 @@ Individual script packages can wrap this command in `package.json` scripts such
|
|
|
334
342
|
npm run installer
|
|
335
343
|
```
|
|
336
344
|
|
|
337
|
-
This scans all `.dsa.ts` entry files, reads each top-level `action(...)` call, and generates `src
|
|
345
|
+
This scans all `.dsa.ts` entry files, reads each top-level `action(...)` call, and generates `src/<bundleName> Setup.dsa.ts` automatically. Projects without a `bundleName` retain the legacy `src/Setup.dsa.ts` name. No installer code to maintain by hand.
|
|
338
346
|
|
|
339
347
|
The generated setup dialog:
|
|
340
348
|
|
|
@@ -359,16 +367,16 @@ Custom action icons are selected from `action(...)` metadata and sibling image f
|
|
|
359
367
|
3. `scriptname.png`
|
|
360
368
|
4. `scriptname.dsa.png` legacy fallback
|
|
361
369
|
|
|
362
|
-
`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`
|
|
370
|
+
`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` remains supported as legacy source input, but builds copy and reference it as `scriptname.png`.
|
|
363
371
|
|
|
364
|
-
Setup dialog header assets are optional and are discovered
|
|
372
|
+
Setup dialog header assets are optional and are discovered in `src`:
|
|
365
373
|
|
|
366
374
|
1. `src/Setup.header.png`
|
|
367
375
|
2. `src/Setup.tip.png`
|
|
368
376
|
3. `src/Setup.png`
|
|
369
377
|
4. `src/Setup.dsa.png` legacy fallback
|
|
370
378
|
|
|
371
|
-
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
|
|
379
|
+
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 the generated setup script, 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.
|
|
372
380
|
|
|
373
381
|
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.
|
|
374
382
|
|
|
@@ -398,7 +406,7 @@ The JSON can be an array or an object containing `actions`, `shortcuts`, or `acc
|
|
|
398
406
|
|
|
399
407
|
Accepted shortcut fields are `shortcut`, `accelerator`, or `key`. Accepted action-name fields are `name` or `action`.
|
|
400
408
|
|
|
401
|
-
At build time the JSON is embedded into generated
|
|
409
|
+
At build time the JSON is embedded into the generated setup script; Daz Studio does not need to read the original JSON file at setup time. During setup, the `Keyboard Shortcuts` tab shows the action label, current shortcut, new shortcut, action type, and conflicts. The user chooses which shortcut rows to apply.
|
|
402
410
|
|
|
403
411
|
Before changing a non-custom Daz Studio action shortcut, setup writes the original value to:
|
|
404
412
|
|
|
@@ -416,8 +424,8 @@ The `bundle` property on `action(...)` is separate from the project-level `bundl
|
|
|
416
424
|
|
|
417
425
|
When `bundle` is set, the installer generator also writes a setup script beside that action:
|
|
418
426
|
|
|
419
|
-
- `bundle: true` → writes `Setup.dsa.ts`
|
|
420
|
-
- `bundle: 'Utilities'` → writes `Setup
|
|
427
|
+
- `bundle: true` → writes `<bundleName> Setup.dsa.ts`, or `Setup.dsa.ts` when no project `bundleName` exists
|
|
428
|
+
- `bundle: 'Utilities'` → writes `Utilities Setup.dsa.ts`
|
|
421
429
|
|
|
422
430
|
Those bundle-scoped setup files use the same setup dialog helper and also receive the project `bundleName`.
|
|
423
431
|
|
|
@@ -536,6 +544,7 @@ Use `this.add` inside `build()` to construct the UI declaratively.
|
|
|
536
544
|
| `add.radio(text)` | Radio button |
|
|
537
545
|
| `add.comboBox()` | Drop-down list |
|
|
538
546
|
| `add.listBox()` | Scrollable list |
|
|
547
|
+
| `add.list.view<T>()` | Tree/list view; chain `.tabFocusWhenPopulated()` to skip empty or fully filtered lists during keyboard navigation |
|
|
539
548
|
| `add.slider(min, max)` | Numeric slider |
|
|
540
549
|
| `add.colorPicker()` | Color picker |
|
|
541
550
|
| `add.nodeSelection()` | Daz Studio node selector |
|
|
@@ -635,7 +644,7 @@ The `test/unit/` files are generated only when you run `dazscript init --unit-te
|
|
|
635
644
|
| `npm run build:release` | Build with `--log-level warn`, then encrypt implementation bundles |
|
|
636
645
|
| `npm run watch` | Recompile on every save |
|
|
637
646
|
| `npm run installer` | Generate the setup dialog |
|
|
638
|
-
| `npm run icons` | Copy
|
|
647
|
+
| `npm run icons` | Copy PNG icons and configured assets to the output folder |
|
|
639
648
|
| `npm test` | Run fast Node/Vitest tests |
|
|
640
649
|
| `npm run test:integration` | Run a DAZ Studio headless integration fixture |
|
|
641
650
|
|
package/dist/scripts/icons.js
CHANGED
|
@@ -10,6 +10,27 @@ function copyFile(sourcePath, targetPath) {
|
|
|
10
10
|
console.log(`copy ${path.relative(process.cwd(), targetPath)}`);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function resolveAssetPath(rootPath, configuredPath, field) {
|
|
14
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
15
|
+
const resolvedPath = path.resolve(resolvedRoot, configuredPath);
|
|
16
|
+
const relativePath = path.relative(resolvedRoot, resolvedPath);
|
|
17
|
+
if (path.isAbsolute(configuredPath) || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
18
|
+
throw new Error(`[dazscript assets] Asset ${field} path must stay inside its allowed root: ${configuredPath}`);
|
|
19
|
+
}
|
|
20
|
+
return resolvedPath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function copyAssets(workdir, outDir, assets) {
|
|
24
|
+
(assets || []).forEach((asset) => {
|
|
25
|
+
const sourcePath = resolveAssetPath(workdir, asset.from, 'from');
|
|
26
|
+
const targetPath = resolveAssetPath(outDir, asset.to, 'to');
|
|
27
|
+
if (!fs.existsSync(sourcePath)) {
|
|
28
|
+
throw new Error(`[dazscript assets] Required asset not found: ${asset.from}`);
|
|
29
|
+
}
|
|
30
|
+
copyFile(sourcePath, targetPath);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
function copyIcons(workdir, options) {
|
|
14
35
|
const sourceRoot = path.resolve(workdir, 'src');
|
|
15
36
|
const outDir = path.resolve(workdir, options.outDir || './out');
|
|
@@ -18,8 +39,12 @@ function copyIcons(workdir, options) {
|
|
|
18
39
|
|
|
19
40
|
files.forEach((filePath) => {
|
|
20
41
|
const relativePath = path.relative(sourceRoot, filePath);
|
|
21
|
-
|
|
42
|
+
const outputRelativePath = relativePath.endsWith('.dsa.png')
|
|
43
|
+
? `${relativePath.slice(0, -'.dsa.png'.length)}.png`
|
|
44
|
+
: relativePath;
|
|
45
|
+
copyFile(filePath, path.join(outDir, outputRelativePath));
|
|
22
46
|
});
|
|
47
|
+
copyAssets(workdir, outDir, options.assets);
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
module.exports = {
|
|
@@ -37,6 +37,29 @@ function replaceEntryOutputSuffix(filePath, suffix) {
|
|
|
37
37
|
return `${filePath}${suffix}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function removeSetupArtifacts(workdir, outDir, scriptsPath, sourceFilePath) {
|
|
41
|
+
const absoluteSourcePath = path.resolve(workdir, sourceFilePath);
|
|
42
|
+
if (fs.existsSync(absoluteSourcePath)) {
|
|
43
|
+
fs.unlinkSync(absoluteSourcePath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const relativeSourcePath = path.relative(
|
|
47
|
+
path.resolve(workdir, scriptsPath),
|
|
48
|
+
absoluteSourcePath
|
|
49
|
+
);
|
|
50
|
+
if (relativeSourcePath.startsWith('..') || path.isAbsolute(relativeSourcePath)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outputFilePath = path.join(
|
|
55
|
+
path.resolve(workdir, outDir),
|
|
56
|
+
replaceEntrySourceSuffix(relativeSourcePath, '.dsa')
|
|
57
|
+
);
|
|
58
|
+
if (fs.existsSync(outputFilePath)) {
|
|
59
|
+
fs.unlinkSync(outputFilePath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
function getActionIconPath(filePath, decorator) {
|
|
41
64
|
if (decorator.icon) {
|
|
42
65
|
return decorator.icon;
|
|
@@ -64,9 +87,10 @@ function getActionIconPath(filePath, decorator) {
|
|
|
64
87
|
: replaceEntryOutputSuffix(getPartialPath(filePath), '.png');
|
|
65
88
|
}
|
|
66
89
|
|
|
67
|
-
return
|
|
68
|
-
? path.parse(filePath).name
|
|
69
|
-
|
|
90
|
+
return replaceEntryOutputSuffix(
|
|
91
|
+
isBundleAction ? path.parse(filePath).name : getPartialPath(filePath),
|
|
92
|
+
'.png'
|
|
93
|
+
);
|
|
70
94
|
}
|
|
71
95
|
|
|
72
96
|
function generateInstallerTemplate(data, options) {
|
|
@@ -200,7 +224,7 @@ function findActionEntryFiles(workdir, options) {
|
|
|
200
224
|
});
|
|
201
225
|
}
|
|
202
226
|
|
|
203
|
-
function processScript(filePath, container, defaultMenuPath, setupOptions) {
|
|
227
|
+
function processScript(workdir, filePath, container, defaultMenuPath, setupOptions, options) {
|
|
204
228
|
const fileInfo = path.parse(filePath);
|
|
205
229
|
const content = fs.readFileSync(filePath, 'utf-8').toString();
|
|
206
230
|
const actionCall = findTopLevelActionCall(content, filePath);
|
|
@@ -251,11 +275,12 @@ function processScript(filePath, container, defaultMenuPath, setupOptions) {
|
|
|
251
275
|
container.scripts.push(script);
|
|
252
276
|
|
|
253
277
|
if (decorator.bundle !== undefined) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
278
|
+
const packageSetupFilePath = decorator.bundle === true
|
|
279
|
+
? `${setupOptions.bundleName ? `${setupOptions.bundleName} ` : ''}Setup.dsa.ts`
|
|
280
|
+
: `${decorator.bundle} Setup.dsa.ts`;
|
|
281
|
+
const legacySetupFilePath = decorator.bundle === true
|
|
282
|
+
? 'Setup.dsa.ts'
|
|
283
|
+
: `Setup ${decorator.bundle}.dsa.ts`;
|
|
259
284
|
|
|
260
285
|
let bundleScriptContent = generateInstallerTemplate(
|
|
261
286
|
JSON.stringify(container.scripts, null, 4),
|
|
@@ -265,14 +290,22 @@ function processScript(filePath, container, defaultMenuPath, setupOptions) {
|
|
|
265
290
|
path.parse(filePath).dir,
|
|
266
291
|
packageSetupFilePath
|
|
267
292
|
);
|
|
293
|
+
if (legacySetupFilePath !== packageSetupFilePath) {
|
|
294
|
+
removeSetupArtifacts(
|
|
295
|
+
workdir,
|
|
296
|
+
options.outDir,
|
|
297
|
+
options.scriptsPath,
|
|
298
|
+
path.join(path.parse(filePath).dir, legacySetupFilePath)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
268
301
|
fs.writeFileSync(bundleScriptFilePath, bundleScriptContent);
|
|
269
302
|
}
|
|
270
303
|
}
|
|
271
304
|
|
|
272
|
-
function processScripts(paths, container, defaultMenuPath, setupOptions) {
|
|
305
|
+
function processScripts(workdir, paths, container, defaultMenuPath, setupOptions, options) {
|
|
273
306
|
paths.forEach((filePath) => {
|
|
274
307
|
console.log(`Processing ${filePath}`);
|
|
275
|
-
processScript(filePath, container, defaultMenuPath, setupOptions);
|
|
308
|
+
processScript(workdir, filePath, container, defaultMenuPath, setupOptions, options);
|
|
276
309
|
});
|
|
277
310
|
}
|
|
278
311
|
|
|
@@ -377,6 +410,10 @@ function generateInstallerFiles(workdir, options) {
|
|
|
377
410
|
? options.defaultMenuPath
|
|
378
411
|
: `${options.defaultMenuPath}/`;
|
|
379
412
|
const { config } = loadConfig(workdir);
|
|
413
|
+
const generatorOptions = {
|
|
414
|
+
scriptsPath: options.scriptsPath,
|
|
415
|
+
outDir: config.outDir || './out',
|
|
416
|
+
};
|
|
380
417
|
const appDataPath = validateAppDataPath(options.appDataPath || config.appDataPath, workdir);
|
|
381
418
|
const settingsPath = `${appDataPath}/Installer`;
|
|
382
419
|
const bundleName = typeof config.bundleName === 'string' && config.bundleName.trim()
|
|
@@ -396,7 +433,7 @@ function generateInstallerFiles(workdir, options) {
|
|
|
396
433
|
const container = { scripts: [] };
|
|
397
434
|
const matches = findActionEntryFiles(workdir, options);
|
|
398
435
|
|
|
399
|
-
processScripts(matches, container, defaultMenuPath, setupOptions);
|
|
436
|
+
processScripts(workdir, matches, container, defaultMenuPath, setupOptions, generatorOptions);
|
|
400
437
|
|
|
401
438
|
container.scripts = container.scripts.sort((a, b) => {
|
|
402
439
|
const aKey = a.menuPath + a.filePath;
|
|
@@ -408,7 +445,11 @@ function generateInstallerFiles(workdir, options) {
|
|
|
408
445
|
JSON.stringify(container.scripts, null, 4),
|
|
409
446
|
setupOptions
|
|
410
447
|
);
|
|
411
|
-
|
|
448
|
+
const setupFileName = `${bundleName ? `${bundleName} ` : ''}Setup.dsa.ts`;
|
|
449
|
+
if (setupFileName !== 'Setup.dsa.ts') {
|
|
450
|
+
removeSetupArtifacts(workdir, generatorOptions.outDir, './src', path.join(workdir, 'src', 'Setup.dsa.ts'));
|
|
451
|
+
}
|
|
452
|
+
fs.writeFileSync(path.join(workdir, 'src', setupFileName), installerScriptContent);
|
|
412
453
|
|
|
413
454
|
const installPath = path.join(workdir, 'src', 'Install.dsa.ts');
|
|
414
455
|
const uninstallPath = path.join(workdir, 'src', 'Uninstall.dsa.ts');
|
package/package.json
CHANGED
|
@@ -15,11 +15,14 @@ const listView = vi.hoisted(() => ({
|
|
|
15
15
|
setSorting: vi.fn(),
|
|
16
16
|
hide: vi.fn(),
|
|
17
17
|
show: vi.fn(),
|
|
18
|
+
getWidget: vi.fn(() => focusWidget),
|
|
18
19
|
selectedItem: vi.fn(() => null),
|
|
19
20
|
doubleClicked: { scriptConnect: vi.fn() },
|
|
20
21
|
contextMenuRequested: { scriptConnect: vi.fn() }
|
|
21
22
|
}))
|
|
22
23
|
|
|
24
|
+
const focusWidget = vi.hoisted(() => ({ focusPolicy: 15 }))
|
|
25
|
+
|
|
23
26
|
vi.mock('./widget-builder', () => ({
|
|
24
27
|
createWidget: () => ({ build: () => listView })
|
|
25
28
|
}))
|
|
@@ -35,6 +38,7 @@ class FakeListViewItem {
|
|
|
35
38
|
data: any
|
|
36
39
|
open = false
|
|
37
40
|
selectable = true
|
|
41
|
+
visible = true
|
|
38
42
|
textByColumn: Record<number, string> = {}
|
|
39
43
|
|
|
40
44
|
constructor(parent: any, public id: number) {
|
|
@@ -48,13 +52,17 @@ class FakeListViewItem {
|
|
|
48
52
|
|
|
49
53
|
vi.stubGlobal('DzListView', { All: 0, Extended: 1 })
|
|
50
54
|
vi.stubGlobal('DzListViewItem', FakeListViewItem)
|
|
55
|
+
vi.stubGlobal('QtFocusPolicy', { NoFocus: 0 })
|
|
51
56
|
|
|
52
|
-
import {
|
|
57
|
+
import { filter } from '@dsf/helpers/list-view-helper'
|
|
58
|
+
import { ListViewBuilder, ListViewRefreshOptions } from './list-view-builder'
|
|
53
59
|
|
|
54
60
|
describe('ListViewBuilder item updates', () => {
|
|
55
61
|
beforeEach(() => {
|
|
56
62
|
listView.items = []
|
|
57
63
|
vi.clearAllMocks()
|
|
64
|
+
vi.mocked(filter).mockReset()
|
|
65
|
+
focusWidget.focusPolicy = 15
|
|
58
66
|
})
|
|
59
67
|
|
|
60
68
|
it('keeps unchanged rows when data has no explicit id', () => {
|
|
@@ -75,4 +83,46 @@ describe('ListViewBuilder item updates', () => {
|
|
|
75
83
|
|
|
76
84
|
expect(listView.items.map(item => item.textByColumn[0])).toEqual(['Action B'])
|
|
77
85
|
})
|
|
86
|
+
|
|
87
|
+
it('removes empty lists from tab focus and restores populated lists', () => {
|
|
88
|
+
const items = new Observable<TreeNode<any>[]>([])
|
|
89
|
+
|
|
90
|
+
new ListViewBuilder<any, any>({ dialog: {}, layout: null } as any)
|
|
91
|
+
.tabFocusWhenPopulated()
|
|
92
|
+
.items(items)
|
|
93
|
+
.rebuildOnItemsChanged()
|
|
94
|
+
.columns(['Name'])
|
|
95
|
+
.text(item => [item.name])
|
|
96
|
+
.data(item => item.value)
|
|
97
|
+
.build()
|
|
98
|
+
|
|
99
|
+
expect(focusWidget.focusPolicy).toBe(0)
|
|
100
|
+
|
|
101
|
+
items.value = [new TreeNode('Action A', '', { name: 'ActionA' })]
|
|
102
|
+
expect(focusWidget.focusPolicy).toBe(15)
|
|
103
|
+
|
|
104
|
+
items.value = []
|
|
105
|
+
expect(focusWidget.focusPolicy).toBe(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('removes fully filtered lists from tab focus', () => {
|
|
109
|
+
const refresh = new Observable<void>()
|
|
110
|
+
|
|
111
|
+
new ListViewBuilder<any, any>({ dialog: {}, layout: null } as any)
|
|
112
|
+
.tabFocusWhenPopulated()
|
|
113
|
+
.refresh(refresh, ListViewRefreshOptions.Filters)
|
|
114
|
+
.items(new Observable([new TreeNode('Action A', '', { name: 'ActionA' })]))
|
|
115
|
+
.filter({ keywords: new Observable(''), field: item => item.textByColumn[0] })
|
|
116
|
+
.columns(['Name'])
|
|
117
|
+
.text(item => [item.name])
|
|
118
|
+
.data(item => item.value)
|
|
119
|
+
.build()
|
|
120
|
+
|
|
121
|
+
vi.mocked(filter).mockImplementation((view: any) => {
|
|
122
|
+
view.items.forEach((item: any) => item.visible = false)
|
|
123
|
+
})
|
|
124
|
+
refresh.trigger()
|
|
125
|
+
|
|
126
|
+
expect(focusWidget.focusPolicy).toBe(0)
|
|
127
|
+
})
|
|
78
128
|
})
|
|
@@ -51,6 +51,12 @@ export class ListViewBuilder<TItem, TData> implements IWidgetBuilder<DzListView>
|
|
|
51
51
|
return this
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** Removes the list from keyboard tab focus when it has no visible selectable rows. */
|
|
55
|
+
tabFocusWhenPopulated(): this {
|
|
56
|
+
this.context.tabFocusWhenPopulated = true
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
/**
|
|
55
61
|
* Binds the list rows to a collection of items
|
|
56
62
|
* @param items
|
|
@@ -156,6 +162,7 @@ class ListViewBuilderContext<TItem, TData> {
|
|
|
156
162
|
flat: Observable<boolean>
|
|
157
163
|
refreshWhat: ListViewRefreshOptions = ListViewRefreshOptions.All
|
|
158
164
|
itemsChangeMode: ListViewItemsChangeMode = 'update'
|
|
165
|
+
tabFocusWhenPopulated: boolean = false
|
|
159
166
|
constructor(public readonly dialogContext: WidgetBuilderContext) { }
|
|
160
167
|
}
|
|
161
168
|
|
|
@@ -235,8 +242,17 @@ class ListViewBindBuilder<TItem, TData> {
|
|
|
235
242
|
|
|
236
243
|
const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzListView => {
|
|
237
244
|
const listView = createWidget(context.dialogContext).build(DzListView)
|
|
245
|
+
const focusWidget = context.tabFocusWhenPopulated ? listView.getWidget() : null
|
|
246
|
+
const populatedFocusPolicy = focusWidget?.focusPolicy
|
|
238
247
|
let rowId = -1
|
|
239
248
|
|
|
249
|
+
const syncTabFocus = () => {
|
|
250
|
+
if (!focusWidget) return
|
|
251
|
+
const hasNavigableRow = listView.getItems(DzListView.All)
|
|
252
|
+
.some(item => item.visible !== false && item.selectable !== false)
|
|
253
|
+
focusWidget.focusPolicy = hasNavigableRow ? populatedFocusPolicy : QtFocusPolicy.NoFocus
|
|
254
|
+
}
|
|
255
|
+
|
|
240
256
|
if (context.visible) {
|
|
241
257
|
if (context.visible.value === false)
|
|
242
258
|
listView.hide()
|
|
@@ -294,6 +310,7 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
|
|
|
294
310
|
|
|
295
311
|
const filterList = (keywords?: string) => {
|
|
296
312
|
filter(listView, context.filter.field, keywords ?? context.filter?.keywords?.value, { selectOnFilter: context.filter.selectOnFilter ?? true, filters: context.filter.filters })
|
|
313
|
+
syncTabFocus()
|
|
297
314
|
}
|
|
298
315
|
let delayedFilter: Delayed | null = null
|
|
299
316
|
|
|
@@ -310,10 +327,15 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
|
|
|
310
327
|
})
|
|
311
328
|
listView.allColumnsShowFocus = true
|
|
312
329
|
|
|
313
|
-
if (context.visible.value === false)
|
|
330
|
+
if (context.visible.value === false) {
|
|
331
|
+
syncTabFocus()
|
|
314
332
|
return
|
|
333
|
+
}
|
|
315
334
|
|
|
316
|
-
if (!items)
|
|
335
|
+
if (!items) {
|
|
336
|
+
syncTabFocus()
|
|
337
|
+
return
|
|
338
|
+
}
|
|
317
339
|
|
|
318
340
|
items.forEach((item) => {
|
|
319
341
|
buildItem(item, listView)
|
|
@@ -334,6 +356,8 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
|
|
|
334
356
|
|
|
335
357
|
if (context.filter?.keywords.value || context.filter?.filters)
|
|
336
358
|
filterList()
|
|
359
|
+
else
|
|
360
|
+
syncTabFocus()
|
|
337
361
|
|
|
338
362
|
if (selectedId) {
|
|
339
363
|
listView.getItems(DzListView.All).forEach(item => {
|
|
@@ -400,6 +424,8 @@ const build = <TItem, TData>(context: ListViewBuilderContext<TItem, TData>): DzL
|
|
|
400
424
|
if (!incomingPaths[path]) listView.deleteItem(li)
|
|
401
425
|
})
|
|
402
426
|
|
|
427
|
+
syncTabFocus()
|
|
428
|
+
|
|
403
429
|
}
|
|
404
430
|
|
|
405
431
|
const onSelectionChanged = (callback: (item: DzListViewItem, data: TData) => void) => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { afterEach, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const { copyIcons } = require('../../dist/scripts/icons')
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = []
|
|
9
|
+
|
|
10
|
+
const makeProject = (): string => {
|
|
11
|
+
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsf-icons-'))
|
|
12
|
+
tempDirs.push(projectDir)
|
|
13
|
+
fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true })
|
|
14
|
+
return projectDir
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
while (tempDirs.length > 0) {
|
|
19
|
+
fs.rmSync(tempDirs.pop()!, { recursive: true, force: true })
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('copies a legacy dsa icon under the preferred output name', () => {
|
|
24
|
+
const projectDir = makeProject()
|
|
25
|
+
fs.writeFileSync(path.join(projectDir, 'src', 'Power Menu Favorite Actions.dsa.png'), 'legacy icon')
|
|
26
|
+
|
|
27
|
+
copyIcons(projectDir, { outDir: './out' })
|
|
28
|
+
|
|
29
|
+
expect(fs.readFileSync(path.join(projectDir, 'out', 'Power Menu Favorite Actions.png'), 'utf8')).toBe('legacy icon')
|
|
30
|
+
expect(fs.existsSync(path.join(projectDir, 'out', 'Power Menu Favorite Actions.dsa.png'))).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('copies a declared asset from the project root into a nested output directory', () => {
|
|
34
|
+
const projectDir = makeProject()
|
|
35
|
+
fs.mkdirSync(path.join(projectDir, 'publishing', 'manual'), { recursive: true })
|
|
36
|
+
fs.writeFileSync(path.join(projectDir, 'publishing', 'manual', 'User Manual.pdf'), 'completed pdf')
|
|
37
|
+
|
|
38
|
+
copyIcons(projectDir, {
|
|
39
|
+
outDir: './build',
|
|
40
|
+
assets: [{ from: './publishing/manual/User Manual.pdf', to: 'docs/User Manual.pdf' }],
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(fs.readFileSync(path.join(projectDir, 'build', 'docs', 'User Manual.pdf'), 'utf8')).toBe('completed pdf')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('fails clearly when a declared asset is missing', () => {
|
|
47
|
+
const projectDir = makeProject()
|
|
48
|
+
|
|
49
|
+
expect(() => copyIcons(projectDir, {
|
|
50
|
+
outDir: './out',
|
|
51
|
+
assets: [{ from: './publishing/manual/User Manual.pdf', to: 'User Manual.pdf' }],
|
|
52
|
+
})).toThrow('[dazscript assets] Required asset not found: ./publishing/manual/User Manual.pdf')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
[{ from: '../User Manual.pdf', to: 'User Manual.pdf' }, 'from'],
|
|
57
|
+
[{ from: './User Manual.pdf', to: '../User Manual.pdf' }, 'to'],
|
|
58
|
+
])('rejects an asset %s path outside its allowed root', (asset, field) => {
|
|
59
|
+
const projectDir = makeProject()
|
|
60
|
+
|
|
61
|
+
expect(() => copyIcons(projectDir, { outDir: './out', assets: [asset] }))
|
|
62
|
+
.toThrow(`[dazscript assets] Asset ${field} path must stay inside its allowed root`)
|
|
63
|
+
})
|
|
@@ -7,13 +7,13 @@ const { generateInstallerFiles } = require('../../dist/scripts/install-generator
|
|
|
7
7
|
|
|
8
8
|
const tempDirs: string[] = []
|
|
9
9
|
|
|
10
|
-
const makeProject = (): string => {
|
|
10
|
+
const makeProject = (bundleName?: string): string => {
|
|
11
11
|
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsf-install-generator-'))
|
|
12
12
|
tempDirs.push(projectDir)
|
|
13
13
|
fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true })
|
|
14
14
|
fs.writeFileSync(
|
|
15
15
|
path.join(projectDir, 'dazscript.config.cjs'),
|
|
16
|
-
|
|
16
|
+
`module.exports = { appDataPath: 'Test/ActionIcons'${bundleName ? `, bundleName: '${bundleName}'` : ''} }\n`
|
|
17
17
|
)
|
|
18
18
|
return projectDir
|
|
19
19
|
}
|
|
@@ -33,7 +33,7 @@ const writeText = (projectDir: string, fileName: string, content: string): void
|
|
|
33
33
|
fs.writeFileSync(path.join(projectDir, 'src', fileName), content)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const
|
|
36
|
+
const runGenerator = (projectDir: string): void => {
|
|
37
37
|
const previousCwd = process.cwd()
|
|
38
38
|
process.chdir(projectDir)
|
|
39
39
|
try {
|
|
@@ -45,6 +45,10 @@ const generateSetup = (projectDir: string): string => {
|
|
|
45
45
|
} finally {
|
|
46
46
|
process.chdir(previousCwd)
|
|
47
47
|
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const generateSetup = (projectDir: string): string => {
|
|
51
|
+
runGenerator(projectDir)
|
|
48
52
|
return fs.readFileSync(path.join(projectDir, 'src', 'Setup.dsa.ts'), 'utf8')
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -55,6 +59,54 @@ afterEach(() => {
|
|
|
55
59
|
}
|
|
56
60
|
})
|
|
57
61
|
|
|
62
|
+
describe('install generator setup naming', () => {
|
|
63
|
+
it('emits only the product-prefixed project setup name', () => {
|
|
64
|
+
const projectDir = makeProject('Power Menu')
|
|
65
|
+
writeScript(projectDir, 'power-menu')
|
|
66
|
+
fs.mkdirSync(path.join(projectDir, 'out'), { recursive: true })
|
|
67
|
+
fs.writeFileSync(path.join(projectDir, 'src', 'Setup.dsa.ts'), 'legacy source')
|
|
68
|
+
fs.writeFileSync(path.join(projectDir, 'out', 'Setup.dsa'), 'legacy output')
|
|
69
|
+
|
|
70
|
+
runGenerator(projectDir)
|
|
71
|
+
|
|
72
|
+
expect(fs.existsSync(path.join(projectDir, 'src', 'Power Menu Setup.dsa.ts'))).toBe(true)
|
|
73
|
+
expect(fs.existsSync(path.join(projectDir, 'src', 'Setup.dsa.ts'))).toBe(false)
|
|
74
|
+
expect(fs.existsSync(path.join(projectDir, 'out', 'Setup.dsa'))).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('emits only the prefix-first action bundle setup name', () => {
|
|
78
|
+
const projectDir = makeProject('Power Menu')
|
|
79
|
+
writeScript(projectDir, 'utilities', ", bundle: 'Utilities'")
|
|
80
|
+
fs.mkdirSync(path.join(projectDir, 'out'), { recursive: true })
|
|
81
|
+
fs.writeFileSync(path.join(projectDir, 'src', 'Setup Utilities.dsa.ts'), 'legacy source')
|
|
82
|
+
fs.writeFileSync(path.join(projectDir, 'out', 'Setup Utilities.dsa'), 'legacy output')
|
|
83
|
+
|
|
84
|
+
runGenerator(projectDir)
|
|
85
|
+
|
|
86
|
+
expect(fs.existsSync(path.join(projectDir, 'src', 'Utilities Setup.dsa.ts'))).toBe(true)
|
|
87
|
+
expect(fs.existsSync(path.join(projectDir, 'src', 'Setup Utilities.dsa.ts'))).toBe(false)
|
|
88
|
+
expect(fs.existsSync(path.join(projectDir, 'out', 'Setup Utilities.dsa'))).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('uses the project prefix for an unnamed action bundle setup', () => {
|
|
92
|
+
const projectDir = makeProject('Power Menu')
|
|
93
|
+
fs.mkdirSync(path.join(projectDir, 'src', 'tools'), { recursive: true })
|
|
94
|
+
fs.mkdirSync(path.join(projectDir, 'out', 'tools'), { recursive: true })
|
|
95
|
+
fs.writeFileSync(
|
|
96
|
+
path.join(projectDir, 'src', 'tools', 'utilities.dsa.ts'),
|
|
97
|
+
"action({ text: 'utilities', bundle: true }, function() {})\n"
|
|
98
|
+
)
|
|
99
|
+
fs.writeFileSync(path.join(projectDir, 'src', 'tools', 'Setup.dsa.ts'), 'legacy source')
|
|
100
|
+
fs.writeFileSync(path.join(projectDir, 'out', 'tools', 'Setup.dsa'), 'legacy output')
|
|
101
|
+
|
|
102
|
+
runGenerator(projectDir)
|
|
103
|
+
|
|
104
|
+
expect(fs.existsSync(path.join(projectDir, 'src', 'tools', 'Power Menu Setup.dsa.ts'))).toBe(true)
|
|
105
|
+
expect(fs.existsSync(path.join(projectDir, 'src', 'tools', 'Setup.dsa.ts'))).toBe(false)
|
|
106
|
+
expect(fs.existsSync(path.join(projectDir, 'out', 'tools', 'Setup.dsa'))).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
58
110
|
describe('install generator action icons', () => {
|
|
59
111
|
it('prefers the action icon convention over the script icon fallback', () => {
|
|
60
112
|
const projectDir = makeProject()
|
|
@@ -82,14 +134,15 @@ describe('install generator action icons', () => {
|
|
|
82
134
|
expect(setup).not.toContain('"icon": "./power-menu.dsa.png"')
|
|
83
135
|
})
|
|
84
136
|
|
|
85
|
-
it('
|
|
137
|
+
it('normalizes the dsa-named legacy icon in generated setup', () => {
|
|
86
138
|
const projectDir = makeProject()
|
|
87
139
|
writeScript(projectDir, 'legacy-icon')
|
|
88
140
|
writePng(projectDir, 'legacy-icon.dsa.png')
|
|
89
141
|
|
|
90
142
|
const setup = generateSetup(projectDir)
|
|
91
143
|
|
|
92
|
-
expect(setup).toContain('"icon": "./legacy-icon.
|
|
144
|
+
expect(setup).toContain('"icon": "./legacy-icon.png"')
|
|
145
|
+
expect(setup).not.toContain('"icon": "./legacy-icon.dsa.png"')
|
|
93
146
|
})
|
|
94
147
|
|
|
95
148
|
it('lets explicit action icon metadata override discovered icon files', () => {
|
|
File without changes
|