diff --git a/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt b/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt index 77996f7..6b82d4c 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/ContentAdapter.kt @@ -10,31 +10,106 @@ import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import dev.lowrespalmtree.comet.databinding.* -class ContentAdapter(private var content: List, 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() { - private var lastLineCount = 0 + private var lines = listOf() + private var currentLine = 0 + private var blocks = mutableListOf() + 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) { + 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, 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, 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) { - 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 } } \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt b/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt index fce267d..4f231c9 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/Gemtext.kt @@ -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, 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() \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt index ba29cff..9eea353 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt @@ -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) { 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.") } } diff --git a/app/src/main/res/drawable/devborders.xml b/app/src/main/res/drawable/devborders.xml new file mode 100644 index 0000000..bc9f51f --- /dev/null +++ b/app/src/main/res/drawable/devborders.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/font/hack.ttf b/app/src/main/res/font/hack.ttf new file mode 100644 index 0000000..92a90cb Binary files /dev/null and b/app/src/main/res/font/hack.ttf differ diff --git a/app/src/main/res/font/preformatted.xml b/app/src/main/res/font/preformatted.xml new file mode 100644 index 0000000..87f5035 --- /dev/null +++ b/app/src/main/res/font/preformatted.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_empty.xml b/app/src/main/res/layout/gemtext_empty.xml index 39d6d03..6f8010f 100644 --- a/app/src/main/res/layout/gemtext_empty.xml +++ b/app/src/main/res/layout/gemtext_empty.xml @@ -1,5 +1,7 @@ \ No newline at end of file + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="0dp" + android:layout_height="0dp" + android:textSize="8sp" + tools:ignore="SmallSp" /> \ No newline at end of file diff --git a/app/src/main/res/layout/gemtext_preformatted.xml b/app/src/main/res/layout/gemtext_preformatted.xml index 6c564a8..7f1e035 100644 --- a/app/src/main/res/layout/gemtext_preformatted.xml +++ b/app/src/main/res/layout/gemtext_preformatted.xml @@ -1,13 +1,19 @@ - \ No newline at end of file + android:paddingEnd="16dp"> + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 26e9727..675686a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -6,6 +6,12 @@ #FF03DAC5 #FF018786 #FF000000 - #FFFFFFFF - #222222 + + #F3F4ED + #536162 + #424642 + #C06014 + #683C19 + #14C0B2 + #147169 \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 917a367..d643c97 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -7,5 +7,6 @@ 16dp 2dp 4dp + true \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 97f48d5..80e61c8 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -2,15 +2,17 @@ \ No newline at end of file