Merge remote-tracking branch 'origin/GP-1-dragonmacher-table-column-visual--SQUASHED'

This commit is contained in:
Ryan Kurtz 2024-09-19 10:32:14 -04:00
commit b641a822d3
3 changed files with 412 additions and 56 deletions

View File

@ -4,9 +4,9 @@
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -16,22 +16,22 @@
package docking.widgets.table;
import java.awt.*;
import java.awt.font.TextAttribute;
import java.text.AttributedString;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import javax.swing.*;
import javax.swing.border.Border;
import javax.swing.table.*;
import generic.theme.*;
import generic.theme.GIcon;
import generic.theme.GThemeDefaults.Colors;
import generic.theme.Gui;
import resources.*;
import resources.icons.EmptyIcon;
import resources.icons.TranslateIcon;
public class GTableHeaderRenderer extends DefaultTableCellRenderer {
private static final Color SORT_NUMBER_FG_COLOR = new GColor("color.fg");
private static final int PADDING_FOR_COLUMN_NUMBER = 8;
private static final Icon UP_ICON =
ResourceManager.getScaledIcon(Icons.SORT_ASCENDING_ICON, 14, 14);
@ -47,10 +47,23 @@ public class GTableHeaderRenderer extends DefaultTableCellRenderer {
private Icon primaryIcon = EMPTY_ICON;
private Icon helpIcon = EMPTY_ICON;
protected boolean isPaintingPrimarySortColumn;
private double sortEmphasis = -1;
private Image sortImage; // cached image
private Component rendererComponent;
/**
* Sets the an emphasis value for this column that is used to slightly enlarge and call out the
* sort for the column.
* @param sortEmphasis the emphasis value
*/
public void setSortEmphasis(double sortEmphasis) {
this.sortEmphasis = sortEmphasis;
if (sortEmphasis < 0) {
sortImage = null;
}
}
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected,
boolean hasFocus, int row, int column) {
@ -147,6 +160,30 @@ public class GTableHeaderRenderer extends DefaultTableCellRenderer {
return clippedText;
}
// creates an image from the given icon; used scaling the image
private Image createImage(Icon icon) {
if (sortImage != null) {
return sortImage;
}
int w = icon.getIconWidth();
int h = icon.getIconHeight();
BufferedImage bi = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2d = (Graphics2D) bi.getGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
icon.paintIcon(this, g2d, 0, 0);
g2d.dispose();
sortImage = bi;
return bi;
}
// We have overridden paint children to add the sort column icon and the help icon, depending
// on if this column is sorted and/or hovered.
@Override
protected void paintChildren(Graphics g) {
@ -155,9 +192,131 @@ public class GTableHeaderRenderer extends DefaultTableCellRenderer {
int offset = 4;
int x = helpPoint.x - primaryIcon.getIconWidth() - offset;
int y = getIconStartY(primaryIcon.getIconHeight());
primaryIcon.paintIcon(this, g, x, y);
helpIcon.paintIcon(this, g, helpPoint.x, helpPoint.y);
if (sortEmphasis <= 1.0) {
// default icon painting; no emphasis
primaryIcon.paintIcon(this, g, x, y);
helpIcon.paintIcon(this, g, helpPoint.x, helpPoint.y);
return;
}
//
// This column has been emphasized. We use the notion of emphasis to remind users that they
// are using a multi-column sort. When users are toggling the sort direction of a given
// column, it is easy to forget that other columns are also sorted, especially when those
// columns are in the users peripheral vision. TableUtils uses an animator to control the
// emphasis for all sorted columns, other than the clicked column. We hope that this
// emphasis creates enough movement in the users peripheral vision to serve as a gentle
// reminder that the table sort consists of more than just the clicked column.
//
// There is no emphasis applied to columns when only a single column is sorted. See the
// paint method for details on how the emphasis is used.
//
// create an image and use the graphics for painting the scaled/emphasized version
Image image = createImage(primaryIcon);
paintImage((Graphics2D) g, image, x, y);
}
// x,y are relative to the end of the component using 0,0
private void paintImage(Graphics2D g2d, Image image, int x, int y) {
//
// Currently, the sort emphasis is used to scale the sort icon. This code will scale the
// icon, up to a maximum. The emphasis set on this column will grow and then shrink as the
// values are updated by an animator. The icon image being painted here will start at the
// current icon location, grow to the max emphasis, and then shrink back to its original
// size.
//
double max = 1.3D;
double scale = sortEmphasis;
scale = Math.min(max, scale);
AffineTransform originalTransform = g2d.getTransform();
try {
AffineTransform cloned = (AffineTransform) originalTransform.clone();
cloned.scale(scale, scale);
g2d.setTransform(cloned);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
// center the growing icon over the normal icon using the size delta and dividing by 2
int iw = image.getWidth(null);
int ih = image.getHeight(null);
double dw = (iw * scale) - iw;
double dh = (ih * scale) - ih;
double halfDw = dw / 2;
double halfDh = dh / 2;
// as the image grows, we must move x,y back so it stays centered
double sx = x / scale;
double sy = y / scale;
int fx = (int) Math.round(sx - halfDw);
int fy = (int) Math.round(sy - halfDh);
// to make the icon change more noticeable to the user, paint a small highlight behind
// the icon being emphasized
paintBgHighlight(g2d, scale, max, fx, fy, iw, ih);
g2d.drawImage(image, fx, fy, null);
}
finally {
g2d.setTransform(originalTransform);
}
}
/**
* Paints a background highlight under the icon that will get painted.
* @param g2d the graphics
* @param scale the current scale, up to max
* @param max the max emphasis size
* @param ix the icon x
* @param iy the icon y
* @param iw the icon width
* @param ih the icon height
*/
private void paintBgHighlight(Graphics2D g2d, double scale, double max, double ix, double iy,
double iw, double ih) {
// range from 0 to max (e.g., 0 to .3); highlight will fade in from full alpha
double range = max - 1;
double current = scale - 1;
double alpha = current;
Composite originalComposite = g2d.getComposite();
try {
AlphaComposite alphaComposite = AlphaComposite.getInstance(
AlphaComposite.SrcOver.getRule(), (float) alpha);
g2d.setComposite(alphaComposite);
g2d.setColor(Colors.FOREGROUND);
// highlight size is a range from 0 to max, where max is currently .3; grow the
// highlight shape as the animation progresses
double percent = current / range;
double bgpadding = 1;
double fullbgw = iw;
double fullbgh = ih;
double bgw = (fullbgw + bgpadding) * percent;
double bgh = (fullbgh + bgpadding) * percent;
// center using the delta between the icon size and the current highlight size
double halfpadding = (bgpadding / 2);
double bgwd = (iw + bgpadding) - bgw;
double bghd = (ih + bgpadding) - bgh;
double halfw = bgwd / 2;
double halfh = bghd / 2;
double bgx = (ix - halfpadding) + halfw;
double bgy = (iy - halfpadding) + halfh;
g2d.fillRoundRect((int) bgx, (int) bgy, (int) bgw, (int) bgh, 6, 6);
}
finally {
g2d.setComposite(originalComposite);
}
}
private Point getHelpIconLocation() {
@ -293,25 +452,19 @@ public class GTableHeaderRenderer extends DefaultTableCellRenderer {
private Icon getColumnIconForSortState(TableSortState columnSortStates,
ColumnSortState sortState, boolean isPendingSort) {
if (isPendingSort) {
return PENDING_ICON;
}
Icon icon = (sortState.isAscending() ? UP_ICON : DOWN_ICON);
if (columnSortStates.getSortedColumnCount() != 1) {
MultiIcon multiIcon = new MultiIcon(icon);
int sortOrder = sortState.getSortOrder();
if (sortOrder == 1) {
isPaintingPrimarySortColumn = true;
}
String numberString = Integer.toString(sortOrder);
multiIcon.addIcon(new NumberPainterIcon(icon.getIconWidth() + PADDING_FOR_COLUMN_NUMBER,
icon.getIconHeight(), numberString));
icon = multiIcon;
}
else {
isPaintingPrimarySortColumn = true;
}
if (isPendingSort) {
icon = PENDING_ICON;
}
return icon;
}
@ -322,6 +475,7 @@ public class GTableHeaderRenderer extends DefaultTableCellRenderer {
int middle = height / 2;
int halfHeight = iconHeight / 2;
int y = middle - halfHeight;
return y;
}
@ -362,26 +516,20 @@ public class GTableHeaderRenderer extends DefaultTableCellRenderer {
Font font = Gui.getFont(FONT_ID);
g.setFont(font);
g.setColor(Colors.FOREGROUND);
FontMetrics fontMetrics = g.getFontMetrics();
int numberHeight = fontMetrics.getAscent();
int padding = 2;
// draw the number on the right...
int padding = 2;
int startX = x + (iconWidth - numberWidth) + padding;
// ...and at the same start y as the sort icon
int iconY = getIconStartY(iconHeight);
int textBaseline = iconY + numberHeight - padding;
AttributedString as = new AttributedString(numberText);
as.addAttribute(TextAttribute.FOREGROUND, SORT_NUMBER_FG_COLOR);
as.addAttribute(TextAttribute.WEIGHT, TextAttribute.WEIGHT_BOLD);
as.addAttribute(TextAttribute.FAMILY, font.getFamily());
as.addAttribute(TextAttribute.SIZE, font.getSize2D());
g.drawString(as.getIterator(), startX, textBaseline);
// note: padding here helps make up the difference between the number's actual height
// and the font metrics ascent
int heightPadding = 2;
int absoluteY = y + numberHeight - heightPadding;
g.drawString(numberText, startX, absoluteY);
}
}

View File

@ -4,9 +4,9 @@
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -44,26 +44,27 @@ public class TableSortState implements Iterable<ColumnSortState> {
/**
* Creates a sort state with the given column as the sorted column (sorted ascending).
*
* @param columnIndex The column to sort
* @param columnModelIndex The column to sort
* @return a sort state with the given column as the sorted column (sorted ascending).
* @see TableSortStateEditor
*/
public static TableSortState createDefaultSortState(int columnIndex) {
return new TableSortState(new ColumnSortState(columnIndex, SortDirection.ASCENDING, 1));
public static TableSortState createDefaultSortState(int columnModelIndex) {
return new TableSortState(
new ColumnSortState(columnModelIndex, SortDirection.ASCENDING, 1));
}
/**
* Creates a sort state with the given column as the sorted column in the given direction.
*
* @param columnIndex The column to sort
* @param columnModelIndex The column to sort
* @param isAscending True to sort ascending; false to sort descending
* @return a sort state with the given column as the sorted column (sorted ascending).
* @see TableSortStateEditor
*/
public static TableSortState createDefaultSortState(int columnIndex, boolean isAscending) {
public static TableSortState createDefaultSortState(int columnModelIndex, boolean isAscending) {
SortDirection sortDirection =
isAscending ? SortDirection.ASCENDING : SortDirection.DESCENDING;
return new TableSortState(new ColumnSortState(columnIndex, sortDirection, 1));
return new TableSortState(new ColumnSortState(columnModelIndex, sortDirection, 1));
}
public TableSortState() {
@ -111,9 +112,9 @@ public class TableSortState implements Iterable<ColumnSortState> {
return columnSortStates.isEmpty();
}
public ColumnSortState getColumnSortState(int columnIndex) {
public ColumnSortState getColumnSortState(int columnModelIndex) {
for (ColumnSortState sortState : columnSortStates) {
if (sortState.getColumnModelIndex() == columnIndex) {
if (sortState.getColumnModelIndex() == columnModelIndex) {
return sortState;
}
}

View File

@ -4,9 +4,9 @@
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -15,15 +15,21 @@
*/
package docking.widgets.table;
import java.util.ArrayList;
import java.util.List;
import java.awt.Graphics;
import java.math.BigDecimal;
import java.time.Duration;
import java.util.*;
import javax.swing.*;
import javax.swing.table.*;
import org.apache.commons.collections4.CollectionUtils;
import org.jdesktop.animation.timing.Animator;
import docking.util.AnimationPainter;
import docking.util.AnimationRunner;
import ghidra.docking.settings.Settings;
import ghidra.util.bean.GGlassPane;
import ghidra.util.table.column.GColumnRenderer;
import ghidra.util.table.column.GColumnRenderer.ColumnConstraintFilterMode;
@ -32,6 +38,11 @@ import ghidra.util.table.column.GColumnRenderer.ColumnConstraintFilterMode;
*/
public class TableUtils {
/**
* An animation runner that emphasizes the sorted columns using the table's header.
*/
private static AnimationRunner sortEmphasizingAnimationRunner;
/**
* Select the given row objects. No selection will be made if the objects are filtered out of
* view. Passing a {@code null} list or an empty list will clear the selection.
@ -56,8 +67,8 @@ public class TableUtils {
if (mode == ListSelectionModel.SINGLE_SELECTION) {
// take the last item to mimic what the selection model does internally
ROW_OBJECT item = items.get(items.size() - 1);
@SuppressWarnings({ "cast", "unchecked" })
int viewRow = gModel.getRowIndex((ROW_OBJECT) item);
@SuppressWarnings({ "unchecked" })
int viewRow = gModel.getRowIndex(item);
table.setRowSelectionInterval(viewRow, viewRow);
return;
}
@ -68,8 +79,8 @@ public class TableUtils {
//
List<Integer> rows = new ArrayList<>();
for (ROW_OBJECT item : items) {
@SuppressWarnings({ "cast", "unchecked" })
int viewRow = gModel.getRowIndex((ROW_OBJECT) item);
@SuppressWarnings({ "unchecked" })
int viewRow = gModel.getRowIndex(item);
if (viewRow >= 0) {
rows.add(viewRow);
}
@ -219,8 +230,21 @@ public class TableUtils {
editor.addSortedColumn(modelColumnIndex);
}
sortedModel.setTableSortState(editor.createTableSortState());
repaintTableHeader(table);
TableSortState newSortState = editor.createTableSortState();
sortedModel.setTableSortState(newSortState);
if (sortEmphasizingAnimationRunner != null) {
sortEmphasizingAnimationRunner.stop();
}
int n = newSortState.getSortedColumnCount();
if (n >= 2) { // don't emphasize a single column
sortEmphasizingAnimationRunner =
new SortEmphasisAnimationRunner(table, newSortState, columnIndex);
sortEmphasizingAnimationRunner.start();
}
repaintTableHeaderForSortChange(table);
}
/**
@ -280,8 +304,21 @@ public class TableUtils {
editor.addSortedColumn(modelColumnIndex);
}
sortedModel.setTableSortState(editor.createTableSortState());
repaintTableHeader(table);
TableSortState newSortState = editor.createTableSortState();
sortedModel.setTableSortState(newSortState);
if (sortEmphasizingAnimationRunner != null) {
sortEmphasizingAnimationRunner.stop();
}
int n = newSortState.getSortedColumnCount();
if (n >= 2) { // don't emphasize a single column
sortEmphasizingAnimationRunner =
new SortEmphasisAnimationRunner(table, newSortState, columnIndex);
sortEmphasizingAnimationRunner.start();
}
repaintTableHeaderForSortChange(table);
}
private static SortedTableModel getSortedTableModel(JTable table) {
@ -297,11 +334,181 @@ public class TableUtils {
return columnModel.getColumn(columnIndex).getModelIndex();
}
private static void repaintTableHeader(JTable table) {
private static void repaintTableHeaderForSortChange(JTable table) {
// force an update on the headers so they display the new sorting order
JTableHeader tableHeader = table.getTableHeader();
if (tableHeader != null) {
tableHeader.paintImmediately(tableHeader.getBounds());
}
}
private static void resetEmphasis(JTable table) {
// clear all emphasis state
TableColumnModel columnModel = table.getColumnModel();
int n = columnModel.getColumnCount();
for (int i = 0; i < n; i++) {
TableColumn column = columnModel.getColumn(i);
TableCellRenderer renderer = column.getHeaderRenderer();
if (renderer instanceof GTableHeaderRenderer gRenderer) {
gRenderer.setSortEmphasis(-1);
}
}
}
/**
* An animation runner that creates the painter and the values that will be interpollated by
* the animator. Each column that is sorted will be emphasized, except for the column that was
* clicked, as not to be annoying to the user. The intent of emphasizing the columns is to
* signal to the user that other columns are part of the sort, not just the column that was
* clicked. We hope that this will remind the user of the overall sort so they are not
* confused when the the column that was clicked produces unexpected sort results.
*/
private static class SortEmphasisAnimationRunner extends AnimationRunner {
private JTable table;
public SortEmphasisAnimationRunner(JTable table, TableSortState tableSortState,
int clickedColumn) {
super(table);
this.table = table;
// Create an array of sort ordinals to use as the values. We need 1 extra value to
// create a range between ordinals (e.g., 1-2, 2-3 for 2 ordinals)
int n = tableSortState.getSortedColumnCount();
int[] ordinals = new int[n];
for (int i = 1; i < n + 1; i++) {
ordinals[i - 1] = i;
}
// create double values to get a range for the client as the timer calls back
Double[] values = new Double[n];
for (int i = 0; i < n; i++) {
values[i] = Double.valueOf(ordinals[i]);
}
EmphasizingSortPainter painter =
new EmphasizingSortPainter(table, tableSortState, clickedColumn, ordinals);
setPainter(painter);
setValues(values);
setDuration(Duration.ofSeconds(1));
setDoneCallback(this::done);
}
@Override
public void start() {
Animator animator = createAnimator();
// acceleration / deceleration make some of the column numbers jiggle, so turn it off
animator.setAcceleration(0);
animator.setDeceleration(0);
super.start();
}
private void done() {
resetEmphasis(table);
}
}
/**
* A painter that will emphasize each sorted column, except for the clicked column, over the
* course of an animation. The painter is called with the current emphasis that is passed to
* the column along with a repaint request.
*/
private static class EmphasizingSortPainter implements AnimationPainter {
private TableSortState tableSortState;
private JTable table;
private Map<Integer, Integer> columnsByOrdinal = new HashMap<>();
private int clickedColumnIndex;
public EmphasizingSortPainter(JTable table, TableSortState tableSortState,
int clickedColumnIndex, int[] ordinals) {
this.table = table;
this.tableSortState = tableSortState;
this.clickedColumnIndex = clickedColumnIndex;
mapOrdinalsToColumns(ordinals);
}
private void mapOrdinalsToColumns(int[] ordinals) {
for (int i = 0; i < ordinals.length; i++) {
List<ColumnSortState> sortStates = tableSortState.getAllSortStates();
for (ColumnSortState ss : sortStates) {
int columnOrdinal = ss.getSortOrder();
if (columnOrdinal == ordinals[i]) {
int sortColumnIndex = ss.getColumnModelIndex();
columnsByOrdinal.put(ordinals[i], sortColumnIndex);
break;
}
}
}
}
@Override
public void paint(GGlassPane glassPane, Graphics graphics, double value) {
JTableHeader tableHeader = table.getTableHeader();
if (tableHeader == null) {
return; // not sure if this can happen
}
resetEmphasis(table);
ColumnAndRange columnAndRange = getColumnAndRange(value);
if (columnAndRange == null) {
return;
}
TableColumnModel columnModel = table.getColumnModel();
int columnViewIndex = table.convertColumnIndexToView(columnAndRange.column());
TableColumn column = columnModel.getColumn(columnViewIndex);
TableCellRenderer renderer = column.getHeaderRenderer();
if (!(renderer instanceof GTableHeaderRenderer gRenderer)) {
return;
}
//
// Have the emphasis transition from normal -> large -> normal over the range 0.0 to
// 1.1, with an emphasis of 1.x.
//
double range = columnAndRange.range();
double emphasis;
if (range < .5) {
emphasis = 1 + range;
}
else {
emphasis = 2 - range;
}
gRenderer.setSortEmphasis(emphasis);
tableHeader.repaint();
}
private ColumnAndRange getColumnAndRange(double value) {
//
// The values are the sort ordinals: 1, 2, 3, etc, in double form: 1.1, 1.5... Each
// value has the ordinal and a range from 0 - .99
//
BigDecimal bigDecimal = new BigDecimal(String.valueOf(value));
int ordinal = bigDecimal.intValue();
Integer columnModelIndex = columnsByOrdinal.get(ordinal);
if (columnModelIndex >= clickedColumnIndex) {
// Ignore the clicked column when emphasizing the header, as to not be distracting
// for the column that they are looking at already. Once we have gotten to or past
// the clicked column, then choose the next ordinal to emphasize.
int nextOrdinal = ordinal + 1;
columnModelIndex = columnsByOrdinal.get(nextOrdinal);
}
BigDecimal bigOrdinal = new BigDecimal(ordinal);
BigDecimal decimalValue = bigDecimal.subtract(bigOrdinal);
return new ColumnAndRange(columnModelIndex, decimalValue.doubleValue());
}
/**
* Simple container for a column index and it's range (from 0 to .99)
*/
private record ColumnAndRange(int column, double range) {}
}
}