diff --git a/src/fontra/client/core/font-controller.js b/src/fontra/client/core/font-controller.js index ac8bd3a05..8190d363c 100644 --- a/src/fontra/client/core/font-controller.js +++ b/src/fontra/client/core/font-controller.js @@ -44,6 +44,7 @@ export class FontController { this._rootObject["unitsPerEm"] = await this.font.getUnitsPerEm(); this._rootObject["customData"] = await this.font.getCustomData(); this._rootClassDef = (await getClassSchema())["Font"]; + this.backendInfo = await this.font.getBackEndInfo(); this._resolveInitialized(); } @@ -129,7 +130,7 @@ export class FontController { await this.getGlyph(glyphName); - for (const subGlyphName of this.iterGlyphMadeOf(glyphName)) { + for (const subGlyphName of this.iterGlyphsMadeOfRecursively(glyphName)) { todo.add(subGlyphName); } }; @@ -276,7 +277,7 @@ export class FontController { } async glyphChanged(glyphName, senderInfo) { - const glyphNames = [glyphName, ...this.iterGlyphUsedBy(glyphName)]; + const glyphNames = [glyphName, ...this.iterGlyphsUsedByRecursively(glyphName)]; for (const glyphName of glyphNames) { this._purgeInstanceCache(glyphName); } @@ -507,7 +508,8 @@ export class FontController { return cachedPattern; } - *iterGlyphMadeOf(glyphName, seenGlyphNames = null) { + *iterGlyphsMadeOfRecursively(glyphName, seenGlyphNames = null) { + // Yield the names of all glyphs that are used as a component in `glyphName`, recursively. if (!seenGlyphNames) { seenGlyphNames = new Set(); } else if (seenGlyphNames.has(glyphName)) { @@ -517,7 +519,7 @@ export class FontController { seenGlyphNames.add(glyphName); for (const dependantGlyphName of this.glyphMadeOf[glyphName] || []) { yield dependantGlyphName; - for (const deeperGlyphName of this.iterGlyphMadeOf( + for (const deeperGlyphName of this.iterGlyphsMadeOfRecursively( dependantGlyphName, seenGlyphNames )) { @@ -526,7 +528,8 @@ export class FontController { } } - *iterGlyphUsedBy(glyphName, seenGlyphNames = null) { + *iterGlyphsUsedByRecursively(glyphName, seenGlyphNames = null) { + // Yield the names of all *loaded* glyphs that use `glyphName` as a component, recursively. if (!seenGlyphNames) { seenGlyphNames = new Set(); } else if (seenGlyphNames.has(glyphName)) { @@ -536,7 +539,7 @@ export class FontController { seenGlyphNames.add(glyphName); for (const dependantGlyphName of this.glyphUsedBy[glyphName] || []) { yield dependantGlyphName; - for (const deeperGlyphName of this.iterGlyphUsedBy( + for (const deeperGlyphName of this.iterGlyphsUsedByRecursively( dependantGlyphName, seenGlyphNames )) { @@ -545,6 +548,11 @@ export class FontController { } } + async getGlyphsUsedBy(glyphName) { + // Ask the backend about which glyphs use glyph `glyphName` as a component, non-recursively. + return await this.font.getGlyphsUsedBy(glyphName); + } + _purgeGlyphCache(glyphName) { this._glyphsPromiseCache.delete(glyphName); this._purgeInstanceCache(glyphName); diff --git a/src/fontra/core/fonthandler.py b/src/fontra/core/fonthandler.py index a26f3d8f6..a3a6e71a3 100644 --- a/src/fontra/core/fonthandler.py +++ b/src/fontra/core/fonthandler.py @@ -147,6 +147,13 @@ async def useConnection(self, connection) -> AsyncGenerator[None, None]: if not self.connections and self.allConnectionsClosedCallback is not None: await self.allConnectionsClosedCallback() + @remoteMethod + async def getBackEndInfo(self, *, connection=None) -> dict: + features = {} + for key, methodName in [("glyphs-used-by", "getGlyphsUsedBy")]: + features[key] = hasattr(self.backend, methodName) + return dict(name=self.backend.__class__.__name__, features=features) + @remoteMethod async def getGlyph( self, glyphName: str, *, connection=None @@ -226,6 +233,12 @@ def _getClientData(self, connection, key, default=None): def _setClientData(self, connection, key, value): self.clientData[connection.clientUUID][key] = value + @remoteMethod + async def getGlyphsUsedBy(self, glyphName: str, *, connection) -> list[str]: + if hasattr(self.backend, "getGlyphsUsedBy"): + return await self.backend.getGlyphsUsedBy(glyphName) + return [] + @remoteMethod async def subscribeChanges(self, pathOrPattern, wantLiveChanges, *, connection): pattern = _ensurePattern(pathOrPattern) @@ -402,16 +415,6 @@ async def scheduleDataWrite(self, writeKey, writeFunc, connection): self._processWritesEvent.set() # write: go! self._writingInProgressEvent.clear() - def iterGlyphMadeOf(self, glyphName): - for dependantGlyphName in self.glyphMadeOf.get(glyphName, ()): - yield dependantGlyphName - yield from self.iterGlyphMadeOf(dependantGlyphName) - - def iterGlyphUsedBy(self, glyphName): - for dependantGlyphName in self.glyphUsedBy.get(glyphName, ()): - yield dependantGlyphName - yield from self.iterGlyphUsedBy(dependantGlyphName) - def updateGlyphDependencies(self, glyphName, glyph): # Zap previous used-by data for this glyph, if any for componentName in self.glyphMadeOf.get(glyphName, ()): diff --git a/src/fontra/views/editor/editor.js b/src/fontra/views/editor/editor.js index 480ad39a5..536b8622e 100644 --- a/src/fontra/views/editor/editor.js +++ b/src/fontra/views/editor/editor.js @@ -619,11 +619,7 @@ export class EditorController { if (!glyphName) { return; } - const codePoint = this.fontController.codePointForGlyph(glyphName); - const glyphInfo = { glyphName: glyphName }; - if (codePoint !== undefined) { - glyphInfo["character"] = getCharFromUnicode(codePoint); - } + const glyphInfo = glyphInfoFromGlyphName(glyphName, this.fontController); let selectedGlyphState = this.sceneSettings.selectedGlyph; const glyphLines = [...this.sceneSettings.glyphLines]; if (selectedGlyphState) { @@ -678,12 +674,7 @@ export class EditorController { if (location) { localLocations[glyphName] = location; } - const glyphInfo = { glyphName: glyphName }; - const codePoint = this.fontController.codePointForGlyph(glyphName); - if (codePoint !== undefined) { - glyphInfo["character"] = getCharFromUnicode(codePoint); - } - glyphInfos.push(glyphInfo); + glyphInfos.push(glyphInfoFromGlyphName(glyphName, this.fontController)); } this.sceneController.updateLocalLocations(localLocations); const selectedGlyphInfo = this.sceneSettings.selectedGlyph; @@ -792,6 +783,12 @@ export class EditorController { }, }); } + + this.glyphSelectedContextMenuItems.push({ + title: () => `Find glyphs that use '${this.sceneSettings.selectedGlyphName}'`, + enabled: () => this.fontController.backendInfo.features["glyphs-used-by"], + callback: () => this.doFindGlyphsUsedBy(), + }); } initShortCuts() { @@ -1447,7 +1444,7 @@ export class EditorController { const dialog = await dialogSetup("Add Component", null, [ { title: "Cancel", isCancelButton: true }, - { title: "Add", isDefaultButton: true, result: "ok", disabled: true }, + { title: "Add", isDefaultButton: true, resultValue: "ok", disabled: true }, ]); dialog.setContent(glyphsSearch); @@ -1553,6 +1550,95 @@ export class EditorController { this.sceneSettings.selectedSourceIndex = newSourceIndex; } + async doFindGlyphsUsedBy() { + const glyphName = this.sceneSettings.selectedGlyphName; + + const usedBy = await loaderSpinner(this.fontController.getGlyphsUsedBy(glyphName)); + + if (!usedBy.length) { + await dialog( + `Glyph '${glyphName}' is not used as a component by any glyph.`, + null, + [{ title: "Okay", resultValue: "ok" }] + ); + return; + } + + usedBy.sort(); + + const glyphMap = Object.fromEntries( + usedBy.map((glyphName) => [glyphName, this.fontController.glyphMap[glyphName]]) + ); + + const glyphsSearch = document.createElement("glyphs-search"); + glyphsSearch.glyphMap = glyphMap; + + glyphsSearch.addEventListener("selectedGlyphNameDoubleClicked", (event) => { + theDialog.defaultButton.click(); + }); + + const theDialog = await dialogSetup( + `Glyphs that use glyph '${glyphName}' as a component`, + null, + [ + { title: "Cancel", isCancelButton: true }, + { title: "Copy names", resultValue: "copy" }, + { + title: "Add to text", + isDefaultButton: true, + resultValue: "add", + }, + ] + ); + + theDialog.setContent(glyphsSearch); + + setTimeout(() => glyphsSearch.focusSearchField(), 0); // next event loop iteration + + switch (await theDialog.run()) { + case "copy": { + const glyphNamesString = chunks(usedBy, 16) + .map((chunked) => chunked.map((glyphName) => "/" + glyphName).join("")) + .join("\n"); + const clipboardObject = { + "text/plain": glyphNamesString, + }; + await writeToClipboard(clipboardObject); + break; + } + case "add": { + const glyphName = glyphsSearch.getSelectedGlyphName(); + const MAX_NUM_GLYPHS = 100; + const truncate = !glyphName && usedBy.length > MAX_NUM_GLYPHS; + const glyphNames = glyphName + ? [glyphName] + : truncate + ? usedBy.slice(0, MAX_NUM_GLYPHS) + : usedBy; + + const glyphInfos = glyphNames.map((glyphName) => + glyphInfoFromGlyphName(glyphName, this.fontController) + ); + const selectedGlyphInfo = this.sceneSettings.selectedGlyph; + const glyphLines = [...this.sceneSettings.glyphLines]; + glyphLines[selectedGlyphInfo.lineIndex].splice( + selectedGlyphInfo.glyphIndex + 1, + 0, + ...glyphInfos + ); + this.sceneSettings.glyphLines = glyphLines; + if (truncate) { + await dialog( + `The number of added glyphs was truncated to ${MAX_NUM_GLYPHS}`, + null, + [{ title: "Okay", resultValue: "ok" }] + ); + } + break; + } + } + } + keyUpHandler(event) { if (event.code === "Space") { this.spaceKeyUpHandler(); @@ -1995,3 +2081,20 @@ async function runDialogWholeGlyphPaste() { return result === "ok" ? controller.model.behavior : null; } + +function chunks(array, n) { + const chunked = []; + for (const i of range(0, array.length, n)) { + chunked.push(array.slice(i, i + n)); + } + return chunked; +} + +function glyphInfoFromGlyphName(glyphName, fontController) { + const glyphInfo = { glyphName: glyphName }; + const codePoint = fontController.codePointForGlyph(glyphName); + if (codePoint !== undefined) { + glyphInfo["character"] = getCharFromUnicode(codePoint); + } + return glyphInfo; +} diff --git a/src/fontra/views/editor/scene-model.js b/src/fontra/views/editor/scene-model.js index 4679f942f..ee49a3fbd 100644 --- a/src/fontra/views/editor/scene-model.js +++ b/src/fontra/views/editor/scene-model.js @@ -742,7 +742,10 @@ function getUsedGlyphNames(fontController, positionedLines) { for (const line of positionedLines) { for (const glyph of line.glyphs) { usedGlyphNames.add(glyph.glyph.name); - updateSet(usedGlyphNames, fontController.iterGlyphMadeOf(glyph.glyph.name)); + updateSet( + usedGlyphNames, + fontController.iterGlyphsMadeOfRecursively(glyph.glyph.name) + ); } } return usedGlyphNames;