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 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>() {
private var lastLineCount = 0
private var lines = listOf<Line>()
private var currentLine = 0
private var blocks = mutableListOf<ContentBlock>()
private var lastBlockCount = 0
interface ContentAdapterListen {
fun onLinkClick(url: String)
}
override fun getItemViewType(position: Int): Int =
when (content[position]) {
is EmptyLine -> TYPE_EMPTY
is ParagraphLine -> TYPE_PARAGRAPH
is LinkLine -> TYPE_LINK
is PreFenceLine -> TYPE_PRE_FENCE
is PreTextLine -> TYPE_PRE_TEXT
is BlockquoteLine -> TYPE_BLOCKQUOTE
is ListItemLine -> TYPE_LIST_ITEM
is TitleLine -> when ((content[position] as TitleLine).level) {
1 -> TYPE_TITLE_1
2 -> TYPE_TITLE_2
3 -> TYPE_TITLE_3
else -> error("invalid title level")
sealed class ContentBlock {
object Empty : ContentBlock()
class Paragraph(val text: String) : ContentBlock()
class Title(val text: String, val level: Int) : ContentBlock()
class Link(val url: String, val label: String) : ContentBlock()
class Pre(val caption: String, var content: String, var closed: Boolean) : ContentBlock()
class Blockquote(val text: String) : ContentBlock()
class ListItem(val text: String) : ContentBlock()
}
/** Replace the content rendered by the recycler. */
@SuppressLint("NotifyDataSetChanged")
fun setLines(newLines: List<Line>) {
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 {
@ -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_3 -> ContentViewHolder.Title3(GemtextTitle3Binding.inflate(it))
TYPE_LINK -> ContentViewHolder.Link(GemtextLinkBinding.inflate(it))
TYPE_PRE_FENCE -> ContentViewHolder.PreFence(GemtextEmptyBinding.inflate(it))
TYPE_PRE_TEXT -> ContentViewHolder.PreText(GemtextPreformattedBinding.inflate(it))
TYPE_PREFORMATTED -> ContentViewHolder.Pre(GemtextPreformattedBinding.inflate(it))
TYPE_BLOCKQUOTE -> ContentViewHolder.Blockquote(GemtextBlockquoteBinding.inflate(it))
TYPE_LIST_ITEM -> ContentViewHolder.ListItem(GemtextListItemBinding.inflate(it))
else -> error("invalid view type")
@ -57,80 +131,45 @@ class ContentAdapter(private var content: List<Line>, private val listener: Cont
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: ContentViewHolder, position: Int) {
val line = content[position]
when (holder) {
is ContentViewHolder.Paragraph ->
holder.binding.textView.text = (line as ParagraphLine).text
is ContentViewHolder.Title1 -> holder.binding.textView.text = (line as TitleLine).text
is ContentViewHolder.Title2 -> holder.binding.textView.text = (line as TitleLine).text
is ContentViewHolder.Title3 -> holder.binding.textView.text = (line as TitleLine).text
is ContentViewHolder.Link -> {
val text = if ((line as LinkLine).label.isNotEmpty()) line.label else line.url
val underlined = SpannableString(text)
underlined.setSpan(UnderlineSpan(), 0, underlined.length, 0)
holder.binding.textView.text = underlined
holder.binding.root.setOnClickListener { listener.onLinkClick(line.url) }
when (val block = blocks[position]) {
is ContentBlock.Empty -> {}
is ContentBlock.Paragraph ->
(holder as ContentViewHolder.Paragraph).binding.textView.text = block.text
is ContentBlock.Blockquote ->
(holder as ContentViewHolder.Blockquote).binding.textView.text = block.text
is ContentBlock.ListItem ->
(holder as ContentViewHolder.ListItem).binding.textView.text = "\u25CF ${block.text}"
is ContentBlock.Title -> {
when (block.level) {
1 -> (holder as ContentViewHolder.Title1).binding.textView.text = block.text
2 -> (holder as ContentViewHolder.Title2).binding.textView.text = block.text
3 -> (holder as ContentViewHolder.Title3).binding.textView.text = block.text
}
}
is ContentViewHolder.PreText ->
holder.binding.textView.text = (line as PreTextLine).text
is ContentViewHolder.Blockquote ->
holder.binding.textView.text = (line as BlockquoteLine).text
is ContentViewHolder.ListItem ->
holder.binding.textView.text = "\u25CF ${(line as ListItemLine).text}"
else -> {}
is ContentBlock.Link -> {
val label = if (block.label.isNotBlank()) block.label else block.url
val underlinedLabel = SpannableString(label)
underlinedLabel.setSpan(UnderlineSpan(), 0, underlinedLabel.length, 0)
(holder as ContentViewHolder.Link).binding.textView.text = underlinedLabel
holder.binding.root.setOnClickListener { listener.onLinkClick(block.url) }
}
is ContentBlock.Pre ->
(holder as ContentViewHolder.Pre).binding.textView.text = block.content
}
}
override fun getItemCount(): Int = content.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)
}
override fun getItemCount(): Int = blocks.size
companion object {
const val TAG = "ContentRecycler"
const val TYPE_EMPTY = 0
const val TYPE_PARAGRAPH = 1
const val TYPE_TITLE_1 = 2
const val TYPE_TITLE_2 = 3
const val TYPE_TITLE_3 = 4
const val TYPE_TITLE_1 = 1
const val TYPE_TITLE_2 = 2
const val TYPE_TITLE_3 = 3
const val TYPE_PARAGRAPH = 4
const val TYPE_LINK = 5
const val TYPE_PRE_FENCE = 6
const val TYPE_PRE_TEXT = 7
const val TYPE_BLOCKQUOTE = 8
const val TYPE_LIST_ITEM = 9
const val TYPE_PREFORMATTED = 6
const val TYPE_BLOCKQUOTE = 7
const val TYPE_LIST_ITEM = 8
}
}

View file

@ -21,6 +21,7 @@ class ListItemLine(val text: String) : Line
const val TAG = "Gemtext"
/** Pipe incoming gemtext data into parsed Lines. */
fun parseData(
inChannel: Channel<ByteArray>,
charset: Charset,
@ -51,19 +52,23 @@ fun parseData(
return channel
}
/** Parse a single line into a Line object. */
private fun parseLine(line: CharBuffer, isPreformatted: Boolean): Line =
when {
line.isEmpty() -> EmptyLine()
line.startsWith("###") -> TitleLine(3, getCharsFrom(line, 3))
line.startsWith("##") -> TitleLine(2, getCharsFrom(line, 2))
line.startsWith("#") -> TitleLine(1, getCharsFrom(line, 1))
line.startsWith(">") -> BlockquoteLine(getCharsFrom(line, 1))
line.startsWith("```") -> PreFenceLine(getCharsFrom(line, 3))
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)
.split(" ", limit = 2)
.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()

View file

@ -39,7 +39,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
setContentView(binding.root)
pageViewModel = ViewModelProvider(this)[PageViewModel::class.java]
adapter = ContentAdapter(listOf(), this)
adapter = ContentAdapter(this)
binding.contentRecycler.layoutManager = LinearLayoutManager(this)
binding.contentRecycler.adapter = adapter
@ -110,7 +110,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
private fun updateLines(lines: List<Line>) {
Log.d(TAG, "updateLines: ${lines.size} lines")
adapter.setContent(lines)
adapter.setLines(lines)
}
private fun handleEvent(event: PageViewModel.Event) {
@ -147,7 +147,7 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen {
try {
startActivity(Intent(ACTION_VIEW, uri))
} 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"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="2dp"
android:layout_height="1dp"
android:backgroundTint="@color/teal_700" />
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="0dp"
android:layout_height="0dp"
android:textSize="8sp"
tools:ignore="SmallSp" />

View file

@ -1,13 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView 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"
<HorizontalScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:background="@color/purple_200" />
android:paddingEnd="16dp">
<TextView
android:id="@+id/text_view"
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_700">#FF018786</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>

View file

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

View file

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