diff --git a/plover/app.py b/plover/app.py index 1367714da..f3aedaa6a 100644 --- a/plover/app.py +++ b/plover/app.py @@ -32,6 +32,7 @@ import plover.dictionary.rtfcre_dict as rtfcre_dict from plover.machine.registry import machine_registry, NoSuchMachineException from plover.logger import Logger +from plover.dictionary.loading_manager import manager as dict_manager # Because 2.7 doesn't have this yet. class SimpleNamespace(object): @@ -47,13 +48,12 @@ def init_engine(engine, config): """Initialize a StenoEngine from a config object.""" reset_machine(engine, config) - dictionary_file_name = config.get_dictionary_file_name() - if dictionary_file_name: - try: - d = load_dictionary(dictionary_file_name) - except DictionaryLoaderException as e: - raise InvalidConfigurationError(unicode(e)) - engine.set_dictionary(d) + dictionary_file_names = config.get_dictionary_file_names() + try: + dicts = dict_manager.load(dictionary_file_names) + except DictionaryLoaderException as e: + raise InvalidConfigurationError(unicode(e)) + engine.get_dictionary().set_dicts(dicts) log_file_name = config.get_log_file_name() if log_file_name: @@ -90,13 +90,13 @@ def update_engine(engine, old, new): raise InvalidConfigurationError(unicode(e)) engine.set_machine(machine_class(machine_options)) - dictionary_file_name = new.get_dictionary_file_name() - if old.get_dictionary_file_name() != dictionary_file_name: + dictionary_file_names = new.get_dictionary_file_names() + if old.get_dictionary_file_names() != dictionary_file_names: try: - d = load_dictionary(dictionary_file_name) + dicts = dict_manager.load(dictionary_file_names) except DictionaryLoaderException as e: raise InvalidConfigurationError(unicode(e)) - engine.set_dictionary(d) + engine.get_dictionary().set_dicts(dicts) log_file_name = new.get_log_file_name() if old.get_log_file_name() != log_file_name: @@ -144,6 +144,7 @@ class plus a hook to the application allows output to the screen and control def __init__(self): """Creates and configures a single steno pipeline.""" self.subscribers = [] + self.stroke_listeners = [] self.is_running = False self.machine = None @@ -239,8 +240,17 @@ def enable_translation_logging(self, b): """Turn translation logging on or off.""" self.logger.enable_translation_logging(b) + def add_stroke_listener(self, listener): + self.stroke_listeners.append(listener) + + def remove_stroke_listener(self, listener): + self.stroke_listeners.remove(listener) + def _translator_machine_callback(self, s): - self.translator.translate(steno.Stroke(s)) + stroke = steno.Stroke(s) + self.translator.translate(stroke) + for listener in self.stroke_listeners: + listener(stroke) def _machine_state_callback(self, s): for callback in self.subscribers: diff --git a/plover/assets/down.png b/plover/assets/down.png new file mode 100644 index 000000000..99bf644be Binary files /dev/null and b/plover/assets/down.png differ diff --git a/plover/assets/remove.png b/plover/assets/remove.png new file mode 100644 index 000000000..871a4e821 Binary files /dev/null and b/plover/assets/remove.png differ diff --git a/plover/assets/up.png b/plover/assets/up.png new file mode 100644 index 000000000..2df759235 Binary files /dev/null and b/plover/assets/up.png differ diff --git a/plover/config.py b/plover/config.py index 21a9dbdb9..07c792193 100644 --- a/plover/config.py +++ b/plover/config.py @@ -25,7 +25,7 @@ DICTIONARY_CONFIG_SECTION = 'Dictionary Configuration' DICTIONARY_FILE_OPTION = 'dictionary_file' -DEFAULT_DICTIONARY_FILE = 'dict.json' +DEFAULT_DICTIONARY_FILE = os.path.join(CONFIG_DIR, 'dict.json') LOGGING_CONFIG_SECTION = 'Logging Configuration' LOG_FILE_OPTION = 'log_file' @@ -35,6 +35,52 @@ ENABLE_TRANSLATION_LOGGING_OPTION = 'enable_translation_logging' DEFAULT_ENABLE_TRANSLATION_LOGGING = True +STROKE_DISPLAY_SECTION = 'Stroke Display' +STROKE_DISPLAY_SHOW_OPTION = 'show' +DEFAULT_STROKE_DISPLAY_SHOW = False +STROKE_DISPLAY_ON_TOP_OPTION = 'on_top' +DEFAULT_STROKE_DISPLAY_ON_TOP = True +STROKE_DISPLAY_STYLE_OPTION = 'style' +DEFAULT_STROKE_DISPLAY_STYLE = 'Paper' +STROKE_DISPLAY_X_OPTION = 'x' +DEFAULT_STROKE_DISPLAY_X = -1 +STROKE_DISPLAY_Y_OPTION = 'y' +DEFAULT_STROKE_DISPLAY_Y = -1 + +CONFIG_FRAME_SECTION = 'Config Frame' +CONFIG_FRAME_X_OPTION = 'x' +DEFAULT_CONFIG_FRAME_X = -1 +CONFIG_FRAME_Y_OPTION = 'y' +DEFAULT_CONFIG_FRAME_Y = -1 +CONFIG_FRAME_WIDTH_OPTION = 'width' +DEFAULT_CONFIG_FRAME_WIDTH = -1 +CONFIG_FRAME_HEIGHT_OPTION = 'height' +DEFAULT_CONFIG_FRAME_HEIGHT = -1 + +MAIN_FRAME_SECTION = 'Main Frame' +MAIN_FRAME_X_OPTION = 'x' +DEFAULT_MAIN_FRAME_X = -1 +MAIN_FRAME_Y_OPTION = 'y' +DEFAULT_MAIN_FRAME_Y = -1 + +TRANSLATION_FRAME_SECTION = 'Translation Frame' +TRANSLATION_FRAME_X_OPTION = 'x' +DEFAULT_TRANSLATION_FRAME_X = -1 +TRANSLATION_FRAME_Y_OPTION = 'y' +DEFAULT_TRANSLATION_FRAME_Y = -1 + +SERIAL_CONFIG_FRAME_SECTION = 'Serial Config Frame' +SERIAL_CONFIG_FRAME_X_OPTION = 'x' +DEFAULT_SERIAL_CONFIG_FRAME_X = -1 +SERIAL_CONFIG_FRAME_Y_OPTION = 'y' +DEFAULT_SERIAL_CONFIG_FRAME_Y = -1 + +KEYBOARD_CONFIG_FRAME_SECTION = 'Keyboard Config Frame' +KEYBOARD_CONFIG_FRAME_X_OPTION = 'x' +DEFAULT_KEYBOARD_CONFIG_FRAME_X = -1 +KEYBOARD_CONFIG_FRAME_Y_OPTION = 'y' +DEFAULT_KEYBOARD_CONFIG_FRAME_Y = -1 + # Dictionary constants. JSON_EXTENSION = '.json' RTF_EXTENSION = '.rtf' @@ -48,6 +94,8 @@ class Config(object): def __init__(self): self._config = RawConfigParser() + # A convenient place for other code to store a file name. + self.target_file = None def load(self, fp): self._config = RawConfigParser() @@ -56,6 +104,9 @@ def load(self, fp): except ConfigParser.Error as e: raise InvalidConfigurationError(str(e)) + def clear(self): + self._config = RawConfigParser() + def save(self, fp): self._config.write(fp) @@ -91,12 +142,25 @@ def get_machine_specific_options(self, machine_name): if k in option_info) return dict((k, v[0]) for k, v in option_info.items()) - def set_dictionary_file_name(self, filename): - self._set(DICTIONARY_CONFIG_SECTION, DICTIONARY_FILE_OPTION, filename) - - def get_dictionary_file_name(self): - return self._get(DICTIONARY_CONFIG_SECTION, DICTIONARY_FILE_OPTION, - DEFAULT_DICTIONARY_FILE) + def set_dictionary_file_names(self, filenames): + if self._config.has_section(DICTIONARY_CONFIG_SECTION): + self._config.remove_section(DICTIONARY_CONFIG_SECTION) + self._config.add_section(DICTIONARY_CONFIG_SECTION) + for ordinal, filename in enumerate(filenames, start=1): + option = DICTIONARY_FILE_OPTION + str(ordinal) + self._config.set(DICTIONARY_CONFIG_SECTION, option, filename) + + def get_dictionary_file_names(self): + filenames = [] + if self._config.has_section(DICTIONARY_CONFIG_SECTION): + options = filter(lambda x: x.startswith(DICTIONARY_FILE_OPTION), + self._config.options(DICTIONARY_CONFIG_SECTION)) + options.sort(key=_dict_entry_key) + filenames = [self._config.get(DICTIONARY_CONFIG_SECTION, o) + for o in options] + if not filenames or filenames == ['dict.json']: + filenames = [DEFAULT_DICTIONARY_FILE] + return filenames def set_log_file_name(self, filename): self._set(LOGGING_CONFIG_SECTION, LOG_FILE_OPTION, filename) @@ -128,6 +192,134 @@ def get_auto_start(self): return self._get_bool(MACHINE_CONFIG_SECTION, MACHINE_AUTO_START_OPTION, DEFAULT_MACHINE_AUTO_START) + def set_show_stroke_display(self, b): + self._set(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_SHOW_OPTION, b) + + def get_show_stroke_display(self): + return self._get_bool(STROKE_DISPLAY_SECTION, + STROKE_DISPLAY_SHOW_OPTION, DEFAULT_STROKE_DISPLAY_SHOW) + + def set_stroke_display_on_top(self, b): + self._set(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_ON_TOP_OPTION, b) + + def get_stroke_display_on_top(self): + return self._get_bool(STROKE_DISPLAY_SECTION, + STROKE_DISPLAY_ON_TOP_OPTION, DEFAULT_STROKE_DISPLAY_ON_TOP) + + def set_stroke_display_style(self, s): + self._set(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_STYLE_OPTION, s) + + def get_stroke_display_style(self): + return self._get(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_STYLE_OPTION, + DEFAULT_STROKE_DISPLAY_STYLE) + + def set_stroke_display_x(self, x): + self._set(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_X_OPTION, x) + + def get_stroke_display_x(self): + return self._get_int(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_X_OPTION, + DEFAULT_STROKE_DISPLAY_X) + + def set_stroke_display_y(self, y): + self._set(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_Y_OPTION, y) + + def get_stroke_display_y(self): + return self._get_int(STROKE_DISPLAY_SECTION, STROKE_DISPLAY_Y_OPTION, + DEFAULT_STROKE_DISPLAY_Y) + + def set_config_frame_x(self, x): + self._set(CONFIG_FRAME_SECTION, CONFIG_FRAME_X_OPTION, x) + + def get_config_frame_x(self): + return self._get_int(CONFIG_FRAME_SECTION, CONFIG_FRAME_X_OPTION, + DEFAULT_CONFIG_FRAME_X) + + def set_config_frame_y(self, y): + self._set(CONFIG_FRAME_SECTION, CONFIG_FRAME_Y_OPTION, y) + + def get_config_frame_y(self): + return self._get_int(CONFIG_FRAME_SECTION, CONFIG_FRAME_Y_OPTION, + DEFAULT_CONFIG_FRAME_Y) + + def set_config_frame_width(self, width): + self._set(CONFIG_FRAME_SECTION, CONFIG_FRAME_WIDTH_OPTION, width) + + def get_config_frame_width(self): + return self._get_int(CONFIG_FRAME_SECTION, CONFIG_FRAME_WIDTH_OPTION, + DEFAULT_CONFIG_FRAME_WIDTH) + + def set_config_frame_height(self, height): + self._set(CONFIG_FRAME_SECTION, CONFIG_FRAME_HEIGHT_OPTION, height) + + def get_config_frame_height(self): + return self._get_int(CONFIG_FRAME_SECTION, CONFIG_FRAME_HEIGHT_OPTION, + DEFAULT_CONFIG_FRAME_HEIGHT) + + def set_main_frame_x(self, x): + self._set(MAIN_FRAME_SECTION, MAIN_FRAME_X_OPTION, x) + + def get_main_frame_x(self): + return self._get_int(MAIN_FRAME_SECTION, MAIN_FRAME_X_OPTION, + DEFAULT_MAIN_FRAME_X) + + def set_main_frame_y(self, y): + self._set(MAIN_FRAME_SECTION, MAIN_FRAME_Y_OPTION, y) + + def get_main_frame_y(self): + return self._get_int(MAIN_FRAME_SECTION, MAIN_FRAME_Y_OPTION, + DEFAULT_MAIN_FRAME_Y) + + def set_translation_frame_x(self, x): + self._set(TRANSLATION_FRAME_SECTION, TRANSLATION_FRAME_X_OPTION, x) + + def get_translation_frame_x(self): + return self._get_int(TRANSLATION_FRAME_SECTION, + TRANSLATION_FRAME_X_OPTION, + DEFAULT_TRANSLATION_FRAME_X) + + def set_translation_frame_y(self, y): + self._set(TRANSLATION_FRAME_SECTION, TRANSLATION_FRAME_Y_OPTION, y) + + def get_translation_frame_y(self): + return self._get_int(TRANSLATION_FRAME_SECTION, + TRANSLATION_FRAME_Y_OPTION, + DEFAULT_TRANSLATION_FRAME_Y) + + def set_serial_config_frame_x(self, x): + self._set(SERIAL_CONFIG_FRAME_SECTION, SERIAL_CONFIG_FRAME_X_OPTION, x) + + def get_serial_config_frame_x(self): + return self._get_int(SERIAL_CONFIG_FRAME_SECTION, + SERIAL_CONFIG_FRAME_X_OPTION, + DEFAULT_SERIAL_CONFIG_FRAME_X) + + def set_serial_config_frame_y(self, y): + self._set(SERIAL_CONFIG_FRAME_SECTION, SERIAL_CONFIG_FRAME_Y_OPTION, y) + + def get_serial_config_frame_y(self): + return self._get_int(SERIAL_CONFIG_FRAME_SECTION, + SERIAL_CONFIG_FRAME_Y_OPTION, + DEFAULT_SERIAL_CONFIG_FRAME_Y) + + def set_keyboard_config_frame_x(self, x): + self._set(KEYBOARD_CONFIG_FRAME_SECTION, KEYBOARD_CONFIG_FRAME_X_OPTION, + x) + + def get_keyboard_config_frame_x(self): + return self._get_int(KEYBOARD_CONFIG_FRAME_SECTION, + KEYBOARD_CONFIG_FRAME_X_OPTION, + DEFAULT_KEYBOARD_CONFIG_FRAME_X) + + def set_keyboard_config_frame_y(self, y): + self._set(KEYBOARD_CONFIG_FRAME_SECTION, KEYBOARD_CONFIG_FRAME_Y_OPTION, + y) + + def get_keyboard_config_frame_y(self): + return self._get_int(KEYBOARD_CONFIG_FRAME_SECTION, + KEYBOARD_CONFIG_FRAME_Y_OPTION, + DEFAULT_KEYBOARD_CONFIG_FRAME_Y) + + def _set(self, section, option, value): if not self._config.has_section(section): self._config.add_section(section) @@ -139,6 +331,24 @@ def _get(self, section, option, default): return default def _get_bool(self, section, option, default): - if self._config.has_option(section, option): - return self._config.getboolean(section, option) + try: + if self._config.has_option(section, option): + return self._config.getboolean(section, option) + except ValueError: + pass return default + + def _get_int(self, section, option, default): + try: + if self._config.has_option(section, option): + return self._config.getint(section, option) + except ValueError: + pass + return default + + +def _dict_entry_key(s): + try: + return int(s[len(DICTIONARY_FILE_OPTION):]) + except ValueError: + return -1 \ No newline at end of file diff --git a/plover/dictionary/base.py b/plover/dictionary/base.py index 02a52bd28..6ea30a807 100644 --- a/plover/dictionary/base.py +++ b/plover/dictionary/base.py @@ -7,7 +7,7 @@ """Common elements to all dictionary formats.""" -from os.path import join, splitext +from os.path import splitext import shutil import threading @@ -23,10 +23,7 @@ def load_dictionary(filename): """Load a dictionary from a file.""" - # The dictionary path can be either absolute or relative to the - # configuration directory. - path = join(CONFIG_DIR, filename) - extension = splitext(path)[1].lower() + extension = splitext(filename)[1].lower() try: dict_type = dictionaries[extension] @@ -38,7 +35,7 @@ def load_dictionary(filename): loader = dict_type.load_dictionary try: - with open(path, 'rb') as f: + with open(filename, 'rb') as f: d = loader(f.read()) except IOError as e: raise DictionaryLoaderException(unicode(e)) diff --git a/plover/dictionary/loading_manager.py b/plover/dictionary/loading_manager.py new file mode 100644 index 000000000..e21576b6e --- /dev/null +++ b/plover/dictionary/loading_manager.py @@ -0,0 +1,52 @@ +# Copyright (c) 2013 Hesky Fisher +# See LICENSE.txt for details. + +"""Centralized place for dictionary loading operation.""" + +import threading +from plover.dictionary.base import load_dictionary +from plover.exception import DictionaryLoaderException + +class DictionaryLoadingManager(object): + def __init__(self): + self.dictionaries = {} + + def start_loading(self, filename): + if filename in self.dictionaries: + return self.dictionaries[filename] + op = DictionaryLoadingOperation(filename) + self.dictionaries[filename] = op + return op + + def load(self, filenames): + self.dictionaries = {f: self.start_loading(f) for f in filenames} + # Result must be in order given so can't just use values(). + ops = [self.dictionaries[f] for f in filenames] + results = [op.get() for op in ops] + dicts = [] + for d, e in results: + if e: + raise e + dicts.append(d) + return dicts + + +class DictionaryLoadingOperation(object): + def __init__(self, filename): + self.loading_thread = threading.Thread(target=self.load) + self.filename = filename + self.exception = None + self.dictionary = None + self.loading_thread.start() + + def load(self): + try: + self.dictionary = load_dictionary(self.filename) + except DictionaryLoaderException as e: + self.exception = e + + def get(self): + self.loading_thread.join() + return self.dictionary, self.exception + +manager = DictionaryLoadingManager() diff --git a/plover/dictionary/rtfcre_dict.py b/plover/dictionary/rtfcre_dict.py index 48fb82636..64ec55867 100644 --- a/plover/dictionary/rtfcre_dict.py +++ b/plover/dictionary/rtfcre_dict.py @@ -49,6 +49,8 @@ def handler(s, pos): self._command_pattern = re.compile( r'(\\\*)?\\([a-z]+)(-?[0-9]+)?[ ]?') self._multiple_whitespace_pattern = re.compile(r'([ ]{2,})') + # This poorly named variable indicates whether the current context is + # one where commands can be inserted (True) or not (False). self._whitespace = True def _make_re_handler(self, pattern, f): @@ -171,6 +173,16 @@ def _re_handle_eclipse_command(self, m): r'({[^\\][^{}]*})' return m.group() + # caseCATalyst doesn't put punctuation in \cxp so we will treat any + # isolated punctuation at the beginning of the translation as special. + def _re_handle_punctuation(self, m): + r'^([.?!:;,])(?=\s|$)' + if self._whitespace: + result = '{%s}' % m.group(1) + else: + result = m.group(1) + return result + def _re_handle_text(self, m): r'[^{}\\\r\n]+' text = m.group() diff --git a/plover/dictionary/test_loading_manager.py b/plover/dictionary/test_loading_manager.py new file mode 100644 index 000000000..529bb2cb3 --- /dev/null +++ b/plover/dictionary/test_loading_manager.py @@ -0,0 +1,40 @@ +# Copyright (c) 2013 Hesky Fisher +# See LICENSE.txt for details. + +"""Tests for loading_manager.py.""" + +from collections import defaultdict +import unittest +from mock import patch +import plover.dictionary.loading_manager as loading_manager + + +class DictionaryLoadingManagerTestCase(unittest.TestCase): + + def test_loading(self): + class MockLoader(object): + def __init__(self, files): + self.files = files + self.load_counts = defaultdict(int) + + def __call__(self, filename): + self.load_counts[filename] += 1 + return self.files[filename] + + files = {c: c * 5 for c in [chr(ord('a') + i) for i in range(10)]} + loader = MockLoader(files) + with patch('plover.dictionary.loading_manager.load_dictionary', loader): + manager = loading_manager.DictionaryLoadingManager() + manager.start_loading('a') + manager.start_loading('b') + results = manager.load(['c', 'b']) + # Returns the right values in the right order. + self.assertEqual(results, ['ccccc', 'bbbbb']) + # Only loaded the files once. + self.assertTrue(all(x == 1 for x in loader.load_counts.values())) + # Dropped superfluous files. + self.assertEqual(['b', 'c'], sorted(manager.dictionaries.keys())) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/plover/dictionary/test_rtfcre_dict.py b/plover/dictionary/test_rtfcre_dict.py index 83349f0d7..e7ae6da6d 100644 --- a/plover/dictionary/test_rtfcre_dict.py +++ b/plover/dictionary/test_rtfcre_dict.py @@ -39,6 +39,13 @@ def test_converter(self): (r'\par\s1', '{#Return}{#Return}'), # Continuation styles are indented too. (r'\par\s2', '{#Return}{#Return}{^ ^}'), + # caseCATalyst punctuation. + (r'.', '{.}'), + (r'. ', '{.} '), + (r' . ', ' . '), + (r'{\cxa Q.}.', 'Q..'), + (r'Mr.', 'Mr.'), # Don't mess with period that is part of a word. + (r'.attribute', '.attribute'), (r'{\cxstit contents}', 'contents'), (r'{\cxfing c}', '{&c}'), (r'{\cxp.}', '{.}'), diff --git a/plover/gui/add_translation.py b/plover/gui/add_translation.py index ff6018839..3e8111e69 100644 --- a/plover/gui/add_translation.py +++ b/plover/gui/add_translation.py @@ -2,7 +2,9 @@ # See LICENSE.txt for details. import wx +from wx.lib.utils import AdjustRectToScreen import sys +from plover.steno import normalize_steno if sys.platform.startswith('win32'): import win32gui @@ -75,11 +77,17 @@ class AddTranslationDialog(wx.Dialog): STROKES_TEXT = 'Strokes:' TRANSLATION_TEXT = 'Translation:' - def __init__(self, parent, engine): + other_instances = [] + + def __init__(self, parent, engine, config): + pos = (config.get_translation_frame_x(), + config.get_translation_frame_y()) wx.Dialog.__init__(self, parent, wx.ID_ANY, TITLE, - wx.DefaultPosition, wx.DefaultSize, + pos, wx.DefaultSize, wx.DEFAULT_DIALOG_STYLE, wx.DialogNameStr) + self.config = config + # components self.strokes_text = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) self.translation_text = wx.TextCtrl(self, style=wx.TE_PROCESS_ENTER) @@ -130,6 +138,7 @@ def __init__(self, parent, engine): global_sizer.Fit(self) global_sizer.SetSizeHints(self) self.Layout() + self.SetRect(AdjustRectToScreen(self.GetRect())) # events button.Bind(wx.EVT_BUTTON, self.on_add_translation) @@ -149,6 +158,7 @@ def __init__(self, parent, engine): self.translation_text.Bind(wx.EVT_KILL_FOCUS, self.on_translation_lost_focus) self.translation_text.Bind(wx.EVT_TEXT_ENTER, self.on_add_translation) self.Bind(wx.EVT_CLOSE, self.on_close) + self.Bind(wx.EVT_MOVE, self.on_move) self.engine = engine @@ -162,14 +172,21 @@ def __init__(self, parent, engine): self.engine.translator.set_state(self.previous_state) self.last_window = GetForegroundWindow() + + # Now that we saved the last window we'll close other instances. This + # may restore their original window but we've already saved ours so it's + # fine. + for instance in self.other_instances: + instance.Close() + del self.other_instances[:] + self.other_instances.append(self) def on_add_translation(self, event=None): d = self.engine.get_dictionary() - strokes = self.strokes_text.GetValue().upper().replace('/', ' ').split() - strokes = tuple(strokes) + strokes = self._normalized_strokes() translation = self.translation_text.GetValue().strip() if strokes and translation: - d[strokes] = translation + d.set(strokes, translation) d.save() self.Close() @@ -179,22 +196,19 @@ def on_close(self, event=None): SetForegroundWindow(self.last_window) except: pass + self.other_instances.remove(self) self.Destroy() def on_strokes_change(self, event): - stroke = event.GetString().upper() - self.strokes_text.ChangeValue(stroke) - self.strokes_text.SetInsertionPointEnd() - strokes = stroke.replace('/', ' ').split() - stroke = '/'.join(strokes) - if stroke: - key = tuple(strokes) + key = self._normalized_strokes() + if key: d = self.engine.get_dictionary() - translation = d.raw_get(key, None) + translation = d.raw_lookup(key) + strokes = '/'.join(key) if translation: - label = '%s maps to %s' % (stroke, translation) + label = '%s maps to %s' % (strokes, translation) else: - label = '%s is not in the dictionary' % stroke + label = '%s is not in the dictionary' % strokes else: label = '' self.stroke_mapping_text.SetLabel(label) @@ -206,7 +220,7 @@ def on_translation_change(self, event): translation = event.GetString().strip() if translation: d = self.engine.get_dictionary() - strokes_list = d.reverse[translation] + strokes_list = d.reverse_lookup(translation) if strokes_list: strokes = ', '.join('/'.join(x) for x in strokes_list) label = '%s is mapped from %s' % (translation, strokes) @@ -241,8 +255,19 @@ def stroke_dict_filter(self, key, value): special = '{#' in escaped or '{PLOVER:' in escaped return not special -def Show(parent, engine): - dialog_instance = AddTranslationDialog(parent, engine) + def on_move(self, event): + pos = self.GetScreenPositionTuple() + self.config.set_translation_frame_x(pos[0]) + self.config.set_translation_frame_y(pos[1]) + event.Skip() + + def _normalized_strokes(self): + strokes = self.strokes_text.GetValue().upper().replace('/', ' ').split() + strokes = normalize_steno('/'.join(strokes)) + return strokes + +def Show(parent, engine, config): + dialog_instance = AddTranslationDialog(parent, engine, config) dialog_instance.Show() dialog_instance.Raise() dialog_instance.strokes_text.SetFocus() diff --git a/plover/gui/config.py b/plover/gui/config.py index 9252661e1..d0060e4a2 100644 --- a/plover/gui/config.py +++ b/plover/gui/config.py @@ -4,33 +4,43 @@ """Configuration dialog graphical user interface.""" import os +import os.path import wx +from wx.lib.utils import AdjustRectToScreen +from collections import namedtuple import wx.lib.filebrowsebutton as filebrowse +from wx.lib.scrolledpanel import ScrolledPanel import plover.config as conf from plover.gui.serial_config import SerialConfigDialog import plover.gui.add_translation from plover.app import update_engine from plover.machine.registry import machine_registry from plover.exception import InvalidConfigurationError +from plover.dictionary.loading_manager import manager as dict_manager +from plover.gui.paper_tape import StrokeDisplayDialog +from plover.gui.keyboard_config import KeyboardConfigDialog ADD_TRANSLATION_BUTTON_NAME = "Add Translation" +ADD_DICTIONARY_BUTTON_NAME = "Add Dictionary" MACHINE_CONFIG_TAB_NAME = "Machine" +DISPLAY_CONFIG_TAB_NAME = "Display" DICTIONARY_CONFIG_TAB_NAME = "Dictionary" LOGGING_CONFIG_TAB_NAME = "Logging" SAVE_CONFIG_BUTTON_NAME = "Save" MACHINE_LABEL = "Stenotype Machine:" MACHINE_AUTO_START_LABEL = "Automatically Start" -DICT_FILE_LABEL = "Dictionary File:" -DICT_FILE_DIALOG_TITLE = "Select a Dictionary File" LOG_FILE_LABEL = "Log File:" LOG_STROKES_LABEL = "Log Strokes" LOG_TRANSLATIONS_LABEL = "Log Translations" LOG_FILE_DIALOG_TITLE = "Select a Log File" CONFIG_BUTTON_NAME = "Configure..." -CONFIG_PANEL_SIZE = (600, 400) +CONFIG_PANEL_SIZE = (-1, -1) UI_BORDER = 4 COMPONENT_SPACE = 3 - +UP_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'up.png') +DOWN_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'down.png') +REMOVE_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'remove.png') +TITLE = "Plover Configuration" class ConfigurationDialog(wx.Dialog): """A GUI for viewing and editing Plover configuration files. @@ -40,30 +50,34 @@ class ConfigurationDialog(wx.Dialog): application, which is typically after an application restart. """ - def __init__(self, engine, config, config_file, - parent=None, - id=-1, - title="Plover Configuration", - pos=wx.DefaultPosition, - size=wx.DefaultSize, - style=wx.DEFAULT_DIALOG_STYLE): + + # Keep track of other instances of ConfigurationDialog. + other_instances = [] + + def __init__(self, engine, config, parent): """Create a configuration GUI based on the given config file. Arguments: - config_file -- The absolute or relative path to the configuration file to view and edit. during_plover_init -- If this is set to True, the configuration dialog won't tell the user that Plover needs to be restarted. """ - wx.Dialog.__init__(self, parent, id, title, pos, size, style) + pos = (config.get_config_frame_x(), config.get_config_frame_y()) + size = wx.Size(config.get_config_frame_width(), + config.get_config_frame_height()) + wx.Dialog.__init__(self, parent, title=TITLE, pos=pos, size=size, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) self.engine = engine self.config = config - self.config_file = config_file - self._setup_ui() + # Close all other instances. + if self.other_instances: + for instance in self.other_instances: + instance.Close() + del self.other_instances[:] + self.other_instances.append(self) - def _setup_ui(self): sizer = wx.BoxSizer(wx.VERTICAL) # The tab container @@ -74,13 +88,15 @@ def _setup_ui(self): self.dictionary_config = DictionaryConfig(self.engine, self.config, notebook) self.logging_config = LoggingConfig(self.config, notebook) + self.display_config = DisplayConfig(self.config, notebook) # Adding each tab notebook.AddPage(self.machine_config, MACHINE_CONFIG_TAB_NAME) notebook.AddPage(self.dictionary_config, DICTIONARY_CONFIG_TAB_NAME) notebook.AddPage(self.logging_config, LOGGING_CONFIG_TAB_NAME) + notebook.AddPage(self.display_config, DISPLAY_CONFIG_TAB_NAME) - sizer.Add(notebook) + sizer.Add(notebook, proportion=1, flag=wx.EXPAND) # The bottom button container button_sizer = wx.StdDialogButtonSizer() @@ -96,11 +112,36 @@ def _setup_ui(self): button_sizer.Realize() sizer.Add(button_sizer, flag=wx.ALL | wx.ALIGN_RIGHT, border=UI_BORDER) + self.SetSizer(sizer) - sizer.Fit(self) - + self.SetAutoLayout(True) + sizer.Layout() + #sizer.Fit(self) + + self.SetRect(AdjustRectToScreen(self.GetRect())) + # Binding the save button to the self._save callback self.Bind(wx.EVT_BUTTON, self._save, save_button) + + self.Bind(wx.EVT_MOVE, self.on_move) + self.Bind(wx.EVT_SIZE, self.on_size) + self.Bind(wx.EVT_CLOSE, self.on_close) + + def on_move(self, event): + pos = self.GetScreenPositionTuple() + self.config.set_config_frame_x(pos[0]) + self.config.set_config_frame_y(pos[1]) + event.Skip() + + def on_size(self, event): + size = self.GetSize() + self.config.set_config_frame_width(size.GetWidth()) + self.config.set_config_frame_height(size.GetHeight()) + event.Skip() + + def on_close(self, event): + self.other_instances.remove(self) + event.Skip() def _save(self, event): old_config = self.config.clone() @@ -108,6 +149,7 @@ def _save(self, event): self.machine_config.save() self.dictionary_config.save() self.logging_config.save() + self.display_config.save() try: update_engine(self.engine, old_config, self.config) @@ -120,7 +162,7 @@ def _save(self, event): alert_dialog.Destroy() return - with open(self.config_file, 'wb') as f: + with open(self.config.target_file, 'wb') as f: self.config.save(f) if self.IsModal(): @@ -144,8 +186,6 @@ def __init__(self, config, parent): """ wx.Panel.__init__(self, parent, size=CONFIG_PANEL_SIZE) self.config = config - self.config_class = None - self.config_instance = None sizer = wx.BoxSizer(wx.VERTICAL) box = wx.BoxSizer(wx.HORIZONTAL) box.Add(wx.StaticText(self, label=MACHINE_LABEL), @@ -190,8 +230,14 @@ class Struct(object): def __init__(self, **kwargs): self.__dict__.update(kwargs) config_instance = Struct(**self.advanced_options) - scd = SerialConfigDialog(config_instance, self) - scd.ShowModal() # SerialConfigDialog destroys itself. + dialog = None + if 'port' in self.advanced_options: + scd = SerialConfigDialog(config_instance, self, self.config) + scd.ShowModal() # SerialConfigDialog destroys itself. + else: + kbd = KeyboardConfigDialog(config_instance, self, self.config) + kbd.ShowModal() + kbd.Destroy() self.advanced_options = config_instance.__dict__ def _update(self, event=None): @@ -201,7 +247,12 @@ def _update(self, event=None): self.advanced_options = options self.config_button.Enable(bool(options)) -class DictionaryConfig(wx.Panel): + +class DictionaryConfig(ScrolledPanel): + + DictionaryControls = namedtuple('DictionaryControls', + 'sizer up down remove label') + """Dictionary configuration graphical user interface.""" def __init__(self, engine, config, parent): """Create a configuration component based on the given ConfigParser. @@ -213,43 +264,112 @@ def __init__(self, engine, config, parent): parent -- This component's parent component. """ - wx.Panel.__init__(self, parent, size=CONFIG_PANEL_SIZE) + ScrolledPanel.__init__(self, parent, size=CONFIG_PANEL_SIZE) self.engine = engine self.config = config - sizer = wx.BoxSizer(wx.VERTICAL) - dict_file = config.get_dictionary_file_name() - dict_file = os.path.join(conf.CONFIG_DIR, dict_file) - dict_dir = os.path.split(dict_file)[0] - mask = 'Json files (*%s)|*%s|RTF/CRE files (*%s)|*%s' % ( - conf.JSON_EXTENSION, conf.JSON_EXTENSION, - conf.RTF_EXTENSION, conf.RTF_EXTENSION, - ) - self.file_browser = filebrowse.FileBrowseButton( - self, - labelText=DICT_FILE_LABEL, - fileMask=mask, - fileMode=wx.OPEN, - dialogTitle=DICT_FILE_DIALOG_TITLE, - initialValue=dict_file, - startDirectory=dict_dir, - ) - sizer.Add(self.file_browser, border=UI_BORDER, flag=wx.ALL | wx.EXPAND) + dictionaries = config.get_dictionary_file_names() - button = wx.Button(self, -1, ADD_TRANSLATION_BUTTON_NAME) - sizer.Add(button, border=UI_BORDER, flag=wx.ALL) + self.up_bitmap = wx.Bitmap(UP_IMAGE_FILE, wx.BITMAP_TYPE_PNG) + self.down_bitmap = wx.Bitmap(DOWN_IMAGE_FILE, wx.BITMAP_TYPE_PNG) + self.remove_bitmap = wx.Bitmap(REMOVE_IMAGE_FILE, wx.BITMAP_TYPE_PNG) - self.SetSizer(sizer) + main_sizer = wx.BoxSizer(wx.VERTICAL) + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + + button = wx.Button(self, wx.ID_ANY, ADD_TRANSLATION_BUTTON_NAME) + button_sizer.Add(button, border=UI_BORDER, flag=wx.ALL) button.Bind(wx.EVT_BUTTON, self.show_add_translation) + + button = wx.Button(self, wx.ID_ANY, ADD_DICTIONARY_BUTTON_NAME) + button_sizer.Add(button, border=UI_BORDER, + flag=wx.TOP | wx.BOTTOM | wx.RIGHT) + button.Bind(wx.EVT_BUTTON, self.add_dictionary) + + main_sizer.Add(button_sizer) + + self.dictionary_controls = [] + self.dicts_sizer = wx.BoxSizer(wx.VERTICAL) + for filename in dictionaries: + self.add_row(filename) + + main_sizer.Add(self.dicts_sizer) + + self.mask = 'Json files (*%s)|*%s|RTF/CRE files (*%s)|*%s' % ( + conf.JSON_EXTENSION, conf.JSON_EXTENSION, + conf.RTF_EXTENSION, conf.RTF_EXTENSION, + ) + + self.SetSizer(main_sizer) + self.SetupScrolling() def save(self): """Write all parameters to the config.""" - self.config.set_dictionary_file_name(self.file_browser.GetValue()) + filenames = [x.label.GetLabel() for x in self.dictionary_controls] + self.config.set_dictionary_file_names(filenames) def show_add_translation(self, event): - plover.gui.add_translation.Show(self, self.engine) - - + plover.gui.add_translation.Show(self, self.engine, self.config) + + def add_dictionary(self, event): + dlg = wx.FileDialog(self, "Choose a file", os.getcwd(), "", self.mask, + wx.OPEN) + if dlg.ShowModal() == wx.ID_OK: + path = dlg.GetPath() + all_dicts = [x.label.GetLabel() for x in self.dictionary_controls] + if path not in all_dicts: + self.add_row(path) + dlg.Destroy() + + def add_row(self, filename): + dict_manager.start_loading(filename) + index = len(self.dictionary_controls) + sizer = wx.BoxSizer(wx.HORIZONTAL) + up = wx.BitmapButton(self, bitmap=self.up_bitmap) + up.Bind(wx.EVT_BUTTON, lambda e: self.move_row_down(index-1)) + if len(self.dictionary_controls) == 0: + up.Disable() + else: + self.dictionary_controls[-1].down.Enable() + sizer.Add(up) + down = wx.BitmapButton(self, bitmap=self.down_bitmap) + down.Bind(wx.EVT_BUTTON, lambda e: self.move_row_down(index)) + down.Disable() + sizer.Add(down) + remove = wx.BitmapButton(self, bitmap=self.remove_bitmap) + remove.Bind(wx.EVT_BUTTON, + lambda e: wx.CallAfter(self.remove_row, index)) + sizer.Add(remove) + label = wx.StaticText(self, label=filename) + sizer.Add(label) + controls = self.DictionaryControls(sizer, up, down, remove, label) + self.dictionary_controls.append(controls) + self.dicts_sizer.Add(sizer) + if self.GetSizer(): + self.GetSizer().Layout() + + def remove_row(self, index): + names = [self.dictionary_controls[i].label.GetLabel() + for i in range(index+1, len(self.dictionary_controls))] + for i, name in enumerate(names, start=index): + self.dictionary_controls[i].label.SetLabel(name) + controls = self.dictionary_controls[-1] + self.dicts_sizer.Detach(controls.sizer) + for e in controls: + e.Destroy() + del self.dictionary_controls[-1] + if self.dictionary_controls: + self.dictionary_controls[-1].down.Disable() + self.GetSizer().Layout() + + def move_row_down(self, index): + top_label = self.dictionary_controls[index].label + bottom_label = self.dictionary_controls[index+1].label + tmp = bottom_label.GetLabel() + bottom_label.SetLabel(top_label.GetLabel()) + top_label.SetLabel(tmp) + self.GetSizer().Layout() + class LoggingConfig(wx.Panel): """Logging configuration graphical user interface.""" def __init__(self, config, parent): @@ -300,3 +420,42 @@ def save(self): self.log_strokes_checkbox.GetValue()) self.config.set_enable_translation_logging( self.log_translations_checkbox.GetValue()) + +class DisplayConfig(wx.Panel): + + SHOW_STROKES_TEXT = "Open strokes display on startup" + SHOW_STROKES_BUTTON_TEXT = "Open stroke display" + + """Display configuration graphical user interface.""" + def __init__(self, config, parent): + """Create a configuration component based on the given Config. + + Arguments: + + config -- A Config object. + + parent -- This component's parent component. + + """ + wx.Panel.__init__(self, parent, size=CONFIG_PANEL_SIZE) + self.config = config + sizer = wx.BoxSizer(wx.VERTICAL) + + show_strokes_button = wx.Button(self, + label=self.SHOW_STROKES_BUTTON_TEXT) + show_strokes_button.Bind(wx.EVT_BUTTON, self.on_show_strokes) + sizer.Add(show_strokes_button, border=UI_BORDER, flag=wx.ALL) + + self.show_strokes = wx.CheckBox(self, label=self.SHOW_STROKES_TEXT) + self.show_strokes.SetValue(config.get_show_stroke_display()) + sizer.Add(self.show_strokes, border=UI_BORDER, + flag=wx.LEFT | wx.RIGHT | wx.BOTTOM) + + self.SetSizer(sizer) + + def save(self): + """Write all parameters to the config.""" + self.config.set_show_stroke_display(self.show_strokes.GetValue()) + + def on_show_strokes(self, event): + StrokeDisplayDialog.display(self.GetParent(), self.config) diff --git a/plover/gui/keyboard_config.py b/plover/gui/keyboard_config.py new file mode 100644 index 000000000..c88c0ee2b --- /dev/null +++ b/plover/gui/keyboard_config.py @@ -0,0 +1,65 @@ +# Copyright (c) 2013 Hesky Fisher +# See LICENSE.txt for details. + +import wx +from wx.lib.utils import AdjustRectToScreen + +DIALOG_TITLE = 'Keyboard Configuration' +ARPEGGIATE_LABEL = "Arpeggiate" +ARPEGGIATE_INSTRUCTIONS = """Arpeggiate allows using non-NKRO keyboards. +Each key can be pressed separately and the space bar +is pressed to send the stroke.""" +UI_BORDER = 4 + +class KeyboardConfigDialog(wx.Dialog): + """Keyboard configuration dialog.""" + + def __init__(self, options, parent, config): + self.config = config + self.options = options + + pos = (config.get_keyboard_config_frame_x(), + config.get_keyboard_config_frame_y()) + wx.Dialog.__init__(self, parent, title=DIALOG_TITLE, pos=pos) + + sizer = wx.BoxSizer(wx.VERTICAL) + + instructions = wx.StaticText(self, label=ARPEGGIATE_INSTRUCTIONS) + sizer.Add(instructions, border=UI_BORDER, flag=wx.ALL) + self.arpeggiate_option = wx.CheckBox(self, label=ARPEGGIATE_LABEL) + self.arpeggiate_option.SetValue(options.arpeggiate) + sizer.Add(self.arpeggiate_option, border=UI_BORDER, + flag=wx.LEFT | wx.RIGHT | wx.BOTTOM) + + ok_button = wx.Button(self, id=wx.ID_OK) + ok_button.SetDefault() + cancel_button = wx.Button(self, id=wx.ID_CANCEL) + + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + button_sizer.Add(ok_button, border=UI_BORDER, flag=wx.ALL) + button_sizer.Add(cancel_button, border=UI_BORDER, flag=wx.ALL) + sizer.Add(button_sizer, flag=wx.ALL | wx.ALIGN_RIGHT, border=UI_BORDER) + + self.SetSizer(sizer) + sizer.Fit(self) + self.SetRect(AdjustRectToScreen(self.GetRect())) + + self.Bind(wx.EVT_MOVE, self.on_move) + ok_button.Bind(wx.EVT_BUTTON, self.on_ok) + cancel_button.Bind(wx.EVT_BUTTON, self.on_cancel) + + def on_move(self, event): + pos = self.GetScreenPositionTuple() + self.config.set_keyboard_config_frame_x(pos[0]) + self.config.set_keyboard_config_frame_y(pos[1]) + event.Skip() + + def on_ok(self, event): + self.options.arpeggiate = self.arpeggiate_option.GetValue() + self.EndModal(wx.ID_OK) + + def on_cancel(self, event): + self.EndModal(wx.ID_CANCEL) + + + \ No newline at end of file diff --git a/plover/gui/main.py b/plover/gui/main.py index 5fd0dd35a..3b112a6f2 100644 --- a/plover/gui/main.py +++ b/plover/gui/main.py @@ -11,14 +11,16 @@ import os import wx import wx.animate +from wx.lib.utils import AdjustRectToScreen import plover.app as app -import plover.config as conf +from plover.config import ASSETS_DIR, SPINNER_FILE from plover.gui.config import ConfigurationDialog import plover.gui.add_translation from plover.oslayer.keyboardcontrol import KeyboardEmulation from plover.machine.base import STATE_ERROR, STATE_INITIALIZING, STATE_RUNNING from plover.machine.registry import machine_registry from plover.exception import InvalidConfigurationError +from plover.gui.paper_tape import StrokeDisplayDialog from plover import __name__ as __software_name__ from plover import __version__ @@ -32,28 +34,29 @@ class PloverGUI(wx.App): """The main entry point for the Plover application.""" - def __init__(self): + def __init__(self, config): + self.config = config wx.App.__init__(self, redirect=False) def OnInit(self): """Called just before the application starts.""" - frame = Frame(conf.CONFIG_FILE) + frame = MainFrame(self.config) self.SetTopWindow(frame) frame.Show() return True -class Frame(wx.Frame): +class MainFrame(wx.Frame): """The top-level GUI element of the Plover application.""" # Class constants. TITLE = "Plover" ALERT_DIALOG_TITLE = TITLE - ON_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'plover_on.png') - OFF_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'plover_off.png') - CONNECTED_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'connected.png') - DISCONNECTED_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'disconnected.png') - REFRESH_IMAGE_FILE = os.path.join(conf.ASSETS_DIR, 'refresh.png') + ON_IMAGE_FILE = os.path.join(ASSETS_DIR, 'plover_on.png') + OFF_IMAGE_FILE = os.path.join(ASSETS_DIR, 'plover_off.png') + CONNECTED_IMAGE_FILE = os.path.join(ASSETS_DIR, 'connected.png') + DISCONNECTED_IMAGE_FILE = os.path.join(ASSETS_DIR, 'disconnected.png') + REFRESH_IMAGE_FILE = os.path.join(ASSETS_DIR, 'refresh.png') BORDER = 5 RUNNING_MESSAGE = "running" STOPPED_MESSAGE = "stopped" @@ -69,11 +72,12 @@ class Frame(wx.Frame): COMMAND_FOCUS = 'FOCUS' COMMAND_QUIT = 'QUIT' - def __init__(self, config_file): - wx.Frame.__init__(self, None, - title=Frame.TITLE, - pos=wx.DefaultPosition, - size=wx.DefaultSize, + def __init__(self, config): + self.config = config + + pos = wx.DefaultPosition + size = wx.DefaultSize + wx.Frame.__init__(self, None, title=self.TITLE, pos=pos, size=size, style=wx.DEFAULT_FRAME_STYLE & ~(wx.RESIZE_BORDER | wx.RESIZE_BOX | wx.MAXIMIZE_BOX)) @@ -95,7 +99,7 @@ def __init__(self, config_file): # Machine status. # TODO: Figure out why spinner has darker gray background. - self.spinner = wx.animate.GIFAnimationCtrl(self, -1, conf.SPINNER_FILE) + self.spinner = wx.animate.GIFAnimationCtrl(self, -1, SPINNER_FILE) self.spinner.GetPlayer().UseBackgroundColour(True) self.spinner.Hide() @@ -147,28 +151,26 @@ def __init__(self, config_file): global_sizer.Add(sizer) self.SetSizer(global_sizer) global_sizer.Fit(self) + + self.SetRect(AdjustRectToScreen(self.GetRect())) self.Bind(wx.EVT_CLOSE, self._quit) + self.Bind(wx.EVT_MOVE, self.on_move) self.reconnect_button.Bind(wx.EVT_BUTTON, lambda e: app.reset_machine(self.steno_engine, self.config)) - self.config = conf.Config() try: - with open(config_file) as f: + with open(config.target_file, 'rb') as f: self.config.load(f) except InvalidConfigurationError as e: self._show_alert(unicode(e)) - self.config = conf.Config() + self.config.clear() self.steno_engine = app.StenoEngine() self.steno_engine.add_callback( lambda s: wx.CallAfter(self._update_status, s)) - self.steno_engine.set_output(Output(self.consume_command)) - - self.config_dialog = ConfigurationDialog(self.steno_engine, - self.config, - config_file, - parent=self) + self.steno_engine.set_output( + Output(self.consume_command, self.steno_engine)) while True: try: @@ -176,28 +178,57 @@ def __init__(self, config_file): break except InvalidConfigurationError as e: self.show_alert(unicode(e)) - ret = self.config_dialog.ShowModal() + dlg = ConfigurationDialog(self.steno_engine, + self.config, + parent=self) + re = dlg.ShowModel() if ret == wx.ID_CANCEL: self._quit() return + + self.steno_engine.add_stroke_listener( + StrokeDisplayDialog.stroke_handler) + if self.config.get_show_stroke_display(): + StrokeDisplayDialog.display(self, self.config) + + pos = (config.get_main_frame_x(), config.get_main_frame_y()) + self.SetPosition(pos) def consume_command(self, command): - # TODO: When using keyboard to resume the stroke is typed. - if command == self.COMMAND_SUSPEND: - self.steno_engine.set_is_running(False) - elif command == self.COMMAND_RESUME: - self.steno_engine.set_is_running(True) + # The first commands can be used whether plover is active or not. + if command == self.COMMAND_RESUME: + wx.CallAfter(self.steno_engine.set_is_running, True) + return True elif command == self.COMMAND_TOGGLE: - self.steno_engine.set_is_running(not self.steno_engine.is_running) + wx.CallAfter(self.steno_engine.set_is_running, + not self.steno_engine.is_running) + return True + elif command == self.COMMAND_QUIT: + wx.CallAfter(self._quit) + return True + + if not self.steno_engine.is_running: + return False + + # These commands can only be run when plover is active. + if command == self.COMMAND_SUSPEND: + wx.CallAfter(self.steno_engine.set_is_running, False) + return True elif command == self.COMMAND_CONFIGURE: - self._show_config_dialog() + wx.CallAfter(self._show_config_dialog) + return True elif command == self.COMMAND_FOCUS: - self.Raise() - self.Iconize(False) - elif command == self.COMMAND_QUIT: - self._quit() + def f(): + self.Raise() + self.Iconize(False) + wx.CallAfter(f) + return True elif command == self.COMMAND_ADD_TRANSLATION: - plover.gui.add_translation.Show(self, self.steno_engine) + wx.CallAfter(plover.gui.add_translation.Show, + self, self.steno_engine, self.config) + return True + + return False def _update_status(self, state): if state: @@ -239,7 +270,10 @@ def _toggle_steno_engine(self, event=None): self.steno_engine.set_is_running(not self.steno_engine.is_running) def _show_config_dialog(self, event=None): - self.config_dialog.Show() + dlg = ConfigurationDialog(self.steno_engine, + self.config, + parent=self) + dlg.Show() def _show_about_dialog(self, event=None): """Called when the About... button is clicked.""" @@ -261,10 +295,18 @@ def _show_alert(self, message): alert_dialog.ShowModal() alert_dialog.Destroy() + def on_move(self, event): + pos = self.GetScreenPositionTuple() + self.config.set_main_frame_x(pos[0]) + self.config.set_main_frame_y(pos[1]) + event.Skip() + + class Output(object): - def __init__(self, engine_command_callback): + def __init__(self, engine_command_callback, engine): self.engine_command_callback = engine_command_callback self.keyboard_control = KeyboardEmulation() + self.engine = engine def send_backspaces(self, b): wx.CallAfter(self.keyboard_control.send_backspaces, b) @@ -275,5 +317,8 @@ def send_string(self, t): def send_key_combination(self, c): wx.CallAfter(self.keyboard_control.send_key_combination, c) + # TODO: test all the commands now def send_engine_command(self, c): - wx.CallAfter(self.engine_command_callback, c) + result = self.engine_command_callback(c) + if result and not self.engine.is_running: + self.engine.machine.suppress = self.send_backspaces diff --git a/plover/gui/paper_tape.py b/plover/gui/paper_tape.py new file mode 100644 index 000000000..6d3ad051e --- /dev/null +++ b/plover/gui/paper_tape.py @@ -0,0 +1,286 @@ +# Copyright (c) 2013 Hesky Fisher +# See LICENSE.txt for details. + +"""A gui display of recent strokes.""" + +import wx +from wx.lib.utils import AdjustRectToScreen +from collections import deque +from plover.steno import STENO_KEY_ORDER, STENO_KEY_NUMBERS + +TITLE = 'Plover: Stroke Display' +ON_TOP_TEXT = "Always on top" +UI_BORDER = 4 +ALL_KEYS = ''.join(x[0].strip('-') for x in + sorted(STENO_KEY_ORDER.items(), key=lambda x: x[1])) +REVERSE_NUMBERS = {v: k for k, v in STENO_KEY_NUMBERS.items()} +STROKE_LINES = 30 +STYLE_TEXT = 'Style:' +STYLE_PAPER = 'Paper' +STYLE_RAW = 'Raw' +STYLES = [STYLE_PAPER, STYLE_RAW] + +class StrokeDisplayDialog(wx.Dialog): + + other_instances = [] + strokes = deque(maxlen=STROKE_LINES) + + def __init__(self, parent, config): + self.config = config + on_top = config.get_stroke_display_on_top() + style = wx.DEFAULT_DIALOG_STYLE + if on_top: + style |= wx.STAY_ON_TOP + pos = (config.get_stroke_display_x(), config.get_stroke_display_y()) + wx.Dialog.__init__(self, parent, title=TITLE, style=style, pos=pos) + + self.SetBackgroundColour(wx.WHITE) + + sizer = wx.BoxSizer(wx.VERTICAL) + + self.on_top = wx.CheckBox(self, label=ON_TOP_TEXT) + self.on_top.SetValue(config.get_stroke_display_on_top()) + self.on_top.Bind(wx.EVT_CHECKBOX, self.handle_on_top) + sizer.Add(self.on_top, flag=wx.ALL, border=UI_BORDER) + + box = wx.BoxSizer(wx.HORIZONTAL) + box.Add(wx.StaticText(self, label=STYLE_TEXT), + border=UI_BORDER, + flag=wx.ALIGN_RIGHT | wx.ALIGN_CENTER_VERTICAL | wx.RIGHT) + self.choice = wx.Choice(self, choices=STYLES) + self.choice.SetStringSelection(self.config.get_stroke_display_style()) + self.choice.Bind(wx.EVT_CHOICE, self.on_style) + box.Add(self.choice, proportion=1) + sizer.Add(box, flag=wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, + border=UI_BORDER) + + self.header = MyStaticText(self, label=ALL_KEYS) + font = self.header.GetFont() + font.SetFaceName("Courier") + self.header.SetFont(font) + sizer.Add(self.header, flag=wx.LEFT | wx.RIGHT | wx.BOTTOM, + border=UI_BORDER) + sizer.Add(wx.StaticLine(self), flag=wx.EXPAND) + + self.labels = [] + for i in range(STROKE_LINES): + label = MyStaticText(self, label=' ') + self.labels.append(label) + font = label.GetFont() + font.SetFaceName("Courier") + font.SetWeight(wx.FONTWEIGHT_NORMAL) + label.SetFont(font) + sizer.Add(label, border=UI_BORDER, + flag=wx.LEFT | wx.RIGHT | wx.BOTTOM) + + self.SetSizer(sizer) + self.SetAutoLayout(True) + sizer.Layout() + sizer.Fit(self) + + self.on_style() + + self.Show() + self.close_all() + self.other_instances.append(self) + + self.SetRect(AdjustRectToScreen(self.GetRect())) + + self.Bind(wx.EVT_MOVE, self.on_move) + + def on_move(self, event): + pos = self.GetScreenPositionTuple() + self.config.set_stroke_display_x(pos[0]) + self.config.set_stroke_display_y(pos[1]) + event.Skip() + + def on_close(self, event): + self.other_instances.remove(self) + event.Skip() + + def show_text(self, text): + for i in range(len(self.labels) - 1): + self.labels[i].SetLabel(self.labels[i + 1].GetLabel()) + self.labels[-1].SetLabel(text) + + def show_stroke(self, stroke): + self.show_text(self.formatter(stroke)) + + def handle_on_top(self, event): + self.config.set_stroke_display_on_top(event.IsChecked()) + self.display(self.GetParent(), self.config) + + def on_style(self, event=None): + format = self.choice.GetStringSelection() + self.formatter = getattr(self, format.lower() + '_format') + for stroke in self.strokes: + self.show_stroke(stroke) + self.header.SetLabel(ALL_KEYS if format == STYLE_PAPER else ' ') + self.config.set_stroke_display_style(format) + + def paper_format(self, stroke): + text = [' '] * len(ALL_KEYS) + keys = stroke.steno_keys[:] + if any(key in REVERSE_NUMBERS for key in keys): + keys.append('#') + for key in keys: + if key in REVERSE_NUMBERS: + key = REVERSE_NUMBERS[key] + index = STENO_KEY_ORDER[key] + text[index] = ALL_KEYS[index] + text = ''.join(text) + return text + + def raw_format(self, stroke): + return stroke.rtfcre + + @staticmethod + def close_all(): + for instance in StrokeDisplayDialog.other_instances: + instance.Close() + del StrokeDisplayDialog.other_instances[:] + + @staticmethod + def stroke_handler(stroke): + StrokeDisplayDialog.strokes.append(stroke) + for instance in StrokeDisplayDialog.other_instances: + wx.CallAfter(instance.show_stroke, stroke) + + @staticmethod + def display(parent, config): + # StrokeDisplayDialog shows itself. + StrokeDisplayDialog(parent, config) + + +# This class exists solely so that the text doesn't get grayed out when the +# window is not in focus. +class MyStaticText(wx.PyControl): + def __init__(self, parent, id=wx.ID_ANY, label="", + pos=wx.DefaultPosition, size=wx.DefaultSize, + style=0, validator=wx.DefaultValidator, + name="MyStaticText"): + wx.PyControl.__init__(self, parent, id, pos, size, style|wx.NO_BORDER, + validator, name) + wx.PyControl.SetLabel(self, label) + self.InheritAttributes() + self.SetInitialSize(size) + self.Bind(wx.EVT_PAINT, self.OnPaint) + self.Bind(wx.EVT_ERASE_BACKGROUND, self.OnEraseBackground) + + def OnPaint(self, event): + dc = wx.BufferedPaintDC(self) + self.Draw(dc) + + def Draw(self, dc): + width, height = self.GetClientSize() + + if not width or not height: + return + + backBrush = wx.Brush(wx.WHITE, wx.SOLID) + dc.SetBackground(backBrush) + dc.Clear() + + dc.SetTextForeground(wx.BLACK) + dc.SetFont(self.GetFont()) + label = self.GetLabel() + dc.DrawText(label, 0, 0) + + def OnEraseBackground(self, event): + pass + + def SetLabel(self, label): + wx.PyControl.SetLabel(self, label) + self.InvalidateBestSize() + self.SetSize(self.GetBestSize()) + self.Refresh() + + def SetFont(self, font): + wx.PyControl.SetFont(self, font) + self.InvalidateBestSize() + self.SetSize(self.GetBestSize()) + self.Refresh() + + def DoGetBestSize(self): + label = self.GetLabel() + font = self.GetFont() + + if not font: + font = wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT) + + dc = wx.ClientDC(self) + dc.SetFont(font) + + textWidth, textHeight = dc.GetTextExtent(label) + best = wx.Size(textWidth, textHeight) + self.CacheBestSize(best) + return best + + def AcceptsFocus(self): + return False + + def SetForegroundColour(self, colour): + wx.PyControl.SetForegroundColour(self, colour) + self.Refresh() + + def SetBackgroundColour(self, colour): + wx.PyControl.SetBackgroundColour(self, colour) + self.Refresh() + + def GetDefaultAttributes(self): + return wx.StaticText.GetClassDefaultAttributes() + + def ShouldInheritColours(self): + return True + + +class fake_config(object): + def __init__(self): + self.on_top = True + self.target_file = 'testfile' + self.x = -1 + self.y = -1 + self.style = 'Raw' + + def get_stroke_display_on_top(self): + return self.on_top + + def set_stroke_display_on_top(self, b): + self.on_top = b + + def get_stroke_display_x(self): + return self.x + + def set_stroke_display_x(self, x): + self.x = x + + def get_stroke_display_y(self): + return self.y + + def set_stroke_display_y(self, y): + self.y = y + + def set_stroke_display_style(self, style): + self.style = style + + def get_stroke_display_style(self): + return self.style + + def save(self, fp): + pass + +class TestApp(wx.App): + def OnInit(self): + StrokeDisplayDialog.display(None, fake_config()) + #self.SetTopWindow(dlg) + import random + from plover.steno import Stroke + keys = STENO_KEY_ORDER.keys() + for i in range(100): + num = random.randint(1, len(keys)) + StrokeDisplayDialog.stroke_handler(Stroke(random.sample(keys, num))) + return True + +if __name__ == "__main__": + app = TestApp(0) + app.MainLoop() diff --git a/plover/gui/serial_config.py b/plover/gui/serial_config.py index 0b35aef2e..74448adc3 100644 --- a/plover/gui/serial_config.py +++ b/plover/gui/serial_config.py @@ -5,13 +5,14 @@ """A graphical user interface for configuring a serial port.""" from serial import Serial +from serial.tools.list_ports import comports import string import wx import wx.animate +from wx.lib.utils import AdjustRectToScreen from threading import Thread import os.path -from plover.oslayer.comscan import comscan from plover.config import SPINNER_FILE DIALOG_TITLE = 'Serial Port Configuration' @@ -38,20 +39,12 @@ def enumerate_ports(): """Enumerates available ports""" - return sorted([x['name'] for x in comscan() if x['available']]) + return sorted(x[0] for x in comports()) class SerialConfigDialog(wx.Dialog): """Serial port configuration dialog.""" - def __init__(self, - serial, - parent, - id=wx.ID_ANY, - title='', - pos=wx.DefaultPosition, - size=wx.DefaultSize, - style=wx.DEFAULT_DIALOG_STYLE, - name=wx.DialogNameStr): + def __init__(self, serial, parent, config): """Create a configuration GUI for the given serial port. Arguments: @@ -61,22 +54,14 @@ def __init__(self, be changed if the cancel button is pressed. parent -- See wx.Dialog. - - id -- See wx.Dialog. - - title -- See wx.Dialog. - - pos -- See wx.Dialog. - - size -- See wx.Dialog. - - style -- See wx.Dialog. - - name -- See wx.Dialog. + + config -- The config object that holds plover's settings. """ - wx.Dialog.__init__(self, parent, id, title, pos, size, style, name) - self.SetTitle(DIALOG_TITLE) + self.config = config + pos = (config.get_serial_config_frame_x(), + config.get_serial_config_frame_y()) + wx.Dialog.__init__(self, parent, title=DIALOG_TITLE, pos=pos) self.serial = serial @@ -208,7 +193,9 @@ def __init__(self, global_sizer.Fit(self) global_sizer.SetSizeHints(self) self.Layout() + self.SetRect(AdjustRectToScreen(self.GetRect())) + self.Bind(wx.EVT_MOVE, self.on_move) self.Bind(wx.EVT_CLOSE, self._on_cancel) self._update() @@ -241,12 +228,13 @@ def _update(self): self.rtscts_checkbox.SetValue(self.serial.rtscts) self.xonxoff_checkbox.SetValue(self.serial.xonxoff) - def _on_ok(self, events): + def _on_ok(self, event): # Transfer the configuration values to the serial config object. + sb = lambda s: int(float(s)) if float(s).is_integer() else float(s) self.serial.port = self.port_combo_box.GetValue() self.serial.baudrate = int(self.baudrate_choice.GetStringSelection()) self.serial.bytesize = int(self.databits_choice.GetStringSelection()) - self.serial.stopbits = float(self.stopbits_choice.GetStringSelection()) + self.serial.stopbits = sb(self.stopbits_choice.GetStringSelection()) self.serial.parity = self.parity_choice.GetStringSelection() self.serial.rtscts = self.rtscts_checkbox.GetValue() self.serial.xonxoff = self.xonxoff_checkbox.GetValue() @@ -261,12 +249,12 @@ def _on_ok(self, events): self.EndModal(wx.ID_OK) self._destroy() - def _on_cancel(self, events): + def _on_cancel(self, event): # Dismiss the dialog without making any changes. self.EndModal(wx.ID_CANCEL) self._destroy() - def _on_timeout_select(self, events): + def _on_timeout_select(self, event): # Dis/allow user input to timeout text control. if self.timeout_checkbox.GetValue(): self.timeout_text_ctrl.Enable(True) @@ -312,6 +300,12 @@ def _destroy(self): self.closed = True else: self.Destroy() + + def on_move(self, event): + pos = self.GetScreenPositionTuple() + self.config.set_serial_config_frame_x(pos[0]) + self.config.set_serial_config_frame_y(pos[1]) + event.Skip() class FloatValidator(wx.PyValidator): """Validates that a string can be converted to a float.""" diff --git a/plover/machine/base.py b/plover/machine/base.py index 86b658030..42846d393 100644 --- a/plover/machine/base.py +++ b/plover/machine/base.py @@ -23,6 +23,7 @@ def __init__(self): self.stroke_subscribers = [] self.state_subscribers = [] self.state = STATE_STOPPED + self.suppress = None def start_capture(self): """Begin listening for output from the stenotype machine.""" @@ -61,8 +62,23 @@ def remove_state_callback(self, callback): def _notify(self, steno_keys): """Invoke the callback of each subscriber with the given argument.""" + # If the stroke matches a command while the keyboard is not suppressed + # then the stroke needs to be suppressed after the fact. One of the + # handlers will set the suppress function. This function is passed in to + # prevent threading issues with the gui. + self.suppress = None for callback in self.stroke_subscribers: callback(steno_keys) + if self.suppress: + self._post_suppress(self.suppress, steno_keys) + + def _post_suppress(self, steno_keys): + """This is a complicated way for the application to tell the machine to + suppress this stroke after the fact. This only currently has meaning for + the keyboard machine so it can backspace over the last stroke when used + to issue a command when plover is 'off'. + """ + pass def _set_state(self, state): self.state = state @@ -159,12 +175,13 @@ def stop_capture(self): def get_option_info(): """Get the default options for this machine.""" bool_converter = lambda s: s == 'True' + sb = lambda s: int(float(s)) if float(s).is_integer() else float(s) return { 'port': (None, str), # TODO: make first port default 'baudrate': (9600, int), 'bytesize': (8, int), 'parity': ('N', str), - 'stopbits': (1, float), + 'stopbits': (1, sb), 'timeout': (2.0, float), 'xonxoff': (False, bool_converter), 'rtscts': (False, bool_converter) diff --git a/plover/machine/passport.py b/plover/machine/passport.py new file mode 100644 index 000000000..46be07cf5 --- /dev/null +++ b/plover/machine/passport.py @@ -0,0 +1,107 @@ +# Copyright (c) 2013 Hesky Fisher +# See LICENSE.txt for details. + +"Thread-based monitoring of a stenotype machine using the passport protocol." + +from plover.machine.base import SerialStenotypeBase +from itertools import izip_longest + +# Passport protocol is documented here: +# http://www.eclipsecat.com/?q=system/files/Passport%20protocol_0.pdf + +STENO_KEY_CHART = { + '!': None, + '#': '#', + '^': None, + '+': None, + 'S': 'S-', + 'C': 'S-', + 'T': 'T-', + 'K': 'K-', + 'P': 'P-', + 'W': 'W-', + 'H': 'H-', + 'R': 'R-', + '~': '*', + '*': '*', + 'A': 'A-', + 'O': 'O-', + 'E': '-E', + 'U': '-U', + 'F': '-F', + 'Q': '-R', + 'N': '-P', + 'B': '-B', + 'L': '-L', + 'G': '-G', + 'Y': '-T', + 'X': '-S', + 'D': '-D', + 'Z': '-Z', +} + + +class Stenotype(SerialStenotypeBase): + """Passport interface.""" + + def __init__(self, params): + SerialStenotypeBase.__init__(self, params) + self.packet = [] + + def _read(self, b): + b = chr(b) + self.packet.append(b) + if b == '>': + self._handle_packet(''.join(self.packet)) + del self.packet[:] + + def _handle_packet(self, packet): + encoded = packet.split('/')[1] + keys = [] + for key, shadow in grouper(encoded, 2, 0): + shadow = int(shadow, base=16) + if shadow >= 8: + key = STENO_KEY_CHART[key] + if key: + keys.append(key) + if keys: + self._notify(keys) + + def run(self): + """Overrides base class run method. Do not call directly.""" + self._ready() + + while not self.finished.isSet(): + # Grab data from the serial port. + raw = self.serial_port.read(self.serial_port.inWaiting()) + + # XXX : work around for python 3.1 and python 2.6 differences + if isinstance(raw, str): + raw = [ord(x) for x in raw] + + for b in raw: + self._read(b) + + @staticmethod + def get_option_info(): + """Get the default options for this machine.""" + bool_converter = lambda s: s == 'True' + sb = lambda s: int(float(s)) if float(s).is_integer() else float(s) + return { + 'port': (None, str), # TODO: make first port default + 'baudrate': (38400, int), + 'bytesize': (8, int), + 'parity': ('N', str), + 'stopbits': (1, sb), + 'timeout': (2.0, float), + 'xonxoff': (False, bool_converter), + 'rtscts': (False, bool_converter) + } + + +def grouper(iterable, n, fillvalue=None): + "Collect data into fixed-length chunks or blocks" + # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx + args = [iter(iterable)] * n + return izip_longest(fillvalue=fillvalue, *args) + diff --git a/plover/machine/registry.py b/plover/machine/registry.py index 146dc9d8e..5bc877bdb 100644 --- a/plover/machine/registry.py +++ b/plover/machine/registry.py @@ -7,6 +7,8 @@ from plover.machine.txbolt import Stenotype as txbolt from plover.machine.sidewinder import Stenotype as sidewinder from plover.machine.stentura import Stenotype as stentura +from plover.machine.passport import Stenotype as passport + try: from plover.machine.treal import Stenotype as treal except: @@ -50,6 +52,7 @@ def resolve_alias(self, name): machine_registry.register('Gemini PR', geminipr) machine_registry.register('TX Bolt', txbolt) machine_registry.register('Stentura', stentura) +machine_registry.register('Passport', passport) if treal: machine_registry.register('Treal', treal) diff --git a/plover/machine/sidewinder.py b/plover/machine/sidewinder.py index b12943627..758274940 100644 --- a/plover/machine/sidewinder.py +++ b/plover/machine/sidewinder.py @@ -71,6 +71,7 @@ def __init__(self, params): self.suppress_keyboard(True) self._down_keys = set() self._released_keys = set() + self.arpeggiate = params['arpeggiate'] def start_capture(self): """Begin listening for output from the stenotype machine.""" @@ -87,7 +88,7 @@ def suppress_keyboard(self, suppress): self._keyboard_capture.suppress_keyboard(suppress) def _key_down(self, event): - # Called when a key is pressed. + """Called when a key is pressed.""" if (self._is_keyboard_suppressed and event.keystring is not None and not self._keyboard_capture.is_keyboard_suppressed()): @@ -95,18 +96,42 @@ def _key_down(self, event): if event.keystring in KEYSTRING_TO_STENO_KEY: self._down_keys.add(event.keystring) + def _post_suppress(self, suppress, steno_keys): + """Backspace the last stroke since it matched a command. + + The suppress function is passed in to prevent threading issues with the + gui. + """ + n = len(steno_keys) + if self.arpeggiate: + n += 1 + suppress(n) + def _key_up(self, event): - if not event.keystring in KEYSTRING_TO_STENO_KEY: - return - # Called when a key is released. - # Remove invalid released keys. - self._released_keys = self._released_keys.intersection(self._down_keys) - # Process the newly released key. - self._released_keys.add(event.keystring) + """Called when a key is released.""" + if event.keystring in KEYSTRING_TO_STENO_KEY: + # Process the newly released key. + self._released_keys.add(event.keystring) + # Remove invalid released keys. + self._released_keys = self._released_keys.intersection(self._down_keys) + # A stroke is complete if all pressed keys have been released. - if self._down_keys == self._released_keys: + # If we are in arpeggiate mode then only send stroke when spacebar is pressed. + send_strokes = (self._down_keys and + self._down_keys == self._released_keys) + if self.arpeggiate: + send_strokes &= event.keystring == ' ' + if send_strokes: steno_keys = [KEYSTRING_TO_STENO_KEY[k] for k in self._down_keys if k in KEYSTRING_TO_STENO_KEY] - self._down_keys.clear() - self._released_keys.clear() - self._notify(steno_keys) + if steno_keys: + self._down_keys.clear() + self._released_keys.clear() + self._notify(steno_keys) + + @staticmethod + def get_option_info(): + bool_converter = lambda s: s == 'True' + return { + 'arpeggiate': (False, bool_converter), + } diff --git a/plover/machine/test_passport.py b/plover/machine/test_passport.py new file mode 100644 index 000000000..34f0e68e7 --- /dev/null +++ b/plover/machine/test_passport.py @@ -0,0 +1,84 @@ +# Copyright (c) 2011 Hesky Fisher +# See LICENSE.txt for details. + +"""Unit tests for passport.py.""" + +from operator import eq +from itertools import starmap +import unittest +from mock import patch +from plover.machine.passport import Stenotype +import time + +class MockSerial(object): + + inputs = [] + index = 0 + + def __init__(self, **params): + MockSerial.index = 0 + + def isOpen(self): + return True + + def _get(self): + if len(MockSerial.inputs) > MockSerial.index: + return MockSerial.inputs[MockSerial.index] + return '' + + def inWaiting(self): + return len(self._get()) + + def read(self, size=1): + assert size == self.inWaiting() + result = [ord(x) for x in self._get()] + MockSerial.index += 1 + return result + + def close(self): + pass + + +def cmp_keys(a, b): + return all(starmap(eq, zip(a, b))) + +class TestCase(unittest.TestCase): + def test_pasport(self): + + def p(s): + return '<123/%s/something>' % s + + cases = ( + # Test all keys + (('!f#f+f*fAfCfBfEfDfGfFfHfKfLfOfNfQfPfSfRfUfTfWfYfXfZf^f~f',), + (('#', '*', 'A-', 'S-', '-B', '-E', '-D', '-G', '-F', 'H-', 'K-', + '-L', 'O-', '-P', '-R', 'P-', 'S-', 'R-', '-U', 'T-', 'W-', '-T', + '-S', '-Z', '*'),)), + # Anything below 8 is not registered + (('S9T8A7',), (('S-', 'T-'),)), + # Sequence of strokes + (('SfTf', 'Zf', 'QfLf'), (('S-', 'T-'), ('-Z',), ('-R', '-L'))), + ) + + params = {k: v[0] for k, v in Stenotype.get_option_info().items()} + results = [] + with patch('plover.machine.base.serial.Serial', MockSerial) as mock: + for inputs, expected in cases: + mock.inputs = map(p, inputs) + actual = [] + m = Stenotype(params) + m.add_stroke_callback(lambda s: actual.append(s)) + m.start_capture() + while mock.index < len(mock.inputs): + time.sleep(0.00001) + m.stop_capture() + result = (len(expected) == len(actual) and + all(starmap(cmp_keys, zip(actual, expected)))) + if not result: + print actual, '!=', expected + results.append(result) + + self.assertTrue(all(results)) + +if __name__ == '__main__': + unittest.main() diff --git a/plover/main.py b/plover/main.py index eff450fca..62484a04c 100644 --- a/plover/main.py +++ b/plover/main.py @@ -12,15 +12,15 @@ import plover.gui.main import plover.oslayer.processlock from plover.oslayer.config import CONFIG_DIR, ASSETS_DIR -from plover.config import CONFIG_FILE, DEFAULT_DICTIONARY_FILE +from plover.config import CONFIG_FILE, DEFAULT_DICTIONARY_FILE, Config def show_error(title, message): """Report error to the user. This shows a graphical error and prints the same to the terminal. """ - app = wx.PySimpleApp() print message + app = wx.PySimpleApp() alert_dialog = wx.MessageDialog(None, message, title, @@ -48,19 +48,24 @@ def init_config_dir(): with open(CONFIG_FILE, 'wb') as f: f.close() + def main(): """Launch plover.""" try: # Ensure only one instance of Plover is running at a time. with plover.oslayer.processlock.PloverLock(): init_config_dir() - gui = plover.gui.main.PloverGUI() + config = Config() + config.target_file = CONFIG_FILE + gui = plover.gui.main.PloverGUI(config) gui.MainLoop() + with open(config.target_file, 'wb') as f: + config.save(f) except plover.oslayer.processlock.LockNotAcquiredException: show_error('Error', 'Another instance of Plover is already running.') except: show_error('Unexpected error', traceback.format_exc()) - os._exit(1) + os._exit(1) if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/plover/oslayer/__init__.py b/plover/oslayer/__init__.py index 90a5eae21..254837e65 100644 --- a/plover/oslayer/__init__.py +++ b/plover/oslayer/__init__.py @@ -3,4 +3,3 @@ """This package abstracts os details for plover.""" -__all__ = ['comscan', 'config', 'keyboardcontrol', 'processlock'] diff --git a/plover/oslayer/comscan.py b/plover/oslayer/comscan.py deleted file mode 100644 index 36f00809f..000000000 --- a/plover/oslayer/comscan.py +++ /dev/null @@ -1,571 +0,0 @@ -#!/usr/bin/env python - -# This code was taken from BITPIM which is under GPL2. - -### BITPIM -### -### Copyright (C) 2003-2004 Roger Binns -### -### This program is free software; you can redistribute it and/or modify -### it under the terms of the BitPim license as detailed in the LICENSE file. -### -### $Id$ - - -"""Detect and enumerate com(serial) ports - -You should close all com ports you have open before calling any -functions in this module. If you don't they will be detected as in -use. - -Call the comscan() function It returns a list with each entry being a -dictionary of useful information. See the platform notes for what is -in each one. - -For your convenience the entries in the list are also sorted into -an order that would make sense to the user. - -Platform Notes: -=============== - -w Windows9x -W WindowsNT/2K/XP -L Linux -M Mac - -wWLM name string Serial device name -wWLM available Bool True if it is possible to open this device -wW active Bool Is the driver actually running? An example of when this is False - is USB devices or Bluetooth etc that aren't currently plugged in. - If you are presenting stuff for users, do not show entries where - this is false -w driverstatus dict status is some random number, problem is non-zero if there is some - issue (eg device disabled) -wW hardwareinstance string instance of the device in the registry as named by Windows -wWLM description string a friendly name to show users -wW driverdate tuple (year, month, day) - W driverversion string version string -wW driverprovider string the manufacturer of the device driver -wW driverdescription string some generic description of the driver - L device tuple (major, minor) device specification - L driver string the driver name from /proc/devices (eg ttyS or ttyUSB) -""" - -from __future__ import with_statement - -version = "$Revision$" - -import sys -import os -import glob - - -def _IsWindows(): - return sys.platform == 'win32' - - -def _IsLinux(): - return sys.platform.startswith('linux') - - -def _IsMac(): - return sys.platform.startswith('darwin') - - -if _IsWindows(): - import _winreg - import win32file - import win32con - - class RegistryAccess: - "A class that is significantly easier to use to access the Registry" - def __init__(self, hive=_winreg.HKEY_LOCAL_MACHINE): - self.rootkey = _winreg.ConnectRegistry(None, hive) - - def getchildren(self, key): - """Returns a list of the child nodes of a key""" - k = _winreg.OpenKey(self.rootkey, key) - index = 0 - res = [] - while 1: - try: - subkey = _winreg.EnumKey(k, index) - res.append(subkey) - index += 1 - except: - # ran out of keys - break - return res - - def safegetchildren(self, key): - """Doesn't throw exception if doesn't exist - - @return: A list of zero or more items""" - try: - k = _winreg.OpenKey(self.rootkey, key) - except: - return [] - index = 0 - res = [] - while 1: - try: - subkey = _winreg.EnumKey(k, index) - res.append(subkey) - index += 1 - except WindowsError, e: - if e[0] == 259: # No more data is available - break - elif e[0] == 234: # more data is available - index += 1 - continue - raise - return res - - def getvalue(self, key, node): - """Gets a value - - The result is returned as the correct type (string, int, etc)""" - k = _winreg.OpenKey(self.rootkey, key) - v, t = _winreg.QueryValueEx(k, node) - if t == 2: - return int(v) - if t == 3: - # lsb data - res = 0 - mult = 1 - for i in v: - res += ord(i) * mult - mult *= 256 - return res - # un unicode if possible - if isinstance(v, unicode): - try: - return str(v) - except: - pass - return v - - def safegetvalue(self, key, node, default=None): - """Gets a value and if nothing is found returns the default""" - try: - return self.getvalue(key, node) - except: - return default - - def findkey(self, start, lookfor, prependresult=""): - """Searches for the named key""" - res = [] - for i in self.getchildren(start): - if i == lookfor: - res.append(prependresult + i) - else: - l = self.findkey(start + "\\" + i, - lookfor, - prependresult + i + "\\") - res.extend(l) - return res - - def getallchildren(self, start, prependresult=""): - """Returns a list of all child nodes in the hierarchy""" - res = [] - for i in self.getchildren(start): - res.append(prependresult + i) - l = self.getallchildren(start + "\\" + i, - prependresult + i + "\\") - res.extend(l) - return res - - -def _comscanwindows(): - """Get detail about all com ports on Windows - - This code functions on both win9x and nt/2k/xp""" - # give results back - results = {} - resultscount = 0 - - # list of active drivers on win98 - activedrivers = {} - - reg = RegistryAccess(_winreg.HKEY_DYN_DATA) - k = r"Config Manager\Enum" - for device in reg.safegetchildren(k): - hw = reg.safegetvalue(k + "\\" + device, "hardwarekey") - if hw is None: - continue - status = reg.safegetvalue(k + "\\" + device, "status", -1) - problem = reg.safegetvalue(k + "\\" + device, "problem", -1) - activedrivers[hw.upper()] = {'status': status, 'problem': problem} - - # list of active drivers on winXP. Apparently Win2k is different? - reg = RegistryAccess(_winreg.HKEY_LOCAL_MACHINE) - k = r"SYSTEM\CurrentControlSet\Services" - for service in reg.safegetchildren(k): - # we will just take largest number - count = int(reg.safegetvalue(k + "\\" + service + "\\Enum", - "Count", 0)) - next = int(reg.safegetvalue(k + "\\" + service + "\\Enum", - "NextInstance", 0)) - for id in range(max(count, next)): - hw = reg.safegetvalue(k + "\\" + service + "\\Enum", repr(id)) - if hw is None: - continue - activedrivers[hw.upper()] = None - - # scan through everything listed in Enum. Enum is the key containing a - # list of all running hardware - reg = RegistryAccess(_winreg.HKEY_LOCAL_MACHINE) - - # The three keys are: - # - # - where to find hardware - # This then has three layers of children. - # Enum - # +-- Category (something like BIOS, PCI etc) - # +-- Driver (vendor/product ids etc) - # +-- Instance (An actual device. You may have more than one instance) - # - # - where to find information about drivers. The driver name is looked up in the instance - # (using the key "driver") and then looked for as a child key of driverlocation to find - # out more about the driver - # - # - where to look for the portname key. Eg in Win98 it is directly in the instance whereas - # in XP it is below "Device Parameters" subkey of the instance - - for enumstr, driverlocation, portnamelocation in ( - (r"SYSTEM\CurrentControlSet\Enum", - r"SYSTEM\CurrentControlSet\Control\Class", - r"\Device Parameters"), # win2K/XP - (r"Enum", r"System\CurrentControlSet\Services\Class", ""), # win98 - ): - for category in reg.safegetchildren(enumstr): - catstr = enumstr + "\\" + category - for driver in reg.safegetchildren(catstr): - drvstr = catstr + "\\" + driver - for instance in reg.safegetchildren(drvstr): - inststr = drvstr + "\\" + instance - - # see if there is a portname - name = reg.safegetvalue(inststr + portnamelocation, - "PORTNAME", "") - - # We only want com ports - if len(name) < 4 or name.lower()[:3] != "com": - continue - - # Get rid of phantom devices - phantom = reg.safegetvalue(inststr, "Phantom", 0) - if phantom: - continue - - # Lookup the class - klassguid = reg.safegetvalue(inststr, "ClassGUID") - if klassguid is not None: - # win2k uses ClassGuid - klass = reg.safegetvalue( - driverlocation + "\\" + klassguid, "Class") - else: - # Win9x and WinXP use Class - klass = reg.safegetvalue(inststr, "Class") - - if klass is None: - continue - klass = klass.lower() - if klass == 'ports': - klass = 'serial' - elif klass == 'modem': - klass = 'modem' - else: - continue - - # verify COM is followed by digits only - try: - portnum = int(name[3:]) - except: - continue - - # we now have some sort of match - res = {} - - res['name'] = name.upper() - res['class'] = klass - - # is the device active? - kp = inststr[len(enumstr) + 1:].upper() - if kp in activedrivers: - res['active'] = True - if activedrivers[kp] is not None: - res['driverstatus'] = activedrivers[kp] - else: - res['active'] = False - - # available? - if res['active']: - try: - usename = name - if (sys.platform == 'win32' - and name.lower().startswith("com")): - usename = "\\\\?\\" + name - ComPort = win32file.CreateFile( - usename, - win32con.GENERIC_READ | win32con.GENERIC_WRITE, - 0, None, - win32con.OPEN_EXISTING, - win32con.FILE_ATTRIBUTE_NORMAL, - None, - ) - win32file.CloseHandle(ComPort) - res['available'] = True - except Exception, e: - print usename, "is not available", e - res['available'] = False - else: - res['available'] = False - - # hardwareinstance - res['hardwareinstance'] = kp - - # friendly name - res['description'] = reg.safegetvalue( - inststr, "FriendlyName", "") - - # driver information key - drv = reg.safegetvalue(inststr, "Driver") - - if drv is not None: - driverkey = driverlocation + "\\" + drv - - # get some useful driver information - for subkey, reskey in ( - ("driverdate", "driverdate"), - ("providername", "driverprovider"), - ("driverdesc", "driverdescription"), - ("driverversion", "driverversion")): - val = reg.safegetvalue(driverkey, subkey, None) - if val is None: - continue - if reskey == "driverdate": - try: - val2 = val.split('-') - val = (int(val2[2]), - int(val2[0]), - int(val2[1]), - ) - except: - # ignore weird dates - continue - res[reskey] = val - - results[resultscount] = res - resultscount += 1 - - return results - - -# There follows a demonstration of how user friendly Linux is. -# Users are expected by some form of magic to know exactly what -# the names of their devices are. We can't even tell the difference -# between a serial port not existing, and there not being sufficient -# permission to open it -def _comscanlinux(maxnum=9): - """Get all the ports on Linux - - Note that Linux doesn't actually provide any way to enumerate actual ports. - Consequently we just look for device nodes. It still isn't possible to - establish if there are actual device drivers behind them. The availability - testing is done however. - - @param maxnum: The highest numbered device to look for (eg maxnum of 17 - will look for ttyS0 ... ttys17) - """ - - # track of mapping majors to drivers - drivers = {} - - # get the list of char drivers from /proc/drivers - with file('/proc/devices', 'r') as f: - f.readline() # skip "Character devices:" header - for line in f.readlines(): - line = line.split() - if len(line) != 2: - break # next section - major, driver = line - drivers[int(major)] = driver - - # device nodes we have seen so we don't repeat them in listing - devcache = {} - - resultscount = 0 - results = {} - for prefix, description, klass in ( - ("/dev/cua", "Standard serial port", "serial"), - ("/dev/ttyUSB", "USB to serial convertor", "serial"), - ("/dev/ttyACM", "USB modem", "modem"), - ("/dev/rfcomm", "Bluetooth", "modem"), - ("/dev/usb/ttyUSB", "USB to serial convertor", "serial"), - ("/dev/usb/tts/", "USB to serial convertor", "serial"), - ("/dev/usb/acm/", "USB modem", "modem"), - ("/dev/input/ttyACM", "USB modem", "modem") - ): - for num in range(maxnum + 1): - name = prefix + repr(num) - if not os.path.exists(name): - continue - res = {} - res['name'] = name - res['class'] = klass - res['description'] = description + " (" + name + ")" - dev = os.stat(name).st_rdev - try: - with file(name, 'rw'): - res['available'] = True - except: - res['available'] = False - # linux specific, and i think they do funky stuff on kernel 2.6 - # there is no way to get these 'normally' from the python library - major = (dev >> 8) & 0xff - minor = dev & 0xff - res['device'] = (major, minor) - if major in drivers: - res['driver'] = drivers[major] - - if res['available']: - if dev not in devcache or not devcache[dev][0]['available']: - results[resultscount] = res - resultscount += 1 - devcache[dev] = [res] - continue - # not available, so add - try: - devcache[dev].append(res) - except: - devcache[dev] = [res] - # add in one failed device type per major/minor - for dev in devcache: - if devcache[dev][0]['available']: - continue - results[resultscount] = devcache[dev][0] - resultscount += 1 - return results - - -def _comscanmac(): - """Get all the ports on Mac - - Just look for /dev/cu.* entries, they all seem to populate here whether - USB->Serial, builtin, bluetooth, etc... - - """ - - resultscount = 0 - results = {} - for name in glob.glob("/dev/cu.*"): - res = {} - res['name'] = name - if name.upper().rfind("MODEM") >= 0: - res['description'] = "Modem" + " (" + name + ")" - res['class'] = "modem" - else: - res['description'] = "Serial" + " (" + name + ")" - res['class'] = "serial" - try: - with file(name, 'rw'): - res['available'] = True - except: - res['available'] = False - results[resultscount] = res - resultscount += 1 - return results - - -def _stringint(str): - """Seperate a string and trailing number into a tuple - - For example "com10" returns ("com", 10) - """ - prefix = str - suffix = "" - - while len(prefix) and prefix[-1] >= '0' and prefix[-1] <= '9': - suffix = prefix[-1] + suffix - prefix = prefix[:-1] - - if len(suffix): - return (prefix, int(suffix)) - else: - return (prefix, None) - - -def _cmpfunc(a, b): - """Comparison function for two port names - - In particular it looks for a number on the end, and sorts by the prefix (as - a string operation) and then by the number. This function is needed - because "com9" needs to come before "com10" - """ - - aa = _stringint(a[0]) - bb = _stringint(b[0]) - - if aa == bb: - if a[1] == b[1]: - return 0 - if a[1] < b[1]: - return -1 - return 1 - if aa < bb: - return -1 - return 1 - - -def comscan(*args, **kwargs): - """Call platform specific version of comscan function""" - res = {} - if _IsWindows(): - res = _comscanwindows(*args, **kwargs) - elif _IsLinux(): - res = _comscanlinux(*args, **kwargs) - elif _IsMac(): - res = _comscanmac(*args, **kwargs) - else: - raise Exception("unknown platform " + sys.platform) - - # sort by name - keys = res.keys() - declist = [(res[k]['name'], k) for k in keys] - declist.sort(_cmpfunc) - - return [res[k[1]] for k in declist] - - -if __name__ == "__main__": - res = comscan() - - output = "ComScan " + version + "\n\n" - - for r in res: - rkeys = r.keys() - rkeys.sort() - - output += r['name'] + ":\n" - offset = 0 - for rk in rkeys: - if rk == 'name': - continue - v = r[rk] - if not isinstance(v, type("")): - v = repr(v) - op = ' %s: %s ' % (rk, v) - if offset + len(op) > 78: - output += "\n" + op - offset = len(op) + 1 - else: - output += op - offset += len(op) - - if output[-1] != "\n": - output += "\n" - output += "\n" - offset = 0 - - print output diff --git a/plover/steno.py b/plover/steno.py index 049f7af1f..60ff16ef0 100644 --- a/plover/steno.py +++ b/plover/steno.py @@ -44,29 +44,29 @@ def normalize_steno(strokes_string): '-L': '-8', '-T': '-9'} -STENO_KEY_ORDER = {"#": -1, - "S-": 0, - "T-": 1, - "K-": 2, - "P-": 3, - "W-": 4, - "H-": 5, - "R-": 6, - "A-": 7, - "O-": 8, - "*": 9, # Also 10, 11, and 12 for some machines. - "-E": 13, - "-U": 14, - "-F": 15, - "-R": 16, - "-P": 17, - "-B": 18, - "-L": 19, - "-G": 20, - "-T": 21, - "-S": 22, - "-D": 23, - "-Z": 24} +STENO_KEY_ORDER = {"#": 0, + "S-": 1, + "T-": 2, + "K-": 3, + "P-": 4, + "W-": 5, + "H-": 6, + "R-": 7, + "A-": 8, + "O-": 9, + "*": 10, + "-E": 11, + "-U": 12, + "-F": 13, + "-R": 14, + "-P": 15, + "-B": 16, + "-L": 17, + "-G": 18, + "-T": 19, + "-S": 20, + "-D": 21, + "-Z": 22} class Stroke: @@ -98,7 +98,7 @@ def __init__(self, steno_keys) : steno_keys = list(steno_keys_set) # Order the steno keys so comparisons can be made. - steno_keys.sort(key=lambda x: STENO_KEY_ORDER[x]) + steno_keys.sort(key=lambda x: STENO_KEY_ORDER.get(x, -1)) # Convert strokes involving the number bar to numbers. if '#' in steno_keys: diff --git a/plover/steno_dictionary.py b/plover/steno_dictionary.py index 63c802e00..3645ef85c 100644 --- a/plover/steno_dictionary.py +++ b/plover/steno_dictionary.py @@ -1,8 +1,6 @@ # Copyright (c) 2013 Hesky Fisher. # See LICENSE.txt for details. -# TODO: unit test filters - """StenoDictionary class and related functions. A steno dictionary maps sequences of steno strokes to translations. @@ -10,6 +8,7 @@ """ import collections +import itertools from steno import normalize_steno class StenoDictionary(collections.MutableMapping): @@ -20,7 +19,7 @@ class StenoDictionary(collections.MutableMapping): Attributes: longest_key -- A read only property holding the length of the longest key. - saver -- If set, is a function that will save this dictionary. + save -- If set, is a function that will save this dictionary. """ def __init__(self, *args, **kw): @@ -111,3 +110,68 @@ def remove_filter(self, f): def raw_get(self, key, default): """Bypass filters.""" return self._dict.get(key, default) + + +class StenoDictionaryCollection(object): + def __init__(self): + self.dicts = [] + self.filters = [] + self.longest_key = 0 + self.longest_key_callbacks = set() + + def set_dicts(self, dicts): + for d in self.dicts: + d.remove_longest_key_listener(self._longest_key_listener) + self.dicts = dicts[:] + self.dicts.reverse() + for d in dicts: + d.add_longest_key_listener(self._longest_key_listener) + self._longest_key_listener() + + def lookup(self, key): + for d in self.dicts: + value = d.get(key, None) + if value: + for f in self.filters: + if f(key, value): + return None + return value + + def raw_lookup(self, key): + for d in self.dicts: + value = d.get(key, None) + if value: + return value + + def reverse_lookup(self, value): + for d in self.dicts: + key = d.reverse.get(value, None) + if key: + return key + + def set(self, key, value): + if self.dicts: + self.dicts[0][key] = value + + def save(self): + if self.dicts: + self.dicts[0].save() + + def add_filter(self, f): + self.filters.append(f) + + def remove_filter(self, f): + self.filters.remove(f) + + def add_longest_key_listener(self, callback): + self.longest_key_callbacks.add(callback) + + def remove_longest_key_listener(self, callback): + self.longest_key_callbacks.remove(callback) + + def _longest_key_listener(self, ignored=None): + new_longest_key = max(d.longest_key for d in self.dicts) + if new_longest_key != self.longest_key: + self.longest_key = new_longest_key + for c in self.longest_key_callbacks: + c(new_longest_key) diff --git a/plover/config_test.py b/plover/test_config.py similarity index 56% rename from plover/config_test.py rename to plover/test_config.py index 608407239..8b516d79d 100644 --- a/plover/config_test.py +++ b/plover/test_config.py @@ -20,9 +20,6 @@ def test_simple_fields(self): ('machine_type', config.MACHINE_CONFIG_SECTION, config.MACHINE_TYPE_OPTION, config.DEFAULT_MACHINE_TYPE, 'foo', 'bar', 'blee'), - ('dictionary_file_name', config.DICTIONARY_CONFIG_SECTION, - config.DICTIONARY_FILE_OPTION, config.DEFAULT_DICTIONARY_FILE, 'dict1', - 'd2', 'third'), ('log_file_name', config.LOGGING_CONFIG_SECTION, config.LOG_FILE_OPTION, config.DEFAULT_LOG_FILE, 'l1', 'log', 'sawzall'), ('enable_stroke_logging', config.LOGGING_CONFIG_SECTION, @@ -34,6 +31,47 @@ def test_simple_fields(self): ('auto_start', config.MACHINE_CONFIG_SECTION, config.MACHINE_AUTO_START_OPTION, config.DEFAULT_MACHINE_AUTO_START, True, False, True), + ('show_stroke_display', config.STROKE_DISPLAY_SECTION, + config.STROKE_DISPLAY_SHOW_OPTION, config.DEFAULT_STROKE_DISPLAY_SHOW, + True, False, True), + ('stroke_display_on_top', config.STROKE_DISPLAY_SECTION, + config.STROKE_DISPLAY_ON_TOP_OPTION, + config.DEFAULT_STROKE_DISPLAY_ON_TOP, False, True, False), + ('stroke_display_style', config.STROKE_DISPLAY_SECTION, + config.STROKE_DISPLAY_STYLE_OPTION, + config.DEFAULT_STROKE_DISPLAY_STYLE, 'Raw', 'Paper', 'Pseudo'), + ('stroke_display_x', config.STROKE_DISPLAY_SECTION, + config.STROKE_DISPLAY_X_OPTION, config.DEFAULT_STROKE_DISPLAY_X, 1, 2, + 3), + ('stroke_display_y', config.STROKE_DISPLAY_SECTION, + config.STROKE_DISPLAY_Y_OPTION, config.DEFAULT_STROKE_DISPLAY_Y, 1, 2, + 3), + ('config_frame_x', config.CONFIG_FRAME_SECTION, + config.CONFIG_FRAME_X_OPTION, config.DEFAULT_CONFIG_FRAME_X, 1, 2, 3), + ('config_frame_y', config.CONFIG_FRAME_SECTION, + config.CONFIG_FRAME_Y_OPTION, config.DEFAULT_CONFIG_FRAME_Y, 1, 2, 3), + ('config_frame_width', config.CONFIG_FRAME_SECTION, + config.CONFIG_FRAME_WIDTH_OPTION, config.DEFAULT_CONFIG_FRAME_WIDTH, 1, + 2, 3), + ('config_frame_height', config.CONFIG_FRAME_SECTION, + config.CONFIG_FRAME_HEIGHT_OPTION, config.DEFAULT_CONFIG_FRAME_HEIGHT, + 1, 2, 3), + ('main_frame_x', config.MAIN_FRAME_SECTION, + config.MAIN_FRAME_X_OPTION, config.DEFAULT_MAIN_FRAME_X, 1, 2, 3), + ('main_frame_y', config.MAIN_FRAME_SECTION, + config.MAIN_FRAME_Y_OPTION, config.DEFAULT_MAIN_FRAME_Y, 1, 2, 3), + ('translation_frame_x', config.TRANSLATION_FRAME_SECTION, + config.TRANSLATION_FRAME_X_OPTION, config.DEFAULT_TRANSLATION_FRAME_X, + 1, 2, 3), + ('translation_frame_y', config.TRANSLATION_FRAME_SECTION, + config.TRANSLATION_FRAME_Y_OPTION, config.DEFAULT_TRANSLATION_FRAME_Y, + 1, 2, 3), + ('serial_config_frame_x', config.SERIAL_CONFIG_FRAME_SECTION, + config.SERIAL_CONFIG_FRAME_X_OPTION, + config.DEFAULT_SERIAL_CONFIG_FRAME_X, 1, 2, 3), + ('serial_config_frame_y', config.SERIAL_CONFIG_FRAME_SECTION, + config.SERIAL_CONFIG_FRAME_Y_OPTION, + config.DEFAULT_SERIAL_CONFIG_FRAME_Y, 1, 2, 3), ) for case in cases: @@ -137,5 +175,43 @@ def get_option_info(): c.save(f) self.assertEqual(f.getvalue(), s + '\n\n') + def test_dictionary_option(self): + c = config.Config() + section = config.DICTIONARY_CONFIG_SECTION + option = config.DICTIONARY_FILE_OPTION + # Check the default value. + self.assertEqual(c.get_dictionary_file_names(), + [config.DEFAULT_DICTIONARY_FILE]) + # Set a value... + names = ['b', 'a', 'd', 'c'] + c.set_dictionary_file_names(names) + # ...and make sure it is really set. + self.assertEqual(c.get_dictionary_file_names(), names) + # Load from a file encoded the old way... + f = StringIO('[%s]\n%s: %s' % (section, option, 'some_file')) + c.load(f) + # ..and make sure the right value is set. + self.assertEqual(c.get_dictionary_file_names(), ['some_file']) + # Load from a file encoded the new way... + filenames = '\n'.join('%s%d: %s' % (option, d, v) + for d, v in enumerate(names, start=1)) + f = StringIO('[%s]\n%s' % (section, filenames)) + c.load(f) + # ...and make sure the right value is set. + self.assertEqual(c.get_dictionary_file_names(), names) + + names.reverse() + + # Set a value... + c.set_dictionary_file_names(names) + f = StringIO() + # ...save it... + c.save(f) + # ...and make sure it's right. + filenames = '\n'.join('%s%d = %s' % (option, d, v) + for d, v in enumerate(names, start=1)) + self.assertEqual(f.getvalue(), + '[%s]\n%s\n\n' % (section, filenames)) + if __name__ == '__main__': unittest.main() diff --git a/plover/test_steno.py b/plover/test_steno.py index 1d6041853..53ce69ea8 100644 --- a/plover/test_steno.py +++ b/plover/test_steno.py @@ -4,7 +4,7 @@ """Unit tests for steno.py.""" import unittest -from steno import normalize_steno +from steno import normalize_steno, Stroke class StenoTestCase(unittest.TestCase): def test_normalize_steno(self): @@ -15,12 +15,20 @@ def test_normalize_steno(self): ('S-', 'S'), ('-S', '-S'), ('ES', 'ES'), - ('-ES', 'ES') - + ('-ES', 'ES'), + ('TW-EPBL', 'TWEPBL'), + ('TWEPBL', 'TWEPBL'), ) for arg, expected in cases: self.assertEqual('/'.join(normalize_steno(arg)), expected) + + def test_steno(self): + self.assertEqual(Stroke(['S-']).rtfcre, 'S') + self.assertEqual(Stroke(['S-', 'T-']).rtfcre, 'ST') + self.assertEqual(Stroke(['T-', 'S-']).rtfcre, 'ST') + self.assertEqual(Stroke(['-P', '-P']).rtfcre, '-P') + self.assertEqual(Stroke(['-P', 'X-']).rtfcre, 'X-P') if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/plover/test_steno_dictionary.py b/plover/test_steno_dictionary.py index 971a5cd34..25403e22e 100644 --- a/plover/test_steno_dictionary.py +++ b/plover/test_steno_dictionary.py @@ -4,7 +4,7 @@ """Unit tests for steno_dictionary.py.""" import unittest -from steno_dictionary import StenoDictionary +from steno_dictionary import StenoDictionary, StenoDictionaryCollection class StenoDictionaryTestCase(unittest.TestCase): @@ -45,5 +45,36 @@ def listener(longest_key): self.assertEqual(StenoDictionary([('a', 'b')]).items(), [('a', 'b')]) self.assertEqual(StenoDictionary(a='b').items(), [('a', 'b')]) + def test_dictionary_collection(self): + dc = StenoDictionaryCollection() + d1 = StenoDictionary() + d1[('S',)] = 'a' + d1[('T',)] = 'b' + d2 = StenoDictionary() + d2[('S',)] = 'c' + d2[('W',)] = 'd' + dc.set_dicts([d1, d2]) + self.assertEqual(dc.lookup(('S',)), 'c') + self.assertEqual(dc.lookup(('W',)), 'd') + self.assertEqual(dc.lookup(('T',)), 'b') + f = lambda k, v: v == 'c' + dc.add_filter(f) + self.assertIsNone(dc.lookup(('S',))) + self.assertEqual(dc.raw_lookup(('S',)), 'c') + self.assertEqual(dc.lookup(('W',)), 'd') + self.assertEqual(dc.lookup(('T',)), 'b') + self.assertEqual(dc.reverse_lookup('c'), [('S',)]) + + dc.remove_filter(f) + self.assertEqual(dc.lookup(('S',)), 'c') + self.assertEqual(dc.lookup(('W',)), 'd') + self.assertEqual(dc.lookup(('T',)), 'b') + + self.assertEqual(dc.reverse_lookup('c'), [('S',)]) + + dc.set(('S',), 'e') + self.assertEqual(dc.lookup(('S',)), 'e') + self.assertEqual(d2[('S',)], 'e') + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/plover/test_translation.py b/plover/test_translation.py index 114e31868..9d7ba5f99 100644 --- a/plover/test_translation.py +++ b/plover/test_translation.py @@ -6,34 +6,37 @@ from collections import namedtuple import copy from mock import patch -from steno_dictionary import StenoDictionary -from translation import Translation, Translator, _State, _translate_stroke +from steno_dictionary import StenoDictionary, StenoDictionaryCollection +from translation import Translation, Translator, _State, _translate_stroke, _lookup import unittest - -class Stroke(object): - def __init__(self, rtfcre, is_correction=False): - self.rtfcre = rtfcre - self.is_correction = is_correction - - def __eq__(self, other): - return isinstance(other, Stroke) and self.rtfcre == other.rtfcre - - def __ne__(self, other): - return not not self.__eq__(other) +from plover.steno import Stroke, normalize_steno + +def stroke(s): + keys = [] + on_left = True + for k in s: + if k in 'EU*-': + on_left = False + if k == '-': + continue + elif k == '*': + keys.append(k) + elif on_left: + keys.append(k + '-') + else: + keys.append('-' + k) + return Stroke(keys) class TranslationTestCase(unittest.TestCase): def test_no_translation(self): - d = StenoDictionary() - t = Translation([Stroke('S'), Stroke('T')], d) - self.assertEqual(t.strokes, [Stroke('S'), Stroke('T')]) + t = Translation([stroke('S'), stroke('T')], None) + self.assertEqual(t.strokes, [stroke('S'), stroke('T')]) self.assertEqual(t.rtfcre, ('S', 'T')) self.assertIsNone(t.english) def test_translation(self): - d = StenoDictionary() - d[('S', 'T')] = 'translation' - t = Translation([Stroke('S'), Stroke('T')], d) - self.assertEqual(t.strokes, [Stroke('S'), Stroke('T')]) + t = Translation([stroke('S'), stroke('T')], 'translation') + self.assertEqual(t.strokes, [stroke('S'), stroke('T')]) self.assertEqual(t.rtfcre, ('S', 'T')) self.assertEqual(t.english, 'translation') @@ -59,7 +62,9 @@ def setUp(self): self.s = type(self).FakeState() self.t._state = self.s self.d = StenoDictionary() - self.t.set_dictionary(self.d) + self.dc = StenoDictionaryCollection() + self.dc.set_dicts([self.d]) + self.t.set_dictionary(self.dc) def test_dictionary_update_grows_size1(self): self.d[('S',)] = '1' @@ -92,14 +97,14 @@ def test_dictionary_update_no_shrink(self): self.assert_size_call(7) def test_translation_calls_restrict(self): - self.t.translate(Stroke('S')) + self.t.translate(stroke('S')) self.assert_size_call(0) class TranslatorTestCase(unittest.TestCase): def test_translate_calls_translate_stroke(self): t = Translator() - s = Stroke('S') + s = stroke('S') def check(stroke, state, dictionary, output): self.assertEqual(stroke, s) self.assertEqual(state, t._state) @@ -119,8 +124,8 @@ def listener2(undo, do, prev): output2.append((undo, do, prev)) t = Translator() - s = Stroke('S') - tr = Translation([s], StenoDictionary()) + s = stroke('S') + tr = Translation([s], None) expected_output = [([], [tr], tr)] t.translate(s) @@ -163,37 +168,39 @@ def listener(undo, do, prev): d = StenoDictionary() d[('S', 'P')] = 'hi' + dc = StenoDictionaryCollection() + dc.set_dicts([d]) t = Translator() - t.set_dictionary(d) - t.translate(Stroke('T')) - t.translate(Stroke('S')) + t.set_dictionary(dc) + t.translate(stroke('T')) + t.translate(stroke('S')) s = copy.deepcopy(t.get_state()) t.add_listener(listener) - expected = [([Translation([Stroke('S')], d)], - [Translation([Stroke('S'), Stroke('P')], d)], - Translation([Stroke('T')], d))] - t.translate(Stroke('P')) + expected = [([Translation([stroke('S')], dc)], + [Translation([stroke('S'), stroke('P')], dc)], + Translation([stroke('T')], dc))] + t.translate(stroke('P')) self.assertEqual(output, expected) del output[:] t.set_state(s) - t.translate(Stroke('P')) + t.translate(stroke('P')) self.assertEqual(output, expected) del output[:] t.clear_state() - t.translate(Stroke('P')) - self.assertEqual(output, [([], [Translation([Stroke('P')], d)], None)]) + t.translate(stroke('P')) + self.assertEqual(output, [([], [Translation([stroke('P')], dc)], None)]) del output[:] t.set_state(s) - t.translate(Stroke('P')) + t.translate(stroke('P')) self.assertEqual(output, [([], - [Translation([Stroke('P')], d)], - Translation([Stroke('S'), Stroke('P')], d))]) + [Translation([stroke('P')], dc)], + Translation([stroke('S'), stroke('P')], dc))]) def test_translator(self): @@ -224,29 +231,31 @@ def clear(self): d = StenoDictionary() out = Output() t = Translator() - t.set_dictionary(d) + dc = StenoDictionaryCollection() + dc.set_dicts([d]) + t.set_dictionary(dc) t.add_listener(out.write) - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 'S') - t.translate(Stroke('T')) + t.translate(stroke('T')) self.assertEqual(out.get(), 'S T') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 'S') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 'S') # Undo buffer ran out. t.set_min_undo_length(3) out.clear() - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 'S') - t.translate(Stroke('T')) + t.translate(stroke('T')) self.assertEqual(out.get(), 'S T') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 'S') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), '') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), '') # Undo buffer ran out. out.clear() @@ -254,80 +263,82 @@ def clear(self): d[('T',)] = 't2' d[('S', 'T')] = 't3' - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 't1') - t.translate(Stroke('T')) + t.translate(stroke('T')) self.assertEqual(out.get(), 't3') - t.translate(Stroke('T')) + t.translate(stroke('T')) self.assertEqual(out.get(), 't3 t2') - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 't3 t2 t1') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't3 t2') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't3') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't1') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), '') - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 't1') - t.translate(Stroke('T')) + t.translate(stroke('T')) self.assertEqual(out.get(), 't3') - t.translate(Stroke('T')) + t.translate(stroke('T')) self.assertEqual(out.get(), 't3 t2') d[('S', 'T', 'T')] = 't4' d[('S', 'T', 'T', 'S')] = 't5' - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 't5') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't3 t2') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't3') - t.translate(Stroke('T')) + t.translate(stroke('T')) self.assertEqual(out.get(), 't4') - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 't5') - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), 't5 t1') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't5') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't4') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't3') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), 't1') - t.translate(Stroke('*', True)) + t.translate(stroke('*')) self.assertEqual(out.get(), '') d.clear() - t.translate(Stroke('S')) - t.translate(Stroke('S')) - t.translate(Stroke('S')) - t.translate(Stroke('S')) - t.translate(Stroke('*', True)) - t.translate(Stroke('*', True)) - t.translate(Stroke('*', True)) - t.translate(Stroke('*', True)) + s = stroke('S') + t.translate(s) + t.translate(s) + t.translate(s) + t.translate(s) + s = stroke('*') + t.translate(s) + t.translate(s) + t.translate(s) + t.translate(s) self.assertEqual(out.get(), 'S') # Not enough undo to clear output. out.clear() t.remove_listener(out.write) - t.translate(Stroke('S')) + t.translate(stroke('S')) self.assertEqual(out.get(), '') class StateTestCase(unittest.TestCase): def setUp(self): - d = StenoDictionary() - self.a = Translation([Stroke('S')], d) - self.b = Translation([Stroke('T'), Stroke('-D')], d) - self.c = Translation([Stroke('-Z'), Stroke('P'), Stroke('T*')], d) + d = StenoDictionaryCollection() + self.a = Translation([stroke('S')], d) + self.b = Translation([stroke('T'), stroke('-D')], d) + self.c = Translation([stroke('-Z'), stroke('P'), stroke('T*')], d) def test_last_list0(self): s = _State() @@ -422,17 +433,19 @@ def __call__(self, undo, new, prev): def t(self, strokes): """A quick way to make a translation.""" - return Translation([Stroke(x) for x in strokes.split('/')], self.d) + strokes = [stroke(x) for x in strokes.split('/')] + return Translation(strokes, _lookup(strokes, self.dc)) def lt(self, translations): - """A quick qay to make a list of translations.""" + """A quick way to make a list of translations.""" return [self.t(x) for x in translations.split()] def define(self, key, value): - self.d[tuple(key.split('/'))] = value + key = normalize_steno(key) + self.d[key] = value def translate(self, stroke): - _translate_stroke(stroke, self.s, self.d, self.o) + _translate_stroke(stroke, self.s, self.dc, self.o) def assertTranslations(self, expected): self.assertEqual(self.s.translations, expected) @@ -442,24 +455,26 @@ def assertOutput(self, undo, do, prev): def setUp(self): self.d = StenoDictionary() + self.dc = StenoDictionaryCollection() + self.dc.set_dicts([self.d]) self.s = _State() self.o = type(self).CaptureOutput() def test_first_stroke(self): - self.translate(Stroke('-B')) + self.translate(stroke('-B')) self.assertTranslations(self.lt('-B')) self.assertOutput([], self.lt('-B'), None) def test_second_stroke(self): self.define('S/P', 'spiders') self.s.translations = self.lt('S') - self.translate(Stroke('-T')) + self.translate(stroke('-T')) self.assertTranslations(self.lt('S -T')) self.assertOutput([], self.lt('-T'), self.t('S')) def test_second_stroke_tail(self): self.s.tail = self.t('T/A/I/L') - self.translate(Stroke('E')) + self.translate(stroke('-E')) self.assertTranslations(self.lt('E')) self.assertOutput([], self.lt('E'), self.t('T/A/I/L')) @@ -467,7 +482,7 @@ def test_with_translation(self): self.define('S', 'is') self.define('-T', 'that') self.s.translations = self.lt('S') - self.translate(Stroke('-T')) + self.translate(stroke('-T')) self.assertTranslations(self.lt('S -T')) self.assertOutput([], self.lt('-T'), self.t('S')) self.assertEqual(self.o.output.do[0].english, 'that') @@ -475,7 +490,7 @@ def test_with_translation(self): def test_finish_two_translation(self): self.define('S/T', 'hello') self.s.translations = self.lt('S') - self.translate(Stroke('T')) + self.translate(stroke('T')) self.assertTranslations(self.lt('S/T')) self.assertOutput(self.lt('S'), self.lt('S/T'), None) self.assertEqual(self.o.output.do[0].english, 'hello') @@ -484,7 +499,7 @@ def test_finish_two_translation(self): def test_finish_three_translation(self): self.define('S/T/-B', 'bye') self.s.translations = self.lt('S T') - self.translate(Stroke('-B')) + self.translate(stroke('-B')) self.assertTranslations(self.lt('S/T/-B')) self.assertOutput(self.lt('S T'), self.lt('S/T/-B'), None) self.assertEqual(self.o.output.do[0].english, 'bye') @@ -493,7 +508,7 @@ def test_finish_three_translation(self): def test_replace_translation(self): self.define('S/T/-B', 'longer') self.s.translations = self.lt('S/T') - self.translate(Stroke('-B')) + self.translate(stroke('-B')) self.assertTranslations(self.lt('S/T/-B')) self.assertOutput(self.lt('S/T'), self.lt('S/T/-B'), None) self.assertEqual(self.o.output.do[0].english, 'longer') @@ -501,37 +516,61 @@ def test_replace_translation(self): def test_undo(self): self.s.translations = self.lt('POP') - self.translate(Stroke('*', True)) + self.translate(stroke('*')) self.assertTranslations([]) self.assertOutput(self.lt('POP'), [], None) def test_empty_undo(self): - self.translate(Stroke('*', True)) + self.translate(stroke('*')) self.assertTranslations([]) self.assertOutput([], [], None) def test_undo_translation(self): self.define('P/P', 'pop') - self.translate(Stroke('P')) - self.translate(Stroke('P')) - self.translate(Stroke('*', True)) + self.translate(stroke('P')) + self.translate(stroke('P')) + self.translate(stroke('*')) self.assertTranslations(self.lt('P')) self.assertOutput(self.lt('P/P'), self.lt('P'), None) def test_undo_longer_translation(self): self.define('P/P/-D', 'popped') - self.translate(Stroke('P')) - self.translate(Stroke('P')) - self.translate(Stroke('-D')) - self.translate(Stroke('*', True)) + self.translate(stroke('P')) + self.translate(stroke('P')) + self.translate(stroke('-D')) + self.translate(stroke('*')) self.assertTranslations(self.lt('P P')) self.assertOutput(self.lt('P/P/-D'), self.lt('P P'), None) def test_undo_tail(self): self.s.tail = self.t('T/A/I/L') - self.translate(Stroke('*', True)) + self.translate(stroke('*')) self.assertTranslations([]) self.assertOutput([], [], self.t('T/A/I/L')) + + def test_suffix_folding(self): + self.define('K-L', 'look') + self.define('-G', '{^ing}') + lt = self.lt('K-LG') + self.assertEqual(lt[0].english, 'look {^ing}') + self.translate(stroke('K-LG')) + self.assertTranslations(lt) + + def test_suffix_folding_no_suffix(self): + self.define('K-L', 'look') + lt = self.lt('K-LG') + self.assertEqual(lt[0].english, None) + self.translate(stroke('K-LG')) + self.assertTranslations(lt) + + def test_suffix_folding_no_main(self): + self.define('-G', '{^ing}') + lt = self.lt('K-LG') + self.assertEqual(lt[0].english, None) + self.translate(stroke('K-LG')) + self.assertTranslations(lt) + + if __name__ == '__main__': unittest.main() diff --git a/plover/translation.py b/plover/translation.py index 1532b5367..0d400d3ce 100644 --- a/plover/translation.py +++ b/plover/translation.py @@ -16,7 +16,8 @@ """ -from steno_dictionary import StenoDictionary +from plover.steno import Stroke +from plover.steno_dictionary import StenoDictionaryCollection class Translation(object): """A data model for the mapping between a sequence of Strokes and a string. @@ -43,20 +44,19 @@ class Translation(object): """ - def __init__(self, strokes, rtfcreDict): + def __init__(self, outline, translation): """Create a translation by looking up strokes in a dictionary. Arguments: - strokes -- A list of Stroke objects. + outline -- A list of Stroke objects. - rtfcreDict -- A dictionary that maps strings in RTF/CRE format - to English phrases or meta commands. + translation -- A translation for the outline or None. """ - self.strokes = strokes - self.rtfcre = tuple(s.rtfcre for s in strokes) - self.english = rtfcreDict.get(self.rtfcre, None) + self.strokes = outline + self.rtfcre = tuple(s.rtfcre for s in outline) + self.english = translation self.replaced = [] self.formatting = None @@ -110,7 +110,7 @@ class Translator(object): def __init__(self): self._undo_length = 0 self._dictionary = None - self.set_dictionary(StenoDictionary()) + self.set_dictionary(StenoDictionaryCollection()) self._listeners = set() self._state = _State() @@ -273,8 +273,9 @@ def _translate_stroke(stroke, state, dictionary, callback): # existing translations by matching a longer entry in the # dictionary. for i in xrange(translation_index, len(state.translations)): - t = Translation(strokes, dictionary) - if t.english != None: + mapping = _lookup(strokes, dictionary) + if mapping != None: + t = Translation(strokes, mapping) t.replaced = state.translations[i:] undo.extend(t.replaced) do.append(t) @@ -282,8 +283,32 @@ def _translate_stroke(stroke, state, dictionary, callback): else: del strokes[:len(state.translations[i])] else: - do.append(Translation([stroke], dictionary)) + do.append(Translation([stroke], _lookup([stroke], dictionary))) del state.translations[len(state.translations) - len(undo):] callback(undo, do, state.last()) state.translations.extend(do) + +SUFFIX_KEYS = ['-S', '-G', '-Z', '-D'] + +def _lookup(strokes, dictionary): + dict_key = tuple(s.rtfcre for s in strokes) + result = dictionary.lookup(dict_key) + if result != None: + return result + + for key in SUFFIX_KEYS: + if key in strokes[-1].steno_keys: + dict_key = (Stroke([key]).rtfcre,) + suffix_mapping = dictionary.lookup(dict_key) + if suffix_mapping == None: continue + keys = strokes[-1].steno_keys[:] + keys.remove(key) + copy = strokes[:] + copy[-1] = Stroke(keys) + dict_key = tuple(s.rtfcre for s in copy) + main_mapping = dictionary.lookup(dict_key) + if main_mapping == None: continue + return main_mapping + ' ' + suffix_mapping + + return None