diff --git a/plugin.py b/plugin.py index 8683dab..0a6f4e5 100644 --- a/plugin.py +++ b/plugin.py @@ -12,7 +12,8 @@ _tasks = [] _client = None _cursor_controller = None _buffer_controller = None -_setting_key = "codemp_buffer" +_txt_change_listener = None +_skip_resend = False _regions_colors = [ "region.redish", @@ -58,13 +59,30 @@ def plugin_loaded(): sublime_asyncio.acquire() # instantiate and start a global asyncio event loop. def plugin_unloaded(): + global _client + global _cursor_controller + global _buffer_controller + global _txt_change_listener for window in sublime.windows(): for view in window.views(): - if "codemp_buffer" in view.settings(): + if view.settings().get("codemp_buffer", False): + sublime_asyncio.dispatch(_client.disconnect_buffer(view.file_name())) del view.settings()["codemp_buffer"] - # disconnect all buffers - # stop all callbacks + view.erase_regions("codemp_cursors") + + if _cursor_controller: + _cursor_controller.drop_callback() + + if _buffer_controller: + _buffer_controller.drop_callback() + + if _txt_change_listener: + if _txt_change_listener.is_attached(): + _txt_change_listener.detach() + _txt_change_listener = None + # disconnect the client. + sublime_asyncio.dispatch(_client.leave_workspace()) print("unloading") async def connect_command(server_host, session="default"): @@ -85,6 +103,7 @@ async def share_buffer_command(buffer): global _client global _cursor_controller global _buffer_controller + global _txt_change_listener status_log("Sharing buffer {}".format(buffer)) @@ -95,12 +114,16 @@ async def share_buffer_command(buffer): await _client.create(buffer, contents) _buffer_controller = await _client.attach(buffer) - _buffer_controller.callback(apply_buffer_change) + # apply_buffer_change returns a "lambda" that internalises the buffer it is being called on. + _buffer_controller.callback(apply_buffer_change(view.buffer())) + + # we create a new event listener and attach it to the shared buffer. + _txt_change_listener = CodempClientTextChangeListener() + _txt_change_listener.attach(view.buffer()) except Exception as e: sublime.error_message("Could not share buffer: {}".format(e)) return - status_log("Listening") view.set_status("z_codemp_buffer", "[Codemp]") view.settings()["codemp_buffer"] = True @@ -110,11 +133,17 @@ def move_cursor(cursor_event): # TODO: make the matching user/color more solid. now all users have one color cursor. # Maybe make all cursors the same color and only use annotations as a discriminant. view = get_matching_view(cursor_event.buffer) - if "codemp_buffer" in view.settings(): + if view.settings().get("codemp_buffer", False): reg = rowcol_to_region(view, cursor_event.start, cursor_event.end) reg_flags = sublime.RegionFlags.DRAW_EMPTY | sublime.RegionFlags.DRAW_NO_FILL - view.add_regions("codemp_cursors", [reg], flags = reg_flags, scope=_regions_colors[2], annotations = [cursor_event.user]) + view.add_regions( + "codemp_cursors", + [reg], + flags = reg_flags, + scope=_regions_colors[2], + annotations = [cursor_event.user], + annotation_color="#000") def send_cursor(view): global _cursor_controller @@ -132,26 +161,31 @@ def send_buffer_change(buffer, changes): view = buffer.primary_view() start, txt, end = compress_changes(view, changes) + # we can't use view.size() since now view has the already modified buffer, + # but we need to clip wrt the unmodified buffer. contlen = len(_buffer_controller.get_content()) - _buffer_controller.delta(start, txt, min(end, contlen)) + _buffer_controller.delta(max(0, start), txt, min(end, contlen)) + time.sleep(0.1) print("server buffer: -------") print(_buffer_controller.get_content()) def compress_changes(view, changes): - ## TODO: doesn't work correctly. - # Sublime text on_text_changed events, gives a list of changes. # in case of simple insertion or deletion this is fine. # but if we swap a string (select it and add another string in it's place) or have multiple selections - # we receive two split text changes, first we add the new string in front of the selection - # and then we delete the old selection. e.g: [1234] -> hello is split into: [1234] -> hello[1234] -> hello[] - # this fucks over the operations factory algorithm, which panics if reading the operations sequentially, - # since the changes refer to the same point in time and are not updated each time. + # or do an undo of some kind after the just mentioned events we receive multiple split text changes, + # e.g. select the world `hello` and replace it with `12345`: Sublime will separate it into two singular changes, + # first add `12345` in front of `hello`: `12345hello` then, delete the `hello`. + # The gotcha here is that now we have an issue of indexing inside the buffer. when adding `12345` we shifted the index of the + # start of the word `hello` to the right by 5. + # By sending these changes one by one generated some buffer length issues in delta, since we have an interdependency of the + # changes. - # as a workaround, we compress all changes into a big change, which gives the region in which the change occurred - # and the new string, extracted directly from the local buffer already modified. + # as a workaround, whenever we receive multiple changes we compress all of them into a "single one" that delta understands, + # namely, we get a bounding region to the change, and all the text in between. if len(changes) == 1: + print("[change]", "[", changes[0].a.pt, changes[0].b.pt, "]", changes[0].str) return (changes[0].a.pt, changes[0].str, changes[0].b.pt) return walk_compress_changes(view, changes) @@ -175,20 +209,24 @@ def walk_compress_changes(view, changes): # .len_utf8 is the length of the deleted/canceled string in the buffer change_delta = len(change.str) - change.len_utf8 - txt_a = min(txt_a, change.a.pt) # the text region is enlarged to the left + # the text region is enlarged to the left + txt_a = min(txt_a, change.a.pt) + # On insertion, change.b.pt == change.a.pt - # If we meet a new insertion further than the current window - # we expand to the right by that change. + # If we meet a new insertion further than the current window + # we expand to the right by that change. # On deletion, change.a.pt == change.b.pt - change.len_utf8 - # when we delete a selection and it is further than the current window - # we enlarge to the right up until the begin of the deleted region. + # when we delete a selection and it is further than the current window + # we enlarge to the right up until the begin of the deleted region. if change.b.pt > txt_b: txt_b = change.b.pt + change_delta else: # otherwise we just shift the window according to the change txt_b += change_delta - - reg_a = min(reg_a, change.a.pt) # text region enlarged to the left + + # the bounding region enlarged to the left + reg_a = min(reg_a, change.a.pt) + # In this bit, we want to look at the buffer BEFORE the modifications # but we are working on the buffer modified by all previous changes for each loop # we use buffer_shift to keep track of how the buffer shifts around @@ -198,60 +236,108 @@ def walk_compress_changes(view, changes): reg_b = change.b.pt + buffer_shift # after using the change delta, we archive it for the next iterations + # the minus is just for being able to "add" the buffer shift with a +. + # since we encode deleted text as negative in the change_delta, but that requires the shift to the + # old position to be positive, and viceversa for text insertion. buffer_shift -= change_delta # print("\t[buff change]", change.a.pt, change.str, "(", change.len_utf8,")", change.b.pt) - # print("[walking txt]", "[", txt_a, txt_b, "]") - # print("[walking reg]", "[", reg_a, reg_b, "]") txt = view.substr(sublime.Region(txt_a, txt_b)) + print("[walking txt]", "[", txt_a, txt_b, "]", txt) + print("[walking reg]", "[", reg_a, reg_b, "]") return reg_a, txt, reg_b -def apply_buffer_change(text_change): - print("test") - print(text_change) - print(text_change.start_incl, text_change.end_excl, text_change.content) +def apply_buffer_change(buffer): + status_log("registring callback for buffer: {}".format(buffer.primary_view().file_name())) + def buffer_callback(text_change): + global _skip_resend + # we need to go through a sublime text command, since the method, view.replace + # needs an edit token, that is obtained only when calling a textcommand associated with a view. + _skip_resend = True + view = buffer.primary_view() + view.run_command("codemp_replace_text", + {"start": text_change.start_incl, + "end": text_change.end_excl, + "content": text_change.content}) + + return buffer_callback # Sublime interface ############################################################################## +## WIP ## +# class CodempSublimeBuffer(): + +# def __init__(self, sublime_buffer): +# self.buffer = sublime_buffer +# self.controller = +# self.listener = CodempClientTextChangeListener() +# self.skip_resend = False +# self.codemp_callback = apply_buffer_change(sublime_buffer) + +# def attach_sublime(self): +# self.listener.attach(self.buffer) + +# def detach_sublime(self): +# self.listener.detach() +## WIP ## class CodempClientViewEventListener(sublime_plugin.ViewEventListener): @classmethod def is_applicable(cls, settings): - return "codemp_buffer" in settings + return ("codemp_buffer" in settings) @classmethod def applies_to_primary_view_only(cls): - return True + return False def on_selection_modified_async(self): global _cursor_controller if _cursor_controller: send_cursor(self.view) - def on_close(self): - del self.view.settings()["codemp_buffer"] + # def on_text_command(self, command, args): + # global _txt_change_listener + # print(command, args) + # if command == "codemp_replace_text": + # print("detaching listener to :", self.view.file_name()) + # _txt_change_listener.detach() - def on_activated_async(self): - #gain input focus - pass + # return None + + # def on_post_text_command(self, command, args): + # global _txt_change_listener + # print(command, args) + # if command == "codemp_replace_text": + # print("reattaching listener to: ", self.view.file_name()) + # _text_change_listener.attach(self.view.buffer()) + + # return None - def on_deactivated_async(self): - pass class CodempClientTextChangeListener(sublime_plugin.TextChangeListener): @classmethod def is_applicable(cls, buffer): - if "codemp_buffer" in buffer.primary_view().settings(): - return True + # don't attach this event listener automatically + # we'll do it by hand with .attach(buffer). return False def on_text_changed_async(self, changes): global _buffer_controller + global _skip_resend + if _skip_resend: + _skip_resend = False + return + if _buffer_controller: send_buffer_change(self.buffer, changes) +class CodempReplaceTextCommand(sublime_plugin.TextCommand): + def run(self, edit, start, end, content): + # start included, end excluded + self.view.replace(edit, sublime.Region(start, end), content) + # See the proxy command class at the bottom class CodempConnectCommand(sublime_plugin.WindowCommand): def run(self, server_host): @@ -262,11 +348,6 @@ class CodempShareCommand(sublime_plugin.WindowCommand): def run(self, buffer): sublime_asyncio.dispatch(share_buffer_command(buffer)) -# see proxy command at the bottom -# class CodempJoinCommand(sublime_plugin.WindowCommand): -# def run(self, buffer): -# sublime_asyncio.dispatch(join_buffer(self.window, buffer)) - class ProxyCodempConnectCommand(sublime_plugin.WindowCommand): # on_window_command, does not trigger when called from the command palette # See: https://github.com/sublimehq/sublime_text/issues/2234 @@ -293,20 +374,6 @@ class ProxyCodempShareCommand(sublime_plugin.WindowCommand): def input_description(self): return 'Share Buffer:' - -# class ProxyCodempJoinCommand(sublime_plugin.WindowCommand): -# # on_window_command, does not trigger when called from the command palette -# # See: https://github.com/sublimehq/sublime_text/issues/2234 -# def run(self, **kwargs): -# self.window.run_command("codemp_join", kwargs) - -# def input(self, args): -# if 'buffer' not in args: -# return BufferInputHandler() - -# def input_description(self): -# return 'Join Buffer:' - class BufferInputHandler(sublime_plugin.ListInputHandler): def list_items(self): ret_list = [] diff --git a/src/codemp_client.py b/src/codemp_client.py index a90312f..58c412f 100644 --- a/src/codemp_client.py +++ b/src/codemp_client.py @@ -10,8 +10,6 @@ class CodempClient(): await self.handle.connect(server_host) def disconnect(self): # -> None - # disconnect all buffers - # stop all callbacks self.handle = None async def create(self, path, content=None): # -> None @@ -30,7 +28,7 @@ class CodempClient(): async def get_buffer(self, path): # -> BufferController return BufferController(await self.handle.get_buffer()) - async def remove_buffer(self, path): # -> None + async def disconnect_buffer(self, path): # -> None await self.handle.disconnect_buffer(path) async def leave_workspace(self): # -> None