ContentAdapter: use intermediate rendering blocks

Rendering one view per line is looking for trouble when considering
preformatted blocks and possibly list/blockquotes: you want to group
actions and styles together. Specifically, make preformatted blocks
horizontally scrollable.

This commit adds an intermediate level for rendering which produces such
blocks.
This commit is contained in:
dece 2022-01-09 21:39:33 +01:00
parent 685d60a09b
commit 4f41d6fecd
11 changed files with 181 additions and 112 deletions

View file

@ -10,31 +10,106 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dev.lowrespalmtree.comet.databinding.* import dev.lowrespalmtree.comet.databinding.*
class ContentAdapter(private var content: List<Line>, private val listener: ContentAdapterListen) : /**
* Adapter for a rendered Gemtext page.
*
* Considering each content line as a different block/View to render works until you want to render
* preformatted lines as a whole block. This adapter approaches this issue by mapping each line to
* a block, where most of the time it will be a 1-to-1 relation, but for blocks and possibly quotes
* it could be a n-to-1 relation: many lines belong to the same block. This of course changes a bit
* the way we have to render things.
*/
class ContentAdapter(private val listener: ContentAdapterListen) :
RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() { RecyclerView.Adapter<ContentAdapter.ContentViewHolder>() {
private var lastLineCount = 0 private var lines = listOf<Line>()
private var currentLine = 0
private var blocks = mutableListOf<ContentBlock>()
private var lastBlockCount = 0
interface ContentAdapterListen { interface ContentAdapterListen {
fun onLinkClick(url: String) fun onLinkClick(url: String)
} }
override fun getItemViewType(position: Int): Int = sealed class ContentBlock {
when (content[position]) { object Empty : ContentBlock()
is EmptyLine -> TYPE_EMPTY class Paragraph(val text: String) : ContentBlock()
is ParagraphLine -> TYPE_PARAGRAPH class Title(val text: String, val level: Int) : ContentBlock()
is LinkLine -> TYPE_LINK class Link(val url: String, val label: String) : ContentBlock()
is PreFenceLine -> TYPE_PRE_FENCE class Pre(val caption: String, var content: String, var closed: Boolean) : ContentBlock()
is PreTextLine -> TYPE_PRE_TEXT class Blockquote(val text: String) : ContentBlock()
is BlockquoteLine -> TYPE_BLOCKQUOTE class ListItem(val text: String) : ContentBlock()
is ListItemLine -> TYPE_LIST_ITEM }
is TitleLine -> when ((content[position] as TitleLine).level) {
1 -> TYPE_TITLE_1 /** Replace the content rendered by the recycler. */
2 -> TYPE_TITLE_2 @SuppressLint("NotifyDataSetChanged")
3 -> TYPE_TITLE_3 fun setLines(newLines: List<Line>) {
else -> error("invalid title level") lines = newLines.toList() // Shallow copy to avoid concurrent update issues.
if (lines.isEmpty()) {
Log.d(TAG, "setLines: empty content")
currentLine = 0
blocks = mutableListOf()
lastBlockCount = 0
notifyDataSetChanged()
} else {
while (currentLine < lines.size) {
when (val line = lines[currentLine]) {
is EmptyLine -> blocks.add(ContentBlock.Empty)
is ParagraphLine -> blocks.add(ContentBlock.Paragraph(line.text))
is LinkLine -> blocks.add(ContentBlock.Link(line.url, line.label))
is BlockquoteLine -> blocks.add(ContentBlock.Blockquote(line.text))
is ListItemLine -> blocks.add(ContentBlock.ListItem(line.text))
is TitleLine -> blocks.add(ContentBlock.Title(line.text, line.level))
is PreFenceLine -> {
var justClosedBlock = false
if (blocks.isNotEmpty()) {
val lastBlock = blocks.last()
if (lastBlock is ContentBlock.Pre && !lastBlock.closed) {
lastBlock.closed = true
justClosedBlock = true
}
}
if (!justClosedBlock)
blocks.add(ContentBlock.Pre(line.caption, "", false))
}
is PreTextLine -> {
val lastBlock = blocks.last()
if (lastBlock is ContentBlock.Pre && !lastBlock.closed)
lastBlock.content += line.text + "\n"
else
Log.e(TAG, "setLines: unexpected preformatted line")
}
}
currentLine++
} }
else -> error("unknown line type") val numAdded = blocks.size - lastBlockCount
Log.d(TAG, "setContent: added $numAdded items")
notifyItemRangeInserted(lastBlockCount, numAdded)
}
lastBlockCount = blocks.size
}
sealed class ContentViewHolder(view: View) : RecyclerView.ViewHolder(view) {
class Empty(binding: GemtextEmptyBinding) : ContentViewHolder(binding.root)
class Paragraph(val binding: GemtextParagraphBinding) : ContentViewHolder(binding.root)
class Title1(val binding: GemtextTitle1Binding) : ContentViewHolder(binding.root)
class Title2(val binding: GemtextTitle2Binding) : ContentViewHolder(binding.root)
class Title3(val binding: GemtextTitle3Binding) : ContentViewHolder(binding.root)
class Link(val binding: GemtextLinkBinding) : ContentViewHolder(binding.root)
class Pre(val binding: GemtextPreformattedBinding) : ContentViewHolder(binding.root)
class Blockquote(val binding: GemtextBlockquoteBinding) : ContentViewHolder(binding.root)
class ListItem(val binding: GemtextListItemBinding) : ContentViewHolder(binding.root)
}
override fun getItemViewType(position: Int): Int =
when (val block = blocks[position]) {
is ContentBlock.Empty -> TYPE_EMPTY
is ContentBlock.Paragraph -> TYPE_PARAGRAPH
is ContentBlock.Link -> TYPE_LINK
is ContentBlock.Blockquote -> TYPE_BLOCKQUOTE
is ContentBlock.ListItem -> TYPE_LIST_ITEM
is ContentBlock.Pre -> TYPE_PREFORMATTED
is ContentBlock.Title -> block.level // ruse
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContentViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContentViewHolder {
@ -46,8 +121,7 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
TYPE_TITLE_2 -> ContentViewHolder.Title2(GemtextTitle2Binding.inflate(it)) TYPE_TITLE_2 -> ContentViewHolder.Title2(GemtextTitle2Binding.inflate(it))
TYPE_TITLE_3 -> ContentViewHolder.Title3(GemtextTitle3Binding.inflate(it)) TYPE_TITLE_3 -> ContentViewHolder.Title3(GemtextTitle3Binding.inflate(it))
TYPE_LINK -> ContentViewHolder.Link(GemtextLinkBinding.inflate(it)) TYPE_LINK -> ContentViewHolder.Link(GemtextLinkBinding.inflate(it))
TYPE_PRE_FENCE -> ContentViewHolder.PreFence(GemtextEmptyBinding.inflate(it)) TYPE_PREFORMATTED -> ContentViewHolder.Pre(GemtextPreformattedBinding.inflate(it))
TYPE_PRE_TEXT -> ContentViewHolder.PreText(GemtextPreformattedBinding.inflate(it))
TYPE_BLOCKQUOTE -> ContentViewHolder.Blockquote(GemtextBlockquoteBinding.inflate(it)) TYPE_BLOCKQUOTE -> ContentViewHolder.Blockquote(GemtextBlockquoteBinding.inflate(it))
TYPE_LIST_ITEM -> ContentViewHolder.ListItem(GemtextListItemBinding.inflate(it)) TYPE_LIST_ITEM -> ContentViewHolder.ListItem(GemtextListItemBinding.inflate(it))
else -> error("invalid view type") else -> error("invalid view type")
@ -57,80 +131,45 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ContentViewHolder, position: Int) { override fun onBindViewHolder(holder: ContentViewHolder, position: Int) {
val line = content[position] when (val block = blocks[position]) {
when (holder) { is ContentBlock.Empty -> {}
is ContentViewHolder.Paragraph -> is ContentBlock.Paragraph ->
holder.binding.textView.text = (line as ParagraphLine).text (holder as ContentViewHolder.Paragraph).binding.textView.text = block.text
is ContentViewHolder.Title1 -> holder.binding.textView.text = (line as TitleLine).text is ContentBlock.Blockquote ->
is ContentViewHolder.Title2 -> holder.binding.textView.text = (line as TitleLine).text (holder as ContentViewHolder.Blockquote).binding.textView.text = block.text
is ContentViewHolder.Title3 -> holder.binding.textView.text = (line as TitleLine).text is ContentBlock.ListItem ->
is ContentViewHolder.Link -> { (holder as ContentViewHolder.ListItem).binding.textView.text = "\u25CF ${block.text}"
val text = if ((line as LinkLine).label.isNotEmpty()) line.label else line.url is ContentBlock.Title -> {
val underlined = SpannableString(text) when (block.level) {
underlined.setSpan(UnderlineSpan(), 0, underlined.length, 0) 1 -> (holder as ContentViewHolder.Title1).binding.textView.text = block.text
holder.binding.textView.text = underlined 2 -> (holder as ContentViewHolder.Title2).binding.textView.text = block.text
holder.binding.root.setOnClickListener { listener.onLinkClick(line.url) } 3 -> (holder as ContentViewHolder.Title3).binding.textView.text = block.text
}
} }
is ContentViewHolder.PreText -> is ContentBlock.Link -> {
holder.binding.textView.text = (line as PreTextLine).text val label = if (block.label.isNotBlank()) block.label else block.url
is ContentViewHolder.Blockquote -> val underlinedLabel = SpannableString(label)
holder.binding.textView.text = (line as BlockquoteLine).text underlinedLabel.setSpan(UnderlineSpan(), 0, underlinedLabel.length, 0)
is ContentViewHolder.ListItem -> (holder as ContentViewHolder.Link).binding.textView.text = underlinedLabel
holder.binding.textView.text = "\u25CF ${(line as ListItemLine).text}" holder.binding.root.setOnClickListener { listener.onLinkClick(block.url) }
else -> {} }
is ContentBlock.Pre ->
(holder as ContentViewHolder.Pre).binding.textView.text = block.content
} }
} }
override fun getItemCount(): Int = content.size override fun getItemCount(): Int = blocks.size
/**
* Replace the content rendered by the recycler.
*
* The new content list may or may not be the same object as the previous one, we don't
* assume anything. The assumptions this function do however are:
* - If the new content is empty, we are about to load new content, so clear the recycler.
* - If it's longer than before, we received new streamed content, so *append* data.
* - If it's shorter or the same size than before, we do not notify anything and let the caller
* manage the changes itself.
*/
@SuppressLint("NotifyDataSetChanged")
fun setContent(newContent: List<Line>) {
content = newContent.toList() // Shallow copy to avoid concurrent update issues.
if (content.isEmpty()) {
Log.d(TAG, "setContent: empty content")
notifyDataSetChanged()
} else if (content.size > lastLineCount) {
val numAdded = content.size - lastLineCount
Log.d(TAG, "setContent: added $numAdded items")
notifyItemRangeInserted(lastLineCount, numAdded)
}
lastLineCount = content.size
}
sealed class ContentViewHolder(view: View) : RecyclerView.ViewHolder(view) {
class Empty(val binding: GemtextEmptyBinding) : ContentViewHolder(binding.root)
class Paragraph(val binding: GemtextParagraphBinding) : ContentViewHolder(binding.root)
class Title1(val binding: GemtextTitle1Binding) : ContentViewHolder(binding.root)
class Title2(val binding: GemtextTitle2Binding) : ContentViewHolder(binding.root)
class Title3(val binding: GemtextTitle3Binding) : ContentViewHolder(binding.root)
class Link(val binding: GemtextLinkBinding) : ContentViewHolder(binding.root)
class PreFence(val binding: GemtextEmptyBinding) : ContentViewHolder(binding.root)
class PreText(val binding: GemtextPreformattedBinding) : ContentViewHolder(binding.root)
class Blockquote(val binding: GemtextBlockquoteBinding) : ContentViewHolder(binding.root)
class ListItem(val binding: GemtextListItemBinding) : ContentViewHolder(binding.root)
}
companion object { companion object {
const val TAG = "ContentRecycler" const val TAG = "ContentRecycler"
const val TYPE_EMPTY = 0 const val TYPE_EMPTY = 0
const val TYPE_PARAGRAPH = 1 const val TYPE_TITLE_1 = 1
const val TYPE_TITLE_1 = 2 const val TYPE_TITLE_2 = 2
const val TYPE_TITLE_2 = 3 const val TYPE_TITLE_3 = 3
const val TYPE_TITLE_3 = 4 const val TYPE_PARAGRAPH = 4
const val TYPE_LINK = 5 const val TYPE_LINK = 5
const val TYPE_PRE_FENCE = 6 const val TYPE_PREFORMATTED = 6
const val TYPE_PRE_TEXT = 7 const val TYPE_BLOCKQUOTE = 7
const val TYPE_BLOCKQUOTE = 8 const val TYPE_LIST_ITEM = 8
const val TYPE_LIST_ITEM = 9
} }
} }

View file

@ -21,6 +21,7 @@ class ListItemLine(val text: String) : Line
const val TAG = "Gemtext" const val TAG = "Gemtext"
/** Pipe incoming gemtext data into parsed Lines. */
fun parseData( fun parseData(
inChannel: Channel<ByteArray>, inChannel: Channel<ByteArray>,
charset: Charset, charset: Charset,
@ -51,19 +52,23 @@ fun parseData(
return channel return channel
} }
/** Parse a single line into a Line object. */
private fun parseLine(line: CharBuffer, isPreformatted: Boolean): Line = private fun parseLine(line: CharBuffer, isPreformatted: Boolean): Line =
when { when {
line.isEmpty() -> EmptyLine()
line.startsWith("###") -> TitleLine(3, getCharsFrom(line, 3)) line.startsWith("###") -> TitleLine(3, getCharsFrom(line, 3))
line.startsWith("##") -> TitleLine(2, getCharsFrom(line, 2)) line.startsWith("##") -> TitleLine(2, getCharsFrom(line, 2))
line.startsWith("#") -> TitleLine(1, getCharsFrom(line, 1)) line.startsWith("#") -> TitleLine(1, getCharsFrom(line, 1))
line.startsWith(">") -> BlockquoteLine(getCharsFrom(line, 1))
line.startsWith("```") -> PreFenceLine(getCharsFrom(line, 3)) line.startsWith("```") -> PreFenceLine(getCharsFrom(line, 3))
line.startsWith("* ") -> ListItemLine(getCharsFrom(line, 2)) line.startsWith("* ") -> ListItemLine(getCharsFrom(line, 2))
line.startsWith(">") -> getCharsFrom(line, 1) // eh empty lines in quotes…
.run { if (isBlank()) EmptyLine() else BlockquoteLine(this) }
line.startsWith("=>") -> getCharsFrom(line, 2) line.startsWith("=>") -> getCharsFrom(line, 2)
.split(" ", limit = 2) .split(" ", limit = 2)
.run { LinkLine(get(0), if (size == 2) get(1).trimStart() else "") } .run { LinkLine(get(0), if (size == 2) get(1).trimStart() else "") }
else -> if (isPreformatted) PreTextLine(line.toString()) else ParagraphLine(line.toString()) isPreformatted -> PreTextLine(line.toString())
line.isEmpty() -> EmptyLine()
else -> ParagraphLine(line.toString())
} }
/** Get trimmed string from the char buffer starting from this position. */
private fun getCharsFrom(line: CharBuffer, index: Int) = line.substring(index).trim() private fun getCharsFrom(line: CharBuffer, index: Int) = line.substring(index).trim()

View file

@ -39,7 +39,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
setContentView(binding.root) setContentView(binding.root)
pageViewModel = ViewModelProvider(this)[PageViewModel::class.java] pageViewModel = ViewModelProvider(this)[PageViewModel::class.java]
adapter = ContentAdapter(listOf(), this) adapter = ContentAdapter(this)
binding.contentRecycler.layoutManager = LinearLayoutManager(this) binding.contentRecycler.layoutManager = LinearLayoutManager(this)
binding.contentRecycler.adapter = adapter binding.contentRecycler.adapter = adapter
@ -110,7 +110,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
private fun updateLines(lines: List<Line>) { private fun updateLines(lines: List<Line>) {
Log.d(TAG, "updateLines: ${lines.size} lines") Log.d(TAG, "updateLines: ${lines.size} lines")
adapter.setContent(lines) adapter.setLines(lines)
} }
private fun handleEvent(event: PageViewModel.Event) { private fun handleEvent(event: PageViewModel.Event) {
@ -147,7 +147,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
try { try {
startActivity(Intent(ACTION_VIEW, uri)) startActivity(Intent(ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
alert("Can't open this URL.") alert("Can't find an app to open \"${uri.scheme}\" URLs.")
} }
} }

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<stroke android:width="1dip" android:color="@color/black"/>
</shape>

Binary file not shown.

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:app="http://schemas.android.com/apk/res-auto">
<font app:fontStyle="normal" app:fontWeight="400" app:font="@font/hack"/>
</font-family>

View file

@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="2dp" xmlns:tools="http://schemas.android.com/tools"
android:layout_height="1dp" android:layout_width="0dp"
android:backgroundTint="@color/teal_700" /> android:layout_height="0dp"
android:textSize="8sp"
tools:ignore="SmallSp" />

View file

@ -1,13 +1,19 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android" <HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/text_view"
android:textSize="14sp"
android:textColor="@color/text"
android:typeface="monospace"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_width="match_parent"
android:paddingStart="16dp" android:paddingStart="16dp"
android:paddingEnd="8dp" android:paddingEnd="16dp">
android:paddingTop="2dp" <TextView
android:paddingBottom="2dp" android:id="@+id/text_view"
android:background="@color/purple_200" /> android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/text"
android:fontFamily="@font/preformatted"
android:typeface="monospace"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp" />
</HorizontalScrollView>

View file

@ -6,6 +6,12 @@
<color name="teal_200">#FF03DAC5</color> <color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color> <color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="text">#222222</color> <color name="bright">#F3F4ED</color>
<color name="text_light">#536162</color>
<color name="text">#424642</color>
<color name="orange">#C06014</color>
<color name="orange_dark">#683C19</color>
<color name="teal">#14C0B2</color>
<color name="teal_dark">#147169</color>
</resources> </resources>

View file

@ -7,5 +7,6 @@
<item name="android:paddingEnd">16dp</item> <item name="android:paddingEnd">16dp</item>
<item name="android:paddingTop">2dp</item> <item name="android:paddingTop">2dp</item>
<item name="android:paddingBottom">4dp</item> <item name="android:paddingBottom">4dp</item>
<item name="android:textIsSelectable">true</item>
</style> </style>
</resources> </resources>

View file

@ -2,15 +2,17 @@
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Theme.Comet" parent="Theme.MaterialComponents.DayNight.NoActionBar"> <style name="Theme.Comet" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. --> <!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item> <item name="colorPrimary">@color/orange</item>
<item name="colorPrimaryVariant">@color/purple_500</item> <item name="colorPrimaryVariant">@color/orange_dark</item>
<item name="colorOnPrimary">@color/white</item> <item name="colorOnPrimary">@color/bright</item>
<!-- Secondary brand color. --> <!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item> <item name="colorSecondary">@color/teal</item>
<item name="colorSecondaryVariant">@color/teal_700</item> <item name="colorSecondaryVariant">@color/teal_dark</item>
<item name="colorOnSecondary">@color/black</item> <item name="colorOnSecondary">@color/text</item>
<!-- Status bar color. --> <!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item> <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
<item name="android:windowBackground">@color/bright</item>
<item name="android:progressBackgroundTint">?attr/colorSecondaryVariant</item>
</style> </style>
</resources> </resources>