draft_talon_helpers.py (9326B)
1 from typing import Optional 2 from talon import ui, settings, Module, Context, actions 3 from .draft_ui import DraftManager 4 5 mod = Module() 6 7 # ctx is for toggling the draft_window_showing variable 8 # which lets you execute actions whenever the window is visible. 9 ctx = Context() 10 11 # ctx_focused is active only when the draft window is focussed. This 12 # lets you execute actions under that condition. 13 ctx_focused = Context() 14 ctx_focused.matches = r""" 15 title: Talon Draft 16 """ 17 18 mod.tag("draft_window_showing", desc="Tag set when draft window showing") 19 setting_theme = mod.setting( 20 "draft_window_theme", 21 type=str, 22 default="dark", 23 desc="Sets the main colors of the window, one of 'dark' or 'light'", 24 ) 25 setting_label_size = mod.setting( 26 "draft_window_label_size", 27 type=int, 28 default=20, 29 desc="Sets the size of the word labels used in the draft window", 30 ) 31 setting_label_color = mod.setting( 32 "draft_window_label_color", 33 type=str, 34 default=None, 35 desc=( 36 "Sets the color of the word labels used in the draft window. " 37 "E.g. 00ff00 would be green" 38 ), 39 ) 40 setting_text_size = mod.setting( 41 "draft_window_text_size", 42 type=int, 43 default=20, 44 desc="Sets the size of the text used in the draft window", 45 ) 46 47 48 draft_manager = DraftManager() 49 50 # Update the styling of the draft window dynamically as user settings change 51 def _update_draft_style(*args): 52 draft_manager.set_styling( 53 **{ 54 arg: setting.get() 55 for setting, arg in ( 56 (setting_theme, "theme"), 57 (setting_label_size, "label_size"), 58 (setting_label_color, "label_color"), 59 (setting_text_size, "text_size"), 60 ) 61 } 62 ) 63 64 65 settings.register("", _update_draft_style) 66 67 68 @ctx_focused.action_class("user") 69 class ContextSensitiveDictationActions: 70 """ 71 Override these actions to assist 'Smart dictation mode'. 72 see https://github.com/knausj85/knausj_talon/pull/356 73 """ 74 75 def dictation_peek_left(clobber=False): 76 area = draft_manager.area 77 return area[max(0, area.sel.left - 50) : area.sel.left] 78 79 def dictation_peek_right(): 80 area = draft_manager.area 81 return area[area.sel.right : area.sel.right + 50] 82 83 def paste(text: str): 84 # todo: remove once user.paste works reliably with the draft window 85 actions.insert(text) 86 87 88 @ctx_focused.action_class("edit") 89 class EditActions: 90 """ 91 Make default edit actions more efficient. 92 """ 93 94 def selected_text() -> str: 95 area = draft_manager.area 96 if area.sel: 97 result = area[area.sel.left : area.sel.right] 98 return result 99 return "" 100 101 102 from talon import cron 103 104 105 class UndoWorkaround: 106 """ 107 Workaround for the experimental textarea's undo being character by character. 108 This keeps a debounced undo history. Can be deleted once this todo item is 109 fixed: https://github.com/talonvoice/talon/issues/254#issuecomment-789149734 110 """ 111 112 # Set this to False if you want to turn it off, or just delete all references 113 # to this class 114 enable_workaround = True 115 116 # Stack of (text_value, selection) tuples representing the undo stack 117 undo_stack = [] 118 # Stack of (text_value, selection) tuples representing the redo stack 119 redo_stack = [] 120 # Used by the timer to check when the text has stopped changing 121 pending_undo = None 122 123 # timer handle 124 timer_handle = None 125 126 @classmethod 127 def start_logger(cls, reset_undo_stack: bool): 128 if reset_undo_stack: 129 cls.undo_stack = [] 130 cls.redo_stack = [] 131 132 cls.stop_logger() 133 cls.timer_handle = cron.interval("500ms", cls._log_changes) 134 135 @classmethod 136 def stop_logger(cls): 137 if cls.timer_handle is not None: 138 cron.cancel(cls.timer_handle) 139 cls.timer_handle = None 140 cls.pending_undo = None 141 142 @classmethod 143 def perform_undo(cls): 144 if len(cls.undo_stack) == 0: 145 return 146 147 curr_text = draft_manager.area.value 148 curr_sel = (draft_manager.area.sel.left, draft_manager.area.sel.right) 149 text, sel = cls.undo_stack[-1] 150 if text == curr_text: 151 cls.undo_stack.pop() 152 if len(cls.undo_stack) == 0: 153 return 154 155 # Most of the time (unless user has only just finished updating) the 156 # top of the stack will have the same contents as the text area. In 157 # this case pop again to get a bit lower. We should never have the 158 # same text twice, hence we don't need a loop. 159 text, sel = cls.undo_stack[-1] 160 161 # Remember the current state in the redo stack 162 cls.redo_stack.append((curr_text, curr_sel)) 163 draft_manager.area.value = text 164 draft_manager.area.sel = sel 165 166 cls.pending_undo = (text, sel) 167 168 @classmethod 169 def perform_redo(cls): 170 if len(cls.redo_stack) == 0: 171 return 172 173 text, sel = cls.redo_stack.pop() 174 175 draft_manager.area.value = text 176 draft_manager.area.sel = sel 177 178 cls.pending_undo = (text, sel) 179 cls.undo_stack.append((text, sel)) 180 181 @classmethod 182 def _log_changes(cls): 183 """ 184 If the text and cursor position hasn't changed for two interval iterations 185 (1s) and the undo stack doesn't match the current state, then add to the stack. 186 """ 187 188 curr_val = draft_manager.area.value 189 # Turn the Span into a tuple, because we can't == Spans 190 curr_sel = (draft_manager.area.sel.left, draft_manager.area.sel.right) 191 curr_state = (curr_val, curr_sel) 192 193 state_stack_mismatch = ( 194 len(cls.undo_stack) == 0 195 or 196 # Only want to update the undo stack if the value has changed, not just 197 # the selection 198 curr_state[0] != cls.undo_stack[-1][0] 199 ) 200 201 if cls.pending_undo == curr_state and state_stack_mismatch: 202 cls.undo_stack.append(curr_state) 203 # Clear out the redo stack because we've changed the text 204 cls.redo_stack = [] 205 elif cls.pending_undo != curr_state: 206 cls.pending_undo = curr_state 207 elif not state_stack_mismatch and len(cls.undo_stack) > 0: 208 # Remember the cursor position in the undo stack for the current text value 209 cls.undo_stack[-1] = (cls.undo_stack[-1][0], curr_sel) 210 else: 211 # The text area text is not changing, do nothing 212 pass 213 214 215 if UndoWorkaround.enable_workaround: 216 ctx_focused.action("edit.undo")(UndoWorkaround.perform_undo) 217 ctx_focused.action("edit.redo")(UndoWorkaround.perform_redo) 218 219 220 @mod.action_class 221 class Actions: 222 def draft_show(text: Optional[str] = None): 223 """ 224 Shows draft window 225 """ 226 227 draft_manager.show(text) 228 UndoWorkaround.start_logger(text is not None) 229 ctx.tags = ["user.draft_window_showing"] 230 231 def draft_hide(): 232 """ 233 Hides draft window 234 """ 235 236 draft_manager.hide() 237 UndoWorkaround.stop_logger() 238 ctx.tags = [] 239 240 def draft_select( 241 start_anchor: str, end_anchor: str = "", include_trailing_whitespace: int = 0 242 ): 243 """ 244 Selects text in the draft window 245 """ 246 247 draft_manager.select_text( 248 start_anchor, 249 end_anchor=None if end_anchor == "" else end_anchor, 250 include_trailing_whitespace=include_trailing_whitespace == 1, 251 ) 252 253 def draft_position_caret(anchor: str, after: int = 0): 254 """ 255 Positions the caret in the draft window 256 """ 257 258 draft_manager.position_caret(anchor, after=after == 1) 259 260 def draft_get_text() -> str: 261 """ 262 Returns the text in the draft window 263 """ 264 265 return draft_manager.get_text() 266 267 def draft_resize(width: int, height: int): 268 """ 269 Resize the draft window. 270 """ 271 272 draft_manager.reposition(width=width, height=height) 273 274 def draft_named_move(name: str, screen_number: Optional[int] = None): 275 """ 276 Lets you move the window to the top, bottom, left, right, or middle 277 of the screen. 278 """ 279 280 screen = ui.screens()[screen_number or 0] 281 window_rect = draft_manager.get_rect() 282 xpos = (screen.width - window_rect.width) / 2 283 ypos = (screen.height - window_rect.height) / 2 284 285 if name == "top": 286 ypos = 50 287 elif name == "bottom": 288 ypos = screen.height - window_rect.height - 50 289 elif name == "left": 290 xpos = 50 291 elif name == "right": 292 xpos = screen.width - window_rect.width - 50 293 elif name == "middle": 294 # That's the default values 295 pass 296 297 # Adjust for the fact that the screen may not be at 0,0. 298 xpos += screen.x 299 ypos += screen.y 300 draft_manager.reposition(xpos=xpos, ypos=ypos) 301 302 303 # Some capture groups we need 304 305 306 @mod.capture(rule="{self.letter}+") 307 def draft_anchor(m) -> str: 308 """ 309 An anchor (string of letters) 310 """ 311 return "".join(m) 312 313 314 @mod.capture(rule="(top|bottom|left|right|middle)") 315 def draft_window_position(m) -> str: 316 """ 317 One of the named positions you can move the window to 318 """ 319 320 return "".join(m)