From 72f03ff0b660ec41a0f218f50807dee09aef102b Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 10:48:49 -0300 Subject: [PATCH 01/10] Add unassociate action and current-app prefill Detach an application from any app module directly from the settings panel, and pre-detect the application the user was in when opening NVDA Settings so they no longer need to type executable names by hand. - Track the last real (non-NVDA) foreground application via event_foreground in the global plugin, skipping NVDA's own windows so the stored value keeps pointing at the app the user came from. - Hide the notAssociated sentinel from the module list; it is now reached only through the dedicated unassociate action. - Add an "Unassociate current app" button that detaches the current app, and show detached apps in the list as "(not associated)". - Pre-fill the Add mapping dialog with the current app and its current module. - Update README to document detaching and current-app detection, and fix long-standing typos. Co-Authored-By: Claude Opus 4.8 --- README.md | 43 ++++++---- README.tpl.md | 43 ++++++---- .../CustomAppModulesMapper/__init__.py | 14 ++++ .../CustomAppModulesMapper/guiHelper.py | 83 +++++++++++++++++-- .../CustomAppModulesMapper/mapperHandler.py | 30 ++++++- 5 files changed, 176 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ebe8916..fa58f3c 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,45 @@ # CustomAppModulesMapper 0.3.0 -Nvda ADDON for allowing dynamic mapping of existing app modules for other applications. +NVDA add-on for dynamically mapping applications to existing app modules, and for detaching applications from their app 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) ## How it works -Some times, it is necessary to map a known application to a known NVDA app module. +Sometimes it is necessary to map a known application to a known NVDA app module. -This module might exist out of the box or be implemented by a third part addon. +The module might ship with NVDA out of the box or be provided by a third party add-on. -A good example is Dbeaver, which is based on Eclipse but has a different executable name. +A good example is DBeaver, which is based on Eclipse but has a different executable name. NVDA provides this mapping out of the box, so that DBeaver reuses the same functionality as Eclipse. -In case of Dbeaver, NVDA offers this mapping out of the box, so that DbEaver reuses the same functionality of Eclipse. +But such a mapping might not exist yet, or might be waiting for a merge that could take months to happen. -But this mapping could either not be provided or could be wairting for a merge which might take months to happen. +Traditionally, the only ways to provide a new mapping are to build a dedicated add-on that exports it, or to merge the mapping into NVDA's appModules package. -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. +Conversely, an application may already be picked up by an app module you would rather it did not use, and there is no built-in way to detach it. -This addon provides a way of performing dynamic mapping of apps to modules, so that: +This add-on lets you do both dynamically, straight from the NVDA Settings dialog, so that: -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. +1. If you need to map an app to a module, you can do it immediately, without coding an add-on or waiting for a merge into NVDA's source code. +2. You can have several modules providing functionality to an app and alternate between them, to test how a given module deals with the app. +3. If an app is associated with a module you do not want, you can detach it so that NVDA applies no app specific behavior to it. -### Features: +## Usage -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. +Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is mapped to (detached applications are shown as `(not associated)`), with the following actions: + +- **Add mapping**: opens a dialog where you choose an application and the module to map it to. The dialog is pre-filled with the application you were in right before opening NVDA's Settings, and with the module that application currently uses, so in the common case you only need to change the module. You can still type any executable name manually. +- **Unassociate current app**: detaches the application you were in right before opening NVDA's Settings from any app module, so NVDA applies no app specific behavior to it. This is the reliable way to detach the app you are working with: focus it, open NVDA's Settings, and press this button. +- **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. + +Changes take effect when you confirm the Settings dialog. + +### Features + +1. Map any executable to any available app module, whether it is a native NVDA module or one exposed by another add-on. +2. Detach any application from its app module, so it behaves as if no app module were present. +3. The application to add or unassociate is detected automatically from the app you were last in, so you rarely need to type executable names by hand. +4. Custom mappings are persisted, so they are kept between NVDA runs. +5. At any time, remove a custom mapping and the original mapping is restored. +6. 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..b4e38cb 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -1,32 +1,45 @@ # CustomAppModulesMapper ${addon_version} -Nvda ADDON for allowing dynamic mapping of existing app modules for other applications. +NVDA add-on for dynamically mapping applications to existing app modules, and for detaching applications from their app 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 -Some times, it is necessary to map a known application to a known NVDA app module. +Sometimes it is necessary to map a known application to a known NVDA app module. -This module might exist out of the box or be implemented by a third part addon. +The module might ship with NVDA out of the box or be provided by a third party add-on. -A good example is Dbeaver, which is based on Eclipse but has a different executable name. +A good example is DBeaver, which is based on Eclipse but has a different executable name. NVDA provides this mapping out of the box, so that DBeaver reuses the same functionality as Eclipse. -In case of Dbeaver, NVDA offers this mapping out of the box, so that DbEaver reuses the same functionality of Eclipse. +But such a mapping might not exist yet, or might be waiting for a merge that could take months to happen. -But this mapping could either not be provided or could be wairting for a merge which might take months to happen. +Traditionally, the only ways to provide a new mapping are to build a dedicated add-on that exports it, or to merge the mapping into NVDA's appModules package. -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. +Conversely, an application may already be picked up by an app module you would rather it did not use, and there is no built-in way to detach it. -This addon provides a way of performing dynamic mapping of apps to modules, so that: +This add-on lets you do both dynamically, straight from the NVDA Settings dialog, so that: -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. +1. If you need to map an app to a module, you can do it immediately, without coding an add-on or waiting for a merge into NVDA's source code. +2. You can have several modules providing functionality to an app and alternate between them, to test how a given module deals with the app. +3. If an app is associated with a module you do not want, you can detach it so that NVDA applies no app specific behavior to it. -### Features: +## Usage -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. +Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is mapped to (detached applications are shown as `(not associated)`), with the following actions: + +- **Add mapping**: opens a dialog where you choose an application and the module to map it to. The dialog is pre-filled with the application you were in right before opening NVDA's Settings, and with the module that application currently uses, so in the common case you only need to change the module. You can still type any executable name manually. +- **Unassociate current app**: detaches the application you were in right before opening NVDA's Settings from any app module, so NVDA applies no app specific behavior to it. This is the reliable way to detach the app you are working with: focus it, open NVDA's Settings, and press this button. +- **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. + +Changes take effect when you confirm the Settings dialog. + +### Features + +1. Map any executable to any available app module, whether it is a native NVDA module or one exposed by another add-on. +2. Detach any application from its app module, so it behaves as if no app module were present. +3. The application to add or unassociate is detected automatically from the app you were last in, so you rarely need to type executable names by hand. +4. Custom mappings are persisted, so they are kept between NVDA runs. +5. At any time, remove a custom mapping and the original mapping is restored. +6. 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..668b4ed 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,19 @@ def __init__(self, *args, **kwargs): loadCustomMappings() gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(CustomAppModuleMapperSettingPanel) + def event_foreground(self, obj, nextHandler): + # Remember the last real application that had the foreground so the settings panel can offer to + # act on it. 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 actual + # application the user was in before opening NVDA's own UI. + 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 foreground 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..242701f 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,9 +52,12 @@ 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. + # Translators: button that opens the dialog to add a new custom mapping. self.addButton = actionsHelper.addItem(wx.Button(self, label=_("&Add mapping"))) - # Translators: This is the label for the IBMTTS folder address. + # Translators: button that detaches the application currently in the foreground from any app + # module, so NVDA applies no application specific behaviour to it. + self.unassociateButton = actionsHelper.addItem(wx.Button(self, label=_("&Unassociate current app"))) + # Translators: button that removes the selected custom mapping and restores the original module. self.removeButton = actionsHelper.addItem(wx.Button(self, label=_("&Remove mapping"))) sHelper.addItem(actionsHelper) settingsSizer.Fit(self) @@ -58,6 +66,7 @@ def makeSettings(self, settingsSizer): def bindEvents(self): self.addButton.Bind(wx.EVT_BUTTON, self.onAdd) + self.unassociateButton.Bind(wx.EVT_BUTTON, self.onUnassociate) self.removeButton.Bind(wx.EVT_BUTTON, self.onRemove) def buildMappingsList(self): @@ -87,7 +96,14 @@ 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))) + + 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 @@ -98,12 +114,53 @@ def onAdd(self, evt): 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 stageMapping(self, app: str, moduleName: str): + # Stage (but do not yet apply) a mapping of app -> moduleName, reusing the existing entry when + # the app is already staged so its original module and ADD/MODIFY state are preserved. Detaching + # an app is simply mapping it to the notAssociated sentinel, so this covers both add and detach. + if app in self.mappings: + item = self.mappings[app] + item.appModule = moduleName + if item.action != CustomMappingAction.ADD: + # A previously persisted (IGNORE) or removed (REMOVE) mapping becomes a modification. + item.action = CustomMappingAction.MODIFY + else: + originalModule = mapperHandler.getAllConfiguredMappings().get(app, None) + self.mappings[app] = CustomMappingItem(app, moduleName, originalModule, CustomMappingAction.ADD) + self.refreshList() + self.selectApp(app) + + def selectApp(self, app: str): + # Move selection/focus to the row for the given app so the change is announced and visible. + for index in range(self.mappingsList.GetItemCount()): + if self.mappingsList.GetItemText(index) == app: + self.mappingsList.Select(index) + self.mappingsList.Focus(index) + return + + def onUnassociate(self, evt): + current = mapperHandler.getLastForegroundApp() + if not current: + wx.MessageBox( + # Translators: shown when the user asks to unassociate the current app but no real + # application was in the foreground before NVDA's settings were opened. + _("There is no application to unassociate. Focus the application you want to detach, then reopen this dialog."), # noqa E501 + # Translators: title of the message shown when there is no application to unassociate. + _("Unassociate current app"), + wx.OK | wx.ICON_INFORMATION, + ) + return + app, _currentModule = current + self.stageMapping(app, mapperHandler.NOT_ASSOCIATED_MODULE) + def onRemove(self, evt): selected = self.mappingsList.GetFirstSelected() if selected == -1: @@ -146,11 +203,14 @@ 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. 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() mainSizer = wx.BoxSizer(wx.VERTICAL) sHelper = guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) @@ -171,9 +231,22 @@ def __init__(self, parent, title, currentMappings): mainSizer.Add(sHelper.sizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL) mainSizer.Fit(self) self.SetSizer(mainSizer) + self.applyPrefill() self.AppTextCtrl.SetFocus() + self.AppTextCtrl.SelectAll() self.Bind(wx.EVT_BUTTON, self.onOk, id=wx.ID_OK) + def applyPrefill(self): + # Pre-populate the app field with the last real foreground application and, when that app + # currently resolves to a module offered in the list, preselect it. The detach sentinel is not + # in the list, so a currently detached app simply leaves the module unselected. + if not self.prefill: + return + appName, currentModule = self.prefill + self.AppTextCtrl.SetValue(appName) + if currentModule in self.availableModules: + self.appModulesComboBox.SetValue(currentModule) + def onOk(self, evt): app = self.AppTextCtrl.GetValue() appModule = self.appModulesComboBox.GetValue() diff --git a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py index ac5ef9f..c388b6c 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -14,7 +14,14 @@ import pkgutil 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" @dataclass @@ -26,6 +33,22 @@ class Mapping: customModulesMapping: List[Mapping] +# Executable name and current module of the last real (non NVDA) application that had the foreground. +# 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_foreground so the panel +# can still offer to act on 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 +63,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', ) From 9b10c4d58d015537d8960968d7b4055b7d212864 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 11:34:31 -0300 Subject: [PATCH 02/10] Rework associate/unassociate flow with app picker and dedup Treat unassociation as an association to the notAssociated module, and make both reachable from one dialog while adding safeguards. - Associate dialog: the app field is now an editable combo pre-populated with the currently open applications (from appModuleHandler.runningTable), the last-focused app preselected; the module list offers "(not associated)" first, so detaching is just another choice and can be typed by hand. - OK is enabled only when both an app and a module are chosen and the pair is not the association the app already has (no duplicate/no-op). - Unassociate current app now confirms, naming the app, before staging. - Application names are handled case-insensitively (NVDA lowercases executables); the panel model is keyed by lowercased name. - Protect the persisted pickle from duplicates via dedupeMappings on both load and save (case-insensitive, later entry wins). - Track the last focused app via event_gainFocus instead of event_foreground so it is populated at startup and stays fresh; the unassociate button is disabled until an app has been seen. - README updated to describe the new flow, case-insensitivity and dedup. Co-Authored-By: Claude Opus 4.8 --- README.md | 15 +- README.tpl.md | 15 +- .../CustomAppModulesMapper/__init__.py | 14 +- .../CustomAppModulesMapper/guiHelper.py | 159 +++++++++++++----- .../CustomAppModulesMapper/mapperHandler.py | 31 +++- 5 files changed, 177 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index fa58f3c..30ccfcc 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,20 @@ This add-on lets you do both dynamically, straight from the NVDA Settings dialog Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is mapped to (detached applications are shown as `(not associated)`), with the following actions: -- **Add mapping**: opens a dialog where you choose an application and the module to map it to. The dialog is pre-filled with the application you were in right before opening NVDA's Settings, and with the module that application currently uses, so in the common case you only need to change the module. You can still type any executable name manually. -- **Unassociate current app**: detaches the application you were in right before opening NVDA's Settings from any app module, so NVDA applies no app specific behavior to it. This is the reliable way to detach the app you are working with: focus it, open NVDA's Settings, and press this button. +- **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 app or type any executable name by hand. + - The **module** list offers `(not associated)` first, then every available module. Choosing a real module maps the app to it; choosing `(not associated)` detaches the app from any app module. This is how you associate, re-map, or unassociate any application, including one that is not currently running. + - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. +- **Unassociate current app**: a shortcut that detaches the application you were in right before opening NVDA's Settings. It asks for confirmation, naming the application, then detaches it. It is disabled when no application has been focused yet. - **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. -Changes take effect when you confirm the Settings dialog. +Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. ### Features -1. Map any executable to any available app module, whether it is a native NVDA module or one exposed by another add-on. +1. Associate any executable with any available app module, whether it is a native NVDA module or one exposed by another add-on. 2. Detach any application from its app module, so it behaves as if no app module were present. -3. The application to add or unassociate is detected automatically from the app you were last in, so you rarely need to type executable names by hand. -4. Custom mappings are persisted, so they are kept between NVDA runs. +3. 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. +4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. 5. At any time, remove a custom mapping and the original mapping is restored. 6. 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 b4e38cb..0a37e50 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -29,17 +29,20 @@ This add-on lets you do both dynamically, straight from the NVDA Settings dialog Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is mapped to (detached applications are shown as `(not associated)`), with the following actions: -- **Add mapping**: opens a dialog where you choose an application and the module to map it to. The dialog is pre-filled with the application you were in right before opening NVDA's Settings, and with the module that application currently uses, so in the common case you only need to change the module. You can still type any executable name manually. -- **Unassociate current app**: detaches the application you were in right before opening NVDA's Settings from any app module, so NVDA applies no app specific behavior to it. This is the reliable way to detach the app you are working with: focus it, open NVDA's Settings, and press this button. +- **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 app or type any executable name by hand. + - The **module** list offers `(not associated)` first, then every available module. Choosing a real module maps the app to it; choosing `(not associated)` detaches the app from any app module. This is how you associate, re-map, or unassociate any application, including one that is not currently running. + - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. +- **Unassociate current app**: a shortcut that detaches the application you were in right before opening NVDA's Settings. It asks for confirmation, naming the application, then detaches it. It is disabled when no application has been focused yet. - **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. -Changes take effect when you confirm the Settings dialog. +Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. ### Features -1. Map any executable to any available app module, whether it is a native NVDA module or one exposed by another add-on. +1. Associate any executable with any available app module, whether it is a native NVDA module or one exposed by another add-on. 2. Detach any application from its app module, so it behaves as if no app module were present. -3. The application to add or unassociate is detected automatically from the app you were last in, so you rarely need to type executable names by hand. -4. Custom mappings are persisted, so they are kept between NVDA runs. +3. 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. +4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. 5. At any time, remove a custom mapping and the original mapping is restored. 6. 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 668b4ed..dea8f76 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/__init__.py +++ b/addon/globalPlugins/CustomAppModulesMapper/__init__.py @@ -30,17 +30,19 @@ def __init__(self, *args, **kwargs): loadCustomMappings() gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(CustomAppModuleMapperSettingPanel) - def event_foreground(self, obj, nextHandler): - # Remember the last real application that had the foreground so the settings panel can offer to - # act on it. 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 actual - # application the user was in before opening NVDA's own UI. + 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 foreground application", exc_info=True) + log.debugWarning("Could not record focused application", exc_info=True) nextHandler() def terminate(self): diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index 242701f..93fda1f 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -52,15 +52,21 @@ def makeSettings(self, settingsSizer): self.mappingsList.InsertColumn(0, _("App Name")) self.mappingsList.InsertColumn(1, _("Module Name")) actionsHelper = guiHelper.BoxSizerHelper(self, orientation=wx.HORIZONTAL) - # Translators: button that opens the dialog to add a new custom mapping. - self.addButton = actionsHelper.addItem(wx.Button(self, label=_("&Add mapping"))) - # Translators: button that detaches the application currently in the foreground from any app - # module, so NVDA applies no application specific behaviour to it. + # 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 detaches the application the user was last in from any app module, + # so NVDA applies no application specific behaviour to it. self.unassociateButton = actionsHelper.addItem(wx.Button(self, label=_("&Unassociate current app"))) # Translators: button that removes the selected custom mapping and restores the original module. self.removeButton = actionsHelper.addItem(wx.Button(self, label=_("&Remove mapping"))) sHelper.addItem(actionsHelper) settingsSizer.Fit(self) + # Detaching acts on the last focused application. If none has been seen yet (e.g. NVDA was just + # restarted and no application has been focused since), there is nothing to unassociate, so the + # button is disabled. This state is stable while the panel is open because focus stays within + # NVDA, whose events are ignored by the tracker. + self.unassociateButton.Enable(mapperHandler.getLastForegroundApp() is not None) self.bindEvents() self.buildMappingsList() @@ -71,10 +77,12 @@ def bindEvents(self): 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 @@ -110,7 +118,8 @@ def onAddDialogResumed(self, item: CustomMappingItem): 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. @@ -126,6 +135,7 @@ def stageMapping(self, app: str, moduleName: str): # Stage (but do not yet apply) a mapping of app -> moduleName, reusing the existing entry when # the app is already staged so its original module and ADD/MODIFY state are preserved. Detaching # an app is simply mapping it to the notAssociated sentinel, so this covers both add and detach. + app = app.lower() if app in self.mappings: item = self.mappings[app] item.appModule = moduleName @@ -147,25 +157,28 @@ def selectApp(self, app: str): return def onUnassociate(self, evt): + # The button is disabled when there is no last focused application, so current is normally set; + # the guard is kept purely defensively. current = mapperHandler.getLastForegroundApp() if not current: - wx.MessageBox( - # Translators: shown when the user asks to unassociate the current app but no real - # application was in the foreground before NVDA's settings were opened. - _("There is no application to unassociate. Focus the application you want to detach, then reopen this dialog."), # noqa E501 - # Translators: title of the message shown when there is no application to unassociate. - _("Unassociate current app"), - wx.OK | wx.ICON_INFORMATION, - ) return app, _currentModule = current - self.stageMapping(app, mapperHandler.NOT_ASSOCIATED_MODULE) + confirmed = wx.MessageBox( + # Translators: confirmation shown before detaching the current application from its app + # module. {app} is the application executable name. + _("Unassociate {app} from any app module?").format(app=app), + # Translators: title of the confirmation shown before unassociating the current application. + _("Unassociate current app"), + wx.YES_NO | wx.ICON_QUESTION, + ) + if confirmed == wx.YES: + self.stageMapping(app, mapperHandler.NOT_ASSOCIATED_MODULE) 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() @@ -206,54 +219,124 @@ class ModuleMappingDialog( 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.applyPrefill() - self.AppTextCtrl.SetFocus() - self.AppTextCtrl.SelectAll() - self.Bind(wx.EVT_BUTTON, self.onOk, id=wx.ID_OK) + 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, when that app - # currently resolves to a module offered in the list, preselect it. The detach sentinel is not - # in the list, so a currently detached app simply leaves the module unselected. + # 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 appName, currentModule = self.prefill - self.AppTextCtrl.SetValue(appName) - if currentModule in self.availableModules: - self.appModulesComboBox.SetValue(currentModule) + self.appComboBox.SetValue(appName) + display = self.moduleToDisplay(currentModule) + if display in self.moduleChoices: + self.appModulesComboBox.SetValue(display) + + def bindDialogEvents(self): + # Re-evaluate whether OK should be enabled whenever either field changes. + self.appComboBox.Bind(wx.EVT_TEXT, self.onFieldChanged) + self.appComboBox.Bind(wx.EVT_COMBOBOX, self.onFieldChanged) + self.appModulesComboBox.Bind(wx.EVT_COMBOBOX, self.onFieldChanged) + self.Bind(wx.EVT_BUTTON, self.onOk, id=wx.ID_OK) + + 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. @@ -261,6 +344,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 c388b6c..7a998cc 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -90,6 +90,23 @@ def getUnmappedModules() -> List[str]: return modules +def getRunningApps() -> List[str]: + # Executable names of the applications NVDA currently has an app module for, i.e. the applications + # the user has open this session. Used to populate the app picker so mappings can be chosen without + # typing. Names are already lowercased by NVDA (see appModuleHandler.getAppNameFromProcessID), which + # matches how executables are matched, so they can be compared and stored as-is. NVDA itself is + # excluded because mapping NVDA is never meaningful. + apps = set() + for mod in list(appModuleHandler.runningTable.values()): + try: + appName = mod.appName + except Exception: + continue + if appName and appName != "nvda": + apps.add(appName) + 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, @@ -146,7 +163,19 @@ 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) log.info("Custom mappings saved to file") @@ -157,7 +186,7 @@ def loadCustomMappings(): migrateLegacyMappingsFile() try: with open(getCustomMappingsFilePath(), "rb") as f: - customModulesMapping = pickle.load(f) + customModulesMapping = dedupeMappings(pickle.load(f)) log.info("Custom mappings loaded from file") mustRestart = False for mapping in customModulesMapping: From 0462bef295383898bc1ea3353731a8210e27d248 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 13:22:40 -0300 Subject: [PATCH 03/10] Make Unassociate button selection-aware The unassociate action was hard-wired to the foreground app, so an already-associated app selected in the list could not be detached. - Unassociate now targets the selected mapping when one is selected, falling back to the last focused app otherwise; the button label updates to name what it will act on. - Enable matrix: a selected mapping is detachable unless already detached; the current-app fallback is enabled for a custom or an internally associated app, but disabled when the app has no app module at all (nothing to unassociate) or is already detached. - Tracker records whether the focused app had a specific module (type(appModule) is not the base AppModule) to drive that decision. Co-Authored-By: Claude Opus 4.8 --- .../CustomAppModulesMapper/__init__.py | 12 ++- .../CustomAppModulesMapper/guiHelper.py | 90 +++++++++++++++---- .../CustomAppModulesMapper/mapperHandler.py | 21 +++-- 3 files changed, 96 insertions(+), 27 deletions(-) diff --git a/addon/globalPlugins/CustomAppModulesMapper/__init__.py b/addon/globalPlugins/CustomAppModulesMapper/__init__.py index dea8f76..390a3f2 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/__init__.py +++ b/addon/globalPlugins/CustomAppModulesMapper/__init__.py @@ -5,6 +5,7 @@ # See the file COPYING.txt for more details. import addonHandler +import appModuleHandler import globalPluginHandler import globalVars import gui @@ -40,7 +41,16 @@ def event_gainFocus(self, obj, nextHandler): try: appModule = obj.appModule if appModule is not None and appModule.appName and appModule.appName != "nvda": - mapperHandler.setLastForegroundApp(appModule.appName, appModule.appModuleName) + # When no specific module could be imported for the executable, NVDA falls back to the + # generic base AppModule (appModuleHandler.AppModule itself). Recording whether the app + # had a specific module lets the panel disable "unassociate" for an app that has no app + # specific behaviour to remove in the first place. + hasSpecificModule = type(appModule) is not appModuleHandler.AppModule + mapperHandler.setLastForegroundApp( + appModule.appName, + appModule.appModuleName, + hasSpecificModule, + ) except Exception: log.debugWarning("Could not record focused application", exc_info=True) nextHandler() diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index 93fda1f..769bbc0 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -55,18 +55,14 @@ def makeSettings(self, settingsSizer): # 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 detaches the application the user was last in from any app module, - # so NVDA applies no application specific behaviour to it. - self.unassociateButton = actionsHelper.addItem(wx.Button(self, label=_("&Unassociate current app"))) + # Translators: button that detaches an application from any app module (so NVDA applies no + # application specific behaviour to it). Its label is updated at runtime to name the application + # it will act on: the selected mapping, or the application the user was last in. + self.unassociateButton = actionsHelper.addItem(wx.Button(self, label=_("&Unassociate"))) # Translators: button that removes the selected custom mapping and restores the original module. self.removeButton = actionsHelper.addItem(wx.Button(self, label=_("&Remove mapping"))) sHelper.addItem(actionsHelper) settingsSizer.Fit(self) - # Detaching acts on the last focused application. If none has been seen yet (e.g. NVDA was just - # restarted and no application has been focused since), there is nothing to unassociate, so the - # button is disabled. This state is stable while the panel is open because focus stays within - # NVDA, whose events are ignored by the tracker. - self.unassociateButton.Enable(mapperHandler.getLastForegroundApp() is not None) self.bindEvents() self.buildMappingsList() @@ -74,6 +70,10 @@ def bindEvents(self): self.addButton.Bind(wx.EVT_BUTTON, self.onAdd) self.unassociateButton.Bind(wx.EVT_BUTTON, self.onUnassociate) self.removeButton.Bind(wx.EVT_BUTTON, self.onRemove) + # The unassociate button acts on the selected mapping when there is one, so keep it in sync with + # the list selection. + self.mappingsList.Bind(wx.EVT_LIST_ITEM_SELECTED, self.onListSelectionChanged) + self.mappingsList.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.onListSelectionChanged) def buildMappingsList(self): customModulesMapping = mapperHandler.getCustomModulesMapping() @@ -105,6 +105,7 @@ def refreshList(self): for key in sorted_keys: mapping = self.mappings[key] self.mappingsList.Append((mapping.app, self.moduleDisplayName(mapping.appModule))) + self.updateUnassociateButton() def moduleDisplayName(self, moduleName: str) -> str: # Detached applications are stored as a mapping to the notAssociated sentinel module, but that @@ -156,19 +157,74 @@ def selectApp(self, app: str): self.mappingsList.Focus(index) return - def onUnassociate(self, evt): - # The button is disabled when there is no last focused application, so current is normally set; - # the guard is kept purely defensively. + def isUnassociated(self, app): + # True when the app is already staged as detached, so unassociating it again would be a no-op. + item = self.mappings.get(app) + return item is not None and item.action != CustomMappingAction.REMOVE \ + and item.appModule == mapperHandler.NOT_ASSOCIATED_MODULE + + def getUnassociateTarget(self): + # Resolve which application the Unassociate button acts on and whether it is currently possible. + # Returns (app, fromSelection) with app None when there is nothing meaningful to unassociate. + # Selection wins: when a mapping is selected the button targets it (all listed mappings are our + # own custom associations), unless it is already detached. + selected = self.mappingsList.GetFirstSelected() + if selected != -1: + app = self.mappingsList.GetItemText(selected).lower() + if self.isUnassociated(app): + return None, True + return app, True + # No selection: fall back to the last focused application. current = mapperHandler.getLastForegroundApp() if not current: + return None, False + app, _module, hasSpecificModule = current + item = self.mappings.get(app) + if item is not None and item.action != CustomMappingAction.REMOVE: + # Already a custom association: detachable only if it is not already detached. + if item.appModule == mapperHandler.NOT_ASSOCIATED_MODULE: + return None, False + return app, False + # Not one of our associations: only meaningful when the app has a specific module to remove + # (an internally associated app). An app with no app module has nothing to unassociate. + if hasSpecificModule: + return app, False + return None, False + + def updateUnassociateButton(self): + app, fromSelection = self.getUnassociateTarget() + if app is None: + # Translators: label of the unassociate button when nothing can be unassociated. + self.unassociateButton.SetLabel(_("&Unassociate")) + self.unassociateButton.Enable(False) + elif fromSelection: + # Translators: label of the unassociate button when a mapping is selected. {app} is the + # application executable name it will be detached. + self.unassociateButton.SetLabel(_("&Unassociate {app}").format(app=app)) + self.unassociateButton.Enable(True) + else: + # Translators: label of the unassociate button acting on the current application. {app} is + # the application executable name. + self.unassociateButton.SetLabel(_("&Unassociate current app ({app})").format(app=app)) + self.unassociateButton.Enable(True) + self.Layout() + + def onListSelectionChanged(self, evt): + self.updateUnassociateButton() + evt.Skip() + + def onUnassociate(self, evt): + # The button is disabled when there is nothing to act on, so target is normally set; the guard is + # kept purely defensively. + app, _fromSelection = self.getUnassociateTarget() + if app is None: return - app, _currentModule = current confirmed = wx.MessageBox( - # Translators: confirmation shown before detaching the current application from its app - # module. {app} is the application executable name. + # Translators: confirmation shown before detaching an application from its app module. {app} + # is the application executable name. _("Unassociate {app} from any app module?").format(app=app), - # Translators: title of the confirmation shown before unassociating the current application. - _("Unassociate current app"), + # Translators: title of the confirmation shown before unassociating an application. + _("Unassociate"), wx.YES_NO | wx.ICON_QUESTION, ) if confirmed == wx.YES: @@ -281,7 +337,7 @@ def applyPrefill(self): # it currently uses (including "not associated" if it is already detached). if not self.prefill: return - appName, currentModule = self.prefill + appName, currentModule, _hasSpecificModule = self.prefill self.appComboBox.SetValue(appName) display = self.moduleToDisplay(currentModule) if display in self.moduleChoices: diff --git a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py index 7a998cc..095989c 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -33,20 +33,23 @@ class Mapping: customModulesMapping: List[Mapping] -# Executable name and current module of the last real (non NVDA) application that had the foreground. -# 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_foreground so the panel -# can still offer to act on the application the user was in right before opening Settings. -_lastForegroundApp: Optional[Tuple[str, str]] = None +# The last real (non NVDA) application that had the focus, as a +# (executableName, currentModuleName, hasSpecificModule) tuple. 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 still offer to act on the +# application the user was in right before opening Settings. hasSpecificModule is False when the app +# only had NVDA's generic base app module, i.e. no app specific behaviour to unassociate. +_lastForegroundApp: Optional[Tuple[str, str, bool]] = None -def setLastForegroundApp(appName: str, moduleName: str): +def setLastForegroundApp(appName: str, moduleName: str, hasSpecificModule: bool): global _lastForegroundApp - _lastForegroundApp = (appName, moduleName) + _lastForegroundApp = (appName, moduleName, hasSpecificModule) -def getLastForegroundApp() -> Optional[Tuple[str, str]]: - # Returns a (executableName, currentModuleName) tuple, or None if no real application has been seen. +def getLastForegroundApp() -> Optional[Tuple[str, str, bool]]: + # Returns a (executableName, currentModuleName, hasSpecificModule) tuple, or None if no real + # application has been seen yet. return _lastForegroundApp From 8669113c9bc1653c36c25ecedf060c2410285e6c Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 13:23:52 -0300 Subject: [PATCH 04/10] Document selection-aware Unassociate button in README Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- README.tpl.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 30ccfcc..acb7329 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Custom mappings are managed in NVDA's Settings dialog, in the **Custom Applicati - 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 app or type any executable name by hand. - The **module** list offers `(not associated)` first, then every available module. Choosing a real module maps the app to it; choosing `(not associated)` detaches the app from any app module. This is how you associate, re-map, or unassociate any application, including one that is not currently running. - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Unassociate current app**: a shortcut that detaches the application you were in right before opening NVDA's Settings. It asks for confirmation, naming the application, then detaches it. It is disabled when no application has been focused yet. +- **Unassociate**: detaches an application from any app module. When a mapping is selected in the list it detaches that application; otherwise it detaches the application you were in right before opening NVDA's Settings. The button label updates to show which application it will act on, and it asks for confirmation first. It is disabled when there is nothing to detach — no application to act on, an application that has no app module at all, or one that is already detached (use **Associate app** instead to detach an internally associated app that isn't the one you were last in). - **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. diff --git a/README.tpl.md b/README.tpl.md index 0a37e50..4c9b716 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -33,7 +33,7 @@ Custom mappings are managed in NVDA's Settings dialog, in the **Custom Applicati - 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 app or type any executable name by hand. - The **module** list offers `(not associated)` first, then every available module. Choosing a real module maps the app to it; choosing `(not associated)` detaches the app from any app module. This is how you associate, re-map, or unassociate any application, including one that is not currently running. - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Unassociate current app**: a shortcut that detaches the application you were in right before opening NVDA's Settings. It asks for confirmation, naming the application, then detaches it. It is disabled when no application has been focused yet. +- **Unassociate**: detaches an application from any app module. When a mapping is selected in the list it detaches that application; otherwise it detaches the application you were in right before opening NVDA's Settings. The button label updates to show which application it will act on, and it asks for confirmation first. It is disabled when there is nothing to detach — no application to act on, an application that has no app module at all, or one that is already detached (use **Associate app** instead to detach an internally associated app that isn't the one you were last in). - **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. From ce9a12516446c9e57fbdf2ac4c45c30687fc12ec Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 13:43:08 -0300 Subject: [PATCH 05/10] Drop Unassociate button; auto-select current app's row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the clarified model, marking an app to no module is an association action (done in the Associate dialog by choosing "(not associated)"), not a disassociation. "Disassociating" means removing a custom mapping to restore the original, which is what Remove mapping already does. - Remove the Unassociate button and all its selection-aware machinery; detaching now goes through Associate app… (which already pre-fills the current app), and reverting goes through Remove mapping. - Convenience: when the panel opens, if the app the user was last in has a custom mapping, its row is selected by default. - Simplify the foreground tracker back to (app, module); the hasSpecificModule flag and appModuleHandler import are no longer used. - README updated to the two-action model and the auto-selection. Co-Authored-By: Claude Opus 4.8 --- README.md | 11 +- README.tpl.md | 11 +- .../CustomAppModulesMapper/__init__.py | 12 +- .../CustomAppModulesMapper/guiHelper.py | 113 ++---------------- .../CustomAppModulesMapper/mapperHandler.py | 22 ++-- 5 files changed, 34 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index acb7329..cd586c2 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,22 @@ This add-on lets you do both dynamically, straight from the NVDA Settings dialog ## Usage -Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is mapped to (detached applications are shown as `(not associated)`), with the following actions: +Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is associated with (applications associated with no module are shown as `(not associated)`), with the following actions: - **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 app or type any executable name by hand. - - The **module** list offers `(not associated)` first, then every available module. Choosing a real module maps the app to it; choosing `(not associated)` detaches the app from any app module. This is how you associate, re-map, or unassociate any application, including one that is not currently running. + - The **module** list offers `(not associated)` first, then every available module. Choosing a real module associates the app with it; choosing `(not associated)` associates the app with no module, so NVDA applies no app specific behavior to it. This single dialog is how you associate any application, running or not, with a module or with nothing. - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Unassociate**: detaches an application from any app module. When a mapping is selected in the list it detaches that application; otherwise it detaches the application you were in right before opening NVDA's Settings. The button label updates to show which application it will act on, and it asks for confirmation first. It is disabled when there is nothing to detach — no application to act on, an application that has no app module at all, or one that is already detached (use **Associate app** instead to detach an internally associated app that isn't the one you were last in). -- **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. +- **Remove mapping**: removes the selected custom mapping (disassociates the application), restoring the module the application originally used. + +When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to remove or re-associate. Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. ### Features 1. Associate any executable with any available app module, whether it is a native NVDA module or one exposed by another add-on. -2. Detach any application from its app module, so it behaves as if no app module were present. +2. Associate any application with no module (`(not associated)`), so it behaves as if no app module were present. 3. 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. 4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. 5. At any time, remove a custom mapping and the original mapping is restored. diff --git a/README.tpl.md b/README.tpl.md index 4c9b716..61e7324 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -27,21 +27,22 @@ This add-on lets you do both dynamically, straight from the NVDA Settings dialog ## Usage -Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is mapped to (detached applications are shown as `(not associated)`), with the following actions: +Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is associated with (applications associated with no module are shown as `(not associated)`), with the following actions: - **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 app or type any executable name by hand. - - The **module** list offers `(not associated)` first, then every available module. Choosing a real module maps the app to it; choosing `(not associated)` detaches the app from any app module. This is how you associate, re-map, or unassociate any application, including one that is not currently running. + - The **module** list offers `(not associated)` first, then every available module. Choosing a real module associates the app with it; choosing `(not associated)` associates the app with no module, so NVDA applies no app specific behavior to it. This single dialog is how you associate any application, running or not, with a module or with nothing. - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Unassociate**: detaches an application from any app module. When a mapping is selected in the list it detaches that application; otherwise it detaches the application you were in right before opening NVDA's Settings. The button label updates to show which application it will act on, and it asks for confirmation first. It is disabled when there is nothing to detach — no application to act on, an application that has no app module at all, or one that is already detached (use **Associate app** instead to detach an internally associated app that isn't the one you were last in). -- **Remove mapping**: removes the selected custom mapping and restores the module the application originally used. +- **Remove mapping**: removes the selected custom mapping (disassociates the application), restoring the module the application originally used. + +When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to remove or re-associate. Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. ### Features 1. Associate any executable with any available app module, whether it is a native NVDA module or one exposed by another add-on. -2. Detach any application from its app module, so it behaves as if no app module were present. +2. Associate any application with no module (`(not associated)`), so it behaves as if no app module were present. 3. 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. 4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. 5. At any time, remove a custom mapping and the original mapping is restored. diff --git a/addon/globalPlugins/CustomAppModulesMapper/__init__.py b/addon/globalPlugins/CustomAppModulesMapper/__init__.py index 390a3f2..dea8f76 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/__init__.py +++ b/addon/globalPlugins/CustomAppModulesMapper/__init__.py @@ -5,7 +5,6 @@ # See the file COPYING.txt for more details. import addonHandler -import appModuleHandler import globalPluginHandler import globalVars import gui @@ -41,16 +40,7 @@ def event_gainFocus(self, obj, nextHandler): try: appModule = obj.appModule if appModule is not None and appModule.appName and appModule.appName != "nvda": - # When no specific module could be imported for the executable, NVDA falls back to the - # generic base AppModule (appModuleHandler.AppModule itself). Recording whether the app - # had a specific module lets the panel disable "unassociate" for an app that has no app - # specific behaviour to remove in the first place. - hasSpecificModule = type(appModule) is not appModuleHandler.AppModule - mapperHandler.setLastForegroundApp( - appModule.appName, - appModule.appModuleName, - hasSpecificModule, - ) + mapperHandler.setLastForegroundApp(appModule.appName, appModule.appModuleName) except Exception: log.debugWarning("Could not record focused application", exc_info=True) nextHandler() diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index 769bbc0..51242d6 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -55,25 +55,24 @@ def makeSettings(self, settingsSizer): # 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 detaches an application from any app module (so NVDA applies no - # application specific behaviour to it). Its label is updated at runtime to name the application - # it will act on: the selected mapping, or the application the user was last in. - self.unassociateButton = actionsHelper.addItem(wx.Button(self, label=_("&Unassociate"))) # Translators: button that removes the selected custom mapping and restores the original module. self.removeButton = actionsHelper.addItem(wx.Button(self, label=_("&Remove mapping"))) sHelper.addItem(actionsHelper) settingsSizer.Fit(self) self.bindEvents() self.buildMappingsList() + self.selectCurrentApp() def bindEvents(self): self.addButton.Bind(wx.EVT_BUTTON, self.onAdd) - self.unassociateButton.Bind(wx.EVT_BUTTON, self.onUnassociate) self.removeButton.Bind(wx.EVT_BUTTON, self.onRemove) - # The unassociate button acts on the selected mapping when there is one, so keep it in sync with - # the list selection. - self.mappingsList.Bind(wx.EVT_LIST_ITEM_SELECTED, self.onListSelectionChanged) - self.mappingsList.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.onListSelectionChanged) + + 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() @@ -105,7 +104,6 @@ def refreshList(self): for key in sorted_keys: mapping = self.mappings[key] self.mappingsList.Append((mapping.app, self.moduleDisplayName(mapping.appModule))) - self.updateUnassociateButton() def moduleDisplayName(self, moduleName: str) -> str: # Detached applications are stored as a mapping to the notAssociated sentinel module, but that @@ -132,104 +130,15 @@ def onAdd(self, evt): dialog.Destroy() gui.mainFrame.postPopup() - def stageMapping(self, app: str, moduleName: str): - # Stage (but do not yet apply) a mapping of app -> moduleName, reusing the existing entry when - # the app is already staged so its original module and ADD/MODIFY state are preserved. Detaching - # an app is simply mapping it to the notAssociated sentinel, so this covers both add and detach. - app = app.lower() - if app in self.mappings: - item = self.mappings[app] - item.appModule = moduleName - if item.action != CustomMappingAction.ADD: - # A previously persisted (IGNORE) or removed (REMOVE) mapping becomes a modification. - item.action = CustomMappingAction.MODIFY - else: - originalModule = mapperHandler.getAllConfiguredMappings().get(app, None) - self.mappings[app] = CustomMappingItem(app, moduleName, originalModule, CustomMappingAction.ADD) - self.refreshList() - self.selectApp(app) - def selectApp(self, app: str): - # Move selection/focus to the row for the given app so the change is announced and visible. + # 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 isUnassociated(self, app): - # True when the app is already staged as detached, so unassociating it again would be a no-op. - item = self.mappings.get(app) - return item is not None and item.action != CustomMappingAction.REMOVE \ - and item.appModule == mapperHandler.NOT_ASSOCIATED_MODULE - - def getUnassociateTarget(self): - # Resolve which application the Unassociate button acts on and whether it is currently possible. - # Returns (app, fromSelection) with app None when there is nothing meaningful to unassociate. - # Selection wins: when a mapping is selected the button targets it (all listed mappings are our - # own custom associations), unless it is already detached. - selected = self.mappingsList.GetFirstSelected() - if selected != -1: - app = self.mappingsList.GetItemText(selected).lower() - if self.isUnassociated(app): - return None, True - return app, True - # No selection: fall back to the last focused application. - current = mapperHandler.getLastForegroundApp() - if not current: - return None, False - app, _module, hasSpecificModule = current - item = self.mappings.get(app) - if item is not None and item.action != CustomMappingAction.REMOVE: - # Already a custom association: detachable only if it is not already detached. - if item.appModule == mapperHandler.NOT_ASSOCIATED_MODULE: - return None, False - return app, False - # Not one of our associations: only meaningful when the app has a specific module to remove - # (an internally associated app). An app with no app module has nothing to unassociate. - if hasSpecificModule: - return app, False - return None, False - - def updateUnassociateButton(self): - app, fromSelection = self.getUnassociateTarget() - if app is None: - # Translators: label of the unassociate button when nothing can be unassociated. - self.unassociateButton.SetLabel(_("&Unassociate")) - self.unassociateButton.Enable(False) - elif fromSelection: - # Translators: label of the unassociate button when a mapping is selected. {app} is the - # application executable name it will be detached. - self.unassociateButton.SetLabel(_("&Unassociate {app}").format(app=app)) - self.unassociateButton.Enable(True) - else: - # Translators: label of the unassociate button acting on the current application. {app} is - # the application executable name. - self.unassociateButton.SetLabel(_("&Unassociate current app ({app})").format(app=app)) - self.unassociateButton.Enable(True) - self.Layout() - - def onListSelectionChanged(self, evt): - self.updateUnassociateButton() - evt.Skip() - - def onUnassociate(self, evt): - # The button is disabled when there is nothing to act on, so target is normally set; the guard is - # kept purely defensively. - app, _fromSelection = self.getUnassociateTarget() - if app is None: - return - confirmed = wx.MessageBox( - # Translators: confirmation shown before detaching an application from its app module. {app} - # is the application executable name. - _("Unassociate {app} from any app module?").format(app=app), - # Translators: title of the confirmation shown before unassociating an application. - _("Unassociate"), - wx.YES_NO | wx.ICON_QUESTION, - ) - if confirmed == wx.YES: - self.stageMapping(app, mapperHandler.NOT_ASSOCIATED_MODULE) - def onRemove(self, evt): selected = self.mappingsList.GetFirstSelected() if selected == -1: @@ -337,7 +246,7 @@ def applyPrefill(self): # it currently uses (including "not associated" if it is already detached). if not self.prefill: return - appName, currentModule, _hasSpecificModule = self.prefill + appName, currentModule = self.prefill self.appComboBox.SetValue(appName) display = self.moduleToDisplay(currentModule) if display in self.moduleChoices: diff --git a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py index 095989c..8a56539 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -33,23 +33,21 @@ class Mapping: customModulesMapping: List[Mapping] -# The last real (non NVDA) application that had the focus, as a -# (executableName, currentModuleName, hasSpecificModule) tuple. 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 still offer to act on the -# application the user was in right before opening Settings. hasSpecificModule is False when the app -# only had NVDA's generic base app module, i.e. no app specific behaviour to unassociate. -_lastForegroundApp: Optional[Tuple[str, str, bool]] = None +# 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, hasSpecificModule: bool): +def setLastForegroundApp(appName: str, moduleName: str): global _lastForegroundApp - _lastForegroundApp = (appName, moduleName, hasSpecificModule) + _lastForegroundApp = (appName, moduleName) -def getLastForegroundApp() -> Optional[Tuple[str, str, bool]]: - # Returns a (executableName, currentModuleName, hasSpecificModule) tuple, or None if no real - # application has been seen yet. +def getLastForegroundApp() -> Optional[Tuple[str, str]]: + # Returns a (executableName, currentModuleName) tuple, or None if no real application has been seen. return _lastForegroundApp From 93e62e00435f3b60ab6f8d871c45c345f71b1d52 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 13:45:25 -0300 Subject: [PATCH 06/10] Rename Remove mapping button to Disassociate Matches the terminology used throughout: removing a custom mapping is disassociating the application (restoring its original module). Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++--- README.tpl.md | 6 +++--- addon/globalPlugins/CustomAppModulesMapper/guiHelper.py | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cd586c2..b227d45 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ Custom mappings are managed in NVDA's Settings dialog, in the **Custom Applicati - 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 app or type any executable name by hand. - The **module** list offers `(not associated)` first, then every available module. Choosing a real module associates the app with it; choosing `(not associated)` associates the app with no module, so NVDA applies no app specific behavior to it. This single dialog is how you associate any application, running or not, with a module or with nothing. - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Remove mapping**: removes the selected custom mapping (disassociates the application), restoring the module the application originally used. +- **Disassociate**: removes the selected custom mapping, restoring the module the application originally used. -When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to remove or re-associate. +When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to disassociate or re-associate. Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. @@ -45,5 +45,5 @@ Changes take effect when you confirm the Settings dialog. Application names are 2. Associate any application with no module (`(not associated)`), so it behaves as if no app module were present. 3. 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. 4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. -5. At any time, remove a custom mapping and the original mapping is restored. +5. At any time, disassociate an application and its original mapping is restored. 6. 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 61e7324..5770d67 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -33,9 +33,9 @@ Custom mappings are managed in NVDA's Settings dialog, in the **Custom Applicati - 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 app or type any executable name by hand. - The **module** list offers `(not associated)` first, then every available module. Choosing a real module associates the app with it; choosing `(not associated)` associates the app with no module, so NVDA applies no app specific behavior to it. This single dialog is how you associate any application, running or not, with a module or with nothing. - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Remove mapping**: removes the selected custom mapping (disassociates the application), restoring the module the application originally used. +- **Disassociate**: removes the selected custom mapping, restoring the module the application originally used. -When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to remove or re-associate. +When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to disassociate or re-associate. Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. @@ -45,5 +45,5 @@ Changes take effect when you confirm the Settings dialog. Application names are 2. Associate any application with no module (`(not associated)`), so it behaves as if no app module were present. 3. 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. 4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. -5. At any time, remove a custom mapping and the original mapping is restored. +5. At any time, disassociate an application and its original mapping is restored. 6. Everything is managed through the NVDA Settings dialog, in the Custom Application Module Mapper category. diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index 51242d6..a2ac704 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -55,8 +55,9 @@ def makeSettings(self, settingsSizer): # 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 removes the selected custom mapping and restores the original module. - self.removeButton = actionsHelper.addItem(wx.Button(self, label=_("&Remove mapping"))) + # 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() From d5a3f40cd6d6dd42c845d8890adc72500e869ac9 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 13:51:24 -0300 Subject: [PATCH 07/10] Disable Disassociate button when no mapping is selected The button was always enabled, so it looked actionable even with nothing selected (onRemove silently no-oped). Track the list selection and enable Disassociate only when a mapping is selected. Co-Authored-By: Claude Opus 4.8 --- .../CustomAppModulesMapper/guiHelper.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index a2ac704..f92aa4c 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -63,10 +63,22 @@ def makeSettings(self, settingsSizer): 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 @@ -105,6 +117,7 @@ def refreshList(self): for key in sorted_keys: mapping = self.mappings[key] 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 From f76517193d48e47c03d058bc60b758c8df24fad8 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 14:01:15 -0300 Subject: [PATCH 08/10] Enumerate open apps by window; keep focus in list after disassociate Two fixes from testing: - getRunningApps now enumerates the processes owning a visible, titled, top-level window (merged with appModuleHandler.runningTable), instead of relying on runningTable alone. runningTable only holds apps NVDA already built a module for this session, so open apps like VS Code and cmd were missing from the picker. - After Disassociate removes the selected (or only) mapping, keep focus on the list (selecting a neighbouring row when one remains) instead of letting it jump to the dialog's OK button when the Disassociate button becomes disabled. Co-Authored-By: Claude Opus 4.8 --- .../CustomAppModulesMapper/guiHelper.py | 8 ++++ .../CustomAppModulesMapper/mapperHandler.py | 44 +++++++++++++++---- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index f92aa4c..ef9f01c 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -160,6 +160,14 @@ def onRemove(self, evt): 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 diff --git a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py index 8a56539..b77ba24 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -7,11 +7,14 @@ 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, Optional, Tuple @@ -92,19 +95,44 @@ def getUnmappedModules() -> List[str]: def getRunningApps() -> List[str]: - # Executable names of the applications NVDA currently has an app module for, i.e. the applications - # the user has open this session. Used to populate the app picker so mappings can be chosen without - # typing. Names are already lowercased by NVDA (see appModuleHandler.getAppNameFromProcessID), which - # matches how executables are matched, so they can be compared and stored as-is. NVDA itself is - # excluded because mapping NVDA is never meaningful. + # 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 mod in list(appModuleHandler.runningTable.values()): + for processID in processIDs: try: - appName = mod.appName + appName = appModuleHandler.getAppNameFromProcessID(processID, includeExt=False) except Exception: continue - if appName and appName != "nvda": + 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) From e0fa6f405a2b744e5e2a9b796ac3b91d22a224d5 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 15:09:26 -0300 Subject: [PATCH 09/10] Re-sync module preselection on app change; expand README - Associate dialog: when the application field changes, re-preselect the module that application is currently associated with (or clear it for an unknown app), so switching apps mid-dialog no longer leaves the previous app's module selected. Re-associating an already-associated app to a different module was already supported (MODIFY path); this makes the starting state and the duplicate guard correct per app. - README: add a "What is an app module association?" section and a "Why would you use this add-on?" section, document re-association and the guarantee that disassociation always restores the true original, and give a complete feature list. Co-Authored-By: Claude Opus 4.8 --- README.md | 68 +++++++++++-------- README.tpl.md | 68 +++++++++++-------- .../CustomAppModulesMapper/guiHelper.py | 32 ++++++--- 3 files changed, 106 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index b227d45..1c3d81b 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,63 @@ # CustomAppModulesMapper 0.3.0 -NVDA add-on for dynamically mapping applications to existing app modules, and for detaching applications from their app module, without writing code. +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) -## How it works +## What is an app module association? -Sometimes 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**: -The module might ship with NVDA out of the box or be provided by a third party add-on. +- 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. NVDA provides this mapping out of the box, so that DBeaver reuses the same functionality as Eclipse. +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. -But such a mapping might not exist yet, or might be waiting for a merge that could take months to happen. +## Why would you use this add-on? -Traditionally, the only ways to provide a new mapping are to build a dedicated add-on that exports it, or to merge the mapping into NVDA's appModules package. +Sometimes the association you want does not exist out of the box: -Conversely, an application may already be picked up by an app module you would rather it did not use, and there is no built-in way to detach it. +- 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 add-on lets you do both dynamically, straight from the NVDA Settings dialog, so that: - -1. If you need to map an app to a module, you can do it immediately, without coding an add-on or waiting for a merge into NVDA's source code. -2. You can have several modules providing functionality to an app and alternate between them, to test how a given module deals with the app. -3. If an app is associated with a module you do not want, you can detach it so that NVDA applies no app specific behavior to it. +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. ## Usage -Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is associated with (applications associated with no module are shown as `(not associated)`), with the following actions: +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)`). + +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 -- **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 app or type any executable name by hand. - - The **module** list offers `(not associated)` first, then every available module. Choosing a real module associates the app with it; choosing `(not associated)` associates the app with no module, so NVDA applies no app specific behavior to it. This single dialog is how you associate any application, running or not, with a module or with nothing. - - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Disassociate**: removes the selected custom mapping, restoring the module the application originally used. +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. -When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to disassociate or re-associate. +### Applying changes -Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. +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 +## Features -1. Associate any executable with any available app module, whether it is a native NVDA module or one exposed by another add-on. +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. 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. -4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. -5. At any time, disassociate an application and its original mapping is restored. -6. Everything is managed through the NVDA Settings dialog, in the Custom Application Module Mapper category. +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 5770d67..6720e07 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -1,49 +1,63 @@ # CustomAppModulesMapper ${addon_version} -NVDA add-on for dynamically mapping applications to existing app modules, and for detaching applications from their app module, without writing code. +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? -Sometimes 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**: -The module might ship with NVDA out of the box or be provided by a third party add-on. +- 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. NVDA provides this mapping out of the box, so that DBeaver reuses the same functionality as Eclipse. +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. -But such a mapping might not exist yet, or might be waiting for a merge that could take months to happen. +## Why would you use this add-on? -Traditionally, the only ways to provide a new mapping are to build a dedicated add-on that exports it, or to merge the mapping into NVDA's appModules package. +Sometimes the association you want does not exist out of the box: -Conversely, an application may already be picked up by an app module you would rather it did not use, and there is no built-in way to detach it. +- 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 add-on lets you do both dynamically, straight from the NVDA Settings dialog, so that: - -1. If you need to map an app to a module, you can do it immediately, without coding an add-on or waiting for a merge into NVDA's source code. -2. You can have several modules providing functionality to an app and alternate between them, to test how a given module deals with the app. -3. If an app is associated with a module you do not want, you can detach it so that NVDA applies no app specific behavior to it. +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. ## Usage -Custom mappings are managed in NVDA's Settings dialog, in the **Custom Application Module Mapper** category. It lists your current custom mappings, each showing the application executable name and the module it is associated with (applications associated with no module are shown as `(not associated)`), with the following actions: +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)`). + +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 -- **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 app or type any executable name by hand. - - The **module** list offers `(not associated)` first, then every available module. Choosing a real module associates the app with it; choosing `(not associated)` associates the app with no module, so NVDA applies no app specific behavior to it. This single dialog is how you associate any application, running or not, with a module or with nothing. - - The confirm button stays disabled until you have both chosen an application and a module, and it will not let you re-select the association the app already has. -- **Disassociate**: removes the selected custom mapping, restoring the module the application originally used. +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. -When you open the category, if the application you were last in already has a custom mapping, its row is selected automatically, ready to disassociate or re-associate. +### Applying changes -Changes take effect when you confirm the Settings dialog. Application names are matched case-insensitively, the same way NVDA matches executables. +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 +## Features -1. Associate any executable with any available app module, whether it is a native NVDA module or one exposed by another add-on. +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. 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. -4. Custom mappings are persisted, so they are kept between NVDA runs, and are protected against duplicates. -5. At any time, disassociate an application and its original mapping is restored. -6. Everything is managed through the NVDA Settings dialog, in the Custom Application Module Mapper category. +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/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index ef9f01c..4971210 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -268,19 +268,35 @@ def applyPrefill(self): # it currently uses (including "not associated" if it is already detached). if not self.prefill: return - appName, currentModule = self.prefill - self.appComboBox.SetValue(appName) - display = self.moduleToDisplay(currentModule) - if display in self.moduleChoices: - self.appModulesComboBox.SetValue(display) + 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): - # Re-evaluate whether OK should be enabled whenever either field changes. - self.appComboBox.Bind(wx.EVT_TEXT, self.onFieldChanged) - self.appComboBox.Bind(wx.EVT_COMBOBOX, self.onFieldChanged) + # 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() From 90272626037e8545ed9c580385f0049a504f164b Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Tue, 14 Jul 2026 15:26:17 -0300 Subject: [PATCH 10/10] Version pickle format and bump to 1.0.0 - Store the mappings pickle as {formatVersion, mappings} instead of a bare list, so a future format change can be detected and migrated. readMappingsFile understands both the new versioned dict and the pre-1.0 bare list (format version 0), staying backward compatible. - Bump addon_version to 1.0.0 and rewrite the changelog for a first complete release. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++-- .../CustomAppModulesMapper/mapperHandler.py | 21 ++++++++++++++++--- buildVars.py | 15 ++++++------- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1c3d81b..b7426fd 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# CustomAppModulesMapper 0.3.0 +# CustomAppModulesMapper 1.0.0 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) ## What is an app module association? diff --git a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py index b77ba24..e20323c 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -26,6 +26,11 @@ # 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 class Mapping: @@ -206,16 +211,26 @@ 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 = dedupeMappings(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",