diff --git a/FoxDot/lib/TimeVar.py b/FoxDot/lib/TimeVar.py index 947c4c7c..a92f1b57 100644 --- a/FoxDot/lib/TimeVar.py +++ b/FoxDot/lib/TimeVar.py @@ -853,6 +853,20 @@ class _var_dict(object): def __init__(self): self.__vars = {} + + # Hack to use var.| with jedi complete + super().__setattr__('__wrapped__', None) + super().__setattr__('__signature__', None) + super().__setattr__('__partialmethod__', None) + super().__setattr__('__name__', None) + super().__setattr__('__code__', None) + super().__setattr__('__defaults__', None) + super().__setattr__('__kwdefaults__', None) + super().__setattr__('__annotations__', None) + super().__setattr__('__text_signature__', None) + def __dir__(self): + return list(self.__vars) + @staticmethod def __call__(*args, **kwargs): return TimeVar(*args, **kwargs) diff --git a/FoxDot/lib/Workspace/Prompt.py b/FoxDot/lib/Workspace/Prompt.py index c738f03c..906b0e41 100644 --- a/FoxDot/lib/Workspace/Prompt.py +++ b/FoxDot/lib/Workspace/Prompt.py @@ -1,32 +1,119 @@ -from __future__ import absolute_import, division, print_function +import inspect +import signal +import re +from dataclasses import dataclass +from collections import deque +from contextlib import suppress from .tkimport import * - -from ..Settings import FONT from .AppFunctions import index as get_index from .Format import get_keywords -import re +from ..Effects import FxList + +try: + import jedi + enable_jedi_complete = True + + jedi.settings.auto_import_modules = [ + "gi", + "FoxDot", + "FoxDot.lib", + "FoxDot.lib.Code", + "FoxDot.lib.Custom", + "FoxDot.lib.Extensions", + "FoxDot.lib.Extensions.VRender", + "FoxDot.lib.Extensions.SonicPi", + "FoxDot.lib.Workspace", + "FoxDot.lib.Workspace.Simple", + "FoxDot.lib.EspGrid", + "FoxDot.lib.Effects", + "FoxDot.lib.Patterns", + "FoxDot.lib.SCLang", + "FoxDot.lib.Settings", + "FoxDot.lib.Utils", + ] +except (ModuleNotFoundError, ImportError): + jedi = None + enable_jedi_complete = False + + +def replace_player_signature(p: 'type[Player]'): + """ + Function to change a Player's signature so that effects can be used as + options in autocomplete. + """ + signature = inspect.signature(p) + parameters = [] + + params = list(p.fx_attributes) + for key in p.fx_attributes: + parameters.append( + inspect.Parameter( + name=key, + default=FxList.defaults[key], + kind=inspect.Parameter.KEYWORD_ONLY, + ), + ) + + for attr in filter( + lambda x: x not in params, + set(p.keywords + p.envelope_keywords + p.base_attributes), + ): + parameters.append( + inspect.Parameter( + name=attr, + kind=inspect.Parameter.KEYWORD_ONLY, + ), + ) + + p.__signature__ = signature.replace(parameters=parameters) + return p + + +@dataclass +class FoxDotComplete: + name: str + type: str = 'FoxDot' + + def docstring(self): + return '' + + +@dataclass +class Timeout: + seconds: int = 1 + error_message: str = 'Timeout' + + def __enter__(self): + def _handle_timeout(_signum, _frame): + raise TimeoutError(self.error_message) + + signal.signal(signal.SIGALRM, _handle_timeout) + signal.alarm(self.seconds) + + def __exit__(self, type, value, traceback): + signal.alarm(0) + class TextPrompt: + enable_jedi_complete = enable_jedi_complete # GUI.prompt.enable_jedi_complete = False + enable_jedi_docstring = False # GUI.prompt.enable_jedi_docstring = True + tab_complete = True # GUI.prompt.tab_complete = False + timeout = 5 # GUI.prompt.timeout = 1 + + visible = True + docstring_visible = False + + selected = 0 + anchor = None + num_items = 6 pady = 2 + def __init__(self, root): self.root = root self.master = self.root.text # text box - # TODO // sort out the name space to check for suggestions - - # keywords = list(self.root.namespace["get_keywords"]()) - keywords = list(get_keywords()) - synthdefs = list(self.root.namespace["SynthDefs"]) - attributes = list(self.root.namespace["Player"].get_attributes()) - player_methods = ["every", "often", "sometimes", "rarely"] - pattern_methods = list(self.root.namespace["Pattern"].get_methods()) - scales = list(self.root.namespace["Scale"].names()) - other = ["SynthDefs"] - - self.namespace = sorted(list(set(keywords + synthdefs + attributes + player_methods + pattern_methods + scales + other))) - self.values = [StringVar() for n in range(self.num_items)] self.clear() @@ -36,7 +123,7 @@ def __init__(self, root): self.bg = "gray40" self.fg = "gray30" - self.suggestions = [] + self.suggestions = deque() self.x = 0 self.y = 0 @@ -47,146 +134,231 @@ def __init__(self, root): self.__visible = True + # Docstring + self.docstring_visible = False + self.docstring_var = StringVar() + self.docstring = Label( + self.master, + textvariable=self.docstring_var, + font=self.root.codefont, + foreground='White', + anchor=W, + justify='left', + pady=self.pady, + ) + + # Binds + self.master.bind('', lambda event: self.tab()) + self.master.bind('', lambda event: self.escape()) + self.master.bind('', lambda event: self.toogle(force=True)) + self.master.bind('', lambda event: self.toggle_docstring()) + self.hide() + @property + def namespace(self): + ns = set(self.root.namespace) + ns = set(self.root.namespace['Samples'].loops) + ns |= set(get_keywords()) + ns |= set(self.root.namespace["SynthDefs"]) + ns |= set(self.root.namespace["Player"].get_attributes()) + ns |= {"every", "often", "sometimes", "rarely"} + ns |= set(self.root.namespace["Pattern"].get_methods()) + ns |= set(self.root.namespace["Scale"].names()) + ns |= {"SynthDefs"} + + return sorted(list(ns)) + def get_start_of_word(self): """ Returns the TK index and characters directly preceeding the INSERT marker """ # Get contents of this line up to the INSERT - row, col = get_index(self.master.index(INSERT)) - - start, end = "{}.0".format(row), "{}.{}".format(row, col) - - text = self.master.get(start, end) - + text = self.master.get(f"{row}.0", f"{row}.{col}") match = self.re.search(text) if match is not None: - - index, text = "{}.{}".format(row, match.start()), match.group(0) - + index, text = f"{row}.{match.start()}", match.group(0) self.anchor = match.span() - else: - index, text = "1.0", "" - self.anchor = None - return (index, text) + return index, text def in_word(self): """ Returns True if the next character is alphanumeric """ return self.master.get(INSERT).isalnum() - def show(self): - """ Displays the prompt with suggestions """ - - if not self.__visible: - + def show(self, force=False): + """Displays the prompt with suggestions.""" + _, column = get_index(self.master.index(INSERT)) + if column == 0 and not force: return - # 1. Get location - start of the word + if not (bbox := self.master.bbox(INSERT)): + return - index, word = self.get_start_of_word() + self.selected = 0 + self.x, self.y, self.w, self.h = bbox - # If there is a alphanumeric character next, dont show + _, word = self.get_start_of_word() - if len(word) == 0 or self.in_word(): + suggestions = self.find_suggestions() + if not suggestions and word: + names = [s.name for s in suggestions] + for name in self.namespace: + if name.startswith(word) and name not in names: + suggestions.append(FoxDotComplete(name=name)) + self.suggestions = deque(suggestions) + if not self.suggestions or word == self.suggestions[0]: return self.hide() - bbox = self.master.bbox(index) - - if bbox is not None: - - self.selected = 0 - - self.x, self.y, self.w, self.h = bbox - - # 3. Find first 4 words - - self.suggestions = self.find_suggestions(word) - - # If there is only 1 suggestion and we're at the end of the word (or no suggestions), just hide - - num_suggestions = len(self.suggestions) - - if num_suggestions == 0 or (num_suggestions == 1 and (word == self.suggestions[0])): - - return self.hide() - - # 4. Show - - self.update_values(self.suggestions) - - self.move(self.x, self.y) + self.update_values() + self.move(self.x, self.y) + self.visible = True + + def tab(self): + """Tab complete action.""" + if not self.visible or not self.tab_complete: + return self.root.tab() + self.autocomplete() + return 'break' + + def escape(self): + """Hide prompt if visible.""" + if not self.visible: + return self.root.toggle_true_fullscreen() + self.hide() - self.visible = True + if self.docstring_visible: + self.toggle_docstring() - return + return 'break' def move(self, x, y): offset = self.h width = max((len(val.get()) for val in self.values)) for i, label in enumerate(self.labels): - if self.values[i].get() != "": - label.place(x=x, y=y + offset) - label.config(width=width, bg=(self.fg if self.selected == i else self.bg),) - offset += (self.h + (self.pady * 2)) - else: + if self.values[i].get() == "": label.place(x=9999, y=9999) - return + continue + + label.place(x=x, y=y + offset) + label.config(width=width, bg=(self.fg if self.selected == i else self.bg),) + offset += (self.h + (self.pady * 2)) + if self.selected == i and self.enable_jedi_docstring: + self.docstring_visible = False + self.toggle_docstring([self.suggestions[i]]) def hide(self): self.clear() self.move(x=9999, y=9999) self.visible = False - return + + if self.docstring_visible: + self.toggle_docstring() def clear(self): for value in self.values: value.set("") - return - def update_values(self, values): - self.clear() - for i, word in enumerate(values[:self.num_items]): - self.values[i].set(word) - return - - def find_suggestions(self, word): - words = [] - i = 0 - for phrase in self.namespace: - # if phrase.lower().startswith(word.lower()): - if phrase.startswith(word): - words.append(phrase) - i += 1 - if i == self.num_items: - break - return words + def update_values(self): + """Update values.""" + total = len(self.suggestions) + for row, value in enumerate(self.values): + if row >= total: + value.set('') + continue + + value.set(self.suggestions[row].name) + if self.enable_jedi_docstring and row == self.selected: + self.docstring_visible = False + self.toggle_docstring([self.suggestions[row]]) + + def update_docstring(self, helpers): + if not helpers or not (doc := helpers[0].docstring()): + return + + self.docstring_var.set(doc) + + width = max(len(line) for line in self.docstring_var.get().splitlines()) + self.docstring.config(width=width, bg=self.fg) + + x = self.x if self.x < 9999 else 200 + y = self.y if self.y < 9999 else 20 + + length = max((len(val.get()) for val in self.values)) + base = length * 20 if length else 300 + + self.docstring.place(x=x+base, y=y+42) + self.docstring_visible = True + + @property + def interpreter(self): + namespace = self.root.namespace.copy() + # adicionando attributes e fxList aos kwargs + player_class = replace_player_signature(namespace['Player']) + # mentindo que SynthDefProxy é Player + for name in list(namespace['SynthDefs']) + ['play']: + namespace[name] = player_class + + return jedi.Interpreter( + self.master.get('1.0', END), + [namespace], + path=self.root.filename, + project=jedi.Project(self.root.namespace['Settings'].USER_CWD), + ) + + def find_suggestions(self, fuzzy=False): + line, column = get_index(self.master.index(INSERT)) + if self.enable_jedi_complete and jedi: + with suppress(Exception), Timeout(seconds=self.timeout): + return self.interpreter.complete(line, column, fuzzy=fuzzy) + return [] def cycle_up(self): + if self.selected == 0: + self.suggestions.rotate() + self.update_values() self.selected = max(0, self.selected - 1) + self.update_values() return self.move(self.x, self.y) def cycle_down(self): - self.selected = min(len(self.suggestions) - 1, self.selected + 1) + selected = self.selected + 1 + if selected >= self.num_items: + self.suggestions.rotate(-1) + self.update_values() + last_row = min(len(self.suggestions), self.num_items) + self.selected = min(last_row - 1, selected) + self.update_values() return self.move(self.x, self.y) def autocomplete(self): """ Inserts the remainder of the currently highlited suggestion """ if self.anchor is not None: LENGTH = self.anchor[1] - self.anchor[0] - self.master.delete("{}-{}c".format(INSERT, LENGTH), INSERT) + self.master.delete(f"{INSERT}-{LENGTH}c", INSERT) WORD = self.values[self.selected].get() self.master.insert(INSERT, WORD) self.root.update() self.hide() - return - def toggle(self): - """ Flags the prompt to show / not show automatically """ - self.__visible = not self.__visible - return + def toogle(self, force=False): + """Toggle prompt""" + self.hide() if self.visible else self.show(force=force) + return 'break' + + def toggle_docstring(self, helpers=None): + self.docstring_var.set('') + + if not self.docstring_visible: + line, column = get_index(self.master.index(INSERT)) + try: + return self.update_docstring(helpers or self.interpreter.help(line, column)) + except AttributeError: + print('AttributeError') + + self.docstring.place(x=9999, y=9999) + self.docstring_visible = False diff --git a/setup.py b/setup.py index 477c02f5..ceb7ac1b 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ extras_require={ "simple": ["wxPython"], "midi": ["midiutil"], + "jedi": ["jedi>=0.19.2,<1.0.0"], }, packages=['FoxDot', 'FoxDot.lib',