from typing import Optional import re from talon.experimental.textarea import ( TextArea, Span, DarkThemeLabels, LightThemeLabels ) word_matcher = re.compile(r"([^\s]+)(\s*)") def calculate_text_anchors(text, cursor_position, anchor_labels=None): """ Produces an iterator of (anchor, start_word_index, end_word_index, last_space_index) tuples from the given text. Each tuple indicates a particular point you may want to reference when editing along with some useful ranges you may want to operate on. - text is the text you want to process. - cursor_position is the current position of the cursor, anchors will be placed around this. - anchor_labels is a list of characters you want to use for your labels. - *index is just a character offset from the start of the string (e.g. the first character is at index 0) - end_word_index is the index of the character after the last one included in the anchor. That is, you can use it with a slice directly like [start:end] - anchor is a short piece of text you can use to identify it (e.g. 'a', or '1'). """ anchor_labels = anchor_labels or "abcdefghijklmnopqrstuvwxyz" if len(text) == 0: return [] # Find all the word spans matches = [] cursor_idx = None for match in word_matcher.finditer(text): matches.append(( match.start(), match.end() - len(match.group(2)), match.end() )) if matches[-1][0] <= cursor_position and matches[-1][2] >= cursor_position: cursor_idx = len(matches) - 1 # Now work out what range of those matches are getting an anchor. The aim is # to centre the anchors around the cursor position, but also to use all the # anchors. anchors_before_cursor = len(anchor_labels) // 2 anchor_start_idx = max(0, cursor_idx - anchors_before_cursor) anchor_end_idx = min(len(matches), anchor_start_idx + len(anchor_labels)) anchor_start_idx = max(0, anchor_end_idx - len(anchor_labels)) # Now add anchors to the selected matches for i, anchor in zip(range(anchor_start_idx, anchor_end_idx), anchor_labels): word_start, word_end, whitespace_end = matches[i] yield ( anchor, word_start, word_end, whitespace_end ) class DraftManager: """ API to the draft window """ def __init__(self): self.area = TextArea() self.area.title = "Talon Draft" self.area.value = "" self.area.register("label", self._update_labels) self.set_styling() def set_styling( self, theme="dark", text_size=20, label_size=20, label_color=None ): """ Allow settings the style of the draft window. Will dynamically update the style based on the passed in parameters. """ area_theme = DarkThemeLabels if theme == "dark" else LightThemeLabels theme_changes = { "text_size": text_size, "label_size": label_size, } if label_color is not None: theme_changes["label"] = label_color self.area.theme = area_theme(**theme_changes) def show(self, text: Optional[str] = None): """ Show the window. If text is None then keep the old contents, otherwise set the text to the given value. """ if text is not None: self.area.value = text self.area.show() def hide(self): """ Hide the window. """ self.area.hide() def get_text(self) -> str: """ Gets the context of the text area """ return self.area.value def get_rect(self) -> "talon.types.Rect": """ Get the Rect for the window """ return self.area.rect def reposition( self, xpos: Optional[int] = None, ypos: Optional[int] = None, width: Optional[int] = None, height: Optional[int] = None, ): """ Move the window or resize it without having to change all properties. """ rect = self.area.rect if xpos is not None: rect.x = xpos if ypos is not None: rect.y = ypos if width is not None: rect.width = width if height is not None: rect.height = height self.area.rect = rect def select_text( self, start_anchor, end_anchor=None, include_trailing_whitespace=False ): """ Selects the word corresponding to start_anchor. If end_anchor supplied, selects from start_anchor to the end of end_anchor. If include_trailing_whitespace=True then also selects trailing space characters (useful for delete). """ start_index, end_index, last_space_index = self.anchor_to_range(start_anchor) if end_anchor is not None: _, end_index, last_space_index = self.anchor_to_range(end_anchor) if include_trailing_whitespace: end_index = last_space_index self.area.sel = Span(start_index, end_index) def position_caret(self, anchor, after=False): """ Positions the caret before the given anchor. If after=True position it directly after. """ start_index, end_index, _ = self.anchor_to_range(anchor) index = end_index if after else start_index self.area.sel = index def anchor_to_range(self, anchor): anchors_data = calculate_text_anchors(self._get_visible_text(), self.area.sel.left) for loop_anchor, start_index, end_index, last_space_index in anchors_data: if anchor == loop_anchor: return (start_index, end_index, last_space_index) raise RuntimeError(f"Couldn't find anchor {anchor}") def _update_labels(self, _visible_text): """ Updates the position of the labels displayed on top of each word """ anchors_data = calculate_text_anchors(self._get_visible_text(), self.area.sel.left) return [ (Span(start_index, end_index), anchor) for anchor, start_index, end_index, _ in anchors_data ] def _get_visible_text(self): # Placeholder for a future method of getting this return self.area.value if False: # Some code for testing, change above False to True and edit as desired draft_manager = DraftManager() draft_manager.show( "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" ) draft_manager.reposition(xpos=100, ypos=100) draft_manager.select_text("c")