0

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:

  1. If I could get my hand on the TableCell object associated with each table cell, I could do TableCell.getText(), and we are done. Unfortunately, TableView does not allow this (have I missed a way to do it?)

  2. I can easily get the CellFactory associated with the column, and therefore create a new TableCell (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 the updateItem method! I tried to use commitEdit(T newValue), but it's pretty messy: there are checks inside, so you need to make the whole stuff (column, row, table) Editable, and call startEdit 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!

Community
  • 1
  • 1
Denis
  • 557
  • 5
  • 17
  • Did you try the extended example on [my gist](https://gist.github.com/Roland09/6fb31781a64d9cb62179)? Of course you'll have to adapt the serialization for further data types. There's no "general" method that works everywhere. e. g. if you want to copy/paste from a tableview to notepad, you'll have to use a string conversion. – Roland Jan 19 '16 at 13:48
  • Yeah, I saw that example, thanks. It has the problem I explained: `text = numberFormatter.format( ((IntegerProperty) observableValue).get());`. By the way, you are putting some formatting intelligence in the `copySelectionToClipboard` method precisely which I wanted to avoid (i.e., copy **as you see it** in the table) – Denis Jan 19 '16 at 15:44
  • Another thing, I don't mind String conversion, I just wanted to avoid _hacks_ like creating new `TableCell` objects and using reflection. – Denis Jan 19 '16 at 15:46
  • The conversion is done for a reason: copy/paste into and from other applications like e. g. Excel, Notepad, etc. But I don't want to crash your question, so I leave it at that. – Roland Jan 19 '16 at 15:56
  • 1
    My instinct here is to say that if you are trying to do this, then your design is wrong. Specifically, the `cell` is the *view* of the data that is specific to the `TableView`. When you copy and paste in an application, you are copying *data*. So if you are expecting to rely on the cell for your copy and paste operation, you are violating the separation of view and model. If you want to reuse the formatting of your data in the table cell and elsewhere, you should define the formatting outside of the cell, and then reuse it in both your cell and your copy implementation. – James_D Jan 19 '16 at 16:22
  • 1
    To put this another way: suppose you decided to change your cell implementation so that instead of setting the text, you set a graphic (e.g. to a `VBox` with a bunch of `Label`s). You would be pretty surprised when that completely destroyed your "copy to clipboard" functionality, which you would really expect to be independent of that. There's really no way to have the copy functionality rely on the cell implementation and always work the way you want. – James_D Jan 19 '16 at 16:55
  • @James_D I see your point, but it's disputable. Exactly because of the _model/view_ separation there's nothing wrong with having the same data type being displayed differently in two tables (or even two columns of the same table), the table keeping the correspondence between the data and formatting. So my question is how to retrieve this formatting for a given column of some arbitrary table. Look at Excel for example. When you copy data from Excel and past it to a text editor, it doesn't copy the underlying data, but applies the currently active formatting. I want the same functionality :) – Denis Jan 20 '16 at 09:49
  • @James_D Good point about the graphics, I've thought about it. But I decided not to support this case, as it never happens in our `TableView`s. I don't see a good solution for it. You can copy the underlying data (more precisely, a `String` representation of it), but the user who will paste it to a text editor will be even more surprised, I guess. – Denis Jan 20 '16 at 09:55

0 Answers0