Add phase 4: markdown highlighting, tap to toggle, JetBrains Mono
Highlighting styles markers in place rather than hiding them, so the offset mapping is the identity and cursor maths needs no translation. The span finding is deliberately free of Compose types, which puts the offset arithmetic under 25 JVM unit tests instead of under a phone. Several cases come straight from the corpus: `*arr` must not italicise, `- [ ]` must not parse as a link, snake_case must be left alone. Tap to toggle did nothing on the first attempt, and the reason was not where it looked. A diagnostic showed the handler firing and resolving exactly the right offsets, so detection was fine. The release was the problem: BasicTextField consumes the tap, and waitForUpOrCancellation returns null for a consumed pointer, so the press arrived and the release never did. Leaving the press unconsumed, to protect ordinary cursor placement, was what handed the gesture away. Now the press is claimed in the Initial pass, but only once the offset is confirmed to be inside a bracket span, so every other tap is untouched. The same diagnostic disproved a plausible worry, that a scrolled field would mis-resolve taps because getOffsetForPosition works in layout coordinates and pointerInput reports viewport ones. The offsets were correct, so a correct piece of code was left alone. Bundled the no-ligature cut of JetBrains Mono. A ligature draws two characters as one glyph, which is at odds with an editor built on every character staying visible. Static faces rather than the variable file, which needs an experimental API, and a real bold rather than a synthesised one. OFL text is in licenses/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
This commit is contained in:
1 parent
1e91ff8f07
commit
bb66567725
9 files changed
+536
-5
No files matched your search
+29
@@ -264,6 +264,35 @@ A second bug in the same few lines: `Modifier.safeDrawingPadding()` in the
|
||||
activity **already includes the IME inset**, and the screen then applied
|
||||
`imePadding()` on top, so the keyboard inset was counted twice.
|
||||
|
||||
## An unconsumed press means waitForUpOrCancellation never returns
|
||||
|
||||
Tap to toggle did nothing at all, while a diagnostic log showed the gesture
|
||||
handler firing on every tap and resolving the correct offset. Tapping the line
|
||||
`- [ ] Take some time to build out...` produced offsets 289 through 292, and
|
||||
that line's bracket span is exactly 289..292, so detection was never the
|
||||
problem.
|
||||
|
||||
The problem was two lines further on. `BasicTextField` handles the tap itself
|
||||
and consumes the pointer, and `waitForUpOrCancellation` returns **null** for a
|
||||
pointer somebody else has consumed. The press arrived, the release never did,
|
||||
and nothing ever toggled.
|
||||
|
||||
This was self inflicted. The press was deliberately left unconsumed so that
|
||||
ordinary cursor placement and scrolling would keep working, and that choice
|
||||
handed the gesture to the text field. The fix is to claim the press in
|
||||
`PointerEventPass.Initial`, which runs outer first, but only once
|
||||
`checkboxSpanAt` has confirmed the press actually landed on a box. Every other
|
||||
tap stays unclaimed and behaves exactly as before.
|
||||
|
||||
The cost is that a scroll begun with a finger already on a `[ ]` will not
|
||||
scroll. The target is three characters wide, so this is a fair trade.
|
||||
|
||||
Worth noting what was *not* wrong. `getOffsetForPosition` takes layout
|
||||
coordinates while `pointerInput` reports viewport coordinates, so a scrolled
|
||||
field looked like it should mis-resolve taps. The logged offsets show it does
|
||||
not. Instrumenting first turned a plausible theory into a measured fact and
|
||||
saved rewriting a correct piece of code.
|
||||
|
||||
## Reconciliation, phase 0, completed 2026-09-09
|
||||
|
||||
| File | Resolution |
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.example.todo.editor
|
||||
|
||||
/**
|
||||
* Finding markup in plain text. Deliberately free of Compose types so the
|
||||
* offset arithmetic, which is the part that actually goes wrong, can be tested
|
||||
* on the JVM rather than on a phone.
|
||||
*
|
||||
* Every character is kept, markers included. That is what lets the editor use
|
||||
* an identity OffsetMapping, and it is why cursor and selection maths stay
|
||||
* simply correct.
|
||||
*/
|
||||
enum class Mark { H1, H2, H3, H4, BOLD, ITALIC, CODE, STRIKE, LINK, DONE }
|
||||
|
||||
/** Half open: [start, end). */
|
||||
data class MarkSpan(val start: Int, val end: Int, val mark: Mark)
|
||||
|
||||
private val HEADING = Regex("""^(#{1,4})\s""")
|
||||
private val DONE_LINE = Regex("""^\s*[-*+]\s+\[[xX]]""")
|
||||
|
||||
// Ordered by priority. Code wins over everything, so a `**` inside backticks
|
||||
// stays literal. Two-character markers are matched before their one-character
|
||||
// counterparts, otherwise `**bold**` would read as an empty italic.
|
||||
private val INLINE = listOf(
|
||||
Regex("""`[^`\n]+`""") to Mark.CODE,
|
||||
Regex("""\[[^\[\]\n]*]\([^)\n]*\)""") to Mark.LINK,
|
||||
Regex("""\*\*[^*\n]+\*\*""") to Mark.BOLD,
|
||||
Regex("""~~[^~\n]+~~""") to Mark.STRIKE,
|
||||
Regex("""(?<!~)~[^~\n]+~(?!~)""") to Mark.STRIKE,
|
||||
Regex("""(?<![*\w])\*[^*\n]+\*(?![*\w])""") to Mark.ITALIC,
|
||||
Regex("""(?<![_\w])_[^_\n]+_(?![_\w])""") to Mark.ITALIC,
|
||||
)
|
||||
|
||||
fun scan(text: String): List<MarkSpan> {
|
||||
val spans = mutableListOf<MarkSpan>()
|
||||
|
||||
forEachLine(text) { start, end ->
|
||||
val line = text.substring(start, end)
|
||||
HEADING.find(line)?.let {
|
||||
spans += MarkSpan(start, end, when (it.groupValues[1].length) {
|
||||
1 -> Mark.H1
|
||||
2 -> Mark.H2
|
||||
3 -> Mark.H3
|
||||
else -> Mark.H4
|
||||
})
|
||||
}
|
||||
if (DONE_LINE.containsMatchIn(line)) spans += MarkSpan(start, end, Mark.DONE)
|
||||
}
|
||||
|
||||
// Line marks do not consume, so inline styling still applies inside a
|
||||
// heading or a finished item.
|
||||
val taken = BooleanArray(text.length)
|
||||
for ((re, mark) in INLINE) {
|
||||
for (m in re.findAll(text)) {
|
||||
val r = m.range
|
||||
if (r.isEmpty() || (r.first..r.last).any { taken[it] }) continue
|
||||
for (i in r.first..r.last) taken[i] = true
|
||||
spans += MarkSpan(r.first, r.last + 1, mark)
|
||||
}
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
private inline fun forEachLine(text: String, body: (Int, Int) -> Unit) {
|
||||
var start = 0
|
||||
while (start <= text.length) {
|
||||
val nl = text.indexOf('\n', start).let { if (it < 0) text.length else it }
|
||||
body(start, nl)
|
||||
if (nl == text.length) return
|
||||
start = nl + 1
|
||||
}
|
||||
}
|
||||
|
||||
/** The `[ ]` or `[x]` of a checkbox, as a half open range over the source. */
|
||||
data class CheckboxSpan(val start: Int, val end: Int, val checked: Boolean)
|
||||
|
||||
private val CHECKBOX = Regex("""^(\s*[-*+]\s+)\[([ xX?])]""")
|
||||
|
||||
/**
|
||||
* The checkbox whose bracket span covers [offset], or null.
|
||||
*
|
||||
* The span is the three characters of the box alone, not the whole line, so a
|
||||
* tap anywhere else keeps its ordinary meaning of placing the cursor.
|
||||
*/
|
||||
fun checkboxSpanAt(text: String, offset: Int): CheckboxSpan? {
|
||||
if (offset < 0 || offset > text.length) return null
|
||||
val lineStart = text.lastIndexOf('\n', (offset - 1).coerceAtLeast(0))
|
||||
.let { if (it < 0) 0 else it + 1 }
|
||||
val lineEnd = text.indexOf('\n', lineStart).let { if (it < 0) text.length else it }
|
||||
val m = CHECKBOX.find(text.substring(lineStart, lineEnd)) ?: return null
|
||||
|
||||
val start = lineStart + m.groupValues[1].length
|
||||
val end = start + 3
|
||||
if (offset !in start..end) return null
|
||||
return CheckboxSpan(start, end, m.groupValues[2].lowercase() == "x")
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips one checkbox. `[ ]` and `[x]` are both three characters wide, so the
|
||||
* text length never changes and every offset after it survives untouched.
|
||||
*/
|
||||
fun toggleCheckbox(text: String, span: CheckboxSpan): String =
|
||||
text.substring(0, span.start) +
|
||||
(if (span.checked) "[ ]" else "[x]") +
|
||||
text.substring(span.end)
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.example.todo.editor
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Styles markdown in place, leaving every marker visible.
|
||||
*
|
||||
* Because no character is added or removed the mapping is the identity, which
|
||||
* is the whole reason for styling in source mode: cursor and selection offsets
|
||||
* need no translation and so cannot drift.
|
||||
*/
|
||||
class MarkdownHighlighter(
|
||||
private val onSurface: Color,
|
||||
private val codeBackground: Color,
|
||||
private val linkColor: Color,
|
||||
private val baseSize: TextUnit = 14.sp,
|
||||
) : VisualTransformation {
|
||||
|
||||
// filter runs on every keystroke over the whole document. Notes are a few
|
||||
// kilobytes so this is cheap, but recomposition calls it far more often
|
||||
// than the text actually changes.
|
||||
private var lastSource: String? = null
|
||||
private var lastResult: AnnotatedString? = null
|
||||
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val source = text.text
|
||||
val styled = lastResult.takeIf { lastSource == source } ?: annotate(source).also {
|
||||
lastSource = source
|
||||
lastResult = it
|
||||
}
|
||||
return TransformedText(styled, OffsetMapping.Identity)
|
||||
}
|
||||
|
||||
private fun annotate(source: String) = AnnotatedString.Builder(source).apply {
|
||||
// Line marks first so inline styling layers on top of them.
|
||||
val spans = scan(source).sortedBy { it.mark.ordinal }
|
||||
for (s in spans) addStyle(styleFor(s.mark), s.start, s.end)
|
||||
}.toAnnotatedString()
|
||||
|
||||
private fun styleFor(mark: Mark): SpanStyle = when (mark) {
|
||||
Mark.H1 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = baseSize * 1.45f)
|
||||
Mark.H2 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = baseSize * 1.28f)
|
||||
Mark.H3 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = baseSize * 1.14f)
|
||||
Mark.H4 -> SpanStyle(fontWeight = FontWeight.Bold, fontSize = baseSize)
|
||||
// Dimmed and struck, so a finished item reads as done without hiding it.
|
||||
Mark.DONE -> SpanStyle(
|
||||
color = onSurface.copy(alpha = 0.45f),
|
||||
textDecoration = TextDecoration.LineThrough,
|
||||
)
|
||||
Mark.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
Mark.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
// The body is already monospace, so a code span is distinguished by its
|
||||
// tint alone. No colour is set, so this does not undo a dimmed line.
|
||||
Mark.CODE -> SpanStyle(background = codeBackground)
|
||||
Mark.STRIKE -> SpanStyle(textDecoration = TextDecoration.LineThrough)
|
||||
Mark.LINK -> SpanStyle(color = linkColor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.todo.theme
|
||||
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.example.todo.R
|
||||
|
||||
/**
|
||||
* JetBrains Mono, bundled rather than fetched so the editor looks the same with
|
||||
* no network. Monospace because the notes nest by indentation, and proportional
|
||||
* glyphs stop those columns lining up.
|
||||
*
|
||||
* The NL cut has no ligatures, which is the point: a ligature draws two
|
||||
* characters as one glyph, and this editor is built on every character staying
|
||||
* visible and every offset staying honest. A real bold face is bundled too,
|
||||
* because a synthesised one looks smeared beside the regular.
|
||||
*/
|
||||
val JetBrainsMono = FontFamily(
|
||||
Font(R.font.jetbrains_mono_nl_regular, FontWeight.Normal),
|
||||
Font(R.font.jetbrains_mono_nl_bold, FontWeight.Bold),
|
||||
)
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.example.todo.ui.note
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -12,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -19,12 +23,20 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.todo.editor.MarkdownHighlighter
|
||||
import com.example.todo.theme.JetBrainsMono
|
||||
import com.example.todo.editor.checkboxSpanAt
|
||||
import com.example.todo.editor.toggleCheckbox
|
||||
|
||||
@Composable
|
||||
fun NoteScreen(viewModel: NoteViewModel = viewModel(), modifier: Modifier = Modifier) {
|
||||
@@ -35,6 +47,32 @@ fun NoteScreen(viewModel: NoteViewModel = viewModel(), modifier: Modifier = Modi
|
||||
return
|
||||
}
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val highlighter = remember(scheme) {
|
||||
MarkdownHighlighter(
|
||||
onSurface = scheme.onBackground,
|
||||
codeBackground = scheme.surfaceVariant,
|
||||
linkColor = scheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
// The editor owns cursor and selection; the view model owns the text that
|
||||
// gets synced. They are only forced back together when the text changes
|
||||
// from underneath, which means a fetch replaced it.
|
||||
val field = remember { mutableStateOf(TextFieldValue(state.text)) }
|
||||
LaunchedEffect(state.text) {
|
||||
if (state.text != field.value.text) {
|
||||
val where = field.value.selection.start.coerceIn(0, state.text.length)
|
||||
field.value = TextFieldValue(state.text, TextRange(where))
|
||||
}
|
||||
}
|
||||
val layout = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
|
||||
fun update(new: TextFieldValue) {
|
||||
field.value = new
|
||||
viewModel.onTextChange(new.text)
|
||||
}
|
||||
|
||||
Column(modifier.fillMaxSize().padding(12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
@@ -52,11 +90,39 @@ fun NoteScreen(viewModel: NoteViewModel = viewModel(), modifier: Modifier = Modi
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = state.text,
|
||||
onValueChange = viewModel::onTextChange,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
value = field.value,
|
||||
onValueChange = ::update,
|
||||
onTextLayout = { layout.value = it },
|
||||
visualTransformation = highlighter,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
// Claimed in the Initial pass, which runs outer first,
|
||||
// and only for a press that actually lands on a box.
|
||||
// Leaving it unclaimed lets the text field consume it,
|
||||
// and a consumed pointer makes waitForUpOrCancellation
|
||||
// return null, so the release never arrives here.
|
||||
val down = awaitFirstDown(
|
||||
requireUnconsumed = false,
|
||||
pass = PointerEventPass.Initial,
|
||||
)
|
||||
val at = layout.value ?: return@awaitEachGesture
|
||||
val text = field.value.text
|
||||
val span = checkboxSpanAt(text, at.getOffsetForPosition(down.position))
|
||||
?: return@awaitEachGesture
|
||||
|
||||
down.consume()
|
||||
val up = waitForUpOrCancellation() ?: return@awaitEachGesture
|
||||
up.consume()
|
||||
// Same box on release, so a tap rather than a drag.
|
||||
if (checkboxSpanAt(text, at.getOffsetForPosition(up.position)) == span) {
|
||||
update(field.value.copy(text = toggleCheckbox(text, span)))
|
||||
}
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = JetBrainsMono,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,150 @@
|
||||
package com.example.todo.editor
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownTest {
|
||||
|
||||
private fun marks(s: String) = scan(s).map { it.mark }.toSet()
|
||||
|
||||
private fun textOf(s: String, m: Mark) =
|
||||
scan(s).filter { it.mark == m }.map { s.substring(it.start, it.end) }
|
||||
|
||||
@Test fun `markers are kept, so spans cover them`() {
|
||||
assertEquals(listOf("**bold**"), textOf("a **bold** b", Mark.BOLD))
|
||||
}
|
||||
|
||||
@Test fun `heading level sets the mark`() {
|
||||
assertTrue(Mark.H1 in marks("# 2026-09-09"))
|
||||
assertTrue(Mark.H2 in marks("## To-Do"))
|
||||
assertTrue(Mark.H3 in marks("### Personal"))
|
||||
assertTrue(Mark.H4 in marks("#### Computer"))
|
||||
}
|
||||
|
||||
@Test fun `five hashes is not a heading`() {
|
||||
assertTrue(marks("##### too deep").none { it in setOf(Mark.H1, Mark.H2, Mark.H3, Mark.H4) })
|
||||
}
|
||||
|
||||
@Test fun `a finished item marks the whole line`() {
|
||||
val s = "- [x] Climb"
|
||||
val done = scan(s).single { it.mark == Mark.DONE }
|
||||
assertEquals(s, s.substring(done.start, done.end))
|
||||
}
|
||||
|
||||
@Test fun `an unfinished item is not marked done`() {
|
||||
assertTrue(Mark.DONE !in marks("- [ ] Climb"))
|
||||
assertTrue(Mark.DONE !in marks(" - [?] Cincinnati Trash"))
|
||||
}
|
||||
|
||||
@Test fun `done marks only its own line`() {
|
||||
val s = "- [x] done\n- [ ] open"
|
||||
val done = scan(s).single { it.mark == Mark.DONE }
|
||||
assertEquals("- [x] done", s.substring(done.start, done.end))
|
||||
}
|
||||
|
||||
@Test fun `bold wins over italic`() {
|
||||
assertEquals(listOf("**bold**"), textOf("**bold**", Mark.BOLD))
|
||||
assertTrue(Mark.ITALIC !in marks("**bold**"))
|
||||
}
|
||||
|
||||
@Test fun `both tilde forms strike`() {
|
||||
assertEquals(listOf("~one~"), textOf("~one~", Mark.STRIKE))
|
||||
assertEquals(listOf("~~two~~"), textOf("~~two~~", Mark.STRIKE))
|
||||
}
|
||||
|
||||
@Test fun `code beats other markup inside it`() {
|
||||
val s = "`a **b** c`"
|
||||
assertEquals(listOf(s), textOf(s, Mark.CODE))
|
||||
assertTrue(Mark.BOLD !in marks(s))
|
||||
}
|
||||
|
||||
@Test fun `links are one span`() {
|
||||
assertEquals(listOf("[text](http://x)"), textOf("see [text](http://x) here", Mark.LINK))
|
||||
}
|
||||
|
||||
@Test fun `a checkbox is not mistaken for a link`() {
|
||||
assertTrue(Mark.LINK !in marks("- [ ] not a link"))
|
||||
assertTrue(Mark.LINK !in marks("- [x] also not"))
|
||||
}
|
||||
|
||||
@Test fun `a lone asterisk does not italicise`() {
|
||||
// Real line from the corpus.
|
||||
val s = "- [x] Have Claude go through my stranded downloads and get them in *arr"
|
||||
assertTrue(Mark.ITALIC !in marks(s))
|
||||
}
|
||||
|
||||
@Test fun `underscores inside a word do not italicise`() {
|
||||
assertTrue(Mark.ITALIC !in marks("snake_case_name"))
|
||||
}
|
||||
|
||||
@Test fun `markup does not run across lines`() {
|
||||
assertTrue(Mark.BOLD !in marks("**a\nb**"))
|
||||
}
|
||||
|
||||
@Test fun `inline markup still applies inside a heading`() {
|
||||
assertTrue(Mark.CODE in marks("## the `CspRouter`"))
|
||||
}
|
||||
|
||||
@Test fun `spans stay inside the text`() {
|
||||
val s = "# h\n- [x] d **b** `c` ~s~ [l](u)\n"
|
||||
assertTrue(scan(s).all { it.start >= 0 && it.end <= s.length && it.start < it.end })
|
||||
}
|
||||
}
|
||||
|
||||
class CheckboxTest {
|
||||
|
||||
@Test fun `offset inside the box finds it`() {
|
||||
val s = "- [ ] Climb"
|
||||
val span = checkboxSpanAt(s, 3)!!
|
||||
assertEquals("[ ]", s.substring(span.start, span.end))
|
||||
assertEquals(false, span.checked)
|
||||
}
|
||||
|
||||
@Test fun `offset on the text does not find it`() {
|
||||
assertNull(checkboxSpanAt("- [ ] Climb", 8))
|
||||
}
|
||||
|
||||
@Test fun `offset on the bullet does not find it`() {
|
||||
assertNull(checkboxSpanAt("- [ ] Climb", 0))
|
||||
}
|
||||
|
||||
@Test fun `indented checkboxes resolve against their own line`() {
|
||||
val s = "## To-Do\n - [x] Runway"
|
||||
val span = checkboxSpanAt(s, s.indexOf("[x]") + 1)!!
|
||||
assertEquals("[x]", s.substring(span.start, span.end))
|
||||
assertEquals(true, span.checked)
|
||||
}
|
||||
|
||||
@Test fun `toggling preserves length and every later offset`() {
|
||||
val s = "- [ ] a\n- [x] b"
|
||||
val span = checkboxSpanAt(s, 3)!!
|
||||
val out = toggleCheckbox(s, span)
|
||||
assertEquals(s.length, out.length)
|
||||
assertEquals("- [x] a\n- [x] b", out)
|
||||
}
|
||||
|
||||
@Test fun `toggling back is the identity`() {
|
||||
val s = " - [x] done"
|
||||
val once = toggleCheckbox(s, checkboxSpanAt(s, 5)!!)
|
||||
val twice = toggleCheckbox(once, checkboxSpanAt(once, 5)!!)
|
||||
assertEquals(s, twice)
|
||||
}
|
||||
|
||||
@Test fun `a question box toggles to done`() {
|
||||
val s = "- [?] unsure"
|
||||
assertEquals("- [x] unsure", toggleCheckbox(s, checkboxSpanAt(s, 3)!!))
|
||||
}
|
||||
|
||||
@Test fun `out of range offsets are safe`() {
|
||||
assertNull(checkboxSpanAt("- [ ] a", -1))
|
||||
assertNull(checkboxSpanAt("- [ ] a", 999))
|
||||
assertNull(checkboxSpanAt("", 0))
|
||||
}
|
||||
|
||||
@Test fun `a line that is not a checkbox yields nothing`() {
|
||||
assertNull(checkboxSpanAt("## To-Do", 3))
|
||||
assertNull(checkboxSpanAt(" - plain bullet", 4))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Reference in new issue
Block a user