draft_ui.py (6798B)
1 from typing import Optional 2 import re 3 4 from talon.experimental.textarea import ( 5 TextArea, 6 Span, 7 DarkThemeLabels, 8 LightThemeLabels 9 ) 10 11 12 word_matcher = re.compile(r"([^\s]+)(\s*)") 13 def calculate_text_anchors(text, cursor_position, anchor_labels=None): 14 """ 15 Produces an iterator of (anchor, start_word_index, end_word_index, last_space_index) 16 tuples from the given text. Each tuple indicates a particular point you may want to 17 reference when editing along with some useful ranges you may want to operate on. 18 19 - text is the text you want to process. 20 - cursor_position is the current position of the cursor, anchors will be placed around 21 this. 22 - anchor_labels is a list of characters you want to use for your labels. 23 - *index is just a character offset from the start of the string (e.g. the first character is at index 0) 24 - end_word_index is the index of the character after the last one included in the 25 anchor. That is, you can use it with a slice directly like [start:end] 26 - anchor is a short piece of text you can use to identify it (e.g. 'a', or '1'). 27 """ 28 anchor_labels = anchor_labels or "abcdefghijklmnopqrstuvwxyz" 29 30 if len(text) == 0: 31 return [] 32 33 # Find all the word spans 34 matches = [] 35 cursor_idx = None 36 for match in word_matcher.finditer(text): 37 matches.append(( 38 match.start(), 39 match.end() - len(match.group(2)), 40 match.end() 41 )) 42 if matches[-1][0] <= cursor_position and matches[-1][2] >= cursor_position: 43 cursor_idx = len(matches) - 1 44 45 # Now work out what range of those matches are getting an anchor. The aim is 46 # to centre the anchors around the cursor position, but also to use all the 47 # anchors. 48 anchors_before_cursor = len(anchor_labels) // 2 49 anchor_start_idx = max(0, cursor_idx - anchors_before_cursor) 50 anchor_end_idx = min(len(matches), anchor_start_idx + len(anchor_labels)) 51 anchor_start_idx = max(0, anchor_end_idx - len(anchor_labels)) 52 53 # Now add anchors to the selected matches 54 for i, anchor in zip(range(anchor_start_idx, anchor_end_idx), anchor_labels): 55 word_start, word_end, whitespace_end = matches[i] 56 yield ( 57 anchor, 58 word_start, 59 word_end, 60 whitespace_end 61 ) 62 63 64 class DraftManager: 65 """ 66 API to the draft window 67 """ 68 69 def __init__(self): 70 self.area = TextArea() 71 self.area.title = "Talon Draft" 72 self.area.value = "" 73 self.area.register("label", self._update_labels) 74 self.set_styling() 75 76 def set_styling( 77 self, 78 theme="dark", 79 text_size=20, 80 label_size=20, 81 label_color=None 82 ): 83 """ 84 Allow settings the style of the draft window. Will dynamically 85 update the style based on the passed in parameters. 86 """ 87 88 area_theme = DarkThemeLabels if theme == "dark" else LightThemeLabels 89 theme_changes = { 90 "text_size": text_size, 91 "label_size": label_size, 92 } 93 if label_color is not None: 94 theme_changes["label"] = label_color 95 self.area.theme = area_theme(**theme_changes) 96 97 def show(self, text: Optional[str] = None): 98 """ 99 Show the window. If text is None then keep the old contents, 100 otherwise set the text to the given value. 101 """ 102 103 if text is not None: 104 self.area.value = text 105 self.area.show() 106 107 def hide(self): 108 """ 109 Hide the window. 110 """ 111 112 self.area.hide() 113 114 def get_text(self) -> str: 115 """ 116 Gets the context of the text area 117 """ 118 119 return self.area.value 120 121 def get_rect(self) -> "talon.types.Rect": 122 """ 123 Get the Rect for the window 124 """ 125 126 return self.area.rect 127 128 def reposition( 129 self, 130 xpos: Optional[int] = None, 131 ypos: Optional[int] = None, 132 width: Optional[int] = None, 133 height: Optional[int] = None, 134 ): 135 """ 136 Move the window or resize it without having to change all properties. 137 """ 138 139 rect = self.area.rect 140 if xpos is not None: 141 rect.x = xpos 142 143 if ypos is not None: 144 rect.y = ypos 145 146 if width is not None: 147 rect.width = width 148 149 if height is not None: 150 rect.height = height 151 152 self.area.rect = rect 153 154 def select_text( 155 self, start_anchor, end_anchor=None, include_trailing_whitespace=False 156 ): 157 """ 158 Selects the word corresponding to start_anchor. If end_anchor supplied, selects 159 from start_anchor to the end of end_anchor. If include_trailing_whitespace=True 160 then also selects trailing space characters (useful for delete). 161 """ 162 163 start_index, end_index, last_space_index = self.anchor_to_range(start_anchor) 164 if end_anchor is not None: 165 _, end_index, last_space_index = self.anchor_to_range(end_anchor) 166 167 if include_trailing_whitespace: 168 end_index = last_space_index 169 170 self.area.sel = Span(start_index, end_index) 171 172 def position_caret(self, anchor, after=False): 173 """ 174 Positions the caret before the given anchor. If after=True position it directly after. 175 """ 176 177 start_index, end_index, _ = self.anchor_to_range(anchor) 178 index = end_index if after else start_index 179 180 self.area.sel = index 181 182 def anchor_to_range(self, anchor): 183 anchors_data = calculate_text_anchors(self._get_visible_text(), self.area.sel.left) 184 for loop_anchor, start_index, end_index, last_space_index in anchors_data: 185 if anchor == loop_anchor: 186 return (start_index, end_index, last_space_index) 187 188 raise RuntimeError(f"Couldn't find anchor {anchor}") 189 190 def _update_labels(self, _visible_text): 191 """ 192 Updates the position of the labels displayed on top of each word 193 """ 194 195 anchors_data = calculate_text_anchors(self._get_visible_text(), self.area.sel.left) 196 return [ 197 (Span(start_index, end_index), anchor) 198 for anchor, start_index, end_index, _ in anchors_data 199 ] 200 201 def _get_visible_text(self): 202 # Placeholder for a future method of getting this 203 return self.area.value 204 205 206 if False: 207 # Some code for testing, change above False to True and edit as desired 208 draft_manager = DraftManager() 209 draft_manager.show( 210 "This is a line of text\nand another line of text and some more text so that the line gets so long that it wraps a bit.\nAnd a final sentence" 211 ) 212 draft_manager.reposition(xpos=100, ypos=100) 213 draft_manager.select_text("c")