Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions addon/globalPlugins/switchSynth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@
import addonHandler
addonHandler.initTranslation()

def _copyConfig(value):
"""Copy settings without retaining NVDA's live configuration sections."""
if hasattr(value, 'items'):
return {key: _copyConfig(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return type(value)(_copyConfig(item) for item in value)
return copy.deepcopy(value)

def _mergeConfig(current, saved):
"""Restore saved values while retaining settings absent from older slots."""
merged = _copyConfig(current)
for key, value in saved.items():
if hasattr(value, 'items') and isinstance(merged.get(key), dict):
merged[key] = _mergeConfig(merged[key], value)
else:
merged[key] = _copyConfig(value)
return merged

class GlobalPlugin(globalPluginHandler.GlobalPlugin):
scriptCategory = _("Switch Synth")

Expand Down Expand Up @@ -53,9 +71,15 @@ def script_setSynth6(self, gesture):
def loadSynth(self, slot):
self.slot = slot
if slot in self.synths:
config.conf.profiles[0]['speech'][self.synths[slot]['name']].clear()
config.conf.profiles[0]['speech'][self.synths[slot]['name']].update(self.synths[slot]['config'])
config.conf['speech'][self.synths[slot]['name']] = copy.deepcopy(self.synths[slot]['config'])
# Capture live settings before switching away. Older slots may not
# contain options added by a newer version of the synthesizer.
getSynth().saveSettings()
name = self.synths[slot]['name']
saved = self.synths[slot]['config']
merged = _mergeConfig(config.conf['speech'][name], saved)
base = config.conf.profiles[0]['speech'][name]
base.update(_mergeConfig(base, saved))
config.conf['speech'][name] = merged

config.conf['speech'][self.synths[slot]['name']]._cache.clear()
if getSynth().name != self.synths[slot]['name']:
Expand All @@ -66,18 +90,11 @@ def loadSynth(self, slot):
ui.message(str(slot))

def script_saveSynth(self, gesture):
getSynth().saveSettings()
if self.slot not in self.synths:
self.synths[self.slot] = {}
self.synths[self.slot]['name'] = getSynth().name
if hasattr(config.conf['speech'][getSynth().name], 'items'):
items = config.conf['speech'][getSynth().name].items()
else:
items = config.conf['speech'][getSynth().name].iteritems()
items = dict(items)
for k, v in items.items():
if isinstance(v, config.AggregatedSection):
items[k] = v.copy()
self.synths[self.slot]['config'] = items
self.synths[self.slot]['config'] = _copyConfig(config.conf['speech'][getSynth().name])
self.write()
ui.message(_("saved"))
#Translators: Input help mode message for save synth command.
Expand Down
2 changes: 1 addition & 1 deletion buildVars.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def _(arg):
To save the current voice in the currently selected slot, press control+shift+NVDA+v."""
),
# version
"addon_version": "1.07.0",
"addon_version": "1.07.1",
# Author(s)
"addon_author": "Tyler Spivey <tspivey@pcdesk.net>",
# URL for the add-on documentation support
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ Slots can be switched by pressing control+shift+NVDA+1 through control+shift+NVD
Once a slot is switched to, its synthesizer and settings will be loaded if they exist.
To save the current synthesizer and its settings to the currently active slot, press control+shift+NVDA+v.

Loading a slot restores the settings saved in that slot. Settings absent from an older slot, such as options added by a synthesizer update, keep their current values. To change a setting explicitly saved in a slot, adjust it and save the slot again.

## Changes in 1.07
The add-on now allows you to change the gesture used for each slot independently from the Input Gestures dialog.
226 changes: 226 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""Exercise saved slots against a layered NVDA configuration and live synths."""
import copy
import importlib.util
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch


class Section:

def __init__(self, manager, path):
self.manager = manager
self.path = path
self._cache = {}

def _layers(self):
layers = []
for profile in self.manager.profiles:
value = profile
for key in self.path:
value = value.get(key, {})
layers.append(value)
return layers

def __getitem__(self, key):
if key not in self._cache:
for layer in reversed(self._layers()):
if key in layer:
value = layer[key]
self._cache[key] = (
self.manager.section(self.path + (key,))
if isinstance(value, dict) else value
)
break
else:
raise KeyError(key)
return self._cache[key]

def __setitem__(self, key, value):
section = self.manager.profiles[-1]
for part in self.path:
section = section.setdefault(part, {})
section[key] = copy.deepcopy(value)
self._cache.pop(key, None)
for path, cached in self.manager.sections.items():
if path[:len(self.path) + 1] == self.path + (key,):
cached._cache.clear()

def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default

def items(self):
keys = set()
for layer in self._layers():
keys.update(layer)
return [(key, self[key]) for key in keys]

def copy(self):
return dict(self.items())


class Config:

def __init__(self, base, profile=None):
self.profiles = [base] + ([] if profile is None else [profile])
self.sections = {}

def section(self, path):
if path not in self.sections:
self.sections[path] = Section(self, path)
return self.sections[path]

def __getitem__(self, key):
return self.section((key,))


class Synth:
defaults = {
"eloquence": {"voice": "Reed", "rate": 50, "pauseMode": "0", "sampleRate": "11025"},
"espeak": {"voice": "en", "rate": 50},
}

def __init__(self, config, name):
self.config = config
self.name = name
self.loadSettings()

def loadSettings(self, onlyChanged=False):
section = self.config["speech"][self.name]
for key, default in self.defaults[self.name].items():
setattr(self, key, section.get(key, default))

def saveSettings(self):
section = self.config["speech"][self.name]
for key in self.defaults[self.name]:
section[key] = getattr(self, key)


class SettingsTests(unittest.TestCase):

def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.config = Config({"speech": {
"eloquence": {"voice": "Reed", "rate": 50, "pauseMode": "2", "sampleRate": "22050"},
"espeak": {"voice": "en", "rate": 50},
}})
self.current = Synth(self.config, "espeak")
self.switches = []
self.messages = []
config = types.ModuleType("config")
config.conf = self.config
config.AggregatedSection = Section
config.getUserDefaultConfigPath = lambda: self.temporary.name
speech = types.ModuleType("speech")
speech.getSynth = lambda: self.current
speech.setSynth = self.setSynth
handler = types.ModuleType("globalPluginHandler")
handler.GlobalPlugin = object
ui = types.ModuleType("ui")
ui.message = self.messages.append
addon = types.ModuleType("addonHandler")
addon.initTranslation = lambda: None
self.addCleanup(patch.stopall)
patch.dict(sys.modules, {
"config": config, "speech": speech, "globalPluginHandler": handler,
"ui": ui, "addonHandler": addon,
}).start()
path = Path(__file__).resolve().parents[1] / "addon/globalPlugins/switchSynth.py"
spec = importlib.util.spec_from_file_location("switchsynth_under_test", path)
module = importlib.util.module_from_spec(spec)
module._ = lambda text: text
spec.loader.exec_module(module)
self.plugin = module.GlobalPlugin()
self.plugin.synths = {"version": 1,
1: {"name": "eloquence", "config": {"voice": "Reed", "rate": 40}},
2: {"name": "espeak", "config": {"voice": "en", "rate": 50}},
}

def setSynth(self, name):
self.switches.append(name)
self.current = Synth(self.config, name)
return True

def test_old_slot_preserves_settings_added_after_it_was_saved(self):
saved = copy.deepcopy(self.plugin.synths[1])
self.plugin.loadSynth(1)
self.assertEqual(self.current.pauseMode, "2")
self.assertEqual(self.current.sampleRate, "22050")
self.assertEqual(self.current.rate, 40)
self.assertEqual(self.plugin.synths[1], saved)

def test_switch_away_and_back_preserves_live_unsaved_pause_mode(self):
self.config["speech"]["eloquence"]["pauseMode"] = "0"
self.current = Synth(self.config, "eloquence")
self.current.pauseMode = "2"
self.plugin.loadSynth(2)
self.plugin.loadSynth(1)
self.assertEqual(self.current.pauseMode, "2")

def test_explicit_slot_value_still_overrides_current_setting(self):
self.plugin.synths[1]["config"]["pauseMode"] = "0"
self.plugin.loadSynth(1)
self.assertEqual(self.current.pauseMode, "0")

def test_reload_of_same_synth_preserves_new_settings(self):
self.current = Synth(self.config, "eloquence")
self.plugin.loadSynth(1)
self.assertEqual(self.switches, [])
self.assertEqual(self.current.pauseMode, "2")

def test_saving_slot_captures_live_values_before_snapshot(self):
self.current = Synth(self.config, "eloquence")
self.current.pauseMode = "1"
self.current.rate = 70
self.plugin.script_saveSynth(None)
self.assertEqual(self.plugin.synths[1]["config"]["pauseMode"], "1")
self.assertEqual(self.plugin.synths[1]["config"]["rate"], 70)
self.plugin.synths = {}
self.plugin.load()
self.assertEqual(self.plugin.synths[1]["config"]["pauseMode"], "1")

def test_nested_new_settings_survive_without_changing_saved_slot(self):
self.config["speech"]["eloquence"]["voiceSettings"] = {
"voice1": {"rate": 50, "newSetting": "keep"},
}
self.plugin.synths[1]["config"]["voiceSettings"] = {"voice1": {"rate": 30}}
self.plugin.loadSynth(1)
nested = self.config["speech"]["eloquence"]["voiceSettings"]["voice1"]
self.assertEqual(nested["newSetting"], "keep")
self.assertEqual(nested["rate"], 30)
self.assertEqual(self.plugin.synths[1]["config"]["voiceSettings"], {"voice1": {"rate": 30}})

def test_profile_value_survives_without_replacing_base_value(self):
self.config.profiles[0]["speech"]["eloquence"]["pauseMode"] = "1"
self.config.profiles.append({"speech": {"eloquence": {"pauseMode": "2"}}})
for section in self.config.sections.values():
section._cache.clear()
self.plugin.loadSynth(1)
self.assertEqual(self.current.pauseMode, "2")
self.assertEqual(self.config.profiles[0]["speech"]["eloquence"]["pauseMode"], "1")

def test_saved_nested_config_is_detached_from_nvda_sections(self):
self.current = Synth(self.config, "eloquence")
self.config["speech"]["eloquence"]["voiceSettings"] = {"voice1": {"rate": 50}}
self.plugin.script_saveSynth(None)
saved = self.plugin.synths[1]["config"]["voiceSettings"]["voice1"]
self.assertIsInstance(saved, dict)
self.config["speech"]["eloquence"]["voiceSettings"]["voice1"]["rate"] = 90
self.assertEqual(saved["rate"], 50)

def test_empty_slot_keeps_current_synth(self):
self.plugin.loadSynth(6)
self.assertEqual(self.current.name, "espeak")
self.assertEqual(self.switches, [])
self.assertEqual(self.messages, ["6"])


if __name__ == "__main__":
unittest.main()
Loading