diff --git a/completions_menu.py b/completions_menu.py new file mode 100644 index 0000000..18b1197 --- /dev/null +++ b/completions_menu.py @@ -0,0 +1,89 @@ +from textual.screen import ModalScreen +from textual.containers import Container + +from context_menu import ButtonStatic +from textual.widgets import TextArea, OptionList +from textual.widgets.option_list import Option +from textual.widget import Widget + +from textual.geometry import Region, Spacing, Offset + + +class CompletionsMenu(Widget): + DEFAULT_CSS = """ + CompletionsMenu { + width: auto; + height: auto; + overlay: screen; + position: absolute; + background: $surface; + min-width: 30; + + OptionList { + padding: 0 1; + background: $surface; + border: $panel tall; + width: 30; + } + } + """ + + def __init__(self, options: list[dict], text_area: TextArea): + super().__init__() + self.options = options + self.text_area = text_area + self.options_list = None + + def compose(self): + self.options_list = OptionList(*[self.render_option(option) for option in self.options]) + yield self.options_list + + def update(self, new_choices: list[dict]): + self.options = new_choices + self.options_list.set_options([self.render_option(option) for option in self.options]) + + def hide(self): + self.display = "none" + + def show(self): + self.display = "block" + + def render_option(self, option_data: dict): + icon = "" + + + match option_data["type"]: + case 1: # text + icon = "󰦨" + case 2 | 3: # method / function + icon = "[blueviolet]󰅩[/]" + case 14: # keyword + icon = "" + case 7: # class + icon = "[orange][/]" + case 9: # module + icon = "[cornflowerblue][/]" + + case _: + icon = str(option_data["type"]) + + return icon + " " + option_data["text"] + + def align_to_cursor(self): + x, y = self.text_area.cursor_screen_offset + dropdown = self.options_list + width, height = dropdown.outer_size + + # Constrain the dropdown within the screen. + x, y, _width, _height = Region(x - 1, y + 1, width, height).constrain( + "inside", + "none", + Spacing.all(0), + self.screen.scrollable_content_region, + ) + + self.absolute_offset = Offset(x, y) + + def on_mount(self): + #self.options_list.styles.height = len(self.options_list.children) + 2 + self.align_to_cursor() \ No newline at end of file diff --git a/lsp_client.py b/lsp_client.py new file mode 100644 index 0000000..927e0f8 --- /dev/null +++ b/lsp_client.py @@ -0,0 +1,226 @@ +import asyncio +import json +import time +import lsprotocol + +from pathlib import Path + + +class NoFileOpen(Exception): + def __init__(self, *args): + super().__init__(self, *args) + +class LSPClient: + def __init__(self, proc_name: str, proc_flags: list[str], langauge_id: str): + self.proc = None + self.proc_flags = proc_flags + self.proc_name = proc_name + self.request_id = 0 + + self.langauge_id = langauge_id + self.file_path = None + self.folder = None + + self.has_file_open = False + + self.pending = {} + self.running = False + + async def message_loop(self): + while True: + msg = await self.read_message() + + if "id" in msg: + future = self.pending.pop(msg["id"], None) + + if future: + future.set_result(msg) + elif "method" in msg: + print("notification:", msg) + + async def open_folder(self, folder_path: str): + self.folder = Path(folder_path).resolve().as_uri() + + async def open_file(self, file_path: str): + + + with open(file_path, "r") as f: + content = f.read() + + resolved_path = Path(file_path).resolve() + self.file_path = resolved_path.as_uri() + await self.open_folder(resolved_path.parent) + + await self.send({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": self.file_path, + "languageId": self.langauge_id, + "version": 1, + "text": content + } + } + }) + + self.has_file_open = True + + async def start(self, starting_file_path: str): + if self.running: + raise Exception("LSP is already running!") + self.running = True + + resolved_path = Path(starting_file_path).resolve() + + self.file_path = resolved_path.as_uri() + self.folder = resolved_path.parent.as_uri() + + # create a subprocess which we talk to over stdio + self.proc = await asyncio.create_subprocess_exec( + self.proc_name, + *self.proc_flags, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + asyncio.create_task(self.message_loop()) + + await self.initialize() + await self.open_file(starting_file_path) + + async def send(self, data: dict): + body = json.dumps(data).encode("utf-8") + + header = ( + f"Content-Length: {len(body)}\r\n\r\n" + ).encode("ascii") + + print("writing") + self.proc.stdin.write(header + body) + + print("draining") + await self.proc.stdin.drain() + print("done draining") + + async def read_message(self): + headers = {} + + while True: + line = await self.proc.stdout.readline() + + if line == b"\r\n": break + + decoded = line.decode("ascii").strip() + + key, value = decoded.split(":", 1) + headers[key.strip()] = value.strip() + + content_length = int(headers["Content-Length"]) + + body = await self.proc.stdout.readexactly(content_length) + + return json.loads(body.decode("utf-8")) + + async def apply_change(self, text: str): + await self.send({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": self.file_path, + "version": 1 + }, + "contentChanges": [ + { + "text": text + } + ] + } + }) + + async def request(self, method, params): + self.request_id += 1 + reg_id = self.request_id + + loop = asyncio.get_running_loop() + + future = loop.create_future() + + print(self.request_id) + + self.pending[reg_id] = future + print(self.pending) + + await self.send({ + "jsonrpc": "2.0", + "id": self.request_id, + "method": method, + "params": params + }) + + return await future + + async def get_completions(self, cursor_pos: tuple[int, int]): + if not self.has_file_open: + raise NoFileOpen("Can't get completions when you haven't openned a file yet.") + + return await self.request( + "textDocument/completion", + { + "textDocument": { + "uri": self.file_path, + }, + "position": { + "line": cursor_pos[0], + "character": cursor_pos[1] + }, + "context": { + "triggerKind": 1 + } + } + ) + + async def initialize(self): + print(f"rootUri: {self.folder}") + + response = await self.request( + "initialize", + { + "processId": None, + "rootUri": self.folder, + "capabilities": {}, + "initializationOptions": { + "clangdFileStatus": True + } + } + ) + + print(response) + + await self.send({ + "jsonrpc": "2.0", + "method": "initialized", + "params": {} + }) + + def get_from_file_path(file_path: str, ): + file_path = Path(file_path) + + + + +if __name__ == "__main__": + async def main(): + client = LSPClient("jedi-language-server", [], "python") + await client.start("test.py") + + start = time.time() + print(await client.get_completions((0, 9))) + + + + print(f"took {round(time.time() - start, 1)} seconds") + + asyncio.run(main()) \ No newline at end of file diff --git a/main.py b/main.py index 3816ed7..35657e7 100644 --- a/main.py +++ b/main.py @@ -15,10 +15,13 @@ from plugin_loader import PluginLoader from settings import SettingsScreen from settings_store import ConfigHandler from directory_tree_custom import CustomDirectoryTree +from completions_menu import CompletionsMenu from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler +from lsp_client import LSPClient + import os, sys @@ -61,6 +64,13 @@ class Berry(App): self.current_editor = None self.file_tabs = {} + + self.completions_menu = None + self.current_lsp = None + self.running_lsps = { + "py": LSPClient("jedi-language-server", [], "python") + } + self.last_change_pos = None def compose(self) -> ComposeResult: yield Header() @@ -244,11 +254,25 @@ class Berry(App): else: tabs.active = self.file_tabs[str(event.path)].id - def open_text_editor(self, file_content: bytes, file_extension: str): - code_editor = TextArea.code_editor(placeholder="This file is empty.", theme="css", disabled=True, soft_wrap=bool(int(self.config_handler.get("editor", "word_wrap"))), show_line_numbers=bool(int(self.config_handler.get("editor", "line_numbers")))) + async def open_text_editor(self, file_content: bytes, file_extension: str): + code_editor = TextArea.code_editor(placeholder="This file is empty.", classes="code-editor", theme="css", disabled=True, soft_wrap=bool(int(self.config_handler.get("editor", "word_wrap"))), show_line_numbers=bool(int(self.config_handler.get("editor", "line_numbers")))) - self.current_editor.mount(code_editor) + await self.current_editor.mount(code_editor) + # start lsp + if file_extension in self.running_lsps: + lsp = self.running_lsps[file_extension] + + self.current_lsp = file_extension + + if not lsp.running: + await lsp.start(self.open_file) + else: + await lsp.open_file(self.open_file) + else: + self.current_lsp = None + + # setup text editor code_editor.language = theme_mappings.get(file_extension, None) try: @@ -261,17 +285,17 @@ class Berry(App): code_editor.focus() - def open_editor(self, file_extension: str, file_content: bytes): + async def open_editor(self, file_extension: str, file_content: bytes): default_editors: dict = eval(self.config_handler.get("editor", "default_editors")) editor_for_file = default_editors.get(file_extension, None) self.current_editor.remove_children() if not editor_for_file: - self.open_text_editor(file_content, file_extension) + await self.open_text_editor(file_content, file_extension) @on(Tabs.TabActivated) - def on_tab_shown(self, event: Tabs.TabActivated): + async def on_tab_shown(self, event: Tabs.TabActivated): if self.file_clicked: self.file_clicked = False @@ -307,7 +331,7 @@ class Berry(App): # close the file f.close() - self.open_editor(file_extension, content) + await self.open_editor(file_extension, content) @@ -316,7 +340,10 @@ class Berry(App): def window_minimized(self, event: Window.Minimized): event.window.remove_window() - def on_text_area_changed(self, event: TextArea.Changed): + async def on_text_area_changed(self, event: TextArea.Changed): + if not event.text_area.has_class("code-editor"): + return + if self.switching: self.switching = False return @@ -329,7 +356,7 @@ class Berry(App): with open(self.open_file, "r", encoding="utf-8") as f: if f.read() == event.text_area.text: # TODO: figure out why im guetting what seems like a race conidition which is making this if statement needed return - self.unsaved_files[self.open_file] = {"current": event.text_area.text, "original": f.read()} + self.unsaved_files[self.open_file] = {"current": event.text_area.text.encode(), "original": f.read()} tabs.active_tab.tooltip = f"Unsaved changes in {tabs.active_tab.label}" @@ -341,6 +368,48 @@ class Berry(App): tabs.active_tab.label = os.path.basename(self.open_file) tabs.active_tab.tooltip = str(tabs.active_tab.label) self.unsaved_files.pop(self.open_file) + + await self.run_completions() + + async def run_completions(self): + code_editor: TextArea = self.current_editor.query_one(TextArea) + if len(code_editor.text.strip()) == 0: return + + lsp: LSPClient = self.running_lsps[self.current_lsp] + + line, col = code_editor.cursor_location + + # ensure we're on the latest version of the file when we apply completions or else we will error + if self.last_change_pos: + await lsp.apply_change(code_editor.text) + self.last_change_pos = (line, col) + + self.log("waiting for completions") + completions = await lsp.get_completions((line,col-1)) + self.log("god completions") + + self.notify(str(completions.get("error", None))) + + if not completions["result"]: + if self.completions_menu: + self.completions_menu.hide() + return + else: + if self.completions_menu: + self.completions_menu.show() + + + completion_choices = [{"text": result["label"], "type": result["kind"]} for result in completions["result"]["items"]] + + + if not self.completions_menu: + + self.completions_menu = CompletionsMenu(completion_choices, code_editor) + await self.current_editor.mount(self.completions_menu) + else: + self.completions_menu.align_to_cursor() + self.completions_menu.update(completion_choices) + def action_new(self): tabs: Tabs = self.query_one("#file-tabs") @@ -390,7 +459,7 @@ class Berry(App): self.observer.join() return super().action_quit() - def on_ready(self): + async def on_ready(self): self.open_file = None self.unsaved_files = {} # list of paths self.switching = False diff --git a/plugins/Markdown Reader/lua/main.lua b/plugins/Markdown Reader/lua/main.lua index e5fc983..87463e3 100644 --- a/plugins/Markdown Reader/lua/main.lua +++ b/plugins/Markdown Reader/lua/main.lua @@ -1,7 +1,7 @@ local plugin = {} function plugin.run() - berry.ui.notify("hi lmao") + --berry.ui.notify("hi lmao") end return plugin \ No newline at end of file