diff --git a/README.md b/README.md index ebe8916..b7426fd 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,63 @@ -# CustomAppModulesMapper 0.3.0 +# CustomAppModulesMapper 1.0.0 -Nvda ADDON for allowing dynamic mapping of existing app modules for other applications. +NVDA add-on for dynamically associating applications with existing app modules — and for associating them with no module — without writing code. ## download -Download the [CustomAppModulesMapper 0.3.0 addon](https://github.com/marlon-sousa/CustomAppModulesMapper/releases/download/0.3.0/CustomAppModulesMapper-0.3.0.nvda-addon) +Download the [CustomAppModulesMapper 1.0.0 addon](https://github.com/marlon-sousa/CustomAppModulesMapper/releases/download/1.0.0/CustomAppModulesMapper-1.0.0.nvda-addon) -## How it works +## What is an app module association? -Some times, it is necessary to map a known application to a known NVDA app module. +To give an application accessible, app specific behavior, NVDA loads an **app module** for it — a small component that improves how that application is read and interacted with. NVDA decides which app module to load for a running application from its **executable name**: -This module might exist out of the box or be implemented by a third part addon. +- Most app modules are matched by having the **same name** as the executable (for example `notepad.exe` uses the `notepad` app module). +- Some applications are matched through a built-in **alias** (for example DBeaver, whose executable is `dbeaver.exe`, is associated with the `eclipse` app module, because DBeaver is based on Eclipse). +- App modules can also be provided by **other add-ons**. +- When nothing matches, NVDA uses a generic, empty app module, so the application gets **no** app specific behavior. -A good example is Dbeaver, which is based on Eclipse but has a different executable name. +This link between an application and the app module NVDA loads for it is its **association**. This add-on lets you control that association: associate an application with any available app module, associate it with **no** module, change an existing association, or remove your change and restore what NVDA used originally. -In case of Dbeaver, NVDA offers this mapping out of the box, so that DbEaver reuses the same functionality of Eclipse. +## Why would you use this add-on? -But this mapping could either not be provided or could be wairting for a merge which might take months to happen. +Sometimes the association you want does not exist out of the box: -As of now, the only way to provide a new mapping is either tu build a new addon exporting the mapping or to merge the mapping into NVDAs appModules package. +- A new application behaves like one NVDA already supports (for example an Eclipse or Electron/VS Code based tool with a different executable name), and you want it to reuse that app module. +- You want to try how a particular app module deals with an application, and switch between modules to compare. +- An application is associated with an app module that gets in your way, and you want to detach it (associate it with no module) so NVDA applies no app specific behavior. -This addon provides a way of performing dynamic mapping of apps to modules, so that: +Traditionally the only ways to change this are to build a dedicated add-on that exports the association, or to get it merged into NVDA's source — which can take months. This add-on lets you do it immediately, from the NVDA Settings dialog. Your associations are persisted between NVDA runs and are fully reversible. -1. If you need to map an app to a module, you can do it imediately without having to code an addon or wait for a merge on NVDA's source code. -2. You can have several modules providing functionality to an app and alternate between them, so that you can test how a given module would deal with thr app. +## Usage -### Features: +Associations are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom associations, each showing the application executable name and the module it is associated with (applications associated with no module are shown as `(not associated)`). -1. Map any executable to any provided app module, either a native NVDA module or a custom addon exposed module. -2. Custom mappings are persisted, so that you can keep them between NVDA runs. -3. At any time, remove a custom mapping and the original mapping will be restored. -4. You can manage custom mappings through NVDA setings dialog, in the Custom Applications module mapper category. +When you open the category, if the application you were in right before opening NVDA's Settings already has a custom association, its row is selected automatically, ready to disassociate or re-associate. + +Two actions are available: + +### Associate app + +Opens a dialog where you choose an application and the module to associate it with. + +- The **application** field is a combo box pre-filled with the applications you currently have open, with the app you were in right before opening NVDA's Settings selected. You can pick another open application or type any executable name by hand (so you can prepare an association for an application that is not currently running). +- The **module** list offers `(not associated)` first, then every available app module — both those bundled with NVDA and those provided by other add-ons. Choosing a real module associates the application with it; choosing `(not associated)` associates it with no module, so NVDA applies no app specific behavior to it. +- When you choose an application that already has a custom association, its current module is pre-selected, and choosing a **different** module **changes** the association (re-associates the application). The module pre-selection follows the application you choose. +- The confirm button stays disabled until you have chosen both an application and a module, and it will not let you re-select the association the application already has (which would do nothing). + +### Disassociate + +Removes the selected custom association, restoring the module the application originally used — that is, the module NVDA loaded before you made any change. This original is preserved even if you re-associated the application several times, so disassociating always returns it to its true original. The button is enabled only when a custom association is selected in the list. + +### Applying changes + +Changes take effect when you confirm the Settings dialog; the running app modules are reloaded so the new associations apply immediately. Application names are matched case-insensitively, the same way NVDA matches executables. + +## Features + +1. Associate any application with any available app module, whether it is bundled with NVDA or provided by another add-on. +2. Associate any application with no module (`(not associated)`), so it behaves as if no app module were present. +3. Change (re-associate) an existing association to a different module; the module restored on disassociation is always the true original, no matter how many times you re-associate. +4. The applications you have open are offered for selection, with the app you were last in pre-selected, so you rarely need to type executable names by hand; you can still type any executable, including one that is not running. +5. Disassociate an application at any time and its original module is restored. +6. Application names are handled case-insensitively, matching how NVDA matches executables. +7. Associations are persisted between NVDA runs, stored in the NVDA configuration directory so they survive updating or reinstalling the add-on, and protected against duplicates. +8. Everything is managed through the NVDA Settings dialog, in the Custom Application Module Mapper category. diff --git a/README.tpl.md b/README.tpl.md index a5b741b..6720e07 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -1,32 +1,63 @@ # CustomAppModulesMapper ${addon_version} -Nvda ADDON for allowing dynamic mapping of existing app modules for other applications. +NVDA add-on for dynamically associating applications with existing app modules — and for associating them with no module — without writing code. ## download Download the [CustomAppModulesMapper ${addon_version} addon](https://github.com/marlon-sousa/CustomAppModulesMapper/releases/download/${addon_version}/CustomAppModulesMapper-${addon_version}.nvda-addon) -## How it works +## What is an app module association? -Some times, it is necessary to map a known application to a known NVDA app module. +To give an application accessible, app specific behavior, NVDA loads an **app module** for it — a small component that improves how that application is read and interacted with. NVDA decides which app module to load for a running application from its **executable name**: -This module might exist out of the box or be implemented by a third part addon. +- Most app modules are matched by having the **same name** as the executable (for example `notepad.exe` uses the `notepad` app module). +- Some applications are matched through a built-in **alias** (for example DBeaver, whose executable is `dbeaver.exe`, is associated with the `eclipse` app module, because DBeaver is based on Eclipse). +- App modules can also be provided by **other add-ons**. +- When nothing matches, NVDA uses a generic, empty app module, so the application gets **no** app specific behavior. -A good example is Dbeaver, which is based on Eclipse but has a different executable name. +This link between an application and the app module NVDA loads for it is its **association**. This add-on lets you control that association: associate an application with any available app module, associate it with **no** module, change an existing association, or remove your change and restore what NVDA used originally. -In case of Dbeaver, NVDA offers this mapping out of the box, so that DbEaver reuses the same functionality of Eclipse. +## Why would you use this add-on? -But this mapping could either not be provided or could be wairting for a merge which might take months to happen. +Sometimes the association you want does not exist out of the box: -As of now, the only way to provide a new mapping is either tu build a new addon exporting the mapping or to merge the mapping into NVDAs appModules package. +- A new application behaves like one NVDA already supports (for example an Eclipse or Electron/VS Code based tool with a different executable name), and you want it to reuse that app module. +- You want to try how a particular app module deals with an application, and switch between modules to compare. +- An application is associated with an app module that gets in your way, and you want to detach it (associate it with no module) so NVDA applies no app specific behavior. -This addon provides a way of performing dynamic mapping of apps to modules, so that: +Traditionally the only ways to change this are to build a dedicated add-on that exports the association, or to get it merged into NVDA's source — which can take months. This add-on lets you do it immediately, from the NVDA Settings dialog. Your associations are persisted between NVDA runs and are fully reversible. -1. If you need to map an app to a module, you can do it imediately without having to code an addon or wait for a merge on NVDA's source code. -2. You can have several modules providing functionality to an app and alternate between them, so that you can test how a given module would deal with thr app. +## Usage -### Features: +Associations are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom associations, each showing the application executable name and the module it is associated with (applications associated with no module are shown as `(not associated)`). -1. Map any executable to any provided app module, either a native NVDA module or a custom addon exposed module. -2. Custom mappings are persisted, so that you can keep them between NVDA runs. -3. At any time, remove a custom mapping and the original mapping will be restored. -4. You can manage custom mappings through NVDA setings dialog, in the Custom Applications module mapper category. +When you open the category, if the application you were in right before opening NVDA's Settings already has a custom association, its row is selected automatically, ready to disassociate or re-associate. + +Two actions are available: + +### Associate app + +Opens a dialog where you choose an application and the module to associate it with. + +- The **application** field is a combo box pre-filled with the applications you currently have open, with the app you were in right before opening NVDA's Settings selected. You can pick another open application or type any executable name by hand (so you can prepare an association for an application that is not currently running). +- The **module** list offers `(not associated)` first, then every available app module — both those bundled with NVDA and those provided by other add-ons. Choosing a real module associates the application with it; choosing `(not associated)` associates it with no module, so NVDA applies no app specific behavior to it. +- When you choose an application that already has a custom association, its current module is pre-selected, and choosing a **different** module **changes** the association (re-associates the application). The module pre-selection follows the application you choose. +- The confirm button stays disabled until you have chosen both an application and a module, and it will not let you re-select the association the application already has (which would do nothing). + +### Disassociate + +Removes the selected custom association, restoring the module the application originally used — that is, the module NVDA loaded before you made any change. This original is preserved even if you re-associated the application several times, so disassociating always returns it to its true original. The button is enabled only when a custom association is selected in the list. + +### Applying changes + +Changes take effect when you confirm the Settings dialog; the running app modules are reloaded so the new associations apply immediately. Application names are matched case-insensitively, the same way NVDA matches executables. + +## Features + +1. Associate any application with any available app module, whether it is bundled with NVDA or provided by another add-on. +2. Associate any application with no module (`(not associated)`), so it behaves as if no app module were present. +3. Change (re-associate) an existing association to a different module; the module restored on disassociation is always the true original, no matter how many times you re-associate. +4. The applications you have open are offered for selection, with the app you were last in pre-selected, so you rarely need to type executable names by hand; you can still type any executable, including one that is not running. +5. Disassociate an application at any time and its original module is restored. +6. Application names are handled case-insensitively, matching how NVDA matches executables. +7. Associations are persisted between NVDA runs, stored in the NVDA configuration directory so they survive updating or reinstalling the add-on, and protected against duplicates. +8. Everything is managed through the NVDA Settings dialog, in the Custom Application Module Mapper category. diff --git a/addon/globalPlugins/CustomAppModulesMapper/__init__.py b/addon/globalPlugins/CustomAppModulesMapper/__init__.py index f7f888f..dea8f76 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/__init__.py +++ b/addon/globalPlugins/CustomAppModulesMapper/__init__.py @@ -9,6 +9,7 @@ import globalVars import gui import gui.settingsDialogs +from . import mapperHandler from .mapperHandler import loadCustomMappings from .guiHelper import CustomAppModuleMapperSettingPanel from logHandler import log @@ -29,6 +30,21 @@ def __init__(self, *args, **kwargs): loadCustomMappings() gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(CustomAppModuleMapperSettingPanel) + def event_gainFocus(self, obj, nextHandler): + # Remember the last real application the user focused so the settings panel can offer to act on + # it. gainFocus is used rather than foreground because NVDA seeds an initial gainFocus at startup + # and fires it on every focus change, so the stored value is populated early and stays fresh, + # whereas foreground is only fired when the frontmost window actually changes. NVDA raises this + # event for its own windows too (menus, dialogs, the Settings window this add-on's panel lives + # in); skipping "nvda" keeps the stored value pointing at the application the user came from. + try: + appModule = obj.appModule + if appModule is not None and appModule.appName and appModule.appName != "nvda": + mapperHandler.setLastForegroundApp(appModule.appName, appModule.appModuleName) + except Exception: + log.debugWarning("Could not record focused application", exc_info=True) + nextHandler() + def terminate(self): super(GlobalPlugin, self).terminate() if not globalVars.appArgs.secure: diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index 945ae39..4971210 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -19,6 +19,11 @@ addonHandler.initTranslation() +# Translators: shown in the module column for an application that has been deliberately detached from +# any app module (see the "Unassociate" action), instead of the internal notAssociated module name. +NOT_ASSOCIATED_LABEL = _("(not associated)") + + class CustomMappingAction(Enum): ADD = 1 IGNORE = 2 @@ -47,25 +52,49 @@ def makeSettings(self, settingsSizer): self.mappingsList.InsertColumn(0, _("App Name")) self.mappingsList.InsertColumn(1, _("Module Name")) actionsHelper = guiHelper.BoxSizerHelper(self, orientation=wx.HORIZONTAL) - # Translators: This is the button to check for new updates of the add-on. - self.addButton = actionsHelper.addItem(wx.Button(self, label=_("&Add mapping"))) - # Translators: This is the label for the IBMTTS folder address. - self.removeButton = actionsHelper.addItem(wx.Button(self, label=_("&Remove mapping"))) + # Translators: button that opens the dialog to associate an application with an app module + # (mapping it to a module, or detaching it by choosing "not associated"). + self.addButton = actionsHelper.addItem(wx.Button(self, label=_("&Associate app..."))) + # Translators: button that disassociates the selected application, removing its custom mapping + # and restoring the module it originally used. + self.removeButton = actionsHelper.addItem(wx.Button(self, label=_("&Disassociate"))) sHelper.addItem(actionsHelper) settingsSizer.Fit(self) self.bindEvents() self.buildMappingsList() + self.selectCurrentApp() + self.updateDisassociateButton() def bindEvents(self): self.addButton.Bind(wx.EVT_BUTTON, self.onAdd) self.removeButton.Bind(wx.EVT_BUTTON, self.onRemove) + # Disassociate acts on the selected mapping, so keep its enabled state in sync with the selection. + self.mappingsList.Bind(wx.EVT_LIST_ITEM_SELECTED, self.onListSelectionChanged) + self.mappingsList.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.onListSelectionChanged) + + def updateDisassociateButton(self): + # There is nothing to disassociate unless a mapping is selected. + self.removeButton.Enable(self.mappingsList.GetFirstSelected() != -1) + + def onListSelectionChanged(self, evt): + self.updateDisassociateButton() + evt.Skip() + + def selectCurrentApp(self): + # Convenience: when the application the user was last in already has a custom mapping, select it + # in the list on open, so it is immediately ready to remove or re-associate. + current = mapperHandler.getLastForegroundApp() + if current: + self.selectApp(current[0]) def buildMappingsList(self): customModulesMapping = mapperHandler.getCustomModulesMapping() + # Key the model by the lowercased executable name so lookups and duplicate detection are + # case-insensitive, matching how NVDA matches executables (it lowercases their names). self.mappings = reduce( lambda acc, item: acc.update({ - item.app: CustomMappingItem( - item.app, + item.app.lower(): CustomMappingItem( + item.app.lower(), item.appModule, item.appOriginalModule, CustomMappingAction.IGNORE @@ -87,30 +116,58 @@ def refreshList(self): sorted_keys = sorted(mappingModel.keys()) for key in sorted_keys: mapping = self.mappings[key] - self.mappingsList.Append((mapping.app, mapping.appModule)) + self.mappingsList.Append((mapping.app, self.moduleDisplayName(mapping.appModule))) + self.updateDisassociateButton() + + def moduleDisplayName(self, moduleName: str) -> str: + # Detached applications are stored as a mapping to the notAssociated sentinel module, but that + # internal name is meaningless to users, so show a friendly label for them instead. + if moduleName == mapperHandler.NOT_ASSOCIATED_MODULE: + return NOT_ASSOCIATED_LABEL + return moduleName def onAddDialogResumed(self, item: CustomMappingItem): self.mappings[item.app] = item self.refreshList() def onAdd(self, evt): - title = _("add mapping") + # Translators: title of the dialog used to associate an application with an app module. + title = _("Associate application") gui.mainFrame.prePopup() # Pass the mappings currently staged in this panel so the dialog can tell whether the app # the user types is already mapped (MODIFY) or new (ADD), and preserve its original module. - dialog = ModuleMappingDialog(self, title, self.mappings) + # Also pass the last real foreground application so the dialog can pre-fill it, saving the user + # from typing the exact executable name of the app they just came from. + dialog = ModuleMappingDialog(self, title, self.mappings, mapperHandler.getLastForegroundApp()) if dialog.ShowModal() == wx.ID_OK: self.onAddDialogResumed(dialog.result) dialog.Destroy() gui.mainFrame.postPopup() + def selectApp(self, app: str): + # Move selection/focus to the row for the given app if present, so it is announced and visible. + app = app.lower() + for index in range(self.mappingsList.GetItemCount()): + if self.mappingsList.GetItemText(index) == app: + self.mappingsList.Select(index) + self.mappingsList.Focus(index) + return + def onRemove(self, evt): selected = self.mappingsList.GetFirstSelected() if selected == -1: return - app = self.mappingsList.GetItemText(selected) + app = self.mappingsList.GetItemText(selected).lower() self.mappings[app].action = CustomMappingAction.REMOVE self.refreshList() + # Keep focus on the list instead of letting it jump to the dialog's OK button when the + # Disassociate button we were on becomes disabled. Select a neighbouring row when one remains. + count = self.mappingsList.GetItemCount() + if count: + neighbour = min(selected, count - 1) + self.mappingsList.Select(neighbour) + self.mappingsList.Focus(neighbour) + self.mappingsList.SetFocus() def onSave(self): mustRestart = False @@ -146,41 +203,143 @@ class ModuleMappingDialog( wx.Dialog, # wxPython does not seem to call base class initializer, put last in MRO ): - def __init__(self, parent, title, currentMappings): + def __init__(self, parent, title, currentMappings, prefill=None): super(ModuleMappingDialog, self).__init__(parent, title=title) self.result = None - # currentMappings maps an app name to the CustomMappingItem currently staged for it. + # currentMappings maps a lowercased app name to the CustomMappingItem currently staged for it. self.currentMappings = currentMappings + # prefill is an optional (executableName, currentModuleName) tuple describing the last real + # foreground application, used to pre-populate the fields. + self.prefill = prefill self.availableModules = mapperHandler.getAllAvailableAppModules() + # Modules offered to the user: the friendly "not associated" label first (so detaching an app is + # just another choice in this list, and is easy to reach), then the real modules alphabetically. + self.moduleChoices = [NOT_ASSOCIATED_LABEL] + self.availableModules mainSizer = wx.BoxSizer(wx.VERTICAL) sHelper = guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) - # Translators: label test for app field in add mapping dialog + # Translators: label for the application field in the associate application dialog. appLabelText = _("&App") - self.AppTextCtrl = sHelper.addLabeledControl(appLabelText, wx.TextCtrl) + # Editable combo: pre-populated with the applications currently open so the user can pick one + # without typing, while still being free to type an executable name that is not running. + appChoices = self.buildAppChoices() + self.appComboBox = sHelper.addLabeledControl(appLabelText, wx.ComboBox, choices=appChoices) - # Translators: label test for app module field in add mapping dialog + # Translators: label for the app module field in the associate application dialog. appModuleLabelText = _("App &module") appLabel = wx.StaticText(self, label=appModuleLabelText) - self.appModulesComboBox = wx.ComboBox(self, choices=self.availableModules, style=wx.CB_READONLY) + self.appModulesComboBox = wx.ComboBox(self, choices=self.moduleChoices, style=wx.CB_READONLY) sHelper.addItem(appLabel) sHelper.addItem(self.appModulesComboBox) sHelper.addDialogDismissButtons(wx.OK | wx.CANCEL, separated=True) + # CreateButtonSizer parents the OK button to the dialog, so it can be found and toggled directly. + self.okButton = self.FindWindow(wx.ID_OK) mainSizer.Add(sHelper.sizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL) mainSizer.Fit(self) self.SetSizer(mainSizer) - self.AppTextCtrl.SetFocus() + self.applyPrefill() + self.bindDialogEvents() + self.updateOkState() + self.appComboBox.SetFocus() + + def buildAppChoices(self): + apps = list(mapperHandler.getRunningApps()) + # Make sure the pre-filled application is offered even if NVDA is not currently tracking it. + if self.prefill and self.prefill[0] and self.prefill[0] not in apps: + apps.append(self.prefill[0]) + return sorted(apps) + + def moduleToDisplay(self, moduleName): + # Map an internal module name to what is shown in the combo (the detach sentinel is friendlier). + if moduleName == mapperHandler.NOT_ASSOCIATED_MODULE: + return NOT_ASSOCIATED_LABEL + return moduleName + + def displayToModule(self, display): + # Inverse of moduleToDisplay: turn the selected combo entry back into an internal module name. + if display == NOT_ASSOCIATED_LABEL: + return mapperHandler.NOT_ASSOCIATED_MODULE + return display + + def applyPrefill(self): + # Pre-populate the app field with the last real foreground application and preselect the module + # it currently uses (including "not associated" if it is already detached). + if not self.prefill: + return + self.appComboBox.SetValue(self.prefill[0]) + self.preselectModuleForApp() + + def preselectModuleForApp(self): + # Show the module the entered application is currently associated with, so a re-association + # starts from its current state (and the duplicate guard is visible). Clear the module when the + # application is unknown. Called whenever the app field changes, so switching apps mid-dialog + # keeps the module in sync with the chosen app rather than the one originally pre-filled. + currentModule = self.currentModuleFor(self.getEnteredApp()) + if currentModule is not None: + display = self.moduleToDisplay(currentModule) + if display in self.moduleChoices: + self.appModulesComboBox.SetValue(display) + return + self.appModulesComboBox.SetSelection(wx.NOT_FOUND) + + def bindDialogEvents(self): + # When the app changes, re-sync the module preselection to the chosen app; on any field change, + # re-evaluate whether OK should be enabled. + self.appComboBox.Bind(wx.EVT_TEXT, self.onAppChanged) + self.appComboBox.Bind(wx.EVT_COMBOBOX, self.onAppChanged) + self.appModulesComboBox.Bind(wx.EVT_COMBOBOX, self.onFieldChanged) self.Bind(wx.EVT_BUTTON, self.onOk, id=wx.ID_OK) + def onAppChanged(self, evt): + self.preselectModuleForApp() + self.updateOkState() + evt.Skip() + + def getEnteredApp(self): + # NVDA matches executables by their lowercased name, so normalise typed input the same way. + return self.appComboBox.GetValue().strip().lower() + + def getSelectedModule(self): + display = self.appModulesComboBox.GetValue() + if not display: + return None + return self.displayToModule(display) + + def currentModuleFor(self, app): + # The module the app is already associated with, if known: either a mapping already staged in the + # panel, or (for the pre-filled current app) the module it is running under. Used to reject a + # selection that would only re-create the association the app already has. + item = self.currentMappings.get(app) + if item is not None and item.action != CustomMappingAction.REMOVE: + return item.appModule + if self.prefill and app == self.prefill[0]: + return self.prefill[1] + return None + + def isDuplicate(self, app, module): + return module is not None and module == self.currentModuleFor(app) + + def updateOkState(self): + if self.okButton is None: + return + app = self.getEnteredApp() + module = self.getSelectedModule() + # OK is available only when an app is given, a module is chosen, and the pair is not a duplicate + # of what the app already uses (which would be a no-op and could add a duplicate entry). + self.okButton.Enable(bool(app) and module is not None and not self.isDuplicate(app, module)) + + def onFieldChanged(self, evt): + self.updateOkState() + evt.Skip() + def onOk(self, evt): - app = self.AppTextCtrl.GetValue() - appModule = self.appModulesComboBox.GetValue() - if not app or not appModule: - wx.MessageBox(_("Please fill all fields"), _("Error"), wx.OK | wx.ICON_ERROR) + app = self.getEnteredApp() + module = self.getSelectedModule() + # OK is disabled in these cases; the guard is purely defensive. + if not app or module is None or self.isDuplicate(app, module): return - originalMapping = mapperHandler.getAllConfiguredMappings() if app in self.currentMappings: # The app is already mapped: keep the module NVDA had before any custom mapping so that # removing the mapping later restores the correct original. @@ -188,6 +347,6 @@ def onOk(self, evt): originalModule = self.currentMappings[app].appOriginalModule else: action = CustomMappingAction.ADD - originalModule = originalMapping.get(app, None) - self.result = CustomMappingItem(app, appModule, originalModule, action) + originalModule = mapperHandler.getAllConfiguredMappings().get(app, None) + self.result = CustomMappingItem(app, module, originalModule, action) evt.Skip() diff --git a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py index ac5ef9f..e20323c 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -7,14 +7,29 @@ import os import pickle import shutil +import ctypes +from ctypes import wintypes import addonHandler import appModules import appModuleHandler import globalVars import pkgutil +import winUser from logHandler import log from dataclasses import dataclass -from typing import List +from typing import List, Optional, Tuple + + +# Name of the deliberately empty app module shipped with this add-on (see appModules/notAssociated.py). +# Mapping an application to this module detaches it from whatever module NVDA would otherwise load. +# It is a sentinel rather than a real mapping target, so it is hidden from the module list offered in +# the GUI and instead reached through the dedicated "unassociate" action. +NOT_ASSOCIATED_MODULE = "notAssociated" + +# On-disk format version of the mappings pickle. Bump this whenever the stored structure changes, so +# older files can be recognised and migrated. Version 0 is the original bare list of Mapping objects +# written before 1.0; version 1 wraps that list in a dict carrying this version number. +PICKLE_FORMAT_VERSION = 1 @dataclass @@ -26,6 +41,23 @@ class Mapping: customModulesMapping: List[Mapping] +# Executable name and current module of the last real (non NVDA) application that had the focus. The +# settings panel lives inside NVDA's own Settings window, so by the time it is built the focused +# application is NVDA itself. The global plugin keeps this up to date via event_gainFocus so the panel +# can pre-fill the associate dialog with, and select the row for, the application the user was in right +# before opening Settings. +_lastForegroundApp: Optional[Tuple[str, str]] = None + + +def setLastForegroundApp(appName: str, moduleName: str): + global _lastForegroundApp + _lastForegroundApp = (appName, moduleName) + + +def getLastForegroundApp() -> Optional[Tuple[str, str]]: + # Returns a (executableName, currentModuleName) tuple, or None if no real application has been seen. + return _lastForegroundApp + def getCustomModulesMapping(): global customModulesMapping @@ -40,13 +72,16 @@ def filterUnmappedModules(modName): # These are generic host/internal modules that are not meaningful mapping targets: # nvda is NVDA itself, and the others are shared hosts (Java, the Edge WebView, the modern # touch keyboard, etc.) that back many unrelated apps, so offering them as targets would be - # misleading. They are hidden from the list of available modules. + # misleading. notAssociated is this add-on's detach sentinel, reached through the dedicated + # "unassociate" action instead of by picking it as a module. They are all hidden from the list + # of available modules. return modName not in ( 'javaw', 'messengerWindow', 'msgComposeWindow', 'msedgewebview2', 'nvda', + NOT_ASSOCIATED_MODULE, 'windowsinternal_composableshell_experiences_textinput_inputapp', ) @@ -64,6 +99,48 @@ def getUnmappedModules() -> List[str]: return modules +def getRunningApps() -> List[str]: + # Executable names of the applications the user currently has open, used to populate the app picker + # so mappings can be chosen without typing. appModuleHandler.runningTable is not enough on its own: + # it only lists apps NVDA has already built an app module for this session, which misses many open + # apps. So enumerate the processes owning a visible, titled, top-level window (what the user can + # actually switch to) and merge in whatever NVDA is already tracking. Names come from the helper + # NVDA uses to match executables, so they are lowercased and can be compared and stored as-is. + processIDs = set() + + @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + def collect(hwnd, _lParam): + try: + if winUser.isWindowVisible(hwnd) and winUser.getWindowText(hwnd): + processIDs.add(winUser.getWindowThreadProcessID(hwnd)[0]) + except Exception: + pass + return True + + try: + ctypes.windll.user32.EnumWindows(collect, 0) + except Exception: + log.debugWarning("Could not enumerate top-level windows", exc_info=True) + + apps = set() + for processID in processIDs: + try: + appName = appModuleHandler.getAppNameFromProcessID(processID, includeExt=False) + except Exception: + continue + if appName: + apps.add(appName) + for mod in list(appModuleHandler.runningTable.values()): + try: + if mod.appName: + apps.add(mod.appName) + except Exception: + continue + # NVDA itself is never a meaningful mapping target. + apps.discard("nvda") + return sorted(apps) + + def getAllAvailableAppModules() -> List[str]: # The full list of modules a user can map an app to is the union of every module physically # present in the appModules package and the targets of the currently configured aliases, @@ -120,18 +197,40 @@ def migrateLegacyMappingsFile(): log.error(f"Could not migrate custom mappings file: {e}") +def dedupeMappings(mappings: List[Mapping]) -> List[Mapping]: + # Guarantee at most one mapping per application. Executables are matched by their lowercased name, + # so applications are compared case-insensitively; later entries win, keeping the most recent choice. + # This protects the persisted file from ever holding duplicates, even if a caller passes some in. + byApp = {} + for mapping in mappings: + byApp[mapping.app.lower()] = mapping + return list(byApp.values()) + + def persist(): + global customModulesMapping + customModulesMapping = dedupeMappings(customModulesMapping) with open(getCustomMappingsFilePath(), "wb") as f: - pickle.dump(customModulesMapping, f) + pickle.dump({"formatVersion": PICKLE_FORMAT_VERSION, "mappings": customModulesMapping}, f) log.info("Custom mappings saved to file") +def readMappingsFile(path) -> List[Mapping]: + # Returns the list of Mapping objects stored at path, understanding both the current versioned + # format (a dict with "formatVersion" and "mappings") and the original bare list of Mapping objects + # written before 1.0 (treated as format version 0). Future format changes branch on the version here. + with open(path, "rb") as f: + data = pickle.load(f) + if isinstance(data, dict): + return data.get("mappings", []) + return data + + def loadCustomMappings(): global customModulesMapping migrateLegacyMappingsFile() try: - with open(getCustomMappingsFilePath(), "rb") as f: - customModulesMapping = pickle.load(f) + customModulesMapping = dedupeMappings(readMappingsFile(getCustomMappingsFilePath())) log.info("Custom mappings loaded from file") mustRestart = False for mapping in customModulesMapping: diff --git a/buildVars.py b/buildVars.py index 5f646b4..ccb48cd 100644 --- a/buildVars.py +++ b/buildVars.py @@ -12,13 +12,14 @@ # Translators: Long description for this add-on in add-on store. addon_description=_("""Lets you dynamically map any application to any existing NVDA app module, straight from the NVDA settings dialog, without writing an add-on or waiting for a mapping to be merged into NVDA."""), # Translators: what's new text for this add-on version shown in add-on store. - addon_changelog=_("""Version 0.3.0: -* Compatible with NVDA 2026.1. -* The list of app modules you can map an application to now includes every module bundled with NVDA and every module provided by other add-ons, instead of only the handful that already had an alias. -* Custom mappings are now stored in the NVDA configuration directory, so they survive updating or reinstalling the add-on. Mappings saved by previous versions are migrated automatically. -* Fixed editing an existing mapping so that removing it afterwards restores the correct original module. -* Modernized the add-on build system and project tooling."""), - addon_version="0.3.0", + addon_changelog=_("""Version 1.0.0: +* Associate any application with any app module — one bundled with NVDA or provided by another add-on — or with no module ("not associated"), straight from the NVDA Settings dialog, without writing an add-on or waiting for a mapping to be merged into NVDA. +* Change an association at any time, or remove it to restore the module the application originally used. +* The applications you currently have open are offered for selection, with the app you were last in pre-selected; you can still type any executable name. +* Application names are matched case-insensitively. +* Associations are stored in the NVDA configuration directory, so they survive updating or reinstalling the add-on, and are protected against duplicates. Mappings saved by previous versions are migrated automatically. +* Compatible with NVDA 2026.1."""), + addon_version="1.0.0", addon_author="Marlon Brandão de Sousa ", addon_url="https://github.com/marlon-sousa/CustomAppModulesMapper", addon_sourceURL="https://github.com/marlon-sousa/CustomAppModulesMapper",