Rename to String Substitution
This commit is contained in:
parent
c49f409fb4
commit
8ab67052cf
@ -1,6 +1,6 @@
|
||||
# Text Replacement
|
||||
# String Substitution
|
||||
|
||||
The Text Replacement module lets a user replace one typed sequence of characters with another. If a string of characters you type matches an entry in your dictionary, it gets deleted and replaced with the corresponding replacement string.
|
||||
The String Substitution module lets a user replace one typed sequence of characters with another. If a string of characters you type matches an entry in your dictionary, it gets deleted and replaced with the corresponding replacement string.
|
||||
|
||||
Potential uses:
|
||||
|
||||
@ -9,20 +9,20 @@ Potential uses:
|
||||
|
||||
## Usage
|
||||
|
||||
The Text Replacement module takes a single argument to be passed during initialization: a user-defined dictionary where the keys are the text to be replaced and the values are the replacement text.
|
||||
The String Substitution module takes a single argument to be passed during initialization: a user-defined dictionary where the keys are the text to be replaced and the values are the replacement text.
|
||||
|
||||
Example is as follows:
|
||||
|
||||
```python
|
||||
from kmk.modules.text_replacement import TextReplacement
|
||||
from kmk.modules.string_substitution import StringSubstitution
|
||||
|
||||
my_dictionary = {
|
||||
'yuo': 'you',
|
||||
':sig': 'John Doe',
|
||||
'idk': "I don't know"
|
||||
}
|
||||
text_replacement = TextReplacement(dictionary=my_dictionary)
|
||||
keyboard.modules.append(text_replacement)
|
||||
string_substitution = StringSubstitution(dictionary=my_dictionary)
|
||||
keyboard.modules.append(string_substitution)
|
||||
```
|
||||
|
||||
### Recommendations
|
||||
|
@ -1,204 +0,0 @@
|
||||
try:
|
||||
from typing import Optional
|
||||
except ImportError:
|
||||
# we're not in a dev environment, so we don't need to worry about typing
|
||||
pass
|
||||
from micropython import const
|
||||
|
||||
from kmk.keys import KC, Key
|
||||
from kmk.modules import Module
|
||||
|
||||
|
||||
class State:
|
||||
LISTENING = const(0)
|
||||
DELETING = const(1)
|
||||
SENDING = const(2)
|
||||
|
||||
|
||||
class Character:
|
||||
'''Helper class for making a left-shifted key identical to a right-shifted key'''
|
||||
|
||||
is_shifted: bool = False
|
||||
|
||||
def __init__(self, key_code: Key, is_shifted: bool) -> None:
|
||||
self.is_shifted = is_shifted
|
||||
self.key_code = KC.LSHIFT(key_code) if is_shifted else key_code
|
||||
|
||||
def __eq__(self, other: any) -> bool: # type: ignore
|
||||
try:
|
||||
return (
|
||||
self.key_code.code == other.key_code.code
|
||||
and self.is_shifted == other.is_shifted
|
||||
)
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
class Phrase:
|
||||
'''Manages a collection of characters and keeps an index of them so that potential matches can be tracked'''
|
||||
|
||||
def __init__(self, string: str) -> None:
|
||||
self._characters: list[Character] = []
|
||||
self._index: int = 0
|
||||
for char in string:
|
||||
try:
|
||||
key_code = getattr(KC, char.upper())
|
||||
shifted = char.isupper() or key_code.has_modifiers == {2}
|
||||
self._characters.append(Character(key_code, shifted))
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid character in dictionary: {char}')
|
||||
|
||||
def next_character(self) -> None:
|
||||
'''Increment the current index for this phrase'''
|
||||
if not self.index_at_end():
|
||||
self._index += 1
|
||||
|
||||
def get_character_at_index(self, index: int) -> Character:
|
||||
'''Returns the character at the given index'''
|
||||
return self._characters[index]
|
||||
|
||||
def get_character_at_current_index(self) -> Character:
|
||||
'''Returns the character at the current index for this phrase'''
|
||||
return self._characters[self._index]
|
||||
|
||||
def reset_index(self) -> None:
|
||||
'''Reset the index to the start of the phrase'''
|
||||
self._index = 0
|
||||
|
||||
def index_at_end(self) -> bool:
|
||||
'''Returns True if the index is at the end of the phrase'''
|
||||
return self._index == len(self._characters)
|
||||
|
||||
def character_is_at_current_index(self, character) -> bool:
|
||||
'''Returns True if the given character is the next character in the phrase'''
|
||||
return self.get_character_at_current_index() == character
|
||||
|
||||
|
||||
class Rule:
|
||||
'''Represents the relationship between a phrase to be substituted and its substitution'''
|
||||
|
||||
def __init__(self, to_substitute: Phrase, substitution: Phrase) -> None:
|
||||
self.to_substitute: Phrase = to_substitute
|
||||
self.substitution: Phrase = substitution
|
||||
|
||||
def restart(self) -> None:
|
||||
'''Resets this rule's to_substitute and substitution phrases'''
|
||||
self.to_substitute.reset_index()
|
||||
self.substitution.reset_index()
|
||||
|
||||
|
||||
class TextReplacement(Module):
|
||||
_shifted: bool = False
|
||||
_rules: list = []
|
||||
_state: State = State.LISTENING
|
||||
_matched_rule: Optional[Phrase] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dictionary: dict,
|
||||
):
|
||||
for key,value in dictionary.items():
|
||||
self._rules.append(Rule(Phrase(key), Phrase(value)))
|
||||
|
||||
|
||||
def process_key(self, keyboard, key, is_pressed, int_coord):
|
||||
if not self._state == State.LISTENING:
|
||||
return
|
||||
if key is KC.LSFT or key is KC.RSFT:
|
||||
if is_pressed:
|
||||
self._shifted = True
|
||||
else:
|
||||
self._shifted = False
|
||||
if is_pressed:
|
||||
character = Character(key, self._shifted)
|
||||
|
||||
# run through the dictionary to check for a possible match on each new keypress
|
||||
for rule in self._rules:
|
||||
if rule.to_substitute.character_is_at_current_index(character):
|
||||
rule.to_substitute.next_character()
|
||||
else:
|
||||
rule.restart()
|
||||
# if character is not a match at the current index,
|
||||
# it could still be a match at the start of the sequence
|
||||
# so redo the check after resetting the sequence
|
||||
if rule.to_substitute.character_is_at_current_index(character):
|
||||
rule.to_substitute.next_character()
|
||||
# we've matched all of the characters in a phrase to be substituted
|
||||
if rule.to_substitute.index_at_end():
|
||||
rule.restart()
|
||||
# set the phrase indexes to where they differ
|
||||
# so that only the characters that differ are replaced
|
||||
for character in rule.to_substitute._characters:
|
||||
if (
|
||||
character
|
||||
== rule.substitution.get_character_at_current_index()
|
||||
):
|
||||
rule.to_substitute.next_character()
|
||||
rule.substitution.next_character()
|
||||
else:
|
||||
break
|
||||
if rule.to_substitute.index_at_end():
|
||||
break
|
||||
self._matched_rule = rule
|
||||
self._state = State.DELETING
|
||||
# if we have a match there's no reason to continue the full key processing, so return out
|
||||
return
|
||||
return key
|
||||
|
||||
|
||||
def during_bootup(self, keyboard):
|
||||
return
|
||||
|
||||
def before_matrix_scan(self, keyboard):
|
||||
return
|
||||
|
||||
def before_hid_send(self, keyboard):
|
||||
|
||||
if self._state == State.LISTENING:
|
||||
return
|
||||
|
||||
if self._state == State.DELETING:
|
||||
# send backspace taps equivalent to the length of the phrase to be substituted
|
||||
to_substitute: Phrase = self._matched_rule.to_substitute # type: ignore
|
||||
to_substitute.next_character()
|
||||
if not to_substitute.index_at_end():
|
||||
keyboard.tap_key(KC.BSPC)
|
||||
else:
|
||||
self._state = State.SENDING
|
||||
# force-release modifiers so sending the replacement text doesn't interact with them
|
||||
# it should not be possible for any modifiers other than shift to be held upon rule activation
|
||||
# as a modified key won't send a keycode that is matched against the user's dictionary,
|
||||
# but, just in case, we'll release those too
|
||||
keys_to_release = [
|
||||
KC.LSHIFT,
|
||||
KC.RSHIFT,
|
||||
KC.LCTL,
|
||||
KC.RCTL,
|
||||
KC.LALT,
|
||||
KC.RALT,
|
||||
]
|
||||
for key in keys_to_release:
|
||||
keyboard.remove_key(key)
|
||||
|
||||
if self._state == State.SENDING:
|
||||
substitution = self._matched_rule.substitution # type: ignore
|
||||
if not substitution.index_at_end():
|
||||
keyboard.tap_key(substitution.get_character_at_current_index().key_code)
|
||||
substitution.next_character()
|
||||
else:
|
||||
self._state = State.LISTENING
|
||||
self._matched_rule = None
|
||||
for rule in self._rules:
|
||||
rule.restart()
|
||||
|
||||
def after_hid_send(self, keyboard):
|
||||
return
|
||||
|
||||
def on_powersave_enable(self, keyboard):
|
||||
return
|
||||
|
||||
def on_powersave_disable(self, keyboard):
|
||||
return
|
||||
|
||||
def after_matrix_scan(self, keyboard):
|
||||
return
|
@ -1,216 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from kmk.keys import ALL_ALPHAS, ALL_NUMBERS, KC
|
||||
from kmk.modules.text_replacement import Character, Phrase, Rule, TextReplacement
|
||||
from tests.keyboard_test import KeyboardTest
|
||||
|
||||
|
||||
class TestTextReplacement(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.symbols = '`-=[]\\;\',./~!@#$%^&*()_+{}|:\"<>?'
|
||||
self.everything = ALL_NUMBERS + ALL_ALPHAS + ALL_ALPHAS.lower() + self.symbols
|
||||
self.test_dictionary = {'aa': 'b', 'b': 'aa', '!': '@', 'dccc': 'dcbb'}
|
||||
self.text_replacement = TextReplacement(self.test_dictionary)
|
||||
self.keyboard = KeyboardTest(
|
||||
[self.text_replacement],
|
||||
[
|
||||
[KC.A, KC.B, KC.N1, KC.LSHIFT, KC.LCTRL, KC.C, KC.D],
|
||||
],
|
||||
debug_enabled=False,
|
||||
)
|
||||
|
||||
return super().setUp()
|
||||
|
||||
def test_keyboard_events_are_correct(self):
|
||||
# backspace doesn't have to fire for the final key pressed
|
||||
# that results in a corresponding match, as that key is never sent
|
||||
# the matching key also never sends a keyup event
|
||||
self.keyboard.test(
|
||||
'multi-character key, single-character value',
|
||||
[(0, True), (0, False), (0, True), (0, False)],
|
||||
[{KC.A}, {}, {KC.BACKSPACE}, {}, {KC.B}, {}],
|
||||
)
|
||||
# note: the pressed key is never sent here, as the event is
|
||||
# intercepted and the replacement is sent instead
|
||||
self.keyboard.test(
|
||||
'multi-character value, single-character key',
|
||||
[(1, True), (1, False)],
|
||||
[{KC.A}, {}, {KC.A}, {}],
|
||||
)
|
||||
# modifiers are force-released if there's a match,
|
||||
# so the keyup event for them isn't sent
|
||||
self.keyboard.test(
|
||||
'shifted alphanumeric or symbol in key and/or value',
|
||||
[(3, True), (2, True), (2, False), (3, False)],
|
||||
[{KC.LSHIFT}, {KC.LSHIFT, KC.N2}, {}],
|
||||
)
|
||||
self.keyboard.test(
|
||||
'backspace is only tapped as many times as necessary to delete the difference between the key and value',
|
||||
[
|
||||
(6, True),
|
||||
(6, False),
|
||||
(5, True),
|
||||
(5, False),
|
||||
(5, True),
|
||||
(5, False),
|
||||
(5, True),
|
||||
(5, False),
|
||||
],
|
||||
[
|
||||
{KC.D},
|
||||
{},
|
||||
{KC.C},
|
||||
{},
|
||||
{KC.C},
|
||||
{},
|
||||
{KC.BACKSPACE},
|
||||
{},
|
||||
{KC.B},
|
||||
{},
|
||||
{KC.B},
|
||||
{},
|
||||
],
|
||||
)
|
||||
|
||||
def test_invalid_character_in_dictionary_throws_error(self):
|
||||
dict = {
|
||||
'illegal_character_in_key': {'é': 'a'},
|
||||
'illegal_character_in_value': {'a': 'é'},
|
||||
}
|
||||
self.assertRaises(ValueError, TextReplacement, dict['illegal_character_in_key'])
|
||||
self.assertRaises(
|
||||
ValueError, TextReplacement, dict['illegal_character_in_value']
|
||||
)
|
||||
|
||||
def test_character_constructs_properly(self):
|
||||
unshifted_character = Character(KC.A, False)
|
||||
shifted_letter = Character(KC.A, True)
|
||||
shifted_symbol = Character(KC.N1, True)
|
||||
self.assertEqual(
|
||||
unshifted_character.key_code,
|
||||
KC.A,
|
||||
'unshifted character key code is correct',
|
||||
)
|
||||
self.assertEqual(
|
||||
shifted_letter.key_code.__dict__,
|
||||
KC.LSHIFT(KC.A).__dict__,
|
||||
'shifted letter key code is correct',
|
||||
)
|
||||
self.assertEqual(
|
||||
shifted_symbol.key_code.__dict__,
|
||||
KC.LSHIFT(KC.N1).__dict__,
|
||||
'shifted symbol key code is correct',
|
||||
)
|
||||
|
||||
def test_phrase_constructs_properly(self):
|
||||
combination = ALL_NUMBERS + ALL_ALPHAS + ALL_ALPHAS.lower()
|
||||
multi_character_phrase = Phrase(combination)
|
||||
|
||||
# lower case
|
||||
for letter in ALL_ALPHAS:
|
||||
letter = letter.lower()
|
||||
phrase = Phrase(letter)
|
||||
self.assertEqual(
|
||||
phrase.get_character_at_index(0).key_code,
|
||||
KC[letter],
|
||||
f'Test failed when constructing phrase with lower-case letter {letter}',
|
||||
)
|
||||
# upper case
|
||||
for letter in ALL_ALPHAS:
|
||||
phrase = Phrase(letter)
|
||||
self.assertEqual(
|
||||
phrase.get_character_at_index(0).key_code.__dict__,
|
||||
KC.LSHIFT(KC[letter]).__dict__,
|
||||
f'Test failed when constructing phrase with upper-case letter {letter}',
|
||||
)
|
||||
# numbers
|
||||
for letter in ALL_NUMBERS:
|
||||
phrase = Phrase(letter)
|
||||
self.assertEqual(
|
||||
phrase.get_character_at_index(0).key_code,
|
||||
KC[letter],
|
||||
f'Test failed when constructing phrase with number {letter}',
|
||||
)
|
||||
# multi-character phrase
|
||||
for i, character in enumerate(combination):
|
||||
self.assertEqual(
|
||||
multi_character_phrase.get_character_at_index(i).key_code.__dict__,
|
||||
KC.LSHIFT(KC[character]).__dict__
|
||||
if combination[i].isupper()
|
||||
else KC[character].__dict__,
|
||||
f'Test failed when constructing phrase with character {character}',
|
||||
)
|
||||
|
||||
def test_phrase_with_symbols_constructs_properly(self):
|
||||
phrase = Phrase(self.symbols)
|
||||
for i, symbol in enumerate(self.symbols):
|
||||
self.assertEqual(
|
||||
phrase.get_character_at_index(i).key_code.__dict__,
|
||||
KC[symbol].__dict__,
|
||||
'Test failed for symbol {}'.format(symbol),
|
||||
)
|
||||
|
||||
def test_phrase_indexes_correctly(self):
|
||||
phrase = Phrase(ALL_ALPHAS.lower())
|
||||
i = 0
|
||||
while not phrase.index_at_end():
|
||||
self.assertTrue(
|
||||
phrase.character_is_at_current_index(phrase.get_character_at_index(i)),
|
||||
'Current character in the phrase is not the expected one',
|
||||
)
|
||||
self.assertEqual(
|
||||
phrase.get_character_at_index(i).key_code.__dict__,
|
||||
KC[ALL_ALPHAS[i]].__dict__,
|
||||
f'Character at index {i} is not {ALL_ALPHAS[i]}',
|
||||
)
|
||||
phrase.next_character()
|
||||
i += 1
|
||||
self.assertLess(
|
||||
i, len(ALL_ALPHAS) + 1, 'While loop checking phrase index ran too long'
|
||||
)
|
||||
phrase.reset_index()
|
||||
self.assertEqual(
|
||||
phrase.get_character_at_current_index().key_code,
|
||||
KC[ALL_ALPHAS[0]],
|
||||
'Phrase did not reset its index to 0',
|
||||
)
|
||||
|
||||
def test_sanity_check(self):
|
||||
'''Test character/phrase construction with every letter, number, and symbol, shifted and unshifted'''
|
||||
phrase = Phrase(self.everything)
|
||||
for i, character in enumerate(self.everything):
|
||||
self.assertEqual(
|
||||
phrase.get_character_at_index(i).key_code.__dict__,
|
||||
KC.LSHIFT(KC[character]).__dict__
|
||||
if self.everything[i].isupper()
|
||||
else KC[character].__dict__,
|
||||
f'Test failed when constructing phrase with character {character}',
|
||||
)
|
||||
|
||||
def test_rule(self):
|
||||
phrase1 = Phrase(self.everything)
|
||||
phrase2 = Phrase(self.everything)
|
||||
rule = Rule(phrase1, phrase2)
|
||||
self.assertEqual(
|
||||
rule.to_substitute, phrase1, "Rule's entry to be substituted is correct"
|
||||
)
|
||||
self.assertEqual(
|
||||
rule.substitution, phrase2, "Rule's substitution entry is correct"
|
||||
)
|
||||
rule.to_substitute.next_character()
|
||||
rule.substitution.next_character()
|
||||
rule.restart()
|
||||
self.assertEqual(
|
||||
rule.to_substitute.get_character_at_index(0).key_code,
|
||||
KC[self.everything[0]],
|
||||
'Rule did not call to_substitute.reset_index() when rule.restart() was called',
|
||||
)
|
||||
self.assertEqual(
|
||||
rule.substitution.get_character_at_index(0).key_code,
|
||||
KC[self.everything[0]],
|
||||
'Rule did not call substitution.reset_index() when rule.restart() was called',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
Loading…
Reference in New Issue
Block a user