formatters.py (9888B)
1 from talon import Module, Context, actions, ui, imgui, app 2 from talon.grammar import Phrase 3 from typing import List, Union 4 import logging 5 import re 6 7 ctx = Context() 8 key = actions.key 9 edit = actions.edit 10 11 words_to_keep_lowercase = "a,an,the,at,by,for,in,is,of,on,to,up,and,as,but,or,nor".split( 12 "," 13 ) 14 15 # The last phrase spoken, without & with formatting. Used for reformatting. 16 last_phrase = "" 17 last_phrase_formatted = "" 18 19 20 def surround(by): 21 def func(i, word, last): 22 if i == 0: 23 word = by + word 24 if last: 25 word += by 26 return word 27 28 return func 29 30 31 def format_phrase(m: Union[str, Phrase], fmtrs: str): 32 global last_phrase, last_phrase_formatted 33 last_phrase = m 34 words = [] 35 if isinstance(m, str): 36 words = m.split(" ") 37 else: 38 # TODO: is this still necessary, and if so why? 39 if m.words[-1] == "over": 40 m.words = m.words[:-1] 41 42 words = actions.dictate.parse_words(m) 43 words = actions.dictate.replace_words(words) 44 45 result = last_phrase_formatted = format_phrase_no_history(words, fmtrs) 46 actions.user.add_phrase_to_history(result) 47 # Arguably, we shouldn't be dealing with history here, but somewhere later 48 # down the line. But we have a bunch of code that relies on doing it this 49 # way and I don't feel like rewriting it just now. -rntz, 2020-11-04 50 return result 51 52 53 def format_phrase_no_history(word_list, fmtrs: str): 54 fmtr_list = fmtrs.split(",") 55 words = [] 56 spaces = True 57 for i, w in enumerate(word_list): 58 for name in reversed(fmtr_list): 59 smash, func = all_formatters[name] 60 w = func(i, w, i == len(word_list) - 1) 61 spaces = spaces and not smash 62 words.append(w) 63 sep = " " if spaces else "" 64 return sep.join(words) 65 66 67 NOSEP = True 68 SEP = False 69 70 71 def words_with_joiner(joiner): 72 """Pass through words unchanged, but add a separator between them.""" 73 74 def formatter_function(i, word, _): 75 return word if i == 0 else joiner + word 76 77 return (NOSEP, formatter_function) 78 79 80 def first_vs_rest(first_func, rest_func=lambda w: w): 81 """Supply one or two transformer functions for the first and rest of 82 words respectively. 83 84 Leave second argument out if you want all but the first word to be passed 85 through unchanged. 86 Set first argument to None if you want the first word to be passed 87 through unchanged.""" 88 if first_func is None: 89 first_func = lambda w: w 90 91 def formatter_function(i, word, _): 92 return first_func(word) if i == 0 else rest_func(word) 93 94 return formatter_function 95 96 97 def every_word(word_func): 98 """Apply one function to every word.""" 99 100 def formatter_function(i, word, _): 101 return word_func(word) 102 103 return formatter_function 104 105 106 formatters_dict = { 107 "NOOP": (SEP, lambda i, word, _: word), 108 "DOUBLE_UNDERSCORE": (NOSEP, first_vs_rest(lambda w: "__%s__" % w)), 109 "PRIVATE_CAMEL_CASE": (NOSEP, first_vs_rest(lambda w: w, lambda w: w.capitalize())), 110 "PROTECTED_CAMEL_CASE": ( 111 NOSEP, 112 first_vs_rest(lambda w: w, lambda w: w.capitalize()), 113 ), 114 "PUBLIC_CAMEL_CASE": (NOSEP, every_word(lambda w: w.capitalize())), 115 "SNAKE_CASE": ( 116 NOSEP, 117 first_vs_rest(lambda w: w.lower(), lambda w: "_" + w.lower()), 118 ), 119 "NO_SPACES": (NOSEP, every_word(lambda w: w)), 120 "DASH_SEPARATED": words_with_joiner("-"), 121 "TERMINAL_DASH_SEPARATED": ( 122 NOSEP, 123 first_vs_rest(lambda w: " --" + w.lower(), lambda w: "-" + w.lower()), 124 ), 125 "DOUBLE_COLON_SEPARATED": words_with_joiner("::"), 126 "ALL_CAPS": (SEP, every_word(lambda w: w.upper())), 127 "ALL_LOWERCASE": (SEP, every_word(lambda w: w.lower())), 128 "DOUBLE_QUOTED_STRING": (SEP, surround('"')), 129 "SINGLE_QUOTED_STRING": (SEP, surround("'")), 130 "SPACE_SURROUNDED_STRING": (SEP, surround(" ")), 131 "DOT_SEPARATED": words_with_joiner("."), 132 "DOT_SNAKE": (NOSEP, lambda i, word, _: "." + word if i == 0 else "_" + word), 133 "SLASH_SEPARATED": (NOSEP, every_word(lambda w: "/" + w)), 134 "CAPITALIZE_FIRST_WORD": (SEP, first_vs_rest(lambda w: w.capitalize())), 135 "CAPITALIZE_ALL_WORDS": ( 136 SEP, 137 lambda i, word, _: word.capitalize() 138 if i == 0 or word not in words_to_keep_lowercase 139 else word, 140 ), 141 "FIRST_THREE": (NOSEP, lambda i, word, _: word[0:3]), 142 "FIRST_FOUR": (NOSEP, lambda i, word, _: word[0:4]), 143 "FIRST_FIVE": (NOSEP, lambda i, word, _: word[0:5]), 144 } 145 146 # This is the mapping from spoken phrases to formatters 147 formatters_words = { 148 "allcaps": formatters_dict["ALL_CAPS"], 149 "alldown": formatters_dict["ALL_LOWERCASE"], 150 "camel": formatters_dict["PRIVATE_CAMEL_CASE"], 151 "dotted": formatters_dict["DOT_SEPARATED"], 152 "dubstring": formatters_dict["DOUBLE_QUOTED_STRING"], 153 "dunder": formatters_dict["DOUBLE_UNDERSCORE"], 154 "hammer": formatters_dict["PUBLIC_CAMEL_CASE"], 155 "kebab": formatters_dict["DASH_SEPARATED"], 156 "packed": formatters_dict["DOUBLE_COLON_SEPARATED"], 157 "padded": formatters_dict["SPACE_SURROUNDED_STRING"], 158 # "say": formatters_dict["NOOP"], 159 # "sentence": formatters_dict["CAPITALIZE_FIRST_WORD"], 160 "slasher": formatters_dict["SLASH_SEPARATED"], 161 "smash": formatters_dict["NO_SPACES"], 162 "snake": formatters_dict["SNAKE_CASE"], 163 # "speak": formatters_dict["NOOP"], 164 "string": formatters_dict["SINGLE_QUOTED_STRING"], 165 "title": formatters_dict["CAPITALIZE_ALL_WORDS"], 166 # disable a few formatters for now 167 # "tree": formatters_dict["FIRST_THREE"], 168 # "quad": formatters_dict["FIRST_FOUR"], 169 # "fiver": formatters_dict["FIRST_FIVE"], 170 } 171 172 all_formatters = {} 173 all_formatters.update(formatters_dict) 174 all_formatters.update(formatters_words) 175 176 mod = Module() 177 mod.list("formatters", desc="list of formatters") 178 mod.list( 179 "prose_formatter", 180 desc="words to start dictating prose, and the formatter they apply", 181 ) 182 183 184 @mod.capture(rule="{self.formatters}+") 185 def formatters(m) -> str: 186 "Returns a comma-separated string of formatters e.g. 'SNAKE,DUBSTRING'" 187 return ",".join(m.formatters_list) 188 189 190 @mod.capture( 191 # Note that if the user speaks something like "snake dot", it will 192 # insert "dot" - otherwise, they wouldn't be able to insert punctuation 193 # words directly. 194 rule="<self.formatters> <user.text> (<user.text> | <user.formatter_immune>)*" 195 ) 196 def format_text(m) -> str: 197 "Formats the text and returns a string" 198 out = "" 199 formatters = m[0] 200 for chunk in m[1:]: 201 if isinstance(chunk, ImmuneString): 202 out += chunk.string 203 else: 204 out += format_phrase(chunk, formatters) 205 return out 206 207 208 class ImmuneString(object): 209 """Wrapper that makes a string immune from formatting.""" 210 211 def __init__(self, string): 212 self.string = string 213 214 215 @mod.capture( 216 # Add anything else into this that you want to be able to speak during a 217 # formatter. 218 rule="(<user.symbol_key> | numb <number>)" 219 ) 220 def formatter_immune(m) -> ImmuneString: 221 """Text that can be interspersed into a formatter, e.g. characters. 222 223 It will be inserted directly, without being formatted. 224 225 """ 226 if hasattr(m, "number"): 227 value = m.number 228 else: 229 value = m[0] 230 return ImmuneString(str(value)) 231 232 233 @mod.action_class 234 class Actions: 235 def formatted_text(phrase: Union[str, Phrase], formatters: str) -> str: 236 """Formats a phrase according to formatters. formatters is a comma-separated string of formatters (e.g. 'CAPITALIZE_ALL_WORDS,DOUBLE_QUOTED_STRING')""" 237 return format_phrase(phrase, formatters) 238 239 def insert_formatted(phrase: Union[str, Phrase], formatters: str): 240 """Inserts a phrase formatted according to formatters. Formatters is a comma separated list of formatters (e.g. 'CAPITALIZE_ALL_WORDS,DOUBLE_QUOTED_STRING')""" 241 actions.insert(format_phrase(phrase, formatters)) 242 243 def formatters_help_toggle(): 244 """Lists all formatters""" 245 if gui.showing: 246 gui.hide() 247 else: 248 gui.show() 249 250 def formatters_reformat_last(formatters: str) -> str: 251 """Clears and reformats last formatted phrase""" 252 global last_phrase, last_phrase_formatted 253 if actions.user.get_last_phrase() != last_phrase_formatted: 254 # The last thing we inserted isn't the same as the last thing we 255 # formatted, so abort. 256 logging.warning( 257 "formatters_reformat_last(): Last phrase wasn't a formatter!" 258 ) 259 return 260 actions.user.clear_last_phrase() 261 actions.user.insert_formatted(last_phrase, formatters) 262 263 def formatters_reformat_selection(formatters: str) -> str: 264 """Reformats the current selection.""" 265 selected = edit.selected_text() 266 if not selected: 267 print("Asked to reformat selection, but nothing selected!") 268 return 269 unformatted = re.sub(r"[^a-zA-Z0-9]+", " ", selected).lower() 270 # TODO: Separate out camelcase & studleycase vars 271 272 # Delete separately for compatibility with programs that don't overwrite 273 # selected text (e.g. Emacs) 274 edit.delete() 275 text = actions.self.formatted_text(unformatted, formatters) 276 actions.insert(text) 277 return text 278 279 def insert_many(strings: List[str]) -> None: 280 """Insert a list of strings, sequentially.""" 281 for string in strings: 282 actions.insert(string) 283 284 285 ctx.lists["self.formatters"] = formatters_words.keys() 286 ctx.lists["self.prose_formatter"] = { 287 "say": "NOOP", 288 "speak": "NOOP", 289 "sentence": "CAPITALIZE_FIRST_WORD", 290 } 291 292 293 @imgui.open() 294 def gui(gui: imgui.GUI): 295 gui.text("List formatters") 296 gui.line() 297 for name in sorted(set(formatters_words.keys())): 298 gui.text(f"{name} | {format_phrase_no_history(['one', 'two', 'three'], name)}")