Skip to content

Commit

Permalink
Introduce Cols builder and remove fromStr approach
Browse files Browse the repository at this point in the history
The builder version is slightly more verbose, but it uses the
compiler to verify valid values and lets us remove a ton of
string parsing logic.
  • Loading branch information
bitspittle committed Feb 15, 2024
1 parent 5053ad9 commit 14d9b5b
Show file tree
Hide file tree
Showing 4 changed files with 49 additions and 153 deletions.
34 changes: 12 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1185,8 +1185,8 @@ wide as the second. If you have two star-sized columns both set to "2" then they
![Star grid example](https://github.com/varabyte/media/raw/main/kotter/images/kotter-grid-star-sized.png)

To determine "remaining space", the `grid` method accepts a `targetWidth` parameter. If you don't have any star-sized
columns, the value does nothing. If you do, then the grid will subtract all fixed and fit width values from it and share
any remaining space between the star-sized columns.
columns, the `targetWidth` value does nothing. If you do, then the grid will subtract all fixed and fit width values
from it and share any remaining space between the star-sized columns.

For a trivial example, say you have a two-column grid with `targetWidth` set to 10. The first column is fixed to 4, and
the second column is set to star-sized. The star-sized column will then receive 6 characters of space.
Expand All @@ -1196,46 +1196,36 @@ automatically adjust to the remaining space.

If you do not set the `targetWidth` at all, then all star-sized columns will shrink to size 1.

#### Column specs and Cols.fromStr
#### Column builder

Earlier, we used `Cols(6, 6)`, a convenience constructor that accepted only integer values indicated fixed widths. But
for more control, you can construct the `Cols` class passing in multiple `Cols.Spec` instances:
for more control, you can construct the `Cols` class using a builder block:

```kotlin
grid(Cols(Cols.Spec.Fit(), Cols.Spec.Fixed(10), Cols.Spec.Star()), targetWidth = 80) {
grid(Cols { fit(); fixed(10); star() }, targetWidth = 80) {
/* ... */
}
```

This works fine but is a bit verbose, so Kotter provides a convenient shortcut using a string value:

```kotlin
grid(Cols.fromStr("fit, 10, *"), targetWidth = 80) {
/* ... */
}
```

The string format is more fragile in that specifying it wrong will result in a runtime exception instead of a
compile-time one. However, it is more concise and easier to read.

#### Column properties

In addition to their base type, columns have a few properties you can set: *minimum value*, *maximum value*, and
In addition to their base value, columns have a few properties you can set: *minimum value*, *maximum value*, and
*justification*.

If you construct a `Cols.Spec` directly, you can set these properties just by passing them in as constructor parameters.
However, if you use the `fromStr` format, you can specify them using a `key:value` (no spaces!) syntax.

Here's an example of setting all three properties:

```kotlin
grid(Cols.fromStr("fit max:10, 10 just:center, * min:5"), targetWidth = 80) {
grid(
Cols {
fit(maxValue = 10); fixed(10, justification = Justification.CENTER); star(minValue = 5)
}, targetWidth = 80
) {
/* ... */
}
```

The above means that the first column will be fit-sized, but will never exceed 10 characters. The second column is fixed
to 10 characters, and its contents will be centered. The final column is star-sized, but will never be less than 5
to 10 characters, and its contents will be centered. The final column is star-sized, but it will never be less than 5
characters.

### 🪝 Shutdown Hook
Expand Down
5 changes: 3 additions & 2 deletions examples/grid/src/main/kotlin/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import com.varabyte.kotter.foundation.*
import com.varabyte.kotter.foundation.input.*
import com.varabyte.kotter.foundation.text.*
import com.varabyte.kotterx.grid.*
import com.varabyte.kotterx.text.*

private const val MIN_TABLE_WIDTH = 20
private const val MIN_TABLE_WIDTH = 25
private const val MAX_TABLE_WIDTH = 40
private const val DEFAULT_TABLE_WIDTH = 30

Expand All @@ -26,7 +27,7 @@ fun main() = session {

textLine("Target width: $tableWidth")
grid(
Cols.fromStr("fit, 7 just:center, * min:5"),
Cols { fit(); fixed(10, Justification.CENTER); star(minWidth = 5)},
targetWidth = tableWidth,
characters = GridCharacters.CURVED,
paddingLeftRight = if (usePadding) 1 else 0,
Expand Down
130 changes: 25 additions & 105 deletions kotter/src/commonMain/kotlin/com/varabyte/kotterx/grid/GridSupport.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class GridCharacters(
/**
* A column specification for a grid.
*/
class Cols(vararg val specs: Spec) {
class Cols private constructor(internal vararg val specs: Spec) {
init {
fun assertPositive(value: Int?, name: String) {
if (value != null) require(value > 0) { "$name must be positive" }
Expand Down Expand Up @@ -117,7 +117,7 @@ class Cols(vararg val specs: Spec) {

constructor(vararg widths: Int) : this(*widths.map { Spec.Fixed(it) }.toTypedArray())

sealed class Spec(val justification: Justification?) {
internal sealed class Spec(val justification: Justification?) {
class Fit(justification: Justification? = null, val minWidth: Int? = null, val maxWidth: Int? = null) :
Spec(justification)

Expand All @@ -131,117 +131,37 @@ class Cols(vararg val specs: Spec) {
) : Spec(justification)
}

class BuilderScope {
private val specs = mutableListOf<Spec>()

fun fit(justification: Justification? = null, minWidth: Int? = null, maxWidth: Int? = null) {
specs.add(Spec.Fit(justification, minWidth, maxWidth))
}

fun fixed(width: Int, justification: Justification? = null) {
specs.add(Spec.Fixed(width, justification))
}

fun star(ratio: Int = 1, justification: Justification? = null, minWidth: Int? = null, maxWidth: Int? = null) {
specs.add(Spec.Star(ratio, justification, minWidth, maxWidth))
}

fun build() = Cols(*specs.toTypedArray())
}

companion object {
/**
* Create a column spec where all columns are the same width.
*/
fun uniform(count: Int, width: Int) = Cols(*IntArray(count) { width })

/**
* Parse a string which represents [Cols.Spec] values and use it to create a [Cols] instance.
*
* In other words, this is an efficient (but less type-safe) way to create a list of grid column specs, with
* concise syntax for fixed-, fit-, and star-sized columns.
*
* For example: `Cols.fromStr("fit, 2*, 20, *")`
*
* Fit-sizing means that the column will take up exactly the space it needs to contain the largest item in that
* column. Use the "fit" keyword to specify this.
*
* Star-sizing means that the column will take up the remaining space in the grid. If multiple different star
* sections exist, they will be divided up evenly based on their ratio. For example, with "2*, *", the first
* column will be twice as wide as the second.
*
* And fixed-sizing means that the column will take up exactly the specified width. Use a raw integer value to
* specify this.
*
* You can mix and match types. So "fit, 2*, 20, *" means: subtract the fit size of the first column and the
* fixed size of the third column, then divide any remaining space up between the star-sized columns. Let's say
* the target width of the grid is 50, and the fit column ended up getting calculated as 6, then the final sizes
* would be: [6, 16, 20, 8].
*
* Additional properties can also be specified, using a `key:value` syntax. For example, `fit min:5 max:10`
* means calculate a fit value for this column, but go no lower than 5 and no higher than 10. Fit and star-sized
* columns can both have min- and max-width properties. All columns can specify a `just` property which can be
* set to `left`, `center`, or `right`.
*
* | Name | Values | Applies to |
* | ---- | ------ | ---------- |
* | min | int | fit, star |
* | max | int | fit, star |
* | just | left, center, right | all |
*
* For example: `Cols.fromStr("fit min:5, 20 just: center, * max: 30")`
* Convenience method for constructing a [Cols] instance using a builder pattern.
*/
fun fromStr(str: String): Cols {
fun invalidPartMessage(part: String, extra: String? = null) =
"Invalid column spec: $part" + (extra?.let { " ($it)" } ?: "")

class ParsedPart(val value: String, val properties: Map<String, String>) {
private val part get() = "$value ${properties.entries.joinToString(" ") { (k, v) -> "$k:$v" }}"

val maxWidth = properties["max"]?.toIntOrNull()
val minWidth = properties["min"]?.toIntOrNull()
val justification = properties["just"]?.let { justStr ->
Justification.values().find { it.name.equals(justStr, ignoreCase = true) }
?: error(invalidPartMessage(
part,
"Invalid justification value \"$justStr\", should be one of [${
Justification.values().joinToString(", ") { it.name.lowercase() }
}]"
))
}
}

fun parsePart(part: String): ParsedPart {
val parts = part.split(" ")
val value = parts.first()
val properties = parts.drop(1).map { it.split(":") }.associate { it[0] to it[1] }
return ParsedPart(value, properties)
}

val specs: List<Spec> = str
.split(",")
.map { it.trim() }
.map { part ->
val parsedPart = parsePart(part)

when {
parsedPart.value.equals("fit", ignoreCase = true) -> {
Spec.Fit(
parsedPart.justification,
parsedPart.minWidth,
parsedPart.maxWidth
)
}

parsedPart.value.endsWith("*") -> {
val ratio = parsedPart.value.dropLast(1).let {
if (it.isEmpty()) 1 else it.toIntOrNull()
}
require(ratio != null) { invalidPartMessage(part, "Invalid star size") }
Spec.Star(
ratio,
parsedPart.justification,
parsedPart.minWidth,
parsedPart.maxWidth
)
}

else -> {
val width = parsedPart.value.toIntOrNull()
require(width != null && width > 0) {
invalidPartMessage(
part,
"Column width must be positive"
)
}
Spec.Fixed(width, parsedPart.justification)
}
}
}

return Cols(*specs.toTypedArray())
operator fun invoke(builder: BuilderScope.() -> Unit): Cols {
val scope = BuilderScope()
scope.builder()
return scope.build()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ class GridSupportTest {
@Test
fun `Cols fromStr works with dynamic sizing`() = testSession { terminal ->
section {
grid(cols = Cols.fromStr("3*, 4, 1*"), targetWidth = 12) {
grid(cols = Cols { star(3); fixed(4); star(1) }, targetWidth = 12) {
cell {
textLine("A")
}
Expand Down Expand Up @@ -184,7 +184,7 @@ class GridSupportTest {
// ... plus padding of 1 on each side -> padded size (8, 5, 11)
// ... plus border walls (4 of them) -> final total width = 8 + 5 + 11 + 4 = 28

grid(cols = Cols.fromStr("2*, *, 3*"), paddingLeftRight = 1, targetWidth = 18) {
grid(cols = Cols { star(2); star(); star(3) }, paddingLeftRight = 1, targetWidth = 18) {
cell {
textLine("A")
}
Expand All @@ -208,7 +208,7 @@ class GridSupportTest {
@Test
fun `fit sizing works`() = testSession { terminal ->
section {
grid(cols = Cols.fromStr("fit, fit, fit")) {
grid(cols = Cols { fit(); fit(); fit() }) {
cell {
textLine("A")
}
Expand Down Expand Up @@ -250,7 +250,7 @@ class GridSupportTest {
@Test
fun `fit sizing takes newlines into account`() = testSession { terminal ->
section {
grid(cols = Cols.fromStr("fit")) {
grid(cols = Cols { fit() }) {
cell {
textLine("X")
}
Expand Down Expand Up @@ -281,7 +281,7 @@ class GridSupportTest {
// - Grid default

grid(
Cols.fromStr("8, 8, 8 just:right"),
Cols { fixed(8); fixed(8); fixed(8, justification = Justification.RIGHT) },
paddingLeftRight = 1,
defaultJustification = Justification.CENTER
) {
Expand Down Expand Up @@ -354,7 +354,7 @@ class GridSupportTest {
@Test
fun `star widths without target width shrink to size 1`() = testSession { terminal ->
section {
grid(Cols.fromStr("*, 10*")) {
grid(Cols { star(); star(10) }) {
cell { textLine("AA") }
cell { textLine("BB") }
}
Expand All @@ -372,7 +372,7 @@ class GridSupportTest {
@Test
fun `maxCellHeight can be used to limit number of cell rows`() = testSession { terminal ->
section {
grid(Cols.fromStr("1, 1, 1, 1"), maxCellHeight = 2) {
grid(Cols.uniform(4, width = 1), maxCellHeight = 2) {
cell { textLine("A") }
cell { textLine("BB") }
cell { textLine("CCC") }
Expand All @@ -397,9 +397,9 @@ class GridSupportTest {
}

@Test
fun `columns can set min and max widths`() = testSession { terminal ->
fun `non-fixed columns can set min and max widths`() = testSession { terminal ->
section {
grid(cols = Cols.fromStr("* min:5, fit max:5"), targetWidth = 1) {
grid(cols = Cols { star(minWidth = 5); fit(maxWidth = 5) }, targetWidth = 1) {
cell {
textLine("A")
}
Expand All @@ -417,19 +417,4 @@ class GridSupportTest {
Ansi.Csi.Codes.Sgr.RESET.toFullEscapeCode(),
).inOrder()
}


@Test
fun `non-integer star widths fails`() = testSession {
section {
assertThrows<IllegalArgumentException> {
grid(Cols.fromStr("1.5*")) {
cell { textLine("A") }
cell { textLine("B") }
}
}.also { ex ->
assertThat(ex.message!!).contains("1.5*")
}
}.run()
}
}

0 comments on commit 14d9b5b

Please sign in to comment.