GP-931: Converted Thread Timeline to a fancy table column.

This commit is contained in:
Dan
2021-06-11 14:22:39 -04:00
parent ead982a5e5
commit 3a997a608f
12 changed files with 468 additions and 569 deletions
@@ -117,7 +117,6 @@ src/main/help/help/topics/DebuggerTraceManagerServicePlugin/DebuggerTraceManager
src/main/help/help/topics/DebuggerWatchesPlugin/DebuggerWatchesPlugin.html||GHIDRA||||END|
src/main/help/help/topics/DebuggerWatchesPlugin/images/DebuggerWatchesPlugin.png||GHIDRA||||END|
src/main/resources/defaultTools/Debugger.tool||GHIDRA||||END|
src/main/resources/define_info_proc_mappings||GHIDRA||||END|
src/main/resources/images/add.png||FAMFAMFAM Icons - CC 2.5|||famfamfam silk icon set|END|
src/main/resources/images/attach.png||GHIDRA||||END|
src/main/resources/images/autoread.png||GHIDRA||||END|
@@ -26,7 +26,7 @@
each thread carries its own execution context, so this window provides a means of navigating
those contexts. Furthermore, this window provides a means of navigating and managing open
traces, much like the static listing provides a means of navigating open programs. The window
also provides a timeline showing thread lifespans, and displays a caret which can be used to
also plots a timeline showing thread lifespans, and displays a caret which can be used to
navigate the current point in time. This window, the <A href=
"help/topics/DebuggerStackPlugin/DebuggerStackPlugin.html">Stack</A> window, and the <A href=
"help/topics/DebuggerListingPlugin/DebuggerListingPlugin.html">Dynamic Listing</A> window
@@ -50,13 +50,13 @@
<H2>Navigating Threads</H2>
<P>Selecting a thread in the timeline or the table will navigate to (or "activate" or "focus")
<P>Selecting a thread in the table will navigate to (or "activate" or "focus")
that thread. Windows which are sensitive to the current thread will update. Notably, the <A
href="help/topics/DebuggerRegistersPlugin/DebuggerRegistersPlugin.html">Registers</A> window
will display the activated thread's register values. <A href=
"help/topics/DebuggerListingPlugin/DebuggerListingPlugin.html">Listing</A> windows with
configured location tracking will re-compute that location with the thread's context and
navigate to it. The thread timeline displays all recorded threads. Threads which are alive will
navigate to it. The thread timeline plots all recorded threads. Threads which are alive will
appear to extend "to the end of time." The threads table displays more detailed information in
the following columns:</P>
@@ -77,12 +77,15 @@
The state always reflects the present, or latest known, state.</LI>
<LI>Comment - a user-modifiable comment about the thread.</LI>
<LI>Plot - a graphical representation of the thread's lifespan. Unlike other column headers, clicking and dragging
in this one will navigate through time. To rearrange this column, hold SHIFT while dragging.</LI>
</UL>
<H2>Navigating Time</H2>
<P>The user can navigate through time within the current trace by using the caret above the
threads timeline. There are also actions for "stepping the trace" forward and backward. See the
<P>The user can navigate through time within the current trace by using the caret in the plot
column header. There are also actions for "stepping the trace" forward and backward. See the
<A href="help/topics/DebuggerTimePlugin/DebuggerTimePlugin.html">Time</A> window for a way to
display and navigate to specific events in the trace's timeline. Note that stepping away from
the present will prevent most windows from interacting with the live target. While some
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -23,22 +23,22 @@ import java.util.List;
import javax.swing.*;
import javax.swing.event.ListSelectionEvent;
import javax.swing.table.TableColumn;
import javax.swing.table.TableColumnModel;
import com.google.common.collect.Range;
import docking.ActionContext;
import docking.WindowPosition;
import docking.action.*;
import docking.widgets.EventTrigger;
import docking.widgets.HorizontalTabPanel;
import docking.widgets.HorizontalTabPanel.TabListCellRenderer;
import docking.widgets.table.GTable;
import docking.widgets.table.RowWrappedEnumeratedColumnTableModel;
import docking.widgets.timeline.TimelineListener;
import docking.widgets.table.*;
import ghidra.app.plugin.core.debug.DebuggerCoordinates;
import ghidra.app.plugin.core.debug.DebuggerPluginPackage;
import ghidra.app.plugin.core.debug.gui.DebuggerResources;
import ghidra.app.plugin.core.debug.gui.DebuggerResources.*;
import ghidra.app.plugin.core.debug.gui.DebuggerSnapActionContext;
import ghidra.app.plugin.core.debug.gui.thread.DebuggerThreadsTimelinePanel.VetoableSnapRequestListener;
import ghidra.app.services.*;
import ghidra.app.services.DebuggerTraceManagerService.BooleanChangeAdapter;
import ghidra.dbg.DebugModelConventions;
@@ -333,30 +333,24 @@ public class DebuggerThreadsProvider extends ComponentProviderAdapter {
private final SuppressableCallback<Void> cbCoordinateActivation = new SuppressableCallback<>();
private final ThreadsListener threadsListener = new ThreadsListener();
private final VetoableSnapRequestListener snapListener = this::snapRequested;
private final CollectionChangeListener<TraceRecorder> recordersListener =
new RecordersChangeListener();
/* package access for testing */
final RangeTableCellRenderer<Long> rangeRenderer = new RangeTableCellRenderer<>();
final RangeCursorTableHeaderRenderer<Long> headerRenderer =
new RangeCursorTableHeaderRenderer<>();
protected final ThreadTableModel threadTableModel = new ThreadTableModel(this);
private JPanel mainPanel;
private JSplitPane splitPane;
HorizontalTabPanel<Trace> traceTabs;
GTable threadTable;
GhidraTableFilterPanel<ThreadRow> threadFilterPanel;
DebuggerThreadsTimelinePanel threadTimeline;
JPopupMenu traceTabPopupMenu;
private ActionContext myActionContext;
private final TimelineListener timelineListener = new TimelineListener() {
@Override
public void itemActivated(int index) {
timelineItemActivated(index);
}
};
DockingAction actionSaveTrace;
StepSnapBackwardAction actionStepSnapBackward;
StepTickBackwardAction actionStepTickBackward;
@@ -464,10 +458,8 @@ public class DebuggerThreadsProvider extends ComponentProviderAdapter {
}
private void doSetSnap(long snap) {
if (threadTimeline.getSnap() == snap) {
return;
}
threadTimeline.setSnap(snap);
headerRenderer.setCursorPosition(snap);
threadTable.getTableHeader().repaint();
}
public void traceOpened(Trace trace) {
@@ -508,7 +500,11 @@ public class DebuggerThreadsProvider extends ComponentProviderAdapter {
}
protected void updateTimelineMax() {
threadTimeline.setMaxSnapAtLeast(orZero(current.getTrace().getTimeManager().getMaxSnap()));
long max = orZero(current.getTrace().getTimeManager().getMaxSnap());
Range<Long> fullRange = Range.closed(0L, max + 1);
rangeRenderer.setFullRange(fullRange);
headerRenderer.setFullRange(fullRange);
threadTable.getTableHeader().repaint();
}
@Override
@@ -524,35 +520,6 @@ public class DebuggerThreadsProvider extends ComponentProviderAdapter {
return myActionContext;
}
private void snapRequested(long req, EventTrigger trigger) {
long snap = req;
if (snap < 0) {
snap = 0;
}
if (current.getTrace() == null) {
snap = 0;
}
/*else {
Long maxSnap = currentTrace.getTimeManager().getMaxSnap();
if (maxSnap == null) {
maxSnap = 0L;
}
if (snap > maxSnap) {
snap = maxSnap;
}
}*/
if (trigger == EventTrigger.GUI_ACTION) {
traceManager.activateSnap(snap);
}
myActionContext = new DebuggerSnapActionContext(snap);
contextChanged();
}
private void timelineItemActivated(int index) {
ThreadRow row = threadTableModel.getRowObject(index);
rowActivated(row);
}
private void rowActivated(ThreadRow row) {
TraceThread thread = row.getThread();
Trace trace = thread.getTrace();
@@ -569,27 +536,15 @@ public class DebuggerThreadsProvider extends ComponentProviderAdapter {
traceTabPopupMenu = new JPopupMenu("Trace");
mainPanel = new JPanel(new BorderLayout());
splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
splitPane.setContinuousLayout(true);
JPanel tablePanel = new JPanel(new BorderLayout());
threadTable = new GhidraTable(threadTableModel);
threadTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
tablePanel.add(new JScrollPane(threadTable));
mainPanel.add(new JScrollPane(threadTable));
threadFilterPanel = new GhidraTableFilterPanel<>(threadTable, threadTableModel);
tablePanel.add(threadFilterPanel, BorderLayout.SOUTH);
splitPane.setLeftComponent(tablePanel);
threadTimeline = new DebuggerThreadsTimelinePanel(threadFilterPanel.getTableFilterModel());
splitPane.setRightComponent(threadTimeline);
splitPane.setResizeWeight(0.4);
mainPanel.add(threadFilterPanel, BorderLayout.SOUTH);
threadTable.getSelectionModel().addListSelectionListener(this::threadRowSelected);
threadTimeline.setSelectionModel(threadTable.getSelectionModel());
threadTimeline.addSnapRequestedListener(snapListener);
threadTimeline.addTimelineListener(timelineListener);
threadTable.addMouseListener(new MouseAdapter() {
@Override
public void mouseReleased(MouseEvent e) {
@@ -599,8 +554,6 @@ public class DebuggerThreadsProvider extends ComponentProviderAdapter {
}
});
mainPanel.add(splitPane, BorderLayout.CENTER);
traceTabs = new HorizontalTabPanel<>();
traceTabs.getList().setCellRenderer(new TabListCellRenderer<>() {
protected String getText(Trace value) {
@@ -633,6 +586,32 @@ public class DebuggerThreadsProvider extends ComponentProviderAdapter {
});
// TODO: The popup key? Only seems to have rawCode=0x93 (147) in Swing
mainPanel.add(traceTabs, BorderLayout.NORTH);
TableColumnModel columnModel = threadTable.getColumnModel();
TableColumn colName = columnModel.getColumn(ThreadTableColumns.NAME.ordinal());
colName.setPreferredWidth(100);
TableColumn colCreated = columnModel.getColumn(ThreadTableColumns.CREATED.ordinal());
colCreated.setPreferredWidth(10);
TableColumn colDestroyed = columnModel.getColumn(ThreadTableColumns.DESTROYED.ordinal());
colDestroyed.setPreferredWidth(10);
TableColumn colState = columnModel.getColumn(ThreadTableColumns.STATE.ordinal());
colState.setPreferredWidth(20);
TableColumn colComment = columnModel.getColumn(ThreadTableColumns.COMMENT.ordinal());
colComment.setPreferredWidth(100);
TableColumn colPlot = columnModel.getColumn(ThreadTableColumns.PLOT.ordinal());
colPlot.setPreferredWidth(200);
colPlot.setCellRenderer(rangeRenderer);
colPlot.setHeaderRenderer(headerRenderer);
headerRenderer.addSeekListener(threadTable, ThreadTableColumns.PLOT.ordinal(), pos -> {
long snap = Math.round(pos);
if (current.getTrace() == null || snap < 0) {
snap = 0;
}
traceManager.activateSnap(snap);
myActionContext = new DebuggerSnapActionContext(snap);
contextChanged();
});
}
private void checkTraceTabPopupViaMouse(MouseEvent e) {
@@ -1,185 +0,0 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ghidra.app.plugin.core.debug.gui.thread;
import java.awt.Dimension;
import java.awt.Rectangle;
import java.util.List;
import javax.swing.JScrollPane;
import javax.swing.ListSelectionModel;
import com.google.common.collect.Range;
import docking.widgets.*;
import docking.widgets.RangeCursorPanel.Direction;
import docking.widgets.table.RowObjectTableModel;
import docking.widgets.timeline.TimelineListener;
import docking.widgets.timeline.TimelinePanel;
import ghidra.app.services.DebuggerModelService;
import ghidra.trace.model.thread.TraceThread;
import ghidra.util.datastruct.ListenerSet;
public class DebuggerThreadsTimelinePanel extends JScrollPane {
static class SnapRequestVetoedException extends Exception {
private final long requestedSnap;
private final long newSnap;
public SnapRequestVetoedException(long requestedSnap, long newSnap) {
this.requestedSnap = requestedSnap;
this.newSnap = newSnap;
}
public long getRequestedSnap() {
return requestedSnap;
}
public long getNewSnap() {
return newSnap;
}
}
public interface VetoableSnapRequestListener {
void snapRequested(long snap, EventTrigger trigger) throws SnapRequestVetoedException;
}
protected class ThreadTimelinePanel extends TimelinePanel<ThreadRow, Long> {
protected final RowObjectTableModel<ThreadRow> model;
public ThreadTimelinePanel(RowObjectTableModel<ThreadRow> model) {
super(model, ThreadRow::getLifespan);
this.model = model;
}
}
protected class ThreadRangeCursorPanel extends RangeCursorPanel {
public ThreadRangeCursorPanel(Direction direction) {
super(direction);
}
@Override
protected double adjustRequestedValue(double requested) {
double rounded = Math.round(requested);
// TODO: Also remove 1-snap view buffer?
// Until I figure out the event processing order, leaving the buffer
// prevents some stepping glitches.
if (range.hasLowerBound() && rounded < range.lowerEndpoint() /*+1*/) {
return range.lowerEndpoint(); // +1
}
if (range.hasUpperBound() && rounded > range.upperEndpoint() /*-1*/) {
return range.upperEndpoint(); // -1
}
return rounded;
}
}
protected final ThreadTimelinePanel timeline;
protected final RangeCursorPanel topCursor = new ThreadRangeCursorPanel(Direction.SOUTH) {
Dimension preferredSize = new Dimension(super.getPreferredSize());
@Override
public Dimension getPreferredSize() {
preferredSize.width = timeline.getPreferredSize().width;
return preferredSize;
}
};
protected final ListenerSet<VetoableSnapRequestListener> listeners =
new ListenerSet<>(VetoableSnapRequestListener.class);
protected final RangeCursorValueListener valueListener = this::cursorValueChanged;
protected final TimelineListener timelineListener = new TimelineListener() {
@Override
public void viewRangeChanged(Range<Double> range) {
timelineViewRangeChanged(range);
}
};
private DebuggerModelService modelService;
private RowObjectTableModel<ThreadRow> model;
public DebuggerThreadsTimelinePanel(RowObjectTableModel<ThreadRow> model) {
this.model = model;
setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
timeline = new ThreadTimelinePanel(model);
setViewportView(timeline);
setColumnHeaderView(topCursor);
topCursor.addValueListener(valueListener);
timeline.addTimelineListener(timelineListener);
}
public void addSnapRequestedListener(VetoableSnapRequestListener listener) {
listeners.add(listener);
}
private void cursorValueChanged(double value, EventTrigger trigger) {
try {
listeners.fire.snapRequested(Math.round(value), trigger);
}
catch (SnapRequestVetoedException e) {
value = e.getNewSnap();
}
}
private void timelineViewRangeChanged(Range<Double> range) {
topCursor.setRange(range);
}
public void setSelectionModel(ListSelectionModel selectionModel) {
timeline.setSelectionModel(selectionModel);
}
public void setSnap(long snap) {
topCursor.requestValue(snap);
}
public long getSnap() {
// TODO: If there are enough snaps, we may not have the required precision
// Consider BigDecimal in cursor? Eww.
return (long) topCursor.getValue();
}
public void setMaxSnapAtLeast(long maxSnapAtLeast) {
timeline.setMaxAtLeast(maxSnapAtLeast);
}
public long getMaxSnapAtLeast() {
return (long) timeline.getMaxAtLeast();
}
public ThreadRow findRow(TraceThread thread) {
RowObjectTableModel<ThreadRow> model = timeline.getTableModel();
List<ThreadRow> data = model.getModelData();
for (ThreadRow row : data) {
if (row.getThread() == thread) {
return row;
}
}
return null;
}
public Rectangle getCellBounds(TraceThread thread) {
ThreadRow row = findRow(thread);
if (row == null) {
return null;
}
return timeline.getCellBounds(row);
}
public void addTimelineListener(TimelineListener listener) {
timeline.addTimelineListener(listener);
}
}
@@ -18,31 +18,37 @@ package ghidra.app.plugin.core.debug.gui.thread;
import java.util.function.BiConsumer;
import java.util.function.Function;
import com.google.common.collect.Range;
import docking.widgets.table.DefaultEnumeratedColumnTableModel.EnumeratedTableColumn;
public enum ThreadTableColumns implements EnumeratedTableColumn<ThreadTableColumns, ThreadRow> {
NAME("Name", String.class, ThreadRow::getName, ThreadRow::setName),
CREATED("Created", Long.class, ThreadRow::getCreationSnap),
DESTROYED("Destroyed", String.class, ThreadRow::getDestructionSnap),
STATE("State", ThreadState.class, ThreadRow::getState),
COMMENT("Comment", String.class, ThreadRow::getComment, ThreadRow::setComment);
NAME("Name", String.class, ThreadRow::getName, ThreadRow::setName, true),
CREATED("Created", Long.class, ThreadRow::getCreationSnap, true),
DESTROYED("Destroyed", String.class, ThreadRow::getDestructionSnap, true),
STATE("State", ThreadState.class, ThreadRow::getState, true),
COMMENT("Comment", String.class, ThreadRow::getComment, ThreadRow::setComment, true),
PLOT("Plot", Range.class, ThreadRow::getLifespan, false);
private final String header;
private final Function<ThreadRow, ?> getter;
private final BiConsumer<ThreadRow, Object> setter;
private final boolean sortable;
private final Class<?> cls;
<T> ThreadTableColumns(String header, Class<T> cls, Function<ThreadRow, T> getter) {
this(header, cls, getter, null);
<T> ThreadTableColumns(String header, Class<T> cls, Function<ThreadRow, T> getter,
boolean sortable) {
this(header, cls, getter, null, sortable);
}
@SuppressWarnings("unchecked")
<T> ThreadTableColumns(String header, Class<T> cls, Function<ThreadRow, T> getter,
BiConsumer<ThreadRow, T> setter) {
BiConsumer<ThreadRow, T> setter, boolean sortable) {
this.header = header;
this.cls = cls;
this.getter = getter;
this.setter = (BiConsumer<ThreadRow, Object>) setter;
this.sortable = sortable;
}
@Override
@@ -65,6 +71,11 @@ public enum ThreadTableColumns implements EnumeratedTableColumn<ThreadTableColum
return setter != null;
}
@Override
public boolean isSortable() {
return sortable;
}
@Override
public void setValueOf(ThreadRow row, Object value) {
setter.accept(row, value);
@@ -17,17 +17,15 @@ package ghidra.app.plugin.core.debug.gui.thread;
import static org.junit.Assert.*;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.Set;
import org.junit.*;
import org.junit.Before;
import org.junit.Test;
import com.google.common.collect.Range;
import docking.widgets.EventTrigger;
import ghidra.app.plugin.core.debug.gui.AbstractGhidraHeadedDebuggerGUITest;
import ghidra.app.services.TraceRecorder;
import ghidra.trace.model.Trace;
@@ -264,14 +262,14 @@ public class DebuggerThreadsProviderTest extends AbstractGhidraHeadedDebuggerGUI
traceManager.activateTrace(tb.trace);
waitForSwing();
assertEquals(0, threadsProvider.threadTimeline.getMaxSnapAtLeast());
assertEquals(1, threadsProvider.rangeRenderer.getFullRange().upperEndpoint().longValue());
try (UndoableTransaction tid = tb.startTransaction()) {
manager.getSnapshot(10, true);
}
waitForSwing();
assertEquals(10, threadsProvider.threadTimeline.getMaxSnapAtLeast());
assertEquals(11, threadsProvider.rangeRenderer.getFullRange().upperEndpoint().longValue());
}
// NOTE: Do not test delete updates timeline max, as maxSnap does not reflect deletion
@@ -290,8 +288,7 @@ public class DebuggerThreadsProviderTest extends AbstractGhidraHeadedDebuggerGUI
assertEquals("15",
threadsProvider.threadTableModel.getModelData().get(0).getDestructionSnap());
assertEquals(Range.closed(-1d, 16d),
threadsProvider.threadTimeline.timeline.getViewRange());
// NOTE: Plot max is based on time table, never thread destruction
}
@Test
@@ -301,8 +298,7 @@ public class DebuggerThreadsProviderTest extends AbstractGhidraHeadedDebuggerGUI
traceManager.activateTrace(tb.trace);
waitForSwing();
assertEquals(Range.closed(-1d, 11d),
threadsProvider.threadTimeline.timeline.getViewRange());
assertEquals(2, threadsProvider.threadTableModel.getModelData().size());
try (UndoableTransaction tid = tb.startTransaction()) {
thread2.delete();
@@ -310,7 +306,7 @@ public class DebuggerThreadsProviderTest extends AbstractGhidraHeadedDebuggerGUI
waitForSwing();
assertEquals(1, threadsProvider.threadTableModel.getModelData().size());
assertEquals(Range.closed(-1d, 1d), threadsProvider.threadTimeline.timeline.getViewRange());
// NOTE: Plot max is based on time table, never thread destruction
}
@Test
@@ -381,33 +377,6 @@ public class DebuggerThreadsProviderTest extends AbstractGhidraHeadedDebuggerGUI
});
}
@Test
@Ignore("TODO") // Not sure why this fails under Gradle but not my IDE
public void testSelectThreadInTimelineActivatesThread() throws Exception {
createAndOpenTrace();
addThreads();
traceManager.activateTrace(tb.trace);
waitForDomainObject(tb.trace);
assertThreadsPopulated();
assertThreadSelected(thread1);
// Otherwise, this test fails unpredictably
waitForPass(noExc(() -> {
Rectangle b = threadsProvider.threadTimeline.getCellBounds(thread2);
threadsProvider.threadTimeline.scrollRectToVisible(b);
Point tsl = threadsProvider.threadTimeline.getLocationOnScreen();
Point vp = threadsProvider.threadTimeline.getViewport().getViewPosition();
Point m =
new Point(tsl.x + b.x + b.width / 2 - vp.x, tsl.y + b.y + b.height / 2 - vp.y);
clickMouse(MouseEvent.BUTTON1, m);
waitForSwing();
assertThreadSelected(thread2);
assertEquals(thread2, traceManager.getCurrentThread());
}));
}
@Test
public void testActivateSnapUpdatesTimelineCursor() throws Exception {
createAndOpenTrace();
@@ -417,29 +386,12 @@ public class DebuggerThreadsProviderTest extends AbstractGhidraHeadedDebuggerGUI
assertThreadsPopulated();
assertEquals(0, traceManager.getCurrentSnap());
assertEquals(0, (int) threadsProvider.threadTimeline.topCursor.getValue());
assertEquals(0, threadsProvider.headerRenderer.getCursorPosition().longValue());
traceManager.activateSnap(6);
waitForSwing();
assertEquals(6, (int) threadsProvider.threadTimeline.topCursor.getValue());
}
@Test
public void testSeekTimelineActivatesSnap() throws Exception {
createAndOpenTrace();
addThreads();
traceManager.activateTrace(tb.trace);
waitForSwing();
assertThreadsPopulated();
assertEquals(0, traceManager.getCurrentSnap());
assertEquals(0, (int) threadsProvider.threadTimeline.topCursor.getValue());
threadsProvider.threadTimeline.topCursor.requestValue(6, EventTrigger.GUI_ACTION);
waitForSwing();
assertEquals(6, traceManager.getCurrentSnap());
assertEquals(6, threadsProvider.headerRenderer.getCursorPosition().longValue());
}
@Test
@@ -1,227 +0,0 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets;
import java.awt.*;
import java.awt.event.*;
import javax.swing.JPanel;
import com.google.common.collect.Range;
import ghidra.util.datastruct.ListenerSet;
public class RangeCursorPanel extends JPanel {
protected final static int MIN_SIZE = 16;
protected final static Dimension MIN_BOX = new Dimension(MIN_SIZE, MIN_SIZE);
protected final static Polygon ARROW =
new Polygon(new int[] { 0, -MIN_SIZE, -MIN_SIZE }, new int[] { 0, MIN_SIZE, -MIN_SIZE }, 3);
protected static double clamp(Range<Double> range, double value) {
return Math.max(range.lowerEndpoint(), Math.min(value, range.upperEndpoint()));
}
protected enum Orientation {
HORIZONTAL {
@Override
void transform(Component component, Graphics2D g, Range<Double> range, double value) {
int offset = valueToOffset(component.getWidth(), range, value);
g.translate(offset, 0);
}
@Override
double getValue(Component component, MouseEvent e, Range<Double> range) {
return offsetToValue(component.getWidth(), range, e.getX());
}
},
VERTICAL {
@Override
void transform(Component component, Graphics2D g, Range<Double> range, double value) {
g.translate(0, valueToOffset(component.getHeight(), range, value));
}
@Override
double getValue(Component component, MouseEvent e, Range<Double> range) {
return offsetToValue(component.getHeight(), range, e.getY());
}
};
protected static int valueToOffset(int size, Range<Double> range, double value) {
double lower = range.lowerEndpoint();
double length = range.upperEndpoint() - lower;
double diff = value - lower;
return (int) (diff * size / length);
}
protected static double offsetToValue(int size, Range<Double> range, int offset) {
double lower = range.lowerEndpoint();
double length = range.upperEndpoint() - lower;
double diff = length * offset / size;
return lower + diff;
}
abstract void transform(Component component, Graphics2D g, Range<Double> range,
double value);
abstract double getValue(Component component, MouseEvent e, Range<Double> range);
}
public enum Direction {
EAST(Orientation.VERTICAL) {
@Override
void transform(Component component, Graphics2D g) {
g.translate(component.getWidth(), 0);
}
},
NORTH(Orientation.HORIZONTAL) {
@Override
void transform(Component component, Graphics2D g) {
g.rotate(-Math.PI / 2);
}
},
WEST(Orientation.VERTICAL) {
@Override
void transform(Component component, Graphics2D g) {
g.rotate(Math.PI);
}
},
SOUTH(Orientation.HORIZONTAL) {
@Override
void transform(Component component, Graphics2D g) {
g.translate(0, component.getHeight());
g.rotate(Math.PI / 2);
}
};
protected final Orientation orientation;
Direction(Orientation orientation) {
this.orientation = orientation;
}
abstract void transform(Component component, Graphics2D g);
}
protected final ListenerSet<RangeCursorValueListener> listeners =
new ListenerSet<>(RangeCursorValueListener.class);
protected final MouseListener mouseListener = new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if (e.getButton() != MouseEvent.BUTTON1) {
return;
}
doSeek(e);
}
};
protected final MouseMotionListener mouseMotionListener = new MouseMotionAdapter() {
@Override
public void mouseDragged(MouseEvent e) {
if ((e.getModifiersEx() & MouseEvent.BUTTON1_DOWN_MASK) == 0) {
return;
}
doSeek(e);
}
};
{
addMouseListener(mouseListener);
addMouseMotionListener(mouseMotionListener);
}
protected Direction direction;
protected Range<Double> range = Range.closed(-1.0, 1.0);
protected double value;
public RangeCursorPanel(Direction direction) {
this.direction = direction;
this.setFocusable(true);
}
protected void doSeek(MouseEvent e) {
double requested = direction.orientation.getValue(RangeCursorPanel.this, e, range);
requestValue(requested, EventTrigger.GUI_ACTION);
}
public void addValueListener(RangeCursorValueListener listener) {
listeners.add(listener);
}
public void removeValueListener(RangeCursorValueListener listener) {
listeners.remove(listener);
}
public void setDirection(Direction direction) {
this.direction = direction;
invalidate();
}
public void setRange(Range<Double> range) {
this.range = range;
repaint();
}
public void requestValue(double requested) {
requestValue(requested, EventTrigger.API_CALL);
}
public void requestValue(double requested, EventTrigger trigger) {
double val = adjustRequestedValue(requested);
if (this.value == val) {
return;
}
this.value = val;
listeners.fire.valueChanged(val, trigger);
repaint();
}
public double getValue() {
return value;
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
//g2.setColor(Color.GREEN);
//g2.fillPolygon(ARROW);
direction.orientation.transform(this, g2, range, value);
//g2.setColor(Color.RED);
//g2.fillPolygon(ARROW);
direction.transform(this, g2);
g2.setColor(getForeground());
g2.fillPolygon(ARROW);
}
protected double adjustRequestedValue(double requested) {
// Extension point
return clamp(range, requested);
}
@Override
public Dimension getPreferredSize() {
return MIN_BOX;
}
@Override
public Dimension getMinimumSize() {
return MIN_BOX;
}
}
@@ -1,20 +0,0 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets;
public interface RangeCursorValueListener {
void valueChanged(double position, EventTrigger trigger);
}
@@ -0,0 +1,131 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets.table;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.function.Consumer;
import javax.swing.JTable;
import javax.swing.table.*;
import com.google.common.collect.Range;
public class RangeCursorTableHeaderRenderer<N extends Number & Comparable<N>>
extends GTableHeaderRenderer {
protected final static int ARROW_SIZE = 10;
protected final static Polygon ARROW = new Polygon(
new int[] { 0, -ARROW_SIZE, -ARROW_SIZE },
new int[] { 0, ARROW_SIZE, -ARROW_SIZE }, 3);
protected Range<Double> fullRange = Range.closed(0d, 1d);
protected double span = 1;
protected N pos;
protected double doublePos;
public void setFullRange(Range<N> fullRange) {
this.fullRange = RangeTableCellRenderer.validateViewRange(fullRange);
this.span = this.fullRange.upperEndpoint() - this.fullRange.lowerEndpoint();
}
public void setCursorPosition(N pos) {
this.pos = pos;
this.doublePos = pos.doubleValue();
}
@Override
protected void paintChildren(Graphics g) {
super.paintChildren(g);
paintCursor(g);
}
protected void paintCursor(Graphics parentG) {
Graphics2D g = (Graphics2D) parentG.create();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
double x = (doublePos - fullRange.lowerEndpoint()) / span * getWidth();
g.translate(x, getHeight());
g.rotate(Math.PI / 2);
g.setColor(getForeground());
g.fillPolygon(ARROW);
}
public void addSeekListener(JTable table, int modelColumn, Consumer<Double> listener) {
TableColumnModel colModel = table.getColumnModel();
JTableHeader header = table.getTableHeader();
TableColumn col = colModel.getColumn(modelColumn);
MouseAdapter l = new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
if ((e.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) != 0) {
return;
}
if ((e.getButton() != MouseEvent.BUTTON1)) {
return;
}
doSeek(e);
e.consume();
}
@Override
public void mouseDragged(MouseEvent e) {
int onmask = MouseEvent.BUTTON1_DOWN_MASK;
int offmask = MouseEvent.SHIFT_DOWN_MASK;
if ((e.getModifiersEx() & (onmask | offmask)) != onmask) {
return;
}
doSeek(e);
e.consume();
}
protected void doSeek(MouseEvent e) {
if (header.getResizingColumn() != null) {
return;
}
int viewColIdx = colModel.getColumnIndexAtX(e.getX());
int modelColIdx = table.convertColumnIndexToModel(viewColIdx);
if (modelColIdx != modelColumn) {
return;
}
TableColumn draggedCol = header.getDraggedColumn();
if (draggedCol == col) {
header.setDraggedColumn(null);
}
else if (draggedCol != null) {
return;
}
int colX = 0;
for (int i = 0; i < viewColIdx; i++) {
colX += colModel.getColumn(i).getWidth();
}
TableColumn col = colModel.getColumn(viewColIdx);
double pos = span * (e.getX() - colX) / col.getWidth() + fullRange.lowerEndpoint();
listener.accept(pos);
}
};
header.addMouseListener(l);
header.addMouseMotionListener(l);
}
public N getCursorPosition() {
return pos;
}
}
@@ -0,0 +1,104 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets.table;
import java.awt.Component;
import java.awt.Graphics;
import com.google.common.collect.Range;
import ghidra.docking.settings.Settings;
import ghidra.util.table.column.AbstractGColumnRenderer;
public class RangeTableCellRenderer<N extends Number & Comparable<N>>
extends AbstractGColumnRenderer<Range<N>> {
protected Range<Double> doubleFullRange = Range.closed(0d, 1d);
protected double span = 1;
protected Range<N> fullRange;
protected Range<N> dataRange;
public static Range<Double> validateViewRange(Range<? extends Number> fullRange) {
if (!fullRange.hasLowerBound() || !fullRange.hasUpperBound()) {
throw new IllegalArgumentException("Cannot have unbounded full range");
}
// I don't care to preserve open/closed, since it just specifies the view bounds
return Range.closed(fullRange.lowerEndpoint().doubleValue(),
fullRange.upperEndpoint().doubleValue());
}
public void setFullRange(Range<N> fullRange) {
this.fullRange = fullRange;
this.doubleFullRange = validateViewRange(fullRange);
this.span = this.doubleFullRange.upperEndpoint() - this.doubleFullRange.lowerEndpoint();
}
@Override
public String getFilterString(Range<N> t, Settings settings) {
return "";
}
@Override
@SuppressWarnings("unchecked")
public Component getTableCellRendererComponent(GTableCellRenderingData data) {
this.dataRange = (Range<N>) data.getValue();
super.getTableCellRendererComponent(data);
setText("");
return this;
}
@Override
protected void paintComponent(Graphics parentG) {
super.paintComponent(parentG);
if (dataRange == null) {
return;
}
int width = getWidth();
int height = getHeight();
int x1 = dataRange.hasLowerBound()
? interpolate(width, dataRange.lowerEndpoint().doubleValue())
: 0;
int x2 = dataRange.hasUpperBound()
? interpolate(width, dataRange.upperEndpoint().doubleValue())
: width;
int y1 = height > 2 ? 1 : 0;
int y2 = height > 2 ? height - 1 : height;
Graphics g = parentG.create();
g.setColor(getForeground());
g.fillRect(x1, y1, x2 - x1, y2 - y1);
}
protected int interpolate(int w, double val) {
double lower = doubleFullRange.lowerEndpoint();
if (val <= lower) {
return 0;
}
if (val >= doubleFullRange.upperEndpoint()) {
return w;
}
double dif = val - lower;
return (int) (dif / span * w);
}
public Range<N> getFullRange() {
return fullRange;
}
}
@@ -0,0 +1,152 @@
/* ###
* IP: GHIDRA
*
* 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.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package docking.widgets.table;
import static org.junit.Assume.assumeFalse;
import java.awt.BorderLayout;
import java.awt.event.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import javax.swing.JFrame;
import javax.swing.JScrollPane;
import javax.swing.table.TableColumn;
import org.junit.Before;
import org.junit.Test;
import com.google.common.collect.Range;
import docking.widgets.table.DefaultEnumeratedColumnTableModel.EnumeratedTableColumn;
import ghidra.util.SystemUtilities;
import ghidra.util.table.GhidraTable;
public class DemoRangeCellRendererTest {
@Before
public void checkNotBatch() {
assumeFalse(SystemUtilities.isInTestingBatchMode());
}
protected static class MyRow {
private final String name;
private Range<Integer> lifespan;
public MyRow(String name, Range<Integer> lifespan) {
this.name = name;
this.lifespan = lifespan;
}
public String getName() {
return name;
}
public Range<Integer> getLifespan() {
return lifespan;
}
public void setLifespan(Range<Integer> lifespan) {
this.lifespan = lifespan;
}
@Override
public String toString() {
return name;
}
}
protected enum MyColumns implements EnumeratedTableColumn<MyColumns, MyRow> {
NAME("Name", String.class, MyRow::getName),
LIFESPAN("Lifespan", Range.class, MyRow::getLifespan);
private String header;
private Class<?> cls;
private Function<MyRow, ?> getter;
private <T> MyColumns(String header, Class<T> cls, Function<MyRow, T> getter) {
this.header = header;
this.cls = cls;
this.getter = getter;
}
@Override
public Class<?> getValueClass() {
return cls;
}
@Override
public Object getValueOf(MyRow row) {
return getter.apply(row);
}
@Override
public String getHeader() {
return header;
}
@Override
public boolean isSortable() {
return this != LIFESPAN;
}
}
@Test
public void testDemoRangeCellRenderer() throws Throwable {
JFrame window = new JFrame();
window.setLayout(new BorderLayout());
DefaultEnumeratedColumnTableModel<MyColumns, MyRow> model =
new DefaultEnumeratedColumnTableModel<>("People", MyColumns.class);
GhidraTable table = new GhidraTable(model);
GTableFilterPanel<MyRow> filterPanel = new GTableFilterPanel<>(table, model);
TableColumn column = table.getColumnModel().getColumn(MyColumns.LIFESPAN.ordinal());
RangeTableCellRenderer<Integer> rangeRenderer = new RangeTableCellRenderer<>();
RangeCursorTableHeaderRenderer<Integer> headerRenderer =
new RangeCursorTableHeaderRenderer<>();
column.setCellRenderer(rangeRenderer);
column.setHeaderRenderer(headerRenderer);
rangeRenderer.setFullRange(Range.closed(1800, 2000));
headerRenderer.setFullRange(Range.closed(1800, 2000));
headerRenderer.setCursorPosition(1940);
model.add(new MyRow("Albert", Range.closed(1879, 1955)));
model.add(new MyRow("Bob", Range.atLeast(1956)));
model.add(new MyRow("Elvis", Range.closed(1935, 1977)));
headerRenderer.addSeekListener(table, MyColumns.LIFESPAN.ordinal(), pos -> {
System.out.println("pos: " + pos);
headerRenderer.setCursorPosition(pos.intValue());
table.getTableHeader().repaint();
});
window.add(new JScrollPane(table));
window.add(filterPanel, BorderLayout.SOUTH);
window.setBounds(0, 0, 1000, 200);
CompletableFuture<Void> windowClosed = new CompletableFuture<>();
WindowListener listener = new WindowAdapter() {
@Override
public void windowClosed(WindowEvent e) {
windowClosed.complete(null);
}
};
window.addWindowListener(listener);
window.setVisible(true);
windowClosed.get();
}
}