I was looking for a universal way (i.e., that can be applied to an arbitrary TableView
or TreeTableView
) to copy data "as you see it" from a TableView
/TreeTableView
. I found a couple of posts about how to copy contents from a TableView
(here and here), but the important part "as you see it" is the issue with all of them.
All solutions I saw are relying on getting the data associated with each cell (pretty easy to do), and calling .toString()
on it. The problem is that when you store one type (let's say a Long
) as actual data in a column, and then define a custom cell factory to display it as a String
(it's beyond the scope why you would do that, I just want a method that works with such table views):
TableColumn<MyData, Long> timeColumn;
<...>
timeColumn.setCellFactory(param -> new TableCell<MyData, Long>() {
@Override
protected void updateItem(Long item, boolean empty) {
super.updateItem(item, empty);
if (item == null || empty) {
super.setText(null);
} else {
super.setText(LocalDate.from(Instant.ofEpochMilli(item)).format(DateTimeFormatter.ISO_DATE));
}
}
}
);
Those methods based on converting the underlying data (which is Long
here) to String
will obviously not work, because they will copy a number and not a date (which is what the user sees in the table).
Possible (envisioned) solutions:
If I could get my hand on the
TableCell
object associated with each table cell, I could doTableCell.getText()
, and we are done. Unfortunately,TableView
does not allow this (have I missed a way to do it?)I can easily get the
CellFactory
associated with the column, and therefore create a newTableCell
(identical to that one existing in the table view):TableCell<T, ?> cell = column.getCellFactory().call(column);
Then the problem is there's no way (again, did I miss it?) to force a
TableCell
to call theupdateItem
method! I tried to usecommitEdit(T newValue)
, but it's pretty messy: there are checks inside, so you need to make the whole stuff (column, row, table)Editable
, and callstartEdit
first.2a. So the only solution that works for me, uses the Reflection to call the protected
updateItem
, which feels kind of dirty hacking:// For TableView: T selectedItem = <...>; // OR for TreeTableView: TreeItem<T> selectedItem = <...>; TableCell<T, Object> cell = (TableCell<T, Object>) column.getCellFactory().call(column); try { Method update = cell.getClass().getDeclaredMethod("updateItem", Object.class, boolean.class); update.setAccessible(true); Object data = column.getCellData(selectedItem); update.invoke(cell, data, data == null); } catch (Exception ex) { logger.warn("Failed to update item: ", ex); } if (cell.getText() != null) { return cell.getText().replaceAll(fieldSeparator, ""); } else { return ""; }
I would appreciate any comment on it, namely if this can be achieved with less blood. Or may be indicate some problems with my solution which I missed.
Here's the full code in case someone wants to use it (in spite of its ugliness :)
package com.mycompany.util;
import com.google.common.collect.Lists;
import javafx.beans.property.ObjectProperty;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.scene.input.KeyCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
public class TableViewCopyable {
private static final Logger logger = LoggerFactory.getLogger(TableViewCopyable.class.getName());
protected final String defaultFieldSep;
protected final String defaultLineSep;
protected TableViewCopyable(String defaultFieldSep, String defaultLineSep) {
this.defaultFieldSep = defaultFieldSep;
this.defaultLineSep = defaultLineSep;
}
protected static final <T> void copyToClipboard(List<T> rows, Function<List<T>, String> extractor) {
logger.info("Copied " + rows.size() + " item(s) to clipboard");
Clipboard.getSystemClipboard().setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, extractor.apply(rows)));
}
public static TableViewCopyable with(String fieldSep, String lineSep) {
return new TableViewCopyable(fieldSep, lineSep);
}
public static TableViewCopyable toCsv() {
// When using System.lineSeparator() as line separator, there appears to be an extra line break :-/
return with(",", "\n");
}
public final <T> void makeCopyable(TableView<T> table, Function<List<T>, String> extractor) {
table.setOnKeyPressed(event -> {
if (event.getCode().equals(KeyCode.C) && event.isControlDown() || event.isControlDown() && event.getCode().equals(KeyCode.INSERT)) {
// "Smart" copying: if single selection, copy all by default. Otherwise copy selected by default
boolean selectedOnly = table.getSelectionModel().getSelectionMode().equals(SelectionMode.MULTIPLE);
copyToClipboard(getItemsToCopy(table, selectedOnly), extractor);
}
});
MenuItem copy = new MenuItem("Copy selected");
copy.setOnAction(event -> copyToClipboard(table.getSelectionModel().getSelectedItems(), extractor));
MenuItem copyAll = new MenuItem("Copy all");
copyAll.setOnAction(event -> copyToClipboard(table.getItems(), extractor));
addToContextMenu(table.contextMenuProperty(), copy, copyAll);
}
public final <T> void makeCopyable(TreeTableView<T> table, Function<List<TreeItem<T>>, String> extractor) {
table.setOnKeyPressed(event -> {
if (event.getCode().equals(KeyCode.C) && event.isControlDown() || event.isControlDown() && event.getCode().equals(KeyCode.INSERT)) {
// "Smart" copying: if single selection, copy all by default. Otherwise copy selected by default
boolean selectedOnly = table.getSelectionModel().getSelectionMode().equals(SelectionMode.MULTIPLE);
copyToClipboard(getItemsToCopy(table, selectedOnly), extractor);
}
});
MenuItem copy = new MenuItem("Copy selected");
copy.setOnAction(event -> copyToClipboard(getItemsToCopy(table, true), extractor));
MenuItem copyAll = new MenuItem("Copy all (expanded only)");
copyAll.setOnAction(event -> copyToClipboard(getItemsToCopy(table, false), extractor));
addToContextMenu(table.contextMenuProperty(), copy, copyAll);
}
protected <T> List<TreeItem<T>> getItemsToCopy(TreeTableView<T> table, boolean selectedOnly) {
if (selectedOnly) {
// If multiple selection is allowed, copy only selected by default:
return table.getSelectionModel().getSelectedItems();
} else {
// Otherwise, copy everything
List<TreeItem<T>> list = Lists.newArrayList();
for (int i = 0; i < table.getExpandedItemCount(); i++) {
list.add(table.getTreeItem(i));
}
return list;
}
}
protected <T> List<T> getItemsToCopy(TableView<T> table, boolean selectedOnly) {
if (selectedOnly) {
// If multiple selection is allowed, copy only selected by default:
return table.getSelectionModel().getSelectedItems();
} else {
return table.getItems();
}
}
protected void addToContextMenu(ObjectProperty<ContextMenu> menu, MenuItem... items) {
if (menu.get() == null) {
menu.set(new ContextMenu(items));
} else {
for (MenuItem item : items) {
menu.get().getItems().add(item);
}
}
}
public final <T> void makeCopyable(TableView<T> table, String fieldSeparator) {
makeCopyable(table, csvVisibleColumns(table, fieldSeparator));
}
public final <T> void makeCopyable(TreeTableView<T> table, String fieldSeparator) {
makeCopyable(table, csvVisibleColumns(table, fieldSeparator));
}
public final <T> void makeCopyable(TableView<T> table) {
makeCopyable(table, csvVisibleColumns(table, defaultFieldSep));
}
public final <T> void makeCopyable(TreeTableView<T> table) {
makeCopyable(table, defaultFieldSep);
}
protected <T> String extractDataFromCell(IndexedCell<T> cell, Object data, String fieldSeparator) {
try {
Method update = cell.getClass().getDeclaredMethod("updateItem", Object.class, boolean.class);
update.setAccessible(true);
update.invoke(cell, data, data == null);
} catch (Exception ex) {
logger.warn("Failed to updated item: ", ex);
}
if (cell.getText() != null) {
return cell.getText().replaceAll(fieldSeparator, "");
} else {
return "";
}
}
public final <T> Function<List<T>, String> csvVisibleColumns(TableView<T> table, String fieldSeparator) {
return (List<T> items) -> {
StringBuilder builder = new StringBuilder();
// Write table header
builder.append(table.getVisibleLeafColumns().stream().map(TableColumn::getText).collect(Collectors.joining(fieldSeparator))).append(defaultLineSep);
items.forEach(item -> builder.append(
table.getVisibleLeafColumns()
.stream()
.map(col -> extractDataFromCell(((TableColumn<T, Object>) col).getCellFactory().call((TableColumn<T, Object>) col), col.getCellData(item), fieldSeparator))
.collect(Collectors.joining(defaultFieldSep))
).append(defaultLineSep));
return builder.toString();
};
}
public final <T> Function<List<TreeItem<T>>, String> csvVisibleColumns(TreeTableView<T> table, String fieldSeparator) {
return (List<TreeItem<T>> items) -> {
StringBuilder builder = new StringBuilder();
// Write table header
builder.append(table.getVisibleLeafColumns().stream().map(TreeTableColumn::getText).collect(Collectors.joining(fieldSeparator))).append(defaultLineSep);
items.forEach(item -> builder.append(
table.getVisibleLeafColumns()
.stream()
.map(col -> extractDataFromCell(((TreeTableColumn<T, Object>) col).getCellFactory().call((TreeTableColumn<T, Object>) col), col.getCellData(item), fieldSeparator))
.collect(Collectors.joining(defaultFieldSep))
).append(defaultLineSep));
return builder.toString();
};
}
}
Then the usage is pretty simple:
TableViewCopyable.toCsv().makeCopyable(someTreeTableView);
TableViewCopyable.toCsv().makeCopyable(someTableView);
Thanks!