diff --git a/plugins/README.txt b/plugins/README.txt deleted file mode 100644 index dfbb0f9..0000000 --- a/plugins/README.txt +++ /dev/null @@ -1,31 +0,0 @@ -### Note -Copy the example and rename it to your desired name. Plugins define a ui target slot with the 'ui_target' requests data but don't have to if not directly interacted with. -Plugins must have a run method defined; though, you do not need to necessarily do anything within it. The run method implies that the passed in event system or other data is ready for the plugin to use. - - -### Manifest Example (All are required even if empty.) -``` -class Manifest: - name: str = "Example Plugin" - author: str = "John Doe" - version: str = "0.0.1" - support: str = "" - requests: {} = { - 'pass_ui_objects': ["plugin_control_list"], - 'pass_events': "true", - 'bind_keys': [] - } - pre_launch: bool = False -``` - - -### Requests -``` -requests: {} = { - 'pass_events': "true", # If empty or not present will be ignored. - "pass_ui_objects": [""], # Request reference to a UI component. Will be passed back as array to plugin. - 'bind_keys': [f"{name}||send_message:f"], - f"{name}||do_save:s"] # Bind keys with method and key pare using list. Must pass "name" like shown with delimiter to its right. - -} -``` diff --git a/plugins/code/commands/autopairs/__init__.py b/plugins/code/commands/autopairs/__init__.py new file mode 100644 index 0000000..e6b1b36 --- /dev/null +++ b/plugins/code/commands/autopairs/__init__.py @@ -0,0 +1,3 @@ +""" + Plugin Module +""" diff --git a/plugins/code/commands/autopairs/__main__.py b/plugins/code/commands/autopairs/__main__.py new file mode 100644 index 0000000..c2e27a7 --- /dev/null +++ b/plugins/code/commands/autopairs/__main__.py @@ -0,0 +1,3 @@ +""" + Plugin Package +""" diff --git a/plugins/code/commands/autopairs/autopairs.py b/plugins/code/commands/autopairs/autopairs.py new file mode 100644 index 0000000..221393a --- /dev/null +++ b/plugins/code/commands/autopairs/autopairs.py @@ -0,0 +1,97 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class Autopairs: + def __init__(self): + ... + + def handle_word_wrap(self, buffer, char_str: str): + wrap_block = self.get_wrap_block(char_str) + if not wrap_block: return + + selection = buffer.get_selection_bounds() + if not selection: + self.insert_pair(buffer, char_str, wrap_block) + return True + + self.wrap_selection(buffer, char_str, wrap_block, selection) + + return True + + def insert_pair( + self, buffer, char_str: str, wrap_block: tuple + ): + buffer.begin_user_action() + + left_block, right_block = wrap_block + insert_mark = buffer.get_insert() + + insert_itr = buffer.get_iter_at_mark(insert_mark) + buffer.insert(insert_itr, f"{left_block}{right_block}") + insert_itr = buffer.get_iter_at_mark( insert_mark ) + insert_itr.backward_char() + + buffer.place_cursor(insert_itr) + + buffer.end_user_action() + + def wrap_selection( + self, buffer, char_str: str, wrap_block: tuple, selection + ): + left_block, \ + right_block = wrap_block + start_itr, \ + end_itr = selection + data = buffer.get_text( + start_itr, end_itr, include_hidden_chars = False + ) + start_mark = buffer.create_mark("startclose", start_itr, False) + end_mark = buffer.create_mark("endclose", end_itr, True) + + buffer.begin_user_action() + + buffer.insert(start_itr, left_block) + end_itr = buffer.get_iter_at_mark(end_mark) + buffer.insert(end_itr, right_block) + + start = buffer.get_iter_at_mark(start_mark) + end = buffer.get_iter_at_mark(end_mark) + + buffer.select_range(start, end) + buffer.delete_mark_by_name("startclose") + buffer.delete_mark_by_name("endclose") + + buffer.end_user_action() + + def get_wrap_block(self, char_str) -> tuple: + left_block = "" + right_block = "" + + match char_str: + case "(" | ")": + left_block = "(" + right_block = ")" + case "[" | "]": + left_block = "[" + right_block = "]" + case "{" | "}": + left_block = "{" + right_block = "}" + case '"': + left_block = '"' + right_block = '"' + case "'": + left_block = "'" + right_block = "'" + case "`": + left_block = "`" + right_block = "`" + case _: + return () + + return left_block, right_block diff --git a/plugins/code/commands/autopairs/manifest.json b/plugins/code/commands/autopairs/manifest.json new file mode 100644 index 0000000..0df9082 --- /dev/null +++ b/plugins/code/commands/autopairs/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Autopairs", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/commands/autopairs/plugin.py b/plugins/code/commands/autopairs/plugin.py new file mode 100644 index 0000000..1da1ad0 --- /dev/null +++ b/plugins/code/commands/autopairs/plugin.py @@ -0,0 +1,73 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .autopairs import Autopairs + + + +autopairs = Autopairs() + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + event = Event_Factory.create_event("register_command", + command_name = "autopairs", + command = Handler, + binding_mode = "held", + binding = [ + "'", "`", "[", "]", + '"', + '(', + ')', + '{', + '}' + ] + ) + + self.emit_to("source_views", event) + + def unload(self): + event = Event_Factory.create_event("unregister_command", + command_name = "autopairs", + command = Handler, + binding_mode = "held", + binding = [ + "'", "`", "[", "]", + '"', + '(', + ')', + '{', + '}' + ] + ) + + self.emit_to("source_views", event) + autopairs = None + + def run(self): + ... + + +class Handler: + @staticmethod + def execute( + view: any, + char_str: str, + *args, + **kwargs + ): + logger.debug("Command: Autopairs") + autopairs.handle_word_wrap(view.get_buffer(), char_str) diff --git a/plugins/code/commands/file_history/__init__.py b/plugins/code/commands/file_history/__init__.py new file mode 100644 index 0000000..e6b1b36 --- /dev/null +++ b/plugins/code/commands/file_history/__init__.py @@ -0,0 +1,3 @@ +""" + Plugin Module +""" diff --git a/plugins/code/commands/file_history/__main__.py b/plugins/code/commands/file_history/__main__.py new file mode 100644 index 0000000..c2e27a7 --- /dev/null +++ b/plugins/code/commands/file_history/__main__.py @@ -0,0 +1,3 @@ +""" + Plugin Package +""" diff --git a/plugins/code/commands/file_history/autopairs.py b/plugins/code/commands/file_history/autopairs.py new file mode 100644 index 0000000..221393a --- /dev/null +++ b/plugins/code/commands/file_history/autopairs.py @@ -0,0 +1,97 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class Autopairs: + def __init__(self): + ... + + def handle_word_wrap(self, buffer, char_str: str): + wrap_block = self.get_wrap_block(char_str) + if not wrap_block: return + + selection = buffer.get_selection_bounds() + if not selection: + self.insert_pair(buffer, char_str, wrap_block) + return True + + self.wrap_selection(buffer, char_str, wrap_block, selection) + + return True + + def insert_pair( + self, buffer, char_str: str, wrap_block: tuple + ): + buffer.begin_user_action() + + left_block, right_block = wrap_block + insert_mark = buffer.get_insert() + + insert_itr = buffer.get_iter_at_mark(insert_mark) + buffer.insert(insert_itr, f"{left_block}{right_block}") + insert_itr = buffer.get_iter_at_mark( insert_mark ) + insert_itr.backward_char() + + buffer.place_cursor(insert_itr) + + buffer.end_user_action() + + def wrap_selection( + self, buffer, char_str: str, wrap_block: tuple, selection + ): + left_block, \ + right_block = wrap_block + start_itr, \ + end_itr = selection + data = buffer.get_text( + start_itr, end_itr, include_hidden_chars = False + ) + start_mark = buffer.create_mark("startclose", start_itr, False) + end_mark = buffer.create_mark("endclose", end_itr, True) + + buffer.begin_user_action() + + buffer.insert(start_itr, left_block) + end_itr = buffer.get_iter_at_mark(end_mark) + buffer.insert(end_itr, right_block) + + start = buffer.get_iter_at_mark(start_mark) + end = buffer.get_iter_at_mark(end_mark) + + buffer.select_range(start, end) + buffer.delete_mark_by_name("startclose") + buffer.delete_mark_by_name("endclose") + + buffer.end_user_action() + + def get_wrap_block(self, char_str) -> tuple: + left_block = "" + right_block = "" + + match char_str: + case "(" | ")": + left_block = "(" + right_block = ")" + case "[" | "]": + left_block = "[" + right_block = "]" + case "{" | "}": + left_block = "{" + right_block = "}" + case '"': + left_block = '"' + right_block = '"' + case "'": + left_block = "'" + right_block = "'" + case "`": + left_block = "`" + right_block = "`" + case _: + return () + + return left_block, right_block diff --git a/plugins/code/commands/file_history/manifest.json b/plugins/code/commands/file_history/manifest.json new file mode 100644 index 0000000..8561d56 --- /dev/null +++ b/plugins/code/commands/file_history/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "File History", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/commands/file_history/plugin.py b/plugins/code/commands/file_history/plugin.py new file mode 100644 index 0000000..18086c6 --- /dev/null +++ b/plugins/code/commands/file_history/plugin.py @@ -0,0 +1,65 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + + + +history: list = [] +history_size: int = 30 + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.RemovedFileEvent): + if event.file.ftype == "buffer": return + + if len(history) == history_size: + history.pop(0) + + history.append(event.file) + + def load(self): + self._manage_signals("register_command") + + def unload(self): + self._manage_signals("unregister_command") + + def _manage_signals(self, action: str): + event = Event_Factory.create_event(action, + command_name = "file_history_pop", + command = Handler, + binding_mode = "released", + binding = "t" + ) + + self.emit_to("source_views", event) + + def run(self): + ... + + +class Handler: + @staticmethod + def execute( + view: any, + char_str: str, + *args, + **kwargs + ): + logger.debug("Command: File History") + if len(history) == 0: return + + view._on_uri_data_received( + [ + history.pop().replace("file://", "") + ] + ) diff --git a/plugins/template/__init__.py b/plugins/code/commands/nanoesq_temp_buffer/__init__.py similarity index 100% rename from plugins/template/__init__.py rename to plugins/code/commands/nanoesq_temp_buffer/__init__.py diff --git a/plugins/template/__main__.py b/plugins/code/commands/nanoesq_temp_buffer/__main__.py similarity index 100% rename from plugins/template/__main__.py rename to plugins/code/commands/nanoesq_temp_buffer/__main__.py diff --git a/plugins/code/commands/nanoesq_temp_buffer/cut_to_temp_buffer.py b/plugins/code/commands/nanoesq_temp_buffer/cut_to_temp_buffer.py new file mode 100644 index 0000000..15cf79e --- /dev/null +++ b/plugins/code/commands/nanoesq_temp_buffer/cut_to_temp_buffer.py @@ -0,0 +1,44 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .helpers import clear_temp_cut_buffer_delayed, set_temp_cut_buffer_delayed + + + +class Handler: + @staticmethod + def execute( + view: GtkSource.View, + *args, + **kwargs + ): + logger.debug("Command: Cut to Temp Buffer") + + clear_temp_cut_buffer_delayed(view) + + buffer = view.get_buffer() + itr = buffer.get_iter_at_mark(buffer.get_insert()) + + start_itr = itr.copy() + start_itr.set_line_offset(0) + + end_itr = start_itr.copy() + if not end_itr.forward_line(): + end_itr = buffer.get_end_iter() + + if not hasattr(view, "_cut_buffer"): + view._cut_buffer = "" + + line_str = buffer.get_text(start_itr, end_itr, True) + view._cut_buffer += line_str + + buffer.delete(start_itr, end_itr) + + set_temp_cut_buffer_delayed(view) diff --git a/plugins/code/commands/nanoesq_temp_buffer/helpers.py b/plugins/code/commands/nanoesq_temp_buffer/helpers.py new file mode 100644 index 0000000..86a8671 --- /dev/null +++ b/plugins/code/commands/nanoesq_temp_buffer/helpers.py @@ -0,0 +1,24 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports + + + +def clear_temp_cut_buffer_delayed(view: any): + if not hasattr(view, "_cut_temp_timeout_id"): return + if not view._cut_temp_timeout_id: return + + GLib.source_remove(view._cut_temp_timeout_id) + +def set_temp_cut_buffer_delayed(view: any): + def clear_temp_buffer(view: any): + view._cut_buffer = "" + view._cut_temp_timeout_id = None + return False + + view._cut_temp_timeout_id = GLib.timeout_add(15000, clear_temp_buffer, view) diff --git a/plugins/code/commands/nanoesq_temp_buffer/manifest.json b/plugins/code/commands/nanoesq_temp_buffer/manifest.json new file mode 100644 index 0000000..383903a --- /dev/null +++ b/plugins/code/commands/nanoesq_temp_buffer/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Nanoesq Temp Buffer", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/commands/nanoesq_temp_buffer/paste_temp_buffer.py b/plugins/code/commands/nanoesq_temp_buffer/paste_temp_buffer.py new file mode 100644 index 0000000..be8d7e2 --- /dev/null +++ b/plugins/code/commands/nanoesq_temp_buffer/paste_temp_buffer.py @@ -0,0 +1,35 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GLib +from gi.repository import GtkSource + +# Application imports +from .helpers import clear_temp_cut_buffer_delayed, set_temp_cut_buffer_delayed + + +class Handler2: + @staticmethod + def execute( + view: GtkSource.View, + *args, + **kwargs + ): + logger.debug("Command: Paste Temp Buffer") + if not hasattr(view, "_cut_temp_timeout_id"): return + if not hasattr(view, "_cut_buffer"): return + if not view._cut_buffer: return + + clear_temp_cut_buffer_delayed(view) + + buffer = view.get_buffer() + itr = buffer.get_iter_at_mark( buffer.get_insert() ) + insert_itr = itr.copy() + + buffer.insert(insert_itr, view._cut_buffer, -1) + + set_temp_cut_buffer_delayed(view) diff --git a/plugins/code/commands/nanoesq_temp_buffer/plugin.py b/plugins/code/commands/nanoesq_temp_buffer/plugin.py new file mode 100644 index 0000000..028c460 --- /dev/null +++ b/plugins/code/commands/nanoesq_temp_buffer/plugin.py @@ -0,0 +1,49 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .cut_to_temp_buffer import Handler +from .paste_temp_buffer import Handler2 + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + self._manage_signals("register_command") + + def load(self): + self._manage_signals("unregister_command") + + def _manage_signals(self, action: str): + event = Event_Factory.create_event(action, + command_name = "cut_to_temp_buffer", + command = Handler, + binding_mode = "held", + binding = "k" + ) + + self.emit_to("source_views", event) + + event = Event_Factory.create_event(action, + command_name = "paste_temp_buffer", + command = Handler2, + binding_mode = "held", + binding = "u" + ) + + self.emit_to("source_views", event) + + def run(self): + ... diff --git a/plugins/code/commands/toggle_source_view/__init__.py b/plugins/code/commands/toggle_source_view/__init__.py new file mode 100644 index 0000000..e6b1b36 --- /dev/null +++ b/plugins/code/commands/toggle_source_view/__init__.py @@ -0,0 +1,3 @@ +""" + Plugin Module +""" diff --git a/plugins/code/commands/toggle_source_view/__main__.py b/plugins/code/commands/toggle_source_view/__main__.py new file mode 100644 index 0000000..c2e27a7 --- /dev/null +++ b/plugins/code/commands/toggle_source_view/__main__.py @@ -0,0 +1,3 @@ +""" + Plugin Package +""" diff --git a/plugins/code/commands/toggle_source_view/manifest.json b/plugins/code/commands/toggle_source_view/manifest.json new file mode 100644 index 0000000..437fd81 --- /dev/null +++ b/plugins/code/commands/toggle_source_view/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Toggle Source View", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/commands/toggle_source_view/plugin.py b/plugins/code/commands/toggle_source_view/plugin.py new file mode 100644 index 0000000..d8c55d2 --- /dev/null +++ b/plugins/code/commands/toggle_source_view/plugin.py @@ -0,0 +1,64 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + event = Event_Factory.create_event("register_command", + command_name = "toggle_source_view", + command = Handler, + binding_mode = "released", + binding = "h" + ) + + self.emit_to("source_views", event) + + def unload(self): + event = Event_Factory.create_event("unregister_command", + command_name = "toggle_source_view", + command = Handler, + binding_mode = "released", + binding = "h" + ) + + self.emit_to("source_views", event) + + def run(self): + ... + + +class Handler: + @staticmethod + def execute( + view: any, + char_str: str, + *args, + **kwargs + ): + logger.debug("Command: Toggle Source View") + target = view.get_parent() + target.hide() if target.is_visible() else target.show() + + if view.sibling_left: + target = view.sibling_left.get_parent() + target.show() + view.sibling_left.grab_focus() + + if view.sibling_right: + target = view.sibling_right.get_parent() + target.show() + view.sibling_right.grab_focus() + diff --git a/plugins/code/completers/example_completer/__init__.py b/plugins/code/completers/example_completer/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/completers/example_completer/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/completers/example_completer/__main__.py b/plugins/code/completers/example_completer/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/completers/example_completer/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/completers/example_completer/manifest.json b/plugins/code/completers/example_completer/manifest.json new file mode 100644 index 0000000..e4c6f7a --- /dev/null +++ b/plugins/code/completers/example_completer/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Example Completer", + "author": "John Doe", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/completers/example_completer/plugin.py b/plugins/code/completers/example_completer/plugin.py new file mode 100644 index 0000000..4e5b6e3 --- /dev/null +++ b/plugins/code/completers/example_completer/plugin.py @@ -0,0 +1,49 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .provider import Provider + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + self.provider: Provider = None + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + self.provider = Provider() + + event = Event_Factory.create_event( + "register_provider", + provider_name = "Example Completer", + provider = self.provider, + language_ids = [] + ) + self.emit_to("completion", event) + + def unload(self): + event = Event_Factory.create_event( + "unregister_provider", + provider_name = "Example Completer" + ) + self.emit_to("completion", event) + + self.provider = None + del self.provider + + def run(self): + ... diff --git a/plugins/code/completers/example_completer/provider.py b/plugins/code/completers/example_completer/provider.py new file mode 100644 index 0000000..d736210 --- /dev/null +++ b/plugins/code/completers/example_completer/provider.py @@ -0,0 +1,76 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GObject +from gi.repository import GtkSource + +# Application imports +from .provider_response_cache import ProviderResponseCache + + + +class Provider(GObject.GObject, GtkSource.CompletionProvider): + """ + This is a custom Completion Example Provider. + # NOTE: used information from here --> https://warroom.rsmus.com/do-that-auto-complete/ + """ + __gtype_name__ = 'ExampleCompletionProvider' + + def __init__(self): + super(Provider, self).__init__() + + self.response_cache: ProviderResponseCache = ProviderResponseCache() + + + def do_get_name(self): + """ Returns: a new string containing the name of the provider. """ + return 'Example Code Completion' + + def do_match(self, context): + # Note: If provider is in interactive activation then need to check + # view focus as otherwise non focus views start trying to grab it. + completion = context.get_property("completion") + if not completion.get_view().has_focus(): return + + word = self.response_cache.get_word(context) + if not word or len(word) < 2: return False + return True + + def do_get_priority(self): + """ Determin position in result list along other providor results. """ + return 5 + + def do_activate_proposal(self, proposal, iter_): + """ Manually handle actual completion insert or set flags and handle normally. """ + + buffer = iter_.get_buffer() + # Note: Flag mostly intended for SourceViewsMultiInsertState + # to insure marker processes inserted text correctly. + buffer.is_processing_completion = True + return False + + def do_get_activation(self): + """ The context for when a provider will show results """ + # return GtkSource.CompletionActivation.NONE + # return GtkSource.CompletionActivation.USER_REQUESTED + # return GtkSource.CompletionActivation.USER_REQUESTED | GtkSource.CompletionActivation.INTERACTIVE + return GtkSource.CompletionActivation.INTERACTIVE + + def do_populate(self, context): + results = self.response_cache.filter_with_context(context) + proposals = [] + + for entry in results: + proposals.append( + self.response_cache.create_completion_item( + entry["label"], + entry["text"], + entry["info"] + ) + ) + + context.add_proposals(self, proposals, True) + diff --git a/plugins/code/completers/example_completer/provider_response_cache.py b/plugins/code/completers/example_completer/provider_response_cache.py new file mode 100644 index 0000000..bc6d50b --- /dev/null +++ b/plugins/code/completers/example_completer/provider_response_cache.py @@ -0,0 +1,113 @@ +# Python imports +from concurrent.futures import ThreadPoolExecutor +import re + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') + +from gi.repository import Gtk +from gi.repository import GLib +from gi.repository import GtkSource + +# Application imports +from libs.event_factory import Code_Event_Types + +from core.widgets.code.completion_providers.provider_response_cache_base import ProviderResponseCacheBase + + + +class ProviderResponseCache(ProviderResponseCacheBase): + def __init__(self): + super(ProviderResponseCache, self).__init__() + + # Note: Using asyncio.run causes a keyboard trap that prevents app + # closure from terminal. ThreadPoolExecutor seems to not have such issues... + self.executor = ThreadPoolExecutor(max_workers = 1) + self.matchers: dict = { + "hello": { + "label": "Hello, World!", + "text": "Hello, World!", + "info": GLib.markup_escape_text( "Says the first ever program developers write..." ) + }, + "foo": { + "label": "foo", + "text": "foo }}" + }, + "bar": { + "label": "bar", + "text": "bar }}" + } + } + + + def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + ... + + def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + ... + + def process_file_save(self, event: Code_Event_Types.SavedFileEvent): + ... + + def process_file_change(self, event: Code_Event_Types.TextChangedEvent): + ... + + def filter(self, word: str) -> list[dict]: + ... + + def filter_with_context(self, context) -> list[dict]: + """ + In this instance, it will do 2 things: + 1) always provide Hello World! (Not ideal but an option so its in the example) + 2) Utilizes the Gtk.TextIter from the TextBuffer to determine if there is a jinja + example of '{{ custom.' if so it will provide you with the options of foo and bar. + If selected it will insert foo }} or bar }}, completing your syntax... + + PLEASE NOTE the GtkTextIter Logic and regex are really rough and should be adjusted and tuned + """ + + proposals: list[dict] = [ + { + "label": self.matchers[ "hello" ]["label"], + "text": self.matchers[ "hello" ]["text"], + "info": self.matchers[ "hello" ]["info"] + } + ] + + # Gtk Versions differ on get_iter responses... + end_iter = context.get_iter() + if not isinstance(end_iter, Gtk.TextIter): + _, end_iter = context.get_iter() + + if not end_iter: return + + buf = end_iter.get_buffer() + mov_iter = end_iter.copy() + if mov_iter.backward_search('{{', Gtk.TextSearchFlags.VISIBLE_ONLY): + mov_iter, _ = mov_iter.backward_search('{{', Gtk.TextSearchFlags.VISIBLE_ONLY) + left_text = buf.get_text(mov_iter, end_iter, True) + else: + left_text = '' + + if re.match(r'.*\{\{\s*custom\.$', left_text): + # optionally proposed based on left search via regex + proposals.append( + { + "label": self.matchers[ "foo" ]["label"], + "text": self.matchers[ "foo" ]["text"], + "info": "" + } + ) + + # optionally proposed based on left search via regex + proposals.append( + { + "label": self.matchers[ "bar" ]["label"], + "text": self.matchers[ "bar" ]["text"], + "info": "" + } + ) + + return proposals diff --git a/plugins/code/completers/snippets_completer/__init__.py b/plugins/code/completers/snippets_completer/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/completers/snippets_completer/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/completers/snippets_completer/__main__.py b/plugins/code/completers/snippets_completer/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/completers/snippets_completer/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/completers/snippets_completer/cson/__init__.py b/plugins/code/completers/snippets_completer/cson/__init__.py new file mode 100644 index 0000000..7eaf7bf --- /dev/null +++ b/plugins/code/completers/snippets_completer/cson/__init__.py @@ -0,0 +1,3 @@ +from .parser import load, loads +from .writer import dump, dumps +from .speg import ParseError diff --git a/plugins/code/completers/snippets_completer/cson/parser.py b/plugins/code/completers/snippets_completer/cson/parser.py new file mode 100644 index 0000000..b8e4e98 --- /dev/null +++ b/plugins/code/completers/snippets_completer/cson/parser.py @@ -0,0 +1,295 @@ +from .speg import peg +import re, sys + +if sys.version_info[0] == 2: + _chr = unichr +else: + _chr = chr + +def load(fin): + return loads(fin.read()) + +def loads(s): + if isinstance(s, bytes): + s = s.decode('utf-8') + if s.startswith(u'\ufeff'): + s = s[1:] + return peg(s.replace('\r\n', '\n'), _p_root) + +def _p_ws(p): + p('[ \t]*') + +def _p_nl(p): + p(r'([ \t]*(?:#[^\n]*)?\r?\n)+') + +def _p_ews(p): + with p: + p(_p_nl) + p(_p_ws) + +def _p_id(p): + return p(r'[$a-zA-Z_][$0-9a-zA-Z_]*') + +_escape_table = { + 'r': '\r', + 'n': '\n', + 't': '\t', + 'f': '\f', + 'b': '\b', +} +def _p_unescape(p): + esc = p('\\\\(?:u[0-9a-fA-F]{4}|[^\n])') + if esc[1] == 'u': + return _chr(int(esc[2:], 16)) + return _escape_table.get(esc[1:], esc[1:]) + +_re_indent = re.compile(r'[ \t]*') +def _p_block_str(p, c): + p(r'{c}{c}{c}'.format(c=c)) + lines = [['']] + with p: + while True: + s = p(r'(?:{c}(?!{c}{c})|[^{c}\\])*'.format(c=c)) + l = s.split('\n') + lines[-1].append(l[0]) + lines.extend([x] for x in l[1:]) + if p(r'(?:\\\n[ \t]*)*'): + continue + p.commit() + lines[-1].append(p(_p_unescape)) + p(r'{c}{c}{c}'.format(c=c)) + + lines = [''.join(l) for l in lines] + strip_ws = len(lines) > 1 + if strip_ws and all(c in ' \t' for c in lines[-1]): + lines.pop() + + indent = None + for line in lines[1:]: + if not line: + continue + if indent is None: + indent = _re_indent.match(line).group(0) + continue + for i, (c1, c2) in enumerate(zip(indent, line)): + if c1 != c2: + indent = indent[:i] + break + + ind_len = len(indent or '') + if strip_ws and all(c in ' \t' for c in lines[0]): + lines = [line[ind_len:] for line in lines[1:]] + else: + lines[1:] = [line[ind_len:] for line in lines[1:]] + + return '\n'.join(lines) + +_re_mstr_nl = re.compile(r'(?:[ \t]*\n)+[ \t]*') +_re_mstr_trailing_nl = re.compile(_re_mstr_nl.pattern + r'\Z') +def _p_multiline_str(p, c): + p('{c}(?!{c}{c})(?:[ \t]*\n[ \t]*)?'.format(c=c)) + string_parts = [] + with p: + while True: + string_parts.append(p(r'[^{c}\\]*'.format(c=c))) + if p(r'(?:\\\n[ \t]*)*'): + string_parts.append('') + continue + p.commit() + string_parts.append(p(_p_unescape)) + p(c) + string_parts[-1] = _re_mstr_trailing_nl.sub('', string_parts[-1]) + string_parts[::2] = [_re_mstr_nl.sub(' ', part) for part in string_parts[::2]] + return ''.join(string_parts) + +def _p_string(p): + with p: + return p(_p_block_str, '"') + with p: + return p(_p_block_str, "'") + with p: + return p(_p_multiline_str, '"') + return p(_p_multiline_str, "'") + +def _p_array_value(p): + with p: + p(_p_nl) + return p(_p_object) + with p: + p(_p_ws) + return p(_p_line_object) + p(_p_ews) + return p(_p_simple_value) + +def _p_key(p): + with p: + return p(_p_id) + return p(_p_string) + +def _p_flow_kv(p): + k = p(_p_key) + p(_p_ews) + p(':') + with p: + p(_p_nl) + return k, p(_p_object) + with p: + p(_p_ws) + return k, p(_p_line_object) + p(_p_ews) + return k, p(_p_simple_value) + +def _p_flow_obj_sep(p): + with p: + p(_p_ews) + p(',') + p(_p_ews) + return + + p(_p_nl) + p(_p_ws) + +def _p_simple_value(p): + with p: + p('null') + return None + + with p: + p('false') + return False + with p: + p('true') + return True + + with p: + return int(p('0b[01]+')[2:], 2) + with p: + return int(p('0o[0-7]+')[2:], 8) + with p: + return int(p('0x[0-9a-fA-F]+')[2:], 16) + with p: + return float(p(r'-?(?:[1-9][0-9]*|0)?\.[0-9]+(?:[Ee][\+-]?[0-9]+)?|(?:[1-9][0-9]*|0)(?:\.[0-9]+)?[Ee][\+-]?[0-9]+')) + with p: + return int(p('-?[1-9][0-9]*|0'), 10) + + with p: + return p(_p_string) + + with p: + p(r'\[') + r = [] + with p: + p.set('I', '') + r.append(p(_p_array_value)) + with p: + while True: + with p: + p(_p_ews) + p(',') + rr = p(_p_array_value) + if not p: + p(_p_nl) + with p: + rr = p(_p_object) + if not p: + p(_p_ews) + rr = p(_p_simple_value) + r.append(rr) + p.commit() + with p: + p(_p_ews) + p(',') + p(_p_ews) + p(r'\]') + return r + + p(r'\{') + + r = {} + p(_p_ews) + with p: + p.set('I', '') + k, v = p(_p_flow_kv) + r[k] = v + with p: + while True: + p(_p_flow_obj_sep) + k, v = p(_p_flow_kv) + r[k] = v + p.commit() + p(_p_ews) + with p: + p(',') + p(_p_ews) + p(r'\}') + return r + +def _p_line_kv(p): + k = p(_p_key) + p(_p_ws) + p(':') + p(_p_ws) + with p: + p(_p_nl) + p(p.get('I')) + return k, p(_p_indented_object) + with p: + return k, p(_p_line_object) + with p: + return k, p(_p_simple_value) + p(_p_nl) + p(p.get('I')) + p('[ \t]') + p(_p_ws) + return k, p(_p_simple_value) + +def _p_line_object(p): + k, v = p(_p_line_kv) + r = { k: v } + with p: + while True: + p(_p_ws) + p(',') + p(_p_ws) + k, v = p(_p_line_kv) + r[k] = v # uniqueness + p.commit() + return r + +def _p_object(p): + p.set('I', p.get('I') + p('[ \t]*')) + r = p(_p_line_object) + with p: + while True: + p(_p_ws) + with p: + p(',') + p(_p_nl) + p(p.get('I')) + rr = p(_p_line_object) + r.update(rr) # unqueness + p.commit() + return r + +def _p_indented_object(p): + p.set('I', p.get('I') + p('[ \t]')) + return p(_p_object) + +def _p_root(p): + with p: + p(_p_nl) + + with p: + p.set('I', '') + r = p(_p_object) + p(_p_ws) + with p: + p(',') + + if not p: + p(_p_ws) + r = p(_p_simple_value) + + p(_p_ews) + p(p.eof) + return r diff --git a/plugins/code/completers/snippets_completer/cson/speg/__init__.py b/plugins/code/completers/snippets_completer/cson/speg/__init__.py new file mode 100644 index 0000000..d3ebb8f --- /dev/null +++ b/plugins/code/completers/snippets_completer/cson/speg/__init__.py @@ -0,0 +1 @@ +from .peg import peg, ParseError diff --git a/plugins/code/completers/snippets_completer/cson/speg/peg.py b/plugins/code/completers/snippets_completer/cson/speg/peg.py new file mode 100644 index 0000000..15bd1d1 --- /dev/null +++ b/plugins/code/completers/snippets_completer/cson/speg/peg.py @@ -0,0 +1,147 @@ +import sys, re + +class ParseError(Exception): + def __init__(self, msg, text, offset, line, col): + self.msg = msg + self.text = text + self.offset = offset + self.line = line + self.col = col + super(ParseError, self).__init__(msg, offset, line, col) + +if sys.version_info[0] == 2: + _basestr = basestring +else: + _basestr = str + +def peg(s, r): + p = _Peg(s) + try: + return p(r) + except _UnexpectedError as e: + offset = max(p._errors) + err = p._errors[offset] + raise ParseError(err.msg, s, offset, err.line, err.col) + +class _UnexpectedError(RuntimeError): + def __init__(self, state, expr): + self.state = state + self.expr = expr + +class _PegState: + def __init__(self, pos, line, col): + self.pos = pos + self.line = line + self.col = col + self.vars = {} + self.commited = False + +class _PegError: + def __init__(self, msg, line, col): + self.msg = msg + self.line = line + self.col = col + +class _Peg: + def __init__(self, s): + self._s = s + self._states = [_PegState(0, 1, 1)] + self._errors = {} + self._re_cache = {} + + def __call__(self, r, *args, **kw): + if isinstance(r, _basestr): + compiled = self._re_cache.get(r) + if not compiled: + compiled = re.compile(r) + self._re_cache[r] = compiled + st = self._states[-1] + m = compiled.match(self._s[st.pos:]) + if not m: + self.error(expr=r, err=kw.get('err')) + + ms = m.group(0) + st.pos += len(ms) + nl_pos = ms.rfind('\n') + if nl_pos < 0: + st.col += len(ms) + else: + st.col = len(ms) - nl_pos + st.line += ms[:nl_pos].count('\n') + 1 + return ms + else: + kw.pop('err', None) + return r(self, *args, **kw) + + def __repr__(self): + pos = self._states[-1].pos + vars = {} + for st in self._states: + vars.update(st.vars) + return '_Peg(%r, %r)' % (self._s[:pos] + '*' + self._s[pos:], vars) + + @staticmethod + def eof(p): + if p._states[-1].pos != len(p._s): + p.error() + + def error(self, err=None, expr=None): + st = self._states[-1] + if err is None: + err = 'expected {!r}, found {!r}'.format(expr, self._s[st.pos:st.pos+4]) + self._errors[st.pos] = _PegError(err, st.line, st.col) + raise _UnexpectedError(st, expr) + + def get(self, key, default=None): + for state in self._states[::-1]: + if key in state.vars: + return state.vars[key][0] + return default + + def set(self, key, value): + self._states[-1].vars[key] = value, False + + def set_global(self, key, value): + self._states[-1].vars[key] = value, True + + def opt(self, *args, **kw): + with self: + return self(*args, **kw) + + def not_(self, s, *args, **kw): + with self: + self(s) + self.error() + + def __enter__(self): + self._states[-1].committed = False + self._states.append(_PegState(self._states[-1].pos, self._states[-1].line, self._states[-1].col)) + + def __exit__(self, type, value, traceback): + if type is None: + self.commit() + self._states.pop() + return type == _UnexpectedError + + def commit(self): + cur = self._states[-1] + prev = self._states[-2] + + for key in cur.vars: + val, g = cur.vars[key] + if not g: + continue + if key in prev.vars: + prev.vars[key] = val, prev.vars[key][1] + else: + prev.vars[key] = val, True + + prev.pos = cur.pos + prev.line = cur.line + prev.col = cur.col + prev.committed = True + + def __nonzero__(self): + return self._states[-1].committed + + __bool__ = __nonzero__ diff --git a/plugins/code/completers/snippets_completer/cson/writer.py b/plugins/code/completers/snippets_completer/cson/writer.py new file mode 100644 index 0000000..de856a4 --- /dev/null +++ b/plugins/code/completers/snippets_completer/cson/writer.py @@ -0,0 +1,191 @@ +import re, json, sys + +if sys.version_info[0] == 2: + def _is_num(o): + return isinstance(o, int) or isinstance(o, long) or isinstance(o, float) + def _stringify(o): + if isinstance(o, str): + return unicode(o) + if isinstance(o, unicode): + return o + return None +else: + def _is_num(o): + return isinstance(o, int) or isinstance(o, float) + def _stringify(o): + if isinstance(o, bytes): + return o.decode() + if isinstance(o, str): + return o + return None + +_id_re = re.compile(r'[$a-zA-Z_][$0-9a-zA-Z_]*\Z') + +class CSONEncoder: + def __init__(self, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, sort_keys=False, + indent=None, default=None): + self._skipkeys = skipkeys + self._ensure_ascii = ensure_ascii + self._allow_nan = allow_nan + self._sort_keys = sort_keys + self._indent = ' ' * (indent or 4) + self._default = default + if check_circular: + self._obj_stack = set() + else: + self._obj_stack = None + + def _format_simple_val(self, o): + if o is None: + return 'null' + if isinstance(o, bool): + return 'true' if o else 'false' + if _is_num(o): + return str(o) + s = _stringify(o) + if s is not None: + return self._escape_string(s) + return None + + def _escape_string(self, s): + r = json.dumps(s, ensure_ascii=self._ensure_ascii) + return u"'{}'".format(r[1:-1].replace("'", r"\'")) + + def _escape_key(self, s): + if s is None or isinstance(s, bool) or _is_num(s): + s = str(s) + s = _stringify(s) + if s is None: + if self._skipkeys: + return None + raise TypeError('keys must be a string') + if not _id_re.match(s): + return self._escape_string(s) + return s + + def _push_obj(self, o): + if self._obj_stack is not None: + if id(o) in self._obj_stack: + raise ValueError('Circular reference detected') + self._obj_stack.add(id(o)) + + def _pop_obj(self, o): + if self._obj_stack is not None: + self._obj_stack.remove(id(o)) + + def _encode(self, o, obj_val=False, indent='', force_flow=False): + if isinstance(o, list): + if not o: + if obj_val: + yield ' []\n' + else: + yield indent + yield '[]\n' + else: + if obj_val: + yield ' [\n' + else: + yield indent + yield '[\n' + indent = indent + self._indent + self._push_obj(o) + for v in o: + for chunk in self._encode(v, obj_val=False, indent=indent, force_flow=True): + yield chunk + self._pop_obj(o) + yield indent[:-len(self._indent)] + yield ']\n' + elif isinstance(o, dict): + items = [(self._escape_key(k), v) for k, v in o.items()] + if self._skipkeys: + items = [(k, v) for k, v in items if k is not None] + if self._sort_keys: + items.sort() + if force_flow or not items: + if not items: + if obj_val: + yield ' {}\n' + else: + yield indent + yield '{}\n' + else: + if obj_val: + yield ' {\n' + else: + yield indent + yield '{\n' + indent = indent + self._indent + self._push_obj(o) + for k, v in items: + yield indent + yield k + yield ':' + for chunk in self._encode(v, obj_val=True, indent=indent + self._indent, force_flow=False): + yield chunk + self._pop_obj(o) + yield indent[:-len(self._indent)] + yield '}\n' + else: + if obj_val: + yield '\n' + self._push_obj(o) + for k, v in items: + yield indent + yield k + yield ':' + for chunk in self._encode(v, obj_val=True, indent=indent + self._indent, force_flow=False): + yield chunk + self._pop_obj(o) + else: + v = self._format_simple_val(o) + if v is None: + self._push_obj(o) + v = self.default(o) + for chunk in self._encode(v, obj_val=obj_val, indent=indent, force_flow=force_flow): + yield chunk + self._pop_obj(o) + else: + if obj_val: + yield ' ' + else: + yield indent + yield v + yield '\n' + + def iterencode(self, o): + return self._encode(o) + + def encode(self, o): + return ''.join(self.iterencode(o)) + + def default(self, o): + if self._default is None: + raise TypeError('Cannot serialize an object of type {}'.format(type(o).__name__)) + return self._default(o) + +def dump(obj, fp, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None, + indent=None, default=None, sort_keys=False, **kw): + if indent is None and cls is None: + return json.dump(obj, fp, skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, default=default, sort_keys=sort_keys, separators=(',', ':')) + + if cls is None: + cls = CSONEncoder + encoder = cls(skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, sort_keys=sort_keys, indent=indent, default=default, **kw) + + for chunk in encoder.iterencode(obj): + fp.write(chunk) + +def dumps(obj, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None, indent=None, + default=None, sort_keys=False, **kw): + if indent is None and cls is None: + return json.dumps(obj, skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, default=default, sort_keys=sort_keys, separators=(',', ':')) + + if cls is None: + cls = CSONEncoder + encoder = cls(skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, sort_keys=sort_keys, indent=indent, default=default, **kw) + + return encoder.encode(obj) diff --git a/plugins/code/completers/snippets_completer/manifest.json b/plugins/code/completers/snippets_completer/manifest.json new file mode 100644 index 0000000..669678e --- /dev/null +++ b/plugins/code/completers/snippets_completer/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Snippets Completer", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/completers/snippets_completer/plugin.py b/plugins/code/completers/snippets_completer/plugin.py new file mode 100644 index 0000000..138571d --- /dev/null +++ b/plugins/code/completers/snippets_completer/plugin.py @@ -0,0 +1,49 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .provider import Provider + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + self.provider: Provider = None + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + self.provider = Provider() + + event = Event_Factory.create_event( + "register_provider", + provider_name = "Snippets Completer", + provider = self.provider, + language_ids = [] + ) + self.emit_to("completion", event) + + def unload(self): + event = Event_Factory.create_event( + "unregister_provider", + provider_name = "Snippets Completer" + ) + self.emit_to("completion", event) + + self.provider = None + del self.provider + + def run(self): + ... diff --git a/plugins/code/completers/snippets_completer/provider.py b/plugins/code/completers/snippets_completer/provider.py new file mode 100644 index 0000000..b7f0773 --- /dev/null +++ b/plugins/code/completers/snippets_completer/provider.py @@ -0,0 +1,68 @@ +# Python imports +import re + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GObject +from gi.repository import GtkSource + +# Application imports +from .provider_response_cache import ProviderResponseCache + + + +class Provider(GObject.GObject, GtkSource.CompletionProvider): + """ + This is a Snippits Completion Provider. + # NOTE: used information from here --> https://warroom.rsmus.com/do-that-auto-complete/ + """ + __gtype_name__ = 'SnippetsCompletionProvider' + + def __init__(self): + super(Provider, self).__init__() + + self.response_cache: ProviderResponseCache = ProviderResponseCache() + + + def do_get_name(self): + return 'Snippits Completion' + + def do_match(self, context): + word = self.response_cache.get_word(context) + if not word or len(word) < 2: return False + + return True + + def do_get_priority(self): + return 2 + + def do_activate_proposal(self, proposal, iter_): + buffer = iter_.get_buffer() + # Note: Flag mostly intended for SourceViewsMultiInsertState + # to insure marker processes inserted text correctly. + buffer.is_processing_completion = True + return False + + def do_get_activation(self): + """ The context for when a provider will show results """ + # return GtkSource.CompletionActivation.NONE + return GtkSource.CompletionActivation.USER_REQUESTED + # return GtkSource.CompletionActivation.INTERACTIVE + + def do_populate(self, context): + word = self.response_cache.get_word(context) + results = self.response_cache.filter(word) + proposals = [] + + for entry in results: + proposals.append( + self.response_cache.create_completion_item( + entry["label"], + entry["text"], + entry["info"] + ) + ) + + context.add_proposals(self, proposals, True) diff --git a/plugins/code/completers/snippets_completer/provider_response_cache.py b/plugins/code/completers/snippets_completer/provider_response_cache.py new file mode 100644 index 0000000..7b0010c --- /dev/null +++ b/plugins/code/completers/snippets_completer/provider_response_cache.py @@ -0,0 +1,68 @@ +# Python imports +from os import path + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GLib +from gi.repository import GtkSource + +# Application imports +from libs.event_factory import Code_Event_Types + +from core.widgets.code.completion_providers.provider_response_cache_base import ProviderResponseCacheBase + +from . import cson + + + +class ProviderResponseCache(ProviderResponseCacheBase): + def __init__(self): + super(ProviderResponseCache, self).__init__() + + self.matchers: dict = {} + + self.load_snippets() + + + def load_snippets(self): + fpath = path.join(path.dirname(path.realpath(__file__)), "snippets.cson") + with open(fpath, 'rb') as f: + self.snippets = cson.load(f) + for group in self.snippets: + self.snippets[group] + for entry in self.snippets[group]: + data = self.snippets[group][entry] + self.matchers[ data["prefix"] ] = { + "label": entry, + "text": data["body"], + "info": GLib.markup_escape_text( data["body"] ) + } + + def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + ... + + def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + ... + + def process_file_save(self, event: Code_Event_Types.SavedFileEvent): + ... + + def process_file_change(self, event: Code_Event_Types.TextChangedEvent): + ... + + def filter(self, word: str) -> list[dict]: + response: list[dict] = [] + + for entry in self.matchers: + if not word in entry: continue + data = self.matchers[entry] + response.append(data) + + return response + + def filter_with_context(self, context: GtkSource.CompletionContext) -> list[dict]: + response: list[dict] = [] + + return response diff --git a/plugins/code/completers/snippets_completer/snippets.cson b/plugins/code/completers/snippets_completer/snippets.cson new file mode 100644 index 0000000..7f99623 --- /dev/null +++ b/plugins/code/completers/snippets_completer/snippets.cson @@ -0,0 +1,614 @@ +# Your snippets +# +# Atom snippets allow you to enter a simple prefix in the editor and hit tab to +# expand the prefix into a larger code block with templated values. +# +# You can create a new snippet in this file by typing "snip" and then hitting +# tab. +# +# An example CoffeeScript snippet to expand log to console.log: +# +# '.source.coffee': +# 'Console log': +# 'prefix': 'log' +# 'body': 'console.log $1' +# +# Each scope (e.g. '.source.coffee' above) can only be declared once. +# +# This file uses CoffeeScript Object Notation (CSON). +# If you are unfamiliar with CSON, you can read more about it in the +# Atom Flight Manual: +# http://flight-manual.atom.io/using-atom/sections/basic-customization/#_cson + + +### HTML SNIPPETS ### +'.text.html.basic': + + 'HTML Template': + 'prefix': 'html' + 'body': """ + + + + + + + + + + + + + + + + + + + +""" + + 'Canvas Tag': + 'prefix': 'canvas' + 'body': """""" + + 'Img Tag': + 'prefix': 'img' + 'body': """""" + + 'Br Tag': + 'prefix': 'br' + 'body': """
""" + + 'Hr Tag': + 'prefix': 'hr' + 'body': """
""" + + 'Server Side Events': + 'prefix': 'sse' + 'body': """// SSE events if supported +if(typeof(EventSource) !== "undefined") { + let source = new EventSource("resources/php/sse.php"); + source.onmessage = (event) => { + if (event.data === "") { + // code here + } + }; +} else { + console.log("SSE Not Supported In Browser..."); +} +""" + + 'AJAX Template Function': + 'prefix': 'ajax template' + 'body': """const doAjax = async (actionPath, data) => { + let xhttp = new XMLHttpRequest(); + + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + if (this.responseText != null) { // this.responseXML if getting XML fata + handleReturnData(JSON.parse(this.responseText)); + } else { + console.log("No content returned. Check the file path."); + } + } + }; + + xhttp.open("POST", actionPath, true); + xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + // Force return to be JSON NOTE: Use application/xml to force XML + xhttp.overrideMimeType('application/json'); + xhttp.send(data); +} +""" + + 'CSS Message Colors': + 'prefix': 'css colors' + 'body': """.error { color: rgb(255, 0, 0); } + .warning { color: rgb(255, 168, 0); } + .success { color: rgb(136, 204, 39); } + """ + + +### JS SNIPPETS ### +'.source.js': + + 'Server Side Events': + 'prefix': 'sse' + 'body': """// SSE events if supported +if(typeof(EventSource) !== "undefined") { + let source = new EventSource("resources/php/sse.php"); + source.onmessage = (event) => { + if (event.data === "") { + // code here + } + }; +} else { + console.log("SSE Not Supported In Browser..."); +} +""" + + 'AJAX Template Function': + 'prefix': 'ajax template' + 'body': """const doAjax = async (actionPath, data) => { + let xhttp = new XMLHttpRequest(); + + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + if (this.responseText != null) { // this.responseXML if getting XML fata + handleReturnData(JSON.parse(this.responseText)); + } else { + console.log("No content returned. Check the file path."); + } + } + }; + + xhttp.open("POST", actionPath, true); + xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + // Force return to be JSON NOTE: Use application/xml to force XML + xhttp.overrideMimeType('application/json'); + xhttp.send(data); +} +""" + + 'SE6 Function': + 'prefix': 'function se6' + 'body': """const funcName = (arg = "") => { + + } +""" + +### CSS SNIPPETS ### +'.source.css': + + 'CSS Message Colors': + 'prefix': 'css colors' + 'body': """.error { color: rgb(255, 0, 0); } + .warning { color: rgb(255, 168, 0); } + .success { color: rgb(136, 204, 39); } + """ + +### PHP SNIPPETS ### +'.text.html.php': + + 'SSE PHP': + 'prefix': 'sse php' + 'body': """ + """ + + 'PHP Template': + 'prefix': 'php' + 'body': """ Illegal Access Method!"; + serverMessage("error", $message); +} +?> +""" + 'HTML Template': + 'prefix': 'html' + 'body': """ + + + + + + + + + + + + + + + + + + + +""" + + +### BASH SNIPPETS ### +'.source.shell': + + 'Bash or Shell Template': + 'prefix': 'bash template' + 'body': """#!/bin/bash + +# . CONFIG.sh + +# set -o xtrace ## To debug scripts +# set -o errexit ## To exit on error +# set -o errunset ## To exit if a variable is referenced but not set + + +function main() { + cd "$(dirname "$0")" + echo "Working Dir: " $(pwd) + + file="$1" + if [ -z "${file}" ]; then + echo "ERROR: No file argument supplied..." + exit + fi + + if [[ -f "${file}" ]]; then + echo "SUCCESS: The path and file exists!" + else + echo "ERROR: The path or file '${file}' does NOT exist..." + fi +} +main "$@"; + """ + + + 'Bash or Shell Config': + 'prefix': 'bash config' + 'body': """#!/bin/bash + + shopt -s expand_aliases + + alias echo="echo -e" + """ + + +### PYTHON SNIPPETS ### +'.source.python': + + 'Glade __main__ Class Template': + 'prefix': 'glade __main__ class' + 'body': """#!/usr/bin/python3 + + +# Python imports +import argparse +import faulthandler +import traceback +import signal +from setproctitle import setproctitle + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import GLib + +# Application imports +from app import Application + + +def run(): + try: + setproctitle('') + faulthandler.enable() # For better debug info + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit) + + parser = argparse.ArgumentParser() + # Add long and short arguments + parser.add_argument("--debug", "-d", default="false", help="Do extra console messaging.") + parser.add_argument("--trace-debug", "-td", default="false", help="Disable saves, ignore IPC lock, do extra console messaging.") + parser.add_argument("--no-plugins", "-np", default="false", help="Do not load plugins.") + + parser.add_argument("--new-tab", "-t", default="", help="Open a file into new tab.") + parser.add_argument("--new-window", "-w", default="", help="Open a file into a new window.") + + # Read arguments (If any...) + args, unknownargs = parser.parse_known_args() + + main = Application(args, unknownargs) + Gtk.main() + except Exception as e: + traceback.print_exc() + quit() + + +if __name__ == "__main__": + ''' Set process title, get arguments, and create GTK main thread. ''' + run() + + + """ + + + 'Glade __main__ Testing Template': + 'prefix': 'glade testing class' + 'body': """#!/usr/bin/python3 + + +# Python imports +import traceback +import faulthandler +import signal + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import GLib + +# Application imports + + +app_name = "Gtk Quick Test" + + +class Application(Gtk.ApplicationWindow): + def __init__(self): + super(Application, self).__init__() + self._setup_styling() + self._setup_signals() + self._load_widgets() + + self.add(Gtk.Box()) + + self.show_all() + + + def _setup_styling(self): + self.set_default_size(1670, 830) + self.set_title(f"{app_name}") + # self.set_icon_from_file( settings.get_window_icon() ) + self.set_gravity(5) # 5 = CENTER + self.set_position(1) # 1 = CENTER, 4 = CENTER_ALWAYS + + def _setup_signals(self): + self.connect("delete-event", Gtk.main_quit) + + + def _load_widgets(self): + ... + + + + +def run(): + try: + faulthandler.enable() # For better debug info + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit) + + main = Application() + Gtk.main() + except Exception as e: + traceback.print_exc() + quit() + + +if __name__ == "__main__": + ''' Set process title, get arguments, and create GTK main thread. ''' + run() + + + """ + + + 'Glade _init_ Class Template': + 'prefix': 'glade __init__ class' + 'body': """# Python imports +import inspect + + +# Lib imports + + +# Application imports +from utils import Settings +from signal_classes import CrossClassSignals + + +class Main: + def __init__(self, args): + settings = Settings() + builder = settings.returnBuilder() + + # Gets the methods from the classes and sets to handler. + # Then, builder connects to any signals it needs. + classes = [CrossClassSignals(settings)] + + handlers = {} + for c in classes: + methods = inspect.getmembers(c, predicate=inspect.ismethod) + handlers.update(methods) + + builder.connect_signals(handlers) + window = settings.createWindow() + window.show() + + """ + + 'Class Method': + 'prefix': 'def1' + 'body': """ + def fname(self): + ... + """ + + 'Gtk Class Method': + 'prefix': 'def2' + 'body': """ + def fname(self, widget, eve): + ... + """ + + + 'Python Glade Settings Template': + 'prefix': 'glade settings class' + 'body': """# Python imports + import os + + # Lib imports + import gi, cairo + gi.require_version('Gtk', '3.0') + gi.require_version('Gdk', '3.0') + + from gi.repository import Gtk + from gi.repository import Gdk + + + # Application imports + + + class Settings: + def __init__(self): + self.SCRIPT_PTH = os.path.dirname(os.path.realpath(__file__)) + "/" + self.builder = Gtk.Builder() + self.builder.add_from_file(self.SCRIPT_PTH + "../resources/Main_Window.glade") + + # 'Filters' + self.office = ('.doc', '.docx', '.xls', '.xlsx', '.xlt', '.xltx', '.xlm', + '.ppt', 'pptx', '.pps', '.ppsx', '.odt', '.rtf') + self.vids = ('.mkv', '.avi', '.flv', '.mov', '.m4v', '.mpg', '.wmv', + '.mpeg', '.mp4', '.webm') + self.txt = ('.txt', '.text', '.sh', '.cfg', '.conf') + self.music = ('.psf', '.mp3', '.ogg' , '.flac') + self.images = ('.png', '.jpg', '.jpeg', '.gif') + self.pdf = ('.pdf') + + + def createWindow(self): + # Get window and connect signals + window = self.builder.get_object("Main_Window") + window.connect("delete-event", gtk.main_quit) + self.setWindowData(window, False) + return window + + def setWindowData(self, window, paintable): + screen = window.get_screen() + visual = screen.get_rgba_visual() + + if visual != None and screen.is_composited(): + window.set_visual(visual) + + # bind css file + cssProvider = gtk.CssProvider() + cssProvider.load_from_path(self.SCRIPT_PTH + '../resources/stylesheet.css') + screen = Gdk.Screen.get_default() + styleContext = Gtk.StyleContext() + styleContext.add_provider_for_screen(screen, cssProvider, gtk.STYLE_PROVIDER_PRIORITY_USER) + + window.set_app_paintable(paintable) + if paintable: + window.connect("draw", self.area_draw) + + def getMonitorData(self): + screen = self.builder.get_object("Main_Window").get_screen() + monitors = [] + for m in range(screen.get_n_monitors()): + monitors.append(screen.get_monitor_geometry(m)) + + for monitor in monitors: + print(str(monitor.width) + "x" + str(monitor.height) + "+" + str(monitor.x) + "+" + str(monitor.y)) + + return monitors + + def area_draw(self, widget, cr): + cr.set_source_rgba(0, 0, 0, 0.54) + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.paint() + cr.set_operator(cairo.OPERATOR_OVER) + + + def returnBuilder(self): return self.builder + + # Filter returns + def returnOfficeFilter(self): return self.office + def returnVidsFilter(self): return self.vids + def returnTextFilter(self): return self.txt + def returnMusicFilter(self): return self.music + def returnImagesFilter(self): return self.images + def returnPdfFilter(self): return self.pdf + + """ + + 'Python Glade CrossClassSignals Template': + 'prefix': 'glade crossClassSignals class' + 'body': """# Python imports + import threading + import subprocess + import os + + # Lib imports + + # Application imports + + + def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs).start() + + return wrapper + + + class CrossClassSignals: + def __init__(self, settings): + self.settings = settings + self.builder = self.settings.returnBuilder() + + + def getClipboardData(self): + proc = subprocess.Popen(['xclip','-selection', 'clipboard', '-o'], stdout=subprocess.PIPE) + retcode = proc.wait() + data = proc.stdout.read() + return data.decode("utf-8").strip() + + def setClipboardData(self, data): + proc = subprocess.Popen(['xclip','-selection','clipboard'], stdin=subprocess.PIPE) + proc.stdin.write(data) + proc.stdin.close() + retcode = proc.wait() + + """ + + + 'Python Glade Generic Template': + 'prefix': 'glade generic class' + 'body': """# Python imports + + # Lib imports + + # Application imports + + + class GenericClass: + def __init__(self): + super(GenericClass, self).__init__() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + + def _setup_styling(self): + ... + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + event_system.subscribe("handle_file_from_ipc", self.handle_file_from_ipc) + + def _load_widgets(self): + ... + + """ diff --git a/plugins/code/completers/words_completer/__init__.py b/plugins/code/completers/words_completer/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/completers/words_completer/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/completers/words_completer/__main__.py b/plugins/code/completers/words_completer/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/completers/words_completer/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/completers/words_completer/default_provider.py b/plugins/code/completers/words_completer/default_provider.py new file mode 100644 index 0000000..7b315dd --- /dev/null +++ b/plugins/code/completers/words_completer/default_provider.py @@ -0,0 +1,68 @@ +# Python imports +import re + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import GtkSource + +# Application imports +from .provider_response_cache import ProviderResponseCache + + + +class Provider(GtkSource.CompletionWords): + """ + This is a Words Completion Provider. + # NOTE: used information from here --> https://warroom.rsmus.com/do-that-auto-complete/ + """ + __gtype_name__ = 'WordsCompletionProvider' + + def __init__(self): + super(Provider, self).__init__() + + self.response_cache: ProviderResponseCache = ProviderResponseCache() + + + def do_get_name(self): + return 'Words Completion' + + def do_match(self, context): + # Note: If provider is in interactive activation then need to check + # view focus as otherwise non focus views start trying to grab it. + completion = context.get_property("completion") + if not completion.get_view().has_focus(): return + + word = self.response_cache.get_word(context) + if not word or len(word) < 2: return False + + iter = self.response_cache.get_iter_correctly(context) + iter.backward_char() + ch = iter.get_char() + # NOTE: Look to re-add or apply supprting logic to use spaces + # As is it slows down the editor in certain contexts... + # if not (ch in ('_', '.', ' ') or ch.isalnum()): + if not (ch in ('_', '.') or ch.isalnum()): + return False + + return True + + def do_get_priority(self): + return 0 + + def do_activate_proposal(self, proposal, iter_): + buffer = iter_.get_buffer() + # Note: Flag mostly intended for SourceViewsMultiInsertState + # to insure marker processes inserted text correctly. + buffer.is_processing_completion = True + return False + + def do_get_activation(self): + """ The context for when a provider will show results """ + # return GtkSource.CompletionActivation.NONE + # return GtkSource.CompletionActivation.USER_REQUESTED + return GtkSource.CompletionActivation.INTERACTIVE diff --git a/plugins/code/completers/words_completer/default_provider_response_cache.py b/plugins/code/completers/words_completer/default_provider_response_cache.py new file mode 100644 index 0000000..3efb546 --- /dev/null +++ b/plugins/code/completers/words_completer/default_provider_response_cache.py @@ -0,0 +1,29 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Code_Event_Types + +from core.widgets.code.completion_providers.provider_response_cache_base import ProviderResponseCacheBase + + + +class ProviderResponseCache(ProviderResponseCacheBase): + def __init__(self): + super(ProviderResponseCache, self).__init__() + + self.matchers: dict = {} + + + def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + ... + + def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + ... + + def process_file_save(self, event: Code_Event_Types.SavedFileEvent): + ... + + def process_file_change(self, event: Code_Event_Types.TextChangedEvent): + ... diff --git a/plugins/code/completers/words_completer/manifest.json b/plugins/code/completers/words_completer/manifest.json new file mode 100644 index 0000000..2ec483d --- /dev/null +++ b/plugins/code/completers/words_completer/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Words Completer", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/completers/words_completer/plugin.py b/plugins/code/completers/words_completer/plugin.py new file mode 100644 index 0000000..a1ab8a0 --- /dev/null +++ b/plugins/code/completers/words_completer/plugin.py @@ -0,0 +1,49 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .provider import Provider + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + self.provider: Provider = None + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + self.provider = Provider() + + event = Event_Factory.create_event( + "register_provider", + provider_name = "Words Completer", + provider = self.provider, + language_ids = [] + ) + self.emit_to("completion", event) + + def unload(self): + event = Event_Factory.create_event( + "unregister_provider", + provider_name = "Words Completer" + ) + self.emit_to("completion", event) + + self.provider = None + del self.provider + + def run(self): + ... diff --git a/plugins/code/completers/words_completer/provider.py b/plugins/code/completers/words_completer/provider.py new file mode 100644 index 0000000..70997b2 --- /dev/null +++ b/plugins/code/completers/words_completer/provider.py @@ -0,0 +1,86 @@ +# Python imports +import re + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GObject +from gi.repository import GtkSource + +# Application imports +from .provider_response_cache import ProviderResponseCache + + + +class Provider(GObject.GObject, GtkSource.CompletionProvider): + """ + This is a Words Completion Provider. + # NOTE: used information from here --> https://warroom.rsmus.com/do-that-auto-complete/ + """ + __gtype_name__ = 'WordsCompletionProvider' + + def __init__(self): + super(Provider, self).__init__() + + self.response_cache: ProviderResponseCache = ProviderResponseCache() + + + def do_get_name(self): + return 'Words Completion' + + def do_match(self, context): + # Note: If provider is in interactive activation then need to check + # view focus as otherwise non focus views start trying to grab it. + completion = context.get_property("completion") + if not completion.get_view().has_focus(): return + + word = self.response_cache.get_word(context) + if not word or len(word) < 2: return False + + iter = self.response_cache.get_iter_correctly(context) + iter.backward_char() + ch = iter.get_char() + # NOTE: Look to re-add or apply supprting logic to use spaces + # As is it slows down the editor in certain contexts... + # if not (ch in ('_', '.', ' ') or ch.isalnum()): + if not (ch in ('_', '.') or ch.isalnum()): + return False + + return True + + def do_get_priority(self): + return 0 + + def do_activate_proposal(self, proposal, iter_): + buffer = iter_.get_buffer() + # Note: Flag mostly intended for SourceViewsMultiInsertState + # to insure marker processes inserted text correctly. + buffer.is_processing_completion = True + return False + + def do_get_activation(self): + """ The context for when a provider will show results """ + # return GtkSource.CompletionActivation.NONE + # return GtkSource.CompletionActivation.USER_REQUESTED + return GtkSource.CompletionActivation.INTERACTIVE + + def do_populate(self, context): + word = self.response_cache.get_word(context) + results = self.response_cache.filter_with_context(context) + # results = self.response_cache.filter(word) + + # if not results: + # results = self.response_cache.filter_with_context(context) + + proposals = [] + for entry in results: + proposals.append( + self.response_cache.create_completion_item( + entry["label"], + entry["text"], + entry["info"] + ) + ) + + context.add_proposals(self, proposals, True) diff --git a/plugins/code/completers/words_completer/provider_response_cache.py b/plugins/code/completers/words_completer/provider_response_cache.py new file mode 100644 index 0000000..663fe95 --- /dev/null +++ b/plugins/code/completers/words_completer/provider_response_cache.py @@ -0,0 +1,136 @@ +# Python imports +from concurrent.futures import ThreadPoolExecutor + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GLib +from gi.repository import GtkSource + +# Application imports +from libs.event_factory import Code_Event_Types + +from core.widgets.code.completion_providers.provider_response_cache_base import ProviderResponseCacheBase + + + +class ProviderResponseCache(ProviderResponseCacheBase): + def __init__(self): + super(ProviderResponseCache, self).__init__() + + self.matchers: dict = {} + self._temp_timeout_id: int = None + + + def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + buffer = event.file.buffer + with ThreadPoolExecutor(max_workers = 1) as executor: + executor.submit(self._handle_change, buffer) + + def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + self.matchers[event.file.buffer] = set() + del self.matchers[event.file.buffer] + + def process_file_save(self, event: Code_Event_Types.SavedFileEvent): + ... + + def process_file_change(self, event: Code_Event_Types.TextChangedEvent): + buffer = event.file.buffer + self._clear_temp_delay() + self._set_temp_delay(buffer) + + def _clear_temp_delay(self): + if self._temp_timeout_id: + GLib.source_remove(self._temp_timeout_id) + + def _set_temp_delay(self, buffer): + def run_refresh_update(buffer): + with ThreadPoolExecutor(max_workers = 1) as executor: + executor.submit(self._handle_change, buffer) + + return False + + self._temp_timeout_id = GLib.timeout_add(1500, run_refresh_update, buffer) + + def _handle_change(self, buffer): + start_itr = buffer.get_start_iter() + end_itr = buffer.get_end_iter() + data = buffer.get_text(start_itr, end_itr, False) + + if not data: + GLib.idle_add(self.load_empty_set, buffer) + return + + if not buffer in self.matchers: + GLib.idle_add(self.load_as_new_set, buffer, data) + return + + new_words = self.get_all_words(data) + GLib.idle_add(self.load_into_set, buffer, new_words) + + def filter(self, word: str) -> list[dict]: + response: list[dict] = [] + + for entry in self.matchers: + if not word in entry: continue + data = self.matchers[entry] + response.append(data) + + return response + + def filter_with_context(self, context: GtkSource.CompletionContext) -> list[dict]: + buffer = self.get_iter_correctly(context).get_buffer() + word = self.get_word(context).rstrip() + + if not buffer in self.matchers: + self.matchers[buffer] = set() + + response: list[dict] = [] + for entry in self.matchers[buffer]: + if not entry.rstrip().startswith(word): continue + + data = { + "label": entry, + "text": entry, + "info": "" + } + + response.append(data) + + return response + + def load_empty_set(self, buffer): + self.matchers[buffer] = set() + + def load_into_set(self, buffer, new_words): + # self.matchers[buffer].update(new_words) + self.matchers[buffer] = new_words + + def load_as_new_set(self, buffer, data): + self.matchers[buffer] = self.get_all_words(data) + + def get_all_words(self, data: str): + words = set() + + def is_word_char(c): + return c.isalnum() or c == '_' + + size = len(data) + i = 0 + + while i < size: + # Skip non-word characters + while i < size and not is_word_char(data[i]): + i += 1 + + start = i + # Consume word characters + while i < size and is_word_char(data[i]): + i += 1 + + word = data[start:i] + if not word: continue + words.add(word) + + return words diff --git a/plugins/code/event-watchers/extend_source_view_menu/__init__.py b/plugins/code/event-watchers/extend_source_view_menu/__init__.py new file mode 100644 index 0000000..e6b1b36 --- /dev/null +++ b/plugins/code/event-watchers/extend_source_view_menu/__init__.py @@ -0,0 +1,3 @@ +""" + Plugin Module +""" diff --git a/plugins/code/event-watchers/extend_source_view_menu/__main__.py b/plugins/code/event-watchers/extend_source_view_menu/__main__.py new file mode 100644 index 0000000..c2e27a7 --- /dev/null +++ b/plugins/code/event-watchers/extend_source_view_menu/__main__.py @@ -0,0 +1,3 @@ +""" + Plugin Package +""" diff --git a/plugins/code/event-watchers/extend_source_view_menu/manifest.json b/plugins/code/event-watchers/extend_source_view_menu/manifest.json new file mode 100644 index 0000000..eb66255 --- /dev/null +++ b/plugins/code/event-watchers/extend_source_view_menu/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Extend Source View Menu", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/event-watchers/extend_source_view_menu/plugin.py b/plugins/code/event-watchers/extend_source_view_menu/plugin.py new file mode 100644 index 0000000..4907807 --- /dev/null +++ b/plugins/code/event-watchers/extend_source_view_menu/plugin.py @@ -0,0 +1,29 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .source_view_menu import extend_source_view_menu + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.PopulateSourceViewPopupEvent): + extend_source_view_menu(event.buffer, event.menu) + + def load(self): + ... + + def unload(self): + ... + + def run(self): + ... diff --git a/plugins/code/event-watchers/extend_source_view_menu/source_view_menu.py b/plugins/code/event-watchers/extend_source_view_menu/source_view_menu.py new file mode 100644 index 0000000..af84daf --- /dev/null +++ b/plugins/code/event-watchers/extend_source_view_menu/source_view_menu.py @@ -0,0 +1,68 @@ +# Python imports +import json + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + +# Application imports + + + +def on_case_handle(menuitem, buffer, action): + start_itr, \ + end_itr = buffer.get_selection_bounds() + data = buffer.get_text(start_itr, end_itr, False) + text = data + + if action == "on_all_upper": + text = data.upper() + elif action == "on_all_lower": + text = data.lower() + elif action == "on_invert": + text = data.swapcase() + elif action == "on_title": + text = data.title() + elif action == "on_title_strip": + text = data.title().replace("-", "").replace("_", "").replace(" ", "") + + buffer.begin_user_action() + buffer.delete(start_itr, end_itr) + buffer.insert(start_itr, text) + buffer.end_user_action() + + + +def extend_source_view_menu(buffer, menu): + if not buffer.get_selection_bounds(): return + + for child in menu.get_children(): + if not child.get_label() == "C_hange Case": continue + menu.remove(child) + + change_case_item = Gtk.MenuItem(label = "Change Case") + + case_menu = Gtk.Menu() + au_case_item = Gtk.MenuItem(label = "All Upper Case") + al_case_item = Gtk.MenuItem(label = "All Lower Case") + inver_case_item = Gtk.MenuItem(label = "Invert Case") + title_case_item = Gtk.MenuItem(label = "Title Case") + title_strip_case_item = Gtk.MenuItem(label = "Title Strip Case") + + au_case_item.connect("activate", on_case_handle, buffer, "on_all_upper") + al_case_item.connect("activate", on_case_handle, buffer, "on_all_lower") + inver_case_item.connect("activate", on_case_handle, buffer, "on_invert") + title_case_item.connect("activate", on_case_handle, buffer, "on_title") + title_strip_case_item.connect("activate", on_case_handle, buffer, "on_title_strip") + + case_menu.append(au_case_item) + case_menu.append(al_case_item) + case_menu.append(inver_case_item) + case_menu.append(title_case_item) + case_menu.append(title_strip_case_item) + change_case_item.set_submenu(case_menu) + + menu.append(change_case_item) diff --git a/plugins/code/event-watchers/file_state_watcher/__init__.py b/plugins/code/event-watchers/file_state_watcher/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/event-watchers/file_state_watcher/__main__.py b/plugins/code/event-watchers/file_state_watcher/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/event-watchers/file_state_watcher/manifest.json b/plugins/code/event-watchers/file_state_watcher/manifest.json new file mode 100644 index 0000000..1cfc512 --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "File State Watcher", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/event-watchers/file_state_watcher/plugin.py b/plugins/code/event-watchers/file_state_watcher/plugin.py new file mode 100644 index 0000000..ae17dd6 --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/plugin.py @@ -0,0 +1,43 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .watcher_checks import * + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if not isinstance(event, Code_Event_Types.FocusedViewEvent): return + event = Event_Factory.create_event( + "get_file", buffer = event.view.get_buffer() + ) + self.emit_to("files", event) + + file = event.response + if file.ftype == "buffer": return + + file.check_file_on_disk() + + if file.is_deleted(): + file_is_deleted(file, self.emit) + elif file.is_externally_modified(): + file_is_externally_modified(file, self.emit) + + def load(self): + ... + + def unload(self): + ... + + def run(self): + ... diff --git a/plugins/code/event-watchers/file_state_watcher/watcher_checks.py b/plugins/code/event-watchers/file_state_watcher/watcher_checks.py new file mode 100644 index 0000000..f3d4f02 --- /dev/null +++ b/plugins/code/event-watchers/file_state_watcher/watcher_checks.py @@ -0,0 +1,54 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + + + +def ask_yes_no(message): + dialog = Gtk.MessageDialog( + parent = None, + flags = 0, + message_type = Gtk.MessageType.QUESTION, + buttons = Gtk.ButtonsType.YES_NO, + text = message, + ) + dialog.set_title("Confirm") + + response = dialog.run() + dialog.destroy() + + return response == Gtk.ResponseType.YES + + +def file_is_deleted(file, emit): + file.was_deleted = True + event = Event_Factory.create_event( + "file_externally_deleted", + file = file, + buffer = file.buffer + ) + emit(event) + + +def file_is_externally_modified(file, emit): + event = Event_Factory.create_event( + "file_externally_modified", + file = file, + buffer = file.buffer + ) + emit(event) + + if not file.buffer.get_modified(): + file.reload() + return + + result = ask_yes_no("File has been externally modified. Reload?") + if not result: return + + file.reload() diff --git a/plugins/code/event-watchers/prettify_json/__init__.py b/plugins/code/event-watchers/prettify_json/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/event-watchers/prettify_json/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/event-watchers/prettify_json/__main__.py b/plugins/code/event-watchers/prettify_json/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/event-watchers/prettify_json/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/event-watchers/prettify_json/manifest.json b/plugins/code/event-watchers/prettify_json/manifest.json new file mode 100644 index 0000000..9444c81 --- /dev/null +++ b/plugins/code/event-watchers/prettify_json/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Prettify JSON", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/event-watchers/prettify_json/plugin.py b/plugins/code/event-watchers/prettify_json/plugin.py new file mode 100644 index 0000000..99afe8d --- /dev/null +++ b/plugins/code/event-watchers/prettify_json/plugin.py @@ -0,0 +1,39 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .prettify_json import add_prettify_json + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.PopulateSourceViewPopupEvent): + language = event.buffer.get_language() + if not language: return + + if "json" == language.get_id(): + add_prettify_json(event.buffer, event.menu) + + def load(self): + ... + + def unload(self): + ... + + def run(self): + ... diff --git a/plugins/code/event-watchers/prettify_json/prettify_json.py b/plugins/code/event-watchers/prettify_json/prettify_json.py new file mode 100644 index 0000000..ae32086 --- /dev/null +++ b/plugins/code/event-watchers/prettify_json/prettify_json.py @@ -0,0 +1,29 @@ +# Python imports +import json + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + +# Application imports + + + +def add_prettify_json(buffer, menu): + def on_prettify_json(menuitem, buffer): + start_itr, \ + end_itr = buffer.get_start_iter(), buffer.get_end_iter() + data = buffer.get_text(start_itr, end_itr, False) + text = json.dumps(json.loads(data), separators = (',', ':'), indent = 4) + + buffer.begin_user_action() + buffer.delete(start_itr, end_itr) + buffer.insert(start_itr, text) + buffer.end_user_action() + + item = Gtk.MenuItem(label = "Prettify JSON") + item.connect("activate", on_prettify_json, buffer) + menu.append(item) \ No newline at end of file diff --git a/plugins/code/language_server_clients/java_lsp_client/__init__.py b/plugins/code/language_server_clients/java_lsp_client/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/language_server_clients/java_lsp_client/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/language_server_clients/java_lsp_client/__main__.py b/plugins/code/language_server_clients/java_lsp_client/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/language_server_clients/java_lsp_client/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/language_server_clients/java_lsp_client/config/lsp-server-config.json b/plugins/code/language_server_clients/java_lsp_client/config/lsp-server-config.json new file mode 100644 index 0000000..59531f7 --- /dev/null +++ b/plugins/code/language_server_clients/java_lsp_client/config/lsp-server-config.json @@ -0,0 +1,214 @@ +{ + "info": "https://download.eclipse.org/jdtls/", + "info-init-options": "https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line", + "info-import-build": "https://www.javahotchocolate.com/tutorials/build-path.html", + "info-external-class-paths": "https://github.com/eclipse-jdtls/eclipse.jdt.ls/issues/3291", + "link": "https://download.eclipse.org/jdtls/milestones/?d", + "command": "lsp-ws-proxy --listen 4114 -- jdtls", + "alt-command": "lsp-ws-proxy -- jdtls", + "alt-command2": "java-language-server", + "socket": "ws://127.0.0.1:9999/java", + "socket-two": "ws://127.0.0.1:9999/?name=jdtls", + "alt-socket": "ws://127.0.0.1:9999/?name=java-language-server", + "initialization-options": { + "bundles": [ + "intellicode-core.jar" + ], + "workspaceFolders": [ + "file://{workspace.folder}" + ], + "extendedClientCapabilities": { + "classFileContentsSupport": true, + "executeClientCommandSupport": false + }, + "settings": { + "java": { + "autobuild": { + "enabled": true + }, + "jdt": { + "ls": { + "javac": { + "enabled": true + }, + "java": { + "home": "{user.home}/Portable_Apps/sdks/javasdk/jdk-22.0.2" + }, + "lombokSupport": { + "enabled": true + }, + "protobufSupport":{ + "enabled": true + }, + "androidSupport": { + "enabled": true + } + } + }, + "configuration": { + "updateBuildConfiguration": "automatic", + "maven": { + "userSettings": "{user.home}/.config/jdtls/settings.xml", + "globalSettings": "{user.home}/.config/jdtls/settings.xml" + }, + "runtimes": [ + { + "name": "JavaSE-17", + "path": "/usr/lib/jvm/java-17-openjdk", + "javadoc": "https://docs.oracle.com/en/java/javase/17/docs/api/", + "default": false + }, + { + "name": "JavaSE-22", + "path": "{user.home}/Portable_Apps/sdks/javasdk/jdk-22.0.2", + "javadoc": "https://docs.oracle.com/en/java/javase/22/docs/api/", + "default": true + } + ] + }, + "classPath": [ + "{user.home}/.config/jdtls/m2/repository/**/*-sources.jar", + "lib/**/*-sources.jar" + ], + "docPath": [ + "{user.home}/.config/jdtls/m2/repository/**/*-javadoc.jar", + "lib/**/*-javadoc.jar" + ], + "project": { + "encoding": "ignore", + "outputPath": "bin", + "referencedLibraries": [ + "{user.home}/.config/jdtls/m2/repository/**/*.jar", + "lib/**/*.jar" + ], + "importOnFirstTimeStartup": "automatic", + "importHint": true, + "resourceFilters": [ + "node_modules", + "\\.git" + ], + "sourcePaths": [ + "src", + "{user.home}/.config/jdtls/m2/repository/**/*.jar" + ] + }, + "sources": { + "organizeImports": { + "starThreshold": 99, + "staticStarThreshold": 99 + } + }, + "imports": { + "gradle": { + "wrapper": { + "checksums": [] + } + } + }, + "import": { + "maven": { + "enabled": true, + "offline": { + "enabled": false + }, + "disableTestClasspathFlag": false + }, + "gradle": { + "enabled": false, + "wrapper": { + "enabled": true + }, + "version": "", + "home": "abs(static/gradle-7.3.3)", + "java": { + "home": "abs(static/launch_jres/17.0.6-linux-x86_64)" + }, + "offline": { + "enabled": false + }, + "arguments": [], + "jvmArguments": [], + "user": { + "home": "" + }, + "annotationProcessing": { + "enabled": true + } + }, + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**" + ], + "generatesMetadataFilesAtProjectRoot": false + }, + "maven": { + "downloadSources": true, + "updateSnapshots": true + }, + "silentNotification": true, + "contentProvider": { + "preferred": "fernflower" + }, + "signatureHelp": { + "enabled": true, + "description": { + "enabled": true + } + }, + "completion": { + "enabled": true, + "engine": "ecj", + "matchCase": "firstletter", + "maxResults": 25, + "guessMethodArguments": true, + "lazyResolveTextEdit": { + "enabled": true + }, + "postfix": { + "enabled": true + }, + "favoriteStaticMembers": [ + "org.junit.Assert.*", + "org.junit.Assume.*", + "org.junit.jupiter.api.Assertions.*", + "org.junit.jupiter.api.Assumptions.*", + "org.junit.jupiter.api.DynamicContainer.*", + "org.junit.jupiter.api.DynamicTest.*" + ], + "importOrder": [ + "#", + "java", + "javax", + "org", + "com" + ] + }, + "references": { + "includeAccessors": true, + "includeDecompiledSources": true + }, + "codeGeneration": { + "toString": { + "template": "${object.className}{${member.name()}=${member.value}, ${otherMembers}}" + }, + "insertionLocation": "afterCursor", + "useBlocks": true + }, + "implementationsCodeLens": { + "enabled": true + }, + "referencesCodeLens": { + "enabled": true + }, + "progressReports": { + "enabled": false + }, + "saveActions": { + "organizeImports": true + } + } + } + } +} diff --git a/plugins/code/language_server_clients/java_lsp_client/manifest.json b/plugins/code/language_server_clients/java_lsp_client/manifest.json new file mode 100644 index 0000000..76cfaf8 --- /dev/null +++ b/plugins/code/language_server_clients/java_lsp_client/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Java LSP Client", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "autoload": false, + "requests": {} +} diff --git a/plugins/code/language_server_clients/java_lsp_client/plugin.py b/plugins/code/language_server_clients/java_lsp_client/plugin.py new file mode 100644 index 0000000..2320324 --- /dev/null +++ b/plugins/code/language_server_clients/java_lsp_client/plugin.py @@ -0,0 +1,43 @@ +# Python imports +from os import path + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .response_handler import JavaHandler + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + dirPth = path.dirname( path.realpath(__file__) ) + with open(f"{dirPth}/config/lsp-server-config.json", "r") as f: + config = f.read() + event = Event_Factory.create_event("register_lsp_client", + lang_id = "java", + lang_config = config, + handler = JavaHandler + ) + self.emit_to("lsp_manager", event) + + def unload(self): + event = Event_Factory.create_event("unregister_lsp_client", + lang_id = "java" + ) + self.emit_to("lsp_manager", event) + + def run(self): + ... diff --git a/plugins/code/language_server_clients/java_lsp_client/response_handler/__init__.py b/plugins/code/language_server_clients/java_lsp_client/response_handler/__init__.py new file mode 100644 index 0000000..5c43090 --- /dev/null +++ b/plugins/code/language_server_clients/java_lsp_client/response_handler/__init__.py @@ -0,0 +1 @@ +from .java import JavaHandler \ No newline at end of file diff --git a/plugins/code/language_server_clients/java_lsp_client/response_handler/java.py b/plugins/code/language_server_clients/java_lsp_client/response_handler/java.py new file mode 100644 index 0000000..c408d76 --- /dev/null +++ b/plugins/code/language_server_clients/java_lsp_client/response_handler/java.py @@ -0,0 +1,51 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from lsp_manager.response_handlers.default import DefaultHandler + + + +class JavaHandler(DefaultHandler): + """Java-specific: overrides definition, handles classFileContents.""" + + def handle(self, method: str, response, controller): + match method: + case "textDocument/definition": + self._handle_definition(response, controller) + case "java/classFileContents": + self._handle_class_file_contents(response) + case _: + super().handle(method, response, controller) + + def _handle_definition(self, response, controller): + if not response: return + + uri = response[0]["uri"] + if "jdt://" in uri: + controller._lsp_java_class_file_contents(uri) + return + + self._prompt_goto_request(uri, response[0]["range"]) + + def _handle_class_file_contents(self, text: str): + event = Event_Factory.create_event("get_active_view") + self.emit_to("source_views", event) + + view = event.response + file = view.command.exec("new_file") + buffer = view.get_buffer() + itr = buffer.get_iter_at_mark(buffer.get_insert()) + lm = GtkSource.LanguageManager.get_default() + language = lm.get_language("java") + file.ftype = "java" + + buffer.set_language(language) + buffer.insert(itr, text, -1) diff --git a/plugins/code/language_server_clients/lsp_manager/__init__.py b/plugins/code/language_server_clients/lsp_manager/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/language_server_clients/lsp_manager/__main__.py b/plugins/code/language_server_clients/lsp_manager/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/language_server_clients/lsp_manager/client/__init__.py b/plugins/code/language_server_clients/lsp_manager/client/__init__.py new file mode 100644 index 0000000..8f09a89 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/client/__init__.py @@ -0,0 +1,3 @@ +""" + LSP Clients Module +""" \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/client/lsp_client.py b/plugins/code/language_server_clients/lsp_manager/client/lsp_client.py new file mode 100644 index 0000000..816e711 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/client/lsp_client.py @@ -0,0 +1,68 @@ +# Python imports +import threading +from os import path +import json + +# Lib imports +import gi +from gi.repository import GLib + +# Application imports +from ..dto.code.lsp.lsp_messages import get_message_str +from ..dto.code.lsp.lsp_message_structs import \ + LSPResponseTypes, ClientRequest, ClientNotification +from .lsp_client_websocket import LSPClientWebsocket + + + +class LSPClient(LSPClientWebsocket): + def __init__(self): + super(LSPClient, self).__init__() + + # https://github.com/microsoft/multilspy/tree/main/src/multilspy/language_servers + # initialize-params-slim.json was created off of jedi_language_server one + # self._init_params = settings_manager.get_lsp_init_data() + + self._language: str = "" + self._init_params: dict = {} + self._event_history: dict[int, str] = {} + + try: + _USER_HOME = path.expanduser('~') + _SCRIPT_PTH = path.dirname( path.realpath(__file__) ) + _LSP_INIT_CONFIG = f"{_SCRIPT_PTH}/../configs/initialize-params-slim.json" + + with open(_LSP_INIT_CONFIG) as file: + data = file.read().replace("{user.home}", _USER_HOME) + self._init_params = json.loads(data) + except Exception as e: + logger.error( f"LSP Controller: {_LSP_INIT_CONFIG}\n\t\t{repr(e)}" ) + + self._message_id: int = -1 + self._socket = None + self.read_lock = threading.Lock() + self.write_lock = threading.Lock() + + def set_language(self, language: str): + self._language = language + + def set_socket(self, socket: str): + self._socket = socket + + def unset_socket(self): + self._socket = None + + def send_notification(self, method: str, params: dict = {}): + self._send_message( ClientNotification(method, params) ) + + def send_request(self, method: str, params: dict = {}): + self._message_id += 1 + self._event_history[self._message_id] = method + self._send_message( ClientRequest(self._message_id, method, params) ) + + def get_event_by_id(self, message_id: int) -> str: + if not message_id in self._event_history: return + return self._event_history[message_id] + + def handle_lsp_response(self, lsp_response: LSPResponseTypes): + raise NotImplementedError diff --git a/plugins/code/language_server_clients/lsp_manager/client/lsp_client_base.py b/plugins/code/language_server_clients/lsp_manager/client/lsp_client_base.py new file mode 100644 index 0000000..73bdf19 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/client/lsp_client_base.py @@ -0,0 +1,20 @@ +# Python imports + +# Lib imports + +# Application imports +from ..dto.code.lsp.lsp_message_structs import ClientRequest, ClientNotification + +from .lsp_client_events import LSPClientEvents + + + +class LSPClientBase(LSPClientEvents): + def _send_message(self, data: ClientRequest or ClientNotification): + raise NotImplementedError + + def start_client(self): + raise NotImplementedError + + def stop_client(self): + raise NotImplementedError diff --git a/plugins/code/language_server_clients/lsp_manager/client/lsp_client_events.py b/plugins/code/language_server_clients/lsp_manager/client/lsp_client_events.py new file mode 100644 index 0000000..a4f2bac --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/client/lsp_client_events.py @@ -0,0 +1,128 @@ +# Python imports +import os + +# Lib imports + +# Application imports +from ..dto.code.lsp.lsp_messages import get_message_obj +from ..dto.code.lsp.lsp_messages import didopen_notification +from ..dto.code.lsp.lsp_messages import didsave_notification +from ..dto.code.lsp.lsp_messages import didclose_notification +from ..dto.code.lsp.lsp_messages import didchange_notification +from ..dto.code.lsp.lsp_messages import completion_request +from ..dto.code.lsp.lsp_messages import definition_request +from ..dto.code.lsp.lsp_messages import references_request +from ..dto.code.lsp.lsp_messages import symbols_request + + + +class LSPClientEvents: + def send_initialize_message(self, init_ops: dict, workspace_file: str, workspace_uri: str): + folder_name = os.path.basename(workspace_file) + + self._init_params["processId"] = None + self._init_params["rootPath"] = workspace_file + self._init_params["rootUri"] = workspace_uri + self._init_params["workspaceFolders"] = [ + { + "name": folder_name, + "uri": workspace_uri + } + ] + + self._init_params["initializationOptions"] = init_ops + self.send_request("initialize", self._init_params) + + def send_initialized_message(self): + self.send_notification("initialized") + + def _lsp_did_open(self, data: dict): + method = "textDocument/didOpen" + params = didopen_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["text"] = data["text"] + + self.send_notification( method, params ) + + def _lsp_did_save(self, data: dict): + method = "textDocument/didSave" + params = didsave_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + params["text"] = data["text"] + + self.send_notification( method, params ) + + def _lsp_did_close(self, data: dict): + method = "textDocument/didClose" + params = didclose_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + + self.send_notification( method, params ) + + def _lsp_did_change(self, data: dict): + method = "textDocument/didChange" + params = didchange_notification["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["version"] = data["version"] + + contentChanges = params["contentChanges"][0] + contentChanges["text"] = data["text"] + + self.send_notification( method, params ) + + # def _lsp_did_change(self, data: dict): + # method = "textDocument/didChange" + # params = didchange_notification_range["params"] + + # params["textDocument"]["uri"] = data["uri"] + # params["textDocument"]["languageId"] = data["language_id"] + # params["textDocument"]["version"] = data["version"] + + # contentChanges = params["contentChanges"][0] + # start = contentChanges["range"]["start"] + # end = contentChanges["range"]["end"] + # contentChanges["text"] = data["text"] + # start["line"] = data["line"] + # start["character"] = 0 + # end["line"] = data["line"] + # end["character"] = data["column"] + + # self.send_notification( method, params ) + + def _lsp_definition(self, data: dict): + method = "textDocument/definition" + params = definition_request["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["version"] = data["version"] + params["position"]["line"] = data["line"] + params["position"]["character"] = data["column"] + + self.send_request( method, params ) + + def _lsp_completion(self, data: dict): + method = "textDocument/completion" + params = completion_request["params"] + + params["textDocument"]["uri"] = data["uri"] + params["textDocument"]["languageId"] = data["language_id"] + params["textDocument"]["version"] = data["version"] + params["position"]["line"] = data["line"] + params["position"]["character"] = data["column"] + + self.send_request( method, params ) + + def _lsp_java_class_file_contents(self, uri: str): + method = "java/classFileContents" + params = { + "uri": uri + } + + self.send_request( method, params ) diff --git a/plugins/code/language_server_clients/lsp_manager/client/lsp_client_websocket.py b/plugins/code/language_server_clients/lsp_manager/client/lsp_client_websocket.py new file mode 100644 index 0000000..7b8fe57 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/client/lsp_client_websocket.py @@ -0,0 +1,56 @@ +# Python imports + +# Lib imports +from gi.repository import GLib + +# Application imports +# from libs import websockets +from ..dto.code.lsp.lsp_messages import get_message_str, get_message_obj +from ..dto.code.lsp.lsp_message_structs import \ + LSPResponseTypes, ClientRequest, ClientNotification, \ + LSPResponseRequest, LSPResponseNotification, LSPIDResponseNotification + +from .lsp_client_base import LSPClientBase +from .websocket_client import WebsocketClient + + + +class LSPClientWebsocket(LSPClientBase): + def _send_message(self, data: ClientRequest | ClientNotification): + if not data: return + + message_str = get_message_str(data) + message_size = len(message_str) + message = f"Content-Length: {message_size}\r\n\r\n{message_str}" + + logger.debug(f"Client: {message_str}") + self.ws_client.send(message_str) + + def start_client(self): + self.ws_client = WebsocketClient() + self.ws_client.set_socket(self._socket) + self.ws_client.set_callback(self._monitor_lsp_response) + self.ws_client.start_client() + + return self.ws_client + + def stop_client(self): + if not hasattr(self, "ws_client"): return + self.ws_client.close_client() + + def _monitor_lsp_response(self, data: dict | None): + if not data: return + + message = get_message_obj(data) + keys = message.keys() + lsp_response = None + + if "result" in keys: + lsp_response = LSPResponseRequest(**get_message_obj(data)) + + if "method" in keys: + lsp_response = LSPResponseNotification(**get_message_obj(data)) if not "id" in keys else LSPIDResponseNotification( **get_message_obj(data) ) + + if not lsp_response: return + + GLib.idle_add(self.handle_lsp_response, lsp_response) \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/client/websocket_client.py b/plugins/code/language_server_clients/lsp_manager/client/websocket_client.py new file mode 100644 index 0000000..65b673d --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/client/websocket_client.py @@ -0,0 +1,62 @@ +# Python imports +import json +import threading + +# Lib imports + +# Application imports +from ..libs import websocket + + + +class WebsocketClient: + def __init__(self): + self.ws = None + self._socket = None + self._connected = threading.Event() + + + def set_socket(self, socket: str): + self._socket = socket + + def unset_socket(self): + self._socket = None + + def send(self, message: str): + self.ws.send(message) + + def on_message(self, ws, message: dict): + self.respond(message) + + def on_error(self, ws, error: str): + logger.debug(f"WS Error: {error}") + + def on_close(self, ws, close_status_code: int, close_msg: str): + logger.debug("WS Closed...") + + def on_open(self, ws): + self._connected.set() + logger.debug("WS opened connection...") + + def wait_for_connection(self, timeout: float = 5.0) -> bool: + return self._connected.wait(timeout) + + def set_callback(self, callback: object): + self.respond = callback + + def close_client(self): + self.ws.close() + + @daemon_threaded + def start_client(self): + if not self._socket: + raise Exception("Socket address isn't set so cannot start WebsocketClient listener...") + + # websocket.enableTrace(True) + self.ws = websocket.WebSocketApp(self._socket, + on_open = self.on_open, + on_message = self.on_message, + on_error = self.on_error, + on_close = self.on_close) + + self.ws.run_forever(reconnect = 0.5) \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/configs/initialize-params-slim.json b/plugins/code/language_server_clients/lsp_manager/configs/initialize-params-slim.json new file mode 100644 index 0000000..e018372 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/configs/initialize-params-slim.json @@ -0,0 +1,151 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "clientInfo": { + "name": "LSP Manager", + "version": "0.0.1" + }, + "locale": "en", + "rootPath": "repository_absolute_path", + "rootUri": "pathlib.Path(repository_absolute_path).as_uri()", + "capabilities": { + "textDocument": { + "completion": { + "dynamicRegistration": true, + "contextSupport": true, + "completionItem": { + "snippetSupport": false, + "commitCharactersSupport": true, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "deprecatedSupport": true, + "preselectSupport": true, + "tagSupport": { + "valueSet": [ + 1 + ] + }, + "insertReplaceSupport": false, + "resolveSupport": { + "properties": [ + "documentation", + "detail", + "additionalTextEdits" + ] + }, + "insertTextModeSupport": { + "valueSet": [ + 1, + 2 + ] + }, + "labelDetailsSupport": true + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "completionList": { + "itemDefaults": [ + "commitCharacters", + "editRange", + "insertTextFormat", + "insertTextMode" + ] + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": [ + "markdown", + "plaintext" + ] + }, + "signatureHelp": { + "dynamicRegistration": true, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ], + "parameterInformation": { + "labelOffsetSupport": true + }, + "activeParameterSupport": true + }, + "contextSupport": true + }, + "definition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "references": { + "dynamicRegistration": true + }, + "typeDefinition": { + "dynamicRegistration": true, + "linkSupport": true + }, + "implementation": { + "dynamicRegistration": true, + "linkSupport": true + }, + "colorProvider": { + "dynamicRegistration": true + }, + "declaration": { + "dynamicRegistration": true, + "linkSupport": true + }, + "callHierarchy": { + "dynamicRegistration": true + }, + "inlayHint": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": [ + "tooltip", + "textEdits", + "label.tooltip", + "label.location", + "label.command" + ] + } + }, + "diagnostic": { + "dynamicRegistration": true, + "relatedDocumentSupport": false + } + } + }, + "trace": "verbose", + "workspaceFolders": "[\n {\n \"uri\": pathlib.Path(repository_absolute_path).as_uri(),\n \"name\": os.path.basename(repository_absolute_path),\n }\n ]" +} \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/configs/lsp-servers-config.json b/plugins/code/language_server_clients/lsp_manager/configs/lsp-servers-config.json new file mode 100644 index 0000000..d8db07e --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/configs/lsp-servers-config.json @@ -0,0 +1,365 @@ +{ + "java": { + "info": "https://download.eclipse.org/jdtls/", + "info-init-options": "https://github.com/eclipse-jdtls/eclipse.jdt.ls/wiki/Running-the-JAVA-LS-server-from-the-command-line", + "info-import-build": "https://www.javahotchocolate.com/tutorials/build-path.html", + "info-external-class-paths": "https://github.com/eclipse-jdtls/eclipse.jdt.ls/issues/3291", + "link": "https://download.eclipse.org/jdtls/milestones/?d", + "command": "lsp-ws-proxy --listen 4114 -- jdtls", + "alt-command": "lsp-ws-proxy -- jdtls", + "alt-command2": "java-language-server", + "socket": "ws://127.0.0.1:9999/java", + "socket-two": "ws://127.0.0.1:9999/?name=jdtls", + "alt-socket": "ws://127.0.0.1:9999/?name=java-language-server", + "initialization-options": { + "bundles": [ + "intellicode-core.jar" + ], + "workspaceFolders": [ + "file://{workspace.folder}" + ], + "extendedClientCapabilities": { + "classFileContentsSupport": true, + "executeClientCommandSupport": false + }, + "settings": { + "java": { + "autobuild": { + "enabled": true + }, + "jdt": { + "ls": { + "javac": { + "enabled": true + }, + "java": { + "home": "{user.home}/Portable_Apps/sdks/javasdk/jdk-22.0.2" + }, + "lombokSupport": { + "enabled": true + }, + "protobufSupport":{ + "enabled": true + }, + "androidSupport": { + "enabled": true + } + } + }, + "configuration": { + "updateBuildConfiguration": "automatic", + "maven": { + "userSettings": "{user.home}/.config/jdtls/settings.xml", + "globalSettings": "{user.home}/.config/jdtls/settings.xml" + }, + "runtimes": [ + { + "name": "JavaSE-17", + "path": "/usr/lib/jvm/java-17-openjdk", + "javadoc": "https://docs.oracle.com/en/java/javase/17/docs/api/", + "default": false + }, + { + "name": "JavaSE-22", + "path": "{user.home}/Portable_Apps/sdks/javasdk/jdk-22.0.2", + "javadoc": "https://docs.oracle.com/en/java/javase/22/docs/api/", + "default": true + } + ] + }, + "classPath": [ + "{user.home}/.config/jdtls/m2/repository/**/*-sources.jar", + "lib/**/*-sources.jar" + ], + "docPath": [ + "{user.home}/.config/jdtls/m2/repository/**/*-javadoc.jar", + "lib/**/*-javadoc.jar" + ], + "project": { + "encoding": "ignore", + "outputPath": "bin", + "referencedLibraries": [ + "{user.home}/.config/jdtls/m2/repository/**/*.jar", + "lib/**/*.jar" + ], + "importOnFirstTimeStartup": "automatic", + "importHint": true, + "resourceFilters": [ + "node_modules", + "\\.git" + ], + "sourcePaths": [ + "src", + "{user.home}/.config/jdtls/m2/repository/**/*.jar" + ] + }, + "sources": { + "organizeImports": { + "starThreshold": 99, + "staticStarThreshold": 99 + } + }, + "imports": { + "gradle": { + "wrapper": { + "checksums": [] + } + } + }, + "import": { + "maven": { + "enabled": true, + "offline": { + "enabled": false + }, + "disableTestClasspathFlag": false + }, + "gradle": { + "enabled": false, + "wrapper": { + "enabled": true + }, + "version": "", + "home": "abs(static/gradle-7.3.3)", + "java": { + "home": "abs(static/launch_jres/17.0.6-linux-x86_64)" + }, + "offline": { + "enabled": false + }, + "arguments": [], + "jvmArguments": [], + "user": { + "home": "" + }, + "annotationProcessing": { + "enabled": true + } + }, + "exclusions": [ + "**/node_modules/**", + "**/.metadata/**", + "**/archetype-resources/**", + "**/META-INF/maven/**" + ], + "generatesMetadataFilesAtProjectRoot": false + }, + "maven": { + "downloadSources": true, + "updateSnapshots": true + }, + "silentNotification": true, + "contentProvider": { + "preferred": "fernflower" + }, + "signatureHelp": { + "enabled": true, + "description": { + "enabled": true + } + }, + "completion": { + "enabled": true, + "engine": "ecj", + "matchCase": "firstletter", + "maxResults": 25, + "guessMethodArguments": true, + "lazyResolveTextEdit": { + "enabled": true + }, + "postfix": { + "enabled": true + }, + "favoriteStaticMembers": [ + "org.junit.Assert.*", + "org.junit.Assume.*", + "org.junit.jupiter.api.Assertions.*", + "org.junit.jupiter.api.Assumptions.*", + "org.junit.jupiter.api.DynamicContainer.*", + "org.junit.jupiter.api.DynamicTest.*" + ], + "importOrder": [ + "#", + "java", + "javax", + "org", + "com" + ] + }, + "references": { + "includeAccessors": true, + "includeDecompiledSources": true + }, + "codeGeneration": { + "toString": { + "template": "${object.className}{${member.name()}=${member.value}, ${otherMembers}}" + }, + "insertionLocation": "afterCursor", + "useBlocks": true + }, + "implementationsCodeLens": { + "enabled": true + }, + "referencesCodeLens": { + "enabled": true + }, + "progressReports": { + "enabled": false + }, + "saveActions": { + "organizeImports": true + } + } + } + } + }, + "python": { + "info": "https://github.com/python-lsp/python-lsp-server", + "command": "lsp-ws-proxy -- pylsp", + "alt-command": "pylsp", + "alt-command2": "lsp-ws-proxy --listen 4114 -- pylsp", + "alt-command3": "pylsp --ws --port 4114", + "socket": "ws://127.0.0.1:9999/python", + "socket-two": "ws://127.0.0.1:9999/?name=pylsp", + "initialization-options": { + "pylsp": { + "rope": { + "ropeFolder": "{user.home}/.config/newton/lsps/ropeproject" + }, + "plugins": { + "ruff": { + "enabled": true, + "extendSelect": ["I"], + "lineLength": 80 + }, + "pycodestyle": { + "enabled": false + }, + "pyflakes": { + "enabled": false + }, + "pylint": { + "enabled": true + }, + "mccabe": { + "enabled": false + }, + "pylsp_rope": { + "rename": false + }, + "rope_rename": { + "enabled": false + }, + "rope_autoimport": { + "enabled": true + }, + "rope_completion": { + "enabled": false, + "eager": false + }, + "jedi_rename": { + "enabled": true + }, + "jedi_completion": { + "enabled": true, + "include_class_objects": true, + "include_function_objects": true, + "fuzzy": false + }, + "jedi": { + "root_dir": "file://{workspace.folder}", + "extra_paths": [ + "{user.home}/Portable_Apps/py-venvs/pylsp-venv/venv/lib/python3.10/site-packages" + ] + } + } + } + } + }, + "python - jedi-language-server": { + "hidden": true, + "info": "https://pypi.org/project/jedi-language-server/", + "command": "jedi-language-server", + "alt-command": "lsp-ws-proxy --listen 3030 -- jedi-language-server", + "socket": "ws://127.0.0.1:9999/python", + "socket-two": "ws://127.0.0.1:9999/?name=jedi-language-server", + "initialization-options": { + "jediSettings": { + "autoImportModules": [], + "caseInsensitiveCompletion": true, + "debug": false + }, + "completion": { + "disableSnippets": false, + "resolveEagerly": false, + "ignorePatterns": [] + }, + "markupKindPreferred": "markdown", + "workspace": { + "extraPaths": [ + "{user.home}/Portable_Apps/py-venvs/pylsp-venv/venv/lib/python3.10/site-packages" + ], + "environmentPath": "{user.home}/Portable_Apps/py-venvs/gtk-apps-venv/venv/bin/python", + "symbols": { + "ignoreFolders": [ + ".nox", + ".tox", + ".venv", + "__pycache__", + "venv" + ], + "maxSymbols": 20 + } + } + } + }, + "cpp": { + "info": "https://clangd.llvm.org/", + "command": "lsp-ws-proxy -- clangd", + "alt-command": "clangd", + "socket": "ws://127.0.0.1:9999/cpp", + "socket-two": "ws://127.0.0.1:9999/?name=clangd", + "initialization-options": {} + }, + "c": { + "hidden": true, + "info": "https://clangd.llvm.org/", + "command": "lsp-ws-proxy -- clangd", + "alt-command": "clangd", + "socket": "ws://127.0.0.1:9999/c", + "socket-two": "ws://127.0.0.1:9999/?name=clangd", + "initialization-options": {} + }, + "go": { + "info": "https://pkg.go.dev/golang.org/x/tools/gopls#section-readme", + "command": "lsp-ws-proxy -- gopls", + "alt-command": "gopls", + "socket": "ws://127.0.0.1:9999/go", + "socket-two": "ws://127.0.0.1:9999/?name=gopls", + "initialization-options": {} + }, + "typescript": { + "info": "https://github.com/typescript-language-server/typescript-language-server", + "command": "lsp-ws-proxy -- typescript-language-server", + "alt-command": "typescript-language-server --stdio", + "socket": "ws://127.0.0.1:9999/typescript", + "socket-two": "ws://127.0.0.1:9999/?name=ts", + "initialization-options": {} + }, + "sh": { + "info": "", + "command": "", + "alt-command": "", + "socket": "ws://127.0.0.1:9999/bash", + "socket-two": "ws://127.0.0.1:9999/?name=shell", + "initialization-options": {} + }, + "lua": { + "info": "https://github.com/LuaLS/lua-language-server", + "command": "lsp-ws-proxy -- lua-language-server", + "alt-command": "lua-language-server", + "socket": "ws://127.0.0.1:9999/lua", + "socket-two": "ws://127.0.0.1:9999/?name=lua", + "initialization-options": {} + } +} \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/dto/code/events/__init__.py b/plugins/code/language_server_clients/lsp_manager/dto/code/events/__init__.py new file mode 100644 index 0000000..ddb9886 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/dto/code/events/__init__.py @@ -0,0 +1,8 @@ +""" + Libs Code DTO(s) Events Package +""" + +from .lsp_event import LspEvent + +from .register_lsp_client_event import RegisterLspClientEvent +from .unregister_lsp_client_event import UnregisterLspClientEvent diff --git a/plugins/code/language_server_clients/lsp_manager/dto/code/events/lsp_event.py b/plugins/code/language_server_clients/lsp_manager/dto/code/events/lsp_event.py new file mode 100644 index 0000000..77d516e --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/dto/code/events/lsp_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from libs.dto.code.events import CodeEvent + + + +@dataclass +class LspEvent(CodeEvent): + ... diff --git a/plugins/code/language_server_clients/lsp_manager/dto/code/events/register_lsp_client_event.py b/plugins/code/language_server_clients/lsp_manager/dto/code/events/register_lsp_client_event.py new file mode 100644 index 0000000..f641c64 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/dto/code/events/register_lsp_client_event.py @@ -0,0 +1,17 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from ....response_handlers.base_handler import BaseHandler + +from .lsp_event import LspEvent + + + +@dataclass +class RegisterLspClientEvent(LspEvent): + lang_id: str = "" + lang_config: str = "{}" + handler: BaseHandler = None diff --git a/plugins/code/language_server_clients/lsp_manager/dto/code/events/unregister_lsp_client_event.py b/plugins/code/language_server_clients/lsp_manager/dto/code/events/unregister_lsp_client_event.py new file mode 100644 index 0000000..c2a6f25 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/dto/code/events/unregister_lsp_client_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .lsp_event import LspEvent + + + +@dataclass +class UnregisterLspClientEvent(LspEvent): + lang_id: str = "" diff --git a/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_message_structs.py b/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_message_structs.py new file mode 100644 index 0000000..edc74d5 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_message_structs.py @@ -0,0 +1,95 @@ +# Python imports +import json +import enum +from dataclasses import dataclass + +# Lib imports + +# Application imports +from .lsp_structs import TextDocumentItem + + + +class MessageEncoder(json.JSONEncoder): + """ + Encodes an object in JSON + """ + + def default(self, o): # pylint: disable=E0202 + return o.__dict__ + + +@dataclass +class ClientRequest(object): + def __init__(self, id: int, method: str, params: dict): + """ + Constructs a new Client Request instance. + + :param int id: Message id to track instance. + :param str method: The type of lsp request being made. + :param dict params: The arguments of the given method. + """ + self.jsonrpc = "2.0" + self.id = id + self.method = method + self.params = params + +@dataclass +class ClientNotification(object): + def __init__(self, method: str, params: dict): + """ + Constructs a new Client Notification instance. + + :param str method: The type of lsp notification being made. + :param dict params: The arguments of the given method. + """ + self.jsonrpc = "2.0" + self.method = method + self.params = params + +@dataclass +class LSPResponseRequest(object): + """ + Constructs a new LSP Response Request instance. + + :param id result: The id of the given message. + :param dict result: The arguments of the given method. + """ + jsonrpc: str + id: int + result: dict + +@dataclass +class LSPResponseNotification(object): + """ + Constructs a new LSP Response Notification instance. + + :param str method: The type of lsp notification being made. + :params dict result: The arguments of the given method. + """ + jsonrpc: str + method: str + params: dict + +@dataclass +class LSPIDResponseNotification(object): + """ + Constructs a new LSP Response Notification instance. + + :param str method: The type of lsp notification being made. + :params dict result: The arguments of the given method. + """ + jsonrpc: str + id: int + method: str + params: dict + + +class MessageTypes(ClientRequest, ClientNotification, LSPResponseRequest, LSPResponseNotification, LSPIDResponseNotification): + ... + +class ClientMessageTypes(ClientRequest, ClientNotification): + ... + +class LSPResponseTypes(LSPResponseRequest, LSPResponseNotification): + ... diff --git a/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_messages.py b/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_messages.py new file mode 100644 index 0000000..81117a4 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_messages.py @@ -0,0 +1,193 @@ +# Python imports +import json + +# Lib imports + +# Application imports +from .lsp_message_structs import MessageEncoder + + + +LEN_HEADER = "Content-Length: " +TYPE_HEADER = "Content-Type: " + + + +def get_message_str(data: dict) -> str: + return json.dumps(data, separators = (',', ':'), indent = 4, cls = MessageEncoder) + +def get_message_obj(data: str): + return json.loads(data) + + + +# Request type formatting +# https://github.com/microsoft/multilspy/blob/main/src/multilspy/language_server.py#L417 +content_part = { + "method": "textDocument/definition", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 5, + "character": 12, + "offset": 0 + } + } +} + + +didopen_notification = { + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + } + } +} + +didsave_notification = { + "method": "textDocument/didSave", + "params": { + "textDocument": { + "uri": "file://" + }, + "text": "" + } +} + +didclose_notification = { + "method": "textDocument/didClose", + "params": { + "textDocument": { + "uri": "file://" + } + } +} + + +didchange_notification = { + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + }, + "contentChanges": [ + { + "text": "" + } + ] + } +} + +didchange_notification_range = { + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "contentChanges": [ + { + "range": { + "start": { + "line": 1, + "character": 1, + }, + "end": { + "line": 1, + "character": 1, + }, + "rangeLength": 0 + } + } + ] + } +} + + +# CompletionTriggerKind = 1 | 2 | 3; +# export const Invoked: 1 = 1; +# export const TriggerCharacter: 2 = 2; +# export const TriggerForIncompleteCompletions: 3 = 3; +completion_request = { + "method": "textDocument/completion", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 5, + "character": 12, + "offset": 0 + }, + "contet": { + "triggerKind": 3, + "triggerCharacter": "" + } + } +} + +definition_request = { + "method": "textDocument/definition", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 5, + "character": 12, + "offset": 0 + } + } +} + +references_request = { + "method": "textDocument/references", + "params": { + "context": { + "includeDeclaration": False + }, + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + }, + "position": { + "line": 30, + "character": 13, + "offset": 0 + } + } +} + + +symbols_request = { + "method": "textDocument/documentSymbol", + "params": { + "textDocument": { + "uri": "file://", + "languageId": "python", + "version": 1, + "text": "" + } + } +} \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_structs.py b/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_structs.py new file mode 100644 index 0000000..7ec4449 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/dto/code/lsp/lsp_structs.py @@ -0,0 +1,571 @@ +# Python imports +import enum + +# Lib imports + +# Application imports + + + +def to_type(o, new_type): + ''' + Helper funciton that receives an object or a dict and convert it to a new + given type. + + :param object|dict o: The object to convert + :param Type new_type: The type to convert to. + ''' + + return o if new_type == type(o) else new_type(**o) + + +class Position(object): + def __init__(self, line, character): + """ + Constructs a new Position instance. + + :param int line: Line position in a document (zero-based). + :param int character: Character offset on a line in a document + (zero-based). + """ + self.line = line + self.character = character + + +class Range(object): + def __init__(self, start, end): + """ + Constructs a new Range instance. + + :param Position start: The range's start position. + :param Position end: The range's end position. + """ + self.start = to_type(start, Position) + self.end = to_type(end, Position) + + +class Location(object): + """ + Represents a location inside a resource, such as a line inside a text file. + """ + + def __init__(self, uri, range): + """ + Constructs a new Location instance. + + :param str uri: Resource file. + :param Range range: The range inside the file + """ + self.uri = uri + self.range = to_type(range, Range) + + +class LocationLink(object): + """ + Represents a link between a source and a target location. + """ + + def __init__(self, originSelectionRange, targetUri, targetRange, targetSelectionRange): + """ + Constructs a new LocationLink instance. + + :param Range originSelectionRange: Span of the origin of this link. + Used as the underlined span for mouse interaction. Defaults to the word range at the mouse position. + :param str targetUri: The target resource identifier of this link. + :param Range targetRange: The full target range of this link. If the target for example is a symbol then target + range is the range enclosing this symbol not including leading/trailing whitespace but everything else + like comments. This information is typically used to highlight the range in the editor. + :param Range targetSelectionRange: The range that should be selected and revealed when this link is being followed, + e.g the name of a function. Must be contained by the the `targetRange`. See also `DocumentSymbol#range` + """ + self.originSelectionRange = to_type(originSelectionRange, Range) + self.targetUri = targetUri + self.targetRange = to_type(targetRange, Range) + self.targetSelectionRange = to_type(targetSelectionRange, Range) + + +class Diagnostic(object): + def __init__(self, range, severity, code, source, message, relatedInformation): + """ + Constructs a new Diagnostic instance. + :param Range range: The range at which the message applies.Resource file. + :param int severity: The diagnostic's severity. Can be omitted. If omitted it is up to the + client to interpret diagnostics as error, warning, info or hint. + :param str code: The diagnostic's code, which might appear in the user interface. + :param str source: A human-readable string describing the source of this + diagnostic, e.g. 'typescript' or 'super lint'. + :param str message: The diagnostic's message. + :param list relatedInformation: An array of related diagnostic information, e.g. when symbol-names within + a scope collide all definitions can be marked via this property. + """ + self.range = range + self.severity = severity + self.code = code + self.source = source + self.message = message + self.relatedInformation = relatedInformation + + +class DiagnosticSeverity(object): + Error = 1 + Warning = 2 # TODO: warning is known in python + Information = 3 + Hint = 4 + + +class DiagnosticRelatedInformation(object): + def __init__(self, location, message): + """ + Constructs a new Diagnostic instance. + :param Location location: The location of this related diagnostic information. + :param str message: The message of this related diagnostic information. + """ + self.location = location + self.message = message + + +class Command(object): + def __init__(self, title, command, arguments): + """ + Constructs a new Diagnostic instance. + :param str title: Title of the command, like `save`. + :param str command: The identifier of the actual command handler. + :param list argusments: Arguments that the command handler should be invoked with. + """ + self.title = title + self.command = command + self.arguments = arguments + + +class TextDocumentItem(object): + """ + An item to transfer a text document from the client to the server. + """ + def __init__(self, uri, languageId, version, text): + """ + Constructs a new Diagnostic instance. + + :param DocumentUri uri: uri file path. + :param str languageId: The identifier of the actual command handler. + :param int version: Arguments that the command handler should be invoked with. + :param str text: Arguments that the command handler should be invoked with. + """ + self.uri = uri + self.languageId = languageId + self.version = version + self.text = text + + +class TextDocumentIdentifier(object): + """ + Text documents are identified using a URI. On the protocol level, URIs are passed as strings. + """ + def __init__(self, uri): + """ + Constructs a new TextDocumentIdentifier instance. + + :param DocumentUri uri: The text document's URI. + """ + self.uri = uri + + +class VersionedTextDocumentIdentifier(TextDocumentIdentifier): + """ + An identifier to denote a specific version of a text document. + """ + def __init__(self, uri, version): + """ + Constructs a new TextDocumentIdentifier instance. + + :param DocumentUri uri: The text document's URI. + :param int version: The version number of this document. If a versioned + text document identifier is sent from the server to the client and + the file is not open in the editor (the server has not received an + open notification before) the server can send `null` to indicate + that the version is known and the content on disk is the truth (as + speced with document content ownership). + The version number of a document will increase after each change, including + undo/redo. The number doesn't need to be consecutive. + """ + super(VersionedTextDocumentIdentifier, self).__init__(uri) + self.version = version + + +class TextDocumentContentChangeEvent(object): + """ + An event describing a change to a text document. If range and rangeLength are omitted + the new text is considered to be the full content of the document. + """ + def __init__(self, range, rangeLength, text): + """ + Constructs a new TextDocumentContentChangeEvent instance. + + :param Range range: The range of the document that changed. + :param int rangeLength: The length of the range that got replaced. + :param str text: The new text of the range/document. + """ + self.range = range + self.rangeLength = rangeLength + self.text = text + + +class TextDocumentPositionParams(object): + """ + A parameter literal used in requests to pass a text document and a position inside that document. + """ + def __init__(self, textDocument, position): + """ + Constructs a new TextDocumentPositionParams instance. + + :param TextDocumentIdentifier textDocument: The text document. + :param Position position: The position inside the text document. + """ + self.textDocument = textDocument + self.position = position + + +class LANGUAGE_IDENTIFIER(object): + BAT = "bat" + BIBTEX = "bibtex" + CLOJURE = "clojure" + COFFESCRIPT = "coffeescript" + C = "c" + CPP = "cpp" + CSHARP = "csharp" + CSS = "css" + DIFF = "diff" + DOCKERFILE = "dockerfile" + FSHARP = "fsharp" + GIT_COMMIT = "git-commit" + GIT_REBASE = "git-rebase" + GO = "go" + GROOVY = "groovy" + HANDLEBARS = "handlebars" + HTML = "html" + INI = "ini" + JAVA = "java" + JAVASCRIPT = "javascript" + JSON = "json" + LATEX = "latex" + LESS = "less" + LUA = "lua" + MAKEFILE = "makefile" + MARKDOWN = "markdown" + OBJECTIVE_C = "objective-c" + OBJECTIVE_CPP = "objective-cpp" + Perl = "perl" + PHP = "php" + POWERSHELL = "powershell" + PUG = "jade" + PYTHON = "python" + R = "r" + RAZOR = "razor" + RUBY = "ruby" + RUST = "rust" + SASS = "sass" + SCSS = "scss" + ShaderLab = "shaderlab" + SHELL_SCRIPT = "shellscript" + SQL = "sql" + SWIFT = "swift" + TYPE_SCRIPT = "typescript" + TEX = "tex" + VB = "vb" + XML = "xml" + XSL = "xsl" + YAML = "yaml" + + +class SymbolKind(enum.Enum): + File = 1 + Module = 2 + Namespace = 3 + Package = 4 + Class = 5 + Method = 6 + Property = 7 + Field = 8 + Constructor = 9 + Enum = 10 + Interface = 11 + Function = 12 + Variable = 13 + Constant = 14 + String = 15 + Number = 16 + Boolean = 17 + Array = 18 + Object = 19 + Key = 20 + Null = 21 + EnumMember = 22 + Struct = 23 + Event = 24 + Operator = 25 + TypeParameter = 26 + + +class SymbolInformation(object): + """ + Represents information about programming constructs like variables, classes, interfaces etc. + """ + def __init__(self, name, kind, location, containerName = None, deprecated = False): + """ + Constructs a new SymbolInformation instance. + + :param str name: The name of this symbol. + :param int kind: The kind of this symbol. + :param bool Location: The location of this symbol. The location's range is used by a tool + to reveal the location in the editor. If the symbol is selected in the + tool the range's start information is used to position the cursor. So + the range usually spans more then the actual symbol's name and does + normally include things like visibility modifiers. + + The range doesn't have to denote a node range in the sense of a abstract + syntax tree. It can therefore not be used to re-construct a hierarchy of + the symbols. + :param str containerName: The name of the symbol containing this symbol. This information is for + user interface purposes (e.g. to render a qualifier in the user interface + if necessary). It can't be used to re-infer a hierarchy for the document + symbols. + :param bool deprecated: Indicates if this symbol is deprecated. + """ + self.name = name + self.kind = SymbolKind(kind) + self.deprecated = deprecated + self.location = to_type(location, Location) + self.containerName = containerName + + +class ParameterInformation(object): + """ + Represents a parameter of a callable-signature. A parameter can + have a label and a doc-comment. + """ + def __init__(self, label, documentation = ""): + """ + Constructs a new ParameterInformation instance. + + :param str label: The label of this parameter. Will be shown in the UI. + :param str documentation: The human-readable doc-comment of this parameter. Will be shown in the UI but can be omitted. + """ + self.label = label + self.documentation = documentation + + +class SignatureInformation(object): + """ + Represents the signature of something callable. A signature + can have a label, like a function-name, a doc-comment, and + a set of parameters. + """ + def __init__(self, label, documentation = "", parameters = []): + """ + Constructs a new SignatureInformation instance. + + :param str label: The label of this signature. Will be shown in the UI. + :param str documentation: The human-readable doc-comment of this signature. Will be shown in the UI but can be omitted. + :param ParameterInformation[] parameters: The parameters of this signature. + """ + self.label = label + self.documentation = documentation + self.parameters = [to_type(parameter, ParameterInformation) for parameter in parameters] + + +class SignatureHelp(object): + """ + Signature help represents the signature of something + callable. There can be multiple signature but only one + active and only one active parameter. + """ + def __init__(self, signatures, activeSignature = 0, activeParameter = 0): + """ + Constructs a new SignatureHelp instance. + + :param SignatureInformation[] signatures: One or more signatures. + :param int activeSignature: + :param int activeParameter: + """ + self.signatures = [to_type(signature, SignatureInformation) for signature in signatures] + self.activeSignature = activeSignature + self.activeParameter = activeParameter + + +class CompletionTriggerKind(object): + Invoked = 1 + TriggerCharacter = 2 + TriggerForIncompleteCompletions = 3 + + +class CompletionContext(object): + """ + Contains additional information about the context in which a completion request is triggered. + """ + def __init__(self, triggerKind, triggerCharacter = None): + """ + Constructs a new CompletionContext instance. + + :param CompletionTriggerKind triggerKind: How the completion was triggered. + :param str triggerCharacter: The trigger character (a single character) that has trigger code complete. + Is undefined if `triggerKind !== CompletionTriggerKind.TriggerCharacter` + """ + self.triggerKind = triggerKind + if triggerCharacter: + self.triggerCharacter = triggerCharacter + + +class TextEdit(object): + """ + A textual edit applicable to a text document. + """ + def __init__(self, range, newText): + """ + :param Range range: The range of the text document to be manipulated. To insert + text into a document create a range where start === end. + :param str newText: The string to be inserted. For delete operations use an empty string. + """ + self.range = range + self.newText = newText + + +class InsertTextFormat(object): + PlainText = 1 + Snippet = 2 + + +class CompletionItem(object): + """ + """ + def __init__(self, label, \ + kind = None, \ + detail = None, \ + documentation = None, \ + deprecated = None, \ + preselect = None, \ + sortText = None, \ + filterText = None, \ + insertText = None, \ + insertTextFormat = None, \ + textEdit = None, \ + additionalTextEdits = None, \ + commitCharacters = None, \ + command = None, \ + data = None, \ + score = 0.0 + ): + """ + :param str label: The label of this completion item. By default also the text that is inserted when selecting + this completion. + :param int kind: The kind of this completion item. Based of the kind an icon is chosen by the editor. + :param str detail: A human-readable string with additional information about this item, like type or symbol information. + :param tr ocumentation: A human-readable string that represents a doc-comment. + :param bool deprecated: Indicates if this item is deprecated. + :param bool preselect: Select this item when showing. Note: that only one completion item can be selected and that the + tool / client decides which item that is. The rule is that the first item of those that match best is selected. + :param str sortText: A string that should be used when comparing this item with other items. When `falsy` the label is used. + :param str filterText: A string that should be used when filtering a set of completion items. When `falsy` the label is used. + :param str insertText: A string that should be inserted into a document when selecting this completion. When `falsy` the label is used. + The `insertText` is subject to interpretation by the client side. Some tools might not take the string literally. For example + VS Code when code complete is requested in this example `con` and a completion item with an `insertText` of `console` is provided it + will only insert `sole`. Therefore it is recommended to use `textEdit` instead since it avoids additional client side interpretation. + @deprecated Use textEdit instead. + :param InsertTextFormat insertTextFormat: The format of the insert text. The format applies to both the `insertText` property + and the `newText` property of a provided `textEdit`. + :param TextEdit textEdit: An edit which is applied to a document when selecting this completion. When an edit is provided the value of `insertText` is ignored. + Note:* The range of the edit must be a single line range and it must contain the position at which completion + has been requested. + :param TextEdit additionalTextEdits: An optional array of additional text edits that are applied when selecting this completion. + Edits must not overlap (including the same insert position) with the main edit nor with themselves. + Additional text edits should be used to change text unrelated to the current cursor position + (for example adding an import statement at the top of the file if the completion item will + insert an unqualified type). + :param str commitCharacters: An optional set of characters that when pressed while this completion is active will accept it first and + then type that character. *Note* that all commit characters should have `length=1` and that superfluous + characters will be ignored. + :param Command command: An optional command that is executed *after* inserting this completion. Note: that + additional modifications to the current document should be described with the additionalTextEdits-property. + :param data: An data entry field that is preserved on a completion item between a completion and a completion resolve request. + :param float score: Score of the code completion item. + """ + self.label = label + self.kind = kind + self.detail = detail + self.documentation = documentation + self.deprecated = deprecated + self.preselect = preselect + self.sortText = sortText + self.filterText = filterText + self.insertText = insertText + self.insertTextFormat = insertTextFormat + self.textEdit = textEdit + self.additionalTextEdits = additionalTextEdits + self.commitCharacters = commitCharacters + self.command = command + self.data = data + self.score = score + + +class CompletionItemKind(enum.Enum): + Text = 1 + Method = 2 + Function = 3 + Constructor = 4 + Field = 5 + Variable = 6 + Class = 7 + Interface = 8 + Module = 9 + Property = 10 + Unit = 11 + Value = 12 + Enum = 13 + Keyword = 14 + Snippet = 15 + Color = 16 + File = 17 + Reference = 18 + Folder = 19 + EnumMember = 20 + Constant = 21 + Struct = 22 + Event = 23 + Operator = 24 + TypeParameter = 25 + + +class CompletionList(object): + """ + Represents a collection of [completion items](#CompletionItem) to be preselect in the editor. + """ + def __init__(self, isIncomplete, items): + """ + Constructs a new CompletionContext instance. + + :param bool isIncomplete: This list it not complete. Further typing should result in recomputing this list. + :param CompletionItem items: The completion items. + """ + self.isIncomplete = isIncomplete + self.items = [to_type(i, CompletionItem) for i in items] + +class ErrorCodes(enum.Enum): + # Defined by JSON RPC + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32602 + InternalError = -32603 + serverErrorStart = -32099 + serverErrorEnd = -32000 + ServerNotInitialized = -32002 + UnknownErrorCode = -32001 + + # Defined by the protocol. + RequestCancelled = -32800 + ContentModified = -32801 + +class ResponseError(Exception): + def __init__(self, code, message, data = None): + self.code = code + self.message = message + if data: + self.data = data \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/libs/__init__.py b/plugins/code/language_server_clients/lsp_manager/libs/__init__.py new file mode 100644 index 0000000..3c1f1c5 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Libs Module +""" diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/__init__.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/__init__.py new file mode 100644 index 0000000..eed90cc --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/__init__.py @@ -0,0 +1,27 @@ +""" +__init__.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from ._abnf import * +from ._app import WebSocketApp as WebSocketApp, setReconnect as setReconnect +from ._core import * +from ._exceptions import * +from ._logging import * +from ._socket import * + +__version__ = "1.8.0" diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_abnf.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_abnf.py new file mode 100644 index 0000000..d7754e0 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_abnf.py @@ -0,0 +1,453 @@ +import array +import os +import struct +import sys +from threading import Lock +from typing import Callable, Optional, Union + +from ._exceptions import WebSocketPayloadException, WebSocketProtocolException +from ._utils import validate_utf8 + +""" +_abnf.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +try: + # If wsaccel is available, use compiled routines to mask data. + # wsaccel only provides around a 10% speed boost compared + # to the websocket-client _mask() implementation. + # Note that wsaccel is unmaintained. + from wsaccel.xormask import XorMaskerSimple + + def _mask(mask_value: array.array, data_value: array.array) -> bytes: + mask_result: bytes = XorMaskerSimple(mask_value).process(data_value) + return mask_result + +except ImportError: + # wsaccel is not available, use websocket-client _mask() + native_byteorder = sys.byteorder + + def _mask(mask_value: array.array, data_value: array.array) -> bytes: + datalen = len(data_value) + int_data_value = int.from_bytes(data_value, native_byteorder) + int_mask_value = int.from_bytes( + mask_value * (datalen // 4) + mask_value[: datalen % 4], native_byteorder + ) + return (int_data_value ^ int_mask_value).to_bytes(datalen, native_byteorder) + + +__all__ = [ + "ABNF", + "continuous_frame", + "frame_buffer", + "STATUS_NORMAL", + "STATUS_GOING_AWAY", + "STATUS_PROTOCOL_ERROR", + "STATUS_UNSUPPORTED_DATA_TYPE", + "STATUS_STATUS_NOT_AVAILABLE", + "STATUS_ABNORMAL_CLOSED", + "STATUS_INVALID_PAYLOAD", + "STATUS_POLICY_VIOLATION", + "STATUS_MESSAGE_TOO_BIG", + "STATUS_INVALID_EXTENSION", + "STATUS_UNEXPECTED_CONDITION", + "STATUS_BAD_GATEWAY", + "STATUS_TLS_HANDSHAKE_ERROR", +] + +# closing frame status codes. +STATUS_NORMAL = 1000 +STATUS_GOING_AWAY = 1001 +STATUS_PROTOCOL_ERROR = 1002 +STATUS_UNSUPPORTED_DATA_TYPE = 1003 +STATUS_STATUS_NOT_AVAILABLE = 1005 +STATUS_ABNORMAL_CLOSED = 1006 +STATUS_INVALID_PAYLOAD = 1007 +STATUS_POLICY_VIOLATION = 1008 +STATUS_MESSAGE_TOO_BIG = 1009 +STATUS_INVALID_EXTENSION = 1010 +STATUS_UNEXPECTED_CONDITION = 1011 +STATUS_SERVICE_RESTART = 1012 +STATUS_TRY_AGAIN_LATER = 1013 +STATUS_BAD_GATEWAY = 1014 +STATUS_TLS_HANDSHAKE_ERROR = 1015 + +VALID_CLOSE_STATUS = ( + STATUS_NORMAL, + STATUS_GOING_AWAY, + STATUS_PROTOCOL_ERROR, + STATUS_UNSUPPORTED_DATA_TYPE, + STATUS_INVALID_PAYLOAD, + STATUS_POLICY_VIOLATION, + STATUS_MESSAGE_TOO_BIG, + STATUS_INVALID_EXTENSION, + STATUS_UNEXPECTED_CONDITION, + STATUS_SERVICE_RESTART, + STATUS_TRY_AGAIN_LATER, + STATUS_BAD_GATEWAY, +) + + +class ABNF: + """ + ABNF frame class. + See http://tools.ietf.org/html/rfc5234 + and http://tools.ietf.org/html/rfc6455#section-5.2 + """ + + # operation code values. + OPCODE_CONT = 0x0 + OPCODE_TEXT = 0x1 + OPCODE_BINARY = 0x2 + OPCODE_CLOSE = 0x8 + OPCODE_PING = 0x9 + OPCODE_PONG = 0xA + + # available operation code value tuple + OPCODES = ( + OPCODE_CONT, + OPCODE_TEXT, + OPCODE_BINARY, + OPCODE_CLOSE, + OPCODE_PING, + OPCODE_PONG, + ) + + # opcode human readable string + OPCODE_MAP = { + OPCODE_CONT: "cont", + OPCODE_TEXT: "text", + OPCODE_BINARY: "binary", + OPCODE_CLOSE: "close", + OPCODE_PING: "ping", + OPCODE_PONG: "pong", + } + + # data length threshold. + LENGTH_7 = 0x7E + LENGTH_16 = 1 << 16 + LENGTH_63 = 1 << 63 + + def __init__( + self, + fin: int = 0, + rsv1: int = 0, + rsv2: int = 0, + rsv3: int = 0, + opcode: int = OPCODE_TEXT, + mask_value: int = 1, + data: Union[str, bytes, None] = "", + ) -> None: + """ + Constructor for ABNF. Please check RFC for arguments. + """ + self.fin = fin + self.rsv1 = rsv1 + self.rsv2 = rsv2 + self.rsv3 = rsv3 + self.opcode = opcode + self.mask_value = mask_value + if data is None: + data = "" + self.data = data + self.get_mask_key = os.urandom + + def validate(self, skip_utf8_validation: bool = False) -> None: + """ + Validate the ABNF frame. + + Parameters + ---------- + skip_utf8_validation: skip utf8 validation. + """ + if self.rsv1 or self.rsv2 or self.rsv3: + raise WebSocketProtocolException("rsv is not implemented, yet") + + if self.opcode not in ABNF.OPCODES: + raise WebSocketProtocolException("Invalid opcode %r", self.opcode) + + if self.opcode == ABNF.OPCODE_PING and not self.fin: + raise WebSocketProtocolException("Invalid ping frame.") + + if self.opcode == ABNF.OPCODE_CLOSE: + l = len(self.data) + if not l: + return + if l == 1 or l >= 126: + raise WebSocketProtocolException("Invalid close frame.") + if l > 2 and not skip_utf8_validation and not validate_utf8(self.data[2:]): + raise WebSocketProtocolException("Invalid close frame.") + + code = 256 * int(self.data[0]) + int(self.data[1]) + if not self._is_valid_close_status(code): + raise WebSocketProtocolException("Invalid close opcode %r", code) + + @staticmethod + def _is_valid_close_status(code: int) -> bool: + return code in VALID_CLOSE_STATUS or (3000 <= code < 5000) + + def __str__(self) -> str: + return f"fin={self.fin} opcode={self.opcode} data={self.data}" + + @staticmethod + def create_frame(data: Union[bytes, str], opcode: int, fin: int = 1) -> "ABNF": + """ + Create frame to send text, binary and other data. + + Parameters + ---------- + data: str + data to send. This is string value(byte array). + If opcode is OPCODE_TEXT and this value is unicode, + data value is converted into unicode string, automatically. + opcode: int + operation code. please see OPCODE_MAP. + fin: int + fin flag. if set to 0, create continue fragmentation. + """ + if opcode == ABNF.OPCODE_TEXT and isinstance(data, str): + data = data.encode("utf-8") + # mask must be set if send data from client + return ABNF(fin, 0, 0, 0, opcode, 1, data) + + def format(self) -> bytes: + """ + Format this object to string(byte array) to send data to server. + """ + if any(x not in (0, 1) for x in [self.fin, self.rsv1, self.rsv2, self.rsv3]): + raise ValueError("not 0 or 1") + if self.opcode not in ABNF.OPCODES: + raise ValueError("Invalid OPCODE") + length = len(self.data) + if length >= ABNF.LENGTH_63: + raise ValueError("data is too long") + + frame_header = chr( + self.fin << 7 + | self.rsv1 << 6 + | self.rsv2 << 5 + | self.rsv3 << 4 + | self.opcode + ).encode("latin-1") + if length < ABNF.LENGTH_7: + frame_header += chr(self.mask_value << 7 | length).encode("latin-1") + elif length < ABNF.LENGTH_16: + frame_header += chr(self.mask_value << 7 | 0x7E).encode("latin-1") + frame_header += struct.pack("!H", length) + else: + frame_header += chr(self.mask_value << 7 | 0x7F).encode("latin-1") + frame_header += struct.pack("!Q", length) + + if not self.mask_value: + if isinstance(self.data, str): + self.data = self.data.encode("utf-8") + return frame_header + self.data + mask_key = self.get_mask_key(4) + return frame_header + self._get_masked(mask_key) + + def _get_masked(self, mask_key: Union[str, bytes]) -> bytes: + s = ABNF.mask(mask_key, self.data) + + if isinstance(mask_key, str): + mask_key = mask_key.encode("utf-8") + + return mask_key + s + + @staticmethod + def mask(mask_key: Union[str, bytes], data: Union[str, bytes]) -> bytes: + """ + Mask or unmask data. Just do xor for each byte + + Parameters + ---------- + mask_key: bytes or str + 4 byte mask. + data: bytes or str + data to mask/unmask. + """ + if data is None: + data = "" + + if isinstance(mask_key, str): + mask_key = mask_key.encode("latin-1") + + if isinstance(data, str): + data = data.encode("latin-1") + + return _mask(array.array("B", mask_key), array.array("B", data)) + + +class frame_buffer: + _HEADER_MASK_INDEX = 5 + _HEADER_LENGTH_INDEX = 6 + + def __init__( + self, recv_fn: Callable[[int], int], skip_utf8_validation: bool + ) -> None: + self.recv = recv_fn + self.skip_utf8_validation = skip_utf8_validation + # Buffers over the packets from the layer beneath until desired amount + # bytes of bytes are received. + self.recv_buffer: list = [] + self.clear() + self.lock = Lock() + + def clear(self) -> None: + self.header: Optional[tuple] = None + self.length: Optional[int] = None + self.mask_value: Union[bytes, str, None] = None + + def has_received_header(self) -> bool: + return self.header is None + + def recv_header(self) -> None: + header = self.recv_strict(2) + b1 = header[0] + fin = b1 >> 7 & 1 + rsv1 = b1 >> 6 & 1 + rsv2 = b1 >> 5 & 1 + rsv3 = b1 >> 4 & 1 + opcode = b1 & 0xF + b2 = header[1] + has_mask = b2 >> 7 & 1 + length_bits = b2 & 0x7F + + self.header = (fin, rsv1, rsv2, rsv3, opcode, has_mask, length_bits) + + def has_mask(self) -> Union[bool, int]: + if not self.header: + return False + header_val: int = self.header[frame_buffer._HEADER_MASK_INDEX] + return header_val + + def has_received_length(self) -> bool: + return self.length is None + + def recv_length(self) -> None: + bits = self.header[frame_buffer._HEADER_LENGTH_INDEX] + length_bits = bits & 0x7F + if length_bits == 0x7E: + v = self.recv_strict(2) + self.length = struct.unpack("!H", v)[0] + elif length_bits == 0x7F: + v = self.recv_strict(8) + self.length = struct.unpack("!Q", v)[0] + else: + self.length = length_bits + + def has_received_mask(self) -> bool: + return self.mask_value is None + + def recv_mask(self) -> None: + self.mask_value = self.recv_strict(4) if self.has_mask() else "" + + def recv_frame(self) -> ABNF: + with self.lock: + # Header + if self.has_received_header(): + self.recv_header() + (fin, rsv1, rsv2, rsv3, opcode, has_mask, _) = self.header + + # Frame length + if self.has_received_length(): + self.recv_length() + length = self.length + + # Mask + if self.has_received_mask(): + self.recv_mask() + mask_value = self.mask_value + + # Payload + payload = self.recv_strict(length) + if has_mask: + payload = ABNF.mask(mask_value, payload) + + # Reset for next frame + self.clear() + + frame = ABNF(fin, rsv1, rsv2, rsv3, opcode, has_mask, payload) + frame.validate(self.skip_utf8_validation) + + return frame + + def recv_strict(self, bufsize: int) -> bytes: + shortage = bufsize - sum(map(len, self.recv_buffer)) + while shortage > 0: + # Limit buffer size that we pass to socket.recv() to avoid + # fragmenting the heap -- the number of bytes recv() actually + # reads is limited by socket buffer and is relatively small, + # yet passing large numbers repeatedly causes lots of large + # buffers allocated and then shrunk, which results in + # fragmentation. + bytes_ = self.recv(min(16384, shortage)) + self.recv_buffer.append(bytes_) + shortage -= len(bytes_) + + unified = b"".join(self.recv_buffer) + + if shortage == 0: + self.recv_buffer = [] + return unified + else: + self.recv_buffer = [unified[bufsize:]] + return unified[:bufsize] + + +class continuous_frame: + def __init__(self, fire_cont_frame: bool, skip_utf8_validation: bool) -> None: + self.fire_cont_frame = fire_cont_frame + self.skip_utf8_validation = skip_utf8_validation + self.cont_data: Optional[list] = None + self.recving_frames: Optional[int] = None + + def validate(self, frame: ABNF) -> None: + if not self.recving_frames and frame.opcode == ABNF.OPCODE_CONT: + raise WebSocketProtocolException("Illegal frame") + if self.recving_frames and frame.opcode in ( + ABNF.OPCODE_TEXT, + ABNF.OPCODE_BINARY, + ): + raise WebSocketProtocolException("Illegal frame") + + def add(self, frame: ABNF) -> None: + if self.cont_data: + self.cont_data[1] += frame.data + else: + if frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY): + self.recving_frames = frame.opcode + self.cont_data = [frame.opcode, frame.data] + + if frame.fin: + self.recving_frames = None + + def is_fire(self, frame: ABNF) -> Union[bool, int]: + return frame.fin or self.fire_cont_frame + + def extract(self, frame: ABNF) -> tuple: + data = self.cont_data + self.cont_data = None + frame.data = data[1] + if ( + not self.fire_cont_frame + and data[0] == ABNF.OPCODE_TEXT + and not self.skip_utf8_validation + and not validate_utf8(frame.data) + ): + raise WebSocketPayloadException(f"cannot decode: {repr(frame.data)}") + return data[0], frame diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_app.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_app.py new file mode 100644 index 0000000..9fee765 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_app.py @@ -0,0 +1,677 @@ +import inspect +import selectors +import socket +import threading +import time +from typing import Any, Callable, Optional, Union + +from . import _logging +from ._abnf import ABNF +from ._core import WebSocket, getdefaulttimeout +from ._exceptions import ( + WebSocketConnectionClosedException, + WebSocketException, + WebSocketTimeoutException, +) +from ._ssl_compat import SSLEOFError +from ._url import parse_url + +""" +_app.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = ["WebSocketApp"] + +RECONNECT = 0 + + +def setReconnect(reconnectInterval: int) -> None: + global RECONNECT + RECONNECT = reconnectInterval + + +class DispatcherBase: + """ + DispatcherBase + """ + + def __init__(self, app: Any, ping_timeout: Union[float, int, None]) -> None: + self.app = app + self.ping_timeout = ping_timeout + + def timeout(self, seconds: Union[float, int, None], callback: Callable) -> None: + time.sleep(seconds) + callback() + + def reconnect(self, seconds: int, reconnector: Callable) -> None: + try: + _logging.info( + f"reconnect() - retrying in {seconds} seconds [{len(inspect.stack())} frames in stack]" + ) + time.sleep(seconds) + reconnector(reconnecting=True) + except KeyboardInterrupt as e: + _logging.info(f"User exited {e}") + raise e + + +class Dispatcher(DispatcherBase): + """ + Dispatcher + """ + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + sel = selectors.DefaultSelector() + sel.register(self.app.sock.sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if sel.select(self.ping_timeout): + if not read_callback(): + break + check_callback() + finally: + sel.close() + + +class SSLDispatcher(DispatcherBase): + """ + SSLDispatcher + """ + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + sock = self.app.sock.sock + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ) + try: + while self.app.keep_running: + if self.select(sock, sel): + if not read_callback(): + break + check_callback() + finally: + sel.close() + + def select(self, sock, sel: selectors.DefaultSelector): + sock = self.app.sock.sock + if sock.pending(): + return [ + sock, + ] + + r = sel.select(self.ping_timeout) + + if len(r) > 0: + return r[0][0] + + +class WrappedDispatcher: + """ + WrappedDispatcher + """ + + def __init__(self, app, ping_timeout: Union[float, int, None], dispatcher) -> None: + self.app = app + self.ping_timeout = ping_timeout + self.dispatcher = dispatcher + dispatcher.signal(2, dispatcher.abort) # keyboard interrupt + + def read( + self, + sock: socket.socket, + read_callback: Callable, + check_callback: Callable, + ) -> None: + self.dispatcher.read(sock, read_callback) + self.ping_timeout and self.timeout(self.ping_timeout, check_callback) + + def timeout(self, seconds: float, callback: Callable) -> None: + self.dispatcher.timeout(seconds, callback) + + def reconnect(self, seconds: int, reconnector: Callable) -> None: + self.timeout(seconds, reconnector) + + +class WebSocketApp: + """ + Higher level of APIs are provided. The interface is like JavaScript WebSocket object. + """ + + def __init__( + self, + url: str, + header: Union[list, dict, Callable, None] = None, + on_open: Optional[Callable[[WebSocket], None]] = None, + on_reconnect: Optional[Callable[[WebSocket], None]] = None, + on_message: Optional[Callable[[WebSocket, Any], None]] = None, + on_error: Optional[Callable[[WebSocket, Any], None]] = None, + on_close: Optional[Callable[[WebSocket, Any, Any], None]] = None, + on_ping: Optional[Callable] = None, + on_pong: Optional[Callable] = None, + on_cont_message: Optional[Callable] = None, + keep_running: bool = True, + get_mask_key: Optional[Callable] = None, + cookie: Optional[str] = None, + subprotocols: Optional[list] = None, + on_data: Optional[Callable] = None, + socket: Optional[socket.socket] = None, + ) -> None: + """ + WebSocketApp initialization + + Parameters + ---------- + url: str + Websocket url. + header: list or dict or Callable + Custom header for websocket handshake. + If the parameter is a callable object, it is called just before the connection attempt. + The returned dict or list is used as custom header value. + This could be useful in order to properly setup timestamp dependent headers. + on_open: function + Callback object which is called at opening websocket. + on_open has one argument. + The 1st argument is this class object. + on_reconnect: function + Callback object which is called at reconnecting websocket. + on_reconnect has one argument. + The 1st argument is this class object. + on_message: function + Callback object which is called when received data. + on_message has 2 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 data received from the server. + on_error: function + Callback object which is called when we get error. + on_error has 2 arguments. + The 1st argument is this class object. + The 2nd argument is exception object. + on_close: function + Callback object which is called when connection is closed. + on_close has 3 arguments. + The 1st argument is this class object. + The 2nd argument is close_status_code. + The 3rd argument is close_msg. + on_cont_message: function + Callback object which is called when a continuation + frame is received. + on_cont_message has 3 arguments. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is continue flag. if 0, the data continue + to next frame data + on_data: function + Callback object which is called when a message received. + This is called before on_message or on_cont_message, + and then on_message or on_cont_message is called. + on_data has 4 argument. + The 1st argument is this class object. + The 2nd argument is utf-8 string which we get from the server. + The 3rd argument is data type. ABNF.OPCODE_TEXT or ABNF.OPCODE_BINARY will be came. + The 4th argument is continue flag. If 0, the data continue + keep_running: bool + This parameter is obsolete and ignored. + get_mask_key: function + A callable function to get new mask keys, see the + WebSocket.set_mask_key's docstring for more information. + cookie: str + Cookie value. + subprotocols: list + List of available sub protocols. Default is None. + socket: socket + Pre-initialized stream socket. + """ + self.url = url + self.header = header if header is not None else [] + self.cookie = cookie + + self.on_open = on_open + self.on_reconnect = on_reconnect + self.on_message = on_message + self.on_data = on_data + self.on_error = on_error + self.on_close = on_close + self.on_ping = on_ping + self.on_pong = on_pong + self.on_cont_message = on_cont_message + self.keep_running = False + self.get_mask_key = get_mask_key + self.sock: Optional[WebSocket] = None + self.last_ping_tm = float(0) + self.last_pong_tm = float(0) + self.ping_thread: Optional[threading.Thread] = None + self.stop_ping: Optional[threading.Event] = None + self.ping_interval = float(0) + self.ping_timeout: Union[float, int, None] = None + self.ping_payload = "" + self.subprotocols = subprotocols + self.prepared_socket = socket + self.has_errored = False + self.has_done_teardown = False + self.has_done_teardown_lock = threading.Lock() + + def send(self, data: Union[bytes, str], opcode: int = ABNF.OPCODE_TEXT) -> None: + """ + send message + + Parameters + ---------- + data: str + Message to send. If you set opcode to OPCODE_TEXT, + data must be utf-8 string or unicode. + opcode: int + Operation code of data. Default is OPCODE_TEXT. + """ + + if not self.sock or self.sock.send(data, opcode) == 0: + raise WebSocketConnectionClosedException("Connection is already closed.") + + def send_text(self, text_data: str) -> None: + """ + Sends UTF-8 encoded text. + """ + if not self.sock or self.sock.send(text_data, ABNF.OPCODE_TEXT) == 0: + raise WebSocketConnectionClosedException("Connection is already closed.") + + def send_bytes(self, data: Union[bytes, bytearray]) -> None: + """ + Sends a sequence of bytes. + """ + if not self.sock or self.sock.send(data, ABNF.OPCODE_BINARY) == 0: + raise WebSocketConnectionClosedException("Connection is already closed.") + + def close(self, **kwargs) -> None: + """ + Close websocket connection. + """ + self.keep_running = False + if self.sock: + self.sock.close(**kwargs) + self.sock = None + + def _start_ping_thread(self) -> None: + self.last_ping_tm = self.last_pong_tm = float(0) + self.stop_ping = threading.Event() + self.ping_thread = threading.Thread(target=self._send_ping) + self.ping_thread.daemon = True + self.ping_thread.start() + + def _stop_ping_thread(self) -> None: + if self.stop_ping: + self.stop_ping.set() + if self.ping_thread and self.ping_thread.is_alive(): + self.ping_thread.join(3) + self.last_ping_tm = self.last_pong_tm = float(0) + + def _send_ping(self) -> None: + if self.stop_ping.wait(self.ping_interval) or self.keep_running is False: + return + while not self.stop_ping.wait(self.ping_interval) and self.keep_running is True: + if self.sock: + self.last_ping_tm = time.time() + try: + _logging.debug("Sending ping") + self.sock.ping(self.ping_payload) + except Exception as e: + _logging.debug(f"Failed to send ping: {e}") + + def run_forever( + self, + sockopt: tuple = None, + sslopt: dict = None, + ping_interval: Union[float, int] = 0, + ping_timeout: Union[float, int, None] = None, + ping_payload: str = "", + http_proxy_host: str = None, + http_proxy_port: Union[int, str] = None, + http_no_proxy: list = None, + http_proxy_auth: tuple = None, + http_proxy_timeout: Optional[float] = None, + skip_utf8_validation: bool = False, + host: str = None, + origin: str = None, + dispatcher=None, + suppress_origin: bool = False, + proxy_type: str = None, + reconnect: int = None, + ) -> bool: + """ + Run event loop for WebSocket framework. + + This loop is an infinite loop and is alive while websocket is available. + + Parameters + ---------- + sockopt: tuple + Values for socket.setsockopt. + sockopt must be tuple + and each element is argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket option. + ping_interval: int or float + Automatically send "ping" command + every specified period (in seconds). + If set to 0, no ping is sent periodically. + ping_timeout: int or float + Timeout (in seconds) if the pong message is not received. + ping_payload: str + Payload message to send with each ping. + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: int or str + HTTP proxy port. If not set, set to 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + http_proxy_auth: tuple + HTTP proxy auth information. tuple of username and password. Default is None. + skip_utf8_validation: bool + skip utf8 validation. + host: str + update host header. + origin: str + update origin header. + dispatcher: Dispatcher object + customize reading data from socket. + suppress_origin: bool + suppress outputting origin header. + proxy_type: str + type of proxy from: http, socks4, socks4a, socks5, socks5h + reconnect: int + delay interval when reconnecting + + Returns + ------- + teardown: bool + False if the `WebSocketApp` is closed or caught KeyboardInterrupt, + True if any other exception was raised during a loop. + """ + + if reconnect is None: + reconnect = RECONNECT + + if ping_timeout is not None and ping_timeout <= 0: + raise WebSocketException("Ensure ping_timeout > 0") + if ping_interval is not None and ping_interval < 0: + raise WebSocketException("Ensure ping_interval >= 0") + if ping_timeout and ping_interval and ping_interval <= ping_timeout: + raise WebSocketException("Ensure ping_interval > ping_timeout") + if not sockopt: + sockopt = () + if not sslopt: + sslopt = {} + if self.sock: + raise WebSocketException("socket is already opened") + + self.ping_interval = ping_interval + self.ping_timeout = ping_timeout + self.ping_payload = ping_payload + self.has_done_teardown = False + self.keep_running = True + + def teardown(close_frame: ABNF = None): + """ + Tears down the connection. + + Parameters + ---------- + close_frame: ABNF frame + If close_frame is set, the on_close handler is invoked + with the statusCode and reason from the provided frame. + """ + + # teardown() is called in many code paths to ensure resources are cleaned up and on_close is fired. + # To ensure the work is only done once, we use this bool and lock. + with self.has_done_teardown_lock: + if self.has_done_teardown: + return + self.has_done_teardown = True + + self._stop_ping_thread() + self.keep_running = False + if self.sock: + self.sock.close() + close_status_code, close_reason = self._get_close_args( + close_frame if close_frame else None + ) + self.sock = None + + # Finally call the callback AFTER all teardown is complete + self._callback(self.on_close, close_status_code, close_reason) + + def setSock(reconnecting: bool = False) -> None: + if reconnecting and self.sock: + self.sock.shutdown() + + self.sock = WebSocket( + self.get_mask_key, + sockopt=sockopt, + sslopt=sslopt, + fire_cont_frame=self.on_cont_message is not None, + skip_utf8_validation=skip_utf8_validation, + enable_multithread=True, + ) + + self.sock.settimeout(getdefaulttimeout()) + try: + header = self.header() if callable(self.header) else self.header + + self.sock.connect( + self.url, + header=header, + cookie=self.cookie, + http_proxy_host=http_proxy_host, + http_proxy_port=http_proxy_port, + http_no_proxy=http_no_proxy, + http_proxy_auth=http_proxy_auth, + http_proxy_timeout=http_proxy_timeout, + subprotocols=self.subprotocols, + host=host, + origin=origin, + suppress_origin=suppress_origin, + proxy_type=proxy_type, + socket=self.prepared_socket, + ) + + _logging.info("Websocket connected") + + if self.ping_interval: + self._start_ping_thread() + + if reconnecting and self.on_reconnect: + self._callback(self.on_reconnect) + else: + self._callback(self.on_open) + + dispatcher.read(self.sock.sock, read, check) + except ( + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + Exception, + ) as e: + handleDisconnect(e, reconnecting) + + def read() -> bool: + if not self.keep_running: + return teardown() + + try: + op_code, frame = self.sock.recv_data_frame(True) + except ( + WebSocketConnectionClosedException, + KeyboardInterrupt, + SSLEOFError, + ) as e: + if custom_dispatcher: + return handleDisconnect(e, bool(reconnect)) + else: + raise e + + if op_code == ABNF.OPCODE_CLOSE: + return teardown(frame) + elif op_code == ABNF.OPCODE_PING: + self._callback(self.on_ping, frame.data) + elif op_code == ABNF.OPCODE_PONG: + self.last_pong_tm = time.time() + self._callback(self.on_pong, frame.data) + elif op_code == ABNF.OPCODE_CONT and self.on_cont_message: + self._callback(self.on_data, frame.data, frame.opcode, frame.fin) + self._callback(self.on_cont_message, frame.data, frame.fin) + else: + data = frame.data + if op_code == ABNF.OPCODE_TEXT and not skip_utf8_validation: + data = data.decode("utf-8") + self._callback(self.on_data, data, frame.opcode, True) + self._callback(self.on_message, data) + + return True + + def check() -> bool: + if self.ping_timeout: + has_timeout_expired = ( + time.time() - self.last_ping_tm > self.ping_timeout + ) + has_pong_not_arrived_after_last_ping = ( + self.last_pong_tm - self.last_ping_tm < 0 + ) + has_pong_arrived_too_late = ( + self.last_pong_tm - self.last_ping_tm > self.ping_timeout + ) + + if ( + self.last_ping_tm + and has_timeout_expired + and ( + has_pong_not_arrived_after_last_ping + or has_pong_arrived_too_late + ) + ): + raise WebSocketTimeoutException("ping/pong timed out") + return True + + def handleDisconnect( + e: Union[ + WebSocketConnectionClosedException, + ConnectionRefusedError, + KeyboardInterrupt, + SystemExit, + Exception, + ], + reconnecting: bool = False, + ) -> bool: + self.has_errored = True + self._stop_ping_thread() + if not reconnecting: + self._callback(self.on_error, e) + + if isinstance(e, (KeyboardInterrupt, SystemExit)): + teardown() + # Propagate further + raise + + if reconnect: + _logging.info(f"{e} - reconnect") + if custom_dispatcher: + _logging.debug( + f"Calling custom dispatcher reconnect [{len(inspect.stack())} frames in stack]" + ) + dispatcher.reconnect(reconnect, setSock) + else: + _logging.error(f"{e} - goodbye") + teardown() + + custom_dispatcher = bool(dispatcher) + dispatcher = self.create_dispatcher( + ping_timeout, dispatcher, parse_url(self.url)[3] + ) + + try: + setSock() + if not custom_dispatcher and reconnect: + while self.keep_running: + _logging.debug( + f"Calling dispatcher reconnect [{len(inspect.stack())} frames in stack]" + ) + dispatcher.reconnect(reconnect, setSock) + except (KeyboardInterrupt, Exception) as e: + _logging.info(f"tearing down on exception {e}") + teardown() + finally: + if not custom_dispatcher: + # Ensure teardown was called before returning from run_forever + teardown() + + return self.has_errored + + def create_dispatcher( + self, + ping_timeout: Union[float, int, None], + dispatcher: Optional[DispatcherBase] = None, + is_ssl: bool = False, + ) -> Union[Dispatcher, SSLDispatcher, WrappedDispatcher]: + if dispatcher: # If custom dispatcher is set, use WrappedDispatcher + return WrappedDispatcher(self, ping_timeout, dispatcher) + timeout = ping_timeout or 10 + if is_ssl: + return SSLDispatcher(self, timeout) + return Dispatcher(self, timeout) + + def _get_close_args(self, close_frame: ABNF) -> list: + """ + _get_close_args extracts the close code and reason from the close body + if it exists (RFC6455 says WebSocket Connection Close Code is optional) + """ + # Need to catch the case where close_frame is None + # Otherwise the following if statement causes an error + if not self.on_close or not close_frame: + return [None, None] + + # Extract close frame status code + if close_frame.data and len(close_frame.data) >= 2: + close_status_code = 256 * int(close_frame.data[0]) + int( + close_frame.data[1] + ) + reason = close_frame.data[2:] + if isinstance(reason, bytes): + reason = reason.decode("utf-8") + return [close_status_code, reason] + else: + # Most likely reached this because len(close_frame_data.data) < 2 + return [None, None] + + def _callback(self, callback, *args) -> None: + if callback: + try: + callback(self, *args) + + except Exception as e: + _logging.error(f"error from callback {callback}: {e}") + if self.on_error: + self.on_error(self, e) diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_cookiejar.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_cookiejar.py new file mode 100644 index 0000000..7480e5f --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_cookiejar.py @@ -0,0 +1,75 @@ +import http.cookies +from typing import Optional + +""" +_cookiejar.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +class SimpleCookieJar: + def __init__(self) -> None: + self.jar: dict = {} + + def add(self, set_cookie: Optional[str]) -> None: + if set_cookie: + simple_cookie = http.cookies.SimpleCookie(set_cookie) + + for v in simple_cookie.values(): + if domain := v.get("domain"): + if not domain.startswith("."): + domain = f".{domain}" + cookie = ( + self.jar.get(domain) + if self.jar.get(domain) + else http.cookies.SimpleCookie() + ) + cookie.update(simple_cookie) + self.jar[domain.lower()] = cookie + + def set(self, set_cookie: str) -> None: + if set_cookie: + simple_cookie = http.cookies.SimpleCookie(set_cookie) + + for v in simple_cookie.values(): + if domain := v.get("domain"): + if not domain.startswith("."): + domain = f".{domain}" + self.jar[domain.lower()] = simple_cookie + + def get(self, host: str) -> str: + if not host: + return "" + + cookies = [] + for domain, _ in self.jar.items(): + host = host.lower() + if host.endswith(domain) or host == domain[1:]: + cookies.append(self.jar.get(domain)) + + return "; ".join( + filter( + None, + sorted( + [ + f"{k}={v.value}" + for cookie in filter(None, cookies) + for k, v in cookie.items() + ] + ), + ) + ) diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_core.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_core.py new file mode 100644 index 0000000..f940ed0 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_core.py @@ -0,0 +1,647 @@ +import socket +import struct +import threading +import time +from typing import Optional, Union + +# websocket modules +from ._abnf import ABNF, STATUS_NORMAL, continuous_frame, frame_buffer +from ._exceptions import WebSocketProtocolException, WebSocketConnectionClosedException +from ._handshake import SUPPORTED_REDIRECT_STATUSES, handshake +from ._http import connect, proxy_info +from ._logging import debug, error, trace, isEnabledForError, isEnabledForTrace +from ._socket import getdefaulttimeout, recv, send, sock_opt +from ._ssl_compat import ssl +from ._utils import NoLock + +""" +_core.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = ["WebSocket", "create_connection"] + + +class WebSocket: + """ + Low level WebSocket interface. + + This class is based on the WebSocket protocol `draft-hixie-thewebsocketprotocol-76 `_ + + We can connect to the websocket server and send/receive data. + The following example is an echo client. + + >>> import websocket + >>> ws = websocket.WebSocket() + >>> ws.connect("ws://echo.websocket.events") + >>> ws.recv() + 'echo.websocket.events sponsored by Lob.com' + >>> ws.send("Hello, Server") + 19 + >>> ws.recv() + 'Hello, Server' + >>> ws.close() + + Parameters + ---------- + get_mask_key: func + A callable function to get new mask keys, see the + WebSocket.set_mask_key's docstring for more information. + sockopt: tuple + Values for socket.setsockopt. + sockopt must be tuple and each element is argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + fire_cont_frame: bool + Fire recv event for each cont frame. Default is False. + enable_multithread: bool + If set to True, lock send method. + skip_utf8_validation: bool + Skip utf8 validation. + """ + + def __init__( + self, + get_mask_key=None, + sockopt=None, + sslopt=None, + fire_cont_frame: bool = False, + enable_multithread: bool = True, + skip_utf8_validation: bool = False, + **_, + ): + """ + Initialize WebSocket object. + + Parameters + ---------- + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + """ + self.sock_opt = sock_opt(sockopt, sslopt) + self.handshake_response = None + self.sock: Optional[socket.socket] = None + + self.connected = False + self.get_mask_key = get_mask_key + # These buffer over the build-up of a single frame. + self.frame_buffer = frame_buffer(self._recv, skip_utf8_validation) + self.cont_frame = continuous_frame(fire_cont_frame, skip_utf8_validation) + + if enable_multithread: + self.lock = threading.Lock() + self.readlock = threading.Lock() + else: + self.lock = NoLock() + self.readlock = NoLock() + + def __iter__(self): + """ + Allow iteration over websocket, implying sequential `recv` executions. + """ + while True: + yield self.recv() + + def __next__(self): + return self.recv() + + def next(self): + return self.__next__() + + def fileno(self): + return self.sock.fileno() + + def set_mask_key(self, func): + """ + Set function to create mask key. You can customize mask key generator. + Mainly, this is for testing purpose. + + Parameters + ---------- + func: func + callable object. the func takes 1 argument as integer. + The argument means length of mask key. + This func must return string(byte array), + which length is argument specified. + """ + self.get_mask_key = func + + def gettimeout(self) -> Union[float, int, None]: + """ + Get the websocket timeout (in seconds) as an int or float + + Returns + ---------- + timeout: int or float + returns timeout value (in seconds). This value could be either float/integer. + """ + return self.sock_opt.timeout + + def settimeout(self, timeout: Union[float, int, None]): + """ + Set the timeout to the websocket. + + Parameters + ---------- + timeout: int or float + timeout time (in seconds). This value could be either float/integer. + """ + self.sock_opt.timeout = timeout + if self.sock: + self.sock.settimeout(timeout) + + timeout = property(gettimeout, settimeout) + + def getsubprotocol(self): + """ + Get subprotocol + """ + if self.handshake_response: + return self.handshake_response.subprotocol + else: + return None + + subprotocol = property(getsubprotocol) + + def getstatus(self): + """ + Get handshake status + """ + if self.handshake_response: + return self.handshake_response.status + else: + return None + + status = property(getstatus) + + def getheaders(self): + """ + Get handshake response header + """ + if self.handshake_response: + return self.handshake_response.headers + else: + return None + + def is_ssl(self): + try: + return isinstance(self.sock, ssl.SSLSocket) + except: + return False + + headers = property(getheaders) + + def connect(self, url, **options): + """ + Connect to url. url is websocket url scheme. + ie. ws://host:port/resource + You can customize using 'options'. + If you set "header" list object, you can set your own custom header. + + >>> ws = WebSocket() + >>> ws.connect("ws://echo.websocket.events", + ... header=["User-Agent: MyProgram", + ... "x-custom: header"]) + + Parameters + ---------- + header: list or dict + Custom http header list or dict. + cookie: str + Cookie value. + origin: str + Custom origin url. + connection: str + Custom connection header value. + Default value "Upgrade" set in _handshake.py + suppress_origin: bool + Suppress outputting origin header. + host: str + Custom host header string. + timeout: int or float + Socket timeout time. This value is an integer or float. + If you set None for this value, it means "use default_timeout value" + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: str or int + HTTP proxy port. Default is 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_auth: tuple + HTTP proxy auth information. Tuple of username and password. Default is None. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + redirect_limit: int + Number of redirects to follow. + subprotocols: list + List of available subprotocols. Default is None. + socket: socket + Pre-initialized stream socket. + """ + self.sock_opt.timeout = options.get("timeout", self.sock_opt.timeout) + self.sock, addrs = connect( + url, self.sock_opt, proxy_info(**options), options.pop("socket", None) + ) + + try: + self.handshake_response = handshake(self.sock, url, *addrs, **options) + for _ in range(options.pop("redirect_limit", 3)): + if self.handshake_response.status in SUPPORTED_REDIRECT_STATUSES: + url = self.handshake_response.headers["location"] + self.sock.close() + self.sock, addrs = connect( + url, + self.sock_opt, + proxy_info(**options), + options.pop("socket", None), + ) + self.handshake_response = handshake( + self.sock, url, *addrs, **options + ) + self.connected = True + except: + if self.sock: + self.sock.close() + self.sock = None + raise + + def send(self, payload: Union[bytes, str], opcode: int = ABNF.OPCODE_TEXT) -> int: + """ + Send the data as string. + + Parameters + ---------- + payload: str + Payload must be utf-8 string or unicode, + If the opcode is OPCODE_TEXT. + Otherwise, it must be string(byte array). + opcode: int + Operation code (opcode) to send. + """ + + frame = ABNF.create_frame(payload, opcode) + return self.send_frame(frame) + + def send_text(self, text_data: str) -> int: + """ + Sends UTF-8 encoded text. + """ + return self.send(text_data, ABNF.OPCODE_TEXT) + + def send_bytes(self, data: Union[bytes, bytearray]) -> int: + """ + Sends a sequence of bytes. + """ + return self.send(data, ABNF.OPCODE_BINARY) + + def send_frame(self, frame) -> int: + """ + Send the data frame. + + >>> ws = create_connection("ws://echo.websocket.events") + >>> frame = ABNF.create_frame("Hello", ABNF.OPCODE_TEXT) + >>> ws.send_frame(frame) + >>> cont_frame = ABNF.create_frame("My name is ", ABNF.OPCODE_CONT, 0) + >>> ws.send_frame(frame) + >>> cont_frame = ABNF.create_frame("Foo Bar", ABNF.OPCODE_CONT, 1) + >>> ws.send_frame(frame) + + Parameters + ---------- + frame: ABNF frame + frame data created by ABNF.create_frame + """ + if self.get_mask_key: + frame.get_mask_key = self.get_mask_key + data = frame.format() + length = len(data) + if isEnabledForTrace(): + trace(f"++Sent raw: {repr(data)}") + trace(f"++Sent decoded: {frame.__str__()}") + with self.lock: + while data: + l = self._send(data) + data = data[l:] + + return length + + def send_binary(self, payload: bytes) -> int: + """ + Send a binary message (OPCODE_BINARY). + + Parameters + ---------- + payload: bytes + payload of message to send. + """ + return self.send(payload, ABNF.OPCODE_BINARY) + + def ping(self, payload: Union[str, bytes] = ""): + """ + Send ping data. + + Parameters + ---------- + payload: str + data payload to send server. + """ + if isinstance(payload, str): + payload = payload.encode("utf-8") + self.send(payload, ABNF.OPCODE_PING) + + def pong(self, payload: Union[str, bytes] = ""): + """ + Send pong data. + + Parameters + ---------- + payload: str + data payload to send server. + """ + if isinstance(payload, str): + payload = payload.encode("utf-8") + self.send(payload, ABNF.OPCODE_PONG) + + def recv(self) -> Union[str, bytes]: + """ + Receive string data(byte array) from the server. + + Returns + ---------- + data: string (byte array) value. + """ + with self.readlock: + opcode, data = self.recv_data() + if opcode == ABNF.OPCODE_TEXT: + data_received: Union[bytes, str] = data + if isinstance(data_received, bytes): + return data_received.decode("utf-8") + elif isinstance(data_received, str): + return data_received + elif opcode == ABNF.OPCODE_BINARY: + data_binary: bytes = data + return data_binary + else: + return "" + + def recv_data(self, control_frame: bool = False) -> tuple: + """ + Receive data with operation code. + + Parameters + ---------- + control_frame: bool + a boolean flag indicating whether to return control frame + data, defaults to False + + Returns + ------- + opcode, frame.data: tuple + tuple of operation code and string(byte array) value. + """ + opcode, frame = self.recv_data_frame(control_frame) + return opcode, frame.data + + def recv_data_frame(self, control_frame: bool = False) -> tuple: + """ + Receive data with operation code. + + If a valid ping message is received, a pong response is sent. + + Parameters + ---------- + control_frame: bool + a boolean flag indicating whether to return control frame + data, defaults to False + + Returns + ------- + frame.opcode, frame: tuple + tuple of operation code and string(byte array) value. + """ + while True: + frame = self.recv_frame() + if isEnabledForTrace(): + trace(f"++Rcv raw: {repr(frame.format())}") + trace(f"++Rcv decoded: {frame.__str__()}") + if not frame: + # handle error: + # 'NoneType' object has no attribute 'opcode' + raise WebSocketProtocolException(f"Not a valid frame {frame}") + elif frame.opcode in ( + ABNF.OPCODE_TEXT, + ABNF.OPCODE_BINARY, + ABNF.OPCODE_CONT, + ): + self.cont_frame.validate(frame) + self.cont_frame.add(frame) + + if self.cont_frame.is_fire(frame): + return self.cont_frame.extract(frame) + + elif frame.opcode == ABNF.OPCODE_CLOSE: + self.send_close() + return frame.opcode, frame + elif frame.opcode == ABNF.OPCODE_PING: + if len(frame.data) < 126: + self.pong(frame.data) + else: + raise WebSocketProtocolException("Ping message is too long") + if control_frame: + return frame.opcode, frame + elif frame.opcode == ABNF.OPCODE_PONG: + if control_frame: + return frame.opcode, frame + + def recv_frame(self): + """ + Receive data as frame from server. + + Returns + ------- + self.frame_buffer.recv_frame(): ABNF frame object + """ + return self.frame_buffer.recv_frame() + + def send_close(self, status: int = STATUS_NORMAL, reason: bytes = b""): + """ + Send close data to the server. + + Parameters + ---------- + status: int + Status code to send. See STATUS_XXX. + reason: str or bytes + The reason to close. This must be string or UTF-8 bytes. + """ + if status < 0 or status >= ABNF.LENGTH_16: + raise ValueError("code is invalid range") + self.connected = False + self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) + + def close(self, status: int = STATUS_NORMAL, reason: bytes = b"", timeout: int = 3): + """ + Close Websocket object + + Parameters + ---------- + status: int + Status code to send. See VALID_CLOSE_STATUS in ABNF. + reason: bytes + The reason to close in UTF-8. + timeout: int or float + Timeout until receive a close frame. + If None, it will wait forever until receive a close frame. + """ + if not self.connected: + return + if status < 0 or status >= ABNF.LENGTH_16: + raise ValueError("code is invalid range") + + try: + self.connected = False + self.send(struct.pack("!H", status) + reason, ABNF.OPCODE_CLOSE) + sock_timeout = self.sock.gettimeout() + self.sock.settimeout(timeout) + start_time = time.time() + while timeout is None or time.time() - start_time < timeout: + try: + frame = self.recv_frame() + if frame.opcode != ABNF.OPCODE_CLOSE: + continue + if isEnabledForError(): + recv_status = struct.unpack("!H", frame.data[0:2])[0] + if recv_status >= 3000 and recv_status <= 4999: + debug(f"close status: {repr(recv_status)}") + elif recv_status != STATUS_NORMAL: + error(f"close status: {repr(recv_status)}") + break + except: + break + self.sock.settimeout(sock_timeout) + self.sock.shutdown(socket.SHUT_RDWR) + except: + pass + + self.shutdown() + + def abort(self): + """ + Low-level asynchronous abort, wakes up other threads that are waiting in recv_* + """ + if self.connected: + self.sock.shutdown(socket.SHUT_RDWR) + + def shutdown(self): + """ + close socket, immediately. + """ + if self.sock: + self.sock.close() + self.sock = None + self.connected = False + + def _send(self, data: Union[str, bytes]): + return send(self.sock, data) + + def _recv(self, bufsize): + try: + return recv(self.sock, bufsize) + except WebSocketConnectionClosedException: + if self.sock: + self.sock.close() + self.sock = None + self.connected = False + raise + + +def create_connection(url: str, timeout=None, class_=WebSocket, **options): + """ + Connect to url and return websocket object. + + Connect to url and return the WebSocket object. + Passing optional timeout parameter will set the timeout on the socket. + If no timeout is supplied, + the global default timeout setting returned by getdefaulttimeout() is used. + You can customize using 'options'. + If you set "header" list object, you can set your own custom header. + + >>> conn = create_connection("ws://echo.websocket.events", + ... header=["User-Agent: MyProgram", + ... "x-custom: header"]) + + Parameters + ---------- + class_: class + class to instantiate when creating the connection. It has to implement + settimeout and connect. It's __init__ should be compatible with + WebSocket.__init__, i.e. accept all of it's kwargs. + header: list or dict + custom http header list or dict. + cookie: str + Cookie value. + origin: str + custom origin url. + suppress_origin: bool + suppress outputting origin header. + host: str + custom host header string. + timeout: int or float + socket timeout time. This value could be either float/integer. + If set to None, it uses the default_timeout value. + http_proxy_host: str + HTTP proxy host name. + http_proxy_port: str or int + HTTP proxy port. If not set, set to 80. + http_no_proxy: list + Whitelisted host names that don't use the proxy. + http_proxy_auth: tuple + HTTP proxy auth information. tuple of username and password. Default is None. + http_proxy_timeout: int or float + HTTP proxy timeout, default is 60 sec as per python-socks. + enable_multithread: bool + Enable lock for multithread. + redirect_limit: int + Number of redirects to follow. + sockopt: tuple + Values for socket.setsockopt. + sockopt must be a tuple and each element is an argument of sock.setsockopt. + sslopt: dict + Optional dict object for ssl socket options. See FAQ for details. + subprotocols: list + List of available subprotocols. Default is None. + skip_utf8_validation: bool + Skip utf8 validation. + socket: socket + Pre-initialized stream socket. + """ + sockopt = options.pop("sockopt", []) + sslopt = options.pop("sslopt", {}) + fire_cont_frame = options.pop("fire_cont_frame", False) + enable_multithread = options.pop("enable_multithread", True) + skip_utf8_validation = options.pop("skip_utf8_validation", False) + websock = class_( + sockopt=sockopt, + sslopt=sslopt, + fire_cont_frame=fire_cont_frame, + enable_multithread=enable_multithread, + skip_utf8_validation=skip_utf8_validation, + **options, + ) + websock.settimeout(timeout if timeout is not None else getdefaulttimeout()) + websock.connect(url, **options) + return websock diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_exceptions.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_exceptions.py new file mode 100644 index 0000000..cd196e4 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_exceptions.py @@ -0,0 +1,94 @@ +""" +_exceptions.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + + +class WebSocketException(Exception): + """ + WebSocket exception class. + """ + + pass + + +class WebSocketProtocolException(WebSocketException): + """ + If the WebSocket protocol is invalid, this exception will be raised. + """ + + pass + + +class WebSocketPayloadException(WebSocketException): + """ + If the WebSocket payload is invalid, this exception will be raised. + """ + + pass + + +class WebSocketConnectionClosedException(WebSocketException): + """ + If remote host closed the connection or some network error happened, + this exception will be raised. + """ + + pass + + +class WebSocketTimeoutException(WebSocketException): + """ + WebSocketTimeoutException will be raised at socket timeout during read/write data. + """ + + pass + + +class WebSocketProxyException(WebSocketException): + """ + WebSocketProxyException will be raised when proxy error occurred. + """ + + pass + + +class WebSocketBadStatusException(WebSocketException): + """ + WebSocketBadStatusException will be raised when we get bad handshake status code. + """ + + def __init__( + self, + message: str, + status_code: int, + status_message=None, + resp_headers=None, + resp_body=None, + ): + super().__init__(message) + self.status_code = status_code + self.resp_headers = resp_headers + self.resp_body = resp_body + + +class WebSocketAddressException(WebSocketException): + """ + If the websocket address info cannot be found, this exception will be raised. + """ + + pass diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_handshake.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_handshake.py new file mode 100644 index 0000000..8e3cd4d --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_handshake.py @@ -0,0 +1,203 @@ +""" +_handshake.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import hashlib +import hmac +import os +from base64 import encodebytes as base64encode +from http import HTTPStatus + +from ._cookiejar import SimpleCookieJar +from ._exceptions import WebSocketException, WebSocketBadStatusException +from ._http import read_headers +from ._logging import dump, error +from ._socket import send + +__all__ = ["handshake_response", "handshake", "SUPPORTED_REDIRECT_STATUSES"] + +# websocket supported version. +VERSION = 13 + +SUPPORTED_REDIRECT_STATUSES = ( + HTTPStatus.MOVED_PERMANENTLY, + HTTPStatus.FOUND, + HTTPStatus.SEE_OTHER, + HTTPStatus.TEMPORARY_REDIRECT, + HTTPStatus.PERMANENT_REDIRECT, +) +SUCCESS_STATUSES = SUPPORTED_REDIRECT_STATUSES + (HTTPStatus.SWITCHING_PROTOCOLS,) + +CookieJar = SimpleCookieJar() + + +class handshake_response: + def __init__(self, status: int, headers: dict, subprotocol): + self.status = status + self.headers = headers + self.subprotocol = subprotocol + CookieJar.add(headers.get("set-cookie")) + + +def handshake( + sock, url: str, hostname: str, port: int, resource: str, **options +) -> handshake_response: + headers, key = _get_handshake_headers(resource, url, hostname, port, options) + + header_str = "\r\n".join(headers) + send(sock, header_str) + dump("request header", header_str) + + status, resp = _get_resp_headers(sock) + if status in SUPPORTED_REDIRECT_STATUSES: + return handshake_response(status, resp, None) + success, subproto = _validate(resp, key, options.get("subprotocols")) + if not success: + raise WebSocketException("Invalid WebSocket Header") + + return handshake_response(status, resp, subproto) + + +def _pack_hostname(hostname: str) -> str: + # IPv6 address + if ":" in hostname: + return f"[{hostname}]" + return hostname + + +def _get_handshake_headers( + resource: str, url: str, host: str, port: int, options: dict +) -> tuple: + headers = [f"GET {resource} HTTP/1.1", "Upgrade: websocket"] + if port in [80, 443]: + hostport = _pack_hostname(host) + else: + hostport = f"{_pack_hostname(host)}:{port}" + if options.get("host"): + headers.append(f'Host: {options["host"]}') + else: + headers.append(f"Host: {hostport}") + + # scheme indicates whether http or https is used in Origin + # The same approach is used in parse_url of _url.py to set default port + scheme, url = url.split(":", 1) + if not options.get("suppress_origin"): + if "origin" in options and options["origin"] is not None: + headers.append(f'Origin: {options["origin"]}') + elif scheme == "wss": + headers.append(f"Origin: https://{hostport}") + else: + headers.append(f"Origin: http://{hostport}") + + key = _create_sec_websocket_key() + + # Append Sec-WebSocket-Key & Sec-WebSocket-Version if not manually specified + if not options.get("header") or "Sec-WebSocket-Key" not in options["header"]: + headers.append(f"Sec-WebSocket-Key: {key}") + else: + key = options["header"]["Sec-WebSocket-Key"] + + if not options.get("header") or "Sec-WebSocket-Version" not in options["header"]: + headers.append(f"Sec-WebSocket-Version: {VERSION}") + + if not options.get("connection"): + headers.append("Connection: Upgrade") + else: + headers.append(options["connection"]) + + if subprotocols := options.get("subprotocols"): + headers.append(f'Sec-WebSocket-Protocol: {",".join(subprotocols)}') + + if header := options.get("header"): + if isinstance(header, dict): + header = [": ".join([k, v]) for k, v in header.items() if v is not None] + headers.extend(header) + + server_cookie = CookieJar.get(host) + client_cookie = options.get("cookie", None) + + if cookie := "; ".join(filter(None, [server_cookie, client_cookie])): + headers.append(f"Cookie: {cookie}") + + headers.extend(("", "")) + return headers, key + + +def _get_resp_headers(sock, success_statuses: tuple = SUCCESS_STATUSES) -> tuple: + status, resp_headers, status_message = read_headers(sock) + if status not in success_statuses: + content_len = resp_headers.get("content-length") + if content_len: + response_body = sock.recv( + int(content_len) + ) # read the body of the HTTP error message response and include it in the exception + else: + response_body = None + raise WebSocketBadStatusException( + f"Handshake status {status} {status_message} -+-+- {resp_headers} -+-+- {response_body}", + status, + status_message, + resp_headers, + response_body, + ) + return status, resp_headers + + +_HEADERS_TO_CHECK = { + "upgrade": "websocket", + "connection": "upgrade", +} + + +def _validate(headers, key: str, subprotocols) -> tuple: + subproto = None + for k, v in _HEADERS_TO_CHECK.items(): + r = headers.get(k, None) + if not r: + return False, None + r = [x.strip().lower() for x in r.split(",")] + if v not in r: + return False, None + + if subprotocols: + subproto = headers.get("sec-websocket-protocol", None) + if not subproto or subproto.lower() not in [s.lower() for s in subprotocols]: + error(f"Invalid subprotocol: {subprotocols}") + return False, None + subproto = subproto.lower() + + result = headers.get("sec-websocket-accept", None) + if not result: + return False, None + result = result.lower() + + if isinstance(result, str): + result = result.encode("utf-8") + + value = f"{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11".encode("utf-8") + hashed = base64encode(hashlib.sha1(value).digest()).strip().lower() + + if hmac.compare_digest(hashed, result): + return True, subproto + else: + return False, None + + +def _create_sec_websocket_key() -> str: + randomness = os.urandom(16) + return base64encode(randomness).decode("utf-8").strip() diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_http.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_http.py new file mode 100644 index 0000000..7765330 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_http.py @@ -0,0 +1,374 @@ +""" +_http.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import errno +import os +import socket +from base64 import encodebytes as base64encode + +from ._exceptions import ( + WebSocketAddressException, + WebSocketException, + WebSocketProxyException, +) +from ._logging import debug, dump, trace +from ._socket import DEFAULT_SOCKET_OPTION, recv_line, send +from ._ssl_compat import HAVE_SSL, ssl +from ._url import get_proxy_info, parse_url + +__all__ = ["proxy_info", "connect", "read_headers"] + +try: + from python_socks._errors import * + from python_socks._types import ProxyType + from python_socks.sync import Proxy + + HAVE_PYTHON_SOCKS = True +except: + HAVE_PYTHON_SOCKS = False + + class ProxyError(Exception): + pass + + class ProxyTimeoutError(Exception): + pass + + class ProxyConnectionError(Exception): + pass + + +class proxy_info: + def __init__(self, **options): + self.proxy_host = options.get("http_proxy_host", None) + if self.proxy_host: + self.proxy_port = options.get("http_proxy_port", 0) + self.auth = options.get("http_proxy_auth", None) + self.no_proxy = options.get("http_no_proxy", None) + self.proxy_protocol = options.get("proxy_type", "http") + # Note: If timeout not specified, default python-socks timeout is 60 seconds + self.proxy_timeout = options.get("http_proxy_timeout", None) + if self.proxy_protocol not in [ + "http", + "socks4", + "socks4a", + "socks5", + "socks5h", + ]: + raise ProxyError( + "Only http, socks4, socks5 proxy protocols are supported" + ) + else: + self.proxy_port = 0 + self.auth = None + self.no_proxy = None + self.proxy_protocol = "http" + + +def _start_proxied_socket(url: str, options, proxy) -> tuple: + if not HAVE_PYTHON_SOCKS: + raise WebSocketException( + "Python Socks is needed for SOCKS proxying but is not available" + ) + + hostname, port, resource, is_secure = parse_url(url) + + if proxy.proxy_protocol == "socks4": + rdns = False + proxy_type = ProxyType.SOCKS4 + # socks4a sends DNS through proxy + elif proxy.proxy_protocol == "socks4a": + rdns = True + proxy_type = ProxyType.SOCKS4 + elif proxy.proxy_protocol == "socks5": + rdns = False + proxy_type = ProxyType.SOCKS5 + # socks5h sends DNS through proxy + elif proxy.proxy_protocol == "socks5h": + rdns = True + proxy_type = ProxyType.SOCKS5 + + ws_proxy = Proxy.create( + proxy_type=proxy_type, + host=proxy.proxy_host, + port=int(proxy.proxy_port), + username=proxy.auth[0] if proxy.auth else None, + password=proxy.auth[1] if proxy.auth else None, + rdns=rdns, + ) + + sock = ws_proxy.connect(hostname, port, timeout=proxy.proxy_timeout) + + if is_secure: + if HAVE_SSL: + sock = _ssl_socket(sock, options.sslopt, hostname) + else: + raise WebSocketException("SSL not available.") + + return sock, (hostname, port, resource) + + +def connect(url: str, options, proxy, socket): + # Use _start_proxied_socket() only for socks4 or socks5 proxy + # Use _tunnel() for http proxy + # TODO: Use python-socks for http protocol also, to standardize flow + if proxy.proxy_host and not socket and proxy.proxy_protocol != "http": + return _start_proxied_socket(url, options, proxy) + + hostname, port_from_url, resource, is_secure = parse_url(url) + + if socket: + return socket, (hostname, port_from_url, resource) + + addrinfo_list, need_tunnel, auth = _get_addrinfo_list( + hostname, port_from_url, is_secure, proxy + ) + if not addrinfo_list: + raise WebSocketException(f"Host not found.: {hostname}:{port_from_url}") + + sock = None + try: + sock = _open_socket(addrinfo_list, options.sockopt, options.timeout) + if need_tunnel: + sock = _tunnel(sock, hostname, port_from_url, auth) + + if is_secure: + if HAVE_SSL: + sock = _ssl_socket(sock, options.sslopt, hostname) + else: + raise WebSocketException("SSL not available.") + + return sock, (hostname, port_from_url, resource) + except: + if sock: + sock.close() + raise + + +def _get_addrinfo_list(hostname, port: int, is_secure: bool, proxy) -> tuple: + phost, pport, pauth = get_proxy_info( + hostname, + is_secure, + proxy.proxy_host, + proxy.proxy_port, + proxy.auth, + proxy.no_proxy, + ) + try: + # when running on windows 10, getaddrinfo without socktype returns a socktype 0. + # This generates an error exception: `_on_error: exception Socket type must be stream or datagram, not 0` + # or `OSError: [Errno 22] Invalid argument` when creating socket. Force the socket type to SOCK_STREAM. + if not phost: + addrinfo_list = socket.getaddrinfo( + hostname, port, 0, socket.SOCK_STREAM, socket.SOL_TCP + ) + return addrinfo_list, False, None + else: + pport = pport and pport or 80 + # when running on windows 10, the getaddrinfo used above + # returns a socktype 0. This generates an error exception: + # _on_error: exception Socket type must be stream or datagram, not 0 + # Force the socket type to SOCK_STREAM + addrinfo_list = socket.getaddrinfo( + phost, pport, 0, socket.SOCK_STREAM, socket.SOL_TCP + ) + return addrinfo_list, True, pauth + except socket.gaierror as e: + raise WebSocketAddressException(e) + + +def _open_socket(addrinfo_list, sockopt, timeout): + err = None + for addrinfo in addrinfo_list: + family, socktype, proto = addrinfo[:3] + sock = socket.socket(family, socktype, proto) + sock.settimeout(timeout) + for opts in DEFAULT_SOCKET_OPTION: + sock.setsockopt(*opts) + for opts in sockopt: + sock.setsockopt(*opts) + + address = addrinfo[4] + err = None + while not err: + try: + sock.connect(address) + except socket.error as error: + sock.close() + error.remote_ip = str(address[0]) + try: + eConnRefused = ( + errno.ECONNREFUSED, + errno.WSAECONNREFUSED, + errno.ENETUNREACH, + ) + except AttributeError: + eConnRefused = (errno.ECONNREFUSED, errno.ENETUNREACH) + if error.errno not in eConnRefused: + raise error + err = error + continue + else: + break + else: + continue + break + else: + if err: + raise err + + return sock + + +def _wrap_sni_socket(sock: socket.socket, sslopt: dict, hostname, check_hostname): + context = sslopt.get("context", None) + if not context: + context = ssl.SSLContext(sslopt.get("ssl_version", ssl.PROTOCOL_TLS_CLIENT)) + # Non default context need to manually enable SSLKEYLOGFILE support by setting the keylog_filename attribute. + # For more details see also: + # * https://docs.python.org/3.8/library/ssl.html?highlight=sslkeylogfile#context-creation + # * https://docs.python.org/3.8/library/ssl.html?highlight=sslkeylogfile#ssl.SSLContext.keylog_filename + context.keylog_filename = os.environ.get("SSLKEYLOGFILE", None) + + if sslopt.get("cert_reqs", ssl.CERT_NONE) != ssl.CERT_NONE: + cafile = sslopt.get("ca_certs", None) + capath = sslopt.get("ca_cert_path", None) + if cafile or capath: + context.load_verify_locations(cafile=cafile, capath=capath) + elif hasattr(context, "load_default_certs"): + context.load_default_certs(ssl.Purpose.SERVER_AUTH) + if sslopt.get("certfile", None): + context.load_cert_chain( + sslopt["certfile"], + sslopt.get("keyfile", None), + sslopt.get("password", None), + ) + + # Python 3.10 switch to PROTOCOL_TLS_CLIENT defaults to "cert_reqs = ssl.CERT_REQUIRED" and "check_hostname = True" + # If both disabled, set check_hostname before verify_mode + # see https://github.com/liris/websocket-client/commit/b96a2e8fa765753e82eea531adb19716b52ca3ca#commitcomment-10803153 + if sslopt.get("cert_reqs", ssl.CERT_NONE) == ssl.CERT_NONE and not sslopt.get( + "check_hostname", False + ): + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + else: + context.check_hostname = sslopt.get("check_hostname", True) + context.verify_mode = sslopt.get("cert_reqs", ssl.CERT_REQUIRED) + + if "ciphers" in sslopt: + context.set_ciphers(sslopt["ciphers"]) + if "cert_chain" in sslopt: + certfile, keyfile, password = sslopt["cert_chain"] + context.load_cert_chain(certfile, keyfile, password) + if "ecdh_curve" in sslopt: + context.set_ecdh_curve(sslopt["ecdh_curve"]) + + return context.wrap_socket( + sock, + do_handshake_on_connect=sslopt.get("do_handshake_on_connect", True), + suppress_ragged_eofs=sslopt.get("suppress_ragged_eofs", True), + server_hostname=hostname, + ) + + +def _ssl_socket(sock: socket.socket, user_sslopt: dict, hostname): + sslopt: dict = {"cert_reqs": ssl.CERT_REQUIRED} + sslopt.update(user_sslopt) + + cert_path = os.environ.get("WEBSOCKET_CLIENT_CA_BUNDLE") + if ( + cert_path + and os.path.isfile(cert_path) + and user_sslopt.get("ca_certs", None) is None + ): + sslopt["ca_certs"] = cert_path + elif ( + cert_path + and os.path.isdir(cert_path) + and user_sslopt.get("ca_cert_path", None) is None + ): + sslopt["ca_cert_path"] = cert_path + + if sslopt.get("server_hostname", None): + hostname = sslopt["server_hostname"] + + check_hostname = sslopt.get("check_hostname", True) + sock = _wrap_sni_socket(sock, sslopt, hostname, check_hostname) + + return sock + + +def _tunnel(sock: socket.socket, host, port: int, auth) -> socket.socket: + debug("Connecting proxy...") + connect_header = f"CONNECT {host}:{port} HTTP/1.1\r\n" + connect_header += f"Host: {host}:{port}\r\n" + + # TODO: support digest auth. + if auth and auth[0]: + auth_str = auth[0] + if auth[1]: + auth_str += f":{auth[1]}" + encoded_str = base64encode(auth_str.encode()).strip().decode().replace("\n", "") + connect_header += f"Proxy-Authorization: Basic {encoded_str}\r\n" + connect_header += "\r\n" + dump("request header", connect_header) + + send(sock, connect_header) + + try: + status, _, _ = read_headers(sock) + except Exception as e: + raise WebSocketProxyException(str(e)) + + if status != 200: + raise WebSocketProxyException(f"failed CONNECT via proxy status: {status}") + + return sock + + +def read_headers(sock: socket.socket) -> tuple: + status = None + status_message = None + headers: dict = {} + trace("--- response header ---") + + while True: + line = recv_line(sock) + line = line.decode("utf-8").strip() + if not line: + break + trace(line) + if not status: + status_info = line.split(" ", 2) + status = int(status_info[1]) + if len(status_info) > 2: + status_message = status_info[2] + else: + kv = line.split(":", 1) + if len(kv) != 2: + raise WebSocketException("Invalid header") + key, value = kv + if key.lower() == "set-cookie" and headers.get("set-cookie"): + headers["set-cookie"] = headers.get("set-cookie") + "; " + value.strip() + else: + headers[key.lower()] = value.strip() + + trace("-----------------------") + + return status, headers, status_message diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_logging.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_logging.py new file mode 100644 index 0000000..0f673d3 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_logging.py @@ -0,0 +1,106 @@ +import logging + +""" +_logging.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +_logger = logging.getLogger("websocket") +try: + from logging import NullHandler +except ImportError: + + class NullHandler(logging.Handler): + def emit(self, record) -> None: + pass + + +_logger.addHandler(NullHandler()) + +_traceEnabled = False + +__all__ = [ + "enableTrace", + "dump", + "error", + "warning", + "debug", + "trace", + "isEnabledForError", + "isEnabledForDebug", + "isEnabledForTrace", +] + + +def enableTrace( + traceable: bool, + handler: logging.StreamHandler = logging.StreamHandler(), + level: str = "DEBUG", +) -> None: + """ + Turn on/off the traceability. + + Parameters + ---------- + traceable: bool + If set to True, traceability is enabled. + """ + global _traceEnabled + _traceEnabled = traceable + if traceable: + _logger.addHandler(handler) + _logger.setLevel(getattr(logging, level)) + + +def dump(title: str, message: str) -> None: + if _traceEnabled: + _logger.debug(f"--- {title} ---") + _logger.debug(message) + _logger.debug("-----------------------") + + +def error(msg: str) -> None: + _logger.error(msg) + + +def warning(msg: str) -> None: + _logger.warning(msg) + + +def debug(msg: str) -> None: + _logger.debug(msg) + + +def info(msg: str) -> None: + _logger.info(msg) + + +def trace(msg: str) -> None: + if _traceEnabled: + _logger.debug(msg) + + +def isEnabledForError() -> bool: + return _logger.isEnabledFor(logging.ERROR) + + +def isEnabledForDebug() -> bool: + return _logger.isEnabledFor(logging.DEBUG) + + +def isEnabledForTrace() -> bool: + return _traceEnabled diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_socket.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_socket.py new file mode 100644 index 0000000..81094ff --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_socket.py @@ -0,0 +1,188 @@ +import errno +import selectors +import socket +from typing import Union + +from ._exceptions import ( + WebSocketConnectionClosedException, + WebSocketTimeoutException, +) +from ._ssl_compat import SSLError, SSLWantReadError, SSLWantWriteError +from ._utils import extract_error_code, extract_err_message + +""" +_socket.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +DEFAULT_SOCKET_OPTION = [(socket.SOL_TCP, socket.TCP_NODELAY, 1)] +if hasattr(socket, "SO_KEEPALIVE"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)) +if hasattr(socket, "TCP_KEEPIDLE"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPIDLE, 30)) +if hasattr(socket, "TCP_KEEPINTVL"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPINTVL, 10)) +if hasattr(socket, "TCP_KEEPCNT"): + DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPCNT, 3)) + +_default_timeout = None + +__all__ = [ + "DEFAULT_SOCKET_OPTION", + "sock_opt", + "setdefaulttimeout", + "getdefaulttimeout", + "recv", + "recv_line", + "send", +] + + +class sock_opt: + def __init__(self, sockopt: list, sslopt: dict) -> None: + if sockopt is None: + sockopt = [] + if sslopt is None: + sslopt = {} + self.sockopt = sockopt + self.sslopt = sslopt + self.timeout = None + + +def setdefaulttimeout(timeout: Union[int, float, None]) -> None: + """ + Set the global timeout setting to connect. + + Parameters + ---------- + timeout: int or float + default socket timeout time (in seconds) + """ + global _default_timeout + _default_timeout = timeout + + +def getdefaulttimeout() -> Union[int, float, None]: + """ + Get default timeout + + Returns + ---------- + _default_timeout: int or float + Return the global timeout setting (in seconds) to connect. + """ + return _default_timeout + + +def recv(sock: socket.socket, bufsize: int) -> bytes: + if not sock: + raise WebSocketConnectionClosedException("socket is already closed.") + + def _recv(): + try: + return sock.recv(bufsize) + except SSLWantReadError: + pass + except socket.error as exc: + error_code = extract_error_code(exc) + if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: + raise + + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_READ) + + r = sel.select(sock.gettimeout()) + sel.close() + + if r: + return sock.recv(bufsize) + + try: + if sock.gettimeout() == 0: + bytes_ = sock.recv(bufsize) + else: + bytes_ = _recv() + except TimeoutError: + raise WebSocketTimeoutException("Connection timed out") + except socket.timeout as e: + message = extract_err_message(e) + raise WebSocketTimeoutException(message) + except SSLError as e: + message = extract_err_message(e) + if isinstance(message, str) and "timed out" in message: + raise WebSocketTimeoutException(message) + else: + raise + + if not bytes_: + raise WebSocketConnectionClosedException("Connection to remote host was lost.") + + return bytes_ + + +def recv_line(sock: socket.socket) -> bytes: + line = [] + while True: + c = recv(sock, 1) + line.append(c) + if c == b"\n": + break + return b"".join(line) + + +def send(sock: socket.socket, data: Union[bytes, str]) -> int: + if isinstance(data, str): + data = data.encode("utf-8") + + if not sock: + raise WebSocketConnectionClosedException("socket is already closed.") + + def _send(): + try: + return sock.send(data) + except SSLWantWriteError: + pass + except socket.error as exc: + error_code = extract_error_code(exc) + if error_code is None: + raise + if error_code not in [errno.EAGAIN, errno.EWOULDBLOCK]: + raise + + sel = selectors.DefaultSelector() + sel.register(sock, selectors.EVENT_WRITE) + + w = sel.select(sock.gettimeout()) + sel.close() + + if w: + return sock.send(data) + + try: + if sock.gettimeout() == 0: + return sock.send(data) + else: + return _send() + except socket.timeout as e: + message = extract_err_message(e) + raise WebSocketTimeoutException(message) + except Exception as e: + message = extract_err_message(e) + if isinstance(message, str) and "timed out" in message: + raise WebSocketTimeoutException(message) + else: + raise diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_ssl_compat.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_ssl_compat.py new file mode 100644 index 0000000..7d98126 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_ssl_compat.py @@ -0,0 +1,49 @@ +""" +_ssl_compat.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = [ + "HAVE_SSL", + "ssl", + "SSLError", + "SSLEOFError", + "SSLWantReadError", + "SSLWantWriteError", +] + +try: + import ssl + from ssl import SSLError, SSLEOFError, SSLWantReadError, SSLWantWriteError + + HAVE_SSL = True +except ImportError: + # dummy class of SSLError for environment without ssl support + class SSLError(Exception): + pass + + class SSLEOFError(Exception): + pass + + class SSLWantReadError(Exception): + pass + + class SSLWantWriteError(Exception): + pass + + ssl = None + HAVE_SSL = False diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_url.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_url.py new file mode 100644 index 0000000..9021317 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_url.py @@ -0,0 +1,190 @@ +import os +import socket +import struct +from typing import Optional +from urllib.parse import unquote, urlparse +from ._exceptions import WebSocketProxyException + +""" +_url.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +__all__ = ["parse_url", "get_proxy_info"] + + +def parse_url(url: str) -> tuple: + """ + parse url and the result is tuple of + (hostname, port, resource path and the flag of secure mode) + + Parameters + ---------- + url: str + url string. + """ + if ":" not in url: + raise ValueError("url is invalid") + + scheme, url = url.split(":", 1) + + parsed = urlparse(url, scheme="http") + if parsed.hostname: + hostname = parsed.hostname + else: + raise ValueError("hostname is invalid") + port = 0 + if parsed.port: + port = parsed.port + + is_secure = False + if scheme == "ws": + if not port: + port = 80 + elif scheme == "wss": + is_secure = True + if not port: + port = 443 + else: + raise ValueError("scheme %s is invalid" % scheme) + + if parsed.path: + resource = parsed.path + else: + resource = "/" + + if parsed.query: + resource += f"?{parsed.query}" + + return hostname, port, resource, is_secure + + +DEFAULT_NO_PROXY_HOST = ["localhost", "127.0.0.1"] + + +def _is_ip_address(addr: str) -> bool: + try: + socket.inet_aton(addr) + except socket.error: + return False + else: + return True + + +def _is_subnet_address(hostname: str) -> bool: + try: + addr, netmask = hostname.split("/") + return _is_ip_address(addr) and 0 <= int(netmask) < 32 + except ValueError: + return False + + +def _is_address_in_network(ip: str, net: str) -> bool: + ipaddr: int = struct.unpack("!I", socket.inet_aton(ip))[0] + netaddr, netmask = net.split("/") + netaddr: int = struct.unpack("!I", socket.inet_aton(netaddr))[0] + + netmask = (0xFFFFFFFF << (32 - int(netmask))) & 0xFFFFFFFF + return ipaddr & netmask == netaddr + + +def _is_no_proxy_host(hostname: str, no_proxy: Optional[list]) -> bool: + if not no_proxy: + if v := os.environ.get("no_proxy", os.environ.get("NO_PROXY", "")).replace( + " ", "" + ): + no_proxy = v.split(",") + if not no_proxy: + no_proxy = DEFAULT_NO_PROXY_HOST + + if "*" in no_proxy: + return True + if hostname in no_proxy: + return True + if _is_ip_address(hostname): + return any( + [ + _is_address_in_network(hostname, subnet) + for subnet in no_proxy + if _is_subnet_address(subnet) + ] + ) + for domain in [domain for domain in no_proxy if domain.startswith(".")]: + if hostname.endswith(domain): + return True + return False + + +def get_proxy_info( + hostname: str, + is_secure: bool, + proxy_host: Optional[str] = None, + proxy_port: int = 0, + proxy_auth: Optional[tuple] = None, + no_proxy: Optional[list] = None, + proxy_type: str = "http", +) -> tuple: + """ + Try to retrieve proxy host and port from environment + if not provided in options. + Result is (proxy_host, proxy_port, proxy_auth). + proxy_auth is tuple of username and password + of proxy authentication information. + + Parameters + ---------- + hostname: str + Websocket server name. + is_secure: bool + Is the connection secure? (wss) looks for "https_proxy" in env + instead of "http_proxy" + proxy_host: str + http proxy host name. + proxy_port: str or int + http proxy port. + no_proxy: list + Whitelisted host names that don't use the proxy. + proxy_auth: tuple + HTTP proxy auth information. Tuple of username and password. Default is None. + proxy_type: str + Specify the proxy protocol (http, socks4, socks4a, socks5, socks5h). Default is "http". + Use socks4a or socks5h if you want to send DNS requests through the proxy. + """ + if _is_no_proxy_host(hostname, no_proxy): + return None, 0, None + + if proxy_host: + if not proxy_port: + raise WebSocketProxyException("Cannot use port 0 when proxy_host specified") + port = proxy_port + auth = proxy_auth + return proxy_host, port, auth + + env_key = "https_proxy" if is_secure else "http_proxy" + value = os.environ.get(env_key, os.environ.get(env_key.upper(), "")).replace( + " ", "" + ) + if value: + proxy = urlparse(value) + auth = ( + (unquote(proxy.username), unquote(proxy.password)) + if proxy.username + else None + ) + return proxy.hostname, proxy.port, auth + + return None, 0, None diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_utils.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_utils.py new file mode 100644 index 0000000..65f3c0d --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_utils.py @@ -0,0 +1,459 @@ +from typing import Union + +""" +_url.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +__all__ = ["NoLock", "validate_utf8", "extract_err_message", "extract_error_code"] + + +class NoLock: + def __enter__(self) -> None: + pass + + def __exit__(self, exc_type, exc_value, traceback) -> None: + pass + + +try: + # If wsaccel is available we use compiled routines to validate UTF-8 + # strings. + from wsaccel.utf8validator import Utf8Validator + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + result: bool = Utf8Validator().validate(utfbytes)[0] + return result + +except ImportError: + # UTF-8 validator + # python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ + + _UTF8_ACCEPT = 0 + _UTF8_REJECT = 12 + + _UTF8D = [ + # The first part of the table maps bytes to character classes that + # to reduce the size of the transition table and create bitmasks. + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 9, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 8, + 8, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 10, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 3, + 4, + 3, + 3, + 11, + 6, + 6, + 6, + 5, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + 8, + # The second part is a transition table that maps a combination + # of a state of the automaton and a character class to a state. + 0, + 12, + 24, + 36, + 60, + 96, + 84, + 12, + 12, + 12, + 48, + 72, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 12, + 12, + 12, + 12, + 0, + 12, + 0, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 24, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 36, + 12, + 36, + 12, + 12, + 12, + 36, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + 12, + ] + + def _decode(state: int, codep: int, ch: int) -> tuple: + tp = _UTF8D[ch] + + codep = ( + (ch & 0x3F) | (codep << 6) if (state != _UTF8_ACCEPT) else (0xFF >> tp) & ch + ) + state = _UTF8D[256 + state + tp] + + return state, codep + + def _validate_utf8(utfbytes: Union[str, bytes]) -> bool: + state = _UTF8_ACCEPT + codep = 0 + for i in utfbytes: + state, codep = _decode(state, codep, int(i)) + if state == _UTF8_REJECT: + return False + + return True + + +def validate_utf8(utfbytes: Union[str, bytes]) -> bool: + """ + validate utf8 byte string. + utfbytes: utf byte string to check. + return value: if valid utf8 string, return true. Otherwise, return false. + """ + return _validate_utf8(utfbytes) + + +def extract_err_message(exception: Exception) -> Union[str, None]: + if exception.args: + exception_message: str = exception.args[0] + return exception_message + else: + return None + + +def extract_error_code(exception: Exception) -> Union[int, None]: + if exception.args and len(exception.args) > 1: + return exception.args[0] if isinstance(exception.args[0], int) else None diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/_wsdump.py b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_wsdump.py new file mode 100755 index 0000000..d4d76dc --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/libs/websocket/_wsdump.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 + +""" +wsdump.py +websocket - WebSocket client library for Python + +Copyright 2024 engn33r + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import argparse +import code +import gzip +import ssl +import sys +import threading +import time +import zlib +from urllib.parse import urlparse + +import websocket + +try: + import readline +except ImportError: + pass + + +def get_encoding() -> str: + encoding = getattr(sys.stdin, "encoding", "") + if not encoding: + return "utf-8" + else: + return encoding.lower() + + +OPCODE_DATA = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) +ENCODING = get_encoding() + + +class VAction(argparse.Action): + def __call__( + self, + parser: argparse.Namespace, + args: tuple, + values: str, + option_string: str = None, + ) -> None: + if values is None: + values = "1" + try: + values = int(values) + except ValueError: + values = values.count("v") + 1 + setattr(args, self.dest, values) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="WebSocket Simple Dump Tool") + parser.add_argument( + "url", metavar="ws_url", help="websocket url. ex. ws://echo.websocket.events/" + ) + parser.add_argument("-p", "--proxy", help="proxy url. ex. http://127.0.0.1:8080") + parser.add_argument( + "-v", + "--verbose", + default=0, + nargs="?", + action=VAction, + dest="verbose", + help="set verbose mode. If set to 1, show opcode. " + "If set to 2, enable to trace websocket module", + ) + parser.add_argument( + "-n", "--nocert", action="store_true", help="Ignore invalid SSL cert" + ) + parser.add_argument("-r", "--raw", action="store_true", help="raw output") + parser.add_argument("-s", "--subprotocols", nargs="*", help="Set subprotocols") + parser.add_argument("-o", "--origin", help="Set origin") + parser.add_argument( + "--eof-wait", + default=0, + type=int, + help="wait time(second) after 'EOF' received.", + ) + parser.add_argument("-t", "--text", help="Send initial text") + parser.add_argument( + "--timings", action="store_true", help="Print timings in seconds" + ) + parser.add_argument("--headers", help="Set custom headers. Use ',' as separator") + + return parser.parse_args() + + +class RawInput: + def raw_input(self, prompt: str = "") -> str: + line = input(prompt) + + if ENCODING and ENCODING != "utf-8" and not isinstance(line, str): + line = line.decode(ENCODING).encode("utf-8") + elif isinstance(line, str): + line = line.encode("utf-8") + + return line + + +class InteractiveConsole(RawInput, code.InteractiveConsole): + def write(self, data: str) -> None: + sys.stdout.write("\033[2K\033[E") + # sys.stdout.write("\n") + sys.stdout.write("\033[34m< " + data + "\033[39m") + sys.stdout.write("\n> ") + sys.stdout.flush() + + def read(self) -> str: + return self.raw_input("> ") + + +class NonInteractive(RawInput): + def write(self, data: str) -> None: + sys.stdout.write(data) + sys.stdout.write("\n") + sys.stdout.flush() + + def read(self) -> str: + return self.raw_input("") + + +def main() -> None: + start_time = time.time() + args = parse_args() + if args.verbose > 1: + websocket.enableTrace(True) + options = {} + if args.proxy: + p = urlparse(args.proxy) + options["http_proxy_host"] = p.hostname + options["http_proxy_port"] = p.port + if args.origin: + options["origin"] = args.origin + if args.subprotocols: + options["subprotocols"] = args.subprotocols + opts = {} + if args.nocert: + opts = {"cert_reqs": ssl.CERT_NONE, "check_hostname": False} + if args.headers: + options["header"] = list(map(str.strip, args.headers.split(","))) + ws = websocket.create_connection(args.url, sslopt=opts, **options) + if args.raw: + console = NonInteractive() + else: + console = InteractiveConsole() + print("Press Ctrl+C to quit") + + def recv() -> tuple: + try: + frame = ws.recv_frame() + except websocket.WebSocketException: + return websocket.ABNF.OPCODE_CLOSE, "" + if not frame: + raise websocket.WebSocketException(f"Not a valid frame {frame}") + elif frame.opcode in OPCODE_DATA: + return frame.opcode, frame.data + elif frame.opcode == websocket.ABNF.OPCODE_CLOSE: + ws.send_close() + return frame.opcode, "" + elif frame.opcode == websocket.ABNF.OPCODE_PING: + ws.pong(frame.data) + return frame.opcode, frame.data + + return frame.opcode, frame.data + + def recv_ws() -> None: + while True: + opcode, data = recv() + msg = None + if opcode == websocket.ABNF.OPCODE_TEXT and isinstance(data, bytes): + data = str(data, "utf-8") + if ( + isinstance(data, bytes) and len(data) > 2 and data[:2] == b"\037\213" + ): # gzip magick + try: + data = "[gzip] " + str(gzip.decompress(data), "utf-8") + except: + pass + elif isinstance(data, bytes): + try: + data = "[zlib] " + str( + zlib.decompress(data, -zlib.MAX_WBITS), "utf-8" + ) + except: + pass + + if isinstance(data, bytes): + data = repr(data) + + if args.verbose: + msg = f"{websocket.ABNF.OPCODE_MAP.get(opcode)}: {data}" + else: + msg = data + + if msg is not None: + if args.timings: + console.write(f"{time.time() - start_time}: {msg}") + else: + console.write(msg) + + if opcode == websocket.ABNF.OPCODE_CLOSE: + break + + thread = threading.Thread(target=recv_ws) + thread.daemon = True + thread.start() + + if args.text: + ws.send(args.text) + + while True: + try: + message = console.read() + ws.send(message) + except KeyboardInterrupt: + return + except EOFError: + time.sleep(args.eof_wait) + return + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(e) diff --git a/plugins/code/language_server_clients/lsp_manager/libs/websocket/py.typed b/plugins/code/language_server_clients/lsp_manager/libs/websocket/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/plugins/code/language_server_clients/lsp_manager/lsp_manager.py b/plugins/code/language_server_clients/lsp_manager/lsp_manager.py new file mode 100644 index 0000000..cbfce28 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/lsp_manager.py @@ -0,0 +1,128 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.controllers.controller_base import ControllerBase +from libs.event_factory import Event_Factory, Code_Event_Types + +from .dto.code.events import \ + RegisterLspClientEvent, UnregisterLspClientEvent +from .dto.code.lsp.lsp_message_structs import \ + LSPResponseTypes, LSPResponseRequest, LSPResponseNotification + +from .provider import Provider +from .provider_response_cache import ProviderResponseCache +from .lsp_manager_ui import LSPManagerUI +from .lsp_manager_client import LSPManagerClient +from .response_handlers.response_registry import ResponseRegistry + + + +class LSPManager(ControllerBase): + def __init__(self): + super(LSPManager, self).__init__() + + self._init() + self._load_widgets() + self._do_bind_mapping() + + + def _init(self): + self.provider: Provider = Provider() + self.response_cache: ProviderResponseCache = ProviderResponseCache() + self.lsp_manager_client: LSPManagerClient = LSPManagerClient() + self.response_registry: ResponseRegistry = ResponseRegistry() + + def _load_widgets(self): + self.lsp_manager_ui: LSPManagerUI = LSPManagerUI() + self.lsp_manager_ui.connect('create-client', self._on_create_client) + self.lsp_manager_ui.connect('close-client', self._on_close_client) + + def _do_bind_mapping(self): + self.response_cache.set_lsp_manager_client(self.lsp_manager_client) + self.provider.response_cache = self.response_cache + self.response_registry.set_event_hub( + self.emit, self.emit_to, self.provider + ) + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.RegisterLspClientEvent): + self.response_registry.register_handler(event.lang_id, event.handler) + self.lsp_manager_ui.add_client_listing(event.lang_id, event.lang_config) + elif isinstance(event, Code_Event_Types.UnregisterLspClientEvent): + self.response_registry.unregister_handler(event.lang_id) + self.lsp_manager_ui.remove_client_listing(event.lang_id) + + def _on_create_client(self, ui, lang_id: str, workspace_uri: str) -> bool: + init_opts = ui.get_init_opts(lang_id) + result = self.create_client(lang_id, workspace_uri, init_opts) + if result: + ui.toggle_client_buttons(show_close=True) + return result + + def _on_close_client(self, ui, lang_id: str) -> bool: + result = self.close_client(lang_id) + if result: + ui.toggle_client_buttons(show_close=False) + return result + + def handle_destroy(self): + self.lsp_manager_ui.disconnect_by_func(self._on_create_client) + self.lsp_manager_ui.disconnect_by_func(self._on_close_client) + + def create_client( + self, + lang_id: str = "python", + workspace_uri: str = "", + init_opts: dict = {} + ) -> bool: + client = self.lsp_manager_client.create_client( + lang_id, workspace_uri, init_opts + ) + handler = self.response_registry.get_handler(lang_id) + self.lsp_manager_client.active_language_id = lang_id + + if not client or not handler: + logger.error(f"LSP Manager: Either 'client' or 'handler' didn't get created...'") + self.close_client(lang_id) + return False + + handler.set_context(self.response_registry) + handler.set_response_cache(self.response_cache) + + client.handle_lsp_response = self.server_response + client.send_initialize_message(init_opts, "", f"file://{workspace_uri}") + + return True + + def close_client(self, lang_id: str) -> bool: + self.lsp_manager_client.close_client(lang_id) + self.response_registry.close_handler(lang_id) + + return True + + def server_response(self, lsp_response: LSPResponseTypes): + logger.debug(f"LSP Response: { lsp_response }") + + if isinstance(lsp_response, LSPResponseRequest): + if not self.lsp_manager_client.active_language_id in self.lsp_manager_client.clients: + logger.debug(f"No LSP client for '{self.lsp_manager_client.active_language_id}', skipping 'server_response'") + return + + controller = self.lsp_manager_client.get_active_client() + event = controller.get_event_by_id(lsp_response.id) + handler = self.response_registry.get_handler( + self.lsp_manager_client.active_language_id, event + ) + + if not handler: return + handler.handle(event, lsp_response.result, controller) + elif isinstance(lsp_response, LSPResponseNotification): + handler = self.response_registry.get_handler("default", lsp_response.method) + + if not handler: return + + handler.set_context(self.response_registry) + handler.set_response_cache(self.response_cache) + handler.handle(lsp_response.method, lsp_response.params, None) diff --git a/plugins/code/language_server_clients/lsp_manager/lsp_manager_client.py b/plugins/code/language_server_clients/lsp_manager/lsp_manager_client.py new file mode 100644 index 0000000..8e649dd --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/lsp_manager_client.py @@ -0,0 +1,57 @@ +# Python imports +from concurrent.futures import ThreadPoolExecutor + +# Lib imports + +# Application imports +from .mixins.lsp_client_events_mixin import LSPClientEventsMixin +from .client.lsp_client import LSPClient + + + +class LSPManagerClient(LSPClientEventsMixin): + def __init__(self): + super(LSPManagerClient, self).__init__() + + self._cache_refresh_timeout_id: int = None + + self.executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers = 1) + self.active_language_id: str = "" + self.clients: dict = {} + + + def create_client( + self, + lang_id: str = "python", + workspace_uri: str = "", + init_opts: dict = {} + ) -> LSPClient: + if lang_id in self.clients: return None + + address = "127.0.0.1" + port = 9999 + uri = f"ws://{address}:{port}/{lang_id}" + client = LSPClient() + + client.set_language(lang_id) + client.set_socket(uri) + client.start_client() + + if not client.ws_client.wait_for_connection(timeout = 5.0): + logger.error(f"Failed to connect to LSP server for {lang_id}") + return None + + self.clients[lang_id] = client + + return client + + def close_client(self, lang_id: str) -> bool: + if lang_id not in self.clients: return False + + controller = self.clients.pop(lang_id) + controller.stop_client() + + return True + + def get_active_client(self) -> LSPClient: + return self.clients[self.active_language_id] diff --git a/plugins/code/language_server_clients/lsp_manager/lsp_manager_ui.py b/plugins/code/language_server_clients/lsp_manager/lsp_manager_ui.py new file mode 100644 index 0000000..740dd0d --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/lsp_manager_ui.py @@ -0,0 +1,227 @@ +# Python imports +import json + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import GLib +from gi.repository import GtkSource + +# Application imports + + + +class LSPManagerUI(Gtk.Dialog): + __gsignals__ = { + 'create-client': (GObject.SignalFlags.RUN_LAST, None, (str, str)), + 'close-client': (GObject.SignalFlags.RUN_LAST, None, (str,)), + } + + def __init__(self): + super(LSPManagerUI, self).__init__() + + self.client_configs: dict[str, str] = {} + + self.source_view = None + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + + def _setup_styling(self): + self.set_modal(True) + self.set_decorated(False) + self.set_vexpand(True) + self.set_hexpand(True) + + def _setup_signals(self): + self.connect("show", self._handle_show) + self.connect("destroy", self._handle_destroy) + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + content_area = self.get_content_area() + self.main_box = Gtk.Grid() + self.path_entry = Gtk.SearchEntry() + self.path_bttn = Gtk.FileChooserButton.new( + title = "Workspace Folder", + action = Gtk.FileChooserAction.SELECT_FOLDER + ) + self.combo_box = Gtk.ComboBoxText() + + self.hide_bttn = Gtk.Button(label = "X") + bttn_box = Gtk.Box() + self.create_client_bttn = Gtk.Button(label = "Create Language Client") + self.close_client_bttn = Gtk.Button(label = "Close Language Client") + + self.path_entry.set_can_focus(False) + self.path_entry.set_placeholder_text("Workspace Folder...") + self.path_entry.connect("changed", self._path_changed, bttn_box) + self.path_bttn.set_halign(Gtk.Align.FILL) + + self.path_bttn.connect("file-set", self._file_set) + self.combo_box.connect("changed", self._on_combo_changed) + self.hide_bttn_id = self.hide_bttn.connect("clicked", lambda widget: self.hide()) + self.create_client_bttn.connect("clicked", self._create_client, self.close_client_bttn) + self.close_client_bttn.connect("clicked", self._close_client, self.create_client_bttn) + + self.main_box.set_column_spacing(15) + self.main_box.set_row_spacing(15) + + bttn_box.pack_start(self.create_client_bttn, False, False, 0) + bttn_box.pack_start(self.close_client_bttn, False, False, 0) + + self.main_box.attach(child = self.path_entry, left = 0, top = 0, width = 4, height = 1) + self.main_box.attach(child = self.path_bttn, left = 4, top = 0, width = 1, height = 1) + self.main_box.attach(child = self.combo_box, left = 5, top = 0, width = 1, height = 1) + self.main_box.attach(child = self.hide_bttn, left = 6, top = 0, width = 1, height = 1) + self.main_box.attach(child = bttn_box, left = 0, top = 1, width = 1, height = 1) + + content_area.set_vexpand(True) + content_area.set_hexpand(True) + + content_area.add(self.main_box) + content_area.show_all() + self.close_client_bttn.hide() + bttn_box.hide() + + def _handle_show(self, widget): + GLib.idle_add(self.path_entry.grab_focus) + + def _handle_destroy(self, widget): + self.disconnect_by_func(self._show) + self.disconnect_by_func(self._handle_destroy) + self.path_bttn.disconnect_by_func(self._file_set) + self.combo_box.disconnect_by_func(self._on_combo_changed) + self.hide_bttn.disconnect(self.hide_bttn_id) + self.create_client_bttn.disconnect_by_func(self._create_client) + self.close_client_bttn.disconnect_by_func(self._close_client) + + def _map_resize(self, widget, parent): + parent_x, parent_y = parent.get_position() + parent_width, parent_height = parent.get_size() + if parent_width == 0 or parent_height == 0: return + + width = int(parent_width * 0.75) + height = int(parent_height * 0.75) + + widget.resize(width, height) + + x = parent_x + (parent_width - width) // 2 + y = parent_y + (parent_height - height) // 2 + widget.move(x, y) + + def _path_changed(self, widget, buttons_widget): + if not widget.get_text(): + self.path_bttn.unselect_all() + self.path_bttn.emit("file-set") + buttons_widget.hide() + return + + self.set_source_view_text( self.path_entry.get_text() ) + buttons_widget.show() + + def _file_set(self, widget): + fname = widget.get_filename() + fname = "" if not fname else fname + self.path_entry.set_text(fname) + + lang_id = self.combo_box.get_active_text() + if not lang_id or lang_id not in self.client_configs: return + + self.set_source_view_text( + "{workspace.folder}" if not fname else fname + ) + + def _create_client(self, widget, sibling): + if not self.source_view: return + + buffer = self.source_view.get_buffer() + lang_id = self.combo_box.get_active_text() + + if not lang_id: return + + workspace_dir = self.path_entry.get_text() + self.emit('create-client', lang_id, workspace_dir) + + def _close_client(self, widget, sibling): + lang_id = self.combo_box.get_active_text() + + if not lang_id: return + self.emit('close-client', lang_id) + + def _on_combo_changed(self, combo: Gtk.ComboBoxText): + lang_id = combo.get_active_text() + self.set_source_view_text( self.path_entry.get_text() ) + + + def set_source_view_text(self, workspace_dir: str): + lang_id = self.combo_box.get_active_text() + if not lang_id: return + + json_str = self.client_configs[lang_id].replace("{workspace.folder}", workspace_dir) + buffer = self.source_view.get_buffer() + + buffer.set_text(json_str, -1) + + def map_parent_resize_event(self, parent): + self.size_allocate_id = parent.connect("size-allocate", lambda w, r: self._map_resize(self, parent)) + + def unmap_parent_resize_event(self, parent): + parent.disconnect(self.size_allocate_id) + + def set_source_view(self, source_view): + scrolled_win = Gtk.ScrolledWindow() + lang_manager = GtkSource.LanguageManager() + buffer = source_view.get_buffer() + language = lang_manager.get_language("json") + self.source_view = source_view + + buffer.set_language(language) + buffer.set_style_scheme(self.source_view.syntax_theme) + + scrolled_win.set_hexpand(True) + scrolled_win.set_vexpand(True) + + scrolled_win.add(self.source_view) + self.main_box.attach(child = scrolled_win, left = 0, top = 2, width = 7, height = 1) + + scrolled_win.show_all() + + def add_client_listing(self, lang_id: str, lang_config: str): + self.combo_box.append_text(lang_id) + self.client_configs[lang_id] = lang_config + + def remove_client_listing(self, lang_id: str): + model = self.combo_box.get_model() + + for i, row in enumerate(model): + if row[0] == lang_id: # assuming text is in column 0 + self.combo_box.remove(i) + break + + if lang_id in self.client_configs: + del self.client_configs[lang_id] + + def get_init_opts(self, lang_id: str) -> dict: + if not lang_id or lang_id not in self.client_configs: return {} + + try: + lang_config = json.loads(self.client_configs[lang_id]) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON for {lang_id}: {e}") + return {} + + return lang_config.get("initialization-options", {}) + + def toggle_client_buttons(self, show_close: bool): + self.create_client_bttn.set_visible(not show_close) + self.close_client_bttn.set_visible(show_close) diff --git a/plugins/code/language_server_clients/lsp_manager/manifest.json b/plugins/code/language_server_clients/lsp_manager/manifest.json new file mode 100644 index 0000000..d356239 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "LSP Manager", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "pre_launch": true, + "autoload": false, + "requests": {} +} diff --git a/plugins/code/language_server_clients/lsp_manager/mixins/__init__.py b/plugins/code/language_server_clients/lsp_manager/mixins/__init__.py new file mode 100644 index 0000000..da03c94 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/mixins/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module Mixins +""" diff --git a/plugins/code/language_server_clients/lsp_manager/mixins/lsp_client_events_mixin.py b/plugins/code/language_server_clients/lsp_manager/mixins/lsp_client_events_mixin.py new file mode 100644 index 0000000..2344716 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/mixins/lsp_client_events_mixin.py @@ -0,0 +1,144 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from libs.event_factory import Code_Event_Types + + + +class LSPClientEventsMixin: + + def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didOpen") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + buffer = event.file.buffer + text = buffer.get_text(*buffer.get_bounds()) + self.active_language_id = lang_id + + controller._lsp_did_open({ + "uri": uri, + "language_id": lang_id, + "text": text + }) + + def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didClose") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + + controller._lsp_did_close({"uri": uri}) + + def process_file_save(self, event: Code_Event_Types.SavedFileEvent): + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didSave") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + buffer = event.file.buffer + text = buffer.get_text(*buffer.get_bounds()) + self.active_language_id = lang_id + + controller._lsp_did_save({"uri": uri, "text": text}) + + def process_file_change(self, event: Code_Event_Types.TextChangedEvent): + self._clear_delayed_cache_refresh_trigger() + + lang_id = event.file.ftype + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping didChange") + return + + controller = self.clients[lang_id] + fpath = event.file.fpath + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + buffer = event.file.buffer + text = buffer.get_text(*buffer.get_bounds()) + self.active_language_id = lang_id + + controller._lsp_did_change({ + "uri": uri, + "language_id": lang_id, + "version": 1, + "text": text + }) + + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + column = iter.get_line_offset() + self._set_cache_refresh_trigger( + lang_id, fpath, line, column + ) + + + def process_goto_definition( + self, lang_id: str, fpath: str, line: int, column: int + ): + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping goto definition") + return + + controller = self.clients[lang_id] + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + self.active_language_id = lang_id + + controller._lsp_definition({ + "uri": uri, + "language_id": lang_id, + "version": 1, + "line": line, + "column": column + }) + + def process_completion_request( + self, lang_id: str, fpath: str, line: int, column: int + ): + if lang_id not in self.clients: + logger.debug(f"No LSP client for '{lang_id}', skipping completion") + return + + controller = self.clients[lang_id] + uri = f"file://{fpath}" if not fpath.startswith("file://") else fpath + self.active_language_id = lang_id + + controller._lsp_completion({ + "uri": uri, + "language_id": lang_id, + "version": 1, + "line": line, + "column": column + }) + + + def _clear_delayed_cache_refresh_trigger(self): + if self._cache_refresh_timeout_id: + GLib.source_remove(self._cache_refresh_timeout_id) + + def _set_cache_refresh_trigger( + self, lang_id: str, fpath: str, line: int, column: int + ): + def trigger_cache_refresh(lang_id, fpath, line, column): + self._cache_refresh_timeout_id = None + self.process_completion_request( + lang_id, fpath, line, column + ) + return False + + self._cache_refresh_timeout_id = GLib.timeout_add(1500, trigger_cache_refresh, lang_id, fpath, line, column) diff --git a/plugins/code/language_server_clients/lsp_manager/plugin.py b/plugins/code/language_server_clients/lsp_manager/plugin.py new file mode 100644 index 0000000..4f8612b --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/plugin.py @@ -0,0 +1,124 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types +from libs.dto.states import SourceViewStates + +from plugins.plugin_types import PluginCode + +from .dto.code import events as lsp_events +from .lsp_manager import LSPManager + + + +lsp_manager = LSPManager() + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + Event_Factory.register_events( lsp_events.__dict__.items() ) + + self.register_controller("lsp_manager", lsp_manager) + + window = self.request_ui_element("main-window") + + lsp_manager.lsp_manager_ui.map_parent_resize_event(window) + + event = Event_Factory.create_event("register_command", + command_name = "LSP Manager", + command = Handler, + binding_mode = "released", + binding = ["l", "g", "i"] + ) + self.emit_to("source_views", event) + + event = Event_Factory.create_event( + "register_provider", + provider_name = "LSP Completer", + provider = lsp_manager.provider, + language_ids = [] + ) + self.emit_to("completion", event) + + event = Event_Factory.create_event( + "create_source_view", + state = SourceViewStates.INDEPENDENT + ) + self.emit_to("source_views", event) + + source_view = event.response + lsp_manager.lsp_manager_ui.set_source_view(source_view) + + def unload(self): + Event_Factory.unregister_events( lsp_events.__dict__.items() ) + + self.unregister_controller("lsp_manager") + + window = self.request_ui_element("main-window") + + lsp_manager.lsp_manager_ui.unmap_parent_resize_event(window) + + event = Event_Factory.create_event("unregister_command", + command_name = "LSP Manager", + command = Handler, + binding_mode = "released", + binding = ["l", "g", "i"] + ) + self.emit_to("source_views", event) + + event = Event_Factory.create_event( + "unregister_provider", + provider_name = "LSP Completer" + ) + self.emit_to("completion", event) + + lsp_manager.handle_destroy() + + def run(self): + ... + + def generate_plugin_element(self): + ... + + +class Handler: + @staticmethod + def execute( + view: any, + *args, + **kwargs + ): + logger.debug("Command: LSP Manager") + + char_str = args[0] + if char_str in ["g", "i"]: + file = view.command.exec("get_current_file") + buffer = view.get_buffer() + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + column = iter.get_line_offset() + + if char_str == "g": + lsp_manager.lsp_manager_client.process_goto_definition( + file.ftype, file.fpath, line, column + ) + + return + + if char_str == "i": + return + + lsp_manager.lsp_manager_ui.hide() if lsp_manager.lsp_manager_ui.is_visible() else lsp_manager.lsp_manager_ui.show() diff --git a/plugins/code/language_server_clients/lsp_manager/provider/__init__.py b/plugins/code/language_server_clients/lsp_manager/provider/__init__.py new file mode 100644 index 0000000..fd976ce --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/provider/__init__.py @@ -0,0 +1,2 @@ +from .provider import Provider +from .provider_response_cache import ProviderResponseCache diff --git a/plugins/code/language_server_clients/lsp_manager/provider/provider.py b/plugins/code/language_server_clients/lsp_manager/provider/provider.py new file mode 100644 index 0000000..a41e0e6 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/provider/provider.py @@ -0,0 +1,85 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GObject +from gi.repository import GtkSource + +# Application imports +from .provider_response_cache import ProviderResponseCache + + + +class Provider(GObject.GObject, GtkSource.CompletionProvider): + """ + This code is an LSP code completion plugin for Newton. + # NOTE: Some code pulled/referenced from here --> https://github.com/isamert/gedi + """ + __gtype_name__ = 'LSPProvider' + + def __init__(self): + super(Provider, self).__init__() + + self.response_cache: ProviderResponseCache = None + + + def pre_populate(self, context): + ... + + def do_get_name(self): + return "LSP Code Completion" + + def do_match(self, context): + # Note: If provider is in interactive activation then need to check + # view focus as otherwise non focus views start trying to grab it. + # completion = context.get_property("completion") + # if not completion.get_view().has_focus(): return + + iter = self.response_cache.get_iter_correctly(context) + iter.backward_char() + ch = iter.get_char() + + # NOTE: Look to re-add or apply supporting logic to use spaces + # As is it slows down the editor in certain contexts... + # if not (ch in ('_', '.', ' ') or ch.isalnum()): + if not (ch in ('_', '.') or ch.isalnum()): + return False + + buffer = iter.get_buffer() + if buffer.get_context_classes_at_iter(iter) != ['no-spell-check']: + return False + + return True + + def do_get_priority(self): + return 5 + + def do_activate_proposal(self, proposal, iter_): + buffer = iter_.get_buffer() + # Note: Flag mostly intended for SourceViewsMultiInsertState + # to insure marker processes inserted text correctly. + buffer.is_processing_completion = True + return False + + def do_get_activation(self): + """ The context for when a provider will show results """ +# return GtkSource.CompletionActivation.NONE + return GtkSource.CompletionActivation.USER_REQUESTED +# return GtkSource.CompletionActivation.INTERACTIVE + + def do_populate(self, context): + results = self.response_cache.filter_with_context(context) + proposals = [] + + for entry in results: + proposals.append( + self.response_cache.create_completion_item( + entry["label"], + entry["text"], + entry["info"] + ) + ) + + context.add_proposals(self, proposals, True) diff --git a/plugins/code/language_server_clients/lsp_manager/provider/provider_response_cache.py b/plugins/code/language_server_clients/lsp_manager/provider/provider_response_cache.py new file mode 100644 index 0000000..2686d91 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/provider/provider_response_cache.py @@ -0,0 +1,44 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from core.widgets.code.completion_providers.provider_response_cache_base import ProviderResponseCacheBase + + + +class ProviderResponseCache(ProviderResponseCacheBase): + def __init__(self): + super(ProviderResponseCache, self).__init__() + + self.matchers: dict = {} + self.lsp_manager_client = None + + def set_lsp_manager_client(self, lsp_client): + self.lsp_manager_client = lsp_client + + def process_file_load(self, event): + if self.lsp_manager_client: + self.lsp_manager_client.process_file_load(event) + + def process_file_close(self, event): + if self.lsp_manager_client: + self.lsp_manager_client.process_file_close(event) + + def process_file_save(self, event): + if self.lsp_manager_client: + self.lsp_manager_client.process_file_save(event) + + def process_file_change(self, event): + if self.lsp_manager_client: + self.lsp_manager_client.process_file_change(event) + + def filter(self, word: str) -> list[dict]: + return [] + + def filter_with_context(self, context: GtkSource.CompletionContext) -> list[dict]: + return list( self.matchers.values() ) diff --git a/plugins/code/language_server_clients/lsp_manager/response_handlers/__init__.py b/plugins/code/language_server_clients/lsp_manager/response_handlers/__init__.py new file mode 100644 index 0000000..f4d1842 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/response_handlers/__init__.py @@ -0,0 +1,3 @@ +from .base_handler import BaseHandler +from .default import DefaultHandler +from .response_registry import ResponseRegistry diff --git a/plugins/code/language_server_clients/lsp_manager/response_handlers/base_handler.py b/plugins/code/language_server_clients/lsp_manager/response_handlers/base_handler.py new file mode 100644 index 0000000..590c3c0 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/response_handlers/base_handler.py @@ -0,0 +1,30 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class BaseHandler: + def __init__(self): + self.context = None + self.response_cache = None + + + def set_context(self, context): + self.context = context + + def set_response_cache(self, response_cache): + self.response_cache = response_cache + + @property + def emit(self): + return self.context.emit + + @property + def emit_to(self): + return self.context.emit_to + + def handle(self, method: str, response, controller): + pass diff --git a/plugins/code/language_server_clients/lsp_manager/response_handlers/default.py b/plugins/code/language_server_clients/lsp_manager/response_handlers/default.py new file mode 100644 index 0000000..f936310 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/response_handlers/default.py @@ -0,0 +1,134 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from .base_handler import BaseHandler + + + +class DefaultHandler(BaseHandler): + """Fallback handler for unknown languages - uses generic LSP handling.""" + + def handle(self, method: str, response, controller): + match method: + case "textDocument/completion": + self._handle_completion(response) + case "textDocument/definition": + self._handle_definition(response, controller) + case "textDocument/publishDiagnostics": + self._handle_diagnostics(response) + + def _handle_completion(self, result): + if not result: return + + items = result.get("items", []) if isinstance(result, dict) else result + + self.response_cache.matchers.clear() + for item in items: + label = item.get("label") + if not label: + continue + + text = ( + item.get("insertText") + or item.get("textEdit", {}).get("newText") + or item.get("textEditText", "") + or label + ) + + detail = item.get("detail") + doc = item.get("documentation") + + if detail: + info = detail + elif isinstance(doc, dict): + info = doc.get("value", "") + else: + info = str(doc) if doc else "" + + self.response_cache.matchers[label] = { + "label": label, + "text": text, + "info": info, + } + + self._prompt_completion_request() + + def _handle_definition(self, response, controller): + if not response: return + + uri = response[0]["uri"] + self._prompt_goto_request(uri, response[0]["range"]) + + def _handle_diagnostics(self, params): + if not params: return + + uri = params.get("uri", "") + diagnostics = params.get("diagnostics", []) + + errors = [] + warnings = [] + hints = [] + + for diag in diagnostics: + severity = diag.get("severity", 1) + message = diag.get("message", "") + range = diag.get("range", {}) + + diag_info = { + "message": message, + "range": range + } + + if severity == 1: + errors.append(diag_info) + elif severity == 2: + warnings.append(diag_info) + elif severity == 3: + hints.append(diag_info) + + self.response_cache.lsp_diagnostics = { + "uri": uri, + "errors": errors, + "warnings": warnings, + "hints": hints + } + + logger.debug(f"LSP Diagnostics for {uri}: {len(errors)} errors, {len(warnings)} warnings, {len(hints)} hints") + + def _prompt_goto_request(self, uri: str, pointer_pos: dict): + event = Event_Factory.create_event( + "get_active_view", + ) + self.emit_to("source_views", event) + view = event.response + view._on_uri_data_received( [uri] ) + + buffer = view.get_buffer() + + def move_cursor(buffer, pointer_pos): + itr = buffer.get_iter_at_line( pointer_pos["end"]["line"] ) + itr.forward_chars( pointer_pos["end"]["character"] ) + buffer.place_cursor(itr) + view.scroll_to_iter(itr, 0.2, False, 0, 0) + + GLib.idle_add( move_cursor, buffer, pointer_pos ) + + def _prompt_completion_request(self): + event = Event_Factory.create_event("get_active_view") + self.emit_to("source_views", event) + view = event.response + + event = Event_Factory.create_event( + "request_completion", + view = view, + provider = self.context._provider + ) + self.emit_to("completion", event) + diff --git a/plugins/code/language_server_clients/lsp_manager/response_handlers/response_registry.py b/plugins/code/language_server_clients/lsp_manager/response_handlers/response_registry.py new file mode 100644 index 0000000..09403fa --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/response_handlers/response_registry.py @@ -0,0 +1,52 @@ +# Python imports + +# Lib imports + +# Application imports +from .base_handler import BaseHandler +from .default import DefaultHandler + + + +class ResponseRegistry: + def __init__(self): + + self._instances: dict = {} + self._lang_handlers: dict = { + "default": DefaultHandler + } + + + def set_event_hub(self, emit, emit_to, provider = None): + self.emit = emit + self.emit_to = emit_to + self._provider = provider + + + def _get_instance(self, handler_cls: type[BaseHandler]) -> BaseHandler: + if handler_cls in self._instances: return self._instances[handler_cls] + + self._instances[handler_cls] = handler_cls() + + return self._instances[handler_cls] + + def register_handler(self, lang_id: str, handler_cls: type[BaseHandler]): + self._lang_handlers[lang_id] = handler_cls + + def unregister_handler(self, lang_id: str): + del self._lang_handlers[lang_id] + + def get_handler(self, lang_id: str = "", method: str = ""): + handler_cls = self._lang_handlers.get( + lang_id, self._lang_handlers.get("default", DefaultHandler) + ) + + if not handler_cls: return None + + return self._get_instance(handler_cls) + + def close_handler(self, lang_id: str): + if not lang_id in self._lang_handlers: return + + handler_cls = self._lang_handlers[lang_id] + self._instances.pop(handler_cls, None) diff --git a/plugins/code/language_server_clients/lsp_manager/scripts/CONFIG.sh b/plugins/code/language_server_clients/lsp_manager/scripts/CONFIG.sh new file mode 100644 index 0000000..9ccc18c --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/scripts/CONFIG.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# set -o xtrace ## To debug scripts +# set -o errexit ## To exit on error +# set -o errunset ## To exit if a variable is referenced but not set + + +CONTAINER="newton-lsp" \ No newline at end of file diff --git a/plugins/code/language_server_clients/lsp_manager/scripts/start.sh b/plugins/code/language_server_clients/lsp_manager/scripts/start.sh new file mode 100755 index 0000000..c980ca0 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/scripts/start.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +. CONFIG.sh + +# set -o xtrace ## To debug scripts +# set -o errexit ## To exit on error +# set -o errunset ## To exit if a variable is referenced but not set + + +function main() { + SCRIPTPATH="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" + cd "${SCRIPTPATH}" + echo "Working Dir: " $(pwd) + + ID=$(podman ps --filter "ancestor=localhost/${CONTAINER}:latest" --format "{{.ID}}") + if [ "${ID}" != "" ]; then + echo "Is up..." + exit 1 + fi + + CODE_HOST="${HOME}/Coding" + CODE_CONTAINER="${HOME}/Coding" + CONFIG_HOST="${HOME}/.config/lsps" + CONFIG_CONTAINER="${HOME}/.config/lsps" + + # podman run -d -m 4G \ + podman run -m 4G \ + -p 9999:9999 \ + -e HOME="${HOME}" \ + -e MAVEN_OPTS="-Duser.home=${HOME}" \ + -e JAVA_TOOL_OPTIONS="-Duser.home=${HOME}" \ + -e JDTLS_CONFIG_PATH="${CONFIG_CONTAINER}/jdtls" \ + -e JDTLS_DATA_PATH="${JDTLS_CONFIG_PATH}/data" \ + -v "${CODE_HOST}":"${CODE_CONTAINER}" \ + -v "${CONFIG_HOST}":"${CONFIG_CONTAINER}" \ + "${CONTAINER}:latest" +} +main $@; diff --git a/plugins/code/language_server_clients/lsp_manager/scripts/stop.sh b/plugins/code/language_server_clients/lsp_manager/scripts/stop.sh new file mode 100755 index 0000000..6f50bd4 --- /dev/null +++ b/plugins/code/language_server_clients/lsp_manager/scripts/stop.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +. CONFIG.sh + +# set -o xtrace ## To debug scripts +# set -o errexit ## To exit on error +# set -o errunset ## To exit if a variable is referenced but not set + + +function main() { + SCRIPTPATH="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" + cd "${SCRIPTPATH}" + echo "Working Dir: " $(pwd) + + ID=$(podman ps --filter "ancestor=localhost/${CONTAINER}:latest" --format "{{.ID}}") + if [ "${ID}" == "" ]; then + echo "Is not up..." + exit 1 + fi + + podman container stop "${ID}" +} +main $@; diff --git a/plugins/code/language_server_clients/python_lsp_client/__init__.py b/plugins/code/language_server_clients/python_lsp_client/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/language_server_clients/python_lsp_client/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/language_server_clients/python_lsp_client/__main__.py b/plugins/code/language_server_clients/python_lsp_client/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/language_server_clients/python_lsp_client/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/language_server_clients/python_lsp_client/config/lsp-server-config.json b/plugins/code/language_server_clients/python_lsp_client/config/lsp-server-config.json new file mode 100644 index 0000000..2aeb1cc --- /dev/null +++ b/plugins/code/language_server_clients/python_lsp_client/config/lsp-server-config.json @@ -0,0 +1,100 @@ +{ + "info": "https://github.com/python-lsp/python-lsp-server", + "command": "lsp-ws-proxy -- pylsp", + "alt-command": "pylsp", + "alt-command2": "lsp-ws-proxy --listen 4114 -- pylsp", + "alt-command3": "pylsp --ws --port 4114", + "socket": "ws://127.0.0.1:9999/python", + "socket-two": "ws://127.0.0.1:9999/?name=pylsp", + "initialization-options": { + "pylsp": { + "rope": { + "ropeFolder": "{user.home}/.config/newton/lsps/ropeproject" + }, + "plugins": { + "ruff": { + "enabled": true, + "extendSelect": ["I"], + "lineLength": 80 + }, + "pycodestyle": { + "enabled": false + }, + "pyflakes": { + "enabled": false + }, + "pylint": { + "enabled": true + }, + "mccabe": { + "enabled": false + }, + "pylsp_rope": { + "rename": false + }, + "rope_rename": { + "enabled": false + }, + "rope_autoimport": { + "enabled": true + }, + "rope_completion": { + "enabled": false, + "eager": false + }, + "jedi_rename": { + "enabled": true + }, + "jedi_completion": { + "enabled": true, + "include_class_objects": true, + "include_function_objects": true, + "fuzzy": false + }, + "jedi": { + "root_dir": "file://{workspace.folder}", + "extra_paths": [ + "{user.home}/Portable_Apps/py-venvs/pylsp-venv/venv/lib/python3.10/site-packages" + ] + } + } + } + } +}, +"python - jedi-language-server": { + "hidden": true, + "info": "https://pypi.org/project/jedi-language-server/", + "command": "jedi-language-server", + "alt-command": "lsp-ws-proxy --listen 3030 -- jedi-language-server", + "socket": "ws://127.0.0.1:9999/python", + "socket-two": "ws://127.0.0.1:9999/?name=jedi-language-server", + "initialization-options": { + "jediSettings": { + "autoImportModules": [], + "caseInsensitiveCompletion": true, + "debug": false + }, + "completion": { + "disableSnippets": false, + "resolveEagerly": false, + "ignorePatterns": [] + }, + "markupKindPreferred": "markdown", + "workspace": { + "extraPaths": [ + "{user.home}/Portable_Apps/py-venvs/pylsp-venv/venv/lib/python3.10/site-packages" + ], + "environmentPath": "{user.home}/Portable_Apps/py-venvs/gtk-apps-venv/venv/bin/python", + "symbols": { + "ignoreFolders": [ + ".nox", + ".tox", + ".venv", + "__pycache__", + "venv" + ], + "maxSymbols": 20 + } + } + } +} diff --git a/plugins/code/language_server_clients/python_lsp_client/manifest.json b/plugins/code/language_server_clients/python_lsp_client/manifest.json new file mode 100644 index 0000000..dff6d04 --- /dev/null +++ b/plugins/code/language_server_clients/python_lsp_client/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "Python LSP Client", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "autoload": false, + "requests": {} +} diff --git a/plugins/code/language_server_clients/python_lsp_client/plugin.py b/plugins/code/language_server_clients/python_lsp_client/plugin.py new file mode 100644 index 0000000..5d4f6cf --- /dev/null +++ b/plugins/code/language_server_clients/python_lsp_client/plugin.py @@ -0,0 +1,43 @@ +# Python imports +from os import path + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .response_handler import PythonHandler + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + dirPth = path.dirname( path.realpath(__file__) ) + with open(f"{dirPth}/config/lsp-server-config.json", "r") as f: + config = f.read() + event = Event_Factory.create_event("register_lsp_client", + lang_id = "python", + lang_config = config, + handler = PythonHandler + ) + self.emit_to("lsp_manager", event) + + def unload(self): + event = Event_Factory.create_event("unregister_lsp_client", + lang_id = "python" + ) + self.emit_to("lsp_manager", event) + + def run(self): + ... diff --git a/plugins/code/language_server_clients/python_lsp_client/response_handler/__init__.py b/plugins/code/language_server_clients/python_lsp_client/response_handler/__init__.py new file mode 100644 index 0000000..d5617e6 --- /dev/null +++ b/plugins/code/language_server_clients/python_lsp_client/response_handler/__init__.py @@ -0,0 +1 @@ +from .python import PythonHandler \ No newline at end of file diff --git a/plugins/code/language_server_clients/python_lsp_client/response_handler/python.py b/plugins/code/language_server_clients/python_lsp_client/response_handler/python.py new file mode 100644 index 0000000..949621e --- /dev/null +++ b/plugins/code/language_server_clients/python_lsp_client/response_handler/python.py @@ -0,0 +1,12 @@ +# Python imports + +# Lib imports + +# Application imports +from lsp_manager.response_handlers.default import DefaultHandler + + + +class PythonHandler(DefaultHandler): + """Uses default handling, can override if Python needs special logic.""" + ... diff --git a/plugins/code/ui/code_minimap/__init__.py b/plugins/code/ui/code_minimap/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/ui/code_minimap/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/ui/code_minimap/__main__.py b/plugins/code/ui/code_minimap/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/ui/code_minimap/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/ui/code_minimap/code_minimap.py b/plugins/code/ui/code_minimap/code_minimap.py new file mode 100644 index 0000000..14c1415 --- /dev/null +++ b/plugins/code/ui/code_minimap/code_minimap.py @@ -0,0 +1,50 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('GtkSource', '4') +from gi.repository.GtkSource import Map +from gi.repository import Pango + +# Application imports + + + +class CodeMiniMap(Map): + def __init__(self): + super(CodeMiniMap, self).__init__() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + self.show() + + + def _setup_styling(self): + ctx = self.get_style_context() + ctx.add_class("mini-view") + + self.set_hexpand(False) + self._set_font_desc() + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + event_system.subscribe(f"set-mini-view", self.set_smini_view) + + def _load_widgets(self): + ... + + def _set_font_desc(self): + default_font = 'Monospace 1' + desc = Pango.FontDescription(default_font) + + desc.set_size(Pango.SCALE) # Set size to 1pt + desc.set_family('BuilderBlocks,' + desc.get_family()) + self.set_property('font-desc', desc) + + def set_smini_view(self, source_view): + self.set_view(source_view) \ No newline at end of file diff --git a/plugins/code/ui/code_minimap/manifest.json b/plugins/code/ui/code_minimap/manifest.json new file mode 100644 index 0000000..fc18ca7 --- /dev/null +++ b/plugins/code/ui/code_minimap/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Code MiniMap", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/ui/code_minimap/plugin.py b/plugins/code/ui/code_minimap/plugin.py new file mode 100644 index 0000000..5c110ef --- /dev/null +++ b/plugins/code/ui/code_minimap/plugin.py @@ -0,0 +1,39 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .code_minimap import CodeMiniMap + + + +code_minimap = CodeMiniMap() + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.FocusedViewEvent): + code_minimap.set_smini_view(event.view) + + def load(self): + editors_container = self.request_ui_element("editors-container") + editors_container.add( code_minimap ) + + event = Event_Factory.create_event("get_active_view") + self.emit_to("source_views", event) + code_minimap.set_smini_view(event.response) + + def unload(self): + code_minimap.destroy() + + def run(self): + ... diff --git a/plugins/code/ui/info_bar/__init__.py b/plugins/code/ui/info_bar/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/ui/info_bar/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/ui/info_bar/__main__.py b/plugins/code/ui/info_bar/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/ui/info_bar/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/ui/info_bar/info_bar_widget.py b/plugins/code/ui/info_bar/info_bar_widget.py new file mode 100644 index 0000000..a5d88e2 --- /dev/null +++ b/plugins/code/ui/info_bar/info_bar_widget.py @@ -0,0 +1,93 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import Pango +from gi.repository import Gio + +# Application imports + + + +class InfoBarWidget(Gtk.Box): + """ docstring for InfoBarWidget. """ + + def __init__(self): + super(InfoBarWidget, self).__init__() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + self.show_all() + + + def _setup_styling(self): + self.set_margin_start(25) + self.set_margin_end(25) + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + self.path_label = Gtk.Label(label = "...") + self.line_char_label = Gtk.Label(label = "1:0") + self.encoding_label = Gtk.Label(label = "utf-8") + self.file_type_label = Gtk.Label(label = "buffer") + + self.add(self.path_label) + self.add(self.line_char_label) + self.add(self.encoding_label) + self.add(self.file_type_label) + + self.path_label.set_hexpand(True) + self.path_label.set_ellipsize(Pango.EllipsizeMode.START) + self.path_label.set_single_line_mode(True) + self.path_label.set_max_width_chars(48) + + self.line_char_label.set_hexpand(True) + self.encoding_label.set_hexpand(True) + self.file_type_label.set_hexpand(True) + + def _set_info_labels( + self, + path: Gio.File or str = None, + line_char: str = None, + file_type: str = None, + encoding_type: str = None + ): + self._set_path_label(path) + self._set_line_char_label(line_char) + self._set_file_type_label(file_type) + self._set_encoding_label(encoding_type) + + def _set_path_label(self, gfile: Gio.File or str = "..."): + gfile = "" if not gfile else gfile + + if isinstance(gfile, str): + self.path_label.set_text( gfile ) + self.path_label.set_tooltip_text( gfile ) + else: + self.path_label.set_text( gfile.get_path() ) + self.path_label.set_tooltip_text( gfile.get_path() ) + + def _set_line_char_label(self, line_char = "1:1"): + line_char = "1:1" if not line_char else line_char + + self.line_char_label.set_text(line_char) + + def _set_file_type_label(self, file_type = "buffer"): + file_type = "buffer" if not file_type else file_type + + self.file_type_label.set_text(file_type) + + def _set_encoding_label(self, encoding_type = "utf-8"): + encoding_type = "utf-8" if not encoding_type else encoding_type + + self.encoding_label.set_text(encoding_type) diff --git a/plugins/code/ui/info_bar/manifest.json b/plugins/code/ui/info_bar/manifest.json new file mode 100644 index 0000000..26546b5 --- /dev/null +++ b/plugins/code/ui/info_bar/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Info Bar", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/ui/info_bar/plugin.py b/plugins/code/ui/info_bar/plugin.py new file mode 100644 index 0000000..f1c08e3 --- /dev/null +++ b/plugins/code/ui/info_bar/plugin.py @@ -0,0 +1,35 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .info_bar_widget import InfoBarWidget + + + +info_bar_widget = InfoBarWidget() + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.SetInfoLabelsEvent): + info_bar_widget._set_info_labels(*event.info) + + def load(self): + header = self.request_ui_element("header-container") + header.add( info_bar_widget ) + + def unload(self): + info_bar_widget.destroy() + + def run(self): + ... diff --git a/plugins/code/ui/tabs_bar/__init__.py b/plugins/code/ui/tabs_bar/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/code/ui/tabs_bar/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/code/ui/tabs_bar/__main__.py b/plugins/code/ui/tabs_bar/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/code/ui/tabs_bar/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/code/ui/tabs_bar/manifest.json b/plugins/code/ui/tabs_bar/manifest.json new file mode 100644 index 0000000..e02eb6f --- /dev/null +++ b/plugins/code/ui/tabs_bar/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Tabs Bar", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": {} +} diff --git a/plugins/code/ui/tabs_bar/plugin.py b/plugins/code/ui/tabs_bar/plugin.py new file mode 100644 index 0000000..1911b6b --- /dev/null +++ b/plugins/code/ui/tabs_bar/plugin.py @@ -0,0 +1,46 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from plugins.plugin_types import PluginCode + +from .tabs_controller import TabsController + + + +class Plugin(PluginCode): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + ... + + def load(self): + self.tabs_controller = TabsController() + code_container = self.request_ui_element("code-container") + + self.register_controller("tabs", self.tabs_controller) + + code_container.add( self.tabs_controller.tabs_widget ) + code_container.reorder_child(self.tabs_controller.tabs_widget, 0) + + event = Event_Factory.create_event("get_files") + self.emit_to("files", event) + for file in event.response: + self.tabs_controller.add_tab(file) + + def unload(self): + self.unregister_controller("tabs") + self.tabs_controller.unload_tabs() + self.tabs_controller.tabs_widget.destroy() + + self.tabs_controller.tabs_widget = None + self.tabs_controller = None + del self.tabs_controller + + def run(self): + ... diff --git a/plugins/code/ui/tabs_bar/tab_widget.py b/plugins/code/ui/tabs_bar/tab_widget.py new file mode 100644 index 0000000..729cc29 --- /dev/null +++ b/plugins/code/ui/tabs_bar/tab_widget.py @@ -0,0 +1,79 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports + + + +class TabWidget(Gtk.Box): + """docstring for TabWidget""" + + def __init__(self): + super(TabWidget, self).__init__() + + self.file = None + + self._handler_id = None + self._eve_handler_id = None + + self._setup_styling() + self._setup_signals() + self._load_widgets() + + + def _setup_styling(self): + ctx = self.get_style_context() + ctx.add_class("tab-widget") + + self.set_orientation(0) + self.set_hexpand(False) + self.set_vexpand(False) + self.set_size_request(-1, 12) + + def _setup_signals(self): + ... + + def _load_widgets(self): + self.event_box = Gtk.EventBox() + self.label = Gtk.Label() + self.close_bttn = Gtk.Button() + icon = Gtk.Image(stock = Gtk.STOCK_CLOSE) + + self.event_box.set_above_child(True) + ctx = self.label.get_style_context() + ctx.add_class("tab-label") + ctx = self.close_bttn.get_style_context() + ctx.add_class("tab-close-bttn") + + self.label.set_xalign(0.0) + self.label.set_margin_left(25) + self.label.set_margin_right(25) + self.label.set_hexpand(True) + + self.close_bttn.add(icon) + self.event_box.add(self.label) + self.add(self.event_box) + self.add(self.close_bttn) + + self.show_all() + + def clear_signals_and_data(self): + self.close_bttn.disconnect(self._handler_id) + self.event_box.disconnect(self._eve_handler_id) + self._handler_id = None + + for child in self.get_children(): + child.unparent() + child.run_dispose() + child.destroy() + + def set_close_signal(self, callback): + self._handler_id = self.close_bttn.connect( + 'clicked', + callback, + self.file + ) diff --git a/plugins/code/ui/tabs_bar/tabs_controller.py b/plugins/code/ui/tabs_bar/tabs_controller.py new file mode 100644 index 0000000..d5cb48f --- /dev/null +++ b/plugins/code/ui/tabs_bar/tabs_controller.py @@ -0,0 +1,84 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import Gtk + +# Application imports +from libs.controllers.controller_base import ControllerBase +from libs.event_factory import Event_Factory, Code_Event_Types + +from core.widgets.code.source_view import SourceView + +from .tabs_widget import TabsWidget +from .tab_widget import TabWidget + + + +class TabsController(ControllerBase): + def __init__(self): + super(TabsController, self).__init__() + + self.tabs_widget: TabsWidget = TabsWidget() + self.tabs_widget.emit = self.emit + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.FocusedViewEvent): + self.tabs_widget.view_changed( event.view.get_buffer() ) + elif isinstance(event, Code_Event_Types.FilePathSetEvent): + self.update_tab_label(event) + elif isinstance(event, Code_Event_Types.ModifiedChangedEvent): + self.tabs_widget.modified_changed( event.buffer ) + elif isinstance(event, Code_Event_Types.FileExternallyDeletedEvent): + self.tabs_widget.externally_deleted( event.buffer ) + elif isinstance(event, Code_Event_Types.AddedNewFileEvent): + self.add_tab(event.file) + elif isinstance(event, Code_Event_Types.PoppedFileEvent): + ... + elif isinstance(event, Code_Event_Types.RemovedFileEvent): + self.remove_tab(event) + + def get_tabs_widget(self): + return self.tabs_widget + + def update_tab_label(self, event: Code_Event_Types.FilePathSetEvent): + for page_widget in self.tabs_widget.get_children(): + tab = self.tabs_widget.get_tab_label(page_widget) + if not event.file == tab.file: continue + + tab.label.set_label(event.file.fname) + + break + + def add_tab(self, file): + tab = TabWidget() + tab.file = file + + tab.label.set_label(file.fname) + + self.tabs_widget.append_page(Gtk.Separator(), tab) + tab.show_all() + + def remove_tab(self, event: Code_Event_Types.RemovedFileEvent): + for page_widget in self.tabs_widget.get_children(): + tab = self.tabs_widget.get_tab_label(page_widget) + if not event.file == tab.file: continue + + tab.clear_signals_and_data() + self.tabs_widget.remove_page( + self.tabs_widget.page_num(page_widget) + ) + + break + + def unload_tabs(self): + for page_widget in self.tabs_widget.get_children(): + tab = self.tabs_widget.get_tab_label(page_widget) + + tab.clear_signals_and_data() + self.tabs_widget.remove_page( + self.tabs_widget.page_num(page_widget) + ) + diff --git a/plugins/code/ui/tabs_bar/tabs_widget.py b/plugins/code/ui/tabs_bar/tabs_widget.py new file mode 100644 index 0000000..9a8b18d --- /dev/null +++ b/plugins/code/ui/tabs_bar/tabs_widget.py @@ -0,0 +1,206 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk +from gi.repository import Gdk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from .tab_widget import TabWidget + + + +class TabsWidget(Gtk.Notebook): + def __init__(self): + super(TabsWidget, self).__init__() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + self.show() + + + def _setup_styling(self): + self.set_scrollable(True) + + def _setup_signals(self): + self.connect("page-added", self._page_added) + self.switch_page_id = \ + self.connect_after("switch-page", self._switch_page) + self.connect("destroy", self._handle_destroy) + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + ... + + def _page_added(self, notebook, page_widget, page_num): + tab = self.get_tab_label(page_widget) + tab.set_close_signal(self._close_tab) + + self._bind_tab_menu(tab, page_widget) + + page_widget.show() + self.set_tab_detachable(page_widget, True) + self.set_tab_reorderable(page_widget, True) + + def _close_tab(self, button, file): + event = Event_Factory.create_event( + "remove_file", + buffer = file.buffer + ) + + self.emit(event) + + def _switch_page(self, notebook, page_widget, page_num): + tab = self.get_tab_label(page_widget) + event = Event_Factory.create_event( + "set_active_file", + buffer = tab.file.buffer + ) + + self.emit(event) + + def _bind_tab_menu(self, tab, page_widget): + def do_context_menu(tab, eve, page_widget): + if eve.type == Gdk.EventType.BUTTON_RELEASE and eve.button == 3: # r-click + menu = self.create_menu(page_widget) + menu.popup_at_pointer(eve) + + tab._eve_handler_id = \ + tab.event_box.connect( + "button-release-event", + do_context_menu, + page_widget + ) + + def create_menu(self, page_widget) -> Gtk.Menu: + context_menu = Gtk.Menu() + close_submenu = Gtk.Menu() + save_item = Gtk.MenuItem(label = "Save") + save_as_item = Gtk.MenuItem(label = "Save As") + + close_actions_menu = Gtk.MenuItem(label = "Close Actions") + close_item = Gtk.MenuItem(label = "Close Tab") + close_left_item = Gtk.MenuItem(label = "Close Tabs Left") + close_right_item = Gtk.MenuItem(label = "Close Tabs Right") + close_other_item = Gtk.MenuItem(label = "Close Other Tabs") + close_all_item = Gtk.MenuItem(label = "Close All Tabs") + + save_item.connect("activate", self.save_item, page_widget) + save_as_item.connect("activate", self.save_as_item, page_widget) + + close_item.connect("activate", self.close_item, page_widget) + close_left_item.connect("activate", self.close_left_items, page_widget) + close_right_item.connect("activate", self.close_right_items, page_widget) + close_other_item.connect("activate", self.close_other_items, page_widget) + close_all_item.connect("activate", self.close_all_items, page_widget) + + close_submenu.append(close_item) + close_submenu.append(close_left_item) + close_submenu.append(close_right_item) + close_submenu.append(close_other_item) + close_submenu.append(close_all_item) + + close_actions_menu.set_submenu(close_submenu) + + context_menu.append(save_item) + context_menu.append(save_as_item) + context_menu.append(close_actions_menu) + + context_menu.show_all() + + return context_menu + + def view_changed(self, buffer): + for page_widget in self.get_children(): + tab = self.get_tab_label(page_widget) + if not buffer == tab.file.buffer: continue + + self.handler_block(self.switch_page_id) + + self.set_current_page( + self.page_num(page_widget) + ) + self.handler_unblock(self.switch_page_id) + + break + + def modified_changed(self, buffer): + for page_widget in self.get_children(): + tab = self.get_tab_label(page_widget) + if not buffer == tab.file.buffer: continue + + ctx = tab.label.get_style_context() + ctx.remove_class("file-deleted") + if buffer.get_modified(): + ctx.add_class("file-changed") + else: + ctx.remove_class("file-changed") + + break + + def externally_deleted(self, buffer): + for page_widget in self.get_children(): + tab = self.get_tab_label(page_widget) + if not buffer == tab.file.buffer: continue + ctx = tab.label.get_style_context() + ctx.add_class("file-deleted") + break + + + def save_item(self, menu_item, page_widget): + tab = self.get_tab_label(page_widget) + tab.file.save() + + def save_as_item(self, menu_item, page_widget): + tab = self.get_tab_label(page_widget) + tab.file.save_as() + + def close_item(self, menu_item, page_widget): + tab = self.get_tab_label(page_widget) + tab.close_bttn.clicked() + + def close_left_items(self, menu_item, page_widget): + children = self.get_children() + i = children.index(page_widget) + + if i == 0: return + + for widget in children[ : i]: + tab = self.get_tab_label(widget) + tab.close_bttn.clicked() + + def close_right_items(self, menu_item, page_widget): + children = self.get_children() + i = children.index(page_widget) + 1 + + if i == len(children): return + + for widget in children[i : ]: + tab = self.get_tab_label(widget) + tab.close_bttn.clicked() + + def close_other_items(self, menu_item, page_widget): + self.close_left_items(menu_item, page_widget) + self.close_right_items(menu_item, page_widget) + + def close_all_items(self, menu_item, page_widget): + children = self.get_children() + + for widget in children[ : ]: + tab = self.get_tab_label(widget) + tab.close_bttn.clicked() + + def _handle_destroy(self, widget): + self.disconnect_by_func(self._page_added) + self.disconnect_by_func(self._switch_page) + self.disconnect_by_func(self._handle_destroy) + diff --git a/plugins/template/manifest.json b/plugins/template/manifest.json deleted file mode 100644 index 1f8c8a5..0000000 --- a/plugins/template/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "manifest": { - "name": "Example Plugin", - "author": "John Doe", - "version": "0.0.1", - "support": "", - "requests": { - "ui_target": "plugin_control_list", - "pass_events": "true", - "bind_keys": ["Example Plugin||send_message:f"] - } - } -} diff --git a/plugins/template/plugin.py b/plugins/template/plugin.py deleted file mode 100644 index c52c0ff..0000000 --- a/plugins/template/plugin.py +++ /dev/null @@ -1,51 +0,0 @@ -# Python imports -import os -import threading -import subprocess -import time - -# Lib imports -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk - -# Application imports -from plugins.plugin_base import PluginBase - - - - -# NOTE: Threads WILL NOT die with parent's destruction. -def threaded(fn): - def wrapper(*args, **kwargs): - threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=False).start() - return wrapper - -# NOTE: Threads WILL die with parent's destruction. -def daemon_threaded(fn): - def wrapper(*args, **kwargs): - threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() - return wrapper - - - - -class Plugin(PluginBase): - def __init__(self): - super().__init__() - - self.name = "Example Plugin" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus - # where self.name should not be needed for message comms - - - def generate_reference_ui_element(self): - button = Gtk.Button(label=self.name) - button.connect("button-release-event", self.send_message) - return button - - def run(self): - ... - - def send_message(self, widget=None, eve=None): - message = "Hello, World!" - event_system.emit("display_message", ("warning", message, None)) diff --git a/plugins/ui/template/__init__.py b/plugins/ui/template/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/ui/template/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/ui/template/__main__.py b/plugins/ui/template/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/ui/template/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/ui/template/manifest.json b/plugins/ui/template/manifest.json new file mode 100644 index 0000000..297166c --- /dev/null +++ b/plugins/ui/template/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "Example Plugin", + "author": "John Doe", + "version": "0.0.1", + "support": "", + "requests": { + "bind_keys": ["Example Plugin||send_message:f"] + } +} diff --git a/plugins/ui/template/plugin.py b/plugins/ui/template/plugin.py new file mode 100644 index 0000000..41b3e54 --- /dev/null +++ b/plugins/ui/template/plugin.py @@ -0,0 +1,48 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from libs.dto.base_event import BaseEvent + +from plugins.plugin_types import PluginUI + + + +class Plugin(PluginUI): + def __init__(self): + super(Plugin, self).__init__() + + + def _controller_message(self, event: BaseEvent): + ... + + def load(self): + ui_element = self.request_ui_element("header-container") + ui_element.add( self.generate_plugin_element() ) + + def unload(self): + ui_element = self.request_ui_element("header-container") + self.button = self.generate_plugin_element() + + ui_element.add( self.button ) + + def run(self): + self.button.disconnect_by_func(self.send_message) + self.button.destroy() + del button + + def generate_plugin_element(self): + button = Gtk.Button(label = "Hello, World!") + + button.connect("button-release-event", self.send_message) + button.show() + + return button + + def send_message(self, widget = None, eve = None): + logger.info("Hello, World!") + \ No newline at end of file diff --git a/pyrightconfig.json b/pyrightconfig.json index 4d8b8ec..7e5dd43 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -5,7 +5,7 @@ "reportDuplicateImport": true, "executionEnvironments": [ { - "root": "./src/versions/solarfm-0.0.1/solarfm" + "root": "./src/" } ], "venvPath": ".", diff --git a/src/__builtins__.py b/src/__builtins__.py index 3642a81..f8225ff 100644 --- a/src/__builtins__.py +++ b/src/__builtins__.py @@ -8,11 +8,11 @@ import sys # Application imports # from libs.db import DB -from libs.event_system import EventSystem -from libs.endpoint_registry import EndpointRegistry -from libs.keybindings import Keybindings from libs.logger import Logger +from libs.event_system import EventSystem +from libs.keybindings import Keybindings from libs.settings.manager import SettingsManager +from libs.widget_registery import WidgetRegisteryController @@ -34,12 +34,8 @@ def daemon_threaded_wrapper(fn): def call_chain_wrapper(fn): def wrapper(*args, **kwargs): - print() - print() for line in traceback.format_stack(): print( line.strip() ) - print() - print() return fn(*args, **kwargs) return wrapper @@ -47,21 +43,20 @@ def call_chain_wrapper(fn): # NOTE: Just reminding myself we can add to builtins two different ways... # __builtins__.update({"event_system": Builtins()}) -builtins.APP_NAME = "" +builtins.APP_NAME = "".replace("<","").replace(">","") builtins.keybindings = Keybindings() builtins.event_system = EventSystem() -builtins.endpoint_registry = EndpointRegistry() builtins.settings_manager = SettingsManager() +builtins.widget_registery = WidgetRegisteryController() # builtins.db = DB() settings_manager.load_settings() -builtins.settings = settings_manager.settings builtins.logger = Logger( - settings_manager.get_home_config_path(), \ - _ch_log_lvl = settings.debugging.ch_log_lvl, \ - _fh_log_lvl = settings.debugging.fh_log_lvl + settings_manager.path_manager.get_home_config_path(), \ + _ch_log_lvl = settings_manager.settings.debugging.ch_log_lvl, \ + _fh_log_lvl = settings_manager.settings.debugging.fh_log_lvl ).get_logger() builtins.threaded = threaded_wrapper diff --git a/src/app.py b/src/app.py index 14eca5a..261c8a6 100644 --- a/src/app.py +++ b/src/app.py @@ -2,6 +2,7 @@ from contextlib import suppress import signal import os +import json # Lib imports @@ -23,13 +24,14 @@ class Application: def __init__(self): super(Application, self).__init__() - if not settings_manager.is_trace_debug(): - self.load_ipc() - self.setup_debug_hook() def run(self): + if not settings_manager.is_trace_debug(): + if not self.load_ipc(): + return + win = Window() win.start() @@ -39,13 +41,24 @@ class Application: ipc_server = IPCServer() self.ipc_realization_check(ipc_server) - if not ipc_server.is_ipc_alive: - for arg in unknownargs + [args.new_tab,]: - if os.path.isfile(arg): - message = f"FILE|{arg}" - ipc_server.send_ipc_message(message) + if ipc_server.is_ipc_alive: + return True - raise AppLaunchException(f"{APP_NAME} IPC Server Exists: Have sent path(s) to it and closing...") + logger.warning(f"{APP_NAME} IPC Server Exists: Have sent path(s) to it and closing...") + files: list = [] + for arg in unknownargs + [args.new_tab,]: + if os.path.isfile(arg): + files.append(f"file://{arg}") + + if os.path.isdir(arg): + message = f"DIR|{arg}" + ipc_server.send_ipc_message(message) + + if files: + message = f"FILES|{json.dumps(files)}" + ipc_server.send_ipc_message(message) + + return False def ipc_realization_check(self, ipc_server): try: @@ -53,9 +66,6 @@ class Application: except Exception: ipc_server.send_test_ipc_message() - with suppress(Exception): - ipc_server.create_ipc_listener() - def setup_debug_hook(self): # Typically: ValueError: signal only works in main thread with suppress(ValueError): diff --git a/src/core/builder_wrapper.py b/src/core/builder_wrapper.py deleted file mode 100644 index 9245da9..0000000 --- a/src/core/builder_wrapper.py +++ /dev/null @@ -1,33 +0,0 @@ -# Python imports - -# Lib imports -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk - -# Application imports - - - -class BuilderWrapper(Gtk.Builder): - """docstring for BuilderWrapper.""" - - def __init__(self): - super(BuilderWrapper, self).__init__() - - self.objects = {} - - def get_object(self, id: str, use_gtk: bool = True) -> any: - if not use_gtk: - return self.objects[id] - - return super(BuilderWrapper, self).get_object(id) - - def expose_object(self, id: str, object: any, use_gtk: bool = True) -> None: - if not use_gtk: - self.objects[id] = object - else: - super(BuilderWrapper, self).expose_object(id, object) - - def dereference_object(self, id: str) -> None: - del self.objects[id] diff --git a/src/core/containers/base_container.py b/src/core/containers/base_container.py index d725c88..cada0ec 100644 --- a/src/core/containers/base_container.py +++ b/src/core/containers/base_container.py @@ -16,34 +16,40 @@ class BaseContainer(Gtk.Box): def __init__(self): super(BaseContainer, self).__init__() - self.ctx = self.get_style_context() - self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self.show() def _setup_styling(self): - self.set_orientation(Gtk.Orientation.VERTICAL) + self.ctx = self.get_style_context() self.ctx.add_class("base-container") + self.set_orientation(Gtk.Orientation.VERTICAL) + self._update_transparency() + def _setup_signals(self): - ... + self.connect("show", self._handle_show) def _subscribe_to_events(self): event_system.subscribe("update-transparency", self._update_transparency) event_system.subscribe("remove-transparency", self._remove_transparency) + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() + def _load_widgets(self): - self.add(HeaderContainer()) - self.add(BodyContainer()) - self.add(FooterContainer()) + widget_registery.expose_object("base-container", self) + + self.add( HeaderContainer() ) + self.add( BodyContainer() ) + self.add( FooterContainer() ) def _update_transparency(self): - self.ctx.add_class(f"mw_transparency_{settings.theming.transparency}") + self.ctx.add_class(f"mw_transparency_{settings_manager.settings.theming.transparency}") def _remove_transparency(self): - self.ctx.remove_class(f"mw_transparency_{settings.theming.transparency}") \ No newline at end of file + self.ctx.remove_class(f"mw_transparency_{settings_manager.settings.theming.transparency}") \ No newline at end of file diff --git a/src/core/containers/body_container.py b/src/core/containers/body_container.py index ffe3043..1a1f3d2 100644 --- a/src/core/containers/body_container.py +++ b/src/core/containers/body_container.py @@ -16,29 +16,32 @@ class BodyContainer(Gtk.Box): def __init__(self): super(BodyContainer, self).__init__() - self.ctx = self.get_style_context() - self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self.show() def _setup_styling(self): - self.set_orientation(Gtk.Orientation.HORIZONTAL) + self.ctx = self.get_style_context() self.ctx.add_class("body-container") - self.set_homogeneous(True) + + self.set_orientation(Gtk.Orientation.HORIZONTAL) def _setup_signals(self): - ... + self.connect("show", self._handle_show) def _subscribe_to_events(self): ... + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() def _load_widgets(self): - self.add(LeftContainer()) - self.add(CenterContainer()) - self.add(RightContainer()) \ No newline at end of file + widget_registery.expose_object("body-container", self) + + self.add( LeftContainer() ) + self.add( CenterContainer() ) + self.add( RightContainer() ) \ No newline at end of file diff --git a/src/core/containers/center_container.py b/src/core/containers/center_container.py index 2fa8887..5fcd3bf 100644 --- a/src/core/containers/center_container.py +++ b/src/core/containers/center_container.py @@ -14,34 +14,39 @@ class CenterContainer(Gtk.Box): def __init__(self): super(CenterContainer, self).__init__() - self._builder = settings_manager.get_builder() - self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self.show() def _setup_styling(self): - self.set_orientation(Gtk.Orientation.VERTICAL) + self.ctx = self.get_style_context() + self.ctx.add_class("center-container") + self.set_orientation(Gtk.Orientation.VERTICAL) self.set_hexpand(True) self.set_vexpand(True) - ctx = self.get_style_context() - ctx.add_class("center-container") - def _setup_signals(self): - ... + self.connect("show", self._handle_show) def _subscribe_to_events(self): ... + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() + def _load_widgets(self): - glade_box = self._builder.get_object("glade_box") - button = Gtk.Button(label = "Click Me!") + widget_registery.expose_object("center-container", self) + + glade_box = widget_registery.get_object("glade_box") + button = Gtk.Button(label = "Click Me!") + webkit_ui = WebkitUI() + + webkit_ui.load_context_base_path() button.connect("clicked", self._hello_world) @@ -50,7 +55,7 @@ class CenterContainer(Gtk.Box): self.add(button) self.add(glade_box) - self.add( WebkitUI() ) + self.add(webkit_ui) def _hello_world(self, widget = None, eve = None): logger.debug("Hello, World!") \ No newline at end of file diff --git a/src/core/containers/code/__init__.py b/src/core/containers/code/__init__.py new file mode 100644 index 0000000..c59e952 --- /dev/null +++ b/src/core/containers/code/__init__.py @@ -0,0 +1,3 @@ +""" + Containers Package +""" \ No newline at end of file diff --git a/src/core/containers/code/code_container.py b/src/core/containers/code/code_container.py new file mode 100644 index 0000000..c0bbe28 --- /dev/null +++ b/src/core/containers/code/code_container.py @@ -0,0 +1,54 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from ...widgets.code.code_base import CodeBase + +from ...widgets.separator_widget import Separator + +from .editors_container import EditorsContainer + + + +class CodeContainer(Gtk.Box): + def __init__(self): + super(CodeContainer, self).__init__() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + self.show_all() + + + def _setup_styling(self): + self.set_orientation(Gtk.Orientation.VERTICAL) + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + widget_registery.expose_object("code-container", self) + + code_base = CodeBase() + + self.add( self._create_editor_widget(code_base) ) + + def _create_editor_widget(self, code_base: CodeBase): + editors_container = Gtk.Box() + + widget_registery.expose_object("editors-container", editors_container) + + editors_container.add( Separator("separator_left") ) + editors_container.add( EditorsContainer(code_base) ) + editors_container.add( Separator("separator_right") ) + + return editors_container diff --git a/src/core/containers/code/editors_container.py b/src/core/containers/code/editors_container.py new file mode 100644 index 0000000..675e9ad --- /dev/null +++ b/src/core/containers/code/editors_container.py @@ -0,0 +1,64 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +#from gi.repository import GLib + +# Application imports + + + +class EditorsContainer(Gtk.Paned): + def __init__(self, code_base: any): + super(EditorsContainer, self).__init__() + + self.code_base = code_base + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + + def _setup_styling(self): + self.ctx = self.get_style_context() + self.ctx.add_class("paned-editors-container") + + self.set_hexpand(True) + self.set_vexpand(True) + self.set_wide_handle(True) + + def _setup_signals(self): + self.connect("map", self._init_map) + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + self.scrolled_win1, \ + self.scrolled_win2 = self._create_views() + + self.pack1( self.scrolled_win1, True, True ) + self.pack2( self.scrolled_win2, True, True ) + + def _create_views(self): + scrolled_win1 = Gtk.ScrolledWindow() + scrolled_win2 = Gtk.ScrolledWindow() + + source_view1 = self.code_base.create_source_view() + source_view2 = self.code_base.create_source_view() + + source_view1.sibling_right = source_view2 + source_view2.sibling_left = source_view1 + + scrolled_win1.add( source_view1 ) + scrolled_win2.add( source_view2 ) + + return scrolled_win1, scrolled_win2 + + def _init_map(self, view): + self.disconnect_by_func( self._init_map ) + self.code_base.first_map_load() + del self.code_base diff --git a/src/core/containers/footer_container.py b/src/core/containers/footer_container.py index 4e21cea..5ec537b 100644 --- a/src/core/containers/footer_container.py +++ b/src/core/containers/footer_container.py @@ -6,6 +6,7 @@ gi.require_version('Gtk', '3.0') from gi.repository import Gtk # Application imports +from ..widgets.separator_widget import Separator @@ -13,29 +14,31 @@ class FooterContainer(Gtk.Box): def __init__(self): super(FooterContainer, self).__init__() - self.ctx = self.get_style_context() - self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self.show() def _setup_styling(self): - self.set_orientation(Gtk.Orientation.HORIZONTAL) - - self.set_hexpand(True) - + self.ctx = self.get_style_context() self.ctx.add_class("footer-container") + self.set_orientation(Gtk.Orientation.VERTICAL) + self.set_hexpand(True) + def _setup_signals(self): - ... + self.connect("show", self._handle_show) def _subscribe_to_events(self): ... + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() def _load_widgets(self): - ... + widget_registery.expose_object("footer-container", self) + + self.add( Separator("separator-footer", 0) ) diff --git a/src/core/containers/header_container.py b/src/core/containers/header_container.py index 7da0a75..3d3a19d 100644 --- a/src/core/containers/header_container.py +++ b/src/core/containers/header_container.py @@ -6,6 +6,7 @@ gi.require_version('Gtk', '3.0') from gi.repository import Gtk # Application imports +from ..widgets.separator_widget import Separator from ..widgets.controls.open_files_button import OpenFilesButton from ..widgets.controls.transparency_scale import TransparencyScale @@ -15,34 +16,37 @@ class HeaderContainer(Gtk.Box): def __init__(self): super(HeaderContainer, self).__init__() - self.ctx = self.get_style_context() - self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self.show() def _setup_styling(self): - self.set_orientation(Gtk.Orientation.HORIZONTAL) - - self.set_hexpand(True) - + self.ctx = self.get_style_context() self.ctx.add_class("header-container") + self.set_orientation(Gtk.Orientation.VERTICAL) + self.set_hexpand(True) + def _setup_signals(self): - ... + self.connect("show", self._handle_show) def _subscribe_to_events(self): event_system.subscribe("tggl-top-main-menubar", self.tggl_top_main_menubar) + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() def _load_widgets(self): + widget_registery.expose_object("header-container", self) + button = Gtk.Button(label = "Interactive Debug") button.connect("clicked", self._interactive_debug) + self.add( Separator("separator-header", 0) ) self.add( OpenFilesButton() ) self.add( TransparencyScale() ) self.add(button) diff --git a/src/core/containers/left_container.py b/src/core/containers/left_container.py index 45e6a37..78190b7 100644 --- a/src/core/containers/left_container.py +++ b/src/core/containers/left_container.py @@ -6,6 +6,8 @@ gi.require_version('Gtk', '3.0') from gi.repository import Gtk # Application imports +from ..widgets.separator_widget import Separator +from .code.code_container import CodeContainer @@ -16,24 +18,29 @@ class LeftContainer(Gtk.Box): self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self.show() def _setup_styling(self): - self.set_orientation(Gtk.Orientation.VERTICAL) + self.ctx = self.get_style_context() + self.ctx.add_class("left-container") + self.set_orientation(Gtk.Orientation.HORIZONTAL) self.set_vexpand(True) - ctx = self.get_style_context() - ctx.add_class("left-container") - def _setup_signals(self): - ... + self.connect("show", self._handle_show) def _subscribe_to_events(self): ... + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() + def _load_widgets(self): - ... \ No newline at end of file + widget_registery.expose_object("left-container", self) + + self.add( Separator("separator-left", 1) ) + self.add( CodeContainer() ) diff --git a/src/core/containers/right_container.py b/src/core/containers/right_container.py index 6e760a7..eb003ad 100644 --- a/src/core/containers/right_container.py +++ b/src/core/containers/right_container.py @@ -6,6 +6,7 @@ gi.require_version('Gtk', '3.0') from gi.repository import Gtk # Application imports +from ..widgets.separator_widget import Separator from ..widgets.vte_widget import VteWidget @@ -17,25 +18,31 @@ class RightContainer(Gtk.Box): self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self.show() def _setup_styling(self): - self.set_orientation(Gtk.Orientation.VERTICAL) + self.ctx = self.get_style_context() + self.ctx.add_class("right-container") + self.set_orientation(Gtk.Orientation.HORIZONTAL) self.set_vexpand(True) - ctx = self.get_style_context() - ctx.add_class("right-container") - def _setup_signals(self): - ... + self.connect("show", self._handle_show) def _subscribe_to_events(self): ... + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() + def _load_widgets(self): + widget_registery.expose_object("right-container", self) + vte_widget = VteWidget() - self.add( vte_widget ) \ No newline at end of file + self.add( vte_widget ) + + self.add( Separator("separator-right", 1) ) diff --git a/src/core/controllers/base_controller.py b/src/core/controllers/base_controller.py index 474e200..28e985e 100644 --- a/src/core/controllers/base_controller.py +++ b/src/core/controllers/base_controller.py @@ -6,33 +6,49 @@ gi.require_version('Gtk', '3.0') from gi.repository import Gtk # Application imports +from plugins import plugins_controller + from libs.mixins.ipc_signals_mixin import IPCSignalsMixin from libs.mixins.keyboard_signals_mixin import KeyboardSignalsMixin from ..containers.base_container import BaseContainer -from .base_controller_data import BaseControllerData +from .base_controller_mixin import BaseControllerMixin from .bridge_controller import BridgeController -class BaseController(IPCSignalsMixin, KeyboardSignalsMixin, BaseControllerData): +class BaseController(IPCSignalsMixin, KeyboardSignalsMixin, BaseControllerMixin): """ docstring for BaseController. """ def __init__(self): self._setup_controller_data() + self.plugins_controller.manual_launch_plugins() + + self._load_plugins(is_pre = True) self._setup_styling() self._setup_signals() self._subscribe_to_events() self._load_controllers() - self._load_plugins_and_files() + self._load_plugins(is_pre = False) + + self._load_files() logger.info(f"Made it past {self.__class__} loading...") settings_manager.set_end_load_time() settings_manager.log_load_time() + def _setup_controller_data(self): + self.window = settings_manager.get_main_window() + self.base_container = BaseContainer() + self.plugins_controller = plugins_controller + + settings_manager.register_signals_to_builder([self, self.base_container]) + + self._collect_files_dirs() + def _setup_styling(self): ... @@ -44,29 +60,29 @@ class BaseController(IPCSignalsMixin, KeyboardSignalsMixin, BaseControllerData): def _subscribe_to_events(self): event_system.subscribe("shutting-down", lambda: print("Shutting down...")) event_system.subscribe("handle-file-from-ipc", self.handle_file_from_ipc) + event_system.subscribe("handle-files-from-ipc", self.handle_files_from_ipc) event_system.subscribe("handle-dir-from-ipc", self.handle_dir_from_ipc) event_system.subscribe("tggl-top-main-menubar", self._tggl_top_main_menubar) def _load_controllers(self): BridgeController() - def _load_plugins_and_files(self): + def _load_plugins(self, is_pre: bool): args, unknownargs = settings_manager.get_starting_args() - if args.no_plugins == "false": - self.plugins_controller.pre_launch_plugins() - self.plugins_controller.post_launch_plugins() + if args.no_plugins == "true": return + if is_pre: + self.plugins_controller.pre_launch_plugins() + return + + if not is_pre: + self.plugins_controller.post_launch_plugins() + return + + def _load_files(self): for file in settings_manager.get_starting_files(): event_system.emit("post-file-to-ipc", file) def _tggl_top_main_menubar(self): logger.debug("_tggl_top_main_menubar > stub...") - def _load_glade_file(self): - self.builder.add_from_file( settings_manager.get_glade_file() ) - self.builder.expose_object("main_window", self.window) - - settings_manager.set_builder(self.builder) - self.base_container = BaseContainer() - - settings_manager.register_signals_to_builder([self, self.base_container]) \ No newline at end of file diff --git a/src/core/controllers/base_controller_data.py b/src/core/controllers/base_controller_mixin.py similarity index 79% rename from src/core/controllers/base_controller_data.py rename to src/core/controllers/base_controller_mixin.py index 8b85498..c0c9eee 100644 --- a/src/core/controllers/base_controller_data.py +++ b/src/core/controllers/base_controller_mixin.py @@ -6,28 +6,11 @@ from shutil import which # Lib imports # Application imports -from plugins.plugins_controller import PluginsController -from ..builder_wrapper import BuilderWrapper -class BaseControllerData: - ''' BaseControllerData contains most of the state of the app at ay given time. It also has some support methods. ''' - - def _setup_controller_data(self) -> None: - self.window = settings_manager.get_main_window() - self.builder = BuilderWrapper() - self.plugins_controller = PluginsController() - - self.base_container = None - self.was_midified_key = False - self.ctrl_down = False - self.shift_down = False - self.alt_down = False - - self._collect_files_dirs() - self._load_glade_file() - +class BaseControllerMixin: + ''' BaseControllerMixin contains most of the state of the app at ay given time. It also has some support methods. ''' def _collect_files_dirs(self): args, \ @@ -46,7 +29,7 @@ class BaseControllerData: logger.info(f"Not a File: {arg}") - if len(files) == 0: return + if not files: return settings_manager.set_is_starting_with_file(True) settings_manager.set_starting_files(files) diff --git a/src/core/widgets/code/__init__.py b/src/core/widgets/code/__init__.py new file mode 100644 index 0000000..24809ae --- /dev/null +++ b/src/core/widgets/code/__init__.py @@ -0,0 +1,3 @@ +""" + Code Package +""" \ No newline at end of file diff --git a/src/core/widgets/code/code_base.py b/src/core/widgets/code/code_base.py new file mode 100644 index 0000000..34ed260 --- /dev/null +++ b/src/core/widgets/code/code_base.py @@ -0,0 +1,63 @@ +# Python imports + +# Lib imports + +# Application imports +from plugins import plugins_controller + +from libs.controllers.controller_manager import ControllerManager + +from .controllers.files_controller import FilesController +from .controllers.commands_controller import CommandsController +from .controllers.completion_controller import CompletionController +from .controllers.views.source_views_controller import SourceViewsController + + + +class CodeBase: + def __init__(self): + super(CodeBase, self).__init__() + + self.controller_manager: ControllerManager = ControllerManager() + + self._subscribe_to_events() + self._load_controllers() + + + def _subscribe_to_events(self): + event_system.subscribe("handle-file", self._load_ipc_file) + event_system.subscribe("handle-files", self._load_ipc_files) + + def _load_controllers(self): + files_controller = FilesController() + commands_controller = CommandsController() + completion_controller = CompletionController() + source_views_controller = SourceViewsController() + + # self.controller_manager.register_controller("base", self) + self.controller_manager.register_controller("files", files_controller) + self.controller_manager.register_controller("commands", commands_controller) + self.controller_manager.register_controller("completion", completion_controller) + self.controller_manager.register_controller("source_views", source_views_controller) + self.controller_manager.register_controller("plugins", plugins_controller) + self.controller_manager.register_controller("widgets", widget_registery) + + def create_source_view(self): + source_view = self.controller_manager["source_views"].create_source_view() + self.controller_manager["completion"].register_completer( + source_view.get_completion() + ) + + return source_view + + def first_map_load(self): + self.controller_manager["source_views"].first_map_load() + + def _load_ipc_file(self, fpath: str): + active_view = self.controller_manager["source_views"].signal_mapper.active_view + uris = [ f"file://{fpath}" ] + active_view._on_uri_data_received(uris) + + def _load_ipc_files(self, uris: list): + active_view = self.controller_manager["source_views"].signal_mapper.active_view + active_view._on_uri_data_received(uris) diff --git a/src/core/widgets/code/command_system/__init__.py b/src/core/widgets/code/command_system/__init__.py new file mode 100644 index 0000000..bd5eb3b --- /dev/null +++ b/src/core/widgets/code/command_system/__init__.py @@ -0,0 +1,5 @@ +""" + Code Command System Package +""" + +from .command_system import CommandSystem diff --git a/src/core/widgets/code/command_system/command_helpers.py b/src/core/widgets/code/command_system/command_helpers.py new file mode 100644 index 0000000..33669c9 --- /dev/null +++ b/src/core/widgets/code/command_system/command_helpers.py @@ -0,0 +1,26 @@ +# Python imports + +# Lib imports +from gi.repository import GtkSource + +# Application imports + + + +def set_language_and_style(view, file): + language = view.language_manager.guess_language(file.fname, None) + file.buffer.set_language(language) + file.buffer.set_style_scheme(view.syntax_theme) + + return language + +def update_info_bar_if_focused(command_system, view: GtkSource): + has_focus = command_system.exec("has_focus") + if has_focus: + command_system.exec("update_info_bar") + +def get_file_and_buffer(view: GtkSource): + file = view.command.get_file(view) + buffer = file.buffer + + return file, buffer \ No newline at end of file diff --git a/src/core/widgets/code/command_system/command_system.py b/src/core/widgets/code/command_system/command_system.py new file mode 100644 index 0000000..a723f46 --- /dev/null +++ b/src/core/widgets/code/command_system/command_system.py @@ -0,0 +1,120 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from ..mixins.command_system_mixin import CommandSystemMixin +from ..source_view import SourceView + +from . import commands + + + +class CommandSystem(CommandSystemMixin): + def __init__(self): + super(CommandSystem, self).__init__() + + self.data: list = () + + + def set_data(self, *args, **kwargs): + self.data = (args, kwargs) + + def exec(self, command: str) -> any: + if not hasattr(commands, command): return + method = getattr(commands, command) + + args, kwargs = self.data + return method.execute(*args, **kwargs) + + def exec_with_args(self, command: str, *args, **kwargs) -> any: + if not hasattr(commands, command): return + + method = getattr(commands, command) + return method.execute(*args, **kwargs) + + def add_command(self, command_name: str, command: callable): + setattr(commands, command_name, command) + + def remove_command(self, command_name: str, command: callable): + if hasattr(commands, command_name): + delattr(commands, command_name) + + + def emit(self, event: Code_Event_Types.CodeEvent): + """ Monkey patch 'emit' from command controller... """ + ... + + def emit_to(self, controller: str, event: Code_Event_Types.CodeEvent): + """ Monkey patch 'emit_to' from command controller... """ + ... + + +# def filter_out_loaded_files(self, uris: list[str]): +# event = Event_Factory.create_event( +# "filter_out_loaded_files", +# uris = uris +# ) +# +# self.emit_to("files", event) +# +# return event.response +# +# def set_info_labels(self, data: tuple[str]): +# event = Event_Factory.create_event( +# "set_info_labels", +# info = data +# ) +# +# self.emit_to("plugins", event) +# +# def get_file(self, view: SourceView): +# event = Event_Factory.create_event( +# "get_file", +# view = view, +# buffer = view.get_buffer() +# ) +# +# self.emit_to("files", event) +# +# return event.response +# +# def get_swap_file(self, view: SourceView): +# event = Event_Factory.create_event( +# "get_swap_file", +# view = view, +# buffer = view.get_buffer() +# ) +# +# self.emit_to("files", event) +# +# return event.response +# +# def new_file(self, view: SourceView): +# event = Event_Factory.create_event("add_new_file", view = view) +# +# self.emit_to("files", event) +# +# return event.response +# +# def remove_file(self, view: SourceView): +# event = Event_Factory.create_event( +# "remove_file", +# view = view, +# buffer = view.get_buffer() +# ) +# +# self.emit_to("files", event) +# +# return event.response +# +# def request_completion(self, view: SourceView): +# event = Event_Factory.create_event( +# "request_completion", +# view = view, +# buffer = view.get_buffer() +# ) +# +# self.emit_to("completion", event) \ No newline at end of file diff --git a/src/core/widgets/code/command_system/commands/__init__.py b/src/core/widgets/code/command_system/commands/__init__.py new file mode 100644 index 0000000..54539e2 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/__init__.py @@ -0,0 +1,16 @@ +""" + Code Commands Package +""" + +import pkgutil +import importlib + +__all__ = [] + +for loader, module_name, is_pkg in pkgutil.walk_packages(__path__): + module = importlib.import_module(f"{__name__}.{module_name}") + # globals()[module_name] = module # Add module to package namespace + __all__.append(module_name) + +del pkgutil +del importlib diff --git a/src/core/widgets/code/command_system/commands/buffer_redo.py b/src/core/widgets/code/command_system/commands/buffer_redo.py new file mode 100644 index 0000000..cce0bc1 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/buffer_redo.py @@ -0,0 +1,25 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Buffer Redo") + + buffer = view.get_buffer() + undo_manager = buffer.get_undo_manager() + + if undo_manager.can_redo(): + buffer.redo() diff --git a/src/core/widgets/code/command_system/commands/buffer_undo.py b/src/core/widgets/code/command_system/commands/buffer_undo.py new file mode 100644 index 0000000..8f08e3d --- /dev/null +++ b/src/core/widgets/code/command_system/commands/buffer_undo.py @@ -0,0 +1,25 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Buffer Undo") + + buffer = view.get_buffer() + undo_manager = buffer.get_undo_manager() + + if undo_manager.can_undo(): + buffer.undo() diff --git a/src/core/widgets/code/command_system/commands/close_file.py b/src/core/widgets/code/command_system/commands/close_file.py new file mode 100644 index 0000000..67f5cb5 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/close_file.py @@ -0,0 +1,22 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from ..command_helpers import update_info_bar_if_focused + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Close File") + view.command.remove_file(view) + update_info_bar_if_focused(view.command, view) diff --git a/src/core/widgets/code/command_system/commands/dnd_load_file_to_buffer.py b/src/core/widgets/code/command_system/commands/dnd_load_file_to_buffer.py new file mode 100644 index 0000000..2a27b92 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/dnd_load_file_to_buffer.py @@ -0,0 +1,45 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource +from gi.repository import Gtk +from gi.repository import Gdk +from gi.repository import Gio +from gi.repository import GLib + +# Application imports +from ..command_helpers import update_info_bar_if_focused + + + +def execute( + view: GtkSource.View, + uri: str, + *args, + **kwargs +): + logger.debug("Command: DnD Load File To Buffer") + file = view.command.new_file(view) + gfile = Gio.File.new_for_uri(uri) + + view.command.exec_with_args( + "load_file", + view, gfile, file + ) + + view.set_buffer(file.buffer) + + update_info_bar_if_focused(view.command, view) + view.emit("focus-in-event", Gdk.Event()) + + def scroll_to_insert_itr(view): + buffer = view.get_buffer() + itr = buffer.get_iter_at_mark( buffer.get_insert() ) + view.scroll_to_iter(itr, 0.2, False, 0, 0) + + GLib.idle_add(scroll_to_insert_itr, view) + diff --git a/src/core/widgets/code/command_system/commands/dnd_load_files.py b/src/core/widgets/code/command_system/commands/dnd_load_files.py new file mode 100644 index 0000000..0356fac --- /dev/null +++ b/src/core/widgets/code/command_system/commands/dnd_load_files.py @@ -0,0 +1,28 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource +from gi.repository import Gio + +# Application imports + + + +def execute( + view: GtkSource.View, + uris: list = [], + *args, + **kwargs +): + logger.debug("Command: DnD Load Files") + for uri in uris: + try: + gfile = Gio.File.new_for_uri(uri) + except Exception as e: + gfile = Gio.File.new_for_path(uri) + + view.command.exec_with_args("load_file", view, gfile) diff --git a/src/core/widgets/code/command_system/commands/duplicate_line.py b/src/core/widgets/code/command_system/commands/duplicate_line.py new file mode 100644 index 0000000..59fc3a6 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/duplicate_line.py @@ -0,0 +1,55 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Duplicate Line") + + buffer = view.get_buffer() + + if not buffer.get_has_selection(): + had_selection = False + itr = buffer.get_iter_at_mark( buffer.get_insert() ) + start_itr = itr.copy() + end_itr = itr.copy() + start_line = itr.get_line() + 1 + start_char = itr.get_line_offset() + else: + had_selection = True + start_itr, end_itr = buffer.get_selection_bounds() + sline = start_itr.get_line() + eline = end_itr.get_line() + start_line = eline + 1 + start_char = start_itr.get_line_offset() + end_char = end_itr.get_line_offset() + range_line_size = eline - sline + + start_itr.backward_visible_line() + start_itr.forward_line() + end_itr.forward_line() + end_itr.backward_char() + + line_str = buffer.get_slice(start_itr, end_itr, True) + end_itr.forward_char() + buffer.insert(end_itr, f"{line_str}\n", -1) + + if not had_selection: + new_itr = buffer.get_iter_at_line_offset(start_line, start_char) + buffer.place_cursor(new_itr) + else: + new_itr = buffer.get_iter_at_line_offset(start_line, start_char) + new_end_itr = buffer.get_iter_at_line_offset((start_line + range_line_size), end_char) + buffer.select_range(new_itr, new_end_itr) diff --git a/src/core/widgets/code/command_system/commands/focus_left_sibling.py b/src/core/widgets/code/command_system/commands/focus_left_sibling.py new file mode 100644 index 0000000..af3d76a --- /dev/null +++ b/src/core/widgets/code/command_system/commands/focus_left_sibling.py @@ -0,0 +1,22 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Focus Left Sibling") + if not view.sibling_left: return + view.sibling_left.get_parent().show() + view.sibling_left.grab_focus() diff --git a/src/core/widgets/code/command_system/commands/focus_right_sibling.py b/src/core/widgets/code/command_system/commands/focus_right_sibling.py new file mode 100644 index 0000000..8b900ba --- /dev/null +++ b/src/core/widgets/code/command_system/commands/focus_right_sibling.py @@ -0,0 +1,22 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Focus Right Sibling") + if not view.sibling_right: return + view.sibling_right.get_parent().show() + view.sibling_right.grab_focus() diff --git a/src/core/widgets/code/command_system/commands/get_current_file.py b/src/core/widgets/code/command_system/commands/get_current_file.py new file mode 100644 index 0000000..68f04eb --- /dev/null +++ b/src/core/widgets/code/command_system/commands/get_current_file.py @@ -0,0 +1,23 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Get Current File") + + file = view.command.get_file(view) + + return file diff --git a/src/core/widgets/code/command_system/commands/get_filetype.py b/src/core/widgets/code/command_system/commands/get_filetype.py new file mode 100644 index 0000000..498474b --- /dev/null +++ b/src/core/widgets/code/command_system/commands/get_filetype.py @@ -0,0 +1,21 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Get File Type") + file = view.command.get_file(view) + return file.ftype diff --git a/src/core/widgets/code/command_system/commands/get_text.py b/src/core/widgets/code/command_system/commands/get_text.py new file mode 100644 index 0000000..e3c667e --- /dev/null +++ b/src/core/widgets/code/command_system/commands/get_text.py @@ -0,0 +1,23 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Get Text") + + buffer = view.get_buffer() + start_itr, end_itr = buffer.get_bounds() + return buffer.get_text(start_itr, end_itr, True) diff --git a/src/core/widgets/code/command_system/commands/go_to.py b/src/core/widgets/code/command_system/commands/go_to.py new file mode 100644 index 0000000..e654588 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/go_to.py @@ -0,0 +1,33 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Go-To") + + file = view.command.get_file(view) + gfile = file.get_location() + uri = gfile.get_uri() + + buffer = view.get_buffer() + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + offset = iter.get_line_offset() + + event_system.emit( + "textDocument/definition", + (view, file.ftype, uri, line, offset,) + ) diff --git a/src/core/widgets/code/command_system/commands/has_focus.py b/src/core/widgets/code/command_system/commands/has_focus.py new file mode 100644 index 0000000..8722f61 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/has_focus.py @@ -0,0 +1,21 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Has Focus") + ctx = view.get_parent().get_style_context() + return ctx.has_class("source-view-focused") diff --git a/src/core/widgets/code/command_system/commands/line_down.py b/src/core/widgets/code/command_system/commands/line_down.py new file mode 100644 index 0000000..8053d4b --- /dev/null +++ b/src/core/widgets/code/command_system/commands/line_down.py @@ -0,0 +1,23 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from libs.dto.states import SourceViewStates + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Line Down") + if not view.state == SourceViewStates.INSERT: return + + view.emit("move-lines", True) diff --git a/src/core/widgets/code/command_system/commands/line_up.py b/src/core/widgets/code/command_system/commands/line_up.py new file mode 100644 index 0000000..da27d27 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/line_up.py @@ -0,0 +1,23 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from libs.dto.states import SourceViewStates + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Line Up") + if not view.state == SourceViewStates.INSERT: return + + view.emit("move-lines", False) diff --git a/src/core/widgets/code/command_system/commands/load_file.py b/src/core/widgets/code/command_system/commands/load_file.py new file mode 100644 index 0000000..da0afd0 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/load_file.py @@ -0,0 +1,30 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource +from gi.repository import Gio + +# Application imports +from ...source_file import SourceFile +from ..command_helpers import set_language_and_style + + + +def execute( + view: GtkSource.View, + gfile: Gio.File, + file: SourceFile = None, + *args, + **kwargs +): + logger.debug("Command: Load File") + if not file: + file = view.command.new_file(view) + + file.load_path(gfile) + + set_language_and_style(view, file) diff --git a/src/core/widgets/code/command_system/commands/load_start_files.py b/src/core/widgets/code/command_system/commands/load_start_files.py new file mode 100644 index 0000000..32b6cb4 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/load_start_files.py @@ -0,0 +1,42 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource +from gi.repository import Gio + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Load Start File(s)") + + starting_files = settings_manager.get_starting_files() + + if not starting_files: return + + file = starting_files.pop() + file = file.replace("FILE|", "") + gfile = Gio.File.new_for_path(file) + file = view.command.get_file(view) + + view.command.exec_with_args( + "load_file", + view, gfile, file + ) + + if not starting_files: return + + for file in starting_files: + file = file.replace("FILE|", "") + gfile = Gio.File.new_for_path(file) + + view.command.exec_with_args("load_file", view, gfile) diff --git a/src/core/widgets/code/command_system/commands/move_to_left_sibling.py b/src/core/widgets/code/command_system/commands/move_to_left_sibling.py new file mode 100644 index 0000000..e1e9096 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/move_to_left_sibling.py @@ -0,0 +1,32 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Move To Left Sibling") + if not view.sibling_left: return + + buffer = view.get_buffer() + popped_file, next_file = view.command.get_swap_file(view) + + view.sibling_left.set_buffer(buffer) + view.sibling_left.get_parent().show() + view.sibling_left.grab_focus() + + if next_file: + view.set_buffer(next_file.buffer) + else: + view.command.exec("new_file") diff --git a/src/core/widgets/code/command_system/commands/move_to_right_sibling.py b/src/core/widgets/code/command_system/commands/move_to_right_sibling.py new file mode 100644 index 0000000..5bce2b1 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/move_to_right_sibling.py @@ -0,0 +1,32 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Move To Right Sibling") + if not view.sibling_right: return + + buffer = view.get_buffer() + popped_file, next_file = view.command.get_swap_file(view) + + view.sibling_right.set_buffer(buffer) + view.sibling_right.get_parent().show() + view.sibling_right.grab_focus() + + if next_file: + view.set_buffer(next_file.buffer) + else: + view.command.exec("new_file") diff --git a/src/core/widgets/code/command_system/commands/new_file.py b/src/core/widgets/code/command_system/commands/new_file.py new file mode 100644 index 0000000..414bdf8 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/new_file.py @@ -0,0 +1,29 @@ +# Python imports + + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from ..command_helpers import set_language_and_style, update_info_bar_if_focused + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: New File") + + file = view.command.new_file(view) + set_language_and_style(view, file) + + view.set_buffer(file.buffer) + + update_info_bar_if_focused(view.command, view) + return file diff --git a/src/core/widgets/code/command_system/commands/open_files.py b/src/core/widgets/code/command_system/commands/open_files.py new file mode 100644 index 0000000..8e003f3 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/open_files.py @@ -0,0 +1,30 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from ..command_helpers import update_info_bar_if_focused + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Open File(s)") + + start_path = None + file = view.command.get_file(view) + if not file.ftype == "buffer": + start_path = file.get_location() + + gfiles = event_system.emit_and_await("open-files", (None, None, start_path)) + view._on_uri_data_received( + [ gfile.get_uri() for gfile in gfiles ] + ) diff --git a/src/core/widgets/code/command_system/commands/save_file.py b/src/core/widgets/code/command_system/commands/save_file.py new file mode 100644 index 0000000..02e379e --- /dev/null +++ b/src/core/widgets/code/command_system/commands/save_file.py @@ -0,0 +1,30 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from ..command_helpers import set_language_and_style + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Save File") + file = view.command.get_file(view) + buffer = file.buffer + + if file.ftype == "buffer": + file.save_as() + set_language_and_style(view, file) + return + + file.save() + diff --git a/src/core/widgets/code/command_system/commands/save_file_as.py b/src/core/widgets/code/command_system/commands/save_file_as.py new file mode 100644 index 0000000..3795308 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/save_file_as.py @@ -0,0 +1,28 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from ..command_helpers import set_language_and_style, update_info_bar_if_focused + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.info("Command: Save File As") + file = view.command.get_file(view) + buffer = file.buffer + + file.save_as() + + set_language_and_style(view, file) + + update_info_bar_if_focused(view.command, view) diff --git a/src/core/widgets/code/command_system/commands/set_buffer.py b/src/core/widgets/code/command_system/commands/set_buffer.py new file mode 100644 index 0000000..b24ab71 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/set_buffer.py @@ -0,0 +1,32 @@ +# Python imports + + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from ...source_file import SourceFile +from ..command_helpers import update_info_bar_if_focused + + + +def execute( + view: GtkSource.View, + file: SourceFile, + *args, + **kwargs +): + logger.debug("Command: Set Buffer") + + if not file: + view.command.new_file(view) + return + + view.set_buffer(file.buffer) + + update_info_bar_if_focused(view.command, view) + diff --git a/src/core/widgets/code/command_system/commands/set_buffer_language.py b/src/core/widgets/code/command_system/commands/set_buffer_language.py new file mode 100644 index 0000000..702f8fa --- /dev/null +++ b/src/core/widgets/code/command_system/commands/set_buffer_language.py @@ -0,0 +1,25 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + language: str, + *args, + **kwargs +): + logger.debug("Command: Set Buffer Language") + + buffer = view.get_buffer() + buffer.set_language( + view.language_manager.get_language(language) + ) diff --git a/src/core/widgets/code/command_system/commands/set_buffer_style.py b/src/core/widgets/code/command_system/commands/set_buffer_style.py new file mode 100644 index 0000000..6fd0e2b --- /dev/null +++ b/src/core/widgets/code/command_system/commands/set_buffer_style.py @@ -0,0 +1,25 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + style: str, + *args, + **kwargs +): + logger.debug("Command: Set Buffer Style") + + buffer = view.get_buffer() + buffer.set_style_scheme( + view.style_scheme_manager.get_scheme(style) + ) diff --git a/src/core/widgets/code/command_system/commands/set_focus_border.py b/src/core/widgets/code/command_system/commands/set_focus_border.py new file mode 100644 index 0000000..20510f7 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/set_focus_border.py @@ -0,0 +1,28 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Set Focus Border") + ctx = view.get_parent().get_style_context() + ctx.add_class("source-view-focused") + + if view.sibling_right: + ctx = view.sibling_right.get_parent().get_style_context() + elif view.sibling_left: + ctx = view.sibling_left.get_parent().get_style_context() + + ctx.remove_class("source-view-focused") diff --git a/src/core/widgets/code/command_system/commands/set_miniview.py b/src/core/widgets/code/command_system/commands/set_miniview.py new file mode 100644 index 0000000..67396a5 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/set_miniview.py @@ -0,0 +1,20 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Set MiniView") + event_system.emit("set-mini-view", (view,)) diff --git a/src/core/widgets/code/command_system/commands/show_completion.py b/src/core/widgets/code/command_system/commands/show_completion.py new file mode 100644 index 0000000..73b561f --- /dev/null +++ b/src/core/widgets/code/command_system/commands/show_completion.py @@ -0,0 +1,30 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Show Completion") + completer = view.get_completion() + providers = completer.get_providers() + + if not providers: + view.command.request_completion(view) + return + + completer.start( + providers, + completer.create_context() + ) diff --git a/src/core/widgets/code/command_system/commands/toggle_plugins_ui.py b/src/core/widgets/code/command_system/commands/toggle_plugins_ui.py new file mode 100644 index 0000000..21af296 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/toggle_plugins_ui.py @@ -0,0 +1,21 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Toggle Plugins UI") + view.command.toggle_plugins_ui() diff --git a/src/core/widgets/code/command_system/commands/update_info_bar.py b/src/core/widgets/code/command_system/commands/update_info_bar.py new file mode 100644 index 0000000..f83a102 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/update_info_bar.py @@ -0,0 +1,32 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Update Info Bar") + file = view.command.get_file(view) + + if not file: return + + buffer = file.buffer + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + 1 + column = iter.get_line_offset() + ftype = file.ftype.get_id() if hasattr(file.ftype, "get_id") else file.ftype + + view.command.set_info_labels( + (file.fpath, f"{line}:{column}", ftype, file.encoding) + ) diff --git a/src/core/widgets/code/command_system/commands/zoom_in.py b/src/core/widgets/code/command_system/commands/zoom_in.py new file mode 100644 index 0000000..792d9af --- /dev/null +++ b/src/core/widgets/code/command_system/commands/zoom_in.py @@ -0,0 +1,29 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource +from gi.repository import Pango + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Zoom In") + + ctx = view.get_style_context() + if view.zoom_level < 99: + view.zoom_level += 1 + + font_desc = \ + Pango.FontDescription(f"Monospace {view.zoom_level}") + + view.modify_font(font_desc) diff --git a/src/core/widgets/code/command_system/commands/zoom_out.py b/src/core/widgets/code/command_system/commands/zoom_out.py new file mode 100644 index 0000000..18cc390 --- /dev/null +++ b/src/core/widgets/code/command_system/commands/zoom_out.py @@ -0,0 +1,29 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource +from gi.repository import Pango + +# Application imports + + + +def execute( + view: GtkSource.View, + *args, + **kwargs +): + logger.debug("Command: Zoom Out") + + ctx = view.get_style_context() + if view.zoom_level > 1: + view.zoom_level -= 1 + + font_desc = \ + Pango.FontDescription(f"Monospace {view.zoom_level}") + + view.modify_font(font_desc) diff --git a/src/core/widgets/code/completion_providers/__init__.py b/src/core/widgets/code/completion_providers/__init__.py new file mode 100644 index 0000000..a46072a --- /dev/null +++ b/src/core/widgets/code/completion_providers/__init__.py @@ -0,0 +1,3 @@ +""" + Code Completion Providers Package +""" diff --git a/src/core/widgets/code/completion_providers/provider_response_cache_base.py b/src/core/widgets/code/completion_providers/provider_response_cache_base.py new file mode 100644 index 0000000..fe9e10e --- /dev/null +++ b/src/core/widgets/code/completion_providers/provider_response_cache_base.py @@ -0,0 +1,120 @@ +# Python imports +import re + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') + +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import GtkSource + +# Application imports +from libs.event_factory import Code_Event_Types + + + +class ProviderResponseCacheException(Exception): + ... + + + +class ProviderResponseCacheBase: + def __init__(self): + super(ProviderResponseCacheBase, self).__init__() + + self._icon_theme = Gtk.IconTheme.get_default() + + + def process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + raise ProviderResponseCacheException("ProviderResponseCacheBase 'process_file_load' not implemented...") + + def process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + raise ProviderResponseCacheException("ProviderResponseCacheBase 'process_file_close' not implemented...") + + def process_file_save(self, event: Code_Event_Types.SavedFileEvent): + raise ProviderResponseCacheException("ProviderResponseCacheBase 'process_file_save' not implemented...") + + def process_file_change(self, event: Code_Event_Types.TextChangedEvent): + raise ProviderResponseCacheException("ProviderResponseCacheBase 'process_change' not implemented...") + + def filter(self, word: str) -> list[dict]: + raise ProviderResponseCacheException("ProviderResponseCacheBase 'filter' not implemented...") + + def filter_with_context(self, context: GtkSource.CompletionContext) -> list[dict]: + raise ProviderResponseCacheException("ProviderResponseCacheBase 'filter_with_context' not implemented...") + + + def create_completion_item( + self, + label: str = "", + text: str = "", + info: str = "", + icon: any = None + ) -> dict: + if not label or not text: return + + comp_item = GtkSource.CompletionItem.new() + comp_item.set_label(label) + comp_item.set_text(text) + + if info: + comp_item.set_info(info) + # comp_item.set_markup(f"

{info}

") + + if icon: + comp_item.set_icon( + self.get_icon_for_type(icon.type) + ) + + return comp_item + + def get_all_marks(self, buffer) -> list: + marks: list = [] + iter_ = buffer.get_start_iter() + + while iter_: + marks = iter_.get_marks() + + for mark in marks: + if mark and mark not in marks: + marks.append(mark) + + if not iter_.forward_char(): + break + + return marks + + def get_all_insert_marks(self, buffer) -> list: + marks: list = [] + iter_ = buffer.get_start_iter() + + while iter_: + marks = iter_.get_marks() + + for mark in marks: + if mark.get_name() and "multi_insert_" in mark.get_name(): + marks.append(mark) + + if not iter_.forward_char(): + break + + return marks + + def get_word(self, context) -> str: + start_iter = self.get_iter_correctly(context) + end_iter = start_iter.copy() + + if not start_iter.starts_word(): + start_iter.backward_word_start() + + if not end_iter.ends_line() and not end_iter.ends_word(): + end_iter.forward_word_end() + + buffer = start_iter.get_buffer() + + return buffer.get_text(start_iter, end_iter, False) + + def get_iter_correctly(self, context) -> Gtk.TextIter: + return context.get_iter()[1] if isinstance(context.get_iter(), tuple) else context.get_iter() diff --git a/src/core/widgets/code/controllers/__init__.py b/src/core/widgets/code/controllers/__init__.py new file mode 100644 index 0000000..8f3b747 --- /dev/null +++ b/src/core/widgets/code/controllers/__init__.py @@ -0,0 +1,3 @@ +""" + Code Controllers Package +""" diff --git a/src/core/widgets/code/controllers/commands_controller.py b/src/core/widgets/code/controllers/commands_controller.py new file mode 100644 index 0000000..532bb07 --- /dev/null +++ b/src/core/widgets/code/controllers/commands_controller.py @@ -0,0 +1,30 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.controllers.controller_base import ControllerBase + +from libs.event_factory import Code_Event_Types + +from ..command_system import CommandSystem + + + +class CommandsController(ControllerBase, list): + def __init__(self): + super(CommandsController, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.GetNewCommandSystemEvent): + event.response = self.get_new_command_system() + + def get_new_command_system(self): + command_system = CommandSystem() + command_system.emit = self.emit + command_system.emit_to = self.emit_to + + self.append(command_system) + + return command_system diff --git a/src/core/widgets/code/controllers/completion_controller.py b/src/core/widgets/code/controllers/completion_controller.py new file mode 100644 index 0000000..a8fd9ed --- /dev/null +++ b/src/core/widgets/code/controllers/completion_controller.py @@ -0,0 +1,108 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GLib +from gi.repository import GtkSource + +# Application imports +from libs.controllers.controller_base import ControllerBase +from libs.event_factory import Event_Factory, Code_Event_Types + + + +class CompletionController(ControllerBase): + def __init__(self): + super(CompletionController, self).__init__() + + self._completers: list[GtkSource.Completion] = [] + self._providers: dict[str, GtkSource.CompletionProvider] = {} + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.RegisterCompleterEvent): + self.register_completer(event.completer) + elif isinstance(event, Code_Event_Types.UnregisterCompleterEvent): + self.unregister_completer(event.completer) + elif isinstance(event, Code_Event_Types.UnregisterProviderEvent): + self.unregister_provider(event.provider_name) + elif isinstance(event, Code_Event_Types.RegisterProviderEvent): + self.register_provider( + event.provider_name, + event.provider, + event.language_ids + ) + elif isinstance(event, Code_Event_Types.AddedNewFileEvent): + ... + elif isinstance(event, Code_Event_Types.LoadedNewFileEvent): + self.provider_process_file_load(event) + elif isinstance(event, Code_Event_Types.RemovedFileEvent): + self.provider_process_file_close(event) + elif isinstance(event, Code_Event_Types.SavedFileEvent): + self.provider_process_file_save(event) + elif isinstance(event, Code_Event_Types.TextChangedEvent): + self.provider_process_file_change(event) + elif isinstance(event, Code_Event_Types.RequestCompletionEvent): + self.request_unbound_completion(event) + + + def register_completer(self, completer: GtkSource.Completion): + self._completers.append(completer) + + for provider in self._providers.values(): + completer.add_provider(provider) + + def unregister_completer(self, completer: GtkSource.Completion): + self._completers.remove(completer) + + def register_provider( + self, + provider_name: str, + provider: GtkSource.CompletionProvider, + language_ids: list = [] + ): + self._providers[provider_name] = provider + + for completer in self._completers: + completer.add_provider(provider) + + def unregister_provider(self, provider_name: str): + provider = self._providers[provider_name] + del self._providers[provider_name] + + for completer in self._completers: + completer.remove_provider(provider) + + def provider_process_file_load(self, event: Code_Event_Types.AddedNewFileEvent): + for provider in self._providers.values(): + # if provider.get_name() == "Words Completion": + # provider.register(event.file.buffer) + provider.response_cache.process_file_load(event) + + def provider_process_file_close(self, event: Code_Event_Types.RemovedFileEvent): + for provider in self._providers.values(): + provider.response_cache.process_file_close(event) + + def provider_process_file_save(self, event: Code_Event_Types.SavedFileEvent): + for provider in self._providers.values(): + provider.response_cache.process_file_save(event) + + def provider_process_file_change(self, event: Code_Event_Types.TextChangedEvent): + for provider in self._providers.values(): + provider.response_cache.process_file_change(event) + + def request_unbound_completion(self, event: Code_Event_Types.RequestCompletionEvent): + completer = event.view.get_completion() + providers = [ *self._providers.values() ] + + if event.provider: + if not isinstance(event.provider, list): + providers = [ event.provider ] + else: + providers = event.provider + + completer.start( + providers, + completer.create_context() + ) diff --git a/src/core/widgets/code/controllers/files_controller.py b/src/core/widgets/code/controllers/files_controller.py new file mode 100644 index 0000000..da73b5e --- /dev/null +++ b/src/core/widgets/code/controllers/files_controller.py @@ -0,0 +1,146 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.controllers.controller_base import ControllerBase +from libs.event_factory import Event_Factory, Code_Event_Types + +from ..source_file import SourceFile +from ..source_buffer import SourceBuffer + + + + +class FilesController(ControllerBase, list): + def __init__(self): + super(FilesController, self).__init__() + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.AddNewFileEvent): + self.new_file(event) + elif isinstance(event, Code_Event_Types.PopFileEvent): + self.pop_file(event) + elif isinstance(event, Code_Event_Types.FilterOutLoadedFilesEvent): + self.filter_loaded(event) + elif isinstance(event, Code_Event_Types.RemoveFileEvent): + self.remove_file(event) + elif isinstance(event, Code_Event_Types.GetFileEvent): + self.get_file(event) + elif isinstance(event, Code_Event_Types.GetFilesEvent): + event.response = self + elif isinstance(event, Code_Event_Types.GetSwapFileEvent): + self.get_swap_file(event) + + + def filter_loaded(self, event: Code_Event_Types.FilterOutLoadedFilesEvent): + loaded_paths = {file.fpath for file in self if not file.fpath == "buffer"} + + files = [ + uri for uri in event.uris if not any(path in uri for path in loaded_paths) + ] + event.response = files + + def new_file(self, event: Code_Event_Types.AddNewFileEvent): + file = SourceFile() + file.emit = self.emit + file.emit_to = self.emit_to + + event.response = file + + eve = Event_Factory.create_event( + "added_new_file", + view = event.view, + file = file + ) + self.message(eve) + + self.append(file) + + return file + + def pop_file(self, event: Code_Event_Types.PopFileEvent): + if not event.buffer: return + + for i, file in enumerate(self): + if not event.buffer == file.buffer: continue + + j = self.next_index(i) + next_file = self[j] if not j == -1 else None + popped_file = self.pop(i) + + event.response = [popped_file, next_file] + + eve = Event_Factory.create_event( + "popped_file", + view = event.view, + file = popped_file, + next_file = next_file + ) + self.message(eve) + + return popped_file, next_file + + def remove_file(self, event: Code_Event_Types.RemoveFileEvent): + if not event.buffer: return + + for i, file in enumerate(self): + if not event.buffer == file.buffer: continue + + j = self.next_index(i) + next_file = self[j] if not j == -1 else None + + event.response = next_file + + eve = Event_Factory.create_event( + "removed_file", + view = event.view, + ignore_focus = True, + file = file, + next_file = next_file + ) + self.message(eve) + + self.remove(file) + file.close() + + return next_file + + def get_file(self, event: Code_Event_Types.GetFileEvent): + if not event.buffer: return + + for file in self: + if not event.buffer == file.buffer: continue + + event.response = file + + return file + + def get_swap_file(self, event: Code_Event_Types.GetSwapFileEvent): + if not event.buffer: return + + for i, file in enumerate(self): + if not event.buffer == file.buffer: continue + + j = self.next_index(i) + next_file = self[j] + swapped_file = self[j] if not j == -1 else None + + event.response = [swapped_file, next_file] + + return swapped_file, next_file + + def next_index(self, i): + size = len(self) + + if (i == 0) and (size >= 2): + j = i + 1 + elif (i == (size - 1)) and (size >= 2): + j = i - 1 + elif (size - 1) == 0: + j = -1 + else: + j = i + 1 + + return j diff --git a/src/core/widgets/code/controllers/views/__init__.py b/src/core/widgets/code/controllers/views/__init__.py new file mode 100644 index 0000000..1461e87 --- /dev/null +++ b/src/core/widgets/code/controllers/views/__init__.py @@ -0,0 +1,10 @@ +""" + Code Controllers Package +""" + +from .state_manager import SourceViewStateManager +from .signal_mapper import SourceViewSignalMapper +from .source_views_controller import SourceViewsController + +# State imports +from .states import * \ No newline at end of file diff --git a/src/core/widgets/code/controllers/views/marker_manager.py b/src/core/widgets/code/controllers/views/marker_manager.py new file mode 100644 index 0000000..3b81b68 --- /dev/null +++ b/src/core/widgets/code/controllers/views/marker_manager.py @@ -0,0 +1,188 @@ +# Python imports + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + +# Application imports +from ...mixins.mark_support_mixin import MarkSupportMixin + + + +class MarkerManager(MarkSupportMixin): + + def __init__(self): + super().__init__() + + self.buffer_markers: dict = {} + + self.selection_tag: Gtk.TextTag = Gtk.TextTag.new("selection") + self.selection_tag.props.background = "rgba(111, 168, 220, 0.64)" + self.selection_tag.props.foreground = "#ffffff" + + + def move_by_char(self, buffer, is_forward: bool, is_selection: bool): + self._move(buffer, is_forward, is_selection, mode = "char") + + def move_by_word(self, buffer, is_forward: bool, is_selection: bool): + self._move(buffer, is_forward, is_selection, mode = "word") + + def move_by_line(self, buffer, is_forward: bool, is_selection: bool): + self._move(buffer, is_forward, is_selection, mode = "line") + + def _move(self, buffer, is_forward: bool, is_selection: bool, mode: str): + self.clear_highlight(buffer) + self.insert_selection_tag(buffer) + + for mark_hash in self.buffer_markers: + marker = self.buffer_markers[mark_hash] + start_mark = marker["start_mark"] + end_mark = marker["end_mark"] + has_selection = marker["is_selection"] + + start_itr = buffer.get_iter_at_mark(start_mark) + end_itr = buffer.get_iter_at_mark(end_mark) + + if is_selection: + self.buffer_markers[mark_hash]["is_selection"] = True + + self._move_iter(buffer, end_itr, mode, is_forward) + buffer.move_mark(end_mark, end_itr) + + self._apply_selection(buffer, start_itr, end_itr) + continue + + if has_selection: + caret_itr = buffer.get_iter_at_mark(end_mark) + start_itr = buffer.get_iter_at_mark(start_mark) + is_left_edge = caret_itr.compare(start_itr) <= 0 + is_right_edge = not is_left_edge + can_move = ( + (is_forward and is_right_edge) or + (not is_forward and is_left_edge) + ) + + self.collapse_selection(buffer, mark_hash, start_mark, end_mark, is_forward) + if mode == "word": + if not can_move: continue + + itr = caret_itr + self._move_iter(buffer, itr, mode, is_forward) + buffer.move_mark(start_mark, itr) + buffer.move_mark(end_mark, itr) + + continue + + + # No selection - move both anchor and caret together + self._move_iter(buffer, end_itr, mode, is_forward) + + buffer.move_mark(start_mark, end_itr) + buffer.move_mark(end_mark, end_itr) + + def collapse_selection(self, + buffer, mark_hash, start_mark, end_mark, is_forward: bool + ): + self.buffer_markers[mark_hash]["is_selection"] = False + + start_itr = buffer.get_iter_at_mark(start_mark) + end_itr = buffer.get_iter_at_mark(end_mark) + + # Determine which side is visually the caret + if start_itr.compare(end_itr) <= 0: + left = start_itr + right = end_itr + else: + left = end_itr + right = start_itr + + # If moving forward -> collapse to right edge + collapse_itr = right if is_forward else left + + buffer.move_mark(start_mark, collapse_itr) + buffer.move_mark(end_mark, collapse_itr) + + def move_word_snake_case(self, itr: Gtk.TextIter, count: int): + def is_word(ch): + return ch and (ch.isalnum() or ch == "_") + + def step(fwd): + return itr.forward_cursor_position() if fwd else itr.backward_cursor_position() + + def peek(fwd): + if fwd: return itr.get_char() + tmp = itr.copy() + return tmp.backward_cursor_position() and tmp.get_char() + + def walk(fwd, cond): + while True: + ch = peek(fwd) + if not cond(ch): break + if not step(fwd): return False + + return True + + fwd = count > 0 + + for _ in range(abs(count)): + ch = itr.get_char() if fwd else peek(False) + + if is_word(ch): + # inside word + if not walk(fwd, is_word): return + else: + # in separators -> skip them, then the word + if not walk(fwd, lambda c: not is_word(c)): return + if not walk(fwd, is_word): return + + def _move_iter(self, buffer, itr_, mode: str, is_forward: bool): + if mode == "char": + itr_.forward_char() if is_forward else itr_.backward_char() + elif mode == "word": + self.move_word_snake_case(itr_, 1 if is_forward else -1) + elif mode == "line": + line = itr_.get_line() + offset = itr_.get_line_offset() + + max_line = buffer.get_line_count() - 1 + new_line = line + 1 if is_forward else line - 1 + new_line = max(0, min(max_line, new_line)) + + itr_.set_line(new_line) + self.move_to_offset(offset, itr_) + + def _apply_selection(self, buffer, start_itr, end_itr): + if start_itr.compare(end_itr) <= 0: + buffer.apply_tag(self.selection_tag, start_itr, end_itr) + else: + buffer.apply_tag(self.selection_tag, end_itr, start_itr) + + + def button_release_event(self, source_view, event): + buffer = source_view.get_buffer() + + coords = source_view.window_to_buffer_coords( + Gtk.TextWindowType.TEXT, + event.x, + event.y, + ) + + is_over_text, target_itr, _ = source_view.get_iter_at_position( + coords.buffer_x, + coords.buffer_y, + ) + + if not is_over_text: + target_itr.forward_visible_line() + target_itr.backward_char() + + if self.remove_mark_set(target_itr, buffer): + return + + self.insert_mark_set(target_itr, buffer) + + def key_press_event(self, source_view, event, key_mapper): + ... diff --git a/src/core/widgets/code/controllers/views/signal_mapper.py b/src/core/widgets/code/controllers/views/signal_mapper.py new file mode 100644 index 0000000..5a30405 --- /dev/null +++ b/src/core/widgets/code/controllers/views/signal_mapper.py @@ -0,0 +1,89 @@ +# Python imports + +# Lib imports +import gi + +from gi.repository import GLib + +# Application imports +from ...source_view import SourceView + + + +class SourceViewSignalMapper: + def __init__(self): + self.active_view: SourceView = None + + + def bind_emit(self, emit: callable): + self.emit = emit + + def set_state_manager(self, state_manager): + self.state_manager = state_manager + + def set_buffer_to_active_view(self, buffer): + self.active_view.set_buffer(buffer) + self.active_view.command.exec("update_info_bar") + GLib.idle_add(self._scroll_to_iter) + + def connect_signals(self, source_view: SourceView): + signal_mappings = self._get_signal_mappings() + for signal, handler in signal_mappings.items(): + if not signal == "populate-popup": + source_view.connect(signal, handler) + continue + + source_view.connect_after(signal, handler) + + def disconnect_signals(self, source_view: SourceView): + signal_mappings = self._get_signal_mappings() + for signal, handler in signal_mappings.items(): + source_view.disconnect_by_func(handler) + + def insert_text(self, file, string: str): + return self.state_manager.handle_insert_text(self.active_view, file, string) + + def _scroll_to_iter(self): + buffer = self.active_view.get_buffer() + itr = buffer.get_iter_at_mark( buffer.get_insert() ) + + self.active_view.scroll_to_iter(itr, 0.2, False, 0, 0) + + def _get_signal_mappings(self): + return { + "focus-in-event": self._focus_in_event, + "move-cursor": self._move_cursor, + "key-press-event": self._key_press_event, + "key-release-event": self._key_release_event, + "button-press-event": self._button_press_event, + "button-release-event": self._button_release_event, + "scroll-event": self._scroll_event, + "populate-popup": self._populate_popup + } + + def _focus_in_event(self, source_view: SourceView, eve): + self.active_view = source_view + return self.state_manager.handle_focus_in_event(source_view, eve, self.emit) + + def _move_cursor(self, source_view: SourceView, step, count, extend_selection): + return self.state_manager.handle_move_cursor( + source_view, step, count, extend_selection, self.emit + ) + + def _key_press_event(self, source_view: SourceView, eve): + return self.state_manager.handle_key_press_event(source_view, eve) + + def _key_release_event(self, source_view: SourceView, eve): + return self.state_manager.handle_key_release_event(source_view, eve) + + def _button_press_event(self, source_view: SourceView, eve): + return self.state_manager.handle_button_press_event(source_view, eve) + + def _button_release_event(self, source_view: SourceView, eve): + return self.state_manager.handle_button_release_event(source_view, eve) + + def _scroll_event(self, source_view: SourceView, eve): + return self.state_manager.handle_scroll_event(source_view, eve) + + def _populate_popup(self, source_view: SourceView, menu): + return self.state_manager.handle_populate_popup(source_view, menu, self.emit) diff --git a/src/core/widgets/code/controllers/views/source_views_controller.py b/src/core/widgets/code/controllers/views/source_views_controller.py new file mode 100644 index 0000000..cb66763 --- /dev/null +++ b/src/core/widgets/code/controllers/views/source_views_controller.py @@ -0,0 +1,125 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.controllers.controller_base import ControllerBase +from libs.event_factory import Event_Factory, Code_Event_Types + +from libs.dto.states import SourceViewStates + +from ...source_view import SourceView + +from .state_manager import SourceViewStateManager +from .signal_mapper import SourceViewSignalMapper + + + +class SourceViewsController(ControllerBase, list): + def __init__(self): + super(SourceViewsController, self).__init__() + + self.state_manager: SourceViewStateManager = SourceViewStateManager() + self.signal_mapper: SourceViewSignalMapper = SourceViewSignalMapper() + + self.signal_mapper.bind_emit(self.emit) + self.signal_mapper.set_state_manager(self.state_manager) + + + def _controller_message(self, event: Code_Event_Types.CodeEvent): + if isinstance(event, Code_Event_Types.CreateSourceViewEvent): + event.response = self.create_source_view(event.state) + elif isinstance(event, Code_Event_Types.RemovedFileEvent): + self._remove_file(event) + elif isinstance(event, Code_Event_Types.RegisterCommandEvent): + self._register_command(event) + elif isinstance(event, Code_Event_Types.UnregisterCommandEvent): + self._unregister_command(event) + + if not self.signal_mapper.active_view: return + + if isinstance(event, Code_Event_Types.GetActiveViewEvent): + event.response = self.signal_mapper.active_view + elif isinstance(event, Code_Event_Types.GetSourceViewsEvent): + event.response = self + elif isinstance(event, Code_Event_Types.TextChangedEvent): + self.signal_mapper.active_view.command.exec("update_info_bar") + elif isinstance(event, Code_Event_Types.SetActiveFileEvent): + self.signal_mapper.set_buffer_to_active_view(event.buffer) + elif isinstance(event, Code_Event_Types.TextInsertedEvent): + self.signal_mapper.insert_text(event.file, event.text) + + def _register_command(self, event: Code_Event_Types.RegisterCommandEvent): + if not isinstance(event.binding, list): + event.binding = [ event.binding ] + + for binding in event.binding: + self.state_manager.key_mapper.map_command( + event.command_name, + { + f"{event.binding_mode}": binding + } + ) + + for view in self: + view.command.add_command( + event.command_name, + event.command + ) + + def _unregister_command(self, event: Code_Event_Types.UnregisterCommandEvent): + if not isinstance(event.binding, list): + event.binding = [ event.binding ] + + for binding in event.binding: + self.state_manager.key_mapper.unmap_command( + event.command_name, + { + f"{event.binding_mode}": binding + } + ) + + for view in self: + view.command.remove_command( + event.command_name, + event.command + ) + + def _get_command_system(self): + event = Event_Factory.create_event("get_new_command_system") + self.message_to("commands", event) + command = event.response + + del event + return command + + def _remove_file(self, event: Code_Event_Types.RemovedFileEvent): + for source_view in self: + if not event.file.buffer == source_view.get_buffer(): continue + if not event.next_file: + if source_view.state in [SourceViewStates.INDEPENDENT, SourceViewStates.READONLY]: continue + source_view.command.exec("new_file") + continue + + source_view.set_buffer(event.next_file.buffer) + + def create_source_view(self, state: SourceViewStates = SourceViewStates.INSERT): + source_view: SourceView = SourceView(state) + source_view.command = self._get_command_system() + source_view.command.set_data(source_view) + + self.signal_mapper.connect_signals(source_view) + + self.append(source_view) + return source_view + + def first_map_load(self): + for source_view in self: + if source_view.state in [SourceViewStates.INDEPENDENT, SourceViewStates.READONLY]: continue + source_view.command.exec("new_file") + if not source_view.sibling_left: continue + source_view.get_parent().hide() + + source_view = self[0] + source_view.grab_focus() + source_view.command.exec("load_start_files") diff --git a/src/core/widgets/code/controllers/views/state_manager.py b/src/core/widgets/code/controllers/views/state_manager.py new file mode 100644 index 0000000..6087cca --- /dev/null +++ b/src/core/widgets/code/controllers/views/state_manager.py @@ -0,0 +1,77 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.dto.states import SourceViewStates + +from ...key_mapper import KeyMapper + +from .states import * + + + +class SourceViewStateManager: + def __init__(self): + self.key_mapper: KeyMapper = KeyMapper() + + self.states: dict = { + SourceViewStates.INSERT: SourceViewsInsertState(), + SourceViewStates.MULTIINSERT: SourceViewsMultiInsertState(), + SourceViewStates.COMMAND: SourceViewsCommandState(), + SourceViewStates.READONLY: SourceViewsReadOnlyState(), + SourceViewStates.INDEPENDENT: SourceViewsIndependentState() + } + + + def handle_focus_in_event(self, source_view, eve, emit): + return self.states[source_view.state].focus_in_event(source_view, eve, emit) + + def handle_insert_text(self, source_view, file, text): + return self.states[source_view.state].insert_text(file, text) + + def handle_move_cursor(self, source_view, step, count, extend_selection, emit): + return self.states[source_view.state].move_cursor( + source_view, step, count, extend_selection, emit + ) + + def handle_key_press_event(self, source_view, eve): + return self.states[source_view.state].key_press_event( + source_view, eve, self.key_mapper + ) + + def handle_key_release_event(self, source_view, eve): + return self.states[source_view.state].key_release_event( + source_view, eve, self.key_mapper + ) + + def handle_button_press_event(self, source_view, eve): + self._handle_multi_insert_toggle(source_view, eve) + + return self.states[source_view.state].button_press_event(source_view, eve) + + def handle_button_release_event(self, source_view, eve): + return self.states[source_view.state].button_release_event(source_view, eve) + + def handle_scroll_event(self, source_view, eve): + return self.states[source_view.state].scroll_event( + source_view, eve, self.key_mapper + ) + + def handle_populate_popup(self, source_view, menu, emit): + return self.states[source_view.state].populate_popup( + source_view, menu, emit + ) + + def _handle_multi_insert_toggle(self, source_view, eve): + is_control = self.key_mapper.is_control(eve) + if is_control and not source_view.state == SourceViewStates.MULTIINSERT: + logger.debug("Entered Multi-Insert Mode...") + source_view.state = SourceViewStates.MULTIINSERT + + if not is_control and source_view.state == SourceViewStates.MULTIINSERT: + logger.debug("Entered Regular Insert Mode...") + self.states[source_view.state].marker_manager.clear_mark_sets(source_view) + + source_view.state = SourceViewStates.INSERT + diff --git a/src/core/widgets/code/controllers/views/states/__init__.py b/src/core/widgets/code/controllers/views/states/__init__.py new file mode 100644 index 0000000..2f9ba93 --- /dev/null +++ b/src/core/widgets/code/controllers/views/states/__init__.py @@ -0,0 +1,9 @@ +""" + Code Controllers Views States Package +""" + +from .source_view_insert_state import SourceViewsInsertState +from .source_view_independent_state import SourceViewsIndependentState +from .source_view_multi_insert_state import SourceViewsMultiInsertState +from .source_view_command_state import SourceViewsCommandState +from .source_view_read_only_state import SourceViewsReadOnlyState diff --git a/src/core/widgets/code/controllers/views/states/source_view_base_state.py b/src/core/widgets/code/controllers/views/states/source_view_base_state.py new file mode 100644 index 0000000..4d877e7 --- /dev/null +++ b/src/core/widgets/code/controllers/views/states/source_view_base_state.py @@ -0,0 +1,118 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gdk', '3.0') +from gi.repository import Gdk + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from libs.dto.states import SourceViewStates + + + +class SourceViewsBaseState: + def __init__(self): + super(SourceViewsBaseState, self).__init__() + + + def focus_in_event(self, source_view, eve, emit): + source_view.command.exec("set_miniview") + source_view.command.exec("set_focus_border") + source_view.command.exec("update_info_bar") + + event = Event_Factory.create_event("focused_view", view = source_view) + emit(event) + + def insert_text(self, file, text: str): + + return True + + def move_cursor(self, source_view, step, count, extend_selection, emit): + buffer = source_view.get_buffer() + itr = buffer.get_iter_at_mark( buffer.get_insert() ) + line = itr.get_line() + char = itr.get_line_offset() + event = Event_Factory.create_event( + "cursor_moved", + view = source_view, + buffer = buffer, + line = line, + char = char + ) + + emit(event) + + source_view.command.exec("update_info_bar") + + def button_press_event(self, source_view, eve): + ... + + def button_release_event(self, source_view, eve): + source_view.command.exec("update_info_bar") + + def key_press_event(self, source_view, eve, key_mapper): + command = key_mapper._key_press_event(eve) + is_future = key_mapper._key_release_event(eve) + char_str = key_mapper.get_char(eve) + modkeys_states = key_mapper.get_modkeys_states(eve) + + if is_future: return True + if not command: return False + + response = source_view.command.exec_with_args( + command, source_view, char_str, modkeys_states + ) + + return True if not response else response + + def key_release_event(self, source_view, eve, key_mapper): + command = key_mapper._key_release_event(eve) + is_past = key_mapper._key_press_event(eve) + char_str = key_mapper.get_char(eve) + modkeys_states = key_mapper.get_modkeys_states(eve) + + if is_past: return True + if not command: return False + + response = source_view.command.exec_with_args( + command, source_view, char_str, modkeys_states + ) + + return True if not response else response + + def scroll_event(self, source_view, eve, key_mapper): + is_control = key_mapper.is_control(eve) + + if not is_control: return + + if eve.direction == Gdk.ScrollDirection.SMOOTH: + has_deltas, dx, dy = eve.get_scroll_deltas() + if not has_deltas: return False + + if dy < 0: + source_view.command.exec("zoom_in") + elif dy > 0: + source_view.command.exec("zoom_out") + + return True + + if eve.direction == Gdk.ScrollDirection.UP: + source_view.command.exec("zoom_in") + elif eve.direction == Gdk.ScrollDirection.DOWN: + source_view.command.exec("zoom_out") + + return True + + def populate_popup(self, source_view, menu, emit): + buffer = source_view.get_buffer() + event = Event_Factory.create_event( + "populate_source_view_popup", + buffer = buffer, + menu = menu + ) + + emit(event) + + menu.show_all() diff --git a/src/core/widgets/code/controllers/views/states/source_view_command_state.py b/src/core/widgets/code/controllers/views/states/source_view_command_state.py new file mode 100644 index 0000000..9042270 --- /dev/null +++ b/src/core/widgets/code/controllers/views/states/source_view_command_state.py @@ -0,0 +1,38 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from libs.dto.states import SourceViewStates + +from .source_view_base_state import SourceViewsBaseState + + + +class SourceViewsCommandState(SourceViewsBaseState): + def __init__(self): + super(SourceViewsCommandState, self).__init__() + + + def focus_in_event(self, source_view, eve, emit): + return True + + def insert_text(self, file, text): + return True + + def move_cursor(self, source_view, step, count, extend_selection, emit): + return True + + def button_press_event(self, source_view, eve): + return True + + def button_release_event(self, source_view, eve): + return True + + def key_press_event(self, source_view, eve, key_mapper): + return True + + def key_release_event(self, source_view, eve, key_mapper): + return True diff --git a/src/core/widgets/code/controllers/views/states/source_view_independent_state.py b/src/core/widgets/code/controllers/views/states/source_view_independent_state.py new file mode 100644 index 0000000..f2f61ef --- /dev/null +++ b/src/core/widgets/code/controllers/views/states/source_view_independent_state.py @@ -0,0 +1,16 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from libs.dto.states import SourceViewStates + +from .source_view_base_state import SourceViewsBaseState + + + +class SourceViewsIndependentState(SourceViewsBaseState): + def __init__(self): + super(SourceViewsIndependentState, self).__init__() diff --git a/src/core/widgets/code/controllers/views/states/source_view_insert_state.py b/src/core/widgets/code/controllers/views/states/source_view_insert_state.py new file mode 100644 index 0000000..6ce341b --- /dev/null +++ b/src/core/widgets/code/controllers/views/states/source_view_insert_state.py @@ -0,0 +1,16 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from libs.dto.states import SourceViewStates + +from .source_view_base_state import SourceViewsBaseState + + + +class SourceViewsInsertState(SourceViewsBaseState): + def __init__(self): + super(SourceViewsInsertState, self).__init__() diff --git a/src/core/widgets/code/controllers/views/states/source_view_multi_insert_state.py b/src/core/widgets/code/controllers/views/states/source_view_multi_insert_state.py new file mode 100644 index 0000000..94673a6 --- /dev/null +++ b/src/core/widgets/code/controllers/views/states/source_view_multi_insert_state.py @@ -0,0 +1,117 @@ +# Python imports + +# Lib imports +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +# Application imports +from libs.event_factory import Event_Factory +from libs.dto.states import CursorAction + +from ..marker_manager import MarkerManager + +from .source_view_base_state import SourceViewsBaseState + + + +class SourceViewsMultiInsertState(SourceViewsBaseState): + def __init__(self): + super(SourceViewsMultiInsertState, self).__init__() + + self.cursor_action: CursorAction = None + self.marker_manager: MarkerManager = MarkerManager() + + + def insert_text(self, file, text: str) -> bool: + if not self.marker_manager.buffer_markers: return False + + buffer = file.buffer + + if buffer.is_processing_completion: + return self._insert_completion_text(buffer, text) + + def insert_text(start_itr, end_itr = None): + if not end_itr: + buffer.insert(start_itr, text, -1) + return + + buffer.delete(start_itr, end_itr) + buffer.insert(start_itr, text, -1) + + self.marker_manager.apply_to_marks(buffer, insert_text) + return True + + def _insert_completion_text(self, buffer, text: str) -> bool: + buffer.is_processing_completion = False + + def replace_word(start_itr, end_itr = None): + if not end_itr: + end_itr = start_itr.copy() + + if not start_itr.starts_word(): + start_itr.backward_word_start() + + if not end_itr.ends_word(): + end_itr.forward_word_end() + + buffer.delete(start_itr, end_itr) + buffer.insert(start_itr, text, -1) + + self.marker_manager.apply_to_marks(buffer, replace_word) + return True + + def move_cursor(self, source_view, step, count, is_selection, emit): + is_forward = count > 0 + buffer = source_view.get_buffer() + + if step in [ + Gtk.MovementStep.LOGICAL_POSITIONS, + Gtk.MovementStep.VISUAL_POSITIONS + ]: + self.marker_manager.move_by_char(buffer, is_forward, is_selection) + elif step == Gtk.MovementStep.WORDS: + self.marker_manager.move_by_word(buffer, is_forward, is_selection) + elif step == Gtk.MovementStep.DISPLAY_LINES: + self.marker_manager.move_by_line(buffer, is_forward, is_selection) + + self._signal_cursor_moved(source_view, emit) + + return False + + def key_press_event(self, source_view, event, key_mapper): + char = key_mapper.get_raw_keyname(event).upper() + self.is_control = key_mapper.is_control(event) + self.is_shift = key_mapper.is_shift(event) + + if char.upper() in ["BACKSPACE", "DELETE", "ENTER"]: + self.marker_manager.process_cursor_action( + source_view.get_buffer(), + char.upper() + ) + return False + + return super().key_press_event(source_view, event, key_mapper) + + def button_press_event(self, source_view, event): + return True + + def button_release_event(self, source_view, event): + self.marker_manager.button_release_event(source_view, event) + + def _signal_cursor_moved(self, source_view, emit): + buffer = source_view.get_buffer() + itr = buffer.get_iter_at_mark( buffer.get_insert() ) + line = itr.get_line() + char = itr.get_line_offset() + + event = Event_Factory.create_event( + "cursor_moved", + view = source_view, + buffer = buffer, + line = line, + char = char + ) + + emit(event) + diff --git a/src/core/widgets/code/controllers/views/states/source_view_read_only_state.py b/src/core/widgets/code/controllers/views/states/source_view_read_only_state.py new file mode 100644 index 0000000..0a2aaf0 --- /dev/null +++ b/src/core/widgets/code/controllers/views/states/source_view_read_only_state.py @@ -0,0 +1,38 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from libs.dto.states import SourceViewStates + +from .source_view_base_state import SourceViewsBaseState + + + +class SourceViewsReadOnlyState(SourceViewsBaseState): + def __init__(self): + super(SourceViewsReadOnlyState, self).__init__() + + + def focus_in_event(self, source_view, eve, emit): + return True + + def insert_text(self, file, text): + return True + + def move_cursor(self, source_view, step, count, extend_selection, emit): + return True + + def button_press_event(self, source_view, eve): + return True + + def button_release_event(self, source_view, eve): + return True + + def key_press_event(self, source_view, eve, key_mapper): + return True + + def key_release_event(self, source_view, eve, key_mapper): + return True diff --git a/src/core/widgets/code/key_mapper.py b/src/core/widgets/code/key_mapper.py new file mode 100644 index 0000000..6f788da --- /dev/null +++ b/src/core/widgets/code/key_mapper.py @@ -0,0 +1,183 @@ +# Python imports +import copy +import json + +# Lib imports +import gi +gi.require_version('Gdk', '3.0') +from gi.repository import Gdk + +# Application imports + + + +class NoKeyState: + held: dict = {} + released: dict = {} + +class CtrlKeyState: + held: dict = {} + released: dict = {} + +class ShiftKeyState: + held: dict = {} + released: dict = {} + +class AltKeyState: + held: dict = {} + released: dict = {} + +class CtrlShiftKeyState: + held: dict = {} + released: dict = {} + +class CtrlAltKeyState: + held: dict = {} + released: dict = {} + +class AltShiftKeyState: + held: dict = {} + released: dict = {} + +class CtrlShiftAltKeyState: + held: dict = {} + released: dict = {} + + + +class KeyMapper: + def __init__(self): + super(KeyMapper, self).__init__() + + self.state = NoKeyState + self._map = { + NoKeyState: NoKeyState(), + NoKeyState | CtrlKeyState : CtrlKeyState(), + NoKeyState | ShiftKeyState: ShiftKeyState(), + NoKeyState | AltKeyState : AltKeyState(), + NoKeyState | CtrlKeyState | ShiftKeyState : CtrlShiftKeyState(), + NoKeyState | CtrlKeyState | AltKeyState : CtrlAltKeyState(), + NoKeyState | AltKeyState | ShiftKeyState : AltShiftKeyState(), + NoKeyState | CtrlKeyState | ShiftKeyState | AltKeyState: CtrlShiftAltKeyState(), + } + + self.load_map() + + + def load_map(self): + self.states = copy.deepcopy(self._map) + bindings_file = f"{settings_manager.path_manager.get_home_config_path()}/code-key-bindings.json" + + with open(bindings_file, 'r') as f: + data = json.load(f)["keybindings"] + for command in data: + self.map_command( command, data[command] ) + + def re_map(self): + self.states = copy.deepcopy(self._map) + + def map_command(self, command, entry): + press_state = "held" if "held" in entry else "released" + keyname = entry[press_state] + + state = NoKeyState + if "" in keyname: + state = state | CtrlKeyState + if "" in keyname: + state = state | ShiftKeyState + if "" in keyname: + state = state | AltKeyState + + keyname = keyname.replace("", "") \ + .replace("", "") \ + .replace("", "") \ + .lower() + + getattr(self.states[state], press_state)[keyname] = command + + def unmap_command(self, command, entry): + press_state = "held" if "held" in entry else "released" + keyname = entry[press_state] + + state = NoKeyState + if "" in keyname: + state = state | CtrlKeyState + if "" in keyname: + state = state | ShiftKeyState + if "" in keyname: + state = state | AltKeyState + + keyname = keyname.replace("", "") \ + .replace("", "") \ + .replace("", "") \ + .lower() + + mapping = getattr(self.states[state], press_state) + + if keyname in mapping and mapping[keyname] == command: + del mapping[keyname] + + def _key_press_event(self, eve): + keyname = self.get_keyname(eve) + char_str = self.get_char(eve) + + self._set_key_state(eve) + if keyname in self.states[self.state].held: + return self.states[self.state].held[keyname] + + if char_str in self.states[self.state].held: + return self.states[self.state].held[char_str] + + + def _key_release_event(self, eve): + keyname = self.get_keyname(eve) + char_str = self.get_char(eve) + + self._set_key_state(eve) + if keyname in self.states[self.state].released: + return self.states[self.state].released[keyname] + + if char_str in self.states[self.state].released: + return self.states[self.state].released[char_str] + + def _set_key_state(self, eve): + is_control, \ + is_shift, \ + is_alt = self.get_modkeys_states(eve) + + self.state = NoKeyState + if is_control: + self.state = self.state | CtrlKeyState + if is_shift: + self.state = self.state | ShiftKeyState + if is_alt: + self.state = self.state | AltKeyState + + def is_control(self, eve): + modifiers = Gdk.ModifierType(eve.get_state() & ~Gdk.ModifierType.LOCK_MASK) + return modifiers & Gdk.ModifierType.CONTROL_MASK + + def is_shift(self, eve): + modifiers = Gdk.ModifierType(eve.get_state() & ~Gdk.ModifierType.LOCK_MASK) + return modifiers & Gdk.ModifierType.SHIFT_MASK + + def get_raw_keyname(self, eve) -> str: + return Gdk.keyval_name(eve.keyval) + + def get_modkeys_states(self, eve) -> tuple: + modifiers = Gdk.ModifierType(eve.get_state() & ~Gdk.ModifierType.LOCK_MASK) + is_control = modifiers & Gdk.ModifierType.CONTROL_MASK + is_shift = modifiers & Gdk.ModifierType.SHIFT_MASK + + try: + is_alt = modifiers & Gdk.ModifierType.ALT_MASK + except: + is_alt = modifiers & Gdk.ModifierType.MOD1_MASK + + return is_control, is_shift, is_alt + + def get_keyname(self, eve) -> str: + return Gdk.keyval_name(eve.keyval).lower() + + def get_char(self, eve) -> str: + return chr( Gdk.keyval_to_unicode(eve.keyval) ) diff --git a/src/core/widgets/code/mixins/__init__.py b/src/core/widgets/code/mixins/__init__.py new file mode 100644 index 0000000..96544fc --- /dev/null +++ b/src/core/widgets/code/mixins/__init__.py @@ -0,0 +1,3 @@ +""" + Code Mixins Package +""" \ No newline at end of file diff --git a/src/core/widgets/code/mixins/command_system_mixin.py b/src/core/widgets/code/mixins/command_system_mixin.py new file mode 100644 index 0000000..6dd7804 --- /dev/null +++ b/src/core/widgets/code/mixins/command_system_mixin.py @@ -0,0 +1,83 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from ..source_view import SourceView + + + +class CommandSystemMixin: + def toggle_plugins_ui(self): + event = Event_Factory.create_event( "toggle_plugins_ui" ) + + self.emit_to("plugins", event) + + def filter_out_loaded_files(self, uris: list[str]): + event = Event_Factory.create_event( + "filter_out_loaded_files", + uris = uris + ) + + self.emit_to("files", event) + + return event.response + + def set_info_labels(self, data: tuple[str]): + event = Event_Factory.create_event( + "set_info_labels", + info = data + ) + + self.emit_to("plugins", event) + + def get_file(self, view: SourceView): + event = Event_Factory.create_event( + "get_file", + view = view, + buffer = view.get_buffer() + ) + + self.emit_to("files", event) + + return event.response + + def get_swap_file(self, view: SourceView): + event = Event_Factory.create_event( + "get_swap_file", + view = view, + buffer = view.get_buffer() + ) + + self.emit_to("files", event) + + return event.response + + def new_file(self, view: SourceView): + event = Event_Factory.create_event("add_new_file", view = view) + + self.emit_to("files", event) + + return event.response + + def remove_file(self, view: SourceView): + event = Event_Factory.create_event( + "remove_file", + view = view, + buffer = view.get_buffer() + ) + + self.emit_to("files", event) + + return event.response + + def request_completion(self, view: SourceView): + event = Event_Factory.create_event( + "request_completion", + view = view, + buffer = view.get_buffer() + ) + + self.emit_to("completion", event) diff --git a/src/core/widgets/code/mixins/mark_support_mixin.py b/src/core/widgets/code/mixins/mark_support_mixin.py new file mode 100644 index 0000000..430e013 --- /dev/null +++ b/src/core/widgets/code/mixins/mark_support_mixin.py @@ -0,0 +1,133 @@ +# Python imports +import random + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + +# Application imports + + + +class MarkSupportMixin: + def clear_mark_sets(self, source_view): + buffer = source_view.get_buffer() + self.clear_highlight(buffer) + + for mark_set in self.buffer_markers.values(): + start_mark, end_mark, is_selection = mark_set.values() + start_mark.set_visible(False) + buffer.delete_mark(start_mark) + buffer.delete_mark(end_mark) + + self.buffer_markers.clear() + + def insert_selection_tag(self, buffer): + tag_table = buffer.get_tag_table() + if not tag_table.lookup("selection"): + tag_table.add(self.selection_tag) + + def clear_highlight(self, buffer): + if not self.selection_tag: return + start_itr, end_itr = buffer.get_bounds() + buffer.remove_tag(self.selection_tag, start_itr, end_itr) + + def apply_to_marks(self, buffer, operation): + buffer.block_insert_after_signal() + buffer.begin_user_action() + + try: + with buffer.freeze_notify(): + for mark_hash in self.buffer_markers: + marker = self.buffer_markers[mark_hash] + start_mark = marker["start_mark"] + end_mark = marker["end_mark"] + has_selection = marker["is_selection"] + + start_itr = buffer.get_iter_at_mark(start_mark) + end_itr = buffer.get_iter_at_mark(end_mark) + + if has_selection: + operation(start_itr, end_itr) + self.collapse_selection( + buffer, mark_hash, start_mark, end_mark, False + ) + else: + operation(start_itr) + finally: + buffer.end_user_action() + buffer.unblock_insert_after_signal() + + def process_cursor_action(self, buffer, action): + def remove_text(start_itr, end_itr = None): + if end_itr: + buffer.delete(start_itr, end_itr) + return + + buffer.backspace(start_itr, interactive = True, default_editable = True) + + def delete_text(start_itr, end_itr = None): + if end_itr: + buffer.delete(start_itr, end_itr) + return + + start_itr.forward_char() + buffer.backspace(start_itr, interactive = True, default_editable = True) + + if action == "BACKSPACE": + self.apply_to_marks(buffer, remove_text) + elif action == "DELETE": + self.apply_to_marks(buffer, delete_text) + elif action == "ENTER": + ... + + def move_to_offset(self, offset, start_itr): + line_itr = start_itr.copy() + + line_itr.forward_to_line_end() + + next_line_length = line_itr.get_line_offset() + new_offset = min(offset, next_line_length) + start_itr.set_line_offset(new_offset) + + def insert_mark_set(self, target_iter, buffer): + random_bits = random.getrandbits(128) + hash = "%032x" % random_bits + + start_mark = Gtk.TextMark.new( + name = f"multi-insert-start-{hash}", + left_gravity = False + ) + + end_mark = Gtk.TextMark.new( + name = f"multi-insert-end-{hash}", + left_gravity = False + ) + + buffer.add_mark(start_mark, target_iter) + buffer.add_mark(end_mark, target_iter) + start_mark.set_visible(True) + self.buffer_markers[f"{hash}"] = { + "start_mark": start_mark, + "end_mark": end_mark, + "is_selection": False + } + + def remove_mark_set(self, target_iter, buffer) -> bool: + marks = target_iter.get_marks() + + for mark_hash in self.buffer_markers: + start_mark, end_mark, is_selection = self.buffer_markers[mark_hash].values() + if not start_mark in marks: continue + + start_mark.set_visible(False) + buffer.delete_mark(start_mark) + buffer.delete_mark(end_mark) + del self.buffer_markers[mark_hash] + + return True + + return False diff --git a/src/core/widgets/code/mixins/source_view_dnd_mixin.py b/src/core/widgets/code/mixins/source_view_dnd_mixin.py new file mode 100644 index 0000000..bee3467 --- /dev/null +++ b/src/core/widgets/code/mixins/source_view_dnd_mixin.py @@ -0,0 +1,44 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports + + + +class SourceViewDnDMixin: + + def _set_up_dnd(self): + PLAIN_TEXT_TARGET_TYPE = 70 + URI_TARGET_TYPE = 80 + text_target = Gtk.TargetEntry.new('text/plain', Gtk.TargetFlags(0), PLAIN_TEXT_TARGET_TYPE) + uri_target = Gtk.TargetEntry.new('text/uri-list', Gtk.TargetFlags(0), URI_TARGET_TYPE) + targets = [ text_target, uri_target ] + + self.drag_dest_set_target_list(targets) + + def _on_drag_data_received(self, widget, drag_context, x, y, data, info, time): + if info == 70: return + + if info == 80: + uris = data.get_uris() + + if not uris: + uris = data.get_text().split("\n") + + self._on_uri_data_received(uris) + + def _on_uri_data_received(self, uris: list[str]): + uris = self.command.filter_out_loaded_files(uris) + if not uris: return + + uri = uris.pop(0) + + self.command.exec_with_args("dnd_load_file_to_buffer", self, uri) + + if not uris: return + + self.command.exec_with_args("dnd_load_files", self, uris) diff --git a/src/core/widgets/code/source_buffer.py b/src/core/widgets/code/source_buffer.py new file mode 100644 index 0000000..d89462c --- /dev/null +++ b/src/core/widgets/code/source_buffer.py @@ -0,0 +1,74 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('GtkSource', '4') +from gi.repository import GtkSource + +# Application imports + + + +class SourceBuffer(GtkSource.Buffer): + def __init__(self): + super(SourceBuffer, self).__init__() + + self._handler_ids = [] + self.is_processing_completion: bool = False + + self.create_tag( + "search-highlight", + background = "yellow", + foreground = "black" + ) + + + def set_signals( + self, + _changed, + _after_changed, + _mark_set, + _insert_text, + _after_insert_text, + _modified_changed, + ): + + self._handler_ids = [ + self.connect("changed", _changed), + self.connect_after("changed", _after_changed), + self.connect("mark-set", _mark_set), + self.connect("insert-text", _insert_text), + self.connect_after("insert-text", _after_insert_text), + self.connect("modified-changed", _modified_changed) + ] + + def block_changed_signal(self): + self.handler_block(self._handler_ids[0]) + + def block_changed_after_signal(self): + self.handler_block(self._handler_ids[1]) + + def block_insert_after_signal(self): + self.handler_block(self._handler_ids[4]) + + def block_modified_changed_signal(self): + self.handler_block(self._handler_ids[5]) + + def unblock_changed_signal(self): + self.handler_unblock(self._handler_ids[0]) + + def unblock_changed_after_signal(self): + self.handler_unblock(self._handler_ids[1]) + + def unblock_insert_after_signal(self): + self.handler_unblock(self._handler_ids[4]) + + def unblock_modified_changed_signal(self): + self.handler_unblock(self._handler_ids[5]) + + def clear_signals(self): + for handle_id in self._handler_ids: + self.disconnect(handle_id) + + def __del__(self): + self.clear_signals() diff --git a/src/core/widgets/code/source_file.py b/src/core/widgets/code/source_file.py new file mode 100644 index 0000000..1e3efc2 --- /dev/null +++ b/src/core/widgets/code/source_file.py @@ -0,0 +1,229 @@ +# Python imports +import os + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') + +from gi.repository import Gtk +from gi.repository import GLib +from gi.repository import GtkSource +from gi.repository import Gio + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types + +from .source_buffer import SourceBuffer + + + +class SourceFile(GtkSource.File): + def __init__(self): + super(SourceFile, self).__init__() + + self.encoding: str = "UTF-8" + self.fname: str = "buffer" + self.fpath: str = "buffer" + self.ftype: str = "buffer" + self.was_deleted: bool = False + self.buffer: SourceBuffer = SourceBuffer() + + self._set_signals() + + + def _set_signals(self): + self.buffer.set_signals( + self._changed, + self._after_changed, + self._mark_set, + self._insert_text, + self._after_insert_text, + self._modified_changed + ) + + def _changed(self, buffer: SourceBuffer): + ... + + def _after_changed(self, buffer: SourceBuffer): + event = Event_Factory.create_event( + "text_changed", + file = self, + buffer = buffer + ) + self.emit(event) + + def _insert_text( + self, + buffer: SourceBuffer, + location: Gtk.TextIter, + text: str, length: int + ): + ... + + def _after_insert_text( + self, + buffer: SourceBuffer, + location: Gtk.TextIter, + text: str, length: int + ): + event = Event_Factory.create_event( + "text_inserted", + file = self, + buffer = self.buffer, + location = location, + text = text, + length = length + ) + + # Note: 'idle_add' needed b/c markers don't get thir positions + # updated relative to the initial insert. + # If not used, seg faults galor during multi insert. + GLib.idle_add(self.emit, event) + + def _mark_set( + self, + buffer: SourceBuffer, + location: Gtk.TextIter, + mark: Gtk.TextMark + ): + # event = Event_Factory.create_event( + # "mark_set", + # file = self, buffer = buffer + # ) + + # self.emit(event) + ... + + def _modified_changed(self, buffer: SourceBuffer): + event = Event_Factory.create_event( + "modified_changed", + file = self, buffer = buffer + ) + + self.emit(event) + + def _write_file(self, gfile: Gio.File): + if not gfile: return + + with open(gfile.get_path(), 'w') as f: + start_itr, end_itr = self.buffer.get_bounds() + text = self.buffer.get_text(start_itr, end_itr, True) + + f.write(text) + + if self.was_deleted: + self.was_deleted = False + self.set_location( None ) + self.set_location( gfile ) + + return gfile + + def _load_data(self, text: str, is_new: bool = True): + undo_manager = self.buffer.get_undo_manager() + + self.buffer.block_changed_signal() + self.buffer.block_changed_after_signal() + self.buffer.block_modified_changed_signal() + + def move_insert_to_start(): + start_itr = self.buffer.get_start_iter() + self.buffer.place_cursor(start_itr) + undo_manager.begin_not_undoable_action() + + with self.buffer.freeze_notify(): + start_itr, end_itr = self.buffer.get_bounds() + + self.buffer.delete(start_itr, end_itr) + self.buffer.insert(start_itr, text, -1) + self.is_externally_modified() + GLib.idle_add(move_insert_to_start) + + undo_manager.end_not_undoable_action() + self.buffer.set_modified(False) + + if is_new: + eve = Event_Factory.create_event( + "loaded_new_file", + file = self + ) + self.emit(eve) + + self.buffer.unblock_changed_signal() + self.buffer.unblock_changed_after_signal() + self.buffer.unblock_modified_changed_signal() + + def is_externally_modified(self) -> bool: + stat = os.stat(self.fpath) + current = (stat.st_mtime_ns, stat.st_size) + + is_modified = \ + hasattr(self, "last_state") and not current == self.last_state + + self.last_state = current + return is_modified + + def load_path(self, gfile: Gio.File): + if not gfile: return + loaded, contents, etag_out = gfile.load_contents() + if not loaded: raise Exception("File couldn't be loaded...'") + + text = contents.decode("UTF-8") + info = gfile.query_info('standard::content-type', Gio.FileQueryInfoFlags.NONE, None) + content_type = info.get_content_type() + self.ftype = Gio.content_type_get_mime_type(content_type) \ + .replace("application/", "") \ + .replace("text/", "") \ + .replace("x-", "") + + self.set_path(gfile) + logger.debug(f"File content type: {self.ftype}") + self._load_data(text) + + def set_path(self, gfile: Gio.File): + if not gfile: return + self.set_location(gfile) + + self.fpath = gfile.get_path() + self.fname = gfile.get_basename() + + event = Event_Factory.create_event("file_path_set", file = self) + self.emit(event) + + def reload(self): + loaded, contents, etag_out = self.get_location().load_contents() + if not loaded: raise Exception("File couldn't be re-loaded...'") + + text = contents.decode("UTF-8") + self._load_data(text, False) + + def save(self): + self._write_file( self.get_location() ) + + self.is_externally_modified() + self.buffer.set_modified(False) + event = Event_Factory.create_event( + "saved_file", + file = self, buffer = self.buffer + ) + + self.emit(event) + + def save_as(self): + file = event_system.emit_and_await("save-file-dialog") + if not file: return + + self._write_file(file) + self.set_path(file) + + return file + + def close(self): + del self.buffer + + def emit(self, event: Code_Event_Types.CodeEvent): + ... + + def emit_to(self, controller: str, event: Code_Event_Types.CodeEvent): + ... diff --git a/src/core/widgets/code/source_view.py b/src/core/widgets/code/source_view.py new file mode 100644 index 0000000..4f348cd --- /dev/null +++ b/src/core/widgets/code/source_view.py @@ -0,0 +1,72 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('GtkSource', '4') + +from gi.repository import Gtk +from gi.repository import GtkSource + +# Application imports +from libs.dto.states import SourceViewStates + +from .mixins.source_view_dnd_mixin import SourceViewDnDMixin + + + +class SourceView(GtkSource.View, SourceViewDnDMixin): + def __init__(self, state: SourceViewStates = SourceViewStates.INSERT): + super(SourceView, self).__init__() + + self.state = state + + self.sibling_right = None + self.sibling_left = None + + self._setup_styles() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + + def _setup_styles(self): + self.zoom_level = settings_manager.settings.theming.default_zoom + ctx = self.get_style_context() + + ctx.add_class("source-view") + + self.set_vexpand(True) + self.set_bottom_margin(800) + + self.set_show_line_marks(True) + self.set_show_line_numbers(True) + self.set_smart_backspace(True) + self.set_indent_on_tab(True) + self.set_insert_spaces_instead_of_tabs(True) + self.set_auto_indent(True) + self.set_monospace(True) + self.set_tab_width(4) + self.set_show_right_margin(True) + self.set_right_margin_position(80) + self.set_background_pattern(0) # 0 = None, 1 = Grid + self.set_highlight_current_line(True) + + def _setup_signals(self): + self.connect("drag-data-received", self._on_drag_data_received) + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + self.language_manager = GtkSource.LanguageManager() + self.style_scheme_manager = GtkSource.StyleSchemeManager() + + self.style_scheme_manager.append_search_path( + f"{settings_manager.path_manager.get_home_config_path()}/code_styles" + ) + self.syntax_theme = self.style_scheme_manager.get_scheme( + f"{settings_manager.settings.theming.syntax_theme}" + ) + + self._set_up_dnd() diff --git a/src/core/widgets/controls/open_files_button.py b/src/core/widgets/controls/open_files_button.py index d29eaea..df5e43c 100644 --- a/src/core/widgets/controls/open_files_button.py +++ b/src/core/widgets/controls/open_files_button.py @@ -35,7 +35,7 @@ class OpenFilesButton(Gtk.Button): self.connect("button-release-event", self._open_files) def _subscribe_to_events(self): - event_system.subscribe("open_files", self._open_files) + event_system.subscribe("open-files", self._open_files) def _load_widgets(self): ... diff --git a/src/core/widgets/controls/transparency_scale.py b/src/core/widgets/controls/transparency_scale.py index 223b59a..5332e38 100644 --- a/src/core/widgets/controls/transparency_scale.py +++ b/src/core/widgets/controls/transparency_scale.py @@ -38,11 +38,11 @@ class TransparencyScale(Gtk.Scale): adjust = self.get_adjustment() adjust.set_lower(0) adjust.set_upper(100) - adjust.set_value(settings.theming.transparency) + adjust.set_value(settings_manager.settings.theming.transparency) adjust.set_step_increment(1.0) def _update_transparency(self, range): event_system.emit("remove-transparency") tp = int(range.get_value()) - settings.theming.transparency = tp + settings_manager.settings.theming.transparency = tp event_system.emit("update-transparency") \ No newline at end of file diff --git a/src/core/widgets/separator_widget.py b/src/core/widgets/separator_widget.py new file mode 100644 index 0000000..533bdbe --- /dev/null +++ b/src/core/widgets/separator_widget.py @@ -0,0 +1,36 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports + + + +class Separator(Gtk.Separator): + def __init__(self, id: str = None, ORIENTATION: int = 0): + super(Separator, self).__init__() + + if id: + widget_registery.expose_object(id, self) + + self.ORIENTATION = ORIENTATION + self._setup_styling() + self._setup_signals() + self._load_widgets() + + self.show() + + + def _setup_styling(self): + # HORIZONTAL = 0, VERTICAL = 1 + self.set_orientation(self.ORIENTATION) + + + def _setup_signals(self): + ... + + def _load_widgets(self): + ... diff --git a/src/core/widgets/vte_widget.py b/src/core/widgets/vte_widget.py index 7a5f18d..3e931d4 100644 --- a/src/core/widgets/vte_widget.py +++ b/src/core/widgets/vte_widget.py @@ -12,7 +12,6 @@ from gi.repository import GLib from gi.repository import Vte # Application imports -from libs.dto.event import Event @@ -46,6 +45,7 @@ class VteWidget(Vte.Terminal): ctx.add_class("vte-widget") self.set_clear_background(False) + self.set_hexpand(True) self.set_enable_sixel(True) self.set_cursor_shape( Vte.CursorShape.IBEAM ) @@ -59,27 +59,29 @@ class VteWidget(Vte.Terminal): ... def _do_session_spawn(self): + env = [ + "DISPLAY=:0", + "LC_ALL=C", + "TERM='xterm-256color'", + f"HOME='{settings_manager.path_manager.get_home_path()}'", + "XDG_RUNTIME_DIR='/run/user/1000'", + f"XAUTHORITY='{settings_manager.path_manager.get_home_path()}/.Xauthority'", + "HISTFILE=/dev/null", + "HISTSIZE=0", + "HISTFILESIZE=0", + "PS1=\\h@\\u \\W -->: ", + ] + self.spawn_sync( Vte.PtyFlags.DEFAULT, - settings_manager.get_home_path(), + settings_manager.path_manager.get_home_path(), ["/bin/bash"], - [], + env, GLib.SpawnFlags.DEFAULT, None, None, ) - # Note: '-->:' is used as a delimiter to split on to get command actual. - # !!! DO NOT REMOVE UNLESS CODE UPDATED ACCORDINGLY !!! startup_cmds = [ - "env -i /bin/bash --noprofile --norc\n", - "export TERM='xterm-256color'\n", - "export LC_ALL=C\n", - "export XDG_RUNTIME_DIR='/run/user/1000'\n", - "export DISPLAY=:0\n", - f"export XAUTHORITY='{settings_manager.get_home_path()}/.Xauthority'\n", - f"\nexport HOME='{settings_manager.get_home_path()}'\n", - "export PS1='\\h@\\u \\W -->: '\n", - "clear\n" ] for i in startup_cmds: @@ -93,6 +95,9 @@ class VteWidget(Vte.Terminal): if not text.encode() == "\r".encode(): return text, attributes = self.get_text() + + if not text: return + lines = text.strip().splitlines() command_ran = None diff --git a/src/core/widgets/webkit/webkit_ui.py b/src/core/widgets/webkit/webkit_ui.py index 263e491..69d7faf 100644 --- a/src/core/widgets/webkit/webkit_ui.py +++ b/src/core/widgets/webkit/webkit_ui.py @@ -1,4 +1,5 @@ # Python imports +from pathlib import Path import json # Lib imports @@ -6,20 +7,23 @@ import gi gi.require_version('Gdk', '3.0') gi.require_version('WebKit2', '4.0') from gi.repository import Gdk +from gi.repository import Gtk +from gi.repository import Gio from gi.repository import WebKit2 # Application imports -from libs.settings.other.webkit_ui_settings import WebkitUISettings -from libs.dto.event import Event +from libs.settings.webkit.webkit_ui_settings import WebkitUISettings +from libs.dto.base_event import BaseEvent class WebkitUI(WebKit2.WebView): def __init__(self): super(WebkitUI, self).__init__() + self._load_settings() self._setup_styling() + self._setup_signals() self._subscribe_to_events() - self._load_view() self._setup_content_manager() self.show_all() @@ -30,21 +34,15 @@ class WebkitUI(WebKit2.WebView): self.set_hexpand(True) self.set_background_color( Gdk.RGBA(0, 0, 0, 0.0) ) + def _setup_signals(self): + self.connect("context-menu", self._on_context_menu) + def _subscribe_to_events(self): event_system.subscribe(f"ui-message", self.ui_message) def _load_settings(self): self.set_settings( WebkitUISettings() ) - def _load_view(self): - path = settings_manager.get_context_path() - data = None - - with open(f"{path}/index.html", "r") as f: - data = f.read() - - self.load_html(content = data, base_uri = f"file://{path}/") - def _setup_content_manager(self): content_manager = self.get_user_content_manager() content_manager.connect("script-message-received", self._process_js_message) @@ -55,11 +53,49 @@ class WebkitUI(WebKit2.WebView): message = js_value.to_string() try: - event = Event( **json.loads(message) ) + event = BaseEvent( **json.loads(message) ) event_system.emit("handle-bridge-event", (event,)) except Exception as e: logger.info(e) + def _on_context_menu(self, web_view, context_menu, event, hit_test_result): + action = Gio.SimpleAction.new("Developer Tools", None) + item = WebKit2.ContextMenuItem.new_from_gaction(action, "Developer Tools") + + def show_developer_tools(action, parameter): + inspector = self.get_inspector() + inspector.show() + + action.connect("activate", show_developer_tools) + + context_menu.append(item) + + def load_url(self, url: str = ""): + if not url: + url = "https://duckduckgo.com/" + + self.load_uri(url) + + def load_context_base_path(self, path: str = ""): + if not path: + path = settings_manager.path_manager.get_context_path() + + base_path = Path(path) + index_file = base_path / "index.html" + + if not index_file.exists(): + raise FileNotFoundError(f"index.html not found in {base_path}") + + try: + data = index_file.read_text(encoding = "utf-8") + except Exception as e: + raise RuntimeError(f"Failed to read {index_file}: {e}") + + self.load_html( + content = data, + base_uri = index_file.as_uri() + ) + def ui_message(self, message, mtype): command = f"displayMessage('{message}', '{mtype}', '3')" self.run_javascript(command, None, None) diff --git a/src/core/window.py b/src/core/window.py index 8199215..ca907b3 100644 --- a/src/core/window.py +++ b/src/core/window.py @@ -21,7 +21,7 @@ from core.controllers.base_controller import BaseController -class ControllerStartExceptiom(Exception): +class ControllerStartException(Exception): ... @@ -42,7 +42,6 @@ class Window(Gtk.ApplicationWindow): self._setup_styling() self._setup_signals() self._subscribe_to_events() - self._load_widgets() self._set_window_data() self._set_size_constraints() @@ -53,7 +52,7 @@ class Window(Gtk.ApplicationWindow): def _setup_styling(self): self.set_title(f"{APP_NAME}") - self.set_icon_from_file( settings_manager.get_window_icon() ) + self.set_icon_from_file( settings_manager.path_manager.get_window_icon() ) self.set_decorated(True) self.set_skip_pager_hint(False) self.set_skip_taskbar_hint(False) @@ -62,11 +61,12 @@ class Window(Gtk.ApplicationWindow): ctx = self.get_style_context() ctx.add_class("main-window") - ctx.add_class(f"mw_transparency_{settings.theming.transparency}") + ctx.add_class(f"mw_transparency_{settings_manager.settings.theming.transparency}") def _setup_signals(self): self.connect("focus-in-event", self._on_focus_in_event) self.connect("focus-out-event", self._on_focus_out_event) + self.connect("show", self._handle_show) self.connect("delete-event", self.stop) GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self.stop) @@ -75,7 +75,13 @@ class Window(Gtk.ApplicationWindow): event_system.subscribe("tear-down", self.stop) event_system.subscribe("load-interactive-debug", self._load_interactive_debug) + def _handle_show(self, widget): + self.disconnect_by_func( self._handle_show ) + self._load_widgets() + def _load_widgets(self): + widget_registery.expose_object("main-window", self) + if settings_manager.is_debug(): self.set_interactive_debugging(True) @@ -96,12 +102,12 @@ class Window(Gtk.ApplicationWindow): return 'X11' def _set_size_constraints(self): - _window_x = settings.config.main_window_x - _window_y = settings.config.main_window_y - _min_width = settings.config.main_window_min_width - _min_height = settings.config.main_window_min_height - _width = settings.config.main_window_width - _height = settings.config.main_window_height + _window_x = settings_manager.settings.config.main_window_x + _window_y = settings_manager.settings.config.main_window_y + _min_width = settings_manager.settings.config.main_window_min_width + _min_height = settings_manager.settings.config.main_window_min_height + _width = settings_manager.settings.config.main_window_width + _height = settings_manager.settings.config.main_window_height self.move(_window_x, _window_y - 28) self.set_size_request(_min_width, _min_height) @@ -111,16 +117,15 @@ class Window(Gtk.ApplicationWindow): screen = self.get_screen() visual = screen.get_rgba_visual() - if visual and screen.is_composited() and settings.config.make_transparent == 0: + if visual and screen.is_composited() and settings_manager.settings.config.make_transparent == 0: self.set_visual(visual) self.set_app_paintable(True) # self.connect("draw", self._area_draw) # bind css file cssProvider = Gtk.CssProvider() - cssProvider.load_from_path( settings_manager.get_css_file() ) - screen = Gdk.Screen.get_default() styleContext = Gtk.StyleContext() + cssProvider.load_from_path( settings_manager.path_manager.get_css_file() ) styleContext.add_provider_for_screen(screen, cssProvider, Gtk.STYLE_PROVIDER_PRIORITY_USER) def _area_draw(self, widget: Gtk.ApplicationWindow, cr: cairo.Context) -> None: diff --git a/src/libs/controllers/__init__.py b/src/libs/controllers/__init__.py new file mode 100644 index 0000000..1dd2ffc --- /dev/null +++ b/src/libs/controllers/__init__.py @@ -0,0 +1,3 @@ +""" + Libs Controllers Package +""" \ No newline at end of file diff --git a/src/libs/controllers/controller_base.py b/src/libs/controllers/controller_base.py new file mode 100644 index 0000000..3997ba6 --- /dev/null +++ b/src/libs/controllers/controller_base.py @@ -0,0 +1,47 @@ +# Python imports + +# Lib imports + +# Application imports +from ..singleton import Singleton + +from ..dto.base_event import BaseEvent + +from .emit_dispatcher import EmitDispatcher +from .controller_message_bus import ControllerMessageBus + + + +class ControllerBaseException(Exception): + ... + + + +class ControllerBase(Singleton, EmitDispatcher): + def __init__(self): + super(ControllerBase, self).__init__() + + self.controller_message_bus: ControllerMessageBus = None + + + def _controller_message(self, event: BaseEvent): + raise ControllerBaseException("Controller Base '_controller_message' must be overridden...") + + def set_controller_message_bus(self, controller_message_bus: ControllerMessageBus): + self.controller_message_bus = controller_message_bus + + def message(self, event: BaseEvent): + return self.controller_message_bus.message(event) + + def message_to(self, name: str, event: BaseEvent): + return self.controller_message_bus.message_to(name, event) + + def message_to_selected(self, names: list[str], event: BaseEvent): + for name in names: + self.controller_message_bus.message_to_selected(name, event) + + def register_controller(self, name: str, controller): + self.controller_message_bus.register_controller(name, controller) + + def unregister_controller(self, name: str): + self.controller_message_bus.unregister_controller(name) diff --git a/src/libs/controllers/controller_manager.py b/src/libs/controllers/controller_manager.py new file mode 100644 index 0000000..aa5afb3 --- /dev/null +++ b/src/libs/controllers/controller_manager.py @@ -0,0 +1,74 @@ +# Python imports + +# Lib imports + +# Application imports +from ..singleton import Singleton +from ..event_factory import Code_Event_Types + +from .controller_base import ControllerBase +from .controller_message_bus import ControllerMessageBus + + + +class ControllerManagerException(Exception): + ... + + + +class ControllerManager(Singleton, dict): + """ + ControllerManager registers controllers by key/value pair. + It binds the message bus methods methods each controller has + due to extending ControllerBase. + """ + + def __init__(self): + super(ControllerManager, self).__init__() + + self.message_bus: ControllerMessageBus \ + = self._crete_controller_message_bus() + + + def _crete_controller_message_bus(self) -> ControllerMessageBus: + controller_message_bus = ControllerMessageBus() + controller_message_bus.message_to = self.message_to + controller_message_bus.message = self.message + controller_message_bus.register_controller = self.register_controller + controller_message_bus.unregister_controller = self.unregister_controller + + return controller_message_bus + + def register_controller(self, name: str, controller: ControllerBase): + if not name or controller == None: + raise ControllerManagerException("Must pass in a 'name' and 'controller'...") + + if name in self.keys(): + raise ControllerManagerException( + f"Can't bind controller to existing registered name of '{name}'..." + ) + + controller.set_controller_message_bus( self.message_bus ) + + self[name] = controller + + def unregister_controller(self, name: str): + if not name: + raise ControllerManagerException("Must pass in a 'name'...") + + if not name in self.keys(): + raise ControllerManagerException( + f"Can't find controller registered with name of '{name}'..." + ) + + self.pop(name, None) + + def get_controllers_key_list(self) -> list[str]: + return self.keys() + + def message_to(self, name: str, event: Code_Event_Types.CodeEvent): + self[name]._controller_message(event) + + def message(self, event: Code_Event_Types.CodeEvent): + for key in self.keys(): + self[key]._controller_message(event) diff --git a/src/libs/controllers/controller_message_bus.py b/src/libs/controllers/controller_message_bus.py new file mode 100644 index 0000000..29e82a8 --- /dev/null +++ b/src/libs/controllers/controller_message_bus.py @@ -0,0 +1,33 @@ +# Python imports + +# Lib imports + +# Application imports +from ..dto.base_event import BaseEvent + + + +class ControllerMessageBusException(Exception): + ... + + + +class ControllerMessageBus: + def __init__(self): + super(ControllerMessageBus, self).__init__() + + + def message(self, event: BaseEvent): + raise ControllerMessageBusException("Controller Message Bus 'message' must be overriden by Controller Manager...") + + def message_to(self, name: str, event: BaseEvent): + raise ControllerMessageBusException("Controller Message Bus 'message_to' must be overriden by Controller Manager...") + + def message_to_selected(self, name: list, event: BaseEvent): + raise ControllerMessageBusException("Controller Message Bus 'message_to_selected' must be overriden by Controller Manager...") + + def register_controller(self, name: str, controller): + raise ControllerMessageBusException("Controller Message Bus 'register_controller' must be overriden by Controller Manager...") + + def unregister_controller(self, name: str): + raise ControllerMessageBusException("Controller Message Bus 'unregister_controller' must be overriden by Controller Manager...") diff --git a/src/libs/controllers/emit_dispatcher.py b/src/libs/controllers/emit_dispatcher.py new file mode 100644 index 0000000..878ce67 --- /dev/null +++ b/src/libs/controllers/emit_dispatcher.py @@ -0,0 +1,29 @@ +# Python imports + +# Lib imports + +# Application imports +from ..dto.base_event import BaseEvent + + + +class EmitDispatcher: + """ + EmitDispatcher is used for allowing controllers to pass/hook in + their message system to children that need to signal events. + Note how we are not handling return info from the 'message' methods + whereas a controller would or could do so. + """ + + def __init__(self): + super(EmitDispatcher, self).__init__() + + + def emit(self, event: BaseEvent): + self.message(event) + + def emit_to(self, controller: str, event: BaseEvent): + self.message_to(controller, event) + + def emit_to_selected(self, names: list[str], event: BaseEvent): + self.message_to_selected(names, event) diff --git a/src/libs/db/__init__.py b/src/libs/db/__init__.py index d20f589..2b83284 100644 --- a/src/libs/db/__init__.py +++ b/src/libs/db/__init__.py @@ -1,5 +1,5 @@ """ - DB Package + Libs DB Package """ from .models import User diff --git a/src/libs/dto/__init__.py b/src/libs/dto/__init__.py index 8c55071..6e319fb 100644 --- a/src/libs/dto/__init__.py +++ b/src/libs/dto/__init__.py @@ -1,5 +1,5 @@ """ - Dasta Class Package + Libs DTO(s) Package """ -from .event import Event \ No newline at end of file +from .base_event import BaseEvent \ No newline at end of file diff --git a/src/libs/dto/base_event.py b/src/libs/dto/base_event.py new file mode 100644 index 0000000..a91c7db --- /dev/null +++ b/src/libs/dto/base_event.py @@ -0,0 +1,16 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports + + + +@dataclass(slots = True) +class BaseEvent: + topic: str = None + content: any = None + raw_content: any = None + success: callable = None + fail: callable = None diff --git a/src/libs/dto/code/events/__init__.py b/src/libs/dto/code/events/__init__.py new file mode 100644 index 0000000..cfe8223 --- /dev/null +++ b/src/libs/dto/code/events/__init__.py @@ -0,0 +1,44 @@ +""" + Libs Code DTO(s) Events Package +""" + + +from .code_event import CodeEvent +from .toggle_plugins_ui_event import TogglePluginsUiEvent +from .create_source_view_event import CreateSourceViewEvent +from .register_completer_event import RegisterCompleterEvent +from .unregister_completer_event import UnregisterCompleterEvent +from .register_provider_event import RegisterProviderEvent +from .unregister_provider_event import UnregisterProviderEvent +from .register_command_event import RegisterCommandEvent +from .unregister_command_event import UnregisterCommandEvent +from .file_externally_modified_event import FileExternallyModifiedEvent +from .file_externally_deleted_event import FileExternallyDeletedEvent +from .set_info_labels_event import SetInfoLabelsEvent +from .populate_source_view_popup_event import PopulateSourceViewPopupEvent +from .filter_out_loaded_files_event import FilterOutLoadedFilesEvent +from .get_active_view_event import GetActiveViewEvent +from .get_source_views_event import GetSourceViewsEvent + +from .get_new_command_system_event import GetNewCommandSystemEvent +from .request_completion_event import RequestCompletionEvent +from .cursor_moved_event import CursorMovedEvent +from .modified_changed_event import ModifiedChangedEvent +from .text_changed_event import TextChangedEvent +from .text_inserted_event import TextInsertedEvent +from .focused_view_event import FocusedViewEvent +from .set_active_file_event import SetActiveFileEvent + +from .file_path_set_event import FilePathSetEvent +from .added_new_file_event import AddedNewFileEvent +from .loaded_new_file_event import LoadedNewFileEvent +from .popped_file_event import PoppedFileEvent +from .removed_file_event import RemovedFileEvent +from .saved_file_event import SavedFileEvent + +from .get_file_event import GetFileEvent +from .get_files_event import GetFilesEvent +from .get_swap_file_event import GetSwapFileEvent +from .add_new_file_event import AddNewFileEvent +from .pop_file_event import PopFileEvent +from .remove_file_event import RemoveFileEvent diff --git a/src/libs/dto/code/events/add_new_file_event.py b/src/libs/dto/code/events/add_new_file_event.py new file mode 100644 index 0000000..fa1235d --- /dev/null +++ b/src/libs/dto/code/events/add_new_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class AddNewFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/added_new_file_event.py b/src/libs/dto/code/events/added_new_file_event.py new file mode 100644 index 0000000..1add983 --- /dev/null +++ b/src/libs/dto/code/events/added_new_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class AddedNewFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/code_event.py b/src/libs/dto/code/events/code_event.py new file mode 100644 index 0000000..6e5f822 --- /dev/null +++ b/src/libs/dto/code/events/code_event.py @@ -0,0 +1,18 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from ...base_event import BaseEvent + + + +@dataclass +class CodeEvent(BaseEvent): + ignore_focus: bool = False + view: any = None + file: any = None + next_file: any = None + buffer: any = None + response: any = None diff --git a/src/libs/dto/code/events/create_source_view_event.py b/src/libs/dto/code/events/create_source_view_event.py new file mode 100644 index 0000000..3cee2a6 --- /dev/null +++ b/src/libs/dto/code/events/create_source_view_event.py @@ -0,0 +1,14 @@ +# Python imports +from dataclasses import dataclass + +# Lib imports + +# Application imports +from .code_event import CodeEvent +from libs.dto.states.source_view_states import SourceViewStates + + + +@dataclass +class CreateSourceViewEvent(CodeEvent): + state: SourceViewStates = SourceViewStates.INSERT diff --git a/src/libs/dto/code/events/cursor_moved_event.py b/src/libs/dto/code/events/cursor_moved_event.py new file mode 100644 index 0000000..608e331 --- /dev/null +++ b/src/libs/dto/code/events/cursor_moved_event.py @@ -0,0 +1,14 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class CursorMovedEvent(CodeEvent): + line: int = 0 + char: int = 0 diff --git a/src/libs/dto/code/events/file_externally_deleted_event.py b/src/libs/dto/code/events/file_externally_deleted_event.py new file mode 100644 index 0000000..2751a47 --- /dev/null +++ b/src/libs/dto/code/events/file_externally_deleted_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class FileExternallyDeletedEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/file_externally_modified_event.py b/src/libs/dto/code/events/file_externally_modified_event.py new file mode 100644 index 0000000..cbefacc --- /dev/null +++ b/src/libs/dto/code/events/file_externally_modified_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class FileExternallyModifiedEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/file_path_set_event.py b/src/libs/dto/code/events/file_path_set_event.py new file mode 100644 index 0000000..40415aa --- /dev/null +++ b/src/libs/dto/code/events/file_path_set_event.py @@ -0,0 +1,15 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class FilePathSetEvent(CodeEvent): + fname: str = "" + fpath: str = "" + ftype: str = "" diff --git a/src/libs/dto/code/events/filter_out_loaded_files_event.py b/src/libs/dto/code/events/filter_out_loaded_files_event.py new file mode 100644 index 0000000..796bfaf --- /dev/null +++ b/src/libs/dto/code/events/filter_out_loaded_files_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class FilterOutLoadedFilesEvent(CodeEvent): + uris: list[str] = None diff --git a/src/libs/dto/code/events/focused_view_event.py b/src/libs/dto/code/events/focused_view_event.py new file mode 100644 index 0000000..1064170 --- /dev/null +++ b/src/libs/dto/code/events/focused_view_event.py @@ -0,0 +1,14 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class FocusedViewEvent(CodeEvent): + left_view: any = False + right_view: any = False diff --git a/src/libs/dto/code/events/get_active_view_event.py b/src/libs/dto/code/events/get_active_view_event.py new file mode 100644 index 0000000..4bd096c --- /dev/null +++ b/src/libs/dto/code/events/get_active_view_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class GetActiveViewEvent(CodeEvent): + ... diff --git a/src/libs/dto/event.py b/src/libs/dto/code/events/get_file_event.py similarity index 60% rename from src/libs/dto/event.py rename to src/libs/dto/code/events/get_file_event.py index 847beb5..4b92c72 100644 --- a/src/libs/dto/event.py +++ b/src/libs/dto/code/events/get_file_event.py @@ -4,11 +4,10 @@ from dataclasses import dataclass, field # Lib imports # Application imports +from .code_event import CodeEvent @dataclass -class Event: - topic: str - content: str - raw_content: str \ No newline at end of file +class GetFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/get_files_event.py b/src/libs/dto/code/events/get_files_event.py new file mode 100644 index 0000000..6ac2d3f --- /dev/null +++ b/src/libs/dto/code/events/get_files_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class GetFilesEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/get_new_command_system_event.py b/src/libs/dto/code/events/get_new_command_system_event.py new file mode 100644 index 0000000..f3831c7 --- /dev/null +++ b/src/libs/dto/code/events/get_new_command_system_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class GetNewCommandSystemEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/get_source_views_event.py b/src/libs/dto/code/events/get_source_views_event.py new file mode 100644 index 0000000..471e3eb --- /dev/null +++ b/src/libs/dto/code/events/get_source_views_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class GetSourceViewsEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/get_swap_file_event.py b/src/libs/dto/code/events/get_swap_file_event.py new file mode 100644 index 0000000..4144756 --- /dev/null +++ b/src/libs/dto/code/events/get_swap_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class GetSwapFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/loaded_new_file_event.py b/src/libs/dto/code/events/loaded_new_file_event.py new file mode 100644 index 0000000..d7d42f8 --- /dev/null +++ b/src/libs/dto/code/events/loaded_new_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class LoadedNewFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/modified_changed_event.py b/src/libs/dto/code/events/modified_changed_event.py new file mode 100644 index 0000000..95e3363 --- /dev/null +++ b/src/libs/dto/code/events/modified_changed_event.py @@ -0,0 +1,15 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class ModifiedChangedEvent(CodeEvent): + line: int = 0 + char: int = 0 + value: str = "" diff --git a/src/libs/dto/code/events/pop_file_event.py b/src/libs/dto/code/events/pop_file_event.py new file mode 100644 index 0000000..702aa17 --- /dev/null +++ b/src/libs/dto/code/events/pop_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class PopFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/popped_file_event.py b/src/libs/dto/code/events/popped_file_event.py new file mode 100644 index 0000000..dc637e2 --- /dev/null +++ b/src/libs/dto/code/events/popped_file_event.py @@ -0,0 +1,15 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class PoppedFileEvent(CodeEvent): + fname: str = "" + fpath: str = "" + ftype: str = "" diff --git a/src/libs/dto/code/events/populate_source_view_popup_event.py b/src/libs/dto/code/events/populate_source_view_popup_event.py new file mode 100644 index 0000000..a3c0ac9 --- /dev/null +++ b/src/libs/dto/code/events/populate_source_view_popup_event.py @@ -0,0 +1,18 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class PopulateSourceViewPopupEvent(CodeEvent): + menu: Gtk.Widget = None diff --git a/src/libs/dto/code/events/register_command_event.py b/src/libs/dto/code/events/register_command_event.py new file mode 100644 index 0000000..d56524e --- /dev/null +++ b/src/libs/dto/code/events/register_command_event.py @@ -0,0 +1,20 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class RegisterCommandEvent(CodeEvent): + command_name: str = "" + command: callable = None + binding_mode: str = "" + binding: str or list = "" diff --git a/src/libs/dto/code/events/register_completer_event.py b/src/libs/dto/code/events/register_completer_event.py new file mode 100644 index 0000000..38317e7 --- /dev/null +++ b/src/libs/dto/code/events/register_completer_event.py @@ -0,0 +1,17 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class RegisterCompleterEvent(CodeEvent): + completer: GtkSource.Completion = None diff --git a/src/libs/dto/code/events/register_provider_event.py b/src/libs/dto/code/events/register_provider_event.py new file mode 100644 index 0000000..3c14a3f --- /dev/null +++ b/src/libs/dto/code/events/register_provider_event.py @@ -0,0 +1,19 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class RegisterProviderEvent(CodeEvent): + provider_name: str = "" + provider: GtkSource.CompletionProvider = None + language_ids: list = field(default_factory=lambda: []) diff --git a/src/libs/dto/code/events/remove_file_event.py b/src/libs/dto/code/events/remove_file_event.py new file mode 100644 index 0000000..c076bd6 --- /dev/null +++ b/src/libs/dto/code/events/remove_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class RemoveFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/removed_file_event.py b/src/libs/dto/code/events/removed_file_event.py new file mode 100644 index 0000000..129faa2 --- /dev/null +++ b/src/libs/dto/code/events/removed_file_event.py @@ -0,0 +1,15 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class RemovedFileEvent(CodeEvent): + fname: str = "" + fpath: str = "" + ftype: str = "" diff --git a/src/libs/dto/code/events/request_completion_event.py b/src/libs/dto/code/events/request_completion_event.py new file mode 100644 index 0000000..e6075eb --- /dev/null +++ b/src/libs/dto/code/events/request_completion_event.py @@ -0,0 +1,17 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class RequestCompletionEvent(CodeEvent): + provider: GtkSource.CompletionProvider or list[GtkSource.CompletionProvider] = None diff --git a/src/libs/dto/code/events/saved_file_event.py b/src/libs/dto/code/events/saved_file_event.py new file mode 100644 index 0000000..42636ec --- /dev/null +++ b/src/libs/dto/code/events/saved_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class SavedFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/set_active_file_event.py b/src/libs/dto/code/events/set_active_file_event.py new file mode 100644 index 0000000..0f466d5 --- /dev/null +++ b/src/libs/dto/code/events/set_active_file_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class SetActiveFileEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/set_info_labels_event.py b/src/libs/dto/code/events/set_info_labels_event.py new file mode 100644 index 0000000..d12a889 --- /dev/null +++ b/src/libs/dto/code/events/set_info_labels_event.py @@ -0,0 +1,15 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +from gi.repository import Gio + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class SetInfoLabelsEvent(CodeEvent): + info: tuple[str or Gio.File] = None diff --git a/src/libs/dto/code/events/text_changed_event.py b/src/libs/dto/code/events/text_changed_event.py new file mode 100644 index 0000000..61b2317 --- /dev/null +++ b/src/libs/dto/code/events/text_changed_event.py @@ -0,0 +1,18 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class TextChangedEvent(CodeEvent): + has_selection: bool = False + start_range: int = 0 + end_range: int = 0 + line: int = 0 + char: int = 0 + value: str = "" diff --git a/src/libs/dto/code/events/text_inserted_event.py b/src/libs/dto/code/events/text_inserted_event.py new file mode 100644 index 0000000..8bdbe96 --- /dev/null +++ b/src/libs/dto/code/events/text_inserted_event.py @@ -0,0 +1,20 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class TextInsertedEvent(CodeEvent): + location: Gtk.TextIter = None + text: str = "" + length: int = 0 diff --git a/src/libs/dto/code/events/toggle_plugins_ui_event.py b/src/libs/dto/code/events/toggle_plugins_ui_event.py new file mode 100644 index 0000000..095a015 --- /dev/null +++ b/src/libs/dto/code/events/toggle_plugins_ui_event.py @@ -0,0 +1,17 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class TogglePluginsUiEvent(CodeEvent): + ... diff --git a/src/libs/dto/code/events/unregister_command_event.py b/src/libs/dto/code/events/unregister_command_event.py new file mode 100644 index 0000000..0662dd0 --- /dev/null +++ b/src/libs/dto/code/events/unregister_command_event.py @@ -0,0 +1,20 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class UnregisterCommandEvent(CodeEvent): + command_name: str = "" + command: callable = None + binding_mode: str = "" + binding: str or list = "" diff --git a/src/libs/dto/code/events/unregister_completer_event.py b/src/libs/dto/code/events/unregister_completer_event.py new file mode 100644 index 0000000..e339520 --- /dev/null +++ b/src/libs/dto/code/events/unregister_completer_event.py @@ -0,0 +1,17 @@ +# Python imports +from dataclasses import dataclass + +# Lib imports +import gi +gi.require_version('GtkSource', '4') + +from gi.repository import GtkSource + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class UnregisterCompleterEvent(CodeEvent): + completer: GtkSource.Completion = None diff --git a/src/libs/dto/code/events/unregister_provider_event.py b/src/libs/dto/code/events/unregister_provider_event.py new file mode 100644 index 0000000..7f8e9cf --- /dev/null +++ b/src/libs/dto/code/events/unregister_provider_event.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass + +# Lib imports + +# Application imports +from .code_event import CodeEvent + + + +@dataclass +class UnregisterProviderEvent(CodeEvent): + provider_name: str = "" diff --git a/src/libs/dto/plugins/__init__.py b/src/libs/dto/plugins/__init__.py new file mode 100644 index 0000000..0f976bd --- /dev/null +++ b/src/libs/dto/plugins/__init__.py @@ -0,0 +1,3 @@ +""" + Libs Plugin DTO(s) Package +""" diff --git a/src/libs/dto/plugins/manifest.py b/src/libs/dto/plugins/manifest.py new file mode 100644 index 0000000..03c1a9c --- /dev/null +++ b/src/libs/dto/plugins/manifest.py @@ -0,0 +1,28 @@ +# Python imports +from dataclasses import dataclass, field +from dataclasses import asdict + +# Gtk imports + +# Application imports +from .requests import Requests + + + +@dataclass +class Manifest: + name: str = "" + author: str = "" + credit: str = "" + version: str = "0.0.1" + support: str = "support@mail.com" + pre_launch: bool = False + autoload: bool = True + requests: Requests = field(default_factory = lambda: Requests()) + + def __post_init__(self): + if isinstance(self.requests, dict): + self.requests = Requests(**self.requests) + + def as_dict(self): + return asdict(self) diff --git a/src/libs/dto/plugins/manifest_meta.py b/src/libs/dto/plugins/manifest_meta.py new file mode 100644 index 0000000..8e1056b --- /dev/null +++ b/src/libs/dto/plugins/manifest_meta.py @@ -0,0 +1,19 @@ +# Python imports +from dataclasses import dataclass, field +from dataclasses import asdict + +# Gtk imports + +# Application imports +from .manifest import Manifest + + + +@dataclass +class ManifestMeta: + folder: str = "" + path: str = "" + manifest: Manifest = field(default_factory = lambda: Manifest()) + + def as_dict(self): + return asdict(self) diff --git a/src/libs/dto/plugins/requests.py b/src/libs/dto/plugins/requests.py new file mode 100644 index 0000000..010ff82 --- /dev/null +++ b/src/libs/dto/plugins/requests.py @@ -0,0 +1,11 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports + + +@dataclass +class Requests: + bind_keys: list = field(default_factory = lambda: []) diff --git a/src/libs/dto/states/__init__.py b/src/libs/dto/states/__init__.py new file mode 100644 index 0000000..58af316 --- /dev/null +++ b/src/libs/dto/states/__init__.py @@ -0,0 +1,6 @@ +""" + Code DTO States Package +""" + +from .source_view_states import SourceViewStates +from .cursor_action import CursorAction diff --git a/src/libs/dto/states/cursor_action.py b/src/libs/dto/states/cursor_action.py new file mode 100644 index 0000000..810561d --- /dev/null +++ b/src/libs/dto/states/cursor_action.py @@ -0,0 +1,14 @@ +# Python imports +from enum import Enum + +# Lib imports + +# Application imports + + + +class CursorAction(Enum): + NONE = 0 + DELETE = 1 + BACKSPACE = 2 + ENTER = 3 diff --git a/src/libs/dto/states/source_view_states.py b/src/libs/dto/states/source_view_states.py new file mode 100644 index 0000000..9c2ecbb --- /dev/null +++ b/src/libs/dto/states/source_view_states.py @@ -0,0 +1,15 @@ +# Python imports +from enum import Enum + +# Lib imports + +# Application imports + + + +class SourceViewStates(Enum): + INSERT = 0 + MULTIINSERT = 1 + COMMAND = 2 + READONLY = 3 + INDEPENDENT = 4 diff --git a/src/libs/endpoint_registry.py b/src/libs/endpoint_registry.py deleted file mode 100644 index 86e4295..0000000 --- a/src/libs/endpoint_registry.py +++ /dev/null @@ -1,22 +0,0 @@ -# Python imports - -# Lib imports - -# Application imports -from .singleton import Singleton - - - -class EndpointRegistry(Singleton): - def __init__(self): - self._endpoints = {} - - def register(self, rule, **options): - def decorator(f): - self._endpoints[rule] = f - return f - - return decorator - - def get_endpoints(self): - return self._endpoints diff --git a/src/libs/event_factory.py b/src/libs/event_factory.py new file mode 100644 index 0000000..e8ccc43 --- /dev/null +++ b/src/libs/event_factory.py @@ -0,0 +1,103 @@ +# Python imports +import inspect +from typing import Dict, Type +import re + +# Lib imports + +# Application imports +from .singleton import Singleton + +from .dto.base_event import BaseEvent +from .dto.code import events as code + + + +class EventFactory(Singleton): + def __init__(self): + + self._event_classes: Dict[str, Type[BaseEvent]] = {} + + self._auto_register_events( code.__dict__.items() ) + + def register_event(self, event_type: str, event_class: Type[BaseEvent]): + self._event_classes[event_type] = event_class + + def register_events(self, events: dict): + i = 0 + for name, obj in events: + if not self._is_valid_event_class(obj): continue + + event_type = self._class_name_to_event_type(name) + + self._event_classes[event_type] = obj + Code_Event_Types.add_event_class(name, obj) + i += 1 + + logger.debug(f"Registered {i} event types:") + + def unregister_events(self, events: dict): + i = 0 + for name, obj in events: + if not self._is_valid_event_class(obj): continue + + event_type = self._class_name_to_event_type(name) + + del self._event_classes[event_type] + Code_Event_Types.remove_event_class(name) + i += 1 + + logger.debug(f"Unregistered {i} event types:") + + def create_event(self, event_type: str, **kwargs) -> BaseEvent: + if event_type not in self._event_classes: + raise ValueError(f"Unknown event type: {event_type}") + + event_class = self._event_classes[event_type] + event = event_class() + + for key, value in kwargs.items(): + if not hasattr(event, key): + raise ValueError(f"Event class {event_class.__name__} has no attribute '{key}'") + + setattr(event, key, value) + + return event + + + def _auto_register_events(self, events: dict): + self.register_events(events) + + def _is_valid_event_class(self, obj) -> bool: + return ( + inspect.isclass(obj) and + issubclass(obj, BaseEvent) and + obj != BaseEvent + ) + + def _class_name_to_event_type(self, class_name: str) -> str: + base_name = class_name[:-5] if class_name.endswith('Event') else class_name + return re.sub(r'(? bool: + return (inspect.isclass(obj) and issubclass(obj, BaseEvent) and obj != BaseEvent) + + def add_event_class(self, name: str, event_class: Type[BaseEvent]): + setattr(self, name, event_class) + + def remove_event_class(self, name: str): + delattr(self, name) + + + +Code_Event_Types = EventNamespace() +Event_Factory = EventFactory() + diff --git a/src/libs/event_system.py b/src/libs/event_system.py index cd6975f..531f843 100644 --- a/src/libs/event_system.py +++ b/src/libs/event_system.py @@ -28,16 +28,16 @@ class EventSystem(Singleton): def _resume_processing_events(self): self._is_paused = False - def subscribe(self, event_type, fn): + def subscribe(self, event_type: str, fn: callable): self.subscribers[event_type].append(fn) - def unsubscribe(self, event_type, fn): + def unsubscribe(self, event_type: str, fn: callable): self.subscribers[event_type].remove(fn) - def unsubscribe_all(self, event_type): + def unsubscribe_all(self, event_type: str): self.subscribers.pop(event_type, None) - def emit(self, event_type, data = None): + def emit(self, event_type: str, data: tuple = ()): if self._is_paused and event_type != "resume_event_processing": return @@ -51,7 +51,7 @@ class EventSystem(Singleton): else: fn() - def emit_and_await(self, event_type, data = None): + def emit_and_await(self, event_type: str, data: tuple = ()): if self._is_paused and event_type != "resume_event_processing": return diff --git a/src/libs/ipc_server.py b/src/libs/ipc_server.py index eacde83..e9803ef 100644 --- a/src/libs/ipc_server.py +++ b/src/libs/ipc_server.py @@ -2,6 +2,7 @@ import os import threading import time +from contextlib import suppress from multiprocessing.connection import Client from multiprocessing.connection import Listener @@ -40,8 +41,9 @@ class IPCServer(Singleton): def create_ipc_listener(self) -> None: if self._conn_type == "socket": - if os.path.exists(self._ipc_address) and settings_manager.is_dirty_start(): - os.unlink(self._ipc_address) + if settings_manager.is_dirty_start(): + with suppress(FileNotFoundError, PermissionError): + os.unlink(self._ipc_address) listener = Listener(address=self._ipc_address, family="AF_UNIX", authkey=self._ipc_authkey) elif "unsecured" not in self._conn_type: @@ -61,8 +63,14 @@ class IPCServer(Singleton): conn = listener.accept() start_time = time.perf_counter() self._handle_ipc_message(conn, start_time) - except Exception as e: + except EOFError as e: logger.debug( repr(e) ) + except (OSError, ConnectionError, BrokenPipeError) as e: + logger.debug( f"IPC connection error: {e}" ) + except Exception as e: + logger.debug( f"Unexpected IPC error: {e}" ) + finally: + conn.close() listener.close() @@ -79,6 +87,16 @@ class IPCServer(Singleton): conn.close() break + if "FILES|" in msg: + import json + data = msg.split("FILES|")[1].strip() + files = json.loads(data) + if files: + event_system.emit("handle-files-from-ipc", (files,)) + + conn.close() + break + if "DIR|" in msg: file = msg.split("DIR|")[1].strip() if file: @@ -88,7 +106,7 @@ class IPCServer(Singleton): break - if msg in ['close connection', 'close server']: + if msg in ['close connection', 'close server', 'Empty Data...']: conn.close() break @@ -112,8 +130,10 @@ class IPCServer(Singleton): conn.close() except ConnectionRefusedError as e: logger.error("Connection refused...") + except (OSError, ConnectionError, BrokenPipeError) as e: + logger.error( f"IPC connection error: {e}" ) except Exception as e: - logger.error( repr(e) ) + logger.error( f"Unexpected IPC error: {e}" ) def send_test_ipc_message(self, message: str = "Empty Data...") -> None: @@ -130,6 +150,9 @@ class IPCServer(Singleton): except ConnectionRefusedError as e: if self._conn_type == "socket": logger.error("IPC Socket no longer valid.... Removing.") - os.unlink(self._ipc_address) + with suppress(FileNotFoundError, PermissionError): + os.unlink(self._ipc_address) + except (OSError, ConnectionError, BrokenPipeError) as e: + logger.error( f"IPC connection error: {e}" ) except Exception as e: - logger.error( repr(e) ) \ No newline at end of file + logger.error( f"Unexpected IPC error: {e}" ) \ No newline at end of file diff --git a/src/libs/mixins/__init__.py b/src/libs/mixins/__init__.py index e852849..c45077c 100644 --- a/src/libs/mixins/__init__.py +++ b/src/libs/mixins/__init__.py @@ -1,3 +1,3 @@ """ - Libs.Mixins Package + Libs Mixins Package """ \ No newline at end of file diff --git a/src/libs/mixins/dnd_mixin.py b/src/libs/mixins/dnd_mixin.py index 4e231d3..8b427f7 100644 --- a/src/libs/mixins/dnd_mixin.py +++ b/src/libs/mixins/dnd_mixin.py @@ -55,7 +55,7 @@ class DnDMixin: uris = data.get_uris() files = [] - if len(uris) == 0: + if not uris: uris = data.get_text().split("\n") for uri in uris: diff --git a/src/libs/mixins/ipc_signals_mixin.py b/src/libs/mixins/ipc_signals_mixin.py index 880266d..30bd167 100644 --- a/src/libs/mixins/ipc_signals_mixin.py +++ b/src/libs/mixins/ipc_signals_mixin.py @@ -21,6 +21,12 @@ class IPCSignalsMixin: self.broadcast_message, "handle-file", (fpath,) ) + def handle_files_from_ipc(self, uris: list) -> None: + logger.debug(f"Files From IPC: {uris}") + GLib.idle_add( + self.broadcast_message, "handle-files", (uris,) + ) + def handle_dir_from_ipc(self, fpath: str) -> None: logger.debug(f"Dir From IPC: {fpath}") GLib.idle_add( diff --git a/src/libs/mixins/keyboard_signals_mixin.py b/src/libs/mixins/keyboard_signals_mixin.py index 03446d0..6f95023 100644 --- a/src/libs/mixins/keyboard_signals_mixin.py +++ b/src/libs/mixins/keyboard_signals_mixin.py @@ -19,6 +19,12 @@ valid_keyvalue_pat = re.compile(r"[a-z0-9A-Z-_\[\]\(\)\| ]") class KeyboardSignalsMixin: """ KeyboardSignalsMixin keyboard hooks controller. """ + was_midified_key = False + ctrl_down = False + shift_down = False + alt_down = False + + # TODO: Need to set methods that use this to somehow check the keybindings state instead. def unset_keys_and_data(self, widget = None, eve = None): self.ctrl_down = False @@ -34,7 +40,7 @@ class KeyboardSignalsMixin: self.alt_down = False def on_global_key_press_controller(self, eve, user_data): - keyname = Gdk.keyval_name(user_data.keyval).lower() + keyname = Gdk.keyval_name(user_data.keyval).lower() modifiers = Gdk.ModifierType(user_data.get_state() & ~Gdk.ModifierType.LOCK_MASK) self.was_midified_key = True if modifiers != 0 else False diff --git a/src/libs/mixins/observable_mixin.py b/src/libs/mixins/observable_mixin.py new file mode 100644 index 0000000..64207e5 --- /dev/null +++ b/src/libs/mixins/observable_mixin.py @@ -0,0 +1,26 @@ +# Python imports + +# Lib imports + +# Application imports +from ..dto.observable_event import ObservableEvent + + + +class ObservableMixin: + observers = [] + + def add_observer(self, observer: any): + if not hasattr(observer, 'notification') or not callable(getattr(observer, 'notification')): + raise ValueError(f"Observer '{observer}' must implement a `notification` method.") + + self.observers.append(observer) + + def remove_observer(self, observer: any): + if not observer in self.observers: return + + self.observers.remove(observer) + + def notify_observers(self, event: ObservableEvent): + for observer in self.observers: + observer.notification(event) \ No newline at end of file diff --git a/src/libs/settings/__init__.py b/src/libs/settings/__init__.py index 228a75d..168d6ee 100644 --- a/src/libs/settings/__init__.py +++ b/src/libs/settings/__init__.py @@ -1,4 +1,4 @@ """ - Settings Package + Libs Settings Package """ from .manager import SettingsManager \ No newline at end of file diff --git a/src/libs/settings/manager.py b/src/libs/settings/manager.py index e4e1c87..191f194 100644 --- a/src/libs/settings/manager.py +++ b/src/libs/settings/manager.py @@ -1,138 +1,38 @@ # Python imports import inspect import time -import json -import zipfile - -from os import path -from os import mkdir # Lib imports # Application imports from ..singleton import Singleton from .start_check_mixin import StartCheckMixin + +from .path_manager import PathManager from .options.settings import Settings -class MissingConfigError(Exception): - pass - - - class SettingsManager(StartCheckMixin, Singleton): def __init__(self): - self._SCRIPT_PTH: str = path.dirname(path.realpath(__file__)) - self._USER_HOME: str = path.expanduser('~') - self._HOME_CONFIG_PATH: str = f"{self._USER_HOME}/.config/{APP_NAME.lower()}" - self._USR_PATH: str = f"/usr/share/{APP_NAME.lower()}" - self._USR_CONFIG_FILE: str = f"{self._USR_PATH}/settings.json" + self.path_manager: PathManager = PathManager() + self.settings: Settings = None - self._CONTEXT_PATH: str = f"{self._HOME_CONFIG_PATH}/context_path" - self._PLUGINS_PATH: str = f"{self._HOME_CONFIG_PATH}/plugins" - self._DEFAULT_ICONS: str = f"{self._HOME_CONFIG_PATH}/icons" - self._CONFIG_FILE: str = f"{self._HOME_CONFIG_PATH}/settings.json" - self._GLADE_FILE: str = f"{self._HOME_CONFIG_PATH}/Main_Window.glade" - self._CSS_FILE: str = f"{self._HOME_CONFIG_PATH}/stylesheet.css" - self._KEY_BINDINGS_FILE: str = f"{self._HOME_CONFIG_PATH}/key-bindings.json" - self._PID_FILE: str = f"{self._HOME_CONFIG_PATH}/{APP_NAME.lower()}.pid" - self._UI_WIDEGTS_PATH: str = f"{self._HOME_CONFIG_PATH}/ui_widgets" - self._CONTEXT_MENU: str = f"{self._HOME_CONFIG_PATH}/contexct_menu.json" - self._WINDOW_ICON: str = f"{self._DEFAULT_ICONS}/{APP_NAME.lower()}.png" + self._main_window = None + self._builder = None - # self._USR_CONFIG_FILE: str = f"{self._USR_PATH}/settings.json" - # self._PLUGINS_PATH: str = f"plugins" - # self._CONFIG_FILE: str = f"settings.json" - # self._GLADE_FILE: str = f"Main_Window.glade" - # self._CSS_FILE: str = f"stylesheet.css" - # self._KEY_BINDINGS_FILE: str = f"key-bindings.json" - # self._PID_FILE: str = f"{APP_NAME.lower()}.pid" - # self._WINDOW_ICON: str = f"{APP_NAME.lower()}.png" - # self._UI_WIDEGTS_PATH: str = f"ui_widgets" - # self._CONTEXT_MENU: str = f"contexct_menu.json" - # self._DEFAULT_ICONS: str = f"icons" + self._trace_debug: bool = False + self._debug: bool = False + self._dirty_start: bool = False + self._passed_in_file: bool = False + self._starting_files: list = [] + self.PAINT_BG_COLOR: tuple = (0, 0, 0, 0.0) - # with zipfile.ZipFile("files.zip", mode="r", allowZip64=True) as zf: - # with io.TextIOWrapper(zf.open("text1.txt"), encoding="utf-8") as f: + self.load_keybindings() + self.load_context_menu_data() - if not path.exists(self._HOME_CONFIG_PATH): - mkdir(self._HOME_CONFIG_PATH) - if not path.exists(self._PLUGINS_PATH): - mkdir(self._PLUGINS_PATH) - - if not path.exists(self._DEFAULT_ICONS): - self._DEFAULT_ICONS = f"{self._USR_PATH}/icons" - if not path.exists(self._DEFAULT_ICONS): - raise MissingConfigError("Unable to find the application icons directory.") - if not path.exists(self._GLADE_FILE): - self._GLADE_FILE = f"{self._USR_PATH}/Main_Window.glade" - if not path.exists(self._GLADE_FILE): - raise MissingConfigError("Unable to find the application Glade file.") - if not path.exists(self._KEY_BINDINGS_FILE): - self._KEY_BINDINGS_FILE = f"{self._USR_PATH}/key-bindings.json" - if not path.exists(self._KEY_BINDINGS_FILE): - raise MissingConfigError("Unable to find the application Keybindings file.") - if not path.exists(self._CSS_FILE): - self._CSS_FILE = f"{self._USR_PATH}/stylesheet.css" - if not path.exists(self._CSS_FILE): - raise MissingConfigError("Unable to find the application Stylesheet file.") - if not path.exists(self._WINDOW_ICON): - self._WINDOW_ICON = f"{self._USR_PATH}/icons/{APP_NAME.lower()}.png" - if not path.exists(self._WINDOW_ICON): - raise MissingConfigError("Unable to find the application icon.") - if not path.exists(self._UI_WIDEGTS_PATH): - self._UI_WIDEGTS_PATH = f"{self._USR_PATH}/ui_widgets" - if not path.exists(self._CONTEXT_MENU): - self._CONTEXT_MENU = f"{self._USR_PATH}/contexct_menu.json" - - - try: - with open(self._KEY_BINDINGS_FILE) as file: - bindings = json.load(file)["keybindings"] - self._guake_key = bindings["guake_key"] - - keybindings.configure(bindings) - except Exception as e: - print( f"Settings Manager: {self._KEY_BINDINGS_FILE}\n\t\t{repr(e)}" ) - - try: - with open(self._CONTEXT_MENU) as file: - self._context_menu_data = json.load(file) - except Exception as e: - print( f"Settings Manager: {self._CONTEXT_MENU}\n\t\t{repr(e)}" ) - - - self.settings: Settings = None - self._main_window = None - self._builder = None - self.PAINT_BG_COLOR: tuple = (0, 0, 0, 0.0) - - self._trace_debug: bool = False - self._debug: bool = False - self._dirty_start: bool = False - self._passed_in_file: bool = False - self._starting_files: list = [] - - - def register_signals_to_builder(self, classes = None): - handlers = {} - - for c in classes: - methods = None - try: - methods = inspect.getmembers(c, predicate = inspect.ismethod) - handlers.update(methods) - except Exception as e: - ... - - self._builder.connect_signals(handlers) - - def set_main_window(self, window): self._main_window = window - def set_builder(self, builder) -> any: self._builder = builder - def get_monitor_data(self) -> list: screen = self._main_window.get_screen() monitors = [] @@ -145,23 +45,18 @@ class SettingsManager(StartCheckMixin, Singleton): def get_main_window(self) -> any: return self._main_window def get_builder(self) -> any: return self._builder def get_paint_bg_color(self) -> any: return self.PAINT_BG_COLOR - def get_glade_file(self) -> str: return self._GLADE_FILE - def get_ui_widgets_path(self) -> str: return self._UI_WIDEGTS_PATH def get_context_menu_data(self) -> str: return self._context_menu_data - def get_context_path(self) -> str: return self._CONTEXT_PATH - def get_plugins_path(self) -> str: return self._PLUGINS_PATH def get_icon_theme(self) -> str: return self._ICON_THEME - def get_css_file(self) -> str: return self._CSS_FILE - def get_home_config_path(self) -> str: return self._HOME_CONFIG_PATH - def get_window_icon(self) -> str: return self._WINDOW_ICON - def get_home_path(self) -> str: return self._USER_HOME def get_starting_files(self) -> list: return self._starting_files def get_guake_key(self) -> tuple: return self._guake_key def get_starting_args(self): return self.args, self.unknownargs + def set_main_window(self, window): self._main_window = window + def set_builder(self, builder) -> any: self._builder = builder + def set_main_window_x(self, x: int = 0): self.settings.config.main_window_x = x def set_main_window_y(self, y: int = 0): self.settings.config.main_window_y = y def set_main_window_width(self, width: int = 800): self.settings.config.main_window_width = width @@ -191,21 +86,41 @@ class SettingsManager(StartCheckMixin, Singleton): def log_load_time(self): logger.info( f"Load Time: {self._end_load_time - self._start_load_time}" ) + + def register_signals_to_builder(self, classes = None): + handlers = {} + + for c in classes: + methods = None + try: + methods = inspect.getmembers(c, predicate = inspect.ismethod) + handlers.update(methods) + except Exception as e: + ... + + self._builder.connect_signals(handlers) + def call_method(self, target_class: any = None, _method_name: str = "", data: any = None): method_name = str(_method_name) method = getattr(target_class, method_name, lambda data: f"No valid key passed...\nkey={method_name}\nargs={data}") return method(data) if data else method() + def load_keybindings(self): + bindings = self.path_manager.load_keybindings() + self._guake_key = bindings["guake_key"] + + keybindings.configure(bindings) + + def load_context_menu_data(self): + self._context_menu_data = self.path_manager.load_context_menu_data() + def load_settings(self): - if not path.exists(self._CONFIG_FILE): + data = self.path_manager.load_settings() + if not data: self.settings = Settings() return - with open(self._CONFIG_FILE) as file: - data = json.load(file) - data["load_defaults"] = False - self.settings = Settings(**data) + self.settings = Settings(**data) def save_settings(self): - with open(self._CONFIG_FILE, 'w') as outfile: - json.dump(self.settings.as_dict(), outfile, separators=(',', ':'), indent=4) \ No newline at end of file + self.path_manager.save_settings(self.settings) diff --git a/src/libs/settings/options/config.py b/src/libs/settings/options/config.py index 4b4ee23..729d8ac 100644 --- a/src/libs/settings/options/config.py +++ b/src/libs/settings/options/config.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field # Application imports -@dataclass +@dataclass(slots = True) class Config: base_of_home: str = "" hide_hidden_files: str = "true" @@ -14,8 +14,8 @@ class Config: blender_thumbnailer_path: str = "" go_past_home: str = "true" lock_folder: str = "false" - locked_folders: list = field(default_factory=lambda: [ "venv", "flasks" ]) - mplayer_options: str = "-quiet -really-quiet -xy 1600 -geometry 50%:50%", + locked_folders: list = field(default_factory=lambda: [ "venv", "flasks" ]) + mplayer_options: str = "-quiet -really-quiet -xy 1600 -geometry 50%:50%" music_app: str = "/opt/deadbeef/bin/deadbeef" media_app: str = "mpv" image_app: str = "mirage" diff --git a/src/libs/settings/options/settings.py b/src/libs/settings/options/settings.py index 0e3597b..d107a42 100644 --- a/src/libs/settings/options/settings.py +++ b/src/libs/settings/options/settings.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from dataclasses import asdict -# Gtk imports +# Lib imports # Application imports from .config import Config diff --git a/src/libs/settings/options/theming.py b/src/libs/settings/options/theming.py index aa8a60e..c52f50e 100644 --- a/src/libs/settings/options/theming.py +++ b/src/libs/settings/options/theming.py @@ -9,6 +9,8 @@ from dataclasses import dataclass @dataclass class Theming: transparency: int = 64 + default_zoom: int = 12 + syntax_theme: str = "penguins-in-space" success_color: str = "#88cc27" warning_color: str = "#ffa800" error_color: str = "#ff0000" diff --git a/src/libs/settings/other/webkit_ui_settings.py b/src/libs/settings/other/webkit_ui_settings.py deleted file mode 100644 index 981ea49..0000000 --- a/src/libs/settings/other/webkit_ui_settings.py +++ /dev/null @@ -1,42 +0,0 @@ -# Python imports - -# Lib imports -import gi -gi.require_version('WebKit2', '4.0') -from gi.repository import WebKit2 - -# Application imports - - - -class WebkitUISettings(WebKit2.Settings): - def __init__(self): - super(WebkitUISettings, self).__init__() - - self._set_default_settings() - - - # Note: Highly insecure setup but most "app" like setup I could think of. - # Audit heavily any scripts/links ran/clicked under this setup! - def _set_default_settings(self): - self.set_enable_xss_auditor(True) - self.set_enable_hyperlink_auditing(True) - # self.set_enable_xss_auditor(False) - # self.set_enable_hyperlink_auditing(False) - self.set_allow_file_access_from_file_urls(True) - self.set_allow_universal_access_from_file_urls(True) - - self.set_enable_page_cache(False) - self.set_enable_offline_web_application_cache(False) - self.set_enable_html5_local_storage(False) - self.set_enable_html5_database(False) - - self.set_enable_fullscreen(False) - self.set_print_backgrounds(False) - self.set_enable_tabs_to_links(False) - self.set_enable_developer_extras(True) - self.set_enable_webrtc(True) - self.set_enable_webaudio(True) - self.set_enable_accelerated_2d_canvas(True) - - self.set_user_agent(f"{APP_NAME}") \ No newline at end of file diff --git a/src/libs/settings/path_manager.py b/src/libs/settings/path_manager.py new file mode 100644 index 0000000..59217e4 --- /dev/null +++ b/src/libs/settings/path_manager.py @@ -0,0 +1,123 @@ +# Python imports +import json +import zipfile + +from os import path +from os import mkdir + +# Lib imports + +# Application imports + + + +class MissingConfigError(Exception): + pass + + + +class PathManager: + def __init__(self): + self._SCRIPT_PTH: str = path.dirname(path.realpath(__file__)) + self._USER_HOME: str = path.expanduser('~') + self._HOME_CONFIG_PATH: str = f"{self._USER_HOME}/.config/{APP_NAME.lower()}" + self._USR_PATH: str = f"/usr/share/{APP_NAME.lower()}" + self._USR_CONFIG_FILE: str = f"{self._USR_PATH}/settings.json" + + self._CONTEXT_PATH: str = f"{self._HOME_CONFIG_PATH}/context_path" + self._PLUGINS_PATH: str = f"{self._HOME_CONFIG_PATH}/plugins" + self._DEFAULT_ICONS: str = f"{self._HOME_CONFIG_PATH}/icons" + self._CONFIG_FILE: str = f"{self._HOME_CONFIG_PATH}/settings.json" + self._GLADE_FILE: str = f"{self._HOME_CONFIG_PATH}/Main_Window.glade" + self._CSS_FILE: str = f"{self._HOME_CONFIG_PATH}/stylesheet.css" + self._KEY_BINDINGS_FILE: str = f"{self._HOME_CONFIG_PATH}/key-bindings.json" + self._PID_FILE: str = f"{self._HOME_CONFIG_PATH}/{APP_NAME.lower()}.pid" + self._UI_WIDGETS_PATH: str = f"{self._HOME_CONFIG_PATH}/ui_widgets" + self._CONTEXT_MENU: str = f"{self._HOME_CONFIG_PATH}/context_menu.json" + self._WINDOW_ICON: str = f"{self._DEFAULT_ICONS}/{APP_NAME.lower()}.png" + + # self._USR_CONFIG_FILE: str = f"{self._USR_PATH}/settings.json" + # self._PLUGINS_PATH: str = f"plugins" + # self._CONFIG_FILE: str = f"settings.json" + # self._GLADE_FILE: str = f"Main_Window.glade" + # self._CSS_FILE: str = f"stylesheet.css" + # self._KEY_BINDINGS_FILE: str = f"key-bindings.json" + # self._PID_FILE: str = f"{APP_NAME.lower()}.pid" + # self._WINDOW_ICON: str = f"{APP_NAME.lower()}.png" + # self._UI_WIDGETS_PATH: str = f"ui_widgets" + # self._CONTEXT_MENU: str = f"context_menu.json" + # self._DEFAULT_ICONS: str = f"icons" + + + # with zipfile.ZipFile("files.zip", mode="r", allowZip64=True) as zf: + # with io.TextIOWrapper(zf.open("text1.txt"), encoding="utf-8") as f: + + + if not path.exists(self._HOME_CONFIG_PATH): + mkdir(self._HOME_CONFIG_PATH) + if not path.exists(self._PLUGINS_PATH): + mkdir(self._PLUGINS_PATH) + + if not path.exists(self._DEFAULT_ICONS): + self._DEFAULT_ICONS = f"{self._USR_PATH}/icons" + if not path.exists(self._DEFAULT_ICONS): + raise MissingConfigError("Unable to find the application icons directory.") + if not path.exists(self._GLADE_FILE): + self._GLADE_FILE = f"{self._USR_PATH}/Main_Window.glade" + if not path.exists(self._GLADE_FILE): + raise MissingConfigError("Unable to find the application Glade file.") + if not path.exists(self._KEY_BINDINGS_FILE): + self._KEY_BINDINGS_FILE = f"{self._USR_PATH}/key-bindings.json" + if not path.exists(self._KEY_BINDINGS_FILE): + raise MissingConfigError("Unable to find the application Keybindings file.") + if not path.exists(self._CSS_FILE): + self._CSS_FILE = f"{self._USR_PATH}/stylesheet.css" + if not path.exists(self._CSS_FILE): + raise MissingConfigError("Unable to find the application Stylesheet file.") + if not path.exists(self._WINDOW_ICON): + self._WINDOW_ICON = f"{self._USR_PATH}/icons/{APP_NAME.lower()}.png" + if not path.exists(self._WINDOW_ICON): + raise MissingConfigError("Unable to find the application icon.") + if not path.exists(self._UI_WIDGETS_PATH): + self._UI_WIDGETS_PATH = f"{self._USR_PATH}/ui_widgets" + if not path.exists(self._CONTEXT_MENU): + self._CONTEXT_MENU = f"{self._USR_PATH}/context_menu.json" + + + def get_glade_file(self) -> str: return self._GLADE_FILE + def get_ui_widgets_path(self) -> str: return self._UI_WIDGETS_PATH + def get_context_path(self) -> str: return self._CONTEXT_PATH + def get_plugins_path(self) -> str: return self._PLUGINS_PATH + def get_css_file(self) -> str: return self._CSS_FILE + def get_home_config_path(self) -> str: return self._HOME_CONFIG_PATH + def get_window_icon(self) -> str: return self._WINDOW_ICON + def get_home_path(self) -> str: return self._USER_HOME + + def load_keybindings(self): + try: + with open(self._KEY_BINDINGS_FILE) as file: + return json.load(file)["keybindings"] + except Exception as e: + print( f"Settings Path Manager: {self._KEY_BINDINGS_FILE}\n\t\t{repr(e)}" ) + return {} + + def load_context_menu_data(self): + try: + with open(self._CONTEXT_MENU) as file: + return json.load(file) + except Exception as e: + print( f"Settings Path Manager: {self._CONTEXT_MENU}\n\t\t{repr(e)}" ) + return {} + + def load_settings(self): + if not path.exists(self._CONFIG_FILE): + return None + + with open(self._CONFIG_FILE) as file: + data = json.load(file) + data["load_defaults"] = False + return data + + def save_settings(self, settings: any): + with open(self._CONFIG_FILE, 'w') as outfile: + json.dump(settings.as_dict(), outfile, separators=(',', ':'), indent=4) \ No newline at end of file diff --git a/src/libs/settings/start_check_mixin.py b/src/libs/settings/start_check_mixin.py index b47d9bd..a15d2b7 100644 --- a/src/libs/settings/start_check_mixin.py +++ b/src/libs/settings/start_check_mixin.py @@ -2,6 +2,7 @@ import os import json import inspect +from contextlib import suppress # Lib imports @@ -24,8 +25,8 @@ class StartCheckMixin: self._print_pid(pid) return - if os.path.exists(self._PID_FILE): - with open(self._PID_FILE, "r") as f: + if os.path.exists(self.path_manager._PID_FILE): + with open(self.path_manager._PID_FILE, "r") as f: pid = f.readline().strip() if pid not in ("", None): if self.is_pid_alive( int(pid) ): @@ -56,8 +57,9 @@ class StartCheckMixin: print(f"{APP_NAME} PID: {pid}") def _clean_pid(self): - os.unlink(self._PID_FILE) + with suppress(FileNotFoundError, PermissionError): + os.unlink(self.path_manager._PID_FILE) def _write_pid(self, pid): - with open(self._PID_FILE, "w") as _pid: + with open(self.path_manager._PID_FILE, "w") as _pid: _pid.write(f"{pid}") \ No newline at end of file diff --git a/src/libs/settings/other/__init__.py b/src/libs/settings/webkit/__init__.py similarity index 100% rename from src/libs/settings/other/__init__.py rename to src/libs/settings/webkit/__init__.py diff --git a/src/libs/settings/webkit/webkit_ui_settings.py b/src/libs/settings/webkit/webkit_ui_settings.py new file mode 100644 index 0000000..0351fc6 --- /dev/null +++ b/src/libs/settings/webkit/webkit_ui_settings.py @@ -0,0 +1,102 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('WebKit2', '4.0') +from gi.repository import WebKit2 + +# Application imports + + + +class WebkitUISettings(WebKit2.Settings): + def __init__(self): + super(WebkitUISettings, self).__init__() + + self._set_default_settings() + + + # Note: Highly insecure setup but most "app" like setup I could think of. + # Audit heavily any scripts/links ran/clicked under this setup! + def _set_default_settings(self): + self.set_enable_xss_auditor(True) + self.set_enable_hyperlink_auditing(True) + # self.set_enable_xss_auditor(False) + # self.set_enable_hyperlink_auditing(False) + self.set_allow_file_access_from_file_urls(True) + self.set_allow_universal_access_from_file_urls(True) + + self.set_enable_page_cache(False) + self.set_enable_offline_web_application_cache(False) + self.set_enable_html5_local_storage(False) + self.set_enable_html5_database(False) + + self.set_enable_fullscreen(False) + self.set_print_backgrounds(False) + self.set_enable_tabs_to_links(False) + self.set_enable_developer_extras(True) + self.set_enable_webrtc(True) + self.set_enable_webaudio(True) + self.set_enable_accelerated_2d_canvas(True) + + self.set_user_agent( + f"Mozilla/5.0 (macOS, AArch64) {APP_NAME}/1.0 Chrome/140.0.0 AppleWebKit/537.36 Safari/537.36" + ) + + # Note: Most "browser" like setup I could think of. + def other_set_default_settings(self): + # Usability + self.set_property('enable-fullscreen', True) + self.set_property('print-backgrounds', True) + self.set_property('enable-frame-flattening', False) + self.set_property('enable-plugins', True) + self.set_property('enable-java', False) + self.set_property('enable-resizable-text-areas', True) + self.set_property('zoom-text-only', False) + self.set_property('enable-smooth-scrolling', True) + self.set_property('enable-back-forward-navigation-gestures', False) + self.set_property('media-playback-requires-user-gesture', False) + self.set_property('enable-tabs-to-links', True) + self.set_property('enable-caret-browsing', False) + + # Security + # self.set_property('user-agent', 'Mozilla/5.0 (X11; Generic; Linux x86-64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Safari/605.1.15') + self.set_property('user-agent', f"Mozilla/5.0 (macOS, AArch64) {APP_NAME}/1.0 Chrome/140.0.0 AppleWebKit/537.36 Safari/537.36") + self.set_property('enable-private-browsing', False) + self.set_property('enable-xss-auditor', True) + self.set_property('enable-hyperlink-auditing', False) + self.set_property('enable-site-specific-quirks', True) + self.set_property('enable-offline-web-application-cache', True) + self.set_property('enable-page-cache', True) + self.set_property('allow-modal-dialogs', True) + self.set_property('enable-html5-local-storage', True) + self.set_property('enable-html5-database', True) + self.set_property('allow-file-access-from-file-urls', True) + self.set_property('allow-universal-access-from-file-urls', False) + self.set_property('enable-dns-prefetching', False) + + # Media stuff + self.set_hardware_acceleration_policy(0) + # self.set_property('hardware-acceleration-policy', 'on-demand') + self.set_property('enable-webgl', True) + self.set_property('enable-webaudio', True) + self.set_property('enable-accelerated-2d-canvas', True) + self.set_property('auto-load-images', True) + self.set_property('enable-media-capabilities', True) + self.set_property('enable-media-stream', True) + self.set_property('enable-mediasource', True) + self.set_property('enable-encrypted-media', True) + self.set_property('media-playback-allows-inline', True) + + # JS + self.set_property('enable-javascript', True) + self.set_property('enable-javascript-markup', True) + self.set_property('javascript-can-access-clipboard', False) + self.set_property('javascript-can-open-windows-automatically', False) + + # Debugging + self.set_property('enable-developer-extras', True) + self.set_property('enable-write-console-messages-to-stdout', False) + self.set_property('draw-compositing-indicators', False) + self.set_property('enable-mock-capture-devices', False) + self.set_property('enable-spatial-navigation', False) diff --git a/src/libs/singleton.py b/src/libs/singleton.py index b484b28..cfe6534 100644 --- a/src/libs/singleton.py +++ b/src/libs/singleton.py @@ -1,4 +1,5 @@ # Python imports +from typing import Type, TypeVar, Any # Lib imports @@ -11,12 +12,21 @@ class SingletonError(Exception): +T = TypeVar('T', bound = 'Singleton') + + + class Singleton: - _instance = None + _instances = {} - def __new__(cls, *args, **kwargs): - if cls._instance: - raise SingletonError(f"'{cls.__name__}' is a Singleton. Cannot create a new instance...") + def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: + if cls in cls._instances: return cls._instances[cls] - cls._instance = super(Singleton, cls).__new__(cls) - return cls._instance + instance = super().__new__(cls) + cls._instances[cls] = instance + return instance + + @classmethod + def destroy(cls): + if cls in cls._instances: + del cls._instances[cls] diff --git a/src/libs/singleton_raised.py b/src/libs/singleton_raised.py new file mode 100644 index 0000000..bb88d9b --- /dev/null +++ b/src/libs/singleton_raised.py @@ -0,0 +1,29 @@ +# Python imports +from typing import Type, TypeVar, Any + +# Lib imports + +# Application imports + + + +class SingletonError(Exception): + pass + + + +T = TypeVar('T', bound='SingletonRaised') + +class SingletonRaised: + __instance = None + + def __new__(cls: Type[T], *args: Any, **kwargs: Any) -> T: + if cls.__instance is not None: + raise SingletonError(f"'{cls.__name__}' is a Singleton. Cannot create a new instance...") + + cls.__instance = super(SingletonRaised, cls).__new__(cls) + return cls.__instance + + def __init__(self) -> None: + if self.__instance is not None: + return diff --git a/src/libs/widget_registery.py b/src/libs/widget_registery.py new file mode 100644 index 0000000..347d056 --- /dev/null +++ b/src/libs/widget_registery.py @@ -0,0 +1,64 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from .controllers.controller_base import ControllerBase +from .dto.base_event import BaseEvent + + + +class WidgetRegisteryController(ControllerBase): + """docstring for WidgetRegisteryController.""" + + def __init__(self): + super(WidgetRegisteryController, self).__init__() + + self._builder: Gtk.Builder = None + self.objects: dict = {} + self.builder_keys: list = [] + + self._load_glade_file() + + + def _load_glade_file(self): + self._builder = Gtk.Builder.new_from_file( settings_manager.path_manager.get_glade_file() ) + settings_manager.set_builder(self._builder) + + widgets = self._builder.get_objects() + for widget in widgets: + self.builder_keys.append( widget.get_name() ) + + def _controller_message(self, event: BaseEvent): + ... + + def list_objects(self, id: str) -> list: + return self.objects.keys() + self.builder_keys + + def list_non_builder_objects(self, id: str) -> list: + return self.objects.keys() + + def list_builder_objects(self, id: str) -> list: + return self.builder_keys + + def get_object(self, id: str) -> any: + if id in self.objects: + return self.objects[id] + + return self._builder.get_object(id) + + def expose_object(self, id: str, object: any, use_gtk: bool = False): + if not use_gtk: + self.objects[id] = object + return + + self._builder.expose_object(id, object) + self.builder_keys.append(id) + + def dereference_object(self, id: str): + self.builder_keys.remove(id) + if id in self.objects: + del self.objects[id] diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py index 5624b32..8fde4dc 100644 --- a/src/plugins/__init__.py +++ b/src/plugins/__init__.py @@ -1,3 +1,5 @@ """ Gtk Bound Plugins Module """ + +from .controller import plugins_controller diff --git a/src/plugins/controller.py b/src/plugins/controller.py new file mode 100644 index 0000000..6b327fc --- /dev/null +++ b/src/plugins/controller.py @@ -0,0 +1,187 @@ +# Python imports +import os +import sys +import importlib +import traceback + +from os.path import join +from os.path import isdir + +# Lib imports +import gi +from gi.repository import Gtk +from gi.repository import GLib + +# Application imports +from libs.event_factory import Event_Factory, Code_Event_Types +from libs.controllers.controller_base import ControllerBase +from libs.dto.plugins.manifest_meta import ManifestMeta +from libs.dto.base_event import BaseEvent + +from .manifest_manager import ManifestManager +from .plugins_controller_mixin import PluginsControllerMixin +from .plugin_reload_mixin import PluginReloadMixin +from .plugin_context import PluginContext +from .plugins_ui import PluginsUI + + + +class PluginsControllerException(Exception): + ... + + + +class PluginsController(ControllerBase, PluginsControllerMixin, PluginReloadMixin): + """ PluginsController controller """ + + def __init__(self): + super(PluginsController, self).__init__() + + # path = os.path.dirname(os.path.realpath(__file__)) + # sys.path.insert(0, path) # NOTE: I think I'm not using this correctly... + + self.plugins_ui: PluginsUI = PluginsUI() + self._manifest_manager: ManifestManager = ManifestManager() + + self._plugin_collection: list = [] + self._plugins_path: str = settings_manager.path_manager.get_plugins_path() + + self._set_plugins_watcher() + + + def _controller_message(self, event: BaseEvent): + for manifest_meta in self._plugin_collection: + manifest_meta.instance._controller_message(event) + + if isinstance(event, Code_Event_Types.PopulateSourceViewPopupEvent): + event.menu.append( Gtk.SeparatorMenuItem() ) + item = Gtk.MenuItem(label = "Plugins") + item.connect("activate", self.toggle_plugins_ui) + event.menu.append(item) + elif isinstance(event, Code_Event_Types.TogglePluginsUiEvent): + self.toggle_plugins_ui() + + def _collect_search_locations(self, path: str, locations: list): + locations.append(path) + for file in os.listdir(path): + _path = os.path.join(path, file) + if not os.path.isdir(_path): continue + self._collect_search_locations(_path, locations) + + def _load_plugins( + self, + manifest_metas: list = [], + is_pre_launch: bool = False + ): + parent_path = os.getcwd() + + for manifest_meta in manifest_metas: + try: + path, \ + folder, \ + manifest = manifest_meta.path, manifest_meta.folder, manifest_meta.manifest + target = join(path, "plugin.py") + + if not os.path.exists(target): + raise PluginsControllerException( + "Invalid Plugin Structure: Plugin doesn't have 'plugin.py'. Aboarting load..." + ) + + module = self._load_plugin_module(path, folder, target) + + self._handle_plugin_execute(is_pre_launch, module, manifest_meta) + except PluginsControllerException as e: + logger.info(f"Malformed Plugin: Not loading -->: '{manifest_meta.folder}' !") + logger.debug(f"Trace: {traceback.print_exc()}") + + os.chdir(parent_path) + + def _load_plugin_module(self, path, folder, target): + os.chdir(path) + + locations = [] + self._collect_search_locations(path, locations) + + spec = importlib.util.spec_from_file_location(folder, target, submodule_search_locations = locations) + module = importlib.util.module_from_spec(spec) + sys.modules[folder] = module + spec.loader.exec_module(module) + + return module + + def _handle_plugin_execute( + self, is_pre_launch: bool, module, manifest_meta + ): + if not is_pre_launch: + GLib.idle_add( + self.execute_plugin, module, manifest_meta + ) + return + + self.execute_plugin(module, manifest_meta) + + def pre_launch_plugins(self): + logger.info(f"Loading pre-launch plugins...") + manifest_metas: list = self._manifest_manager.get_pre_launch_plugins() + self._load_plugins(manifest_metas, is_pre_launch = True) + + for manifest_meta in manifest_metas: + self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state) + + def post_launch_plugins(self): + logger.info(f"Loading post-launch plugins...") + manifest_metas: list = self._manifest_manager.get_post_launch_plugins() + self._load_plugins(manifest_metas) + + for manifest_meta in manifest_metas: + self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state) + + def manual_launch_plugins(self): + logger.info(f"Collecting manual-launch plugins...") + manifest_metas: list = self._manifest_manager.get_manual_launch_plugins() + + for manifest_meta in manifest_metas: + self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state) + + def toggle_plugin_load_state(self, widget, manifest_meta): + if manifest_meta.instance: + self._plugin_collection.remove(manifest_meta) + manifest_meta.instance.unload() + manifest_meta.instance = None + widget.set_label("Load") + return + + self._load_plugins( [manifest_meta] ) + widget.set_label("Unload") + + def execute_plugin(self, module: type, manifest_meta: ManifestMeta): + plugin = module.Plugin() + plugin.plugin_context: PluginContext = self.create_plugin_context() + + manifest = manifest_meta.manifest + manifest_meta.instance = plugin + + if manifest.requests.bind_keys: + keybindings.append_bindings( manifest.requests.bind_keys ) + + manifest_meta.instance.load() + manifest_meta.instance.run() + + self._plugin_collection.append(manifest_meta) + + def create_plugin_context(self): + plugin_context: PluginContext = PluginContext() + + plugin_context.request_ui_element: callable = self.request_ui_element + plugin_context.emit: callable = self.emit + plugin_context.emit_to: callable = self.emit_to + plugin_context.emit_to_selected: callable = self.emit_to_selected + plugin_context.register_controller: callable = self.register_controller + plugin_context.unregister_controller: callable = self.unregister_controller + + return plugin_context + + def toggle_plugins_ui(self, widget = None): + self.plugins_ui.hide() if self.plugins_ui.is_visible() else self.plugins_ui.show() + +plugins_controller = PluginsController() diff --git a/src/plugins/manifest.py b/src/plugins/manifest.py deleted file mode 100644 index 7cb701c..0000000 --- a/src/plugins/manifest.py +++ /dev/null @@ -1,75 +0,0 @@ -# Python imports -import os -import json -from os.path import join - -# Lib imports - -# Application imports - - - - -class ManifestProcessor(Exception): - ... - - -class Plugin: - path: str = None - name: str = None - author: str = None - version: str = None - support: str = None - requests:{} = None - reference: type = None - pre_launch: bool = False - - -class ManifestProcessor: - def __init__(self, path, builder): - manifest = join(path, "manifest.json") - if not os.path.exists(manifest): - raise Exception("Invalid Plugin Structure: Plugin doesn't have 'manifest.json'. Aboarting load...") - - self._path = path - self._builder = builder - with open(manifest) as f: - data = json.load(f) - self._manifest = data["manifest"] - self._plugin = self.collect_info() - - def collect_info(self) -> Plugin: - plugin = Plugin() - plugin.path = self._path - plugin.name = self._manifest["name"] - plugin.author = self._manifest["author"] - plugin.version = self._manifest["version"] - plugin.support = self._manifest["support"] - plugin.requests = self._manifest["requests"] - - if "pre_launch" in self._manifest.keys(): - plugin.pre_launch = True if self._manifest["pre_launch"] == "true" else False - - return plugin - - def get_loading_data(self): - loading_data = {} - requests = self._plugin.requests - - if "pass_events" in requests: - if requests["pass_events"] in ["true"]: - loading_data["pass_events"] = True - - if "pass_ui_objects" in requests: - if isinstance(requests["pass_ui_objects"], list): - loading_data["pass_ui_objects"] = [ self._builder.get_object(obj) for obj in requests["pass_ui_objects"] ] - - if "bind_keys" in requests: - if isinstance(requests["bind_keys"], list): - loading_data["bind_keys"] = requests["bind_keys"] - - return self._plugin, loading_data - - def is_pre_launch(self): - return self._plugin.pre_launch - diff --git a/src/plugins/manifest_manager.py b/src/plugins/manifest_manager.py new file mode 100644 index 0000000..0f7add3 --- /dev/null +++ b/src/plugins/manifest_manager.py @@ -0,0 +1,75 @@ +# Python imports +import os +import json +from os.path import join + +# Lib imports + +# Application imports +from libs.dto.plugins.manifest_meta import ManifestMeta +from libs.dto.plugins.manifest import Manifest + + + +class ManifestMapperException(Exception): + ... + + + +class ManifestManager: + def __init__(self): + + self._plugins_path: str = \ + settings_manager.path_manager.get_plugins_path() + + self.pre_launch_manifests: list = [] + self.post_launch_manifests: list = [] + self.manual_launch_manifests: list = [] + + self.load_manifests() + + + def load_manifests(self): + logger.info(f"Loading manifests...") + + for path, folder in [ + [join(self._plugins_path, item), item] + for item in os.listdir(self._plugins_path) + if os.path.isdir( join(self._plugins_path, item) ) + ]: + self.load(folder, path) + + def load(self, folder, path) -> ManifestMeta: + manifest_pth = join(path, "manifest.json") + + if not os.path.exists(manifest_pth): + raise ManifestMapperException("Invalid Plugin Structure: Plugin doesn't have 'manifest.json'. Aboarting load...") + + with open(manifest_pth) as f: + data = json.load(f) + manifest = Manifest(**data) + manifest_meta = ManifestMeta() + + manifest_meta.folder = folder + manifest_meta.path = path + manifest_meta.manifest = manifest + + if not manifest.autoload: + self.manual_launch_manifests.append(manifest_meta) + return + + if manifest.pre_launch: + self.pre_launch_manifests.append(manifest_meta) + else: + self.post_launch_manifests.append(manifest_meta) + + return manifest_meta + + def get_pre_launch_plugins(self) -> list: + return self.pre_launch_manifests + + def get_post_launch_plugins(self) -> list: + return self.post_launch_manifests + + def get_manual_launch_plugins(self) -> list: + return self.manual_launch_manifests diff --git a/src/plugins/plugin_base.py b/src/plugins/plugin_base.py deleted file mode 100644 index 3650495..0000000 --- a/src/plugins/plugin_base.py +++ /dev/null @@ -1,92 +0,0 @@ -# Python imports -import os -import time -import inspect - -# Lib imports - -# Application imports - - -class PluginBaseException(Exception): - ... - - -class PluginBase: - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.name = "Example Plugin" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus - # where self.name should not be needed for message comms - - self._builder = None - self._ui_objects = None - self._event_system = None - - - def run(self): - """ - Must define regardless if needed and can 'pass' if plugin doesn't need it. - Is intended to be used to setup internal signals or custom Gtk Builders/UI logic. - """ - raise PluginBaseException("Method hasn't been overriden...") - - def generate_reference_ui_element(self): - """ - Requests Key: 'ui_target': "plugin_control_list", - Must define regardless if needed and can 'pass' if plugin doesn't use it. - Must return a widget if "ui_target" is set. - """ - raise PluginBaseException("Method hasn't been overriden...") - - def set_ui_object_collection(self, ui_objects): - """ - Requests Key: "pass_ui_objects": [""] - Request reference to a UI component. Will be passed back as array to plugin. - Must define in plugin if set and an array of valid glade UI IDs is given. - """ - self._ui_objects = ui_objects - - def set_event_system(self, event_system): - """ - Requests Key: 'pass_events': "true" - Must define in plugin if "pass_events" is set to "true" string. - """ - self._event_system = event_system - - def subscribe_to_events(self): - ... - - def _connect_builder_signals(self, caller_class, builder): - classes = [caller_class] - handlers = {} - for c in classes: - methods = None - try: - methods = inspect.getmembers(c, predicate=inspect.ismethod) - handlers.update(methods) - except Exception as e: - logger.debug(repr(e)) - - builder.connect_signals(handlers) - - def reload_package(self, plugin_path, module_dict_main=locals()): - import importlib - from pathlib import Path - - def reload_package_recursive(current_dir, module_dict): - for path in current_dir.iterdir(): - if "__init__" in str(path) or path.stem not in module_dict: - continue - - if path.is_file() and path.suffix == ".py": - importlib.reload(module_dict[path.stem]) - elif path.is_dir(): - reload_package_recursive(path, module_dict[path.stem].__dict__) - - reload_package_recursive(Path(plugin_path).parent, module_dict_main["module_dict_main"]) - - - def clear_children(self, widget: type) -> None: - """ Clear children of a gtk widget. """ - for child in widget.get_children(): - widget.remove(child) diff --git a/src/plugins/plugin_context.py b/src/plugins/plugin_context.py new file mode 100644 index 0000000..eb093e0 --- /dev/null +++ b/src/plugins/plugin_context.py @@ -0,0 +1,42 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.dto.base_event import BaseEvent + + + +class PluginContextException(Exception): + ... + + + +class PluginContext: + """ PluginContext """ + + def __init__(self): + super(PluginContext, self).__init__() + + + def _controller_message(self, event: BaseEvent): + raise PluginContextException("Plugin Context '_controller_message' must be overridden...") + + def request_ui_element(self, element_id: str): + raise PluginContextException("Plugin Context 'request_ui_element' must be overridden...") + + def emit(self, event: BaseEvent): + raise PluginContextException("Plugin Context 'emit' must be overridden...") + + def emit_to(self, name: str, event: BaseEvent): + raise PluginContextException("Plugin Context 'emit_to' must be overridden...") + + def emit_to_selected(self, names: list[str], event: BaseEvent): + raise PluginContextException("Plugin Context 'emit_to_selected' must be overridden...") + + def register_controller(self, name: str, controller): + raise PluginContextException("Plugin Context 'register_controller' must be overridden...") + + def unregister_controller(self, name: str): + raise PluginContextException("Plugin Context 'unregister_controller' must be overridden...") + diff --git a/src/plugins/plugin_reload_mixin.py b/src/plugins/plugin_reload_mixin.py new file mode 100644 index 0000000..eea9a9e --- /dev/null +++ b/src/plugins/plugin_reload_mixin.py @@ -0,0 +1,66 @@ +# Python imports + +# Lib imports +import gi +from gi.repository import Gio + +# Application imports + + + +class PluginReloadMixin: + _plugins_dir_watcher = None + + def _set_plugins_watcher(self) -> None: + self._plugins_dir_watcher = \ + Gio.File.new_for_path( self._plugins_path ) \ + .monitor_directory( + Gio.FileMonitorFlags.WATCH_MOVES, + Gio.Cancellable() + ) + + self._plugins_dir_watcher.connect("changed", self._on_plugins_changed, ()) + + def _on_plugins_changed(self, + file_monitor, file, + other_file = None, + eve_type = None, + data = None + ): + if eve_type is Gio.FileMonitorEvent.RENAMED: + ... + + if eve_type in [Gio.FileMonitorEvent.CREATED, Gio.FileMonitorEvent.MOVED_IN]: + self.add_plugin(file) + + if eve_type in [Gio.FileMonitorEvent.DELETED, Gio.FileMonitorEvent.MOVED_OUT]: + self.remove_plugin(file) + + def add_plugin(self, file: str) -> None: + logger.info(f"Adding plugin: {file.get_uri()}") + uri = file.get_uri() + path = uri.replace("file://", "") + folder = path.split("/")[-1] + manifest_meta = self._manifest_manager.load(folder, path) + + self._load_plugins( [manifest_meta] ) + self.plugins_ui.add_row(manifest_meta, self.toggle_plugin_load_state) + + def remove_plugin(self, file: str) -> None: + logger.info(f"Removing plugin: {file.get_uri()}") + for manifest_meta in self._plugin_collection[:]: + if not manifest_meta.folder in file.get_uri(): continue + + manifest_meta.instance.unload() + manifest_meta.instance = None + self._plugin_collection.remove(manifest_meta) + self.plugins_ui.remove_row(manifest_meta) + + if manifest_meta in self._manifest_manager.pre_launch_manifests: + self._manifest_manager.pre_launch_manifests.remove(manifest_meta) + elif manifest_meta in self._manifest_manager.post_launch_manifests: + self._manifest_manager.post_launch_manifests.remove(manifest_meta) + elif manifest_meta in self._manifest_manager.manual_launch_manifests: + self._manifest_manager.manual_launch_manifests.remove(manifest_meta) + + break diff --git a/src/plugins/plugin_types/__init__.py b/src/plugins/plugin_types/__init__.py new file mode 100644 index 0000000..5814687 --- /dev/null +++ b/src/plugins/plugin_types/__init__.py @@ -0,0 +1,7 @@ +""" + Plugin Types Module +""" + +from .plugin_base import PluginBase +from .plugin_ui import PluginUI +from .plugin_code import PluginCode diff --git a/src/plugins/plugin_types/plugin_base.py b/src/plugins/plugin_types/plugin_base.py new file mode 100644 index 0000000..221643f --- /dev/null +++ b/src/plugins/plugin_types/plugin_base.py @@ -0,0 +1,46 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.dto.base_event import BaseEvent + +from ..plugin_context import PluginContext + + + +class PluginBaseException(Exception): + ... + + + +class PluginBase: + def __init__(self, *args, **kwargs): + super(PluginBase, self).__init__(*args, **kwargs) + + self.plugin_context: PluginContext = None + + + def _controller_message(self, event: BaseEvent): + raise PluginBaseException("Plugin Base '_controller_message' must be overriden by Plugin") + + def load(self): + raise PluginBaseException("Plugin Base 'load' must be overriden by Plugin") + + def unload(self): + raise PluginBaseException("Plugin Base 'unload' must be overriden by Plugin") + + def run(self): + raise PluginBaseException("Plugin Base 'run' must be overriden by Plugin") + + def request_ui_element(self, element_id: str): + raise PluginBaseException("Plugin Base 'request_ui_element' must be overriden by Plugin") + + def emit(self, event: BaseEvent): + raise PluginBaseException("Plugin Base 'emit' must be overriden by Plugin") + + def emit_to(self, name: str, event: BaseEvent): + raise PluginBaseException("Plugin Base 'emit_to' must be overriden by Plugin") + + def emit_to_selected(self, names: list[str], event: BaseEvent): + raise PluginBaseException("Plugin Base 'emit_to_selected' must be overriden by Plugin") diff --git a/src/plugins/plugin_types/plugin_code.py b/src/plugins/plugin_types/plugin_code.py new file mode 100644 index 0000000..3c6188d --- /dev/null +++ b/src/plugins/plugin_types/plugin_code.py @@ -0,0 +1,50 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.dto.base_event import BaseEvent + +from ..plugin_context import PluginContext +from .plugin_base import PluginBase + + + +class PluginCodeException(Exception): + ... + + + +class PluginCode(PluginBase): + def __init__(self, *args, **kwargs): + super(PluginCode, self).__init__(*args, **kwargs) + + self.plugin_context: PluginContext = None + + + def _controller_message(self, event: BaseEvent): + raise PluginCodeException("Plugin Code '_controller_message' must be overriden by Plugin") + + def load(self): + raise PluginCodeException("Plugin Code 'load' must be overriden by Plugin") + + def run(self): + raise PluginCodeException("Plugin Code 'run' must be overriden by Plugin") + + def register_controller(self, name: str, controller): + return self.plugin_context.register_controller(name, controller) + + def unregister_controller(self, name: str): + return self.plugin_context.unregister_controller(name) + + def request_ui_element(self, element_id: str): + return self.plugin_context.request_ui_element(element_id) + + def emit(self, event: BaseEvent): + return self.plugin_context.emit(event) + + def emit_to(self, name: str, event: BaseEvent): + return self.plugin_context.emit_to(name, event) + + def emit_to_selected(self, names: list[str], event: BaseEvent): + return self.plugin_context.emit_to_selected(names, event) diff --git a/src/plugins/plugin_types/plugin_ui.py b/src/plugins/plugin_types/plugin_ui.py new file mode 100644 index 0000000..f09e09f --- /dev/null +++ b/src/plugins/plugin_types/plugin_ui.py @@ -0,0 +1,44 @@ +# Python imports + +# Lib imports + +# Application imports +from libs.dto.base_event import BaseEvent + +from ..plugin_context import PluginContext +from .plugin_base import PluginBase + + + +class PluginCodeException(Exception): + ... + + + +class PluginUI(PluginBase): + def __init__(self, *args, **kwargs): + super(PluginUI, self).__init__(*args, **kwargs) + + self.plugin_context: PluginContext = None + + + def _controller_message(self, event: BaseEvent): + raise PluginCodeException("Plugin UI '_controller_message' must be overriden by Plugin") + + def load(self): + raise PluginCodeException("Plugin UI 'load' must be overriden by Plugin") + + def run(self): + raise PluginCodeException("Plugin UI 'run' must be overriden by Plugin") + + def request_ui_element(self, element_id: str): + return self.plugin_context.request_ui_element(element_id) + + def emit(self, event: BaseEvent): + return self.plugin_context.emit(event) + + def emit_to(self, name: str, event: BaseEvent): + return self.plugin_context.emit_to(name, event) + + def emit_to_selected(self, names: list[str], event: BaseEvent): + return self.plugin_context.emit_to_selected(names, event) diff --git a/src/plugins/plugins_controller.py b/src/plugins/plugins_controller.py deleted file mode 100644 index 10d5dc2..0000000 --- a/src/plugins/plugins_controller.py +++ /dev/null @@ -1,158 +0,0 @@ -# Python imports -import os -import sys -import importlib -import traceback -from os.path import join -from os.path import isdir - -# Lib imports -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk -from gi.repository import GLib -from gi.repository import Gio - -# Application imports -from .manifest import Plugin -from .manifest import ManifestProcessor - - - - -class InvalidPluginException(Exception): - ... - - -class PluginsController: - """PluginsController controller""" - - def __init__(self): - path = os.path.dirname(os.path.realpath(__file__)) - sys.path.insert(0, path) # NOTE: I think I'm not using this correctly... - - self._builder = settings_manager.get_builder() - self._plugins_path = settings_manager.get_plugins_path() - - self._plugins_dir_watcher = None - self._plugin_collection = [] - self._plugin_manifests = {} - - self._load_manifests() - - - def _load_manifests(self): - logger.info(f"Loading manifests...") - - for path, folder in [[join(self._plugins_path, item), item] if os.path.isdir(join(self._plugins_path, item)) else None for item in os.listdir(self._plugins_path)]: - manifest = ManifestProcessor(path, self._builder) - self._plugin_manifests[path] = { - "path": path, - "folder": folder, - "manifest": manifest - } - - self._set_plugins_watcher() - - def _set_plugins_watcher(self) -> None: - self._plugins_dir_watcher = Gio.File.new_for_path(self._plugins_path) \ - .monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, Gio.Cancellable()) - self._plugins_dir_watcher.connect("changed", self._on_plugins_changed, ()) - - def _on_plugins_changed(self, file_monitor, file, other_file=None, eve_type=None, data=None): - if eve_type in [Gio.FileMonitorEvent.CREATED, Gio.FileMonitorEvent.DELETED, - Gio.FileMonitorEvent.RENAMED, Gio.FileMonitorEvent.MOVED_IN, - Gio.FileMonitorEvent.MOVED_OUT]: - self.reload_plugins(file) - - def pre_launch_plugins(self) -> None: - logger.info(f"Loading pre-launch plugins...") - plugin_manifests: {} = {} - - for key in self._plugin_manifests: - target_manifest = self._plugin_manifests[key]["manifest"] - if target_manifest.is_pre_launch(): - plugin_manifests[key] = self._plugin_manifests[key] - - self._load_plugins(plugin_manifests, is_pre_launch = True) - - def post_launch_plugins(self) -> None: - logger.info(f"Loading post-launch plugins...") - plugin_manifests: {} = {} - - for key in self._plugin_manifests: - target_manifest = self._plugin_manifests[key]["manifest"] - if not target_manifest.is_pre_launch(): - plugin_manifests[key] = self._plugin_manifests[key] - - self._load_plugins(plugin_manifests) - - def _load_plugins(self, plugin_manifests: {} = {}, is_pre_launch: bool = False) -> None: - parent_path = os.getcwd() - - for key in plugin_manifests: - target_manifest = plugin_manifests[key] - path, folder, manifest = target_manifest["path"], target_manifest["folder"], target_manifest["manifest"] - - try: - target = join(path, "plugin.py") - if not os.path.exists(target): - raise InvalidPluginException("Invalid Plugin Structure: Plugin doesn't have 'plugin.py'. Aboarting load...") - - plugin, loading_data = manifest.get_loading_data() - module = self.load_plugin_module(path, folder, target) - - if is_pre_launch: - self.execute_plugin(module, plugin, loading_data) - else: - GLib.idle_add(self.execute_plugin, *(module, plugin, loading_data)) - except Exception as e: - logger.info(f"Malformed Plugin: Not loading -->: '{folder}' !") - logger.debug("Trace: ", traceback.print_exc()) - - os.chdir(parent_path) - - - def load_plugin_module(self, path, folder, target): - os.chdir(path) - - locations = [] - self.collect_search_locations(path, locations) - - spec = importlib.util.spec_from_file_location(folder, target, submodule_search_locations = locations) - module = importlib.util.module_from_spec(spec) - sys.modules[folder] = module - spec.loader.exec_module(module) - - return module - - def collect_search_locations(self, path, locations): - locations.append(path) - for file in os.listdir(path): - _path = os.path.join(path, file) - if os.path.isdir(_path): - self.collect_search_locations(_path, locations) - - def execute_plugin(self, module: type, plugin: Plugin, loading_data: []): - plugin.reference = module.Plugin() - keys = loading_data.keys() - - if "ui_target" in keys: - loading_data["ui_target"].add( plugin.reference.generate_reference_ui_element() ) - loading_data["ui_target"].show() - - if "pass_ui_objects" in keys: - plugin.reference.set_ui_object_collection( loading_data["pass_ui_objects"] ) - - if "pass_events" in keys: - plugin.reference.set_event_system(event_system) - plugin.reference.subscribe_to_events() - - if "bind_keys" in keys: - keybindings.append_bindings( loading_data["bind_keys"] ) - - plugin.reference.run() - self._plugin_collection.append(plugin) - - def reload_plugins(self, file: str = None) -> None: - logger.info(f"Reloading plugins... stub.") diff --git a/src/plugins/plugins_controller_mixin.py b/src/plugins/plugins_controller_mixin.py new file mode 100644 index 0000000..2dc72be --- /dev/null +++ b/src/plugins/plugins_controller_mixin.py @@ -0,0 +1,20 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class InvalidPluginException(Exception): + ... + + + +class PluginsControllerMixin: + + def request_ui_element(self, target_id: str): + if not target_id in widget_registery.objects: + raise InvalidPluginException('Unknown UI "target_id" given in requests.') + + return widget_registery.objects[target_id] diff --git a/src/plugins/plugins_ui.py b/src/plugins/plugins_ui.py new file mode 100644 index 0000000..34efa56 --- /dev/null +++ b/src/plugins/plugins_ui.py @@ -0,0 +1,100 @@ +# Python imports + +# Lib imports +import gi +from gi.repository import Gtk + +# Application imports + + + +class PluginsUI(Gtk.Dialog): + def __init__(self): + super(PluginsUI, self).__init__() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + + def _setup_styling(self): + header = Gtk.HeaderBar() + self.ctx = self.get_style_context() + self.ctx.add_class("plugin-ui") + + self.set_title("Plugins") + self.set_size_request(450, 530) + self.set_deletable(False) + self.set_skip_pager_hint(True) + self.set_skip_taskbar_hint(True) + + header.set_title("Plugins") + self.set_titlebar(header) + header.show() + + window = widget_registery.get_object("main-window") + self.set_transient_for(window) + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + widget_registery.expose_object("plugin-ui", self) + + content_area = self.get_content_area() + scrolled_win = Gtk.ScrolledWindow() + viewport = Gtk.Viewport() + self.list_box = Gtk.ListBox() + + self.list_box.set_selection_mode( Gtk.SelectionMode.NONE ) + scrolled_win.set_vexpand(True) + + viewport.add(self.list_box) + scrolled_win.add(viewport) + content_area.add(scrolled_win) + + scrolled_win.show_all() + + def add_row(self, manifest_meta, callback: callable): + box = Gtk.Box() + plugin_lbl = Gtk.Label(label = manifest_meta.manifest.name) + author_lbl = Gtk.Label(label = manifest_meta.manifest.author) + version_lbl = Gtk.Label(label = manifest_meta.manifest.version) + is_autoload = manifest_meta.manifest.autoload + toggle_bttn = Gtk.ToggleButton(label = "Unload" if is_autoload else "Load") + + toggle_bttn.set_active(is_autoload) + plugin_lbl.set_hexpand(True) + box.set_hexpand(True) + version_lbl.set_margin_left(15) + version_lbl.set_margin_right(15) + toggle_bttn.set_size_request(120, -1) + + toggle_bttn.toggle_id = \ + toggle_bttn.connect("toggled", callback, manifest_meta) + + box.add(plugin_lbl) + box.add(author_lbl) + box.add(version_lbl) + box.add(toggle_bttn) + box.manifest_meta = manifest_meta + + box.show_all() + self.list_box.add(box) + + def remove_row(self, manifest_meta): + for row in self.list_box.get_children(): + child = row.get_children()[0] + if not child.manifest_meta == manifest_meta: continue + + child.manifest_meta = None + toggle_bttn = getattr(child, "toggle_bttn", None) + toggle_bttn.disconnect(toggle_bttn.toggle_id) + + self.list_box.remove(row) + box.destroy() + break diff --git a/user_config/bin/ b/user_config/bin/ index c36f8a4..1be4569 100755 --- a/user_config/bin/ +++ b/user_config/bin/ @@ -6,6 +6,8 @@ # set -o errexit ## To exit on error # set -o errunset ## To exit if a variable is referenced but not set +export PYTHONDONTWRITEBYTECODE=1 + function main() { call_path=`pwd` diff --git a/user_config/usr/applications/.desktop b/user_config/usr/applications/change_me.desktop similarity index 100% rename from user_config/usr/applications/.desktop rename to user_config/usr/applications/change_me.desktop diff --git a/user_config/usr/share/app_name/code-key-bindings.json b/user_config/usr/share/app_name/code-key-bindings.json new file mode 100644 index 0000000..7cecd01 --- /dev/null +++ b/user_config/usr/share/app_name/code-key-bindings.json @@ -0,0 +1,58 @@ +{ + "keybindings": { + "show_completion": { + "released": "space" + }, + "line_up": { + "held": "Up" + }, + "line_down": { + "held": "Down" + }, + "zoom_in": { + "held": "equal" + }, + "zoom_out": { + "held": "minus" + }, + "duplicate_line": { + "held": "d" + }, + "go_to": { + "released": "g" + }, + "new_file": { + "released": "t" + }, + "buffer_undo": { + "held": "z" + }, + "buffer_redo": { + "held": "y" + }, + "open_files": { + "released": "o" + }, + "close_file": { + "released": "w" + }, + "save_file": { + "released": "s" + }, + "save_file_as": { + "released": "s" + }, + "focus_left_sibling": { + "released": "Page_Up" + }, + "focus_right_sibling": { + "released": "Page_Down" + }, + "move_to_left_sibling": { + "released": "Up" + }, + "move_to_right_sibling": { + "released": "Down" + } + } +} \ No newline at end of file diff --git a/user_config/usr/share/app_name/code_styles/catppuccin-frappe.xml b/user_config/usr/share/app_name/code_styles/catppuccin-frappe.xml new file mode 100644 index 0000000..76b043e --- /dev/null +++ b/user_config/usr/share/app_name/code_styles/catppuccin-frappe.xml @@ -0,0 +1,100 @@ + + + + + + sacerdos + <_description>Soothing pastel theme for Gedit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +