Merge pull request #154 from noties/develop

4.1.0
This commit is contained in:
Dimitry 2019-08-06 18:31:11 +03:00 committed by GitHub
commit f34c831b9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 456 additions and 12 deletions

View File

@ -1,5 +1,14 @@
# Changelog
# 4.1.0
* Add `Markwon.TextSetter` interface to be able to use PrecomputedText/PrecomputedTextCompat
* Add `PrecomputedTextSetterCompat` and `compileOnly` dependency on `androidx.core:core`
(clients must have this dependency in the classpath)
* Add `requirePlugin(Class)` and `getPlugins` for `Markwon` instance
* TablePlugin -> defer table invalidation (via `View.post`), so only one invalidation
happens with each draw-call
* AsyncDrawableSpan -> defer invalidation
# 4.0.2
* Fix `JLatexMathPlugin` formula placeholder (cannot have line breaks) ([#149])
* Fix `JLatexMathPlugin` to update resulting formula bounds when `fitCanvas=true` and

View File

@ -5,6 +5,7 @@ import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.view.View;
import android.widget.TextView;
@ -15,6 +16,7 @@ import javax.inject.Inject;
import io.noties.debug.Debug;
import io.noties.markwon.Markwon;
import io.noties.markwon.utils.NoCopySpannableFactory;
public class MainActivity extends Activity {
@ -60,6 +62,9 @@ public class MainActivity extends Activity {
appBarRenderer.render(appBarState());
textView.setMovementMethod(LinkMovementMethod.getInstance());
textView.setSpannableFactory(NoCopySpannableFactory.getInstance());
markdownLoader.load(uri(), new MarkdownLoader.OnMarkdownTextLoaded() {
@Override
public void apply(final String text) {

View File

@ -18,9 +18,12 @@
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:breakStrategy="simple"
android:hyphenationFrequency="none"
android:lineSpacingExtra="2dip"
android:textSize="16sp"
tools:text="yo\nman" />
tools:text="yo\nman"
tools:ignore="UnusedAttribute" />
</ScrollView>

View File

@ -59,6 +59,7 @@ ext {
deps = [
'x-annotations' : 'androidx.annotation:annotation:1.1.0',
'x-recycler-view' : 'androidx.recyclerview:recyclerview:1.0.0',
'x-core' : 'androidx.core:core:1.0.2',
'commonmark' : "com.atlassian.commonmark:commonmark:$commonMarkVersion",
'commonmark-strikethrough': "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion",
'commonmark-table' : "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion",

View File

@ -8,7 +8,7 @@ android.enableJetifier=true
android.enableBuildCache=true
android.buildCacheDir=build/pre-dex-cache
VERSION_NAME=4.0.2
VERSION_NAME=4.1.0
GROUP=io.noties.markwon
POM_DESCRIPTION=Markwon markdown for Android

View File

@ -18,6 +18,10 @@ dependencies {
deps.with {
api it['x-annotations']
api it['commonmark']
// @since 4.1.0 to allow PrecomputedTextSetterCompat
// note that this dependency must be added on a client side explicitly
compileOnly it['x-core']
}
deps['test'].with {

View File

@ -9,6 +9,8 @@ import androidx.annotation.Nullable;
import org.commonmark.node.Node;
import java.util.List;
import io.noties.markwon.core.CorePlugin;
/**
@ -119,6 +121,42 @@ public abstract class Markwon {
@Nullable
public abstract <P extends MarkwonPlugin> P getPlugin(@NonNull Class<P> type);
/**
* @since 4.1.0
*/
@NonNull
public abstract <P extends MarkwonPlugin> P requirePlugin(@NonNull Class<P> type);
/**
* @return a list of registered {@link MarkwonPlugin}
* @since 4.1.0
*/
@NonNull
public abstract List<? extends MarkwonPlugin> getPlugins();
/**
* Interface to set text on a TextView. Primary goal is to give a way to use PrecomputedText
* functionality
*
* @see PrecomputedTextSetterCompat
* @since 4.1.0
*/
public interface TextSetter {
/**
* @param textView TextView
* @param markdown prepared markdown
* @param bufferType BufferType specified when building {@link Markwon} instance
* via {@link Builder#bufferType(TextView.BufferType)}
* @param onComplete action to run when set-text is finished (required to call in order
* to execute {@link MarkwonPlugin#afterSetText(TextView)})
*/
void setText(
@NonNull TextView textView,
@NonNull Spanned markdown,
@NonNull TextView.BufferType bufferType,
@NonNull Runnable onComplete);
}
/**
* Builder for {@link Markwon}.
* <p>
@ -138,6 +176,13 @@ public abstract class Markwon {
@NonNull
Builder bufferType(@NonNull TextView.BufferType bufferType);
/**
* @param textSetter {@link TextSetter} to apply text to a TextView
* @since 4.1.0
*/
@NonNull
Builder textSetter(@NonNull TextSetter textSetter);
@NonNull
Builder usePlugin(@NonNull MarkwonPlugin plugin);

View File

@ -25,6 +25,8 @@ class MarkwonBuilderImpl implements Markwon.Builder {
private TextView.BufferType bufferType = TextView.BufferType.SPANNABLE;
private Markwon.TextSetter textSetter;
MarkwonBuilderImpl(@NonNull Context context) {
this.context = context;
}
@ -36,6 +38,13 @@ class MarkwonBuilderImpl implements Markwon.Builder {
return this;
}
@NonNull
@Override
public Markwon.Builder textSetter(@NonNull Markwon.TextSetter textSetter) {
this.textSetter = textSetter;
return this;
}
@NonNull
@Override
public Markwon.Builder usePlugin(@NonNull MarkwonPlugin plugin) {
@ -97,6 +106,7 @@ class MarkwonBuilderImpl implements Markwon.Builder {
return new MarkwonImpl(
bufferType,
textSetter,
parserBuilder.build(),
visitorBuilder.build(configuration, renderProps),
Collections.unmodifiableList(plugins)

View File

@ -9,7 +9,9 @@ import androidx.annotation.Nullable;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
/**
* @since 3.0.0
@ -21,12 +23,18 @@ class MarkwonImpl extends Markwon {
private final MarkwonVisitor visitor;
private final List<MarkwonPlugin> plugins;
// @since 4.1.0
@Nullable
private final TextSetter textSetter;
MarkwonImpl(
@NonNull TextView.BufferType bufferType,
@Nullable TextSetter textSetter,
@NonNull Parser parser,
@NonNull MarkwonVisitor visitor,
@NonNull List<MarkwonPlugin> plugins) {
this.bufferType = bufferType;
this.textSetter = textSetter;
this.parser = parser;
this.visitor = visitor;
this.plugins = plugins;
@ -78,18 +86,33 @@ class MarkwonImpl extends Markwon {
}
@Override
public void setParsedMarkdown(@NonNull TextView textView, @NonNull Spanned markdown) {
public void setParsedMarkdown(@NonNull final TextView textView, @NonNull Spanned markdown) {
for (MarkwonPlugin plugin : plugins) {
plugin.beforeSetText(textView, markdown);
}
// @since 4.1.0
if (textSetter != null) {
textSetter.setText(textView, markdown, bufferType, new Runnable() {
@Override
public void run() {
// on-complete we just must call `afterSetText` on all plugins
for (MarkwonPlugin plugin : plugins) {
plugin.afterSetText(textView);
}
}
});
} else {
// if no text-setter is specified -> just a regular sync operation
textView.setText(markdown, bufferType);
for (MarkwonPlugin plugin : plugins) {
plugin.afterSetText(textView);
}
}
}
@Override
public boolean hasPlugin(@NonNull Class<? extends MarkwonPlugin> type) {
@ -108,4 +131,21 @@ class MarkwonImpl extends Markwon {
//noinspection unchecked
return (P) out;
}
@NonNull
@Override
public <P extends MarkwonPlugin> P requirePlugin(@NonNull Class<P> type) {
final P plugin = getPlugin(type);
if (plugin == null) {
throw new IllegalStateException(String.format(Locale.US, "Requested plugin `%s` is not " +
"registered with this Markwon instance", type.getName()));
}
return plugin;
}
@NonNull
@Override
public List<? extends MarkwonPlugin> getPlugins() {
return Collections.unmodifiableList(plugins);
}
}

View File

@ -0,0 +1,119 @@
package io.noties.markwon;
import android.os.Build;
import android.text.Spanned;
import android.util.Log;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.PrecomputedTextCompat;
import java.lang.ref.WeakReference;
import java.util.concurrent.Executor;
/**
* Please note this class requires `androidx.core:core` artifact being explicitly added to your dependencies.
* Please do not use with `markwon-recycler` as it will lead to bad item rendering (due to async nature)
*
* @see io.noties.markwon.Markwon.TextSetter
* @since 4.1.0
*/
public class PrecomputedTextSetterCompat implements Markwon.TextSetter {
/**
* @param executor for background execution of text pre-computation
*/
@NonNull
public static PrecomputedTextSetterCompat create(@NonNull Executor executor) {
return new PrecomputedTextSetterCompat(executor);
}
private final Executor executor;
@SuppressWarnings("WeakerAccess")
PrecomputedTextSetterCompat(@NonNull Executor executor) {
this.executor = executor;
}
@Override
public void setText(
@NonNull TextView textView,
@NonNull final Spanned markdown,
@NonNull final TextView.BufferType bufferType,
@NonNull final Runnable onComplete) {
// insert version check and do not execute on a device < 21
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
// it's still no-op, so there is no need to start background execution
applyText(textView, markdown, bufferType, onComplete);
return;
}
final WeakReference<TextView> reference = new WeakReference<>(textView);
executor.execute(new Runnable() {
@Override
public void run() {
try {
final PrecomputedTextCompat precomputedTextCompat = precomputedText(reference.get(), markdown);
if (precomputedTextCompat != null) {
applyText(reference.get(), precomputedTextCompat, bufferType, onComplete);
}
} catch (Throwable t) {
Log.e("PrecomputdTxtSetterCmpt", "Exception during pre-computing text", t);
// apply initial markdown
applyText(reference.get(), markdown, bufferType, onComplete);
}
}
});
}
@Nullable
private static PrecomputedTextCompat precomputedText(@Nullable TextView textView, @NonNull Spanned spanned) {
if (textView == null) {
return null;
}
final PrecomputedTextCompat.Params params;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
// use native parameters on P
params = new PrecomputedTextCompat.Params(textView.getTextMetricsParams());
} else {
final PrecomputedTextCompat.Params.Builder builder =
new PrecomputedTextCompat.Params.Builder(textView.getPaint());
// please note that text-direction initialization is omitted
// by default it will be determined by the first locale-specific character
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// another miss on API surface, this can easily be done by the compat class itself
builder
.setBreakStrategy(textView.getBreakStrategy())
.setHyphenationFrequency(textView.getHyphenationFrequency());
}
params = builder.build();
}
return PrecomputedTextCompat.create(spanned, params);
}
private static void applyText(
@Nullable final TextView textView,
@NonNull final Spanned text,
@NonNull final TextView.BufferType bufferType,
@NonNull final Runnable onComplete) {
if (textView != null) {
textView.post(new Runnable() {
@Override
public void run() {
textView.setText(text, bufferType);
onComplete.run();
}
});
}
}
}

View File

@ -57,11 +57,14 @@ public abstract class AsyncDrawableScheduler {
textView.setTag(R.id.markwon_drawables_scheduler, listener);
}
// @since 4.1.0
final DrawableCallbackImpl.Invalidator invalidator = new TextViewInvalidator(textView);
AsyncDrawable drawable;
for (AsyncDrawableSpan span : spans) {
drawable = span.getDrawable();
drawable.setCallback2(new DrawableCallbackImpl(textView, drawable.getBounds()));
drawable.setCallback2(new DrawableCallbackImpl(textView, invalidator, drawable.getBounds()));
}
}
}
@ -109,11 +112,23 @@ public abstract class AsyncDrawableScheduler {
private static class DrawableCallbackImpl implements Drawable.Callback {
// @since 4.1.0
// interface to be used when bounds change and view must be invalidated
interface Invalidator {
void invalidate();
}
private final TextView view;
private final Invalidator invalidator; // @since 4.1.0
private Rect previousBounds;
DrawableCallbackImpl(TextView view, Rect initialBounds) {
DrawableCallbackImpl(
@NonNull TextView view,
@NonNull Invalidator invalidator,
Rect initialBounds) {
this.view = view;
this.invalidator = invalidator;
this.previousBounds = new Rect(initialBounds);
}
@ -136,8 +151,10 @@ public abstract class AsyncDrawableScheduler {
// but if the size has changed, then we need to update the whole layout...
if (!previousBounds.equals(rect)) {
// the only method that seems to work when bounds have changed
view.setText(view.getText());
// @since 4.1.0
// invalidation moved to upper level (so invalidation can be deferred,
// and multiple calls combined)
invalidator.invalidate();
previousBounds = new Rect(rect);
} else {
@ -156,4 +173,24 @@ public abstract class AsyncDrawableScheduler {
view.removeCallbacks(what);
}
}
private static class TextViewInvalidator implements DrawableCallbackImpl.Invalidator, Runnable {
private final TextView textView;
TextViewInvalidator(@NonNull TextView textView) {
this.textView = textView;
}
@Override
public void invalidate() {
textView.removeCallbacks(this);
textView.post(this);
}
@Override
public void run() {
textView.setText(textView.getText());
}
}
}

View File

@ -14,6 +14,7 @@ import org.mockito.stubbing.Answer;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@ -21,7 +22,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
@ -42,6 +45,7 @@ public class MarkwonImplTest {
final MarkwonPlugin plugin = mock(MarkwonPlugin.class);
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
mock(Parser.class),
mock(MarkwonVisitor.class),
Collections.singletonList(plugin));
@ -64,6 +68,7 @@ public class MarkwonImplTest {
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
parser,
mock(MarkwonVisitor.class),
Arrays.asList(first, second));
@ -89,6 +94,7 @@ public class MarkwonImplTest {
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
mock(Parser.class),
visitor,
Collections.singletonList(plugin));
@ -130,6 +136,7 @@ public class MarkwonImplTest {
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
mock(Parser.class),
visitor,
Collections.<MarkwonPlugin>emptyList());
@ -160,6 +167,7 @@ public class MarkwonImplTest {
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
mock(Parser.class),
visitor,
Collections.singletonList(plugin));
@ -195,6 +203,7 @@ public class MarkwonImplTest {
final MarkwonPlugin plugin = mock(MarkwonPlugin.class);
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.EDITABLE,
null,
mock(Parser.class),
mock(MarkwonVisitor.class, RETURNS_MOCKS),
Collections.singletonList(plugin));
@ -241,6 +250,7 @@ public class MarkwonImplTest {
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
mock(Parser.class),
mock(MarkwonVisitor.class),
plugins);
@ -253,4 +263,103 @@ public class MarkwonImplTest {
assertTrue("AbstractMarkwonPlugin", impl.hasPlugin(AbstractMarkwonPlugin.class));
assertTrue("MarkwonPlugin", impl.hasPlugin(MarkwonPlugin.class));
}
@Test
public void text_setter() {
final Markwon.TextSetter textSetter = mock(Markwon.TextSetter.class);
final MarkwonPlugin plugin = mock(MarkwonPlugin.class);
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.EDITABLE,
textSetter,
mock(Parser.class),
mock(MarkwonVisitor.class),
Collections.singletonList(plugin));
final TextView textView = mock(TextView.class);
final Spanned spanned = mock(Spanned.class);
impl.setParsedMarkdown(textView, spanned);
final ArgumentCaptor<TextView> textViewArgumentCaptor =
ArgumentCaptor.forClass(TextView.class);
final ArgumentCaptor<Spanned> spannedArgumentCaptor =
ArgumentCaptor.forClass(Spanned.class);
final ArgumentCaptor<TextView.BufferType> bufferTypeArgumentCaptor =
ArgumentCaptor.forClass(TextView.BufferType.class);
final ArgumentCaptor<Runnable> runnableArgumentCaptor =
ArgumentCaptor.forClass(Runnable.class);
verify(textSetter, times(1)).setText(
textViewArgumentCaptor.capture(),
spannedArgumentCaptor.capture(),
bufferTypeArgumentCaptor.capture(),
runnableArgumentCaptor.capture());
assertEquals(textView, textViewArgumentCaptor.getValue());
assertEquals(spanned, spannedArgumentCaptor.getValue());
assertEquals(TextView.BufferType.EDITABLE, bufferTypeArgumentCaptor.getValue());
assertNotNull(runnableArgumentCaptor.getValue());
}
@Test
public void require_plugin_throws() {
// if plugin is `required`, but it's not added -> an exception is thrown
final class NotPresent extends AbstractMarkwonPlugin {
}
final List<MarkwonPlugin> plugins =
Arrays.asList(mock(MarkwonPlugin.class), mock(MarkwonPlugin.class));
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
mock(Parser.class),
mock(MarkwonVisitor.class), plugins);
// should be returned
assertNotNull(impl.requirePlugin(MarkwonPlugin.class));
try {
impl.requirePlugin(NotPresent.class);
fail();
} catch (Throwable t) {
assertTrue(t.getMessage(), t.getMessage().contains(NotPresent.class.getName()));
}
}
@Test
public void plugins_unmodifiable() {
// returned plugins list must not be modifiable
// modifiable list (created from Arrays.asList -> which returns non)
final List<MarkwonPlugin> plugins = new ArrayList<>(
Arrays.asList(mock(MarkwonPlugin.class), mock(MarkwonPlugin.class)));
// validate that list is modifiable
plugins.add(mock(MarkwonPlugin.class));
assertEquals(3, plugins.size());
final MarkwonImpl impl = new MarkwonImpl(
TextView.BufferType.SPANNABLE,
null,
mock(Parser.class),
mock(MarkwonVisitor.class),
plugins);
final List<? extends MarkwonPlugin> list = impl.getPlugins();
// instance check (different list)
//noinspection SimplifiableJUnitAssertion
assertTrue(plugins != list);
try {
list.add(null);
fail();
} catch (UnsupportedOperationException e) {
assertTrue(e.getMessage(), true);
}
}
}

View File

@ -34,9 +34,22 @@ abstract class TableRowsScheduler {
}
final TableRowSpan.Invalidator invalidator = new TableRowSpan.Invalidator() {
// @since 4.1.0
// let's stack-up invalidation calls (so invalidation happens,
// but not with each table-row-span draw call)
final Runnable runnable = new Runnable() {
@Override
public void run() {
view.setText(view.getText());
}
};
@Override
public void invalidate() {
view.setText(view.getText());
// @since 4.1.0 post invalidation (combine multiple calls)
view.removeCallbacks(runnable);
view.post(runnable);
}
};

View File

@ -49,6 +49,7 @@ dependencies {
deps.with {
implementation it['x-recycler-view']
implementation it['x-core'] // for precomputedTextCompat
implementation it['okhttp']
implementation it['prism4j']
implementation it['debug']

View File

@ -27,6 +27,7 @@
<activity android:name=".html.HtmlActivity" />
<activity android:name=".simpleext.SimpleExtActivity" />
<activity android:name=".customextension2.CustomExtensionActivity2" />
<activity android:name=".precomputed.PrecomputedActivity" />
</application>

View File

@ -24,6 +24,7 @@ import io.noties.markwon.sample.customextension.CustomExtensionActivity;
import io.noties.markwon.sample.customextension2.CustomExtensionActivity2;
import io.noties.markwon.sample.html.HtmlActivity;
import io.noties.markwon.sample.latex.LatexActivity;
import io.noties.markwon.sample.precomputed.PrecomputedActivity;
import io.noties.markwon.sample.recycler.RecyclerActivity;
import io.noties.markwon.sample.simpleext.SimpleExtActivity;
@ -112,6 +113,10 @@ public class MainActivity extends Activity {
activity = CustomExtensionActivity2.class;
break;
case PRECOMPUTED_TEXT:
activity = PrecomputedActivity.class;
break;
default:
throw new IllegalStateException("No Activity is associated with sample-item: " + item);
}

View File

@ -19,7 +19,9 @@ public enum Sample {
SIMPLE_EXT(R.string.sample_simple_ext),
CUSTOM_EXTENSION_2(R.string.sample_custom_extension_2);
CUSTOM_EXTENSION_2(R.string.sample_custom_extension_2),
PRECOMPUTED_TEXT(R.string.sample_precomputed_text);
private final int textResId;

View File

@ -0,0 +1,38 @@
package io.noties.markwon.sample.precomputed;
import android.app.Activity;
import android.os.Bundle;
import android.widget.TextView;
import androidx.annotation.Nullable;
import java.util.concurrent.Executors;
import io.noties.markwon.Markwon;
import io.noties.markwon.PrecomputedTextSetterCompat;
import io.noties.markwon.sample.R;
public class PrecomputedActivity extends Activity {
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_text_view);
final Markwon markwon = Markwon.builder(this)
// please note that precomputedTextCompat is no-op on devices lower than L (21)
.textSetter(PrecomputedTextSetterCompat.create(Executors.newCachedThreadPool()))
.build();
final TextView textView = findViewById(R.id.text_view);
final String markdown = "# Hello!\n\n" +
"This _displays_ how to implement and use `PrecomputedTextCompat` with the **Markwon**\n\n" +
"> consider using PrecomputedText only if your markdown content is large enough\n> \n" +
"> **please note** that it works badly with `markwon-recycler` due to asynchronous nature";
// please note that _sometimes_ (if done without `post` here) further `textView.post`
// (that is used in PrecomputedTextSetterCompat to deliver result to main-thread) won't be called
// making the result of pre-computation absent and text-view clear (no text)
textView.post(() -> markwon.setMarkdown(textView, markdown));
}
}

View File

@ -23,4 +23,6 @@
<string name="sample_custom_extension_2"># \# Custom extension 2\n\nAutomatically
convert `#1` and `@user` to Github links</string>
<string name="sample_precomputed_text"># \# PrecomputedText\n\nUsage of TextSetter and PrecomputedTextCompat</string>
</resources>