diff --git a/.gitignore b/.gitignore index b35526bc..bf8abe9e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ todo tmp _backup _tmp +*.pyc +.history/ +.agents/ diff --git a/license.md b/license.md new file mode 100644 index 00000000..18d755ad --- /dev/null +++ b/license.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Carlos Rico Adega + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/multi_script_editor/__init__.py b/multi_script_editor/__init__.py index b8963ed4..8edc49a8 100644 --- a/multi_script_editor/__init__.py +++ b/multi_script_editor/__init__.py @@ -1,37 +1,63 @@ -import os, sys +import os +import sys + +# Set preferred binding +if not os.environ.get("QT_PREFERRED_BINDING"): + os.environ["QT_PREFERRED_BINDING"] = os.pathsep.join( + ["PySide2", "PySide6", "PyQt5", "PySide", "PyQt4"] + ) + +# Disable High Dpi Scaling in PySide6 +os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "0" root = os.path.dirname(__file__) -if not root in sys.path: +if root not in sys.path: sys.path.append(root) +vendor_path = os.path.join(root, 'vendor') +if vendor_path not in sys.path: + sys.path.insert(0, vendor_path) + # HOUDINI -def showHoudini(clear=False, ontop=False, name=None, floating=False, position=(), size=(), - pane=None, replacePyPanel=False, hideTitleMenu=True): +def showHoudini(*args, **kwargs): """ - This method use hqt module. Download it before + Launch Multi Script Editor in Houdini """ from .managers import _houdini - reload(_houdini) - _houdini.show(clear=clear, ontop=ontop, name=name, floating=floating, position=position, - size=size, pane=pane, replacePyPanel=replacePyPanel, hideTitleMenu=hideTitleMenu) + return _houdini.show(*args, **kwargs) + # NUKE def showNuke(panel=False): + """ + Launch Multi Script Editor in Nuke + """ from .managers import _nuke - reload(_nuke) + _nuke.show(panel) # MAYA def showMaya(dock=False): + """ + Launch Multi Script Editor in Maya + """ from .managers import _maya - reload (_maya) + _maya.show(dock) -# 3DSMAX PLUS -def show3DSMax(): - sys.argv = [] - from .managers import _3dsmax - reload (_3dsmax) - _3dsmax.show() \ No newline at end of file + +def show(*args, **kwargs): + from . import managers + if managers.context == 'hou': + return showHoudini(*args, **kwargs) + elif managers.context == 'maya': + # Maya's show takes 'dock' kwarg + return showMaya(kwargs.get('dock', False)) + elif managers.context == 'nuke': + # Nuke's show takes 'panel' kwarg + return showNuke(kwargs.get('panel', False)) + + import scriptEditor + scriptEditor.show() diff --git a/multi_script_editor/core/__init__.py b/multi_script_editor/core/__init__.py new file mode 100644 index 00000000..c4b209c2 --- /dev/null +++ b/multi_script_editor/core/__init__.py @@ -0,0 +1 @@ +# Core package for Multi Script Editor diff --git a/multi_script_editor/core/autocomplete_provider.py b/multi_script_editor/core/autocomplete_provider.py new file mode 100644 index 00000000..7df752a1 --- /dev/null +++ b/multi_script_editor/core/autocomplete_provider.py @@ -0,0 +1,133 @@ +import re +import managers + +class CompletionItem: + def __init__(self, name, complete, comp_type, docstring_val="", prefix_length=0): + self.name = name + self.complete = complete + self.type = comp_type + self._docstring = docstring_val + self.prefix_length = prefix_length + + def get_completion_prefix_length(self): + return self.prefix_length + + def docstring(self): + return self._docstring + + +class AutocompleteProvider: + def __init__(self): + # Lazy load jedi to avoid heavy startup overhead if possible + self._jedi = None + + def _get_jedi(self): + if self._jedi is None: + import jedi + self._jedi = jedi + return self._jedi + + def get_completions(self, text, line, column, namespace=None, fuzzy=True, context=None, prefer_single_quotes=False): + """ + Returns a list of CompletionItem objects. + """ + comp_items = [] + context_completer = False + + preferred_quote = "'" if prefer_single_quotes else '"' + other_quote = '"' if prefer_single_quotes else "'" + + def format_quotes(name, complete, comp_type): + if comp_type == 'string' or (len(name) >= 2 and name[0] in ('"', "'") and name[-1] in ('"', "'")): + # Convert quotes in name + if len(name) >= 2 and name[0] == other_quote and name[-1] == other_quote: + name = preferred_quote + name[1:-1] + preferred_quote + # Convert quotes in complete + if complete: + if len(complete) >= 2 and complete[0] == other_quote and complete[-1] == other_quote: + complete = preferred_quote + complete[1:-1] + preferred_quote + elif complete[0] == other_quote: + complete = preferred_quote + complete[1:] + elif complete[-1] == other_quote: + complete = complete[:-1] + preferred_quote + return name, complete + + # 1. Try Context-Specific Completers (e.g., Maya, Nuke specific cmds) + if context and context in managers.contextCompleters: + current_line_text = text.split('\n')[line - 1] if text else '' + # We don't have exact cursor offset here easily, but managers used it roughly + # The original code passed `line` string to contextCompleters. + comp, extra = managers.contextCompleters[context](current_line_text, namespace) + if comp or extra: + context_completer = True + # Format them as CompletionItem + if comp: + for c in comp: + name, complete = format_quotes(c.name, getattr(c, 'complete', ''), getattr(c, 'type', 'statement')) + comp_items.append(CompletionItem(name, complete, getattr(c, 'type', 'statement'), c.docstring() if hasattr(c, 'docstring') else '')) + if extra: + for c in extra: + name, complete = format_quotes(c.name, getattr(c, 'complete', ''), getattr(c, 'type', 'statement')) + comp_items.append(CompletionItem(name, complete, getattr(c, 'type', 'statement'), c.docstring() if hasattr(c, 'docstring') else '')) + + if comp_items: + return comp_items + + # 2. Fallback to Jedi Autocompletion + if not context_completer: + jedi = self._get_jedi() + + # Prepend autoImports if necessary + offs = 0 + if context and context in managers.autoImport: + autoImp = managers.autoImport.get(context, '') + text = autoImp + text + offs = len(autoImp.split('\n')) - 1 + + # Shift the line number due to autoImports + jedi_line = line + offs + + try: + if namespace: + script = jedi.Interpreter(text, namespaces=[namespace]) + else: + script = jedi.Script(code=text) + + jedi_comps = script.complete(line=jedi_line, column=column, fuzzy=fuzzy) + + for c in jedi_comps: + # Filter out 'mro' as original code did + if c.name == 'mro': + continue + + doc = '' + try: + doc = c.docstring() + except Exception: + pass + + prefix_len = 0 + if hasattr(c, 'get_completion_prefix_length'): + prefix_len = c.get_completion_prefix_length() + + name, complete = format_quotes(c.name, c.complete, c.type) + + comp_items.append(CompletionItem( + name=name, + complete=complete, + comp_type=c.type, + docstring_val=doc, + prefix_length=prefix_len + )) + except Exception: + pass + + # Filter completions for dictionary keys if inside brackets + current_line_text = text.split('\n')[line - 1] if text else '' + prefix = current_line_text[:column] + if re.search(r'\w+\s*\[\s*[\'"]?\s*\w*$', prefix): + string_comps = [c for c in comp_items if c.type == 'string' or (len(c.name) >= 2 and c.name[0] in ('"', "'") and c.name[-1] in ('"', "'"))] + if string_comps: + comp_items = string_comps + + return comp_items diff --git a/multi_script_editor/core/base_text_widget.py b/multi_script_editor/core/base_text_widget.py new file mode 100644 index 00000000..046bf707 --- /dev/null +++ b/multi_script_editor/core/base_text_widget.py @@ -0,0 +1,185 @@ +import os +import webbrowser +from vendor.Qt.QtGui import QFont, QTextOption, QFontDatabase, QIcon +from vendor.Qt.QtWidgets import QTextEdit, QPlainTextEdit, QAction +from icons import icons + +class BaseTextWidgetMixin: + """ + Mixin class that provides common text editing functionalities + such as font size manipulation, word wrap, and whitespace rendering. + Expects to be mixed into a QTextEdit or QTextBrowser. + """ + def getFontSize(self): + if hasattr(self, 'fs') and self.fs > 0: + return self.fs + + size = self.font().pointSize() + if size > 0: + return size + + return 10 # Safe fallback + + def setFontSize(self, size): + if size >= 8: # Assuming minimumFontSize is around 8-10. + self.fs = size + self.setTextEditFontSize(self.fs) + + def changeFontSize(self, up): + size = self.getFontSize() + + if up: + size = min(30, size + 1) + else: + size = max(8, size - 1) + + self.setFontSize(size) + + def setTextEditFontSize(self, size): + f = self.font() + f.setPointSize(size) + self.setFont(f) + + def wordWrap(self, state): + if isinstance(self, QPlainTextEdit): + if state: + self.setLineWrapMode(QPlainTextEdit.WidgetWidth) + else: + self.setLineWrapMode(QPlainTextEdit.NoWrap) + else: + if state: + self.setLineWrapMode(QTextEdit.WidgetWidth) + else: + self.setLineWrapMode(QTextEdit.NoWrap) + + def set_font(self, font): + self.setFont(font) + + def render_whitespace(self, state): + text_option = self.document().defaultTextOption() + if state: + text_option.setFlags(text_option.flags() | QTextOption.ShowTabsAndSpaces) + else: + text_option.setFlags(text_option.flags() & ~QTextOption.ShowTabsAndSpaces) + self.document().setDefaultTextOption(text_option) + + def set_start_font(self, font_d=None): + if not font_d: + return + family = font_d.get('family', 'monospace') + pointSize = font_d.get('pointSize', 14) + italic = font_d.get('italic', False) + weight = font_d.get('weight', 1) + + # Cross-compatibility patch for PySide2 (NF) vs PySide6 (Nerd Font) + try: + families = QFontDatabase.families() + except TypeError: + db = QFontDatabase() + families = db.families() + + if family not in families: + variations = [ + family.replace(" NFM", " Nerd Font Mono"), + family.replace(" Nerd Font Mono", " NFM"), + family.replace(" NFP", " Nerd Font Propo"), + family.replace(" Nerd Font Propo", " NFP"), + family.replace(" NF", " Nerd Font"), + family.replace(" Nerd Font", " NF"), + family.replace(" Nerd Font Mono", " Nerd Font"), + family.replace(" Nerd Font Propo", " Nerd Font"), + family.replace(" Nerd Font", " Nerd Font Mono"), + family.replace(" Nerd Font", " Nerd Font Propo") + ] + for alt in variations: + if alt in families: + family = alt + break + + editor_font = QFont(family, pointSize, weight, italic) + editor_font.setStyleHint(QFont.Monospace) + self.setFont(editor_font) + if hasattr(self, 'fs'): + self.fs = pointSize + + if hasattr(self, 'completer') and self.completer: + self.completer.setFont(editor_font) + if hasattr(self.completer, 'doc_tooltip') and self.completer.doc_tooltip: + self.completer.doc_tooltip.setFont(editor_font) + + def contextMenuEvent(self, event): + menu = self.createStandardContextMenu() + main_win = self.window() + if hasattr(main_win, 'menubar'): + menu.setFont(main_win.menubar.font()) + menu.setStyleSheet(main_win.menubar.styleSheet()) + + # Selection to tab action + cursor = self.textCursor() + if cursor.hasSelection(): + selected_text = cursor.selectedText().replace('\u2029', '\n') + sel_to_tab_action = QAction('Selection to tab', self) + + def create_selection_tab(): + import datetime + time_str = datetime.datetime.now().strftime("%H:%M:%S") + tab_name = f"selection {time_str}" + if hasattr(main_win, 'tab') and hasattr(main_win.tab, 'addNewTab'): + main_win.tab.addNewTab(name=tab_name, text=selected_text) + + sel_to_tab_action.triggered.connect(create_selection_tab) + if menu.actions(): + first_action = menu.actions()[0] + menu.insertAction(first_action, sel_to_tab_action) + menu.insertSeparator(first_action) + else: + menu.addAction(sel_to_tab_action) + + # Check if we are editing an HTML file to add "Open in browser" + file_path = None + curr = self + while curr: + file_path = getattr(curr, 'file_path', None) + if file_path: + break + if hasattr(curr, 'parent') and callable(curr.parent): + curr = curr.parent() + elif hasattr(curr, 'parentWidget') and callable(curr.parentWidget): + curr = curr.parentWidget() + else: + break + + if file_path and os.path.exists(file_path): + _, ext = os.path.splitext(file_path) + if ext.lower() in ['.html', '.htm']: + open_action = QAction('Open in browser \tCtrl+Alt+B', self) + open_action.setIcon(QIcon(icons['open_in_browser'])) + open_action.triggered.connect(lambda checked=False, path=file_path: webbrowser.open(path)) + if menu.actions(): + first_action = menu.actions()[0] + menu.insertAction(first_action, open_action) + menu.insertSeparator(first_action) + else: + menu.addAction(open_action) + elif ext.lower() == '.md': + preview_action = QAction('Markdown Preview \tCtrl+Alt+B', self) + if 'docs' in icons: + preview_action.setIcon(QIcon(icons['docs'])) + preview_action.triggered.connect(lambda checked=False: self.show_markdown_preview()) + if menu.actions(): + first_action = menu.actions()[0] + menu.insertAction(first_action, preview_action) + menu.insertSeparator(first_action) + else: + menu.addAction(preview_action) + if hasattr(main_win, 'menubar') and not main_win.menubar.isVisible(): + menu.addSeparator() + show_menus_action = QAction('Show menus\tCtrl+M', self) + if 'menu' in icons: + show_menus_action.setIcon(QIcon(icons['menu'])) + if hasattr(main_win, 'toggleMenus_act'): + show_menus_action.triggered.connect(main_win.toggleMenus_act.trigger) + menu.addAction(show_menus_action) + + menu.exec_(event.globalPos()) + del menu diff --git a/multi_script_editor/core/execution_manager.py b/multi_script_editor/core/execution_manager.py new file mode 100644 index 00000000..65b9f1c0 --- /dev/null +++ b/multi_script_editor/core/execution_manager.py @@ -0,0 +1,60 @@ +import sys +import traceback +from vendor.Qt.QtCore import QCoreApplication + +class StdoutProxy: + def __init__(self, write_func): + self.write_func = write_func + self.skip = False + + def write(self, text): + if not self.skip: + stripped_text = text.rstrip('\n') + self.write_func(stripped_text) + # Process events so UI doesn't freeze entirely if output is heavy + QCoreApplication.processEvents() + self.skip = not self.skip + + def flush(self): + pass + + +class ExecutionManager: + def __init__(self): + # We can store execution context or state here + pass + + def run_command(self, command, namespace, output_callback, close_callback): + """ + Executes a python command within the given namespace. + Output is redirected to output_callback. + If a SystemExit is raised, close_callback is called. + """ + if not command: + return + + tmp_stdout = sys.stdout + sys.stdout = StdoutProxy(output_callback) + + try: + try: + # Try evaluating first to see if it's an expression that returns a value + result = eval(command, namespace, namespace) + if result is not None: + output_callback(repr(result)) + except SyntaxError: + # If it's a statement, exec it + exec(command, namespace) + except SystemExit: + close_callback() + except: + traceback_lines = traceback.format_exc().split('\n') + # Remove eval/exec internal traceback lines for cleaner output + try: + for i in (3, 2, 1, -1): + traceback_lines.pop(i) + except IndexError: + pass + output_callback('\n'.join(traceback_lines)) + finally: + sys.stdout = tmp_stdout diff --git a/multi_script_editor/core/file_utils.py b/multi_script_editor/core/file_utils.py new file mode 100644 index 00000000..7136dbf0 --- /dev/null +++ b/multi_script_editor/core/file_utils.py @@ -0,0 +1,36 @@ +import os + +def read_file_text(path): + """ + Safely read text from a file, trying multiple encodings and falling back gracefully. + """ + if not os.path.exists(path): + return "" + + # 1. Try utf-8-sig to automatically handle UTF-8 with BOM and normal UTF-8 + try: + with open(path, 'r', encoding='utf-8-sig') as f: + return f.read() + except UnicodeDecodeError: + pass + + # 2. Try utf-16 (covers UTF-16 LE and UTF-16 BE with BOM) + try: + with open(path, 'r', encoding='utf-16') as f: + return f.read() + except UnicodeDecodeError: + pass + + # 3. Fallback to utf-8 with errors='replace' to avoid UnicodeDecodeError entirely + try: + with open(path, 'r', encoding='utf-8', errors='replace') as f: + return f.read() + except Exception: + pass + + # 4. Final fallback with default encoding and errors='replace' + try: + with open(path, 'r', errors='replace') as f: + return f.read() + except Exception: + return "" diff --git a/multi_script_editor/core/linter_provider.py b/multi_script_editor/core/linter_provider.py new file mode 100644 index 00000000..aa4b02a4 --- /dev/null +++ b/multi_script_editor/core/linter_provider.py @@ -0,0 +1,15 @@ +class LinterProvider: + def check_syntax(self, code): + """ + Validates the python syntax using compile(). + Returns a dictionary of errors: {line_number: error_message} + """ + syntax_errors = {} + if code.strip(): + try: + compile(code.encode('utf-8'), '', 'exec') + except SyntaxError as e: + syntax_errors[e.lineno] = e.msg + except Exception: + pass + return syntax_errors diff --git a/multi_script_editor/core/multi_cursor.py b/multi_script_editor/core/multi_cursor.py new file mode 100644 index 00000000..22bf4e9a --- /dev/null +++ b/multi_script_editor/core/multi_cursor.py @@ -0,0 +1,401 @@ +from vendor.Qt.QtCore import Qt +from vendor.Qt.QtGui import QTextCursor, QColor, QTextDocument +from vendor.Qt.QtWidgets import QTextEdit + +class MultiCursorManager: + def __init__(self, editor): + self.editor = editor + self.multi_cursors = [] + self.is_auto_populated = False + + def clear(self): + self.multi_cursors = [] + self.is_auto_populated = False + if hasattr(self.editor, 'messageSignal'): + self.editor.messageSignal.emit("") + + def has_cursors(self): + return len(self.multi_cursors) > 0 + + def add_cursor_at(self, cursor): + """Adds a copy of the given cursor to the multi-cursors list.""" + if not self.multi_cursors: + # If this is the first additional cursor, we must also store the main cursor's current state + # but wait, the main cursor is added initially elsewhere or implicitly. + # Usually, when Ctrl+Clicking, we add the current cursor to the list first if it's empty + current = self.editor.textCursor() + self.multi_cursors.append(QTextCursor(current)) + + c = QTextCursor(cursor) + self.multi_cursors.append(c) + self.deduplicate_and_sort_cursors() + + def deduplicate_and_sort_cursors(self): + if not self.multi_cursors: + return + seen = set() + unique_cursors = [] + sorted_c = sorted(self.multi_cursors, key=lambda c: (c.position(), c.anchor())) + for c in sorted_c: + key = (c.position(), c.anchor()) + if key not in seen: + seen.add(key) + unique_cursors.append(c) + self.multi_cursors = unique_cursors + + def get_extra_selections(self): + selections = [] + for mc in self.multi_cursors: + sel = QTextEdit.ExtraSelection() + if mc.hasSelection(): + sel.cursor = mc + sel.format.setBackground(QColor(40, 100, 200, 120)) + else: + c_copy = QTextCursor(mc) + if not c_copy.atEnd(): + c_copy.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor) + sel.cursor = c_copy + sel.format.setBackground(QColor(128, 128, 255, 180)) + else: + sel.cursor = c_copy + sel.format.setBackground(QColor(128, 128, 255, 180)) + selections.append(sel) + return selections + + def handle_key_press(self, event): + if not self.multi_cursors: + return False + + key = event.key() + modifiers = event.modifiers() + + if key in (Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, Qt.Key_Meta): + return False + + if key == Qt.Key_Escape: + self.clear() + self.editor.highlight_current_line() + return True + + if key == Qt.Key_A and (modifiers & Qt.ControlModifier): + self.clear() + self.editor.highlight_current_line() + return False + + if getattr(self, 'is_auto_populated', False): + self.clear() + self.editor.highlight_current_line() + return False + + nav_ops = { + Qt.Key_Left: QTextCursor.Left, + Qt.Key_Right: QTextCursor.Right, + Qt.Key_Up: QTextCursor.Up, + Qt.Key_Down: QTextCursor.Down, + Qt.Key_Home: QTextCursor.StartOfLine, + Qt.Key_End: QTextCursor.EndOfLine, + } + + if key in nav_ops: + op = nav_ops[key] + mode = QTextCursor.KeepAnchor if (modifiers & Qt.ShiftModifier) else QTextCursor.MoveAnchor + + if key == Qt.Key_Left and (modifiers & Qt.ControlModifier): + op = QTextCursor.WordLeft + elif key == Qt.Key_Right and (modifiers & Qt.ControlModifier): + op = QTextCursor.WordRight + + for cursor in self.multi_cursors: + cursor.movePosition(op, mode) + + self.deduplicate_and_sort_cursors() + + if self.multi_cursors: + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + self.editor.setTextCursor(self.multi_cursors[0]) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + self.editor.highlight_current_line() + return True + + is_edit = False + text = event.text() + + sorted_cursors = sorted(self.multi_cursors, key=lambda c: c.position(), reverse=True) + + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + + main_cursor = self.editor.textCursor() + main_cursor.beginEditBlock() + try: + if key == Qt.Key_Backspace: + is_edit = True + for cursor in sorted_cursors: + cursor.deletePreviousChar() + elif key == Qt.Key_Delete: + is_edit = True + for cursor in sorted_cursors: + cursor.deleteChar() + elif key in [Qt.Key_Return, Qt.Key_Enter]: + is_edit = True + for cursor in sorted_cursors: + cursor.insertText("\n") + elif key == Qt.Key_Tab: + is_edit = True + for cursor in sorted_cursors: + cursor.insertText(" ") + elif text and text.isprintable(): + is_edit = True + for cursor in sorted_cursors: + cursor.insertText(text) + finally: + main_cursor.endEditBlock() + + if is_edit: + self.deduplicate_and_sort_cursors() + if self.multi_cursors: + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + self.editor.setTextCursor(self.multi_cursors[0]) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + self.editor.highlight_current_line() + + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + + if is_edit: + return True + + return False + + def select_next_occurrence(self): + cursor = self.editor.textCursor() + if not cursor.hasSelection(): + cursor.select(QTextCursor.WordUnderCursor) + self.editor.setTextCursor(cursor) + + if not cursor.hasSelection(): + return + + target_text = cursor.selectedText() + if not target_text: + return + + if getattr(self, 'is_auto_populated', False): + self.clear() + self.is_auto_populated = False + + if not self.multi_cursors: + self.multi_cursors = [cursor] + + last_cursor = self.multi_cursors[-1] + start_pos = last_cursor.position() + try: + from core.settings_model import SettingsModel + data = SettingsModel().read_settings() or {} + case_sensitive = data.get('occurrences_case_sensitive', False) + except ImportError: + case_sensitive = False + + options = QTextDocument.FindCaseSensitively if case_sensitive else QTextDocument.FindFlags() + + found_cursor = self.editor.document().find(target_text, start_pos, options) + + if found_cursor.isNull() or found_cursor.position() <= start_pos: + found_cursor = self.editor.document().find(target_text, 0, options) + + if not found_cursor.isNull(): + already_selected = False + for mc in self.multi_cursors: + if mc.selectionStart() == found_cursor.selectionStart() and mc.selectionEnd() == found_cursor.selectionEnd(): + already_selected = True + break + + if not already_selected: + self.multi_cursors.append(found_cursor) + self.editor.setTextCursor(found_cursor) + self.editor.centerCursor() + + self.editor.highlight_current_line() + if hasattr(self.editor, 'messageSignal'): + count = len(self.multi_cursors) if self.multi_cursors else 1 + self.editor.messageSignal.emit(f"{count} occurrences selected") + + def select_all_occurrences(self): + cursor = self.editor.textCursor() + if not cursor.hasSelection(): + cursor.select(QTextCursor.WordUnderCursor) + self.editor.setTextCursor(cursor) + + if not cursor.hasSelection(): + return + + target_text = cursor.selectedText() + if not target_text: + return + + self.clear() + self.is_auto_populated = getattr(self.editor, '_is_auto_selecting', False) + try: + from core.settings_model import SettingsModel + data = SettingsModel().read_settings() or {} + case_sensitive = data.get('occurrences_case_sensitive', False) + except ImportError: + case_sensitive = False + + options = QTextDocument.FindCaseSensitively if case_sensitive else QTextDocument.FindFlags() + + start_pos = 0 + while True: + found_cursor = self.editor.document().find(target_text, start_pos, options) + if found_cursor.isNull() or found_cursor.position() <= start_pos: + break + self.multi_cursors.append(found_cursor) + start_pos = found_cursor.position() + + self.editor.highlight_current_line() + if hasattr(self.editor, 'messageSignal'): + count = len(self.multi_cursors) if self.multi_cursors else 1 + if getattr(self, 'is_auto_populated', False): + self.editor.messageSignal.emit(f"{count} occurrences") + else: + self.editor.messageSignal.emit(f"{count} occurrences selected") + + def next_selection(self): + if not self.multi_cursors: + return + + main_cursor = self.editor.textCursor() + current_idx = -1 + for i, mc in enumerate(self.multi_cursors): + if mc.position() == main_cursor.position() and mc.anchor() == main_cursor.anchor(): + current_idx = i + break + + if current_idx == -1: + # If main cursor is not in the list, just go to the first one + next_idx = 0 + else: + next_idx = (current_idx + 1) % len(self.multi_cursors) + + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + self.editor.setTextCursor(self.multi_cursors[next_idx]) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + self.editor.highlight_current_line() + self._center_cursor_in_editor() + + def previous_selection(self): + if not self.multi_cursors: + return + + main_cursor = self.editor.textCursor() + current_idx = -1 + for i, mc in enumerate(self.multi_cursors): + if mc.position() == main_cursor.position() and mc.anchor() == main_cursor.anchor(): + current_idx = i + break + + if current_idx == -1: + prev_idx = len(self.multi_cursors) - 1 + else: + prev_idx = (current_idx - 1) % len(self.multi_cursors) + + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + self.editor.setTextCursor(self.multi_cursors[prev_idx]) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + self.editor.highlight_current_line() + self._center_cursor_in_editor() + + def _center_cursor_in_editor(self): + self.editor.centerCursor() + + def add_cursors_to_line_ends(self): + cursor = self.editor.textCursor() + if not cursor.hasSelection(): + # If no selection, place cursor at the end of the current line + c = QTextCursor(cursor) + c.movePosition(QTextCursor.EndOfBlock) + self.clear() + self.multi_cursors = [c] + self.editor.setTextCursor(c) + self.editor.highlight_current_line() + return + + start_pos = cursor.selectionStart() + end_pos = cursor.selectionEnd() + + doc = self.editor.document() + start_block = doc.findBlock(start_pos) + end_block = doc.findBlock(end_pos) + + # If selection ends at the start of a block, exclude it if it is not the only block + if end_pos == end_block.position() and end_block != start_block: + end_block = end_block.previous() + + self.clear() + + # Iterate through all blocks in the selection and add a cursor at the end of each block + block = start_block + while block.isValid(): + c = QTextCursor(block) + c.movePosition(QTextCursor.EndOfBlock) + self.multi_cursors.append(c) + if block == end_block: + break + block = block.next() + + self.deduplicate_and_sort_cursors() + + if self.multi_cursors: + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + self.editor.setTextCursor(self.multi_cursors[-1]) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + + self.editor.highlight_current_line() + + def add_cursor_above(self): + if not self.multi_cursors: + cursor = self.editor.textCursor() + self.multi_cursors.append(QTextCursor(cursor)) + + top_cursor = min(self.multi_cursors, key=lambda c: c.blockNumber()) + new_cursor = QTextCursor(top_cursor) + + moved = new_cursor.movePosition(QTextCursor.Up) + + if moved and new_cursor.blockNumber() < top_cursor.blockNumber(): + self.add_cursor_at(new_cursor) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + self.editor.setTextCursor(new_cursor) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + self.editor.highlight_current_line() + + def add_cursor_below(self): + if not self.multi_cursors: + cursor = self.editor.textCursor() + self.multi_cursors.append(QTextCursor(cursor)) + + bottom_cursor = max(self.multi_cursors, key=lambda c: c.blockNumber()) + new_cursor = QTextCursor(bottom_cursor) + + moved = new_cursor.movePosition(QTextCursor.Down) + + if moved and new_cursor.blockNumber() > bottom_cursor.blockNumber(): + self.add_cursor_at(new_cursor) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = True + self.editor.setTextCursor(new_cursor) + if hasattr(self.editor, '_is_auto_selecting'): + self.editor._is_auto_selecting = False + self.editor.highlight_current_line() diff --git a/multi_script_editor/core/outline_parser.py b/multi_script_editor/core/outline_parser.py new file mode 100644 index 00000000..1d86835a --- /dev/null +++ b/multi_script_editor/core/outline_parser.py @@ -0,0 +1,139 @@ +import ast +import re + +class OutlineParser: + @staticmethod + def parse(code, ext='.py'): + if ext == '.py': + try: + tree = ast.parse(code) + symbols = [] + + class OutlineVisitor(ast.NodeVisitor): + def visit_ClassDef(self, node): + symbols.append({ + 'name': "class {0}".format(node.name), + 'line': node.lineno, + 'indent': getattr(node, 'col_offset', 0) // 4, + 'type': 'class' + }) + self.generic_visit(node) + + def visit_FunctionDef(self, node): + symbols.append({ + 'name': "def {0}()".format(node.name), + 'line': node.lineno, + 'indent': getattr(node, 'col_offset', 0) // 4, + 'type': 'function' + }) + self.generic_visit(node) + + OutlineVisitor().visit(tree) + symbols.sort(key=lambda x: x['line']) + return symbols + except Exception: + return OutlineParser._parse_regex(code, ext) + else: + return OutlineParser._parse_regex(code, ext) + + @staticmethod + def _parse_regex(code, ext): + symbols = [] + lines = code.split('\n') + for i, line in enumerate(lines): + line_num = i + 1 + + if ext == '.py': + class_match = re.match(r'^(\s*)class\s+(\w+)', line) + if class_match: + indent = len(class_match.group(1)) // 4 + name = class_match.group(2) + symbols.append({'name': "class {0}".format(name), 'line': line_num, 'indent': indent, 'type': 'class'}) + continue + def_match = re.match(r'^(\s*)def\s+(\w+)', line) + if def_match: + indent = len(def_match.group(1)) // 4 + name = def_match.group(2) + symbols.append({'name': "def {0}()".format(name), 'line': line_num, 'indent': indent, 'type': 'function'}) + continue + + elif ext in ['.js', '.jsx', '.ts', '.tsx']: + class_match = re.search(r'^(\s*)class\s+(\w+)', line) + if class_match: + symbols.append({'name': "class {0}".format(class_match.group(2)), 'line': line_num, 'indent': len(class_match.group(1)) // 4, 'type': 'class'}) + continue + func_match = re.search(r'^(\s*)(?:async\s+)?function\s+(\w+)', line) + if func_match: + symbols.append({'name': "function {0}()".format(func_match.group(2)), 'line': line_num, 'indent': len(func_match.group(1)) // 4, 'type': 'function'}) + continue + arrow_match = re.search(r'^(\s*)(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z0-9_]+)\s*=>', line) + if arrow_match: + symbols.append({'name': "{0}()".format(arrow_match.group(2)), 'line': line_num, 'indent': len(arrow_match.group(1)) // 4, 'type': 'function'}) + continue + + elif ext in ['.cpp', '.c', '.h', '.hpp', '.vex']: + class_match = re.search(r'^(\s*)(?:class|struct)\s+(\w+)', line) + if class_match: + symbols.append({'name': "{0} {1}".format("class" if "class" in line else "struct", class_match.group(2)), 'line': line_num, 'indent': len(class_match.group(1)) // 4, 'type': 'class'}) + continue + func_match = re.search(r'^(\s*)[\w\:]+\s+(\w+)\s*\([^)]*\)\s*(?:const\s*)?\{', line) + if func_match: + name = func_match.group(2) + if name not in ['if', 'while', 'for', 'switch', 'catch']: + symbols.append({'name': "{0}()".format(name), 'line': line_num, 'indent': len(func_match.group(1)) // 4, 'type': 'function'}) + continue + + elif ext == '.mel': + proc_match = re.search(r'^(\s*)(?:global\s+)?proc\s+(?:[\w\[\]]+\s+)?(\w+)\s*\(', line) + if proc_match: + symbols.append({'name': "proc {0}()".format(proc_match.group(2)), 'line': line_num, 'indent': len(proc_match.group(1)) // 4, 'type': 'function'}) + continue + + elif ext in ['.html', '.htm']: + tag_match = re.search(r'^\s*<([a-zA-Z0-9\-]+)(?:[^>]*)id=["\']([^"\']+)["\']', line) + if tag_match: + symbols.append({'name': "<{0}> #{1}".format(tag_match.group(1), tag_match.group(2)), 'line': line_num, 'indent': 0, 'type': 'class'}) + continue + + elif ext in ['.css', '.scss', '.less']: + rule_match = re.search(r'^\s*([\.#a-zA-Z0-9\-_:,\s]+)\s*\{', line) + if rule_match: + symbols.append({'name': rule_match.group(1).strip(), 'line': line_num, 'indent': 0, 'type': 'function'}) + continue + + elif ext in ['.md', '.markdown']: + md_match = re.search(r'^(#{1,6})\s+(.*)', line) + if md_match: + level = len(md_match.group(1)) - 1 + symbols.append({'name': md_match.group(2).strip(), 'line': line_num, 'indent': level, 'type': 'class'}) + continue + + elif ext in ['.yaml', '.yml']: + yaml_match = re.match(r'^(\s*)([a-zA-Z0-9_\-]+)\s*:', line) + if yaml_match: + indent_str = yaml_match.group(1) + indent = len(indent_str) // 2 + name = yaml_match.group(2) + symbols.append({'name': name, 'line': line_num, 'indent': indent, 'type': 'yaml'}) + continue + + elif ext in ['.usd', '.usda']: + usd_match = re.match(r'^(\s*)(def|class|over)\s+([^{]+)', line) + if usd_match: + indent_str = usd_match.group(1) + indent = len(indent_str) // 4 + keyword = usd_match.group(2) + decl = usd_match.group(3).strip() + symbols.append({'name': "{0} {1}".format(keyword, decl), 'line': line_num, 'indent': indent, 'type': 'usd'}) + continue + + elif ext == '.json': + json_match = re.match(r'^(\s*)"([^"\\]*(?:\\.[^"\\]*)*)"\s*:', line) + if json_match: + indent_str = json_match.group(1) + indent = len(indent_str) // 2 + name = json_match.group(2) + symbols.append({'name': name, 'line': line_num, 'indent': indent, 'type': 'json'}) + continue + + return symbols diff --git a/multi_script_editor/core/search_service.py b/multi_script_editor/core/search_service.py new file mode 100644 index 00000000..c32b1a21 --- /dev/null +++ b/multi_script_editor/core/search_service.py @@ -0,0 +1,52 @@ +import re +from vendor.Qt.QtGui import QTextCursor, QTextDocument + +class SearchService: + def __init__(self, editor): + self.editor = editor + + def select_word(self, pattern, number, replace=None, case_sensitive=False): + text = self.editor.toPlainText() + flags = 0 if case_sensitive else re.IGNORECASE + + indexis = [(m.start(0), m.end(0)) for m in re.finditer(re.escape(pattern), text, flags=flags)] + if not indexis: + return number + + if number > len(indexis) - 1: + number = 0 + + cursor = self.editor.textCursor() + cursor.setPosition(indexis[number][0]) + cursor.setPosition(indexis[number][1], QTextCursor.KeepAnchor) + + if replace is not None: + cursor.removeSelectedText() + cursor.insertText(replace) + + self.editor.setTextCursor(cursor) + self.editor.centerCursor() + self.editor.setFocus() + return number + + def replace_all(self, find_text, rep_text, case_sensitive=False): + if not find_text: + return + + cursor = self.editor.textCursor() + cursor.beginEditBlock() + + # Start from beginning + cursor.movePosition(QTextCursor.Start) + self.editor.setTextCursor(cursor) + + options = QTextDocument.FindCaseSensitively if case_sensitive else QTextDocument.FindFlags() + + while self.editor.find(find_text, options): + self.editor.textCursor().insertText(rep_text) + + cursor.endEditBlock() + + # Trigger autocomplete update if present + if hasattr(self.editor, 'completer') and self.editor.completer: + self.editor.completer.updateCompleteList() diff --git a/multi_script_editor/core/session_model.py b/multi_script_editor/core/session_model.py new file mode 100644 index 00000000..4b1580d4 --- /dev/null +++ b/multi_script_editor/core/session_model.py @@ -0,0 +1,110 @@ +from __future__ import with_statement +import json +import os +import codecs +from core.settings_model import SettingsModel + +sessionFilename = 'pw_scriptEditor_session.json' +backupFilename = 'pw_scriptEditor_session_backup.json' + + +class SessionModel(object): + def __init__(self): + self.path = os.path.normpath(os.path.join(SettingsModel()._get_user_pref_folder(), sessionFilename)) + if not os.path.exists(self.path): + folder = os.path.dirname(self.path) + if folder and not os.path.exists(folder): + os.makedirs(folder) + with codecs.open(self.path, "w", "utf-16") as stream: + json.dump([], stream, indent=4) + + def readSession(self): + if os.path.exists(self.path): + with codecs.open(self.path, "r", "utf-16") as stream: + try: + return json.load(stream) + except: + return [] + return [] + + def writeSession(self, data): + with codecs.open(self.path, "w", "utf-16") as stream: + json.dump(data, stream, indent=4) + return self.path + + # BACKUP METHODS (Auto-save) + def getBackupPath(self): + return os.path.normpath(os.path.join(SettingsModel()._get_user_pref_folder(), backupFilename)) + + def writeBackup(self, data): + path = self.getBackupPath() + with codecs.open(path, "w", "utf-16") as stream: + json.dump(data, stream, indent=4) + return path + + def readBackup(self): + path = self.getBackupPath() + if os.path.exists(path): + with codecs.open(path, "r", "utf-16") as stream: + try: + return json.load(stream) + except: + return [] + return [] + + def backupExists(self): + return os.path.exists(self.getBackupPath()) + + def removeBackup(self): + path = self.getBackupPath() + if os.path.exists(path): + try: + os.remove(path) + except: + pass + + # NAMED SESSIONS METHODS + def getSessionsFolder(self): + folder = os.path.join(SettingsModel()._get_user_pref_folder(), 'mse_sessions') + if not os.path.exists(folder): + try: + os.makedirs(folder) + except: + pass + return folder + + def listNamedSessions(self): + folder = self.getSessionsFolder() + sessions = [] + if os.path.exists(folder): + for f in os.listdir(folder): + if f.endswith('.json'): + sessions.append(os.path.splitext(f)[0]) + return sorted(sessions) + + def writeNamedSession(self, name, data): + folder = self.getSessionsFolder() + path = os.path.join(folder, "{0}.json".format(name)) + with codecs.open(path, "w", "utf-16") as stream: + json.dump(data, stream, indent=4) + return path + + def readNamedSession(self, name): + folder = self.getSessionsFolder() + path = os.path.join(folder, "{0}.json".format(name)) + if os.path.exists(path): + with codecs.open(path, "r", "utf-16") as stream: + try: + return json.load(stream) + except: + return [] + return [] + + def deleteNamedSession(self, name): + folder = self.getSessionsFolder() + path = os.path.join(folder, "{0}.json".format(name)) + if os.path.exists(path): + try: + os.remove(path) + except: + pass diff --git a/multi_script_editor/core/settings_model.py b/multi_script_editor/core/settings_model.py new file mode 100644 index 00000000..ecd76f7a --- /dev/null +++ b/multi_script_editor/core/settings_model.py @@ -0,0 +1,274 @@ +import json +import os +import codecs +from vendor.Qt.QtGui import QFont +class SettingsModel: + settings_filename = 'pw_scriptEditor_pref.json' + _cached_settings = None + + def __init__(self): + self.path = self._get_settings_file_path() + + def _get_user_pref_folder(self): + appData = None + import managers + if managers.context == 'hou': + try: + import hou + appData = hou.homeHoudiniDirectory() + except Exception: + appData = os.getenv('HOUDINI_USER_PREF_DIR') + elif managers.context == 'maya': + appData = os.getenv('MAYA_APP_DIR') + elif managers.context == 'nuke': + home = os.getenv('HOME') or os.path.expanduser('~') + appData = os.path.join(home, '.nuke') + else: + home = os.getenv('HOME') or os.path.expanduser('~') + appData = home + + if not appData: + appData = os.path.expanduser('~') + + mse_settings_dir = os.path.join(appData, 'mse_settings') + + if not getattr(SettingsModel, '_migrated', False): + SettingsModel._migrated = True + + if not os.path.exists(mse_settings_dir): + try: + os.makedirs(mse_settings_dir) + except Exception: + pass + + import shutil + files_to_move = [ + 'pw_scriptEditor_pref.json', + 'pw_scriptEditor_snippets.json', + 'pw_scriptEditor_themes.json', + 'pw_scriptEditor_session.json', + 'pw_scriptEditor_session_backup.json' + ] + folders_to_move = [ + 'mse_sessions' + ] + + for f in files_to_move: + old_path = os.path.join(appData, f) + new_path = os.path.join(mse_settings_dir, f) + if os.path.exists(old_path) and not os.path.exists(new_path): + try: + shutil.move(old_path, new_path) + except Exception: + pass + + for folder in folders_to_move: + old_path = os.path.join(appData, folder) + new_path = os.path.join(mse_settings_dir, folder) + if os.path.exists(old_path) and not os.path.exists(new_path): + try: + shutil.move(old_path, new_path) + except Exception: + pass + + return mse_settings_dir + + def _get_settings_file_path(self): + path = os.path.normpath(os.path.join(self._get_user_pref_folder(), self.settings_filename)).replace('\\','/') + if not os.path.exists(path): + folder = os.path.dirname(path) + if folder and not os.path.exists(folder): + os.makedirs(folder) + with codecs.open(path, "w", "utf-16") as stream: + json.dump(self.get_defaults(), stream, indent=4) + return path + + def _sanitize_font_weights(self, data): + try: + normal_weight = int(getattr(QFont, 'Normal', getattr(QFont.Weight, 'Normal', 50))) + except Exception: + normal_weight = 50 + + def convert_weight(w): + if w in (-1, 1, 1.0): return w + if normal_weight == 400: + if w <= 99: + if w <= 25: return 300 + if w <= 50: return 400 + if w <= 63: return 600 + if w <= 75: return 700 + return 900 + elif normal_weight == 50: + if w > 99: + if w <= 300: return 25 + if w <= 500: return 50 + if w <= 600: return 63 + if w <= 700: return 75 + return 87 + return w + + def traverse(obj): + if isinstance(obj, dict): + if 'weight' in obj and isinstance(obj['weight'], int): + obj['weight'] = convert_weight(obj['weight']) + for v in obj.values(): + traverse(v) + elif isinstance(obj, list): + for v in obj: + traverse(v) + + traverse(data) + + def read_settings_from_disk(self): + if os.path.exists(self.path) and os.path.isfile(self.path): + with codecs.open(self.path, "r", "utf-16") as stream: + try: + data = json.load(stream) + self._sanitize_font_weights(data) + return data + except Exception: + pass + return self.get_defaults() + + def read_settings(self): + if SettingsModel._cached_settings is not None: + return SettingsModel._cached_settings + SettingsModel._cached_settings = self.read_settings_from_disk() + return SettingsModel._cached_settings + + def write_settings(self, data): + SettingsModel._cached_settings = data + with codecs.open(self.path, "w", "utf-16") as stream: + json.dump(data, stream, indent=4) + + @staticmethod + def get_defaults(): + return dict(geometry=None, + outFontSize=8, + wrap=True, + out_wrap=True, + echo_execute=True, + clear_execute=False, + always_ontop=False, + show_whitespace=True, + highlight_all_occurrences=True, + font={"family": "monospace", "pointSize": 12, "weight": 1, "italic": False}, + recent_files=[], + randomize_custom_at_startup=False, + quick_tab_switching=True, + auto_close_delimiters=True + ) + + +class SnippetsModel(SettingsModel): + settings_filename = 'pw_scriptEditor_snippets.json' + _cached_settings = None + + def _get_settings_file_path(self): + return os.path.normpath(os.path.join(self._get_user_pref_folder(), self.settings_filename)).replace('\\','/') + + def read_settings(self): + if SnippetsModel._cached_settings is not None: + return SnippetsModel._cached_settings + if os.path.exists(self.path) and os.path.isfile(self.path): + with codecs.open(self.path, "r", "utf-16") as stream: + try: + SnippetsModel._cached_settings = json.load(stream) + return SnippetsModel._cached_settings + except Exception: + return {'snippets': {}} + return {'snippets': {}} + + def write_settings(self, data): + SnippetsModel._cached_settings = data + folder = os.path.dirname(self.path) + if folder and not os.path.exists(folder): + os.makedirs(folder) + with codecs.open(self.path, "w", "utf-16") as stream: + json.dump(data, stream, indent=4) + + @staticmethod + def get_defaults(): + return dict( + snippets={ + # "Python: Main block": 'if __name__ == "__main__":\n # Main code here\n pass', + # "Python: Class Template": "class MyClass(object):\n def __init__(self):\n super(MyClass, self).__init__()\n pass", + "Qt: Basic Window": 'from vendor.Qt.QtWidgets import QMainWindow\n\n\nclass MyWindow(QMainWindow):\n def __init__(self, parent=None):\n super(MyWindow, self).__init__(parent)\n self.setWindowTitle("My UI")\n self.resize(400, 300)\n\n\nif __name__ == "__main__":\n win = MyWindow(self_main)\n win.show()', + } + ) + +class ThemesModel(SettingsModel): + settings_filename = 'pw_scriptEditor_themes.json' + _cached_settings = None + + def _get_settings_file_path(self): + return os.path.normpath(os.path.join(self._get_user_pref_folder(), self.settings_filename)).replace('\\','/') + + def read_settings(self): + if ThemesModel._cached_settings is not None: + return ThemesModel._cached_settings + if os.path.exists(self.path) and os.path.isfile(self.path): + with codecs.open(self.path, "r", "utf-16") as stream: + try: + data = json.load(stream) + self._sanitize_font_weights(data) + ThemesModel._cached_settings = data + return ThemesModel._cached_settings + except Exception: + pass + + # Migration from pw_scriptEditor_pref.json + pref_model = SettingsModel() + pref_settings = pref_model.read_settings() + if 'colors' in pref_settings: + data = {'colors': pref_settings['colors']} + self.write_settings(data) + ThemesModel._cached_settings = data + return data + + return self.get_defaults() + + def write_settings(self, data): + ThemesModel._cached_settings = data + folder = os.path.dirname(self.path) + if folder and not os.path.exists(folder): + os.makedirs(folder) + with codecs.open(self.path, "w", "utf-16") as stream: + json.dump(data, stream, indent=4) + + @staticmethod + def get_defaults(): + return dict(colors={}) + + +class ClipboardModel(SettingsModel): + settings_filename = 'pw_scriptEditor_clipboard.json' + _cached_settings = None + + def _get_settings_file_path(self): + return os.path.normpath(os.path.join(self._get_user_pref_folder(), self.settings_filename)).replace('\\','/') + + def read_settings(self): + if ClipboardModel._cached_settings is not None: + return ClipboardModel._cached_settings + if os.path.exists(self.path) and os.path.isfile(self.path): + with codecs.open(self.path, "r", "utf-16") as stream: + try: + ClipboardModel._cached_settings = json.load(stream) + return ClipboardModel._cached_settings + except Exception: + return {'history': []} + return {'history': []} + + def write_settings(self, data): + ClipboardModel._cached_settings = data + folder = os.path.dirname(self.path) + if folder and not os.path.exists(folder): + os.makedirs(folder) + with codecs.open(self.path, "w", "utf-16") as stream: + json.dump(data, stream, indent=4) + + @staticmethod + def get_defaults(): + return dict(history=[]) + diff --git a/multi_script_editor/docs/mse.html b/multi_script_editor/docs/mse.html new file mode 100644 index 00000000..7a3eccc0 --- /dev/null +++ b/multi_script_editor/docs/mse.html @@ -0,0 +1,964 @@ + + + + + + Multi Script Editor v6.4.0 Documentation — Read the Docs Style + + + + + +
+ Multi Script Editor v6.4.0 + +
+ + + + + +
+
+ + + + + +
+

Multi Script Editor Documentation

+

Multi Script Editor is an open-source, cross-platform, and cross-application code editor. It can be run as a standalone application or embedded within host software applications (such as Houdini, Maya, and Nuke), offering the ability to edit and execute code efficiently while automatically adapting to the host application's environment. While optimized for Python, it supports syntax highlighting, file parsing, and outline navigation for a wide range of CGI and web file formats.

+ +
+
Note
+

When running embedded inside DCC host applications, Multi Script Editor automatically links into the host's Python engine and environment variables, allowing live inspection and script execution directly inside your active scene session.

+
+
+ + +
+

Key Features

+
    +
  • Automatic Workspace Preservation: Automatically saves and restores all active tabs and code buffers across sessions.
  • +
  • Interactive Code Execution: Live interactive execution of selected blocks, lines, or entire scripts (exclusively for Python files).
  • +
  • Custom Color Themes: Fully customizable themes for syntax highlighting and the entire UI layout.
  • +
  • Advanced Code Completion: Powered by the jedi engine with real-time docstring popup visualizers.
  • +
  • Multi-Format Syntax Support: Highlighting and structure parsing for Python, JavaScript, HTML, YAML, Markdown, CSS, Text, Log, USD, and JSON.
  • +
  • Smart Outline Navigation: Tree outline parser for Python classes/functions, JSON/YAML keys, and USD def/class/over primitives.
  • +
  • Host Integration: Context-aware autocompletion for host node graphs, scene paths, and PySide/PyQt frameworks.
  • +
  • Multi-Cursor & Formatting: Native multi-cursor editing, bracket auto-closing, and automatic syntax checking.
  • +
  • Snippets Library: Predefined and custom code snippet insertion with console execution.
  • +
+
+ + + + + +
+

Tab & Context Menu

+
+
+
Middle-click tab
+
Closes the tab directly under the mouse cursor.
+
+
+
Double-click / Rename TabALT + R
+
Opens inline label editor directly on tab header.
+
+
+
Selection to Tab
+
Creates a new editor tab prepopulated with currently selected text.
+
+
+
Copy File PathALT + SHIFT + C
+
Copies absolute file path of active document tab to clipboard.
+
+
+
+ + +
+

Code Theme Editor

+

Access the Theme Editor via Theme » Edit... (CTRL + SHIFT + T). It provides comprehensive real-time customization of UI colors, syntax tokens, font families, and element text sizes.

+ +
+
Tip
+

The Theme Editor includes a real-time live preview window showing a mock menu bar, code tabs, and status bar so you can see changes immediately before saving.

+
+ +
    +
  • Interactive Color Palette: Double-click color items to open color picker, or right-click to copy/paste color codes. Supports line_num_text, status_bar_text, and bookmark gutter ribbon colors.
  • +
  • Typography & Font Inheritance: Define a custom font for your theme. Configurable checkboxes allow forcing theme font on completion popups, menus, outline, symbols, status bar, and tab labels.
  • +
  • Granular Interface Text Sizing: Independent text sliders for editor text, line numbers, menus, tab labels, symbols popup, outline sidebar, output panel, and status bar.
  • +
  • Layout Customization: Adjust tab corner radius and export/import theme JSON files.
  • +
+
+ + +
+
© Multi Script Editor v6.4.0. Open-source cross-platform editor.
+
+ +
+
+ + + + + diff --git a/multi_script_editor/helpText.txt b/multi_script_editor/helpText.txt index 6854865c..84fad620 100644 --- a/multi_script_editor/helpText.txt +++ b/multi_script_editor/helpText.txt @@ -7,6 +7,8 @@ Multi Script Editor v%s

By PaulWinex paulwinex.com

+

+ By Carlos Rico Adega (Python 3 - PySide 6)

Editor Variables: