diff --git a/bindings/codemp_client.cpython-38-darwin.so.REMOVED.git-id b/bindings/codemp_client.cpython-38-darwin.so.REMOVED.git-id index d43fe94..59ed00c 100644 --- a/bindings/codemp_client.cpython-38-darwin.so.REMOVED.git-id +++ b/bindings/codemp_client.cpython-38-darwin.so.REMOVED.git-id @@ -1 +1 @@ -8114809f135d7ea9f88d0a45a9de8fc93fdbd9ff \ No newline at end of file +a4493ce329ed791cd4bd17b102efdde3ca4acd8f \ No newline at end of file diff --git a/plugin.py b/plugin.py index b6c6561..8683dab 100644 --- a/plugin.py +++ b/plugin.py @@ -14,6 +14,21 @@ _cursor_controller = None _buffer_controller = None _setting_key = "codemp_buffer" +_regions_colors = [ + "region.redish", + "region.orangeish", + "region.yellowish", + "region.greenish", + "region.cyanish", + "region.bluish", + "region.purplish", + "region.pinkish" +] + +def status_log(msg): + sublime.status_message("[codemp] {}".format(msg)) + print("[codemp] {}".format(msg)) + def store_task(name = None): def store_named_task(task): global _tasks @@ -22,154 +37,6 @@ def store_task(name = None): return store_named_task -def plugin_loaded(): - global _client - _client = CodempClient() - sublime_asyncio.acquire() # instantiate and start a global event loop. - -class CodempClientViewEventListener(sublime_plugin.ViewEventListener): - @classmethod - def is_applicable(cls, settings): - return "codemp_buffer" in settings - - def on_selection_modified_async(self): - global _cursor_controller - if _cursor_controller: - sublime_asyncio.dispatch(send_selection(self.view)) - - def on_close(self): - self.view.settings()["codemp_buffer"] = False - -class CodempClientTextChangeListener(sublime_plugin.TextChangeListener): - @classmethod - def is_applicable(cls, buffer): - for view in buffer.views(): - if "codemp_buffer" in view.settings(): - return True - return False - - def on_text_changed(self, changes): - global _buffer_controller - if _buffer_controller: - for change in changes: - sublime_asyncio.dispatch(apply_changes(change)) - -async def apply_changes(change): - global _buffer_controller - - text = change.str - skip = change.a.pt - if change.len_utf8 == 0: # we are inserting new text. - tail = len(_buffer_controller.get_content()) - skip - else: # we are changing an existing region of text of length len_utf8 - tail = len(_buffer_controller.get_content()) - skip - change.len_utf8 - - tail_skip = len(_buffer_controller.get_content()) - tail - print("[buff change]", skip, text, tail_skip) - await _buffer_controller.apply(skip, text, tail) - -async def make_connection(server_host): - global _client - - if _client.ready: - sublime.message_dialog("A connection already exists.") - return - - sublime.status_message("[codemp] Connecting to {}".format(server_host)) - print("[codemp] Connecting to {}".format(server_host)) - await _client.connect(server_host) - - id = await _client.get_id() - sublime.status_message("[codemp] Connected with client ID: {}".format(id)) - print("[codemp] Connected with client ID: ", id) - -async def move_cursor(usr, caller, path, start, end): - print(usr, caller, start, end) - -async def sync_buffer(caller, start, end, txt): - print("[buffer]", caller, start, end, txt) - -async def share_buffer(buffer): - global _client - global _cursor_controller - global _buffer_controller - - if not _client.ready: - sublime.error_message("No connected client.") - return - - sublime.status_message("[codemp] Sharing buffer {}".format(buffer)) - print("[codemp] Sharing buffer {}".format(buffer)) - - view = get_matching_view(buffer) - contents = get_contents(view) - created = await _client.create(view.file_name(), contents) - if not created: - sublime.error_message("Could not share buffer.") - return - - _buffer_controller = await _client.attach(buffer) - _buffer_controller.callback(sync_buffer, _client.id) - - _cursor_controller = await _client.listen() - _cursor_controller.callback(move_cursor, _client.id) - - if not _cursor_controller: - sublime.error_message("Could not subsribe a listener.") - return - if not _buffer_controller: - sublime.error_message("Could not attach to the buffer.") - return - - sublime.status_message("[codemp] Listening") - print("[codemp] Listening") - - view.settings()["codemp_buffer"] = True - -async def join_buffer(window, buffer): - global _client - global _cursor_controller - global _buffer_controller - - if not _client.ready: - sublime.error_message("No connected client.") - return - - view = get_matching_view(buffer) - - sublime.status_message("[codemp] Joining buffer {}".format(buffer)) - print("[codemp] Joining buffer {}".format(buffer)) - - _buffer_controller = await _client.attach(buffer) - content = _buffer_controller.get_content() - view.run_command("codemp_replace_view", {"content": content}) - - _cursor_controller = await _client.listen() - _cursor_controller.callback(move_cursor) - - - if not _cursor_controller: - sublime.error_message("Could not subsribe a listener.") - return - if not _buffer_controller: - sublime.error_message("Could not attach to the buffer.") - return - - sublime.status_message("[codemp] Listening") - print("[codemp] Listening") - - view.settings()["codemp_buffer"] = True - -async def send_selection(view): - global _cursor_controller - - path = view.file_name() - region = view.sel()[0] # TODO: only the last placed cursor/selection. - start = view.rowcol(region.begin()) #only counts UTF8 chars - end = view.rowcol(region.end()) - - await _cursor_controller.send(path, start, end) - def get_contents(view): r = sublime.Region(0, view.size()) return view.substr(r) @@ -180,25 +47,225 @@ def get_matching_view(path): if view.file_name() == path: return view +def rowcol_to_region(view, start, end): + a = view.text_point(start[0], start[1]) + b = view.text_point(end[0], end[1]) + return sublime.Region(a, b) + +def plugin_loaded(): + global _client + _client = CodempClient() # create an empty instance of the codemp client. + sublime_asyncio.acquire() # instantiate and start a global asyncio event loop. + +def plugin_unloaded(): + for window in sublime.windows(): + for view in window.views(): + if "codemp_buffer" in view.settings(): + del view.settings()["codemp_buffer"] + # disconnect all buffers + # stop all callbacks + # disconnect the client. + print("unloading") + +async def connect_command(server_host, session="default"): + global _client + status_log("Connecting to {}".format(server_host)) + await _client.connect(server_host) + await join_workspace(session) + +async def join_workspace(session): + global _client + global _cursor_controller + + status_log("Joining workspace: {}".format(session)) + _cursor_controller = await _client.join(session) + _cursor_controller.callback(move_cursor) + +async def share_buffer_command(buffer): + global _client + global _cursor_controller + global _buffer_controller + + status_log("Sharing buffer {}".format(buffer)) + + view = get_matching_view(buffer) + contents = get_contents(view) + + try: + await _client.create(buffer, contents) + + _buffer_controller = await _client.attach(buffer) + _buffer_controller.callback(apply_buffer_change) + 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 + +def move_cursor(cursor_event): + global _regions_colors + + # 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(): + 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]) + +def send_cursor(view): + global _cursor_controller + + path = view.file_name() + region = view.sel()[0] # TODO: only the last placed cursor/selection. + start = view.rowcol(region.begin()) #only counts UTF8 chars + end = view.rowcol(region.end()) + + _cursor_controller.send(path, start, end) + +def send_buffer_change(buffer, changes): + global _buffer_controller + + view = buffer.primary_view() + start, txt, end = compress_changes(view, changes) + + contlen = len(_buffer_controller.get_content()) + _buffer_controller.delta(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. + + # 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. + if len(changes) == 1: + return (changes[0].a.pt, changes[0].str, changes[0].b.pt) + + return walk_compress_changes(view, changes) + +def walk_compress_changes(view, changes): + # the bounding region of all text changes. + txt_a = float("inf") + txt_b = 0 + + # the region in the original buffer subjected to the change. + reg_a = float("inf") + reg_b = 0 + + # we keep track of how much the changes move the indexing of the buffer + buffer_shift = 0 # left - + right + + for change in changes: + # the change in characters that the change would bring + # len(str) and .len_utf8 are mutually exclusive + # len(str) is when we insert new text at a position + # .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 + # 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. + # 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. + 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 + # 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 + # to map back to the correct index for each change in the unmodified buffer. + if change.b.pt + buffer_shift > reg_b: + # we only enlarge if we have changes that exceede on the right the current window + reg_b = change.b.pt + buffer_shift + + # after using the change delta, we archive it for the next iterations + 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)) + 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) + + +# Sublime interface +############################################################################## + +class CodempClientViewEventListener(sublime_plugin.ViewEventListener): + @classmethod + def is_applicable(cls, settings): + return "codemp_buffer" in settings + + @classmethod + def applies_to_primary_view_only(cls): + return True + + 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_activated_async(self): + #gain input focus + pass + + 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 + return False + + def on_text_changed_async(self, changes): + global _buffer_controller + if _buffer_controller: + send_buffer_change(self.buffer, changes) # See the proxy command class at the bottom class CodempConnectCommand(sublime_plugin.WindowCommand): def run(self, server_host): - sublime_asyncio.dispatch(make_connection(server_host)) + sublime_asyncio.dispatch(connect_command(server_host)) # see proxy command at the bottom class CodempShareCommand(sublime_plugin.WindowCommand): def run(self, buffer): - sublime_asyncio.dispatch(share_buffer(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 CodempPopulateView(sublime_plugin.TextCommand): - def run(self, edit, content): - self.view.replace(edit, sublime.Region(0, self.view.size()), content) +# 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 @@ -227,18 +294,18 @@ class ProxyCodempShareCommand(sublime_plugin.WindowCommand): 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) +# 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(self, args): +# if 'buffer' not in args: +# return BufferInputHandler() - def input_description(self): - return 'Join Buffer:' +# def input_description(self): +# return 'Join Buffer:' class BufferInputHandler(sublime_plugin.ListInputHandler): def list_items(self): @@ -253,4 +320,4 @@ class BufferInputHandler(sublime_plugin.ListInputHandler): class ServerHostInputHandler(sublime_plugin.TextInputHandler): def initial_text(self): - return "http://[::1]:50051" \ No newline at end of file + return "http://127.0.0.1:50051" \ No newline at end of file diff --git a/src/codemp_client.py b/src/codemp_client.py index cb8c6ba..a90312f 100644 --- a/src/codemp_client.py +++ b/src/codemp_client.py @@ -5,42 +5,37 @@ class CodempClient(): def __init__(self): self.handle = libcodemp.codemp_init() - self.ready = False async def connect(self, server_host): # -> None await self.handle.connect(server_host) - self.ready = True def disconnect(self): # -> None # disconnect all buffers # stop all callbacks self.handle = None - self.ready = False async def create(self, path, content=None): # -> None - if self.ready: - return await self.handle.create(path, content) - + await self.handle.create(path, content) + + # join a workspace async def join(self, session): # -> CursorController - if self.ready: return CursorController(await self.handle.join(session)) async def attach(self, path): # -> BufferController - if self.ready: return BufferController(await self.handle.attach(path)) async def get_cursor(self): # -> CursorController - if self.ready: return CursorController(await self.handle.get_cursor()) async def get_buffer(self, path): # -> BufferController - if self.ready: return BufferController(await self.handle.get_buffer()) async def remove_buffer(self, path): # -> None - if self.ready: await self.handle.disconnect_buffer(path) + async def leave_workspace(self): # -> None + pass # todo + class CursorController(): def __init__(self, handle): self.handle = handle @@ -62,7 +57,7 @@ class CursorController(): self.handle.drop_callback() def callback(self, coro): # -> None - self.handle.callback(coro, id) + self.handle.callback(coro) class BufferController(): def __init__(self, handle): diff --git a/src/lib.rs b/src/lib.rs index c061156..ebb4edd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ use std::{sync::Arc, format}; -use codemp::{prelude::*}; +use codemp::prelude::*; use codemp::errors::Error as CodempError; use pyo3::{ @@ -58,7 +58,9 @@ impl PyClientHandle { let rc = self.0.clone(); pyo3_asyncio::tokio::future_into_py(py, async move { - rc.connect(addr.as_str()).await.map_err(PyCodempError::from)?; + rc.connect(addr.as_str()) + .await + .map_err(PyCodempError::from)?; Ok(()) }) } @@ -89,6 +91,7 @@ impl PyClientHandle { }) } + // join a workspace fn join<'a>(&'a self, py: Python<'a>, session: String) -> PyResult<&'a PyAny> { let rc = self.0.clone(); @@ -393,12 +396,11 @@ impl PyBufferController { Ok(cont) } - // What to do with this send? does it make sense to implement it at all? + // TODO: What to do with this send? + // does it make sense to implement it at all for the python side?? + // fn send<'a>(&self, py: Python<'a>, skip: usize, text: String, tail: usize) -> PyResult<&'a PyAny>{ - // let rc = self.handle.clone(); - // pyo3_asyncio::tokio::future_into_py(py, async move { - // Ok(()) - // }) + // todo!() // } fn try_recv(&self, py: Python<'_>) -> PyResult { @@ -440,9 +442,16 @@ impl PyBufferController { #[pyclass] struct PyCursorEvent { + #[pyo3(get, set)] user: String, + + #[pyo3(get, set)] buffer: String, + + #[pyo3(get, set)] start: (i32, i32), + + #[pyo3(get, set)] end: (i32, i32) } @@ -461,8 +470,13 @@ impl From for PyCursorEvent { #[pyclass] struct PyTextChange { + #[pyo3(get, set)] start_incl: usize, + + #[pyo3(get, set)] end_excl: usize, + + #[pyo3(get, set)] content: String }