Monospace text formatting (AND-223)

This commit is contained in:
Grishka 2024-10-09 01:21:50 +03:00
parent 513a57663b
commit 4177faa553
24 changed files with 134 additions and 28 deletions

View File

@ -90,7 +90,7 @@ dependencies {
implementation 'me.grishka.litex:viewpager:1.0.0'
implementation 'me.grishka.litex:viewpager2:1.0.0'
implementation 'me.grishka.litex:palette:1.0.0'
implementation 'me.grishka.appkit:appkit:1.4.2'
implementation 'me.grishka.appkit:appkit:1.4.3'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.squareup:otto:1.3.8'

View File

@ -84,7 +84,7 @@ public class CreateListAddMembersFragment extends BaseAccountListFragment implem
public void onSuccess(HeaderPaginationList<Account> result){
for(Account acc:result)
accountIDsInList.add(acc.id);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()));
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID, getActivity())).collect(Collectors.toList()));
}
})
.exec(accountID);

View File

@ -190,7 +190,7 @@ public class ListMembersFragment extends PaginatedAccountListFragment implements
@Subscribe
public void onAccountAddedToList(AccountAddedToListEvent ev){
if(ev.accountID.equals(accountID) && ev.listID.equals(followList.id)){
data.add(new AccountViewModel(ev.account, accountID));
data.add(new AccountViewModel(ev.account, accountID, getActivity()));
list.getAdapter().notifyItemInserted(data.size()-1);
}
}
@ -337,7 +337,7 @@ public class ListMembersFragment extends PaginatedAccountListFragment implements
onDone.run();
for(Account acc:accounts){
accountIDsInList.add(acc.id);
data.add(new AccountViewModel(acc, accountID));
data.add(new AccountViewModel(acc, accountID, getActivity()));
}
list.getAdapter().notifyItemRangeInserted(data.size()-accounts.size(), accounts.size());
}

View File

@ -80,7 +80,7 @@ public class NotificationRequestsFragment extends MastodonRecyclerFragment<Notif
accountViewModels.clear();
maxID=result.getNextPageMaxID();
for(NotificationRequest req:result){
accountViewModels.put(req.account.id, new AccountViewModel(req.account, accountID, false));
accountViewModels.put(req.account.id, new AccountViewModel(req.account, accountID, false, getActivity()));
}
onDataLoaded(result, !TextUtils.isEmpty(maxID));
endMark.setVisibility(TextUtils.isEmpty(maxID) ? View.VISIBLE : View.GONE);

View File

@ -580,7 +580,7 @@ public class ProfileFragment extends LoaderFragment implements ScrollableToTop{
domain=AccountSessionManager.get(accountID).domain;
usernameDomain.setText(domain);
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account);
CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account, getActivity());
if(TextUtils.isEmpty(parsedBio)){
bio.setVisibility(View.GONE);
}else{
@ -615,7 +615,7 @@ public class ProfileFragment extends LoaderFragment implements ScrollableToTop{
fields.add(joined);
for(AccountField field:account.fields){
field.parsedValue=ssb=HtmlParser.parse(field.value, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account);
field.parsedValue=ssb=HtmlParser.parse(field.value, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account, getActivity());
field.valueEmojis=ssb.getSpans(0, ssb.length(), CustomEmojiSpan.class);
ssb=new SpannableStringBuilder(field.name);
HtmlParser.parseCustomEmoji(ssb, account.emojis);

View File

@ -61,7 +61,7 @@ public class AccountSearchFragment extends BaseAccountListFragment{
protected void onSuccess(List<Account> result){
setEmptyText(R.string.no_search_results);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID, getActivity())).collect(Collectors.toList()), false);
}
protected String getSearchViewPlaceholder(){

View File

@ -44,7 +44,7 @@ public class AddNewListMembersFragment extends AccountSearchFragment{
@Override
public void onSuccess(HeaderPaginationList<Account> result){
setEmptyText("");
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), result.nextPageUri!=null);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID, getActivity())).collect(Collectors.toList()), result.nextPageUri!=null);
maxID=result.getNextPageMaxID();
}
})

View File

@ -24,7 +24,7 @@ public abstract class PaginatedAccountListFragment extends BaseAccountListFragme
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), nextMaxID!=null);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID, getActivity())).collect(Collectors.toList()), nextMaxID!=null);
}
})
.exec(accountID);

View File

@ -31,7 +31,7 @@ public class DiscoverAccountsFragment extends BaseAccountListFragment implements
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowSuggestion> result){
List<AccountViewModel> accounts=result.stream().map(fs->new AccountViewModel(fs.account, accountID)).collect(Collectors.toList());
List<AccountViewModel> accounts=result.stream().map(fs->new AccountViewModel(fs.account, accountID, getActivity())).collect(Collectors.toList());
onDataLoaded(accounts, false);
bannerHelper.onBannerBecameVisible();
}

View File

@ -109,7 +109,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
return;
onDataLoaded(results.stream().map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, true);
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, true, getActivity());
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.setOnClick(i->openHashtag(sr));
}
@ -126,7 +126,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
onDataLoaded(Stream.of(result.hashtags.stream().map(SearchResult::new), result.accounts.stream().map(SearchResult::new))
.flatMap(Function.identity())
.map(sr->{
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false);
SearchResultViewModel vm=new SearchResultViewModel(sr, accountID, false, getActivity());
if(sr.type==SearchResult.Type.HASHTAG){
vm.hashtagItem.setOnClick(i->openHashtag(sr));
}

View File

@ -70,7 +70,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseAccountListFragment
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowSuggestion> result){
onDataLoaded(result.stream().map(fs->new AccountViewModel(fs.account, accountID).stripLinksFromBio()).collect(Collectors.toList()), false);
onDataLoaded(result.stream().map(fs->new AccountViewModel(fs.account, accountID, getActivity()).stripLinksFromBio()).collect(Collectors.toList()), false);
}
})
.exec(accountID);

View File

@ -128,7 +128,7 @@ public class SettingsServerAboutFragment extends LoaderFragment{
hlp.leftMargin=hlp.rightMargin=V.dp(16);
scrollingLayout.addView(heading, hlp);
AccountViewModel model=new AccountViewModel(instance.getContactAccount(), accountID);
AccountViewModel model=new AccountViewModel(instance.getContactAccount(), accountID, getActivity());
AccountViewHolder holder=new AccountViewHolder(this, scrollingLayout, null);
holder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
holder.bind(model);

View File

@ -1,5 +1,6 @@
package org.joinmastodon.android.model.viewmodel;
import android.content.Context;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
@ -24,11 +25,11 @@ public class AccountViewModel{
public final CharSequence parsedName, parsedBio;
public final String verifiedLink;
public AccountViewModel(Account account, String accountID){
this(account, accountID, true);
public AccountViewModel(Account account, String accountID, Context context){
this(account, accountID, true, context);
}
public AccountViewModel(Account account, String accountID, boolean needBio){
public AccountViewModel(Account account, String accountID, boolean needBio, Context context){
this.account=account;
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50));
emojiHelper=new CustomEmojiHelper();
@ -38,7 +39,7 @@ public class AccountViewModel{
parsedName=account.displayName;
SpannableStringBuilder ssb=new SpannableStringBuilder(parsedName);
if(needBio){
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account);
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account, context);
ssb.append(parsedBio);
}else{
parsedBio=null;

View File

@ -11,10 +11,10 @@ public class SearchResultViewModel{
public AccountViewModel account;
public ListItem<Hashtag> hashtagItem;
public SearchResultViewModel(SearchResult result, String accountID, boolean isRecents){
public SearchResultViewModel(SearchResult result, String accountID, boolean isRecents, Context context){
this.result=result;
switch(result.type){
case ACCOUNT -> account=new AccountViewModel(result.account, accountID);
case ACCOUNT -> account=new AccountViewModel(result.account, accountID, context);
case HASHTAG -> {
hashtagItem=new ListItem<>((isRecents ? "#" : "")+result.hashtag.name, null, isRecents ? R.drawable.ic_history_24px : R.drawable.ic_tag_24px, null, result.hashtag);
hashtagItem.isEnabled=true;

View File

@ -15,7 +15,7 @@ public class AccountStatusDisplayItem extends StatusDisplayItem{
public AccountStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Account account){
super(parentID, parentFragment);
this.account=new AccountViewModel(account, parentFragment.getAccountID());
this.account=new AccountViewModel(account, parentFragment.getAccountID(), parentFragment.getActivity());
}
@Override

View File

@ -39,7 +39,7 @@ public class InlineStatusStatusDisplayItem extends StatusDisplayItem{
if(AccountSessionManager.get(parentFragment.getAccountID()).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(parsedName, status.account.emojis);
parsedPostText=HtmlParser.parse(status.content, status.emojis, status.mentions, status.tags, parentFragment.getAccountID(), status.getContentStatus());
parsedPostText=HtmlParser.parse(status.content, status.emojis, status.mentions, status.tags, parentFragment.getAccountID(), status.getContentStatus(), parentFragment.getActivity());
for(Object span:parsedPostText.getSpans(0, parsedPostText.length(), Object.class)){
if(!(span instanceof CustomEmojiSpan))
parsedPostText.removeSpan(span);

View File

@ -141,7 +141,7 @@ public abstract class StatusDisplayItem{
}
if(!TextUtils.isEmpty(statusForContent.content)){
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, statusForContent);
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID, statusForContent, fragment.getActivity());
if(filtered){
HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered);
}

View File

@ -58,7 +58,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
public void setTranslatedText(String text){
Status statusForContent=status.getContentStatus();
translatedText=HtmlParser.parse(text, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, parentFragment.getAccountID(), statusForContent);
translatedText=HtmlParser.parse(text, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, parentFragment.getAccountID(), statusForContent, parentFragment.getActivity());
translationEmojiHelper.setText(translatedText);
}

View File

@ -0,0 +1,35 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.text.TextPaint;
import android.text.style.TypefaceSpan;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.V;
public abstract class BaseMonospaceSpan extends TypefaceSpan{
private final Context context;
public BaseMonospaceSpan(Context context){
super("monospace");
this.context=context;
}
@Override
public void updateDrawState(@NonNull TextPaint paint){
super.updateDrawState(paint);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3Tertiary));
paint.setTextSize(paint.getTextSize()*0.9375f);
paint.baselineShift=V.dp(-1);
}
@Override
public void updateMeasureState(@NonNull TextPaint paint){
super.updateMeasureState(paint);
paint.setTextSize(paint.getTextSize()*0.9375f);
paint.baselineShift=V.dp(-1);
}
}

View File

@ -0,0 +1,9 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
public class CodeBlockSpan extends BaseMonospaceSpan{
public CodeBlockSpan(Context context){
super(context);
}
}

View File

@ -37,6 +37,7 @@ import java.util.regex.Pattern;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import me.grishka.appkit.utils.V;
public class HtmlParser{
private static final String TAG="HtmlParser";
@ -72,7 +73,7 @@ public class HtmlParser{
* @param emojis Custom emojis that are present in source as <code>:code:</code>
* @return a spanned string
*/
public static SpannableStringBuilder parse(String source, List<Emoji> emojis, List<Mention> mentions, List<Hashtag> tags, String accountID, Object parentObject){
public static SpannableStringBuilder parse(String source, List<Emoji> emojis, List<Mention> mentions, List<Hashtag> tags, String accountID, Object parentObject, Context context){
class SpanInfo{
public Object span;
public int start;
@ -95,10 +96,18 @@ public class HtmlParser{
Jsoup.parseBodyFragment(source).body().traverse(new NodeVisitor(){
private final ArrayList<SpanInfo> openSpans=new ArrayList<>();
private boolean isInsidePre(){
for(SpanInfo si:openSpans){
if(si.span instanceof CodeBlockSpan)
return true;
}
return false;
}
@Override
public void head(@NonNull Node node, int depth){
if(node instanceof TextNode textNode){
ssb.append(textNode.text());
ssb.append(isInsidePre() ? textNode.getWholeText().stripTrailing() : textNode.text());
}else if(node instanceof Element el){
switch(el.nodeName()){
case "a" -> {
@ -137,6 +146,15 @@ public class HtmlParser{
case "b", "strong" -> openSpans.add(new SpanInfo(new StyleSpan(Typeface.BOLD), ssb.length(), el));
case "i", "em" -> openSpans.add(new SpanInfo(new StyleSpan(Typeface.ITALIC), ssb.length(), el));
case "s", "del" -> openSpans.add(new SpanInfo(new StrikethroughSpan(), ssb.length(), el));
case "code" -> {
if(!isInsidePre()){
openSpans.add(new SpanInfo(new MonospaceSpan(context), ssb.length(), el));
ssb.append(" ", new SpacerSpan(V.dp(4), 1), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
case "pre" -> {
openSpans.add(new SpanInfo(new CodeBlockSpan(context), ssb.length(), el));
}
}
}
}
@ -152,6 +170,9 @@ public class HtmlParser{
}else if(!openSpans.isEmpty()){
SpanInfo si=openSpans.get(openSpans.size()-1);
if(si.element==el){
if(si.span instanceof MonospaceSpan){
ssb.append(" ", new SpacerSpan(V.dp(4), 1), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
ssb.setSpan(si.span, si.start, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
openSpans.remove(openSpans.size()-1);
}

View File

@ -0,0 +1,9 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
public class MonospaceSpan extends BaseMonospaceSpan{
public MonospaceSpan(Context context){
super(context);
}
}

View File

@ -244,7 +244,7 @@ public class ComposeAutocompleteViewController{
if(mode!=Mode.USERS)
return;
List<AccountViewModel> oldList=users;
users=result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList());
users=result.accounts.stream().map(a->new AccountViewModel(a, accountID, activity)).collect(Collectors.toList());
if(isLoading){
isLoading=false;
if(users.size()>=LOADING_FAKE_USER_COUNT){

View File

@ -4,6 +4,10 @@ import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.CornerPathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.util.AttributeSet;
@ -14,14 +18,22 @@ import android.view.MenuItem;
import android.view.MotionEvent;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.text.ClickableLinksDelegate;
import org.joinmastodon.android.ui.text.CodeBlockSpan;
import org.joinmastodon.android.ui.text.DeleteWhenCopiedSpan;
import org.joinmastodon.android.ui.text.MonospaceSpan;
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.V;
public class LinkedTextView extends TextView{
private ClickableLinksDelegate delegate=new ClickableLinksDelegate(this);
private boolean needInvalidate;
private ActionMode currentActionMode;
private Paint bgPaint=new Paint(Paint.ANTI_ALIAS_FLAG);
private Path tmpPath=new Path();
public LinkedTextView(Context context){
this(context, null);
@ -56,6 +68,9 @@ public class LinkedTextView extends TextView{
currentActionMode=null;
}
});
bgPaint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3TertiaryContainer));
bgPaint.setAlpha(20);
bgPaint.setPathEffect(new CornerPathEffect(V.dp(2)));
}
public boolean onTouchEvent(MotionEvent ev){
@ -64,6 +79,22 @@ public class LinkedTextView extends TextView{
}
public void onDraw(Canvas c){
if(getText() instanceof Spanned spanned){
c.save();
c.translate(getTotalPaddingLeft(), getTotalPaddingTop());
Layout layout=getLayout();
MonospaceSpan[] monospaceSpans=spanned.getSpans(0, spanned.length(), MonospaceSpan.class);
for(MonospaceSpan span:monospaceSpans){
layout.getSelectionPath(spanned.getSpanStart(span), spanned.getSpanEnd(span), tmpPath);
c.drawPath(tmpPath, bgPaint);
}
CodeBlockSpan[] blockSpans=spanned.getSpans(0, spanned.length(), CodeBlockSpan.class);
for(CodeBlockSpan span:blockSpans){
c.drawRoundRect(V.dp(-4), layout.getLineTop(layout.getLineForOffset(spanned.getSpanStart(span)))-V.dp(8), layout.getWidth()+V.dp(4),
layout.getLineBottom(layout.getLineForOffset(spanned.getSpanEnd(span)))+V.dp(4), V.dp(2), V.dp(2), bgPaint);
}
c.restore();
}
super.onDraw(c);
delegate.onDraw(c);
if(needInvalidate)