diff --git a/core/api/core.api b/core/api/core.api index 283e186f54..3684a2e55b 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -2232,11 +2232,21 @@ public final class org/jetbrains/kotlinx/dataframe/api/ForEachKt { } public final class org/jetbrains/kotlinx/dataframe/api/FormatClause { - public fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun toString ()Ljava/lang/String; } +public final class org/jetbrains/kotlinx/dataframe/api/FormatHeaderKt { + public static final fun formatHeader (Lorg/jetbrains/kotlinx/dataframe/DataFrame;)Lorg/jetbrains/kotlinx/dataframe/api/HeaderFormatClause; + public static final fun formatHeader (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function2;)Lorg/jetbrains/kotlinx/dataframe/api/HeaderFormatClause; + public static final fun formatHeader (Lorg/jetbrains/kotlinx/dataframe/DataFrame;[Ljava/lang/String;)Lorg/jetbrains/kotlinx/dataframe/api/HeaderFormatClause; + public static final fun formatHeader (Lorg/jetbrains/kotlinx/dataframe/api/FormattedFrame;)Lorg/jetbrains/kotlinx/dataframe/api/HeaderFormatClause; + public static final fun formatHeader (Lorg/jetbrains/kotlinx/dataframe/api/FormattedFrame;Lkotlin/jvm/functions/Function2;)Lorg/jetbrains/kotlinx/dataframe/api/HeaderFormatClause; + public static final fun formatHeader (Lorg/jetbrains/kotlinx/dataframe/api/FormattedFrame;[Ljava/lang/String;)Lorg/jetbrains/kotlinx/dataframe/api/HeaderFormatClause; + public static final fun with (Lorg/jetbrains/kotlinx/dataframe/api/HeaderFormatClause;Lkotlin/jvm/functions/Function2;)Lorg/jetbrains/kotlinx/dataframe/api/FormattedFrame; +} + public final class org/jetbrains/kotlinx/dataframe/api/FormatKt { public static final fun and (Lorg/jetbrains/kotlinx/dataframe/api/CellAttributes;Lorg/jetbrains/kotlinx/dataframe/api/CellAttributes;)Lorg/jetbrains/kotlinx/dataframe/api/CellAttributes; public static final fun at (Lorg/jetbrains/kotlinx/dataframe/api/FormatClause;Ljava/util/Collection;)Lorg/jetbrains/kotlinx/dataframe/api/FormatClause; @@ -2259,8 +2269,8 @@ public final class org/jetbrains/kotlinx/dataframe/api/FormatKt { } public final class org/jetbrains/kotlinx/dataframe/api/FormattedFrame { - public fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function3;)V - public synthetic fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getDisplayConfiguration (Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration;)Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration; public final fun toHtml (Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration;)Lorg/jetbrains/kotlinx/dataframe/io/DataFrameHtmlData; public static synthetic fun toHtml$default (Lorg/jetbrains/kotlinx/dataframe/api/FormattedFrame;Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration;ILjava/lang/Object;)Lorg/jetbrains/kotlinx/dataframe/io/DataFrameHtmlData; @@ -2545,6 +2555,12 @@ public final class org/jetbrains/kotlinx/dataframe/api/HeadKt { public static synthetic fun head$default (Lorg/jetbrains/kotlinx/dataframe/DataFrame;IILjava/lang/Object;)Lorg/jetbrains/kotlinx/dataframe/DataFrame; } +public final class org/jetbrains/kotlinx/dataframe/api/HeaderFormatClause { + public fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;)V + public synthetic fun (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun toString ()Ljava/lang/String; +} + public final class org/jetbrains/kotlinx/dataframe/api/ImplodeKt { public static final fun implode (Lorg/jetbrains/kotlinx/dataframe/DataFrame;Z)Lorg/jetbrains/kotlinx/dataframe/DataRow; public static final fun implode (Lorg/jetbrains/kotlinx/dataframe/DataFrame;ZLkotlin/jvm/functions/Function2;)Lorg/jetbrains/kotlinx/dataframe/DataFrame; @@ -6194,25 +6210,27 @@ public final class org/jetbrains/kotlinx/dataframe/io/DataFrameHtmlData$Companio public final class org/jetbrains/kotlinx/dataframe/io/DisplayConfiguration { public static final field Companion Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration$Companion; - public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Ljava/lang/String;ZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Ljava/lang/String;ZZZZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Ljava/lang/String;ZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Ljava/lang/String;ZZZZZLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/Integer; public final fun component10 ()Z + public final fun component11 ()Z public final fun component2 ()Ljava/lang/Integer; public final fun component3 ()I public final fun component4 ()Lkotlin/jvm/functions/Function3; - public final fun component5-3Sl7FsM ()Ljava/lang/String; - public final fun component6 ()Z - public final fun component8 ()Z + public final fun component5 ()Lkotlin/jvm/functions/Function2; + public final fun component6-3Sl7FsM ()Ljava/lang/String; + public final fun component7 ()Z public final fun component9 ()Z - public final fun copy-rqXL5tM (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Ljava/lang/String;ZZZZZ)Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration; - public static synthetic fun copy-rqXL5tM$default (Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Ljava/lang/String;ZZZZZILjava/lang/Object;)Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration; + public final fun copy-bMNacXk (Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Ljava/lang/String;ZZZZZ)Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration; + public static synthetic fun copy-bMNacXk$default (Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Ljava/lang/String;ZZZZZILjava/lang/Object;)Lorg/jetbrains/kotlinx/dataframe/io/DisplayConfiguration; public fun equals (Ljava/lang/Object;)Z public final fun getCellContentLimit ()I public final fun getCellFormatter ()Lkotlin/jvm/functions/Function3; public final fun getDecimalFormat-3Sl7FsM ()Ljava/lang/String; public final fun getDownsizeBufferedImage ()Z public final fun getEnableFallbackStaticTables ()Z + public final fun getHeaderFormatter ()Lkotlin/jvm/functions/Function2; public final fun getIsolatedOutputs ()Z public final fun getNestedRowsLimit ()Ljava/lang/Integer; public final fun getRowsLimit ()Ljava/lang/Integer; @@ -6224,6 +6242,7 @@ public final class org/jetbrains/kotlinx/dataframe/io/DisplayConfiguration { public final fun setDecimalFormat-h5o3lmc (Ljava/lang/String;)V public final fun setDownsizeBufferedImage (Z)V public final fun setEnableFallbackStaticTables (Z)V + public final fun setHeaderFormatter (Lkotlin/jvm/functions/Function2;)V public final fun setIsolatedOutputs (Z)V public final fun setNestedRowsLimit (Ljava/lang/Integer;)V public final fun setRowsLimit (Ljava/lang/Integer;)V diff --git a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/format.kt b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/format.kt index 2d7e6c6110..80bd659083 100644 --- a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/format.kt +++ b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/format.kt @@ -325,7 +325,7 @@ public fun DataFrame.format(vararg columns: KProperty): FormatClaus * If unspecified, all columns will be formatted. */ public fun FormattedFrame.format(columns: ColumnsSelector): FormatClause = - FormatClause(df, columns, formatter) + FormatClause(df, columns, formatter, oldHeaderFormatter = headerFormatter) /** * @include [CommonFormatDocs] @@ -390,7 +390,13 @@ public fun FormattedFrame.format(): FormatClause = FormatClause( * Check out the full [Grammar][FormatDocs.Grammar]. */ public fun FormatClause.where(filter: RowValueFilter): FormatClause = - FormatClause(filter = this.filter and filter, df = df, columns = columns, oldFormatter = oldFormatter) + FormatClause( + filter = this.filter and filter, + df = df, + columns = columns, + oldFormatter = oldFormatter, + oldHeaderFormatter = oldHeaderFormatter, + ) /** * Only format the selected columns at given row indices. @@ -780,7 +786,11 @@ public typealias CellFormatter = FormattingDsl.(cell: C) -> CellAttributes? * * You can apply further formatting to this [FormattedFrame] by calling [format()][FormattedFrame.format] once again. */ -public class FormattedFrame(internal val df: DataFrame, internal val formatter: RowColFormatter? = null) { +public class FormattedFrame( + internal val df: DataFrame, + internal val formatter: RowColFormatter? = null, + internal val headerFormatter: HeaderColFormatter<*>? = null, +) { /** * Returns a [DataFrameHtmlData] without additional definitions. @@ -826,7 +836,10 @@ public class FormattedFrame(internal val df: DataFrame, internal val forma /** Applies this formatter to the given [configuration] and returns a new instance. */ @Suppress("UNCHECKED_CAST") public fun getDisplayConfiguration(configuration: DisplayConfiguration): DisplayConfiguration = - configuration.copy(cellFormatter = formatter as RowColFormatter<*, *>?) + configuration.copy( + cellFormatter = formatter as RowColFormatter<*, *>?, + headerFormatter = headerFormatter as HeaderColFormatter<*>?, + ) } /** @@ -858,6 +871,7 @@ public class FormatClause( internal val columns: ColumnsSelector = { all().cast() }, internal val oldFormatter: RowColFormatter? = null, internal val filter: RowValueFilter = { true }, + internal val oldHeaderFormatter: HeaderColFormatter<*>? = null, ) { override fun toString(): String = "FormatClause(df=$df, columns=$columns, oldFormatter=$oldFormatter, filter=$filter)" diff --git a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/formatHeader.kt b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/formatHeader.kt new file mode 100644 index 0000000000..fd740bba29 --- /dev/null +++ b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/api/formatHeader.kt @@ -0,0 +1,174 @@ +package org.jetbrains.kotlinx.dataframe.api + +import org.jetbrains.kotlinx.dataframe.ColumnsSelector +import org.jetbrains.kotlinx.dataframe.DataFrame +import org.jetbrains.kotlinx.dataframe.columns.ColumnWithPath +import org.jetbrains.kotlinx.dataframe.columns.UnresolvedColumnsPolicy +import org.jetbrains.kotlinx.dataframe.columns.toColumnSet +import org.jetbrains.kotlinx.dataframe.impl.getColumnPaths + +// region docs + +/** + * A lambda used to format a column header (its displayed name) when rendering a dataframe to HTML. + * + * The lambda runs in the context of [FormattingDsl] and receives the [ColumnWithPath] of the header to format. + * Return a [CellAttributes] (or `null`) describing CSS you want to apply to the header cell. + * + * Examples: + * - Center a header: `attr("text-align", "center")` + * - Make it bold: `bold` + * - Set custom color: `textColor(rgb(10, 10, 10))` + */ +public typealias HeaderColFormatter = FormattingDsl.(col: ColumnWithPath) -> CellAttributes? + +/** + * An intermediate class used in the header-format operation [formatHeader]. + * + * This class itself does nothing—it represents a selection of columns whose headers will be formatted. + * Finalize this step by calling [with] to produce a new [FormattedFrame]. + * + * Header formatting is additive and supports nested column groups: styles specified for a parent group + * are inherited by its child columns unless overridden for the child. + */ +public class HeaderFormatClause( + internal val df: DataFrame, + internal val columns: ColumnsSelector = { all().cast() }, + internal val oldHeaderFormatter: HeaderColFormatter? = null, + internal val oldCellFormatter: RowColFormatter? = null, +) { + override fun toString(): String = + "HeaderFormatClause(df=$df, columns=$columns, oldHeaderFormatter=$oldHeaderFormatter, oldCellFormatter=$oldCellFormatter)" +} + +// endregion + +// region DataFrame.formatHeader + +/** + * **Experimental API. It may be changed in the future.** + * + * Selects [columns] whose headers should be formatted. + * + * This does not immediately produce a [FormattedFrame]; instead it returns a [HeaderFormatClause] + * which must be finalized using [HeaderFormatClause.with]. + */ +public fun DataFrame.formatHeader(columns: ColumnsSelector): HeaderFormatClause = + HeaderFormatClause(this, columns) + +/** + * **Experimental API. It may be changed in the future.** + * + * Selects [columns] whose headers should be formatted. + * + * This does not immediately produce a [FormattedFrame]; instead it returns a [HeaderFormatClause] + * which must be finalized using [HeaderFormatClause.with]. + */ +public fun DataFrame.formatHeader(vararg columns: String): HeaderFormatClause = + formatHeader { columns.toColumnSet() } + +/** + * **Experimental API. It may be changed in the future.** + * + * Selects all columns for header formatting. + * + * This does not immediately produce a [FormattedFrame]; instead it returns a [HeaderFormatClause] + * which must be finalized using [HeaderFormatClause.with]. + */ +public fun DataFrame.formatHeader(): HeaderFormatClause = HeaderFormatClause(this) + +// endregion + +// region FormattedFrame.formatHeader + +/** + * **Experimental API. It may be changed in the future.** + * + * Selects [columns] whose headers should be formatted. + * + * This does not immediately produce a [FormattedFrame]; instead it returns a [HeaderFormatClause] + * which must be finalized using [HeaderFormatClause.with]. + */ +public fun FormattedFrame.formatHeader(columns: ColumnsSelector): HeaderFormatClause = + HeaderFormatClause( + df = df, + columns = columns, + oldHeaderFormatter = headerFormatter as HeaderColFormatter?, + oldCellFormatter = formatter, + ) + +/** + * **Experimental API. It may be changed in the future.** + * + * Selects [columns] whose headers should be formatted. + * + * This does not immediately produce a [FormattedFrame]; instead it returns a [HeaderFormatClause] + * which must be finalized using [HeaderFormatClause.with]. + */ +public fun FormattedFrame.formatHeader(vararg columns: String): HeaderFormatClause = + formatHeader { columns.toColumnSet() } + +/** + * **Experimental API. It may be changed in the future.** + * + * Selects all columns for header formatting. + * + * This does not immediately produce a [FormattedFrame]; instead it returns a [HeaderFormatClause] + * which must be finalized using [HeaderFormatClause.with]. + */ +public fun FormattedFrame.formatHeader(): HeaderFormatClause = + HeaderFormatClause( + df = df, + oldHeaderFormatter = headerFormatter, + oldCellFormatter = formatter, + ) + +// endregion + +// region terminal operations + +/** + * **Experimental API. It may be changed in the future.** + * + * Creates a new [FormattedFrame] that uses the specified [HeaderColFormatter] to format the selected headers. + * + * Header formatting is additive: attributes from already-applied header formatters are combined with the newly + * returned attributes using [CellAttributes.and]. If a parent column group is selected, its attributes are + * applied to its children unless explicitly overridden. + */ +@Suppress("UNCHECKED_CAST") +public fun HeaderFormatClause.with(formatter: HeaderColFormatter): FormattedFrame { + val selectedPaths = df.getColumnPaths(UnresolvedColumnsPolicy.Skip, columns).toSet() + val oldHeader = oldHeaderFormatter + + val composedHeader: HeaderColFormatter = { col -> + val path = col.path + // Merge attributes from selected parents + val parentAttributes = if (path.size > 1) { + val parentPaths = (0 until path.size - 1).map { i -> path.take(i + 1) } + parentPaths + .map { p -> ColumnWithPath(df[p], p) } + .map { parentCol -> + if (parentCol.path in selectedPaths) { + oldHeader?.invoke(FormattingDsl, parentCol as ColumnWithPath) + } else { + null + } + } + .reduceOrNull(CellAttributes?::and) + } else { + null + } + + val typedCol = col as ColumnWithPath + + val existingAttr = oldHeader?.invoke(FormattingDsl, typedCol) + val newAttr = if (path in selectedPaths) formatter(FormattingDsl, typedCol) else null + + parentAttributes and (existingAttr and newAttr) + } + + return FormattedFrame(df, oldCellFormatter, composedHeader) +} + +// endregion diff --git a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/api/format.kt b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/api/format.kt index 8b78bdfe5e..3f1aec332a 100644 --- a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/api/format.kt +++ b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/api/format.kt @@ -56,15 +56,18 @@ internal inline fun FormatClause.formatImpl( val clause = this val columns = clause.df.getColumnPaths(UnresolvedColumnsPolicy.Skip, clause.columns).toSet() - return FormattedFrame(clause.df) { row, col -> - val oldAttributes = clause.oldFormatter?.invoke(FormattingDsl, row, col.cast()) - if (col.path in columns) { - val value = col[row] as C - if (clause.filter(row, value)) { - return@FormattedFrame oldAttributes and formatter(FormattingDsl, row.cast(), col.cast()) + return FormattedFrame( + df = clause.df, + formatter = { row, col -> + val oldAttributes = clause.oldFormatter?.invoke(FormattingDsl, row, col.cast()) + if (col.path in columns) { + val value = col[row] as C + if (clause.filter(row, value)) { + return@FormattedFrame oldAttributes and formatter(FormattingDsl, row.cast(), col.cast()) + } } - } - - oldAttributes - } + oldAttributes + }, + headerFormatter = clause.oldHeaderFormatter, + ) } diff --git a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt index dec55ef272..ec4d632ce3 100644 --- a/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt +++ b/core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/io/html.kt @@ -8,6 +8,7 @@ import org.jetbrains.kotlinx.dataframe.DataFrame import org.jetbrains.kotlinx.dataframe.api.CellAttributes import org.jetbrains.kotlinx.dataframe.api.FormattedFrame import org.jetbrains.kotlinx.dataframe.api.FormattingDsl +import org.jetbrains.kotlinx.dataframe.api.HeaderColFormatter import org.jetbrains.kotlinx.dataframe.api.RowColFormatter import org.jetbrains.kotlinx.dataframe.api.and import org.jetbrains.kotlinx.dataframe.api.asColumnGroup @@ -68,6 +69,7 @@ internal data class ColumnDataForJs( val nested: List, val rightAlign: Boolean, val values: List, + val headerStyle: String?, ) internal val formatter = DataFrameFormatter( @@ -98,7 +100,8 @@ internal fun getResourceText(resource: String, vararg replacement: Pair${column.name()}" + val styleAttr = if (headerStyle != null) " style=\"$headerStyle\"" else "" + return "${column.name()}" } internal fun tableJs( @@ -234,6 +237,29 @@ internal fun AnyFrame.toHtmlData( HtmlContent(html, style) } } + val headerStyle = run { + val hf = configuration.headerFormatter + if (hf == null) { + null + } else { + // collect attributes from parents + val parentCols = col.path.indices + .map { i -> col.path.take(i + 1) } + .dropLast(1) + .map { ColumnWithPath(this@toHtmlData[it], it) } + val parentAttributes = parentCols + .map { hf(FormattingDsl, it) } + .reduceOrNull(CellAttributes?::and) + val selfAttributes = hf(FormattingDsl, col) + val attrs = parentAttributes and selfAttributes + attrs + ?.attributes() + ?.ifEmpty { null } + ?.toMap() + ?.entries + ?.joinToString(";") { "${it.key}:${it.value}" } + } + } val nested = if (col is ColumnGroup<*>) { col.columns().map { col.columnToJs(it.addParentPath(col.path), rowsLimit, configuration) @@ -246,6 +272,7 @@ internal fun AnyFrame.toHtmlData( nested = nested, rightAlign = col.isSubtypeOf(), values = contents, + headerStyle = headerStyle, ) } @@ -826,12 +853,15 @@ public class DataFrameHtmlData( * @param cellContentLimit -1 to disable content trimming * @param enableFallbackStaticTables true to add additional pure HTML table that will be visible only if JS is disabled; * For example hosting *.ipynb files with outputs on GitHub + * @param cellFormatter Optional cell formatter applied to data cells during HTML rendering. + * @param headerFormatter Optional header formatter applied to column headers; supports inheritance for nested column groups. */ public data class DisplayConfiguration( var rowsLimit: Int? = 20, var nestedRowsLimit: Int? = 5, var cellContentLimit: Int = 40, var cellFormatter: RowColFormatter<*, *>? = null, + var headerFormatter: HeaderColFormatter<*>? = null, var decimalFormat: RendererDecimalFormat = RendererDecimalFormat.DEFAULT, var isolatedOutputs: Boolean = flagFromEnv("LETS_PLOT_HTML_ISOLATED_FRAME"), internal val localTesting: Boolean = flagFromEnv("KOTLIN_DATAFRAME_LOCAL_TESTING"), diff --git a/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/formatHeader.kt b/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/formatHeader.kt new file mode 100644 index 0000000000..295c5dc1eb --- /dev/null +++ b/core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/formatHeader.kt @@ -0,0 +1,76 @@ +package org.jetbrains.kotlinx.dataframe.api + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.jetbrains.kotlinx.dataframe.api.FormattingDsl.blue +import org.jetbrains.kotlinx.dataframe.samples.api.TestBase +import org.jetbrains.kotlinx.dataframe.samples.api.age +import org.jetbrains.kotlinx.dataframe.samples.api.firstName +import org.jetbrains.kotlinx.dataframe.samples.api.name +import org.junit.Test + +class FormatHeaderTests : TestBase() { + + @Test + fun `formatHeader on single column adds inline style to header`() { + val formatted = df.formatHeader { age }.with { attr("border", "3px solid green") } + val html = formatted.toHtml().toString() + + // header style is rendered inline inside + // count exact style occurrences to avoid interference with CSS + val occurrences = html.split("border:3px solid green").size - 1 + occurrences shouldBe 1 + } + + @Test + fun `formatHeader by names overload`() { + val formatted = df.formatHeader("age").with { attr("text-align", "center") } + val html = formatted.toHtml().toString() + val occurrences = html.split("text-align:center").size - 1 + occurrences shouldBe 1 + } + + @Test + fun `header style inherited from group to children`() { + // Apply style to the group header only + val formatted = df.formatHeader { name }.with { attr("border", "1px solid red") } + val html = formatted.toHtml().toString() + + // We expect the style on the group header itself and each direct child header + // In the default TestBase dataset, name group has two children + val occurrences = html.split("border:1px solid red").size - 1 + occurrences shouldBe 3 + } + + @Test + fun `child header overrides parent group header style`() { + val formatted = df + .formatHeader { name }.with { attr("border", "1px solid red") } + .formatHeader { name.firstName }.with { attr("border", "2px dashed green") } + val html = formatted.toHtml().toString() + + // Parent style applies to group and lastName, but firstName gets its own style in addition to or replacing + // We check for both occurrences + val parentOcc = html.split("border:1px solid red").size - 1 + val childOcc = html.split("border:2px dashed green").size - 1 + + parentOcc shouldBe 2 // group + lastName + childOcc shouldBe 1 // firstName only + } + + @Test + fun `format and formatHeader can be chained and both persist`() { + val formatted = df + .format { age }.with { background(blue) } + .formatHeader { age }.with { attr("border", "3px solid green") } + + val html = formatted.toHtml().toString() + + // body cell style + (html.split("background-color:#0000ff").size - 1) shouldBe 7 + // header style + (html.split("border:3px solid green").size - 1) shouldBe 1 + + formatted::class.simpleName shouldNotBe null + } +} diff --git a/dataframe-jupyter/build.gradle.kts b/dataframe-jupyter/build.gradle.kts index 1269ac0602..00723fc9c1 100644 --- a/dataframe-jupyter/build.gradle.kts +++ b/dataframe-jupyter/build.gradle.kts @@ -36,8 +36,12 @@ dependencies { testImplementation(projects.dataframeJupyter) testImplementation(projects.dataframeGeoJupyter) - testImplementation(libs.kandy.notebook) - testImplementation(libs.kandy.stats) + testImplementation(libs.kandy.notebook) { + exclude("org.jetbrains.kotlinx", "dataframe") + } + testImplementation(libs.kandy.stats) { + exclude("org.jetbrains.kotlinx", "dataframe") + } testImplementation(libs.kotestAssertions) { exclude("org.jetbrains.kotlin", "kotlin-stdlib-jdk8") diff --git a/docs/StardustDocs/resources/api/format/formatHeader.html b/docs/StardustDocs/resources/api/format/formatHeader.html new file mode 100644 index 0000000000..574641079b --- /dev/null +++ b/docs/StardustDocs/resources/api/format/formatHeader.html @@ -0,0 +1,516 @@ + + + + + +
+ +

+ + + diff --git a/docs/StardustDocs/topics/_shadow_resources.md b/docs/StardustDocs/topics/_shadow_resources.md index f4c5465027..b9e0d4d08e 100644 --- a/docs/StardustDocs/topics/_shadow_resources.md +++ b/docs/StardustDocs/topics/_shadow_resources.md @@ -179,6 +179,7 @@ + \ No newline at end of file diff --git a/docs/StardustDocs/topics/format.md b/docs/StardustDocs/topics/format.md index b996532ca1..5088935ffc 100644 --- a/docs/StardustDocs/topics/format.md +++ b/docs/StardustDocs/topics/format.md @@ -136,3 +136,30 @@ df2.format().perRowCol { row, col -> + +## formatHeader + +> This method is experimental and may be unstable. +> +> {type="warning"} + +Formats the specified column headers. + + + + + +```kotlin +df + // Format all column headers with bold + .formatHeader().with { bold } + // Format the "name" column (including nested) header with red text + .formatHeader { name }.with { textColor(red) } + // Override "name"/"lastName" column formating header with blue text + .formatHeader { name.lastName }.with { textColor(blue) } + // Format all numeric column headers with underlines + .formatHeader { colsOf() }.with { underline } +``` + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index edf0a98f12..67515d2cd2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,7 +63,7 @@ jai-core = "1.1.3" jts = "1.20.0" # Normal examples Kandy versions -kandy = "0.8.2-dev-87" +kandy = "0.8.1-dev-89" # Example notebooks Kandy versions kandy-notebook = "0.8.1n" diff --git a/samples/build.gradle.kts b/samples/build.gradle.kts index aa48e99d95..6311bffd63 100644 --- a/samples/build.gradle.kts +++ b/samples/build.gradle.kts @@ -118,6 +118,7 @@ korro { include("src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/*.kt") include("src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/*.kt") include("src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/utils/*.kt") + include("src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/render/*.kt") include("src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/collectionsInterop/*.kt") include("src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/column/*.kt") include("src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/info/*.kt") diff --git a/samples/src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/render/FormatHeaderSamples.kt b/samples/src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/render/FormatHeaderSamples.kt new file mode 100644 index 0000000000..cdd717fe04 --- /dev/null +++ b/samples/src/test/kotlin/org/jetbrains/kotlinx/dataframe/samples/api/render/FormatHeaderSamples.kt @@ -0,0 +1,45 @@ +package org.jetbrains.kotlinx.dataframe.samples.api.render + +import org.jetbrains.kotlinx.dataframe.DataRow +import org.jetbrains.kotlinx.dataframe.annotations.DataSchema +import org.jetbrains.kotlinx.dataframe.api.cast +import org.jetbrains.kotlinx.dataframe.api.colsOf +import org.jetbrains.kotlinx.dataframe.api.formatHeader +import org.jetbrains.kotlinx.dataframe.api.with +import org.jetbrains.kotlinx.dataframe.samples.DataFrameSampleHelper +import org.junit.Test + +class FormatHeaderSamples : DataFrameSampleHelper("format", "api") { + val df = peopleDf.cast() + + @DataSchema + interface Name { + val firstName: String + val lastName: String + } + + @DataSchema + interface Person { + val age: Int + val city: String? + val name: DataRow + val weight: Int? + val isHappy: Boolean + } + + @Test + fun formatHeader() { + // SampleStart + df + // Format all column headers with bold + .formatHeader().with { bold } + // Format the "name" column (including nested) header with red text + .formatHeader { name }.with { textColor(red) } + // Override "name"/"lastName" column formating header with blue text + .formatHeader { name.lastName }.with { textColor(blue) } + // Format all numeric column headers with underlines + .formatHeader { colsOf() }.with { underline } + // SampleEnd + .saveDfHtmlSample() + } +}