Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
apply plugin: 'com.android.application'

android {
testOptions {
unitTests.all {
useJUnitPlatform()
}
}

defaultConfig {
applicationId "com.maxistar.textpad"
minSdkVersion 21
Expand Down Expand Up @@ -67,4 +73,3 @@ android {
}



Original file line number Diff line number Diff line change
@@ -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<EditorActivity> 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();
});
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading