Wrap table cells instead of cutting them off, and hand a batch over in one request

The renderer draws every table cell at one line with an ellipsis, so most
of a table was unreadable on a phone -- and an elided cell looks exactly
like a short one, so nothing said anything had been cut. Cells now take as
many lines as they need and align to the top of the row. Width is the other
half: a column narrows to 136dp and no further, and past that the table
scrolls sideways rather than squeezing. 136 is the widest floor that still
fits three columns across a phone, measured rather than picked; four and up
scroll, which is the right answer for genuinely too many columns.

The import screen used to send one request per selected row, so a handover
was only as atomic as the network: some rows started and the rest were
never asked for, and a row nobody asked for looks exactly like a row nobody
picked. `POST /setups/{id}/importable/delete` and `.../import` now take the
whole list, and every id is registered as in flight before the 202 goes
back. Only the registering is atomic -- the work settles per row, since six
deletes that all roll back together is not something a filesystem offers.

The echo driver grows `/table N`, with cells long enough to have been
truncated: a fixture of tidy one-word values renders fine whether or not
the bug is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 21:27:20 -04:00
1 parent 3c159fa1e1
commit 778b2e3b04
7 files changed
+336 -93

No files matched your search

@@ -6,19 +6,31 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.parseMarkdown
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode
/**
* An assistant's reply, rendered as the markdown it is written in.
@@ -107,10 +119,100 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
.toSpanStyle()
),
),
dimens =
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words
// -- and the narrowest column is where the wrapping below has the least room.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table -- the commonest shape there is -- scroll, while
// 136dp fits three across the phone this app is read on. Four and up still scroll,
// which is the right answer for genuinely too many columns: squeezing six columns
// into a phone would give every cell one word per line.
//
// Narrower would fit more, and stop being readable. This is the widest minimum
// that keeps three columns on screen, which is the trade the number is making.
tableCellWidth = 136.dp,
),
components =
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member of it the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
table = {
MarkdownTable(
it.content,
it.node,
style = it.typography.table,
headerBlock = ::WrappingTableHeader,
rowBlock = ::WrappingTableRow,
)
},
),
modifier = modifier,
)
}
/**
* A table header, and a table row, whose cells wrap rather than being cut off.
*
* The renderer draws every cell at `maxLines = 1` with an ellipsis, which on a phone means most of
* a table is simply not readable: a column is 160dp at its narrowest, so anything past about twenty
* characters ends in "..." with no way to see the rest. Nothing about the value says it was cut,
* either -- an elided cell looks like a short one, so a table of measurements reads as a table of
* plausible shorter measurements.
*
* So: as many lines as the cell needs, and [TextOverflow.Clip] rather than an ellipsis, which now
* never has anything to hide since the height grows to fit. Cells align to the top of the row,
* because a two-line cell beside a one-line one centred the short one against the middle of the
* tall one and lost the line the reader was reading across.
*
* What the wrapping does *not* do is make a wide table fit. The renderer already gives each column
* a 160dp minimum and scrolls the whole table sideways when they do not fit the screen, which is
* the right answer for too many columns -- wrapping a six-column table into the width of a phone
* would give every cell one word per line. The two work together: the width is what the columns
* need, and the wrapping is what fills the space that width provides.
*
* Two functions rather than one because the renderer's header and row are separate composables --
* the header is bold and sizes itself to its tallest cell -- and the parameters that matter here
* are the same three in both.
*/
@Composable
private fun WrappingTableHeader(
content: String,
header: ASTNode,
tableWidth: Dp,
style: TextStyle,
) {
MarkdownTableHeader(
content = content,
header = header,
tableWidth = tableWidth,
style = style,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
}
@Composable
private fun WrappingTableRow(content: String, row: ASTNode, tableWidth: Dp, style: TextStyle) {
MarkdownTableRow(
content = content,
header = row,
tableWidth = tableWidth,
style = style,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
}
/**
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time
* afterwards.