From 4993782492478ce8e37a8b336249575700dfe843 Mon Sep 17 00:00:00 2001 From: Anton Pieper <94118572+AntonPieper@users.noreply.github.com> Date: Sun, 2 Jun 2024 19:14:54 +0200 Subject: [PATCH] Add smart auto-completions --- .../shadereditor/activity/MainActivity.java | 71 ++++--- .../shadereditor/fragment/EditorFragment.java | 2 + .../shadereditor/highlighter/Lexer.java | 42 +++- .../shadereditor/widget/ShaderEditor.java | 192 +++++++++++++++--- 4 files changed, 249 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/de/markusfisch/android/shadereditor/activity/MainActivity.java b/app/src/main/java/de/markusfisch/android/shadereditor/activity/MainActivity.java index c14cbeb7..976fd8f3 100644 --- a/app/src/main/java/de/markusfisch/android/shadereditor/activity/MainActivity.java +++ b/app/src/main/java/de/markusfisch/android/shadereditor/activity/MainActivity.java @@ -19,6 +19,7 @@ import android.os.Handler; import android.os.Looper; import android.util.DisplayMetrics; +import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.MotionEvent; @@ -47,12 +48,15 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -73,7 +77,7 @@ public class MainActivity extends AppCompatActivity - implements ShaderEditor.OnTextChangedListener { + implements ShaderEditor.OnTextChangedListener, ShaderEditor.CodeCompletionListener { private static final String SELECTED_SHADER = "selected_shader"; private static final String CODE_VISIBLE = "code_visible"; private static final int PREVIEW_SHADER = 1; @@ -105,6 +109,8 @@ public void run() { private volatile int fps; private float[] qualityValues; private float quality = 1f; + private final List currentCompletions = new ArrayList<>(); + private Adapter completionsAdapter; @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { @@ -139,6 +145,12 @@ public void onTextChanged(String text) { setFragmentShader(text); } + @Override + public void onCodeCompletions(@NonNull List completions, int position) { + completionsAdapter.setPosition(position); + completionsAdapter.submitList(completions); + } + @Override protected void onActivityResult( int requestCode, @@ -296,19 +308,8 @@ private void initExtraKeys() { RecyclerView completions = extraKeys.findViewById(R.id.completions); completions.setLayoutManager(new LinearLayoutManager(this, RecyclerView.HORIZONTAL, false)); - completions.setAdapter(new Adapter(this, - Arrays.asList( - "if", - "else", - "for", - "while", - "texture2D", - "distance", - "smoothstep", - "min", - "max" - ) - )); + this.completionsAdapter = new Adapter(this); + completions.setAdapter(completionsAdapter); DividerItemDecoration divider = new DividerItemDecoration(completions.getContext(), DividerItemDecoration.HORIZONTAL); divider.setDrawable(Objects.requireNonNull(ContextCompat.getDrawable(this, @@ -347,30 +348,47 @@ public void onGlobalLayout() { : View.GONE); } - private class Adapter extends RecyclerView.Adapter { - private final List list; + private static final DiffUtil.ItemCallback DIFF_CALLBACK = + new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull String oldItem, @NonNull String newItem) { + // Update the condition according to your unique identifier + return oldItem.equals(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull String oldItem, + @NonNull String newItem) { + // Return true if the contents of the items have not changed + return oldItem.equals(newItem); + } + }; + + private class Adapter extends ListAdapter { + @NonNull private final LayoutInflater inflater; + int position = 0; - public Adapter(Context context, List list) { + public Adapter(Context context) { + super(DIFF_CALLBACK); this.inflater = LayoutInflater.from(context); - this.list = list; } @NonNull @Override - public Adapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = inflater.inflate(R.layout.extra_key_btn, parent, false); return new ViewHolder(view); } @Override - public void onBindViewHolder(@NonNull Adapter.ViewHolder holder, int position) { - holder.update(list.get(position)); + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + String item = getItem(position); // Use getItem provided by ListAdapter + holder.update(item); } - @Override - public int getItemCount() { - return list.size(); + public void setPosition(int position) { + this.position = position; } private class ViewHolder extends RecyclerView.ViewHolder { @@ -379,7 +397,10 @@ private class ViewHolder extends RecyclerView.ViewHolder { public ViewHolder(@NonNull View itemView) { super(itemView); btn = itemView.findViewById(R.id.btn); - btn.setOnClickListener((v) -> editorFragment.insert(btn.getText())); + btn.setOnClickListener((v) -> { + CharSequence text = btn.getText(); + editorFragment.insert(text.subSequence(position, text.length())); + }); itemView.setOnTouchListener(new View.OnTouchListener() { @SuppressLint("ClickableViewAccessibility") @Override diff --git a/app/src/main/java/de/markusfisch/android/shadereditor/fragment/EditorFragment.java b/app/src/main/java/de/markusfisch/android/shadereditor/fragment/EditorFragment.java index f57585d8..383e4825 100644 --- a/app/src/main/java/de/markusfisch/android/shadereditor/fragment/EditorFragment.java +++ b/app/src/main/java/de/markusfisch/android/shadereditor/fragment/EditorFragment.java @@ -47,6 +47,8 @@ public View onCreateView( if (activity instanceof ShaderEditor.OnTextChangedListener) { shaderEditor.setOnTextChangedListener( (ShaderEditor.OnTextChangedListener) activity); + shaderEditor.setOnCompletionsListener( + (ShaderEditor.CodeCompletionListener) activity); } else { throw new ClassCastException(activity + " must implement " + diff --git a/app/src/main/java/de/markusfisch/android/shadereditor/highlighter/Lexer.java b/app/src/main/java/de/markusfisch/android/shadereditor/highlighter/Lexer.java index b3ea37f4..a7acb8fe 100644 --- a/app/src/main/java/de/markusfisch/android/shadereditor/highlighter/Lexer.java +++ b/app/src/main/java/de/markusfisch/android/shadereditor/highlighter/Lexer.java @@ -10,8 +10,9 @@ import java.util.List; public class Lexer implements Iterable { - public String source(Token token) { - return source.substring(token.startOffset(), token.endOffset()); + @NonNull + public static CharSequence tokenSource(@NonNull Token token, @NonNull CharSequence source) { + return source.subSequence(token.startOffset(), token.endOffset()); } public static class Diff { @@ -599,15 +600,50 @@ private int skipWhitespace() { return possibleOctal && incorrectOctal ? TokenType.INVALID : type; } - public static List complete(@NonNull String text, @NonNull Token.Category type) { + public static List completeKeyword(@NonNull String text, @NonNull Token.Category type) { List result = new ArrayList<>(); TrieNode root = tokenRoot(type); if (root != null) { root.findAll(text, (short) TokenType.INVALID.ordinal(), result); } + + if (type == Token.Category.PREPROC) { + // Also add normal keywords to the list + KEYWORDS_TRIE.findAll(text, (short) TokenType.INVALID.ordinal(), result); + } + return result; } + /** + * Performs a binary search to find the token that includes the given position. + * Assumes tokens are non-overlapping and touch each other. + * + * @param tokens List of tokens sorted by their start offsets. + * @param position The offset to search for. + * @return The token that contains the position, or null if no such token exists. + */ + @Nullable + public static Token findToken(@NonNull List tokens, int position) { + int low = 0; + int high = tokens.size() - 1; + + while (low <= high) { + int mid = low + (high - low) / 2; + Token midToken = tokens.get(mid); + + if (position >= midToken.startOffset() && position <= midToken.endOffset()) { + return midToken; + } else if (position < midToken.startOffset()) { + high = mid - 1; + } else { + low = mid + 1; + } + } + + return null; // No token contains the position + } + @Nullable private static TrieNode tokenRoot(@NonNull Token.Category type) { switch (type) { diff --git a/app/src/main/java/de/markusfisch/android/shadereditor/widget/ShaderEditor.java b/app/src/main/java/de/markusfisch/android/shadereditor/widget/ShaderEditor.java index 6e7ddfa4..63e4f6b8 100644 --- a/app/src/main/java/de/markusfisch/android/shadereditor/widget/ShaderEditor.java +++ b/app/src/main/java/de/markusfisch/android/shadereditor/widget/ShaderEditor.java @@ -18,8 +18,8 @@ import android.text.style.LineHeightSpan; import android.text.style.ReplacementSpan; import android.util.AttributeSet; +import android.util.Log; import android.view.KeyEvent; -import android.view.View; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; @@ -29,6 +29,11 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -44,6 +49,11 @@ public interface OnTextChangedListener { void onTextChanged(String text); } + @FunctionalInterface + public interface CodeCompletionListener { + void onCodeCompletions(@NonNull List completions, int position); + } + private static final Pattern PATTERN_TRAILING_WHITE_SPACE = Pattern.compile( "[\\t ]+$", Pattern.MULTILINE); @@ -65,16 +75,21 @@ public interface OnTextChangedListener { public void run() { Editable e = getText(); - if (e != null && onTextChangedListener != null) { - onTextChangedListener.onTextChanged(e.toString()); + if (e != null) { + if (onTextChangedListener != null) { + onTextChangedListener.onTextChanged(e.toString()); + } + + highlightWithoutChange(e); } - highlightWithoutChange(e); } }; private final int[] colors = new int[Highlight.values().length]; private OnTextChangedListener onTextChangedListener; + @Nullable + private CodeCompletionListener codeCompletionListener; private int updateDelay = 1000; private int errorLine = 0; private boolean dirty = false; @@ -84,6 +99,9 @@ public void run() { private int tabWidthInCharacters = 0; private int tabWidth = 0; private List tokens = new ArrayList<>(); + private int revision = 0; + private final TokenListUpdater tokenListUpdater = new TokenListUpdater(this::provideCompletions); + private boolean editing = false; public ShaderEditor(Context context) { super(context); @@ -99,6 +117,10 @@ public void setOnTextChangedListener(OnTextChangedListener listener) { onTextChangedListener = listener; } + public void setOnCompletionsListener(@NonNull CodeCompletionListener listener) { + codeCompletionListener = listener; + } + public void setUpdateDelay(int ms) { updateDelay = ms; } @@ -167,6 +189,8 @@ public void setTextHighlighted(CharSequence text) { dirty = false; modified = false; + + tokenListUpdater.update(text, ++revision); // `setText` can't be overridden setText(highlight(new SpannableStringBuilder(text), true)); modified = true; @@ -248,6 +272,19 @@ public int getAutofillType() { @Override protected void onSelectionChanged(int selStart, int selEnd) { super.onSelectionChanged(selStart, selEnd); + Editable text = getText(); + CodeCompletionListener listener = codeCompletionListener; + if (text == null || listener == null) { + return; + } + int start = getSelectionStart(); + int end = getSelectionEnd(); + if (start != end) { + return; + } + if (!editing && tokenListUpdater.isDone()) { + provideCompletions(tokens, text); + } } private void removeUniform(Editable e, String statement) { @@ -318,6 +355,7 @@ public void beforeTextChanged( int start, int count, int after) { + editing = true; } @Override @@ -331,10 +369,13 @@ public void afterTextChanged(Editable e) { } if (!modified) { + editing = false; return; } + editing = false; dirty = true; + tokenListUpdater.update(e, ++revision); updateHandler.postDelayed(updateRunnable, updateDelay); } }); @@ -343,19 +384,16 @@ public void afterTextChanged(Editable e) { setUpdateDelay(ShaderEditorApp.preferences.getUpdateDelay()); setTabWidth(ShaderEditorApp.preferences.getTabWidth()); - setOnKeyListener(new OnKeyListener() { - @Override - public boolean onKey(View v, int keyCode, KeyEvent event) { - if (ShaderEditorApp.preferences.useTabForIndent() && - event.getAction() == KeyEvent.ACTION_DOWN && - keyCode == KeyEvent.KEYCODE_TAB) { - // Insert a tab character instead of doing focus - // navigation. - insert("\t"); - return true; - } - return false; + setOnKeyListener((v, keyCode, event) -> { + if (ShaderEditorApp.preferences.useTabForIndent() && + event.getAction() == KeyEvent.ACTION_DOWN && + keyCode == KeyEvent.KEYCODE_TAB) { + // Insert a tab character instead of doing focus + // navigation. + insert("\t"); + return true; } + return false; }); } @@ -373,13 +411,13 @@ private void cancelUpdate() { updateHandler.removeCallbacks(updateRunnable); } - private void highlightWithoutChange(Editable e) { + private void highlightWithoutChange(@NonNull Editable e) { modified = false; highlight(e, false); modified = true; } - private Editable highlight(Editable e, boolean complete) { + private Editable highlight(@NonNull Editable e, boolean complete) { int length = e.length(); clearError(e); @@ -401,17 +439,11 @@ private Editable highlight(Editable e, boolean complete) { clearSpans(e, 0, length, ForegroundColorSpan.class); return e; } - - Lexer lexer = new Lexer(e.toString()); - List oldTokens = tokens; - tokens = new ArrayList<>(); - for (Token token : lexer) { - tokens.add(token); - } + List newTokens = tokenListUpdater.ensureUpdated(e, revision); if (complete) { clearSpans(e, 0, length, ForegroundColorSpan.class); - for (Token token : tokens) { + for (Token token : newTokens) { @ColorInt int color = colors[Highlight.from(token.type()).ordinal()]; if (color != textColor) { e.setSpan( @@ -421,23 +453,45 @@ private Editable highlight(Editable e, boolean complete) { } } } else { - Lexer.Diff diff = Lexer.diff(oldTokens, tokens); + Lexer.Diff diff = Lexer.diff(tokens, newTokens); if (diff.start <= diff.deleteEnd) { - int startOffset = tokens.get(diff.start).startOffset(); - int endOffset = tokens.get(diff.insertEnd).endOffset(); + int startOffset = newTokens.get(diff.start).startOffset(); + int endOffset = newTokens.get(diff.insertEnd).endOffset(); clearSpans(e, startOffset, endOffset, ForegroundColorSpan.class); } for (int i = diff.start; i <= diff.insertEnd; ++i) { - Token token = tokens.get(i); + Token token = newTokens.get(i); e.setSpan( new ForegroundColorSpan(colors[Highlight.from(token.type()).ordinal()]), token.startOffset(), token.endOffset(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } + tokens = newTokens; + return e; } + private void provideCompletions(@NonNull List tokens, @NonNull CharSequence text) { + CodeCompletionListener listener = codeCompletionListener; + if (listener == null) { + return; + } + int start = getSelectionStart(); + Token tok = Lexer.findToken(tokens, start); + if (tok == null) { + listener.onCodeCompletions(new ArrayList<>(), 0); + return; + } + int positionInToken = start - tok.startOffset(); + listener.onCodeCompletions( + Lexer.completeKeyword( + Lexer.tokenSource(tok, text).subSequence(0, positionInToken).toString(), + tok.category() + ), + positionInToken); + } + private static void clearSpans(Spannable e, int start, int end, Class clazz) { // Remove foreground color spans. T[] spans = e.getSpans( @@ -617,4 +671,82 @@ public void chooseHeight(CharSequence text, int start, int end, int spanstartv, int lineHeight, Paint.FontMetricsInt fm) { } } + + @FunctionalInterface + interface OnTokenized { + void onTokens(@NonNull List tokens, @NonNull CharSequence text); + } + + static class TokenizeCalculation implements Callable> { + private final String text; + @NonNull + private final OnTokenized onTokenized; + + public TokenizeCalculation(@NonNull String text, @NonNull OnTokenized onTokenized) { + this.text = text; + this.onTokenized = onTokenized; + } + @Override + public List call() { + Lexer lexer = new Lexer(text); + List tokens = new ArrayList<>(); + for (Token token : lexer) { + tokens.add(token); + if (Thread.currentThread().isInterrupted()) { + break; + } + } + onTokenized.onTokens(tokens, text); + return tokens; + } + } + + static class TokenListUpdater { + @NonNull + private final OnTokenized onTokenized; + @NonNull + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + @Nullable + private FutureTask> task; + private int revision = -1; + + public TokenListUpdater(@NonNull OnTokenized onTokenized) { + this.onTokenized = onTokenized; + } + + @NonNull + public List ensureUpdated(@NonNull CharSequence text, int revision) { + if (task != null && revision == this.revision) { + try { + return task.get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + update(text, revision); + try { + return task.get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public void update(@NonNull CharSequence text, int revision) { + if (revision == this.revision) { + Log.d("TEST", "Same revision: " + revision); + return; + } else if (task != null) { + task.cancel(false); + } + Log.d("TEST", "Different revision: " + revision + " != " + this.revision); + + this.revision = revision; + task = new FutureTask<>(new TokenizeCalculation(text.toString(), onTokenized)); + executor.submit(task); + } + + public boolean isDone() { + return task != null && task.isDone(); + } + } }