TabularBox
August 15, 2024

A widget that allows viewing and editing data in a tabular way, that is, through cells in rows and columns. Custom cells can be created (implemented) in order to display and edit data in various ways. A few cells are provided out of the box.

The animation bellow shows the tabular box in action, showcasing cells for text, booleans, integers and images.

Usage

The "core" of the TabularBox is the TabularDataSource resource. This serves as a "bridge" between the stored data and what the TabularBox will display. What this means is that data storage is not necessarily handled by the data source, even though it's possible to be done like that. This plugin offers a simple implementation, TabularDataSourceSimple which provides basic data viewing/editing. Screenshots provided in the page were done while using an instance of this simple data source. Later I will talk more about the data sources and how to implement them. For the moment I want to deal with the properties provided by the TabularBox, which are:

Bellow some explanation about each one:

Interaction can only occur if there is a valid data source assigned into the TabularBox instance. So the discussion bellow assumes that's the case.

Requesting to add columns or rows can be done by interacting with the context menu shown when a right click occurs inside the TabularBox. Provided auto_handle_column_insertion/auto_handle_row_insertion is true, then the TabularBox will directly relay the requests to the assigned data source. Specifically, the virtual functions _insert_column() and _insert_row() are used.

Now, if the "auto handle" properties are false, then signals are emitted. More specifically insert_column_requested and insert_row_requested. Both receive a single argument, which is the index where the column or row must be inserted at. When column or row is inserted in this way, it's necessary to notify the TabularBox that something has been added. This is done through the data source, by calling the functions notify_new_column() and notify_new_row(). Both functions receive a single argument, which is the index where the new column or row has been added.

This design where the TabularBox can directly request the assigned data source to insert data vs emitting a signal instead, allows some more advanced use cases. As an example, it becomes possible to create a resource type dedicated to store the data, then use the data source to relay that data to the tabular box. In fact, that's exactly what the Database Plugin does under the hood. Data is stored in the GDDatabase resource, while there is a non exposed specialized data source that mediates everything between the database and the TabularBox.

After appending three columns and five rows, this is how the TabularBox looks like (remember, using the TabularDataSourceSimple):

When creating a new column, the TabularDataSourceSimple automatically set the name and assigns String to be its value type. Both can be changed. Changing the name is as a simple as clicking the column header, which is basically a LineEdit:

Right clicking in a column results in a context menu with additional options. One of them is to allow value type change, like this:

And so, when the type is changed to integer, the TabularBox will request the data source for which cell type (class) it should use to deal with that data type. At that point the cells will be replaced. In the specific case of the simple data source, the TabularBoxCellInteger is used to deal with Integer value types. This is the result after the selection:

It's possible to completely disable the context menu, by simply toggling off the allow_context_menu property. This might be useful if you want the TabularBox to show data while also not allowing it to be modified.

Now Notice the "down arrow" at the top left corner of the box. Clicking it will bring the "row selection" menu:

TabularBox - Selection Menu

As you can see, it allows easier tasks related to row selection. Selecting a row with this widget is basically ticking that check box rendered besides the row number. The screenshot bellow shows how it looks like when all rows are selected:

TabularBox - All Rows Selected

If the show_row_checkboxes property is off then that "row selection" menu will be disabled.

As mentioned early, the data source is the core of the TabularBox. Not only it might determine how the data is stored, it also controls a lot of how the UI will behave. The TabularDataSource contains several virtual functions that must be overridden. For a complete list and brief explanation of each one, please refer to the in editor documentation of the data source.

Yet, the list of functions that must be implemented is a bit big. Unfortunately this is the nature of dealing with this kind of data in a very generic way. How the data is stored is entirely up to the implementation. Still, simply changing the internal data wont do anything in the visualization of the tabular box, which must be notified about the fact. There are several functions named notify_*(), which will then trigger signals. The tabular box listen to those signals in order to update the display. Again, refer to the in editor documentation for the complete list (and information) of each of those functions.

The data source also contains a system to filter out data. This is as simple as providing a keyword through its set_filter(keyword, case_sensitive, exclude_columns) function. Giving an empty string will clear the filtering. If the second argument, case_sensitive, is true then the match must be perfect, including upper/lower cases. The third argument is an optional array of strings. the expected is that any entry should match the name of a column. Those columns will be ignored when searching data for the provided keyword.

# Apply a filter to the displayed data, not ignoring any column in the search
tabular_box.data_source.set_filter(keyword, case_sensitive)

Custom Data Source

With all this information we can now delve into the implementation of a custom data source. If you want full reference code to the snippets shown here, check the demo project, more specifically the custom_data_source.gd and tboxcelldropdown.gd files. Both of them are in the scripts subdirectory of the Godot project.

Before working on the script, we need to define:

  1. How the data will be stored in the data source.
  2. Which value types we want to allow as well as which (numeric) "code" should be assigned into each one.

When determining the data storage, one important aspect is that we must ensure the column and row orders are preserved. We know the Array container does that. With that in mind perhaps we create an Array for column and another for the rows.

For each column we need information regarding its value type, title and perhaps some settings. So each entry in the column array should be a Dictionary, which would easily allow us to store whatever information we need for each column in the data source. In this regard it would be fantastic if GDscript had Struct!

As for the rows, we want each entry to contain the values of each column. In here we have the options to store each row as an array of values or as a Dictionary where each key matches a column title, holding the corresponding value. Using the array strategy means less data storage at the cost of higher upkeep. The thing is, if columns are reordered, every single row must be updated to the new order. Going with the Dictionary route means less upkeep in exchange of a little bit of extra storage. For this example we will go with the Dictionary route not only for the fact that it's easier to deal with, but also because it results in less upkeep, which might result in better performance.

Regarding the value types, we will take advantage of the cells that are provided out of the box, which allow us to support single line strings, booleans, integers, floating point numbers, "textures" (actually paths to the texture resources) and multi-line strings. But how about a "custom" type that results in each cell containing a drop down menu, offering "damage type" as option? For that we need to create a custom cell. We will do so later. Yet we need to determine how the type will be identified. To make things easier we will use an enumeration in which each entry will be the "code" for one of the value types.

With all that in mind, we will begin by implementing the data source script. This means that it should extend TabularDataSource. So create it:

One very important aspect of the scripted data sources is the fact that it's a good idea to mark them as @tool scripts. The reason is that the TabularBox attempts to load and call the functions as soon as an instance of the data source is assigned into it. Not marking the script to be a tool one will partially work. In editor data wont be shown and, upon loading the project, error messages are likely to be shown, telling that virtual functions must be implemented before being called. Using the data source while running the game/application will work though. The tabular box will warn when a scripted data source not marked as tool is assigned into its data_source property.

Another thing is that adding a class_name will help create instances of the data source directly from the inspector. Otherwise that task has to be done through code. So, in our script we begin with:

custom_data_source.gd
@tool
class_name TabularDataSourceCustom
extends TabularDataSource

Now the two arrays that will hold all the data:

custom_data_source.gd
@export var column_list: Array[Dictionary]
@export var row_list: Array[Dictionary]

Next we declare the enumeration that will hold the "value type codes":

custom_data_source.gd
enum ValueType {
   VT_STRING,
   VT_BOOL,
   VT_INT,
   VT_FLOAT,
   VT_TEXTURE,
   VT_MULTILINE_STRING,
   VT_DAMAGE_TYPE,
}

One last thing is the damage type. As mentioned we want to provide drop down menus with the available options. We will need to identify those damage types through some "id" (or code, whatever). For this example we will create this "coding" within the same data source script. However consider adapting this to your needs, perhaps adding this into a singleton (auto load script). That said, the damage type ids will be done through an enumeration:

custom_data_source.gd
enum DamageType {
   DT_None,        # Mostly to have a "default value"
   DT_Physical,
   DT_Fire,
   DT_Cold,
   DT_Lightning,
   DT_Poison,
   DT_Arcane,
}

We can now work on the functions. We begin with the _get_type_list() function. It should return a Dictionary where each entry's key is the "code" (identification) while the value is a string that will be shown in the UI. So the keys of this returned dictionary will be direct values of the ValueType enum that we have already implemented. The values are arbitrary strings. This is the code:

custom_data_source.gd
func _get_type_list() -> Dictionary:
   return {
      ValueType.VT_STRING: "String",
      ValueType.VT_BOOL: "Bool",
      ValueType.VT_INT: "Integer",
      ValueType.VT_FLOAT: "Float",
      ValueType.VT_TEXTURE: "Texture",
      ValueType.VT_MULTILINE_STRING: "Multi-line String",
      ValueType.VT_DAMAGE_TYPE: "Damage Type"
   }

The _has_column() function must tell if the incoming title corresponds to a column that exists in the data source. In an advanced implementation we could create some sort of indexing or a Set of column titles, which would make querying this information rather trivial. However to simplify things we will linearly search the column list. In other words, the implementation here will iterate through stored data then comparing stored title with incoming one. If a match is found we return true, which also results in interrupting the loop. This also means that if the loop finishes normally then the title was not found, so we return false at that point. This in code:

custom_data_source.gd
func _has_column(title: String) -> bool:
   for col: Dictionary in column_list:
      if col["title"] == title:
         return true

   return false

Implementing _get_column_count() is actually very trivial! All we need to do is return the amount of entries in the column_list array, which is obtained by simply calling its size() function:

custom_data_source.gd
func _get_column_count() -> int:
   return column_list.size()

The next function, _get_column_info() is slightly more involved, but not that much. The thing is, the returned dictionary is only required to contain the title of the column, while all the rest can remain empty. Yet we still want to use a few of the entries to properly define the behavior. Bellow I list each of the expected entries of the returned dictionary and how we will use it, if at all:

When dealing with the cell_class we need a way to associate the value type with the cell class name. Associations are trivially done through dictionaries. Those also allow us to simply query it instead of having to manually create a match statement! So, we create such dictionary:

custom_data_source.gd
const type_to_class: Dictionary = {
   ValueType.VT_STRING: "TabularBoxCellString",
   ValueType.VT_BOOL: "TabularBoxCellBool",
   ValueType.VT_INT: "TabularBoxCellInteger",
   ValueType.VT_FLOAT: "TabularBoxCellFloat",
   ValueType.VT_TEXTURE: "TabularBoxCellTexture",
   ValueType.VT_MULTILINE_STRING: "TabularBoxCellMultilineString",
   # This is for a custom cell and must be implemented
   ValueType.VT_DAMAGE_TYPE: "TabularBoxCellDropdown",
}

To help build the extra dictionary we will create a simple helper function that receive the "damage type id" and returns a string that is meant to be shown in the UI. It should be pretty self explanatory so here is the function:

custom_data_source.gd
func _get_damage_type_string(type: DamageType) -> String:
   match type:
      DamageType.DT_Physical:
         return "Physical"
      DamageType.DT_Fire:
         return "Fire"
      DamageType.DT_Cold:
         return "Cold"
      DamageType.DT_Lightning:
         return "Lightning"
      DamageType.DT_Poison:
         return "Poison"
      DamageType.DT_Arcane:
         return "Arcane"

   return "None"

Finally to the _get_column_info() implementation. The first task we need is to ensure the incoming index is within range. After that is done we must retrieve from the stored data the information corresponding to the column index. With this retrieved data we now know the title and the value type of the column. If the type is the damage type then we must provide the entries as part of the extra_settings entry in the returned value. The final task is basically assigning the relevant values into the entries of the returned dictionary:

custom_data_source.gd
func _get_column_info(index: int) -> Dictionary:
   if (index < 0 || index >= column_list.size()):
      # In here perhaps print an error message?
      return {}

   var column: Dictionary = column_list[index]

   var type: ValueType = column["type"]
   var extra: Dictionary = {}

   if type == ValueType.VT_DAMAGE_TYPE:
      extra["damage_type"] = [
         { "type_id": DamageType.DT_None, "name": _get_damage_type_string(DamageType.DT_None) },
         { "type_id": DamageType.DT_Physical, "name": _get_damage_type_string(DamageType.DT_Physical) },
         { "type_id": DamageType.DT_Fire, "name": _get_damage_type_string(DamageType.DT_Fire)  },
         { "type_id": DamageType.DT_Cold, "name": _get_damage_type_string(DamageType.DT_Cold) },
         { "type_id": DamageType.DT_Lightning, "name": _get_damage_type_string(DamageType.DT_Lightning) },
         { "type_id": DamageType.DT_Poison, "name": _get_damage_type_string(DamageType.DT_Poison) },
      ]

   return {
      "title": column["title"],
      "type_code": type,
      "cell_class": type_to_class[type],
      "extra_settings": extra,
   }

Now the _insert_column() function. Iit's expected this function can deal with an incoming empty title. Because of that we must have a way of generating a column title. For this simple case we will create a function that will attempt to generate a title that is simply an alphabet character. Each time all characters have been iterated through without finding a valid one, we append a number to it and go over the alphabet again. Note that by valid it's a non existing title. This is not a hard requirement by the TabularBox, however the way we are storing the data means that we have to ensure this uniqueness.

The info parameter is typically used to provide additional settings meant to be directly associated with the column itself, like the flags and move_mode, for example. The extra_settings is used to provide additional data to the cells of the column. In our example here none of the two parameters will be used. The thing is, those exist so additional tooling can provide that information through code. In here we are interacting with the TabularBox almost exclusively through the context menu. In this case both parameters will always be empty.

So, before working on the _insert_column() we work on the function meant to "generate" the column title. Its functionality has already been explained, so here is the code:

custom_data_source.gd
const _alphabet: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"

func _generate_title() -> String:
   var attempt_count: int = 0
   var done: bool = false

   while !done:
      for l: String in _alphabet:
         var attempt: String = "%s%s" % [l, str(attempt_count) if attempt_count > 0 else ""]
         if !_has_column(attempt):
            return attempt
      
      # If still here, all characters have been iterated through
      attempt_count += 1
      
      # Put a hard limit on the number of attempts
      if attempt_count == 15:
         done = true

   return ""

There is one additional "helper function" that we must create though. When adding a column, we must take into account the fact that rows might already exist. This means that we must ensure those possibly existing rows must be updated to contain the entry for this new column. For that to work we need default values. However each value type have its own set of requirements. So we create a function that requires the "value type code" then returns the default value for that type. The implementation should be pretty self explanatory, so here it goes:

custom_data_source.gd
func _get_default_value_for(type: int) -> Variant:
   var ret: Variant
   match type:
      ValueType.VT_STRING, ValueType.VT_MULTILINE_STRING, ValueType.VT_TEXTURE:
         ret = ""
      ValueType.VT_BOOL:
         ret = false
      ValueType.VT_INT:
         ret = int(0)
      ValueType.VT_FLOAT:
         ret = 0.0
      ValueType.VT_DAMAGE_TYPE:
         ret = DamageType.DT_Physical

   return ret

As mentioned, we have to deal with empty incoming title. This will always be the case when _insert_column() is called after requesting a new column through the context menu. So the first task is to verify if the parameter is empty or not. If so, we call the _generate_title() we have just created. Otherwise we have to check if the incoming title is indeed available to be used.

The next task is related to the desired index where the column should be added. If negative then the column must be appended. Otherwise it's expected to be inserted between two existing columns. Yet, we must verify if the index is above expected bounds. If so we simply ensure the column will be appended.

Then we have to verify the validity of the incoming value type. If invalid we default to string.

The rest is basically creating the column data (the Dictionary), inserting into the column_list array, updating the existing rows then notifying the TabularBox that a column has been inserted:

custom_data_source.gd
func _insert_column(title: String, type: int, index: int, _info: Dictionary, _extra: Dictionary) -> void:
   if title.is_empty():
      title = _generate_title()
      
      if title.is_empty():
         # Perhaps an error message could be printed here?
         return
   else:
      if _has_column(title):
         # Perhaps an error message?
         return

   if index < 0 || index > column_list.size():
      # Perhaps an error message here?
      index = column_list.size()

   if type < 0:
      type = ValueType.VT_STRING

   var column: Dictionary = {
      "title": title,
      "type": type,
   }

   @warning_ignore("return_value_discarded")
   column_list.insert(index, column)

   # Update rows to contain the new column
   for row: Dictionary in row_list:
      row[title] = _get_default_value_for(type)

   notify_new_column(index)

Implementing _remove_column() should be rather simple, really. We first have to check if the incoming index is valid. Provided it is, we must update any existing rows, remove the column element from the column_list array then notify the TabularBox about the change! That's it:

custom_data_source.gd
func _remove_column(index: int) -> void:
   if index < 0 || index >= column_list.size():
      return

   var column: Dictionary = column_list[index]
   var title: String = column["title"]

   # Update the rows
   for row: Dictionary in row_list:
      @warning_ignore("return_value_discarded")
      row.erase(title)

   column_list.remove_at(index)

   notify_column_removed(index)

For the _rename_column() we first need to verify the validity of the incoming column index. Provided it is, we then need to check if the provided new name is available. If it's already in use we have to notify that the rename has been rejected. Then, provided the column can be renamed, we have to update any possibly existing rows, which basically consists in "renaming" the corresponding column title's key in each row's dictionary. Normally this is done by copying the old value into the "new key" then deleting the old one. However the extension pack provides ExtPackUtils.change_dictionary_key() function, which automates this. After that we can finally update the column info in the column_list array. We must not forget to notify the TabularBox. The code:

custom_data_source.gd
func _rename_column(index: int, new_title: String) -> void:
   if index < 0 || index >= column_list.size():
      return

   if _has_column(new_title):
      # New title already exists
      # An improvement here could be done, by only notifying about the rejection if the incoming column index
      # is different from the already existing column
      notify_column_rename_rejected(index)
      return

   var column: Dictionary = column_list[index]
   var old_title: String = column["title"]

   # Column can be renamed, so update existing rows
   for row: Dictionary in row_list:
      ExtPackUtils.change_dictionary_key(row, old_title, new_title)

   column["title"] = new_title
   notify_column_renamed(index)

Implementing _change_column_value_type() is probably the most laborious here. The thing is, we must deal with value conversion. Provided the incoming column index is valid, we must then iterate through each row and convert the stored value from the old type into the new one. After that we have to update the stored column data. Of course we can't forget to notify the TabularBox about the change!

To help with the value conversion task we will break down into several functions, one meant to convert the incoming value into a specific "output" type. Basically we will create one "converter" function for each supported value type.

The first converter will take the incoming value and convert it into string. In this function we take the original type and the value itself as parameters. Through a match statement we perform the conversion accordingly:

custom_data_source.gd
func _convert_to_string(from_type: int, value: Variant) -> String:
   var ret: String = ""

   match from_type:
      ValueType.VT_BOOL:
         var bval: bool = value
         if bval:
            ret = "True"
         else:
            ret = "False"
      
      ValueType.VT_INT:
         ret = "%d" % value
      
      ValueType.VT_FLOAT:
         ret = "%f" % value
      
      ValueType.VT_DAMAGE_TYPE:
         # We will deal with this conversion later, after implementing the custom cell
         var dtv: DamageType = value
         ret = _get_damage_type_string(dtv)
      
      _:
         # Texture and multi-line string are already stored as strings, so no conversion is needed here
         ret = value

   return ret

The next converter is to output booleans. This should be simpler than the string case, since not everything can be easily converted into a boolean type. Here is the code:

custom_data_source.gd
func _convert_to_bool(from_type: int, value: Variant) -> bool:
   var ret: bool = false

   match from_type:
      ValueType.VT_STRING:
         var strval: String = value
         if strval.to_lower() == "true":
            ret = true
      
      ValueType.VT_INT, ValueType.VT_FLOAT:
         if value > 0:
            ret = true
      
      # No sense in dealing with the other possible source types

   return ret

Converting to integer must take in consideration the fact that a string might contain a valid numeric value. Even if it's representing a fractional number, we can truncate it into an integer. Alternatively, instead of simply truncating the value we could use round(). I will leave this as an exercise. That said, the implementation:

custom_data_source.gd
func _convert_to_integer(from_type: int, value: Variant) -> int:
   var ret: int = 0

   match from_type:
      ValueType.VT_STRING, ValueType.VT_MULTILINE_STRING:
         var strval: String = value
         if strval.is_valid_int():
            ret = strval.to_int()
         elif strval.is_valid_float():
            ret = int(strval.to_float())
      
      ValueType.VT_BOOL:
         var bval: bool = value
         if bval:
            ret = 1
      
      ValueType.VT_FLOAT:
         var fval: float = value
         ret = int(fval)
      
      ValueType.VT_DAMAGE_TYPE:
         var dt: DamageType = value
         ret = dt

   return ret

The same idea of the integer, but for floating point numbers. A separate function is used because there are a few differences in how things are done depending on the incoming value type.

custom_data_source.gd
func _convert_to_float(from_type: int, value: Variant) -> float:
   var ret: float = 0.0

   match from_type:
      ValueType.VT_STRING, ValueType.VT_MULTILINE_STRING:
         var strval: String = value
         if strval.is_valid_int():
            ret = float(strval.to_int())
         elif strval.is_valid_float():
            ret = strval.to_float()
      
      ValueType.VT_BOOL:
         var bval: bool = value
         if bval:
            ret = 1.0
      
      ValueType.VT_INT:
         var ival: int = value
         ret = float(ival)

   return ret

Converting to texture is rather simple. Internally we will store textures as paths to the image resources. Or in other words, strings. However there is absolutely no sense in converting a numeric value into a "path", so we can ignore several cases. That said, when the incoming value is of any string type, we must verify if it represents a valid path:

custom_data_source.gd
func _convert_to_texture(from_type: int, value: Variant) -> String:
   var ret: String = ""

   match from_type:
      ValueType.VT_STRING, ValueType.VT_MULTILINE_STRING:
         var strval: String = value
         if strval.is_absolute_path() || strval.is_relative_path():
            ret = strval

   return ret

We then have the multi-line string. Well, if we consider this as an "ordinary" string, then we can convert pretty much anything into it. Even though we could have reused the _convert_to_string(), we add a dedicated one just in case we decide to do something special with it. Also, I wanted to show a slightly different way to convert the values into string form:

custom_data_source.gd
func _convert_to_multil_string(from_type: int, value: Variant) -> String:
   var ret: String = ""

   match from_type:
      ValueType.VT_STRING, ValueType.VT_INT, ValueType.VT_FLOAT, ValueType.VT_TEXTURE:
         ret = value
      
      ValueType.VT_BOOL:
         var bval: bool = value;
         if bval:
            ret = "True"
         else:
            ret = "False"
      
      ValueType.VT_DAMAGE_TYPE:
         var dt: DamageType = value
         ret = _get_damage_type_string(dt)

   return ret

The final converter for this example is to output the DamageType. This function is slightly bigger than the others, however it's also the most self explanatory of the converters!

custom_data_source.gd
func _convert_to_damage_type(from_type: int, value: Variant) -> DamageType:
   var ret: DamageType = DamageType.DT_None

   match from_type:
      ValueType.VT_STRING, ValueType.VT_MULTILINE_STRING:
         var strval: String = value
         match strval:
            "Physical":
               ret = DamageType.DT_Physical
            "Fire":
               ret = DamageType.DT_Fire
            "Cold":
               ret = DamageType.DT_Cold
            "Lightning":
               ret = DamageType.DT_Lightning
            "Poison":
               ret = DamageType.DT_Poison
            "Arcane":
               ret = DamageType.DT_Arcane
      
      ValueType.VT_INT:
         var ival: int = value
         match ival:
            DamageType.DT_Physical:
               ret = DamageType.DT_Physical
            DamageType.DT_Fire:
               ret = DamageType.DT_Fire
            DamageType.DT_Cold:
               ret = DamageType.DT_Cold
            DamageType.DT_Lightning:
               ret = DamageType.DT_Lightning
            DamageType.DT_Poison:
               ret = DamageType.DT_Poison
            DamageType.DT_Arcane:
               ret = DamageType.DT_Arcane

   return ret

Ok, now that we have all those converters, we can actually work on the _change_column_value_type() function. Much of its requirements have been explained already. Nevertheless, the most notable thing in this function is the fact that I have used a Callable to call the correct converter function. Based on the new_type parameter, this callable is assigned to a proper function. Then, when iterating through the rows, this callable is used instead of having to check for the new_type every time. The function:

custom_data_source.gd
func _change_column_value_type(index: int, new_type: int) -> void:
   if index < 0 || index >= column_list.size():
      return

   var vt: ValueType = column_list[index]["type"]
   if vt == new_type:
      # "New type" matches current one. No need to do anything else so bail
      return

   # This will be called during the row iteration to perform the conversion. The actual function depends on the "new_type"
   var converter: Callable
   match new_type:
      ValueType.VT_STRING:
         converter = Callable(self, "_convert_to_string")
      ValueType.VT_BOOL:
         converter = Callable(self, "_convert_to_bool")
      ValueType.VT_INT:
         converter = Callable(self, "_convert_to_integer")
      ValueType.VT_FLOAT:
         converter = Callable(self, "_convert_to_float")
      ValueType.VT_TEXTURE:
         converter = Callable(self, "_convert_to_texture")
      ValueType.VT_MULTILINE_STRING:
         converter = Callable(self, "_convert_to_multil_string")
      ValueType.VT_DAMAGE_TYPE:
         converter = Callable(self, "_convert_to_damage_type")

   if !converter.is_valid():
      return

   column_list[index]["type"] = new_type

   var title: String = column_list[index]["title"]
   for row: Dictionary in row_list:
      var stored: Variant = row[title]
      var converted: Variant = converter.call(vt, stored)
      row[title] = converted

   notify_type_changed(index)

The next function is _move_column(). First we need to perform a few checks. The two indices must be different and within valid bounds. Then the task of moving the column data in the column array is rather easy. First we cache the data, remove from the array the insert again into the "new index". We could implement another method if the difference between the two indices is exactly 1. In this case we can basically "swap" the values instead of first removing something from the array. We wont do that in this example though. Once the operations are done we have to notify the TabularBox about the change:

custom_data_source.gd
func _move_column(from: int, to: int) -> void:
   if from == to:
      return

   if from < 0 || from >= column_list.size():
      return

   if to < 0 || to >= column_list.size():
      return

   var column: Dictionary = column_list[from]
   column_list.remove_at(from)

   @warning_ignore("return_value_discarded")
   column_list.insert(to, column)

   notify_column_moved(from, to)

_get_row_count() is extremely trivial! We simple return the size of the row_list array:

custom_data_source.gd
func _get_row_count() -> int:
   return row_list.size()

The _insert_row() must be able to deal with an empty incoming values dictionary. For that we already have a helper function to retrieve default values based on the value type. That said, the first task is to verify the desired index. If negative or above bounds then we set things so the row is appended into the row_list array. We then build the row data Dictionary by iterating existing columns and extracting the values from the incoming values parameter. Once the data is built we insert it into the array then notify the TabularBox about the change:

custom_data_source.gd
func _insert_row(values: Dictionary, index: int) -> void:
   if index < 0 || index > row_list.size():
      index = row_list.size()

   var ccount: int = column_list.size()
   var new_row: Dictionary

   for c: int in ccount:
      var title: String = column_list[c]["title"]
      
      var value: Variant = values.get(title)
      if !value:
         var type: ValueType = column_list[c].get("type", ValueType.VT_STRING)
         value = _get_default_value_for(type)
      
      new_row[title] = value

   @warning_ignore("return_value_discarded")
   row_list.insert(index, new_row)

   notify_new_row(index)

Removing a row (_remove_row()) is rather simple. Obviously we first have to ensure the incoming index is within bounds. Provided it is, we simply remove it from the array then notify the TabularBox about the change!

custom_data_source.gd
func _remove_row(index: int) -> void:
   if index < 0 || index >= row_list.size():
      return

   row_list.remove_at(index)
   notify_row_removed(index)

The _move_row() contains a few similarities to the _move_column(). First we have to make sure the incoming indices are different and both within bounds of the row_list array. Then we perform the task of temporarily storing the row data, removing from the "old index" then inserting again into the "new index". Of course, must not forget to notify the TabularBox about the change:

custom_data_source.gd
func _move_row(from: int, to: int) -> void:
   if from == to:
      return

   if from < 0 || from >= row_list.size():
      return

   if to < 0 || to >= row_list.size():
      return

   var row: Dictionary = row_list[from];
   row_list.remove_at(from)

   @warning_ignore("return_value_discarded")
   row_list.insert(to, row)

   notify_row_moved(from, to)

Next is the _get_value() function. First we have to make sure both column and row indices are within bounds. Then we retrieve the title of the column corresponding to the incoming index and retrieve the stored value in the row, using the retrieved column title:

custom_data_source.gd
func _get_value(column_index: int, row_index: int) -> Variant:
   if column_index < 0 || column_index >= column_list.size():
      return null

   if row_index < 0 || row_index >= row_list.size():
      return null

   var title: String = column_list[column_index]["title"]
   return row_list[row_index].get(title, "")

As for _set_value(), as usual we first verify if the column and row indices are valid. Provided it is, we retrieve the column title so the appropriate entry in the row data can be accessed, overwriting the existing value. Not forgetting to notify the TabularBox about the change:

custom_data_source.gd
func _set_value(column_index: int, row_index: int, value: Variant) -> void:
   if column_index < 0 || column_index >= column_list.size():
      return

   if row_index < 0 || row_index >= row_list.size():
      return

   var title: String = column_list[column_index]["title"]
   row_list[row_index][title] = value

   notify_value_changed(column_index, row_index, value)

Finally, the _get_row() function. This is another trivial function. First, ensure the incoming index is within bounds. And if so, return the stored data. But a duplicate of it. Thing is, dictionaries are given as "reference". Changing one "copy" of the dictionary will result in all of them also being changed. We don't want to accidentally change something that is stored because the returned data was changed outside:

custom_data_source.gd
func _get_row(index: int) -> Dictionary:
   assert(index >= 0 && index < row_list.size(), "Attempting to retrieve row data, but incoming index is not valid")

   return row_list[index].duplicate()

Custom Cells

At this point an instance of this custom data source can be created and assigned into the TabularBox. We can also interact and add, remove and change the data. However, if we attempt to change a column value type to DamageType, we will be greeted by a message telling that the specified class can't be instantiated. To prevent further errors the TabularBox will default the cell creation to TabularBoxCellString.

The error message is obvious. We didn't implement the custom cell, TabularBoxCellDropdown (refer to the type_to_class dictionary that we declared above). Implementing a custom cell requires less functions to be implemented than the data source!

While we could easily tailor this custom cell to deal exclusively with DamageType, we will implement this in a more generic way. Hence the name TabularBoxCellDropdown. The reason for that is reusability and showcase the extra_settings.

So, create a new script, inheriting from TabularBoxCell. Since there is a chance this control might be instanced when in editor, we will have to mark this script as a @tool one in order to ensure it will work and without error messages. And we also need the class_name. It should match the one we specified in the custom data source (or change the data source to the name used here). With that, the first lines are:

tboxcelldropdown.gd
@tool
class_name TabularBoxCellDropdown
extends TabularBoxCell

We will use the OptionButton (find its documentation here ) to handle the drop down menu for us. This control fits perfectly the purpose since the button text is changed to the currently selected entry. This means very easy display and editing capabilities. That said, the button must be declared and instantiated. Then, in the _init() function we ensure it's added into the tree while also setting up its anchoring. I did attempt to set both anchor and offset at the same time, but for some reason the button does not respect the given values. If the anchors are set before then things work as expected. That said, the anchor value are meant to place the option button vertically in the center while fully using the horizontal space. Which results in this code:

tboxcelldropdown.gd
var _optbutton: OptionButton = OptionButton.new()

func _init() -> void:
   add_child(_optbutton)

   _optbutton.set_anchor(SIDE_LEFT, 0)
   _optbutton.set_anchor(SIDE_TOP, 0.5)
   _optbutton.set_anchor(SIDE_RIGHT, 1)
   _optbutton.set_anchor(SIDE_BOTTOM, 0.5)

Now we can move to the virtual functions. The first to be done is _check_theme(). It will be called shortly after instantiating and it's meant to ensure the correct styling is applied into any internal control, if any. In our case we do have one, the _optbutton. We want to respect the cell margins. For that the TabularBoxCell provides four functions that return the relevant internal margins. Those are:

I believe those self explanatory, right? If you refer to the list of functions provided by the TabularBox, you will probably notice that it also contains several functions that return the styles of buttons, background and so on. Yet, there is a handy function named apply_button_style() that can be used to style a button using those same theme entries. We will use that!

Once we apply the styling into the button, we set its offsets. The top offset should be minus half button height while the bottom one must be half button height. We can use the get_button_height() to obtain the height of a button that uses the styling set by apply_button_style().

Finally, in the function we call set_min_height() so the TabularBox can adjust the row height accordingly. In here we need the button height plus the top and bottom margins. The button height can be obtained by calling get_button_height().

All of that in code:

tboxcelldropdown.gd
func _check_theme() -> void:
   var im_left: float = get_internal_margin_left()
   var im_top: float = get_internal_margin_top()
   var im_right: float = get_internal_margin_right()
   var im_bottom: float = get_internal_margin_bottom()

   apply_button_style(_optbutton)
   var btheight: float = get_button_height()
   var half_btheight: float = btheight * 0.5

   _optbutton.set_offset(SIDE_LEFT, im_left)
   _optbutton.set_offset(SIDE_TOP, -half_btheight)
   _optbutton.set_offset(SIDE_RIGHT, -im_right)
   _optbutton.set_offset(SIDE_BOTTOM, half_btheight)

   set_min_height(int(im_top + btheight + im_bottom))

The next function is _apply_extra_settings(). When a cell is created, the TabularBox takes the info retrieved from the data source (_get_column_info()) and relay the extra settings entry directly to the newly instantiated cell. Remember that we implemented that function to contain the list of options that we want in the drop down menu. More specifically, the incoming extra parameter should contain a single entry named damage_type, which is an array of dictionaries. Each inner dictionary contains type_id and name.

The task of the function here basically becomes:

  1. Retrieve the damage_types array from the incoming dictionary parameter
  2. Iterate through its entries. For each one add a new item to the _optbutton. The label should be the name and the id the type_id keys from each damage type.

Yes, that's it! This is the code:

tboxcelldropdown.gd
func _apply_extra_settings(extra_settings: Dictionary) -> void:
   var type_array: Array = extra_settings.get("damage_type", Array())

   for type: Dictionary in type_array:
      var tpname: String = type["name"]
      var tpid: int = type["type_id"]
      
      _optbutton.add_item(tpname, tpid)

For the _assign_value() we want to take the incoming parameter and ensure the _optbutton holds the corresponding entry. The option button control allows us to specify which item we want to be selected through code by simply assigning the selected property. However it deals with indices while the incoming value parameter is the id of the item. So we first have to query the index of the item given its id, which can be done by calling get_item_index() in the option button. Tha said, the code looks like this:

tboxcelldropdown.gd
func _assign_value(value: Variant) -> void:
   var id: int = value
   var index: int = _optbutton.get_item_index(id)

   _optbutton.selected = index

None of the other remaining TabularBoxCell virtual functions are required for our case. I will still comment a little bit about each one.

The _commit_changes() would be used to "forcefully" take any possibly pending change and apply it. The dropdown does not have this kind of "pending change". When an item is selected it's meant to immediately apply the new value. If we were dealing with an input box, such as LineEdit we would almost certainly need to implement this function. However the task would be somewhat simple. Indeed, all we would need to do is take the value from the input box and "notify a change". How this notification is done will be shown shortly.

Next function is _selected_changed(). This is called by the TabularBox whenever the "selected state" is changed in the relevant cell. A "selected cell" shows, by default, a white border to highlight it. The idea of this function is to ensure that any internal control receives input focus when the selected is true, while also removing any input focus when it's false. In our case we could easily request the _optbutton to grab focus. I somewhat decided to skip it, but please feel free to implement it!

We then have the _requires_file_dialog(). This function should return true if this cell might request the FileDialog to be shown. Since we don't need it we can ignore this function. Yet, supposing we wanted this feature, requesting the file dialog itself can be done through calling request_file_dialog(). This function requires three parameters. The first one is the title that will be assigned to the dialog. The second argument is the list of file filters. The last argument is a Callable, which will be the event handler when a file is selected.

The next two functions _setup_shared() and _share_with() are meant to "work together". This feature allow cells in a column to share common data. To better explain this, I will use an example that I have implemented in the Database plugin. In that case I have added support for database tables to reference values in a different table. However to make the selection of the other table easier I wanted to show a drop down menu containing all the values of the other table. Because of the potential somewhat high amount of rows, I decided to create a separate PopupMenu containing the entries and share it with all the cells.

In that case, when the _setup_shared() is called, the cell creates and populates the PopupMenu then attaches it into the incoming control, which is given as parameter. Then when the _share_with() is called, the created PopupMenu is provided to the incoming cell parameter through a special internal function meant to assign that into its own popup menu variable.

To make things work properly, each cell still contains its own button meant to request the popup menu to be displayed. The only difference is that the popup menu instance is shared among all cells of that column. We could have done something like that in this example, but I decided to go into a simpler approach.

All that said, if we test things now we can indeed create columns of DamageType value type. The drop downs will be shown and we will be able to interact with. However there are two things that must be worked on:

  1. Changing the value through the drop down menu will not relay the change to the data source. Thing is, we didn't add an event handler to the drop down! We have to do that!
  2. You may have noticed that interacting with the drop down menu does not change cell selection. Indeed, the _optbutton "eats" the mouse interaction.
  3. As mentioned, the data source has a filtering system. However if we attempt to provide a damage type string into the filter, it wont work as expected.

Solving (1) means that we need an event handler to the item_selected of the _optbutton. This signal provides the index of the selected item. We must notify that the value has been changed, but provide the id. We can easily convert the index to id by calling _optbutton.get_item_id(). So, here is the event handler:

tboxcelldropdown.gd
func _on_item_selected(index: int) -> void:
   var id: int = _optbutton.get_item_id(index)
   notify_value_changed(id)

We still need to connect this function to the event. We will do shortly as in the same code update we can also solve (2). Dealing with that "selection issue" can be done in two different ways. One is that we can connect a function to the pressed event of the button and in it request the TabularBox to select the cell. This request is done by calling notify_selected() function. The other and simpler way is to change the mouse filter of the _optbutton, setting it to pass. That said, we update the _init() function to both connect to the item_selected and change the mouse filter:

tboxcelldropdown.gd
func _init() -> void:
   add_child(_optbutton)
   _optbutton.mouse_filter = Control.MOUSE_FILTER_PASS

   _optbutton.set_anchor(SIDE_LEFT, 0)
   _optbutton.set_anchor(SIDE_TOP, 0.5)
   _optbutton.set_anchor(SIDE_RIGHT, 1)
   _optbutton.set_anchor(SIDE_BOTTOM, 0.5)

   @warning_ignore("return_value_discarded")
   _optbutton.connect("item_selected", Callable(self, "_on_item_selected"))

Issue (3) occurs because damage types are stored as numbers. When the filter is processed, it will compare the keyword to the number, which obviously will not match and hide the line. We can somewhat fix that, though. Basically, the filter system retrieves all rows by calling the _get_row() function of the data source. In the returned dictionary we can replace the damage type ID with its corresponding string. However it's important we only perform this replacement if filtering is occurring, otherwise the tabular box might behave incorrectly. We can call is_filtering() in the data source to query if the filter system is currently being processed. With that in mind we can update the data source:

custom_data_source.gd
func _get_row(index: int) -> Dictionary:
   assert(index >= 0 && index < row_list.size(), "Attempting to retrieve row data, but incoming index is not valid")

   var ret: Dictionary = row_list[index].duplicate()
   if (is_filtering()):
      for cinfo: Dictionary in column_list:
         if (cinfo["type"] == ValueType.VT_DAMAGE_TYPE):
            var cname: String = cinfo["title"]
            
            var dt: DamageType = ret[cname]
            ret[cname] = _get_damage_type_string(dt)

   return ret

It's possible to improve this piece of code by caching the names of columns that are of damage type values, which would prevent having to iterate through all columns.

And now all three issues have been fixed. Both the custom data source and custom cell are implemented and working as expected.

Other

The table bellow lists all available theming options for the TabularBox widget. Remember, overriding any of those is easily done through the Inspector. Creating a Theme resource to customize these elements require manually adding the desired entries within the theme editor. Please refer to the introduction for more information on how to customize the widgets using a Theme resource.

Style NameTypeWhat
backgroundStyleBoxDetermines the style that will be used to draw the background of the entire TabularBox
headerStyleBoxThe background of each column header
row_numberStyleBoxThe "cells" used to drawn row numbers and checkboxes
odd_rowStyleBoxBackground of odd rows
even_rowStyleBoxBackground of even rows
focusStyleBoxStyle used to highlight selected cells
h_scrollbarStyleBoxDirectly assigned into the scroll style of the horizontal scroll bar
v_scrollbarStyleBoxDirectly assigned into the scroll style of the vertical scroll bar
scrollbar_grabberStyleBoxDirectly assigned into the grabber style both scroll bars
scrollbar_highlightStyleBoxDirectly assigned into the grabber_highlight style of both scroll bars
scrollbar_grabber_pressedStyleBoxDirectly assigned into the grabber_pressed style of both scroll bars
button_normalStyleBoxPossibly assigned into the normal style of buttons
button_pressedStyleBoxPossibly assigned into the pressed style of buttons
button_hoverStyleBoxPossibly assigned into the hover style of buttons
checkedIconUsed as "checked state" in checkboxes
uncheckedIconUsed as "unchecked state" in checkboxes
left_arrowIconAssigned to the button meant to move a column to the left
right_arrowIconAssigned to the button meant to move a column to the right
down_arrowIconAssigned to the drop down menu related to "row selection"
trash_binIconOut of the box assigned to the button meant to clear value in a texture cell
no_textureIconOut of the box used in texture cells when no texture is assigned into it
header_fontFontFont assigned to column headers
cell_fontFontFont assigned to cells
header_textColorFont color of the column headers
header_selectedColorFont color used when header title is selected
header_selectionColorColor of the highlight shown when header title is selected
cell_textColorFont color assigned to cells
cell_selectedColorFont color assigned to cells when text is selected
cell_selectionColorColor of the highlight shown when cell text is selected
caretColorColor of the caret assigned into all text input boxes
header_font_sizeInteger (font size)Font size assigned to column headers
cell_font_sizeInteger (font size)Font size assigned to cells
separationInteger (constant)Internal spacing used within cells. As an example, space between move buttons and column title input. Some cells use this to separate internal controls