diff --git a/.gitignore b/.gitignore index b35526b..bf8abe9 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 0000000..18d755a --- /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 b8963ed..2e2c867 100644 --- a/multi_script_editor/__init__.py +++ b/multi_script_editor/__init__.py @@ -1,4 +1,5 @@ -import os, sys +import os +import sys root = os.path.dirname(__file__) if not root in sys.path: @@ -6,32 +7,57 @@ # 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(): + """ + Launch Multi Script Editor in 3DSMax + """ sys.argv = [] from .managers import _3dsmax - reload (_3dsmax) - _3dsmax.show() \ No newline at end of file + + _3dsmax.show() + + +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)) + elif managers.context == 'max': + return show3DSMax() + + 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 0000000..c4b209c --- /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 0000000..908fa5e --- /dev/null +++ b/multi_script_editor/core/autocomplete_provider.py @@ -0,0 +1,102 @@ +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): + """ + Returns a list of CompletionItem objects. + """ + comp_items = [] + context_completer = False + + # 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: + comp_items.append(CompletionItem(c.name, getattr(c, 'complete', ''), getattr(c, 'type', 'statement'), c.docstring() if hasattr(c, 'docstring') else '')) + if extra: + for c in extra: + comp_items.append(CompletionItem(c.name, getattr(c, '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() + + comp_items.append(CompletionItem( + name=c.name, + complete=c.complete, + comp_type=c.type, + docstring_val=doc, + prefix_length=prefix_len + )) + except Exception: + pass + + 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 0000000..a495591 --- /dev/null +++ b/multi_script_editor/core/base_text_widget.py @@ -0,0 +1,91 @@ +from vendor.Qt.QtGui import QFont, QTextOption, QFontDatabase +from vendor.Qt.QtWidgets import QTextEdit + +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 changeFontSize(self, up): + f = self.font() + size = f.pointSize() + if up: + size = min(30, size + 1) + else: + size = max(8, size - 1) + f.setPointSize(size) + self.setFont(f) + + def setTextEditFontSize(self, size): + f = self.font() + f.setPointSize(size) + self.setFont(f) + + def wordWrap(self, state): + 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, '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()) + 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 0000000..65b9f1c --- /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/linter_provider.py b/multi_script_editor/core/linter_provider.py new file mode 100644 index 0000000..aa4b02a --- /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 0000000..f101dbd --- /dev/null +++ b/multi_script_editor/core/multi_cursor.py @@ -0,0 +1,243 @@ +from vendor.Qt.QtCore import Qt +from vendor.Qt.QtGui import QTextCursor, QColor +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 == 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() + found_cursor = self.editor.document().find(target_text, start_pos) + + if found_cursor.isNull() or found_cursor.position() <= start_pos: + found_cursor = self.editor.document().find(target_text, 0) + + 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.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) + start_pos = 0 + while True: + found_cursor = self.editor.document().find(target_text, start_pos) + 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") diff --git a/multi_script_editor/core/outline_parser.py b/multi_script_editor/core/outline_parser.py new file mode 100644 index 0000000..0beeb56 --- /dev/null +++ b/multi_script_editor/core/outline_parser.py @@ -0,0 +1,129 @@ +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': 0, + 'type': 'class' + }) + self.generic_visit(node) + + def visit_FunctionDef(self, node): + symbols.append({ + 'name': "def {0}()".format(node.name), + 'line': node.lineno, + 'indent': 1, + 'type': 'function' + }) + + 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(1)), 'line': line_num, 'indent': 0, '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(1)), 'line': line_num, 'indent': 1, '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(1)), 'line': line_num, 'indent': 1, '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(1)), 'line': line_num, 'indent': 0, 'type': 'class'}) + continue + func_match = re.search(r'^\s*[\w\:]+\s+(\w+)\s*\([^)]*\)\s*(?:const\s*)?\{', line) + if func_match: + name = func_match.group(1) + if name not in ['if', 'while', 'for', 'switch', 'catch']: + symbols.append({'name': "{0}()".format(name), 'line': line_num, 'indent': 1, '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(1)), 'line': line_num, 'indent': 0, '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 + + 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 0000000..74eed79 --- /dev/null +++ b/multi_script_editor/core/search_service.py @@ -0,0 +1,51 @@ +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.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 0000000..4b1580d --- /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 0000000..8118b10 --- /dev/null +++ b/multi_script_editor/core/settings_model.py @@ -0,0 +1,153 @@ +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') + elif managers.context == 'max': + try: + import MaxPlus + appData = os.path.dirname(MaxPlus.PathManager.GetTempDir()) + except Exception: + pass + else: + home = os.getenv('HOME') or os.path.expanduser('~') + appData = home + + return appData + + 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(self): + if SettingsModel._cached_settings is not None: + return SettingsModel._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: + SettingsModel._cached_settings = json.load(stream) + self._sanitize_font_weights(SettingsModel._cached_settings) + return SettingsModel._cached_settings + except Exception: + return self.get_defaults() + return self.get_defaults() + + 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=[] + ) + + +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 PySide2.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()\n win.show()', + } + ) diff --git a/multi_script_editor/docs/mse.html b/multi_script_editor/docs/mse.html new file mode 100644 index 0000000..9cfffb5 --- /dev/null +++ b/multi_script_editor/docs/mse.html @@ -0,0 +1,262 @@ + + + + + + Multi Script Editor - Documentation + + + + +

Multi Script Editor Documentation

+ +

General Overview

+

Multi Script Editor is an open-source, cross-platform, and cross-application Python editor. It can be run as a standalone application or embedded within other software applications (such as Houdini, Nuke, Maya, 3DsMax), offering the ability to script in Python efficiently and centrally while automatically adapting to the host application's environment.

+ +

Key Features:

+ + +
+ +

Options by Menu

+

Below is a detailed breakdown of the different actions and tools grouped by the menu where they are located within the application, utilizing sentence case for ease of use:

+ +

File Menu

+ + +

Tab Context Menu (Right-Click)

+ + +

Edit Menu

+ + +

Run Menu

+ + +

Options Menu

+ + +

Help Menu

+ + +
+ +

Code Theme Editor

+

The Code Theme Editor is an advanced tool designed to let you customize the visual experience of Multi Script Editor. It is accessed by going to Options -> Theme -> Edit....

+ +

Key Capabilities & Features:

+ + + + diff --git a/multi_script_editor/helpText.txt b/multi_script_editor/helpText.txt index 6854865..84fad62 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: