diff --git a/README.md b/README.md index fe885c3..7363523 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,15 @@ the reason of it either the author did not have enough time to implement it or n alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.maxistar.textpad) -This editor can be useful for editing of small texts, write down small notes etc... At the moment application supports only plain text files. +This editor can be useful for editing small text files and writing short notes. + +### Syntax highlighting + +Optional syntax highlighting is available for JSON, Markdown, and JavaScript. It is disabled by default and can be enabled in **Settings > Appearance > Syntax highlighting**. + +The editor uses a file's extension in **Auto** mode. The editor menu also lets you select Plain text, JSON, Markdown, or JavaScript for the current document. A manual selection lasts until another document is opened or a new document is created. + +Highlighting is lexical and does not validate syntax. JavaScript regular-expression literals are left unstyled, and expressions inside template-string interpolation are not parsed separately. Documents larger than 256,000 characters remain plain text to keep editing responsive. The code is open so anyone can review code, send pull requests, new features, translations and so on. diff --git a/app/build.gradle b/app/build.gradle index f5b14fc..8d25202 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,12 @@ apply plugin: 'com.android.application' android { + testOptions { + unitTests.all { + useJUnitPlatform() + } + } + defaultConfig { applicationId "com.maxistar.textpad" minSdkVersion 21 @@ -67,4 +73,3 @@ android { } - diff --git a/app/src/androidTest/java/com/maxistar/textpad/syntax/SyntaxEditorRegressionTest.java b/app/src/androidTest/java/com/maxistar/textpad/syntax/SyntaxEditorRegressionTest.java new file mode 100644 index 0000000..f80721d --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/syntax/SyntaxEditorRegressionTest.java @@ -0,0 +1,65 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.widget.EditText; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.maxistar.textpad.R; +import com.maxistar.textpad.activities.EditorActivity; +import com.maxistar.textpad.utils.EditTextUndoRedo; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class SyntaxEditorRegressionTest { + @Test + public void renderingDoesNotDirtyTextOrEnterUndoHistory() { + try (ActivityScenario scenario = + ActivityScenario.launch(EditorActivity.class)) { + scenario.onActivity(activity -> { + activity.clearFile(); + EditText editor = activity.findViewById(R.id.editText1); + String cleanTitle = activity.getTitle().toString(); + + editor.getText().setSpan( + new SyntaxSpan(0xff005cc5, 1), + 0, + 0, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + + assertEquals("", editor.getText().toString()); + assertEquals(cleanTitle, activity.getTitle().toString()); + + EditTextUndoRedo undoRedo = new EditTextUndoRedo(editor, activity); + undoRedo.clearHistory(); + editor.append("text"); + assertTrue(undoRedo.getCanUndo()); + + BackgroundColorSpan searchSpan = new BackgroundColorSpan(0xffffff00); + editor.getText().setSpan( + searchSpan, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editor.getText().setSpan( + new SyntaxSpan(0xff005cc5, 2), + 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + SyntaxHighlightController.removeSyntaxSpans(editor.getText(), null); + + assertEquals(1, editor.getText().getSpans( + 0, editor.length(), BackgroundColorSpan.class).length); + assertTrue(undoRedo.getCanUndo()); + undoRedo.undo(); + assertEquals("", editor.getText().toString()); + assertFalse(undoRedo.getCanUndo()); + undoRedo.disconnect(); + }); + } + } +} diff --git a/app/src/androidTest/java/com/maxistar/textpad/syntax/SyntaxHighlightControllerTest.java b/app/src/androidTest/java/com/maxistar/textpad/syntax/SyntaxHighlightControllerTest.java new file mode 100644 index 0000000..225541f --- /dev/null +++ b/app/src/androidTest/java/com/maxistar/textpad/syntax/SyntaxHighlightControllerTest.java @@ -0,0 +1,257 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.os.Handler; +import android.os.Looper; +import android.text.NoCopySpan; +import android.text.TextWatcher; +import android.text.Spanned; +import android.text.Editable; +import android.text.style.BackgroundColorSpan; +import android.view.inputmethod.BaseInputConnection; +import android.widget.EditText; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +@RunWith(AndroidJUnit4.class) +public class SyntaxHighlightControllerTest { + private EditText editor; + private SyntaxHighlightController controller; + private ExecutorService executor; + + @Before + public void setUp() { + InstrumentationRegistry.getInstrumentation().runOnMainSync( + () -> editor = new EditText(ApplicationProvider.getApplicationContext())); + } + + @After + public void tearDown() { + if (controller != null) { + InstrumentationRegistry.getInstrumentation().runOnMainSync(controller::destroy); + } else if (executor != null) { + executor.shutdownNow(); + } + } + + @Test + public void rendersOffMainThreadAndPreservesEditorState() throws Exception { + AtomicBoolean tokenizedOnMain = new AtomicBoolean(true); + AtomicInteger textChanges = new AtomicInteger(); + SyntaxTokenizerRegistry registry = new SyntaxTokenizerRegistry(); + registry.register(LanguageMode.JSON, (text, limit) -> { + tokenizedOnMain.set(Looper.myLooper() == Looper.getMainLooper()); + return SyntaxTokenizationResult.success(Collections.singletonList( + new SyntaxToken(0, text.length(), SyntaxTokenType.STRING))); + }); + createController(registry, 10); + + BackgroundColorSpan searchSpan = new BackgroundColorSpan(0xffffff00); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + editor.setText("\"value\""); + editor.setSelection(2, 5); + BaseInputConnection.setComposingSpans(editor.getText()); + editor.getText().setSpan( + searchSpan, 1, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + editor.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged( + CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged( + CharSequence s, int start, int before, int count) { + textChanges.incrementAndGet(); + } + + @Override + public void afterTextChanged(Editable s) { + } + }); + controller.setEnabled(true); + controller.setLanguageMode(LanguageMode.JSON); + controller.start(); + }); + + waitForSpanCount(1); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + assertFalse(tokenizedOnMain.get()); + assertEquals(2, editor.getSelectionStart()); + assertEquals(5, editor.getSelectionEnd()); + assertEquals("\"value\"", editor.getText().toString()); + assertEquals(0, textChanges.get()); + assertEquals(0, BaseInputConnection.getComposingSpanStart(editor.getText())); + assertEquals(editor.length(), + BaseInputConnection.getComposingSpanEnd(editor.getText())); + assertEquals(1, editor.getText().getSpans( + 0, editor.length(), BackgroundColorSpan.class).length); + SyntaxSpan syntaxSpan = editor.getText().getSpans( + 0, editor.length(), SyntaxSpan.class)[0]; + assertTrue(syntaxSpan instanceof NoCopySpan); + }); + } + + @Test + public void latestGenerationWinsAndStaleSpansAreRemoved() throws Exception { + createController(new SyntaxTokenizerRegistry(), 1); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + editor.setText("{\"old\":1}"); + controller.setEnabled(true); + controller.setLanguageMode(LanguageMode.JSON); + controller.start(); + editor.setText("{\"latest\":2}"); + controller.onTextChanged(); + }); + + waitForSpanCount(1); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + for (SyntaxSpan span : editor.getText().getSpans( + 0, editor.length(), SyntaxSpan.class)) { + assertTrue(editor.getText().getSpanEnd(span) <= editor.length()); + } + }); + } + + @Test + public void autoDetectionAndLifecycleReentryScheduleOnlyEligibleWork() throws Exception { + AtomicInteger calls = new AtomicInteger(); + SyntaxTokenizerRegistry registry = new SyntaxTokenizerRegistry(); + registry.register(LanguageMode.MARKDOWN, (text, limit) -> { + calls.incrementAndGet(); + return SyntaxTokenizationResult.success(Collections.singletonList( + new SyntaxToken(0, text.length(), SyntaxTokenType.HEADING))); + }); + createController(registry, 10); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + editor.setText("# Heading"); + controller.setEnabled(true); + controller.setDisplayName("notes.txt"); + controller.start(); + }); + Thread.sleep(100); + assertEquals(0, calls.get()); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + controller.setDisplayName("notes.md"); + controller.stop(); + }); + Thread.sleep(100); + assertEquals(0, calls.get()); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(controller::start); + waitForSpanCount(1); + assertEquals(1, calls.get()); + } + + @Test + public void disabledOversizedFailureAndTokenLimitFallBackToPlainText() throws Exception { + AtomicInteger calls = new AtomicInteger(); + AtomicInteger limitReports = new AtomicInteger(); + SyntaxTokenizerRegistry registry = new SyntaxTokenizerRegistry(); + registry.register(LanguageMode.JSON, (text, limit) -> { + calls.incrementAndGet(); + if ("failure".equals(text)) { + throw new IllegalStateException("test"); + } + return SyntaxTokenizationResult.limitExceeded(); + }); + executor = Executors.newSingleThreadExecutor(); + controller = new SyntaxHighlightController( + editor, + limitReports::incrementAndGet, + new Handler(Looper.getMainLooper()), + executor, + registry, + new LanguageDetector(), + 0, + 1, + 1 + ); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + editor.setText("{}"); + controller.setLanguageMode(LanguageMode.JSON); + controller.start(); + }); + Thread.sleep(100); + assertEquals(0, calls.get()); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + controller.setEnabled(true); + editor.setText(new char[SyntaxHighlightController.DOCUMENT_CHARACTER_LIMIT + 1], + 0, + SyntaxHighlightController.DOCUMENT_CHARACTER_LIMIT + 1); + controller.onTextChanged(); + controller.onTextChanged(); + }); + assertEquals(1, limitReports.get()); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + controller.resetDocument("test.json"); + editor.setText("failure"); + controller.onTextChanged(); + }); + Thread.sleep(150); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + assertEquals(0, spanCount()); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + editor.setText("{}"); + controller.onTextChanged(); + }); + Thread.sleep(150); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + assertEquals(0, spanCount()); + } + + private void createController(SyntaxTokenizerRegistry registry, int batchSize) { + executor = Executors.newSingleThreadExecutor(); + controller = new SyntaxHighlightController( + editor, + () -> { }, + new Handler(Looper.getMainLooper()), + executor, + registry, + new LanguageDetector(), + 0, + 100, + batchSize + ); + } + + private void waitForSpanCount(int minimum) throws Exception { + for (int attempt = 0; attempt < 40; attempt++) { + Thread.sleep(25); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + if (spanCount() >= minimum) { + return; + } + } + assertTrue("Expected syntax spans", spanCount() >= minimum); + } + + private int spanCount() { + AtomicInteger count = new AtomicInteger(); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> count.set( + editor.getText().getSpans(0, editor.length(), SyntaxSpan.class).length)); + return count.get(); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java index 030ed05..94b51ef 100644 --- a/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java +++ b/app/src/main/java/com/maxistar/textpad/activities/EditorActivity.java @@ -22,6 +22,7 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.res.Configuration; import android.graphics.Typeface; import android.net.Uri; import android.os.Build; @@ -58,6 +59,9 @@ import com.maxistar.textpad.service.AlternativeUrlsService; import com.maxistar.textpad.service.RecentFilesService; import com.maxistar.textpad.service.ThemeService; +import com.maxistar.textpad.syntax.LanguageMode; +import com.maxistar.textpad.syntax.SyntaxHighlightController; +import com.maxistar.textpad.syntax.SyntaxPalette; import com.maxistar.textpad.utils.EditTextUndoRedo; import com.maxistar.textpad.utils.FileNameHelper; import com.maxistar.textpad.utils.System; @@ -82,6 +86,7 @@ public class EditorActivity extends AppCompatActivity { private static final String STATE_FILENAME = "filename"; private static final String STATE_CHANGED = "changed"; private static final String STATE_CURSOR_POSITION = "cursor-position"; + private static final String STATE_SYNTAX_LANGUAGE = "syntax-language"; private static final int REQUEST_OPEN = 1; private static final int REQUEST_SAVE = 2; @@ -142,6 +147,8 @@ public class EditorActivity extends AppCompatActivity { private TextWatcher textWatcher; EditTextUndoRedo editTextUndoRedo; + private SyntaxHighlightController syntaxHighlightController; + private LanguageMode syntaxLanguageMode = LanguageMode.AUTO; WebView mWebView; @@ -167,6 +174,10 @@ public void onCreate(Bundle savedInstanceState) { disableEditorAutowrapping(); } editTextUndoRedo = new EditTextUndoRedo(mText, this); + syntaxHighlightController = new SyntaxHighlightController( + mText, + () -> showToast(R.string.syntaxHighlightingDocumentTooLarge) + ); if (simpleScrolling()) { linearLayout = findViewById(R.id.linear_layout); @@ -197,6 +208,7 @@ public void onCreate(Bundle savedInstanceState) { } setTextWatcher(); + configureSyntaxHighlighting(); updateTitle(); mText.requestFocus(); @@ -234,6 +246,7 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + syntaxHighlightController.onTextChanged(); if (changed) { return; } @@ -306,6 +319,8 @@ protected void onResume() { if (settingsService.useWakeLock()) { ServiceLocator.getInstance().getWakeLockService().acquireLock(this.getApplicationContext()); } + configureSyntaxHighlighting(); + syntaxHighlightController.start(); } protected void onPause() { @@ -332,6 +347,14 @@ private void restoreState(Bundle state) { urlFilename = state.getString(STATE_FILENAME); changed = state.getBoolean(STATE_CHANGED); selectionStart = state.getInt(STATE_CURSOR_POSITION); + String savedMode = state.getString(STATE_SYNTAX_LANGUAGE); + if (savedMode != null) { + try { + syntaxLanguageMode = LanguageMode.valueOf(savedMode); + } catch (IllegalArgumentException ignored) { + syntaxLanguageMode = LanguageMode.AUTO; + } + } } /** @@ -342,12 +365,20 @@ public void onSaveInstanceState(@NonNull Bundle outState) { outState.putString(STATE_FILENAME, urlFilename); outState.putBoolean(STATE_CHANGED, changed); outState.putInt(STATE_CURSOR_POSITION, mText.getSelectionStart()); + outState.putString(STATE_SYNTAX_LANGUAGE, syntaxLanguageMode.name()); } protected void onStop() { + syntaxHighlightController.stop(); super.onStop(); } + @Override + protected void onDestroy() { + syntaxHighlightController.destroy(); + super.onDestroy(); + } + @Override public void onBackPressed() { if (this.changed && !exitDialogShown) { @@ -431,6 +462,42 @@ void applyPreferences() { applyFontFace(); applyFontSize(); applyColors(); + if (syntaxHighlightController != null) { + configureSyntaxHighlighting(); + } + } + + private void configureSyntaxHighlighting() { + syntaxHighlightController.setEnabled(settingsService.isSyntaxHighlightingEnabled()); + syntaxHighlightController.setLanguageMode(syntaxLanguageMode); + syntaxHighlightController.setDisplayName(getDocumentDisplayName()); + syntaxHighlightController.setPalette(getSyntaxPalette()); + } + + private SyntaxPalette getSyntaxPalette() { + if (settingsService.isCustomTheme()) { + return SyntaxPalette.forBackground(settingsService.getBgColor()); + } + if (SettingsService.COLOR_THEME_DARK.equals(settingsService.getColorThemeType())) { + return SyntaxPalette.dark(); + } + if (SettingsService.COLOR_THEME_LIGHT.equals(settingsService.getColorThemeType())) { + return SyntaxPalette.light(); + } + int nightMode = getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK; + return nightMode == Configuration.UI_MODE_NIGHT_YES + ? SyntaxPalette.dark() : SyntaxPalette.light(); + } + + private String getDocumentDisplayName() { + if (isFilenameEmpty()) { + return TPStrings.NEW_FILE_TXT; + } + return FileNameHelper.getFilenameByUri( + getApplicationContext(), + Uri.parse(getFilename()) + ); } private void disableEditorAutowrapping() { @@ -535,6 +602,7 @@ public boolean onPrepareOptionsMenu(Menu menu) { redoMenu.setEnabled(editTextUndoRedo.getCanRedo()); updateRecentFiles(menu); + updateSyntaxLanguageMenu(menu); if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { MenuItem printMenu = menu.findItem(R.id.menu_document_print); @@ -544,6 +612,34 @@ public boolean onPrepareOptionsMenu(Menu menu) { return true; } + private void updateSyntaxLanguageMenu(Menu menu) { + MenuItem syntaxMenu = menu.findItem(R.id.menu_syntax_language); + syntaxMenu.setVisible(settingsService.isSyntaxHighlightingEnabled()); + int checkedItem; + switch (syntaxLanguageMode) { + case PLAIN_TEXT: + checkedItem = R.id.menu_syntax_plain_text; + break; + case JSON: + checkedItem = R.id.menu_syntax_json; + break; + case MARKDOWN: + checkedItem = R.id.menu_syntax_markdown; + break; + case JAVASCRIPT: + checkedItem = R.id.menu_syntax_javascript; + break; + case AUTO: + default: + checkedItem = R.id.menu_syntax_auto; + break; + } + MenuItem selectedItem = menu.findItem(checkedItem); + if (selectedItem != null) { + selectedItem.setChecked(true); + } + } + private void updateRecentFiles(Menu menu) { MenuItem recentFilesMenuItem = menu.findItem(R.id.menu_document_open_last); if (settingsService.isShowLastEditedFiles()) { @@ -673,6 +769,16 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { shareText(); } else if (itemId == R.id.menu_document_print) { printText(); + } else if (itemId == R.id.menu_syntax_auto) { + selectSyntaxLanguage(LanguageMode.AUTO, item); + } else if (itemId == R.id.menu_syntax_plain_text) { + selectSyntaxLanguage(LanguageMode.PLAIN_TEXT, item); + } else if (itemId == R.id.menu_syntax_json) { + selectSyntaxLanguage(LanguageMode.JSON, item); + } else if (itemId == R.id.menu_syntax_markdown) { + selectSyntaxLanguage(LanguageMode.MARKDOWN, item); + } else if (itemId == R.id.menu_syntax_javascript) { + selectSyntaxLanguage(LanguageMode.JAVASCRIPT, item); } else if (itemId == R.id.menu_document_settings) { showSettings(); } else if (itemId == R.id.menu_exit) { @@ -682,6 +788,12 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { return super.onOptionsItemSelected(item); } + private void selectSyntaxLanguage(LanguageMode languageMode, MenuItem item) { + syntaxLanguageMode = languageMode; + item.setChecked(true); + syntaxHighlightController.setLanguageMode(languageMode); + } + private void printText() { if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // Create a WebView object specifically for printing @@ -826,12 +938,21 @@ public void clearFile() { mText.setText(TPStrings.EMPTY); setFilename(TPStrings.EMPTY); initEditor(); + resetSyntaxDocument(); updateTitle(); } + private void resetSyntaxDocument() { + syntaxLanguageMode = LanguageMode.AUTO; + syntaxHighlightController.resetDocument(getDocumentDisplayName()); + } + private void setFilename(String value) { this.urlFilename = value; storeLastFileName(value); + if (syntaxHighlightController != null) { + syntaxHighlightController.setDisplayName(getDocumentDisplayName()); + } } private void storeLastFileName(String value) { @@ -1110,6 +1231,7 @@ protected void openNamedFileLegacy(String filename) { settingsService.setLastFilename(filename, this.getApplicationContext()); } selectionStart = 0; + resetSyntaxDocument(); updateTitle(); } catch (FileNotFoundException e) { this.showToast(R.string.File_not_found); @@ -1156,6 +1278,7 @@ protected void openNamedFile(final Uri uri) { lastTriedSystemUri = null; } updateTitle(); + resetSyntaxDocument(); detectReadOnlyAccess(uri); } catch (FileNotFoundException e) { if (isAccessDeniedException(e)) { @@ -1320,6 +1443,7 @@ public synchronized void onActivityResult( showToast(R.string.Operation_Canceled); } } else if (requestCode == REQUEST_SETTINGS) { + settingsService.reloadSettings(getApplicationContext()); applyPreferences(); } else if (requestCode == ACTION_OPEN_FILE && resultCode == Activity.RESULT_OK) { diff --git a/app/src/main/java/com/maxistar/textpad/service/SettingsService.java b/app/src/main/java/com/maxistar/textpad/service/SettingsService.java index 42f112f..d491d02 100644 --- a/app/src/main/java/com/maxistar/textpad/service/SettingsService.java +++ b/app/src/main/java/com/maxistar/textpad/service/SettingsService.java @@ -32,6 +32,7 @@ public class SettingsService { public static final String SETTING_SHOW_LAST_EDITED_FILES = "show_last_edited_files"; public static final String SETTING_AUTO_WRAPPING = "auto_wrapping"; + public static final String SETTING_SYNTAX_HIGHLIGHTING = "syntax_highlighting"; private static final String SETTING_USE_WAKE_LOCK = "use_wake_lock"; public static final String SETTING_USE_SIMPLE_SCROLLING = "use_simple_scrolling"; @@ -65,6 +66,7 @@ public class SettingsService { private boolean alternative_file_access = true; private boolean auto_save_current_file = false; private boolean auto_wrapping = true; + private boolean syntaxHighlighting = false; private String file_encoding = ""; private String last_filename = ""; @@ -100,6 +102,7 @@ public void loadSettings(Context context) { useSimpleScrolling = sharedPref.getBoolean(SETTING_USE_SIMPLE_SCROLLING, false); alternative_file_access = sharedPref.getBoolean(SETTING_ALTERNATIVE_FILE_ACCESS, true); auto_wrapping = sharedPref.getBoolean(SETTING_AUTO_WRAPPING, true); + syntaxHighlighting = sharedPref.getBoolean(SETTING_SYNTAX_HIGHLIGHTING, false); last_filename = sharedPref.getString(SETTING_LAST_FILENAME, TPStrings.EMPTY); file_encoding = sharedPref.getString(SETTING_FILE_ENCODING, TPStrings.UTF_8); delimiters = sharedPref.getString(SETTING_DELIMITERS, TPStrings.EMPTY); @@ -303,4 +306,8 @@ public boolean isUseSimpleScrolling() { public boolean isAutoWrapping() { return this.auto_wrapping; } + + public boolean isSyntaxHighlightingEnabled() { + return syntaxHighlighting; + } } diff --git a/app/src/main/java/com/maxistar/textpad/syntax/JavaScriptSyntaxTokenizer.java b/app/src/main/java/com/maxistar/textpad/syntax/JavaScriptSyntaxTokenizer.java new file mode 100644 index 0000000..4daf04f --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/JavaScriptSyntaxTokenizer.java @@ -0,0 +1,152 @@ +package com.maxistar.textpad.syntax; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class JavaScriptSyntaxTokenizer implements SyntaxTokenizer { + private static final Set KEYWORDS = new HashSet<>(Arrays.asList( + "break", "case", "catch", "class", "const", "continue", "debugger", + "default", "delete", "do", "else", "export", "extends", "finally", + "for", "function", "if", "import", "in", "instanceof", "let", "new", + "return", "static", "super", "switch", "this", "throw", "try", "typeof", + "var", "void", "while", "with", "yield", "async", "await" + )); + private static final Set LITERALS = new HashSet<>(Arrays.asList( + "true", "false", "null", "undefined", "NaN", "Infinity" + )); + + @Override + public SyntaxTokenizationResult tokenize(String text, int tokenLimit) + throws InterruptedException { + List tokens = new ArrayList<>(); + int index = 0; + while (index < text.length()) { + TokenizerSupport.checkInterrupted(index); + char current = text.charAt(index); + if (current == '/' && index + 1 < text.length() + && text.charAt(index + 1) == '/') { + int end = text.indexOf('\n', index + 2); + end = end < 0 ? text.length() : end; + if (!add(tokens, index, end, SyntaxTokenType.COMMENT, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if (current == '/' && index + 1 < text.length() + && text.charAt(index + 1) == '*') { + int end = text.indexOf("*/", index + 2); + end = end < 0 ? text.length() : end + 2; + if (!add(tokens, index, end, SyntaxTokenType.COMMENT, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if (current == '\'' || current == '"' || current == '`') { + int end = TokenizerSupport.scanQuoted(text, index, current); + if (!add(tokens, index, end, SyntaxTokenType.STRING, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if (current == '/' && looksLikeRegexStart(text, index)) { + index = scanRegexLiteral(text, index); + } else if (Character.isDigit(current)) { + int end = TokenizerSupport.scanNumber(text, index); + if (!add(tokens, index, end, SyntaxTokenType.NUMBER, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if (Character.isJavaIdentifierStart(current) || current == '$') { + int end = index + 1; + while (end < text.length() + && (Character.isJavaIdentifierPart(text.charAt(end)) + || text.charAt(end) == '$')) { + end++; + } + String word = text.substring(index, end); + SyntaxTokenType type = KEYWORDS.contains(word) ? SyntaxTokenType.KEYWORD + : LITERALS.contains(word) ? SyntaxTokenType.LITERAL + : SyntaxTokenType.IDENTIFIER; + if (!add(tokens, index, end, type, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if ("{}[]();,.:=+-*%!<>?&|".indexOf(current) >= 0) { + if (!add(tokens, index, index + 1, SyntaxTokenType.PUNCTUATION, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index++; + } else { + index++; + } + } + return SyntaxTokenizationResult.success(tokens); + } + + private static boolean looksLikeRegexStart(String text, int slashIndex) { + int previous = slashIndex - 1; + while (previous >= 0 && Character.isWhitespace(text.charAt(previous))) { + previous--; + } + if (previous < 0) { + return true; + } + if ("=([{,:;!&|?+-*%<>".indexOf(text.charAt(previous)) >= 0) { + return true; + } + if (!Character.isJavaIdentifierPart(text.charAt(previous))) { + return false; + } + int wordStart = previous; + while (wordStart > 0 && Character.isJavaIdentifierPart(text.charAt(wordStart - 1))) { + wordStart--; + } + String previousWord = text.substring(wordStart, previous + 1); + return "return".equals(previousWord) + || "throw".equals(previousWord) + || "case".equals(previousWord) + || "delete".equals(previousWord) + || "typeof".equals(previousWord) + || "void".equals(previousWord) + || "yield".equals(previousWord) + || "await".equals(previousWord); + } + + private static int scanRegexLiteral(String text, int start) throws InterruptedException { + int index = start + 1; + boolean escaped = false; + boolean characterClass = false; + while (index < text.length()) { + TokenizerSupport.checkInterrupted(index); + char current = text.charAt(index++); + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == '[') { + characterClass = true; + } else if (current == ']') { + characterClass = false; + } else if (current == '/' && !characterClass) { + while (index < text.length() + && Character.isJavaIdentifierPart(text.charAt(index))) { + index++; + } + break; + } else if (current == '\n' || current == '\r') { + break; + } + } + return index; + } + + private static boolean add( + List tokens, + int start, + int end, + SyntaxTokenType type, + int tokenLimit + ) { + return TokenizerSupport.add(tokens, start, end, type, tokenLimit); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/JsonSyntaxTokenizer.java b/app/src/main/java/com/maxistar/textpad/syntax/JsonSyntaxTokenizer.java new file mode 100644 index 0000000..0a57a06 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/JsonSyntaxTokenizer.java @@ -0,0 +1,73 @@ +package com.maxistar.textpad.syntax; + +import java.util.ArrayList; +import java.util.List; + +public final class JsonSyntaxTokenizer implements SyntaxTokenizer { + @Override + public SyntaxTokenizationResult tokenize(String text, int tokenLimit) + throws InterruptedException { + List tokens = new ArrayList<>(); + int index = 0; + while (index < text.length()) { + TokenizerSupport.checkInterrupted(index); + char current = text.charAt(index); + if (current == '"') { + int end = TokenizerSupport.scanQuoted(text, index, '"'); + int next = skipWhitespace(text, end); + SyntaxTokenType type = next < text.length() && text.charAt(next) == ':' + ? SyntaxTokenType.PROPERTY : SyntaxTokenType.STRING; + if (!TokenizerSupport.add(tokens, index, end, type, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if (current == '-' || Character.isDigit(current)) { + int end = TokenizerSupport.scanNumber(text, index); + if (!TokenizerSupport.add(tokens, index, end, SyntaxTokenType.NUMBER, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if (startsWord(text, index, "true") + || startsWord(text, index, "false") + || startsWord(text, index, "null")) { + int end = scanIdentifier(text, index); + if (!TokenizerSupport.add(tokens, index, end, SyntaxTokenType.LITERAL, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else if ("{}[],:".indexOf(current) >= 0) { + if (!TokenizerSupport.add( + tokens, index, index + 1, SyntaxTokenType.PUNCTUATION, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index++; + } else { + index++; + } + } + return SyntaxTokenizationResult.success(tokens); + } + + private static int skipWhitespace(String text, int start) { + int index = start; + while (index < text.length() && Character.isWhitespace(text.charAt(index))) { + index++; + } + return index; + } + + private static boolean startsWord(String text, int start, String word) { + int end = start + word.length(); + return end <= text.length() + && text.regionMatches(start, word, 0, word.length()) + && (end == text.length() || !Character.isJavaIdentifierPart(text.charAt(end))); + } + + private static int scanIdentifier(String text, int start) { + int index = start; + while (index < text.length() && Character.isJavaIdentifierPart(text.charAt(index))) { + index++; + } + return index; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/LanguageDetector.java b/app/src/main/java/com/maxistar/textpad/syntax/LanguageDetector.java new file mode 100644 index 0000000..88ac258 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/LanguageDetector.java @@ -0,0 +1,27 @@ +package com.maxistar.textpad.syntax; + +import java.util.Locale; + +public final class LanguageDetector { + public LanguageMode detect(String displayName) { + if (displayName == null) { + return LanguageMode.PLAIN_TEXT; + } + String name = displayName.trim().toLowerCase(Locale.ROOT); + if (name.endsWith(".json")) { + return LanguageMode.JSON; + } + if (name.endsWith(".md") || name.endsWith(".markdown")) { + return LanguageMode.MARKDOWN; + } + if (name.endsWith(".js") || name.endsWith(".mjs") || name.endsWith(".cjs")) { + return LanguageMode.JAVASCRIPT; + } + return LanguageMode.PLAIN_TEXT; + } + + public LanguageMode resolve(LanguageMode selectedMode, String displayName) { + return selectedMode == null || selectedMode == LanguageMode.AUTO + ? detect(displayName) : selectedMode; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/LanguageMode.java b/app/src/main/java/com/maxistar/textpad/syntax/LanguageMode.java new file mode 100644 index 0000000..694bc10 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/LanguageMode.java @@ -0,0 +1,9 @@ +package com.maxistar.textpad.syntax; + +public enum LanguageMode { + AUTO, + PLAIN_TEXT, + JSON, + MARKDOWN, + JAVASCRIPT +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/MarkdownSyntaxTokenizer.java b/app/src/main/java/com/maxistar/textpad/syntax/MarkdownSyntaxTokenizer.java new file mode 100644 index 0000000..391c65a --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/MarkdownSyntaxTokenizer.java @@ -0,0 +1,92 @@ +package com.maxistar.textpad.syntax; + +import java.util.ArrayList; +import java.util.List; + +public final class MarkdownSyntaxTokenizer implements SyntaxTokenizer { + @Override + public SyntaxTokenizationResult tokenize(String text, int tokenLimit) + throws InterruptedException { + List tokens = new ArrayList<>(); + int index = 0; + boolean lineStart = true; + while (index < text.length()) { + TokenizerSupport.checkInterrupted(index); + if (text.startsWith("```", index)) { + int end = text.indexOf("```", index + 3); + end = end < 0 ? text.length() : end + 3; + if (!TokenizerSupport.add(tokens, index, end, SyntaxTokenType.CODE, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + lineStart = end > 0 && text.charAt(end - 1) == '\n'; + index = end; + } else if (lineStart && text.charAt(index) == '#') { + int end = index; + while (end < text.length() && text.charAt(end) == '#') { + end++; + } + if (end < text.length() && text.charAt(end) == ' ') { + end = lineEnd(text, end); + if (!TokenizerSupport.add( + tokens, index, end, SyntaxTokenType.HEADING, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + } else { + index++; + } + lineStart = false; + } else if (text.charAt(index) == '`') { + int end = text.indexOf('`', index + 1); + end = end < 0 ? text.length() : end + 1; + if (!TokenizerSupport.add(tokens, index, end, SyntaxTokenType.CODE, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + lineStart = false; + } else if (text.charAt(index) == '[') { + int labelEnd = text.indexOf(']', index + 1); + int linkEnd = labelEnd >= 0 && labelEnd + 1 < text.length() + && text.charAt(labelEnd + 1) == '(' + ? text.indexOf(')', labelEnd + 2) : -1; + if (linkEnd >= 0) { + if (!TokenizerSupport.add( + tokens, index, linkEnd + 1, SyntaxTokenType.LINK, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = linkEnd + 1; + lineStart = false; + } else { + index++; + } + } else if (text.charAt(index) == '*' || text.charAt(index) == '_') { + char marker = text.charAt(index); + int markerLength = index + 1 < text.length() + && text.charAt(index + 1) == marker ? 2 : 1; + String closing = markerLength == 2 + ? new String(new char[]{marker, marker}) : String.valueOf(marker); + int end = text.indexOf(closing, index + markerLength); + if (end >= 0) { + end += markerLength; + if (!TokenizerSupport.add( + tokens, index, end, SyntaxTokenType.EMPHASIS, tokenLimit)) { + return SyntaxTokenizationResult.limitExceeded(); + } + index = end; + lineStart = false; + } else { + index++; + } + } else { + lineStart = text.charAt(index) == '\n'; + index++; + } + } + return SyntaxTokenizationResult.success(tokens); + } + + private static int lineEnd(String text, int start) { + int end = text.indexOf('\n', start); + return end < 0 ? text.length() : end; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/PlainTextTokenizer.java b/app/src/main/java/com/maxistar/textpad/syntax/PlainTextTokenizer.java new file mode 100644 index 0000000..43837e5 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/PlainTextTokenizer.java @@ -0,0 +1,10 @@ +package com.maxistar.textpad.syntax; + +import java.util.Collections; + +public final class PlainTextTokenizer implements SyntaxTokenizer { + @Override + public SyntaxTokenizationResult tokenize(String text, int tokenLimit) { + return SyntaxTokenizationResult.success(Collections.emptyList()); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxGeneration.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxGeneration.java new file mode 100644 index 0000000..d808bdb --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxGeneration.java @@ -0,0 +1,17 @@ +package com.maxistar.textpad.syntax; + +public final class SyntaxGeneration { + private long current; + + public synchronized long next() { + return ++current; + } + + public synchronized long get() { + return current; + } + + public synchronized boolean isCurrent(long generation) { + return current == generation; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxHighlightController.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxHighlightController.java new file mode 100644 index 0000000..8b1b69e --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxHighlightController.java @@ -0,0 +1,295 @@ +package com.maxistar.textpad.syntax; + +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.Spanned; +import android.widget.EditText; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +public final class SyntaxHighlightController { + public interface Listener { + void onDocumentTooLarge(); + } + + public static final int DOCUMENT_CHARACTER_LIMIT = 256_000; + static final int DEFAULT_TOKEN_LIMIT = 20_000; + static final int DEFAULT_BATCH_SIZE = 200; + static final long DEFAULT_DEBOUNCE_MILLIS = 250; + + private final EditText editor; + private final Handler mainHandler; + private final ExecutorService executor; + private final SyntaxTokenizerRegistry registry; + private final LanguageDetector detector; + private final Listener listener; + private final SyntaxGeneration generations = new SyntaxGeneration(); + private final int tokenLimit; + private final int batchSize; + private final long debounceMillis; + + private boolean enabled; + private boolean active; + private boolean limitReported; + private LanguageMode selectedMode = LanguageMode.AUTO; + private String displayName; + private SyntaxPalette palette = SyntaxPalette.light(); + private Runnable pendingDebounce; + private Future pendingTokenization; + + public SyntaxHighlightController(EditText editor, Listener listener) { + this( + editor, + listener, + new Handler(Looper.getMainLooper()), + Executors.newSingleThreadExecutor(), + new SyntaxTokenizerRegistry(), + new LanguageDetector(), + DEFAULT_DEBOUNCE_MILLIS, + DEFAULT_TOKEN_LIMIT, + DEFAULT_BATCH_SIZE + ); + } + + SyntaxHighlightController( + EditText editor, + Listener listener, + Handler mainHandler, + ExecutorService executor, + SyntaxTokenizerRegistry registry, + LanguageDetector detector, + long debounceMillis, + int tokenLimit, + int batchSize + ) { + this.editor = editor; + this.listener = listener; + this.mainHandler = mainHandler; + this.executor = executor; + this.registry = registry; + this.detector = detector; + this.debounceMillis = debounceMillis; + this.tokenLimit = tokenLimit; + this.batchSize = batchSize; + } + + public void start() { + active = true; + requestHighlight(); + } + + public void stop() { + active = false; + cancelPendingWork(); + } + + public void destroy() { + stop(); + executor.shutdownNow(); + } + + public void setEnabled(boolean enabled) { + if (this.enabled == enabled) { + return; + } + this.enabled = enabled; + invalidateAndClear(); + if (enabled) { + requestHighlight(); + } + } + + public void setLanguageMode(LanguageMode mode) { + LanguageMode nextMode = mode == null ? LanguageMode.AUTO : mode; + if (selectedMode == nextMode) { + return; + } + selectedMode = nextMode; + invalidateAndClear(); + requestHighlight(); + } + + public LanguageMode getLanguageMode() { + return selectedMode; + } + + public LanguageMode getResolvedLanguageMode() { + return detector.resolve(selectedMode, displayName); + } + + public void setDisplayName(String displayName) { + String nextName = displayName == null ? "" : displayName; + if (nextName.equals(this.displayName)) { + return; + } + this.displayName = nextName; + limitReported = false; + invalidateAndClear(); + requestHighlight(); + } + + public void resetDocument(String displayName) { + selectedMode = LanguageMode.AUTO; + limitReported = false; + this.displayName = displayName == null ? "" : displayName; + invalidateAndClear(); + requestHighlight(); + } + + public void setPalette(SyntaxPalette palette) { + if (palette == null || this.palette == palette) { + return; + } + this.palette = palette; + invalidateAndClear(); + requestHighlight(); + } + + public void onTextChanged() { + invalidateAndClear(); + requestHighlight(); + } + + public void requestHighlight() { + if (!active || !enabled) { + return; + } + cancelPendingWork(); + final long generation = generations.next(); + removeSyntaxSpans(editor.getText(), null); + + final String snapshot = editor.getText().toString(); + if (snapshot.length() > DOCUMENT_CHARACTER_LIMIT) { + reportLimitOnce(); + return; + } + final LanguageMode resolvedMode = detector.resolve(selectedMode, displayName); + if (resolvedMode == LanguageMode.PLAIN_TEXT) { + return; + } + + pendingDebounce = () -> { + if (!isCurrent(generation)) { + return; + } + pendingTokenization = executor.submit( + () -> tokenize(snapshot, resolvedMode, generation)); + }; + mainHandler.postDelayed(pendingDebounce, debounceMillis); + } + + private void tokenize(String snapshot, LanguageMode mode, long generation) { + try { + SyntaxTokenizationResult result = registry.get(mode).tokenize(snapshot, tokenLimit); + if (Thread.currentThread().isInterrupted()) { + return; + } + mainHandler.post(() -> render(result, snapshot.length(), generation)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (RuntimeException failure) { + mainHandler.post(() -> clearFailedGeneration(generation)); + } + } + + private void render( + SyntaxTokenizationResult result, + int snapshotLength, + long generation + ) { + if (!isCurrent(generation)) { + removeSyntaxSpans(editor.getText(), generation); + return; + } + Editable editable = editor.getText(); + if (result.isTokenLimitExceeded() || editable.length() != snapshotLength) { + removeSyntaxSpans(editable, null); + return; + } + removeSyntaxSpans(editable, null); + renderBatch(result.getTokens(), 0, generation, snapshotLength); + } + + private void renderBatch( + List tokens, + int startIndex, + long generation, + int snapshotLength + ) { + if (!isCurrent(generation) || editor.getText().length() != snapshotLength) { + removeSyntaxSpans(editor.getText(), generation); + return; + } + Editable editable = editor.getText(); + int endIndex = Math.min(startIndex + batchSize, tokens.size()); + try { + for (int index = startIndex; index < endIndex; index++) { + SyntaxToken token = tokens.get(index); + if (token.getEnd() <= editable.length()) { + editable.setSpan( + new SyntaxSpan(palette.colorFor(token.getType()), generation), + token.getStart(), + token.getEnd(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + } + } + } catch (RuntimeException renderingFailure) { + removeSyntaxSpans(editable, generation); + return; + } + if (endIndex < tokens.size()) { + mainHandler.post( + () -> renderBatch(tokens, endIndex, generation, snapshotLength)); + } + } + + private void clearFailedGeneration(long generation) { + if (generations.isCurrent(generation)) { + removeSyntaxSpans(editor.getText(), null); + } else { + removeSyntaxSpans(editor.getText(), generation); + } + } + + private boolean isCurrent(long generation) { + return active && enabled && generations.isCurrent(generation); + } + + private void reportLimitOnce() { + if (!limitReported && listener != null) { + limitReported = true; + listener.onDocumentTooLarge(); + } + } + + private void invalidateAndClear() { + generations.next(); + cancelPendingWork(); + removeSyntaxSpans(editor.getText(), null); + } + + private void cancelPendingWork() { + if (pendingDebounce != null) { + mainHandler.removeCallbacks(pendingDebounce); + pendingDebounce = null; + } + if (pendingTokenization != null) { + pendingTokenization.cancel(true); + pendingTokenization = null; + } + } + + static void removeSyntaxSpans(Editable editable, Long generation) { + SyntaxSpan[] spans = editable.getSpans(0, editable.length(), SyntaxSpan.class); + for (SyntaxSpan span : spans) { + if (generation == null || span.getGeneration() == generation) { + editable.removeSpan(span); + } + } + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxPalette.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxPalette.java new file mode 100644 index 0000000..db77b3a --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxPalette.java @@ -0,0 +1,76 @@ +package com.maxistar.textpad.syntax; + +import java.util.EnumMap; +import java.util.Map; + +public final class SyntaxPalette { + private static final SyntaxPalette LIGHT = new SyntaxPalette( + 0xff6a737d, 0xff0b6e4f, 0xff9c3d10, 0xff7b2cbf, 0xff005cc5, + 0xff8a2c0d, 0xff5b3cc4, 0xff7b2cbf, 0xff005cc5, 0xff8a2c0d, + 0xff6f42c1, 0xff24292e + ); + private static final SyntaxPalette DARK = new SyntaxPalette( + 0xff8b949e, 0xff7ee787, 0xffffa657, 0xffd2a8ff, 0xff79c0ff, + 0xffffa198, 0xffa5d6ff, 0xffd2a8ff, 0xff79c0ff, 0xffffa198, + 0xffd2a8ff, 0xffc9d1d9 + ); + + private final Map colors = + new EnumMap<>(SyntaxTokenType.class); + + private SyntaxPalette( + int comment, + int string, + int number, + int keyword, + int literal, + int property, + int punctuation, + int heading, + int link, + int emphasis, + int code, + int identifier + ) { + colors.put(SyntaxTokenType.COMMENT, comment); + colors.put(SyntaxTokenType.STRING, string); + colors.put(SyntaxTokenType.NUMBER, number); + colors.put(SyntaxTokenType.KEYWORD, keyword); + colors.put(SyntaxTokenType.LITERAL, literal); + colors.put(SyntaxTokenType.PROPERTY, property); + colors.put(SyntaxTokenType.PUNCTUATION, punctuation); + colors.put(SyntaxTokenType.HEADING, heading); + colors.put(SyntaxTokenType.LINK, link); + colors.put(SyntaxTokenType.EMPHASIS, emphasis); + colors.put(SyntaxTokenType.CODE, code); + colors.put(SyntaxTokenType.IDENTIFIER, identifier); + } + + public static SyntaxPalette light() { + return LIGHT; + } + + public static SyntaxPalette dark() { + return DARK; + } + + public static SyntaxPalette forBackground(int color) { + double red = linear((color >> 16) & 0xff); + double green = linear((color >> 8) & 0xff); + double blue = linear(color & 0xff); + double luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + return luminance > 0.179 ? LIGHT : DARK; + } + + public int colorFor(SyntaxTokenType type) { + Integer color = colors.get(type); + return color == null ? colors.get(SyntaxTokenType.IDENTIFIER) : color; + } + + private static double linear(int component) { + double value = component / 255.0; + return value <= 0.03928 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxSpan.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxSpan.java new file mode 100644 index 0000000..4a28ff2 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxSpan.java @@ -0,0 +1,17 @@ +package com.maxistar.textpad.syntax; + +import android.text.NoCopySpan; +import android.text.style.ForegroundColorSpan; + +public final class SyntaxSpan extends ForegroundColorSpan implements NoCopySpan { + private final long generation; + + public SyntaxSpan(int color, long generation) { + super(color); + this.generation = generation; + } + + public long getGeneration() { + return generation; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxToken.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxToken.java new file mode 100644 index 0000000..99ec2d2 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxToken.java @@ -0,0 +1,52 @@ +package com.maxistar.textpad.syntax; + +import java.util.Objects; + +public final class SyntaxToken { + private final int start; + private final int end; + private final SyntaxTokenType type; + + public SyntaxToken(int start, int end, SyntaxTokenType type) { + if (start < 0 || end < start) { + throw new IllegalArgumentException("Invalid token range"); + } + this.start = start; + this.end = end; + this.type = Objects.requireNonNull(type); + } + + public int getStart() { + return start; + } + + public int getEnd() { + return end; + } + + public SyntaxTokenType getType() { + return type; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof SyntaxToken)) { + return false; + } + SyntaxToken token = (SyntaxToken) other; + return start == token.start && end == token.end && type == token.type; + } + + @Override + public int hashCode() { + return Objects.hash(start, end, type); + } + + @Override + public String toString() { + return type + "[" + start + "," + end + ")"; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenType.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenType.java new file mode 100644 index 0000000..1652b6b --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenType.java @@ -0,0 +1,16 @@ +package com.maxistar.textpad.syntax; + +public enum SyntaxTokenType { + COMMENT, + STRING, + NUMBER, + KEYWORD, + LITERAL, + IDENTIFIER, + PROPERTY, + PUNCTUATION, + HEADING, + LINK, + EMPHASIS, + CODE +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizationResult.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizationResult.java new file mode 100644 index 0000000..473c1a4 --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizationResult.java @@ -0,0 +1,31 @@ +package com.maxistar.textpad.syntax; + +import java.util.Collections; +import java.util.ArrayList; +import java.util.List; + +public final class SyntaxTokenizationResult { + private final List tokens; + private final boolean tokenLimitExceeded; + + private SyntaxTokenizationResult(List tokens, boolean tokenLimitExceeded) { + this.tokens = Collections.unmodifiableList(tokens); + this.tokenLimitExceeded = tokenLimitExceeded; + } + + public static SyntaxTokenizationResult success(List tokens) { + return new SyntaxTokenizationResult(new ArrayList<>(tokens), false); + } + + public static SyntaxTokenizationResult limitExceeded() { + return new SyntaxTokenizationResult(Collections.emptyList(), true); + } + + public List getTokens() { + return tokens; + } + + public boolean isTokenLimitExceeded() { + return tokenLimitExceeded; + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizer.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizer.java new file mode 100644 index 0000000..bca01cb --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizer.java @@ -0,0 +1,5 @@ +package com.maxistar.textpad.syntax; + +public interface SyntaxTokenizer { + SyntaxTokenizationResult tokenize(String text, int tokenLimit) throws InterruptedException; +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizerRegistry.java b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizerRegistry.java new file mode 100644 index 0000000..22762ab --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/SyntaxTokenizerRegistry.java @@ -0,0 +1,25 @@ +package com.maxistar.textpad.syntax; + +import java.util.EnumMap; +import java.util.Map; + +public final class SyntaxTokenizerRegistry { + private final Map tokenizers = + new EnumMap<>(LanguageMode.class); + + public SyntaxTokenizerRegistry() { + tokenizers.put(LanguageMode.PLAIN_TEXT, new PlainTextTokenizer()); + tokenizers.put(LanguageMode.JSON, new JsonSyntaxTokenizer()); + tokenizers.put(LanguageMode.MARKDOWN, new MarkdownSyntaxTokenizer()); + tokenizers.put(LanguageMode.JAVASCRIPT, new JavaScriptSyntaxTokenizer()); + } + + public SyntaxTokenizer get(LanguageMode mode) { + SyntaxTokenizer tokenizer = tokenizers.get(mode); + return tokenizer == null ? tokenizers.get(LanguageMode.PLAIN_TEXT) : tokenizer; + } + + void register(LanguageMode mode, SyntaxTokenizer tokenizer) { + tokenizers.put(mode, tokenizer); + } +} diff --git a/app/src/main/java/com/maxistar/textpad/syntax/TokenizerSupport.java b/app/src/main/java/com/maxistar/textpad/syntax/TokenizerSupport.java new file mode 100644 index 0000000..ee405ed --- /dev/null +++ b/app/src/main/java/com/maxistar/textpad/syntax/TokenizerSupport.java @@ -0,0 +1,74 @@ +package com.maxistar.textpad.syntax; + +import java.util.List; + +final class TokenizerSupport { + private TokenizerSupport() { + } + + static boolean add( + List tokens, + int start, + int end, + SyntaxTokenType type, + int tokenLimit + ) { + if (start >= end) { + return true; + } + if (tokens.size() >= tokenLimit) { + return false; + } + tokens.add(new SyntaxToken(start, end, type)); + return true; + } + + static int scanQuoted(String text, int start, char quote) throws InterruptedException { + int index = start + 1; + boolean escaped = false; + while (index < text.length()) { + checkInterrupted(index); + char current = text.charAt(index++); + if (escaped) { + escaped = false; + } else if (current == '\\') { + escaped = true; + } else if (current == quote) { + break; + } + } + return index; + } + + static int scanNumber(String text, int start) { + int index = start; + if (index < text.length() && (text.charAt(index) == '-' || text.charAt(index) == '+')) { + index++; + } + while (index < text.length() && Character.isDigit(text.charAt(index))) { + index++; + } + if (index < text.length() && text.charAt(index) == '.') { + index++; + while (index < text.length() && Character.isDigit(text.charAt(index))) { + index++; + } + } + if (index < text.length() && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) { + index++; + if (index < text.length() && (text.charAt(index) == '-' || text.charAt(index) == '+')) { + index++; + } + while (index < text.length() && Character.isDigit(text.charAt(index))) { + index++; + } + } + return index; + } + + static void checkInterrupted(int index) throws InterruptedException { + if ((index & 255) == 0 && Thread.currentThread().isInterrupted()) { + throw new InterruptedException("Syntax tokenization interrupted"); + } + } +} diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index 6644248..196d0bd 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -95,6 +95,29 @@ android:title="@string/Menu_Print" android:icon="@drawable/ic_print" app:iconTint="@color/colorIcon" /> + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8661048..5d2c19a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -114,4 +114,13 @@ المتابعة في وضع القراءة فقط + تمييز بناء الجملة + تلوين بناء جملة JSON وMarkdown وJavaScript + لغة بناء الجملة + تلقائي + نص عادي + JSON + Markdown + JavaScript + تم تخطي تمييز بناء الجملة لأن هذا المستند كبير جدًا. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7aa4b14..6b2d9fc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -113,4 +113,13 @@ Im Nur-Lese-Modus fortfahren + Syntaxhervorhebung + JSON-, Markdown- und JavaScript-Syntax farbig darstellen + Syntaxsprache + Automatisch + Nur Text + JSON + Markdown + JavaScript + Die Syntaxhervorhebung wurde übersprungen, weil dieses Dokument zu groß ist. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 00ccd0c..55031b2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -111,4 +111,13 @@ Continuar en modo solo lectura Corregir desplazamiento errático Activa esto si el texto salta inesperadamente al final durante el desplazamiento + Resaltado de sintaxis + Colorear la sintaxis de JSON, Markdown y JavaScript + Lenguaje de sintaxis + Automático + Texto sin formato + JSON + Markdown + JavaScript + Se omitió el resaltado de sintaxis porque este documento es demasiado grande. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index cbd983a..4a8b99e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -111,4 +111,13 @@ Continuer en lecture seule Corriger le défilement erratique Activez ceci si le texte saute de manière inattendue à la fin pendant le défilement + Coloration syntaxique + Colorer la syntaxe JSON, Markdown et JavaScript + Langage de syntaxe + Automatique + Texte brut + JSON + Markdown + JavaScript + La coloration syntaxique a été ignorée car ce document est trop volumineux. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b291d7e..dec9eb4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -111,4 +111,13 @@ Continua in sola lettura Correggi scorrimento irregolare Attiva questa opzione se il testo salta inaspettatamente alla fine durante lo scorrimento + Evidenziazione della sintassi + Colora la sintassi JSON, Markdown e JavaScript + Linguaggio della sintassi + Automatico + Testo semplice + JSON + Markdown + JavaScript + L\'evidenziazione della sintassi è stata ignorata perché il documento è troppo grande. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fd304d5..cef0e35 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -111,4 +111,13 @@ 読み取り専用で続行 不安定なスクロールを修正 スクロール中にテキストが予期せず最後までジャンプする場合に有効にしてください + 構文の強調表示 + JSON、Markdown、JavaScript の構文を色分けします + 構文言語 + 自動 + プレーンテキスト + JSON + Markdown + JavaScript + ドキュメントが大きすぎるため、構文の強調表示を省略しました。 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index caa7823..9bde9cd 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -113,4 +113,13 @@ Kontynuuj w trybie tylko do odczytu + Podświetlanie składni + Kolorowanie składni JSON, Markdown i JavaScript + Język składni + Automatycznie + Zwykły tekst + JSON + Markdown + JavaScript + Pominięto podświetlanie składni, ponieważ dokument jest zbyt duży. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 91a12c9..7476ddd 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -113,4 +113,13 @@ Continuar em modo somente leitura + Realce de sintaxe + Colorir a sintaxe de JSON, Markdown e JavaScript + Linguagem de sintaxe + Automático + Texto simples + JSON + Markdown + JavaScript + O realce de sintaxe foi ignorado porque este documento é muito grande. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index aeef1a5..b42d9c6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -111,4 +111,13 @@ Продолжить в режиме только для чтения Исправить нестабильную прокрутку Включите, если текст неожиданно перепрыгивает в конец при прокрутке + Подсветка синтаксиса + Подсвечивать синтаксис JSON, Markdown и JavaScript + Язык синтаксиса + Автоматически + Обычный текст + JSON + Markdown + JavaScript + Подсветка синтаксиса отключена, потому что документ слишком большой. diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 33a912b..cdfec2f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -114,4 +114,13 @@ Salt okunur olarak devam et + Sözdizimi vurgulama + JSON, Markdown ve JavaScript sözdizimini renklendir + Sözdizimi dili + Otomatik + Düz metin + JSON + Markdown + JavaScript + Bu belge çok büyük olduğu için sözdizimi vurgulama atlandı. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index b4c1183..5b37ca5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -109,4 +109,13 @@ Продовжити в режимі лише для читання + Підсвічування синтаксису + Підсвічувати синтаксис JSON, Markdown і JavaScript + Мова синтаксису + Автоматично + Звичайний текст + JSON + Markdown + JavaScript + Підсвічування синтаксису пропущено, оскільки документ завеликий. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2339f3a..d83b01b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -107,4 +107,13 @@ 以只读模式继续 修复滚动问题 如果文本在滚动时意外跳转到末尾,请启用此选项 + 语法高亮 + 为 JSON、Markdown 和 JavaScript 语法着色 + 语法语言 + 自动 + 纯文本 + JSON + Markdown + JavaScript + 此文档过大,已跳过语法高亮。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 62fda5f..01df810 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -106,4 +106,13 @@ 修正捲動問題 如果文字在捲動時意外跳到末尾,請啟用此選項 自動換行 + 語法醒目提示 + 為 JSON、Markdown 和 JavaScript 語法著色 + 語法語言 + 自動 + 純文字 + JSON + Markdown + JavaScript + 此文件過大,已略過語法醒目提示。 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 2339f3a..d83b01b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -107,4 +107,13 @@ 以只读模式继续 修复滚动问题 如果文本在滚动时意外跳转到末尾,请启用此选项 + 语法高亮 + 为 JSON、Markdown 和 JavaScript 语法着色 + 语法语言 + 自动 + 纯文本 + JSON + Markdown + JavaScript + 此文档过大,已跳过语法高亮。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87d74d6..5ea322f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,6 +108,15 @@ Prevent device going to sleep mode Fix Erratic Scrolling Enable this if text unexpectedly jumps to the end while scrolling + Syntax highlighting + Color JSON, Markdown, and JavaScript syntax + Syntax language + Auto + Plain text + JSON + Markdown + JavaScript + Syntax highlighting was skipped because this document is too large. This file is open in read only mode. You can save it with a different name, or you can open it again using \'Open\' command from the application menu. diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 7001a6e..54f9727 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -80,6 +80,12 @@ android:defaultValue="Medium" android:dialogTitle="@string/Choose_a_font_size" /> + + token.getStart() >= regexStart && token.getEnd() <= regexEnd)); + int returnedRegexStart = source.indexOf("/word+/"); + int returnedRegexEnd = returnedRegexStart + "/word+/".length(); + assertFalse(result.getTokens().stream().anyMatch( + token -> token.getStart() >= returnedRegexStart + && token.getEnd() <= returnedRegexEnd)); + + int interpolation = source.indexOf("${name}"); + assertTrue(result.getTokens().stream().anyMatch(token -> + token.getType() == SyntaxTokenType.STRING + && token.getStart() < interpolation + && token.getEnd() > interpolation)); + } + + @Test + void acceptsUnterminatedStringsAndComments() throws Exception { + String source = "/* unfinished\nconst value = \"unfinished"; + SyntaxTokenizationResult result = tokenizer.tokenize(source, 100); + TokenizerAssertions.assertValid(source, result.getTokens()); + } + + private static boolean has(SyntaxTokenizationResult result, SyntaxTokenType type) { + return result.getTokens().stream().anyMatch(token -> token.getType() == type); + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/JsonSyntaxTokenizerTest.java b/app/src/test/java/com/maxistar/textpad/syntax/JsonSyntaxTokenizerTest.java new file mode 100644 index 0000000..710b32a --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/JsonSyntaxTokenizerTest.java @@ -0,0 +1,36 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class JsonSyntaxTokenizerTest { + private final JsonSyntaxTokenizer tokenizer = new JsonSyntaxTokenizer(); + + @Test + void tokenizesPropertiesStringsNumbersLiteralsAndPunctuation() throws Exception { + String source = "{\"name\":\"a\\\"b\",\"count\":-1.5e2,\"enabled\":true,\"value\":null}"; + SyntaxTokenizationResult result = tokenizer.tokenize(source, 100); + + TokenizerAssertions.assertValid(source, result.getTokens()); + assertTrue(result.getTokens().stream().anyMatch(t -> t.getType() == SyntaxTokenType.PROPERTY)); + assertTrue(result.getTokens().stream().anyMatch(t -> t.getType() == SyntaxTokenType.STRING)); + assertTrue(result.getTokens().stream().anyMatch(t -> t.getType() == SyntaxTokenType.NUMBER)); + assertTrue(result.getTokens().stream().anyMatch(t -> t.getType() == SyntaxTokenType.LITERAL)); + } + + @Test + void acceptsIncompleteAndUnexpectedInput() throws Exception { + String source = "{\"name\":\"unfinished\n???"; + SyntaxTokenizationResult result = tokenizer.tokenize(source, 100); + TokenizerAssertions.assertValid(source, result.getTokens()); + } + + @Test + void discardsAllTokensAtLimit() throws Exception { + SyntaxTokenizationResult result = tokenizer.tokenize("[1,2,3]", 2); + assertTrue(result.isTokenLimitExceeded()); + assertEquals(0, result.getTokens().size()); + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/LanguageDetectorTest.java b/app/src/test/java/com/maxistar/textpad/syntax/LanguageDetectorTest.java new file mode 100644 index 0000000..978c543 --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/LanguageDetectorTest.java @@ -0,0 +1,32 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class LanguageDetectorTest { + private final LanguageDetector detector = new LanguageDetector(); + + @Test + void detectsSupportedExtensionsCaseInsensitively() { + assertEquals(LanguageMode.JSON, detector.detect("settings.JSON")); + assertEquals(LanguageMode.MARKDOWN, detector.detect("/notes/readme.MarkDown")); + assertEquals(LanguageMode.JAVASCRIPT, detector.detect("module.MJS")); + assertEquals(LanguageMode.JAVASCRIPT, detector.detect("script.cjs")); + } + + @Test + void unknownOrMissingNamesUsePlainText() { + assertEquals(LanguageMode.PLAIN_TEXT, detector.detect("notes.txt")); + assertEquals(LanguageMode.PLAIN_TEXT, detector.detect("")); + assertEquals(LanguageMode.PLAIN_TEXT, detector.detect(null)); + } + + @Test + void manualModeOverridesDetection() { + assertEquals(LanguageMode.MARKDOWN, + detector.resolve(LanguageMode.MARKDOWN, "settings.json")); + assertEquals(LanguageMode.JSON, detector.resolve(LanguageMode.AUTO, "settings.json")); + assertEquals(LanguageMode.JSON, detector.resolve(null, "settings.json")); + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/MarkdownSyntaxTokenizerTest.java b/app/src/test/java/com/maxistar/textpad/syntax/MarkdownSyntaxTokenizerTest.java new file mode 100644 index 0000000..91e7321 --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/MarkdownSyntaxTokenizerTest.java @@ -0,0 +1,33 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class MarkdownSyntaxTokenizerTest { + private final MarkdownSyntaxTokenizer tokenizer = new MarkdownSyntaxTokenizer(); + + @Test + void tokenizesCoreMarkdownStructures() throws Exception { + String source = "# Heading\n**bold** [link](https://example.com) `code`\n```\nblock\n```"; + SyntaxTokenizationResult result = tokenizer.tokenize(source, 100); + + TokenizerAssertions.assertValid(source, result.getTokens()); + assertTrue(has(result, SyntaxTokenType.HEADING)); + assertTrue(has(result, SyntaxTokenType.EMPHASIS)); + assertTrue(has(result, SyntaxTokenType.LINK)); + assertTrue(has(result, SyntaxTokenType.CODE)); + } + + @Test + void acceptsIncompleteMultilineConstructs() throws Exception { + String source = "# Heading\n```\nunfinished\n*also unfinished"; + SyntaxTokenizationResult result = tokenizer.tokenize(source, 100); + TokenizerAssertions.assertValid(source, result.getTokens()); + assertTrue(has(result, SyntaxTokenType.CODE)); + } + + private static boolean has(SyntaxTokenizationResult result, SyntaxTokenType type) { + return result.getTokens().stream().anyMatch(token -> token.getType() == type); + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/SyntaxGenerationTest.java b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxGenerationTest.java new file mode 100644 index 0000000..e2ba324 --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxGenerationTest.java @@ -0,0 +1,18 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class SyntaxGenerationTest { + @Test + void onlyLatestGenerationIsCurrent() { + SyntaxGeneration generations = new SyntaxGeneration(); + long first = generations.next(); + long second = generations.next(); + + assertFalse(generations.isCurrent(first)); + assertTrue(generations.isCurrent(second)); + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/SyntaxPaletteTest.java b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxPaletteTest.java new file mode 100644 index 0000000..1591c6b --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxPaletteTest.java @@ -0,0 +1,13 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.junit.jupiter.api.Test; + +class SyntaxPaletteTest { + @Test + void choosesPaletteFromBackgroundLuminance() { + assertSame(SyntaxPalette.light(), SyntaxPalette.forBackground(0xffffffff)); + assertSame(SyntaxPalette.dark(), SyntaxPalette.forBackground(0xff000000)); + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/SyntaxPerformanceFixtureTest.java b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxPerformanceFixtureTest.java new file mode 100644 index 0000000..61211c2 --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxPerformanceFixtureTest.java @@ -0,0 +1,37 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTimeout; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +class SyntaxPerformanceFixtureTest { + @Test + void highTokenCountEligibleDocumentCompletesWithinFixtureBudget() { + StringBuilder source = new StringBuilder("["); + for (int index = 0; index < 5_000; index++) { + source.append("{\"value\":").append(index).append("},"); + } + source.append("{}]"); + + assertTimeout(Duration.ofSeconds(2), () -> { + SyntaxTokenizationResult result = + new JsonSyntaxTokenizer().tokenize(source.toString(), 40_000); + assertFalse(result.isTokenLimitExceeded()); + assertTrue(result.getTokens().size() > 20_000); + }); + } + + @Test + void repeatedGenerationInvalidationKeepsOnlyNewestGeneration() { + SyntaxGeneration generations = new SyntaxGeneration(); + long newest = 0; + for (int index = 0; index < 10_000; index++) { + newest = generations.next(); + } + assertTrue(generations.isCurrent(newest)); + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/SyntaxTokenizerContractTest.java b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxTokenizerContractTest.java new file mode 100644 index 0000000..47da246 --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/SyntaxTokenizerContractTest.java @@ -0,0 +1,19 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class SyntaxTokenizerContractTest { + @Test + void tokenizerHonorsThreadInterruption() { + Thread.currentThread().interrupt(); + try { + String source = new String(new char[1024]).replace('\0', ' '); + assertThrows(InterruptedException.class, + () -> new JsonSyntaxTokenizer().tokenize(source, 100)); + } finally { + Thread.interrupted(); + } + } +} diff --git a/app/src/test/java/com/maxistar/textpad/syntax/TokenizerAssertions.java b/app/src/test/java/com/maxistar/textpad/syntax/TokenizerAssertions.java new file mode 100644 index 0000000..2917a94 --- /dev/null +++ b/app/src/test/java/com/maxistar/textpad/syntax/TokenizerAssertions.java @@ -0,0 +1,20 @@ +package com.maxistar.textpad.syntax; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +final class TokenizerAssertions { + private TokenizerAssertions() { + } + + static void assertValid(String source, List tokens) { + int previousEnd = 0; + for (SyntaxToken token : tokens) { + assertTrue(token.getStart() >= previousEnd, token.toString()); + assertTrue(token.getEnd() > token.getStart(), token.toString()); + assertTrue(token.getEnd() <= source.length(), token.toString()); + previousEnd = token.getEnd(); + } + } +} diff --git a/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java b/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java index 0fc2246..2b9b1c8 100644 --- a/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java +++ b/app/src/test/java/com/maxistar/textpad/utils/TextConverterTest.java @@ -7,14 +7,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import java.util.function.Supplier; - public class TextConverterTest { @org.junit.jupiter.api.Test public void getInstance() { TextConverter converter = TextConverter.getInstance(); - assertNotNull((Object) "Should return instance", (Supplier) converter); + assertNotNull(converter, "Should return instance"); } @org.junit.jupiter.api.Test @@ -44,4 +42,4 @@ public void applyMacEndings() { String result = converter.applyEndings("\r\n\r\n", TextConverter.MACOS); assertEquals("\r\r", result); } -} \ No newline at end of file +}