package com.vgmlr.wedge
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Locale
import java.util.Calendar
data class EditorResult(
val text: TextFieldValue,
val undoHistory: List<TextFieldValue>,
val undoLineIndex: Int,
val lastDel: Long
)
object WedgeEditorEngine {
fun removeTopDay(text: String, format: String = WedgeConfig.HEADER_FORMAT_DEFAULT): String {
val headerRegex = WedgeHeader.matchRegex(format)
val match = headerRegex.find(text) ?: return text
val headerLineStart = text.lastIndexOf('\n', match.range.first)
.let { if (it == -1) 0 else it + 1 }
val headerLineEnd = text.indexOf('\n', match.range.first)
if (headerLineEnd == -1) {
return text.substring(0, headerLineStart)
}
var scan = headerLineEnd + 1
var deleteEnd = text.length
while (scan <= text.length) {
val nextNl = text.indexOf('\n', scan)
val lineEnd = if (nextNl == -1) text.length else nextNl
if (text.substring(scan, lineEnd).isBlank()) {
deleteEnd = if (nextNl == -1) text.length else nextNl + 1
break
}
if (nextNl == -1) break
scan = nextNl + 1
}
return text.removeRange(headerLineStart, deleteEnd)
}
// auto prune
fun removePastDays(text: String, format: String = WedgeConfig.HEADER_FORMAT_DEFAULT): String {
val head = WedgeHeader.matchRegex(format)
val now = Calendar.getInstance()
fun day(d: Int) = WedgeHeader.dateRegex(
format,
(now.clone() as Calendar).apply { add(Calendar.DAY_OF_MONTH, d) }.timeInMillis
)
val past = (1..365).map { day(-it) }
val keep = (0..365).map { day(it) }
var out = text
var from = 0
while (true) {
val m = head.find(out, from) ?: return out
val stale = past.any { it.containsMatchIn(m.value) } &&
keep.none { it.containsMatchIn(m.value) }
if (stale) out = dropBlock(out, m.range.first, head)
else from = m.range.last + 1
}
}
private fun dropBlock(text: String, start: Int, head: Regex): String {
val first = text.indexOf('\n', start)
if (first == -1) return text.substring(0, start)
var scan = first + 1
while (scan < text.length) {
val nl = text.indexOf('\n', scan)
val end = if (nl == -1) text.length else nl
val line = text.substring(scan, end)
if (line.isBlank()) return text.removeRange(start, if (nl == -1) end else nl + 1)
if (head.containsMatchIn(line)) return text.removeRange(start, scan)
if (nl == -1) break
scan = nl + 1
}
return text.substring(0, start)
}
fun applyTextChange(
newValue: TextFieldValue,
textState: TextFieldValue,
undoHistory: List<TextFieldValue>,
undoLineIndex: Int,
lastDel: Long
): EditorResult {
var currentUndoHistory = undoHistory
var currentUndoLineIndex = undoLineIndex
var currentLastDel = lastDel
if (newValue.text.length < textState.text.length) {
val currentLine = textState.text.substring(
0,
textState.selection.start.coerceIn(0, textState.text.length)
).count { it == '\n' }
if (currentUndoHistory.isEmpty() || currentLine != currentUndoLineIndex) {
currentUndoHistory = (listOf(textState) + currentUndoHistory).take(2)
currentUndoLineIndex = currentLine
}
} else if (newValue.text != textState.text && currentUndoHistory.isNotEmpty()) {
val currentLine = newValue.text.substring(
0,
newValue.selection.start.coerceIn(0, newValue.text.length)
).count { it == '\n' }
if (currentLine != currentUndoLineIndex) currentUndoHistory = emptyList()
}
if (newValue.text.length < textState.text.length) {
val now = System.currentTimeMillis()
if (now - currentLastDel < 45) return EditorResult(textState, currentUndoHistory, currentUndoLineIndex, currentLastDel)
currentLastDel = now
val oldText = textState.text
val oldSel = textState.selection
if ((oldText.length - newValue.text.length) > 1 && oldSel.collapsed) {
val start = (oldSel.start - 1).coerceAtLeast(0)
val suppressedText = oldText.removeRange(start, oldSel.start)
return EditorResult(
newValue.copy(text = suppressedText, selection = TextRange(start)),
currentUndoHistory, currentUndoLineIndex, currentLastDel
)
}
val expectedSuffix = oldText.substring(oldSel.max)
val actualSuffix =
newValue.text.substring(newValue.selection.max.coerceAtMost(newValue.text.length))
if (expectedSuffix != actualSuffix) {
val safePrefix = newValue.text.substring(
0,
newValue.selection.max.coerceAtMost(newValue.text.length)
)
return EditorResult(
newValue.copy(text = safePrefix + expectedSuffix),
currentUndoHistory, currentUndoLineIndex, currentLastDel
)
} else {
return EditorResult(newValue, currentUndoHistory, currentUndoLineIndex, currentLastDel)
}
}
var updatedValue = newValue
val lengthDelta = newValue.text.length - textState.text.length
val newCursor = newValue.selection.start
val txt = newValue.text
if (lengthDelta == 1 && newCursor > 0 && newCursor <= txt.length && (txt[newCursor - 1] == '\n' || txt[newCursor - 1] == ' ')) {
try {
val textBeforeCursor =
txt.substring(0, (newCursor - 1).coerceAtLeast(0))
val lastNewlineIndex = textBeforeCursor.lastIndexOf('\n')
val currentLine =
if (lastNewlineIndex == -1) textBeforeCursor else textBeforeCursor.substring(
lastNewlineIndex + 1
)
val dateMatch = WedgeRegex.DATE_MATH_TRIGGER.find(currentLine)
val mathMatch = WedgeRegex.MATH_TRIGGER.find(currentLine)
val hrMatch = WedgeRegex.HR_TRIGGER.find(currentLine)
val dashMatch = WedgeRegex.DASH_TRIGGER.find(currentLine)
val listMatch = WedgeRegex.LIST_TRIGGER.find(currentLine)
val bulletMatch = WedgeRegex.BULLET_TRIGGER.find(currentLine)
if (dateMatch != null) {
val d = dateMatch.groupValues[1].toInt()
val m = dateMatch.groupValues[2]
val y = dateMatch.groupValues[3].toIntOrNull()
val dateResult = WedgeDate.getDateMath(d, m, y)
if (dateResult != null) {
val textBeforeLine =
txt.substring(0, (newCursor - 1).coerceAtLeast(0) - 3)
val textAfterLine =
txt.substring((if (txt[newCursor - 1] == ' ') newCursor else newCursor - 1).coerceAtMost(txt.length))
val finalNewText =
textBeforeLine + dateResult + textAfterLine
updatedValue = newValue.copy(
text = finalNewText,
selection = TextRange(newCursor + dateResult.length - 3 - (if (txt[newCursor - 1] == ' ') 1 else 0))
)
}
} else if (mathMatch != null) {
val result = evaluateMathSequential(mathMatch.value)
if (result != null) {
val formattedResult = fmt(result)
val textBeforeLine =
txt.substring(0, (newCursor - 1).coerceAtLeast(0))
val textAfterLine =
txt.substring((if (txt[newCursor - 1] == ' ') newCursor else newCursor - 1).coerceAtMost(txt.length))
val finalNewText =
textBeforeLine + formattedResult + cascade(formattedResult, textAfterLine)
updatedValue = newValue.copy(
text = finalNewText,
selection = TextRange(newCursor + formattedResult.length - (if (txt[newCursor - 1] == ' ') 1 else 0))
)
}
} else if (hrMatch != null) {
val count = hrMatch.groupValues[1].toIntOrNull() ?: 0
if (count > 0) {
val hrline = "—".repeat(count.coerceAtMost(100))
val matchStart = (newCursor - 1) - hrMatch.value.length
val finalNewText = txt.substring(0, matchStart) + hrline + txt.substring(newCursor)
updatedValue = newValue.copy(
text = finalNewText,
selection = TextRange(matchStart + hrline.length)
)
}
} else if (dashMatch != null) {
val counts = dashMatch.groupValues[1].toIntOrNull() ?: 0
if (counts > 0) {
val dashes = "— ".repeat(counts.coerceAtMost(100))
val dashStart = (newCursor - 1) - dashMatch.value.length
val finalNewText = txt.substring(0, dashStart) + dashes + txt.substring(newCursor)
updatedValue = newValue.copy(
text = finalNewText,
selection = TextRange(dashStart + dashes.length)
)
}
} else if (listMatch != null && txt[newCursor - 1] == '\n') {
val ws = listMatch.groupValues[1]
val currentNum = listMatch.groupValues[2].toInt()
val prefix = "$ws${currentNum + 1}. "
val textBeforeLine = txt.substring(0, newCursor)
val textAfterLine = txt.substring(newCursor)
val lines = textAfterLine.split("\n").toMutableList()
var nextExpected = currentNum + 1
for (i in lines.indices) {
val m = WedgeRegex.LIST_TRIGGER.find(lines[i])
if (m != null) {
val lws = m.groupValues[1]
val foundNum = m.groupValues[2].toInt()
if (lws == ws && foundNum == nextExpected) {
lines[i] =
"$lws${foundNum + 1}. " + lines[i].substring(m.range.last + 1)
nextExpected++
} else break
} else if (i > 0) break
}
val finalNewText =
textBeforeLine + prefix + lines.joinToString("\n")
updatedValue = newValue.copy(
text = finalNewText,
selection = TextRange(newCursor + prefix.length)
)
} else if (bulletMatch != null && txt[newCursor - 1] == '\n') {
val prefix = "${bulletMatch.groupValues[1]}${bulletMatch.groupValues[2]} "
val finalNewText = txt.substring(0, newCursor) + prefix + txt.substring(newCursor)
updatedValue = newValue.copy(
text = finalNewText,
selection = TextRange(newCursor + prefix.length)
)
} else if (txt[newCursor - 1] == '\n') {
val indent = currentLine.takeWhile { it == ' ' || it == '\t' }
if (indent.isNotEmpty()) {
val finalNewText = txt.substring(0, newCursor) + indent + txt.substring(newCursor)
updatedValue = newValue.copy(
text = finalNewText,
selection = TextRange(newCursor + indent.length)
)
}
}
} catch (_: Exception) {
}
}
return EditorResult(updatedValue, currentUndoHistory, currentUndoLineIndex, currentLastDel)
}
fun findNextTimeHighlight(text: String, format: String = WedgeConfig.HEADER_FORMAT_DEFAULT): Int? {
val now = Calendar.getInstance()
val currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
val headerRegex = WedgeHeader.dateRegex(format, now.timeInMillis)
val match = headerRegex.find(text) ?: return null
val headerIdx = match.range.first
val sub = text.substring(headerIdx)
val lines = sub.split("\n")
var charOffset = headerIdx
for (i in 1 until lines.size) {
val line = lines[i]
charOffset += lines[i - 1].length + 1
if (line.trim().isEmpty()) return null
val timeMatch = WedgeRegex.TIME_ENTRY.find(line)
val countdownMatch = WedgeRegex.COUNTDOWN_TIME.find(line)
val targetMatch = countdownMatch ?: timeMatch
if (targetMatch != null) {
val h = targetMatch.groupValues[1].toInt()
val m = targetMatch.groupValues[2].toInt()
if (h * 60 + m > currentMinutes) return charOffset
}
}
return null
}
private fun fmt(d: Double): String =
DecimalFormat("0.##", DecimalFormatSymbols(Locale.US)).format(d)
// chain recalc
private fun cascade(seed: String, rest: String): String {
var acc = seed.toDoubleOrNull() ?: return rest
var src = rest
val out = StringBuilder()
while (true) {
val m = WedgeRegex.MATH_CHAIN.find(src) ?: break
val v = applyOps(acc, m.groupValues[1]) ?: break
val shown = fmt(v)
acc = shown.toDoubleOrNull() ?: break
out.append(m.groupValues[1]).append('=').append(shown)
src = src.substring(m.range.last + 1)
}
return out.toString() + src
}
private fun evaluateMathSequential(expression: String): Double? {
val head = expression.takeWhile { it !in "+-/*=" }
return applyOps(head.toDoubleOrNull() ?: return null, expression.substring(head.length))
}
private fun applyOps(seed: Double, tail: String): Double? {
try {
val ops = tail.filter { it in "+-/*" }
val terms = tail.split(WedgeRegex.MATH_SPLIT).filter { it.isNotEmpty() }
if (terms.size != ops.length) return null
var res = seed
for (i in ops.indices) {
val op = ops[i]
val term = terms[i]
val isPct = term.endsWith("%")
val num = if (isPct) term.dropLast(1).toDouble() else term.toDouble()
val next = if (isPct) {
if (op == '+' || op == '-') res * (num / 100.0) else num / 100.0
} else num
res = when (op) {
'+' -> res + next
'-' -> res - next
'*' -> res * next
'/' -> if (next == 0.0) return null else res / next
else -> res
}
}
return res
} catch (_: Exception) { return null }
}
}