org.netbeans.swing.tabcontrol.plaf.ScrollingTabLayoutModel Maven / Gradle / Ivy
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.netbeans.swing.tabcontrol.plaf;
import org.netbeans.swing.tabcontrol.TabDataModel;
import javax.swing.*;
import java.awt.*;
import java.util.Arrays;
/*
* ScrollingTabLayoutModel.java
*
* Created on December 5, 2003, 5:16 PM
*/
/**
* Layout model which manages an offset into a set of scrollable tabs, and
* recalculates its layout on a change. Also handles adding extra pixels to the
* selected tab if necessary. Basics of how it works:
*
* Wrapppers a DefaultTabLayoutModel, which can simply calculate tab widths and
* 0 based positions. Listens to the data model for changes, and sets a flag
* when a change happens to mark the cached widths and positions as dirty. On
* any call to fetch sizes, first checks if the cached values are good,
* recalculates if needed, and returns the result.
*
* @author Tim Boudreau
*/
public final class ScrollingTabLayoutModel implements TabLayoutModel {
/**
* The index of the first clipped, visible tab, or -1 if the first tab
* should not be clippped
*/
private int offset = -1;
/**
* The wrapped DefaultTabLayoutModel which will give us pure numbers for the
* desired width of tabs
*/
private TabLayoutModel wrapped;
/**
* Flag indicating that any call to get a value should trigger recalculation
* of the cached values
*/
private boolean changed = true;
/**
* The tabDataModel, which we occasionally need to get data from
*/
TabDataModel mdl;
/**
* The selection model we will get the current selection from when we need
* to ensure it is visible
*/
SingleSelectionModel sel;
/**
* Holds the value of the tab that should be made visible if makeVisible is
* called before the component has a valid (>0) width. If not -1, a call to
* setWidth() will trigger a call to makeVisible with this value.
*/
private int makeVisibleTab = -1;
/**
* Integer count of pixels that should be added to the width of the selected
* tab. They will be subtracted from the surrounding tabs
*/
int pixelsToAddToSelection = 0;
/**
* Stores the value of whether the final tab is clipped. Recalculated in
* change()
*/
private boolean lastTabClipped = false;
/**
* Cached index of the first visible tab
*/
private int firstVisibleTab = -1;
/**
* Cached index of the last visible tab
*/
private int lastVisibleTab = -1;
/**
* The last known width for which values were calculated
*/
private int width = -1;
/**
* Cache of the widths of tabs that *are* onscreen. This will always have a
* length of (lastVisibleTab + 1) - firstVisibleTab.
*/
private int[] widths = null;
/**
* Creates a new instance of ScrollingTabLayoutModel
*/
public ScrollingTabLayoutModel(TabLayoutModel wrapped,
SingleSelectionModel sel, TabDataModel mdl) {
this.wrapped = wrapped;
this.mdl = mdl;
this.sel = sel;
}
public ScrollingTabLayoutModel(TabLayoutModel wrapped,
SingleSelectionModel sel, TabDataModel mdl,
int minimumXposition) {
this(wrapped, sel, mdl);
this.minimumXposition = minimumXposition;
}
public void setMinimumXposition (int x) {
this.minimumXposition = x;
setChanged(true);
}
/**
* Some UIs will want to make the selected tab a little wider than the
* rest.
* @param i
*/
public void setPixelsToAddToSelection (int i) {
pixelsToAddToSelection = i;
setChanged (true);
}
private int minimumXposition = 0;
/**
* External operations on the selection or data model can invalidate cached
* widths. The UI will listen for such changes and call this method if the
* data we have cached is probably no good anymore.
*/
public void clearCachedData() {
setChanged(true);
}
/**
* Convenience getter for the "wrapped" model which will give us "pure"
* numbers regarding the widths of tabs
*/
private TabLayoutModel getWrapped() {
return wrapped;
}
/**
* Get the offset - the number of tabs that are scrolled over. The default
* value is -1, which means no tabs are scrolled off to the left. 0 means
* the first tab is visible but clipped...and so forth
*/
public int getOffset() {
if (mdl.size() <= 1) {
return -1;
}
return offset;
}
/**
* Called to recalculate cached values the first time a value that needs to
* be calculated is requested, after some change that invalidates the cached
* values
*/
private void change() {
if (mdl.size() == 0) {
//no tabs, do nothing
widths = new int[0];
updateActions();
setChanged(false);
return;
}
//Create an array that will hold precalculated widths until something
//changes
if (widths == null || widths.length != mdl.size()) {
widths = new int[mdl.size()];
//Fill our array with 0's - any tabs not visible should get 0 width
}
Arrays.fill(widths, 0);
if (widths.length == 1) {
//there's only one tab, get rid of any offset - otherwise there's
//no way to ever make the close button show because it won't be
//able to be scrolled
offset = -1;
}
//Handle throws case where we don't really even have enough room to
//display one tab, by centering the clipped selected tag on what little
//space we have. The UI will make sure it looks clipped.
if (width < getMinimumLeftClippedWidth()) {
int toBeShown = makeVisibleTab != -1 ?
makeVisibleTab : sel.getSelectedIndex();
toBeShown = Math.min(widths.length-1, toBeShown);
if (toBeShown != -1) {
widths[toBeShown] = width;
} else {
widths[0] = width;
}
firstVisibleTab = toBeShown;
lastVisibleTab = toBeShown;
setChanged(false);
return;
}
//init an index to the current position while looping
int x = minimumXposition;
//Find the starting point, the first visible tab
int start = offset >= 0 ? offset : 0;
//Holds a count of pixels to redistribute among other tabs, if we don't
//quite have room to fit the last tab, so we'll stretch the one next
//to it, but we don't want to make it huge
int toRedistribute = -1;
//Reset stored value for the last visible tab, returned from
//getLastVisibleTab()
lastVisibleTab = -1;
//Reset stored value for first visible tab, returned from
//getFirstVisibleTab()
firstVisibleTab = start;
//Reset the lastTabClipped flag returned by isLastTabClipped()
lastTabClipped = false;
//Special case - if the last tab the starting tab and there's not enough room for
//it, show as much of it as possible
if (start == mdl.size() - 1 && width < getWrapped().getW(start) + getMinimumLeftClippedWidth()) {
lastVisibleTab = start;
if (start != 0) {
firstVisibleTab = start - 1;
widths[start] = width - getMinimumLeftClippedWidth();
widths[start - 1] = getMinimumLeftClippedWidth();
lastTabClipped = width - getMinimumLeftClippedWidth() < getWrapped().getW(start);
} else {
firstVisibleTab = start;
widths[start] = width;
lastTabClipped = width < getWrapped().getW(start);
}
updateActions();
//set the changed flag so we won't recalculate all this again until
//the next time something warrants it
setChanged(false);
return;
}
for (int i = start; i < widths.length; i++) {
int w;
if (i == offset) {
//If it's the first tab and it's an offset, it will use the
//fixed width
w = getMinimumLeftClippedWidth();
} else {
//Get a dynamic width from the underlying model, which tells us
//how wide that tab wants to be
w = getWrapped().getW(i);
}
//See if we've overshot the space available for tabs. If we have,
//we'll need to display this tab as right-clipped
if (x + w > width) {
if (width - x < getMinimumRightClippedWidth() && i != start) {
//There's not enough space to fit the current tab. Add all
//the extra space to the previous one (we'll redistribute
//it later - this just makes the algorithm work even if
//you comment out the redistribution code)
widths[i - 1] += (width - x) - 1;
//Now we know how many extra pixels we'll have to redistribute
toRedistribute = (width - x);
//Decrement the last visible tab so it will show a correct
//value
lastVisibleTab = i - 1;
//Set the width of the tab that wouldn't fit to 0
widths[i] = 0;
} else {
//Okay, there's enough space for this last tab as a clipped
//tab. Truncate it at the last possible pixel a tab can
//occupy
widths[i] = (width - x) - 1;
//set this to the last visible tab
lastVisibleTab = i;
}
//Set the clipped flag - the UI will use this to decide what
//border to give the last tab
lastTabClipped = true;
//We're done looping - this tab will be clipped, so it's the last
break;
}
//Okay, we're just iterating through a tab in the middle. Set its
//width to whatever its measurements are and move on
widths[i] = w;
x += w;
//make sure the last visible tab is really set correctly if there
//is no right clipped tab
if (i == widths.length - 1) {
lastVisibleTab = widths.length - 1;
}
}
//Some UIs want to make the selected tab bigger. So try to do that here.
//Get the selection from the selection model
int selected = sel.getSelectedIndex();
//See if we have to add some pixels to the selected tab, but ignore if
//it's the first or last clipped tabs
if (pixelsToAddToSelection != 0 && selected > start
&& selected < lastVisibleTab) {
//Add the pixels to the selected index
widths[selected] += pixelsToAddToSelection;
//Get the average number of pixels per tab to remove. If a small
//number, it may round to 0. Note we are intentionally dividing
//by the number of tabs-1 because the selected tab doesn't count.
int perTab = pixelsToAddToSelection
- 1 / (lastVisibleTab - start);
//In case it does round to 0, keep an exact count
int pixels = pixelsToAddToSelection - 1;
//Iterate all the tabs, skipping the selected one
for (int i = start; i <= lastVisibleTab; i++) {
if (i != selected) {
//if it rounded to 0, we'll just subtract 2 until we get
//there - this will work most of the time and be harmless
//the rest
if (perTab == 0) {
//remove 2 pixels from the tab width
widths[i] -= 2;
pixels -= 2;
if (pixels <= 0) {
//if we'return out of pixels, stop
break;
}
} else {
//Okay, we have an exact (+/- rounding errors) number of
//pixels to remove. Remove them,
widths[i] -= perTab;
//Subtract from our exact count, it will avoid rounding
//errors showing up
pixels -= perTab;
//if we'return out of pixels, stop
if (pixels <= 0) {
break;
}
}
}
}
}
//Now, do we have some spare pixels in the last tab that we need to redistribute
//so we don't have a huge last tab? Only do this if there are > 2 tabs,
//or there's really no point - both are clipped
if (toRedistribute != -1 && lastVisibleTab != start
&& lastVisibleTab != start + 1) {
//Similar algorithm as above
int perTab = toRedistribute / ((lastVisibleTab + 1) - start);
for (int i = start; i < lastVisibleTab; i++) {
if (perTab != 0) {
widths[i] += perTab;
widths[lastVisibleTab] -= perTab;
} else {
int use = toRedistribute > 2 ? 2 : toRedistribute;
widths[i] += use;
widths[lastVisibleTab] -= use;
toRedistribute -= use;
if (toRedistribute <= 0) {
//out of pixels, quit
break;
}
}
}
}
updateActions();
//set the changed flag so we won't recalculate all this again until
//the next time something warrants it
setChanged(false);
}
private void setChanged(boolean val) {
if (changed != val) {
changed = val;
}
}
/**
* Some look and feel specs require that the selected tab be wider. This
* method sets the number of pixels to add to its width. It is important
* that the underlying layout model's padX property include enough padding
* that 1-2 pixels may be stolen without causing overlap problems. The
* default is 0.
*/
public int getPixelsToAddToSelection() {
return pixelsToAddToSelection;
}
/**
* Returns true if the last tab displayed is clipped and should therefore be
* painted as a clipped tab
*/
public boolean isLastTabClipped() {
if (width < getMinimumLeftClippedWidth()) {
return true;
}
return lastTabClipped;
}
/**
* Make a tab visible, according to the rules of the spec. Returns whether
* or not a repaint of the entire control is required. The width of the tab
* view is passed to this method, so that it can tell if the width has
* changed (in which case it needs to recalculate tab bounds), or if it can
* use the existing cached values.
*
* This method will not trigger a repaint - it just adjusts the cached withs
* and positions of tabs so that the next repaint will paint correctly. It
* may be called as part of a more complex operation which would not want to
* trigger spurious repaints - but the return value should be noted, and if
* the return value is true, the caller should repaint the tab displayer
* whenever it is done doing what it is doing.
*/
public boolean makeVisible (int index, int width) {
if (width < 0) {
setWidth (width);
makeVisibleTab = index;
return false;
}
boolean resized = width != this.width || recentlyResized;
recentlyResized = false;
//First, make sure we have an accurate first/last visible tab
setWidth(width);
if (index == -1) {
return false;
}
//Special case a single tab model - the index should always be 0
if (mdl.size() == 1) {
setOffset (-1);
return changed;
}
//Special case two tabs in a very small area - try to show them both
if (mdl.size() == 2) {
int totalWidth = getWrapped().getW(0) + getWrapped().getW(1);
if (totalWidth > width) {
setOffset (0);
return changed;
}
}
if (changed) {
change();
}
//Special case index 0 - it will always get -1
if (index == 0) {
int off = setOffset(-1);
return off != -1;
}
int cachedWidthOfRequestedTab = getW( index );
int widthForRequestedTab = getWrapped().getW(index);
//Special case a single tab which is wider than the entire
//tab displayer area
if (widthForRequestedTab > width) {
//It will be left clipped, but what can you do...
setOffset (index-1);
return changed;
}
//If it's the last tab and it's already not clipped, don't
//do anything
if (index == mdl.size() - 1 && !isLastTabClipped() && !resized && cachedWidthOfRequestedTab == width) {
return false;
}
int newOffset = -2;
int currW = 0;
boolean isOffBack = false;
boolean result = changed;
boolean switchForward = false;
//If it's after the last tab, we'll find it's width, then count
//backward until we're out of tabs or out of space
if (index >= getLastVisibleTab(width)) {
int selIdx = sel.getSelectedIndex();
switchForward = index >= selIdx;
//Find the width of this tab, and count back
currW = getWrapped().getW(index);
if (index == selIdx) {
currW += pixelsToAddToSelection;
}
int firstTab = index;
//Count backward from the requested tab until we're out of space
do {
firstTab--;
if (firstTab > -1) {
if (firstTab == selIdx) {
currW += pixelsToAddToSelection;
}
int wid = getWrapped().getW(firstTab);
currW += wid;
}
} while (currW <= width && firstTab >= -1);
newOffset = firstTab;
if( currW <= width || switchForward ) {
newOffset++;
if( getOffset() == -1 && newOffset == -1 )
newOffset = 0;
}
} else if (index <= getFirstVisibleTab(width)) {
isOffBack = true;
newOffset = index-1;
}
if (resized || !isOffBack || index == mdl.size() && getFirstVisibleTab(width) == index) {
if (newOffset != -2) {
setOffset (newOffset);
}
result = ensureAvailableSpaceUsed(false);
} else {
if (newOffset != -2) {
int old = offset;
int nue = setOffset (Math.min (mdl.size(), newOffset));
result = old != nue;
}
}
return result;
}
boolean ensureAvailableSpaceUsed(boolean useCached) {
if (mdl.size() == 0) {
return false;
}
boolean result = false;
if (changed && !useCached) {
result = true;
change();
}
int last = mdl.size() -1;
int lastTab = useCached ? getCachedLastVisibleTab() : getLastVisibleTab(width);
if (lastTab == last || lastTab == mdl.size() && last > -1) { //one has been removed
int off = offset;
int availableWidth = width - (getX(last) + getW(last));
while (availableWidth > 0 && off > -1) {
availableWidth -= getWrapped().getW(off);
if (availableWidth > 0) {
off--;
}
}
setOffset (off);
if (changed) {
result = true;
change();
}
}
return result;
}
/**
* Probably these should be made into constructor arguments. The minimum
* space to be used for a right-clipped tab
*/
int getMinimumRightClippedWidth() {
return 40;
}
/**
* Probably these should be made into constructor arguments. The minimum
* space to be used for a left-clipped tab
*/
int getMinimumLeftClippedWidth() {
return 40;
}
/**
* Sets the current cached width the model thinks it has for displaying
* tabs. This is used to trigger a recalculation if it differs from the
* previously passed value
*/
public void setWidth(int width) {
if (this.width != width) {
recentlyResized = true;
//see if someone called makeVisible before the component was shown -
//we'll want to do that now
if (width < this.width) {
//Ensure that the current selection stays visible in a resize
makeVisibleTab = sel.getSelectedIndex();
}
boolean needMakeVisible = (width > 0 && this.width < 0
&& makeVisibleTab != -1);
this.width = width - minimumXposition;
setChanged(width > getMinimumLeftClippedWidth());
if (changed && needMakeVisible
&& width > getMinimumLeftClippedWidth()) {
makeVisible(makeVisibleTab, width);
makeVisibleTab = -1;
}
}
}
private boolean recentlyResized = true;
/**
* Set the offset - the number of tabs that should be hidden to the left.
* The default is -1 - tab 0 is showing. If set to 0, tab 0 still shows but
* is clipped, and so forth.
*/
public int setOffset(int i) {
int prevOffset = offset;
if (mdl.size() == 1) {
if (offset > -1) {
offset = -1;
setChanged(true);
}
return prevOffset;
}
if (mdl.size() == 2 && width < getMinimumLeftClippedWidth()
+ getMinimumRightClippedWidth()) {
offset = -1;
setChanged(false);
return prevOffset;
}
if (i < -1) {
//repeated action calls can do this
i = -1;
}
if (i != offset) {
setChanged(true);
offset = i;
}
return prevOffset;
}
/**
* Returns the index of the first tab that is visible (may be clipped - if
* it == getOffset() then it is
*/
public int getFirstVisibleTab(int width) {
setWidth(width);
if (mdl.size() == 0) {
return -1;
}
if (width < getMinimumLeftClippedWidth()) {
int first = makeVisibleTab == -1 ?
sel.getSelectedIndex() : makeVisibleTab;
return first;
}
if (changed) {
change();
}
return firstVisibleTab;
}
/**
* Return the number of tabs currently visible
*/
public int countVisibleTabs(int width) {
return (getLastVisibleTab(width) + 1)
- getFirstVisibleTab(width);
}
/**
* Returns the last visible tab, which may or may not be clipped
*/
public int getLastVisibleTab(int width) {
setWidth(width);
if (mdl.size() == 0) {
return -1;
}
if (width < getMinimumLeftClippedWidth()) {
int first = makeVisibleTab == -1 ?
sel.getSelectedIndex() : makeVisibleTab;
return first;
}
if (changed) {
change();
}
return lastVisibleTab;
}
/**
* Used when components are deleted, so that if the user scrolls to close
* some tabs, and the selection is offscreen, we don't infuriatingly
* re-scroll away from the end tabs.
*/
int getCachedLastVisibleTab() {
return lastVisibleTab;
}
/**
* Used when components are deleted, so that if the user scrolls to close
* some tabs, and the selection is offscreen, we don't infuriatingly
* re-scroll away from the end tabs.
*/
int getCachedFirstVisibleTab() {
return firstVisibleTab;
}
public int dropIndexOfPoint(int x, int y) {
if (changed) {
change();
}
int first = getFirstVisibleTab(width);
int last = getLastVisibleTab(width);
int pos = 0; //XXX - may not be 0 with insets
for (int i = first; i <= last; i++) {
int lastPos = pos;
pos += getW(i);
int h = getH(i);
int ay = getY(i);
if (y < 0 || y > ay + h) {
return -1;
}
if (i == last && x > lastPos + (getW(i) / 2)) {
return last + 1;
}
if (x >= lastPos && x <= pos) {
return i;
}
}
return -1;
}
public void setPadding(Dimension d) {
getWrapped().setPadding(d);
setChanged (true);
}
public int getH(int index) {
if (changed) {
change();
}
try {
return getWrapped().getH(index);
} catch (IndexOutOfBoundsException e) {
//The tab was just removed, and the selection model was notified,
//by the data model, but not everything else has been notified yet
return 0;
}
}
/**
* Returns a cached width, after checking the changed flag and calling
* change() if recalculation is needed
*/
public int getW(int index) {
//widths can be null on OS-X if component is instantiated with
//0 size (some bug with reloading winsys) and has never been painted
if (changed || widths == null || index > widths.length) {
change();
}
if (index >= widths.length) {
//If a tab has just been removed, there may be a request to
//repaint it
return 0;
}
return widths[index];
}
public int getX(int index) {
if (changed) {
change();
}
int result = minimumXposition;
for (int i = 0; i < index; i++) {
result += getW(i);
}
return result;
}
public int getY(int index) {
if (changed) {
change();
}
return getWrapped().getY(index);
}
public int indexOfPoint(int x, int y) {
if (changed) {
change();
}
int pos = minimumXposition;
int lastPos;
for (int i = offset == -1 ? 0 : offset; i < mdl.size(); i++) {
lastPos = pos;
int w = getW(i);
pos += w;
if (w == 0) {
break;
}
int h = getH(i);
int ay = getY(i);
if (y < 0 || y > ay + h) {
return -1;
}
if (x > lastPos && x < pos) {
return i;
}
}
return -1;
}
private Action fAction = null;
private Action bAction = null;
/**
* Returns an Action that the control buttons can call to scroll forward
*/
public Action getForwardAction() {
if (fAction == null) {
fAction = new ForwardAction();
}
return fAction;
}
/**
* Returns an Action that the control buttons can call to scroll backward
*/
public Action getBackwardAction() {
if (bAction == null) {
bAction = new BackwardAction();
}
return bAction;
}
/**
* Update the enabled state of the button actions if the state of the layout
* has changed in a way that affects them
*/
private void updateActions() {
if (width <= getMinimumLeftClippedWidth()) {
bAction.setEnabled(false);
fAction.setEnabled(false);
}
if (bAction != null) {
bAction.setEnabled(mdl.size() > 1 && offset > -1);
}
if (fAction != null) {
fAction.setEnabled(isLastTabClipped() && mdl.size() > 2
&& (lastVisibleTab-firstVisibleTab > 1 //special case when a tab is too wide
|| lastVisibleTab < mdl.size()-1));
}
}
/**
* An action which will scroll forward
*/
private class ForwardAction extends AbstractAction {
public void actionPerformed(java.awt.event.ActionEvent e) {
setOffset(getOffset() + 1);
Component jc = (Component) getValue("control"); //NOI18N
//Use a convenient hack to get the control to paint
if (jc != null) {
jc.repaint();
}
}
}
/**
* An action which will scroll backward
*/
private class BackwardAction extends AbstractAction {
public void actionPerformed(java.awt.event.ActionEvent e) {
setOffset(getOffset() - 1);
//Use a convenient hack to get the control to paint
Component jc = (Component) getValue("control"); //NOI18N
if (jc != null) {
jc.repaint();
}
}
}
}