Skip to content

Commit cac1b7f

Browse files
(Junie) add formatHeader
1 parent 3c01503 commit cac1b7f

File tree

5 files changed

+248
-15
lines changed

5 files changed

+248
-15
lines changed

core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/format.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ public fun <T, C> DataFrame<T>.format(vararg columns: KProperty<C>): FormatClaus
325325
* If unspecified, all columns will be formatted.
326326
*/
327327
public fun <T, C> FormattedFrame<T>.format(columns: ColumnsSelector<T, C>): FormatClause<T, C> =
328-
FormatClause(df, columns, formatter)
328+
FormatClause(df, columns, formatter, oldHeaderFormatter = headerFormatter)
329329

330330
/**
331331
* @include [CommonFormatDocs]
@@ -390,7 +390,7 @@ public fun <T> FormattedFrame<T>.format(): FormatClause<T, Any?> = FormatClause(
390390
* Check out the full [Grammar][FormatDocs.Grammar].
391391
*/
392392
public fun <T, C> FormatClause<T, C>.where(filter: RowValueFilter<T, C>): FormatClause<T, C> =
393-
FormatClause(filter = this.filter and filter, df = df, columns = columns, oldFormatter = oldFormatter)
393+
FormatClause(filter = this.filter and filter, df = df, columns = columns, oldFormatter = oldFormatter, oldHeaderFormatter = oldHeaderFormatter)
394394

395395
/**
396396
* Only format the selected columns at given row indices.
@@ -780,7 +780,11 @@ public typealias CellFormatter<C> = FormattingDsl.(cell: C) -> CellAttributes?
780780
*
781781
* You can apply further formatting to this [FormattedFrame] by calling [format()][FormattedFrame.format] once again.
782782
*/
783-
public class FormattedFrame<T>(internal val df: DataFrame<T>, internal val formatter: RowColFormatter<T, *>? = null) {
783+
public class FormattedFrame<T>(
784+
internal val df: DataFrame<T>,
785+
internal val formatter: RowColFormatter<T, *>? = null,
786+
internal val headerFormatter: HeaderColFormatter<*>? = null,
787+
) {
784788

785789
/**
786790
* Returns a [DataFrameHtmlData] without additional definitions.
@@ -826,7 +830,7 @@ public class FormattedFrame<T>(internal val df: DataFrame<T>, internal val forma
826830
/** Applies this formatter to the given [configuration] and returns a new instance. */
827831
@Suppress("UNCHECKED_CAST")
828832
public fun getDisplayConfiguration(configuration: DisplayConfiguration): DisplayConfiguration =
829-
configuration.copy(cellFormatter = formatter as RowColFormatter<*, *>?)
833+
configuration.copy(cellFormatter = formatter as RowColFormatter<*, *>?, headerFormatter = headerFormatter as HeaderColFormatter<*>?)
830834
}
831835

832836
/**
@@ -858,6 +862,7 @@ public class FormatClause<T, C>(
858862
internal val columns: ColumnsSelector<T, C> = { all().cast() },
859863
internal val oldFormatter: RowColFormatter<T, C>? = null,
860864
internal val filter: RowValueFilter<T, C> = { true },
865+
internal val oldHeaderFormatter: HeaderColFormatter<*>? = null,
861866
) {
862867
override fun toString(): String =
863868
"FormatClause(df=$df, columns=$columns, oldFormatter=$oldFormatter, filter=$filter)"
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package org.jetbrains.kotlinx.dataframe.api
2+
3+
import org.jetbrains.kotlinx.dataframe.ColumnsSelector
4+
import org.jetbrains.kotlinx.dataframe.DataFrame
5+
import org.jetbrains.kotlinx.dataframe.annotations.AccessApiOverload
6+
import org.jetbrains.kotlinx.dataframe.columns.ColumnReference
7+
import org.jetbrains.kotlinx.dataframe.columns.ColumnWithPath
8+
import org.jetbrains.kotlinx.dataframe.columns.toColumnSet
9+
import org.jetbrains.kotlinx.dataframe.impl.getColumnPaths
10+
import org.jetbrains.kotlinx.dataframe.columns.UnresolvedColumnsPolicy
11+
import kotlin.reflect.KProperty
12+
import org.jetbrains.kotlinx.dataframe.util.DEPRECATED_ACCESS_API
13+
14+
/**
15+
* Formats the headers (column names) of the selected columns similarly to how [format] formats cell values.
16+
*
17+
* This does not immediately produce a [FormattedFrame]; instead it returns a [HeaderFormatClause] which must be
18+
* finalized using [HeaderFormatClause.with].
19+
*
20+
* Header formatting is additive and supports nested column groups: styles specified for a parent [ColumnGroup]
21+
* are inherited by its child columns unless overridden for the child.
22+
*
23+
* Examples:
24+
* ```kt
25+
* // center a single column header
26+
* df.formatHeader { age }.with { attr("text-align", "center") }
27+
*
28+
* // style a whole group header and override one child
29+
* df.formatHeader { name }.with { bold }
30+
* .formatHeader { name.firstName }.with { textColor(green) }
31+
* .toStandaloneHtml()
32+
* ```
33+
*/
34+
public typealias HeaderColFormatter<C> = FormattingDsl.(col: ColumnWithPath<C>) -> CellAttributes?
35+
36+
/**
37+
* Intermediate clause for header formatting, analogous to [FormatClause] but without rows.
38+
*
39+
* Use [with] to specify how to format the selected column headers, producing a [FormattedFrame].
40+
*/
41+
public class HeaderFormatClause<T, C>(
42+
internal val df: DataFrame<T>,
43+
internal val columns: ColumnsSelector<T, C> = { all().cast() },
44+
internal val oldHeaderFormatter: HeaderColFormatter<C>? = null,
45+
internal val oldCellFormatter: RowColFormatter<T, *>? = null,
46+
) {
47+
override fun toString(): String =
48+
"HeaderFormatClause(df=$df, columns=$columns, oldHeaderFormatter=$oldHeaderFormatter, oldCellFormatter=$oldCellFormatter)"
49+
}
50+
51+
// region DataFrame.formatHeader
52+
53+
/**
54+
* Selects [columns] whose headers should be formatted; finalize with [HeaderFormatClause.with].
55+
*/
56+
public fun <T, C> DataFrame<T>.formatHeader(columns: ColumnsSelector<T, C>): HeaderFormatClause<T, C> =
57+
HeaderFormatClause(this, columns)
58+
59+
/** Selects columns by [columns] names for header formatting. */
60+
public fun <T> DataFrame<T>.formatHeader(vararg columns: String): HeaderFormatClause<T, Any?> =
61+
formatHeader { columns.toColumnSet() }
62+
63+
/** Selects all columns for header formatting. */
64+
public fun <T> DataFrame<T>.formatHeader(): HeaderFormatClause<T, Any?> = HeaderFormatClause(this)
65+
66+
@Deprecated(DEPRECATED_ACCESS_API)
67+
@AccessApiOverload
68+
public fun <T, C> DataFrame<T>.formatHeader(vararg columns: ColumnReference<C>): HeaderFormatClause<T, C> =
69+
formatHeader { columns.toColumnSet() }
70+
71+
@Deprecated(DEPRECATED_ACCESS_API)
72+
@AccessApiOverload
73+
public fun <T, C> DataFrame<T>.formatHeader(vararg columns: KProperty<C>): HeaderFormatClause<T, C> =
74+
formatHeader { columns.toColumnSet() }
75+
76+
// endregion
77+
78+
// region FormattedFrame.formatHeader
79+
80+
public fun <T, C> FormattedFrame<T>.formatHeader(columns: ColumnsSelector<T, C>): HeaderFormatClause<T, C> =
81+
HeaderFormatClause(df = df, columns = columns, oldHeaderFormatter = headerFormatter as HeaderColFormatter<C>?, oldCellFormatter = formatter)
82+
83+
public fun <T> FormattedFrame<T>.formatHeader(vararg columns: String): HeaderFormatClause<T, Any?> =
84+
formatHeader { columns.toColumnSet() }
85+
86+
public fun <T> FormattedFrame<T>.formatHeader(): HeaderFormatClause<T, Any?> =
87+
HeaderFormatClause(df = df, oldHeaderFormatter = headerFormatter as HeaderColFormatter<Any?>?, oldCellFormatter = formatter)
88+
89+
// endregion
90+
91+
// region terminal operations
92+
93+
@Suppress("UNCHECKED_CAST")
94+
public fun <T, C> HeaderFormatClause<T, C>.with(formatter: HeaderColFormatter<C>): FormattedFrame<T> {
95+
val paths = df.getColumnPaths(UnresolvedColumnsPolicy.Skip, columns).toSet()
96+
val oldHeader = oldHeaderFormatter
97+
val composedHeader: HeaderColFormatter<Any?> = { col ->
98+
val parentCols = col.path.indices
99+
.map { i -> col.path.take(i + 1) }
100+
.dropLast(0) // include self and parents handled below
101+
// Merge attributes from parents that are selected
102+
val parentAttributes = parentCols
103+
.dropLast(1)
104+
.map { path -> ColumnWithPath(df[path], path) }
105+
.map { parentCol -> if (parentCol.path in paths) (oldHeader?.invoke(FormattingDsl, parentCol as ColumnWithPath<C>)) else null }
106+
.reduceOrNull(CellAttributes?::and)
107+
val selfAttr = if (col.path in paths) {
108+
val oldAttr = oldHeader?.invoke(FormattingDsl, col as ColumnWithPath<C>)
109+
oldAttr and formatter(FormattingDsl, col as ColumnWithPath<C>)
110+
} else {
111+
oldHeader?.invoke(FormattingDsl, col as ColumnWithPath<C>)
112+
}
113+
parentAttributes and selfAttr
114+
}
115+
@Suppress("UNCHECKED_CAST")
116+
return FormattedFrame(df, oldCellFormatter, composedHeader)
117+
}
118+
119+
// endregion

core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/api/format.kt

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,18 @@ internal inline fun <T, C> FormatClause<T, C>.formatImpl(
5656
val clause = this
5757
val columns = clause.df.getColumnPaths(UnresolvedColumnsPolicy.Skip, clause.columns).toSet()
5858

59-
return FormattedFrame(clause.df) { row, col ->
60-
val oldAttributes = clause.oldFormatter?.invoke(FormattingDsl, row, col.cast())
61-
if (col.path in columns) {
62-
val value = col[row] as C
63-
if (clause.filter(row, value)) {
64-
return@FormattedFrame oldAttributes and formatter(FormattingDsl, row.cast(), col.cast())
59+
return FormattedFrame(
60+
df = clause.df,
61+
formatter = { row, col ->
62+
val oldAttributes = clause.oldFormatter?.invoke(FormattingDsl, row, col.cast())
63+
if (col.path in columns) {
64+
val value = col[row] as C
65+
if (clause.filter(row, value)) {
66+
return@FormattedFrame oldAttributes and formatter(FormattingDsl, row.cast(), col.cast())
67+
}
6568
}
66-
}
67-
68-
oldAttributes
69-
}
69+
oldAttributes
70+
},
71+
headerFormatter = clause.oldHeaderFormatter
72+
)
7073
}

core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import org.jetbrains.kotlinx.dataframe.api.CellAttributes
99
import org.jetbrains.kotlinx.dataframe.api.FormattedFrame
1010
import org.jetbrains.kotlinx.dataframe.api.FormattingDsl
1111
import org.jetbrains.kotlinx.dataframe.api.RowColFormatter
12+
import org.jetbrains.kotlinx.dataframe.api.HeaderColFormatter
1213
import org.jetbrains.kotlinx.dataframe.api.and
1314
import org.jetbrains.kotlinx.dataframe.api.asColumnGroup
1415
import org.jetbrains.kotlinx.dataframe.api.asNumbers
@@ -68,6 +69,7 @@ internal data class ColumnDataForJs(
6869
val nested: List<ColumnDataForJs>,
6970
val rightAlign: Boolean,
7071
val values: List<CellContent>,
72+
val headerStyle: String?,
7173
)
7274

7375
internal val formatter = DataFrameFormatter(
@@ -98,7 +100,8 @@ internal fun getResourceText(resource: String, vararg replacement: Pair<String,
98100

99101
internal fun ColumnDataForJs.renderHeader(): String {
100102
val tooltip = "${column.name}: ${renderType(column.type())}"
101-
return "<span title=\"$tooltip\">${column.name()}</span>"
103+
val styleAttr = if (headerStyle != null) " style=\"$headerStyle\"" else ""
104+
return "<span title=\"$tooltip\"$styleAttr>${column.name()}</span>"
102105
}
103106

104107
internal fun tableJs(
@@ -234,6 +237,27 @@ internal fun AnyFrame.toHtmlData(
234237
HtmlContent(html, style)
235238
}
236239
}
240+
val headerStyle = run {
241+
val hf = configuration.headerFormatter
242+
if (hf == null) null else {
243+
// collect attributes from parents
244+
val parentCols = col.path.indices
245+
.map { i -> col.path.take(i + 1) }
246+
.dropLast(1)
247+
.map { ColumnWithPath(this@toHtmlData[it], it) }
248+
val parentAttributes = parentCols
249+
.map { hf(FormattingDsl, it) }
250+
.reduceOrNull(CellAttributes?::and)
251+
val selfAttributes = hf(FormattingDsl, col)
252+
val attrs = parentAttributes and selfAttributes
253+
attrs
254+
?.attributes()
255+
?.ifEmpty { null }
256+
?.toMap()
257+
?.entries
258+
?.joinToString(";") { "${it.key}:${it.value}" }
259+
}
260+
}
237261
val nested = if (col is ColumnGroup<*>) {
238262
col.columns().map {
239263
col.columnToJs(it.addParentPath(col.path), rowsLimit, configuration)
@@ -246,6 +270,7 @@ internal fun AnyFrame.toHtmlData(
246270
nested = nested,
247271
rightAlign = col.isSubtypeOf<Number?>(),
248272
values = contents,
273+
headerStyle = headerStyle,
249274
)
250275
}
251276

@@ -826,12 +851,15 @@ public class DataFrameHtmlData(
826851
* @param cellContentLimit -1 to disable content trimming
827852
* @param enableFallbackStaticTables true to add additional pure HTML table that will be visible only if JS is disabled;
828853
* For example hosting *.ipynb files with outputs on GitHub
854+
* @param cellFormatter Optional cell formatter applied to data cells during HTML rendering.
855+
* @param headerFormatter Optional header formatter applied to column headers; supports inheritance for nested column groups.
829856
*/
830857
public data class DisplayConfiguration(
831858
var rowsLimit: Int? = 20,
832859
var nestedRowsLimit: Int? = 5,
833860
var cellContentLimit: Int = 40,
834861
var cellFormatter: RowColFormatter<*, *>? = null,
862+
var headerFormatter: HeaderColFormatter<*>? = null,
835863
var decimalFormat: RendererDecimalFormat = RendererDecimalFormat.DEFAULT,
836864
var isolatedOutputs: Boolean = flagFromEnv("LETS_PLOT_HTML_ISOLATED_FRAME"),
837865
internal val localTesting: Boolean = flagFromEnv("KOTLIN_DATAFRAME_LOCAL_TESTING"),
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package org.jetbrains.kotlinx.dataframe.api
2+
3+
import io.kotest.matchers.shouldBe
4+
import io.kotest.matchers.shouldNotBe
5+
import org.jetbrains.kotlinx.dataframe.api.FormattingDsl.blue
6+
import org.jetbrains.kotlinx.dataframe.api.FormattingDsl.green
7+
import org.jetbrains.kotlinx.dataframe.samples.api.TestBase
8+
import org.jetbrains.kotlinx.dataframe.samples.api.age
9+
import org.jetbrains.kotlinx.dataframe.samples.api.name
10+
import org.jetbrains.kotlinx.dataframe.samples.api.firstName
11+
import org.junit.Test
12+
13+
class FormatHeaderTests : TestBase() {
14+
15+
@Test
16+
fun `formatHeader on single column adds inline style to header`() {
17+
val formatted = df.formatHeader { age }.with { attr("border", "3px solid green") }
18+
val html = formatted.toHtml().toString()
19+
20+
// header style is rendered inline inside <span ... style="...">
21+
// count exact style occurrences to avoid interference with CSS
22+
val occurrences = html.split("border:3px solid green").size - 1
23+
occurrences shouldBe 1
24+
25+
}
26+
27+
@Test
28+
fun `formatHeader by names overload`() {
29+
val formatted = df.formatHeader("age").with { attr("text-align", "center") }
30+
val html = formatted.toHtml().toString()
31+
val occurrences = html.split("text-align:center").size - 1
32+
occurrences shouldBe 1
33+
}
34+
35+
@Test
36+
fun `header style inherited from group to children`() {
37+
// Apply style to the group header only
38+
val formatted = df.formatHeader { name }.with { attr("border", "1px solid red") }
39+
val html = formatted.toHtml().toString()
40+
41+
// We expect the style on the group header itself and each direct child header
42+
// In the default TestBase dataset, name group has two children
43+
val occurrences = html.split("border:1px solid red").size - 1
44+
occurrences shouldBe 3
45+
}
46+
47+
@Test
48+
fun `child header overrides parent group header style`() {
49+
val formatted = df
50+
.formatHeader { name }.with { attr("border", "1px solid red") }
51+
.formatHeader { name.firstName }.with { attr("border", "2px dashed green") }
52+
val html = formatted.toHtml().toString()
53+
54+
// Parent style applies to group and lastName, but firstName gets its own style in addition to or replacing
55+
// We check for both occurrences
56+
val parentOcc = html.split("border:1px solid red").size - 1
57+
val childOcc = html.split("border:2px dashed green").size - 1
58+
59+
parentOcc shouldBe 2 // group + lastName
60+
childOcc shouldBe 1 // firstName only
61+
}
62+
63+
@Test
64+
fun `format and formatHeader can be chained and both persist`() {
65+
val formatted = df
66+
.format { age }.with { background(blue) }
67+
.formatHeader { age }.with { attr("border", "3px solid green") }
68+
69+
val html = formatted.toHtml().toString()
70+
71+
// body cell style
72+
(html.split("background-color:#0000ff").size - 1) shouldBe 7
73+
// header style
74+
(html.split("border:3px solid green").size - 1) shouldBe 1
75+
76+
formatted::class.simpleName shouldNotBe null
77+
}
78+
}

0 commit comments

Comments
 (0)