Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New table fragment (#185) #368

Merged
merged 29 commits into from
Apr 13, 2021
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
54e166c
Basic table implementation
Baret Feb 20, 2021
0f99a74
First visible example
Baret Feb 20, 2021
c2d8911
Created TableBuilder and Columns
Baret Feb 23, 2021
86b8b86
created formatted text column
Baret Feb 23, 2021
220f9f7
Added icon column
Baret Feb 25, 2021
baf63fe
Added panel to show the selected person
Baret Mar 1, 2021
84dfc30
DefaultTable updates the selected row on click
Baret Mar 1, 2021
6bd5ae6
Table uses databinding when cell value is observable
Baret Mar 1, 2021
bca7c9d
Added doc for Columns.icon()
Baret Mar 11, 2021
ffafc2b
Merge remote-tracking branch 'upstream/master' into issue-185-table-f…
Baret Mar 22, 2021
894f3fa
Added back JvmStatic import, formatting
Baret Mar 23, 2021
a181f8b
Renamed gender to height
Baret Mar 24, 2021
1d363bc
Removed textColumnFormatted()
Baret Mar 24, 2021
cfece6d
Added text for height
Baret Mar 24, 2021
448c030
Update wage label on change
Baret Mar 24, 2021
95c621a
Got rid of String.format
Baret Mar 24, 2021
ad84e67
Do not organize my imports! Dammit
Baret Mar 24, 2021
2afd289
Updated label bindings
Baret Mar 24, 2021
4eff194
Renamed Columns to TableColumns
Baret Mar 25, 2021
7b339d6
Improved readability in textColumn()
Baret Mar 25, 2021
09ebb7c
Use withRelativeHeight()
Baret Mar 25, 2021
af28c9e
Tried to simplify the loop in dataPanel() and failed xD
Baret Mar 28, 2021
500e8cb
Added @Beta to new classes
Baret Apr 7, 2021
28c98ed
Moved TableColumn to api package
Baret Apr 7, 2021
1233174
Merge remote-tracking branch 'upstream/master' into issue-185-table-f…
Baret Apr 7, 2021
9870759
Removed old table prototype
Baret Apr 7, 2021
19518d0
basic implementation of data as ObservableList
Baret Apr 7, 2021
7937969
added textColumnObservable
Baret Apr 10, 2021
503f91a
Improved some docs
Baret Apr 10, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package org.hexworks.zircon.api

import org.hexworks.cobalt.databinding.api.binding.bindTransform
import org.hexworks.cobalt.databinding.api.property.Property
import org.hexworks.cobalt.databinding.api.value.ObservableValue
import org.hexworks.zircon.api.component.Icon
import org.hexworks.zircon.api.component.Label
import org.hexworks.zircon.internal.fragment.impl.table.TableColumn

/**
* This object provides different presets for [TableColumn]s.
*/
object Columns {
Baret marked this conversation as resolved.
Show resolved Hide resolved

/**
* Creates a [TableColumn] that represents it's cell values with a [Label]. The text of the label
* is the toString() of the value provided by [valueAccessor].
*

This comment was marked as resolved.

* @param name the name of the column to be used as header
* @param width the width of the column
* @param valueAccessor returns the value to be used in each cell of the column. It should preferable be a
* [String] or similar primitive type so that calling toString on it does not have strange side effects.
* When the value returned is an [ObservableValue] or a [Property] the textProperty of the label
* will update accordingly.
*/
fun <M : Any, V : Any> textColumn(name: String, width: Int, valueAccessor: (M) -> V): TableColumn<M, V, Label> =
TableColumn(
name,
width,
valueAccessor
) { cellValue ->
Components
.label()
.withSize(width, 1)
.build()
.apply {
if (cellValue is ObservableValue<*>) {
textProperty.updateFrom(cellValue.bindTransform { it.toString() }, true)
Baret marked this conversation as resolved.
Show resolved Hide resolved
} else {
text = cellValue.toString()
}
}
}

/**
* Creates a [textColumn] with the value returned by [valueAccessor] formatted with the format string [format].
*/
fun <M : Any, V : Any> textColumnFormatted(
name: String,
width: Int,
format: String,
valueAccessor: (M) -> V
): TableColumn<M, *, Label> =
textColumn(name, width) {
format
.format(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, so String#format is purely a JVM construct, looking at this SO question. My recommendation would be to just drop this method completely and perhaps expose a (M) -> String method in the API instead? Something to let the user either use Kotlin's string templates directly, or to access a ObservableValue.

valueAccessor
.invoke(it)
)
}

/**
* Creates a column of width 1 which represents its values with an [Icon].
*/
fun <M: Any, V: Any> icon(name: String,
valueAccessor: (M) -> V,
iconGenerator: (V) -> Icon): TableColumn<M, V, Icon> =
TableColumn(
name,
1,
valueAccessor,
iconGenerator
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ package org.hexworks.zircon.api

import org.hexworks.zircon.api.builder.fragment.ColorThemeSelectorBuilder
import org.hexworks.zircon.api.builder.fragment.SelectorBuilder
import org.hexworks.zircon.api.builder.fragment.TableBuilder
import org.hexworks.zircon.api.builder.fragment.TilesetSelectorBuilder
import org.hexworks.zircon.api.component.ColorTheme
import org.hexworks.zircon.api.component.Fragment
import org.hexworks.zircon.api.fragment.Selector
import org.hexworks.zircon.api.resource.TilesetResource
import kotlin.jvm.JvmStatic
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out this line is required in order for this file to build 😄 if you put it back in it should work fine.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Umm great find, why didn't IntelliJ tell me? :'(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No lie, I'm surprised that putting what looks like JVM-specific code in commonMain works at all. I guess it's just a no-op in the other targets.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the kotlin.jvm package instantly looks suspicious ;)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have organizing the imports at commit enabled which makes this import disappear... annoying -.-


/**
* This *facade* object provides builders for the built-in [Fragment]s
Expand Down Expand Up @@ -39,4 +39,7 @@ object Fragments {
theme: ColorTheme
): ColorThemeSelectorBuilder = ColorThemeSelectorBuilder.newBuilder(width, theme)

fun <M: Any> table(data: List<M>): TableBuilder<M> =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason for not using ObservableList here?

TableBuilder(data)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.hexworks.zircon.api.builder.fragment

import org.hexworks.zircon.api.builder.Builder
import org.hexworks.zircon.api.data.Position
import org.hexworks.zircon.api.fragment.Table
import org.hexworks.zircon.api.fragment.builder.FragmentBuilder
import org.hexworks.zircon.internal.fragment.impl.table.DefaultTable
import org.hexworks.zircon.internal.fragment.impl.table.TableColumn

class TableBuilder<M : Any>(private val data: List<M>): FragmentBuilder<Table<M>, TableBuilder<M>> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: can we use an ObservableList?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already wanted to do this but somehow forgot. The basics are there with 19518d0 but I feel like I want to further improve it ^^
At least I want to change the TableExample to start out with a table not fully filled and a + and - button to add and remove table entries to demonstrate the observable data.


private val columns: MutableList<TableColumn<M, *, *>> = mutableListOf()
private var height: Int = 5
private var rowSpacing: Int = 0
private var colSpacing: Int = 0
private var position: Position? = null

override fun build(): Table<M> =
DefaultTable(
data,
columns,
height,
rowSpacing,
colSpacing
)
.apply {
position?.let { root.moveTo(it) }
}

/**
* Adds the given [TableColumn]s to the list of columns for this table. Multiple calls
* add to the already defined columns.
*/
fun withColumns(vararg columns: TableColumn<M, *, *>) = also {
this.columns += columns
}

/**
* Sets the fragmentHeight of the resulting [Table].
*/
fun withHeight(height: Int) = also {
this.height = height
}

/**
* Sets the spacing between each data row.
*/
fun withRowSpacing(spacing: Int) = also {
rowSpacing = spacing
}

/**
* Sets the spacing between the columns.
*/
fun withColumnSpacing(spacing: Int) = also {
colSpacing = spacing
}

override fun withPosition(position: Position): TableBuilder<M> = also {
this.position = position
}

override fun withPosition(x: Int, y: Int): TableBuilder<M> =
withPosition(Position.create(x, y))

override fun createCopy(): Builder<Table<M>> {
val copy = TableBuilder(data)
.withColumns(*columns.toTypedArray())
.withHeight(height)
.withRowSpacing(rowSpacing)
.withColumnSpacing(colSpacing)
position?.let { copy.withPosition(it) }
return copy
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.hexworks.zircon.api.fragment

import org.hexworks.cobalt.databinding.api.value.ObservableValue
import org.hexworks.zircon.api.component.Fragment
import org.hexworks.zircon.api.data.Size

/**
* A table fragment displays data in rows. Each row contains several cells. How a cell is displayed depends
* on the definition of it's column.
*
* A table contains data of type [M] (the model). Every model object represents one row in the table. **A table
* may not be empty**.
*
* @param M the type of the underlying model representing this table's data.
*/
interface Table<M: Any>: Fragment {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Table<M: Any>

I see the missing space here as a recurring issue in this change. I'd double-check you're using the official code guidelines in IntelliJ, because the recommendation is to format this as Table<M : Any>. Minor, for sure, but it's good to be consistent.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup I totally agree. I just got used to refrain from formatting whole files. That leads to some lines sneaking through with ugly parts ;)

/**
* The element representing the currently selected row.
*/
val selectedRow: M

/**
* The element representing the currently selected row as [ObservableValue].
*/
val selectedRowValue: ObservableValue<M>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can kill two birds with one stone and modify this to be an ObservableList and name it selectedRowsValue. Then an empty list means no selection and you can also have multiple selected rows. You can change selectedRow accordingly.


/**
* The size this table consumes based on its configured height and column definition.
*/
val size: Size
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package org.hexworks.zircon.internal.fragment.impl.table

import org.hexworks.cobalt.databinding.api.extension.toProperty
import org.hexworks.cobalt.databinding.api.property.Property
import org.hexworks.cobalt.databinding.api.value.ObservableValue
import org.hexworks.zircon.api.Components
import org.hexworks.zircon.api.component.AttachedComponent
import org.hexworks.zircon.api.component.Component
import org.hexworks.zircon.api.component.HBox
import org.hexworks.zircon.api.component.VBox
import org.hexworks.zircon.api.data.Size
import org.hexworks.zircon.api.fragment.Table
import org.hexworks.zircon.api.uievent.MouseEventType
import org.hexworks.zircon.api.uievent.UIEventPhase
import org.hexworks.zircon.api.uievent.UIEventResponse

/**
* The **internal** default implementation of [Table].
*/
class DefaultTable<M: Any>(
private val data: List<M>,
private val columns: List<TableColumn<M, *, *>>,
/**
* The height this fragment will use. Keep in mind that the first row will be used as header row.
*/
fragmentHeight: Int,
private val rowSpacing: Int = 0,
private val colSpacing: Int = 0
): Table<M> {

init {
require(data.isNotEmpty()) {
"A table may not be empty! Please feed it some data to display."
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be nice to lift this requirement. Would that be possible?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought "sure, why not!", but when I just tried it I realized that there is selectedElement: Property<M>. With an empty list we would need to handle that and I don't know how complicated that would become. I think Property can not contain nullable values...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm...maybe the caller would have to provide a "null object pattern" object then? Not really a fan, but might be the only way?

I don't see it being terribly common but this does seem like something callers would expect to work.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @nanodeath . Imagine that you have filter fields above your table and the table itself has an ObservableList so when you type something the list is updated accordingly. It might happen that there are no items that correspond to your query and the table will be empty.

Selection is also a different concept. You can have a table that has n elements but none of them are selected.

See my comment above for a workaround for this problem that would also alleviate the need for nulls and null objects.

require(columns.isNotEmpty()) {
"A table must have at least one column."
}
val minHeight = 2
require(fragmentHeight >= minHeight) {
"A table requires a height of at least $minHeight."
}
}

private val selectedElement: Property<M> = data.first().toProperty()

override val selectedRowValue: ObservableValue<M> = selectedElement

override val selectedRow: M
get() = selectedRowValue.value

override val size: Size = Size.create(
width = columns.sumBy { it.width } + ((columns.size - 1) * colSpacing),
height = fragmentHeight
)

override val root: VBox = Components
.vbox()
.withSpacing(0)
.withSize(size)
.build()

private val currentRows: MutableList<AttachedComponent> = mutableListOf()

init {
val headerRow = headerRow()
root
.addComponents(
headerRow,
dataPanel(Size.create(size.width, size.height - headerRow.height))
Baret marked this conversation as resolved.
Show resolved Hide resolved
)
}

private fun dataPanel(panelSize: Size): VBox {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's a dataPanel? It is not clear from this.

return Components
.vbox()
.withSize(panelSize)
.withSpacing(rowSpacing)
.build()
.apply {
var remainingHeight = panelSize.height
// TODO: Improve this loop to not loop over all elements
data.forEach { model ->
val newRow = newRowFor(model)
if(remainingHeight > 0) {
currentRows.add(addComponent(newRow))
}
remainingHeight -= newRow.height + rowSpacing
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use a conventional for loop and break when remainingHeight <= 0?

}
}

private fun newRowFor(model: M): Component {
val cells: List<Component> = columns
.map { it.newCell(model) }
val rowHeight = cells.maxOf { it.height }
val row = Components
.hbox()
.withSpacing(colSpacing)
.withSize(size.width, rowHeight)
.build()
cells.forEach { row.addComponent(it) }
row.handleMouseEvents(MouseEventType.MOUSE_CLICKED) { _, phase ->
// allow for the cells to implement custom mouse event handling
if (phase == UIEventPhase.BUBBLE) {
selectedElement.updateValue(model)
UIEventResponse.processed()
} else {
UIEventResponse.pass()
}
}
return row
}

private fun headerRow(): HBox {
return Components
.hbox()
.withSize(size.width, 1)
.withSpacing(colSpacing)
.build()
.apply {
columns
.forEach { column ->
addComponent(column.header)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.hexworks.zircon.internal.fragment.impl.table

import org.hexworks.zircon.api.Components
import org.hexworks.zircon.api.component.Component

/**
* This class represents the definition of a column in a table. It provides means to get the value
* from a model object and wrap that value into a [Component]. This component is then displayed
* as table cell.
*
* @param M the type of the model. In other words: Every element/row in the table has this type
* @param V the type of the value of each cell in this column
* @param C type of the [Component] used to represent each cell
*/
open class TableColumn<M : Any, V : Any, C : Component>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be open?

Copy link
Collaborator Author

@Baret Baret Mar 28, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to give the users of the API as much flexibility as possible. You should not be restricted to only use the predefined TableColumns or this constructor. Maybe you have your own fancy implementation and want to use it over and over again so I thought it should be possible to extend this class.

/**
* The name of this column. Will be used as table header.
*/
val name: String,
/**
* The width of this column. The component created by [componentCreator] may not be wider than this.
*/
val width: Int,
private val valueAccessor: (M) -> V,
private val componentCreator: (V) -> C
Baret marked this conversation as resolved.
Show resolved Hide resolved
) {
/**
* The [Component] that should be used as the column's header. It must have a width of [width] and a height
* of 1.
*/
open val header: Component =
Components
.header()
.withText(name)
.withSize(width, 1)
.build()

fun newCell(rowElement: M): C =
componentCreator(
valueAccessor(rowElement)
)
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.hexworks.zircon.examples.playground;

import org.hexworks.zircon.api.*;
import org.hexworks.zircon.api.CP437TilesetResources;
import org.hexworks.zircon.api.ColorThemes;
import org.hexworks.zircon.api.Components;
import org.hexworks.zircon.api.SwingApplications;
import org.hexworks.zircon.api.application.AppConfig;
import org.hexworks.zircon.api.component.ComponentAlignment;
import org.hexworks.zircon.api.component.Panel;
Expand Down Expand Up @@ -35,7 +38,7 @@ public static void main(String[] args) {
new Model(5, "Amanda", "Flair", "Brewer")
);

Table<Model> table = new Table<>(fields, models);
TableOld<Model> table = new TableOld<>(fields, models);

Screen screen = Screen.create(SwingApplications.startTileGrid(
AppConfig.newBuilder()
Expand Down
Loading