def try_save_file(path, history) -> bool: """ Try to save file of the user to the filesystem. Creates a new document, applies all the patches, then saves the resulting document's text to file. :param path: path to save :param history: patch history :type path: Path :type history: List[str] :return: True if successful, otherwise False """ file_doc = Doc() file_doc.site = 0 for patch in history: file_doc.apply_patch(patch) try: with open(path, 'w') as file: file.write(file_doc.text) return True except (OSError, IOError, FileNotFoundError): logging.info(f"Requested [{path}] was not found!") return False
class DocumentEditor: """ Document Editor responds to keypress events and copy/cut/paste/delete events by applying patches to internal CRDT document and then updating the changed text on outer TextEditor document object. """ WINDOWS_LINE_ENDING = '\r\n' UNIX_LINE_ENDING = '\n' CR_CHAR = '\r' def __init__(self, msg_service: MessageService): self.doc = Doc() self.doc.site = int(random.getrandbits(32)) self.patch_set = [] self.msg_service = msg_service self.text_field = TextEditor( scrollbar=True, line_numbers=False, search_field=SearchToolbar(), key_bindings=self.__init_bindings(), lexer=AuthorLexer(self.doc) ) def __register_patch(self, patch) -> None: """ Put provided patch to internal patch set and send patch to the server using Message Service :type patch: str """ self.patch_set.append(patch) message = self.msg_service.prepare_send_request({"type": "patch", "content": patch}) self.msg_service.put_message(message) def do_cut(self) -> None: """ Handle selection cut. """ new_doc, cut_data = self.__get_selection(cut=True) get_app().clipboard.set_data(cut_data) self.text_field.document = new_doc def do_copy(self) -> None: """ Handle selection copy """ _, cut_data = self.__get_selection(cut=False) get_app().clipboard.set_data(cut_data) def do_delete(self) -> None: """ Handle selection delete """ self.text_field.document, _ = self.__get_selection(cut=True) def do_paste(self) -> None: """ Handle paste from clipboard """ paste_text = get_app().clipboard.get_data().text # replace CRLF with LF paste_text = paste_text.replace(self.WINDOWS_LINE_ENDING, self.UNIX_LINE_ENDING) paste_text = paste_text.replace(self.CR_CHAR, self.UNIX_LINE_ENDING) cursor_pos = self.text_field.buffer.cursor_position for idx, char in enumerate(paste_text): self.text_field.buffer.text += str(idx) patch = self.doc.insert(cursor_pos + idx, char) self.__register_patch(patch) self.text_field.buffer.text = self.doc.text self.text_field.buffer.cursor_position += len(paste_text) def __get_selection(self, cut=False) -> Tuple[Document, ClipboardData]: """ Get selection from document selection and the resulting document. If cut is true, remove selection from internal CRDT document immediately. :param cut: cut selected part of document :type cut: bool :return: resulting document and selection text fragment """ if self.text_field.document.selection: cut_parts = [] remaining_parts = [] new_cursor_position = self.text_field.document.cursor_position last_end = 0 for start, end in self.text_field.document.selection_ranges(): if cut: # remove from internal doc for pos in range(end, start, -1): patch = self.doc.delete(pos - 1) self.__register_patch(patch) if last_end == 0: new_cursor_position = start remaining_parts.append( self.text_field.document.text[last_end:start]) cut_parts.append(self.text_field.document.text[start:end]) last_end = end remaining_parts.append(self.text_field.document.text[last_end:]) cut_text = "\n".join(cut_parts) return ( Document(text=self.doc.text, cursor_position=new_cursor_position), ClipboardData(cut_text, self.text_field.document.selection.type), ) else: return self.text_field.document, ClipboardData("") def __init_bindings(self) -> KeyBindings: """ Generate bindings that handles KeyPress events, make changes to internal doc and then displaying them in outer TextEdit buffer. :return: """ bindings = KeyBindings() @bindings.add('delete') def handle_delete(event: KeyPressEvent) -> None: """ Captures Delete KeyPress event and removes char next to cursor pos from internal Doc """ if not self.text_field.buffer.text: return if self.text_field.buffer.selection_state: self.do_delete() elif self.text_field.buffer.cursor_position \ != len(self.text_field.buffer.text): cursor_pos = self.text_field.buffer.cursor_position patch = self.doc.delete(cursor_pos) self.text_field.buffer.text = self.doc.text self.__register_patch(patch) @bindings.add('c-h') def handle_backspace(event: KeyPressEvent) -> None: """ Captures Backspace KeyPress event and removes char before cursor pos from internal Doc """ if not self.text_field.buffer.text or \ self.text_field.buffer.cursor_position == 0: return if self.text_field.buffer.selection_state: self.do_delete() else: cursor_pos = self.text_field.buffer.cursor_position patch = self.doc.delete(cursor_pos - 1) if cursor_pos != len(self.text_field.buffer.text): self.text_field.buffer.cursor_position -= 1 self.text_field.buffer.text = self.doc.text self.__register_patch(patch) @bindings.add('c-m') def handle_enter(event: KeyPressEvent) -> None: """ Captures Enter KeyPress events and applies it to internal Doc """ if self.text_field.buffer.selection_state: self.do_delete() cursor_pos = self.text_field.buffer.cursor_position patch = self.doc.insert(cursor_pos, self.UNIX_LINE_ENDING) self.text_field.buffer.text = self.doc.text self.text_field.buffer.cursor_position += 1 self.__register_patch(patch) @bindings.add('c-i') @bindings.add('<any>') def handle_text_enter(event: KeyPressEvent) -> None: """ Captures General / Tab KeyPress events and applies it to internal Doc """ if self.text_field.buffer.selection_state: self.do_delete() cursor_pos = self.text_field.buffer.cursor_position patch = self.doc.insert(cursor_pos, event.data) self.text_field.buffer.text = self.doc.text self.text_field.buffer.cursor_right() self.__register_patch(patch) @bindings.add(Keys.BracketedPaste) def handle_paste(event: KeyPressEvent) -> None: """ Handle paste event from terminal. :param event: :return: """ self.do_paste() return bindings def update_text(self, patch) -> None: """ Apply patch to internal document and update TextEdit window buffer. :param patch: raw patch :type patch: str """ json_patch = json.loads(patch) operation = json_patch["op"] if operation == "d": patch_pos = self.doc.get_real_position(patch) self.doc.apply_patch(patch) else: self.doc.apply_patch(patch) patch_pos = self.doc.get_real_position(patch) old_pos = self.text_field.buffer.cursor_position self.text_field.buffer.text = self.doc.text if patch_pos == -1 or patch_pos > old_pos + 1: self.text_field.buffer.cursor_position = old_pos else: if operation == "i": self.text_field.buffer.cursor_right() if operation == "d": self.text_field.buffer.cursor_left()